Merge main into enhance/avi-import

This commit is contained in:
tylerjenningsw
2026-10-25 14:26:28 -04:00
427 changed files with 24699 additions and 4513 deletions
+5 -1
View File
@@ -22,10 +22,11 @@ effectcraft-render = { workspace = true }
effectcraft-engine = { workspace = true }
effectcraft-gpu = { workspace = true }
egui = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard"] }
serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
chrono = { workspace = true }
rayon = { workspace = true }
web-time = { workspace = true }
kurbo = { workspace = true }
@@ -35,6 +36,9 @@ image = { workspace = true }
sys-locale = { workspace = true }
[dev-dependencies]
# Fonts for kittest: the workspace `egui` features are what test roots resolve, but stating them
# here documents the requirement (kittest's own dev-dependencies do not propagate to consumers).
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
egui_kittest = { workspace = true, features = ["eframe", "wgpu"] }
pollster = "0.4"
effectcraft-host = { workspace = true }
+9 -9
View File
@@ -16,7 +16,7 @@ use crate::widgets::{open_popup, popup_is_open, pressed_outside, select_all};
/// user picks a colour.
pub fn srgb_color_button(ui: &mut Ui, c: &mut [f32; 3]) -> Response {
let (rect, mut resp) = ui.allocate_exact_size(ui.spacing().interact_size, Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ColorButton, ui.is_enabled(), srgb_hex(*c)));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ColorWell, ui.is_enabled(), srgb_hex(*c)));
let stroke = if resp.hovered() { ui.visuals().widgets.hovered.fg_stroke } else { ui.visuals().widgets.inactive.bg_stroke };
ui.painter().rect_filled(rect, 2.0, srgb32(*c));
ui.painter().rect_stroke(rect, 2.0, stroke, StrokeKind::Inside);
@@ -78,7 +78,7 @@ pub fn color_popup(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, c: &mut [f32; 3])
}
fn open_pass_id() -> egui::Id {
egui::Id::new("color-picker-open-pass")
egui::Id::unique("color-picker-open-pass")
}
/// A colour picker is open: it was drawn, and stayed open, in this pass or the last one (so one
@@ -183,7 +183,7 @@ fn color_picker(ui: &mut Ui, id: egui::Id, c: &mut [f32; 3], original: [f32; 3])
None => Pick { hsv: rgb_to_hsv(*c), rgb: *c },
};
// The slider's channel is remembered between pickers, as After Effects does.
let chan_id = egui::Id::new("color-picker-channel");
let chan_id = egui::Id::unique("color-picker-channel");
let mut chan = ui.data(|d| d.get_temp::<usize>(chan_id)).and_then(|i| CHANS.get(i).map(|c| c.0)).unwrap_or(Chan::H);
let before = pick;
let mut out = Outcome::default();
@@ -268,7 +268,7 @@ const CHANS: [(Chan, &str); 6] = [(Chan::H, "H"), (Chan::S, "S"), (Chan::V, "B")
/// Drag or click on the square to set its two channels.
fn square(ui: &mut Ui, chan: Chan, pick: &mut Pick) {
let (rect, resp) = ui.allocate_exact_size(vec2(SQUARE, SQUARE), Sense::click_and_drag());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Slider, true, "Color field"));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Slider, true, "Color field"));
let (x, y) = chan.square();
if let Some(p) = resp.interact_pointer_pos().filter(|_| resp.is_pointer_button_down_on()) {
*pick = pick.at(chan, (p.x - rect.min.x) / rect.width(), 1.0 - (p.y - rect.min.y) / rect.height());
@@ -297,7 +297,7 @@ fn square(ui: &mut Ui, chan: Chan, pick: &mut Pick) {
/// Drag or click on the slider to set `chan`; arrows on both sides mark its value.
fn slider(ui: &mut Ui, chan: Chan, pick: &mut Pick) {
let (outer, resp) = ui.allocate_exact_size(vec2(SLIDER_W + 12.0, SQUARE), Sense::click_and_drag());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Slider, true, "Color slider"));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Slider, true, "Color slider"));
let bar = Rect::from_center_size(outer.center(), vec2(SLIDER_W, SQUARE));
if let Some(p) = resp.interact_pointer_pos().filter(|_| resp.is_pointer_button_down_on()) {
*pick = pick.set(chan, 1.0 - (p.y - bar.min.y) / bar.height());
@@ -332,10 +332,10 @@ fn slider(ui: &mut Ui, chan: Chan, pick: &mut Pick) {
fn swatches(ui: &mut Ui, new: [f32; 3], original: [f32; 3]) -> bool {
let (rect, _) = ui.allocate_exact_size(vec2(64.0, 64.0), Sense::hover());
let (top, bottom) = rect.split_top_bottom_at_fraction(0.5);
let new_resp = ui.interact(top, ui.id().with("new-swatch"), Sense::hover());
new_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, "New color"));
let old = ui.interact(bottom, ui.id().with("original-swatch"), Sense::click()).on_hover_text(tr("Original color: click to go back to it"));
old.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Original color"));
let new_resp = ui.interact(top, ui.scope_id().with("new-swatch"), Sense::hover());
new_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Unknown, true, "New color"));
let old = ui.interact(bottom, ui.scope_id().with("original-swatch"), Sense::click()).on_hover_text(tr("Original color: click to go back to it"));
old.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Original color"));
ui.painter().rect_filled(top, 0.0, srgb32(new));
ui.painter().rect_filled(bottom, 0.0, srgb32(original));
ui.painter().rect_stroke(rect, 0.0, ui.visuals().widgets.inactive.bg_stroke, StrokeKind::Outside);
+448 -301
View File
@@ -15,7 +15,7 @@
//! - `ui.viewer.locate {x, y}`: screen point of a comp pixel; `ui.viewer.hit {x, y}`: comp pixel at a screen point
//! - `ui.timeline.locate {layer, time?}`: screen point of a layer bar (at a comp time)
//! - `ui.playback {action: play|stop|toggle}`
//! - `ui.screenshot {path?, panel?, id?}`: PNG of the window (or one panel / element)
//! - `ui.screenshot {path?, panel?, id?}`: PNG of the window (or one panel / element, resolved when the capture is issued)
//! - `render.frame {comp?, time?, max_side?, path?, base64?}`: PNG of a comp frame rendered by the session
//! - `ui.resize {width, height}` / `ui.focus` / `app.quit {force?}` (a modified project asks to save first unless `force`)
//!
@@ -53,7 +53,8 @@ pub enum Outcome {
AfterInput,
Screenshot {
path: Option<String>,
crop: Option<[f32; 4]>,
/// The panel/element identity to capture, resolved again when the capture is issued.
target: crate::ShotTarget,
},
/// Reply once background jobs end (a `wait: true` command whose job runs in a worker).
AwaitJobs(JobWaiter),
@@ -169,304 +170,431 @@ fn point(app: &EffectcraftApp, p: &Value) -> Result<egui::Pos2, String> {
Ok(egui::pos2(x as f32, y as f32))
}
pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
pub fn handle(app: &mut EffectcraftApp, ctx: &egui::Context, req: &ControlRequest, now: f64) -> Outcome {
let p = &req.params;
let s = |k: &str| p.get(k).and_then(Value::as_str);
match req.method.as_str() {
"engine.execute" | "ui.menu.invoke" => {
let Some(id) = s("command").or(s("id")) else { return err("missing `command`") };
let params = p.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({}));
// `engine.execute` runs engine commands exactly as headless (never opens a dialog, e.g.
// `comp.new {}` creates a default comp); UI-only ids (`tool.*`, `view.*`, `window.*`,
// `playback.*` …) and `ui.menu.invoke` go through the menu dispatcher like a click.
let r = if req.method == "engine.execute" && effectcraft_engine::find_command(id).is_some() {
execute_engine(app, id, params)
} else {
crate::menus::invoke(app, ctx, id, params).map(ok)
};
match r {
Ok(o) => o,
Err(e) => {
app.ui.status = e.clone();
err(e)
}
}
}
"engine.commands" => match app.session.execute("command.list", json!({"filter": p.get("filter"), "enabledOnly": p.get("enabledOnly")})) {
Ok(v) => ok(v),
Err(e) => err(e),
},
"engine.execute" | "ui.menu.invoke" => execute_command(app, ctx, &req.method, p),
"engine.commands" => engine_commands(app, p),
"ui.menu.list" => ok(serde_json::to_value(crate::menus::menu_items(app)).unwrap_or_default()),
"ui.inspect" => ok(inspect(app, ctx)),
"ui.elements" => {
let prefix = s("prefix").unwrap_or("");
ok(serde_json::to_value(app.auto.query(prefix)).unwrap_or_default())
}
"ui.set" => {
if let Some(t) = s("tool") {
match Tool::from_name(t) {
Some(t) => app.ui.tool = t,
None => return err(format!("unknown tool `{t}`")),
}
}
if let Some(w) = s("workspace") {
match crate::dock::WORKSPACES.iter().find(|x| x.eq_ignore_ascii_case(w)) {
Some(w) => app.set_workspace(w),
None => return err(format!("unknown workspace `{w}`")),
}
}
if let Some(th) = s("theme") {
match crate::theme::ThemeKind::from_name(th) {
Some(k) => app.set_theme(ctx, k),
None => return err("unknown theme (dark, darker, light)"),
}
}
if let Some(f) = s("focused").and_then(PanelKind::from_name) {
app.ui.focused = f;
}
if let Some(v) = p.get("viewer") {
if let Some(z) = v.get("zoom") {
app.ui.viewer.zoom = z.as_f64().map(|z| z as f32);
}
// The active comp's Resolution (`view.res.*`), saved with the project.
if let Some(r) = v.get("res").and_then(Value::as_str) {
let Some(res) = Resolution::ALL.into_iter().find(|x| x.label().eq_ignore_ascii_case(r)) else {
return err(format!("unknown resolution `{r}` (auto, full, half, third, quarter)"));
};
if let Some(cid) = app.session.active_comp_id()
&& let Err(e) = app.session.set_resolution(cid, res)
{
return err(e);
}
}
if let Some(Value::Array(a)) = v.get("pan") {
app.ui.viewer.pan = [a.first().and_then(Value::as_f64).unwrap_or(0.0) as f32, a.get(1).and_then(Value::as_f64).unwrap_or(0.0) as f32];
}
for (k, slot) in [
("grid", &mut app.ui.viewer.grid),
("rulers", &mut app.ui.viewer.rulers),
("safeMargins", &mut app.ui.viewer.safe_margins),
("transparencyGrid", &mut app.ui.viewer.transparency_grid),
] {
if let Some(b) = v.get(k).and_then(Value::as_bool) {
*slot = b;
}
}
}
if let Some(tl) = p.get("timeline") {
if let Some(v) = tl.get("pps") {
app.ui.timeline.pps = v.as_f64();
}
if let Some(v) = tl.get("start").and_then(Value::as_f64) {
app.ui.timeline.start = v;
}
if let Some(v) = tl.get("graphEditor").and_then(Value::as_bool) {
app.ui.timeline.graph_editor = v;
}
if let Some(v) = tl.get("showModes").and_then(Value::as_bool) {
app.ui.timeline.show_modes = v;
}
if let Some(Value::Array(a)) = tl.get("openLayers") {
app.ui.timeline.open_layers = a.iter().filter_map(Value::as_u64).collect();
}
if let Some(Value::Array(a)) = tl.get("openGroups") {
app.ui.timeline.open_groups = a.iter().filter_map(Value::as_u64).collect();
}
}
if let Some(b) = p.get("menuBar").and_then(Value::as_bool) {
app.ui.show_menu_bar = b;
}
// Project panel optional columns, e.g. ["type", "duration", "path", "comment"].
if let Some(Value::Array(a)) = p.get("projectColumns") {
let mut cols = vec![];
for k in a.iter().filter_map(Value::as_str) {
crate::panels::project::set_column(&mut cols, k, true);
}
app.ui.project_columns = cols;
}
// Wiggler / Smoother / Motion Sketch settings (fields of `AnimToolsState`).
if let Some(Value::Object(m)) = p.get("animTools") {
let mut cur = serde_json::to_value(&app.ui.anim_tools).unwrap_or_default();
for (k, v) in m {
cur[k] = v.clone();
}
match serde_json::from_value(cur) {
Ok(v) => app.ui.anim_tools = v,
Err(e) => return err(format!("animTools: {e}")),
}
}
if let Some(b) = p.get("home").and_then(Value::as_bool) {
app.ui.start_screen = b;
}
// Home screen tab: "home", "templates" or "learn".
if let Some(tab) = p.get("homeTab").and_then(Value::as_str) {
app.ui.home_learn = tab == "learn";
app.ui.home_templates = tab == "templates";
}
ok(Value::Null)
}
"ui.panel.show" | "ui.panel.close" => {
let Some(panel) = s("panel").and_then(PanelKind::from_name) else { return err("unknown `panel`") };
if req.method == "ui.panel.show" {
app.show_panel(panel);
} else {
app.ui.dock.close(panel);
}
ok(Value::Null)
}
"ui.click" | "ui.move" => {
let pos = match point(app, p) {
Ok(p) => p,
Err(e) if e.starts_with(RETRY) => return Outcome::Retry(e.trim_start_matches(RETRY).to_string()),
Err(e) => return err(e),
};
let m = modifiers(p);
// egui keeps the modifiers from `ModifiersChanged` (not the ones on a pointer event)
// and the synthetic events arrive one per frame: hold them for the whole gesture.
app.synthetic.push(egui::Event::ModifiersChanged(m));
app.synthetic.push(egui::Event::PointerMoved(pos));
if req.method == "ui.click" {
let button = match s("button") {
Some("right") => egui::PointerButton::Secondary,
Some("middle") => egui::PointerButton::Middle,
_ => egui::PointerButton::Primary,
};
let count = p.get("count").and_then(Value::as_u64).unwrap_or(1);
for _ in 0..count {
app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: true, modifiers: m });
app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers: m });
}
}
app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
Outcome::AfterInput
}
"ui.drag" => {
let (Some(from), Some(to)) = (p.get("from"), p.get("to")) else { return err("need `from` and `to`") };
let (a, b) = match (point(app, from), point(app, to)) {
(Ok(a), Ok(b)) => (a, b),
(Err(e), _) | (_, Err(e)) if e.starts_with(RETRY) => return Outcome::Retry(e.trim_start_matches(RETRY).to_string()),
(Err(e), _) | (_, Err(e)) => return err(e),
};
let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(12).max(2);
let m = modifiers(p);
app.synthetic.push(egui::Event::ModifiersChanged(m));
app.synthetic.push(egui::Event::PointerMoved(a));
app.synthetic.push(egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: m });
for i in 1..=steps {
let f = i as f32 / steps as f32;
app.synthetic.push(egui::Event::PointerMoved(a + (b - a) * f));
}
app.synthetic.push(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: m });
app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
Outcome::AfterInput
}
"ui.scroll" => {
let pos = match point(app, p) {
Ok(p) => p,
Err(e) => return err(e),
};
let dx = p.get("dx").and_then(Value::as_f64).unwrap_or(0.0) as f32;
let dy = p.get("dy").and_then(Value::as_f64).unwrap_or(0.0) as f32;
app.synthetic.push(egui::Event::PointerMoved(pos));
app.synthetic.push(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(dx, dy),
modifiers: modifiers(p),
phase: egui::TouchPhase::Move,
});
app.synthetic.push(egui::Event::PointerButton {
pos: egui::pos2(-100.0, -100.0),
button: egui::PointerButton::Extra1,
pressed: false,
modifiers: Default::default(),
});
Outcome::AfterInput
}
"ui.key" => {
let Some(name) = s("key") else { return err("missing `key`") };
let Some((mut m, key)) = crate::menus::parse_shortcut(name) else { return err(format!("unknown key `{name}`")) };
let extra = modifiers(p);
m |= extra;
let physical_key = name.starts_with("Physical+").then_some(key);
app.synthetic.push(egui::Event::Key { key, physical_key, pressed: true, repeat: false, modifiers: m });
app.synthetic.push(egui::Event::Key { key, physical_key, pressed: false, repeat: false, modifiers: m });
Outcome::AfterInput
}
"ui.type" => {
let Some(text) = s("text") else { return err("missing `text`") };
app.synthetic.push(egui::Event::Text(text.to_string()));
app.synthetic.push(egui::Event::Key { key: egui::Key::F35, physical_key: None, pressed: false, repeat: false, modifiers: Default::default() });
Outcome::AfterInput
}
"ui.viewer.locate" => {
let (Some(x), Some(y)) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) else {
return err("need `x`, `y` (comp pixels)");
};
match crate::panels::viewer::comp_to_screen(ctx, [x as f32, y as f32]) {
Some(pt) => ok(json!({"x": pt.x, "y": pt.y})),
None => Outcome::Retry("the composition viewer is not visible".into()),
}
}
"ui.viewer.hit" => {
let pos = match point(app, p) {
Ok(p) => p,
Err(e) => return err(e),
};
match crate::panels::viewer::screen_to_comp(ctx, pos) {
Some(c) => ok(json!({"x": c[0], "y": c[1]})),
None => err("the composition viewer is not visible"),
}
}
"ui.timeline.locate" => {
let Some(l) = p.get("layer").and_then(Value::as_u64) else { return err("need `layer` (id)") };
match crate::panels::timeline::locate(app, l, p.get("time").and_then(Value::as_f64)) {
Some((x, y)) => ok(json!({"x": x, "y": y})),
None => Outcome::Retry("layer not visible in the timeline".into()),
}
}
"ui.playback" => {
let now = ctx.input(|i| i.time);
match s("action").unwrap_or("toggle") {
"play" => app.play(now),
"stop" => app.stop(),
"status" => {}
_ => app.toggle_play(now),
}
ok(
json!({"playing": app.playback.playing, "time": app.session.time().seconds(), "audio": app.audio.is_some(), "levelsDb": app.meter.level_db, "peaksDb": app.meter.peak_db}),
)
}
"ui.screenshot" => {
let crop = s("panel").and_then(PanelKind::from_name).and_then(|pk| app.auto.find(&format!("panel.{}", pk.id())).map(|e| e.rect));
let crop = crop.or_else(|| s("id").and_then(|id| app.auto.find(id).map(|e| e.rect)));
// Bring the window on screen so a frame is presented, without taking keyboard focus.
app.raise_for_control(ctx);
Outcome::Screenshot { path: s("path").map(str::to_string), crop }
}
"ui.elements" => elements(app, p),
"ui.set" => set_ui(app, ctx, p),
"ui.panel.show" | "ui.panel.close" => panel(app, &req.method, p),
"ui.click" | "ui.move" => click_or_move(app, &req.method, p),
"ui.drag" => drag(app, p),
"ui.scroll" => scroll(app, p),
"ui.key" => key(app, p),
"ui.type" => type_text(app, p),
"ui.viewer.locate" => viewer_locate(ctx, p),
"ui.viewer.hit" => viewer_hit(app, ctx, p),
"ui.timeline.locate" => timeline_locate(app, p),
"ui.playback" => playback(app, p, now),
"ui.screenshot" => screenshot(app, ctx, p),
"render.frame" => render_frame(app, p),
"ui.resize" => {
let w = p.get("width").and_then(Value::as_f64).unwrap_or(1600.0) as f32;
let h = p.get("height").and_then(Value::as_f64).unwrap_or(1000.0) as f32;
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w, h)));
ok(Value::Null)
}
"ui.focus" => {
// Explicit request: activate the app and take keyboard focus.
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
ok(Value::Null)
}
"app.quit" => {
// A modified project asks to save first unless `force: true`.
if p.get("force").and_then(Value::as_bool).unwrap_or(false) {
app.dialog_state.unsaved.quitting = true;
}
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
ok(Value::Null)
}
"ui.resize" => resize(ctx, p),
"ui.focus" => focus(ctx),
"app.quit" => quit(app, ctx, p),
m => err(format!("unknown method `{m}`")),
}
}
fn execute_command(app: &mut EffectcraftApp, ctx: &egui::Context, method: &str, p: &Value) -> Outcome {
let s = |k: &str| p.get(k).and_then(Value::as_str);
let Some(id) = s("command").or(s("id")) else { return err("missing `command`") };
let params = p.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({}));
// `engine.execute` runs engine commands exactly as headless (never opens a dialog, e.g.
// `comp.new {}` creates a default comp); UI-only ids (`tool.*`, `view.*`, `window.*`,
// `playback.*` …) and `ui.menu.invoke` go through the menu dispatcher like a click.
let r = if method == "engine.execute" && effectcraft_engine::find_command(id).is_some() {
execute_engine(app, id, params)
} else {
crate::menus::invoke(app, ctx, id, params).map(ok)
};
match r {
Ok(o) => o,
Err(e) => {
app.ui.status = e.clone();
err(e)
}
}
}
fn engine_commands(app: &mut EffectcraftApp, p: &Value) -> Outcome {
match app.session.execute("command.list", json!({"filter": p.get("filter"), "enabledOnly": p.get("enabledOnly")})) {
Ok(v) => ok(v),
Err(e) => err(e),
}
}
fn elements(app: &EffectcraftApp, p: &Value) -> Outcome {
let prefix = p.get("prefix").and_then(Value::as_str).unwrap_or("");
ok(serde_json::to_value(app.auto.query(prefix)).unwrap_or_default())
}
/// Explicit request: activate the app and take keyboard focus.
fn focus(ctx: &egui::Context) -> Outcome {
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
ok(Value::Null)
}
fn set_ui(app: &mut EffectcraftApp, ctx: &egui::Context, p: &Value) -> Outcome {
let s = |k: &str| p.get(k).and_then(Value::as_str);
if let Some(t) = s("tool") {
match Tool::from_name(t) {
Some(t) => app.ui.tool = t,
None => return err(format!("unknown tool `{t}`")),
}
}
if let Some(w) = s("workspace") {
match crate::dock::WORKSPACES.iter().find(|x| x.eq_ignore_ascii_case(w)) {
Some(w) => app.set_workspace(w),
None => return err(format!("unknown workspace `{w}`")),
}
}
if let Some(th) = s("theme") {
match crate::theme::ThemeKind::from_name(th) {
Some(k) => app.set_theme(ctx, k),
None => return err("unknown theme (dark, darker, light)"),
}
}
if let Some(f) = s("focused").and_then(PanelKind::from_name) {
app.ui.focused = f;
}
if let Some(v) = p.get("viewer")
&& let Err(e) = apply_viewer(app, v)
{
return err(e);
}
set_ui_layout(app, p)
}
/// The remaining `ui.set` fields, applied in the same order after the viewer settings.
fn set_ui_layout(app: &mut EffectcraftApp, p: &Value) -> Outcome {
if let Some(tl) = p.get("timeline") {
apply_timeline(app, tl);
}
if let Some(b) = p.get("menuBar").and_then(Value::as_bool) {
app.ui.show_menu_bar = b;
}
// Project panel optional columns, e.g. ["type", "duration", "path", "comment"].
if let Some(Value::Array(a)) = p.get("projectColumns") {
apply_project_columns(app, a);
}
// Wiggler / Smoother / Motion Sketch settings (fields of `AnimToolsState`).
if let Some(Value::Object(m)) = p.get("animTools")
&& let Err(e) = apply_anim_tools(app, m)
{
return err(e);
}
if let Some(b) = p.get("home").and_then(Value::as_bool) {
app.ui.start_screen = b;
}
// Home screen tab: "home", "templates" or "learn".
if let Some(tab) = p.get("homeTab").and_then(Value::as_str) {
app.ui.home_learn = tab == "learn";
app.ui.home_templates = tab == "templates";
}
ok(Value::Null)
}
fn apply_viewer(app: &mut EffectcraftApp, v: &Value) -> Result<(), String> {
if let Some(z) = v.get("zoom") {
app.ui.viewer.zoom = z.as_f64().map(|z| z as f32);
}
// The active comp's Resolution (`view.res.*`), saved with the project.
if let Some(r) = v.get("res").and_then(Value::as_str) {
let Some(res) = Resolution::ALL.into_iter().find(|x| x.label().eq_ignore_ascii_case(r)) else {
return Err(format!("unknown resolution `{r}` (auto, full, half, third, quarter)"));
};
if let Some(cid) = app.session.active_comp_id()
&& let Err(e) = app.session.set_resolution(cid, res)
{
return Err(e.to_string());
}
}
if let Some(Value::Array(a)) = v.get("pan") {
app.ui.viewer.pan = [a.first().and_then(Value::as_f64).unwrap_or(0.0) as f32, a.get(1).and_then(Value::as_f64).unwrap_or(0.0) as f32];
}
for (k, slot) in [
("grid", &mut app.ui.viewer.grid),
("rulers", &mut app.ui.viewer.rulers),
("safeMargins", &mut app.ui.viewer.safe_margins),
("transparencyGrid", &mut app.ui.viewer.transparency_grid),
] {
if let Some(b) = v.get(k).and_then(Value::as_bool) {
*slot = b;
}
}
Ok(())
}
fn apply_timeline(app: &mut EffectcraftApp, tl: &Value) {
if let Some(v) = tl.get("pps") {
app.ui.timeline.pps = v.as_f64();
}
if let Some(v) = tl.get("start").and_then(Value::as_f64) {
app.ui.timeline.start = v;
}
if let Some(v) = tl.get("graphEditor").and_then(Value::as_bool) {
app.ui.timeline.graph_editor = v;
}
if let Some(v) = tl.get("showModes").and_then(Value::as_bool) {
app.ui.timeline.show_modes = v;
}
if let Some(Value::Array(a)) = tl.get("openLayers") {
app.ui.timeline.open_layers = a.iter().filter_map(Value::as_u64).collect();
}
if let Some(Value::Array(a)) = tl.get("openGroups") {
app.ui.timeline.open_groups = a.iter().filter_map(Value::as_u64).collect();
}
}
fn apply_project_columns(app: &mut EffectcraftApp, a: &[Value]) {
let mut cols = vec![];
for k in a.iter().filter_map(Value::as_str) {
crate::panels::project::set_column(&mut cols, k, true);
}
app.ui.project_columns = cols;
}
fn apply_anim_tools(app: &mut EffectcraftApp, m: &serde_json::Map<String, Value>) -> Result<(), String> {
let mut cur = serde_json::to_value(&app.ui.anim_tools).unwrap_or_default();
for (k, v) in m {
cur[k] = v.clone();
}
match serde_json::from_value(cur) {
Ok(v) => {
app.ui.anim_tools = v;
Ok(())
}
Err(e) => Err(format!("animTools: {e}")),
}
}
fn panel(app: &mut EffectcraftApp, method: &str, p: &Value) -> Outcome {
let s = |k: &str| p.get(k).and_then(Value::as_str);
let Some(panel) = s("panel").and_then(PanelKind::from_name) else { return err("unknown `panel`") };
if method == "ui.panel.show" {
app.show_panel(panel);
} else {
app.ui.dock.close(panel);
}
ok(Value::Null)
}
fn click_gesture_events(method: &str, p: &Value) -> Result<usize, String> {
if method != "ui.click" {
return Ok(3);
}
const MAX_CLICK_COUNT: u64 = 512;
let count = p.get("count").and_then(Value::as_u64).unwrap_or(1);
if count == 0 || count > MAX_CLICK_COUNT {
return Err(format!("`count` must be between 1 and {MAX_CLICK_COUNT}"));
}
usize::try_from(count).ok().and_then(|n| n.checked_mul(2)).and_then(|n| n.checked_add(3)).ok_or_else(|| "click event count overflow".to_string())
}
fn click_or_move(app: &mut EffectcraftApp, method: &str, p: &Value) -> Outcome {
let s = |k: &str| p.get(k).and_then(Value::as_str);
let pos = match point(app, p) {
Ok(p) => p,
Err(e) if e.starts_with(RETRY) => return Outcome::Retry(e.trim_start_matches(RETRY).to_string()),
Err(e) => return err(e),
};
let m = modifiers(p);
// Validate the complete gesture before mutating the queue; the loopback control
// channel is externally reachable and must not amplify tiny requests into huge work.
const MAX_PENDING_EVENTS: usize = 4096;
let pending = app.synthetic.len();
let gesture_events = match click_gesture_events(method, p) {
Ok(events) => events,
Err(e) => return err(e),
};
if pending.checked_add(gesture_events).is_none_or(|n| n > MAX_PENDING_EVENTS) {
return err("synthetic input queue is full");
}
// egui keeps the modifiers from `ModifiersChanged` (not the ones on a pointer event)
// and the synthetic events arrive one per frame: hold them for the whole gesture.
app.synthetic.push(egui::Event::ModifiersChanged(m));
app.synthetic.push(egui::Event::PointerMoved(pos));
if method == "ui.click" {
let button = match s("button") {
Some("right") => egui::PointerButton::Secondary,
Some("middle") => egui::PointerButton::Middle,
_ => egui::PointerButton::Primary,
};
let count = p.get("count").and_then(Value::as_u64).unwrap_or(1);
for _ in 0..count {
app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: true, modifiers: m });
app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers: m });
}
}
app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
Outcome::AfterInput
}
fn drag(app: &mut EffectcraftApp, p: &Value) -> Outcome {
let (Some(from), Some(to)) = (p.get("from"), p.get("to")) else { return err("need `from` and `to`") };
let (a, b) = match (point(app, from), point(app, to)) {
(Ok(a), Ok(b)) => (a, b),
(Err(e), _) | (_, Err(e)) if e.starts_with(RETRY) => return Outcome::Retry(e.trim_start_matches(RETRY).to_string()),
(Err(e), _) | (_, Err(e)) => return err(e),
};
let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(12).max(2);
const MAX_PENDING_EVENTS: usize = 4096;
const MAX_DRAG_STEPS: u64 = 2048;
if steps > MAX_DRAG_STEPS {
return err(format!("`steps` must be at most {MAX_DRAG_STEPS}"));
}
let Some(gesture_events) = usize::try_from(steps).ok().and_then(|n| n.checked_add(5)) else { return err("drag event count overflow") };
if app.synthetic.len().checked_add(gesture_events).is_none_or(|n| n > MAX_PENDING_EVENTS) {
return err("synthetic input queue is full");
}
let m = modifiers(p);
app.synthetic.push(egui::Event::ModifiersChanged(m));
app.synthetic.push(egui::Event::PointerMoved(a));
app.synthetic.push(egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: m });
for i in 1..=steps {
let f = i as f32 / steps as f32;
app.synthetic.push(egui::Event::PointerMoved(a + (b - a) * f));
}
app.synthetic.push(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: m });
app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
Outcome::AfterInput
}
fn scroll(app: &mut EffectcraftApp, p: &Value) -> Outcome {
let pos = match point(app, p) {
Ok(p) => p,
Err(e) => return err(e),
};
let dx = p.get("dx").and_then(Value::as_f64).unwrap_or(0.0) as f32;
let dy = p.get("dy").and_then(Value::as_f64).unwrap_or(0.0) as f32;
app.synthetic.push(egui::Event::PointerMoved(pos));
app.synthetic.push(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(dx, dy),
modifiers: modifiers(p),
phase: egui::TouchPhase::Move,
source: egui::MouseWheelSource::Wheel,
});
app.synthetic.push(egui::Event::PointerButton {
pos: egui::pos2(-100.0, -100.0),
button: egui::PointerButton::Extra1,
pressed: false,
modifiers: Default::default(),
});
Outcome::AfterInput
}
fn key(app: &mut EffectcraftApp, p: &Value) -> Outcome {
let s = |k: &str| p.get(k).and_then(Value::as_str);
let Some(name) = s("key") else { return err("missing `key`") };
let Some((mut m, key)) = crate::menus::parse_shortcut(name) else { return err(format!("unknown key `{name}`")) };
let extra = modifiers(p);
m |= extra;
let physical_key = name.starts_with("Physical+").then_some(key);
app.synthetic.push(egui::Event::Key { key, physical_key, pressed: true, repeat: false, modifiers: m });
app.synthetic.push(egui::Event::Key { key, physical_key, pressed: false, repeat: false, modifiers: m });
Outcome::AfterInput
}
fn type_text(app: &mut EffectcraftApp, p: &Value) -> Outcome {
let Some(text) = p.get("text").and_then(Value::as_str) else { return err("missing `text`") };
app.synthetic.push(egui::Event::Text(text.to_string()));
app.synthetic.push(egui::Event::Key { key: egui::Key::F35, physical_key: None, pressed: false, repeat: false, modifiers: Default::default() });
Outcome::AfterInput
}
fn viewer_locate(ctx: &egui::Context, p: &Value) -> Outcome {
let (Some(x), Some(y)) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) else {
return err("need `x`, `y` (comp pixels)");
};
match crate::panels::viewer::comp_to_screen(ctx, [x as f32, y as f32]) {
Some(pt) => ok(json!({"x": pt.x, "y": pt.y})),
None => Outcome::Retry("the composition viewer is not visible".into()),
}
}
fn viewer_hit(app: &mut EffectcraftApp, ctx: &egui::Context, p: &Value) -> Outcome {
let pos = match point(app, p) {
Ok(p) => p,
Err(e) => return err(e),
};
match crate::panels::viewer::screen_to_comp(ctx, pos) {
Some(c) => ok(json!({"x": c[0], "y": c[1]})),
None => err("the composition viewer is not visible"),
}
}
fn timeline_locate(app: &EffectcraftApp, p: &Value) -> Outcome {
let Some(l) = p.get("layer").and_then(Value::as_u64) else { return err("need `layer` (id)") };
match crate::panels::timeline::locate(app, l, p.get("time").and_then(Value::as_f64)) {
Some((x, y)) => ok(json!({"x": x, "y": y})),
None => Outcome::Retry("layer not visible in the timeline".into()),
}
}
fn playback(app: &mut EffectcraftApp, p: &Value, now: f64) -> Outcome {
let action = p.get("action").and_then(Value::as_str).unwrap_or("toggle");
match action {
"play" => app.play(now),
"stop" => app.stop(),
"status" => {}
_ => app.toggle_play(now),
}
ok(
json!({"playing": app.playback.playing, "time": app.session.time().seconds(), "audio": app.audio.is_some(), "levelsDb": app.meter.level_db, "peaksDb": app.meter.peak_db}),
)
}
fn screenshot(app: &mut EffectcraftApp, ctx: &egui::Context, p: &Value) -> Outcome {
let s = |k: &str| p.get(k).and_then(Value::as_str);
// The panel/element is an identity, not a rectangle: it is resolved when the capture
// is issued (after the UI settles), so a panel that moves meanwhile is not cropped
// with its old rectangle.
let target = match s("panel") {
Some(name) => match PanelKind::from_name(name) {
Some(pk) => crate::ShotTarget::Panel(pk),
None => return err(format!("unknown panel `{name}`")),
},
None => s("id").map_or(crate::ShotTarget::Window, |id| crate::ShotTarget::Id(id.to_string())),
};
// Asking for one element and silently capturing the whole window would hide a missing
// panel: wait for it like any other not-yet-laid-out control.
let missing = match &target {
crate::ShotTarget::Panel(pk) => app.auto.find(&format!("panel.{}", pk.id())).is_none(),
crate::ShotTarget::Id(id) => app.auto.find(id).is_none(),
crate::ShotTarget::Window | crate::ShotTarget::Rect(_) => false,
};
if missing {
return Outcome::Retry(match &target {
crate::ShotTarget::Panel(pk) => format!("the {} panel is not on screen", pk.title()),
crate::ShotTarget::Id(id) => format!("no element `{id}` is on screen"),
crate::ShotTarget::Window | crate::ShotTarget::Rect(_) => "the element is not on screen".into(),
});
}
// Bring the window on screen so a frame is presented, without taking keyboard focus.
app.raise_for_control(ctx);
Outcome::Screenshot { path: s("path").map(str::to_string), target }
}
fn resize(ctx: &egui::Context, p: &Value) -> Outcome {
let w = p.get("width").and_then(Value::as_f64).unwrap_or(1600.0) as f32;
let h = p.get("height").and_then(Value::as_f64).unwrap_or(1000.0) as f32;
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w, h)));
ok(Value::Null)
}
fn quit(app: &mut EffectcraftApp, ctx: &egui::Context, p: &Value) -> Outcome {
// A modified project asks to save first unless `force: true`.
if p.get("force").and_then(Value::as_bool).unwrap_or(false) {
app.dialog_state.unsaved.quitting = true;
}
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
ok(Value::Null)
}
/// `render.frame {comp?, time?, max_side?, path?, base64?}`: render a comp frame through the session
/// (independent of the viewer zoom/resolution) and return it as PNG, written to `path` (default a
/// temp file) or inline as base64 when `base64: true`.
@@ -526,7 +654,8 @@ pub fn base64(data: &[u8]) -> String {
}
pub fn inspect(app: &EffectcraftApp, ctx: &egui::Context) -> Value {
let size = ctx.content_rect().size();
// A logic-only tick refreshes the viewport information but not `InputState`'s screen rect.
let size = ctx.input(|i| i.viewport().inner_rect).map_or_else(|| ctx.content_rect().size(), |r| r.size());
let keyboard_focus = ctx.memory(|m| m.focused()).map(|id| {
let response = ctx.read_response(id);
let rect = response.as_ref().map(|r| [r.rect.min.x, r.rect.min.y, r.rect.width(), r.rect.height()]);
@@ -559,7 +688,9 @@ pub fn inspect(app: &EffectcraftApp, ctx: &egui::Context) -> Value {
"selectedKeys": app.session.state.selected_keys.len(),
"elements": app.auto.previous.len(),
"keyboardFocus": keyboard_focus,
"windowFocused": ctx.input(|i| i.focused),
// Fresh viewport focus only: in a logic-only tick `InputState::focused` still holds the
// previous full pass's value, and the app field is that same fresh value.
"windowFocused": ctx.input(|i| i.viewport().focused).unwrap_or(app.focused),
"workspaceStorageError": app.workspace_store.error,
"dialog": app.dialog.map(|d| format!("{d:?}")),
"renderMs": app.frames.last_ms.lock().map(|v| *v).unwrap_or(0.0),
@@ -567,21 +698,37 @@ pub fn inspect(app: &EffectcraftApp, ctx: &egui::Context) -> Value {
}
/// Save a screenshot (optionally cropped to a rect in points) as PNG; returns the reply JSON.
pub fn save_screenshot(ctx: &egui::Context, image: &egui::ColorImage, path: Option<&str>, crop: Option<[f32; 4]>) -> Value {
let ppp = ctx.pixels_per_point();
///
/// `crop` is relative to the image's own origin (the issued surface's top-left corner), and
/// `scale` is the returned image's pixels per point in x and y, derived from the image size and
/// the issued rect. The image can be smaller than requested when the adapter-limit fit (E2)
/// applies, so its own scale — never the issued or current pixels-per-point — cuts the crop:
/// any other scale reads a shifted or rescaled region.
pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>, crop: Option<[f32; 4]>, scale: [f64; 2]) -> Value {
let [w, h] = image.size;
let [sx, sy] = scale;
if w == 0 || h == 0 || !sx.is_finite() || sx <= 0.0 || !sy.is_finite() || sy <= 0.0 {
return json!({"ok": false, "error": "the screenshot has no pixels to save"});
}
if image.pixels.len() != w.saturating_mul(h) {
return json!({"ok": false, "error": "the screenshot's pixels do not match its dimensions"});
}
let px = |v: f64, s: f64| (v * s).max(0.0) as usize;
let (x0, y0, cw, ch) = match crop {
Some([x, y, cw, ch]) => {
let x0 = ((x * ppp) as usize).min(w);
let y0 = ((y * ppp) as usize).min(h);
(x0, y0, ((cw * ppp) as usize).min(w - x0), ((ch * ppp) as usize).min(h - y0))
Some([x, y, cw, ch]) if [x, y, cw, ch].iter().all(|v| v.is_finite()) => {
let x0 = px(x as f64, sx).min(w);
let y0 = px(y as f64, sy).min(h);
(x0, y0, px(cw as f64, sx).min(w - x0), px(ch as f64, sy).min(h - y0))
}
Some(_) => return json!({"ok": false, "error": "the screenshot crop is not a finite rectangle"}),
None => (0, 0, w, h),
};
let mut rgba = Vec::with_capacity(cw * ch * 4);
let mut rgba = Vec::with_capacity(cw.saturating_mul(ch).saturating_mul(4));
for y in y0..y0 + ch {
for x in x0..x0 + cw {
let c = image.pixels[y * w + x];
let Some(c) = image.pixels.get(y * w + x) else {
return json!({"ok": false, "error": "the screenshot's pixels do not match its dimensions"});
};
rgba.extend_from_slice(&[c.r(), c.g(), c.b(), 255]);
}
}
+1 -1
View File
@@ -241,7 +241,7 @@ impl Default for View {
/// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table. Every
/// control registers an automation id under `about.credits.`.
pub fn contributors_ui(ui: &mut egui::Ui, auto: &mut Registry) {
let id = egui::Id::new("credits_view");
let id = egui::Id::unique("credits_view");
let mut v = ui.data_mut(|d| d.get_temp::<View>(id)).unwrap_or_default();
ui.horizontal_wrapped(|ui| {
ui.label(tr("Show"));
+7 -7
View File
@@ -1057,7 +1057,7 @@ fn resize_handle(gap: Rect, vertical: bool, path: &str, t: &Tokens) -> ResizeHan
let axis = if vertical { SplitAxis::Vertical } else { SplitAxis::Horizontal };
let style = ResizeHandleStyle { hovered: t.focus.gamma_multiply(0.4), dragged: t.focus.gamma_multiply(0.9), focused: t.focus };
let label = if vertical { crate::i18n::tr("Height:") } else { crate::i18n::tr("Width:") };
ResizeHandle::new(egui::Id::new(("dock-gutter", path.to_string())), axis, gap, label, style)
ResizeHandle::new(egui::Id::unique(("dock-gutter", path.to_string())), axis, gap, label, style)
}
/// The least body height of an open stacked panel (when the stack has room for it).
@@ -1224,7 +1224,7 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, t: &Tokens, reg: &mut cra
let w = galley.size().x + 16.0 + menu_w + deco_w;
if x + w > strip.max.x - 20.0 && k > ai {
let r = Rect::from_min_size(pos2(strip.max.x - 22.0, strip.min.y + 6.0), vec2(18.0, 20.0));
let resp = ui.interact(r, egui::Id::new(("tab-overflow", g.path.clone())), Sense::click());
let resp = ui.interact(r, egui::Id::unique(("tab-overflow", g.path.clone())), Sense::click());
icons::paint(&painter, r.shrink(4.0).translate(vec2(-2.0, 0.0)), Icon::ChevronRight, t.tab_text);
icons::paint(&painter, r.shrink(4.0).translate(vec2(2.0, 0.0)), Icon::ChevronRight, t.tab_text);
crate::widgets::focus_outline(ui, &resp, t);
@@ -1241,7 +1241,7 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, t: &Tokens, reg: &mut cra
Some((_, r)) => *r = r.union(tab),
None => out.tabs.push((*p, tab)),
}
let resp = ui.interact(tab, egui::Id::new(("tab", g.path.clone(), k)), Sense::click_and_drag());
let resp = ui.interact(tab, egui::Id::unique(("tab", g.path.clone(), k)), Sense::click_and_drag());
if resp.drag_started() {
out.actions.push(DockAction::BeginDrag(*p));
}
@@ -1259,7 +1259,7 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, t: &Tokens, reg: &mut cra
let mut dx = tab.min.x + 6.0;
if is_active && d.viewer {
let cr = Rect::from_center_size(pos2(dx + 5.0, text_y), vec2(10.0, 10.0));
let cresp = ui.interact(cr.expand(2.0), egui::Id::new(("tab-close", g.path.clone(), k)), Sense::click());
let cresp = ui.interact(cr.expand(2.0), egui::Id::unique(("tab-close", g.path.clone(), k)), Sense::click());
let cc = if cresp.hovered() { t.tab_text_active } else { t.tab_text };
let kk = 3.5;
painter.line_segment([cr.center() + vec2(-kk, -kk), cr.center() + vec2(kk, kk)], Stroke::new(1.2, cc));
@@ -1277,7 +1277,7 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, t: &Tokens, reg: &mut cra
label_x = dx;
if lock {
let lr = Rect::from_center_size(pos2(dx + 6.0, text_y), vec2(12.0, 12.0));
let lresp = ui.interact(lr.expand(2.0), egui::Id::new(("tab-lock", g.path.clone(), k)), Sense::click());
let lresp = ui.interact(lr.expand(2.0), egui::Id::unique(("tab-lock", g.path.clone(), k)), Sense::click());
let lc = if d.locked {
t.tab_text_active
} else if lresp.hovered() {
@@ -1299,7 +1299,7 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, t: &Tokens, reg: &mut cra
painter.galley_with_override_text_color(pos2(label_x, text_y - galley.size().y / 2.0), galley, col);
if is_active {
let mr = Rect::from_center_size(pos2(label_x + label_w + 12.0, text_y), vec2(12.0, 10.0));
let mresp = ui.interact(mr.expand(3.0), egui::Id::new(("tab-menu", g.path.clone())), Sense::click());
let mresp = ui.interact(mr.expand(3.0), egui::Id::unique(("tab-menu", g.path.clone())), Sense::click());
reg.add_response(&format!("panel.menu.{}", p.id()), &mresp, "panel menu");
let mc = if mresp.hovered() { t.tab_text_active } else { t.tab_text };
for dy in [-3.5, 0.0, 3.5] {
@@ -1335,7 +1335,7 @@ pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, t: &Tokens, reg: &mut cra
{
let rest = Rect::from_min_max(pos2((x - 8.0).max(strip.min.x), strip.min.y), strip.max);
if rest.width() > 0.0 {
let resp = ui.interact(rest, egui::Id::new(("tab-rest", g.path.clone())), Sense::click());
let resp = ui.interact(rest, egui::Id::unique(("tab-rest", g.path.clone())), Sense::click());
reg.add(&format!("panel.header.{}", e.panel.id()), rest, &e.label);
if resp.clicked() {
out.actions.push(DockAction::ToggleStacked(e.panel));
+87 -53
View File
@@ -13,7 +13,7 @@ use crate::{EffectcraftApp, panels};
use craft_ui::docking::{Action, DropTarget, resolve_drop};
fn drag_id() -> egui::Id {
egui::Id::new("dock-tab-drag")
egui::Id::unique("dock-tab-drag")
}
impl EffectcraftApp {
@@ -41,8 +41,8 @@ impl EffectcraftApp {
}
let Some(comp) = self.session.active_comp() else { return out };
if let Some(l) = self.session.state.selected_layers.first().and_then(|id| comp.layer(*id)) {
out.push((PanelKind::EffectControls, format!("Effect Controls {}", l.name)));
out.push((PanelKind::Properties, format!("Properties: {}", l.name)));
out.push((PanelKind::EffectControls, format!("{} {}", crate::i18n::command_label("window.panel", "Effect Controls"), l.name)));
out.push((PanelKind::Properties, format!("{}: {}", crate::i18n::command_label("window.panel", "Properties"), l.name)));
}
out
}
@@ -183,6 +183,51 @@ impl EffectcraftApp {
}
}
/// Apply one action after the panel bodies have drawn. A panel-window pass runs this at
/// its own pass end so the final IME descriptor sees the post-action state.
fn handle_dock_action(&mut self, ctx: &egui::Context, action: DockAction) {
match action {
DockAction::Activate(p) => {
let _ = self.dock_command(ctx, Action::Activate { panel: p });
}
DockAction::ToggleStacked(p) => {
let _ = self.dock_command(ctx, Action::SetStackOpen { panel: p, open: !self.ui.dock.is_visible(p) });
}
DockAction::Focus(p) => {
self.ui.focused = p;
panels::viewer_text::panel_clicked(self, ctx, p);
// A click in (or on the tab of) another Composition viewer makes it active.
if let Some(id) = panels::viewers::id_of(p) {
panels::viewers::activate(self, ctx, id);
}
}
DockAction::Close(p) => {
let _ = self.dock_command(ctx, Action::Close { panel: p });
}
DockAction::PanelMenu(p, pos) => {
ctx.data_mut(|d| d.insert_temp(egui::Id::unique("panel-menu"), (p, pos)));
}
DockAction::BeginDrag(p) => ctx.data_mut(|d| {
d.insert_temp(drag_id(), p);
}),
DockAction::ToggleLock(p) => {
if !self.ui.locked_tabs.remove(&p.id()) {
self.ui.locked_tabs.insert(p.id().to_string());
}
}
// The Timeline's comp tabs: show that comp / close its Timeline.
DockAction::ActivateDoc(p, id) => {
let _ = self.dock_command(ctx, Action::Activate { panel: p });
if self.session.state.active_comp.map(|c| c.0) != Some(id) {
let _ = crate::menus::invoke(self, ctx, "comp.open", serde_json::json!({"comp": id}));
}
}
DockAction::CloseDoc(_, id) => {
let _ = crate::menus::invoke(self, ctx, "comp.close", serde_json::json!({"comp": id}));
}
}
}
pub(crate) fn dock_area(&mut self, ui: &mut egui::Ui, body: Rect) {
let t = self.tokens;
let ctx = ui.ctx().clone();
@@ -206,7 +251,9 @@ impl EffectcraftApp {
let titles = self.tab_titles();
let decos = self.tab_decos();
let docs = self.tab_docs();
let title = |p: PanelKind| titles.iter().find(|(k, _)| *k == p).map(|(_, s)| s.clone()).unwrap_or_else(|| p.title().to_string());
let title = |p: PanelKind| {
titles.iter().find(|(k, _)| *k == p).map(|(_, s)| s.clone()).unwrap_or_else(|| crate::i18n::command_label("window.panel", p.title()).to_string())
};
let info = dock::TabInfo { title: &title, decos: &decos, docs: &docs };
// Where each group's tabs are (tab drags show where a tab will land between them).
let mut tab_rects: Vec<(String, Vec<(PanelKind, Rect)>)> = vec![];
@@ -242,18 +289,28 @@ impl EffectcraftApp {
.get(*index)
.and_then(|group| group.panels.first())
.and_then(|panel| {
let id = egui::Id::new(("floating-panel", panel.id()));
let id = egui::Id::unique(("floating-panel", panel.id()));
layers.iter().position(|layer| layer.id == id)
})
.unwrap_or(usize::MAX)
});
// A native window is kept only for a panel that still floats.
// A native window is kept only for a panel that still floats. A native viewport that
// goes away releases its IME context: its composition must not reappear elsewhere.
let ui_state = &mut self.ui;
let gone: Vec<egui::ViewportId> = ui_state
.panel_windows
.iter()
.filter(|w| !ui_state.floating.iter().any(|group| group.panels.contains(&w.panel)))
.map(|w| panel_window_id(w.panel))
.collect();
ui_state.panel_windows.retain(|w| ui_state.floating.iter().any(|group| group.panels.contains(&w.panel)));
for viewport in gone {
panels::viewer_text::end_viewport(self, &ctx, viewport);
}
for i in floating_order {
// Groups in their own native window take no tab drops from the main window.
if let Some(window) = self.ui.floating.get(i).and_then(|group| self.panel_window_of(&group.panels)) {
self.panel_window(&ctx, i, window, &info, &mut actions);
self.panel_window(&ctx, i, window, &info);
continue;
}
if let Some((g, tabs)) = self.floating_window(&ctx, i, &info, &mut actions) {
@@ -265,46 +322,7 @@ impl EffectcraftApp {
let _ = self.dock_command(&ctx, action);
}
for a in actions {
match a {
DockAction::Activate(p) => {
let _ = self.dock_command(&ctx, Action::Activate { panel: p });
}
DockAction::ToggleStacked(p) => {
let _ = self.dock_command(&ctx, Action::SetStackOpen { panel: p, open: !self.ui.dock.is_visible(p) });
}
DockAction::Focus(p) => {
self.ui.focused = p;
panels::viewer_text::panel_clicked(self, &ctx, p);
// A click in (or on the tab of) another Composition viewer makes it active.
if let Some(id) = panels::viewers::id_of(p) {
panels::viewers::activate(self, &ctx, id);
}
}
DockAction::Close(p) => {
let _ = self.dock_command(&ctx, Action::Close { panel: p });
}
DockAction::PanelMenu(p, pos) => {
ctx.data_mut(|d| d.insert_temp(egui::Id::new("panel-menu"), (p, pos)));
}
DockAction::BeginDrag(p) => ctx.data_mut(|d| {
d.insert_temp(drag_id(), p);
}),
DockAction::ToggleLock(p) => {
if !self.ui.locked_tabs.remove(&p.id()) {
self.ui.locked_tabs.insert(p.id().to_string());
}
}
// The Timeline's comp tabs: show that comp / close its Timeline.
DockAction::ActivateDoc(p, id) => {
let _ = self.dock_command(&ctx, Action::Activate { panel: p });
if self.session.state.active_comp.map(|c| c.0) != Some(id) {
let _ = crate::menus::invoke(self, &ctx, "comp.open", serde_json::json!({"comp": id}));
}
}
DockAction::CloseDoc(_, id) => {
let _ = crate::menus::invoke(self, &ctx, "comp.close", serde_json::json!({"comp": id}));
}
}
self.handle_dock_action(&ctx, a);
}
self.tab_drag(&ctx, &groups, &float_groups, &tab_rects);
panels::panel_menu_popup(self, ui);
@@ -328,7 +346,7 @@ impl EffectcraftApp {
(screen.min.x - rect.min.x).max(0.0) + (screen.max.x - 40.0 - rect.min.x).min(0.0),
(screen.min.y - rect.min.y).max(0.0) + (screen.max.y - 30.0 - rect.min.y).min(0.0),
));
let id = egui::Id::new(("floating-panel", f.panels.first().map(|p| p.id()).unwrap_or_default()));
let id = egui::Id::unique(("floating-panel", f.panels.first().map(|p| p.id()).unwrap_or_default()));
let mut group = None;
// Not constrained to the window: the group is drawn where its rect says (the clamp above
// keeps its strip reachable), so it follows the pointer past the window's edges.
@@ -364,7 +382,7 @@ impl EffectcraftApp {
// Resize from the corner.
let grip = Rect::from_min_max(r.max - vec2(14.0, 14.0), r.max);
let rs = ui.interact(grip, id.with("resize"), Sense::click_and_drag());
rs.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, rs.enabled(), crate::i18n::tr("Resize floating panel")));
rs.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Splitter, rs.enabled(), crate::i18n::tr("Resize floating panel")));
self.auto.add_response(&format!("panel.float.{i}.resize"), &rs, "Resize floating panel");
for k in 0..3 {
let o = 4.0 + k as f32 * 4.0;
@@ -454,7 +472,7 @@ impl EffectcraftApp {
/// One floating group in its own native window: the group's tab strip and active panel fill
/// the window. Closing the window brings the group back into the main window.
fn panel_window(&mut self, ctx: &egui::Context, i: usize, window: PanelWindow, info: &dock::TabInfo, actions: &mut Vec<DockAction>) {
fn panel_window(&mut self, ctx: &egui::Context, i: usize, window: PanelWindow, info: &dock::TabInfo) {
let t = self.tokens;
let Some(f) = self.ui.floating.get(i).cloned() else { return };
let active = f.active.min(f.panels.len().saturating_sub(1));
@@ -470,8 +488,14 @@ impl EffectcraftApp {
.with_min_inner_size(dock::PANEL_WINDOW_MIN);
let mut closed = false;
let mut placed = None;
let mut pass_viewport = None;
let mut actions = Vec::new();
ctx.show_viewport_immediate(panel_window_id(window.panel), builder, |ui, class| {
pass_viewport = Some(ui.ctx().viewport_id());
let native = matches!(class, egui::ViewportClass::Immediate | egui::ViewportClass::Deferred);
if native {
panels::viewer_text::begin_ui(ui.ctx());
}
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.panel_bg)).show(ui, |ui| {
let r = ui.max_rect();
let g = Group {
@@ -493,6 +517,12 @@ impl EffectcraftApp {
panels::panel_menu_popup(self, ui);
}
});
// Apply this window's panel/tab actions before publishing its IME descriptor: a
// click-away or close in the floating panel must own the pass's final output.
for action in actions.drain(..) {
self.handle_dock_action(ui.ctx(), action);
}
panels::viewer_text::finish_ime(self, ui);
// An embedded window (no native windows on this platform) reads the main window's
// input: its close request and place aren't this window's.
if native {
@@ -507,6 +537,10 @@ impl EffectcraftApp {
});
if closed {
self.ui.panel_windows.retain(|w| !f.panels.contains(&w.panel));
if let Some(viewport) = pass_viewport {
panels::viewer_text::end_viewport(self, ctx, viewport);
}
panels::viewer_text::end_viewport(self, ctx, panel_window_id(window.panel));
ctx.data_mut(|d| d.remove::<[f32; 4]>(spawn_id));
return;
}
@@ -560,7 +594,7 @@ impl EffectcraftApp {
let over_group = targets.iter().any(|target| target.rect.contains(pos));
let float_drop = ctx.input(|i| i.modifiers.command);
let plan = (!float_drop).then(|| resolve_drop(&p, pos, &targets)).flatten();
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("dock-drop")));
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("dock-drop")));
if let Some(proposal) = &plan {
let preview = proposal.preview.shrink(2.0);
painter.rect_filled(preview, t.radius, t.focus.gamma_multiply(0.18));
@@ -570,7 +604,7 @@ impl EffectcraftApp {
}
}
// Ghost of the dragged tab.
let g = painter.layout_no_wrap(p.title().to_string(), crate::theme::Tokens::ui(12.0), Color32::WHITE);
let g = painter.layout_no_wrap(crate::i18n::command_label("window.panel", p.title()).to_string(), crate::theme::Tokens::ui(12.0), Color32::WHITE);
let gr = Rect::from_min_size(pos + vec2(12.0, 10.0), g.size() + vec2(16.0, 8.0));
painter.rect_filled(gr, 4.0, t.focus.gamma_multiply(0.85));
painter.galley(gr.min + vec2(8.0, 4.0), g, Color32::WHITE);
@@ -610,7 +644,7 @@ pub fn panel_window_id(panel: PanelKind) -> egui::ViewportId {
/// Where a native panel window was told to open (kept for as long as it stays open).
fn panel_window_spawn_id(panel: PanelKind) -> egui::Id {
egui::Id::new(("panel-window-spawn", panel.id()))
egui::Id::unique(("panel-window-spawn", panel.id()))
}
/// A new floating group's default rect near the centre of `screen`.
+427 -51
View File
@@ -24,7 +24,7 @@
//! them (Info panel, eyedroppers, histograms).
use std::collections::{BTreeSet, HashMap, HashSet, VecDeque};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use effectcraft_engine::project::{Comp, ItemId, ItemKind, LayerSource, Node, Project, PropGroup};
@@ -152,6 +152,8 @@ struct Cache {
gpu_budget: usize,
/// The playing preview, if any: evictions spare the frames it shows next.
playhead: Option<Playhead>,
/// Scale of the frame on screen (the newest urgent request): frames of other scales go first.
scale: Option<u32>,
}
impl Cache {
@@ -173,8 +175,14 @@ impl Cache {
/// recently shown of the frames it doesn't reach, then the one it reaches last. Plain LRU
/// evicted the frames just ahead of the playhead first (rendered a while ago, not shown yet)
/// to keep the ones just shown, so a preview longer than the cache played into a gap that
/// prefetch kept refilling while it evicted the next frames (#595).
/// prefetch kept refilling while it evicted the next frames (#595). Frames of another scale
/// than the one on screen go first (a zoom or resolution change).
fn victim(&self) -> Option<usize> {
if let Some(s) = self.scale
&& let Some(i) = self.order.iter().position(|k| k.scale != s)
{
return Some(i);
}
let Some(p) = &self.playhead else { return (!self.order.is_empty()).then_some(0) };
let mut farthest: Option<(i64, usize)> = None;
for (i, k) in self.order.iter().enumerate() {
@@ -186,10 +194,13 @@ impl Cache {
}
farthest.map(|(_, i)| i)
}
/// Video memory: drop the oldest GPU frames past their own budget.
/// Video memory: drop the oldest GPU frames past their own budget, frames of another scale
/// than the one on screen first (a zoom or resolution change).
fn evict_gpu(&mut self) {
while self.gpu_bytes > self.gpu_budget {
let Some(i) = self.order.iter().position(|k| self.map.get(k).is_some_and(FrameImage::is_gpu)) else { break };
let is_gpu = |k: &FrameKey| self.map.get(k).is_some_and(FrameImage::is_gpu);
let other_scale = self.order.iter().position(|k| Some(k.scale) != self.scale && is_gpu(k));
let Some(i) = other_scale.or_else(|| self.order.iter().position(is_gpu)) else { break };
if let Some(old) = self.order.remove(i) {
self.remove(&old);
}
@@ -227,6 +238,19 @@ impl Cache {
}
self.order.retain(|k| !gone.contains(&k.content));
}
/// Move the frames of identity `from` that `keep` accepts to identity `to` (in place in the
/// eviction order), unless `to` already has them.
fn carry(&mut self, from: u64, to: u64, keep: impl Fn(i64) -> bool) {
for k in self.order.iter_mut().filter(|k| k.content == from && keep(k.frame)) {
let moved = FrameKey { content: to, ..*k };
if !self.map.contains_key(&moved)
&& let Some(img) = self.map.remove(k)
{
self.map.insert(moved, img);
*k = moved;
}
}
}
fn evict_to_budget(&mut self) {
while self.bytes > self.budget {
let Some(old) = self.victim().and_then(|i| self.order.remove(i)) else { break };
@@ -258,8 +282,10 @@ pub struct RenderSource {
/// ([`Frames::identity`]).
type Identity = (u64, Option<Arc<Project>>);
/// Content keys of (project revision, comp), computed once per revision.
type ContentKeys = Arc<Mutex<HashMap<(u64, u64), u128>>>;
/// Content keys of (project revision, comp, virtual-file generation). The generation is one
/// counter bumped when a browser file is replaced, so a lookup stays a map read. Hashing the
/// new bytes happens only when that generation misses.
type ContentKeys = Arc<Mutex<HashMap<(u64, u64, u64), u128>>>;
#[cfg(not(target_arch = "wasm32"))]
#[derive(Default)]
@@ -268,16 +294,16 @@ struct ContentKeyJobs {
pending: VecDeque<(Arc<Project>, u64, u64)>,
}
fn content_key(keys: &ContentKeys, project: &Project, revision: u64, comp: u64) -> u128 {
if let Some(k) = keys.lock().ok().and_then(|m| m.get(&(revision, comp)).copied()) {
fn content_key(keys: &ContentKeys, project: &Project, revision: u64, comp: u64, epoch: u64, virtual_id: Option<disk_cache::VirtualFileId<'_>>) -> u128 {
if let Some(k) = keys.lock().ok().and_then(|m| m.get(&(revision, comp, epoch)).copied()) {
return k;
}
let k = disk_cache::comp_content_key(project, ItemId(comp));
let k = disk_cache::comp_content_key_with(project, ItemId(comp), virtual_id);
if let Ok(mut m) = keys.lock() {
if m.len() > 256 {
m.clear();
}
m.insert((revision, comp), k);
m.insert((revision, comp, epoch), k);
}
k
}
@@ -288,7 +314,7 @@ fn content_key(keys: &ContentKeys, project: &Project, revision: u64, comp: u64)
/// hands out), and by value the project settings and the footage, solids and proxies it uses.
/// With expressions in its comps (which can read anything) every item counts.
pub fn comp_content(project: &Project, comp: ItemId) -> u64 {
comp_content_with(project, comp, |_, c| Arc::as_ptr(c))
comp_content_with(project, comp, project, |_, c| c)
}
/// Whether two versions of a comp draw the same pixels. Markers only count when an expression
@@ -308,8 +334,9 @@ fn project_has_expressions(project: &Project) -> bool {
project.items.values().any(|i| matches!(&i.kind, ItemKind::Comp(c) if c.layers.iter().any(|l| has_expression(&l.props))))
}
/// [`comp_content`] with each comp's address given by `addr`.
fn comp_content_with(project: &Project, comp: ItemId, addr: impl Fn(ItemId, &Arc<Comp>) -> *const Comp) -> u64 {
/// [`comp_content`] with each comp's version selected for both its sources and its address.
/// Sources removed by undo are read from `base`.
fn comp_content_with<'a>(project: &'a Project, comp: ItemId, base: &'a Project, version: impl Fn(ItemId, &'a Arc<Comp>) -> &'a Arc<Comp>) -> u64 {
use std::hash::{Hash, Hasher};
fn has_expression(g: &PropGroup) -> bool {
g.children.iter().any(|c| match c {
@@ -319,6 +346,7 @@ fn comp_content_with(project: &Project, comp: ItemId, addr: impl Fn(ItemId, &Arc
}
let mut h = std::collections::hash_map::DefaultHasher::new();
format!("{:?}", project.settings).hash(&mut h);
let item = |id| project.item(id).or_else(|| base.item(id));
let mut seen = BTreeSet::new();
let mut stack = vec![comp];
let mut expressions = false;
@@ -326,7 +354,8 @@ fn comp_content_with(project: &Project, comp: ItemId, addr: impl Fn(ItemId, &Arc
if !seen.insert(id) {
continue;
}
let Some(ItemKind::Comp(c)) = project.item(id).map(|i| &i.kind) else { continue };
let Some(ItemKind::Comp(c)) = item(id).map(|i| &i.kind) else { continue };
let c = version(id, c);
for l in &c.layers {
if let LayerSource::Comp { item } | LayerSource::Footage { item } | LayerSource::Solid { item } = l.source {
stack.push(item);
@@ -336,14 +365,14 @@ fn comp_content_with(project: &Project, comp: ItemId, addr: impl Fn(ItemId, &Arc
}
let ids: Vec<ItemId> = if expressions { project.items.keys().copied().collect() } else { seen.into_iter().collect() };
for id in ids {
let Some(it) = project.item(id) else { continue };
let Some(it) = item(id) else { continue };
id.0.hash(&mut h);
if expressions {
// (`comp("Name")`, `footage("Name")`)
it.name.hash(&mut h);
}
match &it.kind {
ItemKind::Comp(c) => (addr(id, c) as usize).hash(&mut h),
ItemKind::Comp(c) => (Arc::as_ptr(version(id, c)) as usize).hash(&mut h),
k => format!("{k:?}").hash(&mut h),
}
format!("{:?}", it.proxy).hash(&mut h);
@@ -363,8 +392,15 @@ pub fn opts_hash(o: &RenderOpts) -> u64 {
}
/// Disk key of a viewer frame (render options included: region of interest, draft…).
fn frame_disk_key(keys: &ContentKeys, project: &Project, key: &FrameKey, opts_hash: u64) -> u128 {
let content = content_key(keys, project, key.revision, key.comp);
fn frame_disk_key(
keys: &ContentKeys,
project: &Project,
key: &FrameKey,
opts_hash: u64,
epoch: u64,
virtual_id: Option<disk_cache::VirtualFileId<'_>>,
) -> u128 {
let content = content_key(keys, project, key.revision, key.comp, epoch, virtual_id);
disk_cache::frame_key(content, key.frame, key.scale, key.view ^ opts_hash)
}
@@ -441,6 +477,10 @@ pub struct Frames {
/// Render time of the last viewer (urgent) frame in ms, for the Info panel / perf readout.
pub last_ms: Arc<Mutex<f64>>,
content_keys: ContentKeys,
/// Bumped when a virtual file is replaced. Lookups key on it instead of statting footage.
virtual_epoch: Arc<AtomicU64>,
/// Size and content hash for paths with no native metadata. Called only on a cache miss.
virtual_id: Option<Arc<dyn Fn(&str) -> Option<(u64, u64)> + Send + Sync>>,
#[cfg(not(target_arch = "wasm32"))]
content_key_jobs: Arc<Mutex<ContentKeyJobs>>,
gpu_retired: Arc<AtomicBool>,
@@ -462,7 +502,7 @@ impl Default for Frames {
#[cfg(not(target_arch = "wasm32"))]
let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(2, 16);
Frames {
cache: Arc::new(Mutex::new(Cache { map: HashMap::new(), order: VecDeque::new(), bytes: 0, gpu_bytes: 0, budget: 3 << 30, gpu_budget: GPU_BUDGET, playhead: None })),
cache: Arc::new(Mutex::new(Cache { map: HashMap::new(), order: VecDeque::new(), bytes: 0, gpu_bytes: 0, budget: 3 << 30, gpu_budget: GPU_BUDGET, playhead: None, scale: None })),
inflight: Arc::new(Mutex::new(HashSet::new())),
queue: Arc::new(Mutex::new(Queue::default())),
#[cfg(not(target_arch = "wasm32"))]
@@ -477,6 +517,8 @@ impl Default for Frames {
ctx: None,
last_ms: Arc::new(Mutex::new(0.0)),
content_keys: Arc::default(),
virtual_epoch: Arc::new(AtomicU64::new(0)),
virtual_id: None,
#[cfg(not(target_arch = "wasm32"))]
content_key_jobs: Arc::default(),
gpu_retired: Arc::default(),
@@ -724,13 +766,28 @@ impl Frames {
let markers_count = project_has_expressions(project);
if let Some((base_id, base)) = bases.get(&comp.0)
&& *base_id != plain
&& comp_content_with(project, comp, |id, c| match base.item(id).map(|i| &i.kind) {
Some(ItemKind::Comp(b)) if Arc::ptr_eq(b, c) || same_pixels(b, c, markers_count) => Arc::as_ptr(b),
_ => Arc::as_ptr(c),
&& comp_content_with(project, comp, project, |id, c| match base.item(id).map(|i| &i.kind) {
Some(ItemKind::Comp(b)) if Arc::ptr_eq(b, c) || same_pixels(b, c, markers_count) => b,
_ => c,
}) == *base_id
{
return (*base_id, Some(base.clone()));
}
// Only layers of this comp changed: its frames outside their span (and a frame either
// side for rounding) keep drawing the same pixels, so a cut or trim recaches only the
// layers it touched (#1641).
if let Some((base_id, base)) = bases.get(&comp.0)
&& let (Some(old), Some(new)) = (base.comp(comp), project.comp(comp))
&& comp_content_with(project, comp, base, |id, c| match base.item(id).map(|i| &i.kind) {
Some(ItemKind::Comp(b)) if id == comp || Arc::ptr_eq(b, c) || same_pixels(b, c, markers_count) => b,
_ => c,
}) == *base_id
&& let Some((s, e)) = new.changed_span(old)
&& let Ok(mut cache) = self.cache.lock()
{
let (first, last) = (new.frame_rate.frame_at(s).saturating_sub(1), new.frame_rate.frame_at(e).saturating_add(1));
cache.carry(*base_id, plain, |f| f < first || f > last);
}
if bases.len() > MAX_KEEPERS {
bases.clear();
}
@@ -758,7 +815,12 @@ impl Frames {
/// Frames of the series of `base` in the disk cache but not in RAM — the blue cache bar.
/// `opts` are the viewer's render options (part of the disk key). Native queries return
/// no blue frames until the content key is ready; hashing never blocks timeline painting.
pub fn disk_frames(&self, src: &RenderSource, base: &FrameKey, frames: i64, opts: &RenderOpts) -> Vec<i64> {
pub fn disk_frames(&self, src: &RenderSource, base: &FrameKey, range: std::ops::Range<i64>, opts: &RenderOpts) -> Vec<i64> {
let start = range.start.clamp(0, 100_000);
let end = range.end.clamp(0, 100_000);
if start >= end {
return vec![];
}
let remote = self.remote.as_ref().filter(|r| r.disk());
let contains = |k: u128| match (&src.disk, remote) {
(Some(dc), _) => dc.contains(disk_cache::Kind::Frame, k),
@@ -768,9 +830,10 @@ impl Frames {
if src.disk.is_none() && remote.is_none() {
return vec![];
}
let epoch = self.virtual_epoch.load(Ordering::Relaxed);
#[cfg(not(target_arch = "wasm32"))]
let content = {
let known = self.content_keys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&(base.revision, base.comp)).copied();
let known = self.content_keys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&(base.revision, base.comp, epoch)).copied();
let Some(content) = known else {
self.request_content_key(src.project.clone(), base.revision, base.comp);
return vec![];
@@ -778,13 +841,10 @@ impl Frames {
content
};
#[cfg(target_arch = "wasm32")]
let content = content_key(&self.content_keys, &src.project, base.revision, base.comp);
let content = content_key(&self.content_keys, &src.project, base.revision, base.comp, epoch, self.virtual_id.as_deref());
let ram: HashSet<i64> = self.cached_frames(base).into_iter().collect();
let oh = opts_hash(opts);
(0..frames.clamp(0, 100_000))
.filter(|f| !ram.contains(f))
.filter(|f| contains(disk_cache::frame_key(content, *f, base.scale, base.view ^ oh)))
.collect()
(start..end).filter(|f| !ram.contains(f)).filter(|f| contains(disk_cache::frame_key(content, *f, base.scale, base.view ^ oh))).collect()
}
#[cfg(not(target_arch = "wasm32"))]
@@ -803,6 +863,7 @@ impl Frames {
jobs.running = true;
drop(jobs);
let (jobs, keys, ctx) = (self.content_key_jobs.clone(), self.content_keys.clone(), self.ctx.clone());
let (epoch, id) = (self.virtual_epoch.clone(), self.virtual_id.clone());
let work = move || loop {
let next = {
let mut jobs = jobs.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
@@ -813,7 +874,9 @@ impl Frames {
next
};
let Some((project, revision, comp)) = next else { return };
if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| content_key(&keys, &project, revision, comp))).is_err() {
let epoch = epoch.load(Ordering::Relaxed);
let id = id.clone();
if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| content_key(&keys, &project, revision, comp, epoch, id.as_deref()))).is_err() {
log::error!("could not compute a preview disk-cache key; the cache indicator will retry");
}
if let Some(ctx) = &ctx {
@@ -893,7 +956,19 @@ impl Frames {
}
fn request_with(&self, src: &RenderSource, key: FrameKey, comp: ItemId, t: Tick, opts: RenderOpts, urgent: bool) {
if self.is_cached(&key) {
// The frame on screen sets the scale evictions keep: a prefetch or a passive viewer at
// another zoom must not evict the active viewer's frames.
let cached = self
.cache
.lock()
.map(|mut c| {
if urgent {
c.scale = Some(key.scale);
}
c.map.contains_key(&key)
})
.unwrap_or(false);
if cached {
return;
}
let Ok(mut q) = self.queue.lock() else { return };
@@ -964,10 +1039,22 @@ impl Frames {
ctx: self.ctx.clone(),
last: self.last_ms.clone(),
content_keys: self.content_keys.clone(),
virtual_epoch: self.virtual_epoch.clone(),
virtual_id: self.virtual_id.clone(),
gpu_retired: self.gpu_retired.clone(),
}
}
/// Browser file table: `epoch` bumps on each replacement, and `id` is the size and content
/// hash used when a content key is rebuilt. Neither runs on a cache hit.
pub fn set_virtual_media(&mut self, epoch: Arc<AtomicU64>, id: Arc<dyn Fn(&str) -> Option<(u64, u64)> + Send + Sync>) {
self.virtual_epoch = epoch;
self.virtual_id = Some(id);
if let Ok(mut keys) = self.content_keys.lock() {
keys.clear();
}
}
/// Render frames with `remote` from now on (`None`: on this thread again).
pub fn set_remote(&mut self, remote: Option<Arc<dyn RemoteFrames>>) {
self.remote = remote;
@@ -1036,7 +1123,8 @@ impl Frames {
let fw = self.worker();
let busy = self.remote_busy.clone();
let t0 = web_time::Instant::now();
let disk_key = remote.disk().then(|| frame_disk_key(&w.content_keys, &job.src.project, &key, opts_hash(&job.opts)));
let epoch = w.virtual_epoch.load(Ordering::Relaxed);
let disk_key = remote.disk().then(|| frame_disk_key(&w.content_keys, &job.src.project, &key, opts_hash(&job.opts), epoch, w.virtual_id.as_deref()));
let rj = RemoteJob { project: job.src.project.clone(), revision: key.revision, comp: job.comp, t: job.t, opts: job.opts, disk_key };
remote.start(
rj,
@@ -1093,6 +1181,8 @@ struct Worker {
ctx: Option<egui::Context>,
last: Arc<Mutex<f64>>,
content_keys: ContentKeys,
virtual_epoch: Arc<AtomicU64>,
virtual_id: Option<Arc<dyn Fn(&str) -> Option<(u64, u64)> + Send + Sync>>,
gpu_retired: Arc<AtomicBool>,
}
@@ -1147,7 +1237,12 @@ impl Worker {
job
};
let t0 = web_time::Instant::now();
let disk = job.src.disk.as_ref().map(|dc| (dc.clone(), frame_disk_key(content_keys, &job.src.project, &job.key, opts_hash(&job.opts))));
let epoch = self.virtual_epoch.load(Ordering::Relaxed);
let disk = job
.src
.disk
.as_ref()
.map(|dc| (dc.clone(), frame_disk_key(content_keys, &job.src.project, &job.key, opts_hash(&job.opts), epoch, self.virtual_id.as_deref())));
let from_disk = disk.as_ref().and_then(|(dc, k)| dc.get_frame(*k));
let disk_hit = from_disk.is_some();
let ci = match from_disk {
@@ -1375,6 +1470,54 @@ impl Queue {
mod tests {
use super::*;
struct ProbeDisk {
keys: Mutex<HashSet<u128>>,
probes: std::sync::atomic::AtomicUsize,
}
impl RemoteFrames for ProbeDisk {
fn slots(&self) -> usize {
0
}
fn disk(&self) -> bool {
true
}
fn disk_contains(&self, key: u128) -> bool {
self.probes.fetch_add(1, Ordering::Relaxed);
self.keys.lock().unwrap().contains(&key)
}
fn start(&self, _: RemoteJob, _: RemoteDone) {}
}
fn disk_fixture(stored_frames: &[i64]) -> (Frames, RenderSource, FrameKey, RenderOpts, Arc<ProbeDisk>, u128) {
let mut session = effectcraft_engine::Session::default();
session.execute("comp.new", serde_json::json!({"width": 32, "height": 18, "duration": 1})).unwrap();
let comp = session.active_comp_id().unwrap();
let opts = RenderOpts::default();
let content = 1234;
let mut base = key(1, 0, opts_hash(&opts));
base.revision = 7;
base.comp = comp.0;
let disk = Arc::new(ProbeDisk { keys: Mutex::new(HashSet::new()), probes: 0.into() });
disk.keys.lock().unwrap().extend(stored_frames.iter().map(|frame| disk_cache::frame_key(content, *frame, base.scale, base.view ^ opts_hash(&opts))));
let mut frames = Frames::default();
frames.set_remote(Some(disk.clone()));
frames.content_keys.lock().unwrap().insert((base.revision, base.comp, 0), content);
let source = RenderSource {
project: session.project.clone(),
footage: session.footage.clone(),
expr: None,
layer_cache: Arc::default(),
gpu: None,
gpu_display: false,
disk: None,
};
(frames, source, base, opts, disk, content)
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn disk_indicator_does_not_hash_on_ui_thread_and_coalesces_edits() {
@@ -1422,20 +1565,65 @@ mod tests {
base.comp = cid.0;
for revision in 1..50 {
base.revision = revision;
assert!(frames.disk_frames(&src, &base, 30, &opts).is_empty());
assert!(frames.disk_frames(&src, &base, 0..30, &opts).is_empty());
}
assert!(frames.content_keys.lock().unwrap().is_empty(), "painting computed a content hash while the worker was blocked");
assert_eq!(frames.content_key_jobs.lock().unwrap().pending.len(), 1);
release.send(()).unwrap();
let until = Instant::now() + Duration::from_secs(3);
while !frames.content_keys.lock().unwrap().contains_key(&(base.revision, base.comp)) {
while !frames.content_keys.lock().unwrap().contains_key(&(base.revision, base.comp, 0)) {
assert!(Instant::now() < until);
std::thread::sleep(Duration::from_millis(1));
}
assert_eq!(frames.disk_frames(&src, &base, 30, &opts), vec![2], "the blue indicator must return once its key is ready");
assert_eq!(frames.disk_frames(&src, &base, 0..30, &opts), vec![2], "the blue indicator must return once its key is ready");
assert_eq!(frames.content_keys.lock().unwrap().len(), 1, "superseded revisions were hashed");
}
#[test]
fn disk_indicator_range_tracks_live_membership() {
let (frames, src, base, opts, disk, _) = disk_fixture(&[10, 12]);
assert_eq!(frames.disk_frames(&src, &base, 10..13, &opts), vec![10, 12]);
assert_eq!(disk.probes.load(Ordering::Relaxed), 3);
disk.keys.lock().unwrap().insert(disk_cache::frame_key(1234, 11, base.scale, base.view ^ opts_hash(&opts)));
assert_eq!(frames.disk_frames(&src, &base, 10..13, &opts), vec![10, 11, 12]);
insert(&frames, FrameKey { frame: 11, ..base });
disk.probes.store(0, Ordering::Relaxed);
assert_eq!(frames.disk_frames(&src, &base, 10..13, &opts), vec![10, 12]);
assert_eq!(disk.probes.load(Ordering::Relaxed), 2);
disk.keys.lock().unwrap().remove(&disk_cache::frame_key(1234, 12, base.scale, base.view ^ opts_hash(&opts)));
assert_eq!(frames.disk_frames(&src, &base, 10..13, &opts), vec![10]);
let mut revised = base;
revised.revision += 1;
frames.content_keys.lock().unwrap().insert((revised.revision, revised.comp, 0), 5678);
assert!(frames.disk_frames(&src, &revised, 10..13, &opts).is_empty());
let changed = FrameKey { scale: base.scale + 1, ..base };
assert!(frames.disk_frames(&src, &changed, 10..13, &opts).is_empty());
let changed = FrameKey { view: base.view + 1, ..base };
assert!(frames.disk_frames(&src, &changed, 10..13, &opts).is_empty());
assert!(frames.disk_frames(&src, &base, 10..13, &RenderOpts { draft: true, ..opts }).is_empty());
}
#[test]
fn disk_indicator_range_is_bounded() {
let (frames, src, base, opts, disk, _) = disk_fixture(&[0, 1, 99_999]);
assert_eq!(frames.disk_frames(&src, &base, -5..2, &opts), vec![0, 1]);
assert_eq!(disk.probes.load(Ordering::Relaxed), 2);
disk.probes.store(0, Ordering::Relaxed);
assert_eq!(frames.disk_frames(&src, &base, 99_999..i64::MAX, &opts), vec![99_999]);
assert_eq!(disk.probes.load(Ordering::Relaxed), 1);
disk.probes.store(0, Ordering::Relaxed);
assert!(frames.disk_frames(&src, &base, 2..2, &opts).is_empty());
// Written as a struct literal: `3..2` trips clippy::reversed_empty_ranges.
let reversed = std::ops::Range { start: 3, end: 2 };
assert!(frames.disk_frames(&src, &base, reversed, &opts).is_empty());
assert_eq!(disk.probes.load(Ordering::Relaxed), 0);
}
/// Frames larger than the texture limit are averaged down by a whole factor (#201: an
/// 11000×2200 comp at Full resolution against an 8192 limit).
#[test]
@@ -1795,21 +1983,17 @@ mod tests {
assert!(!f.is_cached(&key(1, 13, 0)) && f.is_cached(&key(1, 50, 0)));
}
/// #595: prefetch renders the frames the preview reaches first, whatever order they were
/// asked for in (a frame evicted and asked for again waited behind every frame further
/// ahead); queued frames the preview no longer reaches are dropped and not left in flight.
#[test]
fn prefetch_renders_the_frames_the_playhead_reaches_first() {
struct Idle;
impl RemoteFrames for Idle {
fn slots(&self) -> usize {
0
}
fn start(&self, _: RemoteJob, _: RemoteDone) {}
/// A frame source that queues jobs and never renders them: the tests check the queue.
struct Idle;
impl RemoteFrames for Idle {
fn slots(&self) -> usize {
0
}
let mut f = Frames::default();
f.set_remote(Some(Arc::new(Idle)));
let src = RenderSource {
fn start(&self, _: RemoteJob, _: RemoteDone) {}
}
fn idle_source() -> RenderSource {
RenderSource {
project: Arc::default(),
footage: Arc::new(effectcraft_engine::render::NoFootage),
expr: None,
@@ -1817,7 +2001,17 @@ mod tests {
gpu: None,
gpu_display: false,
disk: None,
};
}
}
/// #595: prefetch renders the frames the preview reaches first, whatever order they were
/// asked for in (a frame evicted and asked for again waited behind every frame further
/// ahead); queued frames the preview no longer reaches are dropped and not left in flight.
#[test]
fn prefetch_renders_the_frames_the_playhead_reaches_first() {
let mut f = Frames::default();
f.set_remote(Some(Arc::new(Idle)));
let src = idle_source();
for frame in [40, 30, 20, 5, 12, 11] {
f.request(&src, key(1, frame, 0), ItemId(1), Tick::ZERO, RenderOpts::default());
}
@@ -1834,6 +2028,38 @@ mod tests {
assert_eq!(f.inflight(), 6, "frame 5, behind the playhead, is no longer in flight");
}
/// The cache is full. A zoom (or preview-resolution) change left the frames of the old
/// size to the plain least-recently-shown order, so the frames of the zoom the viewer is back
/// at went before the ones of the abandoned zoom. The other size's frames go first, then the
/// order is as before.
#[test]
fn a_size_change_evicts_frames_of_other_sizes_first() {
let mut f = Frames::default();
f.set_remote(Some(Arc::new(Idle)));
f.set_budget(4 * 16);
let at = |frame, scale| FrameKey { scale, ..key(1, frame, 0) };
// 100% (frames 0..=1) is cached, then 50% (2..=3): the 100% frames are the oldest.
for (frame, scale) in [(0, 1000), (1, 1000), (2, 500), (3, 500)] {
insert(&f, at(frame, scale));
}
// A passive viewer at 50% asks for a frame: that doesn't change which size is kept.
f.request(&idle_source(), at(9, 500), ItemId(1), Tick::ZERO, RenderOpts::default());
// Back at 100%: the viewer asks for frame 4.
f.request_urgent(&idle_source(), at(4, 1000), ItemId(1), Tick::ZERO, RenderOpts::default());
f.request(&idle_source(), at(10, 500), ItemId(1), Tick::ZERO, RenderOpts::default());
insert(&f, at(4, 1000));
assert!(
f.is_cached(&at(0, 1000)) && f.is_cached(&at(1, 1000)) && !f.is_cached(&at(2, 500)) && f.is_cached(&at(3, 500)),
"the first frame of the old size goes, not the cached ones of the current size"
);
insert(&f, at(5, 1000));
assert!(!f.is_cached(&at(3, 500)), "then the last frame of the old size");
assert_eq!(f.cached_frames(&at(0, 1000)), [0, 1, 4, 5]);
// No other size left: least recently shown first again.
insert(&f, at(6, 1000));
assert_eq!(f.cached_frames(&at(0, 1000)), [1, 4, 5, 6]);
}
#[test]
fn frames_are_keyed_by_content_and_series_are_kept_apart() {
let f = Frames::default();
@@ -1990,6 +2216,156 @@ mod tests {
assert_ne!(at(&s), o1);
}
/// A cut or trim recaches only the frames of the layers it touched (#1641), and none are
/// kept when the change reaches other layers (here a child of the trimmed layer).
#[test]
fn an_edit_keeps_the_frames_outside_the_layers_it_changed() {
use effectcraft_engine::Session;
use serde_json::json;
let mut s = Session::default();
let id = |v: serde_json::Value, k: &str| v[k].as_u64().unwrap();
let comp = ItemId(id(s.execute("comp.new", json!({"name": "Edit", "width": 8, "height": 8, "duration": 10})).unwrap(), "comp"));
let a = id(s.execute("layer.newSolid", json!({"comp": comp.0, "color": "#ff0000"})).unwrap(), "layer");
let b = id(s.execute("layer.newSolid", json!({"comp": comp.0, "color": "#00ff00"})).unwrap(), "layer");
s.execute("layer.timing", json!({"layers": [a], "in": 0, "out": 4})).unwrap();
s.execute("layer.timing", json!({"layers": [b], "in": 4, "out": 10})).unwrap();
let (fr, out) = s.project.comp(comp).map(|c| (c.frame_rate, c.layers.iter().find(|l| l.id.0 == a).unwrap().out_point)).unwrap();
let frames: Vec<i64> = (0..fr.frame_at(Tick::from_seconds_f64(10.0))).collect();
let f = Frames::default();
let cache_all = |s: &Session| {
let c = f.content_of(&s.project, s.revision, comp);
frames.iter().for_each(|&n| insert(&f, key(c, n, 0)));
c
};
let cached = |c: u64| frames.iter().filter(|&&n| f.is_cached(&key(c, n, 0))).copied().collect::<Vec<_>>();
let before = cache_all(&s);
s.execute("time.set", json!({"time": 2.0})).unwrap();
s.execute("edit.splitLayer", json!({"layers": [a]})).unwrap();
let after = f.content_of(&s.project, s.revision, comp);
assert_ne!(after, before);
let kept: Vec<i64> = (fr.frame_at(out) + 2..frames.len() as i64).collect();
assert_eq!(cached(after), kept, "the cut layer's frames (and one either side) render again; the rest stay");
s.execute("layer.setParent", json!({"layers": [b], "parent": a})).unwrap();
let parented = cache_all(&s);
s.execute("layer.timing", json!({"layers": [a], "out": 1})).unwrap();
assert_eq!(cached(f.content_of(&s.project, s.revision, comp)), Vec::<i64>::new(), "the trimmed layer's child moves everywhere");
assert_eq!(cached(parented).len(), frames.len(), "the earlier frames stay for undo");
}
/// Every frame an edit keeps renders as a fresh render of the edited project does: Solo
/// hides the other layers at every frame, and a camera reaches the frames whose shutter
/// samples it (#1641 review).
#[test]
fn the_frames_an_edit_keeps_match_a_fresh_render() {
use effectcraft_engine::Session;
use effectcraft_engine::render::NoFootage;
use serde_json::json;
// Cache every frame of `comp`, run `edit`, check the frames kept against fresh renders.
fn kept(s: &mut Session, comp: ItemId, edit: serde_json::Value) -> Vec<i64> {
let (fr, n) = s.project.comp(comp).map(|c| (c.frame_rate, c.frame_rate.frame_at(c.duration))).unwrap();
let f = Frames::default();
let before = f.content_of(&s.project, s.revision, comp);
(0..n).for_each(|i| insert(&f, key(before, i, 0)));
let old = s.project.clone();
s.execute(edit["cmd"].as_str().unwrap(), edit["params"].clone()).unwrap();
let after = f.content_of(&s.project, s.revision, comp);
let kept: Vec<i64> = (0..n).filter(|&i| f.is_cached(&key(after, i, 0))).collect();
let render = |p, i| Renderer::new(p, &NoFootage, RenderOpts::default()).comp_frame(comp, fr.tick_of(i));
for &i in &kept {
assert_eq!(render(&old, i), render(&s.project, i), "kept frame {i} draws other pixels now");
}
kept
}
let id = |v: serde_json::Value, k: &str| v[k].as_u64().unwrap();
let mut s = Session::default();
// Solo on a 2–3 s layer hides the 10 s underlay everywhere; turning it off shows it again.
let comp = ItemId(id(s.execute("comp.new", json!({"name": "Solo", "width": 8, "height": 8, "duration": 10})).unwrap(), "comp"));
s.execute("layer.newSolid", json!({"comp": comp.0, "color": "#ff0000"})).unwrap();
let short = id(s.execute("layer.newSolid", json!({"comp": comp.0, "color": "#00ff00"})).unwrap(), "layer");
s.execute("layer.timing", json!({"layers": [short], "in": 2, "out": 3})).unwrap();
let solo = |on: bool| json!({"cmd": "layer.setSwitch", "params": {"layers": [short], "switch": "solo", "value": on}});
assert_eq!(kept(&mut s, comp, solo(true)), Vec::<i64>::new());
assert_eq!(kept(&mut s, comp, solo(false)), Vec::<i64>::new());
// A 2–3 s camera over a 4 s motion-blurred 3D layer, shutter 720° at phase 360°: a frame
// samples 1 to 3 frames ahead, so moving the camera reaches the 3 frames before it.
let comp = ItemId(id(
s.execute("comp.new", json!({"name": "Cam", "width": 16, "height": 16, "duration": 4, "frameRate": 30, "shutterAngle": 720, "shutterPhase": 360}))
.unwrap(),
"comp",
));
let plane = id(s.execute("layer.newSolid", json!({"comp": comp.0, "color": "#ff0000"})).unwrap(), "layer");
for sw in ["threeD", "motionBlur"] {
s.execute("layer.setSwitch", json!({"layers": [plane], "switch": sw, "value": true})).unwrap();
}
let cam = id(s.execute("layer.newCamera", json!({"comp": comp.0})).unwrap(), "layer");
s.execute("layer.timing", json!({"layers": [cam], "in": 2, "out": 3})).unwrap();
let moved =
kept(&mut s, comp, json!({"cmd": "prop.set", "params": {"comp": comp.0, "layer": cam, "path": "transform/position", "value": [40, 8, -30]}}));
assert!(moved.contains(&0) && moved.contains(&100), "frames far from the camera stay: {moved:?}");
assert!(!(56..=91).any(|f| moved.contains(&f)), "frames whose shutter samples the camera render again: {moved:?}");
}
#[test]
fn short_solid_additions_and_removals_keep_unaffected_frames() {
use effectcraft_engine::Session;
use serde_json::json;
let mut s = Session::default();
let comp = ItemId(s.execute("comp.new", json!({"width": 8, "height": 8, "frameRate": 30, "duration": 20})).unwrap()["comp"].as_u64().unwrap());
s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap();
let f = Frames::default();
let cache_all = |s: &Session| {
let content = f.content_of(&s.project, s.revision, comp);
(0..600).for_each(|n| insert(&f, key(content, n, 0)));
};
let cached = |s: &Session| f.cached_frames(&key(f.content_of(&s.project, s.revision, comp), 0, 0));
// 2–3 s, a quarter frame of the default shutter and a frame of rounding either side.
let kept: Vec<i64> = (0..=57).chain(92..600).collect();
cache_all(&s);
let solid = s.execute("layer.newSolid", json!({"color": "#00ff00"})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.timing", json!({"layers": [solid], "in": 2, "out": 3})).unwrap();
assert_eq!(cached(&s), kept, "adding a source invalidates only frames 58..=91");
cache_all(&s);
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(cached(&s), (0..600).collect::<Vec<_>>(), "undoing the addition restores every frame");
s.execute("edit.redo", json!({})).unwrap();
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(cached(&s), (0..600).collect::<Vec<_>>(), "redoing the addition restores every frame");
s.execute("edit.clear", json!({"layers": [solid]})).unwrap();
assert_eq!(cached(&s), kept, "removing its layer invalidates the same span");
}
#[test]
fn adding_a_source_does_not_hide_changes_to_existing_dependencies() {
use effectcraft_engine::Session;
use serde_json::json;
for (command, params) in [
("layer.settings", json!({"comp": "Outer", "layer": "Underlay", "color": "#0000ff"})),
("prop.set", json!({"comp": "Inner", "layer": "Inner solid", "path": "transform/opacity", "value": 40})),
] {
let mut s = Session::default();
let comp = ItemId(
s.execute("comp.new", json!({"name": "Outer", "width": 8, "height": 8, "frameRate": 30, "duration": 20})).unwrap()["comp"].as_u64().unwrap(),
);
s.execute("layer.newSolid", json!({"name": "Underlay", "color": "#ff0000"})).unwrap();
s.execute("comp.new", json!({"name": "Inner", "width": 8, "height": 8, "duration": 20})).unwrap();
s.execute("layer.newSolid", json!({"name": "Inner solid", "color": "#00ff00"})).unwrap();
s.execute("comp.open", json!({"comp": "Outer"})).unwrap();
s.execute("layer.addItem", json!({"item": "Inner"})).unwrap();
let f = Frames::default();
let before = f.content_of(&s.project, s.revision, comp);
(0..600).for_each(|n| insert(&f, key(before, n, 0)));
s.execute(command, params).unwrap();
let solid = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.timing", json!({"layers": [solid], "in": 2, "out": 3})).unwrap();
let after = f.content_of(&s.project, s.revision, comp);
assert!(f.cached_frames(&key(after, 0, 0)).is_empty(), "{command} changes pixels outside the added layer");
}
}
#[test]
fn markers_keep_the_frames() {
use effectcraft_engine::Session;
+45 -30
View File
@@ -26,7 +26,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// Brand mark.
let brand = Rect::from_min_size(pos2(x, cy - 12.0), vec2(24.0, 24.0));
paint_logo(&p, brand);
let bresp = ui.interact(brand, egui::Id::new("brand"), Sense::click());
let bresp = ui.interact(brand, egui::Id::unique("brand"), Sense::click());
app.auto.add_response("header.about", &bresp, "About EffectCraft");
widgets::focus_outline(ui, &bresp, &t);
if bresp.on_hover_text(tr("About EffectCraft")).clicked() {
@@ -36,7 +36,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// Home.
let home = Rect::from_min_size(pos2(x, cy - 13.0), vec2(26.0, 26.0));
let home_response = widgets::icon_button(ui, home, Icon::Home, app.ui.start_screen, &t, egui::Id::new("tool-home")).on_hover_text(tr("Home"));
let home_response = widgets::icon_button(ui, home, Icon::Home, app.ui.start_screen, &t, egui::Id::unique("tool-home")).on_hover_text(tr("Home"));
app.auto.add_response("header.home", &home_response, "Home");
if home_response.clicked() {
app.ui.start_screen = !app.ui.start_screen;
@@ -46,7 +46,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
x += 6.0;
// Tool slots (with group separators after camera tools and pan-behind).
let held_id = egui::Id::new("tool-slot-held");
let held_id = egui::Id::unique("tool-slot-held");
if ui.input(|i| i.pointer.any_pressed()) {
ui.data_mut(|d| d.remove::<egui::Id>(held_id));
}
@@ -54,7 +54,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let cur = app.ui.slot_tools.get(si).copied().unwrap_or(slot[0]);
let r = Rect::from_min_size(pos2(x, cy - 13.0), vec2(26.0, 26.0));
let active = slot.contains(&app.ui.tool);
let id = egui::Id::new(("tool-slot", si));
let id = egui::Id::unique(("tool-slot", si));
let resp = widgets::icon_button(ui, r, cur.icon(), active, &t, id);
if slot.len() > 1 {
// Flyout corner triangle.
@@ -174,7 +174,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let snap = Rect::from_min_size(pos2(x, cy - 10.0), vec2(20.0, 20.0));
// The engine owns snapping (View ▸ Snapping); the checkbox mirrors it.
app.ui.snapping = app.session.state.snapping;
if widgets::checkbox(ui, snap, app.ui.snapping, &t, egui::Id::new("snapping")).clicked() {
if widgets::checkbox(ui, snap, app.ui.snapping, &t, egui::Id::unique("snapping")).clicked() {
let _ = app.session.execute("view.snapping", serde_json::json!({}));
app.ui.snapping = app.session.state.snapping;
}
@@ -188,7 +188,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// text at its left, which left a wide gap on the right (#581).
let label = p.layout_no_wrap(tr("Discord").to_string(), Tokens::semibold(12.0), Color32::WHITE);
let discord = Rect::from_min_max(pos2(rx - (10.0 + 14.0 + 6.0 + label.size().x + 12.0), cy - 13.0), pos2(rx, cy + 13.0));
let dresp = ui.interact(discord, egui::Id::new("hdr-discord"), Sense::click());
let dresp = ui.interact(discord, egui::Id::unique("hdr-discord"), Sense::click());
let dc = Color32::from_rgb(0x58, 0x65, 0xf2);
p.rect_filled(discord, 13.0, if dresp.hovered() { dc.gamma_multiply(1.2) } else { dc });
icons::paint(&p, Rect::from_center_size(pos2(discord.min.x + 17.0, cy), vec2(14.0, 14.0)), Icon::Chat, Color32::WHITE);
@@ -202,7 +202,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
[("hdr-github", Icon::Code, "EffectCraft on GitHub", "help.github"), ("hdr-web", Icon::Globe, "EffectCraft on getartcraft.com", "help.appPage")]
{
let r = Rect::from_min_max(pos2(rx - 26.0, cy - 13.0), pos2(rx, cy + 13.0));
if widgets::icon_button(ui, r, icon, false, &t, egui::Id::new(id)).on_hover_text(tip).clicked() {
if widgets::icon_button(ui, r, icon, false, &t, egui::Id::unique(id)).on_hover_text(tip).clicked() {
let _ = app.session.execute(cmd, json!({}));
}
app.auto.add(&format!("header.{}", &id[4..]), r, tip);
@@ -216,7 +216,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
};
let tip = tr_args("Appearance Mode: {}", &[&mode]);
let r = Rect::from_min_max(pos2(rx - 26.0, cy - 13.0), pos2(rx, cy + 13.0));
let response = widgets::icon_button(ui, r, icon, false, &t, egui::Id::new("hdr-appearance")).on_hover_text(&tip);
let response = widgets::icon_button(ui, r, icon, false, &t, egui::Id::unique("hdr-appearance")).on_hover_text(&tip);
if response.clicked() {
app.cycle_appearance(ui.ctx());
}
@@ -252,17 +252,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
rx -= 10.0;
// Workspace tabs (right to left), with a » menu of all.
let more = Rect::from_min_max(pos2(rx - 20.0, cy - 11.0), pos2(rx, cy + 11.0));
let mresp = ui.interact(more, egui::Id::new("ws-more"), Sense::click());
let mresp = ui.interact(more, egui::Id::unique("ws-more"), Sense::click());
p.text(more.center(), Align2::CENTER_CENTER, "»", Tokens::ui(15.0), if mresp.hovered() { t.text } else { t.text_dim });
app.auto.add("header.workspaces", more, "Workspaces");
crate::widgets::context_menu(&mresp, |ui| ws_menu(app, ui));
if mresp.clicked() {
widgets::open_popup(ui, egui::Id::new("ws-pop"));
widgets::open_popup(ui, egui::Id::unique("ws-pop"));
}
let names: Vec<String> = crate::dock::WORKSPACES.iter().map(|s| s.to_string()).collect();
let names: Vec<String> = crate::dock::WORKSPACES.iter().map(|s| crate::i18n::command_label("window.workspace", s).to_string()).collect();
if let Some(i) = widgets::popup_menu(
ui,
egui::Id::new("ws-pop"),
egui::Id::unique("ws-pop"),
more.left_bottom() - vec2(140.0, 0.0),
&names,
crate::dock::WORKSPACES.iter().position(|w| *w == app.ui.workspace),
@@ -274,14 +274,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// Small Screen, Standard.
let shown = ["Standard", "Small Screen", "Learn", "Review", "Default"];
for name in shown {
let g = p.layout_no_wrap(name.to_string(), Tokens::ui(12.0), t.text);
let g = p.layout_no_wrap(crate::i18n::command_label("window.workspace", name).to_string(), Tokens::ui(12.0), t.text);
let w = g.size().x + 16.0;
let r = Rect::from_min_max(pos2(rx - w, cy - 13.0), pos2(rx, cy + 13.0));
if r.min.x < x + 120.0 {
break;
}
let active = app.ui.workspace == name;
let resp = ui.interact(r, egui::Id::new(("ws", name)), Sense::click());
let resp = ui.interact(r, egui::Id::unique(("ws", name)), Sense::click());
let col = if active {
t.hot_text
} else if resp.hovered() {
@@ -305,8 +305,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
/// Features in Collapsed Compositions and Text Layers (`view.snappingOptions`).
fn snapping_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect) {
let t = app.tokens;
let pop = egui::Id::new("snap-options");
if widgets::icon_button(ui, r, Icon::ChevronDown, false, &t, egui::Id::new("snap-options-button")).on_hover_text(tr("Snapping options")).clicked() {
let pop = egui::Id::unique("snap-options");
if widgets::icon_button(ui, r, Icon::ChevronDown, false, &t, egui::Id::unique("snap-options-button")).on_hover_text(tr("Snapping options")).clicked() {
widgets::open_popup(ui, pop);
}
app.auto.add("header.snappingOptions", r, "Snapping options");
@@ -349,7 +349,7 @@ fn gradient_colors(app: &EffectcraftApp, layers: &[u64], paint: &str) -> Option<
/// The colour picker of the Fill or Stroke swatch.
fn picker_id(key: &str) -> egui::Id {
egui::Id::new(("tool-pick", key))
egui::Id::unique(("tool-pick", key))
}
/// Tool Creates Shape / Tool Creates Mask: with a shape layer selected, whether the shape tools
@@ -359,7 +359,7 @@ fn creates_buttons(app: &mut EffectcraftApp, ui: &mut egui::Ui, mut x: f32, cy:
for (mask, icon, tip, key) in [(false, Icon::ShapeLayer, "Tool Creates Shape", "createsShape"), (true, Icon::MaskVis, "Tool Creates Mask", "createsMask")] {
let r = Rect::from_min_size(pos2(x, cy - 13.0), vec2(26.0, 26.0));
let on = app.session.state.shape_tool.creates_mask == mask;
if widgets::icon_button(ui, r, icon, on, &t, egui::Id::new(("tool-creates", key))).on_hover_text(tip).clicked() {
if widgets::icon_button(ui, r, icon, on, &t, egui::Id::unique(("tool-creates", key))).on_hover_text(tip).clicked() {
tool_options(app, json!({"createsMask": mask}));
}
app.auto.add(&format!("header.{key}"), r, tip);
@@ -388,14 +388,14 @@ fn fill_stroke_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Pa
let options = format!("{title} Options");
let g = p.layout_no_wrap(format!("{title}:"), Tokens::ui(12.0), t.text_dim);
let wr = Rect::from_min_size(pos2(x, cy - g.size().y / 2.0), g.size());
let wresp = ui.interact(wr, egui::Id::new(("tool-paint-word", key)), Sense::click()).on_hover_text(&options);
let wresp = ui.interact(wr, egui::Id::unique(("tool-paint-word", key)), Sense::click()).on_hover_text(&options);
let col = if wresp.hovered() { t.text } else { t.text_dim };
if wresp.hovered() {
p.line_segment([pos2(wr.min.x, wr.max.y), pos2(wr.max.x - 3.0, wr.max.y)], Stroke::new(1.0, col));
}
p.galley_with_override_text_color(wr.min, g, col);
app.auto.add(&format!("header.{key}Options"), wr, &options);
let opts = egui::Id::new(("tool-paint-options", key));
let opts = egui::Id::unique(("tool-paint-options", key));
if wresp.clicked() {
widgets::open_popup(ui, opts);
}
@@ -403,7 +403,7 @@ fn fill_stroke_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Pa
// the Gradient Editor (the selected shape layer's Gradient Fill / Gradient Stroke), and
// opens the options otherwise; Alt-click steps through the kinds (as in After Effects).
let sr = Rect::from_min_size(pos2(wr.max.x + 6.0, cy - 8.0), vec2(20.0, 16.0));
let sresp = ui.interact(sr, egui::Id::new(("tool-paint-swatch", key)), Sense::click()).on_hover_text(format!("{title}: {}", tp.kind.label()));
let sresp = ui.interact(sr, egui::Id::unique(("tool-paint-swatch", key)), Sense::click()).on_hover_text(format!("{title}: {}", tp.kind.label()));
paint_swatch(p, sr, &tp, sresp.hovered(), &t);
app.auto.add(&format!("header.{key}"), sr, &format!("{title} Color"));
if sresp.clicked() {
@@ -435,7 +435,7 @@ fn fill_stroke_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Pa
x = sr.max.x + 12.0;
}
let width = app.session.state.shape_tool.stroke_width;
let (r, v, _) = widgets::hot_number_at(ui, pos2(x - 6.0, cy - 9.0), egui::Id::new("tool-stroke-w"), width, 0.2, (0.0, 1000.0), 0, " px", &t);
let (r, v, _) = widgets::hot_number_at(ui, pos2(x - 6.0, cy - 9.0), egui::Id::unique("tool-stroke-w"), width, 0.2, (0.0, 1000.0), 0, " px", &t);
if let Some(v) = v {
tool_options(app, json!({"strokeWidth": v}));
paint(app, "strokeWidth", json!(v));
@@ -466,7 +466,7 @@ fn paint_kind(app: &mut EffectcraftApp, shape_layers: &[u64], key: &str, kind: P
/// Fill Options / Stroke Options: None, Solid Color, Linear Gradient or Radial Gradient, and the
/// blend mode and opacity new shapes get (After Effects' dialogs, applied as they change).
fn paint_options_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, shape_layers: &[u64], key: &str, title: &str, pos: egui::Pos2) {
let id = egui::Id::new(("tool-paint-options", key));
let id = egui::Id::unique(("tool-paint-options", key));
if !widgets::popup_is_open(ui, id) {
return;
}
@@ -573,7 +573,7 @@ fn roto_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
let label = tr("Diameter:");
let lr = p.text(pos2(x, cy), Align2::LEFT_CENTER, label, Tokens::ui(12.0), t.text_dim);
x = lr.max.x + 6.0;
let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::new(("roto-opt", key)), v, 0.5, (1.0, 5000.0), 0, " px", &t);
let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::unique(("roto-opt", key)), v, 0.5, (1.0, 5000.0), 0, " px", &t);
app.auto.add("header.roto.diameter", r, label);
if let Some(nv) = nv
&& let Err(e) = app.session.execute("roto.options", json!({key: nv}))
@@ -591,7 +591,7 @@ fn puppet_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter
p.text(pos2(x, cy), Align2::LEFT_CENTER, tr("Mesh:"), Tokens::ui(12.0), t.text_dim);
x += 40.0;
let show = Rect::from_min_size(pos2(x, cy - 10.0), vec2(20.0, 20.0));
if widgets::checkbox(ui, show, o.show_mesh, &t, egui::Id::new("puppet-show-mesh")).clicked() {
if widgets::checkbox(ui, show, o.show_mesh, &t, egui::Id::unique("puppet-show-mesh")).clicked() {
let _ = app.session.execute("puppet.mesh", json!({"showMesh": !o.show_mesh}));
}
app.auto.add("header.puppet.showMesh", show, "Show mesh");
@@ -600,7 +600,7 @@ fn puppet_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter
for (label, key, v, range) in [("Expansion:", "expansion", o.expansion, (-100.0, 200.0)), ("Density:", "density", o.density, (0.0, 100.0))] {
p.text(pos2(x, cy), Align2::LEFT_CENTER, label, Tokens::ui(12.0), t.text_dim);
x += if key == "expansion" { 64.0 } else { 52.0 };
let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::new(("puppet-opt", key)), v, 0.2, range, 0, "", &t);
let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::unique(("puppet-opt", key)), v, 0.2, range, 0, "", &t);
app.auto.add(&format!("header.puppet.{key}"), r, label);
if let Some(nv) = nv {
let _ = app.session.execute("puppet.mesh", json!({key: nv, "merge": format!("puppet-opt-{key}")}));
@@ -611,7 +611,7 @@ fn puppet_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter
let label = "Record Options...";
let w = 104.0;
let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(w, 20.0));
let resp = ui.interact(r, egui::Id::new("puppet-record-options"), egui::Sense::click()).on_hover_text(tr("⌘/Ctrl-drag a pin to record its motion"));
let resp = ui.interact(r, egui::Id::unique("puppet-record-options"), egui::Sense::click()).on_hover_text(tr("⌘/Ctrl-drag a pin to record its motion"));
p.rect_stroke(r, 3.0, egui::Stroke::new(1.0, if resp.hovered() { t.accent } else { t.field_border }), egui::StrokeKind::Inside);
p.text(r.center(), Align2::CENTER_CENTER, label, Tokens::ui(11.5), t.text);
app.auto.add("header.puppet.recordOptions", r, label);
@@ -625,7 +625,7 @@ fn puppet_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter
let label = "Follow-Through...";
let r = Rect::from_min_size(pos2(r.max.x + 8.0, cy - 10.0), vec2(w, 20.0));
let resp = ui
.interact(r, egui::Id::new("puppet-follow"), egui::Sense::click())
.interact(r, egui::Id::unique("puppet-follow"), egui::Sense::click())
.on_hover_text(tr("Select the leader pin, then Shift-click the pins that should trail it (hair, cloth, tails)"));
p.rect_stroke(r, 3.0, egui::Stroke::new(1.0, if resp.hovered() { t.accent } else { t.field_border }), egui::StrokeKind::Inside);
p.text(r.center(), Align2::CENTER_CENTER, label, Tokens::ui(11.5), t.text);
@@ -639,9 +639,15 @@ fn puppet_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter
r.max.x + 16.0
}
/// A workspace's drawn name: built-ins read the menu bar's Window ▸ Workspace row; names the user
/// saved stay verbatim, even when one matches a catalog source such as "Composition".
fn workspace_label(name: &str) -> &str {
if crate::dock::WORKSPACES.contains(&name) { crate::i18n::command_label("window.workspace", name) } else { name }
}
fn ws_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui) {
for w in app.workspace_names() {
if ui.selectable_label(app.ui.workspace == w, &w).clicked() {
if ui.selectable_label(app.ui.workspace == w, workspace_label(&w)).clicked() {
app.set_workspace(&w);
ui.close();
}
@@ -662,7 +668,7 @@ static ARTCRAFT_MARK: &[u8] = include_bytes!("../../../docs/brand/artcraft-mark.
/// 24 px Tools bar to the About dialog). Nothing is drawn if it can't be decoded.
pub fn paint_logo(p: &egui::Painter, r: Rect) {
let ctx = p.ctx();
let id = egui::Id::new("artcraft-mark");
let id = egui::Id::unique("artcraft-mark");
let tex = ctx.data(|d| d.get_temp::<Option<egui::TextureHandle>>(id)).unwrap_or_else(|| {
let tex = image::load_from_memory_with_format(ARTCRAFT_MARK, image::ImageFormat::Png).ok().map(|img| {
let img = img.to_rgba8();
@@ -692,6 +698,15 @@ mod tests {
out.textures_delta.clear();
}
#[test]
fn saved_workspace_names_stay_verbatim() {
crate::i18n::set_current("zh-hans");
assert_ne!(workspace_label("Default"), "Default");
assert_ne!(crate::i18n::tr("Composition"), "Composition");
assert_eq!(workspace_label("Composition"), "Composition");
crate::i18n::set_current("en");
}
#[test]
fn long_press_type_keeps_the_tool_until_vertical_is_selected() {
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
+837 -439
View File
File diff suppressed because it is too large Load Diff
+395 -94
View File
@@ -14,15 +14,15 @@
//! the id is stable, so a translation never becomes an orphan.
//!
//! Each row repeats the registry's English name so that a rename upstream fails
//! [`tests::every_effect_has_a_row`] instead of silently pointing a translation at another
//! effect.
//! [`tests::every_row_names_a_registry_effect`] instead of silently pointing a translation at
//! another effect. An effect with no row draws the registry's English name; the
//! `missing_translations_report` test (run by `cargo xtask ci`) lists those per language.
//!
//! A language decides for itself which names stay English: After Effects' Japanese interface
//! leaves the `CC` series, the `VR` set and the `OCIO` set in English, while its Simplified
//! Chinese one translates them (the `CC` names come from Adobe's Chinese *documentation*, which
//! names 68 of the 69). Repeating the English name is therefore a language-level choice, not a
//! missing row: [`tests::only_the_brand_names_stay_in_english`] pins *Simplified Chinese's* five
//! brand names and is deliberately not shared by the other languages.
//! missing row.
//!
//! The Simplified Chinese names come from the installed After Effects 2026 (26.5.0.89)
//! Simplified Chinese interface, its 21 category folder names included; `Cryptomatte`,
@@ -70,12 +70,14 @@ fn catalog(language: &str) -> Option<&'static Catalog> {
static SIMPLIFIED: Catalog = Catalog { rows: SIMPLIFIED_CHINESE, categories: SIMPLIFIED_CHINESE_CATEGORIES, index: OnceLock::new() };
static TRADITIONAL: Catalog = Catalog { rows: TRADITIONAL_CHINESE, categories: TRADITIONAL_CHINESE_CATEGORIES, index: OnceLock::new() };
static JAPANESE: Catalog = Catalog { rows: JAPANESE_EFFECTS, categories: JAPANESE_CATEGORIES, index: OnceLock::new() };
static GEORGIAN_CATALOG: Catalog = Catalog { rows: GEORGIAN, categories: GEORGIAN_CATEGORIES, index: OnceLock::new() };
match language {
"zh-hans" => Some(&SIMPLIFIED),
"zh-hant" => Some(&TRADITIONAL),
// Japanese: the slot the Japanese data point on #821 asked for, so both languages share
// this one catalog instead of inventing a second mechanism.
"ja" => Some(&JAPANESE),
"ka" => Some(&GEORGIAN_CATALOG),
_ => None,
}
}
@@ -118,6 +120,19 @@ pub(crate) fn matches(id: &str, english: &str, query: &str) -> bool {
|| catalog(current()).and_then(|c| c.row(id)).is_some_and(|(_, translated)| translated.to_lowercase().contains(query))
}
/// The registry's effect names and categories `language` has no row for, for the missing-strings
/// report.
#[cfg(test)]
pub(super) fn missing(language: &str) -> Vec<String> {
let table = catalog(language);
let effects = effectcraft_engine::effects::registry().iter().filter(|spec| table.and_then(|c| c.row(spec.id)).is_none()).map(|spec| spec.name.to_string());
let categories = effectcraft_engine::effects::categories()
.into_iter()
.filter(|source| table.and_then(|c| c.category(source)).is_none())
.map(|source| format!("{source} (category)"));
effects.chain(categories).collect()
}
/// Simplified Chinese (`zh-hans`): one row per effect in the registry.
const SIMPLIFIED_CHINESE: &[Row] = &[
("ec.3d.channelextract", "3D Channel Extract", "3D 通道提取"),
@@ -490,6 +505,346 @@ const JAPANESE_CATEGORIES: &[(&str, &str)] = &[
("Utility", "ユーティリティ"),
];
/// Georgian (`ka`): one row per effect, ids from [`SIMPLIFIED_CHINESE`]. Written from the meaning
/// of the English names, with the terms the Georgian menus and panels use (FilmCraft's Georgian
/// for the effects the two apps share). The `CC` series and the brand names (`Cryptomatte`,
/// `EXtractoR`, `IDentifier`, `Key Light`, `Unmult`, `Lumetri Color`) stay in English, as
/// Adobe's Japanese interface keeps the `CC` series; `VR` and `OCIO` keep their prefix.
const GEORGIAN: &[Row] = &[
("ec.3d.channelextract", "3D Channel Extract", "3D არხის ამოღება"),
("ec.3d.cryptomatte", "Cryptomatte", "Cryptomatte"),
("ec.3d.depthmatte", "Depth Matte", "სიღრმის მატი"),
("ec.3d.depthoffield", "Depth of Field", "ველის სიღრმე"),
("ec.3d.extractor", "EXtractoR", "EXtractoR"),
("ec.3d.fog3d", "Fog 3D", "3D ნისლი"),
("ec.3d.identifier", "IDentifier", "IDentifier"),
("ec.3d.idmatte", "ID Matte", "ID მატი"),
("ec.audio.backwards", "Backwards", "უკუღმა"),
("ec.audio.basstreble", "Bass & Treble", "ბასი და ტრებლი"),
("ec.audio.compressor", "Compressor", "კომპრესორი"),
("ec.audio.delay", "Delay", "დაყოვნება"),
("ec.audio.distortion", "Distortion", "დამახინჯება"),
("ec.audio.flangechorus", "Flange & Chorus", "ფლენჯერი და ქორუსი"),
("ec.audio.gate", "Gate", "გეიტი"),
("ec.audio.highlowpass", "High-Low Pass", "მაღალი/დაბალი სიხშირეების ფილტრი"),
("ec.audio.modulator", "Modulator", "მოდულატორი"),
("ec.audio.parametriceq", "Parametric EQ", "პარამეტრული ეკვალაიზერი"),
("ec.audio.reverb", "Reverb", "რევერბი"),
("ec.audio.stereomixer", "Stereo Mixer", "სტერეომიქშერი"),
("ec.audio.tone", "Tone", "ტონი"),
("ec.blur.bilateral", "Bilateral Blur", "ბილატერალური გაბუნდოვნება"),
("ec.blur.cameralens", "Camera Lens Blur", "კამერის ობიექტივის გაბუნდოვნება"),
("ec.blur.camerashakedeblur", "Camera-Shake Deblur", "კამერის რყევით გაბუნდოვნების მოცილება"),
("ec.blur.cccross", "CC Cross Blur", "CC Cross Blur"),
("ec.blur.ccradial", "CC Radial Blur", "CC Radial Blur"),
("ec.blur.ccradialfast", "CC Radial Fast Blur", "CC Radial Fast Blur"),
("ec.blur.ccvector", "CC Vector Blur", "CC Vector Blur"),
("ec.blur.channel", "Channel Blur", "არხის გაბუნდოვნება"),
("ec.blur.compound", "Compound Blur", "შედგენილი გაბუნდოვნება"),
("ec.blur.directional", "Directional Blur", "მიმართული გაბუნდოვნება"),
("ec.blur.fastbox", "Fast Box Blur", "სწრაფი ბლოკური გაბუნდოვნება"),
("ec.blur.gaussian", "Gaussian Blur", "გაუსის გაბუნდოვნება"),
("ec.blur.radial", "Radial Blur", "რადიალური გაბუნდოვნება"),
("ec.blur.reduceflicker", "Reduce Interlace Flicker", "ნახევარკადრული ციმციმის შემცირება"),
("ec.blur.sharpen", "Sharpen", "სიმკვეთრე"),
("ec.blur.smart", "Smart Blur", "ჭკვიანი გაბუნდოვნება"),
("ec.blur.unsharp", "Unsharp Mask", "არამკვეთრი ნიღაბი"),
("ec.channel.arithmetic", "Arithmetic", "არითმეტიკა"),
("ec.channel.blend", "Blend", "შერევა"),
("ec.channel.calculations", "Calculations", "გამოთვლები"),
("ec.channel.cccomposite", "CC Composite", "CC Composite"),
("ec.channel.combiner", "Channel Combiner", "არხების გამაერთიანებელი"),
("ec.channel.compoundarithmetic", "Compound Arithmetic", "შედგენილი არითმეტიკა"),
("ec.channel.invert", "Invert", "ინვერსია"),
("ec.channel.minimax", "Minimax", "მინიმაქსი"),
("ec.channel.removecolormatting", "Remove Color Matting", "ფერადი მატინგის მოცილება"),
("ec.channel.setchannels", "Set Channels", "არხების დაყენება"),
("ec.channel.setmatte", "Set Matte", "მატის დაყენება"),
("ec.channel.shiftchannels", "Shift Channels", "არხების გადანაცვლება"),
("ec.channel.solidcomposite", "Solid Composite", "სოლიდთან კომპოზიტი"),
("ec.color.autocolor", "Auto Color", "ავტოფერი"),
("ec.color.autocontrast", "Auto Contrast", "ავტოკონტრასტი"),
("ec.color.autolevels", "Auto Levels", "ავტოდონეები"),
("ec.color.blackwhite", "Black & White", "შავ-თეთრი"),
("ec.color.brightnesscontrast", "Brightness & Contrast", "სიკაშკაშე და კონტრასტი"),
("ec.color.broadcast", "Broadcast Colors", "სამაუწყებლო ფერები"),
("ec.color.cccolorneutralizer", "CC Color Neutralizer", "CC Color Neutralizer"),
("ec.color.cccoloroffset", "CC Color Offset", "CC Color Offset"),
("ec.color.cckernel", "CC Kernel", "CC Kernel"),
("ec.color.cctoner", "CC Toner", "CC Toner"),
("ec.color.changecolor", "Change Color", "ფერის შეცვლა"),
("ec.color.changetocolor", "Change to Color", "ფერად შეცვლა"),
("ec.color.channelmixer", "Channel Mixer", "არხების მიქშერი"),
("ec.color.colorama", "Colorama", "კოლორამა"),
("ec.color.colorbalance", "Color Balance", "ფერთა ბალანსი"),
("ec.color.colorbalancehls", "Color Balance (HLS)", "ფერთა ბალანსი (HLS)"),
("ec.color.colorlink", "Color Link", "ფერის ბმა"),
("ec.color.colorstabilizer", "Color Stabilizer", "ფერის სტაბილიზატორი"),
("ec.color.curves", "Curves", "მრუდები"),
("ec.color.equalize", "Equalize", "გათანაბრება"),
("ec.color.exposure", "Exposure", "ექსპოზიცია"),
("ec.color.gammapedestalgain", "Gamma/Pedestal/Gain", "გამა/საფეხური/გაძლიერება"),
("ec.color.huesaturation", "Hue/Saturation", "ფერის ტონი/გაჯერება"),
("ec.color.leavecolor", "Leave Color", "ფერის დატოვება"),
("ec.color.levels", "Levels", "დონეები"),
("ec.color.levelsic", "Levels (Individual Controls)", "დონეები (ცალკეული მართვა)"),
("ec.color.lumetri", "Lumetri Color", "Lumetri Color"),
("ec.color.ociocdl", "OCIO CDL Transform", "OCIO CDL გარდაქმნა"),
("ec.color.ociocolorspace", "OCIO Color Space Transform", "OCIO ფერთა სივრცის გარდაქმნა"),
("ec.color.ociodisplay", "OCIO Display Transform", "OCIO ეკრანის გარდაქმნა"),
("ec.color.ociofile", "OCIO File Transform", "OCIO ფაილის გარდაქმნა"),
("ec.color.ociolook", "OCIO Look Transform", "OCIO ვიზუალური სტილის გარდაქმნა"),
("ec.color.photofilter", "Photo Filter", "ფოტოფილტრი"),
("ec.color.psarbitrarymap", "PS Arbitrary Map", "PS ნებისმიერი რუკა"),
("ec.color.selectivecolor", "Selective Color", "შერჩევითი ფერი"),
("ec.color.shadowhighlight", "Shadow/Highlight", "ჩრდილები/ღია ტონები"),
("ec.color.tint", "Tint", "ტონირება"),
("ec.color.tritone", "Tritone", "სამტონიანი"),
("ec.color.vibrance", "Vibrance", "სიხალასე"),
("ec.color.videolimiter", "Video Limiter", "ვიდეოლიმიტერი"),
("ec.control.angle", "Angle Control", "კუთხის მართვა"),
("ec.control.checkbox", "Checkbox Control", "მოსანიშნი ველის მართვა"),
("ec.control.color", "Color Control", "ფერის მართვა"),
("ec.control.dropdown", "Dropdown Menu Control", "ჩამოსაშლელი მენიუს მართვა"),
("ec.control.layer", "Layer Control", "შრის მართვა"),
("ec.control.point", "Point Control", "წერტილის მართვა"),
("ec.control.point3d", "3D Point Control", "3D წერტილის მართვა"),
("ec.control.slider", "Slider Control", "სლაიდერის მართვა"),
("ec.control.stereo3d", "Stereo 3D Controls", "სტერეო 3D მართვა"),
("ec.distort.bezierwarp", "Bezier Warp", "ბეზიეს დეფორმაცია"),
("ec.distort.bulge", "Bulge", "გამობერილობა"),
("ec.distort.ccbender", "CC Bender", "CC Bender"),
("ec.distort.ccbendit", "CC Bend It", "CC Bend It"),
("ec.distort.ccblobbylize", "CC Blobbylize", "CC Blobbylize"),
("ec.distort.ccflomotion", "CC Flo Motion", "CC Flo Motion"),
("ec.distort.ccgriddler", "CC Griddler", "CC Griddler"),
("ec.distort.cclens", "CC Lens", "CC Lens"),
("ec.distort.ccpageturn", "CC Page Turn", "CC Page Turn"),
("ec.distort.ccpowerpin", "CC Power Pin", "CC Power Pin"),
("ec.distort.ccripplepulse", "CC Ripple Pulse", "CC Ripple Pulse"),
("ec.distort.ccslant", "CC Slant", "CC Slant"),
("ec.distort.ccsmear", "CC Smear", "CC Smear"),
("ec.distort.ccsplit", "CC Split", "CC Split"),
("ec.distort.ccsplit2", "CC Split 2", "CC Split 2"),
("ec.distort.cctiler", "CC Tiler", "CC Tiler"),
("ec.distort.cornerpin", "Corner Pin", "კუთხეების მიმაგრება"),
("ec.distort.displacementmap", "Displacement Map", "წანაცვლების რუკა"),
("ec.distort.liquify", "Liquify", "გათხევადება"),
("ec.distort.magnify", "Magnify", "გადიდება"),
("ec.distort.meshwarp", "Mesh Warp", "ბადისებრი დეფორმაცია"),
("ec.distort.mirror", "Mirror", "სარკე"),
("ec.distort.offset", "Offset", "წანაცვლება"),
("ec.distort.opticscompensation", "Optics Compensation", "ოპტიკის კომპენსაცია"),
("ec.distort.polar", "Polar Coordinates", "პოლარული კოორდინატები"),
("ec.distort.puppet", "Puppet", "მარიონეტი"),
("ec.distort.reshape", "Reshape", "ფორმის შეცვლა"),
("ec.distort.ripple", "Ripple", "ტალღა"),
("ec.distort.rollingshutterrepair", "Rolling Shutter Repair", "მცოცავი ჩამკეტის გასწორება"),
("ec.distort.smear", "Smear", "გადღაბნა"),
("ec.distort.spherize", "Spherize", "სფერიზება"),
("ec.distort.transform", "Transform", "გარდაქმნა"),
("ec.distort.turbulentdisplace", "Turbulent Displace", "ტურბულენტური წანაცვლება"),
("ec.distort.twirl", "Twirl", "გრიგალი"),
("ec.distort.twirllegacy", "Twirl (Legacy)", "გრიგალი (მოძველებული)"),
("ec.distort.upscale", "Detail-preserving Upscale", "გადიდება დეტალების შენარჩუნებით"),
("ec.distort.warp", "Warp", "დეფორმაცია"),
("ec.distort.warpstabilizer", "Warp Stabilizer", "დეფორმაციის სტაბილიზატორი"),
("ec.distort.wavewarp", "Wave Warp", "ტალღური დეფორმაცია"),
("ec.generate.advancedlightning", "Advanced Lightning", "გაფართოებული ელვა"),
("ec.generate.audiospectrum", "Audio Spectrum", "აუდიოს სპექტრი"),
("ec.generate.audiowaveform", "Audio Waveform", "აუდიოს ტალღის ფორმა"),
("ec.generate.beam", "Beam", "სხივი"),
("ec.generate.ccgluegun", "CC Glue Gun", "CC Glue Gun"),
("ec.generate.cclightburst", "CC Light Burst 2.5", "CC Light Burst 2.5"),
("ec.generate.cclightrays", "CC Light Rays", "CC Light Rays"),
("ec.generate.cclightsweep", "CC Light Sweep", "CC Light Sweep"),
("ec.generate.ccthreads", "CC Threads", "CC Threads"),
("ec.generate.cellpattern", "Cell Pattern", "უჯრედების პატერნი"),
("ec.generate.checkerboard", "Checkerboard", "ჭადრაკის დაფა"),
("ec.generate.circle", "Circle", "წრე"),
("ec.generate.ellipse", "Ellipse", "ელიფსი"),
("ec.generate.eyedropperfill", "Eyedropper Fill", "პიპეტით შევსება"),
("ec.generate.fill", "Fill", "შევსება"),
("ec.generate.fourcolor", "4-Color Gradient", "4-ფერიანი გრადიენტი"),
("ec.generate.fractal", "Fractal", "ფრაქტალი"),
("ec.generate.gradientramp", "Gradient Ramp", "გრადიენტული გადასვლა"),
("ec.generate.grid", "Grid", "ბადე"),
("ec.generate.lensflare", "Lens Flare", "ობიექტივის ანარეკლი"),
("ec.generate.paintbucket", "Paint Bucket", "საღებავის ვედრო"),
("ec.generate.radiowaves", "Radio Waves", "რადიოტალღები"),
("ec.generate.scribble", "Scribble", "ჯღაბნა"),
("ec.generate.stroke", "Stroke", "კონტური"),
("ec.generate.vegas", "Vegas", "ვეგასი"),
("ec.generate.writeon", "Write-on", "თანდათანობითი წერა"),
("ec.key.advancedspill", "Advanced Spill Suppressor", "ფერის გადაღვრის გაფართოებული ჩამხშობი"),
("ec.key.ccsimplewireremoval", "CC Simple Wire Removal", "CC Simple Wire Removal"),
("ec.key.colordifference", "Color Difference Key", "ფერთა სხვაობის გასაღები"),
("ec.key.colorkey", "Color Key", "ფერის გასაღები"),
("ec.key.colorrange", "Color Range", "ფერების დიაპაზონი"),
("ec.key.differencematte", "Difference Matte", "სხვაობის მატი"),
("ec.key.extract", "Extract", "ამოღება"),
("ec.key.innerouter", "Inner/Outer Key", "შიდა/გარე გასაღები"),
("ec.key.keycleaner", "Key Cleaner", "გასაღების გამწმენდი"),
("ec.key.linearcolor", "Linear Color Key", "წრფივი ფერის გასაღები"),
("ec.key.luma", "Luma Key", "სიკაშკაშის გასაღები"),
("ec.key.screen", "Screen Key", "ეკრანის გასაღები"),
("ec.key.spill", "Spill Suppressor", "ფერის გადაღვრის ჩამხშობი"),
("ec.key.unmult", "Unmult", "Unmult"),
("ec.keying.keylight", "Key Light", "Key Light"),
("ec.matte.mattechoker", "Matte Choker", "მატის შემკუმშველი"),
("ec.matte.objectmatte", "Object Matte", "ობიექტის მატი"),
("ec.matte.refinehard", "Refine Hard Matte", "მკვეთრი მატის დახვეწა"),
("ec.matte.refinesoft", "Refine Soft Matte", "რბილი მატის დახვეწა"),
("ec.matte.rotobrush", "Roto Brush & Refine Edge", "როტო-ფუნჯი და კიდის დახვეწა"),
("ec.matte.simplechoker", "Simple Choker", "მარტივი შემკუმშველი"),
("ec.noise.addgrain", "Add Grain", "მარცვლოვნების დამატება"),
("ec.noise.curlnoise", "Curl Noise", "გრიგალური ხმაური"),
("ec.noise.dustscratches", "Dust & Scratches", "მტვერი და ნაკაწრები"),
("ec.noise.fractal", "Fractal Noise", "ფრაქტალური ხმაური"),
("ec.noise.matchgrain", "Match Grain", "მარცვლოვნების მისადაგება"),
("ec.noise.median", "Median", "მედიანა"),
("ec.noise.medianlegacy", "Median (Legacy)", "მედიანა (მოძველებული)"),
("ec.noise.noise", "Noise", "ხმაური"),
("ec.noise.noisealpha", "Noise Alpha", "ალფა-ხმაური"),
("ec.noise.noisehls", "Noise HLS", "HLS ხმაური"),
("ec.noise.noisehlsauto", "Noise HLS Auto", "HLS ხმაური (ავტომატური)"),
("ec.noise.removegrain", "Remove Grain", "მარცვლოვნების მოცილება"),
("ec.noise.turbulent", "Turbulent Noise", "ტურბულენტური ხმაური"),
("ec.obsolete.basic3d", "Basic 3D", "საბაზო 3D"),
("ec.obsolete.basictext", "Basic Text", "საბაზო ტექსტი"),
("ec.obsolete.gaussianlegacy", "Gaussian Blur (Legacy)", "გაუსის გაბუნდოვნება (მოძველებული)"),
("ec.obsolete.lightning", "Lightning", "ელვა"),
("ec.obsolete.mochashape", "Mocha shape", "Mocha-ს ფიგურა"),
("ec.obsolete.pathtext", "Path Text", "ტექსტი ტრაექტორიაზე"),
("ec.paint.paint", "Paint", "ხატვა"),
("ec.perspective.3dglasses", "3D Glasses", "3D სათვალე"),
("ec.perspective.bevelalpha", "Bevel Alpha", "ალფას ფასკა"),
("ec.perspective.beveledges", "Bevel Edges", "კიდეების ფასკა"),
("ec.perspective.cameratracker", "3D Camera Tracker", "3D კამერის ტრეკერი"),
("ec.perspective.cccylinder", "CC Cylinder", "CC Cylinder"),
("ec.perspective.ccenvironment", "CC Environment", "CC Environment"),
("ec.perspective.ccsphere", "CC Sphere", "CC Sphere"),
("ec.perspective.ccspotlight", "CC Spotlight", "CC Spotlight"),
("ec.perspective.dropshadow", "Drop Shadow", "ჩრდილი"),
("ec.perspective.radialshadow", "Radial Shadow", "რადიალური ჩრდილი"),
("ec.sim.carddance", "Card Dance", "მოცეკვავე ბარათები"),
("ec.sim.caustics", "Caustics", "კაუსტიკა"),
("ec.sim.ccballaction", "CC Ball Action", "CC Ball Action"),
("ec.sim.ccbubbles", "CC Bubbles", "CC Bubbles"),
("ec.sim.ccdrizzle", "CC Drizzle", "CC Drizzle"),
("ec.sim.cchair", "CC Hair", "CC Hair"),
("ec.sim.ccmrmercury", "CC Mr. Mercury", "CC Mr. Mercury"),
("ec.sim.ccparticlesystems2", "CC Particle Systems II", "CC Particle Systems II"),
("ec.sim.ccparticleworld", "CC Particle World", "CC Particle World"),
("ec.sim.ccpixelpolly", "CC Pixel Polly", "CC Pixel Polly"),
("ec.sim.ccrainfall", "CC Rainfall", "CC Rainfall"),
("ec.sim.ccscatterize", "CC Scatterize", "CC Scatterize"),
("ec.sim.ccsnowfall", "CC Snowfall", "CC Snowfall"),
("ec.sim.ccstarburst", "CC Star Burst", "CC Star Burst"),
("ec.sim.foam", "Foam", "ქაფი"),
("ec.sim.particleplayground", "Particle Playground", "ნაწილაკების სიმულაცია"),
("ec.sim.shatter", "Shatter", "დამსხვრევა"),
("ec.sim.waveworld", "Wave World", "ტალღების სამყარო"),
("ec.stylize.brushstrokes", "Brush Strokes", "ფუნჯის შტრიხები"),
("ec.stylize.cartoon", "Cartoon", "მულტფილმის სტილი"),
("ec.stylize.ccblockload", "CC Block Load", "CC Block Load"),
("ec.stylize.ccburnfilm", "CC Burn Film", "CC Burn Film"),
("ec.stylize.ccglass", "CC Glass", "CC Glass"),
("ec.stylize.cchextile", "CC HexTile", "CC HexTile"),
("ec.stylize.cckaleida", "CC Kaleida", "CC Kaleida"),
("ec.stylize.ccmrsmoothie", "CC Mr. Smoothie", "CC Mr. Smoothie"),
("ec.stylize.ccplastic", "CC Plastic", "CC Plastic"),
("ec.stylize.ccrepetile", "CC RepeTile", "CC RepeTile"),
("ec.stylize.ccthreshold", "CC Threshold", "CC Threshold"),
("ec.stylize.ccthresholdrgb", "CC Threshold RGB", "CC Threshold RGB"),
("ec.stylize.ccvignette", "CC Vignette", "CC Vignette"),
("ec.stylize.coloremboss", "Color Emboss", "ფერადი რელიეფი"),
("ec.stylize.emboss", "Emboss", "რელიეფი"),
("ec.stylize.findedges", "Find Edges", "კიდეების პოვნა"),
("ec.stylize.glow", "Glow", "ნათება"),
("ec.stylize.mosaic", "Mosaic", "მოზაიკა"),
("ec.stylize.motiontile", "Motion Tile", "მოძრავი ფილები"),
("ec.stylize.posterize", "Posterize", "პოსტერიზაცია"),
("ec.stylize.roughenedges", "Roughen Edges", "კიდეების დაუხეშება"),
("ec.stylize.scatter", "Scatter", "გაბნევა"),
("ec.stylize.strobe", "Strobe Light", "სტრობოსკოპი"),
("ec.stylize.texturize", "Texturize", "ტექსტურირება"),
("ec.stylize.threshold", "Threshold", "ზღვარი"),
("ec.text.numbers", "Numbers", "რიცხვები"),
("ec.text.timecode", "Timecode", "თაიმკოდი"),
("ec.time.ccforcemotionblur", "CC Force Motion Blur", "CC Force Motion Blur"),
("ec.time.ccwidetime", "CC Wide Time", "CC Wide Time"),
("ec.time.echo", "Echo", "ექო"),
("ec.time.pixelmotionblur", "Pixel Motion Blur", "პიქსელების მოძრაობის გაბუნდოვნება"),
("ec.time.posterizetime", "Posterize Time", "დროის პოსტერიზაცია"),
("ec.time.timedifference", "Time Difference", "დროითი სხვაობა"),
("ec.time.timedisplacement", "Time Displacement", "დროითი წანაცვლება"),
("ec.time.timewarp", "Timewarp", "Timewarp"),
("ec.transition.blockdissolve", "Block Dissolve", "ბლოკური გადადნობა"),
("ec.transition.cardwipe", "Card Wipe", "ბარათებით წმენდა"),
("ec.transition.ccglasswipe", "CC Glass Wipe", "CC Glass Wipe"),
("ec.transition.ccgridwipe", "CC Grid Wipe", "CC Grid Wipe"),
("ec.transition.ccimagewipe", "CC Image Wipe", "CC Image Wipe"),
("ec.transition.ccjaws", "CC Jaws", "CC Jaws"),
("ec.transition.cclightwipe", "CC Light Wipe", "CC Light Wipe"),
("ec.transition.cclinesweep", "CC Line Sweep", "CC Line Sweep"),
("ec.transition.ccradialscalewipe", "CC Radial ScaleWipe", "CC Radial ScaleWipe"),
("ec.transition.ccscalewipe", "CC Scale Wipe", "CC Scale Wipe"),
("ec.transition.cctwister", "CC Twister", "CC Twister"),
("ec.transition.ccwarpomatic", "CC WarpoMatic", "CC WarpoMatic"),
("ec.transition.gradientwipe", "Gradient Wipe", "გრადიენტული წმენდა"),
("ec.transition.iriswipe", "Iris Wipe", "დიაფრაგმული წმენდა"),
("ec.transition.linearwipe", "Linear Wipe", "წრფივი წმენდა"),
("ec.transition.radialwipe", "Radial Wipe", "რადიალური წმენდა"),
("ec.transition.venetian", "Venetian Blinds", "ჟალუზი"),
("ec.utility.applylut", "Apply Color LUT", "ფერის LUT-ის გამოყენება"),
("ec.utility.ccoverbrights", "CC Overbrights", "CC Overbrights"),
("ec.utility.cineon", "Cineon Converter", "Cineon-ის გარდამქმნელი"),
("ec.utility.colorprofileconverter", "Color Profile Converter", "ფერის პროფილის გარდამქმნელი"),
("ec.utility.facemeasurements", "Face Measurements", "სახის გაზომვები"),
("ec.utility.facetrackpoints", "Face Track Points", "სახის ტრეკინგის წერტილები"),
("ec.utility.growbounds", "Grow Bounds", "საზღვრების გაზრდა"),
("ec.utility.hdrcompander", "HDR Compander", "HDR კომპანდერი"),
("ec.utility.hdrcompression", "HDR Highlight Compression", "HDR ღია ტონების შეკუმშვა"),
("ec.vr.blur", "VR Blur", "VR გაბუნდოვნება"),
("ec.vr.chromaticaberrations", "VR Chromatic Aberrations", "VR ქრომატული აბერაციები"),
("ec.vr.colorgradients", "VR Color Gradients", "VR ფერადი გრადიენტები"),
("ec.vr.converter", "VR Converter", "VR გარდამქმნელი"),
("ec.vr.denoise", "VR De-Noise", "VR ხმაურის მოცილება"),
("ec.vr.digitalglitch", "VR Digital Glitch", "VR ციფრული ხარვეზი"),
("ec.vr.fractalnoise", "VR Fractal Noise", "VR ფრაქტალური ხმაური"),
("ec.vr.glow", "VR Glow", "VR ნათება"),
("ec.vr.planetosphere", "VR Plane to Sphere", "VR სიბრტყიდან სფეროზე"),
("ec.vr.rotatesphere", "VR Rotate Sphere", "VR სფეროს შემობრუნება"),
("ec.vr.sharpen", "VR Sharpen", "VR სიმკვეთრე"),
("ec.vr.spheretoplane", "VR Sphere to Plane", "VR სფეროდან სიბრტყეზე"),
];
/// Georgian (`ka`) category labels, keyed by the registry's English category.
const GEORGIAN_CATEGORIES: &[(&str, &str)] = &[
("3D Channel", "3D არხი"),
("Audio", "აუდიო"),
("Blur & Sharpen", "გაბუნდოვნება და სიმკვეთრე"),
("Channel", "არხი"),
("Color Correction", "ფერის კორექცია"),
("Distort", "დამახინჯება"),
("Expression Controls", "ექსპრესიის მართვა"),
("Generate", "გენერაცია"),
("Immersive Video", "იმერსიული ვიდეო"),
("Keying", "კეიინგი"),
("Matte", "მატი"),
("Noise & Grain", "ხმაური და მარცვლოვნება"),
("Obsolete", "მოძველებული"),
("Paint", "ხატვა"),
("Perspective", "პერსპექტივა"),
("Simulation", "სიმულაცია"),
("Stylize", "სტილიზაცია"),
("Text", "ტექსტი"),
("Time", "დრო"),
("Transition", "გადასვლა"),
("Utility", "დამხმარე"),
];
/// The 21 category folder names the generated Effect menu and Effects & Presets draw, in
/// Simplified Chinese. Read from the installed After Effects 2026 (26.5.0.89) Simplified Chinese
/// interface (the checklist's `07-类别` sheet); two of them are the interface's own wording where
@@ -524,19 +879,17 @@ mod tests {
use crate::i18n::set_current;
use effectcraft_engine::effects;
/// Every effect in the registry has a row, and that row still carries the registry's English
/// name. A new effect, a rename or a removal fails here rather than drawing an English name
/// in a Chinese interface, or attaching a translation to a different effect.
/// Every row names an effect in the registry and still carries its English name, so a rename
/// or a removal fails here rather than attaching a translation to a different effect.
#[test]
fn every_effect_has_a_row() {
fn every_row_names_a_registry_effect() {
let registry = effects::registry();
assert_eq!(SIMPLIFIED_CHINESE.len(), registry.len(), "the catalog has {} rows for {} effects", SIMPLIFIED_CHINESE.len(), registry.len());
for spec in registry {
let Some((_, english, translated)) = SIMPLIFIED_CHINESE.iter().find(|(id, _, _)| *id == spec.id) else {
panic!("no catalog row for {} ({})", spec.id, spec.name);
for (id, english, translated) in SIMPLIFIED_CHINESE {
let Some(spec) = registry.iter().find(|spec| spec.id == *id) else {
panic!("{id} ({english}) is not an effect in the registry");
};
assert_eq!(*english, spec.name, "{}: the catalog says {english:?}, the registry {:?}", spec.id, spec.name);
assert!(!translated.is_empty(), "{}: empty translation", spec.id);
assert_eq!(*english, spec.name, "{id}: the catalog says {english:?}, the registry {:?}", spec.name);
assert!(!translated.is_empty(), "{id}: empty translation");
}
}
@@ -550,24 +903,12 @@ mod tests {
assert_eq!(listed, ids.len(), "an effect id is listed twice");
}
/// The five names Adobe's Simplified Chinese interface leaves in English as brand names, and
/// the only zh-hans rows that may repeat their English name. Deliberately not shared: another
/// language keeps different names in English (Japanese leaves the whole `CC`, `VR` and `OCIO`
/// sets in English), so this is zh-hans's own choice, not an invariant of the tables.
#[test]
fn only_the_brand_names_stay_in_english() {
const BRANDS: [&str; 5] = ["ec.3d.cryptomatte", "ec.3d.extractor", "ec.3d.identifier", "ec.keying.keylight", "ec.key.unmult"];
for (id, english, translated) in SIMPLIFIED_CHINESE {
assert_eq!(english == translated, BRANDS.contains(id), "{id}: {english:?} is drawn as {translated:?}");
}
}
/// The languages that have no rows yet, with the table a translator fills in for each. A new
/// language is added here and to [`catalog`]; the test then guards its rows as they arrive.
const PENDING: &[(&str, &[Row])] = &[("zh-hant", TRADITIONAL_CHINESE)];
/// The languages whose tables are being filled in: guarded like [`PENDING`] row by row.
const PARTIAL: &[(&str, &[Row])] = &[("ja", JAPANESE_EFFECTS)];
const PARTIAL: &[(&str, &[Row])] = &[("ja", JAPANESE_EFFECTS), ("ka", GEORGIAN)];
/// A pending language may lag Simplified Chinese but may not invent ids or disagree about
/// which effect an id names: a translator fills these tables row by row. While they are
@@ -591,16 +932,14 @@ mod tests {
}
}
/// Every category the registry and its plug-ins report has a Simplified Chinese row, and
/// every row of every language names a category that exists — else the generated Effect menu
/// and Effects & Presets would draw a label no folder has.
/// Every category row of every language names a category that exists, else the generated
/// Effect menu and Effects & Presets would carry a label no folder has.
#[test]
fn every_category_has_a_row() {
fn every_category_row_names_a_category() {
let categories = effects::categories();
for category in &categories {
assert!(SIMPLIFIED_CHINESE_CATEGORIES.iter().any(|(english, _)| english == category), "no catalog row for category {category}");
}
for (language, rows) in [("zh-hans", SIMPLIFIED_CHINESE_CATEGORIES), ("zh-hant", TRADITIONAL_CHINESE_CATEGORIES), ("ja", JAPANESE_CATEGORIES)] {
for (language, rows) in
[("zh-hans", SIMPLIFIED_CHINESE_CATEGORIES), ("zh-hant", TRADITIONAL_CHINESE_CATEGORIES), ("ja", JAPANESE_CATEGORIES), ("ka", GEORGIAN_CATEGORIES)]
{
for (english, translated) in rows {
assert!(categories.contains(english), "{language} lists {english}, which is not a category");
assert!(!translated.is_empty(), "empty {language} translation for {english}");
@@ -613,48 +952,38 @@ mod tests {
#[test]
fn group_names_keep_the_instance_index_and_user_renames() {
set_current("zh-hans");
assert_eq!(group("ec.blur.gaussian", "Gaussian Blur"), "高斯模糊");
assert_eq!(group("ec.blur.gaussian", "Gaussian Blur 2"), "高斯模糊 2");
assert_eq!(group("ec.blur.gaussian", "Gaussian Blur 12"), "高斯模糊 12");
let translated = name("ec.blur.gaussian", "Gaussian Blur");
assert_eq!(group("ec.blur.gaussian", "Gaussian Blur"), translated);
assert_eq!(group("ec.blur.gaussian", "Gaussian Blur 2"), format!("{translated} 2"));
assert_eq!(group("ec.blur.gaussian", "Gaussian Blur 12"), format!("{translated} 12"));
assert_eq!(group("ec.blur.gaussian", "My Blur"), "My Blur", "a rename is not a row");
assert_eq!(group("ec.blur.gaussian", "Gaussian Blur X"), "Gaussian Blur X");
assert_eq!(group("ec.blur.gaussian", "Gaussian Blur 2x"), "Gaussian Blur 2x");
assert_eq!(group("ec.blur.gaussian", "高斯模糊"), "高斯模糊", "an already translated name stays");
assert_eq!(group("ec.blur.gaussian", &translated), translated, "an already translated name stays");
set_current("en");
}
/// English, and a language with no catalog or no row for this effect, draws the registry's
/// name rather than an empty label.
///
/// The pending languages are also checked to still be row-less and to have an arm in
/// [`catalog`]: an arm with no table would leave a translator's rows dead, and the day a
/// pending language gets rows this fails so its slot moves out of [`PENDING`], where the
/// coverage for it lives.
/// English, and a language with no catalog or no row for an effect, draws the registry's
/// name rather than an empty label; a language with a row draws the row. Every table in
/// [`PENDING`] and [`PARTIAL`] has an arm in [`catalog`]: an arm-less table's rows would be dead.
#[test]
fn languages_without_rows_draw_the_registry_name() {
for (language, _) in PENDING.iter().chain(PARTIAL) {
assert!(catalog(language).is_some(), "{language} has a table but no arm in `catalog`: its rows would be dead");
}
let tables = PENDING.iter().chain(PARTIAL).copied().chain([("zh-hans", SIMPLIFIED_CHINESE)]);
for (language, rows) in [("en", &[][..]), ("uk", &[][..]), ("cs", &[][..])].into_iter().chain(tables) {
set_current(language);
let expected = rows.iter().find(|(id, _, _)| *id == "ec.blur.gaussian").map_or("Gaussian Blur", |(_, _, translated)| *translated);
assert_eq!(name("ec.blur.gaussian", "Gaussian Blur"), expected, "{language}");
assert_eq!(group("ec.blur.gaussian", "Gaussian Blur 2"), format!("{expected} 2"), "{language}");
assert_eq!(name("ec.unknown.effect", "Mystery"), "Mystery", "{language}");
assert_eq!(category("Not A Category"), "Not A Category", "{language}");
}
for language in ["en", "uk", "cs"] {
set_current(language);
assert_eq!(name("ec.blur.gaussian", "Gaussian Blur"), "Gaussian Blur", "{language}");
assert_eq!(group("ec.blur.gaussian", "Gaussian Blur 2"), "Gaussian Blur 2", "{language}");
assert_eq!(category("Blur & Sharpen"), "Blur & Sharpen", "{language}");
}
for (language, rows) in PENDING {
assert!(catalog(language).is_some(), "{language} has a table but no arm in `catalog`: its rows would be dead");
assert!(rows.is_empty(), "{language} has rows now: move it out of PENDING and cover it here");
set_current(language);
assert_eq!(name("ec.blur.gaussian", "Gaussian Blur"), "Gaussian Blur", "{language}");
assert_eq!(group("ec.blur.gaussian", "Gaussian Blur 2"), "Gaussian Blur 2", "{language}");
assert_eq!(category("Blur & Sharpen"), "Blur & Sharpen", "{language}");
}
set_current("zh-hans");
assert_eq!(name("ec.blur.gaussian", "Gaussian Blur"), "高斯模糊");
assert_eq!(group("ec.blur.gaussian", "Gaussian Blur 2"), "高斯模糊 2");
assert_eq!(category("Blur & Sharpen"), "模糊和锐化");
assert_eq!(name("ec.unknown.effect", "Mystery"), "Mystery");
assert_eq!(category("Not A Category"), "Not A Category");
set_current("ja");
assert_eq!(name("ec.blur.gaussian", "Gaussian Blur"), "ブラー(ガウス)");
assert_eq!(name("ec.unknown.effect", "Mystery"), "Mystery", "a row a partial table lacks draws the registry name");
set_current("en");
}
@@ -665,38 +994,10 @@ mod tests {
set_current("zh-hans");
assert!(matches("ec.blur.gaussian", "Gaussian Blur", "blur"));
assert!(matches("ec.blur.gaussian", "Gaussian Blur", "gaussian"));
assert!(matches("ec.blur.gaussian", "Gaussian Blur", "高斯"));
assert!(matches("ec.blur.gaussian", "Gaussian Blur", &name("ec.blur.gaussian", "Gaussian Blur").to_lowercase()));
assert!(!matches("ec.blur.gaussian", "Gaussian Blur", "sharpen"));
set_current("en");
assert!(matches("ec.blur.gaussian", "Gaussian Blur", "blur"));
assert!(!matches("ec.blur.gaussian", "Gaussian Blur", "高斯"));
}
/// The drawing sites ask this catalog instead of the registry's English name (#821). Like the
/// panel catalog's literal scan this is a heuristic: it pins the expressions that used to draw
/// the English name, so a revert fails here.
#[test]
fn the_drawing_sites_ask_the_catalog() {
/// A file that must ask the catalog.
const ASKS: &[(&str, &str, &str)] = &[
("i18n.rs", include_str!("../i18n.rs"), "effects::name("),
("i18n.rs", include_str!("../i18n.rs"), "effects::category("),
("panels/effects_presets.rs", include_str!("../panels/effects_presets.rs"), "effects::name("),
("panels/effects_presets.rs", include_str!("../panels/effects_presets.rs"), "effects::category("),
("panels/effect_controls.rs", include_str!("../panels/effect_controls.rs"), "effects::group("),
("panels/timeline.rs", include_str!("../panels/timeline.rs"), "effects::group("),
];
/// An expression that drew the registry's English name and must be gone.
const RAW: &[(&str, &str, &str)] = &[
("panels/effects_presets.rs", include_str!("../panels/effects_presets.rs"), "pos2(r.min.x + 56.0 + indent, r.center().y), e.name, query, &t)"),
("panels/effect_controls.rs", include_str!("../panels/effect_controls.rs"), "Align2::LEFT_CENTER, &g.name,"),
("panels/timeline.rs", include_str!("../panels/timeline.rs"), "Align2::LEFT_CENTER, name,"),
];
for (path, source, call) in ASKS {
assert!(source.contains(call), "{path} does not ask the effect-name catalog (`{call}`)");
}
for (path, source, raw) in RAW {
assert!(!source.contains(raw), "{path} draws the registry's English name again: `{raw}`");
}
}
}
File diff suppressed because it is too large Load Diff
+1527 -296
View File
File diff suppressed because it is too large Load Diff
+43 -6
View File
@@ -10,7 +10,7 @@
//! shortcuts and the panels don't see them (Space must not start a preview).
use egui::containers::menu::{MenuState, SubMenu, find_menu_root};
use egui::{Context, Event, Id, Key, Modifiers, Popup, Pos2, Response, Ui};
use egui::{Context, Event, Id, Key, Modifiers, Popup, Pos2, Rect, Response, Ui};
/// An entry of an open menu as drawn this frame.
#[derive(Clone, Copy)]
@@ -39,6 +39,12 @@ pub struct Nav {
tops: Vec<(Id, Id)>,
/// Open menus by depth: the top-level menu, then each open submenu.
levels: Vec<Level>,
/// The titles and open menus drawn this frame: a press outside them is an outside press,
/// which closes the menu (native behaviour; the press itself still reaches what's under it).
rects: Vec<Rect>,
/// A title press opened (or kept) the menu this frame: releasing the button over an entry
/// chooses it (After Effects' press-and-browse).
browsing: bool,
}
/// What to highlight once a menu opened from the keyboard has been drawn: the first entry of the
@@ -55,16 +61,16 @@ struct KeyOpened {
}
fn pending_id() -> Id {
Id::new("menu-keys-first")
Id::unique("menu-keys-first")
}
fn opened_id() -> Id {
Id::new("menu-keys-opened")
Id::unique("menu-keys-opened")
}
fn active_id() -> Id {
Id::new("menu-keys-active")
Id::unique("menu-keys-active")
}
fn alt_id() -> Id {
Id::new("menu-keys-alt")
Id::unique("menu-keys-alt")
}
impl Nav {
@@ -73,6 +79,37 @@ impl Nav {
self.tops.push((button.id, Popup::default_response_id(button)));
}
/// The rect `rect` (a menu title or an open menu) holds, so a press there doesn't close.
pub fn cover(&mut self, rect: Rect) {
self.rects.push(rect);
}
/// Whether `pos` is inside a title or menu drawn this frame.
pub fn covers(&self, pos: Pos2) -> bool {
self.rects.iter().any(|r| r.contains(pos))
}
/// A title press is in progress (its button still held): see [`Nav::browsing`].
pub fn set_browsing(&mut self) {
self.browsing = true;
}
/// Whether the menu bar is mid press-and-browse (a title was pressed this pointer-down and
/// hasn't been released yet): releasing over an entry then chooses it.
pub fn browsing(&self) -> bool {
self.browsing
}
/// The popup of the open top-level menu (only one can be open), if any.
pub fn open_popup(&self, ctx: &Context) -> Option<Id> {
self.tops.iter().find(|(_, p)| Popup::is_id_open(ctx, *p)).map(|(_, p)| *p)
}
/// The position in the bar of the open top-level menu, if any.
pub fn open_index(&self, ctx: &Context) -> Option<usize> {
self.tops.iter().position(|(_, p)| Popup::is_id_open(ctx, *p))
}
/// An entry drawn at `depth` (0: a top-level menu) in the menu `ui`. Hovering an entry moves
/// the keyboard highlight to it, and a highlighted entry scrolls into view.
pub fn entry(&mut self, ui: &Ui, depth: usize, r: &Response, submenu: bool) {
@@ -81,7 +118,7 @@ impl Nav {
}
let Some(level) = self.levels.get_mut(depth) else { return };
if level.menu.is_none() {
level.menu = Some(find_menu_root(ui).id);
level.menu = Some(find_menu_root(ui).scope_id);
level.sizing = ui.is_sizing_pass();
}
level.rows.push(Row { id: r.id, enabled: r.enabled(), submenu });
+179 -24
View File
@@ -330,7 +330,11 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
}
return r;
}
if no_params(&params) && app.ui.focused == PanelKind::Project {
// The Project-panel focus redirects apply only while no text edit is open: creating a text
// layer leaves focus on Project while the insertion point lives in the comp, so during an
// edit these commands address the text context — redirected, Deselect All would clear a
// project selection the user is not looking at and silently swallow the edit's own end.
if no_params(&params) && app.ui.focused == PanelKind::Project && app.session.state.text_edit.is_none() {
match id {
"edit.duplicate" => return run_engine(app, ctx, "project.duplicate", json!({})),
"edit.selectAll" => {
@@ -363,7 +367,7 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
return invoke(app, ctx, "project.delete", json!({}));
}
if id == "edit.clear" && params.as_object().is_some_and(|p| p.is_empty()) && app.ui.focused == PanelKind::RenderQueue {
let selected = ctx.data(|d| d.get_temp::<(Vec<u64>, Option<u64>)>(egui::Id::new("rq-ui"))).and_then(|(_, id)| id);
let selected = ctx.data(|d| d.get_temp::<(Vec<u64>, Option<u64>)>(egui::Id::unique("rq-ui"))).and_then(|(_, id)| id);
return match selected {
Some(item) => run_engine(app, ctx, "renderQueue.remove", json!({"item": item})),
None => Err("select a Render Queue item first".into()),
@@ -377,7 +381,7 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
}
return crate::panels::delete_items::delete_confirmed(app, ctx, json!({}));
}
let now = ctx.input(|i| i.time);
let now = app.now();
// Closing a modified project asks to save it first; the command runs once answered.
if crate::panels::unsaved::guard(app, id, &params) {
return Ok(json!({"dialog": "unsavedChanges"}));
@@ -399,6 +403,10 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
if crate::panels::dialogs_3d::route(app, id, &params)? {
return Ok(Value::Null);
}
// New 3D Primitive without parameters opens its dialog.
if crate::panels::primitive_dialog::route(app, id, &params)? {
return Ok(Value::Null);
}
// Layer Settings on a solid, adjustment layer or null opens its settings dialog.
if crate::panels::dialogs::route_layer_settings(app, id, &params)? {
return Ok(Value::Null);
@@ -907,7 +915,7 @@ fn toggle(slot: &mut bool, p: &Value) -> Value {
/// Perform a frontend command (from `Event::Frontend`).
pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Value) -> Result<Value, String> {
let now = ctx.input(|i| i.time);
let now = app.now();
let v = &mut app.ui.viewer;
Ok(match id {
"mediaBrowser.copySourcePath" => {
@@ -939,6 +947,18 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
}
// View ▸ New Viewer.
"view.newViewer" => json!({"viewer": crate::panels::viewers::new_viewer(app)}),
// Reveal Layer Source / Reveal Composition: show the Project panel on the item, opening
// its folders and clearing the search so the row is visible.
"project.reveal" => {
let ids: Vec<effectcraft_engine::project::ItemId> = p
.get("items")
.and_then(Value::as_array)
.map(|items| items.iter().filter_map(Value::as_u64).map(effectcraft_engine::project::ItemId).collect())
.or_else(|| p.get("item").and_then(Value::as_u64).map(|item| vec![effectcraft_engine::project::ItemId(item)]))
.unwrap_or_default();
crate::panels::project::reveal(app, ctx, &ids);
json!({"items": ids.iter().map(|item| item.0).collect::<Vec<_>>()})
}
// Run by id (control channel, MCP): the Project panel's selection, whatever has focus.
"project.deleteWithoutConfirmation" => crate::panels::delete_items::delete_confirmed(app, ctx, json!({}))?,
// A gradient property's "Edit Gradient…" (the Timeline's value): the Gradient Editor.
@@ -1224,7 +1244,7 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
"view.lookAt" => {
let r: Vec<f64> = p.get("rect").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
if r.len() == 4 {
let map: Option<crate::panels::viewer::ViewerMap> = ctx.data(|d| d.get_temp(egui::Id::new("viewer-map")));
let map: Option<crate::panels::viewer::ViewerMap> = ctx.data(|d| d.get_temp(egui::Id::unique("viewer-map")));
if let Some(m) = map {
let (rw, rh) = (((r[2] - r[0]) as f32).max(1.0), ((r[3] - r[1]) as f32).max(1.0));
let zoom = ((m.area.width() - 40.0) / rw).min((m.area.height() - 40.0) / rh).clamp(0.015, 32.0);
@@ -1613,7 +1633,10 @@ pub(crate) fn entry_checked(app: &EffectcraftApp, e: &MenuEntry) -> Option<bool>
}
pub(crate) fn entry_enabled(app: &EffectcraftApp, e: &MenuEntry) -> bool {
if app.ui.focused == PanelKind::Project {
// The Project-panel enabled rules mirror the invoke redirects, so they stand down for the
// same reason (see the redirect block): while a text edit is open the menu items must be
// enabled by what their routed command can do, or the menu grays out commands that work.
if app.ui.focused == PanelKind::Project && app.session.state.text_edit.is_none() {
match e.command.as_str() {
"edit.duplicate" => {
return !app.session.state.project_selection.is_empty()
@@ -1781,21 +1804,40 @@ fn mods_match(want: egui::Modifiers, got: egui::Modifiers) -> bool {
want.command == got.command && want.shift == got.shift && want.alt == got.alt && (want.ctrl == got.ctrl || got.command && cfg!(not(target_os = "macos")))
}
/// Whether the `key`+`mods` chord is the current binding of `command` in the active preset
/// (so callers that must match a shortcut themselves — like the viewer's text editor, which
/// holds the keyboard while the shortcut dispatcher is skipped — follow rebinds).
/// `Physical+`-only bindings never match here, exactly as for the app-wide dispatcher.
pub fn chord_matches(session: &effectcraft_engine::Session, command: &str, key: egui::Key, mods: egui::Modifiers) -> bool {
bindings(session).into_iter().any(|(m, k, id, _)| id == command && k == key && mods_match(m, mods))
}
/// Whether a Deselect All chord matched by [`chord_matches`] may run while a text editor holds
/// the keyboard. Chords carrying Cmd/Ctrl/Alt are command chords (the editor treats them as
/// such for undo and the rest), and function keys never type, so those forward from the editor;
/// a chord that could instead produce a character — a bare or Shift-only letter, digit or
/// punctuation key, as from a pathological bare-letter rebinding — stays with the editor so
/// typing keeps working, the same invariant the app-wide dispatcher honors by skipping
/// focused text fields.
pub fn forwardable_during_text_edit(mods: egui::Modifiers, key: egui::Key) -> bool {
mods.command || mods.ctrl || mods.alt || (egui::Key::F1..=egui::Key::F35).contains(&key)
}
/// Set (egui temp data) from a plain Spacebar press until its release while a tap would still
/// run the Spacebar shortcut.
/// The system clipboard's text of this frame's paste event, for the Edit ▸ Paste it runs.
fn system_paste_id() -> egui::Id {
egui::Id::new("menus-system-paste")
egui::Id::unique("menus-system-paste")
}
fn space_tap_id() -> egui::Id {
egui::Id::new("spacebar-tap")
egui::Id::unique("spacebar-tap")
}
/// The modifiers held when this frame's input began: the state the last pass ended with, if it
/// ran this (egui keeps only the state after the frame's events). Records this pass's state.
fn modifiers_at_start(ctx: &egui::Context) -> Option<egui::Modifiers> {
let id = egui::Id::new("shortcut-modifiers");
let id = egui::Id::unique("shortcut-modifiers");
let pass = ctx.cumulative_pass_nr();
let now = ctx.input(|i| i.modifiers);
ctx.data_mut(|d| {
@@ -2028,32 +2070,119 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only
}
}
/// The id under which [`menu_bar`] remembers which of its menus was open at the end of the last
/// frame (its title's open look, and the hover that switches between the titles).
fn open_menu_id() -> egui::Id {
egui::Id::unique("menu-bar-open")
}
/// The id under which [`menu_bar`] remembers a title press still in progress (press-and-browse).
fn browse_menu_id() -> egui::Id {
egui::Id::unique("menu-bar-browse")
}
/// Draw the in-window menu bar (the engine's AE menu tree).
///
/// The titles open on the press, not the release (native menu bars): while one is held the menus
/// can be browsed by moving onto them, and releasing over an entry chooses it. Hovering another
/// title switches the open menu to it, and the open one closes on a press outside it or when the
/// window loses focus. The pointer leaving the window — onto the OS title bar, directly above the
/// bar — closes nothing, however long: the platform forwards none of the title bar's clicks to
/// egui (only the cursor leaving), so there is no click to close on and a graze must not drop the
/// menu. The closing press itself still reaches the widget under it, as a click does in After
/// Effects.
pub fn menu_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui) {
let ctx = ui.ctx().clone();
let mut clicked: Option<(String, Value)> = None;
let mut nav = crate::menu_keys::Nav::default();
// The open menu as of the last frame: the titles are drawn before this frame can open or
// close anything, and what happened last frame is what they must react to.
let open_prev: Option<usize> = ctx.data(|d| d.get_temp(open_menu_id())).flatten();
if ctx.data(|d| d.get_temp(browse_menu_id())).unwrap_or(false) {
nav.set_browsing();
}
let mut drawn = 0;
ui.horizontal_centered(|ui| {
ui.add_space(6.0);
egui::MenuBar::new().config(egui::containers::menu::MenuConfig::new().style(crate::widgets::menu_style)).ui(ui, |ui| {
for node in effectcraft_engine::menus::menu_bar() {
if let MenuNode::Submenu { label, children } = node {
let r = ui.menu_button(crate::i18n::label(app, "", label), |ui| {
ui.set_min_width(if label == "Effect" { 200.0 } else { 280.0 });
crate::widgets::menu_scroll(ui, |ui| menu_nodes(app, ui, children, &mut clicked, &mut nav, 0));
});
nav.top(&r.response);
app.auto.add(&format!("menu.{label}"), r.response.rect, label);
let MenuNode::Submenu { label, children } = node else { continue };
// The open title takes the open button's look while its menu is up (as in AE).
let inactive = ui.style().visuals.widgets.inactive;
if open_prev == Some(drawn) {
ui.style_mut().visuals.widgets.inactive = ui.style().visuals.widgets.open;
}
let mut response = ui.add(egui::Button::new(crate::i18n::label(app, "", label)));
ui.style_mut().visuals.widgets.inactive = inactive;
// A press on a title opens its menu at once (a second press on the open one closes
// it), and while it is held the menus below are browsable. The keyboard and the
// accessibility activation report `clicked` without the primary button (the press
// then happened somewhere else), and toggle the menu. Hovering another title
// switches to it once the pointer has moved: merely resting there must not take a
// keyboard-driven menu over.
let moved = ctx.input(|i| i.pointer.delta() != egui::Vec2::ZERO);
// With the button down elsewhere nothing is hovered, so the title under the
// pointer takes the highlight by hand: the hover-switch to it shows, one frame
// later like egui's own hover (the open title keeps its open look).
if moved && response.contains_pointer() && open_prev.is_some_and(|p| p != drawn) {
response = response.highlight();
}
let pressed = ctx.input(|i| i.pointer.button_pressed(egui::PointerButton::Primary)) && response.hovered();
let cmd = if pressed {
nav.set_browsing();
Some(egui::SetOpenCommand::Bool(open_prev != Some(drawn)))
} else if response.clicked() && !response.clicked_by(egui::PointerButton::Primary) {
Some(egui::SetOpenCommand::Toggle)
} else if moved && response.enabled() && response.contains_pointer() && open_prev.is_some_and(|p| p != drawn) {
Some(egui::SetOpenCommand::Bool(true))
} else {
None
};
let inner = egui::Popup::menu(&response)
.close_behavior(egui::PopupCloseBehavior::IgnoreClicks)
.style(crate::widgets::menu_style)
.open_memory(cmd)
.show(|ui| {
ui.set_min_width(if label == "Effect" { 200.0 } else { 280.0 });
let browsing = nav.browsing();
crate::widgets::menu_scroll(ui, browsing, |ui| menu_nodes(app, ui, children, &mut clicked, &mut nav, 0));
});
nav.top(&response);
// The titles and the open menus: a press inside them is not an outside press.
nav.cover(response.rect);
if let Some(inner) = &inner {
nav.cover(inner.response.rect);
}
app.auto.add(&format!("menu.{label}"), response.rect, label);
drawn += 1;
}
});
});
crate::menu_keys::handle(&ctx, &nav);
// egui leaves the closing to us (`IgnoreClicks`), so the menus close here: at once on a press
// outside them (the press itself still reaches the widget below) and when the window loses
// focus. The pointer leaving the window closes nothing: moving onto the OS title bar fires
// the cursor-left event and the platform forwards none of its clicks to egui, so there is no
// click to close on — and a graze or a stay must not drop the menu, as in native apps.
if let Some(top) = nav.open_popup(&ctx) {
let focus_lost = ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::WindowFocused(false))));
let outside_press = ctx.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| !nav.covers(p)));
if focus_lost || outside_press {
egui::Popup::close_id(&ctx, top);
}
}
if let Some((id, params)) = clicked.map(|(id, params)| menu_choice(app, id, params))
&& let Err(e) = invoke(app, &ctx, &id, if params.is_null() { json!({}) } else { params })
{
app.ui.status = e;
}
// What the next frame starts from: the open title, and a title press still in progress.
let open = nav.open_index(&ctx);
let browsing = open.is_some() && nav.browsing() && ctx.input(|i| i.pointer.primary_down());
ctx.data_mut(|d| {
d.insert_temp(open_menu_id(), open);
d.insert_temp(browse_menu_id(), browsing);
});
}
/// The entries of top-level menu `name` (the Effect menu is the Effect Controls panel's context
@@ -2105,9 +2234,10 @@ fn menu_nodes(
Some(t) => r.on_hover_text(t),
None => r,
};
let r = browse_hover(nav, r);
app.auto.add(&format!("menu.{name}.{i}"), r.rect, &e.label);
nav.entry(ui, depth, &r, false);
if r.clicked() {
if r.clicked() || browse_pick(nav, ui, &r) {
*clicked = Some((e.command.clone(), e.params.clone()));
ui.close();
}
@@ -2119,19 +2249,27 @@ fn menu_nodes(
// every other submenu goes through the menu catalog.
let shown = crate::i18n::effect_category(children, label)
.unwrap_or_else(|| crate::i18n::submenu(app, label, effectcraft_engine::menus::submenu_label(&app.session, label, &dyn_ctx(&ws, &[]))));
let r = ui.menu_button((gutter(false), shown.as_str()), |ui| {
let browsing = nav.browsing();
let egui::InnerResponse { inner, response } = ui.menu_button((gutter(false), shown.as_str()), |ui| {
ui.set_min_width(if children.len() > 30 { 200.0 } else { 240.0 });
crate::widgets::menu_scroll(ui, |ui| menu_nodes(app, ui, children, clicked, nav, depth + 1));
crate::widgets::menu_scroll(ui, browsing, |ui| menu_nodes(app, ui, children, clicked, nav, depth + 1));
});
nav.entry(ui, depth, &r.response, true);
if r.inner.is_some() {
nav.opened(depth, &r.response);
let response = browse_hover(nav, response);
nav.entry(ui, depth, &response, true);
if inner.is_some() {
// `ui.menu_button` drops the popup's own response on the floor: read it back,
// so that a press inside this submenu doesn't count as an outside press.
let sub = egui::containers::menu::SubMenu::id_from_widget_id(response.id);
if let Some(resp) = ui.ctx().read_response(sub) {
nav.cover(resp.rect);
}
nav.opened(depth, &response);
}
}
MenuNode::Item(e) => {
let r = menu_entry(app, ui, e);
let r = browse_hover(nav, menu_entry(app, ui, e));
nav.entry(ui, depth, &r, false);
if r.clicked() {
if r.clicked() || browse_pick(nav, ui, &r) {
*clicked = Some((e.command.clone(), e.params.clone()));
ui.close();
}
@@ -2140,6 +2278,23 @@ fn menu_nodes(
}
}
/// The row `r` highlighted while the bar is mid press-and-browse: with a button down egui reports
/// nothing as hovered — which would leave browsing with no hover feedback at all — so the row
/// under the pointer takes the highlight by hand. It reads as the hover look one frame later,
/// exactly egui's own hover latency; disabled rows ignore it (`Noninteractive`), as in native
/// menus. Context menus use their own [`crate::menu_keys::Nav`], where the bar never browses.
fn browse_hover(nav: &crate::menu_keys::Nav, r: egui::Response) -> egui::Response {
if nav.browsing() && r.contains_pointer() { r.highlight() } else { r }
}
/// Whether a press-and-browse (a menu-bar title still held down) releases over entry `r`, which
/// then chooses it like a click does. egui reports neither for it: the release is far from the
/// press, so there is no click, and nothing but the pressed widget is hovered while a button is
/// down, so the row has to be tested by the pointer being over it.
fn browse_pick(nav: &crate::menu_keys::Nav, ui: &egui::Ui, r: &egui::Response) -> bool {
nav.browsing() && r.enabled() && r.contains_pointer() && ui.input(|i| i.pointer.button_released(egui::PointerButton::Primary))
}
/// The menu bar's submenu at `path` (`["File", "Interpret Footage"]`) in a panel's context menu,
/// with the menu bar's labels, shortcuts and enabled state; a chosen entry goes to `clicked`.
pub(crate) fn submenu_at(app: &mut EffectcraftApp, ui: &mut egui::Ui, path: &[&str], clicked: &mut Option<(String, Value)>) {
+23
View File
@@ -206,6 +206,8 @@ pub fn build(app: &EffectcraftApp) -> NativeMenu {
"Serviços"
} else if crate::i18n::czech(app) {
"Služby"
} else if crate::i18n::georgian(app) {
"სერვისები"
} else {
"Services"
}
@@ -490,6 +492,27 @@ mod tests {
assert_eq!(translated, ["Služby", "Skrýt EffectCraft", "Skrýt ostatní", "Zobrazit vše"]);
}
#[test]
fn georgian_translates_macos_predefined_items() {
if !cfg!(target_os = "macos") {
return;
}
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
app.session.execute("prefs.set", json!({"key": "general.language", "value": "ka"})).unwrap();
fn labels(nodes: &[NativeNode], out: &mut Vec<String>) {
for node in nodes {
match node {
NativeNode::Predefined { label, .. } => out.push(label.clone()),
NativeNode::Submenu { children, .. } => labels(children, out),
_ => {}
}
}
}
let mut translated = vec![];
labels(&build(&app).menus, &mut translated);
assert_eq!(translated, ["სერვისები", "EffectCraft-ის დამალვა", "სხვების დამალვა", "ყველას ჩვენება"]);
}
#[test]
fn hide_items_are_predefined_on_macos() {
let spec = build(&app());
+17 -11
View File
@@ -22,7 +22,7 @@ fn label(p: &egui::Painter, x: f32, y: f32, s: &str, t: &Tokens) {
/// A dropdown with options; returns the new index when changed.
fn choice(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect, auto: &str, options: &[&str], cur: usize) -> Option<usize> {
let t = app.tokens;
let id = egui::Id::new(("animtool-dd", auto));
let id = egui::Id::unique(("animtool-dd", auto));
if widgets::dropdown(ui, r, options.get(cur).copied().unwrap_or(""), &t, id).clicked() {
widgets::open_popup(ui, id);
}
@@ -33,7 +33,7 @@ fn choice(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect, auto: &str, opti
fn number(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &str, v: f64, speed: f64, range: (f64, f64), dec: usize, suffix: &str) -> f64 {
let t = app.tokens;
let (r, nv, _) = widgets::hot_number_at(ui, at, egui::Id::new(("animtool-n", auto)), v, speed, range, dec, suffix, &t);
let (r, nv, _) = widgets::hot_number_at(ui, at, egui::Id::unique(("animtool-n", auto)), v, speed, range, dec, suffix, &t);
app.auto.add(auto, r, &format!("{v}"));
nv.unwrap_or(v)
}
@@ -41,7 +41,7 @@ fn number(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &st
fn checkbox(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &str, text: &str, on: bool) -> bool {
let t = app.tokens;
let r = Rect::from_min_size(at, vec2(16.0, 16.0));
let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::new(("animtool-cb", auto))).clicked();
let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::unique(("animtool-cb", auto))).clicked();
ui.painter().text(pos2(r.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, text, Tokens::ui(12.0), t.text);
app.auto.add(auto, r, text);
if clicked { !on } else { on }
@@ -109,7 +109,7 @@ pub fn wiggler(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
app.ui.anim_tools = st.clone();
let b = Rect::from_min_size(pos2(rect.max.x - 92.0, y), vec2(80.0, 24.0));
let enabled = app.session.state.selected_keys.len() >= 2;
if widgets::text_button(ui, b, "Apply", enabled, &t, egui::Id::new("wg-apply")).clicked() {
if widgets::text_button(ui, b, "Apply", enabled, &t, egui::Id::unique("wg-apply")).clicked() {
let params = json!({
"apply": if st.wiggle_spatial { "spatial" } else { "temporal" },
"noise": if st.wiggle_smooth { "smooth" } else { "jagged" },
@@ -150,7 +150,7 @@ pub fn smoother(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
y += ROW + 8.0;
let b = Rect::from_min_size(pos2(rect.max.x - 92.0, y), vec2(80.0, 24.0));
let enabled = app.session.state.selected_keys.len() >= 3;
if widgets::text_button(ui, b, "Apply", enabled, &t, egui::Id::new("sm-apply")).clicked() {
if widgets::text_button(ui, b, "Apply", enabled, &t, egui::Id::unique("sm-apply")).clicked() {
apply(app, "keys.smooth", json!({"tolerance": tol}));
}
app.auto.add("smoother.apply", b, "Apply");
@@ -184,13 +184,19 @@ pub fn motion_sketch(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let fr = c.frame_rate;
let (s, e) = (app.session.time(), c.work_area.1);
let tc = |x| crate::panels::timecode(&app.session, c, x);
p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, tr_args("Start: {} Duration: {}", &[&(tc(s)), &(tc(e - s))]), Tokens::ui(11.0), t.text_faint);
p.text(
pos2(x0, y + 8.0),
Align2::LEFT_CENTER,
tr_args("Start: {} Duration: {}", &[&(tc(s)), &crate::panels::duration(&app.session, c, e - s)]),
Tokens::ui(11.0),
t.text_faint,
);
let _ = fr;
y += ROW;
let b = Rect::from_min_size(pos2(x0, y), vec2(120.0, 24.0));
let armed = st.sketch_armed;
let has_layer = !app.session.state.selected_layers.is_empty();
if widgets::text_button(ui, b, if armed { "Cancel Capture" } else { "Start Capture" }, has_layer && !armed, &t, egui::Id::new("ms-start")).clicked() {
if widgets::text_button(ui, b, if armed { "Cancel Capture" } else { "Start Capture" }, has_layer && !armed, &t, egui::Id::unique("ms-start")).clicked() {
if !has_layer {
app.ui.status = "Select a layer to sketch its motion".into();
} else {
@@ -213,7 +219,7 @@ struct Capture {
}
fn capture_id() -> egui::Id {
egui::Id::new("motion-sketch-capture")
egui::Id::unique("motion-sketch-capture")
}
/// The Motion Sketch overlay on the Composition viewer (`area`): while armed, a press starts
@@ -224,9 +230,9 @@ pub fn sketch_overlay(app: &mut EffectcraftApp, ui: &mut egui::Ui) {
return;
}
let ctx = ui.ctx().clone();
let Some(map) = ctx.data(|d| d.get_temp::<crate::panels::viewer::ViewerMap>(egui::Id::new("viewer-map"))) else { return };
let Some(map) = ctx.data(|d| d.get_temp::<crate::panels::viewer::ViewerMap>(egui::Id::unique("viewer-map"))) else { return };
let area = map.area;
let resp = ui.interact(area, egui::Id::new("motion-sketch-overlay"), Sense::drag());
let resp = ui.interact(area, egui::Id::unique("motion-sketch-overlay"), Sense::drag());
app.auto.add("motionSketch.overlay", area, "Motion Sketch capture area");
let now = ctx.input(|i| i.time);
let painter = ui.painter().with_clip_rect(area);
@@ -392,7 +398,7 @@ pub fn mask_interpolation(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rec
}
let b = Rect::from_min_size(pos2(rect.max.x - 92.0, y), vec2(80.0, 24.0));
let enabled = app.session.state.selected_keys.len() >= 2;
if widgets::text_button(ui, b, "Apply", enabled, &t, egui::Id::new("mi-apply")).clicked() {
if widgets::text_button(ui, b, "Apply", enabled, &t, egui::Id::unique("mi-apply")).clicked() {
match app.session.execute("mask.interpolate", json!({})) {
Ok(r) => app.ui.status = format!("Mask Interpolation: {} keyframes added ({} vertices)", r["keys"], r["vertices"]),
Err(e) => app.ui.status = e.to_string(),
@@ -217,11 +217,11 @@ pub fn viewer_hook(
// Interaction over the image.
// While Space (Hand tool) is held the overlay only hovers, so the viewer underneath gets the pan drag.
let sense = if super::space_hand(&ctx) { Sense::hover() } else { Sense::click_and_drag() };
let resp = ui.interact(map.area, egui::Id::new("viewer-camera-tracker"), sense);
let resp = ui.interact(map.area, egui::Id::unique("viewer-camera-tracker"), sense);
app.auto.add("viewer.cameraTracker", map.area, "3D Camera Tracker points");
// The overlay is on top of the viewer, so a middle-button drag lands here: pan the viewer like the Hand tool.
if resp.dragged_by(egui::PointerButton::Middle) {
let pid = egui::Id::new("viewer-camera-tracker-pan");
let pid = egui::Id::unique("viewer-camera-tracker-pan");
if resp.drag_started() {
let start_pan = app.ui.viewer.pan;
ui.data_mut(|d| d.insert_temp(pid, start_pan));
@@ -237,7 +237,7 @@ pub fn viewer_hook(
let hover = resp.hover_pos();
let point_at = |p: Pos2| hits.iter().filter(|h| h.1.distance(p) <= h.2 + 3.0).min_by(|x, y| x.1.distance(p).total_cmp(&y.1.distance(p))).map(|h| h.0);
let tri = hover.filter(|p| point_at(*p).is_none()).and_then(|p| triangle_at(&hits, p));
let mid = egui::Id::new("viewer-camera-tracker-marquee");
let mid = egui::Id::unique("viewer-camera-tracker-marquee");
let marquee: Option<Pos2> = ui.data(|d| d.get_temp(mid));
// Hover target / the selection's target.
if marquee.is_none() {
@@ -284,7 +284,7 @@ pub fn viewer_hook(
}
}
// Right-click: the points under the cursor (selection, else the hovered triangle).
let cid = egui::Id::new("viewer-camera-tracker-menu-points");
let cid = egui::Id::unique("viewer-camera-tracker-menu-points");
if resp.secondary_clicked() {
let pts: Vec<u32> = if !selected.is_empty() {
selected.clone()
@@ -413,12 +413,12 @@ pub fn editor(
let b1 = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 4.0), vec2(84.0, 22.0));
let b2 = Rect::from_min_size(pos2(b1.max.x + 8.0, b1.min.y), vec2(84.0, 22.0));
let b3 = Rect::from_min_size(pos2(b2.max.x + 8.0, b1.min.y), vec2(110.0, 22.0));
if widgets::text_button(ui, b1, "Analyze", false, &t, egui::Id::new(("ct-analyze", g.uid))).clicked() && !busy {
if widgets::text_button(ui, b1, "Analyze", false, &t, egui::Id::unique(("ct-analyze", g.uid))).clicked() && !busy {
actions.push(("camera.analyze".into(), me.clone()));
}
app.auto.add(&format!("effectControls.cameraTracker.{}.analyze", g.uid), b1, "Analyze");
if running {
if widgets::text_button(ui, b2, "Cancel", false, &t, egui::Id::new(("ct-cancel", g.uid))).clicked() {
if widgets::text_button(ui, b2, "Cancel", false, &t, egui::Id::unique(("ct-cancel", g.uid))).clicked() {
actions.push(("camera.cancel".into(), json!({})));
}
app.auto.add(&format!("effectControls.cameraTracker.{}.cancel", g.uid), b2, "Cancel");
@@ -426,7 +426,7 @@ pub fn editor(
let params = effectcraft_engine::camera_track::static_params(g);
let solve = ct::solve(&params);
if solve.is_some() {
if widgets::text_button(ui, b3, "Create Camera", false, &t, egui::Id::new(("ct-camera", g.uid))).clicked() {
if widgets::text_button(ui, b3, "Create Camera", false, &t, egui::Id::unique(("ct-camera", g.uid))).clicked() {
actions.push(("camera.create".into(), me.clone()));
}
app.auto.add(&format!("effectControls.cameraTracker.{}.createCamera", g.uid), b3, "Create Camera");
+48 -6
View File
@@ -5,6 +5,7 @@
use crate::i18n::{tr, tr_args};
use crate::icons::Icon;
use effectcraft_engine::prefs::CompPreset;
use effectcraft_engine::project::Resolution;
use effectcraft_engine::time::{FrameRate, format_timecode_frames, parse_timecode};
use egui::{Color32, vec2};
use serde_json::json;
@@ -94,6 +95,9 @@ pub struct CompDraft {
pub preserve_frame_rate: bool,
pub preserve_resolution: bool,
pub advanced_3d: bool,
/// The comp's Resolution (the Composition panel's popup): saved with the project but no undo
/// step, so OK sets it apart from `comp.new` / `comp.settings`.
pub resolution: Resolution,
pub tab: Tab,
/// Timecode text being edited (start, duration).
pub start_tc: Option<String>,
@@ -122,6 +126,7 @@ impl Default for CompDraft {
preserve_frame_rate: false,
preserve_resolution: false,
advanced_3d: false,
resolution: Resolution::Auto,
tab: Tab::Basic,
start_tc: None,
dur_tc: None,
@@ -177,7 +182,7 @@ fn timecode_field(ui: &mut egui::Ui, buf: &mut Option<String>, secs: &mut f64, r
}
fn name_focus_id() -> egui::Id {
egui::Id::new("comp-preset-name-focus")
egui::Id::unique("comp-preset-name-focus")
}
/// Save Preset's Choose Name prompt (drawn in its own modal over the dialog while
@@ -297,7 +302,7 @@ pub fn show(
});
auto.add("dialog.comp.preset", r.response.rect, "Preset");
let (save_r, _) = ui.allocate_exact_size(vec2(20.0, 20.0), egui::Sense::hover());
let save = crate::widgets::icon_button(ui, save_r, Icon::Plus, d.preset_name.is_some(), t, egui::Id::new("comp-preset-save"))
let save = crate::widgets::icon_button(ui, save_r, Icon::Plus, d.preset_name.is_some(), t, egui::Id::unique("comp-preset-save"))
.on_hover_text(tr("Save Preset…"));
auto.add("dialog.comp.presetSave", save_r, "Save Preset");
if save.clicked() {
@@ -306,7 +311,7 @@ pub fn show(
}
if let Some(q) = own {
let (del_r, _) = ui.allocate_exact_size(vec2(20.0, 20.0), egui::Sense::hover());
let del = crate::widgets::icon_button(ui, del_r, Icon::Trash, false, t, egui::Id::new("comp-preset-delete"))
let del = crate::widgets::icon_button(ui, del_r, Icon::Trash, false, t, egui::Id::unique("comp-preset-delete"))
.on_hover_text(tr("Delete Preset"));
auto.add("dialog.comp.presetDelete", del_r, "Delete Preset");
if del.clicked() {
@@ -376,9 +381,30 @@ pub fn show(
}
});
ui.end_row();
ui.label("");
let mb = d.width as f64 * d.height as f64 * 4.0 / (1024.0 * 1024.0);
ui.label(egui::RichText::new(tr_args("{} x {}, {}MB per 8bpc frame", &[&d.width, &d.height, &format!("{mb:.1}")])).color(Color32::GRAY));
// As in After Effects, the Resolution menu (the Composition panel's popup) with
// the frame it renders beside it.
ui.label(tr("Resolution:"));
ui.horizontal(|ui| {
let r = egui::ComboBox::from_id_salt("comp-resolution").width(90.0).selected_text(tr(d.resolution.label())).show_ui(ui, |ui| {
for res in Resolution::ALL {
ui.selectable_value(&mut d.resolution, res, tr(res.label()));
}
let custom = matches!(d.resolution, Resolution::Custom(_));
if ui.selectable_label(custom, tr("Custom")).clicked() && !custom {
d.resolution = Resolution::Custom(5);
}
});
auto.add("dialog.comp.resolution", r.response.rect, "Resolution");
if let Resolution::Custom(n) = d.resolution {
let mut n = n;
let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut n).range(2..=40)));
auto.add("dialog.comp.resolutionFactor", r.rect, "Custom Resolution");
d.resolution = Resolution::Custom(n);
}
let (w, h) = frame_at(d.width, d.height, d.resolution);
let mb = w as f64 * h as f64 * 4.0 / (1024.0 * 1024.0);
ui.label(egui::RichText::new(tr_args("{} x {}, {}MB per 8bpc frame", &[&w, &h, &format!("{mb:.1}")])).color(Color32::GRAY));
});
ui.end_row();
ui.label(tr("Start Timecode:"));
let r = ui.horizontal(|ui| timecode_field(ui, &mut d.start_tc, &mut d.start, rate, i64::MIN / 4)).inner;
@@ -486,10 +512,26 @@ pub fn params(d: &CompDraft) -> serde_json::Value {
})
}
/// The frame size the comp renders at `res` (Auto follows the magnification, so the dialog shows
/// the full frame for it).
pub fn frame_at(w: u32, h: u32, res: Resolution) -> (u32, u32) {
effectcraft_render::frame_size(w, h, 1.0 / f64::from(res.factor().unwrap_or(1).max(1)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frame_size_follows_the_resolution() {
// The renderer's size, which rounds: 1081 / 3 = 360.33 and 1920 / 7 = 274.3.
assert_eq!(frame_at(1920, 1081, Resolution::Third), (640, 360));
assert_eq!(frame_at(1920, 1080, Resolution::Custom(7)), (274, 154));
assert_eq!(frame_at(1920, 1081, Resolution::Half), (960, 541));
assert_eq!(frame_at(1920, 1080, Resolution::Full), (1920, 1080));
assert_eq!(frame_at(1920, 1080, Resolution::Auto), (1920, 1080));
}
#[test]
fn aspect_labels_match_ae() {
assert_eq!(aspect_label(1920.0, 1080.0), "16:9 (1.78)");
+76 -4
View File
@@ -47,6 +47,7 @@ pub struct DialogState {
pub info: (String, String),
pub camera: super::dialogs_3d::CameraDraft,
pub light: super::dialogs_3d::LightDraft,
pub primitive: super::primitive_dialog::PrimitiveDraft,
pub velocity: super::key_dialogs::VelocityDraft,
pub interp: super::key_dialogs::InterpDraft,
pub stretch: super::key_dialogs::StretchDraft,
@@ -102,6 +103,7 @@ pub fn open_comp_settings_for(app: &mut EffectcraftApp, cid: ItemId) -> Result<(
preserve_frame_rate: c.preserve_frame_rate,
preserve_resolution: c.preserve_resolution,
advanced_3d: c.renderer == effectcraft_engine::project::Renderer::Advanced3D,
resolution: c.resolution,
..Default::default()
};
app.dialog_state.editing_existing = true;
@@ -189,7 +191,7 @@ fn modal_with_scroll(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tok
/// window.
pub(crate) fn modal_fit(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, body: impl FnOnce(&mut egui::Ui, f32)) {
let size = size.min(modal_space(ctx));
let id = egui::Id::new(("modal", title));
let id = egui::Id::unique(("modal", title));
// Dragging the title row moves the dialog off what it edits. The offset is kept within the
// window, so dragging back never has to undo a part the dialog could not follow.
let offset_id = id.with("offset");
@@ -248,6 +250,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context) {
Dialog::ViewOptions => super::forms::show_view_options(app, ctx, &t),
Dialog::CameraSettings => super::dialogs_3d::camera(app, ctx, &t),
Dialog::LightSettings => super::dialogs_3d::light(app, ctx, &t),
Dialog::NewPrimitive => super::primitive_dialog::dialog(app, ctx, &t),
Dialog::KeyVelocity => super::key_dialogs::velocity(app, ctx, &t),
Dialog::KeyInterpolation => super::key_dialogs::interpolation(app, ctx, &t),
Dialog::TimeStretch => super::key_dialogs::time_stretch(app, ctx, &t),
@@ -273,7 +276,7 @@ const CREDITS_HEIGHT: f32 = 380.0;
fn about(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let mut close = false;
let mut cmd: Option<&str> = None;
let tab_id = egui::Id::new("about_tab");
let tab_id = egui::Id::unique("about_tab");
modal(ctx, "About EffectCraft", vec2(680.0, 520.0), t, |ui| {
let mut tab = ui.data_mut(|d| d.get_temp::<usize>(tab_id)).unwrap_or(0);
ui.horizontal(|ui| {
@@ -408,6 +411,11 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis
m.insert("comp".into(), json!(cid.0));
}
let r = if existing { app.session.execute("comp.settings", params) } else { app.session.execute("comp.new", params) };
let cid = if existing { app.dialog_state.editing_comp.map(|c| c.0) } else { r.as_ref().ok().and_then(|v| v["comp"].as_u64()) };
let r = match cid {
Some(cid) if r.is_ok() => set_resolution(app, cid, app.dialog_state.comp.resolution).and(r),
_ => r,
};
if !existing && r.is_ok() {
app.dialog_state.last_new_comp = Some(app.dialog_state.comp.clone());
}
@@ -420,6 +428,15 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis
}
}
/// The Resolution chosen in Composition Settings, set as the Composition panel's popup sets it:
/// saved with the project but no undo step, so it stays out of `comp.new` / `comp.settings`.
fn set_resolution(app: &mut EffectcraftApp, cid: u64, r: effectcraft_engine::project::Resolution) -> effectcraft_engine::Result<Value> {
match r.factor() {
None => app.session.execute("view.res.auto", json!({"comp": cid})),
Some(n) => app.session.execute("view.res.custom", json!({"comp": cid, "factor": n})),
}
}
/// One frame of the open dialog with `key` pressed.
#[cfg(test)]
pub(crate) fn press_key(app: &mut EffectcraftApp, ctx: &egui::Context, key: egui::Key, modifiers: egui::Modifiers) {
@@ -462,7 +479,7 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
(ds.solid_name.clone(), ds.solid_color, ds.solid_size, ds.solid_pixel_aspect, ds.solid_affect_all);
let (editing, shared) = (ds.solid_layer, ds.solid_shared);
let comp_size = app.session.active_comp().map(|c| (c.width, c.height)).unwrap_or((1920, 1080));
let lock_id = egui::Id::new("solid-lock-aspect");
let lock_id = egui::Id::unique("solid-lock-aspect");
let mut lock = ctx.data(|m| m.get_temp::<bool>(lock_id)).unwrap_or(true);
let auto = &mut app.auto;
modal(ctx, "Solid Settings", vec2(480.0, if editing.is_some() { 430.0 } else { 390.0 }), t, |ui| {
@@ -613,6 +630,8 @@ fn palette(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
.collect();
modal(ctx, "Command Palette", vec2(560.0, 470.0), t, |ui| {
let r = ui.add(egui::TextEdit::singleline(&mut q).hint_text(tr("Type a command or effect…")).desired_width(f32::INFINITY).font(Tokens::ui(14.0)));
// A placeholder disappears once typing starts; keep the field named for assistive input.
ctx.accesskit_node_builder(r.id, |node| node.set_label(tr("Type a command or effect…")));
// Take the keyboard when the palette opens (and if anything else takes it away), but not
// while the field already has it: egui's `request_focus` interrupts the IME composition,
// and asking on every frame cancelled the pinyin being typed, so no Chinese could be
@@ -731,7 +750,7 @@ mod tests {
assert_eq!(app.dialog_state.palette_query, "nihao");
// Something else taking the keyboard takes it away for a frame; the field claims it back
// (a request is what gets the keys back, and there is no composition to interrupt then).
ctx.memory_mut(|m| m.request_focus(egui::Id::new("elsewhere")));
ctx.memory_mut(|m| m.request_focus(egui::Id::unique("elsewhere")));
assert!(frame_with(&mut app, &ctx, vec![]).platform_output.ime.is_none(), "the field lost the keyboard");
assert!(frame_with(&mut app, &ctx, vec![]).platform_output.ime.is_some(), "and takes it back");
}
@@ -827,6 +846,59 @@ mod tests {
assert_eq!(selected(&ctx), Some(0..app.dialog_state.solid_name.chars().count()));
}
/// One frame of Composition Settings (or New Composition) with Enter pressed: OK.
fn press_ok(app: &mut EffectcraftApp, ctx: &egui::Context, existing: bool) {
let t = app.tokens;
let mut out = ctx.run_ui(Default::default(), |ui| {
app.auto.begin_frame();
ui.input_mut(|i| {
i.events.push(egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() })
});
comp_settings(app, ui.ctx(), &t, existing);
});
out.textures_delta.clear();
assert!(app.dialog.is_none(), "OK closed the dialog");
}
/// The Resolution menu sets the comp's Resolution on OK. As from the Composition panel's popup
/// it is no undo step: the settings are one step, and undoing them keeps the Resolution.
#[test]
fn composition_settings_resolution_applies_on_ok_outside_the_undo_history() {
use effectcraft_engine::project::Resolution;
let (mut app, ctx) = app();
let cid = app.session.active_comp_id().unwrap();
app.session.execute("view.res.third", json!({})).unwrap();
open_comp_settings(&mut app).unwrap();
assert_eq!(app.dialog_state.comp.resolution, Resolution::Third, "the dialog opens on the comp's Resolution");
frame(&mut app, &ctx);
assert!(app.auto.find("dialog.comp.resolution").is_some());
let steps = app.session.history.undo.len();
(app.dialog_state.comp.resolution, app.dialog_state.comp.width) = (Resolution::Half, 700);
app.dialog_state.comp.lock_aspect = false;
press_ok(&mut app, &ctx, true);
let c = app.session.project.comp(cid).unwrap();
assert_eq!((c.width, c.resolution), (700, Resolution::Half));
assert_eq!(app.session.history.undo.len(), steps + 1, "Composition Settings is one undo step");
app.session.execute("edit.undo", json!({})).unwrap();
let c = app.session.project.comp(cid).unwrap();
assert_eq!((c.width, c.resolution), (640, Resolution::Half), "undo keeps the Resolution");
// Only the Resolution changed: still set, and no extra step.
open_comp_settings(&mut app).unwrap();
app.dialog_state.comp.resolution = Resolution::Custom(6);
let steps = app.session.history.undo.len();
press_ok(&mut app, &ctx, true);
assert_eq!(app.session.project.comp(cid).unwrap().resolution, Resolution::Custom(6));
assert_eq!(app.session.history.undo.len(), steps, "no undo step");
// New Composition makes its comp at the menu's Resolution; the comp the viewer showed keeps its own.
open_new_comp(&mut app);
app.dialog_state.comp.resolution = Resolution::Quarter;
press_ok(&mut app, &ctx, false);
let new = app.session.active_comp_id().unwrap();
assert_ne!(new, cid);
assert_eq!(app.session.project.comp(new).unwrap().resolution, Resolution::Quarter);
assert_eq!(app.session.project.comp(cid).unwrap().resolution, Resolution::Custom(6));
}
#[test]
fn composition_settings_register_every_control_and_new_comp_remembers() {
let (mut app, ctx) = app();
+22 -14
View File
@@ -29,7 +29,7 @@ pub struct CameraDraft {
pub struct LightDraft {
pub layer: Option<u64>,
pub name: String,
pub kind: usize,
pub kind: LightKind,
pub color: [f32; 3],
pub intensity: f64,
pub cone_angle: f64,
@@ -42,9 +42,14 @@ pub struct LightDraft {
pub shadow_diffusion: f64,
}
const KINDS: [LightKind; 4] = [LightKind::Parallel, LightKind::Spot, LightKind::Point, LightKind::Ambient];
const FALLOFFS: [&str; 3] = ["None", "Smooth", "Inverse Square Clamped"];
/// Whether a light type has Falloff and Shadows options. Ambient and Environment lights come
/// from no one place, so they have neither.
fn falls_off(kind: LightKind) -> bool {
!matches!(kind, LightKind::Ambient | LightKind::Environment)
}
fn comp_w(app: &EffectcraftApp) -> f64 {
app.session.active_comp().map(|c| c.width as f64).unwrap_or(1920.0)
}
@@ -105,7 +110,7 @@ pub fn open_light(app: &mut EffectcraftApp, layer: Option<u64>) -> Result<(), St
LightDraft {
layer: Some(l.id.0),
name: l.name.clone(),
kind: KINDS.iter().position(|k| *k == kind).unwrap_or(1),
kind,
color: [c[0], c[1], c[2]],
intensity: f("intensity", 100.0),
cone_angle: f("coneAngle", 90.0),
@@ -123,7 +128,7 @@ pub fn open_light(app: &mut EffectcraftApp, layer: Option<u64>) -> Result<(), St
LightDraft {
layer: None,
name: format!("Light {n}"),
kind: 1,
kind: LightKind::Spot,
color: [1.0, 1.0, 1.0],
intensity: 100.0,
cone_angle: 90.0,
@@ -263,11 +268,13 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
crate::widgets::focus_on_open(ui.ctx(), &r, &d.name);
ui.end_row();
ui.label(tr("Light Type"));
egui::ComboBox::from_id_salt("light-type").selected_text(KINDS[d.kind.min(3)].label()).show_ui(ui, |ui| {
for (i, k) in KINDS.iter().enumerate() {
ui.selectable_value(&mut d.kind, i, k.label());
let combo = egui::ComboBox::from_id_salt("light-type").selected_text(tr(d.kind.label())).show_ui(ui, |ui| {
for (i, k) in LightKind::ALL.into_iter().enumerate() {
let item = ui.selectable_value(&mut d.kind, k, tr(k.label()));
app.auto.add(&format!("dialog.light.typeItem.{i}"), item.rect, k.label());
}
});
app.auto.add_response("dialog.light.type", &combo.response, "Light Type");
ui.end_row();
ui.label(tr("Color"));
crate::widgets::srgb_color_button(ui, &mut d.color);
@@ -275,8 +282,7 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
ui.label(tr("Intensity"));
ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.intensity).range(-10000.0..=10000.0).suffix(" %")));
ui.end_row();
let kind = KINDS[d.kind.min(3)];
if kind == LightKind::Spot {
if d.kind == LightKind::Spot {
ui.label(tr("Cone Angle"));
ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.cone_angle).range(0.0..=180.0).suffix("°")));
ui.end_row();
@@ -284,7 +290,7 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.cone_feather).range(0.0..=100.0).suffix(" %")));
ui.end_row();
}
if kind != LightKind::Ambient {
if falls_off(d.kind) {
ui.label(tr("Falloff"));
egui::ComboBox::from_id_salt("light-falloff").selected_text(FALLOFFS[d.falloff.min(2)]).show_ui(ui, |ui| {
for (i, f) in FALLOFFS.iter().enumerate() {
@@ -312,15 +318,17 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
}
});
ui.add_space(14.0);
ui.label(egui::RichText::new(tr("Note: Shadows are only cast from layers with Cast Shadows enabled.")).font(Tokens::ui(11.0)).color(t.text_dim));
ui.add_space(8.0);
if falls_off(d.kind) {
ui.label(egui::RichText::new(tr("Note: Shadows are only cast from layers with Cast Shadows enabled.")).font(Tokens::ui(11.0)).color(t.text_dim));
ui.add_space(8.0);
}
rects = buttons(ui, t, &mut ok, &mut close);
});
app.auto.add("dialog.light.ok", rects.0, "OK");
app.auto.add("dialog.light.cancel", rects.1, "Cancel");
app.dialog_state.light = d.clone();
if ok || super::dialogs::ui_enter(ctx) {
let kind = KINDS[d.kind.min(3)];
let kind = d.kind;
let mut p = json!({
"name": d.name,
"kind": kind.label(),
@@ -331,7 +339,7 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
p["coneAngle"] = json!(d.cone_angle);
p["coneFeather"] = json!(d.cone_feather);
}
if kind != LightKind::Ambient {
if falls_off(kind) {
p["falloff"] = json!(d.falloff);
p["radius"] = json!(d.radius);
p["falloffDistance"] = json!(d.falloff_distance);
+66 -74
View File
@@ -156,7 +156,7 @@ fn key_navigator(
let on_key = prop.keys.iter().any(|k| (layer.comp_time(k.time).0 - ectx.time.0).abs() <= half);
for (what, dx) in [("prev", -44.0), ("key", -32.0), ("next", -20.0)] {
let cr = Rect::from_center_size(pos2(r.max.x + dx, cy), vec2(12.0, 16.0));
let resp = ui.interact(cr, egui::Id::new(("ec-nav", uid, what)), Sense::click());
let resp = ui.interact(cr, egui::Id::unique(("ec-nav", uid, what)), Sense::click());
let col = if resp.hovered() { t.text } else { t.text_dim };
match what {
"prev" => triangle(p, cr.center(), true, col),
@@ -228,7 +228,7 @@ fn prop_row(
}
let swr = Rect::from_center_size(pos2(r.min.x + indent, cy), vec2(14.0, 14.0));
if !prop.static_only && !matches!(prop.ui, ParamUi::Hidden) {
let resp = ui.interact(swr, egui::Id::new(("ec-sw", uid)), Sense::click());
let resp = ui.interact(swr, egui::Id::unique(("ec-sw", uid)), Sense::click());
icons::paint(
p,
swr,
@@ -272,7 +272,7 @@ fn prop_row(
let name_clip = Rect::from_min_max(pos2(swr.max.x + 6.0, r.min.y), pos2(vx - 6.0, r.max.y));
app.auto.add(&format!("effectControls.prop.{uid}.name"), name_clip, &prop.name);
p.with_clip_rect(name_clip.intersect(p.clip_rect())).text(pos2(swr.max.x + 6.0, cy), Align2::LEFT_CENTER, &prop.name, Tokens::ui(12.0), t.text);
let name_response = ui.interact(name_clip, egui::Id::new(("ec-prop-name", uid)), Sense::click());
let name_response = ui.interact(name_clip, egui::Id::unique(("ec-prop-name", uid)), Sense::click());
if name_response.double_clicked() {
app.ui.timeline.search.clear();
app.ui.timeline.scroll_to = Some(crate::state::ScrollTo::Property(uid));
@@ -291,7 +291,7 @@ fn prop_row(
let twirl_open = |app: &mut EffectcraftApp, ui: &mut egui::Ui| {
let open = app.ui.fx_slider_open.contains(&uid);
let tw = Rect::from_center_size(pos2(swr.min.x - 9.0, cy), vec2(10.0, 10.0));
if widgets::twirl(ui, tw, open, egui::Id::new(("ec-stw", uid)), &t).clicked() {
if widgets::twirl(ui, tw, open, egui::Id::unique(("ec-stw", uid)), &t).clicked() {
if open {
app.ui.fx_slider_open.remove(&uid);
} else {
@@ -310,8 +310,8 @@ fn prop_row(
let i = v.round().max(0.0) as usize;
let label = opts.get(i).cloned().unwrap_or_else(|| format!("Mask {i}"));
let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((r.max.x - vx - 56.0).clamp(80.0, 200.0), 18.0));
let pop = egui::Id::new(("ec-mpop", uid));
if widgets::dropdown(ui, dr, &label, &t, egui::Id::new(("ec-m", uid))).clicked() {
let pop = egui::Id::unique(("ec-mpop", uid));
if widgets::dropdown(ui, dr, &label, &t, egui::Id::unique(("ec-m", uid))).clicked() {
widgets::open_popup(ui, pop);
}
app.auto.add(&format!("effectControls.prop.{uid}.value"), dr, &prop.name);
@@ -320,7 +320,7 @@ fn prop_row(
}
}
Value::Scalar(v) if matches!(prop.ui, ParamUi::Angle) => {
let (rr, dr, nv) = fw::angle_field(ui, pos2(vx, cy - 9.0), egui::Id::new(("ec-v", uid)), *v, 1, &t);
let (rr, dr, nv) = fw::angle_field(ui, pos2(vx, cy - 9.0), egui::Id::unique(("ec-v", uid)), *v, 1, &t);
app.auto.add(&format!("effectControls.prop.{uid}.value"), rr.union(dr), &fw::format_angle(*v, 1));
app.auto.add(&format!("effectControls.prop.{uid}.revolutions"), rr, &prop.name);
if let Some(nv) = nv {
@@ -337,14 +337,11 @@ fn prop_row(
ParamUi::Percent => "%",
_ => "",
};
let speed = match &prop.ui {
ParamUi::Slider { slider_min, slider_max, .. } => ((slider_max - slider_min) / 300.0).clamp(0.001, 50.0),
_ => 0.5,
};
let speed = widgets::slider_scrub_speed(&prop.ui);
let (vr, nv, _) = widgets::hot_number_at(
ui,
pos2(vx, cy - 9.0),
egui::Id::new(("ec-v", uid)),
egui::Id::unique(("ec-v", uid)),
*v,
speed,
(min, max),
@@ -368,7 +365,7 @@ fn prop_row(
if matches!(prop.ui, ParamUi::Point | ParamUi::Point3) {
let cr = Rect::from_center_size(pos2(x + 8.0, cy), vec2(18.0, 18.0));
let active = app.ui.fx_pick.as_ref().is_some_and(|k| k.prop == uid && k.kind == "point");
if fw::crosshair_button(ui, cr, active, egui::Id::new(("ec-xh", uid)), &t)
if fw::crosshair_button(ui, cr, active, egui::Id::unique(("ec-xh", uid)), &t)
.on_hover_text(crate::i18n::tr("Click, then click in the Composition panel"))
.clicked()
{
@@ -378,7 +375,7 @@ fn prop_row(
x += 22.0;
}
for d in 0..n.min(c.len()) {
let (vr, nv, _) = widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::new(("ec-v", uid, d)), c[d], 1.0, (-1e9, 1e9), 1, "", &t);
let (vr, nv, _) = widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::unique(("ec-v", uid, d)), c[d], 1.0, (-1e9, 1e9), 1, "", &t);
app.auto.add(&format!("effectControls.prop.{uid}.value.{d}"), vr, &prop.name);
dims.push(vr);
if let Some(nv) = nv {
@@ -396,14 +393,14 @@ fn prop_row(
}
Value::Color(c) => {
let sr = Rect::from_min_size(pos2(vx, cy - 8.0), vec2(30.0, 16.0));
let pop = egui::Id::new(("ec-cpop", uid));
if widgets::swatch(ui, sr, [c[0] as f32, c[1] as f32, c[2] as f32, 1.0], egui::Id::new(("ec-c", uid)), &t).clicked() {
let pop = egui::Id::unique(("ec-cpop", uid));
if widgets::swatch(ui, sr, [c[0] as f32, c[1] as f32, c[2] as f32, 1.0], egui::Id::unique(("ec-c", uid)), &t).clicked() {
widgets::open_popup(ui, pop);
}
app.auto.add(&format!("effectControls.prop.{uid}.value"), sr, &prop.name);
let er = Rect::from_center_size(pos2(sr.max.x + 14.0, cy), vec2(18.0, 18.0));
let active = app.ui.fx_pick.as_ref().is_some_and(|k| k.prop == uid && k.kind == "color");
if fw::eyedropper_button(ui, er, active, egui::Id::new(("ec-eye", uid)), &t)
if fw::eyedropper_button(ui, er, active, egui::Id::unique(("ec-eye", uid)), &t)
.on_hover_text(crate::i18n::tr("Eyedropper: click, then click in the Composition panel"))
.clicked()
{
@@ -417,7 +414,7 @@ fn prop_row(
}
Value::Bool(b) => {
let cr = Rect::from_min_size(pos2(vx, cy - 8.0), vec2(16.0, 16.0));
if widgets::checkbox(ui, cr, *b, &t, egui::Id::new(("ec-b", uid))).clicked() {
if widgets::checkbox(ui, cr, *b, &t, egui::Id::unique(("ec-b", uid))).clicked() {
set(actions, json!(!b));
}
p.text(pos2(cr.max.x + 6.0, cy), Align2::LEFT_CENTER, &prop.name, Tokens::ui(12.0), t.text_dim);
@@ -427,8 +424,8 @@ fn prop_row(
if let ParamUi::Popup { options } = &prop.ui {
let widest = options.iter().map(|o| p.layout_no_wrap(o.clone(), Tokens::ui(11.5), t.text).size().x).fold(0.0, f32::max);
let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((widest + 30.0).clamp(80.0, (r.max.x - vx - 56.0).max(80.0)), 18.0));
let pop = egui::Id::new(("ec-epop", uid));
if widgets::dropdown(ui, dr, options.get(*i as usize).map(String::as_str).unwrap_or(""), &t, egui::Id::new(("ec-e", uid))).clicked() {
let pop = egui::Id::unique(("ec-epop", uid));
if widgets::dropdown(ui, dr, options.get(*i as usize).map(String::as_str).unwrap_or(""), &t, egui::Id::unique(("ec-e", uid))).clicked() {
widgets::open_popup(ui, pop);
}
app.auto.add(&format!("effectControls.prop.{uid}.value"), dr, &prop.name);
@@ -441,26 +438,17 @@ fn prop_row(
let comp = app.session.active_comp_arc();
if let Some(comp) = comp {
let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2(((r.max.x - vx) * 0.55).clamp(90.0, 170.0), 18.0));
let idx_of = |id: u64| comp.layers.iter().position(|x| x.id.0 == id);
let name = l.and_then(|id| idx_of(id).map(|i| format!("{}. {}", i + 1, comp.layers[i].name))).unwrap_or_else(|| "None".into());
let pop = egui::Id::new(("ec-lpop", uid));
if widgets::dropdown(ui, dr, &name, &t, egui::Id::new(("ec-l", uid))).clicked() {
widgets::open_popup(ui, pop);
if let Some(chosen) = widgets::layer_picker(ui, dr, &comp.layers, *l, &t, egui::Id::unique(("ec-l", uid))) {
set(actions, chosen.map(|id| json!(id)).unwrap_or(serde_json::Value::Null));
}
app.auto.add(&format!("effectControls.prop.{uid}.value"), dr, &prop.name);
let mut opts = vec!["None".to_string()];
opts.extend(comp.layers.iter().enumerate().map(|(i, l)| format!("{}. {}", i + 1, l.name)));
let cur = l.and_then(idx_of).map(|i| i + 1).unwrap_or(0);
if let Some(i) = widgets::popup_menu(ui, pop, dr.left_bottom(), &opts, Some(cur)) {
set(actions, if i == 0 { serde_json::Value::Null } else { json!(comp.layers[i - 1].id.0) });
}
// Source / Masks / Effects & Masks (the hidden companion parameter).
if let Some(src) = group.get(&effectcraft_engine::effects::layer_source_id(&prop.match_id)) {
let si = src.value.as_enum() as usize;
let opts: Vec<String> = effectcraft_engine::effects::LAYER_SOURCE_OPTIONS.iter().map(|s| s.to_string()).collect();
let sr = Rect::from_min_size(pos2(dr.max.x + 6.0, cy - 9.0), vec2(((r.max.x - dr.max.x) - 64.0).clamp(70.0, 120.0), 18.0));
let spop = egui::Id::new(("ec-lspop", uid));
if widgets::dropdown(ui, sr, opts.get(si).map(String::as_str).unwrap_or(""), &t, egui::Id::new(("ec-ls", uid))).clicked() {
let spop = egui::Id::unique(("ec-lspop", uid));
if widgets::dropdown(ui, sr, opts.get(si).map(String::as_str).unwrap_or(""), &t, egui::Id::unique(("ec-ls", uid))).clicked() {
widgets::open_popup(ui, spop);
}
app.auto.add(&format!("effectControls.prop.{uid}.source"), sr, &src.name);
@@ -480,7 +468,7 @@ fn prop_row(
Value::Str(s) if matches!(prop.ui, ParamUi::File { .. }) => {
let bw = 70.0;
let br = Rect::from_min_size(pos2(vx, cy - 9.0), vec2(bw, 18.0));
if widgets::text_button(ui, br, crate::i18n::tr("Choose..."), false, &t, egui::Id::new(("ec-file", uid))).clicked() {
if widgets::text_button(ui, br, crate::i18n::tr("Choose..."), false, &t, egui::Id::unique(("ec-file", uid))).clicked() {
let filters: Vec<&str> = match &prop.ui {
ParamUi::File { filters } => filters.iter().map(String::as_str).collect(),
_ => vec![],
@@ -505,7 +493,7 @@ fn prop_row(
let nr = Rect::from_min_max(pos2(br.max.x + 8.0, r.min.y), pos2(r.max.x - 8.0, r.max.y));
let np = p.with_clip_rect(nr.intersect(p.clip_rect()));
np.text(pos2(nr.min.x, cy), Align2::LEFT_CENTER, &shown, Tokens::ui(12.0), if s.trim().is_empty() { t.text_dim } else { t.text });
let hover = ui.interact(nr, egui::Id::new(("ec-file-name", uid)), Sense::hover());
let hover = ui.interact(nr, egui::Id::unique(("ec-file-name", uid)), Sense::hover());
if !s.trim().is_empty() && !s.contains('\n') {
hover.on_hover_text(s.as_str());
}
@@ -515,9 +503,11 @@ fn prop_row(
// typed in a field, set when it loses the focus (Esc keeps the old text).
Value::Str(s) if matches!(prop.ui, ParamUi::Text) => {
let fr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((r.max.x - vx - 12.0).clamp(80.0, 320.0), 18.0));
let bid = egui::Id::new(("ec-text", uid));
let bid = egui::Id::unique(("ec-text", uid));
let mut buf: String = ui.data(|d| d.get_temp(bid)).unwrap_or_else(|| s.clone());
let resp = widgets::text_field(ui, fr, &mut buf, "", &t);
// The field has no visible hint: give the screen reader the parameter's name.
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TextInput, ui.is_enabled(), &prop.name));
app.auto.add(&format!("effectControls.prop.{uid}.value"), fr, &prop.name);
if resp.lost_focus() {
if buf != *s && !ui.input(|i| i.key_pressed(egui::Key::Escape)) {
@@ -598,7 +588,7 @@ fn slider_row(
let dec = decimals as usize;
p.text(pos2(tr.min.x, tr.max.y + 4.0), Align2::LEFT_TOP, format!("{slider_min:.dec$}"), Tokens::ui(10.5), t.text_dim);
p.text(pos2(tr.max.x, tr.max.y + 4.0), Align2::RIGHT_TOP, format!("{slider_max:.dec$}"), Tokens::ui(10.5), t.text_dim);
let sresp = ui.interact(tr.expand2(vec2(4.0, 6.0)), egui::Id::new(("ec-slider", uid)), Sense::click_and_drag());
let sresp = ui.interact(tr.expand2(vec2(4.0, 6.0)), egui::Id::unique(("ec-slider", uid)), Sense::click_and_drag());
app.auto.add(&format!("effectControls.prop.{uid}.slider"), tr, &prop.name);
if (sresp.dragged() || sresp.clicked())
&& let Some(pt) = sresp.interact_pointer_pos()
@@ -619,7 +609,7 @@ fn dial_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, layer: &Layer, prop: &P
let uid = prop.uid;
let d = (r.height() - 12.0).min(52.0);
let dr = Rect::from_min_size(pos2(r.min.x + x0 + 40.0, r.min.y + 6.0), vec2(d, d));
let id = egui::Id::new(("ec-dial", uid));
let id = egui::Id::unique(("ec-dial", uid));
let (resp, nv) = fw::angle_dial(ui, dr, v, id, &t);
app.auto.add(&format!("effectControls.prop.{uid}.dial"), dr, &prop.name);
if let Some(nv) = nv {
@@ -738,7 +728,7 @@ fn group_rows(
if r.max.y >= rect.min.y && r.min.y <= rect.max.y {
app.auto.add(&format!("effectControls.row.{}", sg.uid), r, &sg.name);
app.auto.add(&format!("effectControls.group.{}.twirl", sg.uid), tw, &sg.name);
if widgets::twirl(ui, tw, open, egui::Id::new(("ec-g", sg.uid)), &t).clicked() {
if widgets::twirl(ui, tw, open, egui::Id::unique(("ec-g", sg.uid)), &t).clicked() {
toggle_closed(app, sg, open, ui.input(|i| i.modifiers.alt || i.modifiers.command));
}
let name = p.text(pos2(tw.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, &sg.name, Tokens::ui(12.0), t.text);
@@ -769,12 +759,12 @@ fn warp_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, l
let busy = app.session.is_warp_analyzing();
let b1 = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 4.0), vec2(84.0, 22.0));
let b2 = Rect::from_min_size(pos2(b1.max.x + 8.0, b1.min.y), vec2(84.0, 22.0));
if widgets::text_button(ui, b1, "Analyze", false, &t, egui::Id::new(("warp-analyze", g.uid))).clicked() && !busy {
if widgets::text_button(ui, b1, "Analyze", false, &t, egui::Id::unique(("warp-analyze", g.uid))).clicked() && !busy {
actions.push(("warp.analyze".into(), json!({"layer": layer.id.0, "effect": g.uid})));
}
app.auto.add(&format!("effectControls.warp.{}.analyze", g.uid), b1, "Analyze");
if running {
if widgets::text_button(ui, b2, "Cancel", false, &t, egui::Id::new(("warp-cancel", g.uid))).clicked() {
if widgets::text_button(ui, b2, "Cancel", false, &t, egui::Id::unique(("warp-cancel", g.uid))).clicked() {
actions.push(("warp.cancel".into(), json!({})));
}
app.auto.add(&format!("effectControls.warp.{}.cancel", g.uid), b2, "Cancel");
@@ -800,13 +790,13 @@ fn roto_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, l
let b1 = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 4.0), vec2(84.0, 22.0));
let b2 = Rect::from_min_size(pos2(b1.max.x + 8.0, b1.min.y), vec2(84.0, 22.0));
let label = if frozen { "Unfreeze" } else { "Freeze" };
if widgets::text_button(ui, b1, label, frozen, &t, egui::Id::new(("roto-freeze-ec", g.uid))).clicked() && !running {
if widgets::text_button(ui, b1, label, frozen, &t, egui::Id::unique(("roto-freeze-ec", g.uid))).clicked() && !running {
let id = if frozen { "roto.unfreeze" } else { "roto.freeze" };
actions.push((id.into(), json!({"layer": layer.id.0, "effect": g.uid})));
}
app.auto.add(&format!("effectControls.roto.{}.freeze", g.uid), b1, label);
let (l2, id2) = if running { ("Stop", "roto.cancel") } else { ("Propagate", "roto.propagate") };
if widgets::text_button(ui, b2, l2, false, &t, egui::Id::new(("roto-prop-ec", g.uid))).clicked() && !frozen {
if widgets::text_button(ui, b2, l2, false, &t, egui::Id::unique(("roto-prop-ec", g.uid))).clicked() && !frozen {
actions.push((id2.into(), json!({"layer": layer.id.0, "effect": g.uid})));
}
app.auto.add(&format!("effectControls.roto.{}.propagate", g.uid), b2, l2);
@@ -831,13 +821,13 @@ fn object_matte_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Pa
let b1 = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 4.0), vec2(84.0, 22.0));
let b2 = Rect::from_min_size(pos2(b1.max.x + 8.0, b1.min.y), vec2(84.0, 22.0));
let label = if frozen { "Unfreeze" } else { "Freeze" };
if widgets::text_button(ui, b1, crate::i18n::tr(label), frozen, &t, egui::Id::new(("om-freeze-ec", g.uid))).clicked() && !busy {
if widgets::text_button(ui, b1, crate::i18n::tr(label), frozen, &t, egui::Id::unique(("om-freeze-ec", g.uid))).clicked() && !busy {
let id = if frozen { "objectMatte.unfreeze" } else { "objectMatte.freeze" };
actions.push((id.into(), json!({"layer": layer.id.0, "effect": g.uid})));
}
app.auto.add(&format!("effectControls.objectMatte.{}.freeze", g.uid), b1, label);
let (l2, id2) = if running { ("Stop", "roto.cancel") } else { ("Track", "objectMatte.propagate") };
if widgets::text_button(ui, b2, crate::i18n::tr(l2), false, &t, egui::Id::new(("om-track-ec", g.uid))).clicked() && !frozen && (running || !busy) {
if widgets::text_button(ui, b2, crate::i18n::tr(l2), false, &t, egui::Id::unique(("om-track-ec", g.uid))).clicked() && !frozen && (running || !busy) {
actions.push((id2.into(), json!({"layer": layer.id.0, "effect": g.uid})));
}
app.auto.add(&format!("effectControls.objectMatte.{}.track", g.uid), b2, l2);
@@ -881,7 +871,7 @@ fn curves_size(width: f32) -> f32 {
fn frame_histogram(app: &mut EffectcraftApp, ctx: &egui::Context) -> Option<std::sync::Arc<[[u32; 256]; 5]>> {
let img = app.viewer_pixels()?;
let key = std::sync::Arc::as_ptr(&img) as usize;
let id = egui::Id::new("ec-histogram");
let id = egui::Id::unique("ec-histogram");
if let Some((k, h)) = ctx.data(|d| d.get_temp::<(usize, std::sync::Arc<[[u32; 256]; 5]>)>(id))
&& k == key
{
@@ -944,7 +934,8 @@ fn curves_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
let x0 = r.min.x + 30.0;
let cy = r.min.y + 17.0;
let names: [&str; 5] = [crate::i18n::tr("RGB"), crate::i18n::tr("Red"), crate::i18n::tr("Green"), crate::i18n::tr("Blue"), crate::i18n::tr("Alpha")];
if let Some(n) = channel_popup(app, ui, p, x0, cy, ch, &names, egui::Id::new(("ec-cch", euid)), &format!("effectControls.effect.{euid}.curves.channel")) {
if let Some(n) = channel_popup(app, ui, p, x0, cy, ch, &names, egui::Id::unique(("ec-cch", euid)), &format!("effectControls.effect.{euid}.curves.channel"))
{
match chan_prop {
Some(pr) => actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": pr.uid, "value": n}))),
None => {
@@ -954,7 +945,7 @@ fn curves_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
}
let pencil = app.ui.fx_curve_pencil.contains(&euid);
let mr = Rect::from_min_size(pos2(x0 + 146.0, cy - 9.0), vec2(56.0, 18.0));
if widgets::dropdown(ui, mr, if pencil { "Pencil" } else { "Bezier" }, &t, egui::Id::new(("ec-cmode", euid))).clicked() {
if widgets::dropdown(ui, mr, if pencil { "Pencil" } else { "Bezier" }, &t, egui::Id::unique(("ec-cmode", euid))).clicked() {
if pencil {
app.ui.fx_curve_pencil.remove(&euid);
} else {
@@ -968,7 +959,7 @@ fn curves_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
_ => fw::CurvePoints::identity(),
};
let rr = Rect::from_min_size(pos2(mr.max.x + 8.0, cy - 9.0), vec2(44.0, 18.0));
let rresp = ui.interact(rr, egui::Id::new(("ec-creset", euid)), Sense::click());
let rresp = ui.interact(rr, egui::Id::unique(("ec-creset", euid)), Sense::click());
p.text(rr.center(), Align2::CENTER_CENTER, crate::i18n::tr("Reset"), Tokens::ui(11.5), if rresp.hovered() { t.hot_text } else { t.text_dim });
app.auto.add(&format!("effectControls.effect.{euid}.curves.reset"), rr, "Reset curve");
if rresp.clicked() {
@@ -979,8 +970,8 @@ fn curves_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
// Input/output gradients along the axes.
let col = channel_color(ch);
p.rect_filled(Rect::from_min_max(pos2(gr.min.x, gr.max.y + 3.0), pos2(gr.max.x, gr.max.y + 8.0)), 0.0, col.gamma_multiply(0.5));
let id = egui::Id::new(("ec-curves", euid, ch));
let (resp, edit) = fw::curves_graph(ui, gr, &cur, ch, pencil, id, &t);
let id = egui::Id::unique(("ec-curves", euid, ch));
let (resp, edit) = fw::curves_graph(ui, gr, &cur, ch, pencil, true, id, &t);
app.auto.add(&format!("effectControls.effect.{euid}.curves.graph"), gr, "Curves");
let started = resp.drag_started() || resp.clicked();
if let Some(np) = edit.changed {
@@ -1033,7 +1024,8 @@ fn levels_editor(
};
if effect == "ec.color.levelsic" {
let names: [&str; 5] = [crate::i18n::tr("RGB"), crate::i18n::tr("Red"), crate::i18n::tr("Green"), crate::i18n::tr("Blue"), crate::i18n::tr("Alpha")];
if let Some(n) = channel_popup(app, ui, p, x0, cy, ch, &names, egui::Id::new(("ec-lch", euid)), &format!("effectControls.effect.{euid}.levels.channel"))
if let Some(n) =
channel_popup(app, ui, p, x0, cy, ch, &names, egui::Id::unique(("ec-lch", euid)), &format!("effectControls.effect.{euid}.levels.channel"))
{
app.ui.fx_levels_channel.insert(euid, n);
}
@@ -1055,24 +1047,24 @@ fn levels_editor(
let (ib, iw, gm, ob, ow) = (val(&ids[0]), val(&ids[1]), val(&ids[2]), val(&ids[3]), val(&ids[4]));
let mut set = |ui: &egui::Ui, id: &str, v: f64, started: bool| {
if let Some(pr) = g.prop(id) {
let key = gesture_key(ui, egui::Id::new(("ec-lv", euid, id)), started);
let key = gesture_key(ui, egui::Id::unique(("ec-lv", euid, id)), started);
actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": pr.uid, "value": v, "merge": key})));
}
};
let track = Rect::from_min_size(pos2(hr.min.x, hr.max.y + 1.0), vec2(w, 10.0));
let started = |ui: &egui::Ui| ui.input(|i| i.pointer.any_pressed());
let (r0, nv) = fw::level_handle(ui, track, ib, Color32::BLACK, egui::Id::new(("ec-lib", euid, ch)), &t);
let (r0, nv) = fw::level_handle(ui, track, ib, Color32::BLACK, egui::Id::unique(("ec-lib", euid, ch)), &t);
app.auto.add(&format!("effectControls.effect.{euid}.levels.inBlack"), r0, "Input Black");
if let Some(v) = nv {
set(ui, &ids[0], v.min(iw - 0.005), started(ui));
}
let gx = fw::gamma_handle(ib, iw, gm);
let (r1, nv) = fw::level_handle(ui, track, gx, Color32::from_gray(128), egui::Id::new(("ec-lg", euid, ch)), &t);
let (r1, nv) = fw::level_handle(ui, track, gx, Color32::from_gray(128), egui::Id::unique(("ec-lg", euid, ch)), &t);
app.auto.add(&format!("effectControls.effect.{euid}.levels.gamma"), r1, "Gamma");
if let Some(v) = nv {
set(ui, &ids[2], (fw::gamma_from_handle(ib, iw, v) * 100.0).round() / 100.0, started(ui));
}
let (r2, nv) = fw::level_handle(ui, track, iw, Color32::WHITE, egui::Id::new(("ec-liw", euid, ch)), &t);
let (r2, nv) = fw::level_handle(ui, track, iw, Color32::WHITE, egui::Id::unique(("ec-liw", euid, ch)), &t);
app.auto.add(&format!("effectControls.effect.{euid}.levels.inWhite"), r2, "Input White");
if let Some(v) = nv {
set(ui, &ids[1], v.max(ib + 0.005), started(ui));
@@ -1087,12 +1079,12 @@ fn levels_editor(
p.rect_filled(Rect::from_min_max(pos2(x0, bar.min.y), pos2(x1, bar.max.y)), 0.0, Color32::from_gray(v));
}
let otrack = Rect::from_min_size(pos2(bar.min.x, bar.max.y + 1.0), vec2(w, 10.0));
let (r3, nv) = fw::level_handle(ui, otrack, ob, Color32::BLACK, egui::Id::new(("ec-lob", euid, ch)), &t);
let (r3, nv) = fw::level_handle(ui, otrack, ob, Color32::BLACK, egui::Id::unique(("ec-lob", euid, ch)), &t);
app.auto.add(&format!("effectControls.effect.{euid}.levels.outBlack"), r3, "Output Black");
if let Some(v) = nv {
set(ui, &ids[3], v, started(ui));
}
let (r4, nv) = fw::level_handle(ui, otrack, ow, Color32::WHITE, egui::Id::new(("ec-low", euid, ch)), &t);
let (r4, nv) = fw::level_handle(ui, otrack, ow, Color32::WHITE, egui::Id::unique(("ec-low", euid, ch)), &t);
app.auto.add(&format!("effectControls.effect.{euid}.levels.outWhite"), r4, "Output White");
if let Some(v) = nv {
set(ui, &ids[4], v, started(ui));
@@ -1123,7 +1115,7 @@ fn histogram_only(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter
/// Open the rename field on effect `uid` (Enter with the effect selected), name selected.
pub fn start_rename(ctx: &egui::Context, uid: u64, name: &str) {
let id = egui::Id::new(("ec-rename", uid));
let id = egui::Id::unique(("ec-rename", uid));
ctx.data_mut(|d| d.insert_temp(id, name.to_string()));
ctx.data_mut(|d| d.insert_temp(id.with("select"), true));
ctx.memory_mut(|m| m.request_focus(id.with("field")));
@@ -1147,7 +1139,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
p.text(pos2(hdr.min.x + 10.0, hdr.center().y), Align2::LEFT_CENTER, format!("{} • {}", comp_name, layer.name), Tokens::ui(11.5), t.text_dim);
p.line_segment([hdr.left_bottom(), hdr.right_bottom()], Stroke::new(1.0, t.separator));
let body = Rect::from_min_max(pos2(rect.min.x, hdr.max.y), rect.max);
let scroll = widgets::PanelScroll::begin(ui, egui::Id::new("ec-scroll"), body);
let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("ec-scroll"), body);
let mut y = body.min.y + 4.0 - scroll.offset;
let mut actions: Actions = vec![];
let bp = p.with_clip_rect(body);
@@ -1165,12 +1157,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
);
}
// Clicking empty space deselects the effects.
let bg = ui.interact(body, egui::Id::new("ec-bg"), Sense::click());
let bg = ui.interact(body, egui::Id::unique("ec-bg"), Sense::click());
if bg.clicked() {
app.session.state.selected_props.retain(|(l, u)| !(*l == layer.id && fx.iter().any(|g| effect_active(g, layer.id, &[(*l, *u)]))));
}
let has_clip = !app.session.state.effect_clipboard.is_empty();
let drag_id = egui::Id::new("ec-drag-effect");
let drag_id = egui::Id::unique("ec-drag-effect");
let dragging: Option<u64> = ctx.data(|d| d.get_temp(drag_id));
// Header tops, for drag-to-reorder.
let mut tops: Vec<(u64, f32)> = vec![];
@@ -1197,16 +1189,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
);
}
let fxr = Rect::from_center_size(pos2(r.min.x + 14.0, r.center().y), vec2(16.0, 16.0));
if widgets::icon_toggle(ui, fxr, Icon::Fx, g.enabled, &t, egui::Id::new(("ec-fx", g.uid)), Sense::click()).clicked() {
if widgets::icon_toggle(ui, fxr, Icon::Fx, g.enabled, &t, egui::Id::unique(("ec-fx", g.uid)), Sense::click()).clicked() {
actions.push(("effect.toggle".into(), json!({"layer": layer.id.0, "effect": g.uid})));
}
app.auto.add(&format!("effectControls.effect.{}.fx", g.uid), fxr, &g.name);
let tw = Rect::from_center_size(pos2(r.min.x + 32.0, r.center().y), vec2(12.0, 12.0));
if widgets::twirl(ui, tw, open, egui::Id::new(("ec-tw", g.uid)), &t).clicked() {
if widgets::twirl(ui, tw, open, egui::Id::unique(("ec-tw", g.uid)), &t).clicked() {
toggle_closed(app, g, open, ui.input(|i| i.modifiers.alt || i.modifiers.command));
}
app.auto.add(&format!("effectControls.effect.{}.twirl", g.uid), tw, &g.name);
let rename_id = egui::Id::new(("ec-rename", g.uid));
let rename_id = egui::Id::unique(("ec-rename", g.uid));
let editing: Option<String> = ctx.data(|d| d.get_temp(rename_id));
let reset = Rect::from_min_size(pos2(r.max.x - 96.0, r.center().y - 9.0), vec2(40.0, 18.0));
if let Some(mut value) = editing.as_ref().cloned() {
@@ -1239,7 +1231,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let name = bp.text(pos2(tw.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, &shown, Tokens::semibold(12.0), t.text);
app.auto.add(&format!("effectControls.effect.{}.name", g.uid), name, &g.name);
}
let rresp = ui.interact(reset, egui::Id::new(("ec-reset", g.uid)), Sense::click());
let rresp = ui.interact(reset, egui::Id::unique(("ec-reset", g.uid)), Sense::click());
bp.text(reset.center(), Align2::CENTER_CENTER, crate::i18n::tr("Reset"), Tokens::ui(11.5), if rresp.hovered() { t.hot_text } else { t.text_dim });
app.auto.add(&format!("effectControls.effect.{}.reset", g.uid), reset, "Reset");
if rresp.clicked() {
@@ -1247,10 +1239,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
// About…: the effect's name and category (AE's About dialog).
let about = Rect::from_min_size(pos2(r.max.x - 50.0, r.center().y - 9.0), vec2(44.0, 18.0));
let aresp = ui.interact(about, egui::Id::new(("ec-about", g.uid)), Sense::click());
let aresp = ui.interact(about, egui::Id::unique(("ec-about", g.uid)), Sense::click());
bp.text(about.center(), Align2::CENTER_CENTER, crate::i18n::tr("About..."), Tokens::ui(11.5), if aresp.hovered() { t.hot_text } else { t.text_dim });
app.auto.add(&format!("effectControls.effect.{}.about", g.uid), about, "About");
let apop = egui::Id::new(("ec-about-pop", g.uid));
let apop = egui::Id::unique(("ec-about-pop", g.uid));
if aresp.clicked() {
widgets::open_popup(ui, apop);
}
@@ -1264,14 +1256,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
let hresp = ui.interact(
Rect::from_min_max(pos2(tw.max.x, r.min.y), pos2(reset.min.x - 4.0, r.max.y)),
egui::Id::new(("ec-hdr", g.uid)),
egui::Id::unique(("ec-hdr", g.uid)),
if editing.is_some() { Sense::hover() } else { Sense::click_and_drag() },
);
if hresp.double_clicked() {
ctx.data_mut(|d| d.insert_temp(rename_id, shown.clone()));
ctx.data_mut(|d| d.insert_temp(rename_id.with("start"), true));
}
hresp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, active, &shown));
hresp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, active, &shown));
app.auto.add(&format!("effectControls.effect.{}", g.uid), r, &g.name);
if hresp.clicked() || hresp.drag_started() || hresp.secondary_clicked() {
let add = ui.input(|i| i.modifiers.shift || i.modifiers.command);
@@ -1363,7 +1355,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if effect == "ec.color.tint" {
let r = Rect::from_min_size(pos2(body.min.x + PARAM_NAME_X, y + 2.0), vec2(120.0, ROW));
let label = crate::i18n::tr("Swap Colors");
let resp = widgets::text_button(ui, r, label, false, &t, egui::Id::new(("ec-tint-swap", g.uid)));
let resp = widgets::text_button(ui, r, label, false, &t, egui::Id::unique(("ec-tint-swap", g.uid)));
app.auto.add_response(&format!("effectControls.effect.{}.swapColors", g.uid), &resp, label);
if resp.clicked() {
actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": g.uid})));
@@ -1489,7 +1481,7 @@ pub fn viewer_hook(
// GPU frames: read the shown frame back for sampling.
app.viewer_pixels();
}
let resp = ui.interact(map.area, egui::Id::new("viewer-fx-pick"), Sense::click());
let resp = ui.interact(map.area, egui::Id::unique("viewer-fx-pick"), Sense::click());
app.auto.add("viewer.fxPick", map.area, &pick.name);
let hint = if pick.kind == "point" { format!("Click to set {}", pick.name) } else { format!("Click to sample {}", pick.name) };
let chip = Rect::from_min_size(map.area.min + vec2(10.0, 10.0), vec2(220.0, 22.0));
@@ -1554,7 +1546,7 @@ pub fn viewer_hook(
app.ui.status.clear();
} else {
app.viewer_pixels();
let resp = ui.interact(map.area, egui::Id::new("viewer-text-color-pick"), Sense::click());
let resp = ui.interact(map.area, egui::Id::unique("viewer-text-color-pick"), Sense::click());
app.auto.add("viewer.textColorPick", map.area, &pick.field);
let chip = Rect::from_min_size(map.area.min + vec2(10.0, 10.0), vec2(220.0, 22.0));
painter.rect_filled(chip, 11.0, Color32::from_black_alpha(180));
@@ -1648,7 +1640,7 @@ pub fn viewer_hook(
}
for (pr, s) in pts.iter().zip(screen) {
let hr = Rect::from_center_size(s, vec2(16.0, 16.0));
let id = egui::Id::new(("viewer-fx-pt", pr.uid));
let id = egui::Id::unique(("viewer-fx-pt", pr.uid));
// The picker owns the click. Preview handles must not steal it or start a drag.
let resp = (!picking).then(|| ui.interact(hr, id, Sense::drag()));
let col = if picked_prop == Some(pr.uid) || resp.as_ref().is_some_and(|r| r.hovered() || r.dragged()) { t.accent } else { Color32::WHITE };
+10 -10
View File
@@ -133,7 +133,7 @@ fn folder_row(
if r.max.y < list.min.y || r.min.y > list.max.y {
return open;
}
let resp = ui.interact(r.intersect(list), egui::Id::new(("fxcat", key)), Sense::click());
let resp = ui.interact(r.intersect(list), egui::Id::unique(("fxcat", key)), Sense::click());
if resp.hovered() {
lp.rect_filled(r, 0.0, t.hover);
}
@@ -179,7 +179,7 @@ fn effect_row(
return;
}
let star = Rect::from_center_size(pos2(r.max.x - 38.0, r.center().y), vec2(16.0, 16.0));
let resp = ui.interact(r.intersect(list), egui::Id::new((auto_prefix, e.id)), Sense::click_and_drag());
let resp = ui.interact(r.intersect(list), egui::Id::unique((auto_prefix, e.id)), Sense::click_and_drag());
if resp.hovered() {
lp.rect_filled(r, 0.0, t.hover);
}
@@ -194,7 +194,7 @@ fn effect_row(
app.auto.add(&format!("{auto_prefix}.{}", e.id), r, e.name);
// Favourite star (always shown when starred, else on hover).
let fav = app.session.prefs.is_effect_favorite(e.id);
let sresp = ui.interact(star.intersect(list), egui::Id::new((auto_prefix, e.id, "star")), Sense::click());
let sresp = ui.interact(star.intersect(list), egui::Id::unique((auto_prefix, e.id, "star")), Sense::click());
if fav || resp.hovered() || sresp.hovered() {
let gold = Color32::from_rgb(0xe8, 0xc0, 0x40);
filled_star(lp, star.center(), 6.0, fav.then_some(gold), if fav || sresp.hovered() { gold } else { t.text_faint });
@@ -234,7 +234,7 @@ fn preset_row(
if r.max.y < list.min.y || r.min.y > list.max.y {
return;
}
let resp = ui.interact(r.intersect(list), egui::Id::new(("fxpreset", auto_id)), Sense::click_and_drag());
let resp = ui.interact(r.intersect(list), egui::Id::unique(("fxpreset", auto_id)), Sense::click_and_drag());
if resp.hovered() {
lp.rect_filled(r, 0.0, t.hover);
}
@@ -251,7 +251,7 @@ fn preset_row(
/// User preset files, rescanned every few seconds (and on Refresh List).
fn user_presets(ctx: &egui::Context, force: bool, scan: impl FnOnce() -> Vec<PresetFile>) -> Arc<Vec<PresetFile>> {
let id = egui::Id::new("fx-user-presets");
let id = egui::Id::unique("fx-user-presets");
let now = ctx.input(|i| i.time);
if !force
&& let Some((at, v)) = ctx.data(|d| d.get_temp::<(f64, Arc<Vec<PresetFile>>)>(id))
@@ -281,8 +281,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
app.auto.add("effects.search", sr, "Search");
// Contents menu.
let mr = Rect::from_center_size(pos2(rect.max.x - 18.0, sr.center().y), vec2(22.0, 22.0));
let mpop = egui::Id::new("fx-contents-menu");
if widgets::icon_button(ui, mr, Icon::Hamburger, false, &t, egui::Id::new("fx-menu")).on_hover_text(tr("Effects & Presets options")).clicked() {
let mpop = egui::Id::unique("fx-contents-menu");
if widgets::icon_button(ui, mr, Icon::Hamburger, false, &t, egui::Id::unique("fx-menu")).on_hover_text(tr("Effects & Presets options")).clicked() {
widgets::open_popup(ui, mpop);
}
app.auto.add("effects.menu", mr, "Effects & Presets options");
@@ -350,7 +350,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let view = app.ui.effects_view.clone();
let list = Rect::from_min_max(pos2(rect.min.x, sr.max.y + 6.0), rect.max);
let lp = p.with_clip_rect(list);
let scroll = widgets::PanelScroll::begin(ui, egui::Id::new("fx-scroll"), list);
let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("fx-scroll"), list);
let query = q.trim().to_lowercase();
let searching = !query.is_empty();
let mut y = list.min.y - scroll.offset;
@@ -462,7 +462,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
_ => None,
}) && let Some(pos) = ctx.pointer_hover_pos()
{
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("dnd-fx")));
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("dnd-fx")));
let g = painter.layout_no_wrap(name, Tokens::ui(12.0), t.text);
let r = Rect::from_min_size(pos + vec2(12.0, 8.0), g.size() + vec2(16.0, 8.0));
painter.rect_filled(r, 4.0, Color32::from_rgba_premultiplied(40, 40, 40, 230));
@@ -582,7 +582,7 @@ mod tests {
for _ in 0..5 {
let start = std::time::Instant::now();
for _ in 0..1000 {
let (_, shared) = ctx.data(|d| d.get_temp::<(f64, Arc<Vec<PresetFile>>)>(egui::Id::new("fx-user-presets"))).unwrap();
let (_, shared) = ctx.data(|d| d.get_temp::<(f64, Arc<Vec<PresetFile>>)>(egui::Id::unique("fx-user-presets"))).unwrap();
std::hint::black_box((*shared).clone());
}
before.push(start.elapsed());
+7 -3
View File
@@ -79,6 +79,10 @@ fn body(app: &mut EffectcraftApp, ui: &mut egui::Ui) -> (Actions, Actions) {
}
}
});
// The label beside the combo names it for a screen reader; the selected comp alone
// doesn't say what the dropdown chooses. Keep the visible label and value together.
let primary_name = format!("{} {}", tr("Primary:"), comp_name);
r.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, ui.is_enabled(), &primary_name));
app.auto.add("essential.primary", r.response.rect, &comp_name);
});
let solo = app.session.state.essential_solo;
@@ -90,7 +94,7 @@ fn body(app: &mut EffectcraftApp, ui: &mut egui::Ui) -> (Actions, Actions) {
// ---- Name.
let eg = project.comp(cid).and_then(|c| c.essential.clone()).unwrap_or_default();
let shown_name = if eg.name.is_empty() { comp_name.clone() } else { eg.name.clone() };
let name_id = egui::Id::new("eg-name-buf");
let name_id = egui::Id::unique("eg-name-buf");
let mut name: String = ctx.data(|d| d.get_temp(name_id)).unwrap_or_else(|| shown_name.clone());
let r = ui.add(egui::TextEdit::singleline(&mut name).hint_text(tr("Template name")).desired_width(f32::INFINITY));
app.auto.add("essential.name", r.rect, &shown_name);
@@ -291,7 +295,7 @@ fn link_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui, cid: ItemId, c: &EgCon
/// A control's name; double-click to rename (comments: edit the text).
fn name_label(app: &mut EffectcraftApp, ui: &mut egui::Ui, cid: ItemId, c: &EgControl, text: RichText, actions: &mut Actions) {
let edit_id = egui::Id::new(("eg-rename", c.id));
let edit_id = egui::Id::unique(("eg-rename", c.id));
let editing: Option<String> = ui.ctx().data(|d| d.get_temp(edit_id));
match editing {
Some(mut buf) => {
@@ -329,7 +333,7 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt
KV::Str(s) => s.clone(),
_ => String::new(),
};
let id = egui::Id::new(("eg-text", auto));
let id = egui::Id::unique(("eg-text", auto));
let mut buf: String = ui.ctx().data(|d| d.get_temp(id)).unwrap_or_else(|| cur.clone());
let r = ui.add(egui::TextEdit::singleline(&mut buf).desired_width(140.0));
if r.has_focus() {
+3 -3
View File
@@ -24,7 +24,7 @@ struct Cache {
}
fn cache_id(cid: ItemId) -> egui::Id {
egui::Id::new(("expr-error-bar", cid.0))
egui::Id::unique(("expr-error-bar", cid.0))
}
/// Both layout and painting read the same completed diagnostics; neither evaluates expressions.
@@ -64,7 +64,7 @@ pub fn draw(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, cid: ItemId
let count = Rect::from_center_size(pos2(rect.max.x - 44.0, rect.center().y), vec2(40.0, 16.0));
let prev = Rect::from_center_size(pos2(rect.max.x - 74.0, rect.center().y), vec2(16.0, 16.0));
for (r, label, auto, d) in [(prev, "◀", "viewer.exprErrors.prev", n - 1), (next, "▶", "viewer.exprErrors.next", 1)] {
let resp = ui.interact(r, egui::Id::new(("expr-bar", auto)), Sense::click());
let resp = ui.interact(r, egui::Id::unique(("expr-bar", auto)), Sense::click());
p.text(r.center(), Align2::CENTER_CENTER, label, Tokens::ui(11.0), if resp.hovered() { Color32::WHITE } else { t.text });
app.auto.add(auto, r, label);
if resp.clicked() {
@@ -76,7 +76,7 @@ pub fn draw(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, cid: ItemId
let comp_name = app.session.project.item(cid).map(|it| it.name.clone()).unwrap_or_default();
let Some(e) = c.errors.get(i).cloned() else { return };
let msg_rect = Rect::from_min_max(pos2(rect.min.x + 26.0, rect.min.y), pos2(prev.min.x - 6.0, rect.max.y));
let resp = ui.interact(msg_rect, egui::Id::new("expr-bar-msg"), Sense::click()).on_hover_text(tr("Click to reveal the expression in the timeline"));
let resp = ui.interact(msg_rect, egui::Id::unique("expr-bar-msg"), Sense::click()).on_hover_text(tr("Click to reveal the expression in the timeline"));
p.with_clip_rect(msg_rect).text(
pos2(msg_rect.min.x, rect.center().y),
Align2::LEFT_CENTER,
+3 -3
View File
@@ -270,7 +270,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
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() {
if widgets::text_button(ui, r, label, on, &t, egui::Id::unique(("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));
}
@@ -282,7 +282,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
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());
let bg = ui.interact(body, egui::Id::unique("flow-bg"), Sense::click_and_drag());
if bg.dragged() {
let d = bg.drag_delta();
app.ui.flowchart.pan[0] += d.x;
@@ -322,7 +322,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
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 resp = ui.interact(r.intersect(body), egui::Id::unique(("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(
+3 -3
View File
@@ -58,7 +58,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
&& f.has_video
{
let key = (view.item.0, view.time.0, app.session.revision);
let tid = egui::Id::new("footage-panel-tex");
let tid = egui::Id::unique("footage-panel-tex");
let cached: Option<((u64, i64, u64), egui::TextureHandle, [u32; 2])> = ctx.data(|d| d.get_temp(tid));
let tex = match cached {
Some((k, tex, size)) if k == key => Some((tex, size)),
@@ -97,7 +97,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
p.rect_filled(Rect::from_min_max(pos2(x_of(a), ruler.max.y - 4.0), pos2(x_of(b), ruler.max.y)), 0.0, t.work_area);
let cx = x_of(view.time);
p.line_segment([pos2(cx, ruler.min.y), pos2(cx, ruler.max.y)], Stroke::new(1.5, t.cti));
let resp = ui.interact(ruler, egui::Id::new("footage-ruler"), Sense::click_and_drag());
let resp = ui.interact(ruler, egui::Id::unique("footage-ruler"), Sense::click_and_drag());
app.auto.add("footage.ruler", ruler, "Time ruler");
if (resp.clicked() || resp.dragged())
&& let Some(pt) = resp.interact_pointer_pos()
@@ -106,7 +106,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
kit::exec(app, "footage.setTime", json!({"time": s}));
}
// Playback.
let play_id = egui::Id::new("footage-playing");
let play_id = egui::Id::unique("footage-playing");
let playing: bool = ctx.data(|d| d.get_temp(play_id).unwrap_or(false));
if playing {
let dt = ctx.input(|i| i.stable_dt).min(0.1) as f64;
+2
View File
@@ -542,6 +542,8 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
}
},
),
// Time Display Style ▸ Frames ▸ Frame Count (also numbers Feet + Frames).
Field::choice("frameStart", "Frame count", &[("Start at 0", json!(0)), ("Start at 1", json!(1))], usize::from(st.frame_start == 1)),
Field::choice(
"colorEngine",
"Color engine",
+17 -17
View File
@@ -83,7 +83,7 @@ fn tabs(
for (i, name) in names.iter().enumerate() {
let w = if cols.is_empty() { 16.0 + name.chars().count() as f32 * 6.2 } else { 20.0 };
let r = Rect::from_min_size(pos2(x, cy - 9.0), vec2(w, 18.0));
let resp = ui.interact(r, egui::Id::new((id, i)), Sense::click());
let resp = ui.interact(r, egui::Id::unique((id, i)), Sense::click());
let sel = i == cur;
if sel {
p.rect_filled(r, 3.0, t.row_selected);
@@ -113,7 +113,7 @@ fn tabs(
fn reset_link(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, r: Rect, id: &str) -> bool {
let t = app.tokens;
let resp = ui.interact(r, egui::Id::new(id), Sense::click());
let resp = ui.interact(r, egui::Id::unique(id), Sense::click());
p.text(r.center(), Align2::CENTER_CENTER, tr("Reset"), Tokens::ui(11.5), if resp.hovered() { t.hot_text } else { t.text_dim });
app.auto.add(id, r, "Reset");
resp.clicked()
@@ -192,8 +192,8 @@ fn rgb_curves(
set_str(actions, layer, g, id, fw::CurvePoints::identity().format(), None);
}
let gr = Rect::from_min_size(pos2(x0, r.min.y + 30.0), vec2(size, size));
let gid = egui::Id::new(("lumetri-rgb", euid, ch));
let (resp, edit) = fw::curves_graph(ui, gr, &cur, ch, false, gid, &t);
let gid = egui::Id::unique(("lumetri-rgb", euid, ch));
let (resp, edit) = fw::curves_graph(ui, gr, &cur, ch, false, false, gid, &t);
app.auto.add(&format!("{prefix}.graph"), gr, "RGB Curves");
register_points(app, &prefix, gr, &cur.0);
let key = gesture_key(ui, gid, resp.drag_started() || resp.clicked());
@@ -321,7 +321,7 @@ fn huesat_curves(
})
.collect();
p.add(egui::Shape::line(line, Stroke::new(1.5, Color32::from_gray(0xe8))));
let gid = egui::Id::new(("lumetri-hs", euid, k));
let gid = egui::Id::unique(("lumetri-hs", euid, k));
let resp = ui.interact(gr.expand(6.0), gid, Sense::click_and_drag());
app.auto.add(&format!("{prefix}.graph"), gr, name);
let drag_id = gid.with("drag");
@@ -476,7 +476,7 @@ fn colorama_wheel(
}
p.circle_stroke(c, outer, Stroke::new(1.0, Color32::from_black_alpha(160)));
p.circle_stroke(c, inner, Stroke::new(1.0, Color32::from_black_alpha(160)));
let wid = egui::Id::new(("colorama-wheel", euid));
let wid = egui::Id::unique(("colorama-wheel", euid));
let ring = Rect::from_center_size(c, vec2(outer * 2.0 + 40.0, outer * 2.0 + 40.0));
let resp = ui.interact(ring, wid, Sense::click_and_drag());
app.auto.add(&format!("{prefix}.wheel"), ring, "Output Cycle");
@@ -712,8 +712,8 @@ fn glow_map(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, laye
set_str(actions, layer, g, "arbitraryMap", join_map(&m), None);
}
let gr = Rect::from_min_size(pos2(x0, r.min.y + 30.0), vec2(size, size));
let gid = egui::Id::new(("glow-map", euid, ch));
let (resp, edit) = fw::curves_graph(ui, gr, &map[ch], ch + 1, false, gid, &t);
let gid = egui::Id::unique(("glow-map", euid, ch));
let (resp, edit) = fw::curves_graph(ui, gr, &map[ch], ch + 1, false, false, gid, &t);
app.auto.add(&format!("{prefix}.graph"), gr, "Arbitrary Map");
register_points(app, &prefix, gr, &map[ch].0);
let key = gesture_key(ui, gid, resp.drag_started() || resp.clicked());
@@ -786,19 +786,19 @@ fn reshape_buttons(
let b1 = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 4.0), vec2(84.0, 22.0));
let b2 = Rect::from_min_size(pos2(b1.max.x + 8.0, b1.min.y), vec2(96.0, 22.0));
let b3 = Rect::from_min_size(pos2(b2.max.x + 8.0, b1.min.y), vec2(56.0, 22.0));
if widgets::text_button(ui, b1, "Add Point", false, &t, egui::Id::new(("reshape-add", euid))).clicked() {
if widgets::text_button(ui, b1, "Add Point", false, &t, egui::Id::unique(("reshape-add", euid))).clicked() {
let mut v = pairs.clone();
v.push(add_pair(&pairs));
set_str(actions, layer, g, "correspondencePoints", format_pairs(&v), None);
}
app.auto.add(&format!("effectControls.effect.{euid}.reshape.add"), b1, "Add Point");
if widgets::text_button(ui, b2, "Remove Point", false, &t, egui::Id::new(("reshape-remove", euid))).clicked() && !pairs.is_empty() {
if widgets::text_button(ui, b2, "Remove Point", false, &t, egui::Id::unique(("reshape-remove", euid))).clicked() && !pairs.is_empty() {
let mut v = pairs.clone();
v.pop();
set_str(actions, layer, g, "correspondencePoints", format_pairs(&v), None);
}
app.auto.add(&format!("effectControls.effect.{euid}.reshape.remove"), b2, "Remove Point");
if widgets::text_button(ui, b3, "Clear", false, &t, egui::Id::new(("reshape-clear", euid))).clicked() && !pairs.is_empty() {
if widgets::text_button(ui, b3, "Clear", false, &t, egui::Id::unique(("reshape-clear", euid))).clicked() && !pairs.is_empty() {
set_str(actions, layer, g, "correspondencePoints", String::new(), None);
}
app.auto.add(&format!("effectControls.effect.{euid}.reshape.clear"), b3, "Clear");
@@ -857,8 +857,8 @@ fn extractor_layers(
p.text(pos2(r.min.x + 37.0, r.center().y), Align2::LEFT_CENTER, tr("Layer"), Tokens::ui(12.0), t.text);
let vx = (r.min.x + r.width() * 0.48).max(r.min.x + 161.0);
let dr = Rect::from_min_size(pos2(vx, r.center().y - 9.0), vec2((r.max.x - vx - 56.0).clamp(80.0, 200.0), 18.0));
let pop = egui::Id::new(("extractor-layers", euid));
if widgets::dropdown(ui, dr, &label, &t, egui::Id::new(("extractor-layer", euid)))
let pop = egui::Id::unique(("extractor-layers", euid));
if widgets::dropdown(ui, dr, &label, &t, egui::Id::unique(("extractor-layer", euid)))
.on_hover_text(tr("The OpenEXR layer shown in red, green, blue and alpha"))
.clicked()
{
@@ -899,8 +899,8 @@ pub fn channel_popup(
let t = app.tokens;
let uid = prop.uid;
let label = if cur.trim().is_empty() { NO_CHANNEL } else { cur };
let pop = egui::Id::new(("ec-chpop", uid));
if widgets::dropdown(ui, r, label, &t, egui::Id::new(("ec-ch", uid))).clicked() {
let pop = egui::Id::unique(("ec-chpop", uid));
if widgets::dropdown(ui, r, label, &t, egui::Id::unique(("ec-ch", uid))).clicked() {
widgets::open_popup(ui, pop);
}
app.auto.add(&format!("effectControls.prop.{uid}.value"), r, &prop.name);
@@ -992,7 +992,7 @@ pub fn reshape_overlay(
painter.line_segment([ps, pd], Stroke::new(1.0, Color32::from_white_alpha(150)));
for (end, s, col) in [(0usize, ps, ys), (1, pd, cs)] {
let hr = Rect::from_center_size(s, vec2(12.0, 12.0));
let id = egui::Id::new(("reshape-pt", g.uid, i, end));
let id = egui::Id::unique(("reshape-pt", g.uid, i, end));
let resp = ui.interact(hr, id, Sense::drag());
let hot = resp.hovered() || resp.dragged();
painter.rect_filled(Rect::from_center_size(s, vec2(7.0, 7.0)), 0.0, if hot { Color32::WHITE } else { col });
@@ -1086,7 +1086,7 @@ pub fn mesh_warp_overlay(
let accent = app.tokens.accent;
for (k, s) in screen.iter().enumerate() {
let hr = Rect::from_center_size(*s, vec2(10.0, 10.0));
let id = egui::Id::new(("mesh-warp-vertex", g.uid, k));
let id = egui::Id::unique(("mesh-warp-vertex", g.uid, k));
let resp = ui.interact(hr, id, Sense::drag());
let hot = resp.hovered() || resp.dragged();
let dot = Rect::from_center_size(*s, vec2(5.0, 5.0));
+26 -6
View File
@@ -291,7 +291,8 @@ impl CurvePoints {
pub fn hit(&self, x: f32, y: f32, r: f32) -> Option<usize> {
self.0.iter().enumerate().map(|(i, p)| (i, (p[0] - x).hypot(p[1] - y))).filter(|(_, d)| *d <= r).min_by(|a, b| a.1.total_cmp(&b.1)).map(|(i, _)| i)
}
/// The curve the effect renders (the same monotone spline), evaluated at `x`.
/// The monotone spline (`Curve::parse`) through the points, evaluated at `x`: the curve
/// Lumetri's RGB curves and Glow's arbitrary map render.
pub fn eval(&self, x: f32) -> f32 {
effectcraft_engine::effects::Curve::parse(&self.format()).map(|c| c.eval(x)).unwrap_or(x)
}
@@ -326,8 +327,18 @@ pub struct CurveEdit {
/// The Curves graph: grid, identity diagonal, the curve, its points. Click adds a point, drag
/// moves one, dragging a point off the graph (or Alt-clicking it) removes it. In pencil mode a
/// drag draws the curve freehand.
pub fn curves_graph(ui: &mut egui::Ui, rect: Rect, pts: &CurvePoints, channel: usize, pencil: bool, id: egui::Id, t: &Tokens) -> (egui::Response, CurveEdit) {
/// drag draws the curve freehand. `natural` draws the natural spline the Curves effect renders
/// (`Curve::parse_natural`) instead of the monotone one.
pub fn curves_graph(
ui: &mut egui::Ui,
rect: Rect,
pts: &CurvePoints,
channel: usize,
pencil: bool,
natural: bool,
id: egui::Id,
t: &Tokens,
) -> (egui::Response, CurveEdit) {
let resp = ui.interact(rect.expand(6.0), id, Sense::click_and_drag());
let p = ui.painter().with_clip_rect(rect.expand(8.0));
p.rect_filled(rect, 0.0, Color32::from_rgb(0x1c, 0x1c, 0x1c));
@@ -349,7 +360,16 @@ pub fn curves_graph(ui: &mut egui::Ui, rect: Rect, pts: &CurvePoints, channel: u
};
let to_s = |x: f32, y: f32| pos2(rect.min.x + x * rect.width(), rect.max.y - y * rect.height());
let to_g = |s: Pos2| [((s.x - rect.min.x) / rect.width()), ((rect.max.y - s.y) / rect.height())];
let spline = effectcraft_engine::effects::Curve::parse(&pts.format());
let spline =
if natural { effectcraft_engine::effects::Curve::parse_natural(&pts.format()) } else { effectcraft_engine::effects::Curve::parse(&pts.format()) };
let seed = || {
(0..=64)
.map(|i| {
let x = i as f32 / 64.0;
spline.as_ref().map(|c| c.eval(x)).unwrap_or(x)
})
.collect::<Vec<f32>>()
};
let line: Vec<Pos2> = (0..=96)
.map(|i| {
let x = i as f32 / 96.0;
@@ -375,12 +395,12 @@ pub fn curves_graph(ui: &mut egui::Ui, rect: Rect, pts: &CurvePoints, channel: u
let alt = ui.input(|i| i.modifiers.alt);
if pencil {
if resp.drag_started() {
ui.data_mut(|d| d.insert_temp(pencil_id, (0..=64).map(|i| pts.eval(i as f32 / 64.0)).collect::<Vec<f32>>()));
ui.data_mut(|d| d.insert_temp(pencil_id, seed()));
}
if (resp.dragged() || resp.clicked())
&& let Some(pt) = resp.interact_pointer_pos()
{
let mut samples: Vec<f32> = ui.data(|d| d.get_temp(pencil_id)).unwrap_or_else(|| (0..=64).map(|i| pts.eval(i as f32 / 64.0)).collect());
let mut samples: Vec<f32> = ui.data(|d| d.get_temp(pencil_id)).unwrap_or_else(seed);
let g = to_g(pt);
let i = (g[0].clamp(0.0, 1.0) * 64.0).round() as usize;
samples[i.min(64)] = g[1].clamp(0.0, 1.0);
+2 -2
View File
@@ -291,7 +291,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
// Clicking a row's empty space adds a stop there.
for (track, rowr, id) in [(Track::Opacity, opacity_row, "dialog.gradient.addOpacity"), (Track::Color, color_row, "dialog.gradient.addColor")] {
let r = ui.interact(rowr, egui::Id::new(("gradient-add", id)), Sense::click());
let r = ui.interact(rowr, egui::Id::unique(("gradient-add", id)), Sense::click());
app.auto.add(id, rowr, id);
if r.clicked()
&& let Some(p) = r.interact_pointer_pos()
@@ -323,7 +323,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
Stroke::new(if selected { 2.0 } else { 1.0 }, if selected { t.accent } else { t.field_border }),
StrokeKind::Outside,
);
let resp = ui.interact(r.expand(2.0), egui::Id::new(("gradient-stop", track == Track::Color, id)), Sense::click_and_drag());
let resp = ui.interact(r.expand(2.0), egui::Id::unique(("gradient-stop", track == Track::Color, id)), Sense::click_and_drag());
let name = match track {
Track::Opacity => format!("dialog.gradient.opacityStop.{i}"),
Track::Color => format!("dialog.gradient.colorStop.{i}"),
@@ -76,7 +76,7 @@ pub fn viewer_hook(
}
for (pr, s) in pts.iter().zip(screen) {
let hr = Rect::from_center_size(s, vec2(16.0, 16.0));
let id = egui::Id::new(("viewer-gradient-pt", pr.uid));
let id = egui::Id::unique(("viewer-gradient-pt", pr.uid));
let resp = ui.interact(hr, id, Sense::drag());
let col = if resp.hovered() || resp.dragged() { t.accent } else { Color32::WHITE };
fw::draw_crosshair(painter, s + vec2(1.0, 1.0), 5.0, Color32::from_black_alpha(160));
+16 -16
View File
@@ -190,7 +190,7 @@ impl ButtonBar {
const MORE_W: f32 = 24.0;
fn new(ctx: &egui::Context, bar: Rect) -> Self {
let chosen = ctx.data_mut(|d| d.remove_temp::<String>(egui::Id::new(MORE_POPUP).with("chosen")));
let chosen = ctx.data_mut(|d| d.remove_temp::<String>(egui::Id::unique(MORE_POPUP).with("chosen")));
ButtonBar { x: bar.min.x + 8.0, limit: bar.max.x - 8.0 - Self::MORE_W - 4.0, hidden: Vec::new(), chosen }
}
@@ -201,7 +201,7 @@ impl ButtonBar {
}
let t = app.tokens;
let r = Rect::from_min_size(pos2(bar.max.x - 8.0 - Self::MORE_W, bar.center().y - 9.0), vec2(Self::MORE_W, 18.0));
let pid = egui::Id::new(MORE_POPUP);
let pid = egui::Id::unique(MORE_POPUP);
let resp = ui.interact(r, pid.with("button"), Sense::click()).on_hover_text(crate::i18n::tr("More"));
p.rect_filled(r, 3.0, if resp.hovered() { t.hover } else { Color32::from_rgb(0x2c, 0x2c, 0x2c) });
p.text(r.center(), Align2::CENTER_CENTER, "»", Tokens::ui(12.0), t.text);
@@ -252,7 +252,7 @@ fn text_button(
return bar.chosen.as_deref() == Some(id);
}
let r = Rect::from_min_size(pos2(bar.x, y - 9.0), vec2(w, 18.0));
let resp = ui.interact(r, egui::Id::new(("graph-btn", id)), Sense::click()).on_hover_text(tip);
let resp = ui.interact(r, egui::Id::unique(("graph-btn", id)), Sense::click()).on_hover_text(tip);
p.rect_filled(
r,
3.0,
@@ -278,14 +278,14 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
let plot = Rect::from_min_max(area.min + vec2(0.0, 6.0), pos2(area.max.x, area.max.y - footer_h));
let bar = Rect::from_min_max(pos2(area.min.x, area.max.y - footer_h), area.max);
// Background first so keys and handles (added later) sit on top for hit-testing.
let bg = ui.interact(plot, egui::Id::new("graph-bg"), Sense::click_and_drag());
let bg = ui.interact(plot, egui::Id::unique("graph-bg"), Sense::click_and_drag());
p.rect_filled(area, 0.0, Color32::from_rgb(0x1d, 0x1d, 0x1d));
app.auto.add("timeline.graph", plot, &app.ui.timeline.graph_mode.clone());
let speed = speed_graph(app, comp);
let cs = curves(app, comp, ectx, tm, plot, speed);
// Just opened, or the graph type changed (value and speed have different units): frame the
// graphs (Fit Selection to View, else Fit All Graphs to View).
let was_speed_id = egui::Id::new("graph-was-speed");
let was_speed_id = egui::Id::unique("graph-was-speed");
let type_changed = ctx.data(|d| d.get_temp::<bool>(was_speed_id)).is_some_and(|was| was != speed);
ctx.data_mut(|d| d.insert_temp(was_speed_id, speed));
if ctx.data_mut(|d| d.remove_temp::<bool>(fit_pending_id())).unwrap_or(false) || type_changed {
@@ -322,7 +322,7 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
let pad = (hi - lo) * 0.08;
let auto = (lo - pad, hi + pad);
// While a key or handle is being dragged the scale stays put (no feedback loop).
let drag_id = egui::Id::new("graph-dragging");
let drag_id = egui::Id::unique("graph-dragging");
let dragging = ctx.data(|d| d.get_temp::<bool>(drag_id)).unwrap_or(false) && ctx.input(|i| i.pointer.any_down());
let (lo, hi) = if app.ui.timeline.graph_auto_zoom && !dragging { auto } else { app.ui.timeline.graph_range.unwrap_or(auto) };
if app.ui.timeline.graph_auto_zoom && !dragging {
@@ -398,7 +398,7 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
let kp = pos2(tm.x(k.t), ymap(k.v));
// In the speed graph an end key rises with the handle being dragged; skipping it once it
// leaves the plot would drop the handle's widget and end the drag at the top edge.
let handle_dragged = || ["in", "out"].iter().any(|side| ctx.is_being_dragged(egui::Id::new(("ghandle", uid, d, k.idx, *side))));
let handle_dragged = || ["in", "out"].iter().any(|side| ctx.is_being_dragged(egui::Id::unique(("ghandle", uid, d, k.idx, *side))));
if !plot.expand(6.0).contains(kp) && !handle_dragged() {
continue;
}
@@ -411,7 +411,7 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
pc.circle_filled(hp, 3.5, Color32::from_rgb(0xd8, 0xd8, 0x60));
let side = if h.out { "out" } else { "in" };
let hr = Rect::from_center_size(hp, vec2(10.0, 10.0));
let hresp = ui.interact(hr, egui::Id::new(("ghandle", uid, d, k.idx, side)), Sense::drag());
let hresp = ui.interact(hr, egui::Id::unique(("ghandle", uid, d, k.idx, side)), Sense::drag());
app.auto.add(&format!("timeline.graph.handle.{uid}.{d}.{}.{side}", k.idx), hr, "Bezier handle");
// The same side's handles of every selected key move with it, by as much as the
// pointer moved (After Effects), in one undo step.
@@ -464,7 +464,7 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
pc.rect_filled(Rect::from_center_size(kp, vec2(7.0, 7.0)), 0.0, col);
pc.rect_stroke(Rect::from_center_size(kp, vec2(7.0, 7.0)), 0.0, Stroke::new(1.0, c.color), StrokeKind::Outside);
let kr = Rect::from_center_size(kp, vec2(11.0, 11.0));
let kresp = ui.interact(kr, egui::Id::new(("gkey", uid, d, k.idx)), Sense::click_and_drag());
let kresp = ui.interact(kr, egui::Id::unique(("gkey", uid, d, k.idx)), Sense::click_and_drag());
app.auto.add(&format!("timeline.graph.key.{uid}.{d}.{}", k.idx), kr, &c.prop.name);
let mods = ui.input(|i| i.modifiers);
let key = &c.prop.keys[k.idx];
@@ -526,12 +526,12 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
let br = Rect::from_two_pos(o, cur);
pc.rect_filled(br, 0.0, t.accent.gamma_multiply(0.12));
pc.rect_stroke(br, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Middle);
ctx.data_mut(|d| d.insert_temp(egui::Id::new("graph-box"), br));
ctx.data_mut(|d| d.insert_temp(egui::Id::unique("graph-box"), br));
}
if bg.drag_stopped()
&& let Some(br) = ctx.data(|d| d.get_temp::<Rect>(egui::Id::new("graph-box")))
&& let Some(br) = ctx.data(|d| d.get_temp::<Rect>(egui::Id::unique("graph-box")))
{
ctx.data_mut(|d| d.remove::<Rect>(egui::Id::new("graph-box")));
ctx.data_mut(|d| d.remove::<Rect>(egui::Id::unique("graph-box")));
let keys: Vec<serde_json::Value> = key_screens.iter().filter(|(_, sp)| br.contains(*sp)).map(|(k, _)| k.clone()).collect();
actions.push(("keys.select".into(), json!({"keys": keys, "add": ui.input(|i| i.modifiers.shift)})));
}
@@ -628,14 +628,14 @@ fn fit_all(app: &mut EffectcraftApp) {
}
fn fit_pending_id() -> egui::Id {
egui::Id::new("graph-fit-pending")
egui::Id::unique("graph-fit-pending")
}
/// Called every Timeline frame with whether the Graph Editor is on: switching it on (header
/// button, menu, Shift+F3 or the control channel) frames the graphs on its first frame, when the
/// plot rect is known, as After Effects does.
pub(crate) fn track_open(ctx: &egui::Context, on: bool) {
let was_id = egui::Id::new("graph-editor-was-on");
let was_id = egui::Id::unique("graph-editor-was-on");
let was = ctx.data(|d| d.get_temp::<bool>(was_id)).unwrap_or(false);
ctx.data_mut(|d| {
d.insert_temp(was_id, on);
@@ -740,7 +740,7 @@ struct KeyGrab {
}
fn key_grab_id() -> egui::Id {
egui::Id::new("graph-key-grab")
egui::Id::unique("graph-key-grab")
}
/// A Bezier handle moving with a handle drag: its key, side and dimension, the key's place
@@ -787,7 +787,7 @@ impl HandleGrab {
type HandleDrag = (Pos2, Vec<HandleGrab>);
fn handle_drag_id() -> egui::Id {
egui::Id::new("graph-handle-drag")
egui::Id::unique("graph-handle-drag")
}
/// Which axes a drag may move on (time, value): both, or with Shift only the one the pointer moved more along.
+5 -5
View File
@@ -35,7 +35,7 @@ struct BoxDrag {
}
fn box_id() -> egui::Id {
egui::Id::new("graph-transform-box")
egui::Id::unique("graph-transform-box")
}
/// Bounds of the selected keys: (t0, v0, t1, v1).
@@ -80,7 +80,7 @@ pub(crate) fn transform_box(
let value_ok = !speed && keys.iter().all(|k| k.editable);
let col = Color32::from_rgb(0xd8, 0xd8, 0xd8);
let ctx = ui.ctx().clone();
let drag_id = egui::Id::new("graph-dragging");
let drag_id = egui::Id::unique("graph-dragging");
let hp = handle_points(r);
let data = |q: Pos2| [tm.t(q.x), vmap(q.y)];
// Which data point each handle sits on, and its opposite.
@@ -100,7 +100,7 @@ pub(crate) fn transform_box(
let mut resp_any: Option<egui::Response> = None;
if !handles {
p.rect_stroke(r, 0.0, Stroke::new(1.0, col.gamma_multiply(0.7)), StrokeKind::Middle);
let resp = ui.interact(r, egui::Id::new("graph-box-inside"), Sense::drag());
let resp = ui.interact(r, egui::Id::unique("graph-box-inside"), Sense::drag());
app.auto.add("timeline.graph.transformBox", r, "Transform box");
if resp.drag_started()
&& let Some(o) = ui.input(|i| i.pointer.press_origin())
@@ -117,7 +117,7 @@ pub(crate) fn transform_box(
let hr = Rect::from_center_size(*h, vec2(7.0, 7.0));
p.rect_filled(hr, 0.0, Color32::from_gray(0x20));
p.rect_stroke(hr, 0.0, Stroke::new(1.0, col), StrokeKind::Middle);
let resp = ui.interact(hr.expand(2.0), egui::Id::new(("graph-box-handle", i)), Sense::drag());
let resp = ui.interact(hr.expand(2.0), egui::Id::unique(("graph-box-handle", i)), Sense::drag());
app.auto.add(&format!("timeline.graph.transformBox.{i}"), hr, "Transform box handle");
if resp.drag_started() {
let opp = [2, 3, 0, 1, 6, 7, 4, 5][i];
@@ -255,7 +255,7 @@ struct AltScale {
}
fn alt_id() -> egui::Id {
egui::Id::new("timeline-alt-scale")
egui::Id::unique("timeline-alt-scale")
}
/// Selected keys' comp-time range.
+56 -31
View File
@@ -67,21 +67,32 @@ pub fn recent_entries(prefs: &effectcraft_engine::prefs::Prefs) -> Vec<RecentEnt
.collect()
}
/// `YYYY-MM-DD HH:MM` (UTC) from Unix seconds.
/// Unix seconds → (proleptic Gregorian year, the same wall-clock instant moved into chrono's range
/// by whole 400-year cycles). The Gregorian calendar repeats every 400 years (146_097 days), so
/// month, day and time of day come from chrono and only the year is carried, which keeps every
/// `i64` exact instead of collapsing out-of-range years to the epoch.
fn utc_parts(secs: i64) -> (i64, chrono::NaiveDateTime) {
use chrono::Datelike;
let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0));
match (date, time) {
(Some(d), Some(t)) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t))
}
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
}
}
/// Unix seconds → `YYYY-MM-DD HH:MM` (UTC). Years far outside chrono's own range are preserved
/// (the old hand-rolled conversion kept them) rather than collapsing to the epoch.
pub fn format_utc(secs: i64) -> String {
let days = secs.div_euclid(86_400);
let rem = secs.rem_euclid(86_400);
// Civil date from days since 1970-01-01 (Howard Hinnant's algorithm).
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + i64::from(m <= 2);
format!("{y:04}-{m:02}-{d:02} {:02}:{:02}", rem / 3600, (rem % 3600) / 60)
let (y, t) = utc_parts(secs);
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
format!("{y:04}-{}", t.format("%m-%d %H:%M"))
}
/// How long ago `then` was, as the Home screen's Opened column shows it ("3 hours ago", then the
@@ -95,7 +106,8 @@ pub fn format_ago(now: i64, then: i64) -> String {
3_600..86_400 => plural(d / 3_600, "{} hour ago", "{} hours ago"),
86_400..172_800 => tr("Yesterday").to_string(),
172_800..604_800 => plural(d / 86_400, "{} day ago", "{} days ago"),
_ => format_utc(then).get(..10).unwrap_or_default().to_string(),
// The date part: up to the space, not a fixed 10 bytes, so a year past 9999 stays whole.
_ => format_utc(then).split(' ').next().unwrap_or_default().to_string(),
}
}
@@ -154,12 +166,13 @@ pub fn encode_thumb(img: &egui::ColorImage) -> String {
out
}
/// Decode a stored hex thumbnail, rejecting malformed or non-ASCII text.
pub fn decode_thumb(text: &str) -> Option<egui::ColorImage> {
let t = text.trim();
if t.len() != THUMB_W * THUMB_H * 6 {
if t.len() != THUMB_W * THUMB_H * 6 || !t.is_ascii() {
return None;
}
let b: Vec<u8> = (0..t.len()).step_by(2).map(|i| u8::from_str_radix(&t[i..i + 2], 16)).collect::<Result<_, _>>().ok()?;
let b: Vec<u8> = (0..t.len()).step_by(2).map(|i| u8::from_str_radix(t.get(i..i + 2)?, 16).ok()).collect::<Option<_>>()?;
let px = b.as_chunks::<3>().0.iter().map(|c| Color32::from_rgb(c[0], c[1], c[2])).collect();
Some(egui::ColorImage::new([THUMB_W, THUMB_H], px))
}
@@ -238,7 +251,7 @@ fn rail_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, rail:
let mut y = rail.min.y + 20.0;
for (label, id, primary) in [("New Project", "file.newProject", true), ("Open Project…", "file.open", false)] {
let r = Rect::from_min_size(pos2(x0, y), vec2(w, 34.0));
if widgets::text_button(ui, r, tr(label), primary, &t, egui::Id::new(("home", id))).clicked() {
if widgets::text_button(ui, r, tr(label), primary, &t, egui::Id::unique(("home", id))).clicked() {
actions.push((id, json!({})));
}
app.auto.add(&format!("home.{id}"), r, label);
@@ -255,7 +268,7 @@ fn rail_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, rail:
};
for (k, (label, icon)) in [("Home", Icon::Home), ("Templates", Icon::Grid), ("Learn", Icon::Sparkle)].into_iter().enumerate() {
let r = Rect::from_min_size(pos2(rail.min.x + 8.0, y), vec2(rail.width() - 16.0, 34.0));
let resp = ui.interact(r, egui::Id::new(("home-tab", label)), Sense::click());
let resp = ui.interact(r, egui::Id::unique(("home-tab", label)), Sense::click());
let on = page == k;
if on || resp.hovered() {
p.rect_filled(r, 6.0, if on { t.row_selected } else { t.hover });
@@ -286,7 +299,7 @@ fn rail_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, rail:
cy += 22.0;
for (icon, label, cmd) in links {
let r = Rect::from_min_size(pos2(x0, cy), vec2(w, 26.0));
let resp = ui.interact(r, egui::Id::new(("home-link", cmd)), Sense::click());
let resp = ui.interact(r, egui::Id::unique(("home-link", cmd)), Sense::click());
let discord = cmd == "help.discord";
if discord {
p.rect_filled(r, 13.0, Color32::from_rgb(0x58, 0x65, 0xf2));
@@ -307,7 +320,7 @@ fn rail_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, rail:
let sw = (w - 6.0) / 2.0;
for (i, (name, slug)) in sib.iter().enumerate() {
let r = Rect::from_min_size(pos2(x0 + (i % 2) as f32 * (sw + 6.0), cy + (i / 2) as f32 * 26.0), vec2(sw, 22.0));
let resp = ui.interact(r, egui::Id::new(("sib", *slug)), Sense::click());
let resp = ui.interact(r, egui::Id::unique(("sib", *slug)), Sense::click());
p.rect_filled(r, 11.0, if resp.hovered() { t.hover } else { t.field_bg });
p.text(r.center(), Align2::CENTER_CENTER, *name, Tokens::ui(11.0), t.text);
app.auto.add(&format!("home.sibling.{slug}"), r, name);
@@ -350,7 +363,7 @@ fn home_page(
let tw = ((w - gap * 3.0) / 4.0).clamp(120.0, 220.0);
for (k, (label, id, icon)) in tiles.into_iter().enumerate() {
let r = Rect::from_min_size(pos2(x0 + k as f32 * (tw + gap), y), vec2(tw, 64.0));
let resp = ui.interact(r, egui::Id::new(("home-tile", id)), Sense::click());
let resp = ui.interact(r, egui::Id::unique(("home-tile", id)), Sense::click());
p.rect_filled(r, 8.0, if resp.hovered() { t.hover } else { t.panel_bg });
p.rect_stroke(r, 8.0, Stroke::new(1.0, if resp.hovered() { t.accent } else { t.separator }), StrokeKind::Inside);
let ir = Rect::from_center_size(pos2(r.min.x + 30.0, r.center().y), vec2(32.0, 32.0));
@@ -372,7 +385,7 @@ fn home_page(
// Recent: heading, filter, clear.
p.text(pos2(x0, y + 12.0), Align2::LEFT_CENTER, tr("Recent"), Tokens::semibold(17.0), t.tab_text_active);
let entries = recent_entries(&app.session.prefs);
let fid = egui::Id::new("home-filter");
let fid = egui::Id::unique("home-filter");
let mut filter: String = ctx.data(|d| d.get_temp(fid)).unwrap_or_default();
let fr = Rect::from_min_size(pos2(x0 + w - 240.0, y), vec2(240.0, 26.0));
widgets::search_field(ui, fr, &mut filter, tr("Filter recent files"), &t);
@@ -380,7 +393,7 @@ fn home_page(
ctx.data_mut(|d| d.insert_temp(fid, filter.clone()));
if !entries.is_empty() {
let cr = Rect::from_min_size(pos2(fr.min.x - 96.0, y + 3.0), vec2(84.0, 20.0));
let resp = ui.interact(cr, egui::Id::new("home-clear-recent"), Sense::click());
let resp = ui.interact(cr, egui::Id::unique("home-clear-recent"), Sense::click());
p.text(cr.right_center(), Align2::RIGHT_CENTER, tr("Clear list"), Tokens::ui(11.5), if resp.hovered() { t.text } else { t.text_faint });
app.auto.add("home.clearRecent", cr, "Clear Recent Projects");
if resp.clicked() {
@@ -417,7 +430,7 @@ fn home_page(
if r.min.y > area.max.y {
break;
}
let resp = ui.interact(r, egui::Id::new(("home-recent", e.index)), Sense::click()).on_hover_text(&e.path);
let resp = ui.interact(r, egui::Id::unique(("home-recent", e.index)), Sense::click()).on_hover_text(&e.path);
if resp.hovered() {
p.rect_filled(r, 6.0, t.hover);
}
@@ -484,6 +497,12 @@ fn run_actions(app: &mut EffectcraftApp, ctx: &egui::Context, actions: Vec<(&str
mod tests {
use super::*;
#[test]
fn non_ascii_thumbnail_is_rejected_without_panicking() {
let malformed = format!("aé{}", "0".repeat(THUMB_W * THUMB_H * 6 - 3));
assert!(decode_thumb(&malformed).is_none());
}
#[test]
fn recent_list_comes_from_settings() {
let mut prefs = effectcraft_engine::prefs::Prefs::default();
@@ -510,6 +529,12 @@ mod tests {
assert_eq!(format_utc(0), "1970-01-01 00:00");
assert_eq!(format_utc(1_790_000_000), "2026-09-21 14:13");
assert_eq!(format_utc(951_782_400), "2000-02-29 00:00");
// Years far outside chrono's own range are preserved (the old hand-rolled conversion kept
// them), including negatives and the i64 extremes, rather than collapsing to the epoch.
assert_eq!(format_utc(10_000_000_000_000), "318857-05-20 17:46");
assert_eq!(format_utc(-10_000_000_000_000), "-314918-08-13 06:13");
assert_eq!(format_utc(i64::MAX), "292277026596-12-04 15:30");
assert_eq!(format_utc(i64::MIN), "-292277022657-01-27 08:29");
let img = egui::ColorImage::new([200, 100], vec![Color32::from_rgb(10, 200, 30); 200 * 100]);
let text = encode_thumb(&img);
let back = decode_thumb(&text).unwrap();
@@ -533,20 +558,20 @@ mod tests {
assert_eq!(format_ago(now, now + 100), "Just now");
assert_eq!((format_size(900), format_size(13_000), format_size(3_500_000)), ("900 B".into(), "13 KB".into(), "3.3 MB".into()));
assert_eq!((kind_of("/a/B.ecproj"), kind_of("c.AEP"), kind_of("x")), ("EffectCraft project", "After Effects project", "Project"));
// After a week it is the date, whole even for a five-digit year.
assert_eq!(format_ago(i64::MAX, 253_402_300_800), "10000-01-01");
}
/// The Home screen's own text goes through the interface catalog (#574): the Opened and Kind
/// columns read in Chinese, and English stays English.
/// columns read in the interface language, and English stays English.
#[test]
fn home_text_follows_the_interface_language() {
let now = 1_790_000_000;
crate::i18n::set_current("zh-hans");
let zh = (format_ago(now, now - 5), format_ago(now, now - 3 * 3_600), format_ago(now, now - 90_000), kind_of("/a/B.ecproj"));
crate::i18n::set_current("zh-hant");
let tw = (format_ago(now, now - 60), kind_of("x"));
let shown = (format_ago(now, now - 5), format_ago(now, now - 3 * 3_600), format_ago(now, now - 90_000), kind_of("/a/B.ecproj"));
let catalog = (tr("Just now").to_string(), tr_args("{} hours ago", &[&3]), tr("Yesterday").to_string(), tr("EffectCraft project"));
crate::i18n::set_current("en");
assert_eq!(zh, ("刚刚".into(), "3 小时前".into(), "昨天".into(), "EffectCraft 项目"));
assert_eq!(tw, ("1 分鐘前".into(), "專案"));
assert_eq!(shown, catalog);
assert_eq!(tr("Filter recent files"), "Filter recent files");
}
+1 -1
View File
@@ -10,7 +10,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let p = ui.painter().with_clip_rect(rect);
let x0 = rect.min.x + 12.0;
let scroll = widgets::PanelScroll::begin(ui, egui::Id::new("info-scroll"), rect);
let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("info-scroll"), rect);
let mut y = rect.min.y + 14.0 - scroll.offset;
if app.pointer_comp.is_some() {
// GPU frames: read the shown frame back for sampling.
+7 -7
View File
@@ -41,11 +41,11 @@ struct Tex {
}
fn tex_id() -> egui::Id {
egui::Id::new("layer-panel-tex")
egui::Id::unique("layer-panel-tex")
}
fn fit_id() -> egui::Id {
egui::Id::new("layer-panel-fit")
egui::Id::unique("layer-panel-fit")
}
/// The Layer panel's last Fit magnification (points per layer pixel), for View ▸ Zoom In / Zoom
@@ -144,8 +144,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
names.extend(fx.groups().map(|g| g.name.clone()));
}
let vr = Rect::from_min_size(pos2(vx + 36.0, bar.center().y - 10.0), vec2(150.0, 20.0));
let pop = egui::Id::new("layer-view-pop");
if widgets::dropdown(ui, vr, &names[view.min(names.len() - 1)], &t, egui::Id::new("layer-view")).clicked() {
let pop = egui::Id::unique("layer-view-pop");
if widgets::dropdown(ui, vr, &names[view.min(names.len() - 1)], &t, egui::Id::unique("layer-view")).clicked() {
widgets::open_popup(ui, pop);
}
app.auto.add("layerPanel.view", vr, "View");
@@ -233,7 +233,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
let space = super::space_hand(&ctx);
if space || matches!(app.ui.tool, Tool::Hand | Tool::Zoom) {
let resp = ui.interact(area, egui::Id::new("layer-panel-nav"), Sense::click_and_drag());
let resp = ui.interact(area, egui::Id::unique("layer-panel-nav"), Sense::click_and_drag());
let alt = ui.input(|i| i.modifiers.alt);
if resp.hovered() {
ctx.set_cursor_icon(match app.ui.tool {
@@ -264,10 +264,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
return;
}
// Interaction: paint tools.
let resp = ui.interact(area, egui::Id::new("layer-panel-interact"), Sense::click_and_drag());
let resp = ui.interact(area, egui::Id::unique("layer-panel-interact"), Sense::click_and_drag());
let kind = app.ui.tool.paint_kind();
let o = app.session.state.paint.clone();
let did = egui::Id::new("layer-panel-drag");
let did = egui::Id::unique("layer-panel-drag");
if let Some(hp) = resp.hover_pos() {
let lp = to_layer(hp);
app.pointer_comp = Some([lp[0] as f32, lp[1] as f32]);
@@ -5,6 +5,7 @@
//! edits made in the dialog back, OK keeps them (one undo step per control drag).
use crate::i18n::tr;
use effectcraft_engine::commands::text_edit::active_editor_snapshot;
use effectcraft_engine::keyframe::Value as KV;
use effectcraft_engine::project::styles::{BLENDING, STYLES};
use effectcraft_engine::project::{Layer, LayerId, ParamUi, Property};
@@ -258,13 +259,85 @@ pub fn finish(app: &mut EffectcraftApp, ok: bool) {
&& !ok
{
let changed = !std::sync::Arc::ptr_eq(&app.session.project, &checkpoint.project);
app.session.project = checkpoint.project;
app.session.history = checkpoint.history;
app.session.sanitize_state();
if changed {
// Snapshot only when Cancel has something to restore: opening and cancelling without
// edits must not copy the whole TextDoc.
let edited = active_editor_snapshot(&app.session);
app.session.project = checkpoint.project;
app.session.history = checkpoint.history;
app.session.sanitize_after_replacement(edited.as_ref());
app.session.bump();
} else {
// No edit was committed, so nothing can have become invalid. In particular, do not
// call sanitize_state here: its text-edit cleanup clones the whole TextDoc.
app.session.history = checkpoint.history;
}
}
app.session.history.merge_key = None;
app.dialog = None;
}
#[cfg(test)]
mod tests {
use super::*;
use effectcraft_engine::Session;
use effectcraft_engine::commands::text_edit::layer_doc;
use effectcraft_engine::project::LayerId;
use effectcraft_engine::project::styles::BLENDING;
use serde_json::json;
fn editing_app() -> (EffectcraftApp, u64) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Styles", "width": 320, "height": 180, "duration": 1})).unwrap();
let lid = s.execute("layer.newText", json!({"text": "abcdef"})).unwrap()["layer"].as_u64().unwrap();
s.execute("text.edit", json!({"layer": lid, "caret": 3})).unwrap();
(EffectcraftApp::new(s), lid)
}
fn editor_state(app: &EffectcraftApp) -> Option<(LayerId, u64, usize, usize)> {
app.session.state.text_edit.as_ref().map(|e| (e.layer, e.session, e.anchor, e.caret))
}
#[test]
fn cancel_keeps_an_open_text_editor_when_the_dialog_did_not_change_its_text() {
let (mut app, lid) = editing_app();
open(&mut app, &json!({"layer": lid, "style": BLENDING})).unwrap();
// A live preview edit changes the project but not the edited Source Text.
app.session.execute("prop.set", json!({"layer": lid, "path": "transform/opacity", "value": 40})).unwrap();
let before = editor_state(&app);
assert!(before.is_some());
finish(&mut app, false);
assert_eq!(editor_state(&app), before, "Cancel keeps an editor whose document it did not replace");
}
#[test]
fn cancel_without_a_dialog_edit_keeps_the_open_editor_and_its_document_untouched() {
let (mut app, lid) = editing_app();
open(&mut app, &json!({"layer": lid, "style": BLENDING})).unwrap();
let before = editor_state(&app);
let project = app.session.project.clone();
let undo = app.session.history.undo.len();
assert!(before.is_some());
finish(&mut app, false);
assert!(std::sync::Arc::ptr_eq(&app.session.project, &project), "a no-op Cancel restores the same project");
assert_eq!(app.session.history.undo.len(), undo, "and the same undo history");
assert_eq!(editor_state(&app), before, "and leaves the open editor untouched");
}
#[test]
fn cancel_replaces_an_open_text_editor_when_the_preview_changed_its_text() {
let (mut app, lid) = editing_app();
open(&mut app, &json!({"layer": lid, "style": BLENDING})).unwrap();
app.session.execute("layer.setText", json!({"layer": lid, "text": "ab"})).unwrap();
let preview = editor_state(&app).expect("still editing");
assert_eq!(layer_doc(&app.session, LayerId(lid)).unwrap().text, "ab");
finish(&mut app, false);
let restored = editor_state(&app).expect("the editor survives the rollback");
assert_eq!(layer_doc(&app.session, LayerId(lid)).unwrap().text, "abcdef", "Cancel restores the pre-dialog text");
assert_ne!(restored.1, preview.1, "the restored editor starts a new epoch");
assert_eq!((restored.2, restored.3), (2, 2), "the caret stays in the restored document");
}
}
+4 -4
View File
@@ -31,7 +31,7 @@ pub fn home_tab(app: &mut EffectcraftApp, ui: &mut egui::Ui, area: Rect) {
if r.min.y > area.max.y {
break;
}
let resp = ui.interact(r, egui::Id::new(("learn-card", &tut.id)), Sense::click());
let resp = ui.interact(r, egui::Id::unique(("learn-card", &tut.id)), Sense::click());
p.rect_filled(r, 8.0, if resp.hovered() { Color32::from_rgb(0x2c, 0x2e, 0x37) } else { Color32::from_rgb(0x24, 0x26, 0x2e) });
// A small original badge: the step count in a circle.
let badge = Rect::from_min_size(pos2(r.min.x + 16.0, r.min.y + 16.0), vec2(44.0, 44.0));
@@ -50,7 +50,7 @@ pub fn home_tab(app: &mut EffectcraftApp, ui: &mut egui::Ui, area: Rect) {
p.galley(pos2(tx, r.min.y + 84.0), g, t.text_faint);
let br = Rect::from_min_size(pos2(r.max.x - 104.0, r.min.y + 16.0), vec2(88.0, 28.0));
let label = if running.as_deref() == Some(tut.id.as_str()) { "Resume" } else { "Start" };
let clicked = widgets::text_button(ui, br, label, true, &t, egui::Id::new(("learn-start", &tut.id))).clicked();
let clicked = widgets::text_button(ui, br, label, true, &t, egui::Id::unique(("learn-start", &tut.id))).clicked();
app.auto.add(&format!("home.learn.{}", tut.id), r, &tut.title);
app.auto.add(&format!("home.learn.{}.start", tut.id), br, label);
if clicked || resp.clicked() {
@@ -92,7 +92,7 @@ pub fn coach(app: &mut EffectcraftApp, ctx: &egui::Context) {
{
let time = ctx.input(|i| i.time);
let pulse = (0.5 + 0.5 * (time * 4.0).sin()) as f32;
let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("learn-highlight")));
let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("learn-highlight")));
let hr = r.expand(3.0 + 2.0 * pulse);
p.rect_stroke(hr, 5.0, Stroke::new(2.0, t.accent.gamma_multiply(0.6 + 0.4 * pulse)), StrokeKind::Outside);
app.auto.add("learn.highlight", hr, tp);
@@ -100,7 +100,7 @@ pub fn coach(app: &mut EffectcraftApp, ctx: &egui::Context) {
}
let w = 340.0;
let mut action: Option<&str> = None;
let area = egui::Area::new(egui::Id::new("learn-coach"))
let area = egui::Area::new(egui::Id::unique("learn-coach"))
.order(egui::Order::Foreground)
.fixed_pos(pos2(screen.max.x - w - 20.0, screen.max.y - 250.0))
.show(ctx, |ui| {
+5 -5
View File
@@ -244,7 +244,7 @@ pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
// ---------------------------------------------------------------- timeline drawing + gestures
fn drag_state_id() -> egui::Id {
egui::Id::new("marker-drag")
egui::Id::unique("marker-drag")
}
/// Snap `t` (comp seconds) to the CTI, the work area and other marker times within 8 px.
@@ -301,7 +301,7 @@ fn marker(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, comp:
p.text(pos2(x + 7.0, y0 + h / 2.0), Align2::LEFT_CENTER, m.comment.lines().next().unwrap_or_default(), Tokens::ui(10.0), t.text);
}
let hit = Rect::from_min_max(pos2(x - 5.0, y0 - 1.0), pos2(x + 5.0, y1));
let id = egui::Id::new(("marker", r.layer, r.index));
let id = egui::Id::unique(("marker", r.layer, r.index));
let resp = ui.interact(hit, id, Sense::click_and_drag());
app.auto.add(&r.auto_id(), hit, if m.comment.is_empty() { "marker" } else { &m.comment });
let tip = if m.comment.is_empty() { "Marker".to_string() } else { m.comment.clone() };
@@ -391,7 +391,7 @@ pub(crate) fn comp_markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, comp: &C
/// one undo step. Dropped back on the bin or outside the time graph's width, nothing is added.
pub(crate) fn marker_bin(app: &mut EffectcraftApp, ui: &mut egui::Ui, comp: &Comp, tm: TMap, bin: Rect, strip: Rect) {
let t = app.tokens;
let resp = ui.interact(bin, egui::Id::new("marker-bin"), Sense::drag()).on_hover_text(tr("Composition marker bin: drag a marker to the time ruler"));
let resp = ui.interact(bin, egui::Id::unique("marker-bin"), Sense::drag()).on_hover_text(tr("Composition marker bin: drag a marker to the time ruler"));
app.auto.add("timeline.markerBin", bin, "Composition marker bin");
let hot = resp.hovered() || resp.dragged();
if hot {
@@ -436,7 +436,7 @@ fn nested_markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter
return;
}
// Where they land, once per revision (with time remapping that samples the layer's frames).
let (key, rev) = (egui::Id::new(("nested-markers", cid.0, layer.id.0)), app.session.revision);
let (key, rev) = (egui::Id::unique(("nested-markers", cid.0, layer.id.0)), app.session.revision);
let cached = ui.ctx().data(|d| d.get_temp::<(u64, Vec<(Tick, usize)>)>(key)).filter(|(r, _)| *r == rev);
let at = cached.map(|(_, v)| v).unwrap_or_else(|| {
let v: Vec<(Tick, usize)> =
@@ -457,7 +457,7 @@ fn nested_markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter
}
let hit = Rect::from_min_max(pos2(x - 5.0, y0 - 1.0), pos2(x + 5.0, r.max.y - 3.0));
let tip = if m.comment.is_empty() { format!("Marker in {name}") } else { format!("{} (marker in {name})", m.comment) };
let resp = ui.interact(hit, egui::Id::new(("nested-marker", layer.id.0, i)), Sense::click()).on_hover_text(&tip);
let resp = ui.interact(hit, egui::Id::unique(("nested-marker", layer.id.0, i)), Sense::click()).on_hover_text(&tip);
app.auto.add(&format!("timeline.layer.{}.nestedMarker.{i}", layer.id.0), hit, &tip);
if resp.double_clicked() {
let opened =
+8 -8
View File
@@ -29,7 +29,7 @@ type Listing = (f64, Arc<Result<Vec<mb::Entry>, String>>);
/// Thumbnail of a file (cached per path; `None` while not decoded or not decodable).
fn thumbnail(app: &EffectcraftApp, ctx: &egui::Context, path: &str, budget: &mut u32) -> Thumb {
let id = egui::Id::new(("mb-thumb", path));
let id = egui::Id::unique(("mb-thumb", path));
if let Some(t) = ctx.data(|d| d.get_temp::<Thumb>(id)) {
return t;
}
@@ -76,7 +76,7 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let st = app.session.state.media_browser.clone();
let dir = st.folder.clone().unwrap_or_else(|| b.home());
// Listing cached per folder for a second.
let lid = egui::Id::new(("mb-list", dir.clone(), st.importable_only));
let lid = egui::Id::unique(("mb-list", dir.clone(), st.importable_only));
let now = ctx.input(|i| i.time);
let listing: Option<Listing> = ctx.data(|d| d.get_temp(lid));
let entries = match listing {
@@ -137,7 +137,7 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
);
for (i, (name, path)) in places.iter().enumerate() {
let r = Rect::from_min_size(pos2(side.min.x, fy), vec2(side.width(), 20.0));
let resp = ui.interact(r, egui::Id::new(("mb-fav", i)), Sense::click());
let resp = ui.interact(r, egui::Id::unique(("mb-fav", i)), Sense::click());
if *path == dir {
p.rect_filled(r, 0.0, t.row_selected);
} else if resp.hovered() {
@@ -162,7 +162,7 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let tile = vec2(140.0, 112.0);
let cols = ((list.width() - 8.0) / tile.x).floor().max(1.0) as usize;
let mut budget = 2u32;
let scroll = widgets::PanelScroll::begin(ui, egui::Id::new("mb-scroll"), list);
let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("mb-scroll"), list);
let lp = p.with_clip_rect(list);
let mut actions: Vec<(&str, Value)> = vec![];
for (i, e) in entries.iter().enumerate() {
@@ -173,7 +173,7 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if !r.intersects(list) {
continue;
}
let resp = ui.interact(r.intersect(list), egui::Id::new(("mb-entry", &e.path)), Sense::click_and_drag());
let resp = ui.interact(r.intersect(list), egui::Id::unique(("mb-entry", &e.path)), Sense::click_and_drag());
app.auto.add(&format!("mediaBrowser.entry.{}", e.name), r, &e.name);
lp.rect_filled(r, 4.0, if resp.hovered() { t.hover } else { t.row });
let thumb = Rect::from_min_max(r.min + vec2(6.0, 6.0), pos2(r.max.x - 6.0, r.max.y - 26.0));
@@ -264,7 +264,7 @@ fn rows_of(v: &Value) -> Vec<(String, String)> {
/// An editable comment field; returns the edited text when it changes.
fn comment_field(ui: &mut egui::Ui, r: Rect, id: &str, text: &str) -> Option<String> {
let key = egui::Id::new(("meta-comment", id));
let key = egui::Id::unique(("meta-comment", id));
let mut buf: String = ui.data(|d| d.get_temp(key)).unwrap_or_else(|| text.to_string());
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r));
let resp = child.add(egui::TextEdit::multiline(&mut buf).desired_width(r.width()).desired_rows(2).hint_text(tr("Add a comment")));
@@ -438,8 +438,8 @@ mod tests {
kind: Some("video"),
})
.collect();
let old_id = egui::Id::new("old-list");
let new_id = egui::Id::new("new-list");
let old_id = egui::Id::unique("old-list");
let new_id = egui::Id::unique("new-list");
ctx.data_mut(|d| {
d.insert_temp(old_id, (0.0, Ok::<_, String>(entries.clone())));
d.insert_temp(new_id, (0.0, Arc::new(Ok::<_, String>(entries))));
+12 -12
View File
@@ -24,7 +24,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let p = ui.painter().with_clip_rect(rect);
// The panel scrolls when it is shorter than its controls (the default workspace shows just
// the transport row, as in After Effects).
let scroll = widgets::PanelScroll::begin(ui, egui::Id::new("preview-scroll"), rect);
let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("preview-scroll"), rect);
let panel_clip = ui.clip_rect();
ui.set_clip_rect(rect.intersect(panel_clip));
let y = rect.min.y + 12.0 - scroll.offset;
@@ -42,7 +42,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
for (icon, cmd, tip) in buttons {
let r = Rect::from_min_size(pos2(x, y), vec2(bw, 28.0));
let icon = if cmd == "playback.toggle" && app.playback.playing { Icon::Pause } else { icon };
if widgets::icon_button(ui, r, icon, cmd == "playback.toggle" && app.playback.playing, &t, egui::Id::new(("pv", cmd))).on_hover_text(tip).clicked() {
if widgets::icon_button(ui, r, icon, cmd == "playback.toggle" && app.playback.playing, &t, egui::Id::unique(("pv", cmd))).on_hover_text(tip).clicked() {
// The play button plays with the options of the shortcut shown below.
let params = if cmd == "playback.toggle" { json!({"shortcut": current.id()}) } else { json!({}) };
let _ = crate::menus::invoke(app, &ctx, cmd, params);
@@ -117,7 +117,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
for (k, (key, text, v)) in [("preRoll", "Pre-roll", o.pre_roll), ("postRoll", "Post-roll", o.post_roll)].into_iter().enumerate() {
let lx = xv + k as f32 * 100.0;
p.text(pos2(lx, yy), Align2::LEFT_CENTER, text, Tokens::ui(11.5), t.text_dim);
let (r, nv, _) = widgets::hot_number_at(ui, pos2(lx + 52.0, yy - 9.0), egui::Id::new(("pv-roll", key)), v, 0.05, (0.0, 600.0), 1, "s", &t);
let (r, nv, _) = widgets::hot_number_at(ui, pos2(lx + 52.0, yy - 9.0), egui::Id::unique(("pv-roll", key)), v, 0.05, (0.0, 600.0), 1, "s", &t);
app.auto.add(&format!("preview.{key}"), r, text);
if let Some(nv) = nv {
changes.push(json!({"shortcut": current.id(), key: nv}));
@@ -160,7 +160,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
let sx = xv + 106.0;
p.text(pos2(sx, yy), Align2::LEFT_CENTER, tr("Skip"), Tokens::ui(12.0), t.text_dim);
let (r, nv, _) = widgets::hot_number_at(ui, pos2(sx + 30.0, yy - 9.0), egui::Id::new("pv-skip"), o.skip as f64, 0.1, (0.0, 99.0), 0, "", &t);
let (r, nv, _) = widgets::hot_number_at(ui, pos2(sx + 30.0, yy - 9.0), egui::Id::unique("pv-skip"), o.skip as f64, 0.1, (0.0, 99.0), 0, "", &t);
app.auto.add("preview.skip", r, "Skip");
if let Some(nv) = nv {
changes.push(json!({"shortcut": current.id(), "skip": nv.round() as u32}));
@@ -182,7 +182,7 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
if o.resolution == PreviewResolution::Custom {
let (r, nv, _) =
widgets::hot_number_at(ui, pos2(xv + 106.0, yy - 9.0), egui::Id::new("pv-custom-res"), o.custom_resolution as f64, 0.1, (1.0, 40.0), 0, "", &t);
widgets::hot_number_at(ui, pos2(xv + 106.0, yy - 9.0), egui::Id::unique("pv-custom-res"), o.custom_resolution as f64, 0.1, (1.0, 40.0), 0, "", &t);
app.auto.add("preview.customResolution", r, "Custom Resolution");
if let Some(nv) = nv {
changes.push(json!({"shortcut": current.id(), "customResolution": nv.round() as u32}));
@@ -240,7 +240,7 @@ fn fmt_rate(r: f64) -> String {
fn check_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, at: egui::Pos2, key: &str, text: &str, on: bool) -> bool {
let t = app.tokens;
let r = Rect::from_min_size(pos2(at.x, at.y - 8.0), vec2(16.0, 16.0));
let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::new(("pv-check", key))).clicked();
let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::unique(("pv-check", key))).clicked();
p.text(pos2(r.max.x + 5.0, at.y), Align2::LEFT_CENTER, text, Tokens::ui(12.0), t.text);
app.auto.add(&format!("preview.{key}"), r, text);
clicked
@@ -250,7 +250,7 @@ fn check_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, at:
/// returns the chosen index.
fn dropdown_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect, key: &str, text: &str, items: &[String], current: Option<usize>) -> Option<usize> {
let t = app.tokens;
let pid = egui::Id::new(("pv-pop", key));
let pid = egui::Id::unique(("pv-pop", key));
if widgets::dropdown(ui, r, text, &t, pid.with("btn")).clicked() {
let open: bool = ui.data(|d| d.get_temp(pid.with("open")).unwrap_or(false));
ui.data_mut(|d| d.insert_temp(pid.with("open"), !open));
@@ -318,7 +318,7 @@ pub fn audio(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let clip = Rect::from_min_max(pos2(bar.min.x, top - 14.0), pos2(bar.max.x, top - 4.0));
p.rect_filled(clip, 1.0, if m.clipped[c] { red } else { t.field_bg });
let id = if c == 0 { "audio.clipLeft" } else { "audio.clipRight" };
if ui.interact(clip, egui::Id::new(id), Sense::click()).clicked() {
if ui.interact(clip, egui::Id::unique(id), Sense::click()).clicked() {
app.meter.clipped[c] = false;
}
app.auto.add(id, clip, if c == 0 { "Left clip indicator" } else { "Right clip indicator" });
@@ -370,7 +370,7 @@ pub fn history(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let nodes = app.session.history_tree();
let row_h = 22.0;
let cur = nodes.iter().position(|n| n.current).unwrap_or(0);
let scroll_id = egui::Id::new("history-scroll");
let scroll_id = egui::Id::unique("history-scroll");
let mut scroll = widgets::PanelScroll::begin(ui, scroll_id, rect);
// The current state scrolls into view when it changes (undo, redo, a new step).
if ui.data(|d| d.get_temp::<usize>(scroll_id.with("current"))) != Some(cur) {
@@ -390,7 +390,7 @@ pub fn history(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
for n in nodes.iter().skip(first).take(visible) {
let r = Rect::from_min_size(pos2(rect.min.x, y), vec2(rect.width(), row_h));
y += row_h;
let resp = ui.interact(r, egui::Id::new(("hist", n.id.as_str())), Sense::click());
let resp = ui.interact(r, egui::Id::unique(("hist", n.id.as_str())), Sense::click());
if n.current {
p.rect_filled(r, 0.0, t.row_selected);
} else if resp.hovered() {
@@ -423,7 +423,7 @@ pub fn markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let p = ui.painter().with_clip_rect(rect);
let Some(c) = app.session.active_comp_arc() else { return };
let scroll = widgets::PanelScroll::begin(ui, egui::Id::new("markers-scroll"), rect);
let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("markers-scroll"), rect);
let mut y = rect.min.y + 8.0 - scroll.offset;
if c.markers.is_empty() {
p.text(rect.center(), Align2::CENTER_CENTER, tr("No composition markers"), Tokens::ui(12.0), t.text_faint);
@@ -437,7 +437,7 @@ pub fn markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let tc = crate::panels::timecode(&app.session, &c, m.time);
p.text(pos2(r.min.x + 12.0, r.center().y), Align2::LEFT_CENTER, tc, Tokens::mono(11.5), t.timecode);
p.text(pos2(r.min.x + 120.0, r.center().y), Align2::LEFT_CENTER, &m.comment, Tokens::ui(12.0), t.text);
if ui.interact(r, egui::Id::new(("mk", m.time.0)), Sense::click()).clicked() {
if ui.interact(r, egui::Id::unique(("mk", m.time.0)), Sense::click()).clicked() {
app.session.set_time(m.time);
}
}
+7 -1
View File
@@ -35,6 +35,7 @@ pub mod paint_panels;
pub mod panel_kit;
pub mod path_vr_panels;
pub mod precomp;
pub mod primitive_dialog;
pub mod project;
pub mod properties;
pub mod puppet_tool;
@@ -151,6 +152,11 @@ pub fn timecode(session: &Session, comp: &Comp, t: Tick) -> String {
effectcraft_engine::commands::time::display_time(session, comp, t)
}
/// A length (a Duration) formatted per project settings, with no start offset.
pub fn duration(session: &Session, comp: &Comp, len: Tick) -> String {
effectcraft_engine::commands::time::display_duration(session, comp, len)
}
/// Ctrl/Cmd-click on a current-time display toggles the project's Time Display Style between
/// Timecode and Frames, as in After Effects (through Project Settings: one undo step).
pub(crate) fn toggle_time_display(app: &mut EffectcraftApp, resp: &egui::Response) {
@@ -212,7 +218,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rec
/// The ≡ panel menu (opened from a tab).
pub fn panel_menu_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui) {
let id = egui::Id::new("panel-menu");
let id = egui::Id::unique("panel-menu");
let Some((panel, pos)) = ui.ctx().data(|d| d.get_temp::<(PanelKind, egui::Pos2)>(id)) else { return };
// The menu opens in the window that shows the panel (a panel can have its own window).
if app.panel_viewport(ui.ctx(), panel) != ui.ctx().viewport_id() {
+32 -21
View File
@@ -36,7 +36,7 @@ fn number(
let t = app.tokens;
p.text(pos2(x, y + 9.0), Align2::LEFT_CENTER, label, Tokens::ui(11.5), if enabled { t.text_dim } else { t.text_faint });
let lw = p.layout_no_wrap(label.to_string(), Tokens::ui(11.5), t.text).size().x;
let (r, nv, _) = widgets::hot_number_at(ui, pos2(x + lw + 6.0, y), egui::Id::new(("paint-num", key)), v, 0.5, range, 0, suffix, &t);
let (r, nv, _) = widgets::hot_number_at(ui, pos2(x + lw + 6.0, y), egui::Id::unique(("paint-num", key)), v, 0.5, range, 0, suffix, &t);
app.auto.add(key, r, label);
nv.filter(|_| enabled)
}
@@ -58,8 +58,8 @@ fn popup_row(
let t = app.tokens;
p.text(pos2(x, y + 10.0), Align2::LEFT_CENTER, label, Tokens::ui(11.5), if enabled { t.text_dim } else { t.text_faint });
let r = Rect::from_min_size(pos2(x + 70.0, y), vec2((w - 70.0).max(60.0), 20.0));
let pop = egui::Id::new(("paint-pop", key));
if widgets::dropdown(ui, r, options.get(cur).map(String::as_str).unwrap_or(""), &t, egui::Id::new(("paint-dd", key))).clicked() && enabled {
let pop = egui::Id::unique(("paint-pop", key));
if widgets::dropdown(ui, r, options.get(cur).map(String::as_str).unwrap_or(""), &t, egui::Id::unique(("paint-dd", key))).clicked() && enabled {
widgets::open_popup(ui, pop);
}
app.auto.add(key, r, label);
@@ -70,6 +70,15 @@ fn rgb(c: [f64; 4]) -> [f32; 3] {
[c[0] as f32, c[1] as f32, c[2] as f32]
}
/// The swap-colours icon: one arrow right above one arrow left.
fn paint_swap_icon(p: &egui::Painter, rect: Rect, color: Color32) {
let stroke = Stroke::new(1.0, color);
let (left, right) = (rect.left() + 2.0, rect.right() - 2.0);
let (top, bottom) = (rect.top() + 3.0, rect.bottom() - 3.0);
p.arrow(pos2(left, top), vec2(right - left, 0.0), stroke);
p.arrow(pos2(right, bottom), vec2(left - right, 0.0), stroke);
}
pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let p = ui.painter().with_clip_rect(rect);
@@ -91,33 +100,35 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// Colour swatches (foreground over background) with swap / reset.
let fg = Rect::from_min_size(pos2(x0, y), vec2(26.0, 20.0));
let bg = Rect::from_min_size(pos2(x0 + 14.0, y + 10.0), vec2(26.0, 20.0));
widgets::swatch(ui, bg, [o.background[0] as f32, o.background[1] as f32, o.background[2] as f32, 1.0], egui::Id::new("paint-bg"), &t);
let bresp = ui.interact(Rect::from_min_max(pos2(fg.max.x, bg.min.y), bg.max), egui::Id::new("paint-bg-hit"), Sense::click());
widgets::swatch(ui, bg, [o.background[0] as f32, o.background[1] as f32, o.background[2] as f32, 1.0], egui::Id::unique("paint-bg"), &t);
let bresp = ui.interact(Rect::from_min_max(pos2(fg.max.x, bg.min.y), bg.max), egui::Id::unique("paint-bg-hit"), Sense::click());
if bresp.clicked() {
widgets::open_popup(ui, egui::Id::new("paint-bg-pop"));
widgets::open_popup(ui, egui::Id::unique("paint-bg-pop"));
}
if widgets::swatch(ui, fg, [o.color[0] as f32, o.color[1] as f32, o.color[2] as f32, 1.0], egui::Id::new("paint-fg"), &t).clicked() {
widgets::open_popup(ui, egui::Id::new("paint-fg-pop"));
if widgets::swatch(ui, fg, [o.color[0] as f32, o.color[1] as f32, o.color[2] as f32, 1.0], egui::Id::unique("paint-fg"), &t).clicked() {
widgets::open_popup(ui, egui::Id::unique("paint-fg-pop"));
}
app.auto.add("paint.color", fg, "Foreground color");
app.auto.add("paint.background", bg, "Background color");
let mut fc = rgb(o.color);
if crate::widgets::color_popup(ui, egui::Id::new("paint-fg-pop"), fg.left_bottom(), &mut fc) {
if crate::widgets::color_popup(ui, egui::Id::unique("paint-fg-pop"), fg.left_bottom(), &mut fc) {
set(app, json!({"color": [fc[0], fc[1], fc[2], 1.0]}));
}
let mut bc = rgb(o.background);
if crate::widgets::color_popup(ui, egui::Id::new("paint-bg-pop"), bg.left_bottom(), &mut bc) {
if crate::widgets::color_popup(ui, egui::Id::unique("paint-bg-pop"), bg.left_bottom(), &mut bc) {
set(app, json!({"background": [bc[0], bc[1], bc[2], 1.0]}));
}
let swap = Rect::from_min_size(pos2(x0 + 46.0, y), vec2(18.0, 14.0));
let sresp = ui.interact(swap, egui::Id::new("paint-swap"), Sense::click());
p.text(swap.center(), Align2::CENTER_CENTER, "⇄", Tokens::ui(12.0), if sresp.hovered() { t.text } else { t.text_dim });
let sresp = ui.interact(swap, egui::Id::unique("paint-swap"), Sense::click());
// Two arrows (foreground over background): drawn, so the icon does not depend on a font
// carrying U+21C4 (the bundled fonts do not).
paint_swap_icon(&p, swap, if sresp.hovered() { t.text } else { t.text_dim });
app.auto.add("paint.swapColors", swap, "Swap colors");
if sresp.on_hover_text(tr("Swap Paint Colors (X)")).clicked() {
set(app, json!({"color": o.background, "background": o.color}));
}
let reset = Rect::from_min_size(pos2(x0 + 46.0, y + 18.0), vec2(18.0, 14.0));
let rresp = ui.interact(reset, egui::Id::new("paint-reset-colors"), Sense::click());
let rresp = ui.interact(reset, egui::Id::unique("paint-reset-colors"), Sense::click());
p.rect_filled(Rect::from_min_size(reset.min + vec2(4.0, 3.0), vec2(7.0, 6.0)), 0.0, Color32::WHITE);
p.rect_stroke(Rect::from_min_size(reset.min + vec2(8.0, 6.0), vec2(7.0, 6.0)), 0.0, Stroke::new(1.0, t.text_dim), StrokeKind::Inside);
app.auto.add("paint.resetColors", reset, "Default colors");
@@ -146,7 +157,7 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let (r, nv, _) = widgets::hot_number_at(
ui,
pos2(x0 + w - 52.0, y + 1.0),
egui::Id::new("paint-custom-frames"),
egui::Id::unique("paint-custom-frames"),
o.custom_frames as f64,
0.2,
(1.0, 10000.0),
@@ -174,7 +185,7 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
p.text(pos2(x0, y + 10.0), Align2::LEFT_CENTER, tr("Preset:"), Tokens::ui(11.5), t.text_dim);
for i in 1..=5u32 {
let r = Rect::from_min_size(pos2(x0 + 56.0 + (i - 1) as f32 * 26.0, y), vec2(22.0, 20.0));
let resp = ui.interact(r, egui::Id::new(("clone-preset", i)), Sense::click());
let resp = ui.interact(r, egui::Id::unique(("clone-preset", i)), Sense::click());
let on = o.clone_preset == i;
p.rect_filled(
r,
@@ -210,7 +221,7 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
.enumerate()
{
let r = Rect::from_min_size(pos2(x0 + i as f32 * (w / 2.0), y), vec2(18.0, 20.0));
if widgets::checkbox(ui, r, on, &t, egui::Id::new(key)).clicked() {
if widgets::checkbox(ui, r, on, &t, egui::Id::unique(key)).clicked() {
set(app, json!({k: !on}));
}
p.text(pos2(r.max.x + 4.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::ui(11.5), t.text_dim);
@@ -220,7 +231,7 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let off = o.clone_offset.unwrap_or([0.0, 0.0]);
p.text(pos2(x0, y + 9.0), Align2::LEFT_CENTER, tr("Offset:"), Tokens::ui(11.5), t.text_dim);
for (d, v) in off.iter().enumerate() {
let (r, nv, _) = widgets::hot_number_at(ui, pos2(x0 + 56.0 + d as f32 * 64.0, y), egui::Id::new(("clone-off", d)), *v, 1.0, (-1e5, 1e5), 0, "", &t);
let (r, nv, _) = widgets::hot_number_at(ui, pos2(x0 + 56.0 + d as f32 * 64.0, y), egui::Id::unique(("clone-off", d)), *v, 1.0, (-1e5, 1e5), 0, "", &t);
app.auto.add(&format!("paint.cloneOffset.{d}"), r, "Clone offset");
if let Some(nv) = nv {
let mut no = off;
@@ -237,7 +248,7 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
y += 26.0;
let r = Rect::from_min_size(pos2(x0, y), vec2(18.0, 20.0));
if widgets::checkbox(ui, r, o.show_overlay, &t, egui::Id::new("paint.showOverlay")).clicked() {
if widgets::checkbox(ui, r, o.show_overlay, &t, egui::Id::unique("paint.showOverlay")).clicked() {
set(app, json!({"showOverlay": !o.show_overlay}));
}
app.auto.add("paint.showOverlay", r, "Clone Source Overlay");
@@ -246,7 +257,7 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
set(app, json!({"overlayOpacity": nv}));
}
let dr = Rect::from_min_size(pos2(x0 + w - 18.0, y), vec2(18.0, 20.0));
if widgets::checkbox(ui, dr, o.overlay_difference, &t, egui::Id::new("paint.overlayDifference")).clicked() {
if widgets::checkbox(ui, dr, o.overlay_difference, &t, egui::Id::unique("paint.overlayDifference")).clicked() {
set(app, json!({"overlayDifference": !o.overlay_difference}));
}
app.auto.add("paint.overlayDifference", dr, "Difference mode overlay");
@@ -287,7 +298,7 @@ pub fn brushes(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let cols = ((w / cell).floor() as usize).max(1);
for (i, tip) in BRUSH_PRESETS.iter().enumerate() {
let r = Rect::from_min_size(pos2(x0 + (i % cols) as f32 * cell, y + (i / cols) as f32 * cell), vec2(cell - 2.0, cell - 2.0));
let resp = ui.interact(r, egui::Id::new(("brush-preset", i)), Sense::click());
let resp = ui.interact(r, egui::Id::unique(("brush-preset", i)), Sense::click());
let on = o.preset == Some(i);
p.rect_filled(
r,
@@ -332,7 +343,7 @@ pub fn brushes(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// Spacing (checkbox in AE turns spacing on; we always space dabs, so it shows the value).
p.text(pos2(x0, y + 9.0), Align2::LEFT_CENTER, tr("Spacing:"), Tokens::ui(11.5), t.text_dim);
let _ = r;
let (sr, nv, _) = widgets::hot_number_at(ui, pos2(x0 + 58.0, y), egui::Id::new("brushes-spacing"), o.spacing, 0.5, (1.0, 1000.0), 0, " %", &t);
let (sr, nv, _) = widgets::hot_number_at(ui, pos2(x0 + 58.0, y), egui::Id::unique("brushes-spacing"), o.spacing, 0.5, (1.0, 1000.0), 0, " %", &t);
app.auto.add("brushes.spacing", sr, "Spacing");
if let Some(nv) = nv {
set(app, json!({"spacing": nv}));
+4 -4
View File
@@ -9,7 +9,7 @@ use crate::{EffectcraftApp, widgets};
/// A dropdown; returns the newly chosen index.
pub fn dropdown(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect, auto: &str, options: &[&str], cur: usize) -> Option<usize> {
let t = app.tokens;
let id = egui::Id::new(("kit-dd", auto));
let id = egui::Id::unique(("kit-dd", auto));
let text = options.get(cur).copied().unwrap_or("");
if widgets::dropdown(ui, r, text, &t, id).clicked() {
widgets::open_popup(ui, id);
@@ -23,7 +23,7 @@ pub fn dropdown(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect, auto: &str
pub fn checkbox(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &str, text: &str, on: bool) -> bool {
let t = app.tokens;
let r = Rect::from_min_size(at, vec2(16.0, 16.0));
let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::new(("kit-cb", auto))).clicked();
let clicked = widgets::checkbox(ui, r, on, &t, egui::Id::unique(("kit-cb", auto))).clicked();
ui.painter().text(pos2(r.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, text, Tokens::ui(12.0), t.text);
let full = Rect::from_min_size(at, vec2(22.0 + text.len() as f32 * 6.5, 16.0));
app.auto.add(auto, full, text);
@@ -33,7 +33,7 @@ pub fn checkbox(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, aut
/// A text button; true when clicked.
pub fn button(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect, auto: &str, text: &str, primary: bool) -> bool {
let t = app.tokens;
let clicked = widgets::text_button(ui, r, text, primary, &t, egui::Id::new(("kit-btn", auto))).clicked();
let clicked = widgets::text_button(ui, r, text, primary, &t, egui::Id::unique(("kit-btn", auto))).clicked();
app.auto.add(auto, r, text);
clicked
}
@@ -47,7 +47,7 @@ pub fn label(ui: &egui::Ui, at: egui::Pos2, text: &str, t: &Tokens) {
#[allow(clippy::too_many_arguments)]
pub fn number(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &str, v: f64, speed: f64, range: (f64, f64), dec: usize, suffix: &str) -> f64 {
let t = app.tokens;
let (r, nv, _) = widgets::hot_number_at(ui, at, egui::Id::new(("kit-n", auto)), v, speed, range, dec, suffix, &t);
let (r, nv, _) = widgets::hot_number_at(ui, at, egui::Id::unique(("kit-n", auto)), v, speed, range, dec, suffix, &t);
app.auto.add(auto, r, &format!("{v}"));
nv.unwrap_or(v)
}
+2 -2
View File
@@ -36,7 +36,7 @@ pub fn create_nulls(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let ctx = ui.ctx().clone();
let mut ui = area(ui, rect);
let loop_id = egui::Id::new("createNulls.loop");
let loop_id = egui::Id::unique("createNulls.loop");
let mut looping: bool = ui.data(|d| d.get_temp(loop_id)).unwrap_or(false);
let ready = app.session.is_enabled("paths.nullsFollowPoints") && !app.session.state.selected_layers.is_empty();
ui.label(RichText::new(tr("Select a layer with a mask or shape path (or the path property), or Puppet pins.")).color(t.text_dim).small());
@@ -70,7 +70,7 @@ pub fn vr_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
ui.label(RichText::new(tr("No VR environments yet. Create one with Composition ▸ VR ▸ Create VR Environment…")).color(t.text_faint));
return;
}
let sel_id = egui::Id::new("vrEditor.selected");
let sel_id = egui::Id::unique("vrEditor.selected");
let active = app.session.state.active_comp.map(|c| c.0);
let mut sel: u64 = ui.data(|d| d.get_temp(sel_id)).unwrap_or(0);
// Default: the active comp's environment, else the first.
+58 -57
View File
@@ -209,7 +209,7 @@ pub fn navigator(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
};
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());
let resp = ui.interact(r, egui::Id::unique(("comp-nav", id.0)), Sense::click());
if active {
p.rect_filled(r, 3.0, t.row_selected);
} else if resp.hovered() {
@@ -223,7 +223,7 @@ pub fn navigator(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
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"))
if crate::widgets::icon_button(ui, fr, crate::icons::Icon::Flowchart, false, &t, egui::Id::unique("comp-nav-flow"))
.on_hover_text(tr("Composition Mini-Flowchart"))
.clicked()
{
@@ -248,64 +248,65 @@ pub fn mini_flowchart(app: &mut EffectcraftApp, ctx: &egui::Context) {
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);
let area =
egui::Area::new(egui::Id::unique("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::unique(("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;
}
}
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);
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);
}
}
}
if ups.is_empty() && downs.is_empty() {
p.text(pos2(rect.center().x, rect.max.y - 4.0), Align2::CENTER_BOTTOM, tr("No nested compositions"), Tokens::ui(10.5), t.text_faint);
}
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, tr("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);
@@ -0,0 +1,125 @@
//! New 3D Primitive dialog (Layer ▸ New ▸ 3D Primitive…): the primitive's type, name and
//! dimensions. OK runs `layer.newPrimitive`, which switches a Classic 3D comp to Advanced 3D;
//! the layer's Geometry Options edit it afterwards.
use std::collections::BTreeMap;
use crate::i18n::tr;
use effectcraft_engine::project::build::{Ids, primitive_geometry_options};
use effectcraft_engine::project::{ItemId, ParamUi, PrimitiveKind, Property};
use egui::{Color32, vec2};
use serde_json::{Value, json};
use crate::theme::Tokens;
use crate::{Dialog, EffectcraftApp};
#[derive(Clone, Debug, Default)]
pub struct PrimitiveDraft {
pub kind: PrimitiveKind,
pub name: String,
/// Whether the name was typed; until then it follows the type.
pub named: bool,
/// Dimensions typed, by Geometry Options match id, kept when the type changes (a sphere's
/// radius carries over to a cone); the rest are the type's defaults.
pub dims: BTreeMap<String, f64>,
/// The project (its epoch) and comp the dialog was opened for.
pub target: Option<(u64, ItemId)>,
}
fn target(app: &EffectcraftApp) -> Option<(u64, ItemId)> {
Some((app.session.script_ui.project_epoch, app.session.active_comp_id()?))
}
/// The dimensions a primitive type has: its Geometry Options as the engine builds them.
fn dimensions(kind: PrimitiveKind) -> Vec<Property> {
let mut next = 0;
primitive_geometry_options(&mut Ids(&mut next), kind).props().cloned().collect()
}
/// Open the dialog for a new primitive in the active comp.
pub fn open(app: &mut EffectcraftApp) -> Result<(), String> {
let target = Some(target(app).ok_or("no composition is open")?);
let kind = PrimitiveKind::Cube;
app.dialog_state.primitive = PrimitiveDraft { kind, name: kind.label().to_string(), named: false, dims: BTreeMap::new(), target };
app.dialog = Some(Dialog::NewPrimitive);
Ok(())
}
pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let mut d = app.dialog_state.primitive.clone();
// A project opened or another comp made active since (by a script or the control channel):
// the primitive would land somewhere the dialog was not opened for.
if target(app) != d.target {
app.ui.status = "New 3D Primitive closed: the composition changed".into();
app.dialog = None;
return;
}
let (mut ok, mut close) = (false, false);
super::dialogs::modal(ctx, "New 3D Primitive", vec2(420.0, 300.0), t, |ui| {
egui::Grid::new("primitive-grid").num_columns(2).spacing([14.0, 9.0]).show(ui, |ui| {
ui.label(tr("Type"));
let combo = egui::ComboBox::from_id_salt("primitive-type").selected_text(tr(d.kind.label())).show_ui(ui, |ui| {
for (i, k) in PrimitiveKind::ALL.into_iter().enumerate() {
let item = ui.selectable_value(&mut d.kind, k, tr(k.label()));
app.auto.add(&format!("dialog.primitive.typeItem.{i}"), item.rect, k.label());
}
});
app.auto.add_response("dialog.primitive.type", &combo.response, "Type");
if !d.named {
d.name = d.kind.label().to_string();
}
ui.end_row();
ui.label(tr("Name"));
let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(220.0));
d.named |= r.changed();
app.auto.add_response("dialog.primitive.name", &r, "Name");
ui.end_row();
for p in dimensions(d.kind) {
let ParamUi::Slider { min, max, decimals, .. } = p.ui else { continue };
let mut v = d.dims.get(&p.match_id).copied().unwrap_or(p.value.as_f64());
ui.label(tr(&p.name));
// Counts (segments, rings) are whole numbers; sizes are pixels.
let suffix = if decimals == 0 { "" } else { " px" };
let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut v).range(min..=max).max_decimals(decimals as usize).suffix(suffix)));
app.auto.add_response(&format!("dialog.primitive.{}", p.match_id), &r, &p.name);
if r.changed() {
d.dims.insert(p.match_id, v);
}
ui.end_row();
}
});
ui.add_space(14.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let b = ui.add(egui::Button::new(egui::RichText::new(tr(" OK ")).color(Color32::WHITE)).fill(t.accent));
app.auto.add("dialog.primitive.ok", b.rect, "OK");
ok |= b.clicked();
let c = ui.button(tr("Cancel"));
app.auto.add("dialog.primitive.cancel", c.rect, "Cancel");
close |= c.clicked();
});
});
app.dialog_state.primitive = d.clone();
if ok || super::dialogs::ui_enter(ctx) {
let mut p = json!({"kind": d.kind.label().to_ascii_lowercase(), "name": d.name});
for dim in dimensions(d.kind) {
if let Some(v) = d.dims.get(&dim.match_id) {
p[dim.match_id.as_str()] = json!(v);
}
}
if let Err(e) = app.session.execute("layer.newPrimitive", p) {
app.ui.status = e.to_string();
}
close = true;
}
if close {
app.dialog = None;
}
}
/// Menu routing: New 3D Primitive without parameters opens the dialog. Returns true when it did.
pub fn route(app: &mut EffectcraftApp, id: &str, params: &Value) -> Result<bool, String> {
if id != "layer.newPrimitive" || !params.as_object().is_none_or(|m| m.is_empty()) {
return Ok(false);
}
open(app).map(|_| true)
}
+90 -38
View File
@@ -126,8 +126,8 @@ type PendingThumb = (u64, Arc<Mutex<Option<Option<(u32, u32, Vec<u8>)>>>>);
/// Rendered on a background thread (inline on wasm32): until the new one lands the previous
/// thumbnail stays up, so selecting a big comp or editing it never stalls the panel.
fn thumbnail(app: &EffectcraftApp, ctx: &egui::Context, it: &Item) -> Option<egui::TextureHandle> {
let key = egui::Id::new(("proj-thumb", it.id.0));
let pkey = egui::Id::new(("proj-thumb-pending", it.id.0));
let key = egui::Id::unique(("proj-thumb", it.id.0));
let pkey = egui::Id::unique(("proj-thumb-pending", it.id.0));
let rev = app.session.revision;
let cached: Option<(u64, egui::TextureHandle)> = ctx.data(|d| d.get_temp(key));
let mut tex = cached.as_ref().map(|(_, t)| t.clone());
@@ -192,7 +192,7 @@ fn fit(r: Rect, w: f32, h: f32) -> Rect {
/// An inline text edit in progress: (item, field `name`|`comment`, text).
type Editing = (u64, String, String);
fn edit_id() -> egui::Id {
egui::Id::new("proj-inline-edit")
egui::Id::unique("proj-inline-edit")
}
/// Start renaming the selected item (Enter in the Project panel).
@@ -206,22 +206,27 @@ pub fn begin_rename(app: &EffectcraftApp, ctx: &egui::Context) {
/// Where a Shift-extended selection starts (the last item clicked or moved to without Shift).
fn anchor_id() -> egui::Id {
egui::Id::new("project-selection-anchor")
egui::Id::unique("project-selection-anchor")
}
/// The item the arrow keys move from: the last item clicked or moved to.
fn cursor_id() -> egui::Id {
egui::Id::new("project-selection-cursor")
egui::Id::unique("project-selection-cursor")
}
/// An item row's widget: its click / drag area, keyboard focus and accessibility node.
fn row_id(item: ItemId) -> egui::Id {
egui::Id::new(("pitem", item.0))
egui::Id::unique(("pitem", item.0))
}
/// The last row drawn with keyboard focus (egui drops the focus of a row scrolled out of view).
fn focused_row_id() -> egui::Id {
egui::Id::new("project-focused-row")
egui::Id::unique("project-focused-row")
}
/// The item whose selection the panel last scrolled into view.
fn selection_seen_id() -> egui::Id {
egui::Id::unique("proj-sel-seen")
}
/// The focused widget is one of the panel's rows, or nothing has keyboard focus: the panel's keys
@@ -347,6 +352,33 @@ pub(crate) fn visible_rows(app: &EffectcraftApp) -> Vec<(ItemId, usize)> {
rows
}
/// Bring `items` into view: clear the panel's search, open every folder holding them, select
/// them, and show and focus the Project panel. Used by Reveal Layer Source in Project and
/// Reveal Composition in Project, so the item is visible even in a large, filtered project.
pub(crate) fn reveal(app: &mut EffectcraftApp, ctx: &egui::Context, items: &[ItemId]) {
let items: Vec<ItemId> = items.iter().copied().filter(|item| app.session.project.item(*item).is_some()).collect();
if items.is_empty() {
return;
}
for item in &items {
let mut parent = app.session.project.item(*item).and_then(|item| item.parent);
let mut depth = 0;
while let Some(folder) = parent {
app.ui.project_open_folders.insert(folder.0);
parent = app.session.project.item(folder).and_then(|item| item.parent);
depth += 1;
if depth > 256 {
break;
}
}
}
app.ui.project_search.clear();
app.session.state.project_selection = items;
// Force the panel's selection scroll on its next draw, even when the item was already selected.
ctx.data_mut(|d| d.remove_temp::<Option<u64>>(selection_seen_id()));
app.show_panel(crate::dock::PanelKind::Project);
}
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let p = ui.painter().clone();
@@ -392,7 +424,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
app.auto.add("project.details.name", r, &it.name);
let ar = Rect::from_min_size(pos2(r.max.x + 1.0, name_y - 7.0), vec2(arrow_w, 14.0));
let tip = crate::i18n::tr("Compositions that use this item");
let aresp = ui.interact(ar, egui::Id::new("proj-used-in"), Sense::click()).on_hover_text(tip);
let aresp = ui.interact(ar, egui::Id::unique("proj-used-in"), Sense::click()).on_hover_text(tip);
icons::paint(&p, Rect::from_center_size(ar.center(), vec2(9.0, 9.0)), Icon::ChevronDown, if aresp.hovered() { t.text } else { t.text_dim });
app.auto.add("project.details.usedIn", ar, tip);
// The comma sits right after the arrow (its glyph ends short of the hit area).
@@ -437,13 +469,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
} else {
let hint = "Select an item to see its details";
let r = widgets::text_fit(&p, head.center(), Align2::CENTER_CENTER, hint, Tokens::ui(11.0), head.width() - 20.0, t.text_faint);
let r = widgets::text_fit(&p, head.center(), Align2::CENTER_CENTER, crate::i18n::tr(hint), Tokens::ui(11.0), head.width() - 20.0, t.text_faint);
app.auto.add("project.details.hint", r, hint);
}
// Search.
let sr = Rect::from_min_size(pos2(rect.min.x + 8.0, head.max.y + 2.0), vec2(rect.width() - 16.0, 22.0));
let mut q = app.ui.project_search.clone();
widgets::search_field(ui, sr, &mut q, "Search project", &t);
widgets::search_field(ui, sr, &mut q, crate::i18n::tr("Search project"), &t);
app.ui.project_search = q;
app.auto.add("project.search", sr, "Search");
// Column header: Name, Label, then the visible optional columns.
@@ -474,8 +506,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if hr.width() <= 0.0 {
continue;
}
let resp = ui.interact(hr.intersect(hdr), egui::Id::new(("proj-sort", *key)), Sense::click());
hp.with_clip_rect(hr).text(pos2(*x, hdr.center().y), Align2::LEFT_CENTER, *label, Tokens::ui(11.0), if resp.hovered() { t.text } else { t.text_dim });
let resp = ui.interact(hr.intersect(hdr), egui::Id::unique(("proj-sort", *key)), Sense::click());
hp.with_clip_rect(hr).text(
pos2(*x, hdr.center().y),
Align2::LEFT_CENTER,
crate::i18n::tr(label),
Tokens::ui(11.0),
if resp.hovered() { t.text } else { t.text_dim },
);
if *key == "label" {
icons::paint(&hp, Rect::from_center_size(pos2(label_x, hdr.center().y), vec2(10.0, 10.0)), Icon::Keyframe, t.text_dim);
}
@@ -496,7 +534,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
Some(o) => crate::i18n::tr_args("Sort by {}, {}", &[&column, &o]),
None => crate::i18n::tr_args("Sort by {}", &[&column]),
};
egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name)
egui::WidgetInfo::labeled(egui::Role::Button, true, name)
});
if resp.clicked() {
if app.ui.project_sort == *key {
@@ -508,7 +546,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
column_menu(app, &resp);
}
let hresp = ui.interact(Rect::from_min_max(pos2(x, hdr.min.y), hdr.max), egui::Id::new("proj-hdr-rest"), Sense::CLICK);
let hresp = ui.interact(Rect::from_min_max(pos2(x, hdr.min.y), hdr.max), egui::Id::unique("proj-hdr-rest"), Sense::CLICK);
column_menu(app, &hresp);
app.auto.add("project.columns", hdr, "Columns (right-click to show or hide)");
// Rows.
@@ -519,7 +557,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// a click deselects, a drag draws a selection box, a double-click imports, as in After
// Effects (File ▸ Import ▸ File...).
// Not a Tab stop: the keyboard reaches the rows, and nothing else here takes a key.
let empty = ui.interact(list, egui::Id::new("proj-empty"), Sense::CLICK | Sense::DRAG);
let empty = ui.interact(list, egui::Id::unique("proj-empty"), Sense::CLICK | Sense::DRAG);
app.auto.add("project.empty", list, "Double-click to import files; drag to select items; right-click for project actions");
if overflow > 0.0 && ui.rect_contains_pointer(list) {
let (dx, dy, shift) = ui.input(|i| (i.smooth_scroll_delta.x, i.smooth_scroll_delta.y, i.modifiers.shift));
@@ -562,7 +600,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
// A selection made elsewhere (a command, an agent, the viewer) scrolls into view.
let sel_key = egui::Id::new("proj-sel-seen");
let sel_key = selection_seen_id();
let sel_now = app.session.state.project_selection.last().map(|i| i.0);
if ctx.data(|d| d.get_temp::<Option<u64>>(sel_key)) != Some(sel_now) {
ctx.data_mut(|d| d.insert_temp(sel_key, sel_now));
@@ -610,9 +648,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// For screen readers: a tree item named by the item and its type, with its selection,
// depth and (a folder) open state. One Tab stop per row; the panel's keys then apply.
let open = it.is_folder().then(|| app.ui.project_open_folders.contains(&id.0) || !app.ui.project_search.is_empty());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, format!("{}, {}", it.name, crate::i18n::tr(it.type_name()))));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TreeItem, true, format!("{}, {}", it.name, crate::i18n::tr(it.type_name()))));
ctx.accesskit_node_builder(resp.id, |n| {
n.set_role(egui::accesskit::Role::TreeItem);
n.set_selected(selected);
n.set_level(depth.saturating_add(1));
if let Some(open) = open {
@@ -630,10 +667,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if it.is_folder() {
let open = app.ui.project_open_folders.contains(&id.0);
let tw = Rect::from_center_size(pos2(x0 + 4.0, r.center().y), vec2(12.0, 12.0));
let tresp = widgets::twirl(ui, tw, open, egui::Id::new(("pf", id.0)), &t);
let tresp = widgets::twirl(ui, tw, open, egui::Id::unique(("pf", id.0)), &t);
tresp.widget_info(|| {
let name = crate::i18n::tr_args(if open { "Close folder {}" } else { "Open folder {}" }, &[&it.name]);
egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name)
egui::WidgetInfo::labeled(egui::Role::Button, true, name)
});
if tresp.clicked() {
// Alt/Cmd-click twirls every folder inside it too.
@@ -664,7 +701,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
} else {
lp.rect_stroke(pr, 1.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside);
}
let presp = ui.interact(pr.expand(2.0).intersect(list), egui::Id::new(("pproxy", id.0)), Sense::click()).on_hover_text(format!(
let presp = ui.interact(pr.expand(2.0).intersect(list), egui::Id::unique(("pproxy", id.0)), Sense::click()).on_hover_text(format!(
"Proxy: {} ({})",
px.footage.path,
if px.enabled { "in use" } else { "off" }
@@ -672,7 +709,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
app.auto.add(&format!("project.item.{}.proxy", id.0), pr, if px.enabled { "Proxy in use" } else { "Proxy off" });
presp.widget_info(|| {
let name = crate::i18n::tr_args(if px.enabled { "Proxy of {}: in use" } else { "Proxy of {}: off" }, &[&it.name]);
egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name)
egui::WidgetInfo::labeled(egui::Role::Button, true, name)
});
if presp.clicked() {
actions.push(("file.useProxy".into(), json!({"item": id.0})));
@@ -687,7 +724,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let m = Rect::from_center_size(pos2(r.min.x + name_w - 8.0, r.center().y), vec2(13.0, 12.0));
lp.add(egui::Shape::convex_polygon(vec![m.center_top(), m.right_bottom(), m.left_bottom()], t.warning, Stroke::NONE));
lp.text(m.center() + vec2(0.0, 1.5), Align2::CENTER_CENTER, "!", Tokens::semibold(9.0), Color32::BLACK);
ui.interact(m.expand(2.0).intersect(list), egui::Id::new(("pproblem", id.0)), Sense::hover()).on_hover_text(why.as_str());
ui.interact(m.expand(2.0).intersect(list), egui::Id::unique(("pproblem", id.0)), Sense::hover()).on_hover_text(why.as_str());
app.auto.add(&format!("project.item.{}.problem", id.0), m, why);
}
let name_rect = Rect::from_min_max(pos2(x0 + 28.0, r.min.y + 2.0), pos2(r.min.x + name_w + 2.0, r.max.y - 2.0));
@@ -741,13 +778,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// Label swatch: click for the label colour menu.
let sw = Rect::from_center_size(pos2(label_x, r.center().y), vec2(10.0, 10.0));
lp.rect_filled(sw, 2.0, t.label(it.label));
let sresp = ui.interact(sw.expand(3.0).intersect(list), egui::Id::new(("plabel", id.0)), Sense::click());
let sresp = ui.interact(sw.expand(3.0).intersect(list), egui::Id::unique(("plabel", id.0)), Sense::click());
app.auto.add(&format!("project.item.{}.label", id.0), sw, it.label.name());
sresp.widget_info(|| {
let name = crate::i18n::tr_args("Label of {}: {}", &[&it.name, &app.session.prefs.label_name(it.label)]);
egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name)
egui::WidgetInfo::labeled(egui::Role::Button, true, name)
});
let pop = egui::Id::new(("plabel-pop", id.0));
let pop = egui::Id::unique(("plabel-pop", id.0));
if sresp.clicked() || sresp.secondary_clicked() {
widgets::open_popup(ui, pop);
}
@@ -772,10 +809,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if cell_edit(app, ui, &mut actions, "comment", cr) {
continue;
}
let cresp = ui.interact(cell, egui::Id::new(("pcomment", id.0)), Sense::CLICK);
let cresp = ui.interact(cell, egui::Id::unique(("pcomment", id.0)), Sense::CLICK);
app.auto.add(&format!("project.item.{}.comment", id.0), cell, &it.comment);
cresp.widget_info(|| {
egui::WidgetInfo::labeled(egui::WidgetType::Other, true, crate::i18n::tr_args("Comment of {}: {}", &[&it.name, &it.comment]))
egui::WidgetInfo::labeled(egui::Role::Unknown, true, crate::i18n::tr_args("Comment of {}: {}", &[&it.name, &it.comment]))
});
if cresp.double_clicked() {
ctx.data_mut(|d| d.insert_temp::<Editing>(edit_id(), (id.0, "comment".into(), it.comment.clone())));
@@ -799,7 +836,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
lp.rect_stroke(r.shrink(0.5), 0.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
}
// Part of the row: not a second Tab stop.
let nresp = ui.interact(name_clip, egui::Id::new(("pname", id.0)), Sense::CLICK | Sense::DRAG);
let nresp = ui.interact(name_clip, egui::Id::unique(("pname", id.0)), Sense::CLICK | Sense::DRAG);
app.auto.add(&format!("project.item.{}.name", id.0), name_clip, &it.name);
let resp = resp.union(nresp);
if resp.clicked() {
@@ -867,6 +904,20 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
ctx.data_mut(|d| d.insert_temp::<Editing>(edit_id(), (id.0, "name".into(), it.name.clone())));
ui.close();
}
let duplicate_items = targets(app, *id, selected);
// Like Edit > Duplicate: folders disable the whole operation, including mixed selections.
let can_duplicate =
!duplicate_items.is_empty() && duplicate_items.iter().all(|id| app.session.project.item(ItemId(*id)).is_some_and(|i| !i.is_folder()));
let mut duplicate = egui::Button::new(crate::i18n::tr("Duplicate"));
if let Some(shortcut) = app.session.shortcuts().shortcut_of("edit.duplicate", &serde_json::Value::Null) {
duplicate = duplicate.shortcut_text(crate::menus::shortcut_text(shortcut));
}
let response = ui.add_enabled(can_duplicate, duplicate);
app.auto.add("project.context.duplicate", response.rect, "Duplicate");
if response.clicked() {
actions.push(("project.duplicate".into(), json!({"items": duplicate_items})));
ui.close();
}
if it.parent.is_some() && ui.button(crate::i18n::tr("Move to Project Root")).clicked() {
actions.push(("project.move".into(), json!({"items": targets(app, *id, selected), "folder": null})));
ui.close();
@@ -962,7 +1013,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
&& let Some(pos) = ctx.pointer_hover_pos()
&& let Some(it) = app.session.project.item(ItemId(*iid))
{
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("dnd-ghost")));
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("dnd-ghost")));
let g = painter.layout_no_wrap(it.name.clone(), Tokens::ui(12.0), t.text);
let r = Rect::from_min_size(pos + vec2(12.0, 8.0), g.size() + vec2(16.0, 8.0));
painter.rect_filled(r, 4.0, Color32::from_rgba_premultiplied(40, 40, 40, 230));
@@ -978,9 +1029,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
(Icon::NewComp, "newComp", "Create a new Composition"),
] {
let r = Rect::from_min_size(pos2(x, foot.min.y + 3.0), vec2(22.0, 22.0));
let resp = widgets::icon_button(ui, r, icon, false, &t, egui::Id::new(("pfoot", id))).on_hover_text(tip);
let resp = widgets::icon_button(ui, r, icon, false, &t, egui::Id::unique(("pfoot", id))).on_hover_text(tip);
app.auto.add(&format!("project.{id}"), r, tip);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::i18n::tr(tip)));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, crate::i18n::tr(tip)));
if resp.clicked() {
let footage =
app.session.state.project_selection.first().and_then(|i| app.session.project.item(*i)).is_some_and(|i| matches!(i.kind, ItemKind::Footage(_)));
@@ -1010,10 +1061,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
let depth = app.session.project.settings.bit_depth.label();
let dr = Rect::from_min_size(pos2(x + 4.0, foot.min.y + 5.0), vec2(48.0, 18.0));
let dresp = ui.interact(dr, egui::Id::new("bpc"), Sense::click());
let dresp = ui.interact(dr, egui::Id::unique("bpc"), Sense::click());
p.text(dr.center(), Align2::CENTER_CENTER, depth, Tokens::ui(11.5), if dresp.hovered() { t.text } else { t.text_dim });
app.auto.add("project.bitDepth", dr, depth);
dresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::i18n::tr_args("Project color depth: {}", &[&depth])));
dresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, crate::i18n::tr_args("Project color depth: {}", &[&depth])));
// Click cycles 8/16/32 bpc; Alt-click opens Project Settings (as in After Effects).
if dresp.clicked() {
if ui.input(|i| i.modifiers.alt) {
@@ -1024,8 +1075,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
dresp.on_hover_text(crate::i18n::tr("Project color depth: click to cycle 8/16/32 bpc, Alt-click for Project Settings"));
let tr = Rect::from_min_size(pos2(foot.max.x - 30.0, foot.min.y + 3.0), vec2(22.0, 22.0));
let trash = widgets::icon_button(ui, tr, Icon::Trash, false, &t, egui::Id::new("ptrash")).on_hover_text(crate::i18n::tr("Delete selected project items"));
trash.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::i18n::tr("Delete selected project items")));
let trash =
widgets::icon_button(ui, tr, Icon::Trash, false, &t, egui::Id::unique("ptrash")).on_hover_text(crate::i18n::tr("Delete selected project items"));
trash.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, crate::i18n::tr("Delete selected project items")));
if trash.clicked() && !app.session.state.project_selection.is_empty() {
actions.push(("project.delete".into(), json!({})));
}
@@ -1050,7 +1102,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if empty.clicked() && !adding {
app.session.state.project_selection.clear();
}
let box_id = egui::Id::new("proj-marquee");
let box_id = egui::Id::unique("proj-marquee");
if let (Some(start), Some(end)) = (ctx.input(|i| i.pointer.press_origin()), empty.interact_pointer_pos())
&& empty.dragged()
{
@@ -1103,7 +1155,7 @@ fn column_menu(app: &mut EffectcraftApp, resp: &egui::Response) {
ui.label(egui::RichText::new(crate::i18n::tr("Columns")).weak());
for (key, label, _) in COLUMNS {
let mut on = app.ui.project_columns.iter().any(|c| c == key);
if ui.checkbox(&mut on, label).changed() {
if ui.checkbox(&mut on, crate::i18n::tr(label)).changed() {
set_column(&mut app.ui.project_columns, key, on);
}
}
+27 -25
View File
@@ -67,7 +67,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let ectx = EvalCtx { project: &project, comp_id: cid, comp: &comp, time: now, expr: expr.as_deref(), footage: None };
let x0 = rect.min.x + PAD;
let w = rect.width() - 2.0 * PAD;
let scroll = widgets::PanelScroll::begin(ui, egui::Id::new("props-scroll"), rect);
let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("props-scroll"), rect);
let top = rect.min.y + 8.0 - scroll.offset;
let mut y = top;
let mut actions: Actions = vec![];
@@ -87,7 +87,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if let Some(tr) = layer.transform().cloned() {
section_header(app, ui, &p, x0, w, y, "Layer Transform", "properties.transform");
let rr = Rect::from_min_size(pos2(x0 + w - 40.0, y), vec2(40.0, ROW_H));
let resp = ui.interact(rr, egui::Id::new("props-reset"), Sense::click());
let resp = ui.interact(rr, egui::Id::unique("props-reset"), Sense::click());
p.text(rr.right_center(), Align2::RIGHT_CENTER, crate::i18n::tr("Reset"), Tokens::ui(12.0), if resp.hovered() { t.accent_hover } else { t.accent });
app.auto.add("properties.transform.reset", rr, "Reset Transform");
if resp.clicked() {
@@ -120,13 +120,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
divider(&p, x0, w, y - 4.0, &t);
section_header(app, ui, &p, x0, w, y + 4.0, "Text Animation", "properties.textAnimation");
y += ROW_H + 10.0;
let br = Rect::from_min_size(pos2(x0, y), vec2(116.0, 24.0));
let resp = ui.interact(br, egui::Id::new("props-add-animator"), Sense::click());
// Both buttons grow with their (translated) labels; English keeps its 116 / 84 px.
let text_w = |s: &str| p.layout_no_wrap(s.to_string(), Tokens::ui(12.0), t.text).size().x;
let br = Rect::from_min_size(pos2(x0, y), vec2(116f32.max(text_w(crate::i18n::tr("Add Animator")) + 36.0), 24.0));
let resp = ui.interact(br, egui::Id::unique("props-add-animator"), Sense::click());
p.rect_stroke(br, 4.0, Stroke::new(1.0, if resp.hovered() { t.text_dim } else { t.field_border }), egui::StrokeKind::Inside);
icons::paint(&p, Rect::from_center_size(pos2(br.min.x + 14.0, br.center().y), vec2(10.0, 10.0)), Icon::Plus, t.text);
p.text(pos2(br.min.x + 26.0, br.center().y), Align2::LEFT_CENTER, crate::i18n::tr("Add Animator"), Tokens::ui(12.0), t.text);
app.auto.add("properties.addAnimator", br, "Add Animator");
let pop = egui::Id::new("props-add-animator-pop");
let pop = egui::Id::unique("props-add-animator-pop");
if resp.clicked() {
widgets::open_popup(ui, pop);
}
@@ -142,12 +144,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
// Our own presets (original work, see presets/text_animators.json).
let pr = Rect::from_min_size(pos2(br.max.x + 8.0, y), vec2(84.0, 24.0));
let presp = ui.interact(pr, egui::Id::new("props-text-presets"), Sense::click());
let pr = Rect::from_min_size(pos2(br.max.x + 8.0, y), vec2(84f32.max(text_w(crate::i18n::tr("Presets")) + 16.0), 24.0));
let presp = ui.interact(pr, egui::Id::unique("props-text-presets"), Sense::click());
p.rect_stroke(pr, 4.0, Stroke::new(1.0, if presp.hovered() { t.text_dim } else { t.field_border }), egui::StrokeKind::Inside);
p.text(pr.center(), Align2::CENTER_CENTER, crate::i18n::tr("Presets"), Tokens::ui(12.0), t.text);
app.auto.add("properties.textPresets", pr, "Text Animation Presets");
let ppop = egui::Id::new("props-text-presets-pop");
let ppop = egui::Id::unique("props-text-presets-pop");
if presp.clicked() {
widgets::open_popup(ui, ppop);
}
@@ -205,7 +207,7 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
let cells = [("prev", r.min.x + 4.0), ("key", r.min.x + 16.0), ("next", r.min.x + 28.0)];
for (what, cx) in cells {
let cr = Rect::from_center_size(pos2(cx, cy), vec2(12.0, 16.0));
let resp = ui.interact(cr, egui::Id::new(("props-nav", uid, what)), Sense::click());
let resp = ui.interact(cr, egui::Id::unique(("props-nav", uid, what)), Sense::click());
let col = if resp.hovered() { t.text } else { t.text_dim };
match what {
"prev" => triangle(p, cr.center(), true, col),
@@ -235,7 +237,7 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
}
} else {
let swr = Rect::from_center_size(pos2(r.min.x + 16.0, cy), vec2(14.0, 14.0));
let resp = ui.interact(swr, egui::Id::new(("props-sw", uid)), Sense::click());
let resp = ui.interact(swr, egui::Id::unique(("props-sw", uid)), Sense::click());
icons::paint(p, swr, Icon::Stopwatch, if resp.hovered() { t.text } else { t.text_dim });
app.auto.add(&format!("{base}.stopwatch"), swr, &pr.name);
if resp.clicked() {
@@ -245,7 +247,7 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
p.text(pos2(r.min.x + 42.0, cy), Align2::LEFT_CENTER, &pr.name, Tokens::ui(12.0), t.text);
let vx = r.min.x + (r.width() * 0.5).max(130.0);
let label = Rect::from_min_max(pos2(r.min.x + 42.0, r.min.y), pos2(vx - 20.0, r.max.y));
let resp = ui.interact(label, egui::Id::new(("props-label", uid)), Sense::click());
let resp = ui.interact(label, egui::Id::unique(("props-label", uid)), Sense::click());
app.auto.add(&format!("{base}.label"), label, &pr.name);
if resp.double_clicked() {
app.ui.timeline.search.clear();
@@ -269,7 +271,7 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
match &value {
Value::Scalar(v) if pr.name == "Rotation" || pr.name.ends_with(" Rotation") => {
let (_, deg) = super::fx_widgets::split_angle(*v);
let (rr, dr, nv) = super::fx_widgets::angle_field(ui, pos2(vx, cy - 9.0), egui::Id::new(("props-v", uid)), *v, decimals(deg), &t);
let (rr, dr, nv) = super::fx_widgets::angle_field(ui, pos2(vx, cy - 9.0), egui::Id::unique(("props-v", uid)), *v, decimals(deg), &t);
app.auto.add(&format!("{base}.value"), dr, &pr.name);
app.auto.add(&format!("{base}.revolutions"), rr, &pr.name);
if let Some(nv) = nv {
@@ -278,7 +280,7 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
}
Value::Scalar(v) => {
let range = if pr.name == "Opacity" { (0.0, 100.0) } else { (-1e9, 1e9) };
let (vr, nv, _) = widgets::hot_number_at(ui, pos2(vx, cy - 9.0), egui::Id::new(("props-v", uid)), *v, 0.5, range, decimals(*v), suffix, &t);
let (vr, nv, _) = widgets::hot_number_at(ui, pos2(vx, cy - 9.0), egui::Id::unique(("props-v", uid)), *v, 0.5, range, decimals(*v), suffix, &t);
app.auto.add(&format!("{base}.value"), vr, &pr.name);
if let Some(nv) = nv {
set(actions, json!(nv));
@@ -295,7 +297,7 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
if linkable {
// Constrain Proportions toggle, left of the values.
let lr = Rect::from_center_size(pos2(x - 12.0, cy), vec2(12.0, 12.0));
let resp = ui.interact(lr, egui::Id::new(("props-link", uid)), Sense::click()).on_hover_text(tr("Constrain Proportions"));
let resp = ui.interact(lr, egui::Id::unique(("props-link", uid)), Sense::click()).on_hover_text(tr("Constrain Proportions"));
icons::paint(p, lr, Icon::Link, if linked { t.accent } else { t.text_faint });
app.auto.add(&format!("{base}.constrain"), lr, "Constrain Proportions");
if resp.clicked() && !app.ui.timeline.unlinked.remove(&uid) {
@@ -304,7 +306,7 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
}
for d in 0..n.min(c.len()) {
let (vr, nv, _) =
widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::new(("props-v", uid, d)), c[d], 1.0, (-1e9, 1e9), decimals(c[d]), suffix, &t);
widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::unique(("props-v", uid, d)), c[d], 1.0, (-1e9, 1e9), decimals(c[d]), suffix, &t);
app.auto.add(&format!("{base}.value.{d}"), vr, &pr.name);
if let Some(nv) = nv {
let mut nc = c.clone();
@@ -354,14 +356,14 @@ fn text_section(
y += ROW_H + 4.0;
// Font family and style.
let fr = Rect::from_min_size(pos2(x0, y), vec2(w, 22.0));
let pop = egui::Id::new("props-font-pop");
let pop = egui::Id::unique("props-font-pop");
if let Some(f) = super::text_panels::font_picker(app, ui, pop, fr, &doc.font, "properties.text.font", true) {
set(actions, json!({"font": f}));
}
y += 28.0;
let sr = Rect::from_min_size(pos2(x0, y), vec2(w, 22.0));
let spop = egui::Id::new("props-style-pop");
if widgets::dropdown(ui, sr, &doc.style, &t, egui::Id::new("props-style")).clicked() {
let spop = egui::Id::unique("props-style-pop");
if widgets::dropdown(ui, sr, &doc.style, &t, egui::Id::unique("props-style")).clicked() {
widgets::open_popup(ui, spop);
}
app.auto.add("properties.text.style", sr, "Font style");
@@ -375,7 +377,7 @@ fn text_section(
let col2 = x0 + w / 2.0;
let field = |app: &mut EffectcraftApp, ui: &mut egui::Ui, x: f32, y: f32, glyph: &str, key: &str, v: f64, range: (f64, f64), suffix: &str| {
p.text(pos2(x, y + 9.0), Align2::LEFT_CENTER, glyph, Tokens::semibold(11.0), t.text_dim);
let (r, nv, _) = widgets::hot_number_at(ui, pos2(x + 26.0, y), egui::Id::new(("props-text", key)), v, 0.5, range, decimals(v), suffix, &t);
let (r, nv, _) = widgets::hot_number_at(ui, pos2(x + 26.0, y), egui::Id::unique(("props-text", key)), v, 0.5, range, decimals(v), suffix, &t);
app.auto.add(&format!("properties.text.{key}"), r, key);
nv
};
@@ -392,7 +394,7 @@ fn text_section(
// Auto Leading: click to switch to an explicit value (120% of the size).
p.text(pos2(col2, y + 9.0), Align2::LEFT_CENTER, "A", Tokens::semibold(11.0), t.text_dim);
let r = Rect::from_min_size(pos2(col2 + 26.0, y), vec2(40.0, 18.0));
let resp = ui.interact(r, egui::Id::new("props-leading-auto"), Sense::click());
let resp = ui.interact(r, egui::Id::unique("props-leading-auto"), Sense::click());
p.text(
pos2(r.min.x + 2.0, r.center().y),
Align2::LEFT_CENTER,
@@ -418,14 +420,14 @@ fn text_section(
for (i, (key, label, on, c)) in [("fill", "Fill", doc.apply_fill, doc.fill), ("stroke", "Stroke", doc.apply_stroke, doc.stroke)].into_iter().enumerate() {
let ry = y + i as f32 * 24.0;
let cr = Rect::from_min_size(pos2(x0, ry + 2.0), vec2(14.0, 14.0));
if widgets::checkbox(ui, cr, on, &t, egui::Id::new(("props-apply", key))).clicked() {
if widgets::checkbox(ui, cr, on, &t, egui::Id::unique(("props-apply", key))).clicked() {
let k = if key == "fill" { "applyFill" } else { "applyStroke" };
set(actions, json!({k: !on}));
}
app.auto.add(&format!("properties.text.{key}.enabled"), cr, label);
let sw = Rect::from_min_size(pos2(x0 + 24.0, ry + 1.0), vec2(16.0, 16.0));
let pop = egui::Id::new(("props-color-pop", key));
if widgets::swatch(ui, sw, c, egui::Id::new(("props-color", key)), &t).clicked() {
let pop = egui::Id::unique(("props-color-pop", key));
if widgets::swatch(ui, sw, c, egui::Id::unique(("props-color", key)), &t).clicked() {
widgets::open_popup(ui, pop);
}
app.auto.add(&format!("properties.text.{key}"), sw, label);
@@ -459,7 +461,7 @@ fn paragraph_section(
for (i, (j, key)) in ALIGNS.into_iter().enumerate() {
let r = Rect::from_min_size(pos2(x0 + i as f32 * 26.0, y), vec2(22.0, 22.0));
let on = doc.justify == j;
let resp = ui.interact(r, egui::Id::new(("props-para", key)), Sense::click());
let resp = ui.interact(r, egui::Id::unique(("props-para", key)), Sense::click());
p.rect_filled(
r,
3.0,
@@ -514,7 +516,7 @@ fn paint_align_glyph(p: &egui::Painter, r: Rect, j: Justify, c: Color32) {
fn more_button(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, x0: f32, y: f32, id: &str, auto: &str, panel: PanelKind) -> f32 {
let t = app.tokens;
let r = Rect::from_min_size(pos2(x0, y), vec2(64.0, 22.0));
let resp = ui.interact(r, egui::Id::new(id), Sense::click());
let resp = ui.interact(r, egui::Id::unique(id), Sense::click());
p.rect_filled(r, 4.0, if resp.hovered() { t.hover } else { t.field_bg });
p.text(pos2(r.min.x + 8.0, r.center().y), Align2::LEFT_CENTER, "···", Tokens::semibold(12.0), t.text);
p.text(pos2(r.min.x + 26.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::tr("More"), Tokens::ui(12.0), t.text);
+1 -1
View File
@@ -70,7 +70,7 @@ struct Cached {
pub fn overlay(app: &EffectcraftApp, ctx: &egui::Context, cid: ItemId, layer: &Layer, t: Tick) -> Option<Overlay> {
let fx = layer.effects()?.groups().find(|g| puppet::is_puppet(g))?;
let key = (app.session.revision, cid.0, layer.id.0, t.0);
let id = egui::Id::new(("puppet-overlay", layer.id.0));
let id = egui::Id::unique(("puppet-overlay", layer.id.0));
if let Some(c) = ctx.data(|d| d.get_temp::<Cached>(id))
&& c.key == key
{
+44 -28
View File
@@ -35,21 +35,33 @@ pub fn hms(secs: f64) -> String {
format!("{}:{:02}:{:02}", s / 3600, s / 60 % 60, s % 60)
}
/// Unix seconds → `YYYY-MM-DD hh:mm:ss` (UTC).
/// Unix seconds → (proleptic Gregorian year, the same wall-clock instant moved into chrono's range
/// by whole 400-year cycles). The Gregorian calendar repeats every 400 years (146_097 days), so
/// month, day and time of day come from chrono and only the year is carried, which keeps every
/// input exact instead of collapsing out-of-range years to the epoch. `unix` is `u64`, so `days`
/// always fits an `i64`.
fn utc_parts(unix: u64) -> (i64, chrono::NaiveDateTime) {
use chrono::Datelike;
let (days, sod) = ((unix / 86_400) as i64, (unix % 86_400) as i64);
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0));
match (date, time) {
(Some(d), Some(t)) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(i64::MAX), d.and_time(t))
}
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
}
}
/// Unix seconds → `YYYY-MM-DD hh:mm:ss` (UTC). Years far outside chrono's own range are preserved
/// (the old hand-rolled conversion kept them) rather than collapsing to the epoch.
pub fn utc_stamp(unix: u64) -> String {
let days = (unix / 86_400) as i64;
let rem = unix % 86_400;
// Civil-from-days (proleptic Gregorian).
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + i64::from(m <= 2);
format!("{y:04}-{m:02}-{d:02} {:02}:{:02}:{:02}", rem / 3600, rem / 60 % 60, rem % 60)
let (y, t) = utc_parts(unix);
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
format!("{y:04}-{}", t.format("%m-%d %H:%M:%S"))
}
fn status_color(t: &Tokens, s: &RenderStatus) -> Color32 {
@@ -432,7 +444,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
let mut forms: Vec<(RenderQueueItem, String)> = Vec::new();
let state_id = egui::Id::new("rq-ui");
let state_id = egui::Id::unique("rq-ui");
let (mut open, mut selected): (Vec<u64>, Option<u64>) = ui.data(|d| d.get_temp(state_id).unwrap_or_default());
// ---------------------------------------------------------- Current Render strip
@@ -456,20 +468,20 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let render_b = Rect::from_min_size(pos2(top.max.x - 12.0 - bw, top.min.y + 34.0), vec2(bw, 26.0));
let stop_b = Rect::from_min_size(pos2(render_b.min.x - 8.0 - bw, render_b.min.y), vec2(bw, 26.0));
let can_render = !rendering && queue.iter().any(RenderQueueItem::is_queued) && !available.is_empty();
if widgets::text_button(ui, render_b, "Render", can_render, &t, egui::Id::new("rq-render")).clicked() && can_render {
if widgets::text_button(ui, render_b, "Render", can_render, &t, egui::Id::unique("rq-render")).clicked() && can_render {
actions.push(("renderQueue.render", json!({"wait": false})));
}
app.auto.add("renderQueue.render", render_b, "Render");
// Notify (sound + notification when the queue finishes).
let notify = app.session.project.render_prefs.notify;
let nb = Rect::from_min_size(pos2(render_b.min.x - 8.0 - 2.0 * bw - 8.0, render_b.min.y + 4.0), vec2(18.0, 18.0));
if widgets::checkbox(ui, nb, notify, &t, egui::Id::new("rq-notify")).clicked() {
if widgets::checkbox(ui, nb, notify, &t, egui::Id::unique("rq-notify")).clicked() {
actions.push(("renderQueue.setNotify", json!({"notify": !notify})));
}
p.text(pos2(nb.max.x + 5.0, nb.center().y), Align2::LEFT_CENTER, tr("Notify"), Tokens::ui(11.5), t.text_dim);
app.auto.add("renderQueue.notify", nb, if notify { "Notify: on" } else { "Notify: off" });
if rendering {
if widgets::text_button(ui, stop_b, "Stop", false, &t, egui::Id::new("rq-stop")).clicked() {
if widgets::text_button(ui, stop_b, "Stop", false, &t, egui::Id::unique("rq-stop")).clicked() {
actions.push(("renderQueue.stop", json!({})));
}
app.auto.add("renderQueue.stop", stop_b, "Stop");
@@ -526,7 +538,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// ---------------------------------------------------------- items
let list = Rect::from_min_max(pos2(rect.min.x, head.max.y), pos2(rect.max.x, rect.max.y - FOOT_H));
let lp = p.with_clip_rect(list);
let scroll = widgets::PanelScroll::begin(ui, egui::Id::new("rq-scroll"), list);
let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("rq-scroll"), list);
// Items scroll under the header and footer: clip their widgets (and hit tests) to the list.
let panel_clip = ui.clip_rect();
ui.set_clip_rect(list.intersect(panel_clip));
@@ -534,7 +546,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if queue.is_empty() {
lp.text(pos2(list.center().x, list.min.y + 30.0), Align2::CENTER_CENTER, tr("The render queue is empty."), Tokens::ui(12.0), t.text_faint);
}
let editing_id = egui::Id::new("rq-edit-output");
let editing_id = egui::Id::unique("rq-edit-output");
let mut editing: Option<(u64, String)> = ui.data(|d| d.get_temp(editing_id));
for (k, it) in queue.iter().enumerate() {
let n = k + 1;
@@ -553,7 +565,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
t.row_alt
},
);
let row_resp = ui.interact(row, egui::Id::new(("rq-row", it.id)), Sense::click());
let row_resp = ui.interact(row, egui::Id::unique(("rq-row", it.id)), Sense::click());
if row_resp.clicked() {
selected = Some(it.id);
app.ui.focused = crate::dock::PanelKind::RenderQueue;
@@ -580,7 +592,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
app.auto.add(&aid("row"), row, &format!("#{n}"));
let is_open = open.contains(&it.id);
let tw = Rect::from_center_size(pos2(cx_twirl + 8.0, row.center().y), vec2(14.0, 14.0));
if widgets::twirl(ui, tw, is_open, egui::Id::new(("rq-twirl", it.id)), &t).clicked() {
if widgets::twirl(ui, tw, is_open, egui::Id::unique(("rq-twirl", it.id)), &t).clicked() {
if is_open {
open.retain(|i| *i != it.id);
} else {
@@ -589,7 +601,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
app.auto.add(&aid("twirl"), tw, "Twirl");
let cb = Rect::from_center_size(pos2(cx_render + 20.0, row.center().y), vec2(18.0, 18.0));
if widgets::checkbox(ui, cb, it.render, &t, egui::Id::new(("rq-cb", it.id))).clicked() && !rendering {
if widgets::checkbox(ui, cb, it.render, &t, egui::Id::unique(("rq-cb", it.id))).clicked() && !rendering {
actions.push(("renderQueue.setRender", json!({"item": it.id, "render": !it.render})));
}
app.auto.add(&aid("render"), cb, if it.render { "Render: on" } else { "Render: off" });
@@ -607,7 +619,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let st_rect = Rect::from_min_size(pos2(cx_status, row.min.y), vec2(w_status, ROW_H));
lp.text(pos2(cx_status + 2.0, row.center().y), Align2::LEFT_CENTER, &status, Tokens::ui(12.0), status_color(&t, &it.status));
if let RenderStatus::Failed(e) = &it.status {
ui.interact(st_rect, egui::Id::new(("rq-st", it.id)), Sense::hover()).on_hover_text(e);
ui.interact(st_rect, egui::Id::unique(("rq-st", it.id)), Sense::hover()).on_hover_text(e);
}
app.auto.add(&aid("status"), st_rect, &status);
if let Some(s) = it.started {
@@ -639,7 +651,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
};
lp.text(pos2(cx_num - 6.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::ui(11.5), t.text_dim);
let dd = Rect::from_min_size(pos2(ix + 70.0, r.min.y + 3.0), vec2(220.0, ROW_H - 6.0));
let did = egui::Id::new(("rq-dd", it.id, sub));
let did = egui::Id::unique(("rq-dd", it.id, sub));
let shown: String = if summary.chars().count() > 31 { summary.chars().take(30).chain(std::iter::once('…')).collect() } else { summary.clone() };
if widgets::dropdown(ui, dd, &shown, &t, did).clicked() && !rendering {
widgets::open_popup(ui, did);
@@ -672,7 +684,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// Log: Errors Only / Plus Settings / Plus Per Frame Info.
let ld = Rect::from_min_size(pos2(r.max.x - 190.0, r.min.y + 3.0), vec2(150.0, ROW_H - 6.0));
lp.text(pos2(ld.min.x - 6.0, r.center().y), Align2::RIGHT_CENTER, tr("Log:"), Tokens::ui(11.5), t.text_dim);
let lid = egui::Id::new(("rq-log", it.id));
let lid = egui::Id::unique(("rq-log", it.id));
if widgets::dropdown(ui, ld, it.log.label(), &t, lid).clicked() && !rendering {
widgets::open_popup(ui, lid);
}
@@ -687,7 +699,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let ox = dd.max.x + 14.0;
lp.text(pos2(ox, r.center().y), Align2::LEFT_CENTER, tr("Output To:"), Tokens::ui(11.5), t.text_dim);
let odd = Rect::from_min_size(pos2(ox + 62.0, r.min.y + 3.0), vec2(22.0, ROW_H - 6.0));
let oid = egui::Id::new(("rq-out", it.id));
let oid = egui::Id::unique(("rq-out", it.id));
if widgets::dropdown(ui, odd, "", &t, oid).clicked() && !rendering {
widgets::open_popup(ui, oid);
}
@@ -723,7 +735,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
} else {
let shown = app.session.resolve_output(it).unwrap_or_else(|| "Not yet specified".into());
let resp = ui.interact(path_rect, egui::Id::new(("rq-path", it.id)), Sense::click());
let resp = ui.interact(path_rect, egui::Id::unique(("rq-path", it.id)), Sense::click());
let col = if resp.hovered() { t.accent_hover } else { t.hot_text };
lp.with_clip_rect(path_rect.intersect(list)).text(pos2(path_rect.min.x, r.center().y), Align2::LEFT_CENTER, &shown, Tokens::ui(11.5), col);
// Clicking the file name opens the save dialog (Output Movie To); Alt-click, or a
@@ -807,6 +819,10 @@ mod tests {
fn stamps() {
assert_eq!(super::utc_stamp(0), "1970-01-01 00:00:00");
assert_eq!(super::utc_stamp(1_700_000_000), "2023-11-14 22:13:20");
// Years far outside chrono's own range are preserved (the old hand-rolled conversion kept
// them) rather than collapsing to the epoch.
assert_eq!(super::utc_stamp(10_000_000_000_000), "318857-05-20 17:46:40");
assert_eq!(super::utc_stamp(u64::MAX), "584554051223-11-09 07:00:15");
assert_eq!(super::hms(3725.4), "1:02:05");
}
}
+9 -9
View File
@@ -64,7 +64,7 @@ pub fn draw_overlay(
(scale * 1000.0) as u32,
format!("{}{:?}{:?}{}", o.view, o.overlay_color, o.boundary_color, o.overlay_opacity),
);
let id = egui::Id::new("roto-overlay");
let id = egui::Id::unique("roto-overlay");
let cached: Option<Overlay> = ctx.data(|d| d.get_temp(id));
let ov = match cached {
Some(c) if c.key == key => Some(c),
@@ -149,7 +149,7 @@ pub fn interact(
let ctx = ui.ctx().clone();
let mods = ui.input(|i| i.modifiers);
let Some(kind) = app.ui.tool.roto_kind(mods.alt) else { return false };
let resp = ui.interact(area, egui::Id::new("layer-panel-roto"), Sense::click_and_drag());
let resp = ui.interact(area, egui::Id::unique("layer-panel-roto"), Sense::click_and_drag());
let o = app.session.state.roto.clone();
let refine = kind.starts_with("refine");
let dia = if refine { o.refine_diameter } else { o.diameter };
@@ -170,7 +170,7 @@ pub fn interact(
}
// ⌘/Ctrl-drag resizes the brush (After Effects): the circle stays at the press point and its
// edge follows the pointer. No stroke is painted.
let rid = egui::Id::new("layer-panel-roto-resize");
let rid = egui::Id::unique("layer-panel-roto-resize");
if resp.drag_started()
&& mods.command
&& let Some(pos) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos())
@@ -194,7 +194,7 @@ pub fn interact(
}
return true;
}
let did = egui::Id::new("layer-panel-roto-drag");
let did = egui::Id::unique("layer-panel-roto-drag");
if resp.drag_started()
&& let Some(pos) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos())
{
@@ -277,8 +277,8 @@ pub fn span_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
p.line_segment([pos2(cx, r.min.y), pos2(cx, r.max.y)], Stroke::new(1.0, t.timecode));
p.rect_stroke(track, 2.0, Stroke::new(1.0, Color32::from_black_alpha(140)), StrokeKind::Outside);
app.auto.add("layerPanel.rotoSpan", track, "Segmentation span");
let resp = ui.interact(track, egui::Id::new("roto-span-bar"), Sense::click_and_drag());
let hid = egui::Id::new("roto-span-handle");
let resp = ui.interact(track, egui::Id::unique("roto-span-bar"), Sense::click_and_drag());
let hid = egui::Id::unique("roto-span-handle");
if resp.drag_started()
&& let (Some(pos), Some(_)) = (resp.interact_pointer_pos(), d.base)
{
@@ -322,7 +322,7 @@ pub fn bar_buttons(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
for (id, label) in [("alpha", tr("Alpha")), ("alphaBoundary", tr("Boundary")), ("alphaOverlay", tr("Overlay"))] {
let r = Rect::from_min_size(pos2(x, bar.center().y - 10.0), vec2(66.0, 20.0));
let on = view == id;
if widgets::text_button(ui, r, label, on, &t, egui::Id::new(("roto-view", id))).clicked() {
if widgets::text_button(ui, r, label, on, &t, egui::Id::unique(("roto-view", id))).clicked() {
let v = if on { "none" } else { id };
let _ = app.session.execute("roto.options", json!({"view": v}));
}
@@ -337,7 +337,7 @@ pub fn bar_buttons(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
x += 8.0;
let r = Rect::from_min_size(pos2(x, bar.center().y - 10.0), vec2(74.0, 20.0));
let label = if frozen { tr("Unfreeze") } else { tr("Freeze") };
if widgets::text_button(ui, r, label, frozen, &t, egui::Id::new(("roto-freeze", uid))).clicked() {
if widgets::text_button(ui, r, label, frozen, &t, egui::Id::unique(("roto-freeze", uid))).clicked() {
let id = if frozen { "roto.unfreeze" } else { "roto.freeze" };
if let Err(e) = app.session.execute(id, json!({"layer": layer.id.0, "effect": uid})) {
app.ui.status = e.to_string();
@@ -347,7 +347,7 @@ pub fn bar_buttons(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
x += 80.0;
if running {
let r = Rect::from_min_size(pos2(x, bar.center().y - 10.0), vec2(60.0, 20.0));
if widgets::text_button(ui, r, tr("Stop"), false, &t, egui::Id::new("roto-stop")).clicked() {
if widgets::text_button(ui, r, tr("Stop"), false, &t, egui::Id::unique("roto-stop")).clicked() {
let _ = app.session.execute("roto.cancel", json!({}));
}
app.auto.add("layerPanel.roto.stop", r, "Stop");
+1 -1
View File
@@ -22,7 +22,7 @@ struct State {
}
fn state_id() -> egui::Id {
egui::Id::new("rq-templates-dialog")
egui::Id::unique("rq-templates-dialog")
}
/// Open the dialog for `kind` (`renderSettings` | `outputModule`).
+1 -1
View File
@@ -97,7 +97,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let time = app.session.time();
let key = (app.session.revision, cid.0, time.0, format!("{}{}{}{}{}x{}", st.scope, st.standard, st.float, st.clamp, o.width, o.height), 0);
let ctx = ui.ctx().clone();
let id = egui::Id::new("scopes-cache");
let id = egui::Id::unique("scopes-cache");
let cached: Option<Cached> = ctx.data(|d| d.get_temp(id));
let cached = match cached {
Some(c) if c.key == key => c,
+3 -3
View File
@@ -110,7 +110,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let mut run_now = false;
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(in_rect));
let te = egui::TextEdit::multiline(&mut app.ui.script_console.input)
.id(egui::Id::new("script-console-input"))
.id(egui::Id::unique("script-console-input"))
.font(Tokens::mono(12.0))
.desired_width(in_rect.width())
.desired_rows(4)
@@ -124,10 +124,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let bar = Rect::from_min_size(pos2(rect.min.x, rect.max.y - bar_h), vec2(rect.width(), bar_h));
let run_r = Rect::from_min_size(pos2(bar.max.x - 64.0, bar.min.y + 2.0), vec2(56.0, 20.0));
let clear_r = Rect::from_min_size(pos2(bar.max.x - 128.0, bar.min.y + 2.0), vec2(56.0, 20.0));
let run_resp = ui.interact(run_r, egui::Id::new("script-console-run"), Sense::click());
let run_resp = ui.interact(run_r, egui::Id::unique("script-console-run"), Sense::click());
p.rect_filled(run_r, 3.0, if run_resp.hovered() { t.accent_hover } else { t.accent });
p.text(run_r.center(), Align2::CENTER_CENTER, tr("Run"), Tokens::ui(12.0), egui::Color32::WHITE);
let clear_resp = ui.interact(clear_r, egui::Id::new("script-console-clear"), Sense::click());
let clear_resp = ui.interact(clear_r, egui::Id::unique("script-console-clear"), Sense::click());
p.rect_stroke(clear_r, 3.0, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside);
p.text(clear_r.center(), Align2::CENTER_CENTER, tr("Clear"), Tokens::ui(12.0), t.text);
app.auto.add("scriptConsole.run", run_r, "Run");
+7 -7
View File
@@ -51,7 +51,7 @@ fn draw_children(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2
let tw = (tab.text.chars().count() as f32 * 7.0 + 20.0).max(48.0);
let hr = Rect::from_min_size(pos2(x, r.min.y), vec2(tw, 22.0));
let active = i == w.active_tab;
let resp = ui.interact(hr, egui::Id::new(("sui-tab", win, tab.id)), Sense::click());
let resp = ui.interact(hr, egui::Id::unique(("sui-tab", win, tab.id)), Sense::click());
ui.painter().rect_filled(
hr,
3.0,
@@ -86,7 +86,7 @@ fn draw_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2,
let services = app.session.services.clone();
let rect = rect_of(origin, w.bounds);
register(app, win, w, rect);
let id = egui::Id::new(("sui", win, w.id));
let id = egui::Id::unique(("sui", win, w.id));
let tip = |r: egui::Response| if w.help_tip.is_empty() { r } else { r.on_hover_text(&w.help_tip) };
// onDraw: the script paints the control. Without drawOSControl() its paint replaces the
// default look (buttons and toggles still respond to clicks); with it, it goes on top.
@@ -316,7 +316,7 @@ fn paint_draw(ui: &egui::Ui, rect: Rect, ops: &[DrawOp], t: &Tokens, services: &
/// A ScriptUIImage as a texture (decoded once and kept; `None` when it can't be read or
/// decoded).
fn image_texture(ctx: &egui::Context, img: &ImageRef, services: &dyn Services) -> Option<egui::TextureHandle> {
let id = egui::Id::new(("scriptui-image", img));
let id = egui::Id::unique(("scriptui-image", img));
if let Some(cached) = ctx.data(|d| d.get_temp::<Option<egui::TextureHandle>>(id)) {
return cached;
}
@@ -382,7 +382,7 @@ fn wants_mouse(w: &Widget, depth: usize) -> bool {
/// same control; it replaces the plain click the control reported, so a checkbox toggles once).
/// Only events a listener wants are sent; none while `active` is false (a modal dialog is up).
fn mouse_events(ui: &egui::Ui, origin: egui::Pos2, area: Rect, win: u32, root: &Widget, active: bool, acts: &mut Vec<Act>) {
let id = egui::Id::new(("sui-mouse", win));
let id = egui::Id::unique(("sui-mouse", win));
if !wants_mouse(root, 0) {
ui.data_mut(|d| d.remove::<MouseTrack>(id));
return;
@@ -490,7 +490,7 @@ fn perform(app: &mut EffectcraftApp, ctx: &egui::Context, acts: Vec<Act>) {
Ok(r) => {
if let Some(e) = r.get("error").filter(|e| !e.is_null()) {
let msg = e["message"].as_str().unwrap_or("script error").to_string();
app.toast = Some((msg, ctx.input(|i| i.time)));
app.toast = Some((msg, app.now()));
} else if let Some(o) = r["output"].as_str().filter(|o| !o.is_empty()) {
app.ui.status = o.lines().last().unwrap_or_default().to_string();
}
@@ -512,7 +512,7 @@ pub fn show_windows(app: &mut EffectcraftApp, ctx: &egui::Context) {
let blocked = windows.iter().any(|w| w.modal);
if blocked {
// A modal dialog blocks the app behind it, as in After Effects.
let r = egui::Area::new(egui::Id::new("scriptui-modal-block")).order(egui::Order::Middle).fixed_pos(screen.min).show(ctx, |ui| {
let r = egui::Area::new(egui::Id::unique("scriptui-modal-block")).order(egui::Order::Middle).fixed_pos(screen.min).show(ctx, |ui| {
ui.allocate_exact_size(screen.size(), Sense::click_and_drag());
ui.painter().rect_filled(screen, 0.0, Color32::from_black_alpha(90));
});
@@ -523,7 +523,7 @@ pub fn show_windows(app: &mut EffectcraftApp, ctx: &egui::Context) {
let mut open = true;
let order = if w.modal { egui::Order::Foreground } else { egui::Order::Middle };
let resp = egui::Window::new(if w.title.is_empty() { &w.script } else { &w.title })
.id(egui::Id::new(("scriptui-window", w.id)))
.id(egui::Id::unique(("scriptui-window", w.id)))
.order(order)
.collapsible(false)
.resizable(false)
+16 -7
View File
@@ -346,7 +346,7 @@ fn theme_card(
let favorite = favorites.iter().any(|id| id == value);
let tip = tr(if favorite { "Remove from favorites" } else { "Add to favorites" });
let r = ui.add_visible(favorite || hovered, egui::Button::new("").min_size(vec2(24.0, 24.0)).frame(false)).on_hover_text(tip);
r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("{}: {}", tip, tr(label))));
r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, format!("{}: {}", tip, tr(label))));
reg(app, &format!("settings.appearance.favoriteThemes.{value}"), &r, tip);
if favorite || hovered {
crate::icons::paint(
@@ -589,6 +589,7 @@ fn browser_storage_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, t: &Tokens, a
ui.ctx().request_repaint_after(std::time::Duration::from_secs(1));
}
/// Draw the editable label colors and names in Settings.
fn labels_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, cur: &Value, acts: &mut Vec<Act>) {
ui.label(tr("Label Colors and Names:"));
ui.add_space(4.0);
@@ -622,6 +623,7 @@ fn labels_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, cur: &Value, acts: &mu
}
}
/// Draw the Settings dialog, including its translated page list and the selected page's controls.
pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let all = pages();
let mut page = app.dialog_state.settings_page.clone();
@@ -661,28 +663,35 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
{
let ui = &mut body;
// Page list (left, like After Effects).
let widest_page = all
.iter()
.map(|p| ui.painter().layout_no_wrap(crate::i18n::settings_page_title(p.title).to_owned(), Tokens::ui(12.5), t.text).size().x)
.fold(0.0_f32, f32::max);
let page_width = (widest_page + 20.0).max(176.0).min((ui.available_width() * 0.42).min(300.0));
ui.vertical(|ui| {
ui.set_width(180.0);
ui.set_width(page_width + 4.0);
egui::ScrollArea::vertical().id_salt("settings-pages").max_height(body_h).auto_shrink([false, false]).show(ui, |ui| {
// The rows touch, so the pointer never falls between two of them.
ui.spacing_mut().item_spacing.y = 0.0;
for p in &all {
let (r, resp) = ui.allocate_exact_size(vec2(176.0, 24.0), egui::Sense::click());
let (r, resp) = ui.allocate_exact_size(vec2(page_width, 24.0), egui::Sense::click());
let sel = page == p.id;
if sel {
ui.painter().rect_filled(r, 3.0, t.accent);
} else if resp.hovered() {
ui.painter().rect_filled(r, 3.0, t.hover);
}
ui.painter().text(
crate::widgets::text_fit(
ui.painter(),
r.left_center() + vec2(10.0, 0.0),
egui::Align2::LEFT_CENTER,
tr(p.title),
crate::i18n::settings_page_title(p.title),
Tokens::ui(12.5),
(page_width - 20.0).max(0.0),
if sel { Color32::WHITE } else { t.text },
);
app.auto.add(&format!("settings.page.{}", p.id), r, p.title);
if resp.clicked() {
if resp.on_hover_text(crate::i18n::settings_page_title(p.title)).clicked() {
page = p.id.to_string();
}
}
@@ -694,7 +703,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
// dialog scrolled sideways a little (#284).
ui.set_width(ui.available_width());
let p = &all[idx];
ui.label(RichText::new(tr(p.title)).font(Tokens::semibold(14.0)).color(t.tab_text_active));
ui.label(RichText::new(crate::i18n::settings_page_title(p.title)).font(Tokens::semibold(14.0)).color(t.tab_text_active));
ui.add_space(6.0);
egui::ScrollArea::vertical().max_height((body_h - 30.0).max(0.0)).auto_shrink([false, false]).show(ui, |ui| {
page_ui(app, ui, p, &cur, t, &mut acts);
+1 -1
View File
@@ -377,7 +377,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
}
let id = format!("shortcuts.key.{}", name.trim_start_matches('#'));
app.auto.add(&id, r, label);
let resp = ui.interact(r, egui::Id::new(("sc-key", name, r.min.x as i32)), Sense::click());
let resp = ui.interact(r, egui::Id::unique(("sc-key", name, r.min.x as i32)), Sense::click());
if resp.clicked() {
if let Some(i) = mod_idx {
st.mods[i] = !st.mods[i];
+3 -3
View File
@@ -69,7 +69,7 @@ pub fn home_tab(app: &mut EffectcraftApp, ui: &mut egui::Ui, area: Rect) {
// Save the open project as a user template.
let can_save = app.session.project.comps().next().is_some();
let sr = Rect::from_min_size(pos2(area.max.x - 210.0, y - 4.0), vec2(210.0, 28.0));
let save = widgets::text_button(ui, sr, "Save Current as Template…", false, &t, egui::Id::new("home-templates-save")).clicked();
let save = widgets::text_button(ui, sr, "Save Current as Template…", false, &t, egui::Id::unique("home-templates-save")).clicked();
app.auto.add("home.templates.saveCurrent", sr, "Save Current as Template…");
if save {
if can_save {
@@ -101,7 +101,7 @@ pub fn home_tab(app: &mut EffectcraftApp, ui: &mut egui::Ui, area: Rect) {
let (cx, cy) = (full.min.x + (i % cols) as f32 * (card_w + gap), full.min.y + (i / cols) as f32 * (card_h + gap));
let r = Rect::from_min_size(pos2(cx, cy), vec2(card_w, card_h));
let visible = r.intersects(ui.clip_rect());
let resp = ui.interact(r, egui::Id::new(("template-card", &tpl.id)), Sense::click()).on_hover_text(&tpl.description);
let resp = ui.interact(r, egui::Id::unique(("template-card", &tpl.id)), Sense::click()).on_hover_text(&tpl.description);
p.rect_filled(r, 8.0, if resp.hovered() { Color32::from_rgb(0x2c, 0x2e, 0x37) } else { Color32::from_rgb(0x24, 0x26, 0x2e) });
let tr = Rect::from_min_size(r.min, vec2(card_w, thumb_h));
let tex = if visible { thumb(app, &ctx, &tpl.id, &mut budget) } else { None };
@@ -137,7 +137,7 @@ pub fn home_tab(app: &mut EffectcraftApp, ui: &mut egui::Ui, area: Rect) {
let mut del_hit = false;
if !tpl.builtin {
let dr = Rect::from_min_size(pos2(tr.max.x - 30.0, tr.min.y + 6.0), vec2(24.0, 24.0));
let dresp = ui.interact(dr, egui::Id::new(("template-del", &tpl.id)), Sense::click()).on_hover_text(crate::i18n::tr("Delete this template"));
let dresp = ui.interact(dr, egui::Id::unique(("template-del", &tpl.id)), Sense::click()).on_hover_text(crate::i18n::tr("Delete this template"));
if resp.hovered() || dresp.hovered() {
p.circle_filled(dr.center(), 12.0, if dresp.hovered() { Color32::from_rgb(0xc0, 0x3a, 0x3a) } else { Color32::from_black_alpha(170) });
icons::paint(&p, dr.shrink(6.0), Icon::Close, Color32::WHITE);
+35 -32
View File
@@ -63,7 +63,7 @@ pub fn text_target(app: &EffectcraftApp) -> Option<TextTarget> {
fn toggle(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, r: Rect, id: &str, label: &str, on: bool, bold: bool) -> bool {
let t = app.tokens;
let resp = ui.interact(r, egui::Id::new(("text-toggle", id)), Sense::click());
let resp = ui.interact(r, egui::Id::unique(("text-toggle", id)), Sense::click());
p.rect_filled(
r,
3.0,
@@ -88,7 +88,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let target = text_target(app);
let enabled = target.is_some();
let doc = target.as_ref().map(|t| t.doc.clone()).unwrap_or_default();
let scroll = widgets::PanelScroll::begin(ui, egui::Id::new("character-scroll"), rect);
let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("character-scroll"), rect);
let top = rect.min.y + 10.0 - scroll.offset;
let mut y = top;
let x0 = rect.min.x + 10.0;
@@ -96,14 +96,14 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let mut actions: Vec<serde_json::Value> = vec![];
// Font family + style.
let fr = Rect::from_min_size(pos2(x0, y), vec2(w, 22.0));
let pop = egui::Id::new("char-font-pop");
let pop = egui::Id::unique("char-font-pop");
if let Some(f) = font_picker(app, ui, pop, fr, &doc.font, "character.font", enabled) {
actions.push(json!({"font": f}));
}
y += 28.0;
let sr = Rect::from_min_size(pos2(x0, y), vec2(w - 84.0, 22.0));
let spop = egui::Id::new("char-style-pop");
if widgets::dropdown(ui, sr, &doc.style, &t, egui::Id::new("char-style")).clicked() && enabled {
let spop = egui::Id::unique("char-style-pop");
if widgets::dropdown(ui, sr, &doc.style, &t, egui::Id::unique("char-style")).clicked() && enabled {
widgets::open_popup(ui, spop);
}
app.auto.add("character.style", sr, "Font style");
@@ -135,7 +135,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let fx = x0 + col as f32 * (w / 2.0);
let fy = y + row as f32 * 28.0;
p.text(pos2(fx, fy + 9.0), Align2::LEFT_CENTER, glyph, Tokens::semibold(11.0), t.text_dim);
let (r, nv, _) = widgets::hot_number_at(ui, pos2(fx + 26.0, fy), egui::Id::new(("char", key)), v, 0.5, range, 0, suffix, &t);
let (r, nv, _) = widgets::hot_number_at(ui, pos2(fx + 26.0, fy), egui::Id::unique(("char", key)), v, 0.5, range, 0, suffix, &t);
app.auto.add(&format!("character.{key}"), r, key);
if let Some(nv) = nv
&& enabled
@@ -153,8 +153,8 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
Kerning::Manual(v) => format!("{v:.0}"),
};
let kr = Rect::from_min_size(pos2(x0 + 26.0, fy), vec2((w / 2.0 - 32.0).max(50.0), 20.0));
let kpop = egui::Id::new("char-kerning-pop");
if widgets::dropdown(ui, kr, &label, &t, egui::Id::new("char-kerning")).clicked() && enabled {
let kpop = egui::Id::unique("char-kerning-pop");
if widgets::dropdown(ui, kr, &label, &t, egui::Id::unique("char-kerning")).clicked() && enabled {
widgets::open_popup(ui, kpop);
}
app.auto.add("character.kerning", kr, "Kerning");
@@ -174,8 +174,8 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let fy = y + 2.0 * 28.0;
let dr = Rect::from_min_size(pos2(fx, fy), vec2(w / 2.0, 20.0));
let labels = vec!["Fill Over Stroke".to_string(), "Stroke Over Fill".to_string()];
let pop = egui::Id::new("char-stroke-order-pop");
if widgets::dropdown(ui, dr, &labels[doc.stroke_over_fill as usize], &t, egui::Id::new("char-stroke-order")).clicked() && enabled {
let pop = egui::Id::unique("char-stroke-order-pop");
if widgets::dropdown(ui, dr, &labels[doc.stroke_over_fill as usize], &t, egui::Id::unique("char-stroke-order")).clicked() && enabled {
widgets::open_popup(ui, pop);
}
app.auto.add("character.strokeOverFill", dr, "Stroke order");
@@ -188,13 +188,13 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
for (i, (key, label, on, c)) in [("fill", "Fill", doc.apply_fill, doc.fill), ("stroke", "Stroke", doc.apply_stroke, doc.stroke)].into_iter().enumerate() {
let fx = x0 + i as f32 * (w / 2.0);
let cr = Rect::from_min_size(pos2(fx, y + 3.0), vec2(14.0, 14.0));
if widgets::checkbox(ui, cr, on, &t, egui::Id::new(("char-apply", key))).clicked() && enabled {
if widgets::checkbox(ui, cr, on, &t, egui::Id::unique(("char-apply", key))).clicked() && enabled {
actions.push(json!({if key == "fill" { "applyFill" } else { "applyStroke" }: !on}));
}
app.auto.add(&format!("character.{key}.enabled"), cr, label);
let fs = Rect::from_min_size(pos2(fx + 20.0, y), vec2(26.0, 20.0));
let pop = egui::Id::new(("char-color-pop", key));
if widgets::swatch(ui, fs, c, egui::Id::new(("char-color", key)), &t).clicked() && enabled {
let pop = egui::Id::unique(("char-color-pop", key));
if widgets::swatch(ui, fs, c, egui::Id::unique(("char-color", key)), &t).clicked() && enabled {
widgets::open_popup(ui, pop);
}
app.auto.add(&format!("character.{key}"), fs, label);
@@ -204,7 +204,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
let er = Rect::from_min_size(pos2(fs.max.x + 4.0, y + 1.0), vec2(18.0, 18.0));
let active = app.ui.text_color_pick.as_ref().is_some_and(|pick| pick.layer == target.as_ref().map_or(0, |tt| tt.layer) && pick.field == key);
let eye = super::fx_widgets::eyedropper_button(ui, er, active, egui::Id::new(("char-eye", key)), &t);
let eye = super::fx_widgets::eyedropper_button(ui, er, active, egui::Id::unique(("char-eye", key)), &t);
app.auto.add(&format!("character.{key}.eyedropper"), er, &format!("{label} eyedropper"));
if eye.clicked() {
if active {
@@ -240,17 +240,17 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
y += 32.0;
// Ligatures.
let lr = Rect::from_min_size(pos2(x0, y), vec2(14.0, 14.0));
if widgets::checkbox(ui, lr, doc.ligatures, &t, egui::Id::new("char-ligatures")).clicked() && enabled {
if widgets::checkbox(ui, lr, doc.ligatures, &t, egui::Id::unique("char-ligatures")).clicked() && enabled {
actions.push(json!({"ligatures": !doc.ligatures}));
}
app.auto.add("character.ligatures", lr, "Ligatures");
p.text(pos2(x0 + 20.0, y + 7.0), Align2::LEFT_CENTER, tr("Ligatures"), Tokens::ui(11.5), t.text_dim);
// OpenType: a popup with the font's layout features (like the Character panel's OpenType menu).
let or = Rect::from_min_size(pos2(sr.max.x + 6.0, sr.min.y), vec2(78.0, 22.0));
let opop = egui::Id::new("char-opentype-pop");
let opop = egui::Id::unique("char-opentype-pop");
let n_on = doc.opentype.feature_settings().len();
let olabel = if n_on > 0 { format!("OT ({n_on})") } else { "OpenType".to_string() };
if widgets::dropdown(ui, or, &olabel, &t, egui::Id::new("char-opentype")).clicked() && enabled {
if widgets::dropdown(ui, or, &olabel, &t, egui::Id::unique("char-opentype")).clicked() && enabled {
widgets::open_popup(ui, opop);
}
app.auto.add("character.opentype", or, "OpenType");
@@ -264,7 +264,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
.enumerate()
{
let cr = Rect::from_min_size(pos2(x0 + i as f32 * 120.0, y), vec2(14.0, 14.0));
if widgets::checkbox(ui, cr, on, &t, egui::Id::new(("char-vert", key))).clicked() && enabled {
if widgets::checkbox(ui, cr, on, &t, egui::Id::unique(("char-vert", key))).clicked() && enabled {
actions.push(json!({key: !on}));
}
app.auto.add(&format!("character.{key}"), cr, label);
@@ -288,7 +288,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let v = doc.variations.iter().chain(&instance).find(|(t2, _)| t2.trim_end() == tag).map(|(_, v)| *v).unwrap_or(a.default);
p.text(pos2(fx, fy + 9.0), Align2::LEFT_CENTER, &tag, Tokens::semibold(10.5), t.text_dim);
let (r, nv, _) =
widgets::hot_number_at(ui, pos2(fx + 34.0, fy), egui::Id::new(("char-axis", &tag)), v as f64, 1.0, (a.min as f64, a.max as f64), 0, "", &t);
widgets::hot_number_at(ui, pos2(fx + 34.0, fy), egui::Id::unique(("char-axis", &tag)), v as f64, 1.0, (a.min as f64, a.max as f64), 0, "", &t);
app.auto.add(&format!("character.axis.{tag}"), r, &a.name);
if let Some(nv) = nv
&& enabled
@@ -461,7 +461,7 @@ pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let doc = target.as_ref().map(|t| t.doc.clone()).unwrap_or_default();
let x0 = rect.min.x + 10.0;
let w = rect.width() - 20.0;
let scroll = widgets::PanelScroll::begin(ui, egui::Id::new("paragraph-scroll"), rect);
let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("paragraph-scroll"), rect);
let top = rect.min.y + 10.0 - scroll.offset;
let mut y = top;
let mut actions: Vec<serde_json::Value> = vec![];
@@ -469,7 +469,7 @@ pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
for (i, (j, key)) in ALIGNS.into_iter().enumerate() {
let r = Rect::from_min_size(pos2(x0 + i as f32 * (bw + 3.0), y), vec2(bw, 24.0));
let on = doc.justify == j;
let resp = ui.interact(r, egui::Id::new(("para", key)), Sense::click());
let resp = ui.interact(r, egui::Id::unique(("para", key)), Sense::click());
p.rect_filled(
r,
3.0,
@@ -504,7 +504,7 @@ pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let fx = x0 + (i % 2) as f32 * (w / 2.0);
let fy = y + (i / 2) as f32 * 28.0;
p.text(pos2(fx, fy + 9.0), Align2::LEFT_CENTER, glyph, Tokens::semibold(11.0), t.text_dim);
let (r, nv, _) = widgets::hot_number_at(ui, pos2(fx + 26.0, fy), egui::Id::new(("para-f", key)), v, 0.5, (-5000.0, 5000.0), 0, " px", &t);
let (r, nv, _) = widgets::hot_number_at(ui, pos2(fx + 26.0, fy), egui::Id::unique(("para-f", key)), v, 0.5, (-5000.0, 5000.0), 0, " px", &t);
app.auto.add(&format!("paragraph.{key}"), r, key);
if let Some(nv) = nv
&& enabled
@@ -521,8 +521,8 @@ pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if world_ready || rtl {
let dr = Rect::from_min_size(pos2(x0, y), vec2(w / 2.0 - 4.0, 22.0));
let dirs = vec!["Left-to-Right Text".to_string(), "Right-to-Left Text".to_string()];
let dpop = egui::Id::new("para-direction-pop");
if widgets::dropdown(ui, dr, &dirs[rtl as usize], &t, egui::Id::new("para-direction")).clicked() && enabled {
let dpop = egui::Id::unique("para-direction-pop");
if widgets::dropdown(ui, dr, &dirs[rtl as usize], &t, egui::Id::unique("para-direction")).clicked() && enabled {
widgets::open_popup(ui, dpop);
}
app.auto.add("paragraph.direction", dr, "Text direction");
@@ -536,9 +536,9 @@ pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
} else {
vec!["Every-line Composer".to_string(), "Single-line Composer".to_string()]
};
let cpop = egui::Id::new("para-composer-pop");
let cpop = egui::Id::unique("para-composer-pop");
let single = doc.composer == Composer::SingleLine;
if widgets::dropdown(ui, cr, &comps[single as usize], &t, egui::Id::new("para-composer")).clicked() && enabled {
if widgets::dropdown(ui, cr, &comps[single as usize], &t, egui::Id::unique("para-composer")).clicked() && enabled {
widgets::open_popup(ui, cpop);
}
app.auto.add("paragraph.composer", cr, "Composer");
@@ -547,7 +547,7 @@ pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
y += 30.0;
let hr = Rect::from_min_size(pos2(x0, y), vec2(14.0, 14.0));
if widgets::checkbox(ui, hr, doc.hanging_punctuation, &t, egui::Id::new("para-hanging")).clicked() && enabled {
if widgets::checkbox(ui, hr, doc.hanging_punctuation, &t, egui::Id::unique("para-hanging")).clicked() && enabled {
actions.push(json!({"hangingPunctuation": !doc.hanging_punctuation}));
}
app.auto.add("paragraph.hangingPunctuation", hr, "Roman Hanging Punctuation");
@@ -610,7 +610,10 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui:
search.request_focus();
}
ui.memory_mut(|m| m.set_focus_lock_filter(search_id, egui::EventFilter { vertical_arrows: true, escape: true, ..Default::default() }));
if widgets::icon_button(ui, arrow, crate::icons::Icon::ChevronDown, false, &t, id.with("arrow")).clicked() {
if widgets::icon_button(ui, arrow, crate::icons::Icon::ChevronDown, false, &t, id.with("arrow"))
.on_hover_text(crate::i18n::tr("Close font search"))
.clicked()
{
ui.data_mut(|d| {
d.insert_temp(id.with("open"), false);
d.remove::<FontSearch>(state_id);
@@ -697,7 +700,7 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui:
let row_width = ui.available_width();
let label_width = if preview { (row_width - 100.0).max(40.0) } else { row_width };
let resp = ui.add_sized([row_width, 24.0], egui::Button::selectable(index == state.selected, ""));
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, ui.is_enabled(), index == state.selected, &r.display));
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), index == state.selected, &r.display));
let label_rect = Rect::from_min_size(resp.rect.min + vec2(5.0, 0.0), vec2(label_width - 5.0, resp.rect.height()));
ui.painter().with_clip_rect(label_rect.intersect(ui.clip_rect())).text(
label_rect.left_center(),
@@ -713,7 +716,7 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui:
resp.scroll_to_me(None);
}
if preview && ui.is_rect_visible(resp.rect) {
let key = egui::Id::new(("font-preview", &r.family));
let key = egui::Id::unique(("font-preview", &r.family));
let lines: std::sync::Arc<Vec<Vec<[f32; 2]>>> = match ui.data(|d| d.get_temp(key)) {
Some(l) => l,
None => {
@@ -765,7 +768,7 @@ pub fn align(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
p.text(pos2(x0, y + 10.0), Align2::LEFT_CENTER, tr("Align Layers to:"), Tokens::ui(11.5), t.text_dim);
let dr = Rect::from_min_size(pos2(x0 + 100.0, y), vec2(110.0, 20.0));
let to_sel = app.ui.align_to_selection;
let pid = egui::Id::new("align-to-pop");
let pid = egui::Id::unique("align-to-pop");
if widgets::dropdown(ui, dr, if to_sel { "Selection" } else { "Composition" }, &t, pid.with("btn")).clicked() {
widgets::open_popup(ui, pid);
}
@@ -785,7 +788,7 @@ pub fn align(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
for (i, op) in ops.iter().enumerate() {
let r = Rect::from_min_size(pos2(x0 + i as f32 * 34.0 + if i >= 3 { 12.0 } else { 0.0 }, y), vec2(30.0, 28.0));
let resp = ui.interact(r, egui::Id::new((kind, *op)), Sense::click());
let resp = ui.interact(r, egui::Id::unique((kind, *op)), Sense::click());
p.rect_filled(r, 3.0, if resp.hovered() { t.hover } else { t.field_bg });
align_glyph(&p, r, op, row == 1, &t);
let tip = align_tip(kind, op);
@@ -106,7 +106,7 @@ pub fn viewer_hook(
// A short grab zone around the triangle, so the line above doesn't steal the
// layer's scale handles.
let hr = Rect::from_two_pos(sp, sp + up * 8.0 + inward * 6.0).expand(3.0);
let id = egui::Id::new(("viewer-text-range", pr.uid));
let id = egui::Id::unique(("viewer-text-range", pr.uid));
let on_box_handle = ctx.pointer_latest_pos().is_some_and(|p| box_handles.iter().any(|c| c.distance(p) < 9.0));
let free = ctx.is_being_dragged(id) || !on_box_handle;
let resp = (interactive && free).then(|| ui.interact(hr, id, Sense::drag()));
File diff suppressed because it is too large Load Diff
+9 -9
View File
@@ -52,7 +52,7 @@ fn current(app: &EffectcraftApp) -> Option<(LayerId, Uid)> {
}
fn source_id() -> egui::Id {
egui::Id::new("tracker-motion-source")
egui::Id::unique("tracker-motion-source")
}
/// Motion Source: the current track's layer, the chosen source, or the selected layer.
@@ -88,7 +88,7 @@ fn button(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect, text: &str, enab
ui.painter().text(r.center(), Align2::CENTER_CENTER, text, Tokens::medium(12.0), t.text_faint);
return false;
}
widgets::text_button(ui, r, text, false, &t, egui::Id::new(id)).clicked()
widgets::text_button(ui, r, text, false, &t, egui::Id::unique(id)).clicked()
}
fn run(app: &mut EffectcraftApp, ctx: &egui::Context, cmd: &str, p: serde_json::Value) {
@@ -170,7 +170,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
comp.layers.iter().enumerate().filter(|(_, l)| trackable(l)).map(|(i, l)| (l.id, format!("{}. {}", i + 1, l.name))).collect();
let dd = Rect::from_min_size(pos2(field_x, y), vec2(field_w, 20.0));
let name = source.and_then(|s| srcs.iter().find(|(l, _)| *l == s)).map(|(_, n)| n.clone()).unwrap_or_else(|| "None".into());
let did = egui::Id::new("tracker-source-dd");
let did = egui::Id::unique("tracker-source-dd");
if widgets::dropdown(ui, dd, &name, &t, did).clicked() {
widgets::open_popup(ui, did);
}
@@ -195,7 +195,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
source.and_then(|s| comp.layer(s)).map(|l| l.trackers().map(|(g, _)| (g.uid, g.name.clone())).collect()).unwrap_or_default();
let dd = Rect::from_min_size(pos2(field_x, y), vec2(field_w, 20.0));
let cname = cur.and_then(|(_, u)| trackers.iter().find(|(g, _)| *g == u)).map(|(_, n)| n.clone()).unwrap_or_else(|| "None".into());
let did = egui::Id::new("tracker-current-dd");
let did = egui::Id::unique("tracker-current-dd");
if widgets::dropdown(ui, dd, &cname, &t, did).clicked() && !trackers.is_empty() {
widgets::open_popup(ui, did);
}
@@ -226,7 +226,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
label(&p, pos2(x0, y + 10.0), "Track Type:", &t);
let dd = Rect::from_min_size(pos2(field_x, y), vec2(field_w, 20.0));
let kind = settings.as_ref().map(|s| s.kind).unwrap_or_default();
let did = egui::Id::new("tracker-type-dd");
let did = egui::Id::unique("tracker-type-dd");
if widgets::dropdown(ui, dd, if enabled { kind.label() } else { "" }, &t, did).clicked() && enabled {
widgets::open_popup(ui, did);
}
@@ -246,7 +246,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
("scale", "Scale", settings.as_ref().is_some_and(|s| s.scale)),
] {
let cr = Rect::from_min_size(pos2(xx, y + 2.0), vec2(16.0, 16.0));
let resp = widgets::checkbox(ui, cr, on && transformish, &t, egui::Id::new(("tracker-cb", key)));
let resp = widgets::checkbox(ui, cr, on && transformish, &t, egui::Id::unique(("tracker-cb", key)));
app.auto.add(&format!("tracker.{key}"), cr, text);
p.text(pos2(cr.max.x + 4.0, cr.center().y), Align2::LEFT_CENTER, text, Tokens::ui(12.0), if transformish { t.text } else { t.text_faint });
if resp.clicked() && transformish {
@@ -294,7 +294,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
(effectcraft_engine::tracking::Direction::FrameForward, true, true, "frameForward", "Analyze 1 frame forward"),
] {
let r = Rect::from_min_size(pos2(bx, y), vec2(30.0, 22.0));
let resp = ui.interact(r, egui::Id::new(("tracker-analyze", id)), Sense::click()).on_hover_text(tip);
let resp = ui.interact(r, egui::Id::unique(("tracker-analyze", id)), Sense::click()).on_hover_text(tip);
let stop = tracking && running_dir == Some(dir) && !frame;
let on = enabled && (!tracking || stop);
p.rect_filled(r, 4.0, if resp.hovered() && on { t.hover } else { t.field_bg });
@@ -366,7 +366,7 @@ fn mask_mode(app: &mut EffectcraftApp, ui: &mut egui::Ui, ctx: &egui::Context, p
label(p, pos2(x0, y + 10.0), "Method:", &t);
let method = app.session.state.mask_track_method;
let dd = Rect::from_min_size(pos2(field_x, y), vec2(field_w, 20.0));
let did = egui::Id::new("tracker-mask-method-dd");
let did = egui::Id::unique("tracker-mask-method-dd");
if widgets::dropdown(ui, dd, method.label(), &t, did).clicked() {
widgets::open_popup(ui, did);
}
@@ -387,7 +387,7 @@ fn mask_mode(app: &mut EffectcraftApp, ui: &mut egui::Ui, ctx: &egui::Context, p
(effectcraft_engine::tracking::Direction::FrameForward, true, true, "frameForward", "Track selected masks 1 frame forward"),
] {
let r = Rect::from_min_size(pos2(bx, y), vec2(30.0, 22.0));
let resp = ui.interact(r, egui::Id::new(("tracker-mask-track", id)), Sense::click()).on_hover_text(tip);
let resp = ui.interact(r, egui::Id::unique(("tracker-mask-track", id)), Sense::click()).on_hover_text(tip);
let stop = running && running_dir == Some(dir) && !frame;
let on = !running || stop;
p.rect_filled(r, 4.0, if resp.hovered() && on { t.hover } else { t.field_bg });
+32 -28
View File
@@ -38,7 +38,7 @@ impl ViewerMap {
}
fn map_id() -> egui::Id {
egui::Id::new("viewer-map")
egui::Id::unique("viewer-map")
}
pub fn comp_to_screen(ctx: &egui::Context, p: [f32; 2]) -> Option<Pos2> {
@@ -53,7 +53,7 @@ pub fn screen_to_comp(ctx: &egui::Context, p: Pos2) -> Option<[f64; 2]> {
/// The fit magnification from the last frame.
pub fn last_fit(ctx: &egui::Context) -> f32 {
ctx.data(|d| d.get_temp::<f32>(egui::Id::new("viewer-fit"))).unwrap_or(0.5)
ctx.data(|d| d.get_temp::<f32>(egui::Id::unique("viewer-fit"))).unwrap_or(0.5)
}
/// What a drag in the viewer is doing.
@@ -207,7 +207,7 @@ enum Gesture {
/// Pen tool path in progress: (layer, mask uid).
fn pen_id() -> egui::Id {
egui::Id::new("viewer-pen")
egui::Id::unique("viewer-pen")
}
use super::viewer_overlays::{self as ov, VertexHit};
@@ -610,7 +610,7 @@ fn aux_views(
(app.session.revision, cid.0, t.0, i, scale.to_bits(), format!("{cam:?}")).hash(&mut h);
h.finish()
};
let id = egui::Id::new(("viewer-aux", i));
let id = egui::Id::unique(("viewer-aux", i));
let cached: Option<(u64, egui::TextureHandle)> = ctx.data(|d| d.get_temp(id));
let tex = match cached {
Some((k, tex)) if k == key => tex,
@@ -662,7 +662,7 @@ fn aux_views(
fn pane_selector(app: &mut EffectcraftApp, ui: &mut egui::Ui, pane: usize, area: Rect, current: View3D) -> Option<View3D> {
let t = app.tokens;
let r = Rect::from_min_size(area.min + vec2(6.0, 6.0), vec2(124.0, 20.0));
let pid = egui::Id::new(("viewer-pane-view", pane));
let pid = egui::Id::unique(("viewer-pane-view", pane));
let response = widgets::dropdown(ui, r, crate::i18n::tr(current.label()), &t, pid);
if response.clicked() {
let open = widgets::popup_is_open(ui, pid);
@@ -717,7 +717,7 @@ fn locked_pane(app: &mut EffectcraftApp, ui: &mut egui::Ui, full: Rect, bg: Colo
(app.session.revision, lv.comp.0, t.0, scale.to_bits(), dc_key).hash(&mut h);
h.finish()
};
let id = egui::Id::new("viewer-locked");
let id = egui::Id::unique("viewer-locked");
let cached: Option<(u64, egui::TextureHandle)> = ctx.data(|d| d.get_temp(id));
let tex = match cached {
Some((k, tex)) if k == key => tex,
@@ -744,7 +744,7 @@ fn locked_pane(app: &mut EffectcraftApp, ui: &mut egui::Ui, full: Rect, bg: Colo
app.auto.add("viewer.locked", r, &label);
// Close the locked viewer.
let close = Rect::from_min_size(pos2(r.max.x - 26.0, r.min.y + 4.0), vec2(20.0, 20.0));
let resp = ui.interact(close, egui::Id::new("viewer-locked-close"), Sense::click());
let resp = ui.interact(close, egui::Id::unique("viewer-locked-close"), Sense::click());
p.text(close.center(), Align2::CENTER_CENTER, "\u{00d7}", Tokens::ui(14.0), if resp.hovered() { Color32::WHITE } else { Color32::from_white_alpha(170) });
app.auto.add("viewer.locked.close", close, "Close Locked Viewer");
if resp.clicked() {
@@ -793,7 +793,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let name = app.session.project.item(oc).map(|i| i.name.clone()).unwrap_or_default();
let g = p.layout_no_wrap(name.clone(), Tokens::ui(11.5), t.text);
let r = Rect::from_min_size(pos2(x, nav.min.y + 3.0), vec2(g.size().x + 16.0, 18.0));
let resp = ui.interact(r, egui::Id::new(("nav", oc.0)), Sense::click());
let resp = ui.interact(r, egui::Id::unique(("nav", oc.0)), Sense::click());
let active = oc == cid;
p.rect_filled(
r,
@@ -835,7 +835,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let fit = ((area.width() - 40.0) / cw).min((area.height() - 40.0) / ch).max(0.01);
// Fit up to 100% follows the panel's size like Fit does, and stops at 100%.
let fit = if app.ui.viewer.fit_up_to_100 { fit.min(1.0 / ctx.pixels_per_point()) } else { fit };
ctx.data_mut(|d| d.insert_temp(egui::Id::new("viewer-fit"), fit));
ctx.data_mut(|d| d.insert_temp(egui::Id::unique("viewer-fit"), fit));
let zoom = app.ui.viewer.zoom.unwrap_or(fit);
let center = area.center() + vec2(app.ui.viewer.pan[0], app.ui.viewer.pan[1]);
// On the physical pixel grid, so at 100 % every comp pixel covers exactly one screen pixel
@@ -856,7 +856,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// Queue the frame on screen before any prefetch.
app.request_frame_urgent(cid, comp.frame_rate.frame_at(app.session.time()), scale);
}
crate::tick_playback(app, &ctx, scale);
crate::note_viewer_scale(app, scale);
let time = app.session.time();
let frame = comp.frame_rate.frame_at(time);
let key = app.frame_key(cid, frame, scale);
@@ -1011,9 +1011,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
painter.add(egui::Shape::closed_line(outline.into_iter().map(|p| map.to_screen(p)).collect(), Stroke::new(1.0, col)));
}
}
// A layer whose Video switch is off has no box, handles or paths in the viewer (a hidden
// null disappears from the comp, as in After Effects), even while it is selected.
for l in comp.layers.iter().filter(|l| selected.contains(&l.id) && l.is_active_at(time) && l.switches.video) {
// The eye hides a layer's pixels, not its controls: a selected AV layer keeps its box,
// handles, anchor, motion path and mask/shape paths so it stays editable over the visible
// artwork (#1769). Nulls, cameras and lights have no pixels and disappear from the viewer
// when hidden (#1332/#1642). Picking still uses `selectable_layers`, so a hidden layer is
// selected from the Timeline; its controls are interactive once selected.
for l in comp.layers.iter().filter(|l| selected.contains(&l.id) && l.is_active_at(time) && (l.switches.video || l.source.is_av())) {
let col = if label_handles { t.label(l.label) } else { t.accent };
// Motion path of animated position (keyframes and spatial tangents are draggable).
ov::motion_path(app, &painter, &map, &ectx, l, col, &mut key_hits, &mut motion_frames);
@@ -1276,8 +1279,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
super::viewer_drop::show(app, ui, &painter, &map, &ectx);
// Interaction.
let resp = ui.interact(area, egui::Id::new("viewer-interact"), Sense::click_and_drag());
let menu_hits_id = egui::Id::new(("viewer-menu-hits", cid.0));
let resp = ui.interact(area, egui::Id::unique("viewer-interact"), Sense::click_and_drag());
let menu_hits_id = egui::Id::unique(("viewer-menu-hits", cid.0));
if resp.secondary_clicked()
&& let Some(pos) = resp.interact_pointer_pos()
{
@@ -1296,7 +1299,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let hits = ui.data(|d| d.get_temp::<Vec<(u64, String)>>(menu_hits_id)).unwrap_or_default();
if !hits.is_empty() {
ui.menu_button(crate::i18n::tr("Select"), |ui| {
crate::widgets::menu_scroll(ui, |ui| {
crate::widgets::menu_scroll(ui, false, |ui| {
for (id, name) in &hits {
if ui.button(name).clicked() {
context_actions.push(("layer.select".to_owned(), serde_json::json!({"layers":[id]})));
@@ -1316,6 +1319,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let c = super::timeline::LayerMenuCaps::of(&app.session.project, layer);
caps.vector |= c.vector;
caps.text |= c.text;
caps.source |= c.source;
caps
},
);
@@ -1344,7 +1348,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let reach = vertex_grab_radius(pos, handle_hits.iter().map(|(_, _, h, _)| *h));
vertex_hits.iter().rev().filter(|h| h.tangent.is_some() == tangents).find(|h| h.pos.distance(pos) < reach).copied()
};
let gid = egui::Id::new("viewer-gesture");
let gid = egui::Id::unique("viewer-gesture");
// An overlay on top of the viewer (e.g. the camera tracker points) takes the hover, but the pointer is still over the viewer: keep wheel zoom working.
let hover_pos = resp.hover_pos().or_else(|| if resp.contains_pointer() { ui.input(|i| i.pointer.hover_pos()) } else { None });
if let Some(hp) = hover_pos {
@@ -1372,7 +1376,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
Tool::ObjectMatte => {
// The tool draws its own crosshair (and the layer it would matte) while no box
// is being dragged.
if ui.data(|d| d.get_temp::<Gesture>(egui::Id::new("viewer-gesture"))).is_none() {
if ui.data(|d| d.get_temp::<Gesture>(egui::Id::unique("viewer-gesture"))).is_none() {
super::object_matte_tool::hover(app, &painter, &map, &ectx, hp);
}
egui::CursorIcon::None
@@ -1432,10 +1436,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
// A pen click (press + release without a drag) ends its tangent gesture on release.
if ui.input(|i| i.pointer.primary_released())
&& matches!(ui.data(|d| d.get_temp::<Gesture>(egui::Id::new("viewer-gesture"))), Some(Gesture::Pen { .. }))
&& matches!(ui.data(|d| d.get_temp::<Gesture>(egui::Id::unique("viewer-gesture"))), Some(Gesture::Pen { .. }))
&& !resp.drag_stopped()
{
ui.data_mut(|d| d.remove::<Gesture>(egui::Id::new("viewer-gesture")));
ui.data_mut(|d| d.remove::<Gesture>(egui::Id::unique("viewer-gesture")));
}
// On-canvas text editing (Type tool, double-click a text layer) owns its clicks and drags.
let text_owns = super::viewer_text::hook(app, ui, &painter, &map, &ectx, &resp, space_pan);
@@ -1655,7 +1659,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let gesture: Option<Gesture> = ui.data(|d| d.get_temp(gid));
if let (Some(g), Some(pos)) = (gesture.clone(), resp.interact_pointer_pos()) {
let cpt = map.to_comp(pos);
let merge = format!("viewer-drag-{}", ui.data(|d| d.get_temp::<u64>(egui::Id::new("viewer-drag-n")).unwrap_or(0)));
let merge = format!("viewer-drag-{}", ui.data(|d| d.get_temp::<u64>(egui::Id::unique("viewer-drag-n")).unwrap_or(0)));
match g {
Gesture::Pan { start_pan } => {
// No total drag on the release frame: keep the pan reached so far (it used to
@@ -1923,8 +1927,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
ui.data_mut(|d| {
d.remove::<Gesture>(gid);
let n: u64 = d.get_temp(egui::Id::new("viewer-drag-n")).unwrap_or(0);
d.insert_temp(egui::Id::new("viewer-drag-n"), n + 1);
let n: u64 = d.get_temp(egui::Id::unique("viewer-drag-n")).unwrap_or(0);
d.insert_temp(egui::Id::unique("viewer-drag-n"), n + 1);
});
}
if resp.clicked()
@@ -2083,7 +2087,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let r = Rect::from_min_size(area.min + vec2(10.0, 10.0), vec2(330.0, 22.0));
painter.rect_filled(r, 11.0, Color32::from_black_alpha(190));
painter.text(r.center(), Align2::CENTER_CENTER, crate::i18n::tr("Video preview is off (Preview panel): audio only"), Tokens::ui(11.5), t.timecode);
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("viewer-video-off-chip"), r));
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique("viewer-video-off-chip"), r));
}
vt::draw_snap(&ctx, &painter, &map, &ectx);
@@ -2262,7 +2266,7 @@ fn pen_cursor(ctx: &egui::Context, at: Pos2, icon: crate::icons::Icon) {
Icon::PenConvert => vec2(8.0, 3.5),
_ => vec2(8.0, 1.8),
};
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("viewer-pen-cursor")));
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("viewer-pen-cursor")));
let rect = Rect::from_min_size(at - tip, vec2(16.0, 16.0));
for d in [vec2(-1.0, 0.0), vec2(1.0, 0.0), vec2(0.0, -1.0), vec2(0.0, 1.0)] {
crate::icons::paint(&painter, rect.translate(d), icon, Color32::BLACK);
@@ -2282,7 +2286,7 @@ fn pen_press(
mods: egui::Modifiers,
) {
use effectcraft_engine::project::LayerSource;
let gid = egui::Id::new("viewer-gesture");
let gid = egui::Id::unique("viewer-gesture");
let current: Option<(LayerId, u64)> = ui.data(|d| d.get_temp(pen_id()));
let c0 = map.to_comp(pos);
let corr = vt::snap(app, ui.ctx(), ectx, map, &[], &[c0], mods);
@@ -2608,12 +2612,12 @@ fn empty_state(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let c = rect.center();
p.text(c - vec2(0.0, 40.0), Align2::CENTER_CENTER, crate::i18n::tr("Composition"), Tokens::semibold(16.0), t.text);
let b1 = Rect::from_center_size(c + vec2(0.0, 0.0), vec2(220.0, 30.0));
if widgets::text_button(ui, b1, "New Composition", true, &t, egui::Id::new("empty-newcomp")).clicked() {
if widgets::text_button(ui, b1, "New Composition", true, &t, egui::Id::unique("empty-newcomp")).clicked() {
crate::panels::dialogs::open_new_comp(app);
}
app.auto.add("viewer.empty.newComp", b1, "New Composition");
let b2 = Rect::from_center_size(c + vec2(0.0, 40.0), vec2(220.0, 30.0));
if widgets::text_button(ui, b2, "New Composition From Footage", false, &t, egui::Id::new("empty-fromfootage")).clicked() {
if widgets::text_button(ui, b2, "New Composition From Footage", false, &t, egui::Id::unique("empty-fromfootage")).clicked() {
let _ = crate::menus::invoke(app, &ui.ctx().clone(), "file.import", json!({}));
}
app.auto.add("viewer.empty.import", b2, "New Composition From Footage");
+1 -1
View File
@@ -394,7 +394,7 @@ pub(crate) fn motion_path(
// ---------------------------------------------------------------- free transform
fn ft_id() -> egui::Id {
egui::Id::new("viewer-free-transform")
egui::Id::unique("viewer-free-transform")
}
/// Enter Free Transform on a path (double-click a path with the Selection tool).
File diff suppressed because it is too large Load Diff
+18 -18
View File
@@ -25,7 +25,7 @@ pub(crate) const SNAP_PX: f64 = 8.0;
const SNAP_COLOR: Color32 = Color32::from_rgb(0xff, 0xd2, 0x3c);
fn snap_id() -> egui::Id {
egui::Id::new("viewer-snap")
egui::Id::unique("viewer-snap")
}
/// Layer snapping is on: the Snapping checkbox (View ▸ Snapping), inverted while Cmd/Ctrl is held.
@@ -226,10 +226,10 @@ pub(crate) fn rulers(app: &mut EffectcraftApp, ui: &mut egui::Ui, map: &ViewerMa
p.line_segment([corner.center() - vec2(4.0, 0.0), corner.center() + vec2(4.0, 0.0)], tick);
p.line_segment([corner.center() - vec2(0.0, 4.0), corner.center() + vec2(0.0, 4.0)], tick);
let cr = ui
.interact(corner, egui::Id::new("vw-ruler-origin"), Sense::click_and_drag())
.interact(corner, egui::Id::unique("vw-ruler-origin"), Sense::click_and_drag())
.on_hover_text(crate::i18n::tr("Ruler zero point (drag to move, double-click to reset)"));
app.auto.add("viewer.ruler.origin", corner, "Ruler zero point");
let fg = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("vw-ruler-fg")));
let fg = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("vw-ruler-fg")));
if cr.dragged()
&& let Some(hp) = cr.interact_pointer_pos()
{
@@ -248,7 +248,7 @@ pub(crate) fn rulers(app: &mut EffectcraftApp, ui: &mut egui::Ui, map: &ViewerMa
// Guides dragged out of the rulers: the top ruler makes horizontal guides, the left one
// vertical guides.
for (r, vertical, id) in [(top, false, "top"), (left, true, "left")] {
let resp = ui.interact(r, egui::Id::new(("vw-ruler", id)), Sense::drag()).on_hover_text(crate::i18n::tr("Drag to create a guide"));
let resp = ui.interact(r, egui::Id::unique(("vw-ruler", id)), Sense::drag()).on_hover_text(crate::i18n::tr("Drag to create a guide"));
app.auto.add(&format!("viewer.ruler.{id}"), r, if vertical { "Vertical ruler" } else { "Horizontal ruler" });
if resp.hovered() {
ui.ctx().set_cursor_icon(if vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical });
@@ -320,11 +320,11 @@ pub(crate) fn proportional_grid(app: &EffectcraftApp, painter: &egui::Painter, c
/// Remember the region of interest the displayed texture was rendered with.
pub(crate) fn set_texture_roi(ctx: &egui::Context, roi: Option<[f64; 4]>) {
ctx.data_mut(|d| d.insert_temp(egui::Id::new("viewer-tex-roi"), roi));
ctx.data_mut(|d| d.insert_temp(egui::Id::unique("viewer-tex-roi"), roi));
}
fn texture_roi(ctx: &egui::Context) -> Option<[f64; 4]> {
ctx.data(|d| d.get_temp::<Option<[f64; 4]>>(egui::Id::new("viewer-tex-roi"))).flatten()
ctx.data(|d| d.get_temp::<Option<[f64; 4]>>(egui::Id::unique("viewer-tex-roi"))).flatten()
}
/// The comp area ([x, y, w, h], comp pixels) the shown frame covers: its region of interest or
@@ -398,7 +398,7 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter:
(std::sync::Arc::as_ptr(&s.image) as usize, opts.channel.id(), opts.colorized, opts.exposure.to_bits(), &dc_key).hash(&mut h);
h.finish()
};
let id = egui::Id::new("viewer-snapshot-tex");
let id = egui::Id::unique("viewer-snapshot-tex");
let tex = match ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(id)) {
Some((k, tex)) if k == key => tex,
_ => {
@@ -454,7 +454,7 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter:
(std::sync::Arc::as_ptr(&src) as usize, opts.channel.id(), opts.colorized, opts.exposure.to_bits(), &dc_key, minify).hash(&mut h);
h.finish()
};
let id = egui::Id::new("viewer-display-tex");
let id = egui::Id::unique("viewer-display-tex");
let tex = match ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(id)) {
Some((k, tex)) if k == key => tex,
_ => {
@@ -479,7 +479,7 @@ fn fitted_uv(ctx: &egui::Context, size: [usize; 2], minify: usize) -> Rect {
/// A popup list above `anchor` (selected entries highlighted); entries register
/// `viewer.<auto>.<n>` automation ids.
fn popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: &str, anchor: Rect, items: &[(String, bool)], auto: &str) -> Option<usize> {
let pid = egui::Id::new(id);
let pid = egui::Id::unique(id);
if !widgets::popup_is_open(ui, pid) {
return None;
}
@@ -506,7 +506,7 @@ fn popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: &str, anchor: Rect, it
}
fn toggle_popup(ui: &egui::Ui, id: &str) {
let pid = egui::Id::new(id).with("open");
let pid = egui::Id::unique(id).with("open");
let open: bool = ui.data(|d| d.get_temp(pid).unwrap_or(false));
ui.data_mut(|d| d.insert_temp(pid, !open));
}
@@ -529,8 +529,8 @@ pub(crate) fn magnification_menu(
let t = app.tokens;
let ppp = ui.ctx().pixels_per_point();
let pop = format!("{id}-pop");
let response =
widgets::dropdown(ui, r, &magnification_label(zoom * ppp * 100.0), &t, egui::Id::new(id)).on_hover_text(crate::i18n::tr("Magnification ratio popup"));
let response = widgets::dropdown(ui, r, &magnification_label(zoom * ppp * 100.0), &t, egui::Id::unique(id))
.on_hover_text(crate::i18n::tr("Magnification ratio popup"));
if response.clicked() {
toggle_popup(ui, &pop);
}
@@ -637,7 +637,7 @@ fn bottom_bar_contents(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, z
let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(84.0, 20.0));
let scale = app.viewer_scale(zoom, ppp);
let res = app.viewer_res();
let response = widgets::dropdown(ui, r, &resolution_label(res, scale), &t, egui::Id::new("vw-res"))
let response = widgets::dropdown(ui, r, &resolution_label(res, scale), &t, egui::Id::unique("vw-res"))
.on_hover_text(crate::i18n::tr("Resolution/Down Sample Factor Popup"));
if response.clicked() {
toggle_popup(ui, "vw-res-pop");
@@ -665,7 +665,7 @@ fn bottom_bar_contents(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, z
let r = Rect::from_min_size(pos2(*x, cy - 11.0), vec2(22.0, 22.0));
// The tip is the English source: the tooltip is looked up in the catalog, while the
// automation label stays English for scripts and tests.
let resp = widgets::icon_button(ui, r, icon, on, &t, egui::Id::new(("vw-btn", id))).on_hover_text(crate::i18n::tr(tip));
let resp = widgets::icon_button(ui, r, icon, on, &t, egui::Id::unique(("vw-btn", id))).on_hover_text(crate::i18n::tr(tip));
auto.add_response(&format!("viewer.{id}"), &resp, tip);
*x += 24.0;
(resp.clicked(), r)
@@ -786,7 +786,7 @@ fn bottom_bar_contents(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, z
let renderers = [(Renderer::Classic3D, "Classic 3D", "classic3D"), (Renderer::Advanced3D, "Advanced 3D", "advanced3D")];
let current = renderers.iter().find(|(r, ..)| *r == comp.renderer).map_or("Classic 3D", |(_, label, _)| *label);
let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(96.0, 20.0));
if widgets::dropdown(ui, r, crate::i18n::tr(current), &t, egui::Id::new("vw-3d")).clicked() {
if widgets::dropdown(ui, r, crate::i18n::tr(current), &t, egui::Id::unique("vw-3d")).clicked() {
toggle_popup(ui, "vw-3d-pop");
}
app.auto.add("viewer.renderer3d", r, current);
@@ -801,7 +801,7 @@ fn bottom_bar_contents(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, z
let cid = app.session.active_comp_id();
let cur = cid.and_then(|c| app.session.state.views3d.get(&c)).map(|v| v.current).unwrap_or_default();
let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(118.0, 20.0));
let response = widgets::dropdown(ui, r, cur.label(), &t, egui::Id::new("vw-cam"));
let response = widgets::dropdown(ui, r, cur.label(), &t, egui::Id::unique("vw-cam"));
if response.clicked() {
toggle_popup(ui, "vw-cam-pop");
}
@@ -824,8 +824,8 @@ fn bottom_bar_contents(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, z
let g = p.layout_no_wrap(tc, Tokens::mono(12.0), t.timecode);
let tr = Rect::from_min_size(pos2(x + 4.0, cy - 9.0), g.size() + vec2(8.0, 4.0));
p.galley(pos2(tr.min.x + 4.0, tr.min.y + 2.0), g, t.timecode);
let resp = ui.interact(tr, egui::Id::new("vw-timecode"), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Current time"));
let resp = ui.interact(tr, egui::Id::unique("vw-timecode"), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Current time"));
if resp.has_focus() {
p.rect_stroke(tr.shrink(1.0), 2.0, Stroke::new(1.0, t.focus), StrokeKind::Inside);
}
+3 -3
View File
@@ -89,7 +89,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: u32, rect: Rect) {
let ctx = ui.ctx().clone();
let p = ui.painter().with_clip_rect(rect);
p.rect_filled(rect, 0.0, t.pasteboard);
let resp = ui.interact(rect, egui::Id::new(("passive-viewer", id)), Sense::click()).on_hover_text(tr("Click to make this viewer active"));
let resp = ui.interact(rect, egui::Id::unique(("passive-viewer", id)), Sense::click()).on_hover_text(tr("Click to make this viewer active"));
app.auto.add(&format!("viewers.{id}"), rect, "Composition viewer (click to make it active)");
match comp_of(app, id).and_then(|cid| Some((cid, app.session.project.comp(cid)?.clone()))) {
Some((cid, c)) => {
@@ -137,7 +137,7 @@ fn set_texture(
dc: Option<effectcraft_engine::viewer::DisplayColor>,
dkey: u64,
) {
let dc_id = egui::Id::new(("passive-viewer-display", id));
let dc_id = egui::Id::unique(("passive-viewer-display", id));
let same_display = ctx.data(|d| d.get_temp::<u64>(dc_id)) == Some(dkey);
if let Some((k, _)) = app.passive_tex.get_mut(&id)
&& *k == key
@@ -297,5 +297,5 @@ pub fn on_close(app: &mut EffectcraftApp, p: PanelKind) {
/// The tab label of viewer `id` ("Composition Intro").
pub fn title(app: &EffectcraftApp, id: u32) -> Option<String> {
let c = comp_of(app, id)?;
Some(format!("Composition {}", app.session.project.item(c)?.name))
Some(format!("{} {}", crate::i18n::command_label("window.panel", "Composition"), app.session.project.item(c)?.name))
}
+4 -4
View File
@@ -13,7 +13,7 @@ struct State {
}
fn state_id() -> Id {
Id::new("numeric-pointer-wrap")
Id::unique("numeric-pointer-wrap")
}
/// Opt in only number scrubs, never object, keyframe, panel or scrollbar drags.
@@ -231,12 +231,12 @@ mod tests {
crate::widgets::drag_value(egui::DragValue::new(&mut self.value).speed(1.0).max_decimals(3)),
);
} else if self.ordinary {
ui.interact(Rect::from_min_size(pos2(100.0, 100.0), vec2(60.0, 20.0)), Id::new("ordinary"), egui::Sense::drag());
ui.interact(Rect::from_min_size(pos2(100.0, 100.0), vec2(60.0, 20.0)), Id::unique("ordinary"), egui::Sense::drag());
} else if self.angle {
let (rr, dr, value) = crate::panels::fx_widgets::angle_field(
ui,
pos2(100.0, 100.0),
Id::new("scrub"),
Id::unique("scrub"),
self.value,
1,
&crate::theme::Tokens::for_kind(crate::theme::ThemeKind::Dark),
@@ -249,7 +249,7 @@ mod tests {
let (_, value, _) = crate::widgets::hot_number_at(
ui,
pos2(100.0, 100.0),
Id::new("scrub"),
Id::unique("scrub"),
self.value,
1.0,
(-1e9, 1e9),
+13 -10
View File
@@ -11,10 +11,14 @@ use serde_json::json;
use crate::EffectcraftApp;
use crate::frames::FrameKey;
/// Run once per frame, before the panels draw.
pub fn frame(app: &mut EffectcraftApp, ctx: &egui::Context) {
memory_watch(app, ctx);
/// Settings read once per tick, before any paint (they must keep working while hidden).
pub fn tick(app: &mut EffectcraftApp, ctx: &egui::Context, now: f64) {
memory_watch(app, ctx, now);
home_on_open(app, ctx);
}
/// Settings that show or draw something: run in the UI pass, before the panels draw.
pub fn frame(app: &mut EffectcraftApp, ctx: &egui::Context) {
video_preview(app, ctx);
}
@@ -29,7 +33,7 @@ pub fn dropped_files(app: &mut EffectcraftApp, ui: &egui::Ui, bounds: Rect) {
if hovered != 0 {
let label =
if hovered == 1 { crate::i18n::tr("Drop file to import").to_string() } else { crate::i18n::tr_args("Drop {} files to import", &[&hovered]) };
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("native-file-drop")));
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("native-file-drop")));
painter.rect_stroke(bounds.shrink(8.0), 6.0, Stroke::new(2.0, app.tokens.accent), StrokeKind::Inside);
let hint = Rect::from_center_size(bounds.center(), vec2(300.0, 46.0));
painter.rect_filled(hint, 6.0, app.tokens.panel_bg);
@@ -120,9 +124,8 @@ pub fn dropped_files(app: &mut EffectcraftApp, ui: &egui::Ui, bounds: Rect) {
/// Re-read the system's free memory every 30 s (cache budgets follow it; on Windows each
/// reading starts PowerShell in the background, so it isn't done more often).
fn memory_watch(app: &mut EffectcraftApp, ctx: &egui::Context) {
let now = ctx.input(|i| i.time);
let id = egui::Id::new("prefs-memory-watch");
pub(crate) fn memory_watch(app: &mut EffectcraftApp, ctx: &egui::Context, now: f64) {
let id = egui::Id::unique("prefs-memory-watch");
let last = ctx.data(|d| d.get_temp::<f64>(id));
if last.is_some_and(|t| now - t < 30.0) {
return;
@@ -135,8 +138,8 @@ fn memory_watch(app: &mut EffectcraftApp, ctx: &egui::Context) {
}
/// Startup ▸ Show Home Screen When Opening a Project.
fn home_on_open(app: &mut EffectcraftApp, ctx: &egui::Context) {
let id = egui::Id::new("prefs-last-project");
pub(crate) fn home_on_open(app: &mut EffectcraftApp, ctx: &egui::Context) {
let id = egui::Id::unique("prefs-last-project");
let path = app.session.path.clone();
let last = ctx.data(|d| d.get_temp::<Option<String>>(id));
ctx.data_mut(|d| d.insert_temp(id, path.clone()));
@@ -169,7 +172,7 @@ fn video_preview(app: &mut EffectcraftApp, ctx: &egui::Context) {
return;
}
let v = app.session.prefs.video.clone();
let tex_id = egui::Id::new("video-preview-tex");
let tex_id = egui::Id::unique("video-preview-tex");
let update = !app.playback.playing || v.output_during_playback;
let key = app.viewer_shown.as_ref().map(|(_, k)| *k);
let cur: Option<(FrameKey, egui::TextureHandle)> = ctx.data(|d| d.get_temp(tex_id));
+101 -14
View File
@@ -322,6 +322,10 @@ static INTER_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/Inter-Regula
static INTER_MEDIUM: &[u8] = include_bytes!("../../../assets/fonts/Inter-Medium.ttf");
static INTER_SEMIBOLD: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf");
static JETBRAINS_MONO: &[u8] = include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf");
/// Chrome symbols (`✔` U+2714, `▣` U+25A3, …) that Inter, Inter Medium/SemiBold and
/// JetBrains Mono do not cover. 0.37's default font set no longer includes the old emoji
/// fonts, and named families (`medium`, `semibold`) never received egui's default fallbacks.
static NOTO_SYMBOLS2: &[u8] = include_bytes!("../../../assets/fonts/NotoSansSymbols2-Regular.ttf");
/// Install fonts (Inter, Inter Medium/SemiBold, JetBrains Mono) and egui visuals. `language` is the
/// resolved interface language from [`crate::i18n::language`] (`en`, `ja`, `zh-hans` or `zh-hant`),
@@ -336,16 +340,20 @@ pub fn install(ctx: &egui::Context, t: &Tokens, language: &str) {
fonts.font_data.insert("inter-medium".into(), Arc::new(FontData::from_static(INTER_MEDIUM)));
fonts.font_data.insert("inter-semibold".into(), Arc::new(FontData::from_static(INTER_SEMIBOLD)));
fonts.font_data.insert("jbmono".into(), Arc::new(FontData::from_static(JETBRAINS_MONO)));
fonts.font_data.insert("noto-symbols2".into(), Arc::new(FontData::from_static(NOTO_SYMBOLS2)));
fonts.families.entry(FontFamily::Proportional).or_default().insert(0, "inter".into());
fonts.families.entry(FontFamily::Monospace).or_default().insert(0, "jbmono".into());
fonts.families.insert(FontFamily::Name("semibold".into()), vec!["inter-semibold".into(), "inter".into()]);
fonts.families.insert(FontFamily::Name("medium".into()), vec!["inter-medium".into(), "inter".into()]);
// Inter and egui's own fallbacks have no small triangles (the `▸` of "File ▸ Import"), which
// would draw as missing-glyph boxes: JetBrains Mono fills in the symbols the others lack.
// Noto Sans Symbols 2 follows it for the check/box glyphs (`✔`, `▣`) that JetBrains Mono
// lacks; without it, named families such as `medium` fall straight through to tofu.
for (family, stack) in &mut fonts.families {
if *family != FontFamily::Monospace {
stack.push("jbmono".into());
}
stack.push("noto-symbols2".into());
}
install_script_fallbacks(&mut fonts, language);
ctx.set_fonts(fonts);
@@ -370,6 +378,7 @@ const SCRIPT_PROBES: &[(char, &str)] = &[
('ಕ', "kannada-system"),
('ക', "malayalam-system"),
('ก', "thai-system"),
('ა', "georgian-system"),
];
/// Register the system fallbacks for scripts the bundled fonts lack in every font family, and
@@ -408,21 +417,46 @@ fn install_script_fallbacks(family_fonts: &mut FontDefinitions, language: &str)
let cjk = if is_chinese(language) { [han, KANA] } else { [KANA, han] };
let mut names = Vec::new();
let mut seen = Vec::new();
for &(probe, name) in cjk.iter().chain(SCRIPT_PROBES).chain(std::iter::once(&HANGUL)) {
let id = fonts::fallback_for(probe, base);
if seen.contains(&id) {
// A font covering one Han probe may be a sparse subset. Register additional faces
// needed by the actual interface catalog instead of assuming whole-script coverage.
let extra = if is_chinese(language) { crate::i18n::catalog_characters(language) } else { Vec::new() };
let extra: Vec<_> = extra.into_iter().filter(|ch| *ch >= '\u{2e80}').map(|ch| (ch, "catalog-system")).collect();
// 256-character blocks where a catalog character found no face: with nothing installed
// covering them, each lookup would read every installed font file again.
let mut uncovered_blocks = std::collections::HashSet::new();
for &(probe, name) in cjk.iter().chain(SCRIPT_PROBES).chain(std::iter::once(&HANGUL)).chain(&extra) {
let catalog = name == "catalog-system";
if catalog && (uncovered_blocks.contains(&(probe as u32 >> 8)) || seen.iter().any(|&id| fonts::face(id).has_char(probe))) {
continue;
}
// The interface asks for the platform's UI face first; the order a composed frame
// renders with is `fonts::fallback_for`'s and is not touched here. Another context may
// install a different language concurrently, so the shared fallback is asked with this
// context's own locale rather than the global request.
let id = interface_fallback_candidates(name, language)
.iter()
.find_map(|family| {
let r = fonts::resolve(family, "Regular");
(!r.missing && fonts::face(r.face).has_char(probe)).then_some(r.face)
})
.unwrap_or_else(|| fonts::fallback_for_locale(probe, base, cjk_locale(language)));
let face = fonts::face(id);
if !face.has_char(probe) {
if catalog {
uncovered_blocks.insert(probe as u32 >> 8);
}
continue; // nothing installed covers this script
}
if seen.contains(&id) {
continue;
}
let Some(font) = face.font() else { continue };
seen.push(id);
// Use the already-read, parsed bytes rather than reading a font file twice.
let mut data = FontData::from_owned(font.data().as_bytes().to_vec());
data.index = face.info.index;
family_fonts.font_data.insert(name.to_string(), Arc::new(data));
let name = if name == "catalog-system" { format!("{name}-{:x}", probe as u32) } else { name.to_string() };
family_fonts.font_data.insert(name.clone(), Arc::new(data));
for family in family_fonts.families.values_mut() {
family.push(name.to_string());
}
@@ -432,6 +466,31 @@ fn install_script_fallbacks(family_fonts: &mut FontDefinitions, language: &str)
}
}
/// The faces the interface asks for before it settles on the one a composed frame renders with.
///
/// Windows ships a UI variant of its Japanese and Chinese families - `Meiryo`/`Meiryo UI`, `Yu
/// Gothic`/`Yu Gothic UI`, `MS Gothic`/`MS UI Gothic`, `Microsoft YaHei`/`Microsoft YaHei UI`,
/// `Microsoft JhengHei`/`Microsoft JhengHei UI` - and its own link table for `Segoe UI`
/// (`FontLink\SystemLink`) picks the UI one for what it draws on screen. `PMingLiU` is the one face
/// here that is not a sibling: it is `MingLiU`'s proportional relative, and proportional spacing is
/// what makes the chrome read better in it.
///
/// Only the interface takes this order. `fonts::fallback_for` keeps the order a document renders
/// with, so an existing project's output does not move when the chrome changes.
#[cfg(not(target_arch = "wasm32"))]
fn interface_fallback_candidates(name: &str, language: &str) -> &'static [&'static str] {
match (name, cjk_locale(language)) {
// Kana, and the Han ideographs of a Japanese interface: the language asks for the Japanese
// face, so its UI sibling is the one the platform would draw chrome with.
("japanese-system", _) | ("chinese-system", "ja") => &["Meiryo UI", "Yu Gothic UI", "MS UI Gothic"],
("chinese-system", "zh-hant") => &["Microsoft JhengHei UI", "PMingLiU"],
("chinese-system", "zh-hans") => &["Microsoft YaHei UI"],
// The other scripts have no UI variant, and their shared order already picks the face made
// for the script - the one that draws it correctly at all.
_ => &[],
}
}
/// The CJK locale the text engine should prefer for Han text: the interface language when it names
/// one, otherwise the empty string, which leaves the operating system's locale in charge.
#[cfg(not(target_arch = "wasm32"))]
@@ -443,9 +502,9 @@ fn cjk_locale(language: &str) -> &str {
}
/// The Han character that picks the Chinese fallback face. A Simplified Chinese interface asks for
/// a simplified-only hanzi: a Japanese face covers the shared ideographs (文) but not 页, 选 or 删,
/// and the fallback takes a face already loaded first, so the Japanese face loaded for kana would
/// draw the Chinese interface with boxes (#1141).
/// a hanzi used by its catalog. Some Japanese faces also cover 页 while lacking 选 or 删, so
/// coverage of this probe alone is not enough: the fallback must use the interface's own language
/// policy, even while another context is installing Japanese fonts (#1141).
#[cfg(not(target_arch = "wasm32"))]
fn han_probe(language: &str) -> char {
if language == "zh-hans" { '页' } else { '文' }
@@ -530,6 +589,7 @@ pub fn apply_visuals(ctx: &egui::Context, t: &Tokens) {
#[cfg(all(test, not(target_arch = "wasm32")))]
mod script_font_tests {
use super::{SCRIPT_PROBES, interface_fallback_candidates};
use effectcraft_text::fonts;
/// Whether an installed face covers `probe` (else the glyph check is skipped).
@@ -595,9 +655,35 @@ mod script_font_tests {
}
}
/// #1141: switching the interface to Simplified Chinese drew most labels as boxes. The English
/// interface had already loaded a Japanese face for kana, and that face covers the Han probe,
/// so it became the Chinese fallback too, without the simplified-only hanzi.
#[test]
fn the_interface_asks_for_the_platforms_ui_faces() {
// The face a window draws its own chrome with, which is not the one a composed frame
// renders with: the two orders are deliberately separate.
let ja = &["Meiryo UI", "Yu Gothic UI", "MS UI Gothic"][..];
assert_eq!(interface_fallback_candidates("japanese-system", "ja"), ja);
assert_eq!(interface_fallback_candidates("chinese-system", "ja"), ja);
assert_eq!(interface_fallback_candidates("chinese-system", "zh-hans"), &["Microsoft YaHei UI"][..]);
assert_eq!(interface_fallback_candidates("chinese-system", "zh-hant"), &["Microsoft JhengHei UI", "PMingLiU"][..]);
// Every other script the install probes has no UI variant, so it keeps the shared order.
for &(_, name) in SCRIPT_PROBES.iter() {
assert!(interface_fallback_candidates(name, "en").is_empty(), "{name} asked for a UI face");
}
}
/// The Georgian interface draws its Mkhedruli text with an installed Georgian face (Sylfaen,
/// SF Georgian, Noto Sans Georgian…): Inter has no Georgian, so the labels showed as boxes.
#[test]
fn installed_georgian_fallback_draws_the_georgian_interface() {
if !installed('ა') {
eprintln!("no Georgian system font installed; skipping glyph coverage");
return;
}
assert_drawn("ka", "კომპოზიციაფენასაკვანძოკადრიეფექტიპარამეტრები");
}
/// #1141: switching the interface to Simplified Chinese drew most labels as boxes. A Japanese face
/// can cover the Han probe while lacking other catalog characters. The context must keep its
/// own language policy even when another context installs Japanese fallbacks concurrently.
#[test]
fn simplified_chinese_interface_draws_simplified_hanzi_after_another_language() {
let ctx = egui::Context::default();
@@ -609,7 +695,7 @@ mod script_font_tests {
let font = egui::FontId::new(13.0, egui::FontFamily::Proportional);
let missing: String = ctx.fonts_mut(|fonts| {
let replacement = fonts.layout_no_wrap("\u{10ffff}".into(), font.clone(), egui::Color32::WHITE).rows[0].glyphs[0].uv_rect;
// Simplified-only hanzi of the zh-hans catalog (选择, 删除, 图层, 设置, 导入, 页面).
// Representative hanzi of the zh-hans catalog (选择, 删除, 图层, 设置, 导入, 页面).
"选删层设导页"
.chars()
.filter(|ch| {
@@ -708,7 +794,8 @@ mod tests {
// another test has loaded a system face without `◻` (the fallback tries loaded faces first).
let uv = |s: String| ctx.fonts_mut(|f| f.layout_no_wrap(s, id.clone(), egui::Color32::WHITE).rows[0].glyphs[0].uv_rect);
let missing = uv("\u{10ffff}".into());
for c in "▸•›·▶◀●○⚠…×".chars() {
// Every chrome symbol the panels, menus, shortcut labels and buttons draw.
for c in "✔▣✓⚠▶◀▸●○…×›•·−←→↑↓⌘⇧⌥⌃".chars() {
let drawn = uv(c.to_string());
assert_ne!((drawn.min, drawn.max), (missing.min, missing.max), "{c} has no glyph in {id:?}");
}
@@ -729,7 +816,7 @@ mod tests {
}
let Some(font) = face.font() else { return };
let bytes = font.data().as_bytes();
let covered = fonts_def.font_data.values().any(|d| d.font.as_ref() == bytes && d.index == face.info.index);
let covered = fonts_def.font_data.values().any(|d| d.bytes() == bytes && d.index == face.info.index);
assert!(covered, "a face covering Hangul must be registered with egui");
}
@@ -745,7 +832,7 @@ mod tests {
.iter()
.map(|name| {
let data = fonts.font_data.get(name).unwrap_or_else(|| panic!("{name} is missing its font data"));
(data.font.as_ref(), data.index)
(data.bytes(), data.index)
})
.collect();
for (i, a) in faces.iter().enumerate() {
+169 -10
View File
@@ -2,6 +2,8 @@
use egui::{Align2, Color32, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use effectcraft_project::ParamUi;
use crate::automation::Registry;
use crate::icons::{self, Icon};
use crate::theme::Tokens;
@@ -21,8 +23,8 @@ struct TabOrder {
}
fn tab_order_id(ui: &Ui) -> egui::Id {
let panel = ui.stack().iter().find(|s| s.tags().contains(PANEL_TAG)).map_or(egui::Id::NULL, |s| s.id);
egui::Id::new(("hot-tab-order", panel))
let panel = ui.stack().iter().find(|s| s.tags().contains(PANEL_TAG)).map_or(egui::Id::NULL, |s| s.scope_id);
egui::Id::unique(("hot-tab-order", panel))
}
/// Puts hot-text field `id` in its panel's Tab order. True when a Tab in the field before it
@@ -145,6 +147,16 @@ pub fn proportional_scrub(m: egui::Modifiers) -> bool {
m.command && m.alt
}
/// How far one pointer pixel scrubs a hot-text field: the property's slider (soft) range over
/// 300 px. The Timeline and Effect Controls share this so they cannot drift; a large hard range
/// only allows typing big values, it must not make dragging coarse (#2097).
pub fn slider_scrub_speed(ui: &ParamUi) -> f64 {
match ui {
ParamUi::Slider { slider_min, slider_max, .. } => ((slider_max - slider_min) / 300.0).clamp(0.001, 50.0),
_ => 0.5,
}
}
/// [`hot_number_at`] for a whole number (an angle's revolutions): a drag steps it once every
/// `1 / speed` pixels however slowly the pointer moves (the fractions add up between frames),
/// and a typed value is truncated. Returns (rect, Some(new value) when changed, drag finished).
@@ -262,8 +274,13 @@ pub fn icon_toggle(ui: &mut Ui, rect: Rect, icon: Icon, on: bool, t: &Tokens, id
}
/// Icon button (toolbar style) with optional active state.
///
/// The icon alone carries no text, so the button's accessible name is its tooltip: callers chain
/// `.on_hover_text(…)`, which names the node this reports (an icon button with neither is unnamed
/// and kittest's accessibility check flags it).
pub fn icon_button(ui: &mut Ui, rect: Rect, icon: Icon, active: bool, t: &Tokens, id: egui::Id) -> Response {
let resp = ui.interact(rect, id, Sense::click());
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), active, ""));
if active {
ui.painter().rect_filled(rect, 4.0, t.accent);
} else if resp.hovered() {
@@ -326,7 +343,7 @@ fn field(ui: &mut Ui, rect: Rect, text: &mut String, hint: &str, t: &Tokens, sea
// As tall as the field, so the text and the hint sit on its middle (they sat high, #581).
.min_size(inner.size())
.font(Tokens::ui(12.0))
.vertical_align(egui::Align::Center),
.align(egui::Align2::LEFT_CENTER),
)
}
@@ -338,6 +355,15 @@ pub fn dropdown(ui: &mut Ui, rect: Rect, text: &str, t: &Tokens, id: egui::Id) -
/// [`dropdown`] with a tooltip `tip` (shown always; it replaces the elided text's tooltip).
pub fn dropdown_tip(ui: &mut Ui, rect: Rect, text: &str, tip: Option<&str>, t: &Tokens, id: egui::Id) -> Response {
let resp = ui.interact(rect, id, Sense::click());
// A combo box shows its current value; with no separate label widget that value is also the
// name a screen reader announces. An empty value falls back to the tooltip (or a generic name)
// so the control is still reachable.
let name = match (text.is_empty(), tip) {
(false, _) => text.to_string(),
(true, Some(tip)) if !tip.is_empty() => tip.to_string(),
_ => crate::i18n::tr("Options").to_string(),
};
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, ui.is_enabled(), &name));
ui.painter().rect_filled(rect, 3.0, if resp.hovered() { t.hover } else { t.field_bg });
ui.painter().rect_stroke(rect, 3.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
// Text that doesn't fit before the arrow is elided (narrow panels).
@@ -370,6 +396,7 @@ pub fn dropdown_tip(ui: &mut Ui, rect: Rect, text: &str, tip: Option<&str>, t: &
/// Text button with a subtle fill.
pub fn text_button(ui: &mut Ui, rect: Rect, text: &str, primary: bool, t: &Tokens, id: egui::Id) -> Response {
let resp = ui.interact(rect, id, Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), text));
let bg = if primary {
if resp.hovered() { t.accent_hover } else { t.accent }
} else if resp.hovered() {
@@ -411,7 +438,7 @@ pub fn scroll_bar(ui: &mut Ui, auto: &mut Registry, key: &str, track: Rect, scro
let thumb_len = (len * len / (len + max.max(0.0)).max(1.0)).max(16.0).min(len).max(0.0);
let travel = (len - thumb_len).max(1.0);
let thumb_at = |s: f32| track.min[a] + (len - thumb_len) * if max > 0.0 { (s / max).clamp(0.0, 1.0) } else { 0.0 };
let resp = ui.interact(track.expand2(if vertical { vec2(3.0, 0.0) } else { vec2(0.0, 3.0) }), egui::Id::new(key), Sense::drag());
let resp = ui.interact(track.expand2(if vertical { vec2(3.0, 0.0) } else { vec2(0.0, 3.0) }), egui::Id::unique(key), Sense::drag());
let pressed = resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.any_pressed());
let mut s = scroll;
if let Some(p) = resp.interact_pointer_pos().filter(|p| pressed && !(thumb_at(scroll)..=thumb_at(scroll) + thumb_len).contains(&p[a])) {
@@ -482,8 +509,8 @@ pub fn check_label(on: bool, label: &str) -> String {
if on { format!("✓ {label}") } else { format!(" {label}") }
}
fn label_swatch_id() -> egui::Id {
egui::Id::new("label-swatch")
fn label_swatch_id() -> egui::IdSalt {
egui::IdSalt::new("label-swatch")
}
/// The colour swatch of a Label menu row, as an atom for its button: Label menus show each
@@ -494,7 +521,7 @@ pub fn label_swatch() -> egui::Atom<'static> {
}
/// Paint the [`label_swatch`] of a laid-out button: label `l`'s colour, an empty box for None.
pub fn paint_label_swatch(ui: &Ui, out: &egui::AtomLayoutResponse, l: effectcraft_engine::color::Label, t: &Tokens) {
pub fn paint_label_swatch(ui: &Ui, out: &egui::WidgetAtomResponse, l: effectcraft_engine::color::Label, t: &Tokens) {
let Some(r) = out.rect(label_swatch_id()) else { return };
let r = Rect::from_center_size(r.center(), vec2(11.0, 11.0));
if l == effectcraft_engine::color::Label::None {
@@ -557,6 +584,44 @@ pub fn popup_menu(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, options: &[String]
})
}
/// The drop-down for a layer-valued property (`ParamUi::Layer`): the current layer shown as
/// "N. name" (or "None"), and a popup of "None" plus every layer of the comp. Effect Controls
/// and the Timeline share it so a layer reference (an Environment light's Source, a Displacement
/// Map's layer) is picked the same way in every panel, and the reference is shown as its layer's
/// index and name rather than its internal id. Returns `Some(chosen)` when the user picked —
/// the layer's id, or `None` for "None" — and `None` while nothing is chosen; the caller writes
/// it as an undoable `prop.set` (id, or null for "None").
pub fn layer_picker(
ui: &mut Ui,
rect: Rect,
layers: &[effectcraft_engine::project::Layer],
current: Option<u64>,
t: &Tokens,
id: egui::Id,
) -> Option<Option<u64>> {
let index_of = |wanted: u64| layers.iter().position(|l| l.id.0 == wanted);
let entry = |i: usize| layers.get(i).map(|l| format!("{}. {}", i + 1, l.name));
let current_index = current.and_then(index_of);
let label = current_index.and_then(entry).unwrap_or_else(|| crate::i18n::tr("None").to_string());
let pop = id.with("pop");
if dropdown(ui, rect, &label, t, id).clicked() {
open_popup(ui, pop);
}
if !popup_is_open(ui, pop) {
return None;
}
// Built only while open: a comp can hold hundreds of layers.
let mut options: Vec<String> = Vec::with_capacity(layers.len() + 1);
options.push(crate::i18n::tr("None").to_string());
options.extend((0..layers.len()).filter_map(entry));
// The current entry is highlighted (the first option is "None").
match popup_menu(ui, pop, rect.left_bottom(), &options, Some(current_index.map_or(0, |i| i + 1))) {
Some(0) => Some(None),
Some(picked) => layers.get(picked - 1).map(|l| Some(l.id.0)),
None => None,
}
}
/// The open popup list `id` at `pos`, drawn by `body` (`rows` entries and `seps` separators,
/// for its height): as tall as the window allows, scrolling beyond with a scroll bar (a shape
/// layer's Add menu has 20 entries, the blend modes 40, the fonts hundreds), and moved up when
@@ -592,7 +657,7 @@ pub(crate) fn note_open_popup_list(ctx: &egui::Context) {
/// The pass a [`popup_list`] was last drawn in.
fn popup_list_pass_id() -> egui::Id {
egui::Id::new("popup-list-pass")
egui::Id::unique("popup-list-pass")
}
/// Whether a menu, context menu or popup list is open, or the menu bar has the keyboard (as of
@@ -607,9 +672,94 @@ pub fn any_menu_open(ctx: &egui::Context) -> bool {
/// whole: egui sizes a new menu from a default 400 pt area, so without a minimum a longer one
/// (Window ▸ Workspace) got stuck at that height with its last entries scrolled out of view
/// (#191).
pub fn menu_scroll<R>(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R {
///
/// While the bar is mid press-and-browse (`browsing`), resting the pointer against the menu's
/// bottom or top edge scrolls it from there — a crawl at first, then a run, as native menus do —
/// so a long list can be walked with no wheel. Without the hold the edges do nothing: the wheel
/// and the scroll bar are how a normally opened menu scrolls.
pub fn menu_scroll<R>(ui: &mut Ui, browsing: bool, add: impl FnOnce(&mut Ui) -> R) -> R {
let max_h = ui.ctx().content_rect().height() - 40.0;
egui::ScrollArea::vertical().max_height(max_h).min_scrolled_height(max_h).show(ui, add).inner
let pending = ui.scope_id().with("menu-edge-scroll-offset");
let target = ui.ctx().data_mut(|d| d.remove_temp::<f32>(pending));
let mut area = egui::ScrollArea::vertical().max_height(max_h).min_scrolled_height(max_h);
if let Some(target) = target {
area = area.vertical_scroll_offset(target);
}
let out = area.show(ui, add);
if let Some(target) = edge_scroll(ui, &out, browsing) {
ui.ctx().data_mut(|d| d.insert_temp(pending, target));
}
out.inner
}
/// The edge scroll [`menu_scroll`] adds while browsing: with the pointer resting in the bottom
/// (top) band of an overflowing menu, its content crawls up (down) after a short pause, ramps to
/// a run and stops at the end — and stops at once when the pointer leaves the band or the hold.
///
/// This runs after `ScrollArea::show`, which laid the menu out for the offset it had, so the new
/// offset is supplied once to the next draw's builder: a frame of lag, invisible at these speeds.
/// That explicit vertical offset cancels prior momentum on that draw; later wheel and scroll-bar
/// input still applies normally. A replay can consume the target but cannot queue another step
/// at the same input time.
fn edge_scroll<R>(ui: &Ui, out: &egui::containers::scroll_area::ScrollAreaOutput<R>, browsing: bool) -> Option<f32> {
/// How far from the top / bottom edge the pointer starts the crawl (points).
const BAND: f32 = 18.0;
/// The pause before the crawl starts: a pointer merely grazing the edge must not jump.
const DELAY: f32 = 0.12;
/// Points per second: a crawl at first, reaching the run about three quarters of a second in.
const CRAWL: f32 = 45.0;
const RAMP: f32 = 700.0;
const RUN: f32 = 560.0;
let ctx = ui.ctx();
let over = out.content_size.y - out.inner_rect.height();
let dir = match (browsing, over > 0.0, ctx.input(|i| i.pointer.latest_pos())) {
(true, true, Some(p)) if out.inner_rect.contains(p) => {
if p.y >= out.inner_rect.max.y - BAND {
1
} else if p.y <= out.inner_rect.min.y + BAND {
-1
} else {
0
}
}
_ => 0,
};
// The clock the pause counts from, kept per menu: entering or leaving an edge band restarts
// it, so a later stay there crawls off again instead of jumping straight to the run.
let now = ctx.input(|i| i.time);
let key = out.id.with("menu-edge-scroll");
let (since, prev) = ctx.data(|d| d.get_temp::<(f64, i32)>(key)).unwrap_or((now, 0));
let since = if dir == prev {
since
} else {
ctx.data_mut(|d| d.insert_temp(key, (now, dir)));
now
};
if dir == 0 {
return None;
}
if now - since < f64::from(DELAY) {
// A pointer resting still fires no events: keep the frames coming until the crawl runs.
ctx.request_repaint();
return None;
}
let speed = (CRAWL + RAMP * ((now - since) as f32 - DELAY)).min(RUN);
let crawl = ctx.input(|i| i.unstable_dt).min(0.05) * speed * dir as f32;
let old = out.state.unclamped_offset().y;
let new = (old + crawl).clamp(0.0, over);
if new == old {
// At the end of the list the crawl stands still and asks for no more frames.
return None;
}
ctx.request_repaint();
let stepped = out.id.with("menu-edge-scroll-step");
if ctx.data(|d| d.get_temp::<f64>(stepped)) == Some(now) {
return None;
}
// Stamp the queue operation, not consumption: a later real frame must still take a step.
ctx.data_mut(|d| d.insert_temp(stepped, now));
Some(new)
}
/// egui's menu style with no gap between the rows: the gap the app's item spacing left between
@@ -666,6 +816,15 @@ pub(crate) fn constrained(c: &[f64], d: usize, v: f64) -> Vec<f64> {
mod tests {
use super::*;
/// #2097: the drag speed comes from the slider range, not a much wider hard range.
#[test]
fn slider_scrub_speed_uses_the_slider_range() {
let slider = |min, max, slider_min, slider_max| ParamUi::Slider { min, max, slider_min, slider_max, decimals: 1 };
assert!((slider_scrub_speed(&slider(0.0, 10_000.0, 0.0, 1_000.0)) - 1_000.0 / 300.0).abs() < 1e-9);
assert!((slider_scrub_speed(&slider(0.0, 1.0e9, 0.0, 1.0)) - 1.0 / 300.0).abs() < 1e-9);
assert_eq!(slider_scrub_speed(&ParamUi::Number), 0.5);
}
/// #488: an inline editor selects its whole text only as it opens. Should it be without the
/// keyboard for a frame later (an input method, another handler), it takes the keyboard back
/// with the caret where the user put it instead of selecting everything again, which made
+104
View File
@@ -0,0 +1,104 @@
//! The crate's integration tests, one binary (each module is a former `tests/<name>.rs`).
mod render_queue_snapshot;
mod shared_docking_ui;
mod ui_3d;
mod ui_agent_audit;
mod ui_angles;
mod ui_audio_scrub;
mod ui_azerty_shortcuts;
mod ui_batch_switches;
mod ui_blank_context_menus;
mod ui_camera_tracker;
mod ui_character_eyedropper;
mod ui_color_hex;
mod ui_comp_presets;
mod ui_control_modifiers;
mod ui_dock_resize;
mod ui_drag_drop;
mod ui_effect_controls;
mod ui_effect_drop_highlight;
mod ui_effect_names;
mod ui_font_search;
mod ui_form_fit;
mod ui_frame_step;
mod ui_fx_editors;
mod ui_gpu;
mod ui_gradient_editor;
mod ui_gradient_handles;
mod ui_gradient_value;
mod ui_graph_fit;
mod ui_group_rename;
mod ui_guide_layers;
mod ui_inline_edit;
mod ui_keyframes;
mod ui_large_project;
mod ui_layer_panel_zoom;
mod ui_layer_source_picker;
mod ui_light_settings;
mod ui_live_preview;
mod ui_locked_color;
mod ui_media_browser_cache;
mod ui_media_browser_copy_path;
mod ui_menus;
mod ui_new_primitive;
mod ui_object_matte;
mod ui_panel_focus;
mod ui_panel_windows;
mod ui_panels;
mod ui_parameter_spacing;
mod ui_presets_folder;
mod ui_preview_align;
mod ui_project_delete;
mod ui_project_duplicate;
mod ui_project_keyboard;
mod ui_project_new_folder;
mod ui_project_rows;
mod ui_quick_offset;
mod ui_ram_preview;
mod ui_render_queue;
mod ui_script_console;
mod ui_scriptui;
mod ui_scrolling;
mod ui_settings_info;
mod ui_tabs;
mod ui_tail;
mod ui_text_edit;
mod ui_text_range_handles;
mod ui_theme_favorites;
mod ui_time_zoom_controls;
mod ui_timeline_effect_label;
mod ui_timeline_pan;
mod ui_timeline_shapes;
mod ui_timeline_switches;
mod ui_timeline_trim;
mod ui_timeline_zoom;
mod ui_toolbar_gradient;
mod ui_tracker;
mod ui_unreadable_footage;
mod ui_video_preview_off;
mod ui_viewer;
mod ui_viewers;
mod ui_visual_review;
mod ui_web_gpu_storage;
mod ui_web_job_gpu;
mod ui_web_offload;
mod ui_workflow_selection;
mod ui_workspace_focus;
mod viewer_geometry;
mod viewer_passive_display;
/// Register the managed build's canonical craft-fonts checkout (`CRAFT_FONTS_DIR/fonts`) through
/// the same directory source production scans for `EFFECTCRAFT_FONT_DIRS`. The UI tests then render
/// real Japanese glyphs from the canonical external input instead of relying on host fonts.
pub(crate) fn add_canonical_test_fonts() {
use effectcraft_text::fonts::{self, DirectorySource};
static REGISTER: std::sync::Once = std::sync::Once::new();
REGISTER.call_once(|| {
let Some(root) = std::env::var_os("CRAFT_FONTS_DIR") else { return };
let dir = std::path::PathBuf::from(root).join("fonts");
if dir.is_dir() {
fonts::add_source(&DirectorySource { dirs: vec![dir] });
}
});
}
@@ -1,6 +1,6 @@
//! Headless look at the Render Queue panel (wgpu offscreen). Ignored by default (needs a GPU
//! adapter); run with
//! `RQ_SNAPSHOT=/abs/out.png cargo test -p effectcraft-ui-egui --test render_queue_snapshot -- --ignored`.
//! `RQ_SNAPSHOT=/abs/out.png cargo test -p effectcraft-ui-egui --test it render_queue_snapshot:: -- --ignored`.
use effectcraft_engine::Session;
use effectcraft_engine::project::render_queue::RenderStatus;
@@ -31,7 +31,7 @@ fn render_queue_panel_snapshot() {
let ids: Vec<u64> = s.project.render_queue.iter().take(3).map(|i| i.id).collect();
let mut app = Some(EffectcraftApp::new(s));
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app.take().expect("app"));
h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("rq-ui"), (ids, None::<u64>)));
h.ctx.data_mut(|d| d.insert_temp(egui::Id::unique("rq-ui"), (ids, None::<u64>)));
h.state_mut().show_panel(effectcraft_ui_egui::dock::PanelKind::RenderQueue);
h.run_steps(4);
let img = h.render().expect("render");
@@ -154,7 +154,9 @@ fn pointer_drop_merges_floats_and_redocks_without_project_changes() {
let from = rect(&h, "panel.tab.Info").center();
drag(&mut h, from, pos2(700.0, 400.0), Modifiers::COMMAND);
assert_eq!(h.state().ui.floating.len(), 1);
assert_eq!(h.get_by_label("Resize floating panel").accesskit_node().role(), egui::accesskit::Role::Button);
// The grip is a resize handle: the migration's role mapping (`ResizeHandle → Splitter`) applies
// to floating panels too, and the widget reports it (`dock_ui.rs`).
assert_eq!(h.get_by_label("Resize floating panel").accesskit_node().role(), egui::accesskit::Role::Splitter);
action_log.push(action_state(&h, "floated"));
let original = h.state().ui.floating[0].rect;
let grip = rect(&h, "panel.float.0.resize").center();
@@ -162,7 +164,7 @@ fn pointer_drop_merges_floats_and_redocks_without_project_changes() {
assert!(h.state().ui.floating[0].rect[2] > original[2] + 25.0);
assert!(h.state().ui.floating[0].rect[3] > original[3] + 15.0);
action_log.push(action_state(&h, "resized"));
let grip_id = egui::Id::new(("floating-panel", PanelKind::Info.id())).with("resize");
let grip_id = egui::Id::unique(("floating-panel", PanelKind::Info.id())).with("resize");
assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(grip_id));
let before_keys = h.state().ui.floating[0].rect;
for key in [egui::Key::ArrowRight, egui::Key::ArrowDown] {
@@ -365,7 +367,7 @@ fn accordion_resize_routes_pointer_and_keyboard_without_changing_closed_entries(
};
h.run_steps(3);
let project = h.state().session.project.clone();
let id = egui::Id::new(("dock-gutter", "s0".to_string()));
let id = egui::Id::unique(("dock-gutter", "s0".to_string()));
h.ctx.memory_mut(|memory| memory.request_focus(id));
h.run_steps(2);
for pressed in [true, false] {
@@ -1,6 +1,6 @@
//! Headless UI checks for the 3D viewer (egui_kittest). The `snapshot` test renders the window
//! with wgpu and writes PNGs when `EC_SNAPSHOT_DIR` is set (`cargo test -p effectcraft-ui-egui
//! --test ui_3d -- --ignored`); the other test only runs the UI logic.
//! --test it ui_3d:: -- --ignored`); the other test only runs the UI logic.
use effectcraft_engine::Session;
use effectcraft_engine::render::three_d::View3D;
@@ -29,7 +29,8 @@ fn harness(s: Session) -> Harness<'static, EffectcraftApp> {
fn send(h: &mut Harness<'_, EffectcraftApp>, method: &str, params: Value) -> Value {
let ctx = h.ctx.clone();
let (req, _rx) = ControlRequest::new(method, params);
match control::handle(h.state_mut(), &ctx, &req) {
let now = h.state().now();
match control::handle(h.state_mut(), &ctx, &req, now) {
Outcome::Done(v) => v,
_ => panic!("{method}: no immediate reply"),
}
@@ -70,7 +71,7 @@ fn interactive(ctx: &egui::Context) -> Vec<(Rect, String)> {
}
let info = w.info(r.id);
// Selectable text (labels sense drags to select their text) is not a control.
if info.is_some_and(|i| i.typ == egui::WidgetType::Label) {
if info.is_some_and(|i| i.role == egui::Role::Label) {
continue;
}
out.push((ir, info.and_then(|i| i.label.clone()).unwrap_or_default()));
@@ -9,6 +9,7 @@ use effectcraft_engine::project::ItemId;
use effectcraft_engine::time::Tick;
use effectcraft_ui_egui::EffectcraftApp;
use effectcraft_ui_egui::audio::{AudioDevice, AudioFeed};
use effectcraft_ui_egui::frames::{RemoteDone, RemoteFrame, RemoteFrames, RemoteJob};
use egui::{Event, Modifiers, pos2};
use egui_kittest::Harness;
use serde_json::json;
@@ -30,6 +31,58 @@ impl AudioDevice for Fake {
}
}
/// A frame renderer whose frames the test releases explicitly. Playback then runs against a
/// controlled clock and controlled cache contents instead of racing however many frame workers
/// the host has; a frame that is not released is queued again by the next playback step.
#[derive(Clone, Default)]
struct GatedFrames {
pending: Arc<Mutex<Vec<(i64, u32, u32, RemoteDone)>>>,
}
impl RemoteFrames for GatedFrames {
fn slots(&self) -> usize {
1024
}
fn start(&self, job: RemoteJob, done: RemoteDone) {
let (frame, width, height) = job.project.comp(job.comp).map_or((-1, 1, 1), |c| {
let scale = job.opts.scale.max(0.001);
let width = (c.width as f64 * scale).round().clamp(1.0, 16_384.0) as u32;
let height = (c.height as f64 * scale).round().clamp(1.0, 16_384.0) as u32;
(c.frame_rate.frame_at(job.t), width, height)
});
self.pending.lock().unwrap().push((frame, width, height, done));
}
}
impl GatedFrames {
/// Put `frames` in the cache, dropping every other queued frame (playback asks again).
fn render(&self, h: &mut Harness<'_, EffectcraftApp>, cid: ItemId, frames: &[i64]) {
let scale = h.state().shown_series(cid).scale as f64 / 1000.0;
for &f in frames {
h.state().request_frame(cid, f, scale);
}
while h.state().frames.dispatch_remote() != 0 {}
let pending = std::mem::take(&mut *self.pending.lock().unwrap());
for (frame, width, height, done) in pending {
if frames.contains(&frame) {
let rgba = vec![0; (width as usize).saturating_mul(height as usize).saturating_mul(4)];
done(Ok(RemoteFrame { width, height, rgba, ms: 1.0 }));
} else {
done(Err("held back by the test renderer".into()));
}
}
}
/// Drop everything queued without caching it (the warm-up passes request viewer frames).
fn discard(&self, h: &mut Harness<'_, EffectcraftApp>) {
while h.state().frames.dispatch_remote() != 0 {}
for (_, _, _, done) in std::mem::take(&mut *self.pending.lock().unwrap()) {
done(Err("held back by the test renderer".into()));
}
}
}
/// A 2 s, 30 fps comp whose solid sounds a tone (Audio switch on).
fn setup(audible: bool, fake: &Fake) -> (EffectcraftApp, ItemId) {
setup_long(audible, fake, 2.0)
@@ -120,42 +173,74 @@ fn cmd_dragging_the_current_time_scrubs_and_an_idle_scrub_closes() {
fn preview_with_audio_shows_every_frame_and_sounds_once_cached() {
let fake = Fake::default();
let (mut app, cid) = setup(true, &fake);
// Playback requests at most twice as many frames ahead per step as there are render threads.
// Four times that many frames take at least four steps to cache, so frames are shown silently
// first however fast the renderer is (with a fixed 2 s comp, a machine with many cores cached
// all of it before the first step and the sound started at once).
// Hold rendering in the test: the cache cannot fill ahead of what the test releases, so the
// silently shown frames and the moment the sound starts do not depend on the host's threads.
let gate = GatedFrames::default();
let renderer: Arc<dyn RemoteFrames> = Arc::new(gate.clone());
app.frames.set_remote(Some(renderer));
let ahead = (app.frames.parallelism() * 2).max(4);
app.session.execute("comp.settings", json!({"comp": cid.0, "duration": (4 * ahead) as f64 / 30.0})).unwrap();
// A little under one frame of input time per step.
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 31.0).build_eframe(|_| app);
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app);
// Playback measures time on the app clock, never `ctx.input(|i| i.time)`; drive that clock
// explicitly at one frame per step.
h.state_mut().use_manual_clock();
h.run_steps(2);
gate.discard(&mut h);
let frame = |h: &Harness<'_, EffectcraftApp>| {
let app = h.state();
app.session.project.comp(cid).unwrap().frame_rate.frame_at(app.session.time())
};
let now = h.ctx.input(|i| i.time);
let now = h.state().now();
h.state_mut().play(now);
assert!(h.state().playback.audio_held && h.state().audio.is_none(), "nothing is cached: no sound yet");
let mut last = frame(&h);
let mut shown = 0;
// Wait on the clock, not a step count: under a loaded machine (parallel test binaries) the
// background cache can take far longer than 2000 short steps to get ahead.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
while std::time::Instant::now() < deadline {
// Show frames while only those frames are cached: they play silently first.
for _ in 0..4 {
let target = last + 1;
gate.render(&mut h, cid, &[target]);
h.state_mut().clock_mut().advance(1.0 / 30.0);
h.step();
if h.state().audio.is_some() {
break;
}
let f = frame(&h);
assert!(f == last || f == last + 1 || f < last, "{last} → {f}: a frame was skipped");
// Accumulating 1/30 in f64 can leave the same frame up for one more step; it must never
// jump over a frame.
assert!(f == last || f == last + 1, "{last} → {f}: a frame was skipped");
shown += usize::from(f != last);
last = f;
std::thread::sleep(std::time::Duration::from_millis(1));
}
assert!(h.state().audio.is_some() && fake.0.lock().unwrap().0.is_some(), "the sound started once the frames ahead were cached");
// A frame that has not rendered holds the clock at the frame on screen; once it renders it is
// shown, never jumped over. This exercises the production hold path, not a test-only shortcut.
let waiting = last;
h.state_mut().clock_mut().advance(1.0 / 30.0);
h.step();
assert_eq!(frame(&h), waiting, "an uncached frame holds the preview instead of being skipped");
gate.render(&mut h, cid, &[waiting + 1]);
// f64 clock accumulation need not put the next step exactly on the next frame boundary;
// keep stepping until the held frame appears, and never allow it to jump past that frame.
let mut showed_waiting = false;
for _ in 0..4 {
h.state_mut().clock_mut().advance(1.0 / 30.0);
h.step();
let f = frame(&h);
assert!(f == waiting || f == waiting + 1, "{waiting} → {f}: the held frame was skipped");
if f == waiting + 1 {
showed_waiting = true;
break;
}
}
assert!(showed_waiting, "the waiting frame is shown once it renders");
shown += 1;
last = waiting + 1;
assert!(shown > 0, "frames played silently first");
// A frame that isn't cached stops the sound rather than being skipped. (Frames render fast
// enough here to be cached again before the check: keep them out of the cache.)
assert!(h.state().audio.is_none() && h.state().playback.audio_held, "the rest of the range is not cached: the sound waits");
// Cache everything ahead; only then may the sound start.
let end = h.state().playback.plan.expect("a preview plan").end;
let rest: Vec<i64> = ((last + 1)..=end).collect();
gate.render(&mut h, cid, &rest);
h.step();
assert!(h.state().audio.is_some() && fake.0.lock().unwrap().0.is_some(), "the sound started once the frames ahead were cached");
// A frame that isn't cached stops the sound rather than being skipped.
gate.discard(&mut h);
h.state().frames.set_budget(0);
h.state().frames.clear();
h.step();
@@ -168,27 +253,47 @@ fn preview_with_audio_shows_every_frame_and_sounds_once_cached() {
#[test]
fn preview_with_audio_sounds_once_rendering_keeps_up() {
let fake = Fake::default();
let (app, cid) = setup_long(true, &fake, 30.0);
let (mut app, cid) = setup_long(true, &fake, 30.0);
// Release only the frames this test chooses, and drive the app clock: rendering cannot race
// the clock, so a frame is never skipped by a slow or loaded test host.
let gate = GatedFrames::default();
let renderer: Arc<dyn RemoteFrames> = Arc::new(gate.clone());
app.frames.set_remote(Some(renderer));
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app);
h.state_mut().use_manual_clock();
h.run_steps(2);
gate.discard(&mut h);
let ahead = (h.state().frames.parallelism() * 2).max(4) as i64;
let frame = |h: &Harness<'_, EffectcraftApp>| {
let app = h.state();
app.session.project.comp(cid).unwrap().frame_rate.frame_at(app.session.time())
};
let now = h.ctx.input(|i| i.time);
h.state_mut().play(now);
let start = h.state().now();
h.state_mut().play(start);
assert!(h.state().playback.audio_held && h.state().audio.is_none(), "nothing is cached: no sound yet");
let mut last = frame(&h);
// Two seconds of playback, each step giving the frame workers time to keep up.
for _ in 0..60 {
let mut shown = 0;
// Two seconds of playback at one frame per clock step: keep the prefetch window cached, but
// not the rest of the 30 s comp, so the sound has to start by the real-time rule.
for step in 1..=60 {
let target = last + 1;
let window: Vec<i64> = (target..target + ahead).collect();
gate.render(&mut h, cid, &window);
// Set the clock to this step's exact time rather than adding 1/30 repeatedly: accumulated
// f64 error can put one step just below a frame boundary and the next just above it, so
// `floor(elapsed × fps)` would otherwise jump from frame N to N + 2 for a one-frame step.
let now = h.state().now();
let tick_time = start + step as f64 / 30.0;
h.state_mut().clock_mut().advance(tick_time - now);
h.step();
if h.state().audio.is_some() {
break;
}
let f = frame(&h);
assert!(f == last || f == last + 1, "{last} → {f}: a frame was skipped");
shown += usize::from(f != last);
last = f;
std::thread::sleep(std::time::Duration::from_millis(20));
}
assert!(h.state().audio.is_some() && fake.0.lock().unwrap().0.is_some(), "the sound started within 2 s, at frame {last} of 900");
assert!(shown >= 3, "the sound starts only after the silent preview kept up for a moment");
}
@@ -0,0 +1,244 @@
//! A Timeline row control clicked on one of several selected layers applies to the whole
//! selection (#1849), as in After Effects: the A/V Features and Switches columns, the label
//! swatch and the Modes column. The engine commands already took several layers; the row
//! controls used to pass the clicked layer alone.
use effectcraft_engine::Session;
use effectcraft_engine::color::{BlendMode, Label};
use effectcraft_engine::project::{Layer, LayerId, Switches};
use effectcraft_ui_egui::EffectcraftApp;
use egui::{Event, Modifiers, Pos2, Rect, pos2, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
/// Comp "Main" with solids A (top) to D (bottom); returns their ids top to bottom.
fn harness() -> (Harness<'static, EffectcraftApp>, Vec<u64>) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap();
let mut ids: Vec<u64> = ["D", "C", "B", "A"]
.iter()
.map(|name| s.execute("layer.newSolid", json!({"name": name, "color": "#406080"})).unwrap()["layer"].as_u64().unwrap())
.collect();
ids.reverse();
s.execute("edit.deselectAll", json!({})).unwrap();
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
h.run_steps(3);
(h, ids)
}
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone();
Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3]))
}
/// Press on `from`, move to `to` in `steps`, release there.
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, steps: u32) {
h.event(Event::PointerMoved(from));
h.step();
h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.step();
for k in 1..=steps {
h.event(Event::PointerMoved(from + (to - from) * (k as f32 / steps as f32)));
h.step();
}
h.event(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
}
/// A click on the Timeline control `id`.
fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) {
let p = rect(h, id).center();
drag(h, p, p, 1);
}
/// A click at `p` (a menu entry, found by its label).
fn click_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
drag(h, p, p, 1);
}
fn alt_click(h: &mut Harness<'_, EffectcraftApp>, id: &str) {
let p = rect(h, id).center();
let modifiers = Modifiers::ALT;
h.event(Event::PointerMoved(p));
h.event(Event::ModifiersChanged(modifiers));
h.step();
h.event(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers });
h.step();
h.event(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers });
h.event(Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(3);
}
/// Select `ids` in the Timeline, as a Shift range or a Cmd toggle would.
fn select(h: &mut Harness<'_, EffectcraftApp>, ids: &[u64]) {
h.state_mut().session.execute("layer.select", json!({"layers": ids})).unwrap();
h.run_steps(2);
}
/// The state of switch `f` on each layer of `ids`, top to bottom.
fn states(h: &Harness<'_, EffectcraftApp>, ids: &[u64], f: fn(&Layer) -> bool) -> Vec<bool> {
let comp = h.state().session.active_comp().unwrap();
ids.iter().map(|id| f(comp.layer(LayerId(*id)).unwrap())).collect()
}
fn switches(h: &Harness<'_, EffectcraftApp>, id: u64) -> Switches {
h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().switches.clone()
}
fn locked(l: &Layer) -> bool {
l.switches.locked
}
fn video(l: &Layer) -> bool {
l.switches.video
}
fn three_d(l: &Layer) -> bool {
l.switches.three_d
}
fn solo(l: &Layer) -> bool {
l.switches.solo
}
fn shy(l: &Layer) -> bool {
l.switches.shy
}
fn motion_blur(l: &Layer) -> bool {
l.switches.motion_blur
}
fn preserved(l: &Layer) -> bool {
l.preserve_transparency
}
/// #1849: a click on the Lock, 3D, Solo, Shy or Motion Blur switch of a selected layer gives the
/// switch to every selected layer, in one undo step.
#[test]
fn switches_on_a_selected_row_set_the_whole_selection() {
let (mut h, ids) = harness();
select(&mut h, &[ids[0], ids[2]]);
let steps = h.state().session.history.undo.len();
click(&mut h, &format!("timeline.layer.{}.lock", ids[0]));
assert_eq!(states(&h, &ids, locked), [true, false, true, false]);
assert_eq!(h.state().session.history.undo.len(), steps + 1, "one undo step");
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
h.run_steps(2);
assert_eq!(states(&h, &ids, locked), [false; 4]);
click(&mut h, &format!("timeline.layer.{}.switch.threeD", ids[0]));
assert_eq!(states(&h, &ids, three_d), [true, false, true, false]);
// The switch of the other selected layer acts the same way.
click(&mut h, &format!("timeline.layer.{}.solo", ids[2]));
assert_eq!(states(&h, &ids, solo), [true, false, true, false]);
click(&mut h, &format!("timeline.layer.{}.switch.shy", ids[2]));
assert_eq!(states(&h, &ids, shy), [true, false, true, false]);
click(&mut h, &format!("timeline.layer.{}.switch.motionBlur", ids[0]));
assert_eq!(states(&h, &ids, motion_blur), [true, false, true, false]);
// A second click turns both off again, whatever their own state.
click(&mut h, &format!("timeline.layer.{}.switch.threeD", ids[2]));
assert_eq!(states(&h, &ids, three_d), [false; 4]);
}
/// A switch on a row that isn't selected still changes that layer alone, and leaves the
/// selection.
#[test]
fn a_switch_on_an_unselected_row_sets_that_layer_only() {
let (mut h, ids) = harness();
select(&mut h, &[ids[0], ids[2]]);
click(&mut h, &format!("timeline.layer.{}.lock", ids[1]));
assert_eq!(states(&h, &ids, locked), [false, true, false, false]);
assert_eq!(h.state().session.state.selected_layers, vec![LayerId(ids[0]), LayerId(ids[2])]);
}
/// The targets skip the selected layers a switch doesn't apply to: 3D reaches a null but not a
/// camera (3D by kind), the Effect switch not a layer without effects.
#[test]
fn switch_targets_skip_the_layers_the_switch_does_not_apply_to() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap();
let solid = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap();
let null = s.execute("layer.newNull", json!({"name": "Ctrl"})).unwrap()["layer"].as_u64().unwrap();
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("effect.apply", json!({"effect": "ec.blur.gaussian", "layers": [solid]})).unwrap();
s.execute("edit.deselectAll", json!({})).unwrap();
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
h.run_steps(3);
select(&mut h, &[solid, null, cam]);
click(&mut h, &format!("timeline.layer.{solid}.switch.threeD"));
assert!(switches(&h, solid).three_d && switches(&h, null).three_d, "3D reaches the null");
assert!(!switches(&h, cam).three_d, "a camera is 3D by kind, its own switch stays off");
click(&mut h, &format!("timeline.layer.{solid}.switch.fx"));
assert!(!switches(&h, solid).effects, "the Effect switch reaches the layer that has an effect");
assert!(switches(&h, null).effects && switches(&h, cam).effects, "no effects, no Effect switch");
}
/// Alt-click on an A/V feature sets the selected layers apart (the others off), and the next
/// Alt-click brings the others back — with several layers, the whole selection stands apart.
#[test]
fn alt_click_sets_the_selected_layers_apart() {
let (mut h, ids) = harness();
select(&mut h, &[ids[0], ids[2]]);
alt_click(&mut h, &format!("timeline.layer.{}.video", ids[0]));
assert_eq!(states(&h, &ids, video), [true, false, true, false]);
alt_click(&mut h, &format!("timeline.layer.{}.video", ids[0]));
assert_eq!(states(&h, &ids, video), [true; 4]);
}
/// The label swatch menu of a selected layer sets the label of every selected layer.
#[test]
fn the_label_menu_sets_the_selections_label() {
let (mut h, ids) = harness();
select(&mut h, &[ids[0], ids[2]]);
let green = h.state().session.prefs.label_name(Label::Green);
click(&mut h, &format!("timeline.layer.{}.label", ids[0]));
let entry = h.query_by_label(&green).expect("the label menu is open").rect();
click_at(&mut h, entry.center());
let label = |id: u64| h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().label;
assert_eq!([label(ids[0]), label(ids[1]), label(ids[2]), label(ids[3])], [Label::Green, Label::Red, Label::Green, Label::Red]);
}
/// The Modes column: the Blending Mode menu and the Preserve Transparency checkbox of a selected
/// layer set both selected layers, one undo step each.
#[test]
fn the_modes_column_sets_the_whole_selection() {
let (mut h, ids) = harness();
h.state_mut().ui.timeline.show_modes = true;
h.run_steps(3);
select(&mut h, &[ids[0], ids[2]]);
click(&mut h, &format!("timeline.layer.{}.mode", ids[0]));
let entry = h.query_by_label("Multiply").expect("the blend mode menu is open").rect();
click_at(&mut h, entry.center());
let mode = |id: u64| h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().blend_mode;
assert_eq!([mode(ids[0]), mode(ids[1]), mode(ids[2]), mode(ids[3])], [BlendMode::Multiply, BlendMode::Normal, BlendMode::Multiply, BlendMode::Normal]);
click(&mut h, &format!("timeline.layer.{}.preserveTransparency", ids[0]));
assert_eq!(states(&h, &ids, preserved), [true, false, true, false]);
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
h.run_steps(2);
assert_eq!(states(&h, &ids, preserved), [false; 4], "one undo step put both back");
}
/// Preserve Transparency toggles from the row it is clicked on, not from the first selected layer:
/// A on and C off, clicking C's box turns both on.
#[test]
fn preserve_transparency_toggles_from_the_clicked_row() {
let (mut h, ids) = harness();
h.state_mut().ui.timeline.show_modes = true;
h.run_steps(3);
h.state_mut().session.execute("layer.setSwitch", json!({"layers": [ids[0]], "switch": "preserveTransparency", "value": true})).unwrap();
h.run_steps(2);
select(&mut h, &[ids[0], ids[2]]);
click(&mut h, &format!("timeline.layer.{}.preserveTransparency", ids[2]));
assert_eq!(states(&h, &ids, preserved), [true, false, true, false]);
// The next click turns both off again.
click(&mut h, &format!("timeline.layer.{}.preserveTransparency", ids[2]));
assert_eq!(states(&h, &ids, preserved), [false; 4]);
}
@@ -1,5 +1,6 @@
//! Right-click actions in the blank Project and Timeline areas, and on Timeline property names.
use effectcraft_engine::Session;
use effectcraft_engine::project::ItemKind;
use effectcraft_ui_egui::{Dialog, EffectcraftApp, dock::PanelKind};
use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2};
use egui_kittest::{Harness, kittest::Queryable as _};
@@ -64,7 +65,7 @@ fn timeline_blank_outline_and_time_graph_create_layers() {
let area = rect(&h, "timeline.layerMarquee");
let p = if graph { pos2(area.max.x + 150.0, area.center().y) } else { area.center() };
right_click(&mut h, p);
h.get_by_label("New ⏵").click();
h.get_by_label("New").click();
h.run_steps(3);
snapshot(&mut h, if graph { "timeline-graph" } else { "timeline-outline" });
h.get_by_label("Null Object").click();
@@ -80,7 +81,7 @@ fn timeline_blank_menu_opens_solid_settings() {
let mut h = harness();
let p = rect(&h, "timeline.layerMarquee").center();
right_click(&mut h, p);
h.get_by_label("New ⏵").click();
h.get_by_label("New").click();
h.run_steps(3);
h.get_by_label("Solid…").click();
h.run_steps(3);
@@ -251,13 +252,13 @@ fn timeline_layer_context_menu_has_the_effects_submenu_and_applies_an_effect() {
let r = rect(&h, &format!("timeline.layer.{layer}.row"));
right_click(&mut h, pos2(r.min.x + 210.0, r.center().y));
// The palette sits between Blending Mode and Arrange, as After Effects' layer menu does.
let blending = h.get_by_label("Blending Mode ⏵").rect();
let effects = h.get_by_label("Effects ⏵").rect();
let arrange = h.get_by_label("Arrange ⏵").rect();
let blending = h.get_by_label("Blending Mode").rect();
let effects = h.get_by_label("Effects").rect();
let arrange = h.get_by_label("Arrange").rect();
assert!(blending.bottom() <= effects.top() && effects.bottom() <= arrange.top(), "Effects between Blending Mode and Arrange");
h.get_by_label("Effects ⏵").click();
h.get_by_label("Effects").click();
h.run_steps(3);
h.get_by_label("Blur & Sharpen ⏵").click();
h.get_by_label("Blur & Sharpen").click();
h.run_steps(3);
h.get_by_label("Gaussian Blur").click();
h.run_steps(3);
@@ -282,11 +283,11 @@ fn timeline_layer_context_menu_has_layer_styles_submenu() {
h.run_steps(3);
let r = rect(&h, &format!("timeline.layer.{solid}.row"));
right_click(&mut h, pos2(r.min.x + 210.0, r.center().y));
let blending = h.get_by_label("Blending Mode ⏵").rect();
let styles = h.get_by_label("Layer Styles ⏵").rect();
let effects = h.get_by_label("Effects ⏵").rect();
let blending = h.get_by_label("Blending Mode").rect();
let styles = h.get_by_label("Layer Styles").rect();
let effects = h.get_by_label("Effects").rect();
assert!(blending.bottom() <= styles.top() && styles.bottom() <= effects.top(), "Layer Styles between Blending Mode and Effects");
h.get_by_label("Layer Styles ⏵").click();
h.get_by_label("Layer Styles").click();
h.run_steps(3);
assert!(h.query_by_label("Layer Style Options...").is_some());
assert!(h.query_by_label("Drop Shadow").is_some());
@@ -299,6 +300,43 @@ fn timeline_layer_context_menu_has_layer_styles_submenu() {
assert!(l.layer_styles().is_some_and(|g| g.sub("dropShadow").is_some()));
}
/// #1809: Reveal Layer Source in Project shows the source in a large project: it clears the
/// Project panel's search, opens the folders holding the item and selects it.
#[test]
fn timeline_layer_context_menu_reveals_the_source_in_project() {
let mut h = harness();
let layer = h.state_mut().session.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap();
let solid = h.state().session.project.items.values().find(|i| matches!(i.kind, ItemKind::Solid(_))).unwrap().id;
let outer = h.state_mut().session.execute("project.newFolder", json!({"name": "Assets"})).unwrap()["item"].as_u64().unwrap();
let inner = h.state_mut().session.execute("project.newFolder", json!({"name": "Plates"})).unwrap()["item"].as_u64().unwrap();
h.state_mut().session.execute("project.move", json!({"items": [inner], "folder": outer})).unwrap();
h.state_mut().session.execute("project.move", json!({"items": [solid.0], "folder": inner})).unwrap();
h.state_mut().session.execute("project.select", json!({"items": []})).unwrap();
h.state_mut().ui.project_open_folders.clear();
h.state_mut().ui.project_search = "no such item".into();
h.run_steps(3);
assert!(h.state().auto.find(&format!("project.item.{}", solid.0)).is_none(), "the source starts hidden");
let r = rect(&h, &format!("timeline.layer.{layer}.row"));
right_click(&mut h, pos2(r.min.x + 210.0, r.center().y));
h.get_by_label("Reveal Layer Source in Project").click();
h.run_steps(3);
assert_eq!(h.state().session.state.project_selection, vec![solid]);
assert_eq!(h.state().ui.focused, PanelKind::Project);
assert!(h.state().ui.project_search.is_empty(), "the search is cleared");
assert!([outer, inner].iter().all(|folder| h.state().ui.project_open_folders.contains(folder)), "the folders holding the source open");
assert!(h.state().auto.find(&format!("project.item.{}", solid.0)).is_some(), "the source row is visible");
// In a mixed selection the command still appears when the right-clicked layer has no source,
// because the menu acts on the solid layer too.
let shape = h.state_mut().session.execute("layer.newShape", json!({"kind": "none"})).unwrap()["layer"].as_u64().unwrap();
h.state_mut().session.execute("layer.select", json!({"layers": [shape, layer]})).unwrap();
h.run_steps(3);
let r = rect(&h, &format!("timeline.layer.{shape}.row"));
right_click(&mut h, pos2(r.min.x + 210.0, r.center().y));
h.get_by_label("Reveal Layer Source in Project").click();
h.run_steps(3);
assert_eq!(h.state().session.state.project_selection, vec![solid], "the solid's source, not the shape");
}
/// #976: a layer's context menu has After Effects' Markers ▸ Add Marker, which marks the layer that
/// was right-clicked (not the one selected before, and not the composition) at the current time.
#[test]
@@ -313,7 +351,7 @@ fn layer_context_menu_adds_a_layer_marker() {
h.run_steps(3);
let r = rect(&h, &format!("timeline.layer.{top}.row"));
right_click(&mut h, pos2(r.min.x + 210.0, r.center().y));
h.get_by_label("Markers ⏵").click();
h.get_by_label("Markers").click();
h.run_steps(3);
snapshot(&mut h, "layer-markers");
h.get_by_label("Add Marker").click();
@@ -175,6 +175,7 @@ fn viewer_zooms_and_pans_over_the_track_points() {
delta: egui::vec2(0.0, 120.0),
modifiers: Default::default(),
phase: egui::TouchPhase::Move,
source: egui::MouseWheelSource::Wheel,
});
h.run_steps(20);
assert_ne!(h.state().ui.viewer.zoom, zoom, "the wheel zooms over the track points");
@@ -18,6 +18,8 @@ fn frames(app: &mut EffectcraftApp) -> Vec<(bool, egui::Modifiers)> {
if raw.events.is_empty() {
break;
}
// The harness feeds the batch itself; a real UI pass would apply it now.
app.mark_synthetic_applied();
let mut button = false;
for e in &raw.events {
match e {
@@ -34,7 +36,26 @@ fn frames(app: &mut EffectcraftApp) -> Vec<(bool, egui::Modifiers)> {
fn send(app: &mut EffectcraftApp, method: &str, params: Value) {
let ctx = egui::Context::default();
let (req, _rx) = ControlRequest::new(method, params);
let _ = control::handle(app, &ctx, &req);
let now = app.now();
let _ = control::handle(app, &ctx, &req, now);
}
#[test]
fn control_gestures_reject_excessive_counts_without_queue_mutation() {
let mut app = EffectcraftApp::new(Session::default());
send(&mut app, "ui.click", json!({"x": 10, "y": 10, "count": u64::MAX}));
assert!(app.take_synthetic_input().is_empty(), "an oversized click must not partially queue input");
send(&mut app, "ui.drag", json!({"from": {"x": 10, "y": 10}, "to": {"x": 90, "y": 40}, "steps": u64::MAX}));
assert!(app.take_synthetic_input().is_empty(), "an oversized drag must not partially queue input");
send(&mut app, "ui.click", json!({"x": 10, "y": 10, "count": 512}));
send(&mut app, "ui.click", json!({"x": 10, "y": 10, "count": 512}));
assert_eq!(app.take_synthetic_input().len(), 2054, "two maximum accepted clicks fit under the aggregate cap");
for _ in 0..4 {
send(&mut app, "ui.click", json!({"x": 10, "y": 10, "count": 512}));
}
assert_eq!(app.take_synthetic_input().len(), 3081, "the pending event limit is enforced before requests can exceed it");
}
#[test]
@@ -54,7 +54,7 @@ fn focused_dock_resize_owns_arrows_and_keeps_pointer_dragging() {
h.run_steps(3);
let before_project = h.state().session.project.clone();
let before_times = h.state().session.state.times.clone();
let id = Id::new(("dock-gutter", String::new()));
let id = Id::unique(("dock-gutter", String::new()));
h.ctx.memory_mut(|memory| memory.request_focus(id));
h.run_steps(2);
assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(id));
@@ -108,6 +108,47 @@ fn project_items_land_where_they_are_dropped_in_the_timeline() {
assert!((in_point(&h, 3) - 1.0).abs() < 1e-9, "Shift starts it at the current time");
}
/// Alt/Option-drag from the Project panel replaces the source of the layer under the pointer
/// instead of adding a layer (#991).
#[test]
fn alt_dragging_a_project_item_replaces_the_layer_source() {
let (mut h, clip) = harness();
let top = layer_id(&h, "Top");
let item = rect(&h, &format!("project.item.{clip}.name")).center();
let row = rect(&h, &format!("timeline.layer.{top}.row"));
let before = stack(&h);
drag(&mut h, item, row.center(), Modifiers::ALT);
assert_eq!(stack(&h), before, "no layer is added");
let layer = h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(top)).unwrap();
assert_eq!(layer.source, effectcraft_engine::project::LayerSource::Comp { item: effectcraft_engine::project::ItemId(clip) });
assert_eq!(layer.name, "Top", "the layer keeps its name");
}
/// Ctrl+Alt+/ (Cmd+Opt+/ on macOS) replaces every selected layer's source with the Project panel
/// selection (#991).
#[test]
fn ctrl_alt_slash_replaces_every_selected_layer_source() {
let (mut h, clip) = harness();
let top = layer_id(&h, "Top");
let middle = layer_id(&h, "Middle");
h.state_mut().session.execute("layer.select", json!({"layers": [top, middle]})).unwrap();
h.state_mut().session.state.project_selection = vec![effectcraft_engine::project::ItemId(clip)];
h.run_steps(3);
let modifiers = Modifiers { command: true, alt: true, ..Default::default() };
for pressed in [true, false] {
h.event(Event::Key { key: egui::Key::Slash, physical_key: Some(egui::Key::Slash), pressed, repeat: false, modifiers });
}
h.run_steps(3);
let comp = h.state().session.active_comp().unwrap();
for lid in [top, middle] {
assert_eq!(
comp.layer(effectcraft_engine::project::LayerId(lid)).unwrap().source,
effectcraft_engine::project::LayerSource::Comp { item: effectcraft_engine::project::ItemId(clip) }
);
}
assert_eq!(stack(&h), ["Top", "Middle", "Bottom"], "no layer is added");
}
/// #85: a Project item dropped on the Composition viewer becomes a layer centred where it was
/// dropped (above the selected layer, like any new layer).
#[test]
@@ -421,7 +421,7 @@ fn right_click_in_effect_controls_shows_the_effect_menu() {
app.show_panel(PanelKind::EffectControls);
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app);
h.run_steps(3);
let category = " Blur & Sharpen ⏵";
let category = " Blur & Sharpen";
// An effect's header: its own menu.
let header = rect_of(&h, &format!("effectControls.effect.{fill}")).center();
right_click(&mut h, header);
@@ -778,6 +778,23 @@ fn effect_control_name_can_be_renamed_from_its_header() {
assert_eq!(current_name(&h), "Point Control");
}
// Regression test for #1497: double-clicking an effect parameter's name in Effect Controls did
// nothing; it should reveal that property in the Timeline, as After Effects does.
#[test]
fn double_clicking_an_effect_property_name_reveals_it_in_the_timeline() {
let (app, x) = app();
let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| app);
settle(&mut h);
let row = format!("timeline.prop.{}.name", x.point);
assert!(!ids(&h).contains(&row), "the effect's property is not in the Timeline before the double-click");
assert!(!h.state().ui.timeline.reveal_props.contains(&x.point));
let point = rect_of(&h, &format!("effectControls.prop.{}.name", x.point)).center();
double_click(&mut h, point);
h.run_steps(4);
assert!(h.state().ui.timeline.reveal_props.contains(&x.point), "the property is marked revealed in the Timeline");
assert!(ids(&h).contains(&row), "the Timeline shows the property's row");
}
#[test]
fn effect_rename_escape_keeps_the_name() {
let (app, x) = app();
@@ -32,7 +32,9 @@ fn harness(language: &str) -> (Harness<'static, EffectcraftApp>, u64) {
app.show_panel(PanelKind::EffectControls);
app.show_panel(PanelKind::EffectsPresets);
app.show_panel(PanelKind::Timeline);
let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(|_| app);
// The Chinese/Japanese names under test have no installed face on every host: draw tofu
// instead of failing the missing-glyph gate (the glyph coverage test covers the bundled set).
let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).allow_missing_glyphs().build_eframe(|_| app);
h.run_steps(4);
(h, fx)
}

Some files were not shown because too many files have changed in this diff Show More