From 375a7b8abb94d09d174f24fcac77b58f66ad369e Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Mon, 9 Nov 2026 20:30:25 +0000 Subject: [PATCH 1/4] M1: a click on a fill, stroke or effect row highlights it (#1494) Fill, stroke and effect rows in the Design panel couldn't be selected. A click on a row's reorder grip (the strip left of its fields) or on its background between its controls now highlights it in the selected-row colour, one row at a time, as the help center's "Copy and paste properties between layers" describes ("Click to highlight the property on the right sidebar"). - The row is a click target under its controls (`row_target`), and the grip of these lists also takes a click (`ListDrag::row_clicked`). Neither is focusable, so Tab still goes from control to control. - The highlight belongs to the selection, file and Design tab it was made on. Escape lets it go before it deselects; so does a press on the canvas or elsewhere in the Design panel, or a frame its row isn't drawn in (an undo took its paint away). It follows its paint or effect when rows are reordered or removed. Tests: property_rows_tests (7). --- crates/ui-egui/src/app.rs | 2 + crates/ui-egui/src/lib.rs | 6 + crates/ui-egui/src/panels/list_drag.rs | 14 +- crates/ui-egui/src/panels/right/effects.rs | 16 +- crates/ui-egui/src/panels/right/menu.rs | 21 ++- crates/ui-egui/src/panels/right/paints.rs | 35 ++-- crates/ui-egui/src/property_rows.rs | 117 +++++++++++++ crates/ui-egui/src/property_rows_tests.rs | 191 +++++++++++++++++++++ crates/ui-egui/src/shortcuts.rs | 5 + 9 files changed, 390 insertions(+), 17 deletions(-) create mode 100644 crates/ui-egui/src/property_rows.rs create mode 100644 crates/ui-egui/src/property_rows_tests.rs diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index f0513520b..293de1dca 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -296,6 +296,8 @@ impl FigApp { self.ui.narrow = narrow; // A narrow window shows the right panel when its button asks for it, a board's too. let right_open = (self.right_panel_shown(hidden) && !narrow && !right_minimized) || mobile == Some(MobilePanel::Right); + // A highlighted fill, stroke or effect row goes with the panel it's in, before it draws. + crate::property_rows::reconcile(self, &ctx, right_open); // A right-to-left UI language mirrors the window: the "left" rail and panel go on the // right, the "right" (Design) panel on the left. Their ids and widths stay as they are. let mirrored = self.ui.mirrored; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 60c68f526..9e6f77f0f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -226,6 +226,9 @@ mod present_tests; mod present_view_tests; #[cfg(test)] mod property_menu_tests; +mod property_rows; +#[cfg(test)] +mod property_rows_tests; #[cfg(test)] mod proto_editor_tests; #[cfg(test)] @@ -589,6 +592,8 @@ pub struct UiState { /// copy changes the engine clipboard without writing the OS clipboard; the generation then /// no longer matches, so the stale mirror is not mistaken for the user's own copy. pub last_copy_generation: u64, + /// The fill, stroke or effect row highlighted in the Design panel. + pub property_row: Option, /// A save shortcut pressed while a panel field had the keyboard: the field lets go (which /// commits what was typed) and the command runs next frame for that document/window. pub after_field: Option, @@ -732,6 +737,7 @@ impl Default for UiState { last_copy: None, last_copy_generation: 0, + property_row: None, after_field: None, held: egui::Modifiers::NONE, next_autosave: 0.0, diff --git a/crates/ui-egui/src/panels/list_drag.rs b/crates/ui-egui/src/panels/list_drag.rs index 08a391aeb..4fa614d4e 100644 --- a/crates/ui-egui/src/panels/list_drag.rs +++ b/crates/ui-egui/src/panels/list_drag.rs @@ -75,8 +75,19 @@ impl ListDrag { /// Item `index` (model order) was laid out with its main row at `header`, covering `extent` /// (the header plus anything open under it). Its grip starts a drag carrying `label`. pub fn row(&mut self, ui: &mut Ui, index: usize, header: Rect, extent: Rect, label: impl FnOnce() -> String) { + self.grip_row(ui, index, header, extent, Sense::drag(), label); + } + + /// [`Self::row`] whose grip also takes a click, the way a click beside the row's fields + /// highlights a fill, stroke or effect row: `true` when it was clicked. The grip senses the + /// click without being focusable, so Tab doesn't stop on it. + pub fn row_clicked(&mut self, ui: &mut Ui, index: usize, header: Rect, extent: Rect, label: impl FnOnce() -> String) -> bool { + self.grip_row(ui, index, header, extent, Sense::DRAG | Sense::CLICK, label) + } + + fn grip_row(&mut self, ui: &mut Ui, index: usize, header: Rect, extent: Rect, sense: Sense, label: impl FnOnce() -> String) -> bool { let grip = Rect::from_min_max(header.min, pos2(header.min.x + PAD, header.max.y)); - let resp = ui.interact(grip, grip_of(self.list, index), Sense::drag()); + let resp = ui.interact(grip, grip_of(self.list, index), sense); // The handle shows while the pointer is over the row (or the row is being dragged). let shown = match self.dragging { Some(d) => d == index, @@ -93,6 +104,7 @@ impl ListDrag { ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); } self.row_by(&resp, index, extent, label); + resp.clicked() } /// Item `index` laid out over `extent`, dragged by the whole row (`resp` senses drags; the diff --git a/crates/ui-egui/src/panels/right/effects.rs b/crates/ui-egui/src/panels/right/effects.rs index b25990db7..642892d18 100644 --- a/crates/ui-egui/src/panels/right/effects.rs +++ b/crates/ui-egui/src/panels/right/effects.rs @@ -1,9 +1,10 @@ -use super::menu::{context_popup, menu_check, menu_item, menu_popup, row, row_context_menu, row_rect}; +use super::menu::{context_popup, menu_check, menu_item, menu_popup, row, row_context_menu, row_rect, row_target}; use super::{ Blur, BlurType, Document, Effect, EffectKind, FieldEdit, FigApp, Glass, GridAlign, GridKind, Icon, Id, LayoutGrid, Lead, ListDrag, MAX_BLUR_RADIUS, NodeId, Noise, NoiseMode, Paint, Sense, Shadow, StyleSlot, Texture, Ui, VariableType, common, icons, json, move_flags, moved_index, remove_flags, removed_index, style_picker, tr, trf, trn, vec2, widgets, }; +use crate::property_rows::PropertyKind; /// Draw the effect list with add/style/type/toggle/remove/reorder, open settings, and keep an /// open picker and settings flags aligned when rows move or are removed. @@ -29,9 +30,12 @@ pub(super) fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node let Some(list) = list else { return }; let mut drag = ListDrag::new(ui, "effects"); let mut removed = None; + let selected_row = crate::theme::Tokens::get(ui.ctx()).selected_row; for (i, e) in list.iter().enumerate().rev() { let mut open = effect_open(ui.ctx(), i); - let header = row_rect(ui, 32.0, |ui| { + // A click on the row's background highlights it (`property_rows`). + let highlight = crate::property_rows::fill(app, PropertyKind::Effect, i, selected_row); + let header = row_target(ui, 32.0, highlight, |ui| { if icons::button(ui, Icon::Effects, 24.0, open, tr("Effect settings")).clicked() { open = !open; ui.data_mut(|d| d.insert_temp(Id::unique((FX_OPEN, i)), open)); @@ -63,6 +67,8 @@ pub(super) fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node removed = Some(i); } }); + crate::property_rows::row(app, &header, PropertyKind::Effect, i, &ids); + let header = header.rect; let can_paste = app.session.clipboard.effect.is_some(); row_context_menu(ui, header, Id::unique(("effect_menu", i))).show(|ui| { if menu_item(ui, tr("Copy effect"), "") { @@ -77,10 +83,13 @@ pub(super) fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node if open { effect_settings(app, ui, &ids, i, e); } - drag.row(ui, i, header, header.with_max_y(ui.cursor().min.y.max(header.max.y)), || tr(e.label()).to_string()); + if drag.row_clicked(ui, i, header, header.with_max_y(ui.cursor().min.y.max(header.max.y)), || tr(e.label()).to_string()) { + crate::property_rows::click(app, PropertyKind::Effect, i, &ids, header); + } } if let Some((from, to)) = drag.finish(ui) { app.run("effect.move", json!({"ids": ids, "from": from, "to": to})); + crate::property_rows::follow(app, PropertyKind::Effect, from, Some(to)); move_flags(ui, FX_OPEN, list.len(), from, to); if let Some(crate::colorpicker::PickerTarget::Effect { index, .. }) = &mut app.ui.picker { *index = moved_index(*index, from, to); @@ -88,6 +97,7 @@ pub(super) fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node } // Open settings and an open colour picker stay with their effects, or go with theirs. if let Some(i) = removed { + crate::property_rows::follow(app, PropertyKind::Effect, i, None); remove_flags(ui, FX_OPEN, list.len(), i); if let Some(crate::colorpicker::PickerTarget::Effect { index, .. }) = &mut app.ui.picker { match removed_index(*index, i) { diff --git a/crates/ui-egui/src/panels/right/menu.rs b/crates/ui-egui/src/panels/right/menu.rs index 9b80ab8d8..40215a8a5 100644 --- a/crates/ui-egui/src/panels/right/menu.rs +++ b/crates/ui-egui/src/panels/right/menu.rs @@ -155,12 +155,31 @@ pub(crate) fn row_rect(ui: &mut Ui, h: f32, add: impl FnOnce(&mut Ui)) -> Rect { pub(crate) fn block(ui: &mut Ui, h: f32, inset_y: f32, add: impl FnOnce(&mut Ui)) -> Rect { let w = ui.available_width(); let (r, _) = ui.allocate_exact_size(vec2(w, h), Sense::hover()); + lay_out(ui, r, inset_y, add); + r +} + +/// A [`row_rect`] that is a pointer target itself: a click on its background (beside and between +/// its controls, which are on top of it) comes back in the response. It senses clicks without +/// being focusable, so Tab and F6 still go from control to control. `fill` is drawn under the +/// controls (a highlighted fill, stroke or effect row). +pub(crate) fn row_target(ui: &mut Ui, h: f32, fill: Option, add: impl FnOnce(&mut Ui)) -> egui::Response { + let w = ui.available_width(); + let (r, resp) = ui.allocate_exact_size(vec2(w, h), Sense::CLICK); + if let Some(fill) = fill { + ui.painter().rect_filled(r, CornerRadius::ZERO, fill); + } + lay_out(ui, r, (h - FIELD_H) / 2.0, add); + resp +} + +/// Add a row's controls in `r`, on the section's grid, inset `inset_y` from its top and bottom. +fn lay_out(ui: &mut Ui, r: Rect, inset_y: f32, add: impl FnOnce(&mut Ui)) { // The section's grid: 16 in from the left, 8 from the right (not 16 on both sides). let inner = Rect::from_min_max(pos2(r.min.x + PAD, r.min.y + inset_y), pos2(r.max.x - crate::widgets::PAD_RIGHT, r.max.y - inset_y)); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::left_to_right(egui::Align::Center))); child.spacing_mut().item_spacing = vec2(crate::widgets::GUTTER, 0.0); add(&mut child); - r } /// Width of the check column of [`menu_icon_item`] (the mark, or nothing, so labels line up). diff --git a/crates/ui-egui/src/panels/right/paints.rs b/crates/ui-egui/src/panels/right/paints.rs index 967ae266b..dfe82cc8b 100644 --- a/crates/ui-egui/src/panels/right/paints.rs +++ b/crates/ui-egui/src/panels/right/paints.rs @@ -1,10 +1,11 @@ -use super::menu::{blend_menu, context_popup, menu_check, menu_item, menu_popup, preview_blend, row, row_context_menu, row_rect}; +use super::menu::{blend_menu, context_popup, menu_check, menu_item, menu_popup, preview_blend, row, row_context_menu, row_target}; use super::stroke::stroke_settings; use super::{ Align2, BlendMode, CornerRadius, Document, FIELD_H, FieldEdit, FigApp, Icon, Id, Lead, ListDrag, MaskType, Node, NodeId, NodeKind, Paint, PaintKind, Rect, Sense, Stroke, StrokeAlign, StyleSlot, Tip, Tokens, Ui, Value, VariableType, common, icons, json, moved_index, pos2, regular, removed_index, style_picker, tr, trf, vec2, widgets, }; +use crate::property_rows::PropertyKind; /// Draw visibility/blend/mask, opacity, uniform or per-corner radius, corner smoothing and /// the blend-mode row. @@ -341,10 +342,13 @@ pub(super) fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeI let mut drag = ListDrag::new(ui, if fills { "fills" } else { "strokes" }); for (i, p) in l.iter().enumerate().rev() { let r = paint_row(app, ui, &ids, i, p, fills, region); - drag.row(ui, i, r, r, || paint_label(p)); + if drag.row_clicked(ui, i, r, r, || paint_label(p)) { + crate::property_rows::click(app, if fills { PropertyKind::Fill } else { PropertyKind::Stroke }, i, &ids, r); + } } if let Some((from, to)) = drag.finish(ui) { app.run(if fills { "fill.move" } else { "stroke.move" }, json!({"ids": ids, "from": from, "to": to})); + crate::property_rows::follow(app, if fills { PropertyKind::Fill } else { PropertyKind::Stroke }, from, Some(to)); // An open colour picker keeps editing the paint it was on. if let Some(crate::colorpicker::PickerTarget::Paint { fills: f, index, .. }) = &mut app.ui.picker && *f == fills @@ -451,12 +455,15 @@ pub(super) fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeI /// Paint `i`'s row (swatch, colour or kind, opacity, visibility, remove); returns its area. /// One paint's row; `region`: a row of the regions selected in vector edit mode (#1875), which -/// leaves out what belongs to the layer (its variable binding, the image crop). +/// leaves out what belongs to the layer (its variable binding, the image crop). A click on its +/// background highlights it ([`crate::property_rows`]). fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint, fills: bool, region: bool) -> Rect { let t = Tokens::get(ui.ctx()); let ids = ids.to_vec(); let cmd = if fills { "fill.set" } else { "stroke.set" }; - let r = row_rect(ui, 32.0, |ui| { + let kind = if fills { PropertyKind::Fill } else { PropertyKind::Stroke }; + let highlight = crate::property_rows::fill(app, kind, i, t.selected_row); + let resp = row_target(ui, 32.0, highlight, |ui| { let w = ui.available_width() - 60.0; let (sresp, e) = widgets::paint_field(ui, Id::unique(("swatch", fills, i)), Id::unique(("pop", fills, i)), p, w, |child, text_r| { let r = child.max_rect(); @@ -537,18 +544,22 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint, if toggle { app.run(cmd, json!({"ids": ids, "index": i, "visible": !p.visible})); } - if remove - && app.run(if fills { "fill.remove" } else { "stroke.remove" }, json!({"ids": ids, "index": i})).is_some() - && let Some(crate::colorpicker::PickerTarget::Paint { fills: f, index, .. }) = &mut app.ui.picker - && *f == fills - { + if remove && app.run(if fills { "fill.remove" } else { "stroke.remove" }, json!({"ids": ids, "index": i})).is_some() { + // A highlighted row stays on its paint, or goes with it. + crate::property_rows::follow(app, kind, i, None); // An open colour picker keeps editing the paint it was on, or closes with it. - match removed_index(*index, i) { - Some(k) => *index = k, - None => app.ui.picker = None, + if let Some(crate::colorpicker::PickerTarget::Paint { fills: f, index, .. }) = &mut app.ui.picker + && *f == fills + { + match removed_index(*index, i) { + Some(k) => *index = k, + None => app.ui.picker = None, + } } } }); + crate::property_rows::row(app, &resp, kind, i, &ids); + let r = resp.rect; let copy = if fills { tr("Copy fill") } else { tr("Copy stroke") }; let paste = if fills { tr("Paste fill") } else { tr("Paste stroke") }; let can_paste = app.session.clipboard.paint.as_ref().is_some_and(|(f, _)| *f == fills); diff --git a/crates/ui-egui/src/property_rows.rs b/crates/ui-egui/src/property_rows.rs new file mode 100644 index 000000000..1e06debfa --- /dev/null +++ b/crates/ui-egui/src/property_rows.rs @@ -0,0 +1,117 @@ +//! Fill, stroke and effect rows of the Design panel that highlight, the first step of the help +//! center's "Copy and paste properties between layers": click to highlight the property in the +//! right sidebar. +//! +//! A click on a row's background (beside and between its controls, or on its reorder grip) +//! highlights it, one row at a time. The highlight belongs to the selection it was made on, and +//! goes with Escape or a press on the canvas or elsewhere in the Design panel. + +use egui::{Color32, Context, Rect, Response}; +use figcraft_doc::NodeId; + +use crate::{FigApp, RightTab}; + +/// Which list of the Design panel a row belongs to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PropertyKind { + Fill, + Stroke, + Effect, +} + +/// The row highlighted in the Design panel. It belongs to the layers selected when it was clicked +/// and goes when they are no longer the selection. +#[derive(Clone, Debug, PartialEq)] +pub struct Highlight { + pub kind: PropertyKind, + /// The paint's or effect's index in its list (model order: the top one is the last). + pub index: usize, + /// The selection the row was highlighted on. + pub ids: Vec, + /// The file it was highlighted in. + pub doc: u64, + /// Where the row was drawn this frame (`None` until it draws): a press elsewhere lets it go, + /// and so does a frame it isn't drawn in. + pub rect: Option, +} + +/// The highlighted row, when it still belongs to the selection in the Design panel. +pub(crate) fn current(app: &FigApp) -> Option<&Highlight> { + let h = app.ui.property_row.as_ref()?; + let ds = app.session.ds().ok()?; + (app.ui.right_tab == RightTab::Design && ds.uid == h.doc && ds.selection == h.ids).then_some(h) +} + +/// Let the highlight go once it no longer belongs to what the Design panel shows (another file or +/// tab, another selection, the panel not `shown`), or when the pointer is pressed on the canvas or +/// elsewhere in the Design panel. A press on its own row keeps it; so does one on a menu, a popup +/// or the toolbar, so a menu command can still act on it. Called each frame before +/// the right panel draws its rows. +pub(crate) fn reconcile(app: &mut FigApp, ctx: &Context, shown: bool) { + if app.ui.property_row.is_none() { + return; + } + let pressed = ctx.input(|i| if i.pointer.any_pressed() { i.pointer.press_origin() } else { None }); + // A menu or popup is a foreground area over whatever is under it. + let lets_go = pressed.filter(|p| ctx.layer_id_at(*p).is_none_or(|l| l.order != egui::Order::Foreground)).is_some_and(|p| { + let on_row = app.ui.property_row.as_ref().and_then(|h| h.rect).is_some_and(|r| r.contains(p)); + let in_panel = app.ui.right_panel_rect.is_some_and(|r| r.contains(p)); + !on_row && (in_panel || app.canvas.rect.contains(p)) + }); + // The row draws itself each frame (`row`): one that wasn't drawn last frame is gone (an undo + // took its paint away, say), and its index may now name another row or none. + let drawn = app.ui.property_row.as_ref().is_some_and(|h| h.rect.is_some()); + if lets_go || !shown || !drawn || current(app).is_none() { + app.ui.property_row = None; + } else if let Some(h) = app.ui.property_row.as_mut() { + h.rect = None; + } +} + +/// Escape with a highlighted row: the highlight goes and the selection stays. `false` when no row +/// is highlighted (Escape does what it does otherwise). +pub(crate) fn escape(app: &mut FigApp) -> bool { + let highlighted = current(app).is_some(); + app.ui.property_row = None; + highlighted +} + +/// Row `index` of the `kind` list, drawn as `resp` (a row target, see [`fill`]) for the selection +/// `ids`: a click on its background highlights it; the highlighted row keeps where it was drawn. +pub(crate) fn row(app: &mut FigApp, resp: &Response, kind: PropertyKind, index: usize, ids: &[NodeId]) { + if resp.clicked() { + click(app, kind, index, ids, resp.rect); + } else if let Some(h) = app.ui.property_row.as_mut() + && h.kind == kind + && h.index == index + { + h.rect = Some(resp.rect); + } +} + +/// A click on row `index` of the `kind` list (drawn at `rect`) for the selection `ids`, on its +/// background or on its reorder grip, the strip left of its fields: it is the highlighted row now. +pub(crate) fn click(app: &mut FigApp, kind: PropertyKind, index: usize, ids: &[NodeId], rect: Rect) { + let Some(doc) = app.session.ds().ok().map(|d| d.uid) else { return }; + app.ui.property_row = Some(Highlight { kind, index, ids: ids.to_vec(), doc, rect: Some(rect) }); +} + +/// The background of row `index` of the `kind` list: the selected-row colour when it is the +/// highlighted one. +pub(crate) fn fill(app: &FigApp, kind: PropertyKind, index: usize, selected: Color32) -> Option { + current(app).is_some_and(|h| h.kind == kind && h.index == index).then_some(selected) +} + +/// The highlight follows its paint or effect when the rows of `kind` are reordered (`from` moved +/// to `to`) or one is removed (`to` is `None`); it goes with a removed one. +pub(crate) fn follow(app: &mut FigApp, kind: PropertyKind, from: usize, to: Option) { + let Some(h) = app.ui.property_row.as_mut().filter(|h| h.kind == kind) else { return }; + let moved = match to { + Some(to) => Some(figcraft_engine::cmd::lists::moved_index(h.index, from, to)), + None => figcraft_engine::cmd::lists::removed_index(h.index, from), + }; + match moved { + Some(i) => h.index = i, + None => app.ui.property_row = None, + } +} diff --git a/crates/ui-egui/src/property_rows_tests.rs b/crates/ui-egui/src/property_rows_tests.rs new file mode 100644 index 000000000..47b61a99c --- /dev/null +++ b/crates/ui-egui/src/property_rows_tests.rs @@ -0,0 +1,191 @@ +//! A fill, stroke or effect row of the Design panel highlights on a click on its background or its +//! grip ("Copy and paste properties between layers": click to highlight the property in the right +//! sidebar). + +use std::cell::RefCell; +use std::rc::Rc; + +use egui::{Event, Modifiers, PointerButton, Pos2, pos2}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::Services; +use crate::layers_drag_tests::Ui; +use crate::live_tests::{flat, text_center}; +use crate::property_rows::{Highlight, PropertyKind}; + +/// The test machine's system clipboard: the text last written, which another app may replace. +struct Clip(Rc>>); + +impl Services for Clip { + fn clipboard_set_text(&mut self, s: &str) { + *self.0.borrow_mut() = Some(s.to_string()); + } + fn clipboard_get_text(&mut self) -> Option { + self.0.borrow().clone() + } +} + +/// A 40 x 40 rectangle filled with `color`; its id. +fn rect(s: &mut Session, x: f64, color: &str) -> NodeId { + let id = NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap()); + s.execute("fill.set", &json!({"ids": [id], "color": color})).unwrap(); + id +} + +/// Rectangles A (red, a 3 pt blue stroke and a layer blur) and B (green, a drop shadow), A +/// selected, in a window whose system clipboard the test reads and writes. +fn setup() -> (Ui, NodeId, NodeId, Rc>>) { + let mut s = Session::new(); + let a = rect(&mut s, 0.0, "#FF0000"); + let b = rect(&mut s, 100.0, "#00FF00"); + s.execute("stroke.add", &json!({"ids": [a], "paint": {"type": "solid", "color": "#0000FF"}})).unwrap(); + s.execute("node.set", &json!({"ids": [a], "props": {"strokeWeight": 3.0}})).unwrap(); + s.execute("effect.add", &json!({"ids": [a], "type": "layerBlur"})).unwrap(); + s.execute("effect.add", &json!({"ids": [b], "type": "dropShadow"})).unwrap(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + let mut u = Ui::new(s); + let clip = Rc::new(RefCell::new(None)); + u.app.services = Box::new(Clip(clip.clone())); + u.frame(vec![]); + (u, a, b, clip) +} + +/// A primary click at `p`, and a few frames after it. +fn click(u: &mut Ui, p: Pos2) { + u.frame(vec![Event::PointerMoved(p)]); + for pressed in [true, false] { + u.frame(vec![Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]); + } + u.frame(vec![]); +} + +/// Click the background of the Design panel row showing `label`: its left margin, where no +/// control is (the reorder grip shows there on hover). +fn click_row(u: &mut Ui, label: &str) { + let shapes = flat(&u.frame_output(vec![]).shapes); + let y = text_center(&shapes, label).y; + let panel = u.app.ui.right_panel_rect.expect("the Design panel is open"); + click(u, pos2(panel.min.x + 6.0, y)); +} + +/// Select `id` only, and draw the panel for it. +fn select(u: &mut Ui, id: NodeId) { + u.app.run("select.set", json!({"ids": [id]})); + u.frame(vec![]); +} + +#[test] +fn a_press_elsewhere_lets_the_highlight_go() { + let (mut u, a, _, _) = setup(); + click_row(&mut u, "FF0000"); + assert!(u.app.ui.property_row.is_some()); + // A press on the row's own background keeps it. + click_row(&mut u, "FF0000"); + assert!(u.app.ui.property_row.is_some()); + // One anywhere else in the panel lets it go, the selection staying as it is. + let shapes = flat(&u.frame_output(vec![]).shapes); + click(&mut u, text_center(&shapes, "Effects")); + assert_eq!(u.app.session.selection(), [a]); + assert!(u.app.ui.property_row.is_none()); +} + +#[test] +fn the_highlight_follows_its_row_when_rows_move_or_go() { + let (mut u, a, _, _) = setup(); + let doc = u.app.session.ds().unwrap().uid; + let at = |u: &Ui| u.app.ui.property_row.as_ref().map(|h| h.index); + u.app.ui.property_row = Some(Highlight { kind: PropertyKind::Fill, index: 1, ids: vec![a], doc, rect: None }); + crate::property_rows::follow(&mut u.app, PropertyKind::Fill, 1, Some(3)); + assert_eq!(at(&u), Some(3), "moved with its paint"); + crate::property_rows::follow(&mut u.app, PropertyKind::Stroke, 3, None); + assert_eq!(at(&u), Some(3), "another list's change leaves it"); + crate::property_rows::follow(&mut u.app, PropertyKind::Fill, 0, None); + assert_eq!(at(&u), Some(2), "a paint under it went"); + crate::property_rows::follow(&mut u.app, PropertyKind::Fill, 2, None); + assert_eq!(at(&u), None, "its paint went"); +} + +/// A key press with no modifiers, as the desktop shell delivers it. +fn key(u: &mut Ui, key: egui::Key) { + u.frame(vec![Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); + u.frame(vec![Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }]); +} + +#[test] +fn escape_lets_the_highlight_go_and_keeps_the_selection() { + let (mut u, a, _, _) = setup(); + click_row(&mut u, "FF0000"); + assert!(u.app.ui.property_row.is_some()); + key(&mut u, egui::Key::Escape); + assert!(u.app.ui.property_row.is_none(), "Escape let the highlight go"); + assert_eq!(u.app.session.selection(), [a], "and only that: A is still selected"); + // The next Escape deselects, as it does with no row highlighted. + key(&mut u, egui::Key::Escape); + assert!(u.app.session.selection().is_empty()); +} + +#[test] +fn a_click_on_the_canvas_lets_the_highlight_go() { + let (mut u, a, _, _) = setup(); + u.app.run("view.zoomFit", json!({})); + u.frame(vec![]); + click_row(&mut u, "FF0000"); + assert!(u.app.ui.property_row.is_some()); + // A click on A itself: the selection stays as it is, the highlight goes. + let on_a = u.app.to_screen(20.0, 20.0); + assert!(u.app.canvas.rect.contains(on_a), "A is in view"); + click(&mut u, on_a); + assert_eq!(u.app.session.selection(), [a]); + assert!(u.app.ui.property_row.is_none()); +} + +#[test] +fn a_highlighted_row_whose_paint_an_undo_took_away_goes() { + let (mut u, a, _, _) = setup(); + u.app.run("fill.add", json!({"ids": [a], "paint": {"type": "solid", "color": "#123456"}})); + u.frame(vec![]); + click_row(&mut u, "123456"); + assert_eq!(u.app.ui.property_row.as_ref().map(|h| h.index), Some(1)); + u.app.run("edit.undo", json!({})); + u.frame(vec![]); + u.frame(vec![]); + assert!(u.app.ui.property_row.is_none(), "its row isn't drawn any more"); + assert_eq!(u.app.session.selection(), [a]); +} + +#[test] +fn a_click_beside_the_rows_controls_highlights_it_too() { + let (mut u, _, _, _) = setup(); + // The row's grip, left of the swatch, highlights it (`click_row`); so does the gap between its + // colour field and its visibility button (the grid's 8 pt gutter, 60 pt from the row's right). + click_row(&mut u, "FF0000"); + let row = u.app.ui.property_row.as_ref().and_then(|h| h.rect).expect("the highlighted row was drawn"); + key(&mut u, egui::Key::Escape); + assert!(u.app.ui.property_row.is_none()); + click(&mut u, pos2(row.max.x - 64.0, row.center().y)); + assert_eq!(u.app.ui.property_row.as_ref().map(|h| (h.kind, h.index)), Some((PropertyKind::Fill, 0))); +} + +#[test] +fn a_click_on_a_rows_grip_highlights_that_row_for_the_selection() { + let (mut u, a, b, _) = setup(); + for (label, kind) in [("FF0000", PropertyKind::Fill), ("0000FF", PropertyKind::Stroke), ("Layer blur", PropertyKind::Effect)] { + click_row(&mut u, label); + assert_eq!( + u.app.ui.property_row.as_ref().map(|h| (h.kind, h.index, h.ids.clone())), + Some((kind, 0, vec![a])), + "{label}: one row highlighted, for A" + ); + } + // The row is drawn in the selected-row colour. + let t = crate::theme::Tokens::get(&u.ctx); + assert_eq!(crate::property_rows::fill(&u.app, PropertyKind::Effect, 0, t.selected_row), Some(t.selected_row)); + assert_eq!(crate::property_rows::fill(&u.app, PropertyKind::Fill, 0, t.selected_row), None); + // It belonged to A: another selection lets it go, and A selected again doesn't bring it back. + select(&mut u, b); + assert!(u.app.ui.property_row.is_none()); + select(&mut u, a); + assert!(u.app.ui.property_row.is_none()); +} diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 26bdb60d2..145d9d35a 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -428,6 +428,11 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option, m: Modifiers, re if key == Key::Escape && !egui::Popup::is_any_open(ctx) && crate::colorpicker::close_if_open(app) { return true; } + // A fill, stroke or effect row highlighted in the Design panel: Escape lets it go, before the + // binding deselects the layers. + if key == Key::Escape && m.is_none() && !egui::Popup::is_any_open(ctx) && crate::property_rows::escape(app) { + return true; + } if key_pick(app, key, m) || key_box(app, key, m) { return true; } From 33ac1d14fc0d8d2baaea66aa33a68cdc12a93551 Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Mon, 9 Nov 2026 20:34:32 +0000 Subject: [PATCH 2/4] M1: Cmd/Ctrl+C and Cmd/Ctrl+V copy and paste the highlighted fill, stroke or effect (#1494) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With a fill, stroke or effect row highlighted, Cmd/Ctrl+C (or Edit ▸ Copy) copies that paint or effect instead of the layers, through the same `fill.copy`, `stroke.copy` and `effect.copy` the row's menu runs. Cmd/Ctrl+V then pastes it onto every selected layer with `fill.paste`, `stroke.paste` or `effect.paste`, one undo step: on top of each layer's list, or in place of the highlighted row when it is of the same kind. - A copy keeps its kind. A stroke goes to the strokes and an effect to the effects whatever row is highlighted, and the last row copied is the one pasted. - A solid colour goes to the system clipboard as its hex code, anything else as a marker. A paste takes the property only while the system clipboard still holds that copy and no layers were copied since, so text or an image from another app, or a later layer copy, pastes as before. The row menus' Copy writes the same, so Cmd/Ctrl+V pastes it. - A programmatic copy never writes the system clipboard, and forgets a property mirror it replaced. A mirror whose engine copy is gone pastes nothing rather than the layers copied before it. - The copy is shared by every window, as the layer copy is. Tests: property_rows_tests (15 more, 22 in all); the replace index and the Escape key were each removed once to check the tests fail. --- crates/ui-egui/src/lib.rs | 5 +- crates/ui-egui/src/menus.rs | 4 + crates/ui-egui/src/menus/clipboard.rs | 7 +- crates/ui-egui/src/panels/right/effects.rs | 4 +- crates/ui-egui/src/panels/right/paints.rs | 4 +- crates/ui-egui/src/property_rows.rs | 144 +++++++++++- crates/ui-egui/src/property_rows_tests.rs | 261 ++++++++++++++++++++- crates/ui-egui/src/windows.rs | 2 + docs/ui-parity.md | 4 +- 9 files changed, 420 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9e6f77f0f..8d74db2c9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -592,8 +592,10 @@ pub struct UiState { /// copy changes the engine clipboard without writing the OS clipboard; the generation then /// no longer matches, so the stale mirror is not mistaken for the user's own copy. pub last_copy_generation: u64, - /// The fill, stroke or effect row highlighted in the Design panel. + /// The fill, stroke or effect row highlighted in the Design panel: Cmd/Ctrl+C copies it. pub property_row: Option, + /// A paint or effect copied from its row: Cmd/Ctrl+V pastes it while it's the last copy. + pub property_copy: Option, /// A save shortcut pressed while a panel field had the keyboard: the field lets go (which /// commits what was typed) and the command runs next frame for that document/window. pub after_field: Option, @@ -738,6 +740,7 @@ impl Default for UiState { last_copy: None, last_copy_generation: 0, property_row: None, + property_copy: None, after_field: None, held: egui::Modifiers::NONE, next_autosave: 0.0, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a5361778c..a5a05c31c 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -193,6 +193,10 @@ pub fn invoke(app: &mut FigApp, id: &str) { app.toast(tr("Properties copied")); } } + // A fill, stroke or effect row highlighted in the Design panel: that paint or effect. + "edit.copy" if crate::property_rows::current(app).is_some() => { + crate::property_rows::copy_highlighted(app); + } "edit.copy" | "edit.cut" => { let ids = app.session.selection(); // For other apps: text layers' text, other layers as SVG; taken before a cut removes them. diff --git a/crates/ui-egui/src/menus/clipboard.rs b/crates/ui-egui/src/menus/clipboard.rs index bae7aaca9..17d3c9109 100644 --- a/crates/ui-egui/src/menus/clipboard.rs +++ b/crates/ui-egui/src/menus/clipboard.rs @@ -16,7 +16,7 @@ pub fn paste(app: &mut FigApp, event_text: Option<&str>, at: Option<(f64, f64)>) if app.control_synthetic { // Synthetic input is user-like: Dev Mode refuses the paste here too, and only the engine's // own copied layers are pasted (the system clipboard is never read). - if !dev_mode_refuses(app, "edit.paste") { + if !(at.is_none() && crate::property_rows::paste(app)) && !dev_mode_refuses(app, "edit.paste") { app.run("edit.paste", at.map_or(json!({}), |(x, y)| json!({"x": x, "y": y}))); } return; @@ -81,6 +81,11 @@ pub fn paste(app: &mut FigApp, event_text: Option<&str>, at: Option<(f64, f64)>) return paste_text(app, &text, (cx, cy), at.is_none()); } } + // Nothing newer from another app: a fill, stroke or effect copied from its row, when that was + // the last copy, else our copied layers. + if at.is_none() && crate::property_rows::paste(app) { + return; + } if !dev_mode_refuses(app, "edit.paste") { app.run("edit.paste", at.map_or(json!({}), |(x, y)| json!({"x": x, "y": y}))); } diff --git a/crates/ui-egui/src/panels/right/effects.rs b/crates/ui-egui/src/panels/right/effects.rs index 642892d18..5590a5219 100644 --- a/crates/ui-egui/src/panels/right/effects.rs +++ b/crates/ui-egui/src/panels/right/effects.rs @@ -33,7 +33,7 @@ pub(super) fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node let selected_row = crate::theme::Tokens::get(ui.ctx()).selected_row; for (i, e) in list.iter().enumerate().rev() { let mut open = effect_open(ui.ctx(), i); - // A click on the row's background highlights it (`property_rows`). + // A click on the row's background highlights it for Cmd/Ctrl+C (`property_rows`). let highlight = crate::property_rows::fill(app, PropertyKind::Effect, i, selected_row); let header = row_target(ui, 32.0, highlight, |ui| { if icons::button(ui, Icon::Effects, 24.0, open, tr("Effect settings")).clicked() { @@ -72,7 +72,7 @@ pub(super) fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node let can_paste = app.session.clipboard.effect.is_some(); row_context_menu(ui, header, Id::unique(("effect_menu", i))).show(|ui| { if menu_item(ui, tr("Copy effect"), "") { - app.run("effect.copy", json!({"ids": ids, "index": i})); + crate::property_rows::copy(app, PropertyKind::Effect, i, &ids); ui.close(); } if can_paste && menu_item(ui, tr("Paste effect"), "") { diff --git a/crates/ui-egui/src/panels/right/paints.rs b/crates/ui-egui/src/panels/right/paints.rs index dfe82cc8b..4e257061b 100644 --- a/crates/ui-egui/src/panels/right/paints.rs +++ b/crates/ui-egui/src/panels/right/paints.rs @@ -456,7 +456,7 @@ pub(super) fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeI /// Paint `i`'s row (swatch, colour or kind, opacity, visibility, remove); returns its area. /// One paint's row; `region`: a row of the regions selected in vector edit mode (#1875), which /// leaves out what belongs to the layer (its variable binding, the image crop). A click on its -/// background highlights it ([`crate::property_rows`]). +/// background highlights it for Cmd/Ctrl+C ([`crate::property_rows`]). fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint, fills: bool, region: bool) -> Rect { let t = Tokens::get(ui.ctx()); let ids = ids.to_vec(); @@ -565,7 +565,7 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint, let can_paste = app.session.clipboard.paint.as_ref().is_some_and(|(f, _)| *f == fills); row_context_menu(ui, r, Id::unique(("paint_menu", fills, i))).show(|ui| { if menu_item(ui, copy, "") { - app.run(if fills { "fill.copy" } else { "stroke.copy" }, json!({"ids": ids, "index": i})); + crate::property_rows::copy(app, kind, i, &ids); ui.close(); } if can_paste && menu_item(ui, paste, "") { diff --git a/crates/ui-egui/src/property_rows.rs b/crates/ui-egui/src/property_rows.rs index 1e06debfa..2267fb347 100644 --- a/crates/ui-egui/src/property_rows.rs +++ b/crates/ui-egui/src/property_rows.rs @@ -1,13 +1,19 @@ -//! Fill, stroke and effect rows of the Design panel that highlight, the first step of the help -//! center's "Copy and paste properties between layers": click to highlight the property in the -//! right sidebar. +//! Fill, stroke and effect rows of the Design panel as things to copy, as the help center's "Copy +//! and paste properties between layers" describes: click to highlight the property in the right +//! sidebar, press Cmd/Ctrl+C, select another layer, press Cmd/Ctrl+V. //! //! A click on a row's background (beside and between its controls, or on its reorder grip) //! highlights it, one row at a time. The highlight belongs to the selection it was made on, and -//! goes with Escape or a press on the canvas or elsewhere in the Design panel. +//! goes with Escape or a press on the canvas or elsewhere in the Design panel. Cmd/Ctrl+C then +//! copies that one paint or effect (`fill.copy`, `stroke.copy`, `effect.copy`, as the row's menu +//! does) instead of the layers, and Cmd/Ctrl+V pastes it onto every selected layer (`fill.paste`, +//! `stroke.paste`, `effect.paste`) while it is still the last thing copied: on top of their list, +//! or in place of their highlighted row when it is of the same kind. A copy keeps its kind: a +//! stroke never lands in the fills, whatever row is highlighted. use egui::{Color32, Context, Rect, Response}; use figcraft_doc::NodeId; +use serde_json::json; use crate::{FigApp, RightTab}; @@ -19,6 +25,46 @@ pub enum PropertyKind { Effect, } +impl PropertyKind { + /// The command that copies row `index` of this list. + fn copy_command(self) -> &'static str { + match self { + PropertyKind::Fill => "fill.copy", + PropertyKind::Stroke => "stroke.copy", + PropertyKind::Effect => "effect.copy", + } + } + + /// The command that pastes the copied paint or effect. + fn paste_command(self) -> &'static str { + match self { + PropertyKind::Fill => "fill.paste", + PropertyKind::Stroke => "stroke.paste", + PropertyKind::Effect => "effect.paste", + } + } + + /// What the system clipboard gets when the copy has no text of its own: the marker that also + /// stands for copied layers, so a paste never makes a text layer of it. + fn marker(self) -> &'static str { + match self { + PropertyKind::Fill => "figcraft://clipboard/fill", + PropertyKind::Stroke => "figcraft://clipboard/stroke", + PropertyKind::Effect => "figcraft://clipboard/effect", + } + } + + /// Whether the engine clipboard still holds a copy of this kind. + fn on_clipboard(self, app: &FigApp) -> bool { + let clip = &app.session.clipboard; + match self { + PropertyKind::Fill => clip.paint.as_ref().is_some_and(|(fills, _)| *fills), + PropertyKind::Stroke => clip.paint.as_ref().is_some_and(|(fills, _)| !*fills), + PropertyKind::Effect => clip.effect.is_some(), + } + } +} + /// The row highlighted in the Design panel. It belongs to the layers selected when it was clicked /// and goes when they are no longer the selection. #[derive(Clone, Debug, PartialEq)] @@ -35,6 +81,18 @@ pub struct Highlight { pub rect: Option, } +/// A paint or effect copied from its row: Cmd/Ctrl+V pastes it rather than the copied layers while +/// it is the last thing copied. +#[derive(Clone, Debug, PartialEq)] +pub struct Copied { + pub kind: PropertyKind, + /// The engine clipboard's layers generation at the copy: a layer copy since moves it on. + pub layers_generation: u64, + /// What the copy put on the system clipboard (`None`: a programmatic copy, which never writes + /// it). A user's paste takes the property only while the system clipboard is still that. + pub mirror: Option, +} + /// The highlighted row, when it still belongs to the selection in the Design panel. pub(crate) fn current(app: &FigApp) -> Option<&Highlight> { let h = app.ui.property_row.as_ref()?; @@ -45,7 +103,7 @@ pub(crate) fn current(app: &FigApp) -> Option<&Highlight> { /// Let the highlight go once it no longer belongs to what the Design panel shows (another file or /// tab, another selection, the panel not `shown`), or when the pointer is pressed on the canvas or /// elsewhere in the Design panel. A press on its own row keeps it; so does one on a menu, a popup -/// or the toolbar, so a menu command can still act on it. Called each frame before +/// or the toolbar, so Edit ▸ Copy still copies the highlighted property. Called each frame before /// the right panel draws its rows. pub(crate) fn reconcile(app: &mut FigApp, ctx: &Context, shown: bool) { if app.ui.property_row.is_none() { @@ -115,3 +173,79 @@ pub(crate) fn follow(app: &mut FigApp, kind: PropertyKind, from: usize, to: Opti None => app.ui.property_row = None, } } + +/// Cmd/Ctrl+C with a highlighted row: copy its paint or effect. `false` when no row is +/// highlighted (the layers are copied as usual). +pub(crate) fn copy_highlighted(app: &mut FigApp) -> bool { + let Some(h) = current(app).cloned() else { return false }; + copy(app, h.kind, h.index, &h.ids); + true +} + +/// Copy paint or effect `index` of the `kind` list of `ids` (a highlighted row, or the row's menu) +/// and remember it as the last thing copied. A solid colour goes to the system clipboard as its +/// hex code, as other apps read colours; anything else as a marker of the copy. +pub(crate) fn copy(app: &mut FigApp, kind: PropertyKind, index: usize, ids: &[NodeId]) { + let Some(copied) = app.run(kind.copy_command(), json!({"ids": ids, "index": index})) else { return }; + let layers_generation = app.session.clipboard.layers_generation; + let mirror = if app.control_synthetic { + // The engine's copy is no longer what the system clipboard mirrors: a user's paste must + // not take that text for this copy (`menus::paste` would paste stale layers). + if app.ui.property_copy.as_ref().is_some_and(|c| c.mirror.is_some() && c.mirror == app.ui.last_copy) { + app.ui.last_copy = None; + } + None + } else { + let text = match kind { + PropertyKind::Effect => None, + PropertyKind::Fill | PropertyKind::Stroke => { + serde_json::from_value::(copied).ok().and_then(|p| p.solid_color()).map(figcraft_color::Color::to_css_hex) + } + } + .unwrap_or_else(|| kind.marker().to_string()); + app.clipboard_set_text(&text); + // The system clipboard holds our copy now: a paste reads it as ours (`menus::paste`). + app.ui.last_copy = Some(text.clone()); + app.ui.last_copy_generation = layers_generation; + Some(text) + }; + app.ui.property_copy = Some(Copied { kind, layers_generation, mirror }); +} + +/// Cmd/Ctrl+V when nothing newer than our own copy is on the system clipboard: when a paint or +/// effect copied from its row is the last thing copied, paste it onto the selected layers (in +/// place of their highlighted row of that kind, else on top) and return `true`; `false` leaves +/// the paste to the copied layers. +pub(crate) fn paste(app: &mut FigApp) -> bool { + let Some(c) = app.ui.property_copy.clone() else { return false }; + let still_last = c.layers_generation == app.session.clipboard.layers_generation + && c.kind.on_clipboard(app) + // A user's paste: the system clipboard must still be what this copy put there. A + // programmatic paste reads only the engine clipboard. + && (app.control_synthetic || (c.mirror.is_some() && app.ui.last_copy == c.mirror)); + if !still_last { + // The system clipboard still holds this copy's mirror, but the engine's copy of it is gone + // (a command copied a paint or effect of another kind since): nothing to paste, rather + // than the layers copied before it. + return !app.control_synthetic + && c.mirror.is_some() + && app.ui.last_copy == c.mirror + && app.ui.last_copy_generation == app.session.clipboard.layers_generation + && c.layers_generation == app.session.clipboard.layers_generation; + } + let ids = app.session.selection(); + if ids.is_empty() { + // Nothing to paste onto: a paste of the layers copied before would be surprising. + return true; + } + let cmd = c.kind.paste_command(); + if crate::menus::dev_mode_refuses(app, cmd) { + return true; + } + let mut params = json!({"ids": ids}); + if let Some(h) = current(app).filter(|h| h.kind == c.kind) { + params["index"] = json!(h.index); + } + app.run(cmd, params); + true +} diff --git a/crates/ui-egui/src/property_rows_tests.rs b/crates/ui-egui/src/property_rows_tests.rs index 47b61a99c..b36ea94bb 100644 --- a/crates/ui-egui/src/property_rows_tests.rs +++ b/crates/ui-egui/src/property_rows_tests.rs @@ -1,6 +1,6 @@ -//! A fill, stroke or effect row of the Design panel highlights on a click on its background or its -//! grip ("Copy and paste properties between layers": click to highlight the property in the right -//! sidebar). +//! A fill, stroke or effect row of the Design panel highlights on a click on its background, and +//! the keyboard copies and pastes it ("Copy and paste properties between layers": click to +//! highlight the property in the right sidebar, Cmd/Ctrl+C, select another layer, Cmd/Ctrl+V). use std::cell::RefCell; use std::rc::Rc; @@ -70,12 +70,144 @@ fn click_row(u: &mut Ui, label: &str) { click(u, pos2(panel.min.x + 6.0, y)); } +/// Cmd/Ctrl+C, as the desktop shell delivers it. +fn copy(u: &mut Ui) { + u.frame(vec![Event::Copy]); +} + +/// Cmd/Ctrl+V with the system clipboard's text, as the desktop shell delivers it. +fn paste(u: &mut Ui, clip: &Rc>>) { + let text = clip.borrow().clone().unwrap_or_default(); + u.frame(vec![Event::Paste(text)]); +} + /// Select `id` only, and draw the panel for it. fn select(u: &mut Ui, id: NodeId) { u.app.run("select.set", json!({"ids": [id]})); u.frame(vec![]); } +/// The hex codes of `id`'s fills, bottom to top. +fn fills(u: &Ui, id: NodeId) -> Vec { + let doc = u.app.session.doc().unwrap(); + doc.node(id).unwrap().fills.iter().map(|p| p.solid_color().unwrap().to_hex()).collect() +} + +#[test] +fn a_click_on_a_fill_rows_background_highlights_it_and_the_keyboard_copies_and_pastes_it() { + let (mut u, a, b, clip) = setup(); + click_row(&mut u, "FF0000"); + assert_eq!( + u.app.ui.property_row.as_ref().map(|h| (h.kind, h.index, h.ids.clone())), + Some((PropertyKind::Fill, 0, vec![a])), + "the row is highlighted for A" + ); + let layers_before = u.app.session.clipboard.layers_generation; + copy(&mut u); + let (fills_flag, paint) = u.app.session.clipboard.paint.clone().expect("the fill is on the clipboard"); + assert!(fills_flag); + assert_eq!(paint, u.app.session.doc().unwrap().node(a).unwrap().fills[0]); + assert_eq!(u.app.session.clipboard.layers_generation, layers_before, "the layers were not copied"); + assert!(u.app.session.clipboard.nodes.is_empty()); + assert_eq!(clip.borrow().as_deref(), Some("#FF0000"), "the colour goes to the system clipboard as its hex code"); + // Another layer selected, the paste puts the fill on top of its own. + select(&mut u, b); + assert!(u.app.ui.property_row.is_none(), "the highlight belonged to A"); + let layers = u.app.session.doc().unwrap().node_count(); + paste(&mut u, &clip); + assert_eq!(fills(&u, b), ["00FF00", "FF0000"]); + assert_eq!(u.app.session.doc().unwrap().node_count(), layers, "no layer was pasted"); + assert_eq!(fills(&u, a), ["FF0000"], "A keeps its fill"); + // One undo step takes it back off. + u.app.run("edit.undo", json!({})); + assert_eq!(fills(&u, b), ["00FF00"]); +} + +#[test] +fn a_paste_replaces_the_highlighted_row_of_the_target() { + let (mut u, _, b, clip) = setup(); + click_row(&mut u, "FF0000"); + copy(&mut u); + select(&mut u, b); + click_row(&mut u, "00FF00"); + assert_eq!(u.app.ui.property_row.as_ref().map(|h| (h.kind, h.index)), Some((PropertyKind::Fill, 0))); + paste(&mut u, &clip); + assert_eq!(fills(&u, b), ["FF0000"], "in place of B's highlighted fill"); +} + +#[test] +fn a_stroke_row_copies_its_paint_and_settings() { + let (mut u, a, b, clip) = setup(); + click_row(&mut u, "0000FF"); + assert_eq!(u.app.ui.property_row.as_ref().map(|h| h.kind), Some(PropertyKind::Stroke)); + copy(&mut u); + select(&mut u, b); + paste(&mut u, &clip); + let doc = u.app.session.doc().unwrap(); + let (na, nb) = (doc.node(a).unwrap(), doc.node(b).unwrap()); + assert_eq!(nb.strokes, na.strokes); + assert_eq!(nb.stroke.weight, 3.0, "the weight comes with the stroke"); + assert_eq!(fills(&u, b), ["00FF00"], "the fills are untouched"); +} + +#[test] +fn an_effect_row_copies_and_pastes_its_effect() { + let (mut u, a, b, clip) = setup(); + click_row(&mut u, "Layer blur"); + assert_eq!(u.app.ui.property_row.as_ref().map(|h| (h.kind, h.index)), Some((PropertyKind::Effect, 0))); + copy(&mut u); + assert_eq!(clip.borrow().as_deref(), Some("figcraft://clipboard/effect"), "an effect has no text: a marker of our copy"); + select(&mut u, b); + paste(&mut u, &clip); + let doc = u.app.session.doc().unwrap(); + let blur = doc.node(a).unwrap().effects[0].clone(); + assert_eq!(doc.node(b).unwrap().effects.len(), 2); + assert_eq!(doc.node(b).unwrap().effects[1], blur, "the blur goes on top of B's shadow"); +} + +#[test] +fn without_a_highlighted_row_the_keyboard_copies_and_pastes_layers() { + let (mut u, _, b, clip) = setup(); + copy(&mut u); + assert!(u.app.session.clipboard.paint.is_none(), "no fill was copied"); + assert_eq!(u.app.session.clipboard.nodes.len(), 1, "A was copied"); + select(&mut u, b); + let layers = u.app.session.doc().unwrap().node_count(); + paste(&mut u, &clip); + assert_eq!(u.app.session.doc().unwrap().node_count(), layers + 1, "a copy of A was pasted"); + assert_eq!(fills(&u, b), ["00FF00"]); +} + +#[test] +fn copying_layers_after_a_fill_pastes_the_layers() { + let (mut u, a, b, clip) = setup(); + click_row(&mut u, "FF0000"); + copy(&mut u); + // Another selection lets the highlight go; the next copy takes the layers. + select(&mut u, b); + select(&mut u, a); + assert!(u.app.ui.property_row.is_none(), "selecting A again doesn't bring the highlight back"); + copy(&mut u); + select(&mut u, b); + let layers = u.app.session.doc().unwrap().node_count(); + paste(&mut u, &clip); + assert_eq!(u.app.session.doc().unwrap().node_count(), layers + 1, "the layers were the last copy"); + assert_eq!(fills(&u, b), ["00FF00"]); +} + +#[test] +fn text_copied_in_another_app_afterwards_pastes_as_text() { + let (mut u, _, b, clip) = setup(); + click_row(&mut u, "FF0000"); + copy(&mut u); + select(&mut u, b); + *clip.borrow_mut() = Some("Hello".into()); + let layers = u.app.session.doc().unwrap().node_count(); + paste(&mut u, &clip); + assert_eq!(fills(&u, b), ["00FF00"], "the fill isn't what the clipboard holds now"); + assert_eq!(u.app.session.doc().unwrap().node_count(), layers + 1, "the text became a text layer"); +} + #[test] fn a_press_elsewhere_lets_the_highlight_go() { let (mut u, a, _, _) = setup(); @@ -89,6 +221,9 @@ fn a_press_elsewhere_lets_the_highlight_go() { click(&mut u, text_center(&shapes, "Effects")); assert_eq!(u.app.session.selection(), [a]); assert!(u.app.ui.property_row.is_none()); + // With nothing highlighted Cmd/Ctrl+C copies the layer again. + copy(&mut u); + assert!(u.app.session.clipboard.paint.is_none()); } #[test] @@ -141,6 +276,126 @@ fn a_click_on_the_canvas_lets_the_highlight_go() { assert!(u.app.ui.property_row.is_none()); } +#[test] +fn edit_copy_from_the_menu_copies_the_highlighted_row() { + let (mut u, _, _, _) = setup(); + click_row(&mut u, "FF0000"); + crate::menus::invoke(&mut u.app, "edit.copy"); + assert!(u.app.session.clipboard.paint.as_ref().is_some_and(|(fills, _)| *fills), "the fill was copied"); + assert!(u.app.session.clipboard.nodes.is_empty(), "not the layer"); +} + +#[test] +fn a_paste_onto_several_layers_adds_the_fill_to_each_in_one_undo_step() { + let (mut u, a, b, clip) = setup(); + let c = rect(&mut u.app.session, 200.0, "#0000FF"); + // A new rectangle is selected: A again. + select(&mut u, a); + click_row(&mut u, "FF0000"); + copy(&mut u); + // B and C have different fills: the panel shows mixed content, no row to highlight. + u.app.run("select.set", json!({"ids": [b, c]})); + u.frame(vec![]); + paste(&mut u, &clip); + assert_eq!(fills(&u, b), ["00FF00", "FF0000"]); + assert_eq!(fills(&u, c), ["0000FF", "FF0000"]); + u.app.run("edit.undo", json!({})); + assert_eq!((fills(&u, b), fills(&u, c)), (vec!["00FF00".to_string()], vec!["0000FF".to_string()])); +} + +#[test] +fn a_paste_replaces_the_highlighted_row_on_every_selected_layer() { + let (mut u, a, b, clip) = setup(); + let c = rect(&mut u.app.session, 200.0, "#00FF00"); + // A new rectangle is selected: A again. + select(&mut u, a); + click_row(&mut u, "FF0000"); + copy(&mut u); + // B and C share their fill: its row shows for both. + u.app.run("select.set", json!({"ids": [b, c]})); + u.frame(vec![]); + click_row(&mut u, "00FF00"); + assert_eq!(u.app.ui.property_row.as_ref().map(|h| h.ids.clone()), Some(vec![b, c])); + paste(&mut u, &clip); + assert_eq!((fills(&u, b), fills(&u, c)), (vec!["FF0000".to_string()], vec!["FF0000".to_string()])); + u.app.run("edit.undo", json!({})); + assert_eq!((fills(&u, b), fills(&u, c)), (vec!["00FF00".to_string()], vec!["00FF00".to_string()])); +} + +#[test] +fn a_copied_stroke_goes_to_the_strokes_with_a_fill_row_highlighted() { + let (mut u, a, b, clip) = setup(); + click_row(&mut u, "0000FF"); + copy(&mut u); + select(&mut u, b); + click_row(&mut u, "00FF00"); + assert_eq!(u.app.ui.property_row.as_ref().map(|h| h.kind), Some(PropertyKind::Fill)); + paste(&mut u, &clip); + let doc = u.app.session.doc().unwrap(); + assert_eq!(doc.node(b).unwrap().strokes, doc.node(a).unwrap().strokes, "the stroke went to the strokes"); + assert_eq!(fills(&u, b), ["00FF00"], "the highlighted fill wasn't replaced"); +} + +#[test] +fn an_effect_copied_after_a_fill_is_what_pastes() { + let (mut u, a, b, clip) = setup(); + click_row(&mut u, "FF0000"); + copy(&mut u); + click_row(&mut u, "Layer blur"); + copy(&mut u); + select(&mut u, b); + paste(&mut u, &clip); + assert_eq!(fills(&u, b), ["00FF00"], "the fill was copied first: not pasted"); + let doc = u.app.session.doc().unwrap(); + assert_eq!(doc.node(b).unwrap().effects.get(1), doc.node(a).unwrap().effects.first(), "the blur went on top of B's shadow"); +} + +#[test] +fn a_row_menu_copy_is_pasted_by_the_keyboard() { + let (mut u, _, b, clip) = setup(); + let shapes = flat(&u.frame_output(vec![]).shapes); + let p = text_center(&shapes, "FF0000"); + for pressed in [true, false] { + u.frame(vec![Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }]); + } + let menu = flat(&u.frame_output(vec![]).shapes); + click(&mut u, text_center(&menu, crate::i18n::tr("Copy fill"))); + assert_eq!(clip.borrow().as_deref(), Some("#FF0000")); + select(&mut u, b); + paste(&mut u, &clip); + assert_eq!(fills(&u, b), ["00FF00", "FF0000"]); +} + +#[test] +fn programmatic_copy_and_paste_of_a_row_leave_the_system_clipboard_alone() { + let (mut u, a, b, clip) = setup(); + *clip.borrow_mut() = Some("the user's text".into()); + let doc = u.app.session.ds().unwrap().uid; + u.app.ui.property_row = Some(Highlight { kind: PropertyKind::Fill, index: 0, ids: vec![a], doc, rect: None }); + u.app.control_synthetic = true; + crate::menus::invoke(&mut u.app, "edit.copy"); + assert_eq!(clip.borrow().as_deref(), Some("the user's text"), "never written"); + u.app.run("select.set", json!({"ids": [b]})); + crate::menus::paste(&mut u.app, None, None); + assert_eq!(fills(&u, b), ["00FF00", "FF0000"]); +} + +#[test] +fn a_property_copy_whose_engine_copy_is_gone_pastes_no_stale_layers() { + let (mut u, a, b, clip) = setup(); + // A layer copy first, then the fill row's. + copy(&mut u); + click_row(&mut u, "FF0000"); + copy(&mut u); + // A stroke copied without its row (the Actions palette): the fill is no longer on the clipboard. + u.app.run("stroke.copy", json!({"id": a})); + select(&mut u, b); + let layers = u.app.session.doc().unwrap().node_count(); + paste(&mut u, &clip); + assert_eq!(u.app.session.doc().unwrap().node_count(), layers, "the layer copied before wasn't pasted"); + assert_eq!(fills(&u, b), ["00FF00"]); +} + #[test] fn a_highlighted_row_whose_paint_an_undo_took_away_goes() { let (mut u, a, _, _) = setup(); diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 3397e00de..c0e838e4d 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -236,6 +236,8 @@ fn exchange(app: &mut FigApp, p: &mut Pane) { std::mem::swap(&mut app.ui, &mut p.ui); std::mem::swap(&mut app.ui.last_copy, &mut p.ui.last_copy); std::mem::swap(&mut app.ui.last_copy_generation, &mut p.ui.last_copy_generation); + // A paint or effect copied from its row is on the one clipboard every window pastes from. + std::mem::swap(&mut app.ui.property_copy, &mut p.ui.property_copy); std::mem::swap(&mut app.ui.quit, &mut p.ui.quit); std::mem::swap(&mut app.ui.dark, &mut p.ui.dark); std::mem::swap(&mut app.canvas, &mut p.canvas); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 463be3b47..b1c1e2757 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (a click on a fill, stroke or effect row's grip or background highlights it, Cmd/Ctrl+C copies that paint or effect and Cmd/Ctrl+V pastes it onto the selected layers, as the help center's Copy and paste properties between layers says (#1494); the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -36,6 +36,7 @@ interaction feel not yet compared. | Colour picker image well | partial | the Image tab shows a checkered placeholder and Upload from computer until there is a file (no dialog at once); an image paint shows its asset preview; a file dropped on the open picker replaces the image, keeping the fill mode, crop, position and adjustments (`image.replace`). Layers' fills and strokes only (text ranges, styles and selection colours keep Upload). The Fill and Stroke swatches take no drop: a dragged file's position isn't known, so a drop with none goes to the open picker | | Colour picker on a new fill or stroke | done | + opens the picker on the new paint when the selection shares one paint list; on a mixed selection + adds a paint to each layer and no picker opens (#404) | | Fill section on a text with coloured characters | done | a text's Fill section shows what its characters show: characters coloured differently while editing are mixed content ("Click + to replace mixed content", as a mixed selection; the developer documentation's `fills`: "Text nodes can have multiple sets of fills if some characters are colored differently than others"), and characters that all share fills the layer doesn't have show those. Any layer-level fill edit (a row, its picker, +, reordering, `fill.*`, `node.set`) reaches every character, starting from the list they showed; the characters' own fills go in the same undo step; the canvas gradient and crop handles show and edit the paint the row shows, the characters' own, also when their image is already in Crop mode (`text_mixed_fill_tests`, `canvas::handles::character_fill_tests`, engine `text_fill_tests`; #1856). A range selected while editing keeps its own Fill row | +| Fill, stroke and effect rows: click to highlight, Cmd/Ctrl+C, Cmd/Ctrl+V | done | a click on a row's reorder grip (left of its fields) or its background between its controls highlights it in the selected-row colour, one row at a time; Cmd/Ctrl+C (or Edit ▸ Copy) then copies that paint or effect instead of the layers (`fill.copy`, `stroke.copy`, `effect.copy`, as the row's menu does; a stroke takes its weight and settings along) and Cmd/Ctrl+V pastes it onto every selected layer (`fill.paste`, `stroke.paste`, `effect.paste`), one undo step: on top of each layer's list, or in place of the highlighted row when it is of the same kind. A copy keeps its kind: a stroke goes to the strokes and an effect to the effects whatever row is highlighted, and the last row copied is the one pasted. A solid colour also goes to the system clipboard as its hex code; a programmatic copy never writes it. A later layer copy, or text or an image copied in another app, is pasted instead; every window pastes the same copy. Escape, a press on the canvas or elsewhere in the Design panel, another selection, file or tab let the highlight go (#1494). The help center says only "Click to highlight the property on the right sidebar"; adding on top follows the forum's accepted answer for adding one fill without replacing the others, not an observation. Delete and Cmd/Ctrl+X still act on the layers; the keyboard alone can't highlight a row | | Blend mode menus: the layer's (Design panel) and each paint's (colour picker) | done | Pass through and Normal are distinct (Normal isolates a group's or frame's children); paints have no pass-through; plus darker drawn exactly on the canvas | ## Layers and Assets panels @@ -187,6 +188,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-11-09 | minor | M1, #1494: fill, stroke and effect rows in the Design panel couldn't be selected, so the keyboard couldn't copy one (only the row's menu could). A click on a row's grip or background now highlights it (`property_rows`; the row is a click target under its controls and its grip takes a click, neither focusable, so Tab still goes control to control), Cmd/Ctrl+C copies that paint or effect, and Cmd/Ctrl+V pastes it onto the selected layers, on top or in place of their highlighted row of that kind, while it is still the last copy ("Click to highlight the property on the right sidebar", Copy and paste properties between layers). Escape or a press on the canvas lets the highlight go before the selection. Tests: `property_rows_tests` (22) | | 2026-11-02 | patch | Shortcuts: a Cmd/Ctrl-click on a padding field (the horizontal or vertical pair, or a side with Individual padding open) makes the value typed go to all four sides, in one undo step (Guide to auto layout, Keyboard shortcut guide); a plain click still edits that axis or side alone. A documented gesture row: 134 documented, 133 bound. Test: `padding_individual_tests` | | 2026-11-01 | patch | Number fields: dragging the field itself with Alt held scrubs it ("You can also hover the input field itself and hold down the modifier key: Mac: ⌥ Option Windows: Alt", Adjust alignment, rotation, position, and dimensions), with the resize cursor and the label's rates (Alt is the enabler there, so it isn't the fine rate); a click with Alt doesn't open the editor. Found by a documentation audit of the ui-parity rows. Test: `field_tests::alt_held_over_a_field_scrubs_it`. Counts unchanged | | 2026-11-02 | patch | M6, #1839: with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it by the nudge amounts (`tool.crosshair`), Enter places the object there (`tool.place`), Escape or a tool change takes it away; the canvas draws the crosshair and `ui.inspect` reports it (`tool.crosshair`), the Keyboard shortcuts panel lists the gesture ("Use the arrow keys to activate the crosshair cursor...", Accessibility at Figma). The board Shapes tool places on it too (its 160 px shape centred, as the tool centres a press), and the view pans by the overshoot when the crosshair is nudged past its edge. Tests: `place_crosshair_tests`, `shortcut_keys_tests::arrows_show_and_move_the_placement_crosshair_with_a_shape_tool`. | From 0ad70b95018d47c1b067baf02dc654f7a1232840 Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Tue, 10 Nov 2026 23:31:31 +0100 Subject: [PATCH 3/4] M1: a programmatic paste after a property copy whose engine copy is gone pastes no stale layers (#1494) A row's fill copied through the control channel, then a stroke copied by a command, left the fill off the engine clipboard; a programmatic paste then fell through to the layers copied before the fill. It now does nothing, as a user's paste already did. Tests: the programmatic case above, an effect paste replacing the highlighted effect above the bottom one (index 1), and a property copied in one window pasting in another while the highlight stays in its window. --- crates/ui-egui/src/property_rows.rs | 19 ++++--- crates/ui-egui/src/property_rows_tests.rs | 67 +++++++++++++++++++++++ 2 files changed, 78 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/property_rows.rs b/crates/ui-egui/src/property_rows.rs index 2267fb347..c5c89485c 100644 --- a/crates/ui-egui/src/property_rows.rs +++ b/crates/ui-egui/src/property_rows.rs @@ -10,6 +10,10 @@ //! `stroke.paste`, `effect.paste`) while it is still the last thing copied: on top of their list, //! or in place of their highlighted row when it is of the same kind. A copy keeps its kind: a //! stroke never lands in the fills, whatever row is highlighted. +//! +//! A programmatic copy (the control channel) never writes the system clipboard, so it has no +//! mirror there: a programmatic paste takes it from the engine clipboard, while a user's paste +//! goes by what the system clipboard holds, as it does for copied layers. use egui::{Color32, Context, Rect, Response}; use figcraft_doc::NodeId; @@ -224,14 +228,13 @@ pub(crate) fn paste(app: &mut FigApp) -> bool { // programmatic paste reads only the engine clipboard. && (app.control_synthetic || (c.mirror.is_some() && app.ui.last_copy == c.mirror)); if !still_last { - // The system clipboard still holds this copy's mirror, but the engine's copy of it is gone - // (a command copied a paint or effect of another kind since): nothing to paste, rather - // than the layers copied before it. - return !app.control_synthetic - && c.mirror.is_some() - && app.ui.last_copy == c.mirror - && app.ui.last_copy_generation == app.session.clipboard.layers_generation - && c.layers_generation == app.session.clipboard.layers_generation; + // No layers were copied since this copy, but the engine's copy of it is gone (a command + // copied a paint or effect of another kind since): nothing to paste, rather than the + // layers copied before it. For a user's paste, only while the system clipboard still + // holds this copy's mirror; a programmatic paste reads only the engine clipboard. + let ours = app.control_synthetic + || (c.mirror.is_some() && app.ui.last_copy == c.mirror && app.ui.last_copy_generation == app.session.clipboard.layers_generation); + return ours && c.layers_generation == app.session.clipboard.layers_generation; } let ids = app.session.selection(); if ids.is_empty() { diff --git a/crates/ui-egui/src/property_rows_tests.rs b/crates/ui-egui/src/property_rows_tests.rs index b36ea94bb..d997973b7 100644 --- a/crates/ui-egui/src/property_rows_tests.rs +++ b/crates/ui-egui/src/property_rows_tests.rs @@ -444,3 +444,70 @@ fn a_click_on_a_rows_grip_highlights_that_row_for_the_selection() { select(&mut u, a); assert!(u.app.ui.property_row.is_none()); } + +#[test] +fn a_programmatic_paste_after_a_property_copy_whose_engine_copy_is_gone_pastes_no_stale_layers() { + let (mut u, a, b, _) = setup(); + u.app.control_synthetic = true; + // A layer copy first, then the fill row's, both programmatic. + crate::menus::invoke(&mut u.app, "edit.copy"); + assert_eq!(u.app.session.clipboard.nodes.len(), 1, "A was copied"); + crate::property_rows::copy(&mut u.app, PropertyKind::Fill, 0, &[a]); + // A stroke copied without its row: the fill is no longer on the clipboard. + u.app.run("stroke.copy", json!({"id": a})); + u.app.run("select.set", json!({"ids": [b]})); + let layers = u.app.session.doc().unwrap().node_count(); + crate::menus::paste(&mut u.app, None, None); + assert_eq!(u.app.session.doc().unwrap().node_count(), layers, "the layer copied before wasn't pasted"); + assert_eq!(fills(&u, b), ["00FF00"]); + assert!(u.app.session.doc().unwrap().node(b).unwrap().strokes.is_empty()); +} + +#[test] +fn an_effect_paste_replaces_the_highlighted_effect_above_the_bottom_one() { + let (mut u, a, b, clip) = setup(); + u.app.run("effect.add", json!({"ids": [b], "type": "innerShadow"})); + click_row(&mut u, "Layer blur"); + copy(&mut u); + select(&mut u, b); + // B's effects, bottom to top: a drop shadow, an inner shadow (drawn first, at the top). + click_row(&mut u, "Inner shadow"); + assert_eq!(u.app.ui.property_row.as_ref().map(|h| (h.kind, h.index)), Some((PropertyKind::Effect, 1))); + paste(&mut u, &clip); + let doc = u.app.session.doc().unwrap(); + let (blur, effects) = (&doc.node(a).unwrap().effects[0], &doc.node(b).unwrap().effects); + assert_eq!(effects.len(), 2, "replaced, not added"); + assert_eq!(effects[1], *blur, "the blur took the inner shadow's place"); + assert_eq!(effects[0].label(), "Drop shadow", "the drop shadow under it stays"); +} + +#[test] +fn a_property_copied_in_one_window_pastes_in_another_and_the_highlight_stays_in_its_window() { + let mut s = Session::empty(); + s.execute("file.new", &json!({"name": "A"})).unwrap(); + let a = rect(&mut s, 0.0, "#FF0000"); + s.execute("file.new", &json!({"name": "B"})).unwrap(); + let b = rect(&mut s, 0.0, "#00FF00"); + let clip = Rc::new(RefCell::new(None)); + let ctx = egui::Context::default(); + ctx.set_embed_viewports(false); + let mut app = crate::FigApp::new(s, Box::new(Clip(clip.clone()))); + ctx.run_ui(egui::RawInput::default(), |ui| app.ui(ui)).textures_delta.clear(); + // File A goes to a window of its own; the main window shows B. + let w = crate::control::handle(&mut app, &ctx, "window.new", &json!({"index": 0})).unwrap()["window"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [b]})); + let doc = app.session.ds().unwrap().uid; + app.ui.property_row = Some(Highlight { kind: PropertyKind::Fill, index: 0, ids: vec![b], doc, rect: None }); + crate::menus::invoke(&mut app, "edit.copy"); + assert_eq!(clip.borrow().as_deref(), Some("#00FF00")); + let text = clip.borrow().clone(); + let pasted = crate::windows::as_window(&mut app, w, |app| { + assert!(app.ui.property_row.is_none(), "the highlight belongs to the main window"); + app.run("select.set", json!({"ids": [a]})); + crate::menus::paste(app, text.as_deref(), None); + app.session.doc().unwrap().node(a).unwrap().fills.iter().map(|p| p.solid_color().unwrap().to_hex()).collect::>() + }); + let fills = pasted.expect("the window exists"); + assert_eq!(fills, ["FF0000", "00FF00"], "B's fill went on top of A's"); + assert!(app.ui.property_row.is_some(), "the main window keeps its highlight"); +} From b640fbc1143ae12d1b55d15d4e2a33feca46efdd Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 12 Nov 2026 09:27:39 +0700 Subject: [PATCH 4/4] Docs: describe the property highlight in our own words --- docs/ui-parity.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index d90b7da9b..1d7ca5083 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -36,7 +36,7 @@ interaction feel not yet compared. | Colour picker image well | partial | the Image tab shows a checkered placeholder and Upload from computer until there is a file (no dialog at once); an image paint shows its asset preview; a file dropped on the open picker replaces the image, keeping the fill mode, crop, position and adjustments (`image.replace`). Layers' fills and strokes only (text ranges, styles and selection colours keep Upload). The Fill and Stroke swatches take no drop: a dragged file's position isn't known, so a drop with none goes to the open picker | | Colour picker on a new fill or stroke | done | + opens the picker on the new paint when the selection shares one paint list; on a mixed selection + adds a paint to each layer and no picker opens (#404) | | Fill section on a text with coloured characters | done | a text's Fill section shows what its characters show: characters coloured differently while editing are mixed content ("Click + to replace mixed content", as a mixed selection; the developer documentation's `fills`: "Text nodes can have multiple sets of fills if some characters are colored differently than others"), and characters that all share fills the layer doesn't have show those. Any layer-level fill edit (a row, its picker, +, reordering, `fill.*`, `node.set`) reaches every character, starting from the list they showed; the characters' own fills go in the same undo step; the canvas gradient and crop handles show and edit the paint the row shows, the characters' own, also when their image is already in Crop mode (`text_mixed_fill_tests`, `canvas::handles::character_fill_tests`, engine `text_fill_tests`; #1856). A range selected while editing keeps its own Fill row | -| Fill, stroke and effect rows: click to highlight, Cmd/Ctrl+C, Cmd/Ctrl+V | done | a click on a row's reorder grip (left of its fields) or its background between its controls highlights it in the selected-row colour, one row at a time; Cmd/Ctrl+C (or Edit ▸ Copy) then copies that paint or effect instead of the layers (`fill.copy`, `stroke.copy`, `effect.copy`, as the row's menu does; a stroke takes its weight and settings along) and Cmd/Ctrl+V pastes it onto every selected layer (`fill.paste`, `stroke.paste`, `effect.paste`), one undo step: on top of each layer's list, or in place of the highlighted row when it is of the same kind. A copy keeps its kind: a stroke goes to the strokes and an effect to the effects whatever row is highlighted, and the last row copied is the one pasted. A solid colour also goes to the system clipboard as its hex code; a programmatic copy never writes it. A later layer copy, or text or an image copied in another app, is pasted instead; every window pastes the same copy. Escape, a press on the canvas or elsewhere in the Design panel, another selection, file or tab let the highlight go (#1494). The help center says only "Click to highlight the property on the right sidebar"; adding on top follows the forum's accepted answer for adding one fill without replacing the others, not an observation. Delete and Cmd/Ctrl+X still act on the layers; the keyboard alone can't highlight a row | +| Fill, stroke and effect rows: click to highlight, Cmd/Ctrl+C, Cmd/Ctrl+V | done | a click on a row's reorder grip (left of its fields) or its background between its controls highlights it in the selected-row colour, one row at a time; Cmd/Ctrl+C (or Edit ▸ Copy) then copies that paint or effect instead of the layers (`fill.copy`, `stroke.copy`, `effect.copy`, as the row's menu does; a stroke takes its weight and settings along) and Cmd/Ctrl+V pastes it onto every selected layer (`fill.paste`, `stroke.paste`, `effect.paste`), one undo step: on top of each layer's list, or in place of the highlighted row when it is of the same kind. A copy keeps its kind: a stroke goes to the strokes and an effect to the effects whatever row is highlighted, and the last row copied is the one pasted. A solid colour also goes to the system clipboard as its hex code; a programmatic copy never writes it. A later layer copy, or text or an image copied in another app, is pasted instead; every window pastes the same copy. Escape, a press on the canvas or elsewhere in the Design panel, another selection, file or tab let the highlight go (#1494). The help center says only that a property is highlighted with a click; adding on top follows the forum's accepted answer for adding one fill without replacing the others, not an observation. Delete and Cmd/Ctrl+X still act on the layers; the keyboard alone can't highlight a row | | Blend mode menus: the layer's (Design panel) and each paint's (colour picker) | done | Pass through and Normal are distinct (Normal isolates a group's or frame's children); paints have no pass-through; plus darker drawn exactly on the canvas | ## Layers and Assets panels @@ -189,7 +189,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| | 2026-11-10 | patch | #2217: the main menu's submenus (Object, say) ran past the window's bottom, behind the taskbar, with the lowest commands out of reach, because submenus were not constrained to the window (to keep them beside their row). Every menu panel now scrolls when taller than the window less 8 pt margins, and a submenu is held within the window's height (moved up, never above the top) while keeping its place beside its row, so menu-aim is unchanged. Tests: `menu_overflow_tests.rs`. Numbers unchanged. | -| 2026-11-09 | minor | M1, #1494: fill, stroke and effect rows in the Design panel couldn't be selected, so the keyboard couldn't copy one (only the row's menu could). A click on a row's grip or background now highlights it (`property_rows`; the row is a click target under its controls and its grip takes a click, neither focusable, so Tab still goes control to control), Cmd/Ctrl+C copies that paint or effect, and Cmd/Ctrl+V pastes it onto the selected layers, on top or in place of their highlighted row of that kind, while it is still the last copy ("Click to highlight the property on the right sidebar", Copy and paste properties between layers). Escape or a press on the canvas lets the highlight go before the selection. Tests: `property_rows_tests` (22) | +| 2026-11-09 | minor | M1, #1494: fill, stroke and effect rows in the Design panel couldn't be selected, so the keyboard couldn't copy one (only the row's menu could). A click on a row's grip or background now highlights it (`property_rows`; the row is a click target under its controls and its grip takes a click, neither focusable, so Tab still goes control to control), Cmd/Ctrl+C copies that paint or effect, and Cmd/Ctrl+V pastes it onto the selected layers, on top or in place of their highlighted row of that kind, while it is still the last copy (Copy and paste properties between layers). Escape or a press on the canvas lets the highlight go before the selection. Tests: `property_rows_tests` (22) | | 2026-11-02 | patch | Shortcuts: a Cmd/Ctrl-click on a padding field (the horizontal or vertical pair, or a side with Individual padding open) makes the value typed go to all four sides, in one undo step (Guide to auto layout, Keyboard shortcut guide); a plain click still edits that axis or side alone. A documented gesture row: 134 documented, 133 bound. Test: `padding_individual_tests` | | 2026-11-01 | patch | Number fields: dragging the field itself with Alt held scrubs it ("You can also hover the input field itself and hold down the modifier key: Mac: ⌥ Option Windows: Alt", Adjust alignment, rotation, position, and dimensions), with the resize cursor and the label's rates (Alt is the enabler there, so it isn't the fine rate); a click with Alt doesn't open the editor. Found by a documentation audit of the ui-parity rows. Test: `field_tests::alt_held_over_a_field_scrubs_it`. Counts unchanged | | 2026-11-02 | patch | M6, #1839: with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it by the nudge amounts (`tool.crosshair`), Enter places the object there (`tool.place`), Escape or a tool change takes it away; the canvas draws the crosshair and `ui.inspect` reports it (`tool.crosshair`), the Keyboard shortcuts panel lists the gesture ("Use the arrow keys to activate the crosshair cursor...", Accessibility at Figma). The board Shapes tool places on it too (its 160 px shape centred, as the tool centres a press), and the view pans by the overshoot when the crosshair is nudged past its edge. Tests: `place_crosshair_tests`, `shortcut_keys_tests::arrows_show_and_move_the_placement_crosshair_with_a_shape_tool`. |