simple_editor\ui/
nodes.rs

1//! Node editor: the selected clip's effect chain as a graph you can wire up.
2//!
3//! Canvas with pan (middle-drag / scroll) and zoom (Ctrl+Scroll), plus a properties panel on the right
4//! showing **every** parameter of the picked node — that panel is the only place several kinds (Blend,
5//! Matte, Mask, Color, Clip, Random, …) can be edited at all. Each node is a rounded box: title
6//! (`NodeKind::title`), an enable checkbox, labelled input ports on the left, one output port on the
7//! right, and the first couple of parameters inline. An effect's ports are its picture (port 0) then one
8//! per parameter, so a Number / Random / Math chain can drive any knob; a wired knob greys out in the
9//! panel. Drag from a port to another to connect (`NodeGraph::connect` refuses cycles); drag a wire off a
10//! port to disconnect; right-click a node for Delete / Duplicate / Add mask / **Replace** (swap in any
11//! other kind, or retarget an Asset — connections that still fit are kept); right-click the canvas for
12//! the "Add node" menu (every `EffectKind` grouped by `category()`, the graph nodes, the value nodes, then
13//! one entry per project asset — an Asset node needs a real id, so it is picked there).
14//! Ctrl+click adds/removes a node, a primary drag over empty canvas rubber-bands a group (Shift adds),
15//! dragging any picked node moves the whole selection, Delete removes it and Ctrl+C / Ctrl+V duplicates
16//! it (edges *between* the copied nodes come along; wires to the outside are dropped). Effects and
17//! transitions dragged out of their panels drop in as fresh, unwired nodes; a library asset dropped on
18//! empty canvas becomes an Asset node, and dropped *on* a node it replaces that node's value.
19//! A clip without a graph gets a "Build a node graph from it" button rather than one being made for it:
20//! `ensure_graph` converts the linear stack only when asked, and from then on the renderer evaluates the
21//! graph and ignores `clip.effects` — far too much to do to a clip just because this pane is docked.
22//! Every mutation calls `undo` once per gesture.
23
24use crate::model::{
25    Animated, BlendMode, CmpOp, Edge, Effect, EffectKind, Id, LogicOp, Mask, MaskShape, MathOp, Node, NodeGraph,
26    NodeKind, Project, TextStyle as TextProps, TransitionKind,
27};
28use crate::theme::Palette;
29use crate::ui::DragPayload;
30use eframe::egui;
31use egui::{
32    pos2, vec2, Align2, Color32, CornerRadius, FontId, Pos2, Rect, Sense, Stroke, StrokeKind, TextStyle, UiBuilder,
33};
34
35/// Node box width in graph units.
36const NODE_W: f32 = 150.0;
37const HEADER_H: f32 = 22.0;
38const ROW_H: f32 = 18.0;
39const THUMB_H: f32 = 56.0;
40/// A wire snaps to a port within this many screen px.
41const SNAP: f32 = 12.0;
42const PORT_R: f32 = 4.0;
43/// At most this many parameters are shown on the box itself (the Inspector has the rest).
44const INLINE_PARAMS: usize = 2;
45/// Duplicates and pastes land this far from the original, so they are visibly separate.
46const OFFSET: f32 = 24.0;
47
48#[derive(Default)]
49pub struct NodesState {
50    /// Pan/zoom of the canvas.
51    pub offset: egui::Vec2,
52    pub zoom: f32,
53    /// The picked nodes; the last one is what the Inspector follows (`primary`).
54    pub selection: Vec<Id>,
55    /// A connection being dragged: (node, port, from_output).
56    pub linking: Option<(Id, usize, bool)>,
57    /// The node under the pointer while moving (one undo per gesture; the selection travels with it).
58    pub dragging: Option<Id>,
59    /// Input port a wire is being pulled off, until the pointer actually leaves it.
60    pub detach: Option<(Id, usize)>,
61    /// Rubber band in progress: (press origin in screen space, add instead of replace).
62    pub band: Option<(Pos2, bool)>,
63    /// Ctrl+C copy: the nodes and the edges between them, pasted with fresh ids.
64    pub clipboard: (Vec<Node>, Vec<Edge>),
65    /// Where the canvas was right-clicked, in graph units: new nodes land there.
66    pub menu_at: Option<(f32, f32)>,
67    /// Live previews, keyed by node. Empty unless a renderer hands textures over, and the panel is
68    /// fully usable without them (there is no GL context in tests or headless runs).
69    pub thumbs: std::collections::HashMap<Id, egui::TextureId>,
70}
71
72impl NodesState {
73    /// The node the Inspector shows: the most recently picked one.
74    pub fn primary(&self) -> Option<Id> {
75        self.selection.last().copied()
76    }
77    /// Pick a node; `add` (Ctrl) toggles it in the selection instead of replacing it.
78    fn pick(&mut self, id: Id, add: bool) {
79        match (add, self.selection.iter().position(|&s| s == id)) {
80            (true, Some(i)) => {
81                self.selection.remove(i);
82            }
83            (true, None) => self.selection.push(id),
84            (false, _) => self.selection = vec![id],
85        }
86    }
87}
88
89#[derive(Default)]
90pub struct NodesResponse {
91    pub edited: bool,
92    /// The node whose parameters the inspector should show.
93    pub selected: Option<Id>,
94}
95
96/// One buffered mutation. The UI pass only reads the graph, so the undo snapshot can be taken
97/// between collecting these and applying them.
98#[derive(Debug)]
99enum Act {
100    Add(NodeKind, f32, f32),
101    Delete(Id),
102    Move(Id, f32, f32),
103    SetParam(Id, usize, f64),
104    SetText(Id, String),
105    /// Replace what a node *is* (its value, or its whole kind): the properties panel, the Replace
106    /// menu and a dropped asset all land here. Wires the new kind still has a port for survive.
107    SetKind(Id, NodeKind),
108    SetEnabled(Id, bool),
109    Connect(Id, Id, usize),
110    Disconnect(Id, usize),
111    AddMask(Id),
112    /// Drive a mask node's centre along a saved project path (node, path).
113    SetPath(Id, Id),
114    /// Re-create copied nodes with fresh ids, offset, plus the edges between them.
115    Paste(Vec<Node>, Vec<Edge>),
116}
117
118pub fn show(
119    ui: &mut egui::Ui,
120    state: &mut NodesState,
121    project: &mut Project,
122    selection: &[Id],
123    playhead: f64,
124    palette: &Palette,
125    undo: &mut dyn FnMut(&Project),
126) -> NodesResponse {
127    let mut out = NodesResponse::default();
128    if !(state.zoom > 0.05) {
129        state.zoom = 1.0; // a default-constructed state has zoom 0
130    }
131    let Some(&clip_id) = selection.first() else {
132        hint(ui, palette, "Select a clip to edit its node graph");
133        return out;
134    };
135    if project.clip(clip_id).is_none() {
136        hint(ui, palette, "Select a clip to edit its node graph");
137        return out;
138    }
139    // Converting the linear stack is a real edit, not a side effect of drawing: from here on the
140    // renderer evaluates the graph and ignores clip.effects. So the user has to ask for it — merely
141    // selecting a clip while this pane happens to be docked must not take its effect list away.
142    if project.clip(clip_id).is_some_and(|c| c.graph.is_none()) {
143        let mut make = false;
144        let base_id = ui.id();
145        ui.vertical_centered(|ui| {
146            ui.add_space(24.0);
147            ui.label(egui::RichText::new("This clip renders from its effect list.").color(palette.text_dim));
148            let b = ui.button("Build a node graph from it");
149            // click-sensing interact on top of the button: a stable id the headless test can find
150            make = ui.interact(b.rect, base_id.with("build_graph"), Sense::click()).clicked();
151        });
152        if !make {
153            return out;
154        }
155        undo(project);
156        project.ensure_graph(clip_id);
157        out.edited = true;
158    }
159    // the graph is small; a copy keeps the draw pass read-only so `undo` can snapshot mid-frame
160    let Some(graph) = project.clip(clip_id).and_then(|c| c.graph.clone()) else {
161        return out;
162    };
163    let lt = project.clip(clip_id).map(|c| c.local(playhead)).unwrap_or(0.0);
164    state.selection.retain(|s| graph.node(*s).is_some());
165
166    let mut acts: Vec<Act> = Vec::new();
167    let mut needs_undo = false;
168    let base = ui.id();
169    // pickable sources for the Asset / Clip nodes, and the saved drawings a mask centre can follow
170    let assets: Vec<(Id, String)> = project.assets.iter().map(|a| (a.id, a.name())).collect();
171    let clips: Vec<(Id, String)> =
172        project.tracks.iter().flat_map(|t| t.clips.iter()).map(|c| (c.id, format!("#{} {}", c.id, c.name))).collect();
173    let paths: Vec<(Id, String)> = project.paths.iter().map(|p| (p.id, p.name.clone())).collect();
174
175    egui::SidePanel::right(base.with("props")).default_width(210.0).show_inside(ui, |ui| {
176        match state.primary().and_then(|id| graph.node(id)) {
177            Some(n) => node_panel(ui, n, &graph, lt, &assets, &clips, &mut acts, &mut needs_undo),
178            None => {
179                ui.weak("Select a node");
180            }
181        }
182    });
183
184    let rect = ui.available_rect_before_wrap();
185    let resp = ui.allocate_rect(rect, Sense::click_and_drag());
186    let p = ui.painter().with_clip_rect(rect);
187    p.rect_filled(rect, 0, palette.bg);
188
189    // ---- pan / zoom ----
190    let mods = ui.input(|i| i.modifiers);
191    let pointer = ui.input(|i| i.pointer.hover_pos()).filter(|q| rect.contains(*q));
192    if pointer.is_some() {
193        // egui folds Ctrl+Scroll (and pinch) into zoom_delta and keeps it out of the scroll delta
194        let (zoom_delta, scroll) = ui.input(|i| (i.zoom_delta(), i.raw_scroll_delta));
195        if (zoom_delta - 1.0).abs() > 1e-4 {
196            let anchor = pointer.unwrap_or(rect.center());
197            let g = (anchor - rect.min - state.offset) / state.zoom;
198            state.zoom = (state.zoom * zoom_delta).clamp(0.3, 3.0);
199            state.offset = anchor - rect.min - g * state.zoom;
200        } else if scroll != egui::Vec2::ZERO {
201            state.offset += scroll;
202        }
203    }
204    let z = state.zoom;
205    let org = rect.min + state.offset;
206    let to_screen = |x: f32, y: f32| org + vec2(x * z, y * z);
207    let to_graph = |q: Pos2| ((q - org) / z).to_pos2();
208
209    grid(&p, rect, org, z, palette);
210
211    // ---- geometry ----
212    let mut boxes: Vec<(usize, Rect)> = Vec::with_capacity(graph.nodes.len());
213    for (i, n) in graph.nodes.iter().enumerate() {
214        let h = node_height(&n.kind, state.thumbs.contains_key(&n.id));
215        boxes.push((i, Rect::from_min_size(to_screen(n.x, n.y), vec2(NODE_W * z, h * z))));
216    }
217    let rect_of = |id: Id| boxes.iter().find(|(i, _)| graph.nodes[*i].id == id).map(|(_, r)| *r);
218
219    // ---- wires (behind the boxes) ----
220    let wire = Stroke::new((1.5 * z).max(1.0), palette.text_dim);
221    for e in &graph.edges {
222        let (Some(a), Some(b)) = (rect_of(e.from), rect_of(e.to)) else { continue };
223        bezier(&p, out_port(a, z), in_port(b, e.port, z), wire);
224    }
225
226    // ---- ports (screen space) ----
227    let mut ports: Vec<(Id, usize, bool, Pos2)> = Vec::new();
228    for (i, r) in &boxes {
229        let n = &graph.nodes[*i];
230        for k in 0..n.kind.inputs() {
231            ports.push((n.id, k, false, in_port(*r, k, z)));
232        }
233        if n.kind != NodeKind::Output {
234            ports.push((n.id, 0, true, out_port(*r, z)));
235        }
236    }
237    let nearest = |q: Pos2, want_output: Option<bool>| {
238        ports
239            .iter()
240            .filter(|(_, _, o, _)| want_output.is_none_or(|w| *o == w))
241            .map(|prt| (prt, prt.3.distance(q)))
242            .filter(|(_, d)| *d <= SNAP)
243            .min_by(|a, b| a.1.total_cmp(&b.1))
244            .map(|(prt, _)| *prt)
245    };
246
247    // rubber band: hit-tested while the boxes are drawn, applied on release (mirrors the timeline)
248    let band_rect = state.band.map(|(o, _)| Rect::from_two_pos(o, pointer.unwrap_or(o)).intersect(rect));
249    let mut band_ids: Vec<Id> = Vec::new();
250
251    // ---- wire dragging ----
252    let (pressed, released, down) =
253        ui.input(|i| (i.pointer.primary_pressed(), i.pointer.primary_released(), i.pointer.primary_down()));
254    if pressed && state.linking.is_none() {
255        if let Some(q) = pointer {
256            if let Some((node, port, is_out, _)) = nearest(q, None) {
257                if !is_out {
258                    // pulling a wire off an input detaches it and keeps dragging the far end
259                    match graph.input_of(node, port) {
260                        // the wire is only detached once the pointer leaves the port (below): a plain
261                        // click used to disconnect and reconnect, two undo entries for no net change
262                        Some(up) => {
263                            state.linking = Some((up, 0, true));
264                            state.detach = Some((node, port));
265                        }
266                        None => state.linking = Some((node, port, false)),
267                    }
268                } else {
269                    state.linking = Some((node, port, is_out));
270                }
271            }
272        }
273    }
274    if let Some((node, port, is_out)) = state.linking {
275        if let (Some(r), Some(q)) = (rect_of(node), pointer) {
276            let anchor = if is_out { out_port(r, z) } else { in_port(r, port, z) };
277            let (a, b) = if is_out { (anchor, q) } else { (q, anchor) };
278            bezier(&p, a, b, Stroke::new((1.5 * z).max(1.0), palette.accent));
279            // highlight the ports this wire could land on
280            for (_, _, _, pp) in ports.iter().filter(|(_, _, o, _)| *o != is_out) {
281                if pp.distance(q) <= SNAP {
282                    p.circle_filled(*pp, PORT_R * z + 2.0, palette.accent);
283                }
284            }
285        }
286        // committed detach: the pointer left the port it was pulled from
287        if let Some((dn, dp)) = state.detach {
288            if let (Some(dr), Some(q)) = (rect_of(dn), pointer) {
289                if in_port(dr, dp, z).distance(q) > SNAP {
290                    acts.push(Act::Disconnect(dn, dp));
291                    needs_undo = true;
292                    state.detach = None;
293                }
294            }
295        }
296        if released {
297            // still attached = the gesture was a click on the port: leave the graph alone
298            let clicked_in_place = state.detach.take().is_some();
299            if let (false, Some(q)) = (clicked_in_place, pointer) {
300                if let Some((tn, tp, ..)) = nearest(q, Some(!is_out)) {
301                    let (from, to, prt) = if is_out { (node, tn, tp) } else { (tn, node, port) };
302                    // dry-run on the frame's copy: a refused (cycle-forming) wire must not push an
303                    // undo entry that changes nothing
304                    if graph.clone().connect(from, to, prt) {
305                        acts.push(Act::Connect(from, to, prt));
306                        needs_undo = true;
307                    }
308                }
309            }
310            state.linking = None;
311        }
312    }
313
314    // ---- node boxes ----
315    let linking = state.linking.is_some();
316    for &(i, r) in &boxes {
317        let n = &graph.nodes[i];
318        if band_rect.is_some_and(|br| br.intersects(r)) {
319            band_ids.push(n.id);
320        }
321        let nr = ui.interact(r, base.with(("node", n.id)), Sense::click_and_drag());
322        // grabbing one of several picked nodes keeps the group; anything else picks (Ctrl toggles).
323        // primary only: a held right button must reach the context menu, never re-pick the node.
324        let grabbed = nr.drag_started_by(egui::PointerButton::Primary);
325        if nr.clicked() || (grabbed && !state.selection.contains(&n.id)) {
326            state.pick(n.id, mods.ctrl);
327        }
328        if grabbed && !linking {
329            state.dragging = Some(n.id);
330            needs_undo = true; // exactly one snapshot for the whole move
331        }
332        if state.dragging == Some(n.id) {
333            if nr.dragged() {
334                let d = nr.drag_delta() / z;
335                if d != egui::Vec2::ZERO {
336                    for m in graph.nodes.iter().filter(|m| m.id == n.id || state.selection.contains(&m.id)) {
337                        acts.push(Act::Move(m.id, m.x + d.x, m.y + d.y));
338                    }
339                }
340            }
341            if nr.drag_stopped() {
342                state.dragging = None;
343            }
344        }
345        let selected = state.selection.contains(&n.id);
346        paint_node(&p, ui, r, n, selected, palette, z, state.thumbs.get(&n.id).copied());
347
348        // enable toggle
349        let cb = Rect::from_min_size(r.min + vec2(4.0 * z, 6.0 * z), vec2(10.0 * z, 10.0 * z));
350        let cbr = ui.interact(cb, base.with(("en", n.id)), Sense::click());
351        p.rect_stroke(cb, CornerRadius::same(1), Stroke::new(1.0, palette.border), StrokeKind::Inside);
352        if n.enabled {
353            p.rect_filled(cb.shrink(2.0 * z), CornerRadius::ZERO, palette.accent);
354        }
355        if cbr.clicked() {
356            acts.push(Act::SetEnabled(n.id, !n.enabled));
357            needs_undo = true;
358        }
359
360        // a library asset dropped on a node replaces what that node is
361        if let Some(payload) = nr.dnd_release_payload::<DragPayload>() {
362            if let DragPayload::Asset(aid) = *payload {
363                acts.push(Act::SetKind(n.id, NodeKind::Asset(aid)));
364                needs_undo = true;
365            }
366        }
367
368        // inline parameters (real widgets only when the box is close to full size)
369        if z >= 0.6 {
370            if let NodeKind::Number(a) = &n.kind {
371                let mut v = a.at(lt);
372                let mut sub = ui.new_child(UiBuilder::new().max_rect(row_rect(r, 0, z)).id_salt(("num", n.id)));
373                sub.set_clip_rect(row_rect(r, 0, z).intersect(rect));
374                let dv = sub.add(egui::DragValue::new(&mut v).speed(0.01));
375                if dv.changed() {
376                    let mut a = a.clone();
377                    a.value = v;
378                    acts.push(Act::SetKind(n.id, NodeKind::Number(a)));
379                }
380                if dv.drag_started() || dv.gained_focus() {
381                    needs_undo = true;
382                }
383            }
384            if let NodeKind::Bool(b) = &n.kind {
385                let mut v = *b;
386                let mut sub = ui.new_child(UiBuilder::new().max_rect(row_rect(r, 0, z)).id_salt(("bool", n.id)));
387                sub.set_clip_rect(row_rect(r, 0, z).intersect(rect));
388                let label = if v { "true" } else { "false" };
389                if sub.checkbox(&mut v, label).changed() {
390                    acts.push(Act::SetKind(n.id, NodeKind::Bool(v)));
391                    needs_undo = true;
392                }
393            }
394            if let NodeKind::String(style) = &n.kind {
395                let row = row_rect(r, 0, z);
396                let mut txt = style.text.clone();
397                let mut sub = ui.new_child(UiBuilder::new().max_rect(row).id_salt(("txt", n.id)));
398                sub.set_clip_rect(row.intersect(rect));
399                let te = sub
400                    .add(egui::TextEdit::singleline(&mut txt).desired_width(f32::INFINITY))
401                    .on_hover_text("{frame} timeline frame · {time} clock · {n} frames into the clip");
402                if te.changed() {
403                    acts.push(Act::SetText(n.id, txt));
404                }
405                if te.gained_focus() {
406                    needs_undo = true;
407                }
408            }
409            if let NodeKind::Effect(e) = &n.kind {
410                for (pi, spec) in e.specs().iter().take(INLINE_PARAMS).enumerate() {
411                    // parameter pi sits on the row of the port that can drive it (port 0 is the picture)
412                    let row = row_rect(r, pi + 1, z);
413                    let mut v = e.at(pi, lt);
414                    let mut sub = ui.new_child(UiBuilder::new().max_rect(row).id_salt(("p", n.id, pi)));
415                    sub.set_clip_rect(row.intersect(rect));
416                    let changed = sub
417                        .horizontal(|ui| {
418                            ui.style_mut().spacing.interact_size.y = ROW_H;
419                            ui.label(egui::RichText::new(spec.name).small().color(palette.text_dim));
420                            if e.kind.is_bool_param(pi) {
421                                let mut b = v >= 0.5;
422                                let r = ui.checkbox(&mut b, "");
423                                v = if b { 1.0 } else { 0.0 };
424                                r
425                            } else {
426                                ui.add(
427                                    egui::DragValue::new(&mut v)
428                                        .speed((spec.max - spec.min).abs().max(1.0) / 200.0)
429                                        .range(spec.min..=spec.max),
430                                )
431                            }
432                        })
433                        .inner;
434                    if changed.changed() {
435                        acts.push(Act::SetParam(n.id, pi, v));
436                    }
437                    if changed.drag_started() || changed.gained_focus() {
438                        needs_undo = true;
439                    }
440                }
441            }
442        }
443
444        // per-node menu
445        let is_output = n.kind == NodeKind::Output;
446        let (id, kind) = (n.id, n.kind.clone());
447        let (nx, ny) = (n.x, n.y);
448        nr.context_menu(|ui| {
449            if !state.selection.contains(&id) {
450                state.selection = vec![id];
451            }
452            if !is_output && menu_entry(ui, base.with(("del", id)), "Delete").clicked() {
453                acts.push(Act::Delete(id));
454                needs_undo = true;
455                ui.close();
456            }
457            if menu_entry(ui, base.with(("dup", id)), "Duplicate").clicked() {
458                acts.push(Act::Add(kind.clone(), nx + OFFSET, ny + OFFSET));
459                needs_undo = true;
460                ui.close();
461            }
462            if matches!(&kind, NodeKind::Effect(_)) && menu_entry(ui, base.with(("mask", id)), "Add mask").clicked() {
463                acts.push(Act::AddMask(id));
464                needs_undo = true;
465                ui.close();
466            }
467            if matches!(&kind, NodeKind::Mask(_)) {
468                for (pid, name) in &paths {
469                    if menu_entry(ui, base.with(("path", id, pid)), &format!("Move along: {name}")).clicked() {
470                        acts.push(Act::SetPath(id, *pid));
471                        needs_undo = true;
472                        ui.close();
473                    }
474                }
475            }
476            // swap the node's value (another asset) or the node itself for any other kind
477            if !is_output {
478                ui.menu_button("Replace", |ui| {
479                    add_menu(ui, base.with("rep"), (nx, ny), Some(id), &assets, &mut acts, &mut needs_undo)
480                });
481            }
482        });
483    }
484
485    // ---- canvas ----
486    if resp.dragged_by(egui::PointerButton::Middle) {
487        state.offset += resp.drag_delta();
488    }
489    // a primary drag that missed every node rubber-bands a group instead of panning (Shift adds)
490    if state.band.is_none()
491        && !linking
492        && state.dragging.is_none()
493        && resp.drag_started_by(egui::PointerButton::Primary)
494    {
495        if let Some(o) = resp.interact_pointer_pos().or(pointer) {
496            state.band = Some((o, mods.shift));
497        }
498    }
499    if let (Some(br), Some((_, add))) = (band_rect, state.band) {
500        p.rect_filled(br, 0, palette.accent.gamma_multiply(0.15));
501        p.rect_stroke(br, 0, Stroke::new(1.0, palette.accent), StrokeKind::Inside);
502        if !down {
503            if !add {
504                state.selection.clear();
505            }
506            for id in band_ids {
507                if !state.selection.contains(&id) {
508                    state.selection.push(id);
509                }
510            }
511            state.band = None;
512        }
513    }
514    // an effect or transition dragged out of its panel lands here as a fresh, unwired node
515    if let Some(payload) = resp.dnd_release_payload::<DragPayload>() {
516        let g = to_graph(pointer.unwrap_or(rect.center()));
517        let kind = match &*payload {
518            DragPayload::Effect(k) => Some(NodeKind::Effect(Effect::new(*k))),
519            DragPayload::Transition(k) => Some(transition_node(*k)),
520            // media from the library: the canvas takes it as a source node
521            DragPayload::Asset(id) => Some(NodeKind::Asset(*id)),
522            // ponytail: a bare path would have to be probed and imported first — drop it on the
523            // timeline or the library, then drag the asset here
524            _ => None,
525        };
526        if let Some(kind) = kind {
527            acts.push(Act::Add(kind, g.x, g.y));
528            needs_undo = true;
529        }
530    }
531    if resp.clicked() {
532        state.selection.clear();
533    }
534    if resp.secondary_clicked() {
535        let g = to_graph(pointer.unwrap_or(rect.center()));
536        state.menu_at = Some((g.x, g.y));
537    }
538    let at = state.menu_at.unwrap_or((40.0, 40.0));
539    resp.context_menu(|ui| add_menu(ui, base, at, None, &assets, &mut acts, &mut needs_undo));
540
541    // Delete / Ctrl+D / Ctrl+C / Ctrl+V on the selection (the menu's actions, from the keyboard)
542    if !ui.ctx().wants_keyboard_input() && pointer.is_some() {
543        let (del, dup, copy, paste) = ui.input_mut(|i| {
544            (
545                i.consume_key(egui::Modifiers::NONE, egui::Key::Delete),
546                i.consume_key(egui::Modifiers::CTRL, egui::Key::D),
547                i.consume_key(egui::Modifiers::CTRL, egui::Key::C),
548                i.consume_key(egui::Modifiers::CTRL, egui::Key::V),
549            )
550        });
551        // the Output is part of every graph: it is never deleted, duplicated or copied
552        let sel: Vec<&Node> =
553            state.selection.iter().filter_map(|s| graph.node(*s)).filter(|n| n.kind != NodeKind::Output).collect();
554        for n in &sel {
555            if del {
556                acts.push(Act::Delete(n.id));
557                needs_undo = true;
558            }
559            if dup {
560                acts.push(Act::Add(n.kind.clone(), n.x + OFFSET, n.y + OFFSET));
561                needs_undo = true;
562            }
563        }
564        if copy && !sel.is_empty() {
565            let ids: Vec<Id> = sel.iter().map(|n| n.id).collect();
566            state.clipboard = (
567                sel.iter().map(|n| (*n).clone()).collect(),
568                graph.edges.iter().filter(|e| ids.contains(&e.from) && ids.contains(&e.to)).copied().collect(),
569            );
570        }
571        if paste && !state.clipboard.0.is_empty() {
572            acts.push(Act::Paste(state.clipboard.0.clone(), state.clipboard.1.clone()));
573            needs_undo = true;
574        }
575    }
576
577    // ---- apply ----
578    // not gated on `acts`: a gesture's first frame (drag start / entering a text field) usually has no
579    // act yet, and that is exactly the state the snapshot has to capture
580    if needs_undo {
581        undo(project);
582    }
583    for a in acts {
584        out.edited |= apply(project, clip_id, a, &mut state.selection);
585    }
586    out.selected = state.primary();
587    out
588}
589
590/// The nearest node to a transition: a cross fade / push / wipe is a two-input mix at any one instant,
591/// and a dip to colour is that colour.
592// ponytail: Combine holds the mix but not a wipe's geometry — give the graph a real Transition node if
593// anyone needs to animate one there.
594fn transition_node(kind: TransitionKind) -> NodeKind {
595    match kind {
596        TransitionKind::FadeToColor => NodeKind::Color([0, 0, 0, 255]),
597        _ => NodeKind::Combine { mode: BlendMode::Normal, factor: Animated::new(0.5) },
598    }
599}
600
601/// Apply one buffered action; returns true when the project actually changed.
602fn apply(project: &mut Project, clip: Id, act: Act, selected: &mut Vec<Id>) -> bool {
603    // reads the project before the graph is borrowed mutably
604    if let Act::SetPath(node, path) = act {
605        let Some(pts) = project.path(path).map(|p| p.points.clone()) else { return false };
606        let dur = project.clip(clip).map_or(0.0, |c| c.duration);
607        let (cx, cy) = crate::model::path_to_keys(&pts, dur);
608        if cx.keys.len() < 2 {
609            return false;
610        }
611        let Some(n) = project.clip_mut(clip).and_then(|c| c.graph.as_mut()).and_then(|g| g.node_mut(node)) else {
612            return false;
613        };
614        let NodeKind::Mask(m) = &mut n.kind else { return false };
615        m.cx = cx;
616        m.cy = cy;
617        return true;
618    }
619    if let Act::Add(kind, x, y) = act {
620        let id = project.add_node(clip, kind, x, y);
621        if let Some(id) = id {
622            *selected = vec![id];
623        }
624        return id.is_some();
625    }
626    // fresh ids for the copies, then the edges that had both ends inside the copied set
627    if let Act::Paste(nodes, edges) = act {
628        let mut map: Vec<(Id, Id)> = Vec::with_capacity(nodes.len());
629        for n in &nodes {
630            if let Some(id) = project.add_node(clip, n.kind.clone(), n.x + OFFSET, n.y + OFFSET) {
631                map.push((n.id, id));
632            }
633        }
634        let Some(g) = project.clip_mut(clip).and_then(|c| c.graph.as_mut()) else { return false };
635        let new = |old: Id| map.iter().find(|(o, _)| *o == old).map(|(_, n)| *n);
636        for e in &edges {
637            if let (Some(from), Some(to)) = (new(e.from), new(e.to)) {
638                g.connect(from, to, e.port);
639            }
640        }
641        *selected = map.iter().map(|(_, n)| *n).collect();
642        return !map.is_empty();
643    }
644    let Some(g) = project.clip_mut(clip).and_then(|c| c.graph.as_mut()) else { return false };
645    match act {
646        Act::Add(..) | Act::SetPath(..) | Act::Paste(..) => false,
647        Act::Delete(id) => {
648            if g.node(id).is_none_or(|n| n.kind == NodeKind::Output) {
649                return false;
650            }
651            g.remove_node(id);
652            selected.retain(|s| *s != id);
653            true
654        }
655        Act::Move(id, x, y) => match g.node_mut(id) {
656            Some(n) => {
657                n.x = x;
658                n.y = y;
659                true
660            }
661            None => false,
662        },
663        // the Output is the one node that can neither be replaced nor duplicated
664        Act::SetKind(id, kind) => {
665            if kind == NodeKind::Output
666                || !matches!(g.node(id), Some(n) if n.kind != kind && n.kind != NodeKind::Output)
667            {
668                return false;
669            }
670            let ports = kind.inputs();
671            if let Some(n) = g.node_mut(id) {
672                n.kind = kind;
673            }
674            // wires into ports the new kind does not have go; every other connection is kept
675            g.edges.retain(|e| e.to != id || e.port < ports);
676            true
677        }
678        Act::SetText(id, s) => match g.node_mut(id) {
679            Some(n) => match &mut n.kind {
680                NodeKind::String(style) => {
681                    style.text = s;
682                    true
683                }
684                _ => false,
685            },
686            None => false,
687        },
688        Act::SetParam(id, i, v) => match g.node_mut(id) {
689            Some(n) => match &mut n.kind {
690                NodeKind::Effect(e) => match e.params.get_mut(i) {
691                    Some(a) => {
692                        a.value = v;
693                        true
694                    }
695                    None => false,
696                },
697                _ => false,
698            },
699            None => false,
700        },
701        Act::SetEnabled(id, on) => match g.node_mut(id) {
702            Some(n) => {
703                n.enabled = on;
704                if let NodeKind::Effect(e) = &mut n.kind {
705                    e.enabled = on;
706                }
707                true
708            }
709            None => false,
710        },
711        // refused when it would close a loop — the graph is left exactly as it was
712        Act::Connect(from, to, port) => g.connect(from, to, port),
713        Act::Disconnect(to, port) => {
714            let had = g.input_of(to, port).is_some();
715            g.disconnect(to, port);
716            had
717        }
718        Act::AddMask(id) => match g.node_mut(id) {
719            Some(n) => match &mut n.kind {
720                NodeKind::Effect(e) if e.mask.is_none() => {
721                    e.mask = Some(Mask::default());
722                    true
723                }
724                _ => false,
725            },
726            None => false,
727        },
728    }
729}
730
731/// "Add node": every `EffectKind` under its `category()`, then the non-effect nodes and the project's
732/// assets (an Asset node needs a real id, so it is picked here instead of on the node box). With
733/// `target` set it is the Replace menu instead: the same catalogue, swapped into an existing node.
734#[allow(clippy::too_many_arguments)]
735fn add_menu(
736    ui: &mut egui::Ui,
737    base: egui::Id,
738    at: (f32, f32),
739    target: Option<Id>,
740    assets: &[(Id, String)],
741    acts: &mut Vec<Act>,
742    needs_undo: &mut bool,
743) {
744    let mut push = |ui: &egui::Ui, kind: NodeKind| {
745        acts.push(match target {
746            Some(id) => Act::SetKind(id, kind),
747            None => Act::Add(kind, at.0, at.1),
748        });
749        *needs_undo = true;
750        ui.close();
751    };
752    let mut cats: Vec<&'static str> = Vec::new();
753    for k in EffectKind::ALL {
754        if !cats.contains(&k.category()) {
755            cats.push(k.category());
756        }
757    }
758    egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| {
759        for c in cats {
760            ui.label(egui::RichText::new(c).small().weak());
761            for k in EffectKind::ALL.iter().filter(|k| k.category() == c) {
762                if menu_entry(ui, base.with(("add", k.name())), k.name()).clicked() {
763                    push(ui, NodeKind::Effect(Effect::new(*k)));
764                }
765            }
766            ui.separator();
767        }
768        ui.label(egui::RichText::new("Graph").small().weak());
769        for (name, kind) in [
770            ("Blend", NodeKind::Blend { mode: BlendMode::Normal, opacity: Animated::new(1.0) }),
771            ("Combine", NodeKind::Combine { mode: BlendMode::Normal, factor: Animated::new(0.5) }),
772            ("Merge", NodeKind::Merge),
773            ("Matte", NodeKind::Matte { invert: false, use_alpha: false }),
774            ("Mask", NodeKind::Mask(Mask::default())),
775            ("Color", NodeKind::Color([0, 0, 0, 255])),
776            // ponytail: a fresh Clip node points at nothing — the properties panel picks the source
777            ("Clip", NodeKind::Clip(0)),
778            ("Input", NodeKind::Input),
779        ] {
780            if menu_entry(ui, base.with(("add", name)), name).clicked() {
781                push(ui, kind);
782            }
783        }
784        ui.separator();
785        ui.label(egui::RichText::new("Value").small().weak());
786        // one entry per family: the operator itself is a combo in the properties panel
787        for (name, kind) in [
788            ("Number", NodeKind::Number(Animated::new(1.0))),
789            ("Boolean", NodeKind::Bool(true)),
790            // a fresh String node is the clock; a plain string is one edit away
791            ("String", NodeKind::String(TextProps { text: "{time}".into(), ..Default::default() })),
792            ("Random", NodeKind::Random { seed: 1, min: 0.0, max: 1.0 }),
793            ("Math", NodeKind::Math(MathOp::Add)),
794            ("Compare", NodeKind::Compare(CmpOp::Lt)),
795            ("Logic", NodeKind::Logic(LogicOp::And)),
796            ("Select", NodeKind::Select),
797        ] {
798            if menu_entry(ui, base.with(("add", name)), name).clicked() {
799                push(ui, kind);
800            }
801        }
802        if !assets.is_empty() {
803            ui.separator();
804            ui.label(egui::RichText::new("Asset").small().weak());
805            for (id, name) in assets {
806                if menu_entry(ui, base.with(("asset", *id)), name).clicked() {
807                    push(ui, NodeKind::Asset(*id));
808                }
809            }
810        }
811    });
812}
813
814// ---------- properties panel ----------
815
816/// One gesture's worth of widget responses: `changed` decides whether to write the node back,
817/// `snapshot` whether to push an undo entry first (drags and typing snapshot on their first frame,
818/// click-once widgets on the change itself).
819#[derive(Default)]
820struct Edits {
821    changed: bool,
822    snapshot: bool,
823}
824
825impl Edits {
826    fn add(&mut self, r: &egui::Response) -> &mut Self {
827        self.changed |= r.changed();
828        self.snapshot |= r.drag_started() || r.gained_focus() || (r.changed() && !r.dragged());
829        self
830    }
831}
832
833/// Every parameter of one node, edited on a clone and written back as a single `SetKind`. This is
834/// the answer to "I can't edit properties of many nodes": *every* `NodeKind` has an arm here.
835#[allow(clippy::too_many_arguments)]
836fn node_panel(
837    ui: &mut egui::Ui,
838    node: &Node,
839    graph: &NodeGraph,
840    lt: f64,
841    assets: &[(Id, String)],
842    clips: &[(Id, String)],
843    acts: &mut Vec<Act>,
844    needs_undo: &mut bool,
845) {
846    ui.strong(node.kind.title());
847    ui.separator();
848    let mut k = node.kind.clone();
849    let mut e = Edits::default();
850    egui::ScrollArea::vertical().show(ui, |ui| match &mut k {
851        NodeKind::Input => {
852            ui.weak("this clip's own picture");
853        }
854        NodeKind::Output => {
855            ui.weak("what the compositor draws");
856        }
857        NodeKind::Merge => {
858            ui.weak("b straight over a");
859        }
860        NodeKind::Select => {
861            ui.weak("cond >= 0.5 picks a, else b — works on pictures and on numbers");
862        }
863        NodeKind::Color(c) => {
864            ui.horizontal(|ui| {
865                ui.label("Color");
866                e.add(&ui.color_edit_button_srgba_unmultiplied(c));
867            });
868        }
869        NodeKind::Clip(id) => {
870            pick(ui, &mut e, "Clip", id, clips);
871        }
872        NodeKind::Asset(id) => {
873            pick(ui, &mut e, "Asset", id, assets);
874        }
875        NodeKind::Blend { mode, opacity } => {
876            blend_ui(ui, &mut e, mode, opacity, "Opacity", graph, node.id);
877        }
878        NodeKind::Combine { mode, factor } => {
879            blend_ui(ui, &mut e, mode, factor, "Factor", graph, node.id);
880        }
881        NodeKind::Matte { invert, use_alpha } => {
882            e.add(&ui.checkbox(use_alpha, "Use alpha (not luma)"));
883            e.add(&ui.checkbox(invert, "Invert"));
884        }
885        NodeKind::Mask(m) => mask_ui(ui, &mut e, m),
886        NodeKind::String(s) => {
887            e.add(&ui.text_edit_multiline(&mut s.text))
888                .add(&ui.text_edit_singleline(&mut s.font).on_hover_text("Font family"));
889            num(ui, &mut e, "Size", &mut s.size, 4.0..=400.0);
890            ui.horizontal(|ui| {
891                e.add(&ui.checkbox(&mut s.bold, "Bold")).add(&ui.checkbox(&mut s.italic, "Italic"));
892            });
893            ui.horizontal(|ui| {
894                ui.label("Fill");
895                e.add(&ui.color_edit_button_srgba_unmultiplied(&mut s.color));
896                ui.label("Outline");
897                e.add(&ui.color_edit_button_srgba_unmultiplied(&mut s.outline_color));
898            });
899            num(ui, &mut e, "Outline width", &mut s.outline_width, 0.0..=40.0);
900            ui.horizontal(|ui| {
901                e.add(&ui.checkbox(&mut s.shadow, "Shadow"));
902                e.add(&ui.color_edit_button_srgba_unmultiplied(&mut s.shadow_color));
903            });
904            num(ui, &mut e, "Shadow x", &mut s.shadow_x, -100.0..=100.0);
905            num(ui, &mut e, "Shadow y", &mut s.shadow_y, -100.0..=100.0);
906            num(ui, &mut e, "Shadow blur", &mut s.shadow_blur, 0.0..=100.0);
907            ui.horizontal(|ui| {
908                ui.label("Align");
909                for (i, name) in ["Left", "Center", "Right"].iter().enumerate() {
910                    e.add(&ui.selectable_value(&mut s.align, i as u8, *name));
911                }
912            });
913            num(ui, &mut e, "Line spacing", &mut s.line_spacing, 0.1..=4.0);
914            num(ui, &mut e, "Letter spacing", &mut s.letter_spacing, -20.0..=40.0);
915            ui.horizontal(|ui| {
916                ui.label("Box");
917                e.add(&ui.color_edit_button_srgba_unmultiplied(&mut s.box_color));
918            });
919            num(ui, &mut e, "Box padding", &mut s.box_padding, 0.0..=200.0);
920            ui.weak("{frame} · {time} · {n}");
921        }
922        NodeKind::Number(a) => {
923            ui.horizontal(|ui| {
924                ui.label("Value");
925                e.add(&ui.add(egui::DragValue::new(&mut a.value).speed(0.01)));
926            });
927            if a.is_animated() {
928                ui.weak(format!("{} keyframes — {:.3} now", a.keys.len(), a.at(lt)));
929            }
930        }
931        NodeKind::Bool(b) => {
932            e.add(&ui.checkbox(b, if *b { "true" } else { "false" }));
933        }
934        NodeKind::Random { seed, min, max } => {
935            ui.horizontal(|ui| {
936                ui.label("Seed");
937                e.add(&ui.add(egui::DragValue::new(seed)));
938            });
939            num64(ui, &mut e, "Min", min);
940            num64(ui, &mut e, "Max", max);
941            ui.weak("hashed from (seed, frame): the same project always renders the same numbers");
942        }
943        NodeKind::Math(op) => op_ui(ui, &mut e, op, &MathOp::ALL, MathOp::name),
944        NodeKind::Compare(op) => op_ui(ui, &mut e, op, &CmpOp::ALL, CmpOp::name),
945        NodeKind::Logic(op) => op_ui(ui, &mut e, op, &LogicOp::ALL, LogicOp::name),
946        NodeKind::Effect(fx) => {
947            for (i, spec) in fx.kind.params().iter().enumerate() {
948                while fx.params.len() <= i {
949                    let d = fx.kind.params()[fx.params.len()].default;
950                    fx.params.push(Animated::new(d));
951                }
952                // a value node on port i+1 computes this knob, so the widget stops mattering
953                let wired = graph.input_of(node.id, i + 1).is_some();
954                ui.horizontal(|ui| {
955                    ui.label(spec.name);
956                    let r = if fx.kind.is_bool_param(i) {
957                        let mut b = fx.params[i].value >= 0.5;
958                        let r = ui.add_enabled(!wired, egui::Checkbox::new(&mut b, ""));
959                        fx.params[i].value = if b { 1.0 } else { 0.0 };
960                        r
961                    } else {
962                        ui.add_enabled(
963                            !wired,
964                            egui::DragValue::new(&mut fx.params[i].value)
965                                .speed((spec.max - spec.min).abs().max(1.0) / 200.0)
966                                .range(spec.min..=spec.max),
967                        )
968                    };
969                    e.add(&r);
970                    if wired {
971                        ui.weak("wired");
972                    }
973                });
974            }
975            if fx.kind == EffectKind::Shader {
976                ui.weak("GLSL lives in the shader window");
977            }
978            let mut on = fx.mask.is_some();
979            if ui.checkbox(&mut on, "Mask").changed() {
980                fx.mask = on.then(Mask::default);
981                e.changed = true;
982                e.snapshot = true;
983            }
984            if let Some(m) = &mut fx.mask {
985                mask_ui(ui, &mut e, m);
986            }
987        }
988    });
989    if e.changed {
990        *needs_undo |= e.snapshot;
991        acts.push(Act::SetKind(node.id, k));
992    } else if e.snapshot {
993        *needs_undo = true;
994    }
995}
996
997/// Combo over (id, name) pairs — the Clip and Asset nodes' whole editor.
998fn pick(ui: &mut egui::Ui, e: &mut Edits, label: &str, id: &mut Id, items: &[(Id, String)]) {
999    let cur = items.iter().find(|(i, _)| i == id).map(|(_, n)| n.clone()).unwrap_or_else(|| "(none)".into());
1000    egui::ComboBox::from_label(label).selected_text(cur).show_ui(ui, |ui| {
1001        for (i, name) in items {
1002            e.add(&ui.selectable_value(id, *i, name));
1003        }
1004    });
1005    if items.is_empty() {
1006        ui.weak("nothing to point at yet");
1007    }
1008}
1009
1010fn op_ui<T: Copy + PartialEq>(ui: &mut egui::Ui, e: &mut Edits, op: &mut T, all: &[T], name: fn(T) -> &'static str) {
1011    egui::ComboBox::from_label("Operator").selected_text(name(*op)).show_ui(ui, |ui| {
1012        for o in all {
1013            e.add(&ui.selectable_value(op, *o, name(*o)));
1014        }
1015    });
1016}
1017
1018fn num(ui: &mut egui::Ui, e: &mut Edits, label: &str, v: &mut f32, range: std::ops::RangeInclusive<f32>) {
1019    ui.horizontal(|ui| {
1020        ui.label(label);
1021        e.add(&ui.add(egui::DragValue::new(v).range(range)));
1022    });
1023}
1024
1025fn num64(ui: &mut egui::Ui, e: &mut Edits, label: &str, v: &mut f64) {
1026    ui.horizontal(|ui| {
1027        ui.label(label);
1028        e.add(&ui.add(egui::DragValue::new(v).speed(0.01)));
1029    });
1030}
1031
1032fn anim(ui: &mut egui::Ui, e: &mut Edits, label: &str, a: &mut Animated) {
1033    ui.horizontal(|ui| {
1034        ui.label(label);
1035        e.add(&ui.add(egui::DragValue::new(&mut a.value).speed(0.5)));
1036        if a.is_animated() {
1037            ui.weak(format!("{}k", a.keys.len()));
1038        }
1039    });
1040}
1041
1042fn blend_ui(
1043    ui: &mut egui::Ui,
1044    e: &mut Edits,
1045    mode: &mut BlendMode,
1046    amount: &mut Animated,
1047    label: &str,
1048    graph: &NodeGraph,
1049    id: Id,
1050) {
1051    egui::ComboBox::from_label("Mode").selected_text(mode.name()).show_ui(ui, |ui| {
1052        for m in BlendMode::ALL {
1053            e.add(&ui.selectable_value(mode, m, m.name()));
1054        }
1055    });
1056    let wired = graph.input_of(id, 2).is_some();
1057    ui.horizontal(|ui| {
1058        ui.label(label);
1059        e.add(&ui.add_enabled(!wired, egui::DragValue::new(&mut amount.value).speed(0.01).range(0.0..=1.0)));
1060        if wired {
1061            ui.weak("wired");
1062        }
1063    });
1064}
1065
1066/// The mask editor, shared by the Mask node and an effect node's own mask.
1067fn mask_ui(ui: &mut egui::Ui, e: &mut Edits, m: &mut Mask) {
1068    egui::ComboBox::from_label("Shape").selected_text(m.shape.name()).show_ui(ui, |ui| {
1069        for s in MaskShape::ALL {
1070            e.add(&ui.selectable_value(&mut m.shape, s, s.name()));
1071        }
1072    });
1073    anim(ui, e, "Center x", &mut m.cx);
1074    anim(ui, e, "Center y", &mut m.cy);
1075    anim(ui, e, "Radius x", &mut m.rx);
1076    anim(ui, e, "Radius y", &mut m.ry);
1077    anim(ui, e, "Rotation", &mut m.rotation);
1078    anim(ui, e, "Feather", &mut m.feather);
1079    anim(ui, e, "Expand", &mut m.expand);
1080    anim(ui, e, "Opacity", &mut m.opacity);
1081    ui.horizontal(|ui| {
1082        e.add(&ui.checkbox(&mut m.invert, "Invert")).add(&ui.checkbox(&mut m.enabled, "Enabled"));
1083    });
1084    if matches!(m.shape, MaskShape::Polygon | MaskShape::Path) {
1085        ui.weak(format!("{} points (drawn on the preview)", m.points.len()));
1086    }
1087}
1088
1089/// Menu row with a caller-chosen id (so the headless tests can find it).
1090fn menu_entry(ui: &mut egui::Ui, id: egui::Id, label: &str) -> egui::Response {
1091    let w = ui.available_width().max(120.0);
1092    let (rect, _) = ui.allocate_exact_size(vec2(w, 18.0), Sense::hover());
1093    let r = ui.interact(rect, id, Sense::click());
1094    if r.hovered() {
1095        ui.painter().rect_filled(rect, CornerRadius::same(2), ui.visuals().widgets.hovered.bg_fill);
1096    }
1097    ui.painter().text(
1098        rect.left_center() + vec2(4.0, 0.0),
1099        Align2::LEFT_CENTER,
1100        label,
1101        TextStyle::Button.resolve(ui.style()),
1102        ui.visuals().text_color(),
1103    );
1104    r
1105}
1106
1107// ---------- painting ----------
1108
1109fn hint(ui: &mut egui::Ui, palette: &Palette, text: &str) {
1110    ui.centered_and_justified(|ui| {
1111        ui.label(egui::RichText::new(text).color(palette.text_dim));
1112    });
1113}
1114
1115fn node_height(kind: &NodeKind, thumb: bool) -> f32 {
1116    // an effect's parameter rows are its ports, so only the source nodes need a row of their own
1117    let fields = match kind {
1118        NodeKind::String(_) | NodeKind::Number(_) | NodeKind::Bool(_) => 1,
1119        _ => 0,
1120    };
1121    let rows = kind.inputs().max(fields).max(1);
1122    HEADER_H + rows as f32 * ROW_H + 6.0 + if thumb { THUMB_H } else { 0.0 }
1123}
1124
1125/// The inline widget strip on row `i` of a node box (row 0 sits just under the header).
1126fn row_rect(r: Rect, i: usize, z: f32) -> Rect {
1127    Rect::from_min_size(
1128        r.min + vec2(6.0 * z, (HEADER_H + ROW_H * i as f32) * z + 2.0),
1129        vec2((NODE_W - 12.0) * z, ROW_H * z),
1130    )
1131}
1132
1133/// Rows that already carry an inline widget: their port needs no painted label.
1134fn inline_row(kind: &NodeKind, row: usize) -> bool {
1135    match kind {
1136        NodeKind::Effect(e) => row >= 1 && row <= INLINE_PARAMS.min(e.specs().len()),
1137        _ => false,
1138    }
1139}
1140
1141fn in_port(r: Rect, i: usize, z: f32) -> Pos2 {
1142    pos2(r.left(), r.top() + (HEADER_H + ROW_H * (i as f32 + 0.5)) * z)
1143}
1144
1145fn out_port(r: Rect, z: f32) -> Pos2 {
1146    pos2(r.right(), r.top() + (HEADER_H + ROW_H * 0.5) * z)
1147}
1148
1149fn bezier(p: &egui::Painter, a: Pos2, b: Pos2, stroke: Stroke) {
1150    let d = ((b.x - a.x).abs() * 0.5).clamp(24.0, 140.0);
1151    p.add(egui::epaint::CubicBezierShape::from_points_stroke(
1152        [a, pos2(a.x + d, a.y), pos2(b.x - d, b.y), b],
1153        false,
1154        Color32::TRANSPARENT,
1155        stroke,
1156    ));
1157}
1158
1159fn grid(p: &egui::Painter, rect: Rect, org: Pos2, z: f32, palette: &Palette) {
1160    let step = 40.0 * z;
1161    if step < 8.0 {
1162        return;
1163    }
1164    let s = Stroke::new(1.0, palette.border.gamma_multiply(0.4));
1165    let mut x = rect.left() + (org.x - rect.left()).rem_euclid(step);
1166    while x < rect.right() {
1167        p.line_segment([pos2(x, rect.top()), pos2(x, rect.bottom())], s);
1168        x += step;
1169    }
1170    let mut y = rect.top() + (org.y - rect.top()).rem_euclid(step);
1171    while y < rect.bottom() {
1172        p.line_segment([pos2(rect.left(), y), pos2(rect.right(), y)], s);
1173        y += step;
1174    }
1175}
1176
1177#[allow(clippy::too_many_arguments)]
1178fn paint_node(
1179    p: &egui::Painter,
1180    ui: &egui::Ui,
1181    r: Rect,
1182    n: &crate::model::Node,
1183    selected: bool,
1184    palette: &Palette,
1185    z: f32,
1186    thumb: Option<egui::TextureId>,
1187) {
1188    let cr = CornerRadius::same((3.0 * z) as u8);
1189    p.rect_filled(r, cr, palette.panel);
1190    let head = Rect::from_min_size(r.min, vec2(r.width(), HEADER_H * z));
1191    p.rect_filled(head, cr, if n.enabled { palette.header } else { palette.bg });
1192    let border = if selected { Stroke::new(2.0, palette.accent) } else { Stroke::new(1.0, palette.border) };
1193    p.rect_stroke(r, cr, border, StrokeKind::Inside);
1194    // the logic nodes carry numbers, not pictures — the accent title says so at a glance
1195    let title_color = match (n.enabled, n.kind.is_value()) {
1196        (false, _) => palette.text_dim,
1197        (true, true) => palette.accent,
1198        (true, false) => palette.text,
1199    };
1200    p.text(
1201        pos2(r.left() + 18.0 * z, head.center().y),
1202        Align2::LEFT_CENTER,
1203        n.kind.title(),
1204        FontId::proportional((11.0 * z).clamp(7.0, 16.0)),
1205        title_color,
1206    );
1207    if let Some(tex) = thumb {
1208        let t = Rect::from_min_size(
1209            pos2(r.left() + 4.0 * z, r.bottom() - (THUMB_H - 2.0) * z),
1210            vec2(r.width() - 8.0 * z, (THUMB_H - 6.0) * z),
1211        );
1212        p.image(tex, t, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
1213    }
1214    // ports, each labelled unless an inline widget already says what it is
1215    let small = TextStyle::Small.resolve(ui.style());
1216    for i in 0..n.kind.inputs() {
1217        let at = in_port(r, i, z);
1218        p.circle_filled(at, PORT_R * z, palette.text_dim);
1219        let label = n.kind.port_label(i);
1220        if !label.is_empty() && !inline_row(&n.kind, i) && z >= 0.6 {
1221            p.text(pos2(r.left() + 8.0 * z, at.y), Align2::LEFT_CENTER, label, small.clone(), palette.text_dim);
1222        }
1223    }
1224    if n.kind != NodeKind::Output {
1225        p.circle_filled(out_port(r, z), PORT_R * z, palette.text_dim);
1226    }
1227    // nodes without inline widgets get a one-line summary, right-aligned so port labels stay readable
1228    let summary = match &n.kind {
1229        NodeKind::Blend { mode, opacity } => Some(format!("{:?}  {:.0}%", mode, opacity.value * 100.0)),
1230        NodeKind::Combine { mode, factor } => Some(format!("{:?}  {:.0}%", mode, factor.value * 100.0)),
1231        NodeKind::Merge => Some("b over a".into()),
1232        NodeKind::Asset(id) => Some(format!("#{id}")),
1233        NodeKind::Matte { invert, use_alpha } => {
1234            Some(format!("{}{}", if *use_alpha { "alpha" } else { "luma" }, if *invert { " inv" } else { "" }))
1235        }
1236        NodeKind::Color(c) => Some(format!("#{:02X}{:02X}{:02X}", c[0], c[1], c[2])),
1237        NodeKind::Clip(id) => Some(if *id == 0 { "(pick a clip)".into() } else { format!("#{id}") }),
1238        NodeKind::Random { seed, min, max } => Some(format!("#{seed}  {min:.2}..{max:.2}")),
1239        _ => None,
1240    };
1241    if let Some(s) = summary {
1242        p.text(
1243            pos2(r.right() - 8.0 * z, r.top() + (HEADER_H + ROW_H * 0.5) * z),
1244            Align2::RIGHT_CENTER,
1245            s,
1246            small,
1247            palette.text_dim,
1248        );
1249    }
1250}
1251
1252#[cfg(test)]
1253mod tests {
1254    use super::*;
1255    use crate::model::{Asset, AudioStreamInfo, ClipKind};
1256    use egui::{Event, Modifiers, PointerButton, RawInput, Vec2};
1257
1258    struct Harness {
1259        ctx: egui::Context,
1260        state: NodesState,
1261        project: Project,
1262        selection: Vec<Id>,
1263        base: egui::Id,
1264        undos: usize,
1265        time: f64,
1266        out: NodesResponse,
1267    }
1268
1269    impl Harness {
1270        fn new() -> Self {
1271            let mut project = Project::new();
1272            let aid = project.add_asset(Asset {
1273                id: 0,
1274                path: "C:/x.mp4".into(),
1275                kind: ClipKind::Video,
1276                duration: 10.0,
1277                width: 1280,
1278                height: 720,
1279                fps: 30.0,
1280                audio_streams: vec![AudioStreamInfo { channels: 2, sample_rate: 48000, ..Default::default() }],
1281                codec: "h264".into(),
1282                folder: String::new(),
1283                tags: Vec::new(),
1284                label: 0,
1285                description: String::new(),
1286            });
1287            project.insert_asset_clips(aid, 0.0, None);
1288            let clip = project.tracks[0].clips[0].id;
1289            project.ensure_graph(clip); // the pane no longer builds one just by being open
1290            let mut h = Self {
1291                ctx: egui::Context::default(),
1292                state: NodesState::default(),
1293                project,
1294                selection: vec![clip],
1295                base: egui::Id::NULL,
1296                undos: 0,
1297                time: 0.0,
1298                out: NodesResponse::default(),
1299            };
1300            h.frame(vec![]);
1301            h
1302        }
1303        fn clip(&self) -> Id {
1304            self.selection[0]
1305        }
1306        fn graph(&self) -> &crate::model::NodeGraph {
1307            self.project.clip(self.clip()).and_then(|c| c.graph.as_ref()).expect("graph")
1308        }
1309        fn frame(&mut self, events: Vec<Event>) {
1310            self.frame_m(events, Modifiers::NONE);
1311        }
1312        fn frame_m(&mut self, events: Vec<Event>, modifiers: Modifiers) {
1313            self.time += 0.05;
1314            let input = RawInput {
1315                screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(900.0, 600.0))),
1316                time: Some(self.time),
1317                events,
1318                modifiers,
1319                ..Default::default()
1320            };
1321            let pal = Palette::new(true, Color32::from_rgb(0, 120, 212));
1322            let Harness { ctx, state, project, selection, base, undos, out, .. } = self;
1323            let _ = ctx.run(input, |ctx| {
1324                egui::CentralPanel::default().show(ctx, |ui| {
1325                    *base = ui.id();
1326                    let mut undo = |_: &Project| *undos += 1;
1327                    *out = show(ui, state, project, selection, 0.0, &pal, &mut undo);
1328                });
1329            });
1330        }
1331        fn node_rect(&self, id: Id) -> Rect {
1332            self.ctx
1333                .read_response(self.base.with(("node", id)))
1334                .unwrap_or_else(|| panic!("node {id} not laid out"))
1335                .rect
1336        }
1337        fn press(&mut self, pos: Pos2, button: PointerButton) {
1338            self.press_m(pos, button, Modifiers::NONE);
1339        }
1340        fn press_m(&mut self, pos: Pos2, button: PointerButton, modifiers: Modifiers) {
1341            self.frame_m(vec![Event::PointerMoved(pos)], modifiers);
1342            self.frame_m(vec![Event::PointerButton { pos, button, pressed: true, modifiers }], modifiers);
1343        }
1344        fn release(&mut self, pos: Pos2, button: PointerButton) {
1345            self.release_m(pos, button, Modifiers::NONE);
1346        }
1347        fn release_m(&mut self, pos: Pos2, button: PointerButton, modifiers: Modifiers) {
1348            self.frame_m(vec![Event::PointerButton { pos, button, pressed: false, modifiers }], modifiers);
1349            self.frame_m(vec![], modifiers);
1350        }
1351        /// Click a node's title bar (its middle is an inline parameter widget) — Ctrl to add it to /
1352        /// drop it from the selection.
1353        fn click_node(&mut self, id: Id, modifiers: Modifiers) {
1354            let r = self.node_rect(id);
1355            let c = pos2(r.center().x, r.top() + HEADER_H * 0.5 * self.state.zoom);
1356            self.press_m(c, PointerButton::Primary, modifiers);
1357            self.release_m(c, PointerButton::Primary, modifiers);
1358        }
1359        fn key(&mut self, key: egui::Key, modifiers: Modifiers) {
1360            self.frame(vec![Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }]);
1361        }
1362        fn pos_of(&self, id: Id) -> (f32, f32) {
1363            let n = self.graph().node(id).expect("node");
1364            (n.x, n.y)
1365        }
1366        fn drag(&mut self, from: Pos2, to: Pos2) {
1367            self.press(from, PointerButton::Primary);
1368            for i in 1..=4 {
1369                self.frame(vec![Event::PointerMoved(from + (to - from) * (i as f32 / 4.0))]);
1370            }
1371            self.release(to, PointerButton::Primary);
1372        }
1373        /// Right-click at `pos`, then click the menu row with the given salt. The menu Area needs a
1374        /// couple of frames to settle into its final position before its rows can be hit.
1375        fn menu_click<S: std::hash::Hash + std::fmt::Debug>(&mut self, pos: Pos2, salt: S) {
1376            self.press(pos, PointerButton::Secondary);
1377            self.release(pos, PointerButton::Secondary);
1378            let id = self.base.with(&salt);
1379            let mut r = Pos2::ZERO;
1380            for _ in 0..4 {
1381                self.frame(vec![]);
1382                r = self.ctx.read_response(id).unwrap_or_else(|| panic!("menu row {salt:?} missing")).rect.center();
1383                self.frame(vec![Event::PointerMoved(r)]);
1384            }
1385            self.press(r, PointerButton::Primary);
1386            self.release(r, PointerButton::Primary);
1387        }
1388        fn ids(&self) -> (Id, Id) {
1389            let g = self.graph();
1390            (g.nodes[0].id, g.output().expect("output"))
1391        }
1392    }
1393
1394    #[test]
1395    #[test]
1396    fn middle_drag_pans_the_canvas() {
1397        let mut h = Harness::new();
1398        let offset0 = h.state.offset;
1399        h.press(pos2(300.0, 200.0), PointerButton::Middle);
1400        for i in 1..=4 {
1401            let p = pos2(300.0, 200.0) + Vec2::new(50.0, -30.0) * (i as f32 / 4.0);
1402            h.frame(vec![Event::PointerMoved(p)]);
1403        }
1404        h.release(pos2(350.0, 170.0), PointerButton::Middle);
1405        assert_ne!(h.state.offset, offset0, "middle-drag pans the canvas");
1406        assert!(h.state.band.is_none(), "middle-drag must not start a rubber band");
1407        assert_eq!(h.undos, 0, "panning is not a project edit");
1408    }
1409
1410    #[test]
1411    fn opening_the_pane_converts_the_effect_stack() {
1412        let mut h = Harness::new();
1413        assert!(h.project.clip(h.clip()).unwrap().graph.is_some(), "ensure_graph ran");
1414        let (input, output) = h.ids();
1415        assert_eq!(h.graph().nodes.len(), 2);
1416        assert_eq!(h.graph().eval_order(), vec![input, output]);
1417        h.frame(vec![]);
1418        assert_eq!(h.graph().nodes.len(), 2, "idempotent");
1419    }
1420
1421    /// The pane is opt-in: pointing it at a clip that has no graph offers a button and leaves the clip's
1422    /// effect list alone until it is pressed. Otherwise a Nodes pane sitting in the layout silently took
1423    /// over every clip the user selected, and effects could never be put on footage directly again.
1424    #[test]
1425    fn a_clip_without_a_graph_is_left_alone_until_asked() {
1426        let mut h = Harness::new();
1427        let id = h.clip();
1428        let c = h.project.clip_mut(id).unwrap();
1429        c.graph = None;
1430        c.effects.push(Effect::new(EffectKind::Blur));
1431        h.frame(vec![]);
1432        h.frame(vec![]);
1433        assert!(h.project.clip(id).unwrap().graph.is_none(), "drawing the pane is not an edit");
1434        assert_eq!(h.undos, 0);
1435        assert!(!h.out.edited);
1436        assert_eq!(h.project.clip(id).unwrap().effects.len(), 1, "and the effect list is untouched");
1437    }
1438
1439    #[test]
1440    fn no_selection_shows_a_hint_instead_of_panicking() {
1441        let mut h = Harness::new();
1442        h.selection.clear();
1443        h.frame(vec![]);
1444        assert_eq!(h.out.selected, None);
1445        assert!(!h.out.edited);
1446    }
1447
1448    #[test]
1449    fn adding_an_effect_node_inserts_it() {
1450        let mut h = Harness::new();
1451        h.menu_click(pos2(600.0, 140.0), ("add", "Blur"));
1452        let g = h.graph();
1453        assert_eq!(g.nodes.len(), 3, "{:?}", g.nodes.iter().map(|n| n.kind.title()).collect::<Vec<_>>());
1454        let added = g.nodes.iter().find(|n| n.kind.title() == "Blur").expect("blur node");
1455        assert!(matches!(&added.kind, NodeKind::Effect(e) if e.kind == EffectKind::Blur));
1456        assert_eq!(h.out.selected, Some(added.id), "a new node becomes the selection");
1457        assert!(h.undos >= 1, "adding a node is undoable");
1458    }
1459
1460    #[test]
1461    fn input_effect_output_evaluates_in_order() {
1462        let mut h = Harness::new();
1463        let (input, output) = h.ids();
1464        h.menu_click(pos2(560.0, 200.0), ("add", "Blur"));
1465        let fx = h.out.selected.expect("selected");
1466        // wire Input -> Blur -> Output by dragging between the ports
1467        let (ir, fr) = (h.node_rect(input), h.node_rect(fx));
1468        let z = h.state.zoom;
1469        h.drag(out_port(ir, z), in_port(fr, 0, z));
1470        let (fr, orr) = (h.node_rect(fx), h.node_rect(output));
1471        h.drag(out_port(fr, z), in_port(orr, 0, z));
1472        let g = h.graph();
1473        assert_eq!(g.input_of(fx, 0), Some(input), "edges: {:?}", g.edges);
1474        assert_eq!(g.input_of(output, 0), Some(fx), "edges: {:?}", g.edges);
1475        assert_eq!(g.eval_order(), vec![input, fx, output]);
1476    }
1477
1478    #[test]
1479    fn a_cycle_is_refused() {
1480        let mut h = Harness::new();
1481        let (input, _) = h.ids();
1482        h.menu_click(pos2(420.0, 120.0), ("add", "Blur"));
1483        let fx = h.out.selected.expect("selected");
1484        h.menu_click(pos2(560.0, 260.0), ("add", "Sharpen"));
1485        let fx2 = h.out.selected.expect("selected");
1486        let z = h.state.zoom;
1487        let (ir, fr) = (h.node_rect(input), h.node_rect(fx));
1488        h.drag(out_port(ir, z), in_port(fr, 0, z)); // Input -> Blur
1489        let (fr, fr2) = (h.node_rect(fx), h.node_rect(fx2));
1490        h.drag(out_port(fr, z), in_port(fr2, 0, z)); // Blur -> Sharpen
1491        assert_eq!(h.graph().input_of(fx2, 0), Some(fx), "edges: {:?}", h.graph().edges);
1492        let mut edges = h.graph().edges.clone();
1493        let undos = h.undos;
1494        let (fr, fr2) = (h.node_rect(fx), h.node_rect(fx2));
1495        h.drag(out_port(fr2, z), in_port(fr, 0, z)); // Sharpen -> Blur would loop
1496                                                     // `connect` restores the previous edge, so compare as a set
1497        let mut after = h.graph().edges.clone();
1498        let key = |e: &crate::model::Edge| (e.from, e.to, e.port);
1499        edges.sort_by_key(key);
1500        after.sort_by_key(key);
1501        assert_eq!(after, edges, "the cycle must be refused");
1502        assert!(!h.graph().has_cycle());
1503        assert_eq!(undos, h.undos, "a refused wire pushes no undo entry");
1504    }
1505
1506    #[test]
1507    fn deleting_a_node_relinks_its_neighbour() {
1508        let mut h = Harness::new();
1509        let (input, output) = h.ids();
1510        h.menu_click(pos2(560.0, 200.0), ("add", "Blur"));
1511        let fx = h.out.selected.expect("selected");
1512        let z = h.state.zoom;
1513        let (ir, fr) = (h.node_rect(input), h.node_rect(fx));
1514        h.drag(out_port(ir, z), in_port(fr, 0, z));
1515        let (fr, orr) = (h.node_rect(fx), h.node_rect(output));
1516        h.drag(out_port(fr, z), in_port(orr, 0, z));
1517        assert_eq!(h.graph().input_of(output, 0), Some(fx));
1518        let fr = h.node_rect(fx);
1519        h.menu_click(fr.center(), ("del", fx));
1520        let g = h.graph();
1521        assert_eq!(g.nodes.len(), 2);
1522        assert_eq!(g.input_of(output, 0), Some(input), "the neighbour was re-linked");
1523    }
1524
1525    #[test]
1526    fn the_output_node_cannot_be_deleted() {
1527        let mut h = Harness::new();
1528        let (_, output) = h.ids();
1529        h.state.selection = vec![output];
1530        h.frame(vec![]);
1531        // no Delete row in the Output menu
1532        let orr = h.node_rect(output);
1533        h.press(orr.center(), PointerButton::Secondary);
1534        h.release(orr.center(), PointerButton::Secondary);
1535        assert!(h.ctx.read_response(h.base.with(("del", output))).is_none(), "Output offers no Delete");
1536        // and the keyboard shortcut is refused too
1537        h.frame(vec![Event::Key {
1538            key: egui::Key::Delete,
1539            physical_key: None,
1540            pressed: true,
1541            repeat: false,
1542            modifiers: Modifiers::NONE,
1543        }]);
1544        assert!(h.graph().output().is_some(), "Output survives");
1545        assert_eq!(h.graph().nodes.len(), 2);
1546    }
1547
1548    #[test]
1549    fn dragging_a_node_moves_it_with_one_undo() {
1550        let mut h = Harness::new();
1551        let (input, _) = h.ids();
1552        let before = {
1553            let n = h.graph().node(input).unwrap();
1554            (n.x, n.y)
1555        };
1556        let start = h.node_rect(input).center();
1557        h.undos = 0;
1558        h.drag(start, start + vec2(60.0, 40.0));
1559        let after = {
1560            let n = h.graph().node(input).unwrap();
1561            (n.x, n.y)
1562        };
1563        assert!((after.0 - before.0 - 60.0).abs() < 1.0, "x {before:?} -> {after:?}");
1564        assert!((after.1 - before.1 - 40.0).abs() < 1.0, "y {before:?} -> {after:?}");
1565        assert_eq!(h.undos, 1, "exactly one undo snapshot per drag gesture");
1566        assert!(h.out.selected == Some(input));
1567    }
1568
1569    #[test]
1570    fn pulling_a_wire_off_an_input_disconnects_it() {
1571        let mut h = Harness::new();
1572        let (input, output) = h.ids();
1573        assert_eq!(h.graph().input_of(output, 0), Some(input));
1574        let z = h.state.zoom;
1575        let port = in_port(h.node_rect(output), 0, z);
1576        h.drag(port, port + vec2(0.0, 160.0)); // drop on empty canvas
1577        assert_eq!(h.graph().input_of(output, 0), None, "the wire was pulled off");
1578    }
1579
1580    #[test]
1581    fn converting_the_effect_stack_is_undoable() {
1582        let mut h = Harness::new();
1583        let id = h.clip();
1584        h.project.clip_mut(id).unwrap().graph = None;
1585        h.frame(vec![]);
1586        let pos = h.ctx.read_response(h.base.with("build_graph")).expect("build button").rect.center();
1587        h.press(pos, PointerButton::Primary);
1588        // the release frame is the one that reports the edit; release() would draw another and clear it
1589        h.frame(vec![Event::PointerButton {
1590            pos,
1591            button: PointerButton::Primary,
1592            pressed: false,
1593            modifiers: Modifiers::NONE,
1594        }]);
1595        assert!(h.project.clip(id).unwrap().graph.is_some());
1596        assert_eq!(h.undos, 1, "the conversion is a snapshotted edit, not a silent side effect");
1597        assert!(h.out.edited, "and the app is told the project changed");
1598    }
1599
1600    #[test]
1601    fn clicking_a_connected_input_port_changes_nothing() {
1602        let mut h = Harness::new();
1603        let (input, output) = h.ids();
1604        let z = h.state.zoom;
1605        let port = in_port(h.node_rect(output), 0, z);
1606        h.undos = 0;
1607        h.press(port, PointerButton::Primary);
1608        h.release(port, PointerButton::Primary);
1609        assert_eq!(h.graph().input_of(output, 0), Some(input), "the wire survives a plain click");
1610        assert_eq!(h.undos, 0, "detach + re-attach must not push two no-op undo entries");
1611    }
1612
1613    #[test]
1614    fn typing_an_inline_parameter_snapshots_undo_first() {
1615        let mut h = Harness::new();
1616        h.menu_click(pos2(560.0, 200.0), ("add", "Blur"));
1617        let fx = h.out.selected.expect("selected");
1618        let value = |h: &Harness| match &h.graph().node(fx).unwrap().kind {
1619            NodeKind::Effect(e) => e.at(0, 0.0),
1620            _ => panic!("not an effect"),
1621        };
1622        let before = value(&h);
1623        let r = h.node_rect(fx);
1624        let z = h.state.zoom;
1625        // the first inline parameter row (row 0 is the picture port): its DragValue fills the right side
1626        let start = pos2(r.left() + 62.0 * z, r.top() + (HEADER_H + ROW_H * 1.5) * z + 2.0);
1627        h.undos = 0;
1628        // click the DragValue to type into it (the frame that gains focus has no change to apply yet)
1629        h.press(start, PointerButton::Primary);
1630        h.release(start, PointerButton::Primary);
1631        h.frame(vec![Event::Text("9".into())]);
1632        h.frame(vec![Event::Key {
1633            key: egui::Key::Enter,
1634            physical_key: None,
1635            pressed: true,
1636            repeat: false,
1637            modifiers: Modifiers::NONE,
1638        }]);
1639        h.frame(vec![]);
1640        assert!((value(&h) - before).abs() > 1e-9, "the typed parameter took: {before} -> {}", value(&h));
1641        assert_eq!(h.undos, 1, "the snapshot lands on the gesture's first frame, before the value changes");
1642    }
1643
1644    #[test]
1645    fn ctrl_scroll_zooms_around_the_pointer() {
1646        let mut h = Harness::new();
1647        let anchor = pos2(400.0, 300.0);
1648        h.frame(vec![Event::PointerMoved(anchor)]);
1649        let before = h.state.zoom;
1650        h.frame(vec![Event::MouseWheel {
1651            unit: egui::MouseWheelUnit::Point,
1652            delta: vec2(0.0, 100.0),
1653            modifiers: Modifiers::CTRL,
1654        }]);
1655        assert!(h.state.zoom > before, "zoom {} -> {}", before, h.state.zoom);
1656        assert!(h.state.zoom <= 3.0);
1657    }
1658
1659    #[test]
1660    fn ctrl_d_duplicates_the_selected_node() {
1661        let mut h = Harness::new();
1662        h.menu_click(pos2(560.0, 200.0), ("add", "Blur"));
1663        let fx = h.out.selected.expect("selected");
1664        let (x, y) = {
1665            let n = h.graph().node(fx).unwrap();
1666            (n.x, n.y)
1667        };
1668        h.frame(vec![Event::PointerMoved(pos2(600.0, 500.0))]);
1669        h.frame(vec![Event::Key {
1670            key: egui::Key::D,
1671            physical_key: None,
1672            pressed: true,
1673            repeat: false,
1674            modifiers: Modifiers::CTRL,
1675        }]);
1676        let g = h.graph();
1677        assert_eq!(g.nodes.len(), 4, "the duplicate was added");
1678        let copies: Vec<_> = g.nodes.iter().filter(|n| n.kind.title() == "Blur").collect();
1679        assert_eq!(copies.len(), 2);
1680        let dup = copies.iter().find(|n| n.id != fx).unwrap();
1681        assert!((dup.x - x - 24.0).abs() < 0.01 && (dup.y - y - 24.0).abs() < 0.01, "offset from the original");
1682    }
1683
1684    #[test]
1685    fn the_header_checkbox_disables_a_node() {
1686        let mut h = Harness::new();
1687        h.menu_click(pos2(560.0, 200.0), ("add", "Blur"));
1688        let fx = h.out.selected.expect("selected");
1689        assert!(h.graph().node(fx).unwrap().enabled);
1690        let cb = h.node_rect(fx).min + vec2(9.0, 11.0);
1691        h.undos = 0;
1692        h.press(cb, PointerButton::Primary);
1693        h.release(cb, PointerButton::Primary);
1694        let n = h.graph().node(fx).unwrap();
1695        assert!(!n.enabled, "the checkbox toggles the node off");
1696        assert!(matches!(&n.kind, NodeKind::Effect(e) if !e.enabled), "and the effect with it");
1697        assert_eq!(h.undos, 1);
1698    }
1699
1700    #[test]
1701    fn apply_edits_parameters_and_masks() {
1702        let mut h = Harness::new();
1703        h.menu_click(pos2(560.0, 200.0), ("add", "Blur"));
1704        let fx = h.out.selected.expect("selected");
1705        let mut sel = vec![fx];
1706        let clip = h.clip();
1707        assert!(apply(&mut h.project, clip, Act::SetParam(fx, 0, 7.5), &mut sel));
1708        assert!(apply(&mut h.project, clip, Act::AddMask(fx), &mut sel));
1709        assert!(!apply(&mut h.project, clip, Act::AddMask(fx), &mut sel), "a second mask is a no-op");
1710        let n = h.graph().node(fx).unwrap();
1711        let NodeKind::Effect(e) = &n.kind else { panic!("not an effect") };
1712        assert_eq!(e.at(0, 0.0), 7.5);
1713        assert!(e.mask.is_some());
1714    }
1715
1716    #[test]
1717    fn node_height_grows_with_a_thumbnail() {
1718        let plain = node_height(&NodeKind::Effect(Effect::new(EffectKind::Blur)), false);
1719        let with = node_height(&NodeKind::Effect(Effect::new(EffectKind::Blur)), true);
1720        assert!(with > plain + 40.0, "{plain} vs {with}");
1721        assert!(node_height(&NodeKind::Output, false) >= HEADER_H + ROW_H);
1722    }
1723
1724    /// Two effect nodes, wired Input -> a -> b, neither selected afterwards.
1725    fn two_nodes(h: &mut Harness) -> (Id, Id) {
1726        h.menu_click(pos2(420.0, 160.0), ("add", "Blur"));
1727        let a = h.out.selected.expect("selected");
1728        h.menu_click(pos2(560.0, 320.0), ("add", "Sharpen"));
1729        let b = h.out.selected.expect("selected");
1730        let (input, _) = h.ids();
1731        let z = h.state.zoom;
1732        let (ir, ar) = (h.node_rect(input), h.node_rect(a));
1733        h.drag(out_port(ir, z), in_port(ar, 0, z));
1734        let (ar, br) = (h.node_rect(a), h.node_rect(b));
1735        h.drag(out_port(ar, z), in_port(br, 0, z));
1736        (a, b)
1737    }
1738
1739    #[test]
1740    fn ctrl_click_picks_several_nodes_and_they_move_and_delete_together() {
1741        let mut h = Harness::new();
1742        let (a, b) = two_nodes(&mut h);
1743        h.click_node(a, Modifiers::NONE);
1744        h.click_node(b, Modifiers::CTRL);
1745        assert_eq!(h.state.selection, vec![a, b], "Ctrl+click added the second node");
1746        let (before_a, before_b) = (h.pos_of(a), h.pos_of(b));
1747        h.undos = 0;
1748        let start = h.node_rect(a).center();
1749        h.drag(start, start + vec2(50.0, 30.0));
1750        let (after_a, after_b) = (h.pos_of(a), h.pos_of(b));
1751        for (before, after) in [(before_a, after_a), (before_b, after_b)] {
1752            assert!((after.0 - before.0 - 50.0).abs() < 1.0, "x {before:?} -> {after:?}");
1753            assert!((after.1 - before.1 - 30.0).abs() < 1.0, "y {before:?} -> {after:?}");
1754        }
1755        assert_eq!(h.undos, 1, "one snapshot for the whole group move");
1756        // Ctrl+click again drops a node from the selection, then Delete takes the rest
1757        h.click_node(a, Modifiers::CTRL);
1758        assert_eq!(h.state.selection, vec![b]);
1759        h.click_node(a, Modifiers::CTRL);
1760        h.frame(vec![Event::PointerMoved(pos2(600.0, 520.0))]);
1761        h.key(egui::Key::Delete, Modifiers::NONE);
1762        assert_eq!(h.graph().nodes.len(), 2, "both picked nodes were deleted");
1763        assert!(h.state.selection.is_empty());
1764    }
1765
1766    #[test]
1767    fn a_rubber_band_picks_every_node_it_covers() {
1768        let mut h = Harness::new();
1769        let (input, output) = h.ids();
1770        // press on empty canvas below the two nodes and drag up across both
1771        h.drag(pos2(620.0, 500.0), pos2(10.0, 12.0));
1772        assert_eq!(h.state.selection, vec![input, output]);
1773        assert!(h.state.band.is_none(), "band cleared on release");
1774        // a band over empty space replaces the selection with nothing
1775        h.drag(pos2(620.0, 500.0), pos2(500.0, 300.0));
1776        assert!(h.state.selection.is_empty(), "{:?}", h.state.selection);
1777    }
1778
1779    #[test]
1780    fn copy_paste_clones_the_selection_with_the_edges_inside_it() {
1781        let mut h = Harness::new();
1782        let (a, b) = two_nodes(&mut h);
1783        let (input, _) = h.ids();
1784        h.click_node(a, Modifiers::NONE);
1785        h.click_node(b, Modifiers::CTRL);
1786        h.frame(vec![Event::PointerMoved(pos2(600.0, 520.0))]);
1787        h.key(egui::Key::C, Modifiers::CTRL);
1788        h.undos = 0;
1789        h.key(egui::Key::V, Modifiers::CTRL);
1790        assert_eq!(h.graph().nodes.len(), 6, "two copies joined the four nodes");
1791        assert_eq!(h.undos, 1, "one snapshot for the paste");
1792        let copies = h.state.selection.clone();
1793        assert_eq!(copies.len(), 2, "the copies are what stays selected");
1794        assert!(!copies.contains(&a) && !copies.contains(&b), "fresh ids: {copies:?}");
1795        let g = h.graph();
1796        assert_eq!(g.input_of(copies[1], 0), Some(copies[0]), "the edge inside the copy came along");
1797        assert_eq!(g.input_of(copies[0], 0), None, "the edge from Input did not");
1798        assert_eq!(g.input_of(b, 0), Some(a), "the originals are untouched");
1799        assert_eq!(g.input_of(a, 0), Some(input));
1800        let (orig, copy) = (h.pos_of(a), h.pos_of(copies[0]));
1801        assert!((copy.0 - orig.0 - OFFSET).abs() < 0.01 && (copy.1 - orig.1 - OFFSET).abs() < 0.01, "offset");
1802    }
1803
1804    #[test]
1805    fn dropping_an_effect_or_a_transition_makes_a_node_where_it_landed() {
1806        let mut h = Harness::new();
1807        // the drag starts outside the canvas (in the effects panel), so only the release lands here
1808        let drop = |h: &mut Harness, payload: DragPayload, at: Pos2| {
1809            h.press(pos2(2.0, 2.0), PointerButton::Primary);
1810            egui::DragAndDrop::set_payload(&h.ctx, payload);
1811            h.frame(vec![Event::PointerMoved(at)]);
1812            h.release(at, PointerButton::Primary);
1813        };
1814        let at = pos2(600.0, 400.0);
1815        drop(&mut h, DragPayload::Effect(EffectKind::Pixelate), at);
1816        let g = h.graph();
1817        assert_eq!(g.nodes.len(), 3, "{:?}", g.nodes.iter().map(|n| n.kind.title()).collect::<Vec<_>>());
1818        let n = g.nodes.iter().find(|n| n.kind.title() == "Pixelate").expect("effect node");
1819        assert!(g.edges.iter().all(|e| e.from != n.id && e.to != n.id), "wired to nothing");
1820        // dropped at the pointer: graph units = (screen - origin) / zoom, and the canvas starts unpanned
1821        let origin = h.ctx.read_response(h.base.with(("node", g.nodes[0].id))).expect("input node").rect.min;
1822        assert!((n.x - (at.x - origin.x)).abs() < 1.0 && (n.y - (at.y - origin.y)).abs() < 1.0, "at {},{}", n.x, n.y);
1823        assert!(h.undos >= 1, "a dropped node is undoable");
1824        drop(&mut h, DragPayload::Transition(TransitionKind::CrossFade), pos2(300.0, 450.0));
1825        assert!(h.graph().nodes.iter().any(|n| n.kind.title() == "Combine"), "a cross fade drops in as a mix");
1826    }
1827
1828    #[test]
1829    fn replacing_a_node_keeps_every_wire_the_new_kind_still_has_a_port_for() {
1830        let mut h = Harness::new();
1831        let (input, output) = h.ids();
1832        let clip = h.clip();
1833        let blend = h
1834            .project
1835            .add_node(clip, NodeKind::Blend { mode: BlendMode::Normal, opacity: Animated::new(1.0) }, 0.0, 0.0)
1836            .unwrap();
1837        {
1838            let g = h.project.clip_mut(clip).unwrap().graph.as_mut().unwrap();
1839            // all three ports fed, and the blend feeding the output
1840            assert!(g.connect(input, blend, 0) && g.connect(input, blend, 1) && g.connect(input, blend, 2));
1841            assert!(g.connect(blend, output, 0));
1842        }
1843        let mut sel = vec![blend];
1844        // Merge has two ports, so the opacity wire (port 2) is the only one that cannot survive
1845        assert!(apply(&mut h.project, clip, Act::SetKind(blend, NodeKind::Merge), &mut sel));
1846        let g = h.graph();
1847        assert_eq!(g.node(blend).unwrap().kind, NodeKind::Merge);
1848        assert_eq!(g.input_of(blend, 0), Some(input));
1849        assert_eq!(g.input_of(blend, 1), Some(input));
1850        assert_eq!(g.input_of(blend, 2), None, "the port is gone, so is its wire");
1851        assert_eq!(g.input_of(output, 0), Some(blend), "what the node feeds never changes");
1852        // the Output node is not replaceable, and nothing else may become one
1853        assert!(!apply(&mut h.project, clip, Act::SetKind(output, NodeKind::Merge), &mut sel));
1854        assert!(!apply(&mut h.project, clip, Act::SetKind(blend, NodeKind::Output), &mut sel));
1855        assert_eq!(h.graph().nodes.iter().filter(|n| n.kind == NodeKind::Output).count(), 1);
1856    }
1857
1858    #[test]
1859    fn dropping_an_asset_on_a_node_retargets_it() {
1860        let mut h = Harness::new();
1861        let (input, output) = h.ids();
1862        let aid = h.project.assets[0].id;
1863        // the drag starts in the library, so only the release lands on the node
1864        h.press(pos2(2.0, 2.0), PointerButton::Primary);
1865        egui::DragAndDrop::set_payload(&h.ctx, DragPayload::Asset(aid));
1866        let at = h.node_rect(input).center();
1867        h.frame(vec![Event::PointerMoved(at)]);
1868        h.release(at, PointerButton::Primary);
1869        assert_eq!(h.graph().node(input).unwrap().kind, NodeKind::Asset(aid));
1870        assert_eq!(h.graph().input_of(output, 0), Some(input), "the wire out of it survives");
1871        assert_eq!(h.graph().nodes.len(), 2, "no node was added");
1872    }
1873
1874    #[test]
1875    fn the_properties_panel_follows_the_picked_node() {
1876        let mut h = Harness::new();
1877        let clip = h.clip();
1878        // one node of every kind that used to have no editor at all
1879        for kind in [
1880            NodeKind::Matte { invert: false, use_alpha: false },
1881            NodeKind::Mask(Mask::default()),
1882            NodeKind::Color([1, 2, 3, 4]),
1883            NodeKind::Random { seed: 3, min: 0.0, max: 1.0 },
1884            NodeKind::Math(MathOp::Mul),
1885            NodeKind::Select,
1886            NodeKind::String(TextProps::default()),
1887        ] {
1888            let id = h.project.add_node(clip, kind.clone(), 40.0, 40.0).unwrap();
1889            h.state.selection = vec![id];
1890            h.frame(vec![]);
1891            assert_eq!(h.out.selected, Some(id));
1892            assert_eq!(h.graph().node(id).unwrap().kind, kind, "drawing the panel must not edit anything");
1893        }
1894    }
1895
1896    #[test]
1897    fn the_add_menu_lists_every_effect_kind_once() {
1898        let mut seen = Vec::new();
1899        let mut cats = Vec::new();
1900        for k in EffectKind::ALL {
1901            assert!(!seen.contains(&k.name()), "{} listed twice", k.name());
1902            seen.push(k.name());
1903            if !cats.contains(&k.category()) {
1904                cats.push(k.category());
1905            }
1906        }
1907        assert_eq!(seen.len(), EffectKind::ALL.len());
1908        assert!(cats.len() > 1, "the menu is grouped: {cats:?}");
1909    }
1910}