vectorcraft/crates/ui-egui/src/canvas.rs

1495 lines
72 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! The document canvas: rendering, rulers, navigation, pointer routing to tools and on-canvas
//! selection visuals (bounding box, anchors, handles, smart-guide style labels).
use egui::{Color32, CornerRadius, Pos2, Sense, Shape, Stroke, StrokeKind, Ui, pos2, vec2};
use serde_json::json;
use vectorcraft_doc::{Node, NodeKind, Unit};
use vectorcraft_geom::{Affine, BezPath, PathEl, Point, Rect};
use vectorcraft_tools::{Cursor, Mods, Overlay, PointerEvent, PointerKind};
use crate::state::View;
use crate::theme::{self, Tokens};
use crate::{CacheKey, VectorcraftApp, now_ms, widgets};
const RULER: f32 = 16.0;
/// Screen ↔ document mapping for one frame.
#[derive(Clone, Copy, Debug)]
pub struct Xf {
pub rect: egui::Rect,
pub zoom: f64,
pub center: Point,
/// View rotation in radians (Rotate View), clockwise on screen.
pub rot: f64,
}
impl Xf {
pub fn new(rect: egui::Rect, v: &View) -> Self {
Self { rect, zoom: v.zoom, center: v.center, rot: v.rotation.to_radians() }
}
pub fn to_screen(&self, p: Point) -> Pos2 {
let c = self.rect.center();
let (dx, dy) = ((p.x - self.center.x) * self.zoom, (p.y - self.center.y) * self.zoom);
let (sn, cs) = self.rot.sin_cos();
pos2(c.x + (dx * cs - dy * sn) as f32, c.y + (dx * sn + dy * cs) as f32)
}
pub fn to_doc(&self, p: Pos2) -> Point {
let c = self.rect.center();
let (dx, dy) = ((p.x - c.x) as f64, (p.y - c.y) as f64);
let (sn, cs) = self.rot.sin_cos();
let (ux, uy) = (dx * cs + dy * sn, -dx * sn + dy * cs);
Point::new(self.center.x + ux / self.zoom, self.center.y + uy / self.zoom)
}
/// Screen-space corners of a document rect (a rotated quad when the view is rotated).
pub fn quad(&self, r: Rect) -> Vec<Pos2> {
[Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)].iter().map(|p| self.to_screen(*p)).collect()
}
/// Convert a screen-space delta to a document delta.
pub fn delta_to_doc(&self, d: egui::Vec2) -> vectorcraft_geom::Vec2 {
let (sn, cs) = self.rot.sin_cos();
let (dx, dy) = (d.x as f64, d.y as f64);
vectorcraft_geom::Vec2::new((dx * cs + dy * sn) / self.zoom, (-dx * sn + dy * cs) / self.zoom)
}
pub fn rect_to_screen(&self, r: Rect) -> egui::Rect {
egui::Rect::from_two_pos(self.to_screen(Point::new(r.x0, r.y0)), self.to_screen(Point::new(r.x1, r.y1)))
}
/// Affine mapping document points to screen points.
pub fn affine(&self) -> Affine {
let c = self.rect.center();
Affine::translate((c.x as f64, c.y as f64)) * Affine::rotate(self.rot) * Affine::scale(self.zoom) * Affine::translate(-self.center.to_vec2())
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Drag {
Tool,
/// Hand tool / Space drag, or a middle-button drag with any tool (`middle`).
Pan {
start: Pos2,
center: Point,
middle: bool,
},
ZoomBox {
start: Pos2,
},
RotateView {
start_angle: f64,
start_rot: f64,
},
/// Cmd held: temporary selection tool; restore this tool on release.
TempSelect,
/// A Selection tool move dragged off the canvas: the panels get the art
/// ([`widgets::PanelDrag::Art`]); `temp` restores the tool Cmd switched from on release.
Art {
temp: bool,
},
}
fn drag_id() -> egui::Id {
egui::Id::new("canvas-drag")
}
fn temp_tool_id() -> egui::Id {
egui::Id::new("canvas-temp-tool")
}
/// The pen pressure (0..1) of the press in progress: the force of this frame's pen or touch
/// input, else the last one seen since the press (`pressed`: a new press, whose mouse has none
/// until a pen reports it). A mouse presses fully (1).
fn pen_pressure(ui: &Ui, pressed: bool) -> f32 {
let id = egui::Id::new("canvas-pressure");
let force = ui.input(|i| {
i.events.iter().rev().find_map(|e| match e {
egui::Event::Touch { force: Some(f), .. } if f.is_finite() => Some(f.clamp(0.0, 1.0)),
_ => None,
})
});
let p = force.or_else(|| if pressed { None } else { ui.data(|d| d.get_temp::<f32>(id)) }).unwrap_or(1.0);
ui.data_mut(|d| d.insert_temp(id, p));
p
}
pub fn mods(m: egui::Modifiers, space: bool) -> Mods {
Mods { shift: m.shift, alt: m.alt, cmd: m.command, ctrl: m.ctrl, space }
}
/// Fit the view (View → Fit Artboard / Fit All / Actual Size).
pub fn fit(app: &mut VectorcraftApp, how: &str) {
let Some(rect) = app.canvas_rect else {
if let Some(v) = app.view_mut() {
v.fitted = false;
}
return;
};
let Some(st) = app.session.active() else { return };
let target = match how {
"view.fitAll" => {
st.doc.art_bounds().map(|a| st.doc.artboards.iter().fold(a, |r, ab| r.union(ab.rect))).or(st.doc.artboards.first().map(|a| a.rect))
}
_ => st.doc.artboards.first().map(|a| a.rect),
};
let Some(target) = target else { return };
let Some(v) = app.view_mut() else { return };
v.center = target.center();
v.fitted = true;
if how == "view.actualSize" {
v.zoom = 1.0;
} else {
let zx = (rect.width() as f64 - 60.0) / target.width().max(1.0);
let zy = (rect.height() as f64 - 60.0) / target.height().max(1.0);
v.zoom = zx.min(zy).clamp(0.0313, 640.0);
}
}
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let full = ui.available_rect_before_wrap();
// A document that opened, closed or became active since Home was chosen replaces it.
if app.ui.home.is_some_and(|k| k != crate::menus::home_key(app)) {
app.ui.home = None;
}
if app.session.active().is_none() || app.ui.home.is_some() {
home(app, ui, full);
return;
}
let rect = if app.ui.view.rulers && app.ui.screen_mode < 3 { egui::Rect::from_min_max(full.min + vec2(RULER, RULER), full.max) } else { full };
app.canvas_rect = Some(rect);
let fitted = app.view().is_some_and(|v| v.fitted);
if !fitted {
fit(app, "view.fitArtboard");
}
let resp = ui.interact(rect, egui::Id::new("canvas"), Sense::click_and_drag());
handle_input(app, ui, &resp, rect);
let v = *app.view().unwrap_or(&View::default());
let xf = Xf::new(rect, &v);
panel_drop(app, ui, &resp, &xf);
let painter = ui.painter_at(rect);
let Some(st) = app.session.active() else { return };
let doc = st.doc.clone();
let mask_view = st.shown_mask();
// Pasteboard, artboard shadows and paper (Document Setup: the transparency grid's look, the
// simulated paper colour; a white Background Contents hides the grid).
painter.rect_filled(rect, 0.0, t.pasteboard);
if app.ui.view.artboards && !app.ui.view.outline {
let setup = &doc.setup;
let grid = (st.transparency_grid && setup.background == vectorcraft_doc::Background::Transparent).then(|| checker_texture(ui.ctx(), setup));
let paper = if setup.simulate_paper { crate::panels::c32(&setup.paper()) } else { Color32::WHITE };
for ab in &doc.artboards {
let q = xf.quad(ab.rect);
// Hard 2 pt drop shadow, right and bottom (measured: #4d4d4d then #565656 on #606060).
let shift = |d: f32| q.iter().map(|p| *p + vec2(d, d)).collect::<Vec<_>>();
painter.add(Shape::convex_polygon(shift(2.0), Color32::from_black_alpha(26), Stroke::NONE));
painter.add(Shape::convex_polygon(shift(1.0), Color32::from_black_alpha(52), Stroke::NONE));
match &grid {
Some((tex, cell)) => checker(&painter, &q, ab.rect, xf.zoom, tex.id(), *cell),
None => {
painter.add(Shape::convex_polygon(q, paper, Stroke::NONE));
}
}
}
} else if app.ui.view.outline {
for ab in &doc.artboards {
painter.add(Shape::convex_polygon(xf.quad(ab.rect), Color32::WHITE, Stroke::NONE));
}
}
if app.ui.view.grid {
grid(&painter, &xf, doc.grid.spacing, doc.grid.subdivisions);
}
// Artwork raster.
let ppp = ui.ctx().pixels_per_point();
let (w, h) = ((rect.width() * ppp).round().max(1.0) as u32, (rect.height() * ppp).round().max(1.0) as u32);
let key = CacheKey {
doc: st.uid as usize,
revision: st.revision,
zoom: v.zoom,
cx: v.center.x,
cy: v.center.y,
w,
h,
outline: app.ui.view.outline,
trim: app.ui.view.trim_view,
ppp,
hidden: vec![],
rot: v.rotation,
};
if !app.canvas.worker_started {
app.canvas.worker_started = true;
if std::env::var_os("VECTORCRAFT_SYNC_RENDER").is_none() {
app.canvas.worker = crate::render_worker::Worker::spawn(ui.ctx().clone());
}
}
// Upload finished background renders.
if let Some(done) = app.canvas.worker.as_mut().and_then(|w| w.poll())
&& done.key.doc == key.doc
{
upload(app, ui.ctx(), &done.img);
app.canvas.key = Some(done.key);
app.perf.render_ms = done.ms;
app.canvas.last_ms = done.ms;
}
if app.canvas.key.as_ref() != Some(&key) || app.canvas.texture.is_none() {
let view = Affine::translate((w as f64 / 2.0, h as f64 / 2.0))
* Affine::rotate(v.rotation.to_radians())
* Affine::scale(v.zoom * ppp as f64)
* Affine::translate(-v.center.to_vec2());
let opts = vectorcraft_render::RenderOptions { outline: app.ui.view.outline, background: None, artboards: false, ..Default::default() };
let opts = vectorcraft_render::RenderOptions {
proof: vectorcraft_render::proof::active_proof(),
overprint_preview: vectorcraft_render::proof::overprint_preview_on(),
trim: app.ui.view.trim_view,
tile_edge: vectorcraft_color::Color::from_hex(&app.session.prefs.pattern_tile_edge_color).map_or(opts.tile_edge, |c| {
let [r, g, b, _] = c.to_rgba8(1.0);
[r, g, b]
}),
mask_view,
highlight_substitutions: true,
..opts
};
// Light documents render synchronously (no lag vs overlays); heavy ones go to the worker.
// A document switch renders synchronously so another document's frame is never shown.
let same_doc = app.canvas.key.as_ref().is_some_and(|k| k.doc == key.doc);
let heavy = app.canvas.last_ms > 8.0 && app.canvas.texture.is_some() && same_doc;
match (&mut app.canvas.worker, heavy) {
(Some(worker), true) => worker.submit(crate::render_worker::Job { key: key.clone(), doc: doc.clone(), w, h, view, opts }),
_ => {
let t0 = now_ms();
let img = app.canvas.renderer.render(&doc, w, h, view, &opts);
upload(app, ui.ctx(), &img);
app.canvas.key = Some(key.clone());
app.perf.render_ms = now_ms() - t0;
app.canvas.last_ms = app.perf.render_ms;
}
}
}
if let (Some(tex), Some(k)) = (&app.canvas.texture, &app.canvas.key)
&& k.doc == key.doc
&& (k.rot - v.rotation).abs() < 1e-9
{
// Reproject the last frame if it was rendered for a different view.
let old = Xf { rect, zoom: k.zoom, center: Point::new(k.cx, k.cy), rot: xf.rot };
let _ = k.rot;
let a = xf.to_screen(old.to_doc(rect.min));
let b = xf.to_screen(old.to_doc(rect.max));
painter.image(tex.id(), egui::Rect::from_min_max(a, b), egui::Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
// Artboard edges and names.
let active_ab = 0;
for (i, ab) in doc.artboards.iter().enumerate() {
let r = xf.rect_to_screen(ab.rect);
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)));
// The bleed (Document Setup) as a red outline around the artboard.
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 app.session.tool_id() == "artboard" || doc.artboards.len() > 1 {
painter.text(
r.left_top() - vec2(0.0, 4.0),
egui::Align2::LEFT_BOTTOM,
format!("{:02} - {}", i + 1, ab.name),
egui::FontId::proportional(11.0),
t.text_dim,
);
}
}
if app.ui.view.guides {
for g in &doc.guides {
let (a, b) = if g.vertical {
let x = xf.to_screen(Point::new(g.pos, 0.0)).x;
(pos2(x, rect.top()), pos2(x, rect.bottom()))
} else {
let y = xf.to_screen(Point::new(0.0, g.pos)).y;
(pos2(rect.left(), y), pos2(rect.right(), y))
};
painter.line_segment([a, b], Stroke::new(1.0, t.guide));
}
}
if !app.session.slices_hidden() {
slice_overlay(app, &painter, &xf);
}
if app.session.active().is_some_and(|d| d.print_tiling) || app.session.tool_id() == "printTiling" {
print_tiling_overlay(app, &painter, &xf);
}
// Selection visuals and tool overlays.
if app.ui.view.edges {
hover_highlight(app, &painter, &xf);
selection_overlay(app, &painter, &xf);
if app.ui.view.text_threads {
thread_overlay(app, &painter, &xf);
}
if app.ui.view.hidden_chars {
hidden_chars_overlay(app, &painter, &xf);
}
}
let view_info = app.view_info();
// View → Hide Gradient Annotator hides the Gradient tool's annotator.
if app.session.tool_id() != "gradient" || app.ui.view.gradient_annotator {
let overlays = app.session.overlays(view_info);
draw_overlays(&painter, &xf, &overlays, &t);
}
crate::place::paint_drop_highlight(app, ui.ctx(), &painter, rect);
if app.ui.view.rulers && app.ui.screen_mode < 3 {
rulers(ui, full, &xf, app.hover_doc, app.session.general_unit(), &t);
}
if app.ui.task_bar && !app.session.tool_busy() && app.ui.screen_mode < 3 {
task_bar(app, ui, &xf);
}
// Cursor.
if resp.hovered() {
let m = ui.input(|i| i.modifiers);
let space = ui.input(|i| i.key_down(egui::Key::Space));
let panning = matches!(ui.data(|d| d.get_temp::<Drag>(drag_id())), Some(Drag::Pan { .. }));
let cur = if panning {
egui::CursorIcon::Grabbing
} else if space || app.session.tool_id() == "hand" {
egui::CursorIcon::Grab
} else if app.session.tool_id() == "zoom" {
if m.alt { egui::CursorIcon::ZoomOut } else { egui::CursorIcon::ZoomIn }
} else if let Some(p) = app.hover_doc {
let c = app.session.cursor(p, mods(m, space), view_info);
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("tool-cursor")));
// The loaded place cursor carries the file's thumbnail.
if app.session.tool_id() == "place"
&& let Some(hp) = ui.input(|i| i.pointer.hover_pos())
{
crate::place::paint_cursor(app, ui.ctx(), &painter, hp);
}
// Caps Lock gives precise (crosshair) cursors, like Illustrator; env opt-out for system cursors.
let custom = std::env::var_os("VECTORCRAFT_SYSTEM_CURSORS").is_none();
match ui.input(|i| i.pointer.hover_pos()) {
Some(hp) if custom && crate::cursors::paint(&painter, c, hp) => egui::CursorIcon::None,
_ => cursor_icon(c),
}
} else {
egui::CursorIcon::Default
};
ui.ctx().set_cursor_icon(cur);
}
}
fn cursor_icon(c: Cursor) -> egui::CursorIcon {
use egui::CursorIcon as C;
match c {
Cursor::Arrow | Cursor::ArrowHollow | Cursor::CornerRadius => C::Default,
Cursor::Move => C::Move,
Cursor::Crosshair => C::Crosshair,
Cursor::ResizeH => C::ResizeHorizontal,
Cursor::ResizeV => C::ResizeVertical,
Cursor::ResizeNwSe => C::ResizeNwSe,
Cursor::ResizeNeSw => C::ResizeNeSw,
Cursor::Rotate => C::Alias,
Cursor::Pen | Cursor::PenAdd | Cursor::PenDelete | Cursor::PenClose | Cursor::PenContinue => C::Crosshair,
Cursor::Text => C::Text,
Cursor::Hand => C::Grab,
Cursor::HandGrab => C::Grabbing,
Cursor::ZoomIn => C::ZoomIn,
Cursor::ZoomOut => C::ZoomOut,
Cursor::Eyedropper => C::Crosshair,
Cursor::NotAllowed => C::NotAllowed,
Cursor::AddStop => C::Copy,
Cursor::RemoveStop => C::NotAllowed,
Cursor::Slice => C::Crosshair,
Cursor::SliceSelect => C::Default,
Cursor::Blend | Cursor::BlendObject | Cursor::BlendAnchor => C::Crosshair,
}
}
fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: egui::Rect) {
let (pointer, m, space, scroll, zoom_delta) =
ui.input(|i| (i.pointer.clone(), i.modifiers, i.key_down(egui::Key::Space), i.smooth_scroll_delta, i.zoom_delta()));
let v = *app.view().unwrap_or(&View::default());
let xf = Xf::new(rect, &v);
let hover = pointer.hover_pos().filter(|p| rect.contains(*p));
app.hover_doc = hover.map(|p| xf.to_doc(p));
let view = app.view_info();
let drag: Option<Drag> = ui.data(|d| d.get_temp(drag_id()));
// Zoom: pinch / Cmd-scroll / Alt-scroll around the pointer. Plain scroll pans.
if resp.hovered() {
let mut factor = zoom_delta as f64;
if m.alt && scroll.y != 0.0 {
factor *= (scroll.y as f64 * 0.01).exp();
}
if (factor - 1.0).abs() > 1e-6 {
if let (Some(p), Some(vm)) = (hover, app.view_mut()) {
let before = xf.to_doc(p);
vm.zoom = (vm.zoom * factor).clamp(0.0313, 640.0);
let nx = Xf { rect, zoom: vm.zoom, center: vm.center, rot: vm.rotation.to_radians() };
let after = nx.to_doc(p);
vm.center += before - after;
}
} else if (scroll.x != 0.0 || scroll.y != 0.0)
&& !m.alt
&& let Some(vm) = app.view_mut()
{
let d = Xf { rect, zoom: vm.zoom, center: vm.center, rot: vm.rotation.to_radians() }.delta_to_doc(scroll);
vm.center -= d;
}
}
let tool = app.session.tool_id();
let pan_mode = space || tool == "hand";
let middle_pan = matches!(drag, Some(Drag::Pan { middle: true, .. }));
if pointer.primary_pressed() && resp.hovered() && !middle_pan {
ui.ctx().memory_mut(|mem| mem.stop_text_input());
app.ui.flyout = None;
let p = hover.unwrap_or(rect.center());
let d = if pan_mode {
Drag::Pan { start: p, center: v.center, middle: false }
} else if tool == "zoom" {
Drag::ZoomBox { start: p }
} 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 {
let selection_family = matches!(tool, "selection" | "directSelection" | "groupSelection");
let mut kind = Drag::Tool;
if m.command && !selection_family {
ui.data_mut(|d| d.insert_temp(temp_tool_id(), tool.to_string()));
app.select_tool("selection");
kind = Drag::TempSelect;
}
let ev = PointerEvent { kind: PointerKind::Down, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, true) };
dispatch(app, &ev, view);
kind
};
ui.data_mut(|dd| dd.insert_temp(drag_id(), d));
} else if drag.is_none() && resp.hovered() && pointer.button_pressed(egui::PointerButton::Middle) {
// Middle-button drag pans the view whatever the tool.
let start = hover.unwrap_or(rect.center());
ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::Pan { start, center: v.center, middle: true }));
} else if let Some(d) = drag {
let p = pointer.interact_pos().unwrap_or(rect.center());
let held = if middle_pan { pointer.button_down(egui::PointerButton::Middle) } else { pointer.primary_down() };
if held {
match d {
Drag::Pan { start, center, .. } => {
let d = xf.delta_to_doc(p - start);
if let Some(vm) = app.view_mut() {
vm.center = center - d;
}
}
Drag::RotateView { start_angle, start_rot } => {
let c = rect.center();
let a = (p.y - c.y).atan2(p.x - c.x) as f64;
let mut deg = start_rot + (a - start_angle).to_degrees();
if m.shift {
deg = (deg / 15.0).round() * 15.0;
}
if let Some(vm) = app.view_mut() {
vm.rotation = vectorcraft_geom::normalize_deg(deg);
}
}
Drag::ZoomBox { .. } | Drag::Art { .. } => {}
Drag::Tool | Drag::TempSelect if drag_art_out(app, ui, resp, p, view) => {
ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::Art { temp: d == Drag::TempSelect }));
}
Drag::Tool | Drag::TempSelect => {
if pointer.delta() != egui::Vec2::ZERO {
let ev = PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, false) };
dispatch(app, &ev, view);
}
// Time held (Twirl, Pucker and Bloat keep applying); a stalled frame counts
// a quarter second at most.
if app.session.tool_wants_ticks() {
let dt = f64::from(ui.input(|i| i.unstable_dt).min(0.25));
let r = app.session.tool_tick(dt, view);
apply_requests(app, r);
}
}
}
} else {
ui.data_mut(|dd| dd.remove::<Drag>(drag_id()));
match d {
Drag::ZoomBox { start } => {
let r = egui::Rect::from_two_pos(start, p);
if let Some(vm) = app.view_mut() {
if r.width() > 8.0 && r.height() > 8.0 {
let a = xf.to_doc(r.min);
let b = xf.to_doc(r.max);
vm.center = Point::new((a.x + b.x) / 2.0, (a.y + b.y) / 2.0);
vm.zoom = (rect.width() as f64 / (b.x - a.x)).min(rect.height() as f64 / (b.y - a.y)).clamp(0.0313, 640.0);
} else {
let before = xf.to_doc(p);
vm.zoom = crate::state::next_zoom(vm.zoom, !m.alt);
let nx = Xf { rect, zoom: vm.zoom, center: vm.center, rot: vm.rotation.to_radians() };
vm.center += before - nx.to_doc(p);
}
}
}
Drag::Tool | Drag::TempSelect | Drag::Art { .. } => {
if !matches!(d, Drag::Art { .. }) {
let ev = PointerEvent { kind: PointerKind::Up, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, false) };
dispatch(app, &ev, view);
}
if matches!(d, Drag::TempSelect | Drag::Art { temp: true })
&& let Some(prev) = ui.data(|dd| dd.get_temp::<String>(temp_tool_id()))
{
app.select_tool(&prev);
}
}
Drag::Pan { .. } | Drag::RotateView { .. } => {}
}
}
} else if let Some(p) = hover
&& pointer.is_moving()
{
let ev = PointerEvent { kind: PointerKind::Move, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 };
dispatch(app, &ev, view);
}
if resp.double_clicked()
&& let Some(p) = hover
{
let ev = PointerEvent { kind: PointerKind::DoubleClick, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 };
dispatch(app, &ev, view);
}
if drag.is_some() || pointer.is_moving() {
ui.ctx().request_repaint();
}
}
/// A Selection tool move dragged off the canvas (to `p`, over a panel) turns into a panel drag of
/// the selected art ([`widgets::PanelDrag::Art`]): the move is dropped, so the art stays where it
/// was, and the panel it is released on takes it (the Graphic Styles panel makes a style of it).
fn drag_art_out(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, p: Pos2, view: vectorcraft_engine::ViewInfo) -> bool {
let off = !ui.clip_rect().contains(p) || ui.ctx().layer_id_at(p).is_some_and(|l| l != resp.layer_id);
let Some(st) = app.session.active().filter(|_| off && app.session.tool_id() == "selection") else { return false };
// The Selection tool's interaction while it moves (or Alt-copies) the selection.
if !st.interaction.as_ref().is_some_and(|i| i.label == "Move" || i.label == "Copy") {
return false;
}
let ids = st.selection.objects.clone();
if app.session.cancel_interaction().is_err() {
return false;
}
// With nothing left to commit, the tool's mouse-up just returns it to rest.
dispatch(app, &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), pressure: 1.0 }, view);
egui::DragAndDrop::set_payload(ui.ctx(), widgets::PanelDrag::Art(ids));
true
}
/// Send a pointer event to the active tool and act on UI requests (dialogs, tool switches).
pub fn dispatch(app: &mut VectorcraftApp, ev: &PointerEvent, view: vectorcraft_engine::ViewInfo) {
let r = app.session.pointer(ev, view);
apply_requests(app, r);
}
/// Act on what a tool event asked the UI for (dialogs, tool switches), or show its error.
pub fn apply_requests(app: &mut VectorcraftApp, r: vectorcraft_engine::Result<Vec<vectorcraft_engine::UiRequest>>) {
match r {
Ok(reqs) => {
for r in reqs {
match r {
vectorcraft_engine::UiRequest::Dialog(kind, p) => crate::dialogs::open_tool_dialog(app, &kind, p),
vectorcraft_engine::UiRequest::SwitchTool(t) => app.select_tool(&t),
vectorcraft_engine::UiRequest::Status(msg) => app.status(msg),
}
}
}
Err(e) => app.status(e.to_string()),
}
}
/// The transparency grid's 2 × 2-cell tile in the document's colours (Document Setup), cached per
/// look, and its cell size in screen points.
pub(crate) fn checker_texture(ctx: &egui::Context, setup: &vectorcraft_doc::DocSetup) -> (egui::TextureHandle, f32) {
let colors = setup.grid_colors.map(|c| crate::panels::c32(&c));
let id = egui::Id::new("transparency-grid-tile");
let cached = ctx.data(|d| d.get_temp::<([Color32; 2], egui::TextureHandle)>(id)).filter(|(c, _)| *c == colors);
let tex = cached.map(|(_, t)| t).unwrap_or_else(|| {
let [a, b] = colors;
let img = egui::ColorImage::new([2, 2], vec![a, b, b, a]);
let opts = egui::TextureOptions { wrap_mode: egui::TextureWrapMode::Repeat, ..egui::TextureOptions::NEAREST };
let t = ctx.load_texture(TRANSPARENCY_GRID, img, opts);
ctx.data_mut(|d| d.insert_temp(id, (colors, t.clone())));
t
});
(tex, setup.grid_size.cell())
}
/// Name of the transparency grid's texture.
pub(crate) const TRANSPARENCY_GRID: &str = "transparency grid";
/// The transparency grid over the artboard `ab` (screen corners `quad`): one textured quad whose
/// cells stay `cell` screen points at every zoom and turn with a rotated view.
fn checker(p: &egui::Painter, quad: &[Pos2], ab: Rect, zoom: f64, tex: egui::TextureId, cell: f32) {
let mut mesh = egui::Mesh::with_texture(tex);
// Two cells per texture repeat.
let per = (2.0 * cell as f64 / zoom).max(1e-9);
let (u, v) = ((ab.width() / per) as f32, (ab.height() / per) as f32);
for (pos, uv) in quad.iter().zip([pos2(0.0, 0.0), pos2(u, 0.0), pos2(u, v), pos2(0.0, v)]) {
mesh.vertices.push(egui::epaint::Vertex { pos: *pos, uv, color: Color32::WHITE });
}
mesh.indices.extend([0, 1, 2, 0, 2, 3]);
p.add(Shape::mesh(mesh));
}
fn grid(p: &egui::Painter, xf: &Xf, spacing: f64, subdiv: u32) {
let r = xf.rect;
let a = xf.to_doc(r.min);
let b = xf.to_doc(r.max);
let sub = spacing / subdiv.max(1) as f64;
let step = if sub * xf.zoom >= 6.0 { sub } else { spacing };
if step * xf.zoom < 4.0 {
return;
}
let mut x = (a.x / step).floor() * step;
while x <= b.x {
let major = (x / spacing).round() * spacing == x || (x / spacing - (x / spacing).round()).abs() < 1e-6;
let sx = xf.to_screen(Point::new(x, 0.0)).x;
p.line_segment([pos2(sx, r.top()), pos2(sx, r.bottom())], Stroke::new(1.0, Color32::from_black_alpha(if major { 60 } else { 22 })));
x += step;
}
let mut y = (a.y / step).floor() * step;
while y <= b.y {
let major = (y / spacing - (y / spacing).round()).abs() < 1e-6;
let sy = xf.to_screen(Point::new(0.0, y)).y;
p.line_segment([pos2(r.left(), sy), pos2(r.right(), sy)], Stroke::new(1.0, Color32::from_black_alpha(if major { 60 } else { 22 })));
y += step;
}
}
/// A ruler label for `v` (in the ruler's unit) on a ruler whose labels are `step` apart: whole
/// numbers, or as many decimals as the step has.
pub(crate) fn ruler_label(v: f64, step: f64) -> String {
let decimals = if step >= 1.0 { 0 } else { (-step.log10()).ceil() as usize };
let s = format!("{v:.decimals$}");
if s.trim_start_matches('-').chars().all(|c| c == '0' || c == '.') { "0".into() } else { s }
}
/// The rulers, numbered in `unit` (the General unit).
fn rulers(ui: &Ui, full: egui::Rect, xf: &Xf, hover: Option<Point>, unit: Unit, t: &Tokens) {
let p = ui.painter();
let top = egui::Rect::from_min_max(pos2(full.left() + RULER, full.top()), pos2(full.right(), full.top() + RULER));
let left = egui::Rect::from_min_max(pos2(full.left(), full.top() + RULER), pos2(full.left() + RULER, full.bottom()));
let corner = egui::Rect::from_min_size(full.min, vec2(RULER, RULER));
for r in [top, left, corner] {
p.rect_filled(r, 0.0, t.ruler);
}
p.line_segment([top.left_bottom(), top.right_bottom()], Stroke::new(1.0, t.border));
p.line_segment([left.right_top(), left.right_bottom()], Stroke::new(1.0, t.border));
// Crosshair in the origin box.
p.line_segment([corner.center() - vec2(4.0, 0.0), corner.center() + vec2(4.0, 0.0)], Stroke::new(1.0, t.ruler_tick));
p.line_segment([corner.center() - vec2(0.0, 4.0), corner.center() + vec2(0.0, 4.0)], Stroke::new(1.0, t.ruler_tick));
// Pick a label step (in `unit`) that gives ≥ 50 px between labels; positions below are in `unit`
// (`per` points each).
let per = unit.points();
let steps = [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 25.0, 50.0, 100.0, 200.0, 500.0, 1000.0, 2000.0, 5000.0, 10000.0];
let step = steps.iter().copied().find(|s| s * per * xf.zoom >= 50.0).unwrap_or(10000.0);
let minor = step / 10.0;
let font = egui::FontId::proportional(9.5);
let a = xf.to_doc(top.left_top());
let b = xf.to_doc(top.right_top());
let clip_top = p.with_clip_rect(top);
let mut x = (a.x / per / minor).floor() * minor;
while x * per <= b.x {
let sx = xf.to_screen(Point::new(x * per, 0.0)).x;
let is_major = ((x / step).round() * step - x).abs() < minor * 0.01;
let is_mid = ((x / (step / 2.0)).round() * (step / 2.0) - x).abs() < minor * 0.01;
let len = if is_major {
RULER
} else if is_mid {
7.0
} else {
4.0
};
clip_top.line_segment([pos2(sx, top.bottom() - len), pos2(sx, top.bottom())], Stroke::new(1.0, t.ruler_tick));
if is_major {
clip_top.text(pos2(sx + 2.0, top.top() + 1.0), egui::Align2::LEFT_TOP, ruler_label(x, step), font.clone(), t.ruler_tick);
}
x += minor;
}
let a = xf.to_doc(left.left_top());
let b = xf.to_doc(left.left_bottom());
let clip_left = p.with_clip_rect(left);
let mut y = (a.y / per / minor).floor() * minor;
while y * per <= b.y {
let sy = xf.to_screen(Point::new(0.0, y * per)).y;
let is_major = ((y / step).round() * step - y).abs() < minor * 0.01;
let is_mid = ((y / (step / 2.0)).round() * (step / 2.0) - y).abs() < minor * 0.01;
let len = if is_major {
RULER
} else if is_mid {
7.0
} else {
4.0
};
clip_left.line_segment([pos2(left.right() - len, sy), pos2(left.right(), sy)], Stroke::new(1.0, t.ruler_tick));
if is_major {
// Vertical labels read top-to-bottom, one digit per line like Illustrator.
let s = ruler_label(y, step);
for (k, ch) in s.chars().enumerate() {
clip_left.text(
pos2(left.left() + 4.0, sy + 2.0 + k as f32 * 8.5),
egui::Align2::LEFT_TOP,
ch.to_string(),
font.clone(),
t.ruler_tick,
);
}
}
y += minor;
}
if let Some(h) = hover {
let s = xf.to_screen(h);
clip_top.line_segment([pos2(s.x, top.top()), pos2(s.x, top.bottom())], Stroke::new(1.0, t.text));
clip_left.line_segment([pos2(left.left(), s.y), pos2(left.right(), s.y)], Stroke::new(1.0, t.text));
}
}
fn to_screen_path(bp: &BezPath, xf: &Xf) -> Vec<Vec<Pos2>> {
// Flatten for drawing: a polyline per subpath (tolerance ~0.25 screen px).
let mut out: Vec<Vec<Pos2>> = vec![];
let a = xf.affine();
let mut t = bp.clone();
t.apply_affine(a);
let mut cur: Vec<Pos2> = vec![];
kurbo_flatten(&t, 0.25, &mut |el| match el {
PathEl::MoveTo(p) => {
if cur.len() > 1 {
out.push(std::mem::take(&mut cur));
}
cur.clear();
cur.push(pos2(p.x as f32, p.y as f32));
}
PathEl::LineTo(p) => cur.push(pos2(p.x as f32, p.y as f32)),
PathEl::ClosePath => {
if let Some(f) = cur.first().copied() {
cur.push(f);
}
}
_ => {}
});
if cur.len() > 1 {
out.push(cur);
}
out
}
fn stroke_path(p: &egui::Painter, bp: &BezPath, xf: &Xf, s: Stroke) {
for line in to_screen_path(bp, xf) {
p.add(Shape::line(line, s));
}
}
fn c32(rgb: [u8; 3]) -> Color32 {
Color32::from_rgb(rgb[0], rgb[1], rgb[2])
}
/// Outline of a node for highlighting (paths, compound children, text/image bounds).
fn node_outline(n: &Node) -> BezPath {
let mut bp = BezPath::new();
n.walk(&mut |c| match &c.kind {
NodeKind::Path { path, .. } => bp.extend(path.to_bezpath()),
NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => {
if let Some(b) = c.geometric_bounds() {
bp.extend(vectorcraft_geom::shapes::rectangle(b).to_bezpath());
}
}
_ => {}
});
bp
}
/// The topmost editable object under document point `p` at `zoom` (3 px tolerance).
fn hit_at(app: &VectorcraftApp, p: Point, zoom: f64) -> Option<vectorcraft_doc::hit::Hit> {
let opt = vectorcraft_doc::hit::HitOptions { tol: 3.0 / zoom, outline: app.ui.view.outline, path_only: false };
vectorcraft_doc::hit::hit_test(&app.session.active()?.doc, p, opt)
}
/// A panel drag ([`widgets::PanelDrag`]) dropped on art acts on the object under the pointer,
/// selected or not: a paint (swatches, a Fill/Stroke proxy, the Gradient panel's thumbnail) goes
/// to its active proxy (`paint.setFill`/`paint.setStroke` with its `ids`; a gradient fits it), the
/// Appearance panel's thumbnail gives the object (the topmost one hit) the appearance it carries
/// (`appearance.copyFrom`), a graphic style is applied to it (`graphicStyle.apply`; with Alt, on
/// top of its appearance). A chip follows the pointer meanwhile.
fn panel_drop(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, xf: &Xf) {
crate::panels::swatches::drag_preview(app, ui.ctx());
let Some(pos) = ui.input(|i| i.pointer.interact_pos()) else { return };
// Not through a floating panel over the canvas.
if ui.ctx().layer_id_at(pos).is_some_and(|l| l != resp.layer_id) {
return;
}
let Some(d) = resp.dnd_release_payload::<widgets::PanelDrag>() else { return };
let Some(hit) = hit_at(app, xf.to_doc(pos), xf.zoom) else { return };
let Some(st) = app.session.active() else { return };
let (cmd, params) = match &*d {
widgets::PanelDrag::Paint { params, .. } => {
// Colour groups paint nothing.
if params.is_null() {
return;
}
let mut params = params.clone();
params["ids"] = json!([vectorcraft_tools::xform::paint_owner(&st.doc, hit.leaf).0]);
params["focus"] = json!(false);
(crate::panels::proxy_cmd(app, false), params)
}
widgets::PanelDrag::Appearance(source) => {
let target = hit.top_object(st.isolation);
if target == *source {
return;
}
("appearance.copyFrom", json!({"source": source.0, "ids": [target.0]}))
}
widgets::PanelDrag::GraphicStyle(name) => {
let add = ui.input(|i| i.modifiers.alt);
("graphicStyle.apply", json!({"name": name, "ids": [hit.top_object(st.isolation).0], "add": add}))
}
// Art dragged back onto the canvas: its move was already dropped.
widgets::PanelDrag::Art(_) => return,
};
if let Err(e) = app.run(cmd, params) {
app.status(e);
}
}
fn hover_highlight(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) {
let Some(h) = app.hover_doc else { return };
if app.session.tool_busy() || !matches!(app.session.tool_id(), "selection" | "directSelection" | "groupSelection") {
return;
}
let Some(st) = app.session.active() else { return };
let Some(hit) = hit_at(app, h, xf.zoom) else { return };
let id = if app.session.tool_id() == "selection" { hit.top_object(st.isolation) } else { hit.leaf };
if st.selection.contains(id) {
return;
}
if let Some(n) = st.doc.node(id) {
let color = c32(st.doc.layer_color(id));
stroke_path(p, &node_outline(n), xf, Stroke::new(1.5, color));
}
}
/// Selected anchors are drawn slightly deeper than the layer colour (#4f80ff → #3d82ff for Layer 1).
fn selected_anchor(c: Color32) -> Color32 {
if c == Color32::from_rgb(0x4f, 0x80, 0xff) { Color32::from_rgb(0x3d, 0x82, 0xff) } else { c }
}
fn anchor_square(p: &egui::Painter, c: Pos2, color: Color32, filled: bool, size: f32) {
let r = egui::Rect::from_center_size(c, vec2(size, size));
if filled {
p.rect_filled(r, 0.0, color);
} else {
p.rect_filled(r, 0.0, Color32::WHITE);
p.rect_stroke(r, 0.0, Stroke::new(1.0, color), StrokeKind::Inside);
}
}
/// Type → Show Hidden Characters: spaces as dots, ¶ at paragraph ends, # at the end of a story.
fn hidden_chars_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) {
let Some(st) = app.session.active() else { return };
let clip = p.clip_rect();
let color = Color32::from_rgb(0x4f, 0x9d, 0xff);
let font = egui::FontId::proportional(((10.0 * xf.zoom) as f32).clamp(7.0, 18.0));
st.doc.walk(|n| {
let NodeKind::Text(tx) = &n.kind else { return };
if !n.visible || n.geometric_bounds().is_none_or(|b| !xf.rect_to_screen(b).intersects(clip)) {
return;
}
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), tx);
if lay.on_path {
return;
}
let text = tx.plain_text();
let to = |q: Point| xf.to_screen(tx.xf * q);
for g in &lay.glyphs {
if text.get(g.byte..).is_some_and(|s| s.starts_with(' ')) {
p.circle_filled(to(Point::new(g.origin.x + g.advance / 2.0, g.origin.y - 3.0)), 1.2, color);
}
}
for (i, line) in lay.lines.iter().enumerate() {
let last = i + 1 == lay.lines.len();
let breaks = text.get(line.end..).is_some_and(|s| s.starts_with('\n'));
let (glyph, at) = if last && line.end >= text.len() {
("#", true)
} else if breaks {
("", true)
} else {
("", false)
};
if at {
p.text(to(Point::new(line.x1 + 1.0, line.baseline)), egui::Align2::LEFT_BOTTOM, glyph, font.clone(), color);
}
}
});
}
/// Text threads of the selected frames: a line from each frame's out port (bottom right) to the
/// next frame's in port (top left), like Illustrator's thread indicators.
fn thread_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) {
let Some(st) = app.session.active() else { return };
for thread in &st.doc.text_threads {
if !thread.iter().any(|id| st.selection.objects.contains(id)) {
continue;
}
let color = c32(st.doc.layer_color(thread[0]));
let frames: Vec<vectorcraft_geom::Rect> = thread.iter().filter_map(|id| st.doc.node(*id).and_then(|n| n.geometric_bounds())).collect();
for w in frames.windows(2) {
let (out, inp) = (xf.to_screen(Point::new(w[0].x1, w[0].y1)), xf.to_screen(Point::new(w[1].x0, w[1].y0)));
p.line_segment([out, inp], Stroke::new(1.0, color));
for port in [out, inp] {
p.rect_filled(egui::Rect::from_center_size(port, egui::vec2(7.0, 7.0)), 0.0, Color32::WHITE);
p.rect_stroke(egui::Rect::from_center_size(port, egui::vec2(7.0, 7.0)), 0.0, Stroke::new(1.0, color), egui::StrokeKind::Inside);
p.line_segment([port - egui::vec2(2.0, 0.0), port + egui::vec2(2.0, 0.0)], Stroke::new(1.0, color));
}
}
}
}
/// The slices (unless View → Hide Slices): user and object slices outlined in the Slices
/// preferences' line colour, auto slices dashed and dimmer, each numbered at its top left (Show
/// Slice Numbers); selected slices have a bolder outline. The layout is cached per revision.
fn slice_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
let Some(st) = app.session.active() else { return };
let key = (st.uid, st.revision);
let slices = match &app.canvas.slices {
Some((k, s)) if *k == key => s.clone(),
_ => {
let s = std::sync::Arc::new(st.doc.slice_layout());
app.canvas.slices = Some((key, s.clone()));
s
}
};
if slices.is_empty() {
return;
}
let prefs = &app.session.prefs;
let line = vectorcraft_color::Color::from_hex(&prefs.slice_line_color).map_or(Color32::from_rgb(0xff, 0x3f, 0x3f), |c| {
let [r, g, b, _] = c.to_rgba8(1.0);
Color32::from_rgb(r, g, b)
});
let dim = line.gamma_multiply(0.55);
let selected = &st.selection.slices;
let objects = &st.selection.objects;
for a in slices.iter() {
let auto = a.source == vectorcraft_doc::SliceSource::Auto;
let on = a.id.is_some_and(|id| selected.contains(&id) || objects.contains(&id));
let q = xf.quad(a.rect);
let ring: Vec<Pos2> = q.iter().chain(q.first()).copied().collect();
if auto {
p.extend(Shape::dashed_line(&ring, Stroke::new(1.0, dim), 3.0, 3.0));
} else {
p.add(Shape::line(ring, Stroke::new(if on { 2.0 } else { 1.0 }, line)));
}
if prefs.show_slice_numbers
&& let Some(tl) = q.first()
{
let font = egui::FontId::proportional(9.0);
let galley = p.layout_no_wrap(format!("{:02}", a.number), font, Color32::WHITE);
let badge = egui::Rect::from_min_size(*tl + vec2(1.0, 1.0), galley.size() + vec2(6.0, 2.0));
p.rect_filled(badge, CornerRadius::ZERO, if auto { dim } else { line });
p.galley(badge.min + vec2(3.0, 1.0), galley, Color32::WHITE);
}
}
}
/// The print tiling (View → Show Print Tiling, or the Print Tiling tool): each page of the
/// document's print settings as `print.preview` lays it out, its paper edge and its imageable
/// area dashed, numbered at its top left; tiles outside the tile range dimmer. Cached per revision.
fn print_tiling_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
let Some(st) = app.session.active() else { return };
let key = (st.uid, st.revision);
let pages = match &app.canvas.print_tiling {
Some((k, pages)) if *k == key => pages.clone(),
_ => {
// Settings that can't print have no pages to show.
let pages = std::sync::Arc::new(vectorcraft_engine::cmd::printtiling::pages(&st.doc).unwrap_or_default());
app.canvas.print_tiling = Some((key, pages.clone()));
pages
}
};
let font = egui::FontId::proportional(10.0);
let ring = |r: [f64; 4]| {
let q = xf.quad(Rect::new(r[0], r[1], r[2], r[3]));
q.iter().chain(q.first()).copied().collect::<Vec<Pos2>>()
};
for page in pages.iter() {
let color = if page.printed { PRINT_TILING } else { PRINT_TILING.gamma_multiply(0.45) };
p.add(Shape::line(ring(page.page), Stroke::new(0.75, color)));
let inner = ring(page.imageable);
p.extend(Shape::dashed_line(&inner, Stroke::new(1.0, color), 4.0, 3.0));
if let Some(corner) = inner.iter().copied().reduce(|a, b| pos2(a.x.min(b.x), a.y.min(b.y))) {
p.text(corner + vec2(3.0, 2.0), egui::Align2::LEFT_TOP, page.number.to_string(), font.clone(), color);
}
}
}
/// The print tiling's lines: a neutral grey that reads on the paper and on the pasteboard.
const PRINT_TILING: Color32 = Color32::from_gray(96);
fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
// The Selection tool's bounding box (rotated with the objects after a rotation).
let show_box = app.session.tool_id() == "selection"
&& app.ui.view.bounding_box
&& app.session.active().is_some_and(|st| !st.selection.is_empty() && st.selection.anchors.is_empty());
let bbox = if show_box { app.selection_box() } else { None };
let app = &*app;
let Some(st) = app.session.active() else { return };
let tool = app.session.tool_id();
let direct = matches!(tool, "directSelection" | "pen" | "addAnchor" | "deleteAnchor" | "anchorPoint" | "curvature");
for id in &st.selection.objects {
let Some(n) = st.doc.node(*id) else { continue };
let color = c32(st.doc.layer_color(*id));
let partial = st.selection.partial(*id);
// Path outlines.
stroke_path(p, &node_outline(n), xf, Stroke::new(1.0, color));
// Anchors (and handles for selected anchors in direct mode).
n.walk(&mut |c| {
let NodeKind::Path { path, .. } = &c.kind else { return };
for (si, ai, a) in path.anchors() {
let sel = match partial {
Some(set) => set.contains(&(si, ai)),
None => !direct || c.id == *id,
};
let sp = xf.to_screen(a.p);
if sel && (direct || partial.is_some()) {
for h in [a.h_in, a.h_out] {
if h.distance(a.p) > 1e-6 {
let hp = xf.to_screen(h);
p.line_segment([sp, hp], Stroke::new(1.0, color));
p.circle_filled(hp, 2.75, color);
}
}
}
anchor_square(
p,
sp,
if sel && partial.is_some() { selected_anchor(color) } else { color },
sel,
if partial.is_some() || direct { 5.0 } else { 4.0 },
);
}
});
// Centre point (Attributes panel → Show Center).
if n.shows_center()
&& let Some(b) = n.geometric_bounds()
{
anchor_square(p, xf.to_screen(b.center()), color, true, 4.0);
}
// Text: baseline marker.
if let NodeKind::Text(tx) = &n.kind {
let o = xf.to_screen(tx.xf * Point::ZERO);
let b = n.geometric_bounds().unwrap_or_default();
let e = xf.to_screen(Point::new(b.x1, (tx.xf * Point::ZERO).y));
p.line_segment([o, e], Stroke::new(1.0, color));
p.circle_filled(o, 2.5, color);
}
}
// The spine of each selected blend (or of the blend a selected key object belongs to).
let mut spines = vec![];
for id in &st.selection.objects {
let is_blend = |b: &vectorcraft_doc::NodeId| st.doc.node(*b).is_some_and(|n| matches!(n.kind, NodeKind::Blend { .. }));
let Some(b) = [Some(*id), st.doc.parent_of(*id)].into_iter().flatten().find(is_blend).filter(|b| !spines.contains(b)) else { continue };
spines.push(b);
let Some(NodeKind::Blend { children, spec }) = st.doc.node(b).map(|n| &n.kind) else { continue };
let Some((path, _)) = vectorcraft_doc::live::blend_spine(children, spec) else { continue };
let color = c32(st.doc.layer_color(b));
stroke_path(p, &path.to_bezpath(), xf, Stroke::new(1.0, color));
for (_, _, a) in path.anchors() {
anchor_square(p, xf.to_screen(a.p), color, false, if direct { 5.0 } else { 4.0 });
}
}
// Live Corners widgets (Selection / Direct Selection on a single live rectangle).
if matches!(tool, "selection" | "directSelection")
&& app.ui.view.corner_widgets
&& let Some(w) = vectorcraft_tools::corners::CornerWidgets::of(&st.doc, &st.selection, xf.zoom)
{
let color = c32(st.doc.layer_color(w.id));
for sp in w.points.map(|q| xf.to_screen(q)) {
p.circle_filled(sp, 3.0, Color32::WHITE);
p.circle_stroke(sp, 3.0, Stroke::new(1.0, color));
p.circle_filled(sp, 1.0, color);
}
}
// Bounding box with handles (Selection tool).
if let Some(b) = bbox {
let color = c32(st.doc.layer_color(st.selection.objects[0]));
p.add(Shape::closed_line(b.corners().iter().map(|q| xf.to_screen(*q)).collect(), Stroke::new(1.0, color)));
for h in vectorcraft_tools::bbox::Handle::ALL {
let c = xf.to_screen(b.to_doc() * h.pos(b.rect));
let hr = egui::Rect::from_center_size(c, vec2(6.0, 6.0));
p.rect_filled(hr, 0.0, Color32::WHITE);
p.rect_stroke(hr, 0.0, Stroke::new(1.0, color), StrokeKind::Inside);
}
}
}
fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: &Tokens) {
for o in overlays {
match o {
Overlay::Marquee(r) => {
let sr = xf.rect_to_screen(*r);
let pts = [sr.left_top(), sr.right_top(), sr.right_bottom(), sr.left_bottom(), sr.left_top()];
for w in pts.windows(2) {
p.extend(Shape::dashed_line(&[w[0], w[1]], Stroke::new(1.0, Color32::from_gray(90)), 3.0, 3.0));
}
}
Overlay::Path { path, color, width, dashed } => {
let s = Stroke::new(*width, c32(*color));
if *dashed {
for line in to_screen_path(path, xf) {
p.extend(Shape::dashed_line(&line, s, 4.0, 3.0));
}
} else {
stroke_path(p, path, xf, s);
}
}
Overlay::Line { a, b, color, dashed } => {
let s = Stroke::new(1.0, c32(*color));
let (a, b) = (xf.to_screen(*a), xf.to_screen(*b));
if *dashed {
p.extend(Shape::dashed_line(&[a, b], s, 4.0, 3.0));
} else {
p.line_segment([a, b], s);
}
}
Overlay::Anchor { p: pt, color, filled, size } => anchor_square(p, xf.to_screen(*pt), c32(*color), *filled, *size),
Overlay::Handle { p: pt, color } => {
p.circle_filled(xf.to_screen(*pt), 2.8, c32(*color));
}
Overlay::Label { p: pt, text, color } => {
let sp = xf.to_screen(*pt) + vec2(8.0, -14.0);
p.text(sp, egui::Align2::LEFT_TOP, text, egui::FontId::proportional(11.0), c32(*color));
}
Overlay::Highlight { quad, color } => {
let c = Color32::from_rgba_unmultiplied(color[0], color[1], color[2], color[3]);
p.add(Shape::convex_polygon(quad.iter().map(|q| xf.to_screen(*q)).collect(), c, Stroke::NONE));
}
Overlay::Measure { p: pt, text } => {
let sp = xf.to_screen(*pt) + vec2(14.0, 14.0);
let galley = p.layout(text.clone(), egui::FontId::proportional(11.0), Color32::WHITE, 200.0);
let r = egui::Rect::from_min_size(sp, galley.size() + vec2(12.0, 8.0));
p.rect_filled(r, CornerRadius::same(3), t.measure_bg);
p.galley(sp + vec2(6.0, 4.0), galley, Color32::WHITE);
}
Overlay::GridLine { a, b, color } => {
let c = Color32::from_rgba_unmultiplied(color[0], color[1], color[2], color[3]);
p.line_segment([xf.to_screen(*a), xf.to_screen(*b)], Stroke::new(1.0, c));
}
Overlay::Swatch { p: pt, color, selected } => {
// A white disc under the colour shows its opacity; a dark rim keeps it readable on
// any art, and the accent ring marks the selected stop.
let c = xf.to_screen(*pt);
p.circle_filled(c, 6.0, Color32::WHITE);
p.circle_filled(c, 5.0, Color32::from_rgba_unmultiplied(color[0], color[1], color[2], color[3]));
p.circle_stroke(c, 6.0, Stroke::new(1.0, Color32::from_gray(32)));
if *selected {
p.circle_stroke(c, 8.0, Stroke::new(2.0, t.accent));
}
}
}
}
}
/// The Home screen shown when no document is open.
fn home(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) {
let t = Tokens::get(ui.ctx());
ui.painter().rect_filled(rect, 0.0, t.panel_darker);
let inner = rect.shrink2(vec2((rect.width() - 820.0).max(40.0) / 2.0, 60.0));
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::top_down(egui::Align::Min)));
let ui = &mut child;
ui.label(egui::RichText::new("Welcome to VectorCraft").font(theme::semibold(26.0)).color(t.text));
ui.add_space(4.0);
ui.label(egui::RichText::new("Vector illustration — fast, open, scriptable.").size(14.0).color(t.text_dim));
ui.add_space(22.0);
ui.horizontal(|ui| {
if widgets::primary_button(ui, "New file").clicked() {
app.run("file.newDialog", json!({})).ok();
}
ui.add_space(8.0);
if widgets::secondary_button(ui, "Open").clicked() {
app.run("file.open", json!({})).ok();
}
});
ui.add_space(28.0);
ui.label(egui::RichText::new("Quickly start a new file").font(theme::semibold(14.0)).color(t.text));
ui.add_space(10.0);
// A few of New Document's presets (`file.newPresets`), as its cards.
let presets = ["Letter", "A4", "Web 1920×1080", "Phone 390×844", "Postcard", "Social Square Post 1080×1080"];
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(14.0, 14.0);
let cards: Vec<_> = presets.iter().filter_map(|name| vectorcraft_engine::cmd::newdoc::find(&app.session, name)).collect();
for s in cards {
if crate::dialogs::preset_card(ui, &s, false).clicked() {
app.run("file.new", json!({ "preset": s.name })).ok();
}
}
});
ui.add_space(28.0);
ui.label(egui::RichText::new("Community").font(theme::semibold(14.0)).color(t.text));
ui.add_space(10.0);
crate::community::links(app, ui);
}
fn kurbo_flatten(p: &BezPath, tol: f64, f: &mut impl FnMut(PathEl)) {
vectorcraft_geom::kurbo::flatten(p.elements().iter().copied(), tol, f);
}
fn upload(app: &mut VectorcraftApp, ctx: &egui::Context, img: &vectorcraft_render::Rendered) {
let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels);
match &mut app.canvas.texture {
Some(tex) => tex.set(color, egui::TextureOptions::LINEAR),
None => app.canvas.texture = Some(ctx.load_texture("canvas", color, egui::TextureOptions::LINEAR)),
}
}
/// The Contextual Task Bar: a floating pill under the selection with the most likely next actions.
fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
let t = Tokens::get(ui.ctx());
let Some(st) = app.session.active() else { return };
if st.selection.is_empty() || !matches!(app.session.tool_id(), "selection" | "directSelection" | "groupSelection") {
return;
}
let Some(b) = st.doc.bounds_of(&st.selection.objects, true) else { return };
let n = st.selection.len();
let first = st.selection.objects.first().and_then(|id| st.doc.node(*id)).cloned();
let is_group = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Group { .. }));
let is_text = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Text(_)));
let mut items: Vec<(&str, &str, &str)> = vec![]; // (label, icon, command)
if n > 1 {
items.push(("Group", "group", "object.group"));
items.push(("Unite", "squares-unite", "object.pathfinder.unite"));
} else if is_group {
items.push(("Ungroup", "ungroup", "object.ungroup"));
items.push(("Isolate", "square-dashed", "object.isolate"));
} else if is_text {
items.push(("Create Outlines", "type", "type.createOutlines"));
} else {
items.push(("Offset Path", "square-dashed", "object.path.offsetPath"));
items.push(("Simplify", "spline", "object.path.simplify"));
}
items.push(("Duplicate", "copy", "edit.duplicate"));
let fill = first.as_ref().map(|f| f.appearance.fill_paint()).unwrap_or_default();
let anchor = xf.to_screen(Point::new(b.center().x, b.y1));
let est_w = 118.0 + items.iter().map(|(l, _, _)| l.len() as f32 * 7.2 + 44.0).sum::<f32>();
let x = (anchor.x - est_w / 2.0).clamp(xf.rect.left() + 8.0, (xf.rect.right() - est_w - 8.0).max(xf.rect.left() + 8.0));
let y = (anchor.y + 28.0).min(xf.rect.bottom() - 56.0);
let mut run: Option<String> = None;
egui::Area::new(egui::Id::new("task-bar")).order(egui::Order::Middle).fixed_pos(pos2(x, y)).show(ui.ctx(), |ui| {
egui::Frame::NONE
.fill(t.panel)
.stroke(Stroke::new(1.0, t.tool_active))
.corner_radius(CornerRadius::same(5))
.inner_margin(egui::Margin::symmetric(8, 6))
.shadow(egui::epaint::Shadow { offset: [0, 3], blur: 10, spread: 0, color: Color32::from_black_alpha(70) })
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
let (g, _) = ui.allocate_exact_size(vec2(4.0, 28.0), Sense::hover());
ui.painter().rect_filled(g.shrink2(vec2(0.5, 4.0)), CornerRadius::same(2), t.button_border);
for (label, icon, cmd) in &items {
let galley = ui.painter().layout_no_wrap(label.to_string(), egui::FontId::proportional(13.0), t.text_strong);
let (r, resp) = ui.allocate_exact_size(vec2(galley.size().x + 38.0, 30.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(3), t.hover);
}
ui.painter().rect_stroke(r, CornerRadius::same(3), Stroke::new(1.0, t.button_border), StrokeKind::Inside);
crate::icons::paint(ui, icon, egui::Rect::from_min_size(r.min + vec2(8.0, 7.0), vec2(16.0, 16.0)), t.icon);
ui.painter().galley(pos2(r.left() + 30.0, r.center().y - galley.size().y / 2.0), galley, t.text_strong);
if resp.clicked() {
run = Some(cmd.to_string());
}
}
let (r, resp) = ui.allocate_exact_size(vec2(26.0, 30.0), Sense::click());
widgets::paint_chip(ui, egui::Rect::from_center_size(r.center(), vec2(16.0, 16.0)), &fill);
ui.painter().rect_stroke(
egui::Rect::from_center_size(r.center(), vec2(16.0, 16.0)),
0.0,
Stroke::new(1.0, t.button_border),
StrokeKind::Outside,
);
if resp.on_hover_text("Fill").clicked() {
app.session.fill_active = true;
app.ui.open_panel = Some("swatches".into());
}
if widgets::icon_button(ui, "lock", "Lock (⌘2)", false, 30.0).clicked() {
run = Some("object.lock".into());
}
if widgets::icon_button(ui, "ellipsis", "Hide Contextual Task Bar", false, 30.0).clicked() {
run = Some("window.taskBar".into());
}
});
});
});
if let Some(c) = run {
crate::menus::invoke(app, &c, json!({}));
}
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_engine::Session;
/// One headless canvas frame on an 800 × 600 window.
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) {
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
out.textures_delta.clear();
}
fn middle(pos: Pos2, pressed: bool) -> egui::Event {
egui::Event::PointerButton { pos, button: egui::PointerButton::Middle, pressed, modifiers: Default::default() }
}
#[test]
fn middle_drag_pans_the_view_with_any_tool() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
// A drawing tool: the middle button must pan, never draw.
app.select_tool("rectangle");
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos2(400.0, 300.0))]);
let before = *app.view().unwrap();
frame(&mut app, &ctx, vec![middle(pos2(400.0, 300.0), true)]);
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos2(450.0, 330.0))]);
frame(&mut app, &ctx, vec![middle(pos2(450.0, 330.0), false)]);
let after = *app.view().unwrap();
assert_eq!(after.zoom, before.zoom);
assert!((after.center.x - (before.center.x - 50.0 / before.zoom)).abs() < 1e-6);
assert!((after.center.y - (before.center.y - 30.0 / before.zoom)).abs() < 1e-6);
// Released: moving no longer pans, and nothing was drawn.
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos2(300.0, 200.0))]);
assert_eq!(app.view().unwrap().center, after.center);
assert_eq!(app.session.active().unwrap().doc.art_bounds(), None);
}
#[test]
fn swatches_and_proxy_paints_dropped_on_art_fill_the_object_hit() {
use vectorcraft_color::{Color, GradientGeom, GradientPaint, Paint};
use widgets::{PanelDrag, SwatchRows};
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let id = app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
app.session.execute("select.none", &json!({})).unwrap();
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
let drop = |app: &mut VectorcraftApp, d: PanelDrag, at: Point| {
let at = xf.to_screen(at);
egui::DragAndDrop::set_payload(&ctx, d);
let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() };
frame(app, &ctx, vec![egui::Event::PointerMoved(at), up]);
};
let fill = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.fill_paint();
let red = Paint::solid(Color::from_hex("#ed1c24").unwrap());
let rows = SwatchRows { grabbed: "Red".into(), names: vec!["Red".into()], groups: false };
let swatch = PanelDrag::Paint { paint: red.clone(), params: json!({"swatch": "Red"}), rows: Some(rows) };
// Off the art nothing happens.
drop(&mut app, swatch.clone(), Point::new(300.0, 250.0));
assert_eq!(fill(&app), Paint::solid(Color::WHITE));
drop(&mut app, swatch, Point::new(100.0, 100.0));
assert_eq!(fill(&app), red, "paint.setFill with the hit id");
assert!(app.session.active().unwrap().selection.is_empty(), "the selection stays as it was");
// A proxy's paint works the same.
drop(&mut app, PanelDrag::paint(Paint::solid(Color::rgb(0.0, 0.0, 1.0))), Point::new(60.0, 60.0));
assert_eq!(fill(&app), Paint::solid(Color::rgb(0.0, 0.0, 1.0)));
// A dragged gradient (the Gradient panel's thumbnail, a proxy) fits the object it lands on.
let mut g = GradientPaint::new(Default::default());
g.geom = Some(GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(10.0, 0.0), aspect: 1.0, focal: None });
drop(&mut app, PanelDrag::paint(Paint::Gradient(Box::new(g))), Point::new(100.0, 100.0));
assert!(matches!(fill(&app), Paint::Gradient(g) if g.geom.is_none()));
}
#[test]
fn dropping_the_appearance_thumbnail_on_art_copies_the_appearance() {
use vectorcraft_doc::NodeId;
let mut app = VectorcraftApp::new(Session::new(), Default::default());
let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap();
run(&mut app, "file.new", json!({"width": 400, "height": 300}));
let a = run(&mut app, "shape.rectangle", json!({"x": 20, "y": 20, "width": 100, "height": 100}))["id"].as_u64().unwrap();
run(&mut app, "effect.apply", json!({"effect": "distort.twist"}));
run(&mut app, "transparency.set", json!({"opacity": 40}));
let b = run(&mut app, "shape.rectangle", json!({"x": 200, "y": 20, "width": 100, "height": 100}))["id"].as_u64().unwrap();
run(&mut app, "select.set", json!({ "ids": [a] }));
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
// The Appearance panel's thumbnail (its drag payload) released over the second rectangle.
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
let drop = |app: &mut VectorcraftApp, at: Point| {
let at = xf.to_screen(at);
egui::DragAndDrop::set_payload(&ctx, widgets::PanelDrag::Appearance(NodeId(a)));
frame(app, &ctx, vec![egui::Event::PointerMoved(at)]);
let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() };
frame(app, &ctx, vec![up]);
};
drop(&mut app, Point::new(250.0, 70.0));
let doc = &app.session.active().unwrap().doc;
let (na, nb) = (doc.node(NodeId(a)).unwrap(), doc.node(NodeId(b)).unwrap());
assert_eq!((&nb.appearance, nb.opacity), (&na.appearance, na.opacity));
assert_eq!(nb.appearance.effects[0].id, "distort.twist");
// Dropped on empty canvas or on the source itself: nothing changes.
let undo_len = app.session.active().unwrap().history.undo.len();
drop(&mut app, Point::new(350.0, 250.0));
drop(&mut app, Point::new(70.0, 70.0));
assert_eq!(app.session.active().unwrap().history.undo.len(), undo_len);
}
#[test]
fn a_graphic_style_dropped_on_art_applies_to_the_object_hit() {
use vectorcraft_doc::NodeId;
let mut app = VectorcraftApp::new(Session::new(), Default::default());
let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap();
run(&mut app, "file.new", json!({"width": 400, "height": 300}));
let a = NodeId(run(&mut app, "shape.rectangle", json!({"x": 20, "y": 20, "width": 100, "height": 100}))["id"].as_u64().unwrap());
let b = NodeId(run(&mut app, "shape.rectangle", json!({"x": 200, "y": 20, "width": 100, "height": 100}))["id"].as_u64().unwrap());
run(&mut app, "select.set", json!({ "ids": [a.0] }));
let name = app.session.active().unwrap().doc.graphic_styles[2].name.clone();
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
let at = xf.to_screen(Point::new(250.0, 70.0));
egui::DragAndDrop::set_payload(&ctx, widgets::PanelDrag::GraphicStyle(name.clone()));
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at)]);
let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() };
frame(&mut app, &ctx, vec![up]);
let st = app.session.active().unwrap();
let g = st.doc.graphic_style(&name).unwrap();
// `graphicStyle.apply` with the hit object's id: linked, the selection left alone.
assert_eq!(st.doc.node(b).unwrap().graphic_style, Some(g.id));
assert_eq!(st.doc.node(b).unwrap().appearance, g.appearance);
assert_eq!(st.doc.node(a).unwrap().graphic_style, None);
assert_eq!(st.selection.objects, [a]);
}
#[test]
fn art_moved_off_the_canvas_becomes_a_panel_drag_and_stays_put() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let id = app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
let id = vectorcraft_doc::NodeId(id);
app.select_tool("selection");
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
let before = app.session.active().unwrap().doc.clone();
let undo = app.session.active().unwrap().history.undo.len();
let button = |pos: Pos2, pressed: bool| egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed,
modifiers: Default::default(),
};
let start = xf.to_screen(Point::new(100.0, 100.0));
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start), button(start, true)]);
// Moving on the canvas moves the art...
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start + vec2(40.0, 0.0))]);
assert!(app.session.active().unwrap().interaction.is_some());
// ...until the pointer leaves it: the move is dropped and the panels get the art.
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos2(850.0, 100.0))]);
assert!(app.session.active().unwrap().interaction.is_none());
assert_eq!(*app.session.active().unwrap().doc, *before, "the art is back where it was");
assert_eq!(egui::DragAndDrop::payload::<widgets::PanelDrag>(&ctx).as_deref(), Some(&widgets::PanelDrag::Art(vec![id])));
assert!(!app.session.tool_busy());
// Released anywhere, nothing moves and nothing is recorded.
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start), button(start, false)]);
let st = app.session.active().unwrap();
assert_eq!(*st.doc, *before);
assert_eq!(st.history.undo.len(), undo);
}
}