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>) {