mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 22:06:49 -06:00
feat: 混合模式悬停预览、多选层点击与图层属性预览
This commit is contained in:
@@ -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<()> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user