From ce93dc6ff5319cd88b144ab2d6692a721c51fad4 Mon Sep 17 00:00:00 2001 From: Nicek Date: Wed, 28 Oct 2026 13:36:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=B7=E5=90=88=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=E6=82=AC=E5=81=9C=E9=A2=84=E8=A7=88=E3=80=81=E5=A4=9A=E9=80=89?= =?UTF-8?q?=E5=B1=82=E7=82=B9=E5=87=BB=E4=B8=8E=E5=9B=BE=E5=B1=82=E5=B1=9E?= =?UTF-8?q?=E6=80=A7=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/engine/src/canvas/textpreview.rs | 27 ++++-- crates/engine/src/canvas/tools.rs | 3 + crates/engine/src/canvas/tools/drag.rs | 11 ++- crates/engine/src/canvas/tools/draw.rs | 9 +- crates/engine/src/cmd/text.rs | 4 +- .../engine/src/selection_behaviour_tests.rs | 14 +++ crates/ui-egui/src/blend_preview_tests.rs | 90 +++++++++++++++++++ crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/panels/right.rs | 2 + crates/ui-egui/src/panels/right/menu.rs | 16 +++- 10 files changed, 163 insertions(+), 17 deletions(-) create mode 100644 crates/ui-egui/src/blend_preview_tests.rs diff --git a/crates/engine/src/canvas/textpreview.rs b/crates/engine/src/canvas/textpreview.rs index da101d5ea..357a24241 100644 --- a/crates/engine/src/canvas/textpreview.rs +++ b/crates/engine/src/canvas/textpreview.rs @@ -1,9 +1,9 @@ -//! Live text previews: what a text command would do, shown on the canvas without an undo step — -//! while the pointer is over a font family or style, a text style in the style browser or a -//! font's named instance, or while a variable font axis slider is dragged. The document under the preview is kept and comes -//! back when the preview ends ([`end`], `text.endPreview`). Every other command ends it first, so -//! committing (running the command for real) applies to the document itself as one undo step, and -//! Escape just ends the preview. +//! Live previews: what a command would do, shown on the canvas without an undo step — while the +//! pointer is over a font family or style, a text style in the style browser, a font's named +//! instance or a blend mode, or while a variable font axis slider is dragged. The document under +//! the preview is kept and comes back when the preview ends ([`end`], `text.endPreview`). Every +//! other command ends it first, so committing (running the command for real) applies to the +//! document itself as one undo step, and Escape just ends the preview. use std::sync::Arc; @@ -14,8 +14,19 @@ use crate::{EngineError, Result, Session, bad, find_command, guard, normalize}; /// The commands that show or end a preview (they don't end the one showing). pub const COMMANDS: [&str; 2] = ["text.preview", "text.endPreview"]; -/// What can be previewed: text styles, font styles and axis values. -pub const PREVIEWABLE: [&str; 4] = ["style.apply", "library.applyStyle", "text.setVariations", "text.style"]; +/// What can be previewed: text styles, font styles and axis values, layers' properties and the +/// blend of a paint on a layer, a selection or a style. +pub const PREVIEWABLE: [&str; 9] = [ + "style.apply", + "library.applyStyle", + "text.setVariations", + "text.style", + "node.set", + "fill.set", + "stroke.set", + "selection.setPaint", + "style.update", +]; /// Show `command params` as a live preview (in place of an earlier one). pub fn show(s: &mut Session, command: &str, params: &Value) -> Result<()> { diff --git a/crates/engine/src/canvas/tools.rs b/crates/engine/src/canvas/tools.rs index b1dc3eff3..959dce9c2 100644 --- a/crates/engine/src/canvas/tools.rs +++ b/crates/engine/src/canvas/tools.rs @@ -422,6 +422,9 @@ pub(crate) enum Gesture { /// A Shift-press on this selected layer: it leaves the selection on release, unless the /// press became a drag. deselect: Option, + /// A press on one layer of a multi-selection that keeps the selection to drag it all: a + /// release without a drag selects just this layer. + keep: Option, }, Resize { start: Point, diff --git a/crates/engine/src/canvas/tools/drag.rs b/crates/engine/src/canvas/tools/drag.rs index d1db8f92d..82772290f 100644 --- a/crates/engine/src/canvas/tools/drag.rs +++ b/crates/engine/src/canvas/tools/drag.rs @@ -102,13 +102,15 @@ impl Session { }; match target { Some(target) => { - // Clicking inside the current selection's box keeps it (drag them all). + // Clicking inside the current selection's box keeps it (drag them all); a click + // that doesn't become a drag narrows it to the clicked layer. let in_sel_box = sel.len() > 1 && doc.bounds_of(&sel).is_some_and(|b| b.contains(ev.p())) && !ev.shift; // Shift on a selected layer takes it out on release if the press doesn't become a // drag; a drag moves the selection with it. Where no drag can start, it goes now. let deselect = (ev.shift && sel.contains(&target)).then_some(target); let without_target = || sel.iter().copied().filter(|x| *x != target).collect::>(); - let new_sel = if in_sel_box && (sel.contains(&target) || sel.iter().any(|s| doc.is_ancestor(*s, target))) { + let keep = (in_sel_box && !ev.cmd && (sel.contains(&target) || sel.iter().any(|s| doc.is_ancestor(*s, target)))).then_some(target); + let new_sel = if keep.is_some() { sel.clone() } else if ev.shift && deselect.is_none() { let mut s2 = sel.clone(); @@ -132,6 +134,9 @@ impl Session { if new_sel.is_empty() || doc.node(target).is_some_and(|n| n.locked) || ids.is_empty() { if deselect.is_some() { self.set_selection(without_target()); + } else if keep.is_some() { + // No drag can start here, so the click narrows the selection now. + self.set_selection(vec![target]); } return Ok(()); } @@ -148,7 +153,7 @@ impl Session { } return Err(e); } - self.tool.gesture = Gesture::Move { start: ev.p(), ids, targets, duplicate: ev.alt, moved: false, deselect }; + self.tool.gesture = Gesture::Move { start: ev.p(), ids, targets, duplicate: ev.alt, moved: false, deselect, keep }; self.tool.overlay.cursor = if ev.alt { Cursor::Copy } else { Cursor::Default }; } None => { diff --git a/crates/engine/src/canvas/tools/draw.rs b/crates/engine/src/canvas/tools/draw.rs index 36a12bc29..4e0a69b99 100644 --- a/crates/engine/src/canvas/tools/draw.rs +++ b/crates/engine/src/canvas/tools/draw.rs @@ -111,7 +111,7 @@ impl Session { self.tool.overlay.drop_cell = None; let drop_target = self.tool.overlay.drop_target.take(); match g { - Gesture::Move { ids, moved, duplicate, start, deselect, .. } => { + Gesture::Move { ids, moved, duplicate, start, deselect, keep, .. } => { if moved { // A free move lands in the frame under the pointer (or the page) where it was // dropped; layers in an auto layout frame already sit in their slot. @@ -156,6 +156,13 @@ impl Session { if let Some(d) = deselect { let sel = self.selection().into_iter().filter(|x| *x != d).collect(); self.set_selection(sel); + } else if let Some(k) = keep { + // A click (not a drag) on one layer of a multi-selection selects just it. + let doc = self.doc()?.clone(); + let sel = self.selection(); + if sel.len() > 1 && (sel.contains(&k) || sel.iter().any(|s| doc.is_ancestor(*s, k))) { + self.set_selection(vec![k]); + } } if deselect.is_none() && !(ev.shift || ev.cmd || duplicate) { self.widget_click(start, ev.tol() * 2.0)?; diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index e8433fa5e..5f00fbe7b 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -361,8 +361,8 @@ pub fn specs() -> Vec { ), spec!( "text.preview", - "Preview text change", - "{command: style.apply|library.applyStyle|text.setVariations|text.style, params} shows what the command would do on the canvas without an undo step (a hovered font family or style, text style or named instance, a dragged axis slider), in place of an earlier preview; text.endPreview or any other command ends it, so running the command for real commits it as one undo step", + "Preview a change", + "{command: style.apply|library.applyStyle|text.setVariations|text.style|node.set|fill.set|stroke.set|selection.setPaint|style.update, params} shows what the command would do on the canvas without an undo step (a hovered font family or style, text style, named instance, blend mode, or a dragged axis slider), in place of an earlier preview; text.endPreview or any other command ends it, so running the command for real commits it as one undo step", false, |s, p| { let c = req_string("text.preview", p, "command")?; diff --git a/crates/engine/src/selection_behaviour_tests.rs b/crates/engine/src/selection_behaviour_tests.rs index 02abbfb79..35c43a4cc 100644 --- a/crates/engine/src/selection_behaviour_tests.rs +++ b/crates/engine/src/selection_behaviour_tests.rs @@ -125,6 +125,8 @@ fn clicking_selects_by_the_documented_rules() { ((90.0, 30.0), NONE, vec![k.a], vec![k.b], "with a layer selected, its siblings select at its level"), ((520.0, 20.0), SHIFT, vec![k.c], vec![k.c, k.r], "Shift-click adds to the selection"), ((30.0, 130.0), SHIFT, vec![k.c, k.r], vec![k.r], "Shift-click on a selected layer removes it"), + ((520.0, 20.0), NONE, vec![k.r, k.empty], vec![k.r], "a click on one of several selected layers selects just it"), + ((520.0, 20.0), CMD, vec![k.r, k.empty], vec![k.r, k.empty], "silent: Cmd/Ctrl-click on a selected layer keeps the whole selection (pinned)"), ((520.0, 120.0), NONE, vec![], vec![k.r2], "a locked layer can't be selected on the canvas: the click goes to what is under it"), ((520.0, 220.0), NONE, vec![k.r], vec![], "a hidden layer can't be selected on the canvas (empty canvas deselects)"), ((390.0, 290.0), NONE, vec![k.r], vec![], "the background of a top-level frame with layers is like the canvas: a click deselects"), @@ -300,6 +302,18 @@ fn locked_layers_selected_in_the_layers_panel_stay_put() { assert_eq!(handle_at(s.doc().unwrap(), &[k.a], Point::new(60.0, 60.0), 1.0), None); } +#[test] +fn dragging_one_of_several_selected_layers_moves_them_all() { + let mut s = Session::new(); + let k = scene(&mut s); + let at = |s: &Session, i: NodeId| s.doc().unwrap().world_bounds(i).unwrap().origin(); + ex(&mut s, "select.set", json!({"ids": [k.r, k.empty]})); + drag(&mut s, (520.0, 20.0), (540.0, 20.0), NONE); + assert_eq!(sorted(s.selection()), sorted(vec![k.r, k.empty]), "a drag on one of several selected layers keeps them all selected"); + assert_eq!(at(&s, k.r), Point::new(520.0, 0.0), "the dragged layer moves"); + assert_eq!(at(&s, k.empty), Point::new(620.0, 0.0), "and so do the others"); +} + #[test] fn shift_drag_moves_the_selection() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/blend_preview_tests.rs b/crates/ui-egui/src/blend_preview_tests.rs new file mode 100644 index 000000000..77b1e9a08 --- /dev/null +++ b/crates/ui-egui/src/blend_preview_tests.rs @@ -0,0 +1,90 @@ +//! Hovering a blend mode in the layer's or a paint's menu shows it on the canvas as a live +//! preview: no edit, no undo step, and the layer is back as it was when the pointer leaves. + +use egui::{Event, Pos2, RawInput, Rect, pos2, vec2}; +use figcraft_color::BlendMode; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; + +fn id(v: serde_json::Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn scene() -> (Ui, NodeId) { + let mut s = Session::new(); + let a = id(s.execute("node.create", &json!({"type": "rectangle", "x": 0, "y": 0, "width": 40, "height": 20})).unwrap()); + s.execute("fill.set", &json!({"ids": [a], "index": 0, "paint": {"type": "solid", "color": "#ff0000"}})).unwrap(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + (Ui::new(s), a) +} + +#[test] +fn every_row_of_the_blend_menu_previews_its_own_mode_under_the_pointer() { + let ctx = egui::Context::default(); + let mut seen: Vec = Vec::new(); + let mut time = 0.0; + // Down the menu, a row at a time: each row under the pointer reports its mode. + for y in (0..1000).step_by(2) { + time += 1.0 / 60.0; + let input = RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(600.0, 1000.0))), + time: Some(time), + events: vec![Event::PointerMoved(pos2(40.0, y as f32))], + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| { + let _ = crate::right::blend_menu(ui, true, |b| seen.push(b)); + }); + out.textures_delta.clear(); + } + seen.dedup(); + assert_eq!(seen, BlendMode::ALL.to_vec(), "rows preview their modes top to bottom"); +} + +#[test] +fn a_hovered_layer_blend_shows_on_the_canvas_and_leaves_no_trace() { + let (mut u, a) = scene(); + let ctx = u.ctx.clone(); + assert_eq!(u.app.session.doc().unwrap().node(a).unwrap().blend, BlendMode::Normal); + let steps = u.app.session.undo_labels(100).len(); + crate::right::preview_blend(&mut u.app, &ctx, &[a], BlendMode::Multiply); + assert_eq!(u.app.session.doc().unwrap().node(a).unwrap().blend, BlendMode::Multiply, "the canvas shows the hovered mode"); + assert!(figcraft_engine::textpreview::active(&u.app.session), "as a preview"); + // Nothing asks for it on the next frame: the layer is back as it was. + u.frame(vec![]); + u.frame(vec![]); + assert_eq!(u.app.session.doc().unwrap().node(a).unwrap().blend, BlendMode::Normal, "the preview ended"); + assert!(!figcraft_engine::textpreview::active(&u.app.session)); + assert_eq!(u.app.session.undo_labels(100).len(), steps, "a preview is no undo step"); +} + +#[test] +fn picking_after_a_preview_is_one_step_back_to_the_original() { + let (mut u, a) = scene(); + let steps = u.app.session.undo_labels(100).len(); + crate::right::preview_blend(&mut u.app, &u.ctx.clone(), &[a], BlendMode::Multiply); + // The click lands while the hovered mode shows: the pick applies to the document, not the preview. + u.app.set_props(&[a], json!({"blend": BlendMode::Screen.label()})); + assert_eq!(u.app.session.doc().unwrap().node(a).unwrap().blend, BlendMode::Screen); + assert!(!figcraft_engine::textpreview::active(&u.app.session), "the pick ended the preview"); + assert_eq!(u.app.session.undo_labels(100).len(), steps + 1, "one undo step"); + u.app.session.undo().unwrap(); + assert_eq!(u.app.session.doc().unwrap().node(a).unwrap().blend, BlendMode::Normal, "undo goes back to the original, not the hovered mode"); +} + +#[test] +fn a_hovered_paint_blend_shows_on_its_layer_and_leaves_no_trace() { + let (mut u, a) = scene(); + let target = crate::colorpicker::PickerTarget::Paint { fills: true, index: 0, ids: vec![a] }; + let paint = u.app.session.doc().unwrap().node(a).unwrap().fills[0].clone(); + let steps = u.app.session.undo_labels(100).len(); + crate::colorpicker::preview_blend(&mut u.app, &u.ctx.clone(), &target, &paint, BlendMode::Screen); + assert_eq!(u.app.session.doc().unwrap().node(a).unwrap().fills[0].blend, BlendMode::Screen, "the fill shows the hovered mode"); + u.frame(vec![]); + u.frame(vec![]); + assert_eq!(u.app.session.doc().unwrap().node(a).unwrap().fills[0].blend, BlendMode::Normal, "the preview ended"); + assert_eq!(u.app.session.undo_labels(100).len(), steps, "a preview is no undo step"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4c1ed3370..0cbf1a231 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -38,6 +38,8 @@ mod a11y_tests; #[cfg(test)] mod async_dialog_tests; #[cfg(test)] +mod blend_preview_tests; +#[cfg(test)] mod branch_ui_tests; #[cfg(test)] mod canvas_double_click_tests; @@ -189,6 +191,8 @@ mod shortcuts_tests; #[cfg(test)] mod style_folder_rename_tests; #[cfg(test)] +mod style_props_tests; +#[cfg(test)] mod text_enter_tests; #[cfg(test)] mod titlebar_tests; diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index b0c0e9950..80b0c3e00 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -31,6 +31,8 @@ use inspect::inspect; pub(crate) use inspect::{inspect_section, prop_row}; #[cfg(test)] pub(crate) use menu::menu_row_width; +#[cfg(test)] +pub(crate) use menu::preview_blend; pub use menu::{blend_menu, context_popup, menu_check, menu_item, menu_item_beside_checks, menu_item_with, menu_popup, menu_row}; pub(crate) use menu::{block, menu_style, row, row_rect}; use paints::{appearance, paints, variable_modes}; diff --git a/crates/ui-egui/src/panels/right/menu.rs b/crates/ui-egui/src/panels/right/menu.rs index fb51d8579..8161d20af 100644 --- a/crates/ui-egui/src/panels/right/menu.rs +++ b/crates/ui-egui/src/panels/right/menu.rs @@ -19,11 +19,16 @@ pub(crate) fn menu_style(s: &mut egui::Style) { /// (else its 600 px default area width) and keeps the size measured; rows ([`menu_row`]) take the /// larger of that width and their content, so the menu fits its content. Chain `.width(w)` for a /// menu at least `w` wide. -/// The blend modes as menu items in groups (pass-through only for layers); the one picked. -pub fn blend_menu(ui: &mut Ui, with_pass_through: bool) -> Option { +/// The blend modes as menu items in groups (pass-through only for layers); the one picked. Each +/// row `preview`s its mode on the canvas while hovered. +pub fn blend_menu(ui: &mut Ui, with_pass_through: bool, mut preview: impl FnMut(BlendMode)) -> Option { let mut picked = None; for b in BlendMode::ALL.into_iter().filter(|b| with_pass_through || *b != BlendMode::PassThrough) { - if menu_item(ui, tr(b.label()), "") { + let resp = menu_row(ui, tr(b.label()), "", 0.0, |_, _, _| {}); + if resp.hovered() { + preview(b); + } + if resp.clicked() { picked = Some(b); ui.close(); } @@ -34,6 +39,11 @@ pub fn blend_menu(ui: &mut Ui, with_pass_through: bool) -> Option { picked } +/// Show `blend` on `ids` on the canvas while its row in a blend mode menu is hovered. +pub(crate) fn preview_blend(app: &mut FigApp, ctx: &egui::Context, ids: &[NodeId], blend: BlendMode) { + app.preview_text(ctx, "node.set", json!({"ids": ids, "props": {"blend": blend.label()}})); +} + pub fn menu_popup(anchor: &egui::Response) -> egui::Popup<'static> { egui::Popup::menu(anchor).width(anchor.rect.width().max(MENU_MIN_W)).style(menu_style) }