From 59b144439ef5259f235b854ff49da041d5be5c70 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 13:31:10 +0000 Subject: [PATCH] #1436: the Gradient Editor previews live and leaves the window undimmed The Gradient Editor edited a draft and set the property only on OK, in a centred dialog over a dimmed, greyed-out window, so the canvas could not show the gradient while it was edited. In After Effects the dialog sits at the lower right, the window stays at full brightness, and the canvas updates live (Preview checkbox). - Preview (on by default): each edit is applied with `prop.set` and one merged undo step; OK keeps it, Cancel and Escape roll back to a checkpoint of the project and undo history taken on open (the Layer Styles dialog's mechanism, now shared). Preview off sets the property on OK only, as before; switching it off takes back what it applied. - The editor opens at the lower right (`Placement` for modal dialogs), without the dark backdrop, and the window behind is no longer faded (`Dialog::leaves_window_undimmed`); clicks still don't reach it. - Other dialogs are unchanged. Claude-Session: https://claude.ai/code/session_013fTnVxnXTv2MBW6uj2PPyU --- crates/ui-egui/src/lib.rs | 13 ++- crates/ui-egui/src/menus.rs | 7 ++ crates/ui-egui/src/panels/dialogs.rs | 34 ++++++- crates/ui-egui/src/panels/gradient_editor.rs | 88 +++++++++++++++--- .../ui-egui/src/panels/layer_styles_dialog.rs | 26 ++++-- crates/ui-egui/tests/ui_gradient_editor.rs | 93 ++++++++++++++++++- 6 files changed, 235 insertions(+), 26 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 105a075e7..edf4a96c3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -101,6 +101,14 @@ pub enum Dialog { DeleteTemplate, } +impl Dialog { + /// The dialog edits what is behind it live (Preview), so the window stays at full brightness + /// while it is open: clicks still don't reach it, but it is not greyed out (After Effects). + pub fn leaves_window_undimmed(self) -> bool { + matches!(self, Dialog::GradientEditor) + } +} + /// The desktop's light or dark appearance where egui cannot see it (Wayland compositors that send /// winit no theme). `None` = the host does not know. pub type SystemThemeFn = Box Option>; @@ -1680,8 +1688,11 @@ impl EffectcraftApp { } let t = self.tokens; let full = ui.max_rect(); - if self.dialog.is_some() { + if let Some(d) = self.dialog { ui.disable(); + if d.leaves_window_undimmed() { + 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/menus.rs b/crates/ui-egui/src/menus.rs index 8b5fd1c30..b104f0b9a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1785,6 +1785,9 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only 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 { @@ -1902,6 +1905,10 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only crate::panels::forms::close_info(app); continue; } + if app.dialog == Some(crate::Dialog::GradientEditor) { + crate::panels::gradient_editor::finish(app, false, ctx); + 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() diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index d773fc433..f0e44368e 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -176,6 +176,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(crate) 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, _| { @@ -188,16 +213,21 @@ 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 size = size.min(modal_space(ctx)); let id = egui::Id::new(("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"); 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); diff --git a/crates/ui-egui/src/panels/gradient_editor.rs b/crates/ui-egui/src/panels/gradient_editor.rs index bb6923025..bd20c28cd 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,7 @@ 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, restore}; use crate::theme::Tokens; use crate::{Dialog, EffectcraftApp}; @@ -58,6 +63,12 @@ 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 and undo history as the editor opened, for Cancel. + checkpoint: Option, next_id: u64, } @@ -220,7 +231,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"})) } @@ -230,6 +244,11 @@ pub fn open(app: &mut EffectcraftApp, p: &Json) -> Result { fn commit(app: &mut EffectcraftApp, ctx: &egui::Context) { app.dialog = None; let d = std::mem::take(&mut app.dialog_state.gradient); + // The preview already set the property, in one merged undo step: OK keeps it. + if d.applied.is_some() { + app.session.history.merge_key = None; + return; + } let g = d.gradient(); let Some(tg) = d.target else { return }; if g == d.baseline { @@ -241,6 +260,49 @@ 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 { + commit(app, ctx); + return; + } + let d = std::mem::take(&mut app.dialog_state.gradient); + if d.applied.is_some() + && let Some(cp) = d.checkpoint + { + restore(app, cp); + } + 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), + Err(e) => app.ui.status = e, + } + } else if !want + && d.applied.is_some() + && let Some(cp) = d.checkpoint.clone() + { + restore(app, cp); + app.dialog_state.gradient.applied = None; + } +} + 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])) @@ -252,7 +314,9 @@ 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). + let place = super::dialogs::Placement { start: vec2(0.75, 0.8), dim: false }; + super::dialogs::modal_placed(ctx, tr("Gradient Editor"), vec2(520.0, 270.0), t, 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)); @@ -410,6 +474,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. @@ -421,12 +489,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 c0b03f77e..440784b7e 100644 --- a/crates/ui-egui/src/panels/layer_styles_dialog.rs +++ b/crates/ui-egui/src/panels/layer_styles_dialog.rs @@ -23,8 +23,9 @@ 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 { +pub(crate) struct Checkpoint { project: std::sync::Arc, history: effectcraft_engine::history::History, } @@ -35,7 +36,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() } } @@ -257,14 +258,21 @@ 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); - app.session.project = checkpoint.project; - app.session.history = checkpoint.history; - app.session.sanitize_state(); - if changed { - app.session.bump(); - } + 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); + app.session.project = checkpoint.project; + app.session.history = checkpoint.history; + app.session.sanitize_state(); + if changed { + app.session.bump(); + } + app.session.history.merge_key = None; +} diff --git a/crates/ui-egui/tests/ui_gradient_editor.rs b/crates/ui-egui/tests/ui_gradient_editor.rs index fa8c7b73f..7d8f46281 100644 --- a/crates/ui-egui/tests/ui_gradient_editor.rs +++ b/crates/ui-egui/tests/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,83 @@ 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); +} + +/// 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] @@ -491,3 +571,12 @@ fn the_escape_after_the_picker_closes_cancels_the_editor() { assert_eq!(h.state().dialog, None, "the next pass's Escape cancels the editor"); assert_eq!(h.state().session.project, project, "and changes nothing"); } + +/// The editor shows its edits on the canvas live, so the window behind it is not greyed out while +/// it is open; other dialogs still are. +#[test] +fn only_dialogs_that_preview_leave_the_window_undimmed() { + assert!(Dialog::GradientEditor.leaves_window_undimmed()); + assert!(!Dialog::SolidSettings.leaves_window_undimmed()); + assert!(!Dialog::KeyVelocity.leaves_window_undimmed()); +}