Files
effectcraft/crates/ui-egui/src/panels/precomp.rs
T
bflatasticandClaude Opus 5.5 8a415e03ca Render Queue: Render Settings templates apply from the menu (#117)
Choosing Draft Settings (or any entry) in a Render Queue item's Render
Settings menu did nothing when the menu had been moved up to fit the
window. egui hit-tests a fixed-position Area where it was asked to go,
not where it was moved, so the popup's "pressed outside" check was true
over its own entries and the menu closed on the press, before the click.

widgets::pressed_outside tests the press against the rect the popup was
drawn at. Every popup with the same check uses it: popup_menu (Render
Queue, Effect Controls, dropdowns), the colour popup, the Preview and
viewer popups, the panel menu, the Flowchart, OpenType and font menus.

Regression test: ui_render_queue.rs picks Draft Settings from a menu
moved up to fit a 1000 px window; it failed before the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 01:19:25 +07:00

356 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Precomposition workflow: the Pre-compose dialog (Leave / Move all attributes, Adjust
//! composition duration, Open New Composition), the Composition Navigator bar above the viewer
//! (the flow of nested comps, click to open) and the Composition Mini-Flowchart popup (Tab).
use effectcraft_engine::project::{ItemId, LayerSource, Project};
use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2};
use serde_json::json;
use crate::theme::Tokens;
use crate::{Dialog, EffectcraftApp};
// ---------------------------------------------------------------- flow graph
/// Comps that contain a layer whose source is `cid` (upstream), in project order.
pub fn upstream(project: &Project, cid: ItemId) -> Vec<ItemId> {
project.comps().filter(|(_, c)| c.layers.iter().any(|l| l.source == LayerSource::Comp { item: cid })).map(|(id, _)| *id).collect()
}
/// Comps used as layer sources in `cid` (downstream), first use first, without repeats.
pub fn downstream(project: &Project, cid: ItemId) -> Vec<ItemId> {
let mut out = vec![];
if let Some(c) = project.comp(cid) {
for l in &c.layers {
if let LayerSource::Comp { item } = l.source
&& !out.contains(&item)
&& project.comp(item).is_some()
{
out.push(item);
}
}
}
out
}
/// The navigator chain ending at `cid`: walk upstream, preferring comps opened most recently
/// (`open` is the open-comps order), stopping at a root or a cycle.
pub fn chain(project: &Project, cid: ItemId, open: &[ItemId]) -> Vec<ItemId> {
let mut v = vec![cid];
let mut cur = cid;
while v.len() < 32 {
let ups = upstream(project, cur);
let Some(next) = open.iter().rev().find(|o| ups.contains(o)).or(ups.first()).copied() else { break };
if v.contains(&next) {
break;
}
v.push(next);
cur = next;
}
v.reverse();
v
}
fn name(project: &Project, id: ItemId) -> String {
project.item(id).map(|i| i.name.clone()).unwrap_or_default()
}
// ---------------------------------------------------------------- Pre-compose dialog
#[derive(Clone, Debug, Default)]
pub struct PrecomposeDraft {
pub layers: Vec<u64>,
pub name: String,
/// "Leave all attributes in" (only one footage/solid/precomp layer).
pub leave: bool,
pub can_leave: bool,
pub layer_name: String,
pub adjust: bool,
pub open: bool,
}
/// Open the Pre-compose dialog for the selected (or given) layers.
pub fn open(app: &mut EffectcraftApp, p: &serde_json::Value) -> Result<(), String> {
let s = &app.session;
let c = s.active_comp().ok_or("no composition is open")?;
let layers: Vec<u64> = match p.get("layers").and_then(|v| v.as_array()) {
Some(a) => a.iter().filter_map(|v| v.as_u64()).collect(),
None => s.state.selected_layers.iter().map(|l| l.0).collect(),
};
if layers.is_empty() {
return Err("select the layers to pre-compose".into());
}
let first = c.layer(effectcraft_engine::project::LayerId(layers[0]));
let can_leave = layers.len() == 1 && first.is_some_and(|l| l.source.item().is_some());
let n = s.project.comps().count() + 1;
let base = if layers.len() == 1 { first.map(|l| format!("{} Comp 1", l.name)) } else { None };
app.dialog_state.precompose = PrecomposeDraft {
layers,
name: base.unwrap_or_else(|| format!("Pre-comp {n}")),
leave: false,
can_leave,
layer_name: first.map(|l| l.name.clone()).unwrap_or_default(),
adjust: false,
open: true,
};
app.dialog = Some(Dialog::Precompose);
Ok(())
}
/// The `layer.precompose` parameters of the dialog.
pub fn draft_params(d: &PrecomposeDraft) -> serde_json::Value {
json!({
"layers": d.layers,
"name": d.name,
"mode": if d.leave && d.can_leave { "leave" } else { "move" },
"adjustDuration": d.adjust && !(d.leave && d.can_leave),
"open": d.open,
})
}
pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let mut d = app.dialog_state.precompose.clone();
let (mut ok, mut cancel) = (false, false);
super::dialogs::modal(ctx, "Pre-compose", vec2(520.0, 360.0), t, |ui| {
ui.horizontal(|ui| {
ui.label("New composition name:");
let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(260.0));
app.auto.add("dialog.precompose.name", r.rect, "New composition name");
});
ui.add_space(10.0);
ui.add_enabled_ui(d.can_leave, |ui| {
let r = ui.radio_value(&mut d.leave, true, format!("Leave all attributes in \"{}\"", d.layer_name));
app.auto.add("dialog.precompose.leave", r.rect, "Leave all attributes");
ui.label(
egui::RichText::new(format!(
"Use this option to create a new intermediate composition with only \"{}\" in it. The new composition will become the source to the current layer.",
d.layer_name
))
.color(t.text_dim)
.size(11.0),
);
});
ui.add_space(6.0);
let r = ui.radio_value(&mut d.leave, false, "Move all attributes into the new composition");
app.auto.add("dialog.precompose.move", r.rect, "Move all attributes");
ui.label(
egui::RichText::new("Use this option to place the currently selected layers together into a new intermediate composition.")
.color(t.text_dim)
.size(11.0),
);
ui.add_space(4.0);
ui.add_enabled_ui(!d.leave, |ui| {
let r = ui.checkbox(&mut d.adjust, "Adjust composition duration to the time span of the selected layers");
app.auto.add("dialog.precompose.adjust", r.rect, "Adjust composition duration");
});
ui.add_space(8.0);
let r = ui.checkbox(&mut d.open, "Open New Composition");
app.auto.add("dialog.precompose.open", r.rect, "Open New Composition");
ui.add_space(14.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let r = ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent));
app.auto.add("dialog.precompose.ok", r.rect, "OK");
ok = r.clicked();
let r = ui.button("Cancel");
app.auto.add("dialog.precompose.cancel", r.rect, "Cancel");
cancel = r.clicked();
});
});
app.dialog_state.precompose = d.clone();
if ok || super::dialogs::ui_enter(ctx) {
if let Err(e) = app.session.execute("layer.precompose", draft_params(&d)) {
app.ui.status = e.to_string();
}
cancel = true;
}
if cancel {
app.dialog = None;
}
}
// ---------------------------------------------------------------- navigator + mini-flowchart
/// Height of the Composition Navigator bar.
pub const NAV_H: f32 = 22.0;
/// Whether the active comp has a flow to show (it is nested somewhere or nests others).
pub fn has_flow(app: &EffectcraftApp) -> bool {
app.session.active_comp_id().is_some_and(|c| !upstream(&app.session.project, c).is_empty() || !downstream(&app.session.project, c).is_empty())
}
/// The Composition Navigator bar: the upstream chain › active comp › (dim) its precomps.
pub fn navigator(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let Some(cid) = app.session.active_comp_id() else { return };
let p = ui.painter().with_clip_rect(rect);
p.rect_filled(rect, 0.0, t.panel_bg);
p.line_segment([rect.left_bottom(), rect.right_bottom()], Stroke::new(1.0, t.separator));
let proj = app.session.project.clone();
let ch = chain(&proj, cid, &app.session.state.open_comps);
let down = downstream(&proj, cid);
let mut x = rect.min.x + 10.0;
let cy = rect.center().y;
let mut open: Option<ItemId> = None;
let items: Vec<(ItemId, bool)> = ch.iter().map(|c| (*c, false)).chain(down.iter().take(1).map(|c| (*c, true))).collect();
for (i, (id, downstream_item)) in items.iter().enumerate() {
if i > 0 {
crate::icons::paint(&p, Rect::from_center_size(pos2(x + 6.0, cy), vec2(8.0, 8.0)), crate::icons::Icon::ChevronRight, t.text_faint);
x += 16.0;
}
let active = *id == cid;
let label = name(&proj, *id);
let col = if active {
t.tab_text_active
} else if *downstream_item {
t.text_faint
} else {
t.text_dim
};
let g = p.layout_no_wrap(label.clone(), Tokens::ui(11.5), col);
let r = Rect::from_min_size(pos2(x, rect.min.y + 2.0), vec2(g.size().x + 12.0, rect.height() - 4.0));
let resp = ui.interact(r, egui::Id::new(("comp-nav", id.0)), Sense::click());
if active {
p.rect_filled(r, 3.0, t.row_selected);
} else if resp.hovered() {
p.rect_filled(r, 3.0, t.hover);
}
p.galley(pos2(r.min.x + 6.0, cy - g.size().y / 2.0), g, col);
app.auto.add(&format!("viewer.navigator.{}", id.0), r, &label);
if resp.clicked() && !active {
open = Some(*id);
}
x = r.max.x + 2.0;
}
let fr = Rect::from_min_size(pos2(rect.max.x - 26.0, rect.min.y + 2.0), vec2(22.0, rect.height() - 4.0));
if crate::widgets::icon_button(ui, fr, crate::icons::Icon::Flowchart, false, &t, egui::Id::new("comp-nav-flow"))
.on_hover_text("Composition Mini-Flowchart")
.clicked()
{
app.ui.mini_flowchart = Some([fr.min.x, fr.max.y]);
}
app.auto.add("viewer.navigator.flowchart", fr, "Composition Mini-Flowchart");
if let Some(id) = open {
app.session.open_comp(id);
}
}
/// Composition Mini-Flowchart: upstream comps → active → downstream comps; click to open.
pub fn mini_flowchart(app: &mut EffectcraftApp, ctx: &egui::Context) {
let Some(at) = app.ui.mini_flowchart else { return };
let Some(cid) = app.session.active_comp_id() else {
app.ui.mini_flowchart = None;
return;
};
let t = app.tokens;
let proj = app.session.project.clone();
let ups = upstream(&proj, cid);
let downs = downstream(&proj, cid);
let mut open = None;
let mut close = false;
let area = egui::Area::new(egui::Id::new("mini-flowchart")).order(egui::Order::Foreground).constrain(true).fixed_pos(pos2(at[0], at[1])).show(ctx, |ui| {
egui::Frame::popup(ui.style()).fill(t.panel_bg).inner_margin(egui::Margin::same(10)).show(ui, |ui| {
let col_w = 160.0;
let rows = ups.len().max(downs.len()).max(1) as f32;
let h = rows * 26.0 + 8.0;
let (rect, _) = ui.allocate_exact_size(vec2(col_w * 3.0 + 60.0, h), Sense::hover());
let p = ui.painter().clone();
let node = |ui: &mut egui::Ui, app: &mut EffectcraftApp, r: Rect, id: ItemId, active: bool| {
let resp = ui.interact(r, egui::Id::new(("mfc", id.0)), Sense::click());
p.rect_filled(
r,
4.0,
if active {
t.accent
} else if resp.hovered() {
t.hover
} else {
t.field_bg
},
);
p.text(r.center(), Align2::CENTER_CENTER, name(&proj, id), Tokens::ui(11.5), if active { Color32::WHITE } else { t.text });
app.auto.add(&format!("flowchart.mini.{}", id.0), r, &name(&proj, id));
resp.clicked()
};
let mid = Rect::from_center_size(rect.center(), vec2(col_w, 22.0));
if node(ui, app, mid, cid, true) {
close = true;
}
let col = |i: usize, n: usize, x: f32| {
let y0 = rect.center().y - (n as f32 * 26.0) / 2.0 + 13.0;
Rect::from_center_size(pos2(x, y0 + i as f32 * 26.0), vec2(col_w, 22.0))
};
let arrow = |a: egui::Pos2, b: egui::Pos2| {
p.line_segment([a, b], Stroke::new(1.2, t.text_dim));
let d = (b - a).normalized();
let n = vec2(-d.y, d.x);
p.add(egui::Shape::convex_polygon(vec![b, b - d * 7.0 + n * 3.5, b - d * 7.0 - n * 3.5], t.text_dim, Stroke::NONE));
};
for (i, u) in ups.iter().enumerate() {
let r = col(i, ups.len(), rect.min.x + col_w / 2.0);
// Upstream comps contain the active one: arrow from the nested comp up into them.
arrow(mid.left_center(), r.right_center());
if node(ui, app, r, *u, false) {
open = Some(*u);
}
}
for (i, d) in downs.iter().enumerate() {
let r = col(i, downs.len(), rect.max.x - col_w / 2.0);
arrow(r.left_center(), mid.right_center());
if node(ui, app, r, *d, false) {
open = Some(*d);
}
}
if ups.is_empty() && downs.is_empty() {
p.text(pos2(rect.center().x, rect.max.y - 4.0), Align2::CENTER_BOTTOM, "No nested compositions", Tokens::ui(10.5), t.text_faint);
}
});
});
let outside = crate::widgets::pressed_outside(ctx, &area.response);
if let Some(id) = open {
app.session.open_comp(id);
close = true;
}
if close || outside || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
app.ui.mini_flowchart = None;
}
}
#[cfg(test)]
mod tests {
use super::*;
use effectcraft_engine::Session;
#[test]
fn flow_chain_and_precompose_dialog_params() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 200, "height": 100, "frameRate": 25, "duration": 2})).unwrap();
let main = s.active_comp_id().unwrap();
let l = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
let r = s.execute("layer.precompose", json!({"layers": [l], "name": "Mid", "open": true})).unwrap();
let mid = ItemId(r["comp"].as_u64().unwrap());
let l2 = s.project.comp(mid).unwrap().layers[0].id.0;
let r = s.execute("layer.precompose", json!({"layers": [l2], "name": "Inner", "open": true})).unwrap();
let inner = ItemId(r["comp"].as_u64().unwrap());
assert_eq!(upstream(&s.project, inner), vec![mid]);
assert_eq!(downstream(&s.project, main), vec![mid]);
assert_eq!(chain(&s.project, inner, &s.state.open_comps), vec![main, mid, inner]);
assert_eq!(chain(&s.project, main, &[]), vec![main]);
let mut app = EffectcraftApp::new(s);
app.session.open_comp(main);
let top = app.session.active_comp().unwrap().layers[0].id.0;
app.session.execute("layer.select", json!({"layers": [top]})).unwrap();
open(&mut app, &json!({})).unwrap();
assert_eq!(app.dialog, Some(Dialog::Precompose));
let d = &mut app.dialog_state.precompose;
assert!(d.can_leave);
d.leave = true;
d.adjust = true;
let p = draft_params(d);
assert_eq!(p["mode"], "leave");
assert_eq!(p["adjustDuration"], false);
d.leave = false;
assert_eq!(draft_params(d)["mode"], "move");
assert_eq!(draft_params(d)["adjustDuration"], true);
}
}