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/engine/src/tools.rs b/crates/engine/src/tools.rs index a5b61ca2..00599afc 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) 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..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; @@ -79,3 +79,80 @@ 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.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)); + 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.id), 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()); +} + +#[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/crates/ui-egui/src/frame_labels.rs b/crates/ui-egui/src/frame_labels.rs index 2718fddd..79557141 100644 --- a/crates/ui-egui/src/frame_labels.rs +++ b/crates/ui-egui/src/frame_labels.rs @@ -151,6 +151,28 @@ 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))) } +/// 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(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; + }; + 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", 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.run("object.rename", serde_json::json!({"ids": [r.id], "name": new})); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5d1be479..bef5de63 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -434,6 +434,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). + pub rename_label: Option, pub main_menu: bool, pub palette: Option, /// The Actions palette's tab. @@ -591,6 +593,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, @@ -965,6 +968,9 @@ impl FigApp { "layers.collapseAll" => layers::collapse_all(self), "contextMenu" => self.ui.key_menu = true, "selectArea" => self.start_key_box(), + "canvas.renameLabel" => { + 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 = r.data.get("ids").and_then(Value::as_array).into_iter().flatten().filter_map(figcraft_engine::params::id_of).collect(); 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"}}}