Merge pull request #2197 from turtwig/artboard-edit-fixes

Artboard editing: fit on double-click, rename on the canvas, and highlight the active artboard
This commit is contained in:
BFlat
2026-10-16 15:54:00 +07:00
committed by GitHub
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]