feat: 混合模式悬停预览、多选层点击与图层属性预览

This commit is contained in:
Nicek
2026-10-29 09:33:54 +08:00
committed by trae-solo-agent
parent 88477723c5
commit ce93dc6ff5
10 changed files with 163 additions and 17 deletions
+19 -8
View File
@@ -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<()> {
+3
View File
@@ -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<NodeId>,
/// 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<NodeId>,
},
Resize {
start: Point,
+8 -3
View File
@@ -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::<Vec<_>>();
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 => {
+8 -1
View File
@@ -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)?;
+2 -2
View File
@@ -361,8 +361,8 @@ pub fn specs() -> Vec<CommandSpec> {
),
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")?;
@@ -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();
+90
View File
@@ -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<BlendMode> = 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");
}
+4
View File
@@ -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;
+2
View File
@@ -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};
+13 -3
View File
@@ -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<BlendMode> {
/// 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<BlendMode> {
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<BlendMode> {
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)
}