diff --git a/crates/engine/tests/artboard_click.rs b/crates/engine/tests/artboard_click.rs index d35cffeb2..ff0066154 100644 --- a/crates/engine/tests/artboard_click.rs +++ b/crates/engine/tests/artboard_click.rs @@ -1,9 +1,11 @@ //! Artboard clicks fit the object without changing its art or selection, and are one undo step. +//! The second click of a double-click on an object does not fit another artboard, and that +//! double-click does not open Artboard Options. Empty artboard space still does. #![allow(clippy::unwrap_used)] use serde_json::json; use vectorcraft_doc::NodeId; -use vectorcraft_engine::{Session, ViewInfo}; +use vectorcraft_engine::{Session, UiRequest, ViewInfo}; use vectorcraft_geom::Rect; use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; @@ -20,6 +22,27 @@ fn pointer(s: &mut Session, kind: PointerKind, x: f64, y: f64) { s.pointer(&PointerEvent::new(kind, x, y), ViewInfo::default()).unwrap(); } +fn pointer_reqs(s: &mut Session, kind: PointerKind, x: f64, y: f64) -> Vec { + s.pointer(&PointerEvent::new(kind, x, y), ViewInfo::default()).unwrap() +} + +fn is_dialog(r: &UiRequest) -> bool { + matches!(r, UiRequest::Dialog(_, _)) +} + +/// The two clicks of a double-click, then the double-click. `suppress` is what the canvas does on +/// the second release, so that click does not fit a second artboard. +fn double_click(s: &mut Session, x: f64, y: f64, suppress: bool) -> Vec { + pointer(s, PointerKind::Down, x, y); + pointer(s, PointerKind::Up, x, y); + pointer(s, PointerKind::Down, x, y); + if suppress { + s.set_tool_option("suppressFitClick", &json!(true)); + } + pointer(s, PointerKind::Up, x, y); + pointer_reqs(s, PointerKind::DoubleClick, x, y) +} + #[test] fn clicks_inside_and_outside_an_artboard_fit_the_visible_object_in_one_undo_step() { for (x, y) in [(100.0, 100.0), (650.0, 700.0)] { @@ -128,3 +151,85 @@ fn locked_hidden_modifier_handle_and_drag_gestures_keep_their_existing_meaning() assert_eq!(s.doc().unwrap().doc.artboards.len(), 1, "a drag returning to its start does not become an object click"); } } + +#[test] +fn a_second_single_click_on_the_same_object_fits_another_artboard() { + let (mut s, _) = fixture(100.0, 100.0); + pointer(&mut s, PointerKind::Down, 140.0, 125.0); + pointer(&mut s, PointerKind::Up, 140.0, 125.0); + pointer(&mut s, PointerKind::Down, 140.0, 125.0); + pointer(&mut s, PointerKind::Up, 140.0, 125.0); + assert_eq!(s.doc().unwrap().doc.artboards.len(), 3, "each single click fits one artboard"); +} + +#[test] +fn a_double_click_on_an_object_fits_once_and_skips_options() { + let (mut s, id) = fixture(100.0, 100.0); + s.execute("artboard.new", &json!({"x": 2000.0, "y": 0.0, "width": 80.0, "height": 40.0, "name": "Far"})).unwrap(); + s.execute("artboard.new", &json!({"x": 205.0, "y": 95.0, "width": 40.0, "height": 40.0, "name": "Touch"})).unwrap(); + s.execute("artboard.new", &json!({"x": 180.0, "y": 150.0, "width": 40.0, "height": 40.0, "name": "Overlap"})).unwrap(); + let before: Vec<_> = s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect(); + let layers = json!(s.doc().unwrap().doc.layers); + let sel = s.doc().unwrap().selection.clone(); + let history = s.doc().unwrap().history.undo.len(); + let reqs = double_click(&mut s, 140.0, 125.0, true); + assert!(reqs.iter().all(|r| !is_dialog(r)), "no Artboard Options on the object, {reqs:?}"); + let st = s.doc().unwrap(); + let fitted = Rect::new(95.0, 95.0, 205.0, 165.0); + let rects: Vec<_> = st.doc.artboards.iter().map(|a| a.rect).collect(); + assert_eq!(rects.iter().filter(|r| **r == fitted).count(), 1, "the second click does not fit another: {rects:?}"); + for kept in &before { + assert!(rects.contains(kept), "existing artboards stay: missing {kept:?} in {rects:?}"); + } + assert_eq!(rects.len(), before.len() + 1); + assert_eq!(st.doc.bounds_of(&[id], true), Some(fitted)); + assert_eq!(json!(st.doc.layers), layers); + assert_eq!(json!(st.selection), json!(sel)); + assert_eq!(st.history.undo.len(), history + 1, "only the first click's new artboard"); + s.execute("edit.undo", &json!({})).unwrap(); + let after: Vec<_> = s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect(); + assert_eq!(after, before); + assert_eq!(json!(s.doc().unwrap().selection), json!(sel)); +} + +#[test] +fn without_the_canvas_flag_the_second_release_still_fits_another_artboard() { + let (mut s, _) = fixture(100.0, 100.0); + let reqs = double_click(&mut s, 140.0, 125.0, false); + assert!(reqs.iter().all(|r| !is_dialog(r)), "{reqs:?}"); + let fitted = Rect::new(95.0, 95.0, 205.0, 165.0); + let n = s.doc().unwrap().doc.artboards.iter().filter(|a| a.rect == fitted).count(); + assert_eq!(n, 2, "each release fits until the canvas suppresses the second one"); + assert!(s.doc().unwrap().doc.artboards.iter().any(|a| a.rect == Rect::new(0.0, 0.0, 500.0, 500.0))); +} + +#[test] +fn a_double_click_on_an_object_does_not_open_options_or_change_the_document() { + let (mut s, _) = fixture(100.0, 100.0); + let before = s.doc().unwrap().doc.clone(); + let history = s.doc().unwrap().history.undo.len(); + let reqs = pointer_reqs(&mut s, PointerKind::DoubleClick, 140.0, 125.0); + assert!(reqs.iter().all(|r| !is_dialog(r)), "{reqs:?}"); + assert_eq!(json!(s.doc().unwrap().doc), json!(before)); + assert_eq!(s.doc().unwrap().history.undo.len(), history); +} + +#[test] +fn double_click_on_an_artboard_handle_opens_options() { + let (mut s, _) = fixture(0.0, 0.0); + let before = s.doc().unwrap().doc.clone(); + let reqs = pointer_reqs(&mut s, PointerKind::DoubleClick, 0.0, 0.0); + assert!(reqs.iter().any(is_dialog), "a handle keeps Artboard Options, {reqs:?}"); + assert_eq!(json!(s.doc().unwrap().doc), json!(before)); +} + +#[test] +fn double_click_on_empty_artboard_space_opens_options_and_changes_nothing() { + let (mut s, _) = fixture(100.0, 100.0); + let before = s.doc().unwrap().doc.clone(); + let history = s.doc().unwrap().history.undo.len(); + let reqs = pointer_reqs(&mut s, PointerKind::DoubleClick, 300.0, 300.0); + assert!(reqs.iter().any(is_dialog), "{reqs:?}"); + assert_eq!(json!(s.doc().unwrap().doc), json!(before)); + assert_eq!(s.doc().unwrap().history.undo.len(), history); +} diff --git a/crates/tools/src/xform/artboard.rs b/crates/tools/src/xform/artboard.rs index 8680e868b..a40f9d04b 100644 --- a/crates/tools/src/xform/artboard.rs +++ b/crates/tools/src/xform/artboard.rs @@ -1,8 +1,10 @@ //! Artboard tool (Shift+O). //! //! Click an object to create an artboard fitted to its visible bounds; drag still moves the -//! containing artboard or draws one on the pasteboard. Click an empty part of an artboard to -//! make it active (dashed bounds, 8 handles and its name); Shift-click adds +//! containing artboard or draws one on the pasteboard. The second click of a double-click on an +//! object does not fit another artboard, and that double-click does not open Artboard Options. +//! Double-click an empty part of an artboard to open Artboard Options. Click an empty +//! part of an artboard to make it active (dashed bounds, 8 handles and its name); Shift-click adds //! artboards to the selection or takes them out (dashed bounds), and a drag or Delete then acts on //! all of them (#1139). Drag inside moves //! it (with its artwork when the `moveArt` option is on, Shift constrains; Alt moves a copy and @@ -73,6 +75,9 @@ pub struct ArtboardTool { guides: Vec, /// Smart Guides for the corners of a new artboard. draw: DrawSnap, + /// The canvas sets this on the second click of a double-click, so that release does not fit + /// a second artboard before [`PointerKind::DoubleClick`] arrives. + suppress_fit: bool, } impl Default for ArtboardTool { @@ -89,6 +94,7 @@ impl Default for ArtboardTool { copy_targets: None, guides: vec![], draw: DrawSnap::default(), + suppress_fit: false, } } } @@ -195,11 +201,19 @@ impl ArtboardTool { /// An artboard's shortest side (as `artboard.setProps` keeps it). const MIN_SIDE: f64 = 1.0; +fn rect_params(r: Rect) -> Value { + json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height() }) +} + fn create_artboard(r: Rect) -> Vec { - vec![ - Action::Exec("artboard.new".into(), json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height() })), - Action::Notify("created".into()), - ] + vec![Action::Exec("artboard.new".into(), rect_params(r)), Action::Notify("created".into())] +} + +/// The visible bounds of the topmost object under `p`, when they are large enough to be an artboard. +fn fittable_bounds(cx: &ToolContext, p: Point) -> Option { + vectorcraft_doc::hit::hit_test(cx.doc, p, cx.hit_options()) + .and_then(|h| cx.doc.bounds_of(&[h.top_object(cx.isolation)], true)) + .filter(|r| r.width() >= MIN_SIDE && r.height() >= MIN_SIDE && [r.x0, r.y0, r.x1, r.y1].iter().all(|n| n.is_finite())) } /// `r` grown about `anchor` until both sides are at least [`MIN_SIDE`], by one factor, so a @@ -233,6 +247,10 @@ impl Tool for ArtboardTool { fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { let p = ev.pos; let m = ev.mods; + // Set by the canvas on the second click of a double-click, and only for the release that + // follows. Any other event drops it, so a later single click still fits. + let skip_fit = ev.kind == PointerKind::Up && self.suppress_fit; + self.suppress_fit = false; if ev.kind == PointerKind::Drag && self.click_art.is_some_and(|(start, _)| m != Mods::default() || p.distance(start) >= cx.tol(3.0)) { self.click_art = None; } @@ -246,12 +264,9 @@ impl Tool for ArtboardTool { return vec![]; } if m == Mods::default() { - self.click_art = vectorcraft_doc::hit::hit_test(cx.doc, p, cx.hit_options()) - .and_then(|h| cx.doc.bounds_of(&[h.top_object(cx.isolation)], true)) - // Match Fit to Selected Art, and do not stretch zero-sized art to the - // artboard command's minimum side or pass non-finite bounds to it. - .filter(|r| r.width() >= MIN_SIDE && r.height() >= MIN_SIDE && [r.x0, r.y0, r.x1, r.y1].iter().all(|n| n.is_finite())) - .map(|r| (p, r)); + // Match Fit to Selected Art, and do not stretch zero-sized art to the + // artboard command's minimum side or pass non-finite bounds to it. + self.click_art = fittable_bounds(cx, p).map(|r| (p, r)); } if let Some(i) = cx.doc.artboard_at(p) && let Some(a) = cx.doc.artboards.get(i) @@ -327,6 +342,7 @@ impl Tool for ArtboardTool { let click_art = self.click_art; self.end_drag(); if m == Mods::default() + && !skip_fit && let Some((start, r)) = click_art && p.distance(start) < cx.tol(3.0) { @@ -357,6 +373,12 @@ impl Tool for ArtboardTool { } (PointerKind::DoubleClick, _) => { self.end_drag(); + // Fitting already happened on the first click. On an object this gesture does not + // open Artboard Options; a handle, a modifier, or empty artboard space still does. + let on_handle = self.active_rect(cx).and_then(|r| hit_handle(r, p, cx.tol(5.0))).is_some(); + if m == Mods::default() && !on_handle && fittable_bounds(cx, p).is_some() { + return vec![]; + } match cx.doc.artboards.get(self.active) { Some(a) => { let mut v = rect_json(self.active, a.rect); @@ -485,6 +507,8 @@ impl Tool for ArtboardTool { } "moveArt" => self.move_art = value.as_bool().unwrap_or(true), "scaleArt" => self.scale_art = value.as_bool().unwrap_or(false), + // Not one of the saved options: the canvas sets it for the release of a double-click. + "suppressFitClick" => self.suppress_fit = value.as_bool().unwrap_or(false), _ => {} } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 69bd00c9a..dd197f9cb 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -428,6 +428,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let active_ab = crate::panels::artboards::selected(app, doc.artboards.len()); let artboard_tool = app.session.tool_id() == "artboard"; let tool_labelled = artboard_tool.then(|| app.session.tool_options()["active"].as_u64()).flatten(); + let editing_name = ui.data(|d| d.get_temp::(name_edit_id()).map(|e| e.index)); for (i, ab) in doc.artboards.iter().enumerate() { let c = if i == active_ab { Color32::from_gray(0) } else { Color32::from_gray(120) }; painter.add(Shape::closed_line(xf.quad(ab.rect), Stroke::new(if i == active_ab { 1.0 } else { 0.6 }, c))); @@ -435,7 +436,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if doc.setup.has_bleed() { painter.add(Shape::closed_line(xf.quad(doc.setup.bleed_rect(ab.rect)), Stroke::new(1.0, t.bleed))); } - if (artboard_tool || doc.artboards.len() > 1) && tool_labelled != u64::try_from(i).ok() { + if editing_name != Some(i) && (artboard_tool || doc.artboards.len() > 1) && tool_labelled != u64::try_from(i).ok() { // At the artboard's own top-left corner, which a rotated view turns away from its // bounding box's (#1095). let corner = xf.to_screen(Point::new(ab.rect.x0, ab.rect.y0)); @@ -514,6 +515,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if app.ui.screen_mode < 3 { crate::free_transform::show(app, ui, xf.rect); } + artboard_name_editor(app, ui, &xf); // Cursor. if resp.hovered() { let m = ui.input(|i| i.modifiers); @@ -698,26 +700,32 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: { ui.ctx().memory_mut(|mem| mem.stop_text_input()); app.ui.flyout = None; - let d = if crate::touch::finger_navigates_only() { - // A finger (or a palm) once a pen has been seen: it navigates, it doesn't draw. - Drag::Gesture - } else if zoom_mode { - if animated_zoom(&app.session.prefs) { Drag::ZoomScrub { start: p, held: 0.0, moved: false } } else { Drag::ZoomBox { start: p } } - } else if pan_mode { - Drag::Pan { start: p, center: v.center, middle: false } - } else if tool == "rotateView" { - let c = rect.center(); - Drag::RotateView { start_angle: (p.y - c.y).atan2(p.x - c.x) as f64, start_rot: v.rotation } + // The artboard's name sits above its corner. A press there renames on a double-click + // and does not start a tool gesture (a fitted artboard, a new one, Artboard Options). + if let Some(i) = artboard_name_at(app, ui, &xf, p) { + ui.data_mut(|dd| dd.insert_temp(name_press_id(), i)); } else { - // Cmd with another tool drags with the selection tool used last (the session lends it). - let ev = PointerEvent { kind: PointerKind::Down, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, true) }; - dispatch(app, &ev, view); - Drag::Tool - }; - ui.data_mut(|dd| { - dd.insert_temp(drag_id(), d); - dd.insert_temp(drag_pos_id(), p); - }); + let d = if crate::touch::finger_navigates_only() { + // A finger (or a palm) once a pen has been seen: it navigates, it doesn't draw. + Drag::Gesture + } else if zoom_mode { + if animated_zoom(&app.session.prefs) { Drag::ZoomScrub { start: p, held: 0.0, moved: false } } else { Drag::ZoomBox { start: p } } + } else if pan_mode { + Drag::Pan { start: p, center: v.center, middle: false } + } else if tool == "rotateView" { + let c = rect.center(); + Drag::RotateView { start_angle: (p.y - c.y).atan2(p.x - c.x) as f64, start_rot: v.rotation } + } else { + // Cmd with another tool drags with the selection tool used last (the session lends it). + let ev = PointerEvent { kind: PointerKind::Down, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, true) }; + dispatch(app, &ev, view); + Drag::Tool + }; + ui.data_mut(|dd| { + dd.insert_temp(drag_id(), d); + dd.insert_temp(drag_pos_id(), p); + }); + } } else if drag.is_none() && resp.hovered() && pointer.button_pressed(egui::PointerButton::Middle) @@ -819,6 +827,12 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: } Drag::ZoomScrub { .. } => {} Drag::Tool => { + // The second click of a double-click: don't fit another artboard on this release. + // A double-click on an object does not open Artboard Options. Empty artboard + // space still does. + if resp.double_clicked() && app.session.tool_id() == "artboard" { + app.session.set_tool_option("suppressFitClick", &json!(true)); + } let ev = PointerEvent { kind: PointerKind::Up, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, false) }; dispatch(app, &ev, view); } @@ -832,18 +846,27 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: let ev = PointerEvent { kind: PointerKind::Move, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 }; dispatch(app, &ev, view); } + if pointer.primary_released() + && let Some(i) = ui.data_mut(|d| d.remove_temp::(name_press_id())) + { + select_named_artboard(app, i); + } if resp.double_clicked() && let Some(p) = at { - let ev = PointerEvent { kind: PointerKind::DoubleClick, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 }; - let before = app.session.tool_id().to_string(); - dispatch(app, &ev, view); - // A double-click on type switched a selection tool to the Type tool: the caret goes where - // the type was clicked, so typing edits it at once. - if before != "type" && app.session.tool_id() == "type" { - app.session.set_tool_option("prevTool", &json!(before)); - for kind in [PointerKind::Down, PointerKind::Up] { - dispatch(app, &PointerEvent { kind, ..ev }, view); + if let Some(i) = artboard_name_at(app, ui, &xf, p) { + begin_artboard_name_edit(ui, app, i); + } else { + let ev = PointerEvent { kind: PointerKind::DoubleClick, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 }; + let before = app.session.tool_id().to_string(); + dispatch(app, &ev, view); + // A double-click on type switched a selection tool to the Type tool: the caret goes where + // the type was clicked, so typing edits it at once. + if before != "type" && app.session.tool_id() == "type" { + app.session.set_tool_option("prevTool", &json!(before)); + for kind in [PointerKind::Down, PointerKind::Up] { + dispatch(app, &PointerEvent { kind, ..ev }, view); + } } } } @@ -2286,16 +2309,163 @@ fn ime_output(app: &mut VectorcraftApp, ctx: &egui::Context, xf: &Xf) { /// out rather than spilling over its neighbours. const MIN_LABELLED_ARTBOARD: f32 = 24.0; -/// Artboard `name` above its top-left `corner` on screen, no wider than the artboard (`width` on -/// screen): a longer name ends in "…", and an artboard too narrow shows none (#949). -fn artboard_label(painter: &egui::Painter, corner: Pos2, width: f32, name: &str, color: Color32) { +fn name_press_id() -> egui::Id { + egui::Id::new("canvas-artboard-name-press") +} + +fn name_edit_id() -> egui::Id { + egui::Id::new("canvas-artboard-name-edit") +} + +/// A name being typed above an artboard. `fresh`: this frame opened it, so a focus loss from the +/// press that started it is not the user leaving the field. +#[derive(Clone)] +struct ArtboardNameEdit { + index: usize, + text: String, + fresh: bool, +} + +/// The canvas label for `name`, laid out as [`artboard_label`] draws it. +fn canvas_name_galley(painter: &egui::Painter, corner: Pos2, width: f32, name: &str, color: Color32) -> Option<(std::sync::Arc, Pos2)> { if !width.is_finite() || width < MIN_LABELLED_ARTBOARD { - return; + return None; } let mut job = egui::text::LayoutJob::simple_singleline(name.to_owned(), egui::FontId::proportional(11.0), color); job.wrap = egui::text::TextWrapping { max_width: width, max_rows: 1, break_anywhere: true, overflow_character: Some('…') }; let galley = painter.layout_job(job); - painter.galley(corner - vec2(0.0, 4.0 + galley.size().y), galley, color); + let at = corner - vec2(0.0, 4.0 + galley.size().y); + Some((galley, at)) +} + +/// Artboard `name` above its top-left `corner` on screen, no wider than the artboard (`width` on +/// screen): a longer name ends in "…", and an artboard too narrow shows none (#949). +fn artboard_label(painter: &egui::Painter, corner: Pos2, width: f32, name: &str, color: Color32) { + if let Some((galley, at)) = canvas_name_galley(painter, corner, width, name, color) { + painter.galley(at, galley, color); + } +} + +fn canvas_name_rect(painter: &egui::Painter, corner: Pos2, width: f32, name: &str) -> Option { + canvas_name_galley(painter, corner, width, name, Color32::WHITE).map(|(galley, at)| egui::Rect::from_min_size(at, galley.size()).expand(2.0)) +} + +/// Where the Artboard tool draws "01 - name" for its active artboard (see its overlay label). +fn tool_name_rect(painter: &egui::Painter, xf: &Xf, rect: Rect, index: usize, name: &str) -> egui::Rect { + let z = xf.zoom.max(1e-9); + let anchor = Point::new(rect.x0 - 8.0 / z, rect.y0 - 4.0 / z); + let sp = xf.to_screen(anchor) + vec2(8.0, -14.0); + let galley = painter.layout(format!("{:02} - {name}", index + 1), egui::FontId::proportional(11.0), Color32::WHITE, f32::INFINITY); + egui::Rect::from_min_size(sp, galley.size()).expand(2.0) +} + +/// The artboard whose on-canvas name contains `screen`, if that name is being drawn. +fn artboard_name_at(app: &VectorcraftApp, ui: &Ui, xf: &Xf, screen: Pos2) -> Option { + let st = app.session.active()?; + let artboard_tool = app.session.tool_id() == "artboard"; + if !artboard_tool && st.doc.artboards.len() <= 1 { + return None; + } + let tool_labelled = artboard_tool.then(|| app.session.tool_options()["active"].as_u64()).flatten(); + let painter = ui.painter(); + let mut hit = None; + for (i, ab) in st.doc.artboards.iter().enumerate() { + if tool_labelled == u64::try_from(i).ok() { + continue; + } + let corner = xf.to_screen(Point::new(ab.rect.x0, ab.rect.y0)); + let width = (ab.rect.width() * xf.zoom) as f32; + let text = format!("{:02} - {}", i + 1, ab.name); + if canvas_name_rect(painter, corner, width, &text).is_some_and(|r| r.contains(screen)) { + hit = Some(i); + } + } + if let Some(i) = tool_labelled.and_then(|i| usize::try_from(i).ok()) + && let Some(ab) = st.doc.artboards.get(i) + && tool_name_rect(painter, xf, ab.rect, i, &ab.name).contains(screen) + { + return Some(i); + } + hit +} + +fn select_named_artboard(app: &mut VectorcraftApp, index: usize) { + let n = app.session.active().map(|st| st.doc.artboards.len()).unwrap_or(0); + if index >= n { + return; + } + if let Some(v) = app.view_mut() { + v.artboard = index; + } + if app.session.tool_id() == "artboard" { + app.session.set_tool_option("active", &json!(index)); + } +} + +fn begin_artboard_name_edit(ui: &Ui, app: &VectorcraftApp, index: usize) { + let Some(name) = app.session.active().and_then(|st| st.doc.artboards.get(index).map(|a| a.name.clone())) else { return }; + ui.data_mut(|d| d.insert_temp(name_edit_id(), ArtboardNameEdit { index, text: name, fresh: true })); +} + +fn commit_artboard_name(app: &mut VectorcraftApp, index: usize, text: &str) { + let trimmed = text.trim(); + if trimmed.is_empty() { + return; + } + let current = app.session.active().and_then(|st| st.doc.artboards.get(index).map(|a| a.name.clone())); + if current.as_deref() == Some(trimmed) { + return; + } + // A refusal is reported by the command; the label just closes. + app.run("artboard.setProps", json!({ "index": index, "name": trimmed })).ok(); +} + +/// The text field over an artboard name a double-click opened. +fn artboard_name_editor(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { + let Some(mut edit) = ui.data(|d| d.get_temp::(name_edit_id())) else { return }; + let alive = app.session.active().is_some_and(|st| edit.index < st.doc.artboards.len()); + if !alive { + ui.data_mut(|d| d.remove::(name_edit_id())); + return; + } + let Some(rect) = artboard_name_field_rect(app, ui, xf, edit.index) else { + ui.data_mut(|d| d.remove::(name_edit_id())); + return; + }; + let fresh = edit.fresh; + edit.fresh = false; + let te = ui.put(rect, egui::TextEdit::singleline(&mut edit.text).id(name_edit_id().with("field")).font(egui::FontId::proportional(11.0))); + if fresh || (!te.has_focus() && !te.lost_focus()) { + te.request_focus(); + } + let done = !fresh && te.lost_focus(); + let escape = ui.input(|i| i.key_pressed(egui::Key::Escape)); + let (index, text) = (edit.index, edit.text.clone()); + if done { + ui.data_mut(|d| d.remove::(name_edit_id())); + if !escape { + commit_artboard_name(app, index, &text); + } + } else { + ui.data_mut(|d| d.insert_temp(name_edit_id(), edit)); + } +} + +/// Where the rename field sits: the name label's rectangle, at least wide enough to type in. +fn artboard_name_field_rect(app: &VectorcraftApp, ui: &Ui, xf: &Xf, index: usize) -> Option { + let st = app.session.active()?; + let ab = st.doc.artboards.get(index)?; + let artboard_tool = app.session.tool_id() == "artboard"; + let tool_labelled = artboard_tool.then(|| app.session.tool_options()["active"].as_u64()).flatten(); + let painter = ui.painter(); + let rect = if tool_labelled == u64::try_from(index).ok() { + tool_name_rect(painter, xf, ab.rect, index, &ab.name) + } else { + let corner = xf.to_screen(Point::new(ab.rect.x0, ab.rect.y0)); + let width = (ab.rect.width() * xf.zoom) as f32; + canvas_name_rect(painter, corner, width, &format!("{:02} - {}", index + 1, ab.name))? + }; + Some(egui::Rect::from_min_size(rect.min, vec2(rect.width().max(80.0), rect.height().max(16.0)))) } /// Is overlay label `text` the Artboard tool's "01 - "? It holds a name, so it is @@ -3014,6 +3184,45 @@ mod tests { assert!(!names_an_artboard("anchor") && !names_an_artboard("1 - x") && !names_an_artboard("Off the mesh - move closer")); } + /// Double-clicking the name drawn above an artboard edits that name and does not fit a new + /// artboard or open Artboard Options. + #[test] + fn double_clicking_an_artboard_name_edits_it() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("artboard.new", &json!({"x": 500, "y": 0, "width": 100, "height": 80, "name": "Far"})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 40, "y": 40, "width": 30, "height": 20})).unwrap(); + app.select_tool("artboard"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let rect = app.canvas_rect.unwrap(); + let xf = Xf::new(rect, app.view().unwrap()); + let mut target = None; + let _ = + ctx.run_ui(egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }, |ui| { + let ab = &app.session.active().unwrap().doc.artboards[0]; + target = Some(tool_name_rect(ui.painter(), &xf, ab.rect, 0, &ab.name).center()); + }); + let at = target.unwrap(); + assert!(rect.contains(at), "the name is in the working area, at {at:?} canvas {rect:?}"); + let before = app.session.active().unwrap().doc.artboards.len(); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let click = |app: &mut VectorcraftApp| { + frame(app, &ctx, vec![egui::Event::PointerMoved(at)]); + frame(app, &ctx, vec![button(true)]); + frame(app, &ctx, vec![button(false)]); + }; + click(&mut app); + click(&mut app); + assert!(ctx.data(|d| d.get_temp::(name_edit_id()).is_some()), "the name is being edited"); + assert_eq!(app.session.active().unwrap().doc.artboards.len(), before, "clicking the name does not fit an artboard"); + assert!(app.ui.dialog.is_none(), "Artboard Options stays closed"); + commit_artboard_name(&mut app, 0, "Poster"); + assert_eq!(app.session.active().unwrap().doc.artboards[0].name, "Poster"); + commit_artboard_name(&mut app, 0, " "); + assert_eq!(app.session.active().unwrap().doc.artboards[0].name, "Poster", "an empty name is left as it is"); + } + /// #949: an artboard's name stays within the artboard's width on screen, ending in "…" when /// longer, and an artboard too narrow shows none. #[test]