simple_editor\ui/
presets_ui.rs

1//! Presets pane: everything reusable that lives on the machine instead of in the project — saved effect
2//! chains, node graphs, adjustment layers and clip templates. All of it is in settings.json, so it
3//! survives a new project. Clicking a row applies it to the selection (templates place at the playhead);
4//! its context menu renames and deletes. "Save from selection" captures the selected clip's node graph
5//! or effect stack, and falls back to a clip template for adjustment layers and multi-clip selections.
6//!
7//! Effects and node graphs share `Settings::effect_presets` (the JSON shape tells them apart —
8//! `EffectPreset::is_graph`), adjustment layers and templates share `Settings::templates`.
9
10use crate::settings::Settings;
11use crate::ui::library::{confirm, inline_edit};
12use eframe::egui;
13
14#[cfg(test)]
15use crate::ui::effects_ui::test_rects;
16
17#[derive(Default)]
18pub struct PresetsState {
19    /// Row being renamed: (lives in `templates`, index into that Vec, edit buffer).
20    rename: Option<(bool, usize, String)>,
21    /// The "Save from selection" name field.
22    name: String,
23}
24
25#[derive(Default)]
26pub struct PresetsResponse {
27    /// Index into `Settings::effect_presets` to apply to the selected clips.
28    pub apply: Option<usize>,
29    /// Template names to place at the playhead.
30    pub place: Vec<String>,
31    /// "Save from selection" was pressed with this (trimmed, non-empty) name.
32    pub save: Option<String>,
33    pub settings_changed: bool,
34}
35
36/// Rename / delete, deferred until the rows are drawn (they hold `settings` borrowed).
37enum Act {
38    Rename(bool, usize, String),
39    Delete(bool, usize),
40}
41
42/// (index into `effect_presets`, name) for one of the two flavours.
43fn effect_rows(settings: &Settings, graph: bool) -> Vec<(usize, String)> {
44    settings
45        .effect_presets
46        .iter()
47        .enumerate()
48        .filter(|(_, p)| p.is_graph() == graph)
49        .map(|(i, p)| (i, p.name.clone()))
50        .collect()
51}
52
53pub fn show(
54    ui: &mut egui::Ui,
55    state: &mut PresetsState,
56    settings: &mut Settings,
57    has_selection: bool,
58) -> PresetsResponse {
59    #[cfg(test)]
60    test_rects::clear();
61    let mut resp = PresetsResponse::default();
62    ui.horizontal(|ui| {
63        ui.add(egui::TextEdit::singleline(&mut state.name).desired_width(140.0).hint_text("Preset name"));
64        let name = state.name.trim().to_string();
65        let b = ui.add_enabled(has_selection && !name.is_empty(), egui::Button::new("Save from selection"));
66        #[cfg(test)]
67        test_rects::push("save".into(), b.rect);
68        let tip = "The selected clip's node graph or effect stack — or the whole selection as a template";
69        if b.on_hover_text(tip).clicked() {
70            resp.save = Some(name);
71            state.name.clear();
72        }
73    });
74    ui.separator();
75
76    // rows are read out before any of them can mutate `settings`
77    let (fx, graphs) = (effect_rows(settings, false), effect_rows(settings, true));
78    let (mut adj, mut cont, mut tpl) = (Vec::new(), Vec::new(), Vec::new());
79    for (i, t) in settings.templates.iter().enumerate() {
80        let dst = if crate::engine::presets::is_adjustment_template(t) {
81            &mut adj
82        } else if crate::engine::presets::is_container_template(t) {
83            &mut cont
84        } else {
85            &mut tpl
86        };
87        dst.push((i, t.name.clone()));
88    }
89
90    let mut act = None;
91    egui::ScrollArea::vertical().show(ui, |ui| {
92        // names are user-typed and the pane is narrow: cut them off rather than widening every row
93        ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Truncate);
94        section(ui, state, false, "Effects", &fx, &mut act, &mut resp);
95        section(ui, state, false, "Node graphs", &graphs, &mut act, &mut resp);
96        section(ui, state, true, "Adjustment layers", &adj, &mut act, &mut resp);
97        section(ui, state, true, "Container templates", &cont, &mut act, &mut resp);
98        section(ui, state, true, "Templates", &tpl, &mut act, &mut resp);
99    });
100
101    if let Some(act) = act {
102        resp.settings_changed = match act {
103            Act::Rename(true, i, name) => {
104                settings.templates[i].name = name;
105                true
106            }
107            Act::Rename(false, i, name) => {
108                settings.effect_presets[i].name = name;
109                true
110            }
111            // presets live in settings.json, outside undo — ask before losing one
112            Act::Delete(t, i) => {
113                let name = if t { &settings.templates[i].name } else { &settings.effect_presets[i].name };
114                let go = confirm("Delete preset", &format!("Delete the saved preset \"{name}\"?"));
115                if go {
116                    if t {
117                        settings.templates.remove(i);
118                    } else {
119                        settings.effect_presets.remove(i);
120                    }
121                }
122                go
123            }
124        };
125    }
126    resp
127}
128
129/// One titled list. `templates` picks both the backing Vec and what a click means (place vs apply).
130fn section(
131    ui: &mut egui::Ui,
132    state: &mut PresetsState,
133    templates: bool,
134    title: &str,
135    rows: &[(usize, String)],
136    act: &mut Option<Act>,
137    resp: &mut PresetsResponse,
138) {
139    ui.add_space(4.0);
140    ui.strong(title);
141    if rows.is_empty() {
142        ui.weak("(none)");
143        return;
144    }
145    let tip = if templates { "Place at the playhead" } else { "Apply to the selection" };
146    for (i, name) in rows {
147        if state.rename.as_ref().is_some_and(|(t, j, _)| *t == templates && j == i) {
148            let buf = &mut state.rename.as_mut().unwrap().2;
149            if let Some(new) = inline_edit(ui, buf) {
150                if !new.is_empty() {
151                    *act = Some(Act::Rename(templates, *i, new));
152                }
153                state.rename = None;
154            }
155            continue;
156        }
157        let w = ui.available_width();
158        let r = ui.add(egui::Button::new(name.as_str()).min_size(egui::vec2(w, 0.0))).on_hover_text(tip);
159        #[cfg(test)]
160        test_rects::push(format!("{title}{i}"), r.rect);
161        if r.clicked() {
162            if templates {
163                resp.place.push(name.clone());
164            } else {
165                resp.apply = Some(*i);
166            }
167        }
168        r.context_menu(|ui| {
169            if ui.button("Rename").clicked() {
170                state.rename = Some((templates, *i, name.clone()));
171                ui.close();
172            }
173            if ui.button("Delete").clicked() {
174                *act = Some(Act::Delete(templates, *i));
175                ui.close();
176            }
177        });
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use crate::settings::EffectPreset;
185    use eframe::egui::{vec2, Event, Modifiers, PointerButton, Pos2, RawInput, Rect};
186
187    struct Harness {
188        ctx: egui::Context,
189        state: PresetsState,
190        settings: Settings,
191        time: f64,
192    }
193
194    impl Harness {
195        /// One row in each of the four sections.
196        fn new() -> Self {
197            let mut settings = Settings::default();
198            settings.effect_presets.push(EffectPreset { name: "Look".into(), json: "[]".into() });
199            settings.effect_presets.push(EffectPreset { name: "Graph".into(), json: "{\"nodes\":[]}".into() });
200            let mut p = crate::model::Project::new();
201            let a = p.add_adjustment_clip(0.0, 2.0);
202            let t = p.add_text_clip(0.0, 2.0);
203            settings.templates.push(crate::engine::presets::capture_template("Grade", &p, &[a]));
204            settings.templates.push(crate::engine::presets::capture_template("Title", &p, &[t]));
205            Self { ctx: egui::Context::default(), state: PresetsState::default(), settings, time: 0.0 }
206        }
207        fn frame(&mut self, events: Vec<Event>) -> PresetsResponse {
208            self.time += 0.05;
209            let input = RawInput {
210                screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 600.0))),
211                time: Some(self.time),
212                events,
213                ..Default::default()
214            };
215            let mut resp = PresetsResponse::default();
216            let Harness { ctx, state, settings, .. } = self;
217            let _ = ctx.run(input, |ctx| {
218                egui::CentralPanel::default().show(ctx, |ui| resp = show(ui, state, settings, true));
219            });
220            resp
221        }
222        /// The click lands on the release frame, which is the one returned.
223        fn click(&mut self, name: &str) -> PresetsResponse {
224            let pos = test_rects::get(name).unwrap_or_else(|| panic!("no widget rect for {name}")).center();
225            let btn = |pressed| Event::PointerButton {
226                pos,
227                button: PointerButton::Primary,
228                pressed,
229                modifiers: Modifiers::NONE,
230            };
231            self.frame(vec![Event::PointerMoved(pos)]);
232            self.frame(vec![btn(true)]);
233            self.frame(vec![btn(false)])
234        }
235    }
236
237    /// Headless: the four sections lay out and a row hands back the right intent.
238    #[test]
239    fn rows_apply_and_place() {
240        let mut h = Harness::new();
241        h.frame(vec![]);
242        // one row per section, split by JSON shape (effects vs graph) and clip kind (adjustment vs not)
243        for name in ["Effects0", "Node graphs1", "Adjustment layers0", "Templates1"] {
244            assert!(test_rects::get(name).is_some(), "no row for {name}");
245        }
246        // an effect preset applies to the selection, a template places at the playhead
247        let r = h.click("Node graphs1");
248        assert_eq!(r.apply, Some(1));
249        assert!(r.place.is_empty());
250        let r = h.click("Templates1");
251        assert_eq!(r.place, vec!["Title".to_string()]);
252        assert!(r.apply.is_none());
253        // "Save from selection" stays disabled until a name is typed
254        assert!(h.click("save").save.is_none());
255        h.state.name = " Intro ".into();
256        let r = h.click("save");
257        assert_eq!(r.save.as_deref(), Some("Intro"));
258        assert!(h.state.name.is_empty());
259    }
260
261    /// A machine with no presets yet still draws (every section empty, nothing requested).
262    #[test]
263    fn empty_settings_draw_nothing_to_apply() {
264        let mut h = Harness::new();
265        h.settings = Settings::default();
266        let r = h.frame(vec![]);
267        assert!(r.apply.is_none() && r.place.is_empty() && !r.settings_changed);
268        assert!(test_rects::get("Effects0").is_none());
269    }
270}