From 541c740f1859b7e9cf072b868590b07b0e7cea85 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 22:30:04 -0400 Subject: [PATCH] CMP-6: Flowchart panel Composition > Composition Flowchart charts the active comp as a node graph: nested comps, footage and solids, optionally the layers and their effects (panel switches Solids / Layers / Effects), left to right or top to bottom. Double-click a comp to open it, Cmd/Ctrl-click to reveal an item in the Project panel (a layer: select it), drag or scroll to pan. Reveal Layer in Project Flowchart shows layers with the layer selected. The graph builder is pure and cycle-safe (shared items drawn once, back edges to comps on the path). Commands flowchart.options {layers, effects, solids, direction, comp} and flowchart.graph (the graph as JSON); automation ids flowchart.node. and flowchart.option.. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/menus.rs | 10 + crates/ui-egui/src/panels/flowchart.rs | 460 +++++++++++++++++++++++++ crates/ui-egui/src/panels/misc.rs | 2 +- crates/ui-egui/src/panels/mod.rs | 3 +- crates/ui-egui/src/state.rs | 4 + 5 files changed, 477 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/panels/flowchart.rs diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 4c85162e5..da5eb8423 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -72,6 +72,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("timeline.column", "Show/Hide Timeline Column", [], None), uic!("timeline.sourceName", "Source Name / Layer Name", [], None), uic!("timeline.search", "Search Timeline", [], None), + uic!("flowchart.options", "Flowchart Options", [], None), + uic!("flowchart.graph", "Flowchart Graph", [], None), uic!("timeline.collapseAll", "Collapse All", [], Some("Cmd+`")), uic!("tool.selection", "Selection Tool", [], Some("V")), uic!("tool.hand", "Hand Tool", [], Some("H")), @@ -331,6 +333,7 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V tl.source_name = params.get("value").and_then(Value::as_bool).unwrap_or(!tl.source_name); return Ok(json!({"sourceName": tl.source_name})); } + "flowchart.options" | "flowchart.graph" => return crate::panels::flowchart::command(app, id, ¶ms), "timeline.search" => { app.ui.timeline.search = params.get("query").and_then(Value::as_str).unwrap_or_default().to_string(); app.show_panel(PanelKind::Timeline); @@ -712,6 +715,13 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu Value::Null } "comp.flowchart" => { + // Chart the active comp; Layer ▸ Reveal ▸ Reveal Layer in Project Flowchart shows + // layers and selects the layer's node. + app.ui.flowchart.root = app.session.active_comp_id().map(|c| c.0); + if let (Some(c), Some(l)) = (app.session.active_comp_id(), app.session.state.selected_layers.first()) { + app.ui.flowchart.layers = true; + app.ui.flowchart.selected = Some(format!("layer:{}:{}", c.0, l.0)); + } app.show_panel(PanelKind::Flowchart); Value::Null } diff --git a/crates/ui-egui/src/panels/flowchart.rs b/crates/ui-egui/src/panels/flowchart.rs new file mode 100644 index 000000000..2ba345cf2 --- /dev/null +++ b/crates/ui-egui/src/panels/flowchart.rs @@ -0,0 +1,460 @@ +//! The Flowchart panel (Composition ▸ Composition Flowchart): a node graph of a composition and +//! everything it uses — nested comps, footage, solids — optionally with its layers and their +//! effects. Left to right or top to bottom; double-click a comp to open it; Cmd/Ctrl-click a +//! node to reveal it in the Project panel (layers: select the layer); drag to pan. +//! +//! [`build`] is pure and cycle-safe (a comp nested in itself is drawn once, with an edge back), +//! so agents get the same graph through `flowchart.graph`. + +use std::collections::{BTreeMap, BTreeSet}; + +use effectcraft_engine::project::{ItemId, ItemKind, LayerSource, Project}; +use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::icons::{self, Icon}; +use crate::theme::Tokens; +use crate::{EffectcraftApp, widgets}; + +/// Flowchart panel options (the panel's switches). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct FlowOptions { + /// Show layers between a comp and its sources. + pub layers: bool, + /// Show each layer's effects (with layers shown). + pub effects: bool, + /// Show solids. + pub solids: bool, + /// Top to bottom instead of left to right. + pub vertical: bool, + /// The comp the chart starts from (None = the active comp). + pub root: Option, + #[serde(skip)] + pub pan: [f32; 2], + /// Selected node id. + #[serde(skip)] + pub selected: Option, +} + +impl Default for FlowOptions { + fn default() -> Self { + FlowOptions { layers: false, effects: false, solids: true, vertical: false, root: None, pan: [0.0, 0.0], selected: None } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct FlowNode { + /// `item:`, `layer::` or `fx:::`. + pub id: String, + /// `comp`, `footage`, `solid`, `layer` or `effect`. + pub kind: &'static str, + pub label: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub item: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub comp: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub layer: Option, + /// Distance from the root (column, or row when vertical). + pub depth: usize, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct FlowGraph { + pub nodes: Vec, + /// (from, to) node ids: from a comp to what it uses. + pub edges: Vec<(String, String)>, +} + +/// Build the flowchart of `root` (a comp). Shared items appear once; cycles stop at the comp +/// already on the path. +pub fn build(project: &Project, root: ItemId, opts: &FlowOptions) -> FlowGraph { + struct B<'a> { + p: &'a Project, + o: &'a FlowOptions, + nodes: Vec, + index: BTreeMap, + edges: Vec<(String, String)>, + stack: BTreeSet, + } + impl B<'_> { + fn node(&mut self, n: FlowNode) -> String { + let id = n.id.clone(); + match self.index.get(&id) { + Some(&i) => self.nodes[i].depth = self.nodes[i].depth.min(n.depth), + None => { + self.index.insert(id.clone(), self.nodes.len()); + self.nodes.push(n); + } + } + id + } + fn edge(&mut self, a: &str, b: &str) { + let e = (a.to_string(), b.to_string()); + if !self.edges.contains(&e) { + self.edges.push(e); + } + } + fn item(&mut self, id: ItemId, depth: usize) -> Option { + let it = self.p.item(id)?; + let kind = match &it.kind { + ItemKind::Comp(_) => "comp", + ItemKind::Footage(_) => "footage", + ItemKind::Solid(_) => "solid", + ItemKind::Folder => return None, + }; + if kind == "solid" && !self.o.solids { + return None; + } + let seen = self.index.contains_key(&format!("item:{}", id.0)); + let nid = self.node(FlowNode { id: format!("item:{}", id.0), kind, label: it.name.clone(), item: Some(id.0), comp: None, layer: None, depth }); + if kind == "comp" && !seen && !self.stack.contains(&id.0) { + self.comp(id, depth); + } + Some(nid) + } + fn comp(&mut self, cid: ItemId, depth: usize) { + let Some(c) = self.p.comp(cid) else { return }; + self.stack.insert(cid.0); + let from = format!("item:{}", cid.0); + for l in &c.layers { + let src_depth = if self.o.layers { depth + 2 } else { depth + 1 }; + let mut parent = from.clone(); + if self.o.layers { + let lid = self.node(FlowNode { + id: format!("layer:{}:{}", cid.0, l.id.0), + kind: "layer", + label: l.name.clone(), + item: None, + comp: Some(cid.0), + layer: Some(l.id.0), + depth: depth + 1, + }); + self.edge(&from, &lid); + if self.o.effects + && let Some(fx) = l.effects() + { + for g in fx.groups() { + let fid = self.node(FlowNode { + id: format!("fx:{}:{}:{}", cid.0, l.id.0, g.uid), + kind: "effect", + label: g.name.clone(), + item: None, + comp: Some(cid.0), + layer: Some(l.id.0), + depth: depth + 2, + }); + self.edge(&lid, &fid); + } + } + parent = lid; + } + let src = match l.source { + LayerSource::Footage { item } | LayerSource::Comp { item } | LayerSource::Solid { item } | LayerSource::Model { item } => Some(item), + _ => None, + }; + if let Some(item) = src { + // A comp already on the path: link back to it, don't descend (cycle). + if self.stack.contains(&item.0) { + self.edge(&parent, &format!("item:{}", item.0)); + } else if let Some(sid) = self.item(item, src_depth) { + self.edge(&parent, &sid); + } + } + } + self.stack.remove(&cid.0); + } + } + let mut b = B { p: project, o: opts, nodes: vec![], index: BTreeMap::new(), edges: vec![], stack: BTreeSet::new() }; + b.item(root, 0); + FlowGraph { nodes: b.nodes, edges: b.edges } +} + +const NODE_W: f32 = 168.0; +const NODE_H: f32 = 34.0; + +/// Node positions (top-left, before panning): one column (or row) per depth, in discovery order. +pub fn layout(g: &FlowGraph, vertical: bool) -> Vec<[f32; 2]> { + let mut slot: BTreeMap = BTreeMap::new(); + g.nodes + .iter() + .map(|n| { + let k = slot.entry(n.depth).or_insert(0); + let i = *k as f32; + *k += 1; + let d = n.depth as f32; + if vertical { [i * (NODE_W + 24.0), d * (NODE_H + 46.0)] } else { [d * (NODE_W + 60.0), i * (NODE_H + 14.0)] } + }) + .collect() +} + +/// The comp the panel charts. +pub fn root(app: &EffectcraftApp) -> Option { + app.ui.flowchart.root.map(ItemId).filter(|r| app.session.project.comp(*r).is_some()).or_else(|| app.session.active_comp_id()) +} + +/// `flowchart.options` / `flowchart.graph` (UI commands). +pub fn command(app: &mut EffectcraftApp, id: &str, p: &Value) -> Result { + let o = &mut app.ui.flowchart; + let b = |k: &str| p.get(k).and_then(Value::as_bool); + if let Some(v) = b("layers") { + o.layers = v; + } + if let Some(v) = b("effects") { + o.effects = v; + } + if let Some(v) = b("solids") { + o.solids = v; + } + if let Some(d) = p.get("direction").and_then(Value::as_str) { + o.vertical = match d { + "lr" | "leftToRight" => false, + "tb" | "topToBottom" => true, + _ => return Err(format!("direction: lr|tb, not `{d}`")), + }; + } + if let Some(c) = p.get("comp") { + let cid = match c { + Value::Number(n) => n.as_u64().map(ItemId), + Value::String(s) => app.session.project.find_by_name(s).map(|i| i.id), + _ => None, + }; + let cid = cid.filter(|c| app.session.project.comp(*c).is_some()).ok_or("comp: a composition id or name")?; + app.ui.flowchart.root = Some(cid.0); + } + let o = app.ui.flowchart.clone(); + if id == "flowchart.graph" { + let r = root(app).ok_or("no composition")?; + return Ok(serde_json::to_value(build(&app.session.project, r, &o)).unwrap_or_default()); + } + Ok(json!({"layers": o.layers, "effects": o.effects, "solids": o.solids, "direction": if o.vertical { "tb" } else { "lr" }, "root": o.root})) +} + +fn kind_style(kind: &str) -> (Icon, Color32) { + match kind { + "comp" => (Icon::Comp, Color32::from_rgb(0x3d, 0x5a, 0x8c)), + "footage" => (Icon::Footage, Color32::from_rgb(0x3c, 0x6e, 0x58)), + "solid" => (Icon::Solid, Color32::from_rgb(0x6a, 0x58, 0x3a)), + "effect" => (Icon::Fx, Color32::from_rgb(0x6c, 0x44, 0x6e)), + _ => (Icon::Null, Color32::from_rgb(0x44, 0x48, 0x52)), + } +} + +/// Draw the Flowchart panel. +pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { + let t = app.tokens; + let ctx = ui.ctx().clone(); + let p = ui.painter().with_clip_rect(rect); + p.rect_filled(rect, 0.0, t.tl_bg); + let Some(rid) = root(app) else { + p.text(rect.center(), Align2::CENTER_CENTER, "(no composition)", Tokens::ui(12.0), t.text_faint); + return; + }; + // ---- toolbar: switches and direction. + let bar = Rect::from_min_size(rect.min, vec2(rect.width(), 30.0)); + p.rect_filled(bar, 0.0, t.panel_bg); + let root_name = app.session.project.item(rid).map(|i| i.name.clone()).unwrap_or_default(); + p.text(pos2(bar.min.x + 10.0, bar.center().y), Align2::LEFT_CENTER, format!("Flowchart: {root_name}"), Tokens::medium(12.0), t.text); + let mut x = bar.max.x - 8.0; + let mut cmds: Vec<(String, Value)> = vec![]; + let o = app.ui.flowchart.clone(); + for (key, label, on) in [ + ("direction", if o.vertical { "Top to Bottom" } else { "Left to Right" }, false), + ("effects", "Effects", o.effects), + ("layers", "Layers", o.layers), + ("solids", "Solids", o.solids), + ] { + let w = 18.0 + label.len() as f32 * 6.6; + let r = Rect::from_min_size(pos2(x - w, bar.min.y + 5.0), vec2(w, 20.0)); + x -= w + 6.0; + if widgets::text_button(ui, r, label, on, &t, egui::Id::new(("flow-opt", key))).clicked() { + let v = if key == "direction" { json!({"direction": if o.vertical { "lr" } else { "tb" }}) } else { json!({key: !on}) }; + cmds.push(("flowchart.options".into(), v)); + } + app.auto.add(&format!("flowchart.option.{key}"), r, label); + } + + // ---- graph. + let body = Rect::from_min_max(pos2(rect.min.x, bar.max.y), rect.max); + let g = build(&app.session.project, rid, &o); + let pos = layout(&g, o.vertical); + let origin = body.min + vec2(24.0 + o.pan[0], 24.0 + o.pan[1]); + let bg = ui.interact(body, egui::Id::new("flow-bg"), Sense::click_and_drag()); + if bg.dragged() { + let d = bg.drag_delta(); + app.ui.flowchart.pan[0] += d.x; + app.ui.flowchart.pan[1] += d.y; + } + if ui.rect_contains_pointer(body) { + let s = ui.input(|i| i.smooth_scroll_delta); + if s != egui::Vec2::ZERO { + app.ui.flowchart.pan[0] += s.x; + app.ui.flowchart.pan[1] += s.y; + } + } + if bg.clicked() { + app.ui.flowchart.selected = None; + } + let gp = p.with_clip_rect(body); + let rect_of = |i: usize| Rect::from_min_size(origin + vec2(pos[i][0], pos[i][1]), vec2(NODE_W, NODE_H)); + let idx: BTreeMap<&str, usize> = g.nodes.iter().enumerate().map(|(i, n)| (n.id.as_str(), i)).collect(); + // Edges first (curves with an arrowhead at the target). + for (a, b) in &g.edges { + let (Some(&ia), Some(&ib)) = (idx.get(a.as_str()), idx.get(b.as_str())) else { continue }; + let (ra, rb) = (rect_of(ia), rect_of(ib)); + let back = g.nodes[ib].depth <= g.nodes[ia].depth; + let (s, e, n) = if o.vertical { (ra.center_bottom(), rb.center_top(), vec2(0.0, 1.0)) } else { (ra.right_center(), rb.left_center(), vec2(1.0, 0.0)) }; + let k = if o.vertical { (e.y - s.y).abs() * 0.5 } else { (e.x - s.x).abs() * 0.5 }.max(30.0); + let col = if back { Color32::from_rgb(0xd0, 0x80, 0x40) } else { t.text_faint }; + let curve = egui::epaint::CubicBezierShape::from_points_stroke([s, s + n * k, e - n * k, e], false, Color32::TRANSPARENT, Stroke::new(1.2, col)); + gp.add(curve); + let (l, r) = if o.vertical { (vec2(-4.0, -7.0), vec2(4.0, -7.0)) } else { (vec2(-7.0, -4.0), vec2(-7.0, 4.0)) }; + gp.add(egui::Shape::convex_polygon(vec![e, e + l, e + r], col, Stroke::NONE)); + } + let cmd_down = ui.input(|i| i.modifiers.command || i.modifiers.ctrl); + for (i, n) in g.nodes.iter().enumerate() { + let r = rect_of(i); + if !r.intersects(body) { + continue; + } + let (icon, fill) = kind_style(n.kind); + let selected = o.selected.as_deref() == Some(n.id.as_str()); + let resp = ui.interact(r.intersect(body), egui::Id::new(("flow-node", &n.id)), Sense::click()); + let fill = if resp.hovered() { fill.gamma_multiply(1.25) } else { fill }; + gp.rect_filled(r, 5.0, fill); + gp.rect_stroke( + r, + 5.0, + Stroke::new(if selected { 2.0 } else { 1.0 }, if selected { t.accent } else { Color32::from_black_alpha(120) }), + StrokeKind::Inside, + ); + icons::paint(&gp, Rect::from_center_size(pos2(r.min.x + 16.0, r.center().y), vec2(14.0, 14.0)), icon, Color32::WHITE); + gp.with_clip_rect(r.shrink(2.0).intersect(body)).text( + pos2(r.min.x + 30.0, r.center().y), + Align2::LEFT_CENTER, + &n.label, + Tokens::ui(12.0), + Color32::WHITE, + ); + app.auto.add(&format!("flowchart.node.{}", n.id), r, &n.label); + if resp.clicked() { + app.ui.flowchart.selected = Some(n.id.clone()); + if cmd_down { + // Reveal: items in the Project panel, layers selected in their comp. + match (n.item, n.comp, n.layer) { + (Some(item), ..) => { + cmds.push(("project.select".into(), json!({"items": [item]}))); + cmds.push(("window.panel".into(), json!({"panel": "project"}))); + } + (None, Some(c), Some(l)) => { + cmds.push(("comp.open".into(), json!({"comp": c}))); + cmds.push(("layer.select".into(), json!({"layers": [l]}))); + } + _ => {} + } + } + } + if resp.double_clicked() { + match (n.kind, n.item, n.comp) { + ("comp", Some(c), _) => cmds.push(("comp.open".into(), json!({"comp": c}))), + (_, _, Some(c)) => cmds.push(("comp.open".into(), json!({"comp": c}))), + _ => {} + } + } + resp.on_hover_text(format!("{} — double-click to open, {}-click to reveal", n.label, if cfg!(target_os = "macos") { "Cmd" } else { "Ctrl" })); + } + for (id, params) in cmds { + if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) { + app.ui.status = e; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use effectcraft_engine::Session; + + /// Main ⊃ Mid ⊃ Inner (with a solid), Main also uses Inner directly. + fn session() -> (Session, ItemId, ItemId, ItemId) { + let mut s = Session::default(); + let id = |v: Value| ItemId(v["id"].as_u64().or_else(|| v["comp"].as_u64()).or_else(|| v["item"].as_u64()).unwrap()); + let inner = id(s.execute("comp.new", json!({"name": "Inner", "width": 100, "height": 100, "duration": 2})).unwrap()); + s.execute("layer.newSolid", json!({"name": "Red", "color": "#ff0000", "width": 100, "height": 100})).unwrap(); + s.execute("layer.select", json!({"layers": []})).unwrap(); + let mid = id(s.execute("comp.new", json!({"name": "Mid", "width": 100, "height": 100, "duration": 2})).unwrap()); + s.execute("layer.addItem", json!({"item": inner.0})).unwrap(); + let main = id(s.execute("comp.new", json!({"name": "Main", "width": 100, "height": 100, "duration": 2})).unwrap()); + s.execute("layer.addItem", json!({"item": mid.0})).unwrap(); + s.execute("layer.addItem", json!({"item": inner.0})).unwrap(); + s.execute("layer.newText", json!({"text": "Title"})).unwrap(); + (s, main, mid, inner) + } + + #[test] + fn nested_comps_make_a_graph() { + let (s, main, mid, inner) = session(); + let g = build(&s.project, main, &FlowOptions::default()); + let label = |id: &str| g.nodes.iter().find(|n| n.id == id).map(|n| (n.label.clone(), n.depth)); + assert_eq!(label(&format!("item:{}", main.0)), Some(("Main".into(), 0))); + assert_eq!(label(&format!("item:{}", mid.0)), Some(("Mid".into(), 1))); + // Inner is reached at depth 1 (directly) and 2 (through Mid): drawn once, at depth 1. + assert_eq!(label(&format!("item:{}", inner.0)), Some(("Inner".into(), 1))); + assert_eq!(g.nodes.iter().filter(|n| n.label == "Inner").count(), 1); + let has = |a: ItemId, b: ItemId| g.edges.contains(&(format!("item:{}", a.0), format!("item:{}", b.0))); + assert!(has(main, mid) && has(mid, inner) && has(main, inner)); + // The solid is shown (Solids on) and hidden with Solids off; text layers have no source. + assert!(g.nodes.iter().any(|n| n.kind == "solid")); + let g2 = build(&s.project, main, &FlowOptions { solids: false, ..Default::default() }); + assert!(!g2.nodes.iter().any(|n| n.kind == "solid")); + // Layers between comps and sources; effects under layers. + let g3 = build(&s.project, main, &FlowOptions { layers: true, ..Default::default() }); + assert!(g3.nodes.iter().any(|n| n.kind == "layer" && n.label == "Title")); + let mid_layer = g3.nodes.iter().find(|n| n.kind == "layer" && n.comp == Some(main.0) && n.label == "Mid").unwrap(); + assert!(g3.edges.contains(&(mid_layer.id.clone(), format!("item:{}", mid.0)))); + // Layout: one column per depth, no overlaps. + let pos = layout(&g3, false); + for i in 0..pos.len() { + for j in i + 1..pos.len() { + assert!(pos[i] != pos[j], "{} and {} overlap", g3.nodes[i].label, g3.nodes[j].label); + } + } + } + + #[test] + fn cycles_are_safe() { + let (mut s, main, mid, _) = session(); + // Make Mid contain Main: Main → Mid → Main. + s.execute("comp.open", json!({"comp": mid.0})).unwrap(); + // Adding a cyclic layer may be refused by the engine; build a cycle in the model directly. + let mut p = (*s.project).clone(); + let mut c = (*p.comp(mid).unwrap()).clone(); + let mut l = c.layers[0].clone(); + l.id = effectcraft_engine::project::LayerId(9999); + l.source = LayerSource::Comp { item: main }; + c.layers.push(l); + if let Some(ItemKind::Comp(arc)) = p.items.get_mut(&mid).map(|i| &mut i.kind) { + *arc = std::sync::Arc::new(c); + } + let g = build(&p, main, &FlowOptions { layers: true, effects: true, ..Default::default() }); + assert_eq!(g.nodes.iter().filter(|n| n.kind == "comp" && n.label == "Main").count(), 1); + // The back edge points at the root. + assert!(g.edges.iter().any(|(_, b)| *b == format!("item:{}", main.0))); + assert!(g.nodes.len() < 50); + } + + #[test] + fn options_command() { + let (s, main, ..) = session(); + let mut app = EffectcraftApp::new(s); + let r = command(&mut app, "flowchart.options", &json!({"layers": true, "direction": "tb"})).unwrap(); + assert_eq!(r["direction"], "tb"); + assert!(app.ui.flowchart.layers && app.ui.flowchart.vertical); + assert!(command(&mut app, "flowchart.options", &json!({"direction": "diagonal"})).is_err()); + let g = command(&mut app, "flowchart.graph", &json!({"comp": "Main"})).unwrap(); + assert_eq!(g["nodes"][0]["id"], format!("item:{}", main.0)); + assert!(g["edges"].as_array().is_some_and(|e| !e.is_empty())); + } +} diff --git a/crates/ui-egui/src/panels/misc.rs b/crates/ui-egui/src/panels/misc.rs index 0db1ed7f6..69d95f664 100644 --- a/crates/ui-egui/src/panels/misc.rs +++ b/crates/ui-egui/src/panels/misc.rs @@ -1,4 +1,4 @@ -//! Smaller panels: Preview, Audio, History, Markers, the Home screen and placeholders. +//! Smaller panels: Preview, Audio, History, Markers and placeholders. use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; use serde_json::json; diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index dae89f166..cc103e84b 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -6,6 +6,7 @@ pub mod dialogs; pub mod dialogs_3d; pub mod effect_controls; pub mod effects_presets; +pub mod flowchart; pub mod forms; pub mod fx_widgets; pub mod graph; @@ -91,7 +92,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rec PanelKind::Layer => layer_panel::show(app, ui, rect), PanelKind::Paint => paint_panels::paint(app, ui, rect), PanelKind::Brushes => paint_panels::brushes(app, ui, rect), - other => misc::placeholder(app, ui, rect, other), + PanelKind::Flowchart => flowchart::show(app, ui, rect), } } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index af59bb6b7..1d1dcb236 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -584,6 +584,9 @@ pub struct UiState { /// Composition Mini-Flowchart popup position (open when set). #[serde(default)] pub mini_flowchart: Option<[f32; 2]>, + /// Flowchart panel options (layers, effects, solids, direction, root comp). + #[serde(default)] + pub flowchart: crate::panels::flowchart::FlowOptions, } impl Default for UiState { @@ -634,6 +637,7 @@ impl Default for UiState { layer_view: None, anim_tools: AnimToolsState::default(), mini_flowchart: None, + flowchart: Default::default(), } } }