mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-10 04:11:38 +00:00
Merge main into enhance/avi-import
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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}`");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1081
-266
File diff suppressed because it is too large
Load Diff
+1527
-296
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -330,7 +330,11 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
|
||||
}
|
||||
return r;
|
||||
}
|
||||
if no_params(¶ms) && 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(¶ms) && 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, ¶ms) {
|
||||
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, ¶ms)? {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
// New 3D Primitive without parameters opens its dialog.
|
||||
if crate::panels::primitive_dialog::route(app, id, ¶ms)? {
|
||||
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, ¶ms)? {
|
||||
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)>) {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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(¶ms);
|
||||
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");
|
||||
|
||||
@@ -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)");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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))));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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}));
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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] });
|
||||
}
|
||||
});
|
||||
}
|
||||
+2
-2
@@ -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");
|
||||
+5
-3
@@ -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()));
|
||||
+130
-25
@@ -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]);
|
||||
}
|
||||
+50
-12
@@ -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();
|
||||
+1
@@ -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");
|
||||
+22
-1
@@ -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]
|
||||
+18
-1
@@ -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();
|
||||
+3
-1
@@ -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
Reference in New Issue
Block a user