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] 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`. |