From 36a7c6f892a789af5e0841cf88f47f4cc72dd91d Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 10 Oct 2026 17:05:55 +0700 Subject: [PATCH] M1: Tab renames the next layer (#85) While renaming a layer in the layers panel, Tab commits the name and renames the row below (Shift+Tab the row above) and the selection follows; Enter commits and Escape cancels without deselecting. Cmd/Ctrl+R (object.rename without a name) renames the topmost selected layer inline, opening its parents. The rename's field stays alive when its row scrolls out of view. Ctrl now matches Cmd shortcuts off macOS. The Inspect tab's empty-state hints wrap inside the panel. Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/engine/src/cmd/edit.rs | 8 +- crates/ui-egui/src/dev_mode.rs | 2 +- crates/ui-egui/src/layers.rs | 141 ++++++++++++++----- crates/ui-egui/src/layers_rename_tests.rs | 164 ++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 24 +++- crates/ui-egui/src/right.rs | 4 +- crates/ui-egui/src/shortcuts.rs | 4 +- crates/ui-egui/src/variables_panel.rs | 5 +- crates/ui-egui/src/widgets.rs | 35 +++++ 10 files changed, 338 insertions(+), 51 deletions(-) create mode 100644 crates/ui-egui/src/layers_rename_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 4cd90e1..596c1c2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -272,7 +272,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent). |---|---|---|---|---| | Canvas & navigation | 6 | 78% | rulers and guides on rotated frames, visual QA at extreme zooms | 1–2 | | Selection & transform | 8 | 78% | measuring and transform handles checked side by side, rotated groups and mixed selections QA | 2–4 | -| Layers panel | 5 | 86% | Tab to rename the next layer, visual QA of long lists | 1 | +| Layers panel | 5 | 88% | visual QA of long lists and deep trees | 0.5–1 | | Shapes & vector | 8 | 76% | live boolean editing, Design panel arc fields, shape builder on strokes and open paths, one-sided widths | 4–8 | | Paint | 7 | 78% | a wide-gamut canvas (egui draws sRGB, so Display P3 colours outside sRGB clip on screen; exports keep them), images' own embedded colour profiles (images are taken as sRGB) | 1–3 | | Strokes | 3 | 82% | stroke styles | 1–2 | diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index d824ea1..d006f89 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -138,9 +138,13 @@ pub fn specs() -> Vec { true, nudge_size ), - spec!("object.rename", "Rename", "{id?, name}", true, |s, p| { - let name = req_string("object.rename", p, "name")?; + spec!("object.rename", "Rename", "{ids?, name?} (without a name: rename the layer inline in the layers panel)", true, |s, p| { let ids = req_targets("object.rename", s, p)?; + if p.get("name").is_none() { + s.request_ui("layers.rename", json!({"ids": ids})); + return ok(); + } + let name = req_string("object.rename", p, "name")?; let doc = s.doc_mut()?; for i in ids { doc.get_mut(i)?.name = name.clone(); diff --git a/crates/ui-egui/src/dev_mode.rs b/crates/ui-egui/src/dev_mode.rs index 4fc965e..816a87e 100644 --- a/crates/ui-egui/src/dev_mode.rs +++ b/crates/ui-egui/src/dev_mode.rs @@ -270,7 +270,7 @@ pub fn ready_list(app: &mut FigApp, ui: &mut Ui) { }); }); if app.ui.dev.ready.is_empty() { - row(ui, 28.0, |ui| widgets::label(ui, "Nothing here yet. Set a frame's status to list it.", t.text_secondary)); + widgets::note(ui, "Nothing here yet. Set a frame's status to list it.", t.text_secondary); return; } let mut go = None; diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 9959fbd..7361122 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -308,18 +308,24 @@ pub(crate) fn rows_for(ui: &Ui, doc: &Arc, page: NodeId, expanded: &BT let c = d.get_temp_mut_or_default::(Id::new("layer_rows")); let fresh = c.doc.as_ref().is_some_and(|d| Arc::ptr_eq(d, doc)) && c.page == Some(page) && c.expanded == ex && c.filter == filter; if !fresh { - let mut rows = Vec::new(); - if filter.is_empty() { - collect(doc, page, 0, false, expanded, &mut rows); - } else { - matching(doc, page, filter, &mut rows); - } + let rows = listed_rows(doc, page, expanded, filter); *c = RowCache { doc: Some(doc.clone()), page: Some(page), expanded: ex, filter: filter.to_string(), rows: Arc::new(rows) }; } c.rows.clone() }) } +/// The rows to list, uncached: the expanded tree, or the layers matching `filter` (lowercase). +fn listed_rows(doc: &Document, page: NodeId, expanded: &BTreeSet, filter: &str) -> Vec { + let mut rows = Vec::new(); + if filter.is_empty() { + collect(doc, page, 0, false, expanded, &mut rows); + } else { + matching(doc, page, filter, &mut rows); + } + rows +} + /// Whether the children of `n` don't show on the canvas (given whether `n` itself doesn't). fn hides_children(n: &figcraft_doc::Node, dimmed: bool) -> bool { dimmed || !n.visible || n.hides_contents() @@ -367,12 +373,7 @@ pub(crate) fn key_nav(app: &mut FigApp, key: egui::Key, shift: bool) -> bool { let (doc, page, sel) = (ds.doc.clone(), ds.page, ds.selection.clone()); // The rows as listed: the expanded tree, or the search results. let filter = app.ui.layer_filter.trim().to_lowercase(); - let mut rows = Vec::new(); - if filter.is_empty() { - collect(&doc, page, 0, false, &app.ui.expanded, &mut rows); - } else { - matching(&doc, page, &filter, &mut rows); - } + let rows = listed_rows(&doc, page, &app.ui.expanded, &filter); let select = |app: &mut FigApp, ids: Vec, anchor: NodeId| { app.ui.layer_anchor = Some(anchor); app.run("select.set", json!({"ids": ids})); @@ -424,6 +425,61 @@ pub(crate) fn key_nav(app: &mut FigApp, key: egui::Key, shift: bool) -> bool { true } +/// A layer being renamed in the layers panel. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Rename { + pub id: NodeId, + /// The rename just started: its field takes the keyboard (and its row scrolls into view) the + /// next time the panel draws. + pub start: bool, +} + +/// Start renaming a layer in the layers panel (Cmd/Ctrl+R): the topmost listed of `ids`, which +/// becomes the selection. Shows the panel and opens the rows above the layer. +pub(crate) fn start_rename(app: &mut FigApp, ids: &[NodeId]) { + let Ok(ds) = app.session.ds() else { return }; + let (doc, page) = (ds.doc.clone(), ds.page); + for a in ids.iter().flat_map(|i| doc.ancestors(*i)).filter(|a| *a != page) { + app.ui.expanded.insert(a); + } + let rows = listed_rows(&doc, page, &app.ui.expanded, &app.ui.layer_filter.trim().to_lowercase()); + let Some(id) = rows.iter().map(|r| r.id).find(|r| ids.contains(r)) else { return }; + app.ui.left_tab = LeftTab::File; + app.ui.left_collapsed = false; + rename_layer(app, id); +} + +/// Rename `id` in its row, selected (the selection follows a rename that moves with Tab). +fn rename_layer(app: &mut FigApp, id: NodeId) { + app.ui.rename = Some(Rename { id, start: true }); + if app.session.selection() != [id] { + app.run("select.set", json!({"ids": [id]})); + } + app.ui.layer_anchor = Some(id); + // The rename scrolls its row into view itself. + app.ui.layers_seen_sel = app.session.selection(); +} + +fn rename_id(id: NodeId) -> Id { + Id::new(("rename", id)) +} + +/// Left edge of a row's contents (disclosure triangle) in the row's inner rect `rr`. +fn row_x0(rr: Rect, depth: usize) -> f32 { + rr.min.x + 4.0 + depth as f32 * 16.0 +} + +/// The name field of the row being renamed (row rect `r`): the new name once committed. The +/// rename ends when the field lets go of the keyboard. +fn rename_field(app: &mut FigApp, ui: &mut Ui, id: NodeId, depth: usize, name: &str, r: Rect) -> Option { + let rr = r.shrink2(vec2(8.0, 0.0)); + let rect = Rect::from_min_max(pos2(row_x0(rr, depth) + 32.0, r.min.y + 4.0), pos2(rr.max.x - 4.0, r.max.y - 4.0)); + let start = app.ui.rename.is_some_and(|x| x.start); + let (out, open) = crate::widgets::rename_field(ui, rename_id(id), name, rect, regular(11.0), start); + app.ui.rename = open.then_some(Rename { id, start: false }); + out +} + /// Expand or collapse `id` and everything inside it (Alt-click on a disclosure triangle). fn set_expanded_deep(doc: &Document, id: NodeId, open: bool, expanded: &mut BTreeSet) { let mut stack = vec![id]; @@ -507,11 +563,28 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { } let filter = app.ui.layer_filter.trim().to_lowercase(); let rows = rows_for(ui, &doc, page, &app.ui.expanded, &filter); - let scroll_to = if reveal { sel.first().and_then(|s| rows.iter().position(|r| r.id == *s)) } else { None }; + let mut scroll_to = if reveal { sel.first().and_then(|s| rows.iter().position(|r| r.id == *s)) } else { None }; + // A rename in progress ends when its layer isn't listed any more; Tab / Shift+Tab commit it and + // go on to the next / previous row. + let mut rename_row: Option<(usize, NodeId, usize)> = None; + let mut rename_next: Option = None; + if let Some(rn) = app.ui.rename { + match rows.iter().position(|r| r.id == rn.id) { + None => app.ui.rename = None, + Some(i) => { + if rn.start { + scroll_to = Some(i); + } else if let Some(back) = crate::widgets::take_tab(ui) { + ui.memory_mut(|m| m.surrender_focus(rename_id(rn.id).with("te"))); + rename_next = if back { i.checked_sub(1) } else { Some(i + 1) }.and_then(|j| rows.get(j)).map(|r| r.id); + } + rename_row = rows.get(i).map(|r| (i, r.id, r.depth)); + } + } + } let mut clicked: Option<(NodeId, egui::Modifiers)> = None; let mut toggle: Option = None; let mut deep: Option<(NodeId, bool)> = None; - let mut rename_next: Option<(usize, bool)> = None; let mut vis: Option = None; let mut lock: Option = None; let mut rename_done: Option<(NodeId, String)> = None; @@ -568,7 +641,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { // Stable widget ids for a row wherever the list is scrolled. band_ui.skip_ahead_auto_ids(first); let ui = &mut band_ui; - for (ri, row) in rows.iter().enumerate().take(last).skip(first) { + for row in rows.iter().take(last).skip(first) { let Some(n) = doc.node(row.id) else { continue }; let (r, resp) = ui.allocate_exact_size(vec2(w, ROW), Sense::click_and_drag()); let selected = sel.contains(&row.id); @@ -590,7 +663,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { if resp.hovered() { hovered_row = Some(row.id); } - let x0 = rr.min.x + 4.0 + row.depth as f32 * 16.0; + let x0 = row_x0(rr, row.depth); // Expand triangle. if row.has_kids { let tri = Rect::from_center_size(pos2(x0 + 6.0, r.center().y), vec2(14.0, 14.0)); @@ -616,14 +689,8 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { }; icons::paint(ui.painter(), Rect::from_center_size(pos2(x0 + 22.0, r.center().y), vec2(14.0, 14.0)), icon, icon_col); let name_x = x0 + 36.0; - let editing = app.ui.rename.as_ref().is_some_and(|(i, _)| *i == row.id); - if editing { - // Tab / Shift+Tab: commit and rename the next / previous layer. - if let Some(back) = ui.input(|i| i.key_pressed(egui::Key::Tab).then_some(i.modifiers.shift)) { - rename_next = Some((ri, back)); - } - let tr = Rect::from_min_max(pos2(name_x - 4.0, r.min.y + 4.0), pos2(rr.max.x - 4.0, r.max.y - 4.0)); - if let Some(new) = crate::widgets::text_field_inline(ui, Id::new(("rename", row.id)), &n.name, tr, regular(11.0)) { + if app.ui.rename.is_some_and(|rn| rn.id == row.id) { + if let Some(new) = rename_field(app, ui, row.id, row.depth, &n.name, r) { rename_done = Some((row.id, new)); } } else { @@ -669,8 +736,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { clicked = Some((row.id, ui.input(|i| i.modifiers))); } if resp.double_clicked() { - app.ui.rename = Some((row.id, n.name.clone())); - ui.memory_mut(|m| m.request_focus(Id::new(("rename", row.id)).with("te"))); + app.ui.rename = Some(Rename { id: row.id, start: true }); } if resp.secondary_clicked() { if !sel.contains(&row.id) { @@ -729,6 +795,15 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { } } } + // The field of a rename scrolled out of view stays (unseen), so the rename goes on. + if let Some((i, id, depth)) = rename_row.filter(|(i, ..)| !(first..last).contains(i)) + && let Some(n) = doc.node(id) + { + let r = Rect::from_min_size(pos2(band.min.x, top + i as f32 * pitch), vec2(w, ROW)); + if let Some(new) = rename_field(app, ui, id, depth, &n.name, r) { + rename_done = Some((id, new)); + } + } // Empty space below clears selection. let rest = Rect::from_x_y_ranges(band.x_range(), (top + total)..=(top + total.max(viewport.max.y))); if rest.height() > 0.0 && ui.interact(rest, Id::new("layers_empty_space"), Sense::click()).clicked() { @@ -787,20 +862,10 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { app.run("object.toggleLock", json!({"ids": [id]})); } if let Some((id, new)) = rename_done { - app.ui.rename = None; app.run("object.rename", json!({"ids": [id], "name": new})); } - if let Some((i, back)) = rename_next { - app.ui.rename = None; - let next = if back { i.checked_sub(1) } else { i.checked_add(1) }.and_then(|j| rows.get(j)); - if let Some(row) = next - && let Some(n) = doc.node(row.id) - { - app.ui.rename = Some((row.id, n.name.clone())); - app.run("select.set", json!({"ids": [row.id]})); - app.ui.layers_seen_sel = app.session.selection(); - ui.memory_mut(|m| m.request_focus(Id::new(("rename", row.id)).with("te"))); - } + if let Some(id) = rename_next { + rename_layer(app, id); } if let Some((id, m)) = clicked { app.ui.layers_focus = true; diff --git a/crates/ui-egui/src/layers_rename_tests.rs b/crates/ui-egui/src/layers_rename_tests.rs new file mode 100644 index 0000000..f5ae7ae --- /dev/null +++ b/crates/ui-egui/src/layers_rename_tests.rs @@ -0,0 +1,164 @@ +//! Renaming layers in the layers panel, driven through egui headless: a double-click or Cmd/Ctrl+R +//! starts it, Tab / Shift+Tab commit and go on to the next / previous row (the selection follows), +//! Enter commits and Escape cancels. + +use egui::{Event, Key, Modifiers}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; + +fn id(v: serde_json::Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn press(u: &mut Ui, key: Key, modifiers: Modifiers) { + u.frame(vec![ + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }, + Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }, + ]); +} + +fn type_text(u: &mut Ui, text: &str) { + u.frame(vec![Event::Text(text.into())]); +} + +fn name(u: &Ui, id: NodeId) -> String { + u.app.session.doc().unwrap().node(id).unwrap().name.clone() +} + +fn renaming(u: &Ui) -> Option { + u.app.ui.rename.map(|r| r.id) +} + +/// Rows top to bottom: `c` (rectangle), `frame` (collapsed, holding `child`), `a` (rectangle). +fn setup() -> (Ui, NodeId, NodeId, NodeId, NodeId) { + let mut s = Session::new(); + let a = id(s.execute("node.create", &json!({"type": "rectangle", "x": 0, "y": 0, "width": 10, "height": 10})).unwrap()); + let frame = id(s.execute("node.create", &json!({"type": "frame", "x": 50, "y": 0, "width": 100, "height": 100})).unwrap()); + let child = id(s.execute("node.create", &json!({"type": "ellipse", "parent": frame, "x": 10, "y": 10, "width": 10, "height": 10})).unwrap()); + let c = id(s.execute("node.create", &json!({"type": "rectangle", "x": 200, "y": 0, "width": 10, "height": 10})).unwrap()); + s.execute("select.none", &json!({})).unwrap(); + let mut u = Ui::new(s); + u.app.ui.expanded.clear(); + u.frame(vec![]); + (u, a, frame, child, c) +} + +/// Double-click `id`'s row and let its name field take the keyboard. +fn double_click(u: &mut Ui, id: NodeId) { + let p = u.row_at(id); + for _ in 0..2 { + u.button(p, true); + u.button(p, false); + } + u.frame(vec![]); + assert_eq!(renaming(u), Some(id)); + assert!(u.ctx.memory(|m| m.focused().is_some()), "the name field has the keyboard"); +} + +#[test] +fn tab_and_shift_tab_commit_and_rename_the_next_and_previous_rows() { + let (mut u, _, frame, _, c) = setup(); + double_click(&mut u, c); + type_text(&mut u, "Top"); + press(&mut u, Key::Tab, Modifiers::NONE); + assert_eq!(name(&u, c), "Top"); + assert_eq!(renaming(&u), Some(frame), "Tab renames the row below"); + assert_eq!(u.app.session.selection(), vec![frame], "the selection follows the rename"); + u.frame(vec![]); + // The whole name is selected: typing replaces it. + type_text(&mut u, "Box"); + press(&mut u, Key::Tab, Modifiers::SHIFT); + assert_eq!(name(&u, frame), "Box"); + assert_eq!(renaming(&u), Some(c), "Shift+Tab renames the row above"); + assert_eq!(u.app.session.selection(), vec![c]); + u.frame(vec![]); + type_text(&mut u, "Discarded"); + press(&mut u, Key::Escape, Modifiers::NONE); + assert_eq!(renaming(&u), None, "Escape ends the rename"); + assert_eq!(name(&u, c), "Top", "Escape keeps the old name"); + assert_eq!(u.app.session.selection(), vec![c], "Escape only ends the rename"); + // A later rename of the same row starts from its name again. + double_click(&mut u, c); + press(&mut u, Key::Enter, Modifiers::NONE); + assert_eq!(renaming(&u), None, "Enter ends the rename"); + assert_eq!(name(&u, c), "Top"); +} + +#[test] +fn tab_on_the_last_row_commits_and_ends() { + let (mut u, a, _, _, _) = setup(); + double_click(&mut u, a); + type_text(&mut u, "Bottom"); + press(&mut u, Key::Tab, Modifiers::NONE); + assert_eq!(name(&u, a), "Bottom"); + assert_eq!(renaming(&u), None); + assert_eq!(u.app.session.selection(), vec![a]); +} + +#[test] +fn cmd_r_renames_the_selection_and_opens_its_parents() { + // Cmd+R on macOS; Ctrl+R elsewhere, where egui sets both `ctrl` and `command`. + let ctrl = Modifiers { ctrl: true, command: true, ..Default::default() }; + for (k, held) in [Modifiers::MAC_CMD | Modifiers::COMMAND, ctrl].into_iter().enumerate() { + let (mut u, _, frame, child, _) = setup(); + u.app.session.execute("select.set", &json!({"ids": [child]})).unwrap(); + u.frame(vec![]); + // Collapsed again after the selection was revealed. + u.app.ui.expanded.clear(); + u.frame(vec![]); + press(&mut u, Key::R, held); + assert!(u.app.ui.expanded.contains(&frame), "the renamed layer's parent opens ({k})"); + assert_eq!(renaming(&u), Some(child), "({k})"); + u.frame(vec![]); + type_text(&mut u, "Dot"); + press(&mut u, Key::Enter, Modifiers::NONE); + assert_eq!(name(&u, child), "Dot", "({k})"); + assert_eq!(renaming(&u), None); + } +} + +#[test] +fn rename_without_a_name_picks_the_topmost_selected_row() { + let (mut u, a, _, _, c) = setup(); + let before = name(&u, a); + u.app.run("object.rename", json!({"ids": [a, c]})); + assert_eq!(renaming(&u), Some(c), "the topmost listed of the layers"); + assert_eq!(u.app.session.selection(), vec![c]); + assert_eq!(name(&u, a), before, "nothing renamed yet"); + // Headless, the command changes nothing and leaves no undo step. + let mut s = Session::new(); + let r = id(s.execute("node.create", &json!({"type": "rectangle", "x": 0, "y": 0, "width": 10, "height": 10})).unwrap()); + let doc = s.ds().unwrap().doc.clone(); + s.execute("object.rename", &json!({"ids": [r]})).unwrap(); + assert!(std::sync::Arc::ptr_eq(&doc, &s.ds().unwrap().doc)); +} + +#[test] +fn a_rename_scrolled_out_of_view_goes_on() { + let mut s = Session::new(); + let ids: Vec = (0..60) + .map(|k| id(s.execute("node.create", &json!({"type": "rectangle", "x": k * 20, "y": 0, "width": 10, "height": 10})).unwrap())) + .collect(); + s.execute("select.none", &json!({})).unwrap(); + let mut u = Ui::new(s); + // The top row (the last created) is listed first; scroll it away while renaming it. + let top = *ids.last().unwrap(); + double_click(&mut u, top); + let p = egui::pos2(150.0, 400.0); + let wheel = Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -400.0), + phase: egui::TouchPhase::Move, + modifiers: Modifiers::NONE, + }; + for _ in 0..10 { + u.frame(vec![Event::PointerMoved(p), wheel.clone()]); + } + type_text(&mut u, "Far"); + assert_eq!(renaming(&u), Some(top), "still renaming"); + press(&mut u, Key::Enter, Modifiers::NONE); + assert_eq!(name(&u, top), "Far"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 46845c0..7849b64 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -54,6 +54,8 @@ mod layers_drag_tests; #[cfg(test)] mod layers_keys_tests; #[cfg(test)] +mod layers_rename_tests; +#[cfg(test)] mod panel_reorder_tests; #[cfg(test)] mod perf_tests; @@ -148,7 +150,8 @@ pub struct UiState { pub left_tab: LeftTab, pub pages_open: bool, pub expanded: BTreeSet, - pub rename: Option<(NodeId, String)>, + /// The layer being renamed in the layers panel. + pub rename: Option, pub rename_page: Option<(NodeId, String)>, pub main_menu: bool, pub palette: Option, @@ -296,6 +299,8 @@ pub struct FigApp { fonts_installed: bool, /// System faces for scripts the built-in fonts lack were added (after the font scan). script_fonts: bool, + /// A field had the keyboard when the last frame ended. + field_focus: bool, /// Control-channel requests waiting for the next frame. pub control_queue: Vec, /// Input events from the control channel (`ui.input`) for the next frame's raw input. @@ -312,6 +317,7 @@ impl FigApp { worker: None, fonts_installed: false, script_fonts: false, + field_focus: false, control_queue: Vec::new(), control_input: Vec::new(), } @@ -435,6 +441,11 @@ impl FigApp { "preview.close" => self.ui.preview = None, "open.help" => self.services.open_url("https://getartcraft.com/apps/figcraft"), "layers.collapseAll" => self.ui.expanded.clear(), + "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(); + layers::start_rename(self, &ids); + } "comment.open" => { if let Some(id) = r.data.get("id").and_then(Value::as_u64) { self.ui.comments.show(id); @@ -547,8 +558,9 @@ impl FigApp { } let t = theme::Tokens::get(&ctx); // A field that had the keyboard when the frame began keeps its keys from the shortcuts - // even if it lets go during the frame (Enter commits it). - let typing = ctx.memory(|m| m.focused().is_some()); + // even if it lets go during the frame (Enter commits it), or as it began (egui drops the + // focus on Escape before the frame, which cancels the field's edit and nothing else). + let typing = std::mem::take(&mut self.field_focus) || ctx.memory(|m| m.focused().is_some()); // Control requests run first so their results reflect this frame. control::drain(self, &ctx); if self.ui.title_bar == TitleBar::Custom { @@ -615,6 +627,12 @@ impl FigApp { self.end_unwanted_text_preview(&ctx); dialogs::show(self, &ctx); self.toast_ui(&ctx); + // A rename started after the layers panel drew (Cmd/Ctrl+R, a double-click) opens its field + // next frame. + if self.ui.rename.is_some_and(|r| r.start) && !hidden && !self.ui.left_collapsed && self.ui.left_tab == LeftTab::File { + ctx.request_repaint(); + } + self.field_focus = ctx.memory(|m| m.focused().is_some()); #[cfg(not(target_arch = "wasm32"))] { self.ui.frame_ms = t0.elapsed().as_secs_f64() * 1000.0; diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index 8b15f6a..4a60dc8 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -1809,8 +1809,8 @@ fn inspect(app: &mut FigApp, ui: &mut Ui) { let sel = ds.selection.clone(); let Some(first) = sel.first().copied() else { widgets::section_header(ui, "Inspect", &[], false); - row(ui, 32.0, |ui| widgets::label(ui, "Select a layer to see its properties and code", t.text_secondary)); - row(ui, 32.0, |ui| widgets::label(ui, "Hover other layers to measure the distance", t.text_secondary)); + widgets::note(ui, "Select a layer to see its properties and code", t.text_secondary); + widgets::note(ui, "Hover other layers to measure the distance", t.text_secondary); crate::dev_mode::ready_list(app, ui); return; }; diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 67a3a03..494f093 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -44,8 +44,10 @@ fn key_of(k: &str) -> Option { }) } +/// Whether the modifiers held match a shortcut's. Off macOS, Ctrl is the command key (egui sets +/// both `ctrl` and `command` for it), so a held command stands for Ctrl too. fn mods_match(want: Modifiers, got: Modifiers) -> bool { - want.shift == got.shift && want.command == got.command && want.alt == got.alt && (want.ctrl == got.ctrl || got.mac_cmd) + want.shift == got.shift && want.command == got.command && want.alt == got.alt && (want.ctrl == got.ctrl || got.mac_cmd || got.command) } /// Run the shortcuts this frame's keys press. `typing`: a field had the keyboard when the frame diff --git a/crates/ui-egui/src/variables_panel.rs b/crates/ui-egui/src/variables_panel.rs index fc2ee4b..1e90650 100644 --- a/crates/ui-egui/src/variables_panel.rs +++ b/crates/ui-egui/src/variables_panel.rs @@ -199,9 +199,8 @@ fn start_rename(ui: &Ui, st: &mut Editor, target: Rename) { /// The inline editor of a rename in progress: the new name once committed. The rename ends when /// the editor closes. fn rename_field(ui: &mut Ui, st: &mut Editor, target: &Rename, value: &str, rect: Rect, font: egui::FontId) -> Option { - let id = rename_id(target); - let out = widgets::text_field_inline(ui, id, value, rect, font); - if out.is_some() || !ui.memory(|m| m.has_focus(id.with("te"))) { + let (out, open) = widgets::rename_field(ui, rename_id(target), value, rect, font, false); + if !open { st.rename = None; } out diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index b39aeb5..d5691f8 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -710,6 +710,41 @@ pub fn text_field_inline(ui: &mut Ui, id: Id, value: &str, rect: Rect, font: egu } } +/// [`text_field_inline`] for a rename in progress: it takes the keyboard, with the whole name +/// selected, when `start`, and the rename is over once it lets go. Returns the new name once +/// committed and whether the rename goes on. +pub fn rename_field(ui: &mut Ui, id: Id, value: &str, rect: Rect, font: egui::FontId, start: bool) -> (Option, bool) { + let te_id = id.with("te"); + if start { + ui.data_mut(|d| d.remove::(id.with("buf"))); + ui.memory_mut(|m| m.request_focus(te_id)); + } + let out = text_field_inline(ui, id, value, rect, font); + let open = out.is_none() && ui.memory(|m| m.has_focus(te_id)); + (out, open) +} + +/// Tab (`Some(false)`) or Shift+Tab (`Some(true)`) pressed this frame, taken from the input and +/// from egui's focus traversal, for a field that moves on to the next item itself. +pub fn take_tab(ui: &Ui) -> Option { + let back = ui.input_mut(|i| { + if i.consume_key(egui::Modifiers::SHIFT, egui::Key::Tab) { + Some(true) + } else { + i.consume_key(egui::Modifiers::NONE, egui::Key::Tab).then_some(false) + } + })?; + ui.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); + Some(back) +} + +/// A short secondary note (an empty list's hint) that wraps inside the panel's side padding. +pub fn note(ui: &mut Ui, text: &str, color: Color32) { + egui::Frame::NONE.inner_margin(egui::Margin::symmetric(PAD as i8, 6)).show(ui, |ui| { + ui.add(egui::Label::new(egui::RichText::new(text).font(regular(11.0)).color(color)).wrap()); + }); +} + /// A small grey label above a row of fields ("Position", "Dimensions"…). With two labels the /// second starts at the second field column. pub fn sublabels(ui: &mut Ui, a: &str, b: Option<&str>) {