From 6ce46b3532dfba9f1631d2aa9349387d2177130f Mon Sep 17 00:00:00 2001 From: Filip Strajnar Date: Sat, 17 Oct 2026 17:09:51 +0200 Subject: [PATCH 1/5] M1: double-click a frame's name on the canvas to rename it (#757) A frame's name above its top edge (or a section's pill) only selected and dragged it; a double-click there did nothing, since the label lies outside the frame and hits no layer. With the Move or Scale tool, a double-click on a label now selects that frame and asks the UI (`canvas.renameLabel`) for a field over the label with the name selected, as the layers panel and pages list do. Enter or a click elsewhere renames it (`object.rename`, one undo step) and Escape leaves the name as it was. Rotated labels work too. Tests drive the canvas: a double-click on a label opens the field, typing and Enter rename the frame without moving it, undo restores the name, and Escape cancels on a rotated frame. --- crates/engine/src/tools.rs | 11 +++++ crates/ui-egui/src/canvas.rs | 1 + crates/ui-egui/src/frame_label_tests.rs | 55 +++++++++++++++++++++++++ crates/ui-egui/src/frame_labels.rs | 31 ++++++++++++++ crates/ui-egui/src/lib.rs | 9 ++++ docs/roadmap.md | 1 + docs/ui-parity.md | 5 ++- 7 files changed, 111 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index dbda1894..dba3eaf7 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -1875,6 +1875,17 @@ impl Session { self.tool.overlay.marquee = None; self.cancel_interaction()?; } + // A frame's or section's name label: rename it there (the canvas puts a field over it). + if matches!(self.tool.tool, ToolId::Move | ToolId::Scale) + && !(ev.shift || ev.cmd || ev.alt) + && let Some(id) = label_at(&doc, page, &self.tool.labels, ev.p()) + { + if sel != [id] { + self.set_selection(vec![id]); + } + self.request_ui("canvas.renameLabel", serde_json::json!({"id": id})); + return Ok(()); + } let Some(leaf) = self.hit(&doc, page, ev.p(), ev.tol() * 2.0) else { return Ok(()) }; let target = select_target(&doc, leaf, &sel, true); // Text: edit at the click. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index bc184270..4c2b66ef 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -242,6 +242,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { painter.image(tex.id(), image_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } app.session.tool.labels = overlays(app, &painter, &t); + crate::frame_labels::rename_field(app, ui); // The zoom tool's area. if let (Some(start), Some(p)) = (app.canvas.zoom_drag, ctx.input(|i| i.pointer.hover_pos())) { let area = Rect::from_two_pos(start, p); diff --git a/crates/ui-egui/src/frame_label_tests.rs b/crates/ui-egui/src/frame_label_tests.rs index 38ebef0a..d7f2371a 100644 --- a/crates/ui-egui/src/frame_label_tests.rs +++ b/crates/ui-egui/src/frame_label_tests.rs @@ -79,3 +79,58 @@ fn pressing_a_rotated_label_selects_and_drags_the_frame() { assert!(after.x0 > before.x0 + 1.0 && after.y0 > before.y0 + 1.0, "{before:?} → {after:?}"); assert_eq!(u.app.session.ds().unwrap().history.undo.len(), undo + 1); } + +fn key(k: egui::Key) -> Vec { + vec![ + Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }, + Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: Default::default() }, + ] +} + +/// Double-click `f`'s label and wait for the field to take the keyboard. +fn double_click_label(u: &mut Ui, f: NodeId) { + let at = mid(&label(u, f)); + for _ in 0..2 { + u.button(at, true); + u.button(at, false); + } + u.frame(vec![]); + u.frame(vec![]); +} + +fn name(u: &Ui, f: NodeId) -> String { + u.app.session.doc().unwrap().node(f).unwrap().name.clone() +} + +#[test] +fn double_clicking_a_label_renames_the_frame_there() { + let (mut u, a, b) = setup(); + let before = u.app.session.doc().unwrap().world_bounds(a).unwrap(); + double_click_label(&mut u, a); + assert_eq!(u.app.session.selection(), vec![a]); + assert_eq!(u.app.ui.rename_label.map(|r| r.0), Some(a), "a field over the label"); + // The name starts selected: typing replaces it, Enter keeps it. + u.frame(vec![Event::Text("Sign in".into())]); + u.frame(key(egui::Key::Enter)); + u.frame(vec![]); + assert_eq!(name(&u, a), "Sign in"); + assert!(u.app.ui.rename_label.is_none()); + // The double-click didn't move the frame or rename the other one. + assert_eq!(u.app.session.doc().unwrap().world_bounds(a).unwrap(), before); + assert_eq!(name(&u, b), "Turned"); + // One undo step puts the old name back. + u.app.session.undo().unwrap(); + assert_eq!(name(&u, a), "Upright"); +} + +#[test] +fn escape_leaves_a_label_rename_with_the_old_name() { + let (mut u, _, b) = setup(); + double_click_label(&mut u, b); + assert_eq!(u.app.ui.rename_label.map(|r| r.0), Some(b), "rotated labels rename too"); + u.frame(vec![Event::Text("Nope".into())]); + u.frame(key(egui::Key::Escape)); + u.frame(vec![]); + assert_eq!(name(&u, b), "Turned"); + assert!(u.app.ui.rename_label.is_none()); +} diff --git a/crates/ui-egui/src/frame_labels.rs b/crates/ui-egui/src/frame_labels.rs index 2718fddd..43dd712d 100644 --- a/crates/ui-egui/src/frame_labels.rs +++ b/crates/ui-egui/src/frame_labels.rs @@ -151,6 +151,37 @@ pub fn draw(app: &FigApp, painter: &Painter, t: &Tokens, doc: &Document, id: Nod Some([area.left_top(), area.right_top(), area.right_bottom(), area.left_bottom()].map(|p| tl + rot * (p - tl))) } +/// Rename `id`'s name label on the canvas: a field over the label from the next frame on. +pub fn start_rename(app: &mut FigApp, id: NodeId) { + // The field takes the focus once it's drawn (as the pages list does). + app.ui.rename_label = Some((id, true)); +} + +/// The field over a label being renamed (see [`start_rename`]): Enter or a click elsewhere +/// renames the layer, Escape keeps the name. +pub fn rename_field(app: &mut FigApp, ui: &mut egui::Ui) { + let Some((id, start)) = app.ui.rename_label else { return }; + let quad = app.session.tool.labels.iter().find(|(i, _)| *i == id).map(|(_, q)| q.map(|p| app.to_screen(p.x, p.y))); + let name = app.session.doc().ok().and_then(|d| d.node(id)).map(|n| n.name.clone()); + let (Some(quad), Some(name)) = (quad, name) else { + app.ui.rename_label = None; + return; + }; + let area = Rect::from_points(&quad); + let font = regular(11.0); + let text_w = ui.painter().layout_no_wrap(name.clone(), font.clone(), Color32::WHITE).size().x; + let rect = Rect::from_min_size(pos2(area.min.x, area.max.y - 16.0), vec2((text_w + 24.0).max(area.width()).clamp(80.0, 480.0), 16.0)); + let (done, open) = crate::widgets::rename_field(ui, egui::Id::new(("frame_label_rename", id)), &name, rect, font, start); + if let Some(new) = done { + app.ui.rename_label = None; + app.run("object.rename", serde_json::json!({"ids": [id], "name": new})); + } else if !open && !start { + app.ui.rename_label = None; + } else { + app.ui.rename_label = Some((id, false)); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e6a14ede..6141b3dd 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -430,6 +430,9 @@ pub struct UiState { /// The layer being renamed in the layers panel. pub rename: Option, pub rename_page: Option<(NodeId, String)>, + /// The frame or section whose name label on the canvas is being renamed (a double-click on + /// it), and whether the rename just started (its field takes the keyboard when next drawn). + pub rename_label: Option<(NodeId, bool)>, pub main_menu: bool, pub palette: Option, /// The Actions palette's tab. @@ -587,6 +590,7 @@ impl Default for UiState { expanded: BTreeSet::new(), rename: None, rename_page: None, + rename_label: None, main_menu: false, palette: None, palette_tab: menus::PaletteTab::All, @@ -961,6 +965,11 @@ impl FigApp { "layers.collapseAll" => layers::collapse_all(self), "contextMenu" => self.ui.key_menu = true, "selectArea" => self.start_key_box(), + "canvas.renameLabel" => { + if let Some(id) = r.data.get("id").and_then(figcraft_engine::params::id_of) { + frame_labels::start_rename(self, id); + } + } "layers.rename" => { let ids: Vec = r.data.get("ids").and_then(Value::as_array).into_iter().flatten().filter_map(figcraft_engine::params::id_of).collect(); diff --git a/docs/roadmap.md b/docs/roadmap.md index 8d63a469..b89f1842 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -73,6 +73,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | M1: double-clicking a frame's or section's name on the canvas renames it in place (#757; Enter commits, Escape cancels, one undo step). Numbers unchanged | | 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). Numbers unchanged. | | 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. | | 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 0cccf008..d2b3c9e2 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-10-17 · **Change:** minor (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; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; 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) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (renaming a frame or section from its name on the canvas (#757); 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; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; 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) · **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: @@ -98,7 +98,7 @@ interaction feel not yet compared. |---|---|---| | Pan/zoom: wheel, pinch, Space-drag, Shift+0/1/2, Cmd+0, `+`/`-` | done | Shift-wheel pans sideways, pinch and the zoom tool keep the point under the pointer; zoom steps, limits (2%–25,600%) and frame order pinned where the documentation is silent (`view_behaviour_tests`) | | Rulers and guides (drag out, move, remove; rotated frames; frame guides) | done | guides belong to the page or a top-level frame (a frame moved into another keeps its guides but they stop applying); a click selects one for Delete, the arrows or the context menu's Remove guide; Alt-drag drops a copy; Alt while dragging measures to the layers in the frame it crosses; ruler numbering checked by eye only | -| Frame labels (rotated, right-to-left), size badge | done | | +| Frame labels (rotated, right-to-left), size badge | done | double-click a frame's or section's name to rename it in place: Enter or a click elsewhere keeps the name, Escape restores it, one undo step (#757, `frame_label_tests`) | | Outline mode | done | Outlines, outlines for the selection, mask outlines, frame outlines; shapes are clicked on their outlines, reaching layers behind; View ▸ Outlines ▸ Include hidden layers (`view.outlinesHiddenLayers`) outlines hidden layers too and Include object bounds (`view.outlinesObjectBounds`) draws the box of every layer whose outline isn't its box (groups, text, ellipses, vectors); what either draws can be clicked there (a hidden layer on its outline, a group on its box) and nothing else can; both are view settings like the other outline flags (per file, off in a new one, checked in the menu); the lines are light on a dark page (`outline_options_tests`). The main menu now shows a check mark beside every View toggle that is on | | Slices shown on the canvas (View ▸ Slices) | done | dashed rectangles | | Dragging a layer inside a group keeps it in the group | done | frames and auto layout take other drops | @@ -172,6 +172,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Frame and section names on the canvas rename on a double-click (#757): a field over the label with the name selected; Enter or a click elsewhere commits (`object.rename`, one undo step), Escape cancels. Rotated labels too. Not compared side by side with Figma | | 2026-10-17 | minor | The community batches #733, #735, #736, #729 and #753: submenus open after a short hover, keep their place along a safe triangle and reopen closed (main and context menus); the Actions palette's tabs, ranked search, keyboard highlight and recents; menu items that need a value ask for it, File ▸ Rename edits in place; Shift+I and Resources open Assets; a draggable Pages/Layers divider; Shift-drag moves the selection; a double-click near a thin stroke, merging dropped points and Flatten keeping open paths in vector editing; live font preview and the selection highlight after line breaks; the colour picker on a new fill or stroke; Paste over and Paste properties from the keyboard; Selection colors (partial: no library styles or variables there) and smart-selection spacing fields and canvas handles (partial: no reorder rings), both also checked by their author on macOS; the keyboard shortcuts panel closes on the home screen; Adwaita window controls on Wayland compositors without server decorations (checked by its author on GNOME; KDE not run). Checked in the app on Windows (submenus by their author), not side by side with Figma. Summary and hours unchanged | | 2026-10-15 | minor | Hex fields take CSS colour names (`red` → `FF0000`): fills, strokes, effects, grids, variables, backgrounds and the colour picker | | 2026-10-15 | minor | Typography: drag the line-height, letter-spacing and paragraph-spacing icons to scrub their values; shared numeric-field rates, resize cursor and one Undo step per gesture (#373). | From e03c2a65f7a28e43a18e6bb0196e9c3a8ad14bca Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 23:43:32 +0700 Subject: [PATCH 2/5] #766: check a label before a widget's clickable part on a double-click --- crates/engine/src/tools.rs | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index dba3eaf7..d5485c9c 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -1859,6 +1859,20 @@ impl Session { if self.tool.tool == ToolId::Pen { return self.pen_finish(); } + // A frame's or section's name label: rename it there (the canvas puts a field over it). + // Labels come before widgets, as on a press; the second press began a move: undo it. + if matches!(self.tool.tool, ToolId::Move | ToolId::Scale) + && !(ev.shift || ev.cmd || ev.alt) + && let Some(id) = label_at(&doc, page, &self.tool.labels, ev.p()) + { + self.tool.gesture = Gesture::None; + self.cancel_interaction()?; + if sel != [id] { + self.set_selection(vec![id]); + } + self.request_ui("canvas.renameLabel", serde_json::json!({"id": id})); + return Ok(()); + } // Quick clicks on a widget's clickable part are clicks, one by one. if matches!(self.tool.tool, ToolId::Move | ToolId::Scale) && !(ev.shift || ev.cmd || ev.alt) @@ -1875,17 +1889,6 @@ impl Session { self.tool.overlay.marquee = None; self.cancel_interaction()?; } - // A frame's or section's name label: rename it there (the canvas puts a field over it). - if matches!(self.tool.tool, ToolId::Move | ToolId::Scale) - && !(ev.shift || ev.cmd || ev.alt) - && let Some(id) = label_at(&doc, page, &self.tool.labels, ev.p()) - { - if sel != [id] { - self.set_selection(vec![id]); - } - self.request_ui("canvas.renameLabel", serde_json::json!({"id": id})); - return Ok(()); - } let Some(leaf) = self.hit(&doc, page, ev.p(), ev.tol() * 2.0) else { return Ok(()) }; let target = select_target(&doc, leaf, &sel, true); // Text: edit at the click. From f33ffb4367af045b202afac6024e01c06cbdb29e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 23:43:58 +0700 Subject: [PATCH 3/5] #766: keep the label rename in layers::Rename like the layers panel --- crates/ui-egui/src/frame_label_tests.rs | 4 ++-- crates/ui-egui/src/frame_labels.rs | 27 +++++++++---------------- crates/ui-egui/src/lib.rs | 9 +++------ 3 files changed, 14 insertions(+), 26 deletions(-) diff --git a/crates/ui-egui/src/frame_label_tests.rs b/crates/ui-egui/src/frame_label_tests.rs index d7f2371a..dbb981bd 100644 --- a/crates/ui-egui/src/frame_label_tests.rs +++ b/crates/ui-egui/src/frame_label_tests.rs @@ -108,7 +108,7 @@ fn double_clicking_a_label_renames_the_frame_there() { let before = u.app.session.doc().unwrap().world_bounds(a).unwrap(); double_click_label(&mut u, a); assert_eq!(u.app.session.selection(), vec![a]); - assert_eq!(u.app.ui.rename_label.map(|r| r.0), Some(a), "a field over the label"); + assert_eq!(u.app.ui.rename_label.map(|r| r.id), Some(a), "a field over the label"); // The name starts selected: typing replaces it, Enter keeps it. u.frame(vec![Event::Text("Sign in".into())]); u.frame(key(egui::Key::Enter)); @@ -127,7 +127,7 @@ fn double_clicking_a_label_renames_the_frame_there() { fn escape_leaves_a_label_rename_with_the_old_name() { let (mut u, _, b) = setup(); double_click_label(&mut u, b); - assert_eq!(u.app.ui.rename_label.map(|r| r.0), Some(b), "rotated labels rename too"); + assert_eq!(u.app.ui.rename_label.map(|r| r.id), Some(b), "rotated labels rename too"); u.frame(vec![Event::Text("Nope".into())]); u.frame(key(egui::Key::Escape)); u.frame(vec![]); diff --git a/crates/ui-egui/src/frame_labels.rs b/crates/ui-egui/src/frame_labels.rs index 43dd712d..79557141 100644 --- a/crates/ui-egui/src/frame_labels.rs +++ b/crates/ui-egui/src/frame_labels.rs @@ -151,18 +151,13 @@ pub fn draw(app: &FigApp, painter: &Painter, t: &Tokens, doc: &Document, id: Nod Some([area.left_top(), area.right_top(), area.right_bottom(), area.left_bottom()].map(|p| tl + rot * (p - tl))) } -/// Rename `id`'s name label on the canvas: a field over the label from the next frame on. -pub fn start_rename(app: &mut FigApp, id: NodeId) { - // The field takes the focus once it's drawn (as the pages list does). - app.ui.rename_label = Some((id, true)); -} - -/// The field over a label being renamed (see [`start_rename`]): Enter or a click elsewhere -/// renames the layer, Escape keeps the name. +/// The field over a frame's or section's name label being renamed (`UiState::rename_label`, from a +/// double-click on the label): Enter or a click elsewhere renames the layer, Escape keeps the name. +/// The rename ends when the field lets go of the keyboard or the label is gone. pub fn rename_field(app: &mut FigApp, ui: &mut egui::Ui) { - let Some((id, start)) = app.ui.rename_label else { return }; - let quad = app.session.tool.labels.iter().find(|(i, _)| *i == id).map(|(_, q)| q.map(|p| app.to_screen(p.x, p.y))); - let name = app.session.doc().ok().and_then(|d| d.node(id)).map(|n| n.name.clone()); + let Some(r) = app.ui.rename_label else { return }; + let quad = app.session.tool.labels.iter().find(|(i, _)| *i == r.id).map(|(_, q)| q.map(|p| app.to_screen(p.x, p.y))); + let name = app.session.doc().ok().and_then(|d| d.node(r.id)).map(|n| n.name.clone()); let (Some(quad), Some(name)) = (quad, name) else { app.ui.rename_label = None; return; @@ -171,14 +166,10 @@ pub fn rename_field(app: &mut FigApp, ui: &mut egui::Ui) { let font = regular(11.0); let text_w = ui.painter().layout_no_wrap(name.clone(), font.clone(), Color32::WHITE).size().x; let rect = Rect::from_min_size(pos2(area.min.x, area.max.y - 16.0), vec2((text_w + 24.0).max(area.width()).clamp(80.0, 480.0), 16.0)); - let (done, open) = crate::widgets::rename_field(ui, egui::Id::new(("frame_label_rename", id)), &name, rect, font, start); + let (done, open) = crate::widgets::rename_field(ui, egui::Id::new(("frame_label_rename", r.id)), &name, rect, font, r.start); + app.ui.rename_label = open.then_some(crate::layers::Rename { id: r.id, start: false }); if let Some(new) = done { - app.ui.rename_label = None; - app.run("object.rename", serde_json::json!({"ids": [id], "name": new})); - } else if !open && !start { - app.ui.rename_label = None; - } else { - app.ui.rename_label = Some((id, false)); + app.run("object.rename", serde_json::json!({"ids": [r.id], "name": new})); } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6141b3dd..80a4b784 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -430,9 +430,8 @@ pub struct UiState { /// The layer being renamed in the layers panel. pub rename: Option, pub rename_page: Option<(NodeId, String)>, - /// The frame or section whose name label on the canvas is being renamed (a double-click on - /// it), and whether the rename just started (its field takes the keyboard when next drawn). - pub rename_label: Option<(NodeId, bool)>, + /// The frame or section whose name label on the canvas is being renamed (a double-click on it). + pub rename_label: Option, pub main_menu: bool, pub palette: Option, /// The Actions palette's tab. @@ -966,9 +965,7 @@ impl FigApp { "contextMenu" => self.ui.key_menu = true, "selectArea" => self.start_key_box(), "canvas.renameLabel" => { - if let Some(id) = r.data.get("id").and_then(figcraft_engine::params::id_of) { - frame_labels::start_rename(self, id); - } + self.ui.rename_label = r.data.get("id").and_then(figcraft_engine::params::id_of).map(|id| layers::Rename { id, start: true }); } "layers.rename" => { let ids: Vec = From a7da760f9c21d9ec48b58e699cafae538f0f880e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 23:44:41 +0700 Subject: [PATCH 4/5] #766: drop the progress-doc edits (the maintainer adds those) --- docs/roadmap.md | 1 - docs/ui-parity.md | 5 ++--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index b89f1842..8d63a469 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -73,7 +73,6 @@ blockers. | Date | Change | Summary | |---|---|---| -| 2026-10-17 | minor | M1: double-clicking a frame's or section's name on the canvas renames it in place (#757; Enter commits, Escape cancels, one undo step). Numbers unchanged | | 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). Numbers unchanged. | | 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. | | 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index d2b3c9e2..0cccf008 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-10-17 · **Change:** minor (renaming a frame or section from its name on the canvas (#757); 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; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; 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) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (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; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; 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) · **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: @@ -98,7 +98,7 @@ interaction feel not yet compared. |---|---|---| | Pan/zoom: wheel, pinch, Space-drag, Shift+0/1/2, Cmd+0, `+`/`-` | done | Shift-wheel pans sideways, pinch and the zoom tool keep the point under the pointer; zoom steps, limits (2%–25,600%) and frame order pinned where the documentation is silent (`view_behaviour_tests`) | | Rulers and guides (drag out, move, remove; rotated frames; frame guides) | done | guides belong to the page or a top-level frame (a frame moved into another keeps its guides but they stop applying); a click selects one for Delete, the arrows or the context menu's Remove guide; Alt-drag drops a copy; Alt while dragging measures to the layers in the frame it crosses; ruler numbering checked by eye only | -| Frame labels (rotated, right-to-left), size badge | done | double-click a frame's or section's name to rename it in place: Enter or a click elsewhere keeps the name, Escape restores it, one undo step (#757, `frame_label_tests`) | +| Frame labels (rotated, right-to-left), size badge | done | | | Outline mode | done | Outlines, outlines for the selection, mask outlines, frame outlines; shapes are clicked on their outlines, reaching layers behind; View ▸ Outlines ▸ Include hidden layers (`view.outlinesHiddenLayers`) outlines hidden layers too and Include object bounds (`view.outlinesObjectBounds`) draws the box of every layer whose outline isn't its box (groups, text, ellipses, vectors); what either draws can be clicked there (a hidden layer on its outline, a group on its box) and nothing else can; both are view settings like the other outline flags (per file, off in a new one, checked in the menu); the lines are light on a dark page (`outline_options_tests`). The main menu now shows a check mark beside every View toggle that is on | | Slices shown on the canvas (View ▸ Slices) | done | dashed rectangles | | Dragging a layer inside a group keeps it in the group | done | frames and auto layout take other drops | @@ -172,7 +172,6 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| -| 2026-10-17 | minor | Frame and section names on the canvas rename on a double-click (#757): a field over the label with the name selected; Enter or a click elsewhere commits (`object.rename`, one undo step), Escape cancels. Rotated labels too. Not compared side by side with Figma | | 2026-10-17 | minor | The community batches #733, #735, #736, #729 and #753: submenus open after a short hover, keep their place along a safe triangle and reopen closed (main and context menus); the Actions palette's tabs, ranked search, keyboard highlight and recents; menu items that need a value ask for it, File ▸ Rename edits in place; Shift+I and Resources open Assets; a draggable Pages/Layers divider; Shift-drag moves the selection; a double-click near a thin stroke, merging dropped points and Flatten keeping open paths in vector editing; live font preview and the selection highlight after line breaks; the colour picker on a new fill or stroke; Paste over and Paste properties from the keyboard; Selection colors (partial: no library styles or variables there) and smart-selection spacing fields and canvas handles (partial: no reorder rings), both also checked by their author on macOS; the keyboard shortcuts panel closes on the home screen; Adwaita window controls on Wayland compositors without server decorations (checked by its author on GNOME; KDE not run). Checked in the app on Windows (submenus by their author), not side by side with Figma. Summary and hours unchanged | | 2026-10-15 | minor | Hex fields take CSS colour names (`red` → `FF0000`): fills, strokes, effects, grids, variables, backgrounds and the colour picker | | 2026-10-15 | minor | Typography: drag the line-height, letter-spacing and paragraph-spacing icons to scrub their values; shared numeric-field rates, resize cursor and one Undo step per gesture (#373). | From 76f0edce08dec541c8f266971608dbdb6891d890 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 23:44:41 +0700 Subject: [PATCH 5/5] #766: port the engine test, a section and click-elsewhere UI case, and the control-protocol note --- crates/engine/src/frame_label_tests.rs | 24 +++++++++++++++++++++++- crates/ui-egui/src/frame_label_tests.rs | 24 +++++++++++++++++++++++- docs/control-protocol.md | 3 ++- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/frame_label_tests.rs b/crates/engine/src/frame_label_tests.rs index 8e9ede7b..3da9522f 100644 --- a/crates/engine/src/frame_label_tests.rs +++ b/crates/engine/src/frame_label_tests.rs @@ -1,6 +1,7 @@ //! Frame name labels as pointer targets: a press on a label (its area as the canvas drew it, //! turned with a rotated frame) selects the frame and drags it in one undo step, hovering it -//! highlights the frame, and labels of locked, hidden, deleted or other pages' frames do nothing. +//! highlights the frame, a double-click asks the UI to rename it there, and labels of locked, +//! hidden, deleted or other pages' frames do nothing. use figcraft_doc::NodeId; use figcraft_geom::Point; @@ -90,3 +91,24 @@ fn labels_of_frames_that_cant_be_picked_do_nothing() { let flat = [Point::new(1.0, 1.0); 4]; assert_eq!(label_at(s.doc().unwrap(), page, &[(f, nan), (f, flat)], Point::new(1.0, 1.0)), None); } + +#[test] +fn double_clicking_a_label_asks_to_rename_its_frame() { + let (mut s, f, q) = setup(); + let c = center(&q); + let before = s.doc().unwrap().world_bounds(f).unwrap(); + let undo = s.ds().unwrap().history.undo.len(); + for kind in [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::DoubleClick] { + s.pointer(&ev(kind, c)).unwrap(); + } + assert_eq!(s.selection(), vec![f]); + let asked = s.ui_requests.iter().find(|r| r.kind == "canvas.renameLabel").map(|r| id_of(&r.data["id"])); + assert_eq!(asked, Some(Some(f)), "the UI renames the frame at its label"); + assert_eq!(s.doc().unwrap().world_bounds(f), Some(before), "not moved"); + assert_eq!(s.ds().unwrap().history.undo.len(), undo, "nothing to undo yet"); + // Other tools ignore the label. + s.ui_requests.clear(); + s.set_tool(crate::tools::ToolId::Hand); + s.pointer(&ev(PointerKind::DoubleClick, c)).unwrap(); + assert!(s.ui_requests.iter().all(|r| r.kind != "canvas.renameLabel")); +} diff --git a/crates/ui-egui/src/frame_label_tests.rs b/crates/ui-egui/src/frame_label_tests.rs index dbb981bd..687669e8 100644 --- a/crates/ui-egui/src/frame_label_tests.rs +++ b/crates/ui-egui/src/frame_label_tests.rs @@ -1,6 +1,6 @@ //! Frame name labels on the canvas, driven through egui headless: an upright frame's label sits //! just above its top-left corner, a rotated frame's runs along its turned top edge, and pressing -//! either label selects and drags its frame. +//! either label selects and drags its frame; double-clicking one renames the frame or section there. use egui::{Event, Pos2, pos2, vec2}; use figcraft_doc::NodeId; @@ -134,3 +134,25 @@ fn escape_leaves_a_label_rename_with_the_old_name() { assert_eq!(name(&u, b), "Turned"); assert!(u.app.ui.rename_label.is_none()); } + +#[test] +fn a_sections_label_renames_it_and_a_click_elsewhere_commits() { + let mut s = Session::new(); + let a = id(s.execute("node.create", &json!({"type": "section", "x": 0, "y": 0, "width": 300, "height": 200, "name": "Area"})).unwrap()); + s.execute("select.none", &json!({})).unwrap(); + let mut u = Ui::new(s); + u.frame(vec![]); + let undo = u.app.session.ds().unwrap().history.undo.len(); + double_click_label(&mut u, a); + assert_eq!(u.app.ui.rename_label.map(|r| r.id), Some(a), "a field over the section's label"); + assert_eq!(u.app.session.selection(), vec![a]); + u.frame(vec![Event::Text("Zone".into())]); + let at = mid(&label(&u, a)); + let away = pos2(at.x + 200.0, at.y + 500.0); + u.button(away, true); + u.button(away, false); + u.frame(vec![]); + assert_eq!(name(&u, a), "Zone", "a click on the canvas commits"); + assert!(u.app.ui.rename_label.is_none()); + assert_eq!(u.app.session.ds().unwrap().history.undo.len(), undo + 1, "one undo step"); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 3a06874e..0dc52a1e 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -198,7 +198,8 @@ A mouse gesture with the current tool. `kind`: `down`, `move`, `up`, `click`, `d (`x`, `y`) or screen pixels (`sx`, `sy`). `pressure` (0–1) is pen pressure (the Pencil records it); a drag's pressure goes from `pressure` to `pressureTo`. Returns `{selection, tool}`. With the Move and Scale tools, a press on a frame's or section's name label (as the canvas last drew it, along a rotated -frame's top edge) selects and drags that frame. +frame's top edge) selects and drags that frame; a double-click there renames the frame in place in +the desktop app (the name field opens over the label). ```json {"id":4,"method":"engine.execute","params":{"command":"tool.set","params":{"tool":"ellipse"}}}