1use 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
35const NODE_W: f32 = 150.0;
37const HEADER_H: f32 = 22.0;
38const ROW_H: f32 = 18.0;
39const THUMB_H: f32 = 56.0;
40const SNAP: f32 = 12.0;
42const PORT_R: f32 = 4.0;
43const INLINE_PARAMS: usize = 2;
45const OFFSET: f32 = 24.0;
47
48#[derive(Default)]
49pub struct NodesState {
50 pub offset: egui::Vec2,
52 pub zoom: f32,
53 pub selection: Vec<Id>,
55 pub linking: Option<(Id, usize, bool)>,
57 pub dragging: Option<Id>,
59 pub detach: Option<(Id, usize)>,
61 pub band: Option<(Pos2, bool)>,
63 pub clipboard: (Vec<Node>, Vec<Edge>),
65 pub menu_at: Option<(f32, f32)>,
67 pub thumbs: std::collections::HashMap<Id, egui::TextureId>,
70}
71
72impl NodesState {
73 pub fn primary(&self) -> Option<Id> {
75 self.selection.last().copied()
76 }
77 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 pub selected: Option<Id>,
94}
95
96#[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 SetKind(Id, NodeKind),
108 SetEnabled(Id, bool),
109 Connect(Id, Id, usize),
110 Disconnect(Id, usize),
111 AddMask(Id),
112 SetPath(Id, Id),
114 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; }
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 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 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 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 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 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 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 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 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 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 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 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 match graph.input_of(node, port) {
260 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 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 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 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 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 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 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; }
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 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 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 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 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 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 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 if resp.dragged_by(egui::PointerButton::Middle) {
487 state.offset += resp.drag_delta();
488 }
489 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 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 DragPayload::Asset(id) => Some(NodeKind::Asset(*id)),
522 _ => 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 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 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 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
590fn 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
601fn apply(project: &mut Project, clip: Id, act: Act, selected: &mut Vec<Id>) -> bool {
603 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 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 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 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 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#[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 ("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 for (name, kind) in [
788 ("Number", NodeKind::Number(Animated::new(1.0))),
789 ("Boolean", NodeKind::Bool(true)),
790 ("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#[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#[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 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
997fn 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
1066fn 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
1089fn 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
1107fn 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 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
1125fn 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
1133fn 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 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 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 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); 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 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 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 #[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 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)); let (fr, fr2) = (h.node_rect(fx), h.node_rect(fx2));
1490 h.drag(out_port(fr, z), in_port(fr2, 0, z)); 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)); 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 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 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)); 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 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 let start = pos2(r.left() + 62.0 * z, r.top() + (HEADER_H + ROW_H * 1.5) * z + 2.0);
1627 h.undos = 0;
1628 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 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 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 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 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 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 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 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 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 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 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 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}