Merge pull request #1 from storytold/live-corners-pan-close-prompt
Live Corners dragging, transform pivot snapping, middle-mouse pan, save prompt on close
This commit is contained in:
commit
1531cb0d11
14
ROADMAP.md
14
ROADMAP.md
|
|
@ -28,14 +28,16 @@ _Last updated: 2026-10-02._
|
|||
- **UI:** Illustrator 2026 layout restyled to measured values:
|
||||
- Medium Dark theme, categorized and Advanced toolbars, 35 pt document tabs, 33 pt panel tabs.
|
||||
- Hint bar, contextual task bar, 19 dock panels with ≡ menus.
|
||||
- Native macOS menu bar, vector tool cursors, a ⌘K command palette.
|
||||
- Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool.
|
||||
- Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close).
|
||||
- Four brightness themes, persistent preferences.
|
||||
- **Tools:**
|
||||
- **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso.
|
||||
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join.
|
||||
- **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools.
|
||||
- **Cutting:** Eraser, Scissors, Knife.
|
||||
- **Transform:** Rotate, Reflect, Scale, Shear, Reshape, Free Transform (distort/perspective).
|
||||
- **Transform:** Rotate, Reflect, Scale, Shear (click or Alt-click snaps the reference point to anchors and centres), Reshape, Free Transform (distort/perspective).
|
||||
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout.
|
||||
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type.
|
||||
- **Other:** Eyedropper, Gradient annotator, Artboard, Measure, Type, Hand, Zoom, Rotate View.
|
||||
- **Drawing aids:** Smart Guides and snapping, and Draw Normal / Behind / Inside modes.
|
||||
|
|
@ -57,17 +59,17 @@ _Last updated: 2026-10-02._
|
|||
| # | Milestone | Status | Est. remaining, one agent (h) |
|
||||
|---|---|---|---|
|
||||
| M0 | Skeleton + vertical slice | ✅ done | — |
|
||||
| M1 | Selection, transform, layers, MCP | ✅ mostly done (rotated persistent bbox pending) | 4–6 |
|
||||
| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape landed; Shaper, Pen modifier nuances) | 15–20 |
|
||||
| M1 | Selection, transform, layers, MCP | ✅ mostly done (transform reference point snaps to anchors/centres; rotated persistent bbox pending) | 4–6 |
|
||||
| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging landed; Shaper, Pen modifier nuances) | 15–20 |
|
||||
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | 🟡 panels done; opacity masks done (make/release, clip, invert, disable, link; render + SVG `<mask>` in/out + PDF soft mask); mask-editing mode (click the mask thumbnail; live update) done; freeform gradients pending | 18–27 |
|
||||
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | 🟡 Export for Screens (PNG/JPG/WebP/SVG/PDF × scales) done; headless CLI/MCP export every format; system clipboard: copy puts SVG markup on it, paste takes SVG from other apps (Ctrl/Cmd+C/X/V now also work off macOS); live effects now survive SVG/PDF/clipboard export (geometry baked, SVG filters for shadows/glows/blur/feather); PDF raster effects, PNG/PDF clipboard flavours, EPS/DXF pending | 52–73 |
|
||||
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | 🟡 Export for Screens (PNG/JPG/WebP/SVG/PDF × scales) done; headless CLI/MCP export every format; system clipboard: copy puts SVG markup on it, paste takes SVG from other apps (Ctrl/Cmd+C/X/V now also work off macOS); live effects now survive SVG/PDF/clipboard export (geometry baked, SVG filters for shadows/glows/blur/feather); Save / Don't Save / Cancel before closing modified documents; PDF raster effects, PNG/PDF clipboard flavours, EPS/DXF pending | 52–73 |
|
||||
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
|
||||
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Shape Builder edge erase, large-offset bug open) | 3–6 |
|
||||
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 |
|
||||
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass pending | 8–12 |
|
||||
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
|
||||
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
|
||||
| M11 | Artboards & views (artboard panel/tool done, Trim View; print tiling, multiple windows, presentation polish) | 🟡 | 20–33 |
|
||||
| M11 | Artboards & views (artboard panel/tool done, Trim View, middle-button pan; print tiling, multiple windows, presentation polish) | 🟡 | 20–33 |
|
||||
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (12 presets, 18 ms/1k² image), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, regenerate in place) done; graph Design/Column/Marker designs pending | 6–10 |
|
||||
| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 |
|
||||
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor, workspaces done; accessibility, Windows/Linux packaging pending | 20–30 |
|
||||
|
|
|
|||
|
|
@ -16,11 +16,24 @@ pub struct ViewInfo {
|
|||
pub show_bbox: bool,
|
||||
/// View → Snap to Pixel: drawing and moving land on whole pixels (points at 72 ppi).
|
||||
pub snap_to_pixel: bool,
|
||||
/// View → Snap to Point: picked points (a transform's reference point) land on anchors.
|
||||
pub snap_to_point: bool,
|
||||
/// View → Show Corner Widget.
|
||||
pub corner_widgets: bool,
|
||||
}
|
||||
|
||||
impl Default for ViewInfo {
|
||||
fn default() -> Self {
|
||||
Self { zoom: 1.0, outline: false, smart_guides: true, snap_to_grid: false, show_bbox: true, snap_to_pixel: false }
|
||||
Self {
|
||||
zoom: 1.0,
|
||||
outline: false,
|
||||
smart_guides: true,
|
||||
snap_to_grid: false,
|
||||
show_bbox: true,
|
||||
snap_to_pixel: false,
|
||||
snap_to_point: true,
|
||||
corner_widgets: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -63,6 +76,8 @@ impl Session {
|
|||
snap_to_grid: view.snap_to_grid,
|
||||
show_bbox: view.show_bbox,
|
||||
snap_to_pixel: view.snap_to_pixel,
|
||||
snap_to_point: view.snap_to_point,
|
||||
corner_widgets: view.corner_widgets,
|
||||
};
|
||||
f(self.tool.as_mut(), &cx)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,239 @@
|
|||
//! Live Corners: the widgets inside each corner of a selected live rectangle. Dragging one rounds
|
||||
//! (or sharpens) all four corners together. The Selection and Direct Selection tools share this,
|
||||
//! and the canvas draws the widgets from the same geometry.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{Document, LiveShape, NodeId, NodeKind, Selection};
|
||||
use vectorcraft_geom::{Affine, Point, Vec2};
|
||||
|
||||
use crate::{Action, Overlay, ToolContext};
|
||||
|
||||
/// Widgets sit at least this far inside their corner (screen px), further in once the radius is.
|
||||
const MIN_INSET_PX: f64 = 10.0;
|
||||
/// Shapes whose shorter side is smaller than this on screen (px) hide their widgets.
|
||||
const MIN_SIDE_PX: f64 = 3.0 * MIN_INSET_PX;
|
||||
/// Corners in radii order (top-left, top-right, bottom-right, bottom-left): position as factors
|
||||
/// of (w, h) and the inward diagonal.
|
||||
const CORNERS: [((f64, f64), (f64, f64)); 4] =
|
||||
[((0.0, 0.0), (1.0, 1.0)), ((1.0, 0.0), (-1.0, 1.0)), ((1.0, 1.0), (-1.0, -1.0)), ((0.0, 1.0), (1.0, -1.0))];
|
||||
|
||||
/// The corner widgets of a selection that is exactly one editable live rectangle.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct CornerWidgets {
|
||||
pub id: NodeId,
|
||||
w: f64,
|
||||
h: f64,
|
||||
radii: [f64; 4],
|
||||
xf: Affine,
|
||||
/// Widget centres in document coordinates, in radii order.
|
||||
pub points: [Point; 4],
|
||||
}
|
||||
|
||||
impl CornerWidgets {
|
||||
/// The widgets at `zoom` (screen px per document point), if the selection has them.
|
||||
pub fn of(doc: &Document, selection: &Selection, zoom: f64) -> Option<Self> {
|
||||
let [id] = selection.objects[..] else { return None };
|
||||
if !doc.is_editable(id) {
|
||||
return None;
|
||||
}
|
||||
let Some(NodeKind::Path { live: Some(LiveShape::Rectangle { w, h, radii, xf }), .. }) = doc.node(id).map(|n| &n.kind) else {
|
||||
return None;
|
||||
};
|
||||
let (w, h, radii, xf) = (*w, *h, *radii, *xf);
|
||||
// Screen pixels per shape unit along each side (the shape may be scaled or skewed).
|
||||
let c = xf.as_coeffs();
|
||||
let (px, py) = (Vec2::new(c[0], c[1]).hypot() * zoom, Vec2::new(c[2], c[3]).hypot() * zoom);
|
||||
if xf.determinant().abs() < 1e-12 || (w * px).min(h * py) < MIN_SIDE_PX {
|
||||
return None;
|
||||
}
|
||||
let points = std::array::from_fn(|k| {
|
||||
let ((fx, fy), (sx, sy)) = CORNERS[k];
|
||||
let ix = radii[k].max(MIN_INSET_PX / px).min(w / 2.0);
|
||||
let iy = radii[k].max(MIN_INSET_PX / py).min(h / 2.0);
|
||||
xf * Point::new(fx * w + sx * ix, fy * h + sy * iy)
|
||||
});
|
||||
Some(Self { id, w, h, radii, xf, points })
|
||||
}
|
||||
|
||||
/// The widgets the active tool can drag (View → Show Corner Widget on).
|
||||
pub fn for_tool(cx: &ToolContext) -> Option<Self> {
|
||||
if !cx.corner_widgets {
|
||||
return None;
|
||||
}
|
||||
Self::of(cx.doc, cx.selection, cx.zoom)
|
||||
}
|
||||
|
||||
/// Index of the widget nearest to `p` within `tol` (document units).
|
||||
pub fn hit(&self, p: Point, tol: f64) -> Option<usize> {
|
||||
(0..4).filter(|k| self.points[*k].distance(p) <= tol).min_by(|a, b| self.points[*a].distance(p).total_cmp(&self.points[*b].distance(p)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `p` over a corner widget the active tool would drag?
|
||||
pub fn over_widget(cx: &ToolContext, p: Point) -> bool {
|
||||
CornerWidgets::for_tool(cx).and_then(|w| w.hit(p, cx.tol(5.0))).is_some()
|
||||
}
|
||||
|
||||
/// Dragging a corner widget: the radius follows the pointer along the corner's diagonal.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct CornerDrag {
|
||||
widgets: CornerWidgets,
|
||||
corner: usize,
|
||||
start: Point,
|
||||
began: bool,
|
||||
radius: f64,
|
||||
at: Point,
|
||||
}
|
||||
|
||||
impl CornerDrag {
|
||||
/// Start a drag when `p` is on a widget of the selection.
|
||||
pub fn hit(cx: &ToolContext, p: Point) -> Option<Self> {
|
||||
let widgets = CornerWidgets::for_tool(cx)?;
|
||||
let corner = widgets.hit(p, cx.tol(5.0))?;
|
||||
Some(Self { widgets, corner, start: p, began: false, radius: widgets.radii[corner], at: p })
|
||||
}
|
||||
|
||||
/// The start radius plus the pointer's travel along the corner's inward diagonal (in the
|
||||
/// shape's own units), from square to fully round.
|
||||
fn radius_at(&self, p: Point) -> f64 {
|
||||
let w = &self.widgets;
|
||||
let inv = w.xf.inverse();
|
||||
let d = inv * p - inv * self.start;
|
||||
let (_, (sx, sy)) = CORNERS[self.corner];
|
||||
(w.radii[self.corner] + (d.x * sx + d.y * sy) / 2.0).clamp(0.0, w.w.min(w.h) / 2.0)
|
||||
}
|
||||
|
||||
pub fn drag(&mut self, cx: &ToolContext, p: Point) -> Vec<Action> {
|
||||
let mut out = vec![];
|
||||
if !self.began {
|
||||
if p.distance(self.start) < cx.tol(3.0) {
|
||||
return out;
|
||||
}
|
||||
self.began = true;
|
||||
out.push(Action::Begin("Corner Radius".into()));
|
||||
}
|
||||
self.radius = self.radius_at(p);
|
||||
self.at = p;
|
||||
out.push(Action::Preview("object.setLiveShape".into(), json!({ "id": self.widgets.id.0, "radius": self.radius })));
|
||||
out
|
||||
}
|
||||
|
||||
pub fn finish(self) -> Vec<Action> {
|
||||
if self.began { vec![Action::Commit] } else { vec![] }
|
||||
}
|
||||
|
||||
/// The radius readout next to the pointer.
|
||||
pub fn overlays(&self) -> Vec<Overlay> {
|
||||
if !self.began {
|
||||
return vec![];
|
||||
}
|
||||
vec![Overlay::Measure { p: self.at, text: format!("Radius: {}", crate::xform::fmt_pt(self.radius)) }]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testutil::*;
|
||||
use crate::{PointerEvent, PointerKind};
|
||||
use vectorcraft_doc::{Appearance, Node};
|
||||
|
||||
/// A live 100 × 100 rectangle at (100, 100) with corner radius `r`.
|
||||
fn live_rect(r: f64, xf: Affine) -> (Document, NodeId) {
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let live = LiveShape::Rectangle { w: 100.0, h: 100.0, radii: [r; 4], xf };
|
||||
let mut n = Node::path(id, live.to_path(), Appearance::default_art());
|
||||
if let NodeKind::Path { live: slot, .. } = &mut n.kind {
|
||||
*slot = Some(live);
|
||||
}
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
(d, id)
|
||||
}
|
||||
|
||||
fn selected(id: NodeId) -> Selection {
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
s
|
||||
}
|
||||
|
||||
fn radius(a: &Action) -> f64 {
|
||||
let Action::Preview(c, v) = a else { panic!("not a preview: {a:?}") };
|
||||
assert_eq!(c, "object.setLiveShape");
|
||||
v["radius"].as_f64().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn widgets_sit_inside_each_corner() {
|
||||
let (d, id) = live_rect(0.0, Affine::translate((100.0, 100.0)));
|
||||
let w = CornerWidgets::of(&d, &selected(id), 1.0).unwrap();
|
||||
assert_eq!(w.points, [Point::new(110.0, 110.0), Point::new(190.0, 110.0), Point::new(190.0, 190.0), Point::new(110.0, 190.0)]);
|
||||
// A larger radius moves them to the arc centres; zooming in keeps a 10 px minimum.
|
||||
let (d, id) = live_rect(20.0, Affine::translate((100.0, 100.0)));
|
||||
assert_eq!(CornerWidgets::of(&d, &selected(id), 1.0).unwrap().points[0], Point::new(120.0, 120.0));
|
||||
assert_eq!(CornerWidgets::of(&d, &selected(id), 4.0).unwrap().points[2], Point::new(180.0, 180.0));
|
||||
// Hidden when the shape is tiny on screen, for plain paths and without a single selection.
|
||||
assert!(CornerWidgets::of(&d, &selected(id), 0.2).is_none());
|
||||
let (d, id) = doc_with_rect();
|
||||
assert!(CornerWidgets::of(&d, &selected(id), 1.0).is_none());
|
||||
assert!(CornerWidgets::of(&d, &Selection::default(), 1.0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_widget_inward_rounds_all_corners_in_one_step() {
|
||||
let (d, id) = live_rect(0.0, Affine::translate((100.0, 100.0)));
|
||||
let s = selected(id);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
for mut t in [crate::create("selection"), crate::create("directSelection")] {
|
||||
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 190.0, 191.0)).is_empty());
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 175.0, 176.0));
|
||||
assert_eq!(a[0], Action::Begin("Corner Radius".into()));
|
||||
assert!((radius(&a[1]) - 15.0).abs() < 1e-9);
|
||||
assert!(matches!(&a[1], Action::Preview(_, v) if v["id"] == id.0));
|
||||
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == "Radius: 15.00 pt")));
|
||||
// Past the middle the corner is fully round; back outside it is square.
|
||||
assert_eq!(radius(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 100.0, 100.0))[0]), 50.0);
|
||||
assert_eq!(radius(&t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 260.0, 260.0))[0]), 0.0);
|
||||
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 260.0, 260.0)), vec![Action::Commit]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drag_follows_a_rotated_shape_and_a_click_does_nothing() {
|
||||
// Rotated 90°: the shape's top-left corner is at document (200, 100).
|
||||
let xf = Affine::translate((200.0, 100.0)) * Affine::rotate(std::f64::consts::FRAC_PI_2);
|
||||
let (d, id) = live_rect(10.0, xf);
|
||||
let s = selected(id);
|
||||
let p = paint();
|
||||
let cx = cx(&d, &s, &p);
|
||||
let w = CornerWidgets::of(&d, &s, 1.0).unwrap();
|
||||
assert!(w.points[0].distance(Point::new(190.0, 110.0)) < 1e-9);
|
||||
let mut t = crate::create("directSelection");
|
||||
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 190.0, 110.0));
|
||||
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 180.0, 120.0));
|
||||
assert!((radius(&a[1]) - 20.0).abs() < 1e-9);
|
||||
// A click without a drag leaves the shape (and the undo history) alone.
|
||||
let mut t = crate::create("selection");
|
||||
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 190.0, 110.0)).is_empty());
|
||||
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 190.0, 110.0)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn widgets_off_or_elsewhere_keep_the_tools_behaviour() {
|
||||
let (d, id) = live_rect(0.0, Affine::translate((100.0, 100.0)));
|
||||
let s = selected(id);
|
||||
let p = paint();
|
||||
let mut c = cx(&d, &s, &p);
|
||||
assert!(over_widget(&c, Point::new(110.0, 110.0)));
|
||||
assert!(!over_widget(&c, Point::new(150.0, 150.0)));
|
||||
c.corner_widgets = false;
|
||||
assert!(!over_widget(&c, Point::new(110.0, 110.0)));
|
||||
// With the widgets hidden, a drag from the same spot moves the object.
|
||||
let mut t = crate::create("selection");
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 110.0, 110.0));
|
||||
let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 130.0, 110.0));
|
||||
assert_eq!(a[0], Action::Begin("Move".into()));
|
||||
}
|
||||
}
|
||||
|
|
@ -2,8 +2,8 @@
|
|||
//!
|
||||
//! Direct Selection: click an anchor to select it (Shift toggles), click a segment to select the
|
||||
//! path's anchors on that segment, drag to move selected anchors, drag a direction handle to
|
||||
//! reshape, marquee to select anchors. Group Selection: click selects the leaf; each further click
|
||||
//! on it adds the next enclosing group.
|
||||
//! reshape, marquee to select anchors, drag a live rectangle's corner widget to round its corners.
|
||||
//! Group Selection: click selects the leaf; each further click on it adds the next enclosing group.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::hit::hit_test;
|
||||
|
|
@ -11,6 +11,7 @@ use vectorcraft_doc::{AnchorRef, NodeId, NodeKind};
|
|||
use vectorcraft_geom::{Point, Rect};
|
||||
|
||||
use crate::bbox::move_delta;
|
||||
use crate::corners::{CornerDrag, over_widget};
|
||||
use crate::select::matrix_json;
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext};
|
||||
|
||||
|
|
@ -21,6 +22,7 @@ enum State {
|
|||
MoveObject { start: Point, began: bool },
|
||||
Handle { id: NodeId, si: usize, ai: usize, out: bool },
|
||||
Marquee { start: Point, cur: Point, add: bool },
|
||||
Corner(CornerDrag),
|
||||
}
|
||||
|
||||
pub struct DirectSelectionTool {
|
||||
|
|
@ -117,6 +119,10 @@ impl Tool for DirectSelectionTool {
|
|||
}
|
||||
}
|
||||
(PointerKind::Down, _) => {
|
||||
if let Some(c) = CornerDrag::hit(cx, p) {
|
||||
self.state = State::Corner(c);
|
||||
return vec![];
|
||||
}
|
||||
if let Some((id, si, ai, out)) = hit_handle(cx, p, tol) {
|
||||
self.state = State::Handle { id, si, ai, out };
|
||||
return vec![Action::Begin("Reshape".into())];
|
||||
|
|
@ -185,6 +191,15 @@ impl Tool for DirectSelectionTool {
|
|||
self.state = State::Marquee { start, cur: p, add };
|
||||
vec![]
|
||||
}
|
||||
(PointerKind::Drag, State::Corner(mut c)) => {
|
||||
let out = c.drag(cx, p);
|
||||
self.state = State::Corner(c);
|
||||
out
|
||||
}
|
||||
(PointerKind::Up, State::Corner(c)) => {
|
||||
self.state = State::Idle;
|
||||
c.finish()
|
||||
}
|
||||
(PointerKind::Up, State::MoveAnchors { began, .. } | State::MoveObject { began, .. }) => {
|
||||
self.state = State::Idle;
|
||||
if began { vec![Action::Commit] } else { vec![] }
|
||||
|
|
@ -217,12 +232,16 @@ impl Tool for DirectSelectionTool {
|
|||
}
|
||||
}
|
||||
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
|
||||
match self.state {
|
||||
State::Marquee { start, cur, .. } => vec![Overlay::Marquee(Rect::from_points(start, cur))],
|
||||
match &self.state {
|
||||
State::Marquee { start, cur, .. } => vec![Overlay::Marquee(Rect::from_points(*start, *cur))],
|
||||
State::Corner(c) => c.overlays(),
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor {
|
||||
if !self.group && (matches!(self.state, State::Corner(_)) || over_widget(cx, p)) {
|
||||
return Cursor::CornerRadius;
|
||||
}
|
||||
Cursor::ArrowHollow
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
//! Smart Guides: snapping to anchors, object bounds (edges/centres), artboards, with the magenta
|
||||
//! construction lines and labels Illustrator users expect.
|
||||
|
||||
use vectorcraft_doc::hit::{HitOptions, hit_test};
|
||||
use vectorcraft_doc::{Document, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{Point, Rect, Vec2};
|
||||
|
||||
|
|
@ -75,6 +76,28 @@ impl Targets {
|
|||
t
|
||||
}
|
||||
|
||||
/// Anchors and centres of the visible leaves under `roots` (the roots included) whose bounds
|
||||
/// reach into `near`.
|
||||
fn points_near(doc: &Document, roots: &[NodeId], near: Rect) -> Self {
|
||||
let mut t = Targets::default();
|
||||
for n in roots.iter().filter_map(|id| doc.node(*id)) {
|
||||
n.walk(&mut |c| {
|
||||
if c.is_container() || !c.visible {
|
||||
return;
|
||||
}
|
||||
let Some(b) = c.geometric_bounds() else { return };
|
||||
if b.x0 > near.x1 || b.x1 < near.x0 || b.y0 > near.y1 || b.y1 < near.y0 {
|
||||
return;
|
||||
}
|
||||
if let NodeKind::Path { path, .. } = &c.kind {
|
||||
t.points.extend(path.anchors().map(|(_, _, a)| (a.p, Kind::Anchor)));
|
||||
}
|
||||
t.points.push((b.center(), Kind::Center));
|
||||
});
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// Snap a single point. Returns the snapped point and guide overlays.
|
||||
pub fn snap_point(&self, p: Point, tol: f64) -> (Point, Vec<Overlay>) {
|
||||
if let Some((q, k)) = self.points.iter().filter(|(q, _)| q.distance(p) <= tol).min_by(|a, b| a.0.distance(p).total_cmp(&b.0.distance(p))) {
|
||||
|
|
@ -148,6 +171,19 @@ pub fn snap_draw(cx: &ToolContext, p: Point, exclude: &[NodeId]) -> (Point, Vec<
|
|||
Targets::collect(cx.doc, exclude, None).snap_point(p, cx.tol(5.0))
|
||||
}
|
||||
|
||||
/// Snap a picked point (a transform tool's reference point) to the nearest anchor or centre of the
|
||||
/// selection or of the object under the pointer, when Snap to Point or Smart Guides is on.
|
||||
pub fn snap_pick(cx: &ToolContext, p: Point) -> (Point, Vec<Overlay>) {
|
||||
if !(cx.snap_to_point || cx.smart_guides) {
|
||||
return (p, vec![]);
|
||||
}
|
||||
let tol = cx.tol(5.0);
|
||||
let mut roots = cx.selection.objects.clone();
|
||||
roots.extend(hit_test(cx.doc, p, HitOptions { tol, ..cx.hit_options() }).map(|h| h.leaf));
|
||||
let near = Rect::new(p.x - tol, p.y - tol, p.x + tol, p.y + tol);
|
||||
Targets::points_near(cx.doc, &roots, near).snap_point(p, tol)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
pub mod bbox;
|
||||
pub mod builder;
|
||||
pub mod catalog;
|
||||
pub mod corners;
|
||||
pub mod direct;
|
||||
pub mod distort;
|
||||
pub mod draw2;
|
||||
|
|
@ -150,6 +151,10 @@ pub struct ToolContext<'a> {
|
|||
pub show_bbox: bool,
|
||||
/// View → Snap to Pixel.
|
||||
pub snap_to_pixel: bool,
|
||||
/// View → Snap to Point: picked points (a transform's reference point) land on anchors.
|
||||
pub snap_to_point: bool,
|
||||
/// View → Show Corner Widget: live rectangles show draggable Live Corners widgets.
|
||||
pub corner_widgets: bool,
|
||||
}
|
||||
|
||||
impl ToolContext<'_> {
|
||||
|
|
@ -195,6 +200,8 @@ pub enum Cursor {
|
|||
ResizeNwSe,
|
||||
ResizeNeSw,
|
||||
Rotate,
|
||||
/// Over a Live Corners widget (drag to round the corners).
|
||||
CornerRadius,
|
||||
Pen,
|
||||
PenAdd,
|
||||
PenDelete,
|
||||
|
|
@ -315,6 +322,8 @@ pub(crate) mod testutil {
|
|||
snap_to_grid: false,
|
||||
show_bbox: true,
|
||||
snap_to_pixel: false,
|
||||
snap_to_point: true,
|
||||
corner_widgets: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
//! The Selection tool (V): click/shift-click, marquee, move (Alt copies, Shift constrains),
|
||||
//! bounding-box scale (Shift proportional, Alt from centre) and rotate (outside corners, Shift 45°),
|
||||
//! double-click to enter isolation mode.
|
||||
//! drag a live rectangle's corner widget to round its corners, double-click to enter isolation mode.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeId;
|
||||
|
|
@ -8,6 +8,7 @@ use vectorcraft_doc::hit::{hit_test, marquee};
|
|||
use vectorcraft_geom::{Affine, Point, Rect};
|
||||
|
||||
use crate::bbox::{Handle, hit_handle, in_rotate_zone, move_delta, rotate_for_drag, scale_for_drag};
|
||||
use crate::corners::{CornerDrag, over_widget};
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, json_ids};
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
|
|
@ -32,6 +33,8 @@ enum State {
|
|||
cur: Point,
|
||||
add: bool,
|
||||
},
|
||||
/// Dragging a Live Corners widget.
|
||||
Corner(CornerDrag),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
|
|
@ -89,7 +92,11 @@ impl Tool for SelectionTool {
|
|||
vec![]
|
||||
}
|
||||
(PointerKind::Down, _) => {
|
||||
// 1. Bounding-box handles of the current selection.
|
||||
// 1. Live Corners widgets, then the bounding-box handles of the current selection.
|
||||
if let Some(c) = CornerDrag::hit(cx, p) {
|
||||
self.state = State::Corner(c);
|
||||
return vec![];
|
||||
}
|
||||
if cx.show_bbox
|
||||
&& let Some(r) = selection_bounds(cx)
|
||||
{
|
||||
|
|
@ -173,6 +180,15 @@ impl Tool for SelectionTool {
|
|||
self.state = State::Marquee { start, cur: p, add };
|
||||
vec![]
|
||||
}
|
||||
(PointerKind::Drag, State::Corner(mut c)) => {
|
||||
let out = c.drag(cx, p);
|
||||
self.state = State::Corner(c);
|
||||
out
|
||||
}
|
||||
(PointerKind::Up, State::Corner(c)) => {
|
||||
self.state = State::Idle;
|
||||
c.finish()
|
||||
}
|
||||
(PointerKind::Up, State::Moving { began, .. }) => {
|
||||
self.state = State::Idle;
|
||||
self.measure = None;
|
||||
|
|
@ -204,8 +220,10 @@ impl Tool for SelectionTool {
|
|||
|
||||
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
|
||||
let mut o = vec![];
|
||||
if let State::Marquee { start, cur, .. } = self.state {
|
||||
o.push(Overlay::Marquee(Rect::from_points(start, cur)));
|
||||
match &self.state {
|
||||
State::Marquee { start, cur, .. } => o.push(Overlay::Marquee(Rect::from_points(*start, *cur))),
|
||||
State::Corner(c) => o.extend(c.overlays()),
|
||||
_ => {}
|
||||
}
|
||||
o.extend(self.guides.iter().cloned());
|
||||
if let Some((p, t)) = &self.measure {
|
||||
|
|
@ -219,8 +237,12 @@ impl Tool for SelectionTool {
|
|||
State::Rotating { .. } => return Cursor::Rotate,
|
||||
State::Scaling { handle, .. } => return handle_cursor(handle),
|
||||
State::Moving { began: true, .. } => return Cursor::Arrow,
|
||||
State::Corner(_) => return Cursor::CornerRadius,
|
||||
_ => {}
|
||||
}
|
||||
if over_widget(cx, p) {
|
||||
return Cursor::CornerRadius;
|
||||
}
|
||||
if cx.show_bbox
|
||||
&& let Some(r) = selection_bounds(cx)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@
|
|||
//! The reference point defaults to the centre of the selection. A click sets it (shown as a cyan
|
||||
//! target); a drag transforms about it with a live preview (Shift constrains to 45° / uniform, Alt
|
||||
//! at release makes a copy). Alt-click sets the point and opens the tool's dialog; double-click
|
||||
//! or Return opens the dialog too.
|
||||
//! or Return opens the dialog too. A clicked or Alt-clicked point snaps to the nearest anchor or
|
||||
//! centre (Snap to Point / Smart Guides), labelled while hovering, so transforms pivot exactly
|
||||
//! on a corner.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeId;
|
||||
|
|
@ -11,6 +13,7 @@ use vectorcraft_geom::{Affine, Point, Rect, Vec2};
|
|||
|
||||
use super::target_overlays;
|
||||
use crate::bbox::rotate_for_drag;
|
||||
use crate::guides::snap_pick;
|
||||
use crate::select::{matrix_json, selection_bounds};
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
||||
|
||||
|
|
@ -54,6 +57,8 @@ impl TransformKind {
|
|||
#[derive(Clone, Copy, Debug)]
|
||||
struct Drag {
|
||||
start: Point,
|
||||
/// The pressed point snapped to an anchor or centre (becomes the reference point on a click).
|
||||
pick: Point,
|
||||
origin: Point,
|
||||
began: bool,
|
||||
bounds: Rect,
|
||||
|
|
@ -66,6 +71,8 @@ pub struct TransformTool {
|
|||
origin: Option<(Point, Vec<NodeId>)>,
|
||||
drag: Option<Drag>,
|
||||
measure: Option<(Point, String)>,
|
||||
/// Snap feedback while hovering ("anchor" / "center" label).
|
||||
hover: Vec<Overlay>,
|
||||
}
|
||||
|
||||
fn about(o: Point, a: Affine) -> Affine {
|
||||
|
|
@ -79,7 +86,7 @@ pub fn reflect_matrix(o: Point, theta: f64) -> Affine {
|
|||
|
||||
impl TransformTool {
|
||||
pub fn new(kind: TransformKind) -> Self {
|
||||
Self { kind, origin: None, drag: None, measure: None }
|
||||
Self { kind, origin: None, drag: None, measure: None, hover: vec![] }
|
||||
}
|
||||
|
||||
/// The effective reference point: the custom one if it belongs to the current selection,
|
||||
|
|
@ -160,13 +167,15 @@ impl Tool for TransformTool {
|
|||
match ev.kind {
|
||||
PointerKind::Down => {
|
||||
let Some(bounds) = selection_bounds(cx) else { return vec![] };
|
||||
self.hover.clear();
|
||||
let (pick, _) = snap_pick(cx, p);
|
||||
if ev.mods.alt {
|
||||
self.origin = Some((p, cx.selection.objects.clone()));
|
||||
self.origin = Some((pick, cx.selection.objects.clone()));
|
||||
self.drag = None;
|
||||
return vec![self.dialog(p)];
|
||||
return vec![self.dialog(pick)];
|
||||
}
|
||||
let origin = self.reference_point(cx).unwrap_or(bounds.center());
|
||||
self.drag = Some(Drag { start: p, origin, began: false, bounds, last: Affine::IDENTITY });
|
||||
self.drag = Some(Drag { start: p, pick, origin, began: false, bounds, last: Affine::IDENTITY });
|
||||
vec![]
|
||||
}
|
||||
PointerKind::Drag => {
|
||||
|
|
@ -194,7 +203,7 @@ impl Tool for TransformTool {
|
|||
vec![Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(d.last), "copy": ev.mods.alt })), Action::Commit]
|
||||
} else {
|
||||
// A click sets the reference point.
|
||||
self.origin = Some((d.start, cx.selection.objects.clone()));
|
||||
self.origin = Some((d.pick, cx.selection.objects.clone()));
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
|
@ -205,7 +214,10 @@ impl Tool for TransformTool {
|
|||
None => vec![],
|
||||
}
|
||||
}
|
||||
PointerKind::Move => vec![],
|
||||
PointerKind::Move => {
|
||||
self.hover = if self.drag.is_none() && !cx.selection.is_empty() { snap_pick(cx, p).1 } else { vec![] };
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -230,6 +242,7 @@ impl Tool for TransformTool {
|
|||
if let Some(r) = reference {
|
||||
o.extend(target_overlays(r, cx.tol(8.0)));
|
||||
}
|
||||
o.extend(self.hover.iter().cloned());
|
||||
if let (Some(d), TransformKind::Reflect | TransformKind::Rotate) = (self.drag, self.kind)
|
||||
&& d.began
|
||||
&& let Some((p, _)) = &self.measure
|
||||
|
|
@ -360,6 +373,37 @@ mod tests {
|
|||
assert!(matches!(&t.key(&cx, ToolKey::Enter, Mods::default())[0], Action::Dialog(k, v) if k == "rotate" && v["angle"] == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_click_and_click_snap_the_reference_point_to_anchors_and_centres() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let mut s = Selection::default();
|
||||
s.add(id);
|
||||
let p = paint();
|
||||
let mut c = cx(&d, &s, &p);
|
||||
// Alt-click 3 px from the top-left corner: the reflect pivots exactly on the corner.
|
||||
let mut t = TransformTool::new(TransformKind::Reflect);
|
||||
let a = t.pointer(&c, &ev(PointerKind::Down, 102.0, 98.0).with_mods(alt()));
|
||||
assert_eq!(a, vec![Action::Dialog("reflect".into(), json!({"axis": "vertical", "origin": [100.0, 100.0]}))]);
|
||||
// A plain click near the bottom-right corner, then near the centre.
|
||||
let mut t = TransformTool::new(TransformKind::Rotate);
|
||||
t.pointer(&c, &ev(PointerKind::Down, 198.0, 203.0));
|
||||
t.pointer(&c, &ev(PointerKind::Up, 198.0, 203.0));
|
||||
assert_eq!(t.reference_point(&c), Some(Point::new(200.0, 200.0)));
|
||||
t.pointer(&c, &ev(PointerKind::Down, 151.0, 148.0));
|
||||
t.pointer(&c, &ev(PointerKind::Up, 151.0, 148.0));
|
||||
assert_eq!(t.reference_point(&c), Some(Point::new(150.0, 150.0)));
|
||||
// Hovering a corner labels it before the click.
|
||||
t.pointer(&c, &ev(PointerKind::Move, 199.0, 101.0));
|
||||
assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Label { text, p, .. } if text == "anchor" && *p == Point::new(200.0, 100.0))));
|
||||
t.pointer(&c, &ev(PointerKind::Move, 175.0, 120.0));
|
||||
assert!(!t.overlays(&c).iter().any(|o| matches!(o, Overlay::Label { .. })));
|
||||
// Snapping off: the point stays where it was clicked.
|
||||
c.snap_to_point = false;
|
||||
c.smart_guides = false;
|
||||
let a = t.pointer(&c, &ev(PointerKind::Down, 102.0, 98.0).with_mods(alt()));
|
||||
assert!(matches!(&a[0], Action::Dialog(_, v) if v["origin"] == json!([102.0, 98.0])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reflect_vertical_axis_mirrors_x() {
|
||||
let t = TransformTool::new(TransformKind::Reflect);
|
||||
|
|
|
|||
|
|
@ -63,9 +63,11 @@ impl Xf {
|
|||
#[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,
|
||||
|
|
@ -291,8 +293,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
if resp.hovered() {
|
||||
let m = ui.input(|i| i.modifiers);
|
||||
let space = ui.input(|i| i.key_down(egui::Key::Space));
|
||||
let cur = if space || app.session.tool_id() == "hand" {
|
||||
if ui.input(|i| i.pointer.primary_down()) { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab }
|
||||
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 {
|
||||
|
|
@ -314,8 +319,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
fn cursor_icon(c: Cursor) -> egui::CursorIcon {
|
||||
use egui::CursorIcon as C;
|
||||
match c {
|
||||
Cursor::Arrow => C::Default,
|
||||
Cursor::ArrowHollow => C::Default,
|
||||
Cursor::Arrow | Cursor::ArrowHollow | Cursor::CornerRadius => C::Default,
|
||||
Cursor::Move => C::Move,
|
||||
Cursor::Crosshair => C::Crosshair,
|
||||
Cursor::ResizeH => C::ResizeHorizontal,
|
||||
|
|
@ -369,12 +373,13 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
|
|||
|
||||
let tool = app.session.tool_id();
|
||||
let pan_mode = space || tool == "hand";
|
||||
if pointer.primary_pressed() && resp.hovered() {
|
||||
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 }
|
||||
Drag::Pan { start: p, center: v.center, middle: false }
|
||||
} else if tool == "zoom" {
|
||||
Drag::ZoomBox { start: p }
|
||||
} else if tool == "rotateView" {
|
||||
|
|
@ -393,11 +398,16 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
|
|||
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());
|
||||
if pointer.primary_down() {
|
||||
let held = if middle_pan { pointer.button_down(egui::PointerButton::Middle) } else { pointer.primary_down() };
|
||||
if held {
|
||||
match d {
|
||||
Drag::Pan { start, center } => {
|
||||
Drag::Pan { start, center, .. } => {
|
||||
let d = xf.delta_to_doc(p - start);
|
||||
if let Some(vm) = app.view_mut() {
|
||||
vm.center = center - d;
|
||||
|
|
@ -795,23 +805,19 @@ fn selection_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
|||
p.circle_filled(o, 2.5, color);
|
||||
}
|
||||
}
|
||||
// Bounding box with handles (Selection tool).
|
||||
// Live corner widgets (Direct Selection on a single live rectangle).
|
||||
if tool == "directSelection"
|
||||
// Live Corners widgets (Selection / Direct Selection on a single live rectangle).
|
||||
if matches!(tool, "selection" | "directSelection")
|
||||
&& app.ui.view.corner_widgets
|
||||
&& st.selection.len() == 1
|
||||
&& let Some(NodeKind::Path { live: Some(vectorcraft_doc::LiveShape::Rectangle { w, h, radii, xf: lxf }), .. }) =
|
||||
st.doc.node(st.selection.objects[0]).map(|n| &n.kind)
|
||||
&& let Some(w) = vectorcraft_tools::corners::CornerWidgets::of(&st.doc, &st.selection, xf.zoom)
|
||||
{
|
||||
let color = c32(st.doc.layer_color(st.selection.objects[0]));
|
||||
let inset = (radii[0].max(10.0 / xf.zoom)).min(w.min(*h) / 2.0);
|
||||
for (cx, cy) in [(inset, inset), (w - inset, inset), (w - inset, h - inset), (inset, h - inset)] {
|
||||
let sp = xf.to_screen(*lxf * Point::new(cx, cy));
|
||||
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 tool == "selection" && app.ui.view.bounding_box && !st.selection.is_empty() && st.selection.anchors.is_empty() {
|
||||
let Some(b) = st.doc.bounds_of(&st.selection.objects, false) else { return };
|
||||
let color = c32(st.doc.layer_color(st.selection.objects[0]));
|
||||
|
|
@ -1024,3 +1030,42 @@ fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -219,11 +219,9 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
x += w;
|
||||
}
|
||||
if let Some(i) = close {
|
||||
app.session.close_document(i);
|
||||
if i < app.views.len() {
|
||||
app.views.remove(i);
|
||||
if let Err(e) = crate::unsaved::close(app, i) {
|
||||
app.status(e);
|
||||
}
|
||||
app.sync_views();
|
||||
} else if let Some(i) = activate {
|
||||
app.session.set_active(i);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@
|
|||
//! - `ui.resize {width, height}`, `ui.focus`, `ui.screenshot {path?}`
|
||||
//! - `ui.render {path?, scale?}`: render the active artboard headlessly (PNG)
|
||||
//! - `app.open {path}` / `app.save {path?}` / `app.export {format, path, scale?}` / `app.quit`
|
||||
//! (`file.close`, `file.closeAll` and `app.quit` first open a `saveChanges` dialog for each
|
||||
//! modified document: `ui.dialog.confirm` saves, set `discard: true` then confirm to discard)
|
||||
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
|
|
|
|||
|
|
@ -93,6 +93,20 @@ fn rotate(p: &Painter, o: Pos2) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Live Corners: the hollow arrow with a rounded-corner badge.
|
||||
fn corner_radius(p: &Painter, o: Pos2) {
|
||||
arrow(p, o, true);
|
||||
let b = o + vec2(12.0, 13.0);
|
||||
let mut pts = vec![b + vec2(0.0, 10.0)];
|
||||
pts.extend((0..=8).map(|i| {
|
||||
let a = std::f32::consts::PI * (1.0 + 0.5 * i as f32 / 8.0);
|
||||
b + vec2(4.0 + 4.0 * a.cos(), 4.0 + 4.0 * a.sin())
|
||||
}));
|
||||
pts.push(b + vec2(10.0, 0.0));
|
||||
p.add(Shape::line(pts.clone(), Stroke::new(3.0, HALO)));
|
||||
p.add(Shape::line(pts, Stroke::new(1.2, INK)));
|
||||
}
|
||||
|
||||
fn ibeam(p: &Painter, o: Pos2) {
|
||||
line(p, o + vec2(0.0, -8.0), o + vec2(0.0, 8.0));
|
||||
line(p, o + vec2(-3.0, -8.0), o + vec2(3.0, -8.0));
|
||||
|
|
@ -116,6 +130,7 @@ pub fn paint(painter: &Painter, c: Cursor, p: Pos2) -> bool {
|
|||
Cursor::ResizeNwSe => double_arrow(painter, p, vec2(1.0, 1.0)),
|
||||
Cursor::ResizeNeSw => double_arrow(painter, p, vec2(1.0, -1.0)),
|
||||
Cursor::Rotate => rotate(painter, p),
|
||||
Cursor::CornerRadius => corner_radius(painter, p),
|
||||
Cursor::Pen => pen(painter, p, "*"),
|
||||
Cursor::PenAdd => pen(painter, p, "+"),
|
||||
Cursor::PenDelete => pen(painter, p, "-"),
|
||||
|
|
|
|||
|
|
@ -138,6 +138,7 @@ fn title(kind: &str) -> &'static str {
|
|||
"allTools" => "All Tools",
|
||||
"exportForScreens" => "Export for Screens",
|
||||
"recolor" => "Recolor Artwork",
|
||||
crate::unsaved::KIND => "Save Changes",
|
||||
_ => "Dialog",
|
||||
}
|
||||
}
|
||||
|
|
@ -150,6 +151,7 @@ pub fn confirm(app: &mut VectorcraftApp) -> Result<Value, String> {
|
|||
"shortcuts" => return crate::shortcut_editor::confirm(app),
|
||||
"newWorkspace" | "manageWorkspaces" => return crate::workspaces::confirm(app),
|
||||
"findFont" => return crate::find_font::confirm(app),
|
||||
crate::unsaved::KIND => return crate::unsaved::confirm(app),
|
||||
_ => {}
|
||||
}
|
||||
let copy = d.bool("copy");
|
||||
|
|
@ -308,6 +310,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
|||
let t = Tokens::get(ctx);
|
||||
let mut ok = false;
|
||||
let mut cancel = false;
|
||||
let mut discard = false;
|
||||
egui::Area::new(egui::Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::pos2(0.0, 0.0)).show(ctx, |ui| {
|
||||
// Illustrator's dialogs are modal but don't dim the canvas (previews stay readable).
|
||||
ui.allocate_rect(ctx.content_rect(), egui::Sense::click());
|
||||
|
|
@ -322,10 +325,21 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
|||
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(22)))
|
||||
.show(ctx, |ui| {
|
||||
ui.set_min_width(if d.kind == "newDocument" { 560.0 } else { 320.0 });
|
||||
let heading = if d.kind == "effect" || d.kind == "command" { d.str("__label") } else { title(&d.kind).to_string() };
|
||||
if d.kind == crate::unsaved::KIND {
|
||||
// Long document names wrap instead of widening the dialog.
|
||||
ui.set_max_width(420.0);
|
||||
}
|
||||
let heading = match d.kind.as_str() {
|
||||
"effect" | "command" => d.str("__label"),
|
||||
crate::unsaved::KIND => format!("Do you want to save the changes you made to “{}”?", d.str("name")),
|
||||
k => title(k).to_string(),
|
||||
};
|
||||
ui.label(egui::RichText::new(heading).font(theme::semibold(16.0)).color(t.text));
|
||||
ui.add_space(12.0);
|
||||
match d.kind.as_str() {
|
||||
crate::unsaved::KIND => {
|
||||
ui.label(egui::RichText::new("Your changes will be lost if you don't save them.").color(t.text_dim));
|
||||
}
|
||||
"exportForScreens" => export_for_screens_ui(app, ui, &mut d),
|
||||
"recolor" => {
|
||||
if recolor_ui(ui, &mut d) || !app.session.in_interaction() {
|
||||
|
|
@ -387,6 +401,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
|||
let label = match d.kind.as_str() {
|
||||
"newDocument" => "Create",
|
||||
"exportForScreens" => "Export Artboard",
|
||||
crate::unsaved::KIND => "Save",
|
||||
_ => "OK",
|
||||
};
|
||||
if d.kind != "allTools" && widgets::primary_button(ui, label).clicked() {
|
||||
|
|
@ -396,11 +411,19 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
|||
if widgets::secondary_button(ui, if d.kind == "allTools" { "Close" } else { "Cancel" }).clicked() {
|
||||
cancel = true;
|
||||
}
|
||||
if d.kind == crate::unsaved::KIND {
|
||||
ui.add_space(28.0);
|
||||
discard = widgets::secondary_button(ui, "Don't Save").clicked();
|
||||
}
|
||||
});
|
||||
});
|
||||
if ctx.input(|i| i.key_pressed(egui::Key::Enter)) && d.kind != "allTools" {
|
||||
ok = true;
|
||||
}
|
||||
if discard {
|
||||
d.fields.insert("discard".into(), json!(true));
|
||||
ok = true;
|
||||
}
|
||||
let is_effect = d.kind == "effect" || d.kind == "recolor";
|
||||
app.ui.dialog = Some(d);
|
||||
if cancel {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ pub mod shortcuts;
|
|||
pub mod state;
|
||||
pub mod theme;
|
||||
pub mod toolbar;
|
||||
pub mod unsaved;
|
||||
pub mod widgets;
|
||||
pub mod workspaces;
|
||||
|
||||
|
|
@ -212,6 +213,8 @@ impl VectorcraftApp {
|
|||
snap_to_grid: self.ui.view.snap_to_grid,
|
||||
snap_to_pixel: self.ui.view.snap_to_pixel,
|
||||
show_bbox: self.ui.view.bounding_box,
|
||||
snap_to_point: self.ui.view.snap_to_point,
|
||||
corner_widgets: self.ui.view.corner_widgets,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -418,6 +421,13 @@ impl VectorcraftApp {
|
|||
}
|
||||
self.last_time = now;
|
||||
self.sync_views();
|
||||
// The window's close button (or the system quitting the app) asks about unsaved documents.
|
||||
if ctx.input(|i| i.viewport().close_requested()) && unsaved::any_dirty(self) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
if let Err(e) = unsaved::close_all(self, "quit") {
|
||||
self.status(e);
|
||||
}
|
||||
}
|
||||
shortcut_editor::sync(&self.ui);
|
||||
prefs_dialog::apply_runtime(self, ctx);
|
||||
self.drain_control(ctx);
|
||||
|
|
|
|||
|
|
@ -467,10 +467,13 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
|||
app.ui.palette_query.clear();
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"app.quit" => {
|
||||
app.ui.status = "quit".into();
|
||||
Ok(Value::Null)
|
||||
}
|
||||
// Closing asks Save / Don't Save / Cancel for modified documents first (`unsaved`).
|
||||
"file.close" => match p.get("index").and_then(Value::as_u64).map(|i| i as usize).or(app.session.active_index()) {
|
||||
Some(i) => crate::unsaved::close(app, i),
|
||||
None => return None,
|
||||
},
|
||||
"file.closeAll" => crate::unsaved::close_all(app, "closeAll"),
|
||||
"app.quit" => crate::unsaved::close_all(app, "quit"),
|
||||
_ => return None,
|
||||
};
|
||||
Some(r)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,192 @@
|
|||
//! Closing documents with unsaved changes. File → Close / Close All, a tab's ×, Quit and the
|
||||
//! window's close button first ask Save / Don't Save / Cancel for each modified document (one
|
||||
//! dialog per document); Cancel stops the whole operation and a cancelled Save As keeps the
|
||||
//! document open.
|
||||
//!
|
||||
//! The question is an ordinary dialog (`UiState::dialog`, kind [`KIND`]), so agents answer it
|
||||
//! like any other: `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then
|
||||
//! confirm discards, `ui.dialog.cancel` cancels.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::state::Dialog;
|
||||
use crate::{VectorcraftApp, io};
|
||||
|
||||
/// Dialog kind. Fields: `index` (document), `name` (its title), `then` (`close`, `closeAll` or
|
||||
/// `quit`: what continues after this document) and `discard` (Don't Save).
|
||||
pub const KIND: &str = "saveChanges";
|
||||
|
||||
/// File → Close (or a tab's ×) for document `i`.
|
||||
pub fn close(app: &mut VectorcraftApp, i: usize) -> Result<Value, String> {
|
||||
let dirty = app.session.documents().get(i).ok_or("no such document")?.is_dirty();
|
||||
if dirty { ask(app, i, "close") } else { close_now(app, i) }
|
||||
}
|
||||
|
||||
/// File → Close All (`then` = `closeAll`) or Quit (`quit`): ask about the next modified document,
|
||||
/// or finish once none is left.
|
||||
pub fn close_all(app: &mut VectorcraftApp, then: &str) -> Result<Value, String> {
|
||||
if let Some(i) = app.session.documents().iter().position(|d| d.is_dirty()) {
|
||||
return ask(app, i, then);
|
||||
}
|
||||
if then == "quit" {
|
||||
// The host closes the window.
|
||||
app.ui.status = "quit".into();
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
if app.session.documents().is_empty() {
|
||||
return Ok(json!({ "closed": 0 }));
|
||||
}
|
||||
let r = app.session.execute("file.closeAll", &json!({})).map_err(|e| e.to_string());
|
||||
app.sync_views();
|
||||
r
|
||||
}
|
||||
|
||||
/// Is any open document modified (the window's close button must ask first)?
|
||||
pub fn any_dirty(app: &VectorcraftApp) -> bool {
|
||||
app.session.documents().iter().any(|d| d.is_dirty())
|
||||
}
|
||||
|
||||
/// Show document `i` and ask whether to save it.
|
||||
fn ask(app: &mut VectorcraftApp, i: usize, then: &str) -> Result<Value, String> {
|
||||
app.session.set_active(i);
|
||||
let name = app.session.documents()[i].title();
|
||||
app.ui.dialog = Some(Dialog::new(KIND, json!({ "index": i, "name": name, "then": then })));
|
||||
Ok(json!({ "pending": KIND }))
|
||||
}
|
||||
|
||||
/// Close document `i` through the engine command, keeping `views` aligned with the documents.
|
||||
fn close_now(app: &mut VectorcraftApp, i: usize) -> Result<Value, String> {
|
||||
let r = app.session.execute("file.close", &json!({ "index": i })).map_err(|e| e.to_string())?;
|
||||
if i < app.views.len() {
|
||||
app.views.remove(i);
|
||||
}
|
||||
app.sync_views();
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Answer the open dialog: save (or, with `discard`, don't), close the document and carry on.
|
||||
pub fn confirm(app: &mut VectorcraftApp) -> Result<Value, String> {
|
||||
let d = app.ui.dialog.take().ok_or("no dialog open")?;
|
||||
let i = d.fields.get("index").and_then(Value::as_u64).map(|i| i as usize).filter(|i| *i < app.session.documents().len());
|
||||
let i = i.ok_or("no such document")?;
|
||||
if !d.bool("discard") {
|
||||
app.session.set_active(i);
|
||||
io::save(app, None, false)?;
|
||||
}
|
||||
close_now(app, i)?;
|
||||
match d.str("then").as_str() {
|
||||
"close" => Ok(Value::Null),
|
||||
then => close_all(app, then),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
/// An app whose Save As picks `out.vectorcraft` and records written paths.
|
||||
fn app() -> (VectorcraftApp, Arc<Mutex<Vec<String>>>) {
|
||||
let written = Arc::new(Mutex::new(vec![]));
|
||||
let w = written.clone();
|
||||
let services = crate::Services {
|
||||
pick_save: Some(Box::new(|_: &str| Some("out.vectorcraft".to_string()))),
|
||||
write: Some(Box::new(move |p: &str, _: &[u8]| {
|
||||
w.lock().unwrap().push(p.to_string());
|
||||
Ok(())
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
(VectorcraftApp::new(Session::new(), services), written)
|
||||
}
|
||||
|
||||
fn new_doc(app: &mut VectorcraftApp, dirty: bool) {
|
||||
app.run("file.new", json!({ "width": 100, "height": 100 })).unwrap();
|
||||
if dirty {
|
||||
app.run("shape.rectangle", json!({ "x": 0, "y": 0, "width": 10, "height": 10 })).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn docs(app: &VectorcraftApp) -> usize {
|
||||
app.session.documents().len()
|
||||
}
|
||||
|
||||
fn pending(app: &VectorcraftApp) -> Option<usize> {
|
||||
app.ui.dialog.as_ref().filter(|d| d.kind == KIND).and_then(|d| d.fields["index"].as_u64()).map(|i| i as usize)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_documents_close_at_once_keeping_views_aligned() {
|
||||
let (mut app, _) = app();
|
||||
for _ in 0..3 {
|
||||
new_doc(&mut app, false);
|
||||
}
|
||||
assert!(!any_dirty(&app));
|
||||
app.views[0].zoom = 2.0;
|
||||
app.views[2].zoom = 3.0;
|
||||
close(&mut app, 1).unwrap();
|
||||
assert_eq!(docs(&app), 2);
|
||||
assert_eq!(app.views.iter().map(|v| v.zoom).collect::<Vec<_>>(), vec![2.0, 3.0]);
|
||||
assert_eq!(app.run("file.close", json!({})).unwrap(), Value::Null);
|
||||
assert_eq!(docs(&app), 1);
|
||||
assert!(app.ui.dialog.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_a_modified_document_asks_and_cancel_keeps_it() {
|
||||
let (mut app, written) = app();
|
||||
new_doc(&mut app, true);
|
||||
new_doc(&mut app, false);
|
||||
assert!(any_dirty(&app));
|
||||
assert_eq!(app.run("file.close", json!({ "index": 0 })).unwrap(), json!({ "pending": KIND }));
|
||||
assert_eq!((docs(&app), pending(&app), app.session.active_index()), (2, Some(0), Some(0)));
|
||||
// Cancel (Esc / Cancel button / ui.dialog.cancel) closes nothing.
|
||||
app.ui.dialog = None;
|
||||
assert_eq!(docs(&app), 2);
|
||||
// Save writes the file (Save As for an untitled document), then closes it.
|
||||
app.run("file.close", json!({ "index": 0 })).unwrap();
|
||||
confirm(&mut app).unwrap();
|
||||
assert_eq!(*written.lock().unwrap(), vec!["out.vectorcraft".to_string()]);
|
||||
assert_eq!((docs(&app), app.views.len()), (1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cancelled_save_as_keeps_the_document_open() {
|
||||
let (mut app, _) = app();
|
||||
app.services.pick_save = Some(Box::new(|_: &str| None));
|
||||
new_doc(&mut app, true);
|
||||
close(&mut app, 0).unwrap();
|
||||
assert!(confirm(&mut app).is_err());
|
||||
assert_eq!(docs(&app), 1);
|
||||
assert!(app.ui.dialog.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_all_and_quit_ask_once_per_modified_document() {
|
||||
let (mut app, written) = app();
|
||||
new_doc(&mut app, false);
|
||||
new_doc(&mut app, true);
|
||||
new_doc(&mut app, true);
|
||||
// Close All: asks about each modified document in turn, then closes the rest.
|
||||
app.run("file.closeAll", json!({})).unwrap();
|
||||
assert_eq!(pending(&app), Some(1));
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true));
|
||||
confirm(&mut app).unwrap();
|
||||
assert_eq!((docs(&app), pending(&app)), (2, Some(1)));
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true));
|
||||
confirm(&mut app).unwrap();
|
||||
assert_eq!((docs(&app), app.views.len(), app.ui.dialog.is_none()), (0, 0, true));
|
||||
assert!(written.lock().unwrap().is_empty());
|
||||
// Quit: nothing modified quits at once; otherwise only after every answer.
|
||||
new_doc(&mut app, true);
|
||||
app.run("app.quit", json!({})).unwrap();
|
||||
assert_ne!(app.ui.status, "quit");
|
||||
assert_eq!(pending(&app), Some(0));
|
||||
confirm(&mut app).unwrap();
|
||||
assert_eq!(app.ui.status, "quit");
|
||||
app.ui.status.clear();
|
||||
app.run("app.quit", json!({})).unwrap();
|
||||
assert_eq!(app.ui.status, "quit");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue