Artboard tool: a double-click on an object fits one artboard, and the canvas name renames

A double-click on an object or group fits one artboard to the visible bounds and does not open Artboard Options. The second click does not fit another artboard. Other artboards stay. Double-clicking empty artboard space still opens Artboard Options. Double-clicking the name drawn above an artboard renames it.
This commit is contained in:
turtwig
2026-10-16 15:27:54 +07:00
committed by bflatastic
parent 05b160b32e
commit b795d15d69
3 changed files with 385 additions and 47 deletions
+106 -1
View File
@@ -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<UiRequest> {
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<UiRequest> {
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);
}
+36 -12
View File
@@ -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<Overlay>,
/// 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<Action> {
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<Rect> {
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<Action> {
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),
_ => {}
}
}
+243 -34
View File
@@ -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::<ArtboardNameEdit>(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::<usize>(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<egui::Galley>, 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<egui::Rect> {
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<usize> {
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::<ArtboardNameEdit>(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::<ArtboardNameEdit>(name_edit_id()));
return;
}
let Some(rect) = artboard_name_field_rect(app, ui, xf, edit.index) else {
ui.data_mut(|d| d.remove::<ArtboardNameEdit>(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::<ArtboardNameEdit>(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<egui::Rect> {
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 - <artboard name>"? 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::<ArtboardNameEdit>(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]