diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index afc2af855..2cb103dcc 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -387,6 +387,11 @@ impl EffectcraftApp { let full = ui.max_rect(); if self.dialog.is_some() { ui.disable(); + // A dialog that edits what is behind it live (Preview) leaves the window at full + // brightness: clicks still don't reach it, but the canvas is not greyed out. + if self.dialog == Some(Dialog::GradientEditor) || panels::live_preview::active(self) { + ui.set_opacity(1.0); + } } ui.painter().rect_filled(full, 0.0, t.app_bg); let mut top = full.min.y; diff --git a/crates/ui-egui/src/app/window.rs b/crates/ui-egui/src/app/window.rs index 10a53f1d2..7326b9f32 100644 --- a/crates/ui-egui/src/app/window.rs +++ b/crates/ui-egui/src/app/window.rs @@ -56,6 +56,18 @@ impl EffectcraftApp { &mut self.dialog_state.gradient } + /// The open settings dialog's draft and Preview state (`panels::dialogs`): Solid, Camera, + /// Light and Composition Settings keep what OK would apply here. + pub fn dialog_state(&self) -> &panels::dialogs::DialogState { + &self.dialog_state + } + + /// The open settings dialog's draft, to edit (tests and automation; the dialog's own fields + /// do the same). + pub fn dialog_state_mut(&mut self) -> &mut panels::dialogs::DialogState { + &mut self.dialog_state + } + /// Show panel `p` (opening it in its usual place if it is closed), bring it to the front and /// give it the focus: Window ▸ ``. pub fn show_panel(&mut self, p: PanelKind) { diff --git a/crates/ui-egui/src/menus/shortcuts.rs b/crates/ui-egui/src/menus/shortcuts.rs index b9990786c..f50fbdb3c 100644 --- a/crates/ui-egui/src/menus/shortcuts.rs +++ b/crates/ui-egui/src/menus/shortcuts.rs @@ -210,6 +210,9 @@ fn escape_dialog(app: &mut EffectcraftApp, ctx: &egui::Context) { ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)); if app.dialog == Some(crate::Dialog::LayerStyles) { crate::panels::layer_styles_dialog::finish(app, false); + } else if app.dialog == Some(crate::Dialog::GradientEditor) { + // Escape is Cancel: the preview rolls back. + crate::panels::gradient_editor::finish(app, false, ctx); } else if app.dialog == Some(crate::Dialog::Info) { crate::panels::forms::close_info(app); } else { @@ -222,10 +225,37 @@ fn escape_dialog(app: &mut EffectcraftApp, ctx: &egui::Context) { if app.dialog == Some(crate::Dialog::UnsavedChanges) { app.dialog_state.unsaved.command = None; } + // Escape is Cancel: a Preview that edited the project rolls back. + crate::panels::live_preview::cancel(app); app.dialog = None; } } +/// Escape with a dialog open: it is Cancel. Escape while recording a shortcut cancels the +/// recording, not the editor; so does Escape in its Delete Preset prompt. +fn cancel_dialog(app: &mut EffectcraftApp, ctx: &egui::Context) { + if crate::panels::shortcut_editor::recording(app) || crate::panels::shortcut_editor::confirming(app) { + return; + } + if app.dialog == Some(crate::Dialog::Info) { + crate::panels::forms::close_info(app); + return; + } + if app.dialog == Some(crate::Dialog::GradientEditor) { + crate::panels::gradient_editor::finish(app, false, ctx); + return; + } + // Escape in Settings is Cancel: restore the settings from when it opened. + if app.dialog == Some(crate::Dialog::Settings) + && let Some(p) = app.dialog_state.prefs_snapshot.take() + { + app.session.prefs = p; + app.session.prefs_changed(); + } + crate::panels::live_preview::cancel(app); + app.dialog = None; +} + type KeyEvent = (egui::Key, egui::Modifiers, bool, Option); /// The frame's key presses, with clipboard events mapped back to Ctrl+C / X / V. @@ -341,23 +371,7 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only } // Escape closes dialogs. if key == egui::Key::Escape && app.dialog.is_some() { - // Escape while recording a shortcut cancels the recording, not the editor; so does - // Escape in its Delete Preset prompt. - if crate::panels::shortcut_editor::recording(app) || crate::panels::shortcut_editor::confirming(app) { - continue; - } - if app.dialog == Some(crate::Dialog::Info) { - crate::panels::forms::close_info(app); - continue; - } - // Escape in Settings is Cancel: restore the settings from when it opened. - if app.dialog == Some(crate::Dialog::Settings) - && let Some(p) = app.dialog_state.prefs_snapshot.take() - { - app.session.prefs = p; - app.session.prefs_changed(); - } - app.dialog = None; + cancel_dialog(app, ctx); continue; } if app.dialog.is_some() { diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index d12ee62a1..f6a5b5321 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -27,6 +27,7 @@ pub mod key_dialogs; pub mod layer_panel; pub mod layer_styles_dialog; pub mod learn; +pub mod live_preview; pub mod markers_ui; pub mod media_panels; pub mod misc; @@ -35,6 +36,7 @@ pub mod paint_panels; pub mod panel_kit; pub mod path_vr_panels; pub mod precomp; +pub(crate) mod preview_merge; pub mod primitive_dialog; pub mod project; pub mod properties; diff --git a/crates/ui-egui/src/panels/comp_settings.rs b/crates/ui-egui/src/panels/comp_settings.rs index 402a7fd6d..01fbc5995 100644 --- a/crates/ui-egui/src/panels/comp_settings.rs +++ b/crates/ui-egui/src/panels/comp_settings.rs @@ -487,6 +487,7 @@ pub fn show( auto: &mut Registry, custom: &[CompPreset], cmds: &mut Vec<(&'static str, serde_json::Value)>, + preview: Option<&mut bool>, ) -> Option { let mut out = None; ui.horizontal(|ui| { @@ -528,6 +529,9 @@ pub fn show( if r.clicked() { out = Some(serde_json::Value::Null); } + if let Some(on) = preview { + super::live_preview::checkbox(ui, auto, on, "dialog.comp.preview"); + } }); }); out diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 8e89c109a..2a70d057d 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -66,6 +66,8 @@ pub struct DialogState { pub delete_items: super::delete_items::Pending, /// The Gradient Editor's draft and the property it edits. pub gradient: super::gradient_editor::Draft, + /// Preview of the settings dialog being edited (Solid, Camera, Light, Composition Settings). + pub live: super::live_preview::LivePreview, /// The user template the Delete Template prompt asks about: its id and name. pub delete_template: Option<(String, String)>, } @@ -76,6 +78,7 @@ pub fn open_new_comp(app: &mut EffectcraftApp) { app.dialog_state.comp = CompDraft { name: format!("Comp {n}"), tab: super::comp_settings::Tab::Basic, start_tc: None, dur_tc: None, preset_name: None, ..last }; app.dialog_state.editing_existing = false; + app.dialog_state.live = Default::default(); app.dialog = Some(Dialog::NewComp); } @@ -109,6 +112,7 @@ pub fn open_comp_settings_for(app: &mut EffectcraftApp, cid: ItemId) -> Result<( app.dialog_state.editing_existing = true; app.dialog_state.editing_comp = Some(cid); app.dialog = Some(Dialog::CompSettings); + super::live_preview::begin(app, Dialog::CompSettings); Ok(()) } @@ -123,6 +127,7 @@ pub fn open_new_solid(app: &mut EffectcraftApp) -> Result<(), String> { if d.solid_color == [0.0; 3] { d.solid_color = [0.2, 0.45, 0.85]; } + d.live = Default::default(); app.dialog = Some(Dialog::SolidSettings); Ok(()) } @@ -138,6 +143,7 @@ fn open_solid_settings(app: &mut EffectcraftApp, layer: u64, item: ItemId) -> Re (d.solid_name, d.solid_color, d.solid_size, d.solid_pixel_aspect) = (name, so.color, [so.width, so.height], so.pixel_aspect); (d.solid_layer, d.solid_shared, d.solid_affect_all) = (Some(layer), users > 1, false); app.dialog = Some(Dialog::SolidSettings); + super::live_preview::begin(app, Dialog::SolidSettings); Ok(()) } @@ -190,6 +196,31 @@ pub(crate) fn modal_vertical(ctx: &egui::Context, title: &str, size: egui::Vec2, modal_with_scroll(ctx, title, size, t, true, body); } +/// Where a modal dialog opens and whether it dims the window behind it. The default is the middle +/// of the window over a dimmed backdrop. +#[derive(Clone, Copy, Debug)] +pub struct Placement { + /// The opening position as a fraction of the room the dialog can move in, from the middle + /// `(0, 0)` to the bottom right `(1, 1)`. + pub start: egui::Vec2, + /// Dim the window behind the dialog. + pub dim: bool, +} + +impl Default for Placement { + fn default() -> Self { + Placement { start: vec2(0.0, 0.0), dim: true } + } +} + +/// [`modal`] at `place`: a dialog that edits what is behind it keeps the canvas in view. +pub(crate) fn modal_placed(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, place: Placement, body: impl FnOnce(&mut egui::Ui)) { + let max_height = (modal_space(ctx).y - 84.0).max(40.0); + modal_fit_placed(ctx, title, size, t, place, |ui, _| { + egui::ScrollArea::both().max_height(max_height).auto_shrink([false, true]).show(ui, body); + }); +} + fn modal_with_scroll(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, vertical_only: bool, body: impl FnOnce(&mut egui::Ui)) { let max_height = (modal_space(ctx).y - 84.0).max(40.0); modal_fit(ctx, title, size, t, |ui, _| { @@ -202,6 +233,11 @@ fn modal_with_scroll(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tok /// only its content: `body` gets the height left under the title, with `size` kept within the /// window. pub(crate) fn modal_fit(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, body: impl FnOnce(&mut egui::Ui, f32)) { + modal_fit_placed(ctx, title, size, t, Placement::default(), body); +} + +/// [`modal_fit`] at `place`. +pub(crate) fn modal_fit_placed(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, place: Placement, body: impl FnOnce(&mut egui::Ui, f32)) { let pass = ctx.cumulative_pass_nr_for(egui::ViewportId::ROOT); ctx.data_mut(|d| d.insert_temp(modal_pass_id(), pass)); let size = size.min(modal_space(ctx)); @@ -210,10 +246,10 @@ pub(crate) fn modal_fit(ctx: &egui::Context, title: &str, size: egui::Vec2, t: & // window, so dragging back never has to undo a part the dialog could not follow. let offset_id = id.with("offset"); let room = ((ctx.content_rect().size() - size) * 0.5).max(vec2(0.0, 0.0)); - let offset = ctx.data(|d| d.get_temp::(offset_id)).unwrap_or_default().clamp(-room, room); + let offset = ctx.data(|d| d.get_temp::(offset_id)).unwrap_or(room * place.start).clamp(-room, room); egui::Modal::new(id) .area(egui::Modal::default_area(id).anchor(Align2::CENTER_CENTER, offset)) - .backdrop_color(Color32::from_black_alpha(120)) + .backdrop_color(Color32::from_black_alpha(if place.dim { 120 } else { 0 })) .frame(egui::Frame::window(&ctx.style_of(ctx.theme())).fill(t.panel_bg).inner_margin(egui::Margin::same(18))) .show(ctx, |ui| { ui.set_width(size.x - 36.0); @@ -405,9 +441,13 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis let mut cmds = Vec::new(); // The title keys the modal id and its drag offset; `modal_fit` translates the heading itself, // so passing the source keeps the dialog in place when the interface language changes. - modal(ctx, "Composition Settings", vec2(600.0, 470.0), t, |ui| { - result = super::comp_settings::show(ui, &mut d, t, &mut app.auto, &custom, &mut cmds); + let live = existing && super::live_preview::active(app); + let place = super::live_preview::place(app); + let mut preview = app.dialog_state.live.on; + modal_placed(ctx, "Composition Settings", vec2(600.0, 470.0), t, place, |ui| { + result = super::comp_settings::show(ui, &mut d, t, &mut app.auto, &custom, &mut cmds, live.then_some(&mut preview)); }); + app.dialog_state.live.on = preview; if d.preset_name.is_some() { // Save Preset asks for the name in its own window over the dialog, as After Effects does. result = None; @@ -425,11 +465,25 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis } app.dialog_state.comp = d; match result { - Some(serde_json::Value::Null) => app.dialog = None, + Some(serde_json::Value::Null) => { + super::live_preview::cancel(app); + app.dialog = None; + } Some(mut params) => { if existing && let (Some(cid), Some(m)) = (app.dialog_state.editing_comp, params.as_object_mut()) { m.insert("comp".into(), json!(cid.0)); } + // A preview already made the edit (one undo step): OK keeps it. + if existing && super::live_preview::keep(app, "comp.settings", params.clone()) { + // The Resolution is not part of the command, so the preview never applied it. + if let Some(cid) = app.dialog_state.editing_comp + && let Err(e) = set_resolution(app, cid.0, app.dialog_state.comp.resolution) + { + app.ui.status = e.to_string(); + } + app.dialog = None; + return; + } 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 { @@ -444,7 +498,15 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis } app.dialog = None; } - None => {} + None => { + if existing && let Some(cid) = app.dialog_state.editing_comp { + let mut params = super::comp_settings::params(&app.dialog_state.comp); + if let Some(m) = params.as_object_mut() { + m.insert("comp".into(), json!(cid.0)); + } + super::live_preview::sync(app, "comp.settings", params); + } + } } } @@ -501,8 +563,11 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let comp_size = app.session.active_comp().map(|c| (c.width, c.height)).unwrap_or((1920, 1080)); let lock_id = egui::Id::unique("solid-lock-aspect"); let mut lock = ctx.data(|m| m.get_temp::(lock_id)).unwrap_or(true); + let live = super::live_preview::active(app); + let place = super::live_preview::place(app); + let mut preview = app.dialog_state.live.on; let auto = &mut app.auto; - modal(ctx, "Solid Settings", vec2(480.0, if editing.is_some() { 430.0 } else { 390.0 }), t, |ui| { + modal_placed(ctx, "Solid Settings", vec2(480.0, if editing.is_some() { 430.0 } else { 390.0 }), t, place, |ui| { ui.horizontal(|ui| { ui.label(tr("Name:")); let r = ui.add(egui::TextEdit::singleline(&mut name).desired_width(320.0)); @@ -588,16 +653,28 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let r = ui.button(tr("Cancel")); auto.add("dialog.solid.cancel", r.rect, "Cancel"); close = r.clicked(); + if live { + super::live_preview::checkbox(ui, auto, &mut preview, "dialog.solid.preview"); + } }); }); ctx.data_mut(|m| m.insert_temp(lock_id, lock)); let ds = &mut app.dialog_state; + ds.live.on = preview; (ds.solid_name, ds.solid_color, ds.solid_size, ds.solid_pixel_aspect, ds.solid_affect_all) = (name, color, size, par, affect_all); + let (command, params) = (solid_command(ds), solid_params(ds)); if ok || ui_enter(ctx) { - if let Err(e) = app.session.execute(solid_command(ds), solid_params(ds)) { + // A preview already made the edit (one undo step): OK keeps it. + if !super::live_preview::keep(app, command, params.clone()) + && let Err(e) = app.session.execute(command, params) + { app.ui.status = e.to_string(); } close = true; + } else if close { + super::live_preview::cancel(app); + } else { + super::live_preview::sync(app, command, params); } if close { app.dialog = None; diff --git a/crates/ui-egui/src/panels/dialogs_3d.rs b/crates/ui-egui/src/panels/dialogs_3d.rs index 3811f8766..9b3535288 100644 --- a/crates/ui-egui/src/panels/dialogs_3d.rs +++ b/crates/ui-egui/src/panels/dialogs_3d.rs @@ -99,8 +99,14 @@ pub fn open_camera(app: &mut EffectcraftApp, layer: Option) -> Result<(), S } } }; + let editing = d.layer.is_some(); app.dialog_state.camera = d; app.dialog = Some(Dialog::CameraSettings); + if editing { + super::live_preview::begin(app, Dialog::CameraSettings); + } else { + app.dialog_state.live = Default::default(); + } Ok(()) } @@ -149,13 +155,19 @@ pub fn open_light(app: &mut EffectcraftApp, layer: Option) -> Result<(), St } } }; + let editing = d.layer.is_some(); app.dialog_state.light = d; app.dialog = Some(Dialog::LightSettings); + if editing { + super::live_preview::begin(app, Dialog::LightSettings); + } else { + app.dialog_state.live = Default::default(); + } Ok(()) } -fn buttons(ui: &mut egui::Ui, t: &Tokens, ok: &mut bool, close: &mut bool) -> (egui::Rect, egui::Rect) { - let mut r = (egui::Rect::NOTHING, egui::Rect::NOTHING); +fn buttons(ui: &mut egui::Ui, t: &Tokens, ok: &mut bool, close: &mut bool, preview: Option<&mut bool>) -> (egui::Rect, egui::Rect, egui::Rect) { + let mut r = (egui::Rect::NOTHING, egui::Rect::NOTHING, egui::Rect::NOTHING); 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)); r.0 = b.rect; @@ -167,6 +179,9 @@ fn buttons(ui: &mut egui::Ui, t: &Tokens, ok: &mut bool, close: &mut bool) -> (e if c.clicked() { *close = true; } + if let Some(on) = preview { + r.2 = ui.checkbox(on, tr("Preview")).rect; + } }); r } @@ -175,8 +190,11 @@ pub fn camera(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let w = comp_w(app); let mut d = app.dialog_state.camera.clone(); let (mut ok, mut close) = (false, false); - let mut rects = (egui::Rect::NOTHING, egui::Rect::NOTHING); - super::dialogs::modal(ctx, "Camera Settings", vec2(520.0, 420.0), t, |ui| { + let mut rects = (egui::Rect::NOTHING, egui::Rect::NOTHING, egui::Rect::NOTHING); + let live = super::live_preview::active(app); + let place = super::live_preview::place(app); + let mut preview = app.dialog_state.live.on; + super::dialogs::modal_placed(ctx, "Camera Settings", vec2(520.0, 420.0), t, place, |ui| { egui::Grid::new("cam-grid").num_columns(2).spacing([14.0, 9.0]).show(ui, |ui| { ui.label(tr("Type")); craft_ui::ComboBox::from_id_salt("cam-type").selected_text(if d.two_node { "Two-Node Camera" } else { "One-Node Camera" }).show_ui(ui, |ui| { @@ -229,46 +247,66 @@ pub fn camera(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.end_row(); }); ui.add_space(14.0); - rects = buttons(ui, t, &mut ok, &mut close); + rects = buttons(ui, t, &mut ok, &mut close, live.then_some(&mut preview)); }); + app.dialog_state.live.on = preview; if d.lock_to_zoom { d.focus = d.zoom; } app.auto.add("dialog.camera.ok", rects.0, "OK"); app.auto.add("dialog.camera.cancel", rects.1, "Cancel"); + if live { + app.auto.add("dialog.camera.preview", rects.2, "Preview"); + } app.dialog_state.camera = d.clone(); + let (id, p) = camera_command(&d); if ok || super::dialogs::ui_enter(ctx) { - let mut p = json!({ - "name": d.name, - "type": if d.two_node { "twoNode" } else { "oneNode" }, - "zoom": d.zoom, - "dof": d.dof, - "focusDistance": d.focus, - "aperture": d.aperture, - "blurLevel": d.blur, - }); - let id = match d.layer { - Some(l) => { - p["layer"] = json!(l); - "layer.cameraSettings" - } - None => "layer.newCamera", - }; - if let Err(e) = app.session.execute(id, p) { + // A preview already made the edit (one undo step): OK keeps it. + if !super::live_preview::keep(app, id, p.clone()) + && let Err(e) = app.session.execute(id, p) + { app.ui.status = e.to_string(); } close = true; + } else if close { + super::live_preview::cancel(app); + } else { + super::live_preview::sync(app, id, p); } if close { app.dialog = None; } } +/// The command Camera Settings' OK runs, and its parameters. +fn camera_command(d: &CameraDraft) -> (&'static str, Value) { + let mut p = json!({ + "name": d.name, + "type": if d.two_node { "twoNode" } else { "oneNode" }, + "zoom": d.zoom, + "dof": d.dof, + "focusDistance": d.focus, + "aperture": d.aperture, + "blurLevel": d.blur, + }); + let id = match d.layer { + Some(l) => { + p["layer"] = json!(l); + "layer.cameraSettings" + } + None => "layer.newCamera", + }; + (id, p) +} + pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let mut d = app.dialog_state.light.clone(); let (mut ok, mut close) = (false, false); - let mut rects = (egui::Rect::NOTHING, egui::Rect::NOTHING); - super::dialogs::modal(ctx, "Light Settings", vec2(480.0, 470.0), t, |ui| { + let mut rects = (egui::Rect::NOTHING, egui::Rect::NOTHING, egui::Rect::NOTHING); + let live = super::live_preview::active(app); + let place = super::live_preview::place(app); + let mut preview = app.dialog_state.live.on; + super::dialogs::modal_placed(ctx, "Light Settings", vec2(480.0, 470.0), t, place, |ui| { egui::Grid::new("light-grid").num_columns(2).spacing([14.0, 9.0]).show(ui, |ui| { ui.label(tr("Name")); let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(220.0)); @@ -332,50 +370,67 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { 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); + rects = buttons(ui, t, &mut ok, &mut close, live.then_some(&mut preview)); }); + app.dialog_state.live.on = preview; app.auto.add("dialog.light.ok", rects.0, "OK"); app.auto.add("dialog.light.cancel", rects.1, "Cancel"); + if live { + app.auto.add("dialog.light.preview", rects.2, "Preview"); + } app.dialog_state.light = d.clone(); + let (id, p) = light_command(&d); if ok || super::dialogs::ui_enter(ctx) { - let kind = d.kind; - let mut p = json!({ - "name": d.name, - "kind": kind.label(), - "color": [d.color[0], d.color[1], d.color[2]], - "intensity": d.intensity, - }); - if kind == LightKind::Spot { - p["coneAngle"] = json!(d.cone_angle); - p["coneFeather"] = json!(d.cone_feather); - } - if falls_off(kind) { - p["falloff"] = json!(d.falloff); - p["radius"] = json!(d.radius); - p["falloffDistance"] = json!(d.falloff_distance); - } - if casts_shadows(kind) { - p["castsShadows"] = json!(d.casts_shadows); - p["shadowDarkness"] = json!(d.shadow_darkness); - p["shadowDiffusion"] = json!(d.shadow_diffusion); - } - let id = match d.layer { - Some(l) => { - p["layer"] = json!(l); - "layer.lightSettings" - } - None => "layer.newLight", - }; - if let Err(e) = app.session.execute(id, p) { + // A preview already made the edit (one undo step): OK keeps it. + if !super::live_preview::keep(app, id, p.clone()) + && let Err(e) = app.session.execute(id, p) + { app.ui.status = e.to_string(); } close = true; + } else if close { + super::live_preview::cancel(app); + } else { + super::live_preview::sync(app, id, p); } if close { app.dialog = None; } } +/// The command Light Settings' OK runs, and its parameters. +fn light_command(d: &LightDraft) -> (&'static str, Value) { + let kind = d.kind; + let mut p = json!({ + "name": d.name, + "kind": kind.label(), + "color": [d.color[0], d.color[1], d.color[2]], + "intensity": d.intensity, + }); + if kind == LightKind::Spot { + p["coneAngle"] = json!(d.cone_angle); + p["coneFeather"] = json!(d.cone_feather); + } + if falls_off(kind) { + p["falloff"] = json!(d.falloff); + p["radius"] = json!(d.radius); + p["falloffDistance"] = json!(d.falloff_distance); + } + if casts_shadows(kind) { + p["castsShadows"] = json!(d.casts_shadows); + p["shadowDarkness"] = json!(d.shadow_darkness); + p["shadowDiffusion"] = json!(d.shadow_diffusion); + } + let id = match d.layer { + Some(l) => { + p["layer"] = json!(l); + "layer.lightSettings" + } + None => "layer.newLight", + }; + (id, p) +} + /// Menu/shortcut routing: commands that open a 3D settings dialog when invoked without params. /// Returns true when a dialog was opened. pub fn route(app: &mut EffectcraftApp, id: &str, params: &Value) -> Result { diff --git a/crates/ui-egui/src/panels/gradient_editor.rs b/crates/ui-egui/src/panels/gradient_editor.rs index e132a42d1..25e3d1d44 100644 --- a/crates/ui-egui/src/panels/gradient_editor.rs +++ b/crates/ui-egui/src/panels/gradient_editor.rs @@ -3,14 +3,18 @@ //! Opacity stops sit above the preview bar and colour stops below it. Click above or below the //! bar to add a stop (it takes that track's value there), drag a stop to move it, edit the //! selected stop's location, opacity or colour, and Delete removes it (never the last stop of a -//! track). The editor works on a draft: OK sets the property once with `prop.set` at the time it -//! opened (one undo step, nothing at all when nothing changed); Cancel and Escape leave it as it -//! was. +//! track). The editor works on a draft. With **Preview** on (the default, as in After Effects) each +//! edit is applied to the property at once, with `prop.set` at the time the editor opened and one +//! merged undo step, so the canvas shows the gradient as it is edited; OK keeps it, Cancel and +//! Escape roll it back to the checkpoint taken when the editor opened. With Preview off, OK sets +//! the property once (one undo step, nothing at all when nothing changed). The dialog opens at +//! the lower right of the window and does not dim it, so the canvas stays in view. //! //! Automation ids: `dialog.gradient.bar`, `dialog.gradient.addOpacity`, `dialog.gradient.addColor`, //! `dialog.gradient.opacityStop.`, `dialog.gradient.colorStop.` (in order along the bar), //! `dialog.gradient.location`, `dialog.gradient.opacity`, `dialog.gradient.color`, -//! `dialog.gradient.delete`, `dialog.gradient.ok`, `dialog.gradient.cancel`. +//! `dialog.gradient.delete`, `dialog.gradient.preview`, `dialog.gradient.ok`, +//! `dialog.gradient.cancel`. use effectcraft_engine::keyframe::{Gradient, Value}; use effectcraft_engine::project::{ItemId, LayerId}; @@ -18,6 +22,8 @@ use egui::{Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use serde_json::{Value as Json, json}; use crate::i18n::tr; +use crate::panels::layer_styles_dialog::{Checkpoint, checkpoint}; +use crate::panels::preview_merge::{self, Applied}; use crate::theme::Tokens; use crate::{Dialog, EffectcraftApp}; @@ -58,6 +64,14 @@ pub struct Draft { pub baseline: Gradient, /// Stop counts (colour, opacity) of the keyframes just before and after the edit time. pub neighbors: Vec<(usize, usize)>, + /// Preview: edits are applied to the property as they are made. + pub preview: bool, + /// The gradient the property was last given by the preview, if it has been. + pub applied: Option, + /// The project as the preview left it, to tell edits made since from the preview's. + applied_at: Option, + /// The project and undo history as the editor opened, for Cancel. + checkpoint: Option, next_id: u64, } @@ -220,7 +234,10 @@ pub fn open(app: &mut EffectcraftApp, p: &Json) -> Result { let before = prop.keys.iter().rev().find(|k| k.time < lt).and_then(|k| counts(&k.value)); let after = prop.keys.iter().find(|k| k.time > lt).and_then(|k| counts(&k.value)); let target = Target { comp: cid.0, layer: lid, prop: uid, time: ct.seconds() }; - app.dialog_state.gradient = Draft::new(&g, Some(target), before.into_iter().chain(after).collect()); + let mut draft = Draft::new(&g, Some(target), before.into_iter().chain(after).collect()); + draft.preview = true; + draft.checkpoint = Some(checkpoint(app)); + app.dialog_state.gradient = draft; app.dialog = Some(Dialog::GradientEditor); Ok(json!({"dialog": "gradientEditor"})) } @@ -231,6 +248,11 @@ fn commit(app: &mut EffectcraftApp, ctx: &egui::Context) { app.dialog = None; let d = std::mem::take(&mut app.dialog_state.gradient); let g = d.gradient(); + // The preview already set the property to this draft, in one merged undo step: OK keeps it. + if d.applied.as_ref() == Some(&g) { + app.session.history.merge_key = None; + return; + } let Some(tg) = d.target else { return }; if g == d.baseline { return; @@ -241,6 +263,55 @@ fn commit(app: &mut EffectcraftApp, ctx: &egui::Context) { } } +/// Close the editor without keeping the preview: the project and undo history go back to how they +/// were when it opened (nothing happens when the preview never changed the property). +pub fn finish(app: &mut EffectcraftApp, ok: bool, ctx: &egui::Context) { + if ok { + // An edit made in the same frame as OK is not in the last preview yet. + sync_preview(app, ctx); + commit(app, ctx); + return; + } + let d = std::mem::take(&mut app.dialog_state.gradient); + if d.applied.is_some() + && let Some(cp) = d.checkpoint + { + preview_merge::rollback(app, cp, d.applied_at.as_ref()); + } + app.session.history.merge_key = None; + app.dialog = None; +} + +/// Keep the property in step with the draft while Preview is on: apply a changed draft with one +/// merged undo step, and roll back to the checkpoint when Preview goes off or the draft is back to +/// how it opened. +fn sync_preview(app: &mut EffectcraftApp, ctx: &egui::Context) { + let d = &app.dialog_state.gradient; + let Some(tg) = d.target.clone() else { return }; + let g = d.gradient(); + let want = d.preview && g != d.baseline; + if want && d.applied.as_ref() != Some(&g) { + let params = json!({ + "comp": tg.comp, "layer": tg.layer, "prop": tg.prop, "time": tg.time, "timeBase": "comp", + "value": Value::Gradient(g.clone()).to_json(), "merge": format!("gradient-editor-{}", tg.prop), + }); + match crate::menus::invoke(app, ctx, "prop.set", params) { + Ok(_) => { + app.dialog_state.gradient.applied = Some(g); + app.dialog_state.gradient.applied_at = Some(preview_merge::applied(app)); + } + Err(e) => app.ui.status = e, + } + } else if !want + && d.applied.is_some() + && let Some(cp) = d.checkpoint.clone() + { + let at = app.dialog_state.gradient.applied_at.take(); + preview_merge::rollback(app, cp, at.as_ref()); + app.dialog_state.gradient.applied = None; + } +} + pub(super) fn to_color32(c: [f32; 4]) -> Color32 { let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; Color32::from_rgba_unmultiplied(b(c[0]), b(c[1]), b(c[2]), b(c[3])) @@ -258,7 +329,8 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { return; } let mut answer: Option = None; - super::dialogs::modal(ctx, tr("Gradient Editor"), vec2(520.0, 270.0), t, |ui| { + // At the lower right, undimmed: the canvas it edits stays in view (After Effects). + super::dialogs::modal_placed(ctx, tr("Gradient Editor"), vec2(520.0, 270.0), t, super::live_preview::PLACE, |ui| { let w = ui.available_width().max(200.0); let (area, _) = ui.allocate_exact_size(vec2(w, 72.0), Sense::hover()); let bar = Rect::from_min_max(pos2(area.min.x + 8.0, area.min.y + 22.0), pos2(area.max.x - 8.0, area.min.y + 48.0)); @@ -416,6 +488,10 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { if r.clicked() { answer = Some(true); } + let mut preview = app.dialog_state.gradient.preview; + let r = ui.checkbox(&mut preview, tr("Preview")); + app.auto.add("dialog.gradient.preview", r.rect, "Preview"); + app.dialog_state.gradient.preview = preview; }); }); // Delete / Backspace remove the selected stop, but not while typing in a field. @@ -427,12 +503,8 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { app.dialog_state.gradient.remove(track, id); } match answer { - Some(true) => commit(app, ctx), - Some(false) => { - app.dialog = None; - app.dialog_state.gradient = Draft::default(); - } - None => {} + Some(ok) => finish(app, ok, ctx), + None => sync_preview(app, ctx), } } diff --git a/crates/ui-egui/src/panels/layer_styles_dialog.rs b/crates/ui-egui/src/panels/layer_styles_dialog.rs index 14507b868..713dfe6d4 100644 --- a/crates/ui-egui/src/panels/layer_styles_dialog.rs +++ b/crates/ui-egui/src/panels/layer_styles_dialog.rs @@ -24,10 +24,11 @@ pub struct LayerStyleState { checkpoint: Option, } +/// The project and undo history as a dialog found them, for a Cancel that rolls its live edits back. #[derive(Clone)] -struct Checkpoint { - project: std::sync::Arc, - history: effectcraft_engine::history::History, +pub(crate) struct Checkpoint { + pub(crate) project: std::sync::Arc, + pub(crate) history: effectcraft_engine::history::History, } impl std::fmt::Debug for Checkpoint { @@ -36,7 +37,7 @@ impl std::fmt::Debug for Checkpoint { } } -fn checkpoint(app: &mut EffectcraftApp) -> Checkpoint { +pub(crate) fn checkpoint(app: &mut EffectcraftApp) -> Checkpoint { app.session.history.merge_key = None; Checkpoint { project: app.session.project.clone(), history: app.session.history.clone() } } @@ -258,25 +259,32 @@ pub fn finish(app: &mut EffectcraftApp, ok: bool) { if let Some(checkpoint) = app.dialog_state.layer_style.checkpoint.take() && !ok { - let changed = !std::sync::Arc::ptr_eq(&app.session.project, &checkpoint.project); - 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; - } + restore(app, checkpoint); } app.session.history.merge_key = None; app.dialog = None; } +/// Put the project and undo history back as `checkpoint` has them (nothing happens when nothing +/// changed since). +pub(crate) fn restore(app: &mut EffectcraftApp, checkpoint: Checkpoint) { + let changed = !std::sync::Arc::ptr_eq(&app.session.project, &checkpoint.project); + 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; +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/panels/live_preview.rs b/crates/ui-egui/src/panels/live_preview.rs new file mode 100644 index 000000000..83d0d2836 --- /dev/null +++ b/crates/ui-egui/src/panels/live_preview.rs @@ -0,0 +1,129 @@ +//! Preview for the settings dialogs that edit something that already exists (Solid Settings on a +//! solid layer, Camera Settings, Light Settings, Composition Settings): After Effects' Preview +//! checkbox. Each change of the draft is applied to the project at once, so the canvas shows it +//! while it is made; OK keeps it and Cancel and Escape take it back. +//! +//! Every step goes back to the checkpoint taken when the dialog opened (the project and its undo +//! history) and then runs the dialog's command once with the draft's parameters. So the project is +//! always "as it opened, plus one command": OK leaves exactly one undo step, whatever was tried +//! meanwhile, and Cancel only has to put the checkpoint back. With Preview off nothing is applied +//! until OK, and switching it off takes back what was applied. +//! +//! While a preview is active the dialog opens at the lower right of the window, without the dark +//! backdrop, and the window behind it is not greyed out, so the canvas stays in view. + +use egui::vec2; +use serde_json::Value; + +use crate::panels::dialogs::Placement; +use crate::panels::layer_styles_dialog::{Checkpoint, checkpoint}; +use crate::panels::preview_merge::{self, Applied}; +use crate::{Dialog, EffectcraftApp}; + +/// Where a previewing dialog opens: the lower right, undimmed. +pub const PLACE: Placement = Placement { start: vec2(0.75, 0.8), dim: false }; + +/// The state of the one dialog being previewed (only one dialog is open at a time). +#[derive(Clone, Debug, Default)] +pub struct LivePreview { + /// The dialog this belongs to; a preview left over from another one is ignored. + dialog: Option, + /// The Preview checkbox. + pub on: bool, + checkpoint: Option, + /// The draft's parameters when the dialog opened: nothing is applied while it equals them. + baseline: Option, + /// The command and parameters the preview last applied. + applied: Option<(&'static str, Value)>, + /// The project as that command left it, to tell edits made since from the preview's. + applied_at: Option, +} + +impl LivePreview { + fn active_for(&self, dialog: Option) -> bool { + self.checkpoint.is_some() && self.dialog.is_some() && self.dialog == dialog + } +} + +/// Start previewing for `dialog`, which has just opened on an existing item (Preview on). +pub fn begin(app: &mut EffectcraftApp, dialog: Dialog) { + app.dialog_state.live = LivePreview { dialog: Some(dialog), on: true, checkpoint: Some(checkpoint(app)), ..Default::default() }; +} + +/// The dialog that is open is previewing. +pub fn active(app: &EffectcraftApp) -> bool { + app.dialog_state.live.active_for(app.dialog) +} + +/// Where the open dialog goes: the lower right, undimmed, while it previews; else the middle. +pub fn place(app: &EffectcraftApp) -> Placement { + if active(app) { PLACE } else { Placement::default() } +} + +/// Keep the project in step with the draft: `command` with `params` is what OK would run. The +/// first call (the draft as it opened) only notes it. +pub fn sync(app: &mut EffectcraftApp, command: &'static str, params: Value) { + if !active(app) { + return; + } + let live = &app.dialog_state.live; + if live.baseline.is_none() { + app.dialog_state.live.baseline = Some(params); + return; + } + let want = live.on && live.baseline.as_ref() != Some(¶ms); + let is_applied = live.applied.is_some(); + if want { + if live.applied.as_ref().is_some_and(|(c, p)| *c == command && *p == params) { + return; + } + if is_applied { + put_back(app); + } + match app.session.execute(command, params.clone()) { + Ok(_) => { + app.dialog_state.live.applied = Some((command, params)); + app.dialog_state.live.applied_at = Some(preview_merge::applied(app)); + } + Err(e) => app.ui.status = e.to_string(), + } + } else if is_applied { + put_back(app); + } +} + +/// The project as the dialog found it. +fn put_back(app: &mut EffectcraftApp) { + if let Some(cp) = app.dialog_state.live.checkpoint.clone() { + let at = app.dialog_state.live.applied_at.take(); + // Only what the preview did is taken back: an edit made while the dialog was open stays. + preview_merge::rollback(app, cp, at.as_ref()); + } + app.dialog_state.live.applied = None; + app.dialog_state.live.applied_at = None; +} + +/// OK: the preview's result is the edit. The preview is first brought up to `command` with +/// `params`, the draft as OK found it (an edit made in the same frame as OK is not in the last +/// preview). Returns whether the project then already has the edit (the caller must not run the +/// command again); otherwise the caller runs it, as without a preview. +pub fn keep(app: &mut EffectcraftApp, command: &'static str, params: Value) -> bool { + sync(app, command, params); + let kept = active(app) && app.dialog_state.live.applied.is_some(); + app.dialog_state.live = LivePreview::default(); + kept +} + +/// Cancel and Escape: take back whatever the preview applied. +pub fn cancel(app: &mut EffectcraftApp) { + if active(app) { + put_back(app); + } + app.dialog_state.live = LivePreview::default(); +} + +/// The Preview checkbox, when the open dialog previews; `id` is its automation id. +pub fn checkbox(ui: &mut egui::Ui, auto: &mut crate::automation::Registry, on: &mut bool, id: &str) { + let r = ui.checkbox(on, crate::i18n::tr("Preview")); + auto.add(id, r.rect, "Preview"); +} diff --git a/crates/ui-egui/src/panels/preview_merge.rs b/crates/ui-egui/src/panels/preview_merge.rs new file mode 100644 index 000000000..4eb99f2f0 --- /dev/null +++ b/crates/ui-egui/src/panels/preview_merge.rs @@ -0,0 +1,204 @@ +//! Taking a dialog's Preview back without taking other edits with it. +//! +//! A previewing dialog (Camera, Light, Solid, Composition Settings, the Gradient Editor) puts the +//! project and undo history back to a checkpoint when it is cancelled or Preview goes off. If the +//! project changed in the meantime for another reason (a camera analysis finishing, a script, the +//! control channel), swapping the checkpoint in would throw that edit away. This module undoes +//! only what the preview did: a three-way merge of the checkpoint (before the preview), the project +//! right after the preview (`Applied`) and the project now. Whatever the preview did not touch +//! stays as it is now; what only the preview touched goes back to the checkpoint; what both touched +//! keeps the other edit. + +use std::collections::BTreeSet; +use std::sync::Arc; + +use effectcraft_engine::commands::text_edit::active_editor_snapshot; +use effectcraft_engine::project::{Comp, Item, ItemId, ItemKind, Layer, LayerId, Project}; + +use crate::EffectcraftApp; +use crate::panels::layer_styles_dialog::{Checkpoint, restore}; + +/// The project right after a preview was applied, and the undo depth then. +#[derive(Clone)] +pub(crate) struct Applied { + project: Arc, + undo_len: usize, +} + +impl std::fmt::Debug for Applied { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Applied").field("undo_len", &self.undo_len).finish_non_exhaustive() + } +} + +/// Note the project as the preview that has just been applied left it. +pub(crate) fn applied(app: &EffectcraftApp) -> Applied { + Applied { project: app.session.project.clone(), undo_len: app.session.history.undo.len() } +} + +/// Put the project back as `checkpoint` has it, minus nothing but the preview: when the project is +/// still what the preview made (or nothing says what that was) the checkpoint is simply restored. +pub(crate) fn rollback(app: &mut EffectcraftApp, checkpoint: Checkpoint, applied: Option<&Applied>) { + let Some(applied) = applied.filter(|a| !Arc::ptr_eq(&a.project, &app.session.project)) else { + restore(app, checkpoint); + return; + }; + let merged = merge(&checkpoint.project, &applied.project, &app.session.project); + // One step for what was done meanwhile, from the project before the dialog: Undo takes it back. + let label = app.session.history.undo.get(applied.undo_len).map(|(label, _)| label.clone()).unwrap_or_else(|| "Edit".to_string()); + let mut history = checkpoint.history; + history.undo.push((label, checkpoint.project)); + history.redo.clear(); + history.merge_key = None; + let edited = active_editor_snapshot(&app.session); + app.session.project = Arc::new(merged); + app.session.history = history; + app.session.sanitize_after_replacement(edited.as_ref()); + app.session.bump(); +} + +/// `now` without the changes between `before` and `after`, keeping any other change. +fn merge(before: &Project, after: &Project, now: &Project) -> Project { + let mut merged = now.clone(); + let ids: BTreeSet = before.items.keys().chain(after.items.keys()).chain(now.items.keys()).copied().collect(); + for id in ids { + match item(before.items.get(&id), after.items.get(&id), now.items.get(&id)) { + Some(it) => { + merged.items.insert(id, it); + } + None => { + merged.items.remove(&id); + } + } + } + merged +} + +/// The three-way choice for one value: untouched by the preview keeps `now`, touched only by the +/// preview goes back to `before`, touched by both is `both`. +fn pick(before: Option<&T>, after: Option<&T>, now: Option<&T>, both: impl FnOnce() -> Option) -> Option { + if after == before { + now.cloned() + } else if now == after { + before.cloned() + } else { + both() + } +} + +fn item(before: Option<&Item>, after: Option<&Item>, now: Option<&Item>) -> Option { + pick(before, after, now, || { + if let (Some(b), Some(a), Some(n)) = (before, after, now) + && let (ItemKind::Comp(bc), ItemKind::Comp(ac), ItemKind::Comp(nc)) = (&b.kind, &a.kind, &n.kind) + { + let mut merged = n.clone(); + // Name, folder and the like: only a preview that changed the item itself and nothing else + // is taken back; the comp's own contents are merged below. + merged.kind = ItemKind::Comp(Arc::new(comp(bc, ac, nc))); + return Some(merged); + } + now.cloned() + }) +} + +fn bare(c: &Comp) -> Comp { + let mut b = c.clone(); + b.layers.clear(); + b +} + +fn layer_of(c: &Comp, id: LayerId) -> Option<&Layer> { + c.layers.iter().find(|l| l.id == id) +} + +fn comp(before: &Comp, after: &Comp, now: &Comp) -> Comp { + // The comp's own settings, apart from its layers. + let mut merged = pick(Some(&bare(before)), Some(&bare(after)), Some(&bare(now)), || Some(bare(now))).unwrap_or_else(|| bare(now)); + let merge_layer = |id: LayerId| pick(layer_of(before, id), layer_of(after, id), layer_of(now, id), || layer_of(now, id).cloned()); + // Layers as they stand now, in their order, then the ones the preview removed put back. + let mut layers: Vec = now.layers.iter().filter_map(|l| merge_layer(l.id)).collect(); + for (at, l) in before.layers.iter().enumerate() { + if layer_of(now, l.id).is_none() + && let Some(back) = merge_layer(l.id) + { + layers.insert(at.min(layers.len()), back); + } + } + merged.layers = layers; + merged +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Dialog; + use crate::panels::live_preview; + use effectcraft_engine::Session; + use serde_json::json; + + fn app_with_light() -> (EffectcraftApp, u64) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Scene", "width": 320, "height": 180, "duration": 1})).unwrap(); + let light = s.execute("layer.newLight", json!({"kind": "Point"})).unwrap()["layer"].as_u64().unwrap(); + (EffectcraftApp::new(s), light) + } + + fn name_of(app: &EffectcraftApp, layer: u64) -> String { + app.session.active_comp().unwrap().layer(LayerId(layer)).unwrap().name.clone() + } + + fn layer_count(app: &EffectcraftApp) -> usize { + app.session.active_comp().unwrap().layers.len() + } + + /// The preview renames the light; a null is added meanwhile (what a finished background job + /// would do). Cancel takes the rename back and keeps the null, with one undo step for it. + #[test] + fn cancelling_a_preview_keeps_an_edit_made_while_it_was_open() { + let (mut app, light) = app_with_light(); + let original = name_of(&app, light); + app.dialog = Some(Dialog::LightSettings); + live_preview::begin(&mut app, Dialog::LightSettings); + let rename = |name: &str| json!({"layer": light, "name": name}); + live_preview::sync(&mut app, "layer.rename", rename(&original)); + live_preview::sync(&mut app, "layer.rename", rename("Previewed")); + assert_eq!(name_of(&app, light), "Previewed"); + let layers = layer_count(&app); + app.session.execute("layer.newNull", json!({})).unwrap(); + live_preview::cancel(&mut app); + assert_eq!(name_of(&app, light), original, "the preview is taken back"); + assert_eq!(layer_count(&app), layers + 1, "the other edit stays"); + assert!(app.session.undo()); + assert_eq!(layer_count(&app), layers, "and has its own undo step"); + assert_eq!(name_of(&app, light), original); + } + + /// With nothing else done meanwhile the checkpoint is restored as before. + #[test] + fn cancelling_an_undisturbed_preview_restores_the_checkpoint() { + let (mut app, light) = app_with_light(); + let original = name_of(&app, light); + let steps = app.session.history.undo.len(); + app.dialog = Some(Dialog::LightSettings); + live_preview::begin(&mut app, Dialog::LightSettings); + live_preview::sync(&mut app, "layer.rename", json!({"layer": light, "name": original})); + live_preview::sync(&mut app, "layer.rename", json!({"layer": light, "name": "Previewed"})); + live_preview::cancel(&mut app); + assert_eq!(name_of(&app, light), original); + assert_eq!(app.session.history.undo.len(), steps); + } + + /// An item both the preview and another edit changed keeps the other edit. + #[test] + fn a_layer_both_edits_changed_keeps_the_other_edit() { + let (mut app, light) = app_with_light(); + let original = name_of(&app, light); + app.dialog = Some(Dialog::LightSettings); + live_preview::begin(&mut app, Dialog::LightSettings); + live_preview::sync(&mut app, "layer.rename", json!({"layer": light, "name": original})); + live_preview::sync(&mut app, "layer.rename", json!({"layer": light, "name": "Previewed"})); + app.session.execute("layer.rename", json!({"layer": light, "name": "Renamed elsewhere"})).unwrap(); + live_preview::cancel(&mut app); + assert_eq!(name_of(&app, light), "Renamed elsewhere"); + } +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index db8172c0a..7d38e3089 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -16,6 +16,7 @@ mod ui_character_eyedropper; mod ui_color_hex; mod ui_comp_presets; mod ui_control_modifiers; +mod ui_dialog_preview; mod ui_dock_resize; mod ui_drag_drop; mod ui_effect_controls; diff --git a/crates/ui-egui/tests/it/ui_dialog_preview.rs b/crates/ui-egui/tests/it/ui_dialog_preview.rs new file mode 100644 index 000000000..ce5f2803e --- /dev/null +++ b/crates/ui-egui/tests/it/ui_dialog_preview.rs @@ -0,0 +1,303 @@ +//! Preview in the settings dialogs that edit something that exists (After Effects' Preview +//! checkbox): Solid Settings on a solid layer, Camera Settings, Light Settings and Composition +//! Settings. A change of the draft reaches the project at once, so the canvas shows it while it is +//! made; OK keeps it in one undo step, Cancel and Escape take it back, and with Preview off nothing +//! is applied until OK. The window behind a previewing dialog is not dimmed, and the dialog opens +//! at the lower right; the same dialogs creating something new keep the middle. + +use effectcraft_engine::Session; +use effectcraft_engine::project::{ItemKind, LayerId, Resolution}; +use effectcraft_ui_egui::{Dialog, EffectcraftApp, menus}; +use egui::{Event, Key, Modifiers, PointerButton, Pos2, pos2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::{Value, json}; + +fn harness(setup: impl FnOnce(&mut Session)) -> Harness<'static, EffectcraftApp> { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 10})).unwrap(); + setup(&mut s); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + h +} + +fn open(h: &mut Harness<'_, EffectcraftApp>, id: &str, params: Value) { + let ctx = h.ctx.clone(); + menus::invoke(h.state_mut(), &ctx, id, params).unwrap(); + h.run_steps(3); +} + +fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 { + let r = h.state().auto.find(id).unwrap_or_else(|| panic!("no automation id {id}")).rect; + pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0) +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let p = center(h, id); + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +fn escape(h: &mut Harness<'_, EffectcraftApp>) { + h.input_mut().events.push(Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::Key { key: Key::Escape, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +fn undo_len(h: &Harness<'_, EffectcraftApp>) -> usize { + h.state().session.history.undo.len() +} + +fn solid_size(h: &Harness<'_, EffectcraftApp>, layer: u64) -> [u32; 2] { + let s = &h.state().session; + let l = s.active_comp().unwrap().layer(LayerId(layer)).unwrap(); + let effectcraft_engine::project::LayerSource::Solid { item } = l.source else { panic!("not a solid") }; + match &s.project.item(item).unwrap().kind { + ItemKind::Solid(so) => [so.width, so.height], + k => panic!("{k:?}"), + } +} + +fn scalar(h: &Harness<'_, EffectcraftApp>, layer: u64, path: &str) -> f64 { + let s = &h.state().session; + let l = s.active_comp().unwrap().layer(LayerId(layer)).unwrap(); + l.props.prop(path).unwrap().value_at(l.layer_time(s.time())).as_f64() +} + +fn comp_width(h: &Harness<'_, EffectcraftApp>) -> u32 { + h.state().session.active_comp().unwrap().width +} + +fn solid_setup(s: &mut Session) { + s.execute("layer.newSolid", json!({"name": "Box", "color": "#ff0000", "width": 100, "height": 100})).unwrap(); +} + +fn solid_layer(h: &Harness<'_, EffectcraftApp>) -> u64 { + h.state().session.active_comp().unwrap().layers[0].id.0 +} + +#[test] +fn solid_settings_previews_live_and_cancel_and_escape_take_it_back() { + let mut h = harness(solid_setup); + let l = solid_layer(&h); + let undo = undo_len(&h); + open(&mut h, "layer.settings", json!({"layer": l})); + assert_eq!(h.state().dialog, Some(Dialog::SolidSettings)); + assert!(h.state().auto.find("dialog.solid.preview").is_some(), "a Preview checkbox"); + h.state_mut().dialog_state_mut().solid_size = [200, 150]; + h.run_steps(3); + assert_eq!(solid_size(&h, l), [200, 150], "the canvas shows the new size before OK"); + h.state_mut().dialog_state_mut().solid_size = [240, 120]; + h.run_steps(3); + assert_eq!(solid_size(&h, l), [240, 120]); + assert_eq!(undo_len(&h), undo + 1, "however much was tried, the project is the original plus one command"); + click(&mut h, "dialog.solid.cancel"); + assert_eq!(h.state().dialog, None); + assert_eq!(solid_size(&h, l), [100, 100], "Cancel puts it back"); + assert_eq!(undo_len(&h), undo, "and leaves no undo step"); + // Escape does the same. + open(&mut h, "layer.settings", json!({"layer": l})); + h.state_mut().dialog_state_mut().solid_size = [200, 150]; + h.run_steps(3); + assert_eq!(solid_size(&h, l), [200, 150]); + escape(&mut h); + assert_eq!(h.state().dialog, None); + assert_eq!(solid_size(&h, l), [100, 100]); + assert_eq!(undo_len(&h), undo); +} + +#[test] +fn solid_settings_ok_keeps_the_preview_as_one_undo_step() { + let mut h = harness(solid_setup); + let l = solid_layer(&h); + let undo = undo_len(&h); + open(&mut h, "layer.settings", json!({"layer": l})); + h.state_mut().dialog_state_mut().solid_size = [200, 150]; + h.run_steps(3); + h.state_mut().dialog_state_mut().solid_size = [220, 130]; + h.run_steps(3); + click(&mut h, "dialog.solid.ok"); + assert_eq!(h.state().dialog, None); + assert_eq!(solid_size(&h, l), [220, 130]); + assert_eq!(undo_len(&h), undo + 1, "one undo step"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(solid_size(&h, l), [100, 100], "undone in one go"); +} + +#[test] +fn with_preview_off_nothing_is_applied_until_ok() { + let mut h = harness(solid_setup); + let l = solid_layer(&h); + let undo = undo_len(&h); + open(&mut h, "layer.settings", json!({"layer": l})); + h.state_mut().dialog_state_mut().solid_size = [200, 150]; + h.run_steps(3); + assert_eq!(solid_size(&h, l), [200, 150]); + click(&mut h, "dialog.solid.preview"); + assert!(!h.state().dialog_state().live.on); + assert_eq!(solid_size(&h, l), [100, 100], "switching Preview off takes the preview back"); + assert_eq!(undo_len(&h), undo); + h.state_mut().dialog_state_mut().solid_size = [260, 140]; + h.run_steps(3); + assert_eq!(solid_size(&h, l), [100, 100], "nothing is applied while editing"); + click(&mut h, "dialog.solid.ok"); + assert_eq!(solid_size(&h, l), [260, 140], "OK applies the draft"); + assert_eq!(undo_len(&h), undo + 1); +} + +#[test] +fn camera_settings_preview() { + let mut h = harness(|s| { + s.execute("layer.newCamera", json!({})).unwrap(); + }); + let l = solid_layer(&h); + let before = scalar(&h, l, "cameraOptions/zoom"); + let undo = undo_len(&h); + open(&mut h, "layer.cameraSettings", json!({"layer": l})); + assert_eq!(h.state().dialog, Some(Dialog::CameraSettings)); + assert!(h.state().auto.find("dialog.camera.preview").is_some()); + h.state_mut().dialog_state_mut().camera.zoom = before + 500.0; + h.run_steps(3); + assert!((scalar(&h, l, "cameraOptions/zoom") - (before + 500.0)).abs() < 1e-6, "the zoom changes live"); + assert_eq!(undo_len(&h), undo + 1); + click(&mut h, "dialog.camera.cancel"); + assert!((scalar(&h, l, "cameraOptions/zoom") - before).abs() < 1e-6, "Cancel puts the zoom back"); + assert_eq!(undo_len(&h), undo); + open(&mut h, "layer.cameraSettings", json!({"layer": l})); + h.state_mut().dialog_state_mut().camera.zoom = before + 300.0; + h.run_steps(3); + click(&mut h, "dialog.camera.ok"); + assert!((scalar(&h, l, "cameraOptions/zoom") - (before + 300.0)).abs() < 1e-6, "OK keeps it"); + assert_eq!(undo_len(&h), undo + 1); +} + +#[test] +fn light_settings_preview() { + let mut h = harness(|s| { + s.execute("layer.newLight", json!({"kind": "Point"})).unwrap(); + }); + let l = solid_layer(&h); + let before = scalar(&h, l, "lightOptions/intensity"); + let undo = undo_len(&h); + open(&mut h, "layer.lightSettings", json!({"layer": l})); + assert_eq!(h.state().dialog, Some(Dialog::LightSettings)); + assert!(h.state().auto.find("dialog.light.preview").is_some()); + h.state_mut().dialog_state_mut().light.intensity = before + 37.0; + h.run_steps(3); + assert!((scalar(&h, l, "lightOptions/intensity") - (before + 37.0)).abs() < 1e-6, "the intensity changes live"); + escape(&mut h); + assert_eq!(h.state().dialog, None); + assert!((scalar(&h, l, "lightOptions/intensity") - before).abs() < 1e-6, "Escape puts it back"); + assert_eq!(undo_len(&h), undo); +} + +#[test] +fn composition_settings_preview() { + let mut h = harness(|_| {}); + let before = comp_width(&h); + let undo = undo_len(&h); + open(&mut h, "comp.settings", json!({})); + assert_eq!(h.state().dialog, Some(Dialog::CompSettings)); + assert!(h.state().auto.find("dialog.comp.preview").is_some()); + h.state_mut().dialog_state_mut().comp.width = before + 80; + h.run_steps(3); + assert_eq!(comp_width(&h), before + 80, "the comp is resized live"); + click(&mut h, "dialog.comp.cancel"); + assert_eq!(comp_width(&h), before, "Cancel puts the size back"); + assert_eq!(undo_len(&h), undo); + open(&mut h, "comp.settings", json!({})); + h.state_mut().dialog_state_mut().comp.width = before + 40; + h.run_steps(3); + click(&mut h, "dialog.comp.ok"); + assert_eq!(comp_width(&h), before + 40, "OK keeps it"); + assert_eq!(undo_len(&h), undo + 1, "in one undo step"); +} + +/// The Resolution is no part of the Composition Settings command, so a preview never applies it: +/// OK has to, also when it keeps a preview of another field. +#[test] +fn composition_settings_ok_after_a_preview_still_applies_the_resolution() { + let mut h = harness(|_| {}); + let before = comp_width(&h); + open(&mut h, "comp.settings", json!({})); + h.state_mut().dialog_state_mut().comp.width = before + 80; + h.run_steps(3); + assert_eq!(comp_width(&h), before + 80, "the preview applied the width"); + h.state_mut().dialog_state_mut().comp.resolution = Resolution::Half; + h.run_steps(3); + click(&mut h, "dialog.comp.ok"); + assert_eq!(comp_width(&h), before + 80); + assert_eq!(h.state().session.active_comp().unwrap().resolution, Resolution::Half, "OK applied the Resolution"); +} + +/// An edit made in the same frame as OK (Enter) is not in the last preview yet: OK brings the +/// project up to the draft it finds instead of keeping the older preview. +#[test] +fn ok_in_the_frame_of_an_edit_keeps_the_edit_not_the_older_preview() { + let mut h = harness(solid_setup); + let l = solid_layer(&h); + open(&mut h, "layer.settings", json!({"layer": l})); + h.state_mut().dialog_state_mut().solid_size = [200, 150]; + h.run_steps(3); + assert_eq!(solid_size(&h, l), [200, 150]); + h.state_mut().dialog_state_mut().solid_size = [240, 120]; + h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.step(); + h.run_steps(2); + assert_eq!(h.state().dialog, None); + assert_eq!(solid_size(&h, l), [240, 120], "OK kept the draft it found, not the earlier preview"); +} + +/// A previewing dialog opens at the lower right so the canvas stays in view; the same dialogs +/// creating something new keep the middle and have no Preview. +#[test] +fn previewing_dialogs_open_at_the_lower_right_and_new_ones_in_the_middle() { + let mut h = harness(solid_setup); + let l = solid_layer(&h); + open(&mut h, "layer.settings", json!({"layer": l})); + let ok = center(&h, "dialog.solid.ok"); + assert!(ok.x > 800.0 && ok.y > 500.0, "Solid Settings on a layer is in the lower right: {ok:?}"); + click(&mut h, "dialog.solid.cancel"); + open(&mut h, "layer.newSolid", json!({})); + assert_eq!(h.state().dialog, Some(Dialog::SolidSettings)); + assert!(h.state().auto.find("dialog.solid.preview").is_none(), "a new solid has no Preview"); + let ok = center(&h, "dialog.solid.ok"); + assert!(ok.x < 1000.0, "New Solid stays in the middle: {ok:?}"); +} + +fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) { + h.input_mut().events.push(Event::PointerMoved(from)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + for k in 1..=6 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (k as f32 / 6.0))); + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +/// A previewing dialog opens at the lower right, but it can be dragged by its title to wherever +/// the canvas needs to be seen, and the preview keeps working from there. +#[test] +fn a_previewing_dialog_can_be_dragged_by_its_title() { + let mut h = harness(solid_setup); + let l = solid_layer(&h); + open(&mut h, "layer.settings", json!({"layer": l})); + let ok = center(&h, "dialog.solid.ok"); + let title = h.get_by_label("Solid Settings").rect().center(); + drag(&mut h, title, title + egui::vec2(-500.0, -300.0)); + let moved = center(&h, "dialog.solid.ok"); + assert!(moved.x < ok.x - 300.0 && moved.y < ok.y - 150.0, "the dialog followed the drag: {ok:?} -> {moved:?}"); + // Still previewing, and the position is kept from frame to frame. + h.state_mut().dialog_state_mut().solid_size = [200, 150]; + h.run_steps(3); + assert_eq!(solid_size(&h, l), [200, 150]); + assert_eq!(center(&h, "dialog.solid.ok"), moved, "it stays where it was put"); +} diff --git a/crates/ui-egui/tests/it/ui_gradient_editor.rs b/crates/ui-egui/tests/it/ui_gradient_editor.rs index fa8c7b73f..f5ee8634b 100644 --- a/crates/ui-egui/tests/it/ui_gradient_editor.rs +++ b/crates/ui-egui/tests/it/ui_gradient_editor.rs @@ -197,7 +197,8 @@ fn stop_edits_ok_sets_the_gradient_in_one_undo_step() { let from = center(&h, "dialog.gradient.opacityStop.1"); let to = along(&h, "dialog.gradient.opacityStop.1", 0.5); drag(&mut h, from, to); - assert_eq!(undo_len(&h), undo, "nothing is set while editing"); + assert_eq!(undo_len(&h), undo + 1, "the live preview is one merged undo step while editing"); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 3, "the property shows the edit before OK"); click(&mut h, "dialog.gradient.ok"); assert_eq!(h.state().dialog, None); let g = gradient(&colors_prop(&h, l, colors).value); @@ -403,11 +404,13 @@ fn location_moves_past_a_neighbour_and_the_fields_follow_the_stop() { assert!(!shown.iter().any(|v| (v - 70.0).abs() < 1.0), "{shown:?}"); } -/// A layer deleted while the editor is open: OK reports it, without a crash. +/// A layer deleted while the editor is open, Preview off: OK reports the failed set, without a +/// crash. #[test] fn a_layer_deleted_while_editing_is_reported() { let (mut h, l, colors) = setup(&[]); open_editor(&mut h, colors); + click(&mut h, "dialog.gradient.preview"); let p = along(&h, "dialog.gradient.addColor", 0.5); click_at(&mut h, p); h.state_mut().session.execute("edit.clear", json!({"layers": [l]})).unwrap(); @@ -417,6 +420,98 @@ fn a_layer_deleted_while_editing_is_reported() { assert!(!h.state().ui.status.is_empty(), "the failed set is reported"); } +/// The same with Preview on: the preview's set fails at the next edit and says so, and OK closes +/// the editor without a crash. +#[test] +fn a_layer_deleted_during_the_preview_is_reported_at_the_next_edit() { + let (mut h, l, colors) = setup(&[]); + open_editor(&mut h, colors); + h.state_mut().session.execute("edit.clear", json!({"layers": [l]})).unwrap(); + h.run_steps(2); + let p = along(&h, "dialog.gradient.addColor", 0.5); + click_at(&mut h, p); + assert!(!h.state().ui.status.is_empty(), "the failed preview set is reported"); + click(&mut h, "dialog.gradient.ok"); + assert_eq!(h.state().dialog, None); +} + +/// Preview (on by default, as in After Effects): edits reach the property as they are made, in one +/// undo step, and Cancel and Escape roll them back to how the editor opened. +#[test] +fn the_preview_applies_edits_live_and_cancel_rolls_them_back() { + let (mut h, l, colors) = setup(&[]); + let before = colors_prop(&h, l, colors).value.clone(); + let undo = undo_len(&h); + open_editor(&mut h, colors); + assert!(h.state().gradient_draft().preview, "Preview starts on"); + let p = along(&h, "dialog.gradient.addColor", 0.25); + click_at(&mut h, p); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 3, "the new stop shows at once"); + let p = along(&h, "dialog.gradient.addColor", 0.75); + click_at(&mut h, p); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 4); + assert_eq!(undo_len(&h), undo + 1, "both edits are one undo step"); + click(&mut h, "dialog.gradient.cancel"); + assert_eq!(h.state().dialog, None); + assert_eq!(colors_prop(&h, l, colors).value, before, "Cancel puts the gradient back"); + assert_eq!(undo_len(&h), undo, "and leaves no undo step"); + // Escape does the same. + open_editor(&mut h, colors); + let p = along(&h, "dialog.gradient.addColor", 0.5); + click_at(&mut h, p); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 3); + key(&mut h, Key::Escape); + assert_eq!(h.state().dialog, None); + assert_eq!(colors_prop(&h, l, colors).value, before); + assert_eq!(undo_len(&h), undo); +} + +/// An edit made in the same frame as OK is not in the last preview: OK sets the draft it finds. +#[test] +fn ok_in_the_frame_of_an_edit_sets_the_draft_not_the_older_preview() { + let (mut h, l, colors) = setup(&[]); + open_editor(&mut h, colors); + let p = along(&h, "dialog.gradient.addColor", 0.25); + click_at(&mut h, p); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 3, "the preview has three stops"); + h.state_mut().gradient_draft_mut().add(Track::Color, 0.75); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::panels::gradient_editor::finish(h.state_mut(), true, &ctx); + assert_eq!(h.state().dialog, None); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 4, "OK sets the draft it found"); +} + +/// With Preview off nothing is set until OK, and switching it off takes back what it applied. +#[test] +fn with_preview_off_nothing_is_set_until_ok() { + let (mut h, l, colors) = setup(&[]); + let before = colors_prop(&h, l, colors).value.clone(); + let undo = undo_len(&h); + open_editor(&mut h, colors); + let p = along(&h, "dialog.gradient.addColor", 0.25); + click_at(&mut h, p); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 3, "previewed"); + click(&mut h, "dialog.gradient.preview"); + assert!(!h.state().gradient_draft().preview); + assert_eq!(colors_prop(&h, l, colors).value, before, "switching Preview off takes the preview back"); + assert_eq!(undo_len(&h), undo); + let p = along(&h, "dialog.gradient.addColor", 0.75); + click_at(&mut h, p); + assert_eq!(colors_prop(&h, l, colors).value, before, "nothing is set while editing"); + click(&mut h, "dialog.gradient.ok"); + assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 4, "OK sets the draft"); + assert_eq!(undo_len(&h), undo + 1, "in one undo step"); +} + +/// The editor opens at the lower right of the window, so the canvas it edits stays in view. +#[test] +fn the_editor_opens_at_the_lower_right() { + let (mut h, _, colors) = setup(&[]); + open_editor(&mut h, colors); + let ok = center(&h, "dialog.gradient.ok"); + assert!(ok.x > 800.0 && ok.y > 500.0, "the editor's OK button is in the lower right of the 1600 × 1000 window: {ok:?}"); +} + /// `prop.editGradient` by id (agents, MCP, the control channel) is an error for a target that /// isn't a gradient property, from the engine and through the UI, and opens nothing. #[test] diff --git a/crates/ui-egui/tests/it/ui_project_rows.rs b/crates/ui-egui/tests/it/ui_project_rows.rs index 27ce5e693..b8638bbd1 100644 --- a/crates/ui-egui/tests/it/ui_project_rows.rs +++ b/crates/ui-egui/tests/it/ui_project_rows.rs @@ -274,12 +274,13 @@ fn comp_context_menu_opens_that_comps_settings() { assert_eq!(h.state().dialog, Some(Dialog::CompSettings)); assert_eq!(h.state().session.active_comp_id(), Some(main), "the viewer keeps its comp"); // A new name, then OK: Side's own size goes back to Side under it. Main's values in the - // dialog, or OK writing to the viewer's comp, would resize or rename the wrong one. + // dialog, or OK writing to the viewer's comp, would resize or rename the wrong one. The + // dialog previews the name as it is typed; OK keeps that as one undo step. + let undo = h.state().session.history.undo.len(); click(&mut h, "dialog.comp.name"); h.input_mut().events.push(Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }); h.input_mut().events.push(Event::Text("Side 2".into())); h.run_steps(2); - let undo = h.state().session.history.undo.len(); h.input_mut().events.push(Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); h.run_steps(2); assert_eq!(h.state().dialog, None, "OK closed the dialog");