mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 02:37:01 -06:00
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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user