Extract UI event and rendering helpers

This commit is contained in:
Dylan M. Taylor
2026-10-28 16:44:17 +00:00
parent 0b821b3c54
commit 55dddf6adf
81 changed files with 2084 additions and 1455 deletions
+6 -1
View File
@@ -8,7 +8,12 @@ use super::state::{
CaptureEpoch, GeometryStamp, InputGeneration, MAX_PENDING_SCREENSHOTS, MAX_SCREENSHOT_BYTES, MAX_SCREENSHOT_TOTAL_BYTES, PendingScreenshot,
QueuedScreenshot, SCREENSHOT_SETTLE_FRAMES, SCREENSHOT_TIMEOUT_ERROR, SCREENSHOT_TIMEOUT_MS, SYSTEM_CLIPBOARD_POLL, take_frame_once,
};
use super::*;
use super::{
Arc, AutomationRoots, ControlRequest, Ordering, Receiver, Session, UiState, Value, VectorcraftApp, View, ViewInfo, background, canvas, chrome,
clipboard_probe, dialogs, export_progress, file_access, fileio, font_menu, graphics, i18n, io, json, menus, native_menu, opening, palette,
panel_docking, panels, picks, place, placing, plugin_runs, prefs_dialog, recovery, screen_color, scrub, shortcut_editor, shortcuts, theme,
titlebar, toolbar, tools_float, unsaved, updates_ui, widgets,
};
mod clipboard;
mod commands;
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::VectorcraftApp;
impl VectorcraftApp {
/// Hand a queued text copy to egui. Called after `tick`, after the UI phase's input and at the
+1 -1
View File
@@ -1,5 +1,5 @@
use super::fonts::MAX_RECENT_FONTS;
use super::*;
use super::{Value, VectorcraftApp, dialogs, fileio, io, menus, panels, picks, toolbar};
impl VectorcraftApp {
/// Run a UI or engine command by id. The single entry point for every frontend path.
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
MAX_PENDING_SCREENSHOTS, MAX_SCREENSHOT_BYTES, MAX_SCREENSHOT_TOTAL_BYTES, PendingScreenshot, QueuedScreenshot, SCREENSHOT_TIMEOUT_MS,
VectorcraftApp, file_access, json, now_ms,
};
use crate::control;
impl VectorcraftApp {
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{VectorcraftApp, io, place};
impl VectorcraftApp {
/// A file dialog is open off the UI thread (a sheet on macOS, [`picks`]): what asked for it runs
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{VectorcraftApp, json};
/// The most fonts Type › Recent Fonts lists (Preferences › Type › Number of Recent Fonts).
pub const MAX_RECENT_FONTS: usize = 15;
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{GeometryStamp, InputGeneration, VectorcraftApp, canvas};
impl VectorcraftApp {
/// The host iteration count: one increment per `App::logic`/`App::ui` iteration (or a
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::VectorcraftApp;
use crate::ime;
impl VectorcraftApp {
+3 -1
View File
@@ -1,4 +1,6 @@
use super::*;
use super::{Arc, Ordering, VectorcraftApp, graphics, native_menu, shortcuts, take_frame_once};
#[cfg(not(target_arch = "wasm32"))]
use super::{fileio, place};
impl VectorcraftApp {
/// The first UI pass of a host iteration takes the host's input, once per context, viewport and
+3 -1
View File
@@ -1,4 +1,6 @@
use super::*;
#[cfg(not(target_arch = "wasm32"))]
use super::dialogs;
use super::{VectorcraftApp, browsable_url};
impl VectorcraftApp {
/// Open a link in the browser (Help → Discord, website, GitHub…).
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::VectorcraftApp;
impl VectorcraftApp {
/// Was Preferences confirmed since the last call? The host then writes the preferences file,
+4 -1
View File
@@ -1,4 +1,7 @@
use super::*;
use super::{
Arc, CaptureEpoch, MAX_SCREENSHOT_BYTES, MAX_SCREENSHOT_TOTAL_BYTES, QueuedScreenshot, SCREENSHOT_SETTLE_FRAMES, SCREENSHOT_TIMEOUT_ERROR,
VectorcraftApp, file_access, json, now_ms,
};
use crate::control;
impl VectorcraftApp {
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Value, VectorcraftApp, View, ViewInfo, panels};
impl VectorcraftApp {
/// Keep `views` aligned with the session's documents (a new one starts at its saved view).
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{canvas, cursors, render_worker};
/// Cached canvas raster.
pub struct CanvasCache {
+412 -315
View File
@@ -206,21 +206,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
scroll_bars(app, ui, rect, outer, &t);
}
let resp = ui.interact(rect, egui::Id::unique("canvas"), Sense::click_and_drag());
// A press on the canvas while the context menu is open only closes it (a drag too, which egui
// alone would leave open); the tool doesn't get it.
if app.ui.dialog.as_ref().is_some_and(crate::dialogs::revolve_gizmo::active) {
// The artboard's 3D gizmo owns these gestures while its options are open.
// Do not let a ring drag also move or deselect the editable source path.
} else if resp.context_menu_opened() && resp.hovered() && ui.input(|i| i.pointer.any_pressed()) {
egui::Popup::close_all(ui.ctx());
} else {
handle_input(app, ui, &resp, rect);
// `hover_doc` is the document point under the pointer in the view `handle_input` left. The
// wheel, a pan, a Rotate View drag, the Zoom tool and a Presentation Mode click move the view
// there.
let moved = Xf::new(rect, app.view().unwrap_or(&View::default()));
app.hover_doc = ui.input(|i| i.pointer.hover_pos()).filter(|p| rect.contains(*p)).map(|p| moved.to_doc(p));
}
canvas_input(app, ui, &resp, rect);
let v = *app.view().unwrap_or(&View::default());
let xf = Xf::new(rect, &v);
// While the view pans, zooms or turns under the pointer, the art under it isn't hit-tested.
@@ -241,35 +227,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
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).
// An artboard with a background color draws its paper in that color. With Simulate Colored Paper
// on, the canvas multiplies it with the paper color: each sRGB channel is artboard × paper / 255,
// rounded. The transparency grid, while it shows, covers it as it covers the others.
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 => {
let fill = ab.color.map_or(paper, |c| crate::panels::c32(&c) * paper);
painter.add(Shape::convex_polygon(q, fill, 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));
}
}
paint_paper(app, ui, &painter, &xf, rect, &t);
// The grid behind the art (Guides & Grid › Grids In Back), else over it below.
let grid_look = app.ui.view.grid.then(|| LineLook::grid(&app.session.prefs));
let grids_in_back = app.session.prefs.grids_in_back;
@@ -290,9 +248,205 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
// File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview: off, images show
// their pixels as they rasterize, hard-edged.
let smooth_images = pixel.is_none() || app.session.prefs.anti_aliased_bitmaps;
let key = cache_key(app, ui, st, &t, &v, (w, h, ppp), (pixel, smooth_images));
// Placed documents' bitmaps are being made: draw again when they are ready.
if vectorcraft_render::placed_document::busy() {
ui.ctx().request_repaint_after(std::time::Duration::from_millis(30));
}
poll_worker(app, ui, key.doc);
// With `waits_for_still` set (the web build, which has no render worker), a heavy document is
// not rendered while only the view changes: the frame shown is drawn reprojected below, and the
// first frame after the view has been still for STILL_SECS renders.
let wait = if app.canvas.waits_for_still {
let slow = app.canvas.last_ms > 8.0 && app.canvas.texture.is_some();
still_wait(&mut app.canvas.still, app.canvas.key.as_ref(), &key, slow, ui.input(|i| i.time))
} else {
None
};
if let Some(secs) = wait {
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(secs));
}
if wait.is_none() && (app.canvas.key.as_ref() != Some(&key) || app.canvas.texture.is_none()) {
let (w, h, view) = frame_view(pixel, (w, h), &v, ppp);
let opts = frame_options(app, &key, mask_view, smooth_images);
draw_frame(app, ui, Frame { key: &key, doc: &doc, size: (w, h), view, opts, pixel });
}
// The status bar reports each image that a renderer (this thread's or the worker's) could not
// decode, once per image in the process.
let undecoded = vectorcraft_render::newly_undecodable(doc.images.values());
if undecoded > 0 {
app.status(format!("{undecoded} image(s) couldn't be decoded and show as crossed boxes"));
}
reproject_frame(app, &painter, &xf, rect, (key.doc, v.rotation));
if let Some(look) = grid_look.filter(|_| !grids_in_back) {
grid(&painter, &xf, doc.grid.spacing, doc.grid.subdivisions, look);
}
// The pixel grid (Guides & Grid › Show Pixel Grid (Above 600% Zoom)): in Pixel Preview from
// 600% zoom, a line at every document pixel over the art, so the pixels it rasterizes to show.
if app.ui.view.pixel_preview && v.zoom >= PIXEL_GRID_ZOOM && app.session.prefs.show_pixel_grid {
grid(&painter, &xf, 1.0, 1, LineLook { color: PIXEL_GRID, dots: false });
}
artboard_outlines(app, ui, &painter, &xf, &t);
if app.ui.view.guides {
guide_lines(app, &painter, &xf, rect, &t);
}
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);
}
original_path_overlay(app, &painter, &xf);
let revolve_edit = app.ui.dialog.as_ref().is_some_and(crate::dialogs::revolve_gizmo::active);
let view_info = app.view_info();
let overlays = selection_visuals(app, &painter, &xf, (hidden_chars, hover_still), revolve_edit, view_info);
draw_overlays(&painter, &xf, &overlays, &t, HandleLook::of(&app.session.prefs));
if !revolve_edit {
shaper_widget_overlay(app, &painter, &xf);
}
ime_geometry(app, ui.ctx(), &xf);
crate::place::paint_drop_highlight(app, ui.ctx(), &painter, rect);
// A Rotate View drag (the tool, or Space+Shift with any tool) shows the view's angle on a dial
// at the canvas center, the point the view turns about.
if app.ui.screen_mode < 3 && matches!(ui.data(|d| d.get_temp::<Drag>(drag_id())), Some(Drag::RotateView { .. })) {
rotate_view_dial(&painter, rect.center(), v.rotation, &t);
}
if app.ui.view.rulers && app.ui.screen_mode < 3 {
ruler_bands(app, ui, &xf, (full, rect), origin_preview, &t);
}
// The task bar is not drawn while the Simplify slider shows.
if app.ui.task_bar && !revolve_edit && !app.session.tool_busy() && app.ui.screen_mode < 3 && !crate::dialogs::simplify_slider::showing(app) {
task_bar(app, ui, &xf);
}
if app.ui.screen_mode < 3 {
crate::free_transform::show(app, ui, xf.rect);
}
artboard_name_editor(app, ui, &xf);
if resp.hovered() {
canvas_cursor(app, ui, view_info, hover_still);
}
}
/// The pointer on the canvas: the tool's input, unless the context menu or the 3D gizmo has the press.
fn canvas_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: egui::Rect) {
// A press on the canvas while the context menu is open only closes it (a drag too, which egui
// alone would leave open); the tool doesn't get it.
if app.ui.dialog.as_ref().is_some_and(crate::dialogs::revolve_gizmo::active) {
// The artboard's 3D gizmo owns these gestures while its options are open.
// Do not let a ring drag also move or deselect the editable source path.
} else if resp.context_menu_opened() && resp.hovered() && ui.input(|i| i.pointer.any_pressed()) {
egui::Popup::close_all(ui.ctx());
} else {
handle_input(app, ui, resp, rect);
// `hover_doc` is the document point under the pointer in the view `handle_input` left. The
// wheel, a pan, a Rotate View drag, the Zoom tool and a Presentation Mode click move the view
// there.
let moved = Xf::new(rect, app.view().unwrap_or(&View::default()));
app.hover_doc = ui.input(|i| i.pointer.hover_pos()).filter(|p| rect.contains(*p)).map(|p| moved.to_doc(p));
}
}
/// The pointer over the canvas: the view tools' cursors, else the tool's own.
fn canvas_cursor(app: &mut VectorcraftApp, ui: &Ui, view_info: vectorcraft_engine::ViewInfo, hover_still: bool) {
if opening::long_operation_busy(app) {
ui.ctx().set_cursor_icon(cursor_icon(Cursor::Busy));
return;
}
let m = ui.input(|i| i.modifiers);
let space = ui.input(|i| i.key_down(egui::Key::Space));
let drag = ui.data(|d| d.get_temp::<Drag>(drag_id()));
let panning = matches!(drag, Some(Drag::Pan { .. }));
let zooming = if space { m.command } else { app.session.tool_id() == "zoom" };
// A rotating drag keeps its cursor when Shift is let go.
let rotating = matches!(drag, Some(Drag::RotateView { .. })) || if space { m.shift && !m.command } else { app.session.tool_id() == "rotateView" };
// The hand and the magnifier are drawn like the tool cursors below: Windows has no
// system cursor for them (winit shows the four-headed move cursor for Grab).
let view_cursor = if panning {
Some(Cursor::HandGrab)
} else if rotating {
Some(Cursor::Rotate)
} else if zooming {
Some(if m.alt { Cursor::ZoomOut } else { Cursor::ZoomIn })
} else if space || app.session.tool_id() == "hand" {
Some(Cursor::Hand)
} else {
None
};
// In Presentation Mode the pointer is the default one, with no tool cursor.
let tool_cursor = (app.ui.screen_mode < 3).then(|| view_cursor.or_else(|| hover_cursor(app, mods(m, space), view_info, hover_still)));
let cur = if let Some(c) = tool_cursor.flatten() {
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("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);
}
// The glyph cursors are drawn here; General › Use Precise Cursors (the session turns the drawing tools' pointers
// into crosshairs, `Cursor::precise`) needs no handling. VECTORCRAFT_SYSTEM_CURSORS opts out of the glyphs.
let custom = std::env::var_os("VECTORCRAFT_SYSTEM_CURSORS").is_none();
// User Interface › Scale Cursor Proportional to UI: the glyphs grow with UI Scaling;
// off, they keep their size.
let ui_scale = if app.session.prefs.scale_cursor_with_ui { 1.0 } else { ui.ctx().zoom_factor() };
match ui.input(|i| i.pointer.hover_pos()) {
// An OS cursor: the system moves it at once, where a painted one trails the pointer
// (#444). The system cursor stands in for cursors without a glyph. Over a window
// without the focus, macOS shows its own pointer whichever cursor the window sets,
// and the glyph is not painted there.
Some(_) if custom && crate::cursors::os_cursors(crate::cursors::OS_CURSORS, app.services.wayland) => {
let ppp = ui.ctx().pixels_per_point() / ui_scale;
ui.ctx().set_cursor_image(app.canvas.cursors.get(c, ppp));
cursor_icon(c)
}
Some(hp)
if custom
&& crate::cursors::paint_glyphs(crate::cursors::HIDES_ONLY_WHEN_KEY, ui.input(|i| i.focused))
&& crate::cursors::paint(&painter, c, hp, 1.0 / ui_scale) =>
{
// No cursor theme (Wayland): the glyph is painted, the system pointer stays (#2450).
if crate::cursors::hide_system_pointer(app.services.no_cursor_theme) { egui::CursorIcon::None } else { cursor_icon(c) }
}
_ => cursor_icon(c),
}
} else {
egui::CursorIcon::Default
};
ui.ctx().set_cursor_icon(cur);
}
/// The rulers, and the origin being dragged out of their corner.
fn ruler_bands(app: &VectorcraftApp, ui: &Ui, xf: &Xf, (full, rect): (egui::Rect, egui::Rect), origin_preview: Option<Point>, t: &Tokens) {
let painter = ui.painter_at(rect);
if let Some(origin) = origin_preview {
let at = xf.to_screen(origin);
painter.line_segment([pos2(at.x, rect.top()), pos2(at.x, rect.bottom())], Stroke::new(1.0, t.text_dim));
painter.line_segment([pos2(rect.left(), at.y), pos2(rect.right(), at.y)], Stroke::new(1.0, t.text_dim));
}
// View › Rulers › Show Video Rulers counts the active artboard in video pixels.
let video = app.ui.view.video_rulers.then(|| crate::dialogs::artboard_options::active_index(app));
let scale = match video.and_then(|i| app.session.active()?.doc.artboards.get(i)) {
Some(ab) => RulerScale::video(ab),
None => RulerScale::document(app.session.general_unit(), origin_preview.unwrap_or_else(|| app.ruler_origin())),
};
rulers(ui, full, xf, app.hover_doc, scale, t);
}
/// What the cached frame depends on.
fn cache_key(
app: &VectorcraftApp,
ui: &Ui,
st: &vectorcraft_engine::DocState,
t: &Tokens,
v: &View,
(w, h, ppp): (u32, u32, f32),
(pixel, smooth_images): (Option<[i64; 4]>, bool),
) -> CacheKey {
// Paths made into guides are drawn as the ruler guides are (Preferences › Guides & Grid).
let guide_look = LineLook::guides(&app.session.prefs, &t);
let key = CacheKey {
let guide_look = LineLook::guides(&app.session.prefs, t);
CacheKey {
guide: ([guide_look.color.r(), guide_look.color.g(), guide_look.color.b()], guide_look.dots),
doc: st.uid as usize,
revision: st.revision,
@@ -322,11 +476,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
dragging: app.session.in_interaction() && ui.data(|d| d.get_temp::<Drag>(drag_id())) == Some(Drag::Tool),
// The Image Trace panel's eye button, while it is held.
trace_source: app.ui.trace_hold_source.clone(),
};
// Placed documents' bitmaps are being made: draw again when they are ready.
if vectorcraft_render::placed_document::busy() {
ui.ctx().request_repaint_after(std::time::Duration::from_millis(30));
}
}
/// Start the render worker, and upload the frame it finished.
fn poll_worker(app: &mut VectorcraftApp, ui: &Ui, key_doc: usize) {
if !app.canvas.worker_started {
app.canvas.worker_started = true;
if std::env::var_os("VECTORCRAFT_SYNC_RENDER").is_none() {
@@ -335,7 +489,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
// Upload finished background renders.
if let Some(done) = app.canvas.worker.as_mut().and_then(|w| w.poll())
&& done.key.doc == key.doc
&& done.key.doc == key_doc
{
upload(app, ui.ctx(), &done.img, done.key.pixel.is_some());
app.canvas.key = Some(done.key);
@@ -345,121 +499,164 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
app.canvas.moved = done.moved > 0;
app.canvas.from_worker = true;
}
// With `waits_for_still` set (the web build, which has no render worker), a heavy document is
// not rendered while only the view changes: the frame shown is drawn reprojected below, and the
// first frame after the view has been still for STILL_SECS renders.
let wait = if app.canvas.waits_for_still {
let slow = app.canvas.last_ms > 8.0 && app.canvas.texture.is_some();
still_wait(&mut app.canvas.still, app.canvas.key.as_ref(), &key, slow, ui.input(|i| i.time))
} else {
None
};
if let Some(secs) = wait {
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(secs));
}
/// The size and view matrix of the frame to render: the pixel region in Pixel Preview, else the whole canvas.
fn frame_view(pixel: Option<[i64; 4]>, (w, h): (u32, u32), v: &View, ppp: f32) -> (u32, u32, Affine) {
match pixel {
Some([x0, y0, x1, y1]) => ((x1 - x0) as u32, (y1 - y0) as u32, Affine::translate((-x0 as f64, -y0 as f64))),
None => (
w,
h,
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()),
),
}
if wait.is_none() && (app.canvas.key.as_ref() != Some(&key) || app.canvas.texture.is_none()) {
let (w, h, view) = match pixel {
Some([x0, y0, x1, y1]) => ((x1 - x0) as u32, (y1 - y0) as u32, Affine::translate((-x0 as f64, -y0 as f64))),
None => (
w,
h,
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,
hide_templates: !app.ui.view.templates,
hide_guides: !app.ui.view.guides,
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]
}),
guide: key.guide,
mask_view,
highlight_substitutions: true,
// Type › Highlight Alternate Glyphs (#394): pink behind OpenType substitutions.
highlight_alternate_glyphs: app.session.prefs.highlight_alternate_glyphs,
// Type › Highlight Substituted Fonts: pink behind text in a substitute font.
highlight_substituted_fonts: app.session.prefs.highlight_substituted_fonts,
// General › Anti-aliased Artwork: off, edges are hard on screen (raster effects and
// pattern tiles stay smooth), as in Illustrator.
anti_alias: if app.session.prefs.anti_aliased_artwork { vectorcraft_render::AntiAlias::Art } else { vectorcraft_render::AntiAlias::None },
progressive_placed: true,
trace_views: true,
// Image Trace › the eye button held: the source image of the selected objects.
trace_source: key.trace_source.clone(),
smooth_images,
// Isolation mode: the art around the isolated group or layer is dimmed (#833).
isolated: key.isolated.map(vectorcraft_doc::NodeId),
// Images far smaller on screen than they are sample their mip levels (#1244).
image_mips: true,
// An image whose pixels can't be decoded shows as a crossed box, and the status message
// below reports it.
mark_undecoded_images: true,
// During a drag or a dialog's preview, objects that only moved draw their raster
// effects from the cached rasters of an earlier frame (#1257).
interacting: key.interacting,
// During a tool's drag, raster effects without a cached raster to draw render at a
// lower resolution (#2558); the frame after the drag renders them as a still frame does.
dragging: key.dragging,
rich_black: key.rich_black,
..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.
// The first frame after an interaction, when the frame shown drew moved rasters, also goes
// to the worker. An interaction's frames stay on the worker once it drew the frame shown:
// its renderer keeps the rasters of the art's effects that make those frames fast, and this
// thread's renderer has none, so a frame fast on the worker would be slow here.
let same_doc = app.canvas.key.as_ref().is_some_and(|k| k.doc == key.doc);
let settling = app.canvas.moved && !key.interacting;
let staying = key.interacting && app.canvas.from_worker;
let heavy = (app.canvas.last_ms > 8.0 || settling || staying) && app.canvas.texture.is_some() && same_doc;
let changed = changed_pixels(app, &key, &doc, &opts, view, w, h);
match (&mut app.canvas.worker, heavy, changed) {
// Only the document changed since the frame shown, in a part of it: that part is
// rendered again, here, into the texture. `last_ms` stays the whole frame's.
(_, _, Some(region)) => {
let t0 = now_ms();
if let (Some([x0, y0, x1, y1]), Some(tex)) = (region, &mut app.canvas.texture) {
let part = app.canvas.renderer.render(&doc, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view, &opts);
let color = color_image(&part);
let options = if pixel.is_some() { egui::TextureOptions::NEAREST } else { egui::TextureOptions::LINEAR };
tex.set_partial([x0 as usize, y0 as usize], color, options);
app.canvas.moved |= app.canvas.renderer.stats.moved > 0;
}
app.canvas.key = Some(key.clone());
app.canvas.shown = Some((doc.clone(), opts.clone()));
app.perf.render_ms = now_ms() - t0;
}
(Some(worker), true, None) => 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, pixel.is_some());
app.canvas.key = Some(key.clone());
app.canvas.shown = Some((doc.clone(), opts.clone()));
app.perf.render_ms = now_ms() - t0;
app.canvas.last_ms = app.perf.render_ms;
app.canvas.moved = app.canvas.renderer.stats.moved > 0;
app.canvas.from_worker = false;
}
/// The options the canvas renders the document with.
fn frame_options(
app: &VectorcraftApp,
key: &CacheKey,
mask_view: Option<vectorcraft_doc::NodeId>,
smooth_images: bool,
) -> vectorcraft_render::RenderOptions {
let opts = vectorcraft_render::RenderOptions { outline: app.ui.view.outline, background: None, artboards: false, ..Default::default() };
vectorcraft_render::RenderOptions {
proof: vectorcraft_render::proof::active_proof(),
overprint_preview: vectorcraft_render::proof::overprint_preview_on(),
trim: app.ui.view.trim_view,
hide_templates: !app.ui.view.templates,
hide_guides: !app.ui.view.guides,
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]
}),
guide: key.guide,
mask_view,
highlight_substitutions: true,
// Type › Highlight Alternate Glyphs (#394): pink behind OpenType substitutions.
highlight_alternate_glyphs: app.session.prefs.highlight_alternate_glyphs,
// Type › Highlight Substituted Fonts: pink behind text in a substitute font.
highlight_substituted_fonts: app.session.prefs.highlight_substituted_fonts,
// General › Anti-aliased Artwork: off, edges are hard on screen (raster effects and
// pattern tiles stay smooth), as in Illustrator.
anti_alias: if app.session.prefs.anti_aliased_artwork { vectorcraft_render::AntiAlias::Art } else { vectorcraft_render::AntiAlias::None },
progressive_placed: true,
trace_views: true,
// Image Trace › the eye button held: the source image of the selected objects.
trace_source: key.trace_source.clone(),
smooth_images,
// Isolation mode: the art around the isolated group or layer is dimmed (#833).
isolated: key.isolated.map(vectorcraft_doc::NodeId),
// Images far smaller on screen than they are sample their mip levels (#1244).
image_mips: true,
// An image whose pixels can't be decoded shows as a crossed box, and the status message
// below reports it.
mark_undecoded_images: true,
// During a drag or a dialog's preview, objects that only moved draw their raster
// effects from the cached rasters of an earlier frame (#1257).
interacting: key.interacting,
// During a tool's drag, raster effects without a cached raster to draw render at a
// lower resolution (#2558); the frame after the drag renders them as a still frame does.
dragging: key.dragging,
rich_black: key.rich_black,
..opts
}
}
/// A frame to render: here, or on the worker.
struct Frame<'a> {
key: &'a CacheKey,
doc: &'a std::sync::Arc<vectorcraft_doc::Document>,
size: (u32, u32),
view: Affine,
opts: vectorcraft_render::RenderOptions,
pixel: Option<[i64; 4]>,
}
fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) {
let Frame { key, doc, size: (w, h), view, opts, pixel } = frame;
// to the worker. An interaction's frames stay on the worker once it drew the frame shown:
// its renderer keeps the rasters of the art's effects that make those frames fast, and this
// thread's renderer has none, so a frame fast on the worker would be slow here.
let same_doc = app.canvas.key.as_ref().is_some_and(|k| k.doc == key.doc);
let settling = app.canvas.moved && !key.interacting;
let staying = key.interacting && app.canvas.from_worker;
let heavy = (app.canvas.last_ms > 8.0 || settling || staying) && app.canvas.texture.is_some() && same_doc;
let changed = changed_pixels(app, key, doc, &opts, view, w, h);
match (&mut app.canvas.worker, heavy, changed) {
// Only the document changed since the frame shown, in a part of it: that part is
// rendered again, here, into the texture. `last_ms` stays the whole frame's.
(_, _, Some(region)) => {
let t0 = now_ms();
if let (Some([x0, y0, x1, y1]), Some(tex)) = (region, &mut app.canvas.texture) {
let part = app.canvas.renderer.render(doc, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view, &opts);
let color = color_image(&part);
let options = if pixel.is_some() { egui::TextureOptions::NEAREST } else { egui::TextureOptions::LINEAR };
tex.set_partial([x0 as usize, y0 as usize], color, options);
app.canvas.moved |= app.canvas.renderer.stats.moved > 0;
}
app.canvas.key = Some(key.clone());
app.canvas.shown = Some((doc.clone(), opts.clone()));
app.perf.render_ms = now_ms() - t0;
}
(Some(worker), true, None) => 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, pixel.is_some());
app.canvas.key = Some(key.clone());
app.canvas.shown = Some((doc.clone(), opts.clone()));
app.perf.render_ms = now_ms() - t0;
app.canvas.last_ms = app.perf.render_ms;
app.canvas.moved = app.canvas.renderer.stats.moved > 0;
app.canvas.from_worker = false;
}
}
// The status bar reports each image that a renderer (this thread's or the worker's) could not
// decode, once per image in the process.
let undecoded = vectorcraft_render::newly_undecodable(doc.images.values());
if undecoded > 0 {
app.status(format!("{undecoded} image(s) couldn't be decoded and show as crossed boxes"));
}
/// The pasteboard, the artboards' shadows and paper, and the outline mode's white artboards.
fn paint_paper(app: &VectorcraftApp, ui: &Ui, painter: &egui::Painter, xf: &Xf, rect: egui::Rect, t: &Tokens) {
let Some(st) = app.session.active() else { return };
let doc = &st.doc;
// Pasteboard, artboard shadows and paper (Document Setup: the transparency grid's look, the
// simulated paper colour; a white Background Contents hides the grid).
// An artboard with a background color draws its paper in that color. With Simulate Colored Paper
// on, the canvas multiplies it with the paper color: each sRGB channel is artboard × paper / 255,
// rounded. The transparency grid, while it shows, covers it as it covers the others.
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 => {
let fill = ab.color.map_or(paper, |c| crate::panels::c32(&c) * paper);
painter.add(Shape::convex_polygon(q, fill, 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));
}
}
}
/// The last rendered frame, placed through the current view while a new one is on its way.
fn reproject_frame(app: &VectorcraftApp, painter: &egui::Painter, xf: &Xf, rect: egui::Rect, (key_doc, rotation): (usize, f64)) {
if let (Some(tex), Some(k)) = (&app.canvas.texture, &app.canvas.key)
&& k.doc == key.doc
&& k.doc == key_doc
{
// The frame being shown covers the canvas rect it was rendered for. While a panel is
// dragged that rect is a different size, so reprojecting the frame through the *current*
@@ -472,9 +669,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
match k.pixel {
// Document pixels: placed over the document rect they cover, turning with the view.
Some([x0, y0, x1, y1]) => {
textured_quad(&painter, tex.id(), &xf.quad(Rect::new(x0 as f64, y0 as f64, x1 as f64, y1 as f64)), (1.0, 1.0));
textured_quad(painter, tex.id(), &xf.quad(Rect::new(x0 as f64, y0 as f64, x1 as f64, y1 as f64)), (1.0, 1.0));
}
None if (k.rot - v.rotation).abs() < 1e-9 => {
None if (k.rot - rotation).abs() < 1e-9 => {
// Reproject the last frame if it was rendered for a different view.
let old = Xf { rect: old_rect, zoom: k.zoom, center: Point::new(k.cx, k.cy), rot: xf.rot };
let a = xf.to_screen(old.to_doc(old_rect.min));
@@ -488,18 +685,16 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let old = Xf { rect: old_rect, zoom: k.zoom, center: Point::new(k.cx, k.cy), rot: k.rot.to_radians() };
let q =
[old_rect.left_top(), old_rect.right_top(), old_rect.right_bottom(), old_rect.left_bottom()].map(|c| xf.to_screen(old.to_doc(c)));
textured_quad(&painter, tex.id(), &q, (1.0, 1.0));
textured_quad(painter, tex.id(), &q, (1.0, 1.0));
}
}
}
if let Some(look) = grid_look.filter(|_| !grids_in_back) {
grid(&painter, &xf, doc.grid.spacing, doc.grid.subdivisions, look);
}
// The pixel grid (Guides & Grid › Show Pixel Grid (Above 600% Zoom)): in Pixel Preview from
// 600% zoom, a line at every document pixel over the art, so the pixels it rasterizes to show.
if app.ui.view.pixel_preview && v.zoom >= PIXEL_GRID_ZOOM && app.session.prefs.show_pixel_grid {
grid(&painter, &xf, 1.0, 1, LineLook { color: PIXEL_GRID, dots: false });
}
}
/// The artboards' edges, bleeds, marks and names.
fn artboard_outlines(app: &VectorcraftApp, ui: &Ui, painter: &egui::Painter, xf: &Xf, t: &Tokens) {
let Some(st) = app.session.active() else { return };
let doc = &st.doc;
// Artboard edges and names. The Artboard tool labels its active artboard itself (following
// a move or resize): that one isn't named twice.
let active_ab = crate::panels::artboards::selected(app, doc.artboards.len());
@@ -515,53 +710,56 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
// Show Center Mark, Show Cross Hairs and Show Video Safe Areas (Artboard Options). Only the
// canvas draws them, as it does the edges: exports and print leave them out.
artboard_marks(&painter, &xf, ab, t.artboard_mark);
artboard_marks(painter, xf, ab, t.artboard_mark);
if editing_name != Some(i) && (artboard_tool || doc.artboards.len() > 1) && tool_labelled != u64::try_from(i).ok() {
// At the artboard's own top-left corner, which a rotated view turns away from its
// bounding box's (#1095).
let corner = xf.to_screen(Point::new(ab.rect.x0, ab.rect.y0));
artboard_label(&painter, corner, (ab.rect.width() * xf.zoom) as f32, &format!("{:02} - {}", i + 1, ab.name), t.text_dim);
artboard_label(painter, corner, (ab.rect.width() * xf.zoom) as f32, &format!("{:02} - {}", i + 1, ab.name), t.text_dim);
}
}
if app.ui.view.guides {
let look = LineLook::guides(&app.session.prefs, &t);
// Selected guides in the selection colour.
let picked = LineLook { color: t.selection, ..look };
let selected: &[usize] = app.session.active().map_or(&[], |st| &st.selection.guides);
let key = app.session.active().and_then(|st| st.selection.key_guide);
let view = view_bounds(&xf);
// Guides on a hidden layer hide with it.
for (i, g) in doc.guides.iter().enumerate().filter(|(_, g)| doc.guide_shown(g)) {
let at = |along: f64| xf.to_screen(if g.vertical { Point::new(g.pos, along) } else { Point::new(along, g.pos) });
// An artboard guide runs across its artboard, a canvas guide across the window (the part
// of its document line that the canvas shows, turned with the view).
let (a, b) = match doc.guide_span(g) {
Some((from, to)) => (at(from), at(to)),
None => {
let (from, to) = if g.vertical { (view.y0, view.y1) } else { (view.x0, view.x1) };
let Some(ends) = clip_segment(at(from), at(to), rect) else { continue };
ends
}
};
if selected.contains(&i) { picked } else { look }.guide(&painter, a, b);
// The key guide (Align to Key Object, #2143): thicker, as a key object's outline is.
if key == Some(i) {
painter.line_segment([a, b], Stroke::new(KEY_OUTLINE, picked.color));
}
/// The ruler guides.
fn guide_lines(app: &VectorcraftApp, painter: &egui::Painter, xf: &Xf, rect: egui::Rect, t: &Tokens) {
let Some(st) = app.session.active() else { return };
let doc = &st.doc;
let look = LineLook::guides(&app.session.prefs, t);
// Selected guides in the selection colour.
let picked = LineLook { color: t.selection, ..look };
let selected: &[usize] = app.session.active().map_or(&[], |st| &st.selection.guides);
let key = app.session.active().and_then(|st| st.selection.key_guide);
let view = view_bounds(xf);
// Guides on a hidden layer hide with it.
for (i, g) in doc.guides.iter().enumerate().filter(|(_, g)| doc.guide_shown(g)) {
let at = |along: f64| xf.to_screen(if g.vertical { Point::new(g.pos, along) } else { Point::new(along, g.pos) });
// An artboard guide runs across its artboard, a canvas guide across the window (the part
// of its document line that the canvas shows, turned with the view).
let (a, b) = match doc.guide_span(g) {
Some((from, to)) => (at(from), at(to)),
None => {
let (from, to) = if g.vertical { (view.y0, view.y1) } else { (view.x0, view.x1) };
let Some(ends) = clip_segment(at(from), at(to), rect) else { continue };
ends
}
};
if selected.contains(&i) { picked } else { look }.guide(painter, a, b);
// The key guide (Align to Key Object, #2143): thicker, as a key object's outline is.
if key == Some(i) {
painter.line_segment([a, b], Stroke::new(KEY_OUTLINE, picked.color));
}
}
}
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);
}
original_path_overlay(app, &painter, &xf);
// Selection visuals and tool overlays.
let revolve_edit = app.ui.dialog.as_ref().is_some_and(crate::dialogs::revolve_gizmo::active);
let view_info = app.view_info();
/// The selection's visuals and the tools' overlays, to be drawn.
fn selection_visuals(
app: &mut VectorcraftApp,
painter: &egui::Painter,
xf: &Xf,
(hidden_chars, hover_still): (HiddenChars, bool),
revolve_edit: bool,
view_info: vectorcraft_engine::ViewInfo,
) -> Vec<Overlay> {
// View → Hide Gradient Annotator hides the Gradient tool's annotator.
let mut overlays = if !revolve_edit && (app.session.tool_id() != "gradient" || app.ui.view.gradient_annotator) {
app.session.overlays(view_info)
@@ -572,11 +770,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
overlays.extend(crate::dialogs::live_paint_gaps::overlays(app));
if app.ui.view.edges && !revolve_edit {
if hover_still {
hover_highlight(app, &painter, &xf);
hover_highlight(app, painter, xf);
}
selection_overlay(app, &painter, &xf, overlays.iter().any(|o| matches!(o, Overlay::CurvePoint { .. })));
mirror_axis_overlay(app, &painter, &xf);
grid_repeat_overlay(app, &painter, &xf);
selection_overlay(app, painter, xf, overlays.iter().any(|o| matches!(o, Overlay::CurvePoint { .. })));
mirror_axis_overlay(app, painter, xf);
grid_repeat_overlay(app, painter, xf);
if app.ui.view.text_threads {
// Under the tools' overlays: a selected frame's own ports are drawn over its thread's.
let mut threads = thread_overlays(app, xf.zoom);
@@ -588,111 +786,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
if app.ui.view.hidden_chars {
app.canvas.hidden_chars = hidden_chars;
hidden_chars_overlay(app, &painter, &xf);
hidden_chars_overlay(app, painter, xf);
}
}
draw_overlays(&painter, &xf, &overlays, &t, HandleLook::of(&app.session.prefs));
if !revolve_edit {
shaper_widget_overlay(app, &painter, &xf);
}
ime_geometry(app, ui.ctx(), &xf);
crate::place::paint_drop_highlight(app, ui.ctx(), &painter, rect);
// A Rotate View drag (the tool, or Space+Shift with any tool) shows the view's angle on a dial
// at the canvas center, the point the view turns about.
if app.ui.screen_mode < 3 && matches!(ui.data(|d| d.get_temp::<Drag>(drag_id())), Some(Drag::RotateView { .. })) {
rotate_view_dial(&painter, rect.center(), v.rotation, &t);
}
if app.ui.view.rulers && app.ui.screen_mode < 3 {
if let Some(origin) = origin_preview {
let at = xf.to_screen(origin);
painter.line_segment([pos2(at.x, rect.top()), pos2(at.x, rect.bottom())], Stroke::new(1.0, t.text_dim));
painter.line_segment([pos2(rect.left(), at.y), pos2(rect.right(), at.y)], Stroke::new(1.0, t.text_dim));
}
// View › Rulers › Show Video Rulers counts the active artboard in video pixels.
let video = app.ui.view.video_rulers.then(|| crate::dialogs::artboard_options::active_index(app));
let scale = match video.and_then(|i| app.session.active()?.doc.artboards.get(i)) {
Some(ab) => RulerScale::video(ab),
None => RulerScale::document(app.session.general_unit(), origin_preview.unwrap_or_else(|| app.ruler_origin())),
};
rulers(ui, full, &xf, app.hover_doc, scale, &t);
}
// The task bar is not drawn while the Simplify slider shows.
if app.ui.task_bar && !revolve_edit && !app.session.tool_busy() && app.ui.screen_mode < 3 && !crate::dialogs::simplify_slider::showing(app) {
task_bar(app, ui, &xf);
}
if app.ui.screen_mode < 3 {
crate::free_transform::show(app, ui, xf.rect);
}
artboard_name_editor(app, ui, &xf);
// Cursor.
if resp.hovered() {
if opening::long_operation_busy(app) {
ui.ctx().set_cursor_icon(cursor_icon(Cursor::Busy));
return;
}
let m = ui.input(|i| i.modifiers);
let space = ui.input(|i| i.key_down(egui::Key::Space));
let drag = ui.data(|d| d.get_temp::<Drag>(drag_id()));
let panning = matches!(drag, Some(Drag::Pan { .. }));
let zooming = if space { m.command } else { app.session.tool_id() == "zoom" };
// A rotating drag keeps its cursor when Shift is let go.
let rotating =
matches!(drag, Some(Drag::RotateView { .. })) || if space { m.shift && !m.command } else { app.session.tool_id() == "rotateView" };
// The hand and the magnifier are drawn like the tool cursors below: Windows has no
// system cursor for them (winit shows the four-headed move cursor for Grab).
let view_cursor = if panning {
Some(Cursor::HandGrab)
} else if rotating {
Some(Cursor::Rotate)
} else if zooming {
Some(if m.alt { Cursor::ZoomOut } else { Cursor::ZoomIn })
} else if space || app.session.tool_id() == "hand" {
Some(Cursor::Hand)
} else {
None
};
// In Presentation Mode the pointer is the default one, with no tool cursor.
let tool_cursor = (app.ui.screen_mode < 3).then(|| view_cursor.or_else(|| hover_cursor(app, mods(m, space), view_info, hover_still)));
let cur = if let Some(c) = tool_cursor.flatten() {
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("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);
}
// The glyph cursors are drawn here; General › Use Precise Cursors (the session turns the drawing tools' pointers
// into crosshairs, `Cursor::precise`) needs no handling. VECTORCRAFT_SYSTEM_CURSORS opts out of the glyphs.
let custom = std::env::var_os("VECTORCRAFT_SYSTEM_CURSORS").is_none();
// User Interface › Scale Cursor Proportional to UI: the glyphs grow with UI Scaling;
// off, they keep their size.
let ui_scale = if app.session.prefs.scale_cursor_with_ui { 1.0 } else { ui.ctx().zoom_factor() };
match ui.input(|i| i.pointer.hover_pos()) {
// An OS cursor: the system moves it at once, where a painted one trails the pointer
// (#444). The system cursor stands in for cursors without a glyph. Over a window
// without the focus, macOS shows its own pointer whichever cursor the window sets,
// and the glyph is not painted there.
Some(_) if custom && crate::cursors::os_cursors(crate::cursors::OS_CURSORS, app.services.wayland) => {
let ppp = ui.ctx().pixels_per_point() / ui_scale;
ui.ctx().set_cursor_image(app.canvas.cursors.get(c, ppp));
cursor_icon(c)
}
Some(hp)
if custom
&& crate::cursors::paint_glyphs(crate::cursors::HIDES_ONLY_WHEN_KEY, ui.input(|i| i.focused))
&& crate::cursors::paint(&painter, c, hp, 1.0 / ui_scale) =>
{
// No cursor theme (Wayland): the glyph is painted, the system pointer stays (#2450).
if crate::cursors::hide_system_pointer(app.services.no_cursor_theme) { egui::CursorIcon::None } else { cursor_icon(c) }
}
_ => cursor_icon(c),
}
} else {
egui::CursorIcon::Default
};
ui.ctx().set_cursor_icon(cur);
}
overlays
}
#[cfg(test)]
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Color32, Point, Pos2, Rect, Shape, Stroke, Ui, VectorcraftApp, Xf, json, vec2};
/// The width an artboard needs on screen for its name to show (px): narrower, the name is left
/// out rather than spilling over its neighbours.
+1 -1
View File
@@ -1,5 +1,5 @@
use super::outline::{hit_at, hit_with, selection_hit_at};
use super::*;
use super::{Ui, VectorcraftApp, Xf, json, widgets};
/// Right-click: the object under the pointer is selected first unless it already is, then the
/// context menu lists what applies to the selection ([`crate::menus::context_items`]). The locked
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{CacheKey, Cursor, Mods, Point, VectorcraftApp};
/// The active tool's cursor at the pointer, as `mods` make it. While the view moves (`still`
/// false), the cursor the tool last gave for the same tool and keys stays: asking the tool again
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Drag, Mods, Point, PointerEvent, PointerKind, Pos2, StylusFeed, Ui, VectorcraftApp, Xf, widgets};
/// Whether drag `d` is a press the tool holds. Every other drag (panning, zooming, turning the
/// view, a touch gesture, Presentation Mode) never reaches the tool, so its hover ends with it.
+3 -1
View File
@@ -1,5 +1,7 @@
use super::drag::mods;
use super::*;
use super::{
Color32, Point, PointerEvent, PointerKind, Pos2, RULER, Rect, Sense, Shape, Stroke, Tokens, Ui, Unit, VectorcraftApp, Xf, json, pos2, vec2,
};
/// 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.
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{CornerRadius, Tokens, Ui, VectorcraftApp, json, theme, vec2, widgets};
/// The Home screen (`app.home`; with no document open, unless its preference is off).
pub(super) fn home(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) {
+301 -251
View File
@@ -7,43 +7,11 @@ use super::view::{
HOLD_DELAY, HOLD_ZOOM, SCRUB_SLOP, SCRUB_ZOOM, WheelTurn, animated_zoom, pan_pixels, rotate_about, scroll_view, touch_gestures, trackpad_turn,
wheel, whole_pixels, zoom_about,
};
use super::*;
use super::{Color32, Drag, Mods, Point, PointerEvent, PointerKind, Pos2, Shape, Stroke, Ui, VectorcraftApp, View, Xf, json};
/// Dispatch canvas pointer, keyboard and drop events through the active document view.
pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: egui::Rect) {
let line = ui.ctx().options(|o| o.input_options.line_scroll_speed);
let wheel_zooms = app.session.prefs.zoom_with_mouse_wheel;
let turn_id = egui::Id::unique("canvas-wheel-turn");
let turn_before: WheelTurn = ui.data(|d| d.get_temp(turn_id)).unwrap_or_default();
let mut turn = turn_before;
let (pointer, m, space, (factor, scroll)) =
ui.input(|i| (i.pointer.clone(), i.modifiers, i.key_down(egui::Key::Space), wheel(i, wheel_zooms, line, rect.height(), &mut turn)));
if turn != turn_before {
ui.data_mut(|d| d.insert_temp(turn_id, turn));
}
if turn.glide != 0.0 {
ui.ctx().request_repaint();
}
let hover = pointer.hover_pos().filter(|p| rect.contains(*p));
// Zoom around the pointer, and scroll ([`wheel`]): fingers pinching on a touch screen may also
// slide. Then turn the view about the pointer for a trackpad rotation ([`trackpad_turn`]). The
// tool's events below take their document points from the view this moves to.
if resp.hovered() {
if (factor - 1.0).abs() > 1e-6
&& let (Some(p), Some(vm)) = (hover, app.view_mut())
{
zoom_about(vm, rect, p, vm.zoom * factor);
}
if scroll != egui::Vec2::ZERO {
scroll_view(app, ui, scroll);
}
if let Some(p) = hover
&& let Some(deg) = trackpad_turn(ui, app.session.prefs.trackpad_rotate_view)
&& let Some(vm) = app.view_mut()
{
rotate_about(vm, rect, p, deg);
}
}
let (pointer, m, space, hover) = wheel_view(app, ui, resp, rect);
let v = *app.view().unwrap_or(&View::default());
let xf = Xf::new(rect, &v);
// A lifted pen or finger takes the pointer away in the frame it lifts (egui's `PointerGone`),
@@ -51,6 +19,7 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo
// double-taps) where it was seen last (#491).
let at = hover.or_else(|| pointer.interact_pos().filter(|p| rect.contains(*p)));
let view = app.view_info();
let f = Pointing { resp, rect, pointer: &pointer, m, space, xf, v, view };
touch_gestures(app, ui, rect, view);
// Pressed and held still, a pen or a finger opens the context menu (egui's long touch): the
// tool's press is dropped, as a right-click presses nothing.
@@ -97,24 +66,8 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo
prev.map_or((false, false), |prev| (prev.tool != context.tool, prev != context))
});
// In Presentation Mode a click anywhere goes to the next artboard. The tool receives no press,
// drag or hover, and a drag does not pan the view. A drag that began before the mode was turned
// on ends as usual.
if app.ui.screen_mode == 3 && matches!(drag, None | Some(Drag::Present | Drag::Gesture)) {
let pressed = drag.is_none() && pointer.primary_pressed() && resp.hovered() && at.is_some();
// `press` is this frame's new press, or else the press held so far. A quick tap can be
// pressed and released in one frame.
let press = if pressed { Some(Drag::Present) } else { drag };
if press.is_some() && !pointer.primary_down() {
ui.data_mut(|dd| dd.remove::<Drag>(drag_id()));
// A tap of several fingers leaves `Drag::Gesture` (`touch_gestures`), and its release
// does not move the view to the next artboard.
if press == Some(Drag::Present) && resp.clicked() {
crate::shortcuts::presentation_step(app, true);
}
} else if pressed {
ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::Present));
}
present_step(app, ui, &f, drag, at);
return;
}
@@ -125,12 +78,6 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo
if drag.is_none() && ui.input(|i| i.pointer.has_pointer()) {
crate::stylus::sync_eraser_tool(app);
}
let tool = app.session.tool_id();
// Held, Space is the Hand tool for the moment, Space+Shift is the Rotate View tool and
// Cmd+Space is the Zoom tool (with Alt, zooming out), whatever the tool.
let zoom_mode = if space { m.command } else { tool == "zoom" };
let rotate_mode = if space { m.shift && !m.command } else { tool == "rotateView" };
let pan_mode = if space { !m.command && !m.shift } else { tool == "hand" };
let middle_pan = matches!(drag, Some(Drag::Pan { middle: true, .. }));
// egui counts a press a few pixels outside the canvas as on it (its interaction radius): only a
// press on the canvas itself reaches the tools, else it would land at the canvas's centre.
@@ -146,35 +93,7 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo
if let Some(i) = artboard_name_at(app, ui, &xf, p) {
ui.data_mut(|dd| dd.insert_temp(name_press_id(), i));
} else {
let d = if crate::touch::finger_navigates_only() {
// A finger (or a palm) once a pen has been seen: it navigates, it doesn't draw.
Drag::Gesture
} else if zoom_mode {
if animated_zoom(&app.session.prefs) { Drag::ZoomScrub { start: p, held: 0.0, moved: false } } else { Drag::ZoomBox { start: p } }
} else if pan_mode {
Drag::Pan { start: p, center: v.center, middle: false }
} else if rotate_mode {
let c = rect.center();
Drag::RotateView { start_angle: (p.y - c.y).atan2(p.x - c.x) as f64, start_rot: v.rotation, snaps: !space }
} else {
// Cmd with another tool drags with the selection tool used last (the session lends it).
let ev = PointerEvent { kind: PointerKind::Down, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, true) };
dispatch(app, &ev, view);
crate::document_drag::begin(app, ui.ctx(), ev.pos);
Drag::Tool
};
// Panning, zooming or turning the view takes the pointer from the tool's hover.
if !tool_holds(d) {
app.session.tool_hover_ended(view);
}
ui.data_mut(|dd| {
dd.insert_temp(drag_id(), d);
dd.insert_temp(drag_pos_id(), p);
dd.insert_temp(drag_layer_id(), resp.layer_id);
// A new press has not dragged yet, whatever an interrupted one left behind.
dd.remove::<Point>(tool_drag_doc_id());
dd.insert_temp(tool_press_doc_id(), xf.to_doc(p));
});
begin_drag(app, ui, &f, p);
}
} else if drag.is_none()
&& resp.hovered()
@@ -189,157 +108,9 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo
let p = pointer.interact_pos().or_else(|| ui.data(|dd| dd.get_temp(drag_pos_id()))).unwrap_or(rect.center());
let held = if middle_pan { pointer.button_down(egui::PointerButton::Middle) } else { pointer.primary_down() };
if held {
ui.data_mut(|dd| dd.insert_temp(drag_pos_id(), p));
match d {
Drag::Pan { start, center, .. } => {
let ppp = ui.ctx().pixels_per_point();
if let Some(vm) = app.view_mut() {
vm.center = center;
pan_pixels(vm, ppp, whole_pixels(p - start, ppp));
}
}
Drag::RotateView { start_angle, start_rot, snaps } => {
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 snaps && 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 { start } => marquee(ui.painter(), egui::Rect::from_two_pos(start, p)),
Drag::ZoomScrub { start, held, moved } => {
let dt = ui.input(|i| i.unstable_dt).min(0.25);
let (held, moved) = (held + dt, moved || (p - start).length() > SCRUB_SLOP);
// Dragged sideways it zooms with the pointer, held still it zooms on (Alt: out).
let exponent = if moved {
f64::from(pointer.delta().x) * SCRUB_ZOOM
} else if held > HOLD_DELAY {
f64::from(dt) * HOLD_ZOOM * if m.alt { -1.0 } else { 1.0 }
} else {
0.0
};
if exponent != 0.0
&& let Some(vm) = app.view_mut()
{
zoom_about(vm, rect, start, vm.zoom * exponent.exp());
}
ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::ZoomScrub { start, held, moved }));
}
// Without a pointer it is over a floating panel's own OS window (`drag_art_out`).
Drag::Art if pointer.has_pointer() && on_canvas(ui, resp, p) => {
// Back on the canvas: the move goes on from where it left, with the pointer.
egui::DragAndDrop::clear_payload(ui.ctx());
let ev =
PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) };
dispatch(app, &ev, view);
ui.data_mut(|dd| {
dd.insert_temp(drag_id(), Drag::Tool);
dd.insert_temp(drag_pos_id(), p);
});
}
Drag::Art | Drag::Gesture | Drag::Present => {}
Drag::Tool if drag_art_out(app, ui, resp, p) => {
ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::Art));
}
Drag::Tool => {
// Every position the pen or mouse passed this frame, not just the last, for a
// tool that builds a stroke from them: a fast stroke keeps its curve (a pen or a
// mouse reports far more often than frames come). Touch events (a pen on Windows
// or the web, a finger) and a macOS pen's moves carry their own pressure; a
// mouse's, a pen's on X11 among them, take the host's latest. Other tools get
// the last position only, so a costly drag preview runs once a frame.
let moves = tool_frame_moves(ui, app);
// A frame whose pointer ends where the last one did still moved in between.
if tool_drag_moved(ui, &xf, p, &pointer) || !moves.is_empty() {
for (at, force) in moves {
let pressure = force.unwrap_or_else(|| pen_pressure(ui, app, false));
dispatch(app, &PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(at), mods: mods(m, space), pressure }, view);
}
let ev =
PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, 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);
}
}
}
drag_held(app, ui, &f, d, p);
} else {
// The last position the tool's drag got: none if the press never dragged.
let last_drag: Option<Point> = ui.data(|dd| dd.get_temp(tool_drag_doc_id()));
ui.data_mut(|dd| {
dd.remove::<Drag>(drag_id());
dd.remove::<Pos2>(drag_pos_id());
dd.remove::<Point>(tool_drag_doc_id());
dd.remove::<Point>(tool_press_doc_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 {
// The marquee's center and its size on screen set the view, at any rotation.
vm.center = xf.to_doc(r.center());
let fill = (rect.width() / r.width()).min(rect.height() / r.height());
vm.zoom = (xf.zoom * f64::from(fill)).clamp(0.0313, 640.0);
} else {
zoom_about(vm, rect, p, crate::state::next_zoom(vm.zoom, !m.alt));
}
}
}
// A click (not held, not dragged) steps the zoom as with the marquee.
Drag::ZoomScrub { held, moved: false, .. } if held <= HOLD_DELAY => {
if let Some(vm) = app.view_mut() {
zoom_about(vm, rect, p, crate::state::next_zoom(vm.zoom, !m.alt));
}
}
Drag::ZoomScrub { .. } => {}
Drag::Tool => {
// The second click of a double-click: don't fit another artboard on this release.
// A double-click on an object does not open Artboard Options. Empty artboard
// space still does.
if resp.double_clicked() && app.session.tool_id() == "artboard" {
app.session.set_tool_option("suppressFitClick", &json!(true));
}
// A move that came in the release's own frame shapes the preview first: the
// tools commit the last preview, which should be where the pointer let go
// (#834). Only once the press has dragged, so a click's jitter stays a click
// (the Pen's corner anchor, no nudge with Direct Selection).
if let Some(last) = last_drag
&& last != xf.to_doc(p)
{
let ev =
PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) };
dispatch(app, &ev, view);
}
let ev = PointerEvent { kind: PointerKind::Up, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) };
dispatch(app, &ev, view);
}
Drag::Art if on_canvas(ui, resp, p) => {
// Came back to the canvas and released in one frame: finish the move here.
egui::DragAndDrop::clear_payload(ui.ctx());
let pressure = pen_pressure(ui, app, false);
let pos = xf.to_doc(p);
dispatch(app, &PointerEvent { kind: PointerKind::Drag, pos, mods: mods(m, space), pressure }, view);
dispatch(app, &PointerEvent { kind: PointerKind::Up, pos, mods: mods(m, space), pressure }, view);
}
Drag::Art => {
// Released over a panel: the move goes back. (The app ends it before the panels
// see the release, so a panel can take the art; this is the headless path.)
// With no document open there is nothing to roll back; the tool still gets its up.
let _ = app.session.cancel_interaction();
dispatch(app, &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), pressure: 1.0 }, view);
}
Drag::Gesture | Drag::Present => {}
Drag::Pan { .. } | Drag::RotateView { .. } => {}
}
drag_released(app, ui, &f, d, p);
}
} else if let Some(p) = hover_tool
&& (pointer.is_moving() || mods_changed || !was_over || tool_switched || (context_changed && app.session.tool_wants_rehover()))
@@ -370,27 +141,306 @@ pub(super) fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Respo
if resp.double_clicked()
&& let Some(p) = at
{
if let Some(i) = artboard_name_at(app, ui, &xf, p) {
begin_artboard_name_edit(ui, app, i);
} else {
let ev = PointerEvent { kind: PointerKind::DoubleClick, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 };
let before = app.session.tool_id().to_string();
dispatch(app, &ev, view);
// A double-click on type switched a selection tool to the Type tool: the caret goes where
// the type was clicked, so typing edits it at once.
if before != "type" && app.session.tool_id() == "type" {
app.session.set_tool_option("prevTool", &json!(before));
for kind in [PointerKind::Down, PointerKind::Up] {
dispatch(app, &PointerEvent { kind, ..ev }, view);
}
}
}
double_click(app, ui, &f, p);
}
if drag.is_some() || pointer.is_moving() || mods_changed {
ui.ctx().request_repaint();
}
}
/// What the pointer handlers share for one frame.
struct Pointing<'a> {
resp: &'a egui::Response,
rect: egui::Rect,
pointer: &'a egui::PointerState,
m: egui::Modifiers,
space: bool,
xf: Xf,
v: View,
view: vectorcraft_engine::ViewInfo,
}
/// Zoom around the pointer, and scroll ([`wheel`]); a trackpad rotation turns the view. Returns the pointer, the
/// modifiers, whether Space is held and where the pointer hovers over the canvas.
fn wheel_view(
app: &mut VectorcraftApp,
ui: &Ui,
resp: &egui::Response,
rect: egui::Rect,
) -> (egui::PointerState, egui::Modifiers, bool, Option<Pos2>) {
let line = ui.ctx().options(|o| o.input_options.line_scroll_speed);
let wheel_zooms = app.session.prefs.zoom_with_mouse_wheel;
let turn_id = egui::Id::unique("canvas-wheel-turn");
let turn_before: WheelTurn = ui.data(|d| d.get_temp(turn_id)).unwrap_or_default();
let mut turn = turn_before;
let (pointer, m, space, (factor, scroll)) =
ui.input(|i| (i.pointer.clone(), i.modifiers, i.key_down(egui::Key::Space), wheel(i, wheel_zooms, line, rect.height(), &mut turn)));
if turn != turn_before {
ui.data_mut(|d| d.insert_temp(turn_id, turn));
}
if turn.glide != 0.0 {
ui.ctx().request_repaint();
}
let hover = pointer.hover_pos().filter(|p| rect.contains(*p));
// Zoom around the pointer, and scroll ([`wheel`]): fingers pinching on a touch screen may also
// slide. Then turn the view about the pointer for a trackpad rotation ([`trackpad_turn`]). The
// tool's events below take their document points from the view this moves to.
if resp.hovered() {
if (factor - 1.0).abs() > 1e-6
&& let (Some(p), Some(vm)) = (hover, app.view_mut())
{
zoom_about(vm, rect, p, vm.zoom * factor);
}
if scroll != egui::Vec2::ZERO {
scroll_view(app, ui, scroll);
}
if let Some(p) = hover
&& let Some(deg) = trackpad_turn(ui, app.session.prefs.trackpad_rotate_view)
&& let Some(vm) = app.view_mut()
{
rotate_about(vm, rect, p, deg);
}
}
(pointer, m, space, hover)
}
/// In Presentation Mode a click anywhere goes to the next artboard. The tool receives no press, drag or hover, and a
/// drag does not pan the view. A drag that began before the mode was turned on ends as usual.
fn present_step(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, drag: Option<Drag>, at: Option<Pos2>) {
let (resp, pointer) = (f.resp, f.pointer);
let pressed = drag.is_none() && pointer.primary_pressed() && resp.hovered() && at.is_some();
// `press` is this frame's new press, or else the press held so far. A quick tap can be
// pressed and released in one frame.
let press = if pressed { Some(Drag::Present) } else { drag };
if press.is_some() && !pointer.primary_down() {
ui.data_mut(|dd| dd.remove::<Drag>(drag_id()));
// A tap of several fingers leaves `Drag::Gesture` (`touch_gestures`), and its release
// does not move the view to the next artboard.
if press == Some(Drag::Present) && resp.clicked() {
crate::shortcuts::presentation_step(app, true);
}
} else if pressed {
ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::Present));
}
}
/// A press on the canvas at `p` starts a gesture: a zoom, pan or turn of the view, or the tool's drag.
fn begin_drag(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, p: Pos2) {
let Pointing { resp, rect, m, space, xf, v, view, .. } = *f;
let tool = app.session.tool_id();
// Held, Space is the Hand tool for the moment, Space+Shift is the Rotate View tool and
// Cmd+Space is the Zoom tool (with Alt, zooming out), whatever the tool.
let zoom_mode = if space { m.command } else { tool == "zoom" };
let rotate_mode = if space { m.shift && !m.command } else { tool == "rotateView" };
let pan_mode = if space { !m.command && !m.shift } else { tool == "hand" };
let d = if crate::touch::finger_navigates_only() {
// A finger (or a palm) once a pen has been seen: it navigates, it doesn't draw.
Drag::Gesture
} else if zoom_mode {
if animated_zoom(&app.session.prefs) { Drag::ZoomScrub { start: p, held: 0.0, moved: false } } else { Drag::ZoomBox { start: p } }
} else if pan_mode {
Drag::Pan { start: p, center: v.center, middle: false }
} else if rotate_mode {
let c = rect.center();
Drag::RotateView { start_angle: (p.y - c.y).atan2(p.x - c.x) as f64, start_rot: v.rotation, snaps: !space }
} else {
// Cmd with another tool drags with the selection tool used last (the session lends it).
let ev = PointerEvent { kind: PointerKind::Down, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, true) };
dispatch(app, &ev, view);
crate::document_drag::begin(app, ui.ctx(), ev.pos);
Drag::Tool
};
// Panning, zooming or turning the view takes the pointer from the tool's hover.
if !tool_holds(d) {
app.session.tool_hover_ended(view);
}
ui.data_mut(|dd| {
dd.insert_temp(drag_id(), d);
dd.insert_temp(drag_pos_id(), p);
dd.insert_temp(drag_layer_id(), resp.layer_id);
// A new press has not dragged yet, whatever an interrupted one left behind.
dd.remove::<Point>(tool_drag_doc_id());
dd.insert_temp(tool_press_doc_id(), xf.to_doc(p));
});
}
/// A gesture held at `p`: the view follows, or the tool gets drag events.
fn drag_held(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Pos2) {
let Pointing { resp, rect, pointer, m, space, xf, view, .. } = *f;
ui.data_mut(|dd| dd.insert_temp(drag_pos_id(), p));
match d {
Drag::Pan { start, center, .. } => {
let ppp = ui.ctx().pixels_per_point();
if let Some(vm) = app.view_mut() {
vm.center = center;
pan_pixels(vm, ppp, whole_pixels(p - start, ppp));
}
}
Drag::RotateView { start_angle, start_rot, snaps } => {
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 snaps && 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 { start } => marquee(ui.painter(), egui::Rect::from_two_pos(start, p)),
Drag::ZoomScrub { start, held, moved } => {
let dt = ui.input(|i| i.unstable_dt).min(0.25);
let (held, moved) = (held + dt, moved || (p - start).length() > SCRUB_SLOP);
// Dragged sideways it zooms with the pointer, held still it zooms on (Alt: out).
let exponent = if moved {
f64::from(pointer.delta().x) * SCRUB_ZOOM
} else if held > HOLD_DELAY {
f64::from(dt) * HOLD_ZOOM * if m.alt { -1.0 } else { 1.0 }
} else {
0.0
};
if exponent != 0.0
&& let Some(vm) = app.view_mut()
{
zoom_about(vm, rect, start, vm.zoom * exponent.exp());
}
ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::ZoomScrub { start, held, moved }));
}
// Without a pointer it is over a floating panel's own OS window (`drag_art_out`).
Drag::Art if pointer.has_pointer() && on_canvas(ui, resp, p) => {
// Back on the canvas: the move goes on from where it left, with the pointer.
egui::DragAndDrop::clear_payload(ui.ctx());
let ev = PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) };
dispatch(app, &ev, view);
ui.data_mut(|dd| {
dd.insert_temp(drag_id(), Drag::Tool);
dd.insert_temp(drag_pos_id(), p);
});
}
Drag::Art | Drag::Gesture | Drag::Present => {}
Drag::Tool if drag_art_out(app, ui, resp, p) => {
ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::Art));
}
Drag::Tool => {
// Every position the pen or mouse passed this frame, not just the last, for a
// tool that builds a stroke from them: a fast stroke keeps its curve (a pen or a
// mouse reports far more often than frames come). Touch events (a pen on Windows
// or the web, a finger) and a macOS pen's moves carry their own pressure; a
// mouse's, a pen's on X11 among them, take the host's latest. Other tools get
// the last position only, so a costly drag preview runs once a frame.
let moves = tool_frame_moves(ui, app);
// A frame whose pointer ends where the last one did still moved in between.
if tool_drag_moved(ui, &xf, p, pointer) || !moves.is_empty() {
for (at, force) in moves {
let pressure = force.unwrap_or_else(|| pen_pressure(ui, app, false));
dispatch(app, &PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(at), mods: mods(m, space), pressure }, view);
}
let ev = PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, 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);
}
}
}
}
/// A gesture released at `p`: the view settles, or the tool gets its up.
fn drag_released(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Pos2) {
let Pointing { resp, rect, m, space, xf, view, .. } = *f;
// The last position the tool's drag got: none if the press never dragged.
let last_drag: Option<Point> = ui.data(|dd| dd.get_temp(tool_drag_doc_id()));
ui.data_mut(|dd| {
dd.remove::<Drag>(drag_id());
dd.remove::<Pos2>(drag_pos_id());
dd.remove::<Point>(tool_drag_doc_id());
dd.remove::<Point>(tool_press_doc_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 {
// The marquee's center and its size on screen set the view, at any rotation.
vm.center = xf.to_doc(r.center());
let fill = (rect.width() / r.width()).min(rect.height() / r.height());
vm.zoom = (xf.zoom * f64::from(fill)).clamp(0.0313, 640.0);
} else {
zoom_about(vm, rect, p, crate::state::next_zoom(vm.zoom, !m.alt));
}
}
}
// A click (not held, not dragged) steps the zoom as with the marquee.
Drag::ZoomScrub { held, moved: false, .. } if held <= HOLD_DELAY => {
if let Some(vm) = app.view_mut() {
zoom_about(vm, rect, p, crate::state::next_zoom(vm.zoom, !m.alt));
}
}
Drag::ZoomScrub { .. } => {}
Drag::Tool => {
// The second click of a double-click: don't fit another artboard on this release.
// A double-click on an object does not open Artboard Options. Empty artboard
// space still does.
if resp.double_clicked() && app.session.tool_id() == "artboard" {
app.session.set_tool_option("suppressFitClick", &json!(true));
}
// A move that came in the release's own frame shapes the preview first: the
// tools commit the last preview, which should be where the pointer let go
// (#834). Only once the press has dragged, so a click's jitter stays a click
// (the Pen's corner anchor, no nudge with Direct Selection).
if let Some(last) = last_drag
&& last != xf.to_doc(p)
{
let ev = PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) };
dispatch(app, &ev, view);
}
let ev = PointerEvent { kind: PointerKind::Up, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) };
dispatch(app, &ev, view);
}
Drag::Art if on_canvas(ui, resp, p) => {
// Came back to the canvas and released in one frame: finish the move here.
egui::DragAndDrop::clear_payload(ui.ctx());
let pressure = pen_pressure(ui, app, false);
let pos = xf.to_doc(p);
dispatch(app, &PointerEvent { kind: PointerKind::Drag, pos, mods: mods(m, space), pressure }, view);
dispatch(app, &PointerEvent { kind: PointerKind::Up, pos, mods: mods(m, space), pressure }, view);
}
Drag::Art => {
// Released over a panel: the move goes back. (The app ends it before the panels
// see the release, so a panel can take the art; this is the headless path.)
// With no document open there is nothing to roll back; the tool still gets its up.
let _ = app.session.cancel_interaction();
dispatch(app, &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), pressure: 1.0 }, view);
}
Drag::Gesture | Drag::Present => {}
Drag::Pan { .. } | Drag::RotateView { .. } => {}
}
}
/// A double-click at `p`: an artboard's name edits, else the tool gets it.
fn double_click(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, p: Pos2) {
let Pointing { m, space, xf, view, .. } = *f;
if let Some(i) = artboard_name_at(app, ui, &xf, p) {
begin_artboard_name_edit(ui, app, i);
} else {
let ev = PointerEvent { kind: PointerKind::DoubleClick, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 };
let before = app.session.tool_id().to_string();
dispatch(app, &ev, view);
// A double-click on type switched a selection tool to the Type tool: the caret goes where
// the type was clicked, so typing edits it at once.
if before != "type" && app.session.tool_id() == "type" {
app.session.set_tool_option("prevTool", &json!(before));
for kind in [PointerKind::Down, PointerKind::Up] {
dispatch(app, &PointerEvent { kind, ..ev }, view);
}
}
}
}
/// A marquee around screen rectangle `r`: dark dashes over a light line, so it shows on a white
/// artboard and on the grey pasteboard alike (#725).
fn marquee(p: &egui::Painter, r: egui::Rect) {
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{BezPath, Color32, Node, NodeKind, PathEl, Point, Pos2, Shape, Stroke, SymbolBoxes, VectorcraftApp, Xf, pos2};
pub(super) fn to_screen_path(bp: &BezPath, xf: &Xf) -> Vec<Vec<Pos2>> {
// Flatten for drawing: a polyline per subpath (tolerance ~0.25 screen px).
+1 -1
View File
@@ -1,7 +1,7 @@
use super::artboards::names_an_artboard;
use super::input::marquee_through;
use super::outline::{KEY_OUTLINE, c32, node_outline, selection_hit_at, stroke_path, to_screen_path, too_many_anchors, walk_drawn};
use super::*;
use super::{Color32, CornerRadius, NodeKind, Overlay, Point, Pos2, Shape, Stroke, StrokeKind, Tokens, VectorcraftApp, Xf, vec2};
pub(super) fn hover_highlight(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) {
let Some(h) = app.hover_doc else { return };
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Affine, CacheKey, VectorcraftApp, Xf};
/// The rendered raster `img` as an egui image. Both are premultiplied RGBA8, so its bytes are copied
/// over whole instead of pixel by pixel, which costs milliseconds for a canvas on a large screen.
+3 -1
View File
@@ -1,4 +1,6 @@
use super::*;
use super::{
Color32, CornerRadius, NodeKind, Pos2, Rect, Sense, Stroke, StrokeKind, TextKind, Tokens, Ui, Vec2, VectorcraftApp, Xf, json, pos2, vec2, widgets,
};
/// The Contextual Task Bar's area.
fn task_bar_id() -> egui::Id {
+1 -1
View File
@@ -1,6 +1,6 @@
use super::grid::{clip_segment, pref_color};
use super::outline::{OVERLAY_MAX_ANCHORS, c32, stroke_path};
use super::*;
use super::{Color32, CornerRadius, Node, NodeKind, Overlay, Point, Pos2, Rect, Shape, Stroke, StrokeKind, Tokens, VectorcraftApp, Xf, pos2, vec2};
/// Type → Show Hidden Characters: spaces as dots, ¶ at paragraph ends, # at the end of a story.
/// The marks of each text object are kept from one frame to the next ([`HiddenChars`]).
+1 -1
View File
@@ -1,5 +1,5 @@
use super::drag::{drag_id, drop_press};
use super::*;
use super::{CornerRadius, Drag, Point, Pos2, Rect, SCROLL_BAR, Sense, Stroke, Tokens, Ui, VectorcraftApp, View, Xf, pos2, vec2};
/// Fit the view (View → Fit Artboard / Fit All / Actual Size).
pub fn fit(app: &mut VectorcraftApp, how: &str) {
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{NodeKind, Sense, Stroke, Tokens, Ui, VectorcraftApp, json, stroke_panel, theme, vec2, widgets};
/// An anchor button: icon, tooltip, command and the convert command's `to`.
type AnchorButton<'a> = (&'a str, &'a str, &'a str, Option<&'a str>);
+1 -1
View File
@@ -1,5 +1,5 @@
use super::status_bar::ANIMATED_ZOOM_HINT;
use super::*;
use super::{Cow, Sense, Stroke, Tokens, Ui, VectorcraftApp, menus, theme, vec2, widgets};
fn hint_for(tool: &str) -> Option<&'static [(&'static str, bool)]> {
Some(match tool {
+1 -1
View File
@@ -1,5 +1,5 @@
use super::tabs::artboard_list;
use super::*;
use super::{Arc, ROTATE_VIEW_ANGLES, Sense, Stroke, Tokens, Ui, VectorcraftApp, ZOOM_STOPS, json, theme, vec2, widgets, zoom_label};
/// Draw the status message, bounded readouts and artboard navigation controls.
pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Sense, Stroke, Tokens, Ui, VectorcraftApp, icons, json, theme, vec2, widgets, zoom_label};
/// A document tab's title: "Name* @ 66.67 % (RGB/Preview)", or while an opacity mask is edited
/// "Name* @ 66.67 % (<Opacity Mask>/Opacity Mask)".
+203 -144
View File
@@ -707,81 +707,103 @@ fn artboard_navigation_properties_frame(app: &mut crate::VectorcraftApp, ctx: &e
.collect()
}
/// Delete the shown artboard with `key`, or with Properties' Delete Artboard button.
fn trigger_artboard_delete(app: &mut crate::VectorcraftApp, ctx: &egui::Context, key: Option<egui::Key>, before: &vectorcraft_doc::Document) {
if let Some(key) = key {
let ev = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() };
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 300.0));
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events: vec![ev], ..Default::default() }, |ui| {
crate::shortcuts::handle(app, ui.ctx());
super::status_bar(app, ui);
});
out.textures_delta.clear();
} else {
artboard_navigation_properties_frame(app, ctx, vec![]);
let texts = artboard_navigation_properties_frame(app, ctx, vec![]);
assert!(texts.iter().any(|(label, _)| label == &before.artboards[1].name), "Properties shows the navigated board");
let at = texts.iter().find(|(label, _)| label == "Delete Artboard").map(|(_, rect)| rect.center()).expect("Properties Delete button");
assert!(!app.canvas_rect.unwrap().contains(at));
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
for event in [egui::Event::PointerMoved(at), button(true), button(false)] {
artboard_navigation_properties_frame(app, ctx, vec![event]);
}
}
}
/// None clicks Properties' Delete Artboard; Some routes the real key through shortcuts.
fn artboard_navigation_delete(key: Option<egui::Key>) {
for multiple in [false, true] {
let (mut app, id) = artboard_navigation_app("artboard");
let selected = if multiple { json!([0, 2]) } else { json!([0]) };
app.session.set_tool_option("selected", &selected);
assert_eq!(app.view().unwrap().artboard, 0);
assert_eq!(app.session.tool_options()["active"], json!(0));
assert_eq!(app.session.tool_options()["selected"], selected);
app.session.active_mut().unwrap().mark_saved();
let st = app.session.active().unwrap();
let before = st.doc.clone();
let selection = st.selection.clone();
let undo = st.history.undo.len();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
artboard_navigation_next(&mut app, &ctx);
assert_eq!(app.view().unwrap().artboard, 1, "Next shows board 2");
assert_eq!(app.session.tool_options()["active"], json!(1), "Next must change the destructive target before deletion");
assert_eq!(app.session.tool_options()["selected"], json!([1]), "navigation replaces the old artboard multiselection");
assert_eq!(app.view().unwrap().center, before.artboards[1].rect.center());
let st = app.session.active().unwrap();
assert_eq!(st.doc, before, "navigation only changes view and tool state");
assert_eq!(st.selection, selection, "ordinary artwork stays selected");
assert_eq!(st.history.undo.len(), undo);
assert!(st.history.redo.is_empty());
assert!(!st.is_dirty());
if let Some(key) = key {
let ev = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() };
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 300.0));
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events: vec![ev], ..Default::default() }, |ui| {
crate::shortcuts::handle(&mut app, ui.ctx());
super::status_bar(&mut app, ui);
});
out.textures_delta.clear();
} else {
artboard_navigation_properties_frame(&mut app, &ctx, vec![]);
let texts = artboard_navigation_properties_frame(&mut app, &ctx, vec![]);
assert!(texts.iter().any(|(label, _)| label == &before.artboards[1].name), "Properties shows the navigated board");
let at = texts.iter().find(|(label, _)| label == "Delete Artboard").map(|(_, rect)| rect.center()).expect("Properties Delete button");
assert!(!app.canvas_rect.unwrap().contains(at));
let button =
|pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
for event in [egui::Event::PointerMoved(at), button(true), button(false)] {
artboard_navigation_properties_frame(&mut app, &ctx, vec![event]);
}
}
let st = app.session.active().unwrap();
assert_eq!(
st.doc.artboards,
vec![before.artboards[0].clone(), before.artboards[2].clone()],
"first and third original board records survive"
);
assert_eq!(st.doc.node(id), before.node(id), "selected artwork survives unchanged");
assert_eq!(st.selection, selection);
assert_eq!(st.history.undo.len(), undo + 1);
assert_eq!(st.history.undo.last().unwrap().label, "Delete Artboard");
assert!(st.history.redo.is_empty());
assert!(st.is_dirty());
let deleted = st.doc.clone();
app.run("edit.undo", json!({})).unwrap();
let st = app.session.active().unwrap();
assert_eq!(st.doc, before, "one Undo restores every board and the artwork");
assert_eq!(st.selection, selection);
assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo, 1));
assert!(!st.is_dirty());
app.run("edit.redo", json!({})).unwrap();
assert_eq!(app.session.active().unwrap().doc, deleted);
artboard_navigation_delete_case(key, multiple);
}
}
fn artboard_navigation_delete_case(key: Option<egui::Key>, multiple: bool) {
let (mut app, id) = artboard_navigation_app("artboard");
let selected = if multiple { json!([0, 2]) } else { json!([0]) };
app.session.set_tool_option("selected", &selected);
assert_eq!(app.view().unwrap().artboard, 0);
assert_eq!(app.session.tool_options()["active"], json!(0));
assert_eq!(app.session.tool_options()["selected"], selected);
app.session.active_mut().unwrap().mark_saved();
let st = app.session.active().unwrap();
let before = st.doc.clone();
let selection = st.selection.clone();
let undo = st.history.undo.len();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
navigate_to_next_board(&mut app, &ctx, &before, &selection, undo);
delete_navigated_board(&mut app, &ctx, key, id, &before, &selection, undo);
}
fn navigate_to_next_board(
app: &mut crate::VectorcraftApp,
ctx: &egui::Context,
before: &vectorcraft_doc::Document,
selection: &vectorcraft_doc::Selection,
undo: usize,
) {
artboard_navigation_next(app, ctx);
assert_eq!(app.view().unwrap().artboard, 1, "Next shows board 2");
assert_eq!(app.session.tool_options()["active"], json!(1), "Next must change the destructive target before deletion");
assert_eq!(app.session.tool_options()["selected"], json!([1]), "navigation replaces the old artboard multiselection");
assert_eq!(app.view().unwrap().center, before.artboards[1].rect.center());
let st = app.session.active().unwrap();
assert_eq!(st.doc.as_ref(), before, "navigation only changes view and tool state");
assert_eq!(&st.selection, selection, "ordinary artwork stays selected");
assert_eq!(st.history.undo.len(), undo);
assert!(st.history.redo.is_empty());
assert!(!st.is_dirty());
}
fn delete_navigated_board(
app: &mut crate::VectorcraftApp,
ctx: &egui::Context,
key: Option<egui::Key>,
id: vectorcraft_doc::NodeId,
before: &vectorcraft_doc::Document,
selection: &vectorcraft_doc::Selection,
undo: usize,
) {
trigger_artboard_delete(app, ctx, key, before);
let st = app.session.active().unwrap();
assert_eq!(st.doc.artboards, vec![before.artboards[0].clone(), before.artboards[2].clone()], "first and third original board records survive");
assert_eq!(st.doc.node(id), before.node(id), "selected artwork survives unchanged");
assert_eq!(&st.selection, selection);
assert_eq!(st.history.undo.len(), undo + 1);
assert_eq!(st.history.undo.last().unwrap().label, "Delete Artboard");
assert!(st.history.redo.is_empty());
assert!(st.is_dirty());
let deleted = st.doc.clone();
app.run("edit.undo", json!({})).unwrap();
let st = app.session.active().unwrap();
assert_eq!(st.doc.as_ref(), before, "one Undo restores every board and the artwork");
assert_eq!(&st.selection, selection);
assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo, 1));
assert!(!st.is_dirty());
app.run("edit.redo", json!({})).unwrap();
assert_eq!(app.session.active().unwrap().doc, deleted);
}
#[test]
fn artboard_navigation_next_then_delete_uses_shown_board() {
artboard_navigation_delete(Some(egui::Key::Delete));
@@ -1201,59 +1223,54 @@ fn artboard_document_tab_roundtrip_preserves_other_tools() {
}
}
#[test]
fn artboard_close_active_tab_properties_delete_uses_shown_board() {
let (mut app, ctx) = artboard_document_app();
let history = |st: &vectorcraft_engine::DocState| {
let entries = |items: &[vectorcraft_engine::HistoryEntry]| {
items.iter().map(|e| (e.label.clone(), e.doc.clone(), e.selection.clone())).collect::<Vec<_>>()
};
(entries(&st.history.undo), entries(&st.history.redo))
};
let outside_canvas = |app: &crate::VectorcraftApp| {
let p = ctx.input(|i| i.pointer.hover_pos()).expect("a real chrome or panel pointer position");
assert!(!app.canvas_rect.unwrap().contains(p), "the pointer must stay outside the canvas: {p:?}");
};
app.select_tool("artboard");
app.run("tool.setOption", json!({"key": "moveArt", "value": false})).unwrap();
app.run("tool.setOption", json!({"key": "scaleArt", "value": true})).unwrap();
artboard_navigation_next(&mut app, &ctx);
outside_canvas(&app);
assert_eq!(app.view().unwrap().artboard, 1);
assert_eq!(app.session.tool_options()["selected"], json!([1]), "the initial navigator action targets Shown");
app.session.active_mut().unwrap().mark_saved();
let st = app.session.active().unwrap();
let (uid, revision) = (st.uid, st.revision);
let before = st.doc.clone();
let selection = st.selection.clone();
let history_before = history(st);
let history_limits = (st.history.limit, st.history.byte_budget);
assert_eq!(before.artboards.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(), vec!["Keep", "Shown"]);
assert_eq!(before.guides.len(), 2);
assert!(!selection.objects.is_empty());
assert!(history_before.1.is_empty());
assert!(!st.is_dirty());
let mut expected = before.as_ref().clone();
expected.artboards = vec![before.artboards[0].clone()];
expected.guides = vec![before.guides[0].clone()];
/// One history entry: its label, document and selection.
type HistoryRow = (String, Arc<vectorcraft_doc::Document>, Arc<vectorcraft_doc::Selection>);
/// The undo and redo entries of a document.
type HistoryDump = (Vec<HistoryRow>, Vec<HistoryRow>);
fn history_of(st: &vectorcraft_engine::DocState) -> HistoryDump {
let entries =
|items: &[vectorcraft_engine::HistoryEntry]| items.iter().map(|e| (e.label.clone(), e.doc.clone(), e.selection.clone())).collect::<Vec<_>>();
(entries(&st.history.undo), entries(&st.history.redo))
}
fn assert_outside_canvas(app: &crate::VectorcraftApp, ctx: &egui::Context) {
let p = ctx.input(|i| i.pointer.hover_pos()).expect("a real chrome or panel pointer position");
assert!(!app.canvas_rect.unwrap().contains(p), "the pointer must stay outside the canvas: {p:?}");
}
/// Document A as it was saved, before its second tab closed and its shown board was deleted.
struct Baseline {
uid: u64,
revision: u64,
before: Arc<vectorcraft_doc::Document>,
selection: vectorcraft_doc::Selection,
history_before: HistoryDump,
history_limits: (usize, usize),
expected: vectorcraft_doc::Document,
}
/// Close document B's tab with its close box, leaving A as it was.
fn close_b_tab(app: &mut crate::VectorcraftApp, ctx: &egui::Context, base: &Baseline) {
let Baseline { uid, revision, before, selection, history_before, history_limits, expected: _ } = base;
// Route from the status bar along the panel edge to the tab strip, never through canvas.
for at in [egui::pos2(1000.0, 280.0), egui::pos2(1000.0, 20.0)] {
artboard_document_tabs_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at)]);
outside_canvas(&app);
artboard_document_tabs_frame(app, ctx, vec![egui::Event::PointerMoved(at)]);
assert_outside_canvas(app, ctx);
}
artboard_document_click_tab(&mut app, &ctx, 1);
outside_canvas(&app);
artboard_document_click_tab(app, ctx, 1);
assert_outside_canvas(app, ctx);
app.session.active_mut().unwrap().mark_saved();
let other_uid = app.session.active().unwrap().uid;
assert_ne!(other_uid, uid);
assert_ne!(other_uid, *uid);
assert!(app.session.documents().iter().all(|d| !d.is_dirty()), "both ordinary documents are in saved state");
assert!(app.ui.dialog.is_none());
eprintln!("ARTBOARD_CLOSE_TARGET_SETUP_READY");
// The actual B tab x, using the existing close-box test's title-relative hit position.
let titles = artboard_document_tabs_frame(&mut app, &ctx, vec![]);
outside_canvas(&app);
let titles = artboard_document_tabs_frame(app, ctx, vec![]);
assert_outside_canvas(app, ctx);
assert_eq!(titles.len(), 2);
let title = titles[1];
let x = if super::CLOSE_BEFORE_TITLE { title.left() - 16.0 } else { title.right() + 20.0 };
@@ -1261,35 +1278,39 @@ fn artboard_close_active_tab_properties_delete_uses_shown_board() {
assert!(!app.canvas_rect.unwrap().contains(at));
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
for event in [egui::Event::PointerMoved(at), button(true), button(false)] {
artboard_document_tabs_frame(&mut app, &ctx, vec![event]);
outside_canvas(&app);
artboard_document_tabs_frame(app, ctx, vec![event]);
assert_outside_canvas(app, ctx);
}
assert_eq!(app.session.documents().len(), 1, "B tab x must really close B");
assert_eq!(app.session.active_index(), Some(0));
assert_eq!(app.session.active().unwrap().uid, uid, "the survivor is the original A, not a replacement document");
assert_eq!(app.session.active().unwrap().uid, *uid, "the survivor is the original A, not a replacement document");
assert!(app.session.documents().iter().all(|d| d.uid != other_uid));
assert!(app.ui.dialog.is_none(), "a clean close must not leave a save/discard modal");
for _ in 0..2 {
artboard_document_tabs_frame(&mut app, &ctx, vec![]);
outside_canvas(&app);
artboard_document_tabs_frame(app, ctx, vec![]);
assert_outside_canvas(app, ctx);
}
let st = app.session.active().unwrap();
assert_eq!(st.doc, before, "closing B must not edit A");
assert_eq!(st.selection, selection);
assert_eq!(history(st), history_before, "closing B preserves all A Undo/Redo entries");
assert_eq!(st.revision, revision);
assert_eq!((st.history.limit, st.history.byte_budget), history_limits);
assert_eq!(st.doc.as_ref(), before.as_ref(), "closing B must not edit A");
assert_eq!(&st.selection, selection);
assert_eq!(history_of(st), *history_before, "closing B preserves all A Undo/Redo entries");
assert_eq!(st.revision, *revision);
assert_eq!((st.history.limit, st.history.byte_budget), *history_limits);
assert!(!st.is_dirty());
assert_eq!(app.view().unwrap().artboard, 1);
eprintln!("ARTBOARD_CLOSE_TARGET_CLOSED_B");
}
/// Delete the board Properties shows, then Undo and Redo it.
fn delete_shown_board(app: &mut crate::VectorcraftApp, ctx: &egui::Context, base: &Baseline) {
let Baseline { uid, revision, before, selection, history_before, history_limits, expected } = base;
// Cross the tab strip to the Properties panel before moving down to its Delete control.
app.sync_views();
artboard_navigation_properties_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(egui::pos2(1000.0, 20.0))]);
outside_canvas(&app);
artboard_navigation_properties_frame(app, ctx, vec![egui::Event::PointerMoved(egui::pos2(1000.0, 20.0))]);
assert_outside_canvas(app, ctx);
app.sync_views();
let texts = artboard_navigation_properties_frame(&mut app, &ctx, vec![]);
outside_canvas(&app);
let texts = artboard_navigation_properties_frame(app, ctx, vec![]);
assert_outside_canvas(app, ctx);
assert!(texts.iter().any(|(label, _)| label == "Shown"), "actual Properties must display Shown before deletion");
assert_eq!(app.view().unwrap().artboard, 1);
let tool_before_delete = app.session.tool_options();
@@ -1300,14 +1321,15 @@ fn artboard_close_active_tab_properties_delete_uses_shown_board() {
let commands = app.run_count;
for event in [egui::Event::PointerMoved(at), button(true), button(false)] {
app.sync_views();
artboard_navigation_properties_frame(&mut app, &ctx, vec![event]);
outside_canvas(&app);
artboard_navigation_properties_frame(app, ctx, vec![event]);
assert_outside_canvas(app, ctx);
}
let st = app.session.active().unwrap();
assert_eq!(app.run_count, commands.wrapping_add(1), "the actual Properties click executes one command");
assert_eq!(st.doc.artboards.len(), 1, "the real Delete action removed one board");
assert_eq!(st.history.undo.len(), history_before.0.len() + 1);
assert_eq!(st.history.undo.last().unwrap().label, "Delete Artboard");
assert_eq!((st.history.limit, st.history.byte_budget), *history_limits);
eprintln!("ARTBOARD_CLOSE_TARGET_DELETE_CLICKED tool_before={tool_before_delete}");
// Do not assert tool alignment before this destructive-action oracle: it must expose the wrong deletion.
assert_eq!(
@@ -1315,40 +1337,77 @@ fn artboard_close_active_tab_properties_delete_uses_shown_board() {
vec!["Keep"],
"ARTBOARD_CLOSE_TARGET_WRONG_SURVIVOR: Properties showing Shown must delete Shown after closing B"
);
assert_eq!(st.doc.as_ref(), &expected, "only Shown and its attached guide may be removed from the full document");
assert_eq!(st.doc.as_ref(), expected, "only Shown and its attached guide may be removed from the full document");
assert_eq!(st.doc.layers, before.layers, "all artwork and symbol instances stay unchanged");
assert_eq!(st.doc.symbols, before.symbols);
assert_eq!(st.doc.guides, vec![before.guides[0].clone()]);
assert_eq!(st.selection, selection);
assert_eq!(st.uid, uid);
assert_eq!(&st.selection, selection);
assert_eq!(st.uid, *uid);
assert_eq!(st.revision, revision + 1);
let history_after = history(st);
undo_and_redo_the_deletion(app, base);
}
/// Undo and Redo of the deletion that [`delete_shown_board`] made.
fn undo_and_redo_the_deletion(app: &mut crate::VectorcraftApp, base: &Baseline) {
let Baseline { uid, before, selection, history_before, history_limits, expected, revision: _ } = base;
let st = app.session.active().unwrap();
let history_after = history_of(st);
assert_eq!(&history_after.0[..history_before.0.len()], history_before.0.as_slice());
let entry = history_after.0.last().unwrap();
assert_eq!(entry.1, before);
assert_eq!(entry.2.as_ref(), &selection);
assert_eq!(entry.1.as_ref(), before.as_ref());
assert_eq!(entry.2.as_ref(), selection);
assert!(history_after.1.is_empty());
assert!(st.is_dirty());
let deleted = st.doc.clone();
app.run("edit.undo", json!({})).unwrap();
let st = app.session.active().unwrap();
assert_eq!(st.uid, uid);
assert_eq!(st.doc, before, "one Undo restores the full saved A including both boards, guides and artwork");
assert_eq!(st.selection, selection);
assert_eq!(history(st).0, history_before.0);
assert_eq!(st.uid, *uid);
assert_eq!(st.doc.as_ref(), before.as_ref(), "one Undo restores the full saved A including both boards, guides and artwork");
assert_eq!(&st.selection, selection);
assert_eq!(history_of(st).0, history_before.0);
assert_eq!(st.history.redo.len(), 1);
assert_eq!(st.history.redo[0].label, "Delete Artboard");
assert_eq!(st.history.redo[0].doc, deleted);
assert_eq!(st.history.redo[0].selection.as_ref(), &selection);
assert_eq!(st.history.redo[0].doc.as_ref(), deleted.as_ref());
assert_eq!(st.history.redo[0].selection.as_ref(), selection);
assert!(!st.is_dirty());
app.run("edit.redo", json!({})).unwrap();
let st = app.session.active().unwrap();
assert_eq!(st.uid, uid);
assert_eq!(st.doc.as_ref(), &expected, "one Redo repeats only the correct deletion");
assert_eq!(st.selection, selection);
assert_eq!(history(st), history_after);
assert_eq!((st.history.limit, st.history.byte_budget), history_limits);
assert_eq!(st.uid, *uid);
assert_eq!(st.doc.as_ref(), expected, "one Redo repeats only the correct deletion");
assert_eq!(&st.selection, selection);
assert_eq!(history_of(st), history_after);
assert_eq!((st.history.limit, st.history.byte_budget), *history_limits);
assert!(st.is_dirty());
assert_eq!(app.session.documents().len(), 1, "Undo/Redo do not reopen B");
eprintln!("ARTBOARD_CLOSE_TARGET_MODEL_AND_HISTORY_PRESERVED");
}
#[test]
fn artboard_close_active_tab_properties_delete_uses_shown_board() {
let (mut app, ctx) = artboard_document_app();
app.select_tool("artboard");
app.run("tool.setOption", json!({"key": "moveArt", "value": false})).unwrap();
app.run("tool.setOption", json!({"key": "scaleArt", "value": true})).unwrap();
artboard_navigation_next(&mut app, &ctx);
assert_outside_canvas(&app, &ctx);
assert_eq!(app.view().unwrap().artboard, 1);
assert_eq!(app.session.tool_options()["selected"], json!([1]), "the initial navigator action targets Shown");
app.session.active_mut().unwrap().mark_saved();
let st = app.session.active().unwrap();
let (uid, revision) = (st.uid, st.revision);
let before = st.doc.clone();
let selection = st.selection.clone();
let history_before = history_of(st);
let history_limits = (st.history.limit, st.history.byte_budget);
assert_eq!(before.artboards.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(), vec!["Keep", "Shown"]);
assert_eq!(before.guides.len(), 2);
assert!(!selection.objects.is_empty());
assert!(history_before.1.is_empty());
assert!(!st.is_dirty());
let mut expected = before.as_ref().clone();
expected.artboards = vec![before.artboards[0].clone()];
expected.guides = vec![before.guides[0].clone()];
let base = Baseline { uid, revision, before, selection, history_before, history_limits, expected };
close_b_tab(&mut app, &ctx, &base);
delete_shown_board(&mut app, &ctx, &base);
}
+136 -122
View File
@@ -259,41 +259,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
"ui.contextMenu.list" => ok(serde_json::to_value(crate::menus::context_entries(app)).unwrap_or_default()),
"ui.tool.select" => wrap(app.run("tool.select", json!({"tool": s("tool").unwrap_or("")}))),
"ui.tool.list" => ok(serde_json::to_value(vectorcraft_tools::TOOL_GROUPS).unwrap_or_default()),
"ui.pointer" => {
let Some(events) = p.get("events").and_then(Value::as_array) else { return err("missing `events`") };
let base_mods: Mods = p.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or_default();
let view = app.view_info();
let xf = app.canvas_rect.zip(app.view().copied()).map(|(rect, v)| Xf::new(rect, &v));
for e in events {
let kind = match e.get("kind").and_then(Value::as_str).unwrap_or("") {
"down" => PointerKind::Down,
"drag" => PointerKind::Drag,
"up" => PointerKind::Up,
"move" => PointerKind::Move,
"doubleclick" | "dblclick" => PointerKind::DoubleClick,
other => return err(format!("unknown pointer kind `{other}`")),
};
let x = e.get("x").and_then(Value::as_f64).unwrap_or(0.0);
let y = e.get("y").and_then(Value::as_f64).unwrap_or(0.0);
let pos = if e.get("space").and_then(Value::as_str) == Some("screen") {
match xf {
Some(xf) => xf.to_doc(egui::pos2(x as f32, y as f32)),
None => return err("no canvas yet"),
}
} else {
vectorcraft_geom::Point::new(x, y)
};
let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base_mods);
crate::canvas::dispatch(app, &PointerEvent { kind, pos, mods, pressure: PointerEvent::json_pressure(e) }, view);
let hold = PointerEvent::json_hold(e);
if hold > 0.0 {
let r = app.session.tool_tick(hold, view);
crate::canvas::apply_requests(app, r);
}
}
ctx.request_repaint();
wrap(app.run("document.inspect", json!({})).map(|d| json!({"selection": d["selection"], "key": d["key"], "tool": app.session.tool_id()})))
}
"ui.pointer" => pointer_events(app, ctx, req),
"ui.key" => {
let Some(k) = s("key").and_then(key_from) else { return err("unknown or missing `key`") };
if let Err(e) = reserve_synthetic(app, 2 + usize::from(s("text").is_some())) {
@@ -318,51 +284,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
ctx.request_repaint();
ok(Value::Null)
}
"ui.click" | "ui.drag" => {
// Validate the entire expansion before appending even the initial move/press.
let steps = if req.method == "ui.drag" {
match input_count(p, "steps", 8, MAX_DRAG_STEPS) {
Ok(n) => n,
Err(e) => return err(e),
}
} else {
0
};
let count = match input_count(p, "count", 1, MAX_CLICK_COUNT) {
Ok(n) => n,
Err(e) => return err(e),
};
let Some(events) = count.checked_mul(2).and_then(|n| n.checked_add(steps)).and_then(|n| n.checked_add(1)) else {
return err("synthetic input event count is out of range");
};
if let Err(e) = reserve_synthetic(app, events) {
return err(e);
}
// Screen-space (egui points) pointer input through egui itself: reaches every widget.
let f = |k: &str| p.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32;
let button = match s("button") {
Some("right") | Some("secondary") => egui::PointerButton::Secondary,
_ => egui::PointerButton::Primary,
};
let modifiers = modifiers(p);
let a = egui::pos2(f("x"), f("y"));
let end = if req.method == "ui.drag" { egui::pos2(f("toX"), f("toY")) } else { a };
app.synthetic.push(egui::Event::PointerMoved(a));
app.synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: true, modifiers });
if req.method == "ui.drag" {
for i in 1..=steps {
let t = i as f32 / steps as f32;
app.synthetic.push(egui::Event::PointerMoved(a + (end - a) * t));
}
}
app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers });
for _ in 1..count {
app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: true, modifiers });
app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers });
}
ctx.request_repaint();
ok(Value::Null)
}
"ui.click" | "ui.drag" => synthetic_click(app, ctx, req),
"ui.wheel" => {
if let Err(e) = reserve_synthetic(app, 2) {
return err(e);
@@ -392,48 +314,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
ctx.request_repaint();
ok(Value::Null)
}
"ui.set" => {
let mut r = Ok(Value::Null);
if let Some(b) = s("brightness") {
r = app.run("window.brightness", json!({"brightness": b}));
}
if let Some(pn) = s("panel") {
r = app.run("window.panel", json!({"panel": pn}));
}
if let Some(st) = s("status") {
app.ui.status = st.to_string();
}
for (k, flag) in [
("rulers", "view.rulers"),
("outline", "view.outline"),
("grid", "view.grid"),
("smartGuides", "view.smartGuides"),
("boundingBox", "view.boundingBox"),
] {
if let Some(want) = p.get(k).and_then(Value::as_bool) {
let cur = match k {
"rulers" => app.ui.view.rulers,
"outline" => app.ui.view.outline,
"grid" => app.ui.view.grid,
"smartGuides" => app.ui.view.smart_guides,
_ => app.ui.view.bounding_box,
};
if cur != want {
r = app.run(flag, json!({}));
}
}
}
if let Some(v) = p.get("controlBar").and_then(Value::as_bool) {
app.ui.control_bar = v;
}
if let Some(v) = p.get("imageTracePreview").and_then(Value::as_bool) {
r = app.run("imageTrace.preview", json!({"on": v}));
}
if p.get("closePanel").is_some() {
app.ui.open_panel = None;
}
wrap(r)
}
"ui.set" => set_ui(app, req),
"ui.dialog.set" => match app.ui.dialog.as_mut() {
Some(d) => {
let fields = match dialog_fields(p) {
@@ -619,3 +500,136 @@ pub(crate) fn save_screenshot_with_limit(
#[cfg(test)]
mod tests;
/// `ui.pointer`: pointer events through the tools.
fn pointer_events(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
let p = &req.params;
let Some(events) = p.get("events").and_then(Value::as_array) else { return err("missing `events`") };
let base_mods: Mods = p.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or_default();
let view = app.view_info();
let xf = app.canvas_rect.zip(app.view().copied()).map(|(rect, v)| Xf::new(rect, &v));
for e in events {
let kind = match e.get("kind").and_then(Value::as_str).unwrap_or("") {
"down" => PointerKind::Down,
"drag" => PointerKind::Drag,
"up" => PointerKind::Up,
"move" => PointerKind::Move,
"doubleclick" | "dblclick" => PointerKind::DoubleClick,
other => return err(format!("unknown pointer kind `{other}`")),
};
let x = e.get("x").and_then(Value::as_f64).unwrap_or(0.0);
let y = e.get("y").and_then(Value::as_f64).unwrap_or(0.0);
let pos = if e.get("space").and_then(Value::as_str) == Some("screen") {
match xf {
Some(xf) => xf.to_doc(egui::pos2(x as f32, y as f32)),
None => return err("no canvas yet"),
}
} else {
vectorcraft_geom::Point::new(x, y)
};
let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base_mods);
crate::canvas::dispatch(app, &PointerEvent { kind, pos, mods, pressure: PointerEvent::json_pressure(e) }, view);
let hold = PointerEvent::json_hold(e);
if hold > 0.0 {
let r = app.session.tool_tick(hold, view);
crate::canvas::apply_requests(app, r);
}
}
ctx.request_repaint();
wrap(app.run("document.inspect", json!({})).map(|d| json!({"selection": d["selection"], "key": d["key"], "tool": app.session.tool_id()})))
}
/// `ui.click` and `ui.drag`: pointer input through egui itself.
fn synthetic_click(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
let p = &req.params;
let s = |k: &str| p.get(k).and_then(Value::as_str);
// Validate the entire expansion before appending even the initial move/press.
let steps = if req.method == "ui.drag" {
match input_count(p, "steps", 8, MAX_DRAG_STEPS) {
Ok(n) => n,
Err(e) => return err(e),
}
} else {
0
};
let count = match input_count(p, "count", 1, MAX_CLICK_COUNT) {
Ok(n) => n,
Err(e) => return err(e),
};
let Some(events) = count.checked_mul(2).and_then(|n| n.checked_add(steps)).and_then(|n| n.checked_add(1)) else {
return err("synthetic input event count is out of range");
};
if let Err(e) = reserve_synthetic(app, events) {
return err(e);
}
// Screen-space (egui points) pointer input through egui itself: reaches every widget.
let f = |k: &str| p.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32;
let button = match s("button") {
Some("right") | Some("secondary") => egui::PointerButton::Secondary,
_ => egui::PointerButton::Primary,
};
let modifiers = modifiers(p);
let a = egui::pos2(f("x"), f("y"));
let end = if req.method == "ui.drag" { egui::pos2(f("toX"), f("toY")) } else { a };
app.synthetic.push(egui::Event::PointerMoved(a));
app.synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: true, modifiers });
if req.method == "ui.drag" {
for i in 1..=steps {
let t = i as f32 / steps as f32;
app.synthetic.push(egui::Event::PointerMoved(a + (end - a) * t));
}
}
app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers });
for _ in 1..count {
app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: true, modifiers });
app.synthetic.push(egui::Event::PointerButton { pos: end, button, pressed: false, modifiers });
}
ctx.request_repaint();
ok(Value::Null)
}
/// `ui.set`: view flags, panels and status.
fn set_ui(app: &mut VectorcraftApp, req: &ControlRequest) -> Outcome {
let p = &req.params;
let s = |k: &str| p.get(k).and_then(Value::as_str);
let mut r = Ok(Value::Null);
if let Some(b) = s("brightness") {
r = app.run("window.brightness", json!({"brightness": b}));
}
if let Some(pn) = s("panel") {
r = app.run("window.panel", json!({"panel": pn}));
}
if let Some(st) = s("status") {
app.ui.status = st.to_string();
}
for (k, flag) in [
("rulers", "view.rulers"),
("outline", "view.outline"),
("grid", "view.grid"),
("smartGuides", "view.smartGuides"),
("boundingBox", "view.boundingBox"),
] {
if let Some(want) = p.get(k).and_then(Value::as_bool) {
let cur = match k {
"rulers" => app.ui.view.rulers,
"outline" => app.ui.view.outline,
"grid" => app.ui.view.grid,
"smartGuides" => app.ui.view.smart_guides,
_ => app.ui.view.bounding_box,
};
if cur != want {
r = app.run(flag, json!({}));
}
}
}
if let Some(v) = p.get("controlBar").and_then(Value::as_bool) {
app.ui.control_bar = v;
}
if let Some(v) = p.get("imageTracePreview").and_then(Value::as_bool) {
r = app.run("imageTrace.preview", json!({"on": v}));
}
if p.get("closePanel").is_some() {
app.ui.open_panel = None;
}
wrap(r)
}
+59 -37
View File
@@ -380,6 +380,50 @@ fn warp_options_slider_drag_previews_exact_parameter_and_cancel_restores() {
assert!(app.ui.dialog.is_none() && !app.session.in_interaction());
}
/// The edit the Warp dialog test makes: the Bend field, the style, or none.
fn make_warp_edit(app: &mut VectorcraftApp, ctx: &egui::Context, edit: &str) {
match edit {
"bend" => {
click_number(app, ctx, "Bend:");
frame(app, ctx, replace_number("25%"));
// Blur via the existing orientation choice; it stays Horizontal.
click_text(app, ctx, "Horizontal");
}
"style" => {
click_text(app, ctx, "Arc");
click_text(app, ctx, "Arc Lower");
}
_ => {}
}
}
/// OK or Cancel was clicked: the document, the history and Redo as each leaves them.
fn finish_warp_dialog(
app: &mut VectorcraftApp,
accept: bool,
(before, expected): (&std::sync::Arc<vectorcraft_doc::Document>, &std::sync::Arc<vectorcraft_doc::Document>),
(undo, redo): (usize, usize),
) {
let state = app.session.doc().unwrap();
if accept {
// Whole-document equality also checks both targets, sibling effects and
// the first visible / second hidden Warp. Existing OK history stays one step.
assert_eq!(state.doc.as_ref(), expected.as_ref());
assert_eq!(state.history.undo.len(), undo + 1);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc.as_ref(), before.as_ref());
assert!(!app.session.doc().unwrap().is_dirty());
app.run("edit.redo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc.as_ref(), expected.as_ref());
} else {
assert_eq!(state.doc.as_ref(), before.as_ref());
assert_eq!((state.history.undo.len(), state.history.redo.len()), (undo, redo));
assert!(!state.is_dirty());
app.run("edit.redo", json!({})).unwrap();
assert_ne!(app.session.doc().unwrap().doc.as_ref(), before.as_ref(), "Cancel keeps the original Redo usable");
}
}
#[test]
fn warp_options_mixed_styles_change_only_after_explicit_style_choice() {
for item in [Value::Null, json!(1)] {
@@ -423,19 +467,7 @@ fn warp_options_mixed_styles_change_only_after_explicit_style_choice() {
click_text(&mut app, &ctx, "Edit");
settle(&mut app, &ctx);
assert_eq!(app.session.doc().unwrap().doc, before, "opening must not broadcast the first target's style");
match edit {
"bend" => {
click_number(&mut app, &ctx, "Bend:");
frame(&mut app, &ctx, replace_number("25%"));
// Blur via the existing orientation choice; it stays Horizontal.
click_text(&mut app, &ctx, "Horizontal");
}
"style" => {
click_text(&mut app, &ctx, "Arc");
click_text(&mut app, &ctx, "Arc Lower");
}
_ => {}
}
make_warp_edit(&mut app, &ctx, edit);
assert_eq!(app.session.doc().unwrap().doc, expected, "edit={edit}, item={item}");
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo);
click_text(&mut app, &ctx, "Preview");
@@ -449,23 +481,7 @@ fn warp_options_mixed_styles_change_only_after_explicit_style_choice() {
let state = app.session.doc().unwrap();
assert_eq!(state.selection, selection);
assert_eq!(app.last_effect, last_effect);
if accept {
// Whole-document equality also checks both targets, sibling effects and
// the first visible / second hidden Warp. Existing OK history stays one step.
assert_eq!(state.doc, expected);
assert_eq!(state.history.undo.len(), undo + 1);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, before);
assert!(!app.session.doc().unwrap().is_dirty());
app.run("edit.redo", json!({})).unwrap();
assert_eq!(app.session.doc().unwrap().doc, expected);
} else {
assert_eq!(state.doc, before);
assert_eq!((state.history.undo.len(), state.history.redo.len()), (undo, redo));
assert!(!state.is_dirty());
app.run("edit.redo", json!({})).unwrap();
assert_ne!(app.session.doc().unwrap().doc, before, "Cancel keeps the original Redo usable");
}
finish_warp_dialog(&mut app, accept, (&before, &expected), (undo, redo));
}
}
}
@@ -525,6 +541,18 @@ fn tab_to_warp_control(app: &mut VectorcraftApp, ctx: &egui::Context, label: &st
panic!("Tab traversal did not focus {label}");
}
/// With Preview on the Style choice shows on the art in an interaction; off, the document is as it was.
fn check_style_preview(app: &VectorcraftApp, preview: bool, id: NodeId, before: &std::sync::Arc<vectorcraft_doc::Document>) {
let state = app.session.doc().unwrap();
if preview {
assert_eq!(state.doc.node(id).unwrap().appearance.effects[0].id, "warp.arcLower");
assert!(app.session.in_interaction());
} else {
assert_eq!(state.doc.as_ref(), before.as_ref());
assert!(!state.is_dirty() && !app.session.in_interaction());
}
}
#[test]
fn warp_options_style_keyboard_enter_does_not_accept_dialog() {
for preview in [true, false] {
@@ -575,13 +603,7 @@ fn warp_options_style_keyboard_enter_does_not_accept_dialog() {
assert_eq!(entries(&state.history.undo), undo);
assert_eq!(entries(&state.history.redo), redo);
assert_eq!(app.last_effect, last_effect);
if preview {
assert_eq!(state.doc.node(id).unwrap().appearance.effects[0].id, "warp.arcLower");
assert!(app.session.in_interaction());
} else {
assert_eq!(state.doc, before);
assert!(!state.is_dirty() && !app.session.in_interaction());
}
check_style_preview(&app, preview, id, &before);
click_text(&mut app, &ctx, "Cancel");
let state = app.session.doc().unwrap();
assert_eq!(state.doc, before);
+31 -21
View File
@@ -452,6 +452,34 @@ fn header<'s>(ui: &mut Ui, state_id: egui::Id, st: &'s mut MenuState, current: &
Listed { st, rows, current: current.to_string(), enter, keyed, opening }
}
/// The disclosure triangle of a family with several styles; whether the pointer is on it.
fn disclosure(p: &egui::Painter, resp: &egui::Response, tri: egui::Rect, open: bool, t: &Tokens) -> bool {
p.text(tri.center(), egui::Align2::CENTER_CENTER, if open { "▾" } else { "▸" }, egui::FontId::proportional(11.0), t.text_dim);
resp.interact_pointer_pos().is_some_and(|pos| tri.contains(pos))
}
/// The star at the end of a family's row, shown when it is a favourite, hovered or highlighted; whether the pointer is
/// on it.
fn star_cell(p: &egui::Painter, resp: &egui::Response, star: egui::Rect, (on, highlighted): (bool, bool), t: &Tokens) -> bool {
let hot = resp.hover_pos().is_some_and(|pos| star.contains(pos));
if on || hot || highlighted {
let color = if on { t.accent } else { t.text_dim };
p.text(star.center(), egui::Align2::CENTER_CENTER, if on { "★" } else { "☆" }, egui::FontId::proportional(12.0), color);
}
resp.interact_pointer_pos().is_some_and(|pos| star.contains(pos))
}
/// A heading row (Recent Fonts, Fonts), which cannot preview or apply a font.
fn heading_row(ui: &mut Ui, row_h: f32, label: &str, t: &Tokens) {
let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::hover());
// Existing translated menu labels; the heading cannot preview or apply a font.
let label = match label {
"Recent Fonts" => tl!("Recent Fonts"),
_ => tl!("Fonts"),
};
ui.painter_at(rect).text(rect.left_center() + vec2(4.0, 0.0), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(11.0), t.text_dim);
}
/// The rows of the open menu in `range`, the ones in view: the menu's one scrolling part, under
/// the search field and filters (#555).
fn rows(ui: &mut Ui, range: std::ops::Range<usize>, h: Listed<'_>, sample: Option<&str>, look: MenuLook) -> Option<FontPick> {
@@ -490,19 +518,7 @@ fn rows(ui: &mut Ui, range: std::ops::Range<usize>, h: Listed<'_>, sample: Optio
for i in range {
let Some(row) = rows.get(i) else { continue };
if let Row::Heading(label) = row {
let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::hover());
// Existing translated menu labels; the heading cannot preview or apply a font.
let label = match *label {
"Recent Fonts" => tl!("Recent Fonts"),
_ => tl!("Fonts"),
};
ui.painter_at(rect).text(
rect.left_center() + vec2(4.0, 0.0),
egui::Align2::LEFT_CENTER,
label,
egui::FontId::proportional(11.0),
t.text_dim,
);
heading_row(ui, row_h, label, &t);
continue;
}
let Some(family) = row.family() else { continue };
@@ -523,8 +539,7 @@ fn rows(ui: &mut Ui, range: std::ops::Range<usize>, h: Listed<'_>, sample: Optio
&& db.styles(f).len() > 1
{
let open = st.expanded.iter().any(|e| e.eq_ignore_ascii_case(f));
p.text(tri.center(), egui::Align2::CENTER_CENTER, if open { "▾" } else { "▸" }, egui::FontId::proportional(11.0), t.text_dim);
on_triangle = resp.interact_pointer_pos().is_some_and(|pos| tri.contains(pos));
on_triangle = disclosure(&p, &resp, tri, open, &t);
if resp.clicked() && on_triangle {
toggled = Some(f.to_lowercase());
}
@@ -534,12 +549,7 @@ fn rows(ui: &mut Ui, range: std::ops::Range<usize>, h: Listed<'_>, sample: Optio
let mut on_star = false;
if let Row::Family(f) = row {
let on = favorite(f);
let hot = resp.hover_pos().is_some_and(|pos| star.contains(pos));
if on || hot || st.highlight == Some(i) {
let color = if on { t.accent } else { t.text_dim };
p.text(star.center(), egui::Align2::CENTER_CENTER, if on { "★" } else { "☆" }, egui::FontId::proportional(12.0), color);
}
on_star = resp.interact_pointer_pos().is_some_and(|pos| star.contains(pos));
on_star = star_cell(&p, &resp, star, (on, st.highlight == Some(i)), &t);
if resp.clicked() && on_star {
starred = Some(f.clone());
}
+21 -16
View File
@@ -23,6 +23,27 @@ fn tags_map_to_languages() {
for tag in ["fr", "fr_FR.UTF-8", "fr-BE", "fr_CA", "fr-CH", "fr-LU"] {
assert_eq!(lang_from_tag(tag), Lang::from_code("fr"), "{tag}");
}
other_tags_map_to_languages();
// Traditional Chinese: by region, by script, and with a region after the script.
assert_eq!(lang_from_tag("zh_TW.UTF-8"), Some(ZH()));
assert_eq!(lang_from_tag("zh-TW"), Some(ZH()));
assert_eq!(lang_from_tag("zh-HK"), Some(ZH()));
assert_eq!(lang_from_tag("zh_MO"), Some(ZH()));
assert_eq!(lang_from_tag("zh-Hant"), Some(ZH()));
assert_eq!(lang_from_tag("zh-Hant-TW"), Some(ZH()));
assert_eq!(lang_from_tag("zh-Hant-HK"), Some(ZH()));
assert_eq!(lang_from_tag("zh_TW.UTF-8@radical"), Some(ZH()));
// Simplified Chinese locales never pick up the Traditional catalog (they resolve to a
// `zh-hans` catalog once one is registered, and to English until then).
for tag in ["zh-CN", "zh_CN.UTF-8", "zh_SG", "zh-Hans", "zh-Hans-CN", "zh"] {
assert_eq!(lang_from_tag(tag), Lang::from_code("zh-hans"), "{tag}");
}
assert_eq!(lang_from_tag(""), None);
assert_eq!(lang_from_tag("_"), None);
}
/// The Georgian, Ukrainian, Hungarian, Korean, Vietnamese and Swedish tags of [`tags_map_to_languages`].
fn other_tags_map_to_languages() {
for tag in ["ka", "ka-GE", "ka_GE.UTF-8", "KA-ge"] {
assert_eq!(lang_from_tag(tag), Some(ka()), "{tag}");
}
@@ -41,22 +62,6 @@ fn tags_map_to_languages() {
for tag in ["sv", "sv-SE", "sv-FI", "sv_SE.UTF-8", "SV-se"] {
assert_eq!(lang_from_tag(tag), Lang::from_code("sv"), "{tag}");
}
// Traditional Chinese: by region, by script, and with a region after the script.
assert_eq!(lang_from_tag("zh_TW.UTF-8"), Some(ZH()));
assert_eq!(lang_from_tag("zh-TW"), Some(ZH()));
assert_eq!(lang_from_tag("zh-HK"), Some(ZH()));
assert_eq!(lang_from_tag("zh_MO"), Some(ZH()));
assert_eq!(lang_from_tag("zh-Hant"), Some(ZH()));
assert_eq!(lang_from_tag("zh-Hant-TW"), Some(ZH()));
assert_eq!(lang_from_tag("zh-Hant-HK"), Some(ZH()));
assert_eq!(lang_from_tag("zh_TW.UTF-8@radical"), Some(ZH()));
// Simplified Chinese locales never pick up the Traditional catalog (they resolve to a
// `zh-hans` catalog once one is registered, and to English until then).
for tag in ["zh-CN", "zh_CN.UTF-8", "zh_SG", "zh-Hans", "zh-Hans-CN", "zh"] {
assert_eq!(lang_from_tag(tag), Lang::from_code("zh-hans"), "{tag}");
}
assert_eq!(lang_from_tag(""), None);
assert_eq!(lang_from_tag("_"), None);
}
/// Italian, Brazilian Portuguese, Finnish, Turkish and Polish resolve from their locale tags and
+1 -1
View File
@@ -2,7 +2,7 @@ use super::save::{
ask_before_losing, export_path, is_web, report_saved, report_write_error, saved_status, target_path, target_path_keeping, view_file,
write_encoded, write_named, write_reporting,
};
use super::*;
use super::{EngineError, Progress, Value, VectorcraftApp, Writer, background, cancel, fileio, json, progress};
/// The status once an export was cancelled.
pub const EXPORT_CANCELLED: &str = "Export cancelled";
+1 -1
View File
@@ -1,5 +1,5 @@
use super::save::suggested;
use super::*;
use super::{Dialog, FilePick, Format, Value, VectorcraftApp, dialogs, fileio, json};
/// Template extensions New from Template's open dialog lists first.
const TEMPLATE_EXTS: &[&str] = &["vctemplate", "ait", "vectorcraft", "drawcraft"];
+4 -1
View File
@@ -1,6 +1,9 @@
use super::export::file_url;
use super::open::{note_recent, open_options};
use super::*;
use super::{
EngineError, FilePick, Format, SAVE_FORMATS, SaveMode, SavePlan, SavedView, Services, TEMPLATE_FORMATS, Value, VectorcraftApp, Writer,
background, dialogs, fileio, json, svg_options,
};
/// A save or an export that didn't write `path`: say so in a dialog that names the file and the
/// reason and stays until it is closed, as an editor does (#1685). The status bar's line, which
+14 -9
View File
@@ -75,6 +75,20 @@ fn a_layout_saved_before_the_canvas_joined_keeps_its_arrangement() {
assert_eq!(groups(docked), [vec!["pathfinder"], vec!["transform"]], "set docked as a column");
assert!(!docked.iconic);
floating_windows_keep_their_places(layout);
// Symbols is in the icon column, and the floating panels came from it: none keeps a
// place in the dock to go back to.
assert!(ui.docking_hidden.is_empty(), "{:?}", ui.docking_hidden);
let workspace = ui.custom_workspaces.iter().find(|w| w.name == "Migration Test").expect("custom workspace");
assert_eq!(workspace.docking, ui.docking);
assert_eq!(workspace.docking_hidden, ui.docking_hidden);
assert!(workspace.floating_panels.is_empty() && workspace.docking_icons.is_empty());
}
/// The three floating windows of the migrated layout.
fn floating_windows_keep_their_places(layout: &Layout<String>) {
assert_eq!(layout.floating.len(), 3);
let color = &layout.floating[0];
assert_eq!(window_groups(color), [vec!["color", "stroke"]]);
@@ -88,15 +102,6 @@ fn a_layout_saved_before_the_canvas_joined_keeps_its_arrangement() {
assert_eq!(window_groups(align), [vec!["align"]]);
assert!(align.minimized, "collapsed floating group");
assert_eq!(&align.rect[..2], &[300.0, 560.0]);
// Symbols is in the icon column, and the floating panels came from it: none keeps a
// place in the dock to go back to.
assert!(ui.docking_hidden.is_empty(), "{:?}", ui.docking_hidden);
let workspace = ui.custom_workspaces.iter().find(|w| w.name == "Migration Test").expect("custom workspace");
assert_eq!(workspace.docking, ui.docking);
assert_eq!(workspace.docking_hidden, ui.docking_hidden);
assert!(workspace.floating_panels.is_empty() && workspace.docking_icons.is_empty());
}
#[test]
+1 -1
View File
@@ -1,7 +1,7 @@
use super::entries::{Row, entry, pretty_shortcut};
use super::invoke::{field_event, focused_text_field, invoke, to_field};
use super::tree::menu_tree_named;
use super::*;
use super::{Entry, Item, Tokens, Value, VectorcraftApp, widgets};
/// Render the menu bar.
/// The in-window menu bar. Returns where its titles end (x): the bar itself takes the full width.
+1 -1
View File
@@ -1,7 +1,7 @@
use super::bar::render_items;
use super::state::enabled;
use super::tree::{arrange_items, transform_items};
use super::*;
use super::{Item, Sep, Value, VectorcraftApp, c, cp, json, sub, widgets};
/// The canvas context menu (right-click): what applies to the selection, or to the view when
/// nothing is selected. Commands that can't run now are left out rather than greyed out.
+1 -1
View File
@@ -3,7 +3,7 @@ use super::invoke::click_target;
use super::labels::{display_label, dynamic_label, effect_item_label, item_shortcut, unlock_item_label};
use super::state::{shortcut_of, shown_state};
use super::tree::menu_tree_named;
use super::*;
use super::{Entry, Item, Serialize, Value, VectorcraftApp};
/// A menu item as it shows now ([`entry`]).
pub struct Row<'a> {
+154 -139
View File
@@ -1,5 +1,5 @@
use super::state::{SUBJECT_COMMANDS, has_subject, waits_for_ime};
use super::*;
use super::{Value, VectorcraftApp, json};
/// What a menu click runs: items whose label ends with "…" and that carry default params open a
/// generic parameter dialog (fields = the defaults) instead of running immediately.
@@ -119,144 +119,7 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) {
app.ui.dialog = Some(crate::state::Dialog::new(kind, fields));
return;
}
// Envelope Distort: Warp Options, Envelope Mesh and Envelope Options, from the selection.
if crate::dialogs::envelope::opens(id) && p.as_object().is_none_or(|o| o.is_empty()) {
if let Err(e) = crate::dialogs::envelope::open(app, id) {
app.status(e);
}
return;
}
// Variables (data merge): a name and a kind, a name and a row of values.
if crate::dialogs::variables::opens(id) && p.as_object().is_none_or(|o| o.is_empty()) {
crate::dialogs::variables::open(app, id, None);
return;
}
// Pathfinder Options: a dialog with the current options.
if id == "object.pathfinder.options" && p.as_object().is_none_or(|o| o.is_empty()) {
crate::dialogs::pathfinder_options::open(app);
return;
}
// Repeat Options: a dialog with the selected repeat's current values.
if id == "object.repeat.options"
&& p.as_object().is_none_or(|o| o.is_empty())
&& let Some(fields) = crate::panels::pattern_options::repeat_fields(app)
{
let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Repeat Options", "params": fields}));
return;
}
// Area Type Options and Type on a Path Options: dialogs with the selected type's current
// values (the command's query), less those the dialog leaves alone.
if let Some((label, hidden)) = queried_dialog(id)
&& p.as_object().is_none_or(|o| o.is_empty())
{
match app.session.execute(id, &json!({})) {
Ok(mut fields) => {
if let Some(o) = fields.as_object_mut() {
o.retain(|k, _| !hidden.contains(&k.as_str()));
}
let _ = app.run("ui.paramDialog", json!({"command": id, "label": label, "params": fields}));
}
Err(e) => app.status(e.to_string()),
}
return;
}
// Object → Graph → Type… / Data…: dialogs with the selected graph's current values.
if matches!(id, "graph.setType" | "graph.setData") && p.as_object().is_none_or(|o| o.is_empty()) {
if let Err(e) = graph_dialog(app, id) {
app.status(e);
}
return;
}
// Object → Graph → Design… / Column… / Marker…: the document's graph designs.
if matches!(id, "graph.design" | "graph.column" | "graph.marker") && p.as_object().is_none_or(|o| o.is_empty()) {
let r = match id {
"graph.design" => crate::dialogs::graph_design::open_design(app),
"graph.column" => crate::dialogs::graph_design::open_column(app),
_ => crate::dialogs::graph_design::open_marker(app),
};
if let Err(e) = r {
app.status(e);
}
return;
}
// New View…: a name dialog, starting from the first free "View N".
if id == "view.saved.new" && p.as_object().is_none_or(|o| o.is_empty()) {
let name = app.session.active().map(|d| vectorcraft_engine::doc::SavedView::default_name(&d.doc.views)).unwrap_or_default();
let _ = app.run("ui.paramDialog", json!({"command": id, "label": "New View", "params": {"name": name}}));
return;
}
// Edit Views…: the saved views in a list, to rename or delete.
if id == "view.saved.edit" && p.as_object().is_none_or(|o| o.is_empty()) {
if let Err(e) = crate::dialogs::edit_views::open(app) {
app.status(e);
}
return;
}
// Rotate View with no angle, as the command palette runs it, opens a dialog for the angle, with
// the view's angle filled in.
if id == "view.rotate" && p.as_object().is_none_or(|o| o.is_empty()) {
match app.view_mut().map(|v| v.rotation) {
Some(angle) => {
app.ui.dialog = Some(crate::state::Dialog::new("command", json!({"__command": id, "__label": "Rotate View", "angle": angle})));
}
None => app.status("no document"),
}
return;
}
// Save Selection…: a name dialog, starting from the first free "Selection N".
if id == "select.save" && p.as_object().is_none_or(|o| o.is_empty()) {
let name = app.session.active().map(|d| vectorcraft_engine::doc::SavedSelection::default_name(&d.doc.saved_selections)).unwrap_or_default();
let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Save Selection", "params": {"name": name}}));
return;
}
// Edit Selection…: the saved selections in a list, to rename or delete.
if id == "select.editSaved" && p.as_object().is_none_or(|o| o.is_empty()) {
if let Err(e) = crate::dialogs::edit_selection::open(app) {
app.status(e);
}
return;
}
// Document Raster Effects Settings and File Info: their dialogs.
if matches!(id, "document.rasterEffectsSettings" | "file.info") && p.as_object().is_none_or(|o| o.is_empty()) {
let r = if id == "file.info" { crate::dialogs::file_info::open(app) } else { crate::dialogs::raster_effects::open(app) };
if let Err(e) = r {
app.status(e);
}
return;
}
// Rasterize…: its options, starting from the document's raster effects settings.
if id == "object.rasterize" && p.as_object().is_none_or(|o| o.is_empty()) {
match app.session.execute("document.rasterEffectsSettings", &json!({})) {
Ok(v) => {
let params = json!({"ppi": v["resolution"], "colorModel": v["colorModel"], "background": v["background"], "antiAlias": if v["antiAlias"] == true { "art" } else { "none" }, "clippingMask": v["clippingMask"], "addAround": v["addAround"]});
let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Rasterize", "params": params}));
}
Err(e) => app.status(e.to_string()),
}
return;
}
// Text Wrap Options: a dialog with the selected wrap object's current values.
if id == "object.textWrap.options" && p.as_object().is_none_or(|o| o.is_empty()) {
match app.session.execute(id, &json!({})) {
Ok(fields) => {
let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Text Wrap Options", "params": fields}));
}
Err(e) => app.status(e.to_string()),
}
return;
}
// Blend Options…: its dialog, on the selected blend's options.
if id == "object.blend.options" && p.as_object().is_none_or(|o| o.is_empty()) {
if let Err(e) = crate::dialogs::blend_options::open(app) {
app.status(e);
}
return;
}
// Expand…: its dialog.
if id == "object.expand" && p.as_object().is_none_or(|o| o.is_empty()) {
if let Err(e) = crate::dialogs::expand::open(app) {
app.status(e);
}
if open_view_dialog(app, id, &p) || open_object_dialog(app, id, &p) {
return;
}
// Help links: the command returns the URL; a menu click opens it (agents just get the URL).
@@ -277,3 +140,155 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) {
app.ui.open_panel = Some("patternOptions".into());
}
}
/// The dialogs the Envelope, Variables, Pathfinder, Repeat, type, graph and View commands open when called without
/// params; whether `id` was one of them.
fn open_view_dialog(app: &mut VectorcraftApp, id: &str, p: &Value) -> bool {
// Envelope Distort: Warp Options, Envelope Mesh and Envelope Options, from the selection.
if crate::dialogs::envelope::opens(id) && p.as_object().is_none_or(|o| o.is_empty()) {
if let Err(e) = crate::dialogs::envelope::open(app, id) {
app.status(e);
}
return true;
}
// Variables (data merge): a name and a kind, a name and a row of values.
if crate::dialogs::variables::opens(id) && p.as_object().is_none_or(|o| o.is_empty()) {
crate::dialogs::variables::open(app, id, None);
return true;
}
// Pathfinder Options: a dialog with the current options.
if id == "object.pathfinder.options" && p.as_object().is_none_or(|o| o.is_empty()) {
crate::dialogs::pathfinder_options::open(app);
return true;
}
// Repeat Options: a dialog with the selected repeat's current values.
if id == "object.repeat.options"
&& p.as_object().is_none_or(|o| o.is_empty())
&& let Some(fields) = crate::panels::pattern_options::repeat_fields(app)
{
let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Repeat Options", "params": fields}));
return true;
}
// Area Type Options and Type on a Path Options: dialogs with the selected type's current
// values (the command's query), less those the dialog leaves alone.
if let Some((label, hidden)) = queried_dialog(id)
&& p.as_object().is_none_or(|o| o.is_empty())
{
match app.session.execute(id, &json!({})) {
Ok(mut fields) => {
if let Some(o) = fields.as_object_mut() {
o.retain(|k, _| !hidden.contains(&k.as_str()));
}
let _ = app.run("ui.paramDialog", json!({"command": id, "label": label, "params": fields}));
}
Err(e) => app.status(e.to_string()),
}
return true;
}
// Object → Graph → Type… / Data…: dialogs with the selected graph's current values.
if matches!(id, "graph.setType" | "graph.setData") && p.as_object().is_none_or(|o| o.is_empty()) {
if let Err(e) = graph_dialog(app, id) {
app.status(e);
}
return true;
}
// Object → Graph → Design… / Column… / Marker…: the document's graph designs.
if matches!(id, "graph.design" | "graph.column" | "graph.marker") && p.as_object().is_none_or(|o| o.is_empty()) {
let r = match id {
"graph.design" => crate::dialogs::graph_design::open_design(app),
"graph.column" => crate::dialogs::graph_design::open_column(app),
_ => crate::dialogs::graph_design::open_marker(app),
};
if let Err(e) = r {
app.status(e);
}
return true;
}
// New View…: a name dialog, starting from the first free "View N".
if id == "view.saved.new" && p.as_object().is_none_or(|o| o.is_empty()) {
let name = app.session.active().map(|d| vectorcraft_engine::doc::SavedView::default_name(&d.doc.views)).unwrap_or_default();
let _ = app.run("ui.paramDialog", json!({"command": id, "label": "New View", "params": {"name": name}}));
return true;
}
// Edit Views…: the saved views in a list, to rename or delete.
if id == "view.saved.edit" && p.as_object().is_none_or(|o| o.is_empty()) {
if let Err(e) = crate::dialogs::edit_views::open(app) {
app.status(e);
}
return true;
}
// Rotate View with no angle, as the command palette runs it, opens a dialog for the angle, with
// the view's angle filled in.
if id == "view.rotate" && p.as_object().is_none_or(|o| o.is_empty()) {
match app.view_mut().map(|v| v.rotation) {
Some(angle) => {
app.ui.dialog = Some(crate::state::Dialog::new("command", json!({"__command": id, "__label": "Rotate View", "angle": angle})));
}
None => app.status("no document"),
}
return true;
}
false
}
/// The dialogs the selection, raster, wrap, blend and Expand commands open when called without params; whether `id`
/// was one of them.
fn open_object_dialog(app: &mut VectorcraftApp, id: &str, p: &Value) -> bool {
// Save Selection…: a name dialog, starting from the first free "Selection N".
if id == "select.save" && p.as_object().is_none_or(|o| o.is_empty()) {
let name = app.session.active().map(|d| vectorcraft_engine::doc::SavedSelection::default_name(&d.doc.saved_selections)).unwrap_or_default();
let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Save Selection", "params": {"name": name}}));
return true;
}
// Edit Selection…: the saved selections in a list, to rename or delete.
if id == "select.editSaved" && p.as_object().is_none_or(|o| o.is_empty()) {
if let Err(e) = crate::dialogs::edit_selection::open(app) {
app.status(e);
}
return true;
}
// Document Raster Effects Settings and File Info: their dialogs.
if matches!(id, "document.rasterEffectsSettings" | "file.info") && p.as_object().is_none_or(|o| o.is_empty()) {
let r = if id == "file.info" { crate::dialogs::file_info::open(app) } else { crate::dialogs::raster_effects::open(app) };
if let Err(e) = r {
app.status(e);
}
return true;
}
// Rasterize…: its options, starting from the document's raster effects settings.
if id == "object.rasterize" && p.as_object().is_none_or(|o| o.is_empty()) {
match app.session.execute("document.rasterEffectsSettings", &json!({})) {
Ok(v) => {
let params = json!({"ppi": v["resolution"], "colorModel": v["colorModel"], "background": v["background"], "antiAlias": if v["antiAlias"] == true { "art" } else { "none" }, "clippingMask": v["clippingMask"], "addAround": v["addAround"]});
let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Rasterize", "params": params}));
}
Err(e) => app.status(e.to_string()),
}
return true;
}
// Text Wrap Options: a dialog with the selected wrap object's current values.
if id == "object.textWrap.options" && p.as_object().is_none_or(|o| o.is_empty()) {
match app.session.execute(id, &json!({})) {
Ok(fields) => {
let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Text Wrap Options", "params": fields}));
}
Err(e) => app.status(e.to_string()),
}
return true;
}
// Blend Options…: its dialog, on the selected blend's options.
if id == "object.blend.options" && p.as_object().is_none_or(|o| o.is_empty()) {
if let Err(e) = crate::dialogs::blend_options::open(app) {
app.status(e);
}
return true;
}
// Expand…: its dialog.
if id == "object.expand" && p.as_object().is_none_or(|o| o.is_empty()) {
if let Err(e) = crate::dialogs::expand::open(app) {
app.status(e);
}
return true;
}
false
}
+64 -29
View File
@@ -2,12 +2,21 @@ use super::slots::{perspective_grid, recent_slot, saved_selection_name, saved_se
use super::state::{hidden_when_disabled, shortcut_of};
use super::tree::menu_tree;
use super::ui_commands::UI_COMMANDS;
use super::*;
use super::{Item, Value, VectorcraftApp};
/// Label for toggles whose text flips (Outline/Preview, Hide/Show …).
pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String {
view_toggle_label(app, id)
.or_else(|| window_toggle_label(app, id))
.or_else(|| slot_label(app, id))
.or_else(|| command_label(app, id, label))
.unwrap_or_else(|| label.into())
}
/// The View menu items that flip between Hide and Show (edges, template, threads, …).
fn view_toggle_label(app: &VectorcraftApp, id: &str) -> Option<String> {
let v = &app.ui.view;
match id {
let text: String = match id {
"view.outline" => if v.outline { "Preview" } else { "Outline" }.into(),
"window.workspace.reset" => format!("Reset {}", app.ui.workspace),
"view.edges" => if v.edges { "Hide Edges" } else { "Show Edges" }.into(),
@@ -17,16 +26,15 @@ pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String {
"type.hiddenCharacters" => if v.hidden_chars { "Hide Hidden Characters" } else { "Show Hidden Characters" }.into(),
"view.gradientAnnotator" => if v.gradient_annotator { "Hide Gradient Annotator" } else { "Show Gradient Annotator" }.into(),
"view.livePaintGaps" => if v.live_paint_gaps { "Hide Live Paint Gaps" } else { "Show Live Paint Gaps" }.into(),
"effect.last" => match &app.last_effect {
Some((e, _)) => format!("Last Effect: {}", vectorcraft_effects::effect_info(e).map(|i| i.label).unwrap_or(e.as_str())),
None => label.into(),
},
id if id.starts_with("type.recentFont") => {
let n: usize = id["type.recentFont".len()..].parse().unwrap_or(0);
n.checked_sub(1).and_then(|i| app.recent_fonts().get(i)).cloned().unwrap_or_else(|| "—".into())
}
id if saved_view_slot(id).is_some() => saved_view(app, id).map(|v| v.name.clone()).unwrap_or_else(|| "—".into()),
id if saved_selection_slot(id).is_some() => saved_selection_name(app, id).unwrap_or_else(|| "—".into()),
_ => return None,
};
Some(text)
}
/// The rulers, guides, grid and print tiling items that flip between Hide and Show.
fn window_toggle_label(app: &VectorcraftApp, id: &str) -> Option<String> {
let v = &app.ui.view;
let text: String = match id {
"view.artboards" => if v.artboards { "Hide Artboards" } else { "Show Artboards" }.into(),
"view.rulers" => if v.rulers { "Hide Rulers" } else { "Show Rulers" }.into(),
"view.videoRulers" => if v.video_rulers && v.rulers { "Hide Video Rulers" } else { "Show Video Rulers" }.into(),
@@ -45,9 +53,50 @@ pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String {
"view.guides.lock" => if app.session.guides_locked() { "Unlock Guides" } else { "Lock Guides" }.into(),
"view.slices.hide" => if app.session.slices_hidden() { "Show Slices" } else { "Hide Slices" }.into(),
"view.printTiling" => if app.session.active().is_some_and(|d| d.print_tiling) { "Hide Print Tiling" } else { "Show Print Tiling" }.into(),
_ => return None,
};
Some(text)
}
/// The menu slots that show a name: recent fonts, saved views and selections, recent files, libraries and presets.
fn slot_label(app: &VectorcraftApp, id: &str) -> Option<String> {
let text: String = match id {
id if id.starts_with("type.recentFont") => {
let n: usize = id["type.recentFont".len()..].parse().unwrap_or(0);
n.checked_sub(1).and_then(|i| app.recent_fonts().get(i)).cloned().unwrap_or_else(|| "—".into())
}
id if saved_view_slot(id).is_some() => saved_view(app, id).map(|v| v.name.clone()).unwrap_or_else(|| "—".into()),
id if saved_selection_slot(id).is_some() => saved_selection_name(app, id).unwrap_or_else(|| "—".into()),
id if id.starts_with("file.openRecent") => recent_slot(app, id)
.map(|p| std::path::Path::new(p).file_name().map_or(p.clone(), |f| f.to_string_lossy().to_string()))
.unwrap_or_else(|| "—".into()),
id if id.starts_with(crate::panels::swatches::USER_SLOT) => {
crate::panels::swatches::user_library(app, id).map_or_else(|| "—".into(), |l| l.name)
}
id if id.starts_with(crate::panels::graphic_styles::USER_SLOT) => {
crate::panels::graphic_styles::user_library(app, id).map_or_else(|| "—".into(), |l| l.name)
}
id if id.starts_with(crate::panels::symbols::USER_SLOT) => {
crate::panels::symbols::user_library(app, id).map_or_else(|| "—".into(), |l| l.name)
}
id if id.starts_with(crate::panels::brushes::USER_SLOT) => {
crate::panels::brushes::user_library(app, id).map_or_else(|| "—".into(), |l| l.name)
}
id if id.starts_with(crate::dialogs::perspective_presets::SLOT) => {
crate::dialogs::perspective_presets::slot_preset(app, id).unwrap_or_else(|| "—".into())
}
_ => return None,
};
Some(text)
}
/// The labels that follow the last effect, the envelope being edited, the history and the perspective grid.
fn command_label(app: &VectorcraftApp, id: &str, label: &str) -> Option<String> {
let text: String = match id {
"effect.last" => match &app.last_effect {
Some((e, _)) => format!("Last Effect: {}", vectorcraft_effects::effect_info(e).map(|i| i.label).unwrap_or(e.as_str())),
None => label.into(),
},
// Edit Contents reads Edit Envelope while an envelope's contents are being edited.
"object.envelope.editContents" => {
let editing = app.session.active().is_some_and(|st| {
@@ -64,26 +113,12 @@ pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String {
}
"edit.undo" => app.session.active().and_then(|d| d.history.undo.last()).map(|h| format!("Undo {}", h.label)).unwrap_or_else(|| "Undo".into()),
"edit.redo" => app.session.active().and_then(|d| d.history.redo.last()).map(|h| format!("Redo {}", h.label)).unwrap_or_else(|| "Redo".into()),
id if id.starts_with(crate::panels::swatches::USER_SLOT) => {
crate::panels::swatches::user_library(app, id).map_or_else(|| "—".into(), |l| l.name)
}
id if id.starts_with(crate::panels::graphic_styles::USER_SLOT) => {
crate::panels::graphic_styles::user_library(app, id).map_or_else(|| "—".into(), |l| l.name)
}
id if id.starts_with(crate::panels::symbols::USER_SLOT) => {
crate::panels::symbols::user_library(app, id).map_or_else(|| "—".into(), |l| l.name)
}
id if id.starts_with(crate::panels::brushes::USER_SLOT) => {
crate::panels::brushes::user_library(app, id).map_or_else(|| "—".into(), |l| l.name)
}
id if id.starts_with(crate::dialogs::perspective_presets::SLOT) => {
crate::dialogs::perspective_presets::slot_preset(app, id).unwrap_or_else(|| "—".into())
}
"perspective.grid.show" => if perspective_grid(app).is_some_and(|g| g.visible) { "Hide Grid" } else { "Show Grid" }.into(),
"perspective.grid.rulers" => if perspective_grid(app).is_some_and(|g| g.rulers) { "Hide Rulers" } else { "Show Rulers" }.into(),
"perspective.grid.lock" => if perspective_grid(app).is_some_and(|g| g.locked) { "Unlock Grid" } else { "Lock Grid" }.into(),
_ => label.into(),
}
_ => return None,
};
Some(text)
}
/// Does the menu item `id` show a name that is user, file or system data rather than an interface
+145 -5
View File
@@ -3,7 +3,7 @@ use super::invoke::menu_dialog;
use super::slots::{recent_slot, saved_selection_name, saved_selection_slot, saved_view, saved_view_slot};
use super::state::{checked, selected_image};
use super::ui_commands::UI_COMMANDS;
use super::*;
use super::{Brightness, Face, SaveMode, Value, VectorcraftApp, io, json, next_zoom};
/// Canonical panel id for `window.panel`: a dock tab's or an icon panel's id or display label,
/// matched case-insensitively (`"Layers"`, `"swatches"`, `"Color Guide"`), or a library panel's id
@@ -74,11 +74,24 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
{
return Some(r);
}
run_ui_part1(app, id, p)
.or_else(|| run_ui_part2(app, id, p))
.or_else(|| run_ui_part3(app, id, p))
.or_else(|| run_ui_part4(app, id, p))
.or_else(|| run_ui_part5(app, id, p))
.or_else(|| run_ui_part6(app, id, p))
.or_else(|| run_ui_part7(app, id, p))
.or_else(|| run_ui_part8(app, id, p))
.or_else(|| run_ui_part9(app, id, p))
.or_else(|| run_ui_part10(app, id, p))
.or_else(|| run_ui_part11(app, id, p))
.or_else(|| run_ui_part12(app, id, p))
.or_else(|| run_ui_part13(app, id, p))
}
/// UI commands, part 1.
fn run_ui_part1(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string);
let flag = |b: &mut bool| {
*b = !*b;
Ok(json!(*b))
};
let r = match id {
"file.newDialog" => {
crate::dialogs::open_new_document(app);
@@ -116,6 +129,19 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
crate::find_font::open(app);
Ok(Value::Null)
}
_ => return None,
};
Some(r)
}
/// UI commands, part 2.
fn run_ui_part2(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string);
let flag = |b: &mut bool| {
*b = !*b;
Ok(json!(*b))
};
let r = match id {
"ui.missingFontsDialog" => crate::dialogs::missing_fonts::open_command(app, s("folder")),
"ui.findFontsInFolder" => crate::dialogs::missing_fonts::find_in_folder_command(app, s("folder")),
"file.clearRecent" => {
@@ -156,6 +182,18 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
app.session.execute(id, p).map_err(|e| e.to_string())
}
"view.outline" => flag(&mut app.ui.view.outline),
_ => return None,
};
Some(r)
}
/// UI commands, part 3.
fn run_ui_part3(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
let flag = |b: &mut bool| {
*b = !*b;
Ok(json!(*b))
};
let r = match id {
"view.pixelPreview" => flag(&mut app.ui.view.pixel_preview),
"view.trimView" => flag(&mut app.ui.view.trim_view),
"view.cornerWidget" => flag(&mut app.ui.view.corner_widgets),
@@ -203,6 +241,18 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
}
"type.applyBullets" => crate::panels::paragraph::apply_bullets(app, p),
"type.applyNumbers" => crate::panels::paragraph::apply_numbers(app, p),
_ => return None,
};
Some(r)
}
/// UI commands, part 4.
fn run_ui_part4(app: &mut VectorcraftApp, id: &str, _p: &Value) -> Option<Result<Value, String>> {
let flag = |b: &mut bool| {
*b = !*b;
Ok(json!(*b))
};
let r = match id {
"type.convertListToText" => crate::panels::paragraph::convert_list_to_text(app),
id if let Some(b) = crate::panels::align::button(id) => crate::panels::align::run_button(app, b),
"view.gradientAnnotator" => flag(&mut app.ui.view.gradient_annotator),
@@ -259,6 +309,19 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
"view.smartGuides" => flag(&mut app.ui.view.smart_guides),
"view.grid" => flag(&mut app.ui.view.grid),
"view.snapToGrid" => flag(&mut app.ui.view.snap_to_grid),
_ => return None,
};
Some(r)
}
/// UI commands, part 5.
fn run_ui_part5(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string);
let flag = |b: &mut bool| {
*b = !*b;
Ok(json!(*b))
};
let r = match id {
"view.snapToPoint" => flag(&mut app.ui.view.snap_to_point),
"view.zoomIn" | "view.zoomOut" => {
let up = id == "view.zoomIn";
@@ -370,6 +433,19 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
let Some(tool) = s("tool") else { return Some(Err("tool (a tool id) is required".into())) };
crate::toolbar::float_group(app, &tool, floating).map(Value::Bool)
}
_ => return None,
};
Some(r)
}
/// UI commands, part 6.
fn run_ui_part6(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string);
let flag = |b: &mut bool| {
*b = !*b;
Ok(json!(*b))
};
let r = match id {
"window.taskBar" => flag(&mut app.ui.task_bar),
"window.taskBar.pin" => {
let place = &mut app.ui.task_bar_place;
@@ -462,6 +538,15 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
},
"file.export.pdf" if p.as_object().is_none_or(|o| o.is_empty()) => crate::dialogs::open_save_pdf(app, &json!({})),
"file.export.pdf" => io::export_pdf(app, p.clone()),
_ => return None,
};
Some(r)
}
/// UI commands, part 7.
fn run_ui_part7(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string);
let r = match id {
"help.about" => {
app.ui.about = true;
Ok(Value::Null)
@@ -506,6 +591,15 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
"ui.newBrush" => crate::dialogs::new_brush::open(app, p.get("ids").cloned()),
"ui.brushOptions" => crate::dialogs::brush_options::open(app, s("name").as_deref()),
"tool.options" => crate::toolbar::open_options(app, &s("tool").unwrap_or_default()),
_ => return None,
};
Some(r)
}
/// UI commands, part 8.
fn run_ui_part8(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string);
let r = match id {
"ui.colorGuideOptions" => {
crate::dialogs::color_guide_options::open(app);
Ok(Value::Null)
@@ -543,6 +637,15 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
crate::dialogs::flatten::open(app);
Ok(Value::Null)
}
_ => return None,
};
Some(r)
}
/// UI commands, part 9.
fn run_ui_part9(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string);
let r = match id {
"ui.flattenerPresetsDialog" => {
crate::dialogs::flattener_presets::open(app, s("selected").as_deref());
Ok(Value::Null)
@@ -580,6 +683,15 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
Some(l) => crate::panels::brushes::open_library(app, &json!({ "library": l.id })),
None => Err("no such user library".into()),
},
_ => return None,
};
Some(r)
}
/// UI commands, part 10.
fn run_ui_part10(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string);
let r = match id {
"ui.expandDialog" => crate::dialogs::expand::open(app),
"ui.simplifyAuto" => crate::dialogs::simplify_slider::auto(app),
"ui.simplifyMoreOptions" => crate::dialogs::simplify_slider::more_options(app),
@@ -608,6 +720,15 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
Ok(Value::Null)
}
"file.exportAs" => io::export(app, s("format").as_deref(), s("path"), p),
_ => return None,
};
Some(r)
}
/// UI commands, part 11.
fn run_ui_part11(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string);
let r = match id {
"ui.fileInfoDialog" => crate::dialogs::file_info::open(app),
"ui.rasterEffectsSettingsDialog" => crate::dialogs::raster_effects::open(app),
"ui.effectGalleryDialog" => crate::dialogs::effect_gallery::open(app, p),
@@ -637,6 +758,15 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
crate::dialogs::action_batch::open(app, set, action)
}
"ui.shortcutImportDialog" => crate::dialogs::shortcut_import::command(app, p),
_ => return None,
};
Some(r)
}
/// UI commands, part 12.
fn run_ui_part12(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string);
let r = match id {
"file.showPackage" => crate::dialogs::package::show_package(app, p),
"docInfo.save" => crate::panels::doc_info::save_report(app, p),
// Slice Options… and Divide Slices…: their dialogs, on the selected slices.
@@ -673,6 +803,15 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
None => Err("printing isn't available here".into()),
}
}
_ => return None,
};
Some(r)
}
/// UI commands, part 13.
fn run_ui_part13(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string);
let r = match id {
"ui.printPresetsDialog" => {
crate::dialogs::print_presets::open(app, s("selected").as_deref());
Ok(Value::Null)
@@ -696,6 +835,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
"ui.blendOptions" => crate::dialogs::blend_options::open(app),
"ui.livePaintGapOptions" => crate::dialogs::live_paint_gaps::open(app),
"ui.perspectivePlane" => crate::dialogs::perspective_plane::open(app, p),
_ => return None,
};
Some(r)
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{VectorcraftApp, io};
/// The active document's perspective grid (View → Perspective Grid toggles).
pub(super) fn perspective_grid(app: &VectorcraftApp) -> Option<vectorcraft_tools::distort::perspective::PerspectiveGrid> {
+1 -1
View File
@@ -1,6 +1,6 @@
use super::run::normalize_panel;
use super::slots::{perspective_grid, recent_slot, saved_selection_name, saved_selection_slot, saved_view, saved_view_slot};
use super::*;
use super::{Brightness, Value, VectorcraftApp};
/// Checked state for toggle items.
pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option<bool> {
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Item, Sep, c, cp, json, sub, todo};
/// Type → Size presets.
pub(super) const TYPE_SIZES: [(&str, u32); 14] = [
+1 -1
View File
@@ -1,6 +1,6 @@
use super::slots::{PERSPECTIVE_SLOTS, RECENT_FONT_IDS, RECENT_IDS, SAVED_SELECTION_IDS, SAVED_VIEW_IDS};
use super::submenus::{INSERT_BREAK, INSERT_SPECIAL, INSERT_WHITESPACE, PATH_EFFECTS, TYPE_SIZES, effect_menu, font_items, insert_items};
use super::*;
use super::{Item, Sep, c, cp, json, sub, todo, todos};
/// The in-window and native menu tree. The in-window bar skips the VectorCraft menu (see
/// `menu_bar`); the native Mac bar is composed from the whole tree (see `native_menu`).
+57 -23
View File
@@ -568,12 +568,8 @@ fn open_popover(app: &mut VectorcraftApp, i: usize, at: Pos2) {
app.ui.dialog = Some(Dialog::new("gradientStop", json!({ "index": i, "screen": [at.x, at.y], "tab": "color" })));
}
/// The gradient slider: ramp, stops below, midpoint diamonds above.
fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) {
let t = Tokens::get(ui.ctx());
let w = ui.available_width();
let (area, area_resp) = ui.allocate_exact_size(vec2(w, 50.0), Sense::hover());
let bar = Rect::from_min_size(area.min + vec2(8.0, 10.0), vec2(w - 16.0, 18.0));
/// The ramp's colours over a checkerboard, which shows the opacity.
fn paint_ramp(ui: &Ui, g: &Gradient, bar: Rect) {
// Checkerboard under the ramp (opacity).
let cell = 6.0;
let mut y = bar.top();
@@ -599,6 +595,42 @@ fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) {
Rect::from_min_size(pos2(bar.left() + i as f32 * bar.width() / n as f32, bar.top()), vec2(bar.width() / n as f32 + 0.5, bar.height()));
ui.painter().rect_filled(rr, 0.0, Color32::from_rgba_unmultiplied(r, gg, b, a));
}
}
/// A midpoint diamond centred at `c`.
fn paint_diamond(ui: &Ui, t: &Tokens, c: Pos2, lit: bool) {
let pts = vec![c + vec2(0.0, -4.0), c + vec2(4.0, 0.0), c + vec2(0.0, 4.0), c + vec2(-4.0, 0.0)];
ui.painter().add(egui::Shape::convex_polygon(pts, if lit { Color32::WHITE } else { t.icon }, Stroke::new(1.0, t.border)));
}
/// A house-shaped stop marker at `(x, top)`, with a chip of the stop's colour.
fn paint_stop(ui: &Ui, t: &Tokens, (x, top): (f32, f32), (selected, outline): (bool, Color32), color: &vectorcraft_color::Color) {
let body = vec![pos2(x, top), pos2(x + 6.0, top + 5.0), pos2(x + 6.0, top + 15.0), pos2(x - 6.0, top + 15.0), pos2(x - 6.0, top + 5.0)];
ui.painter().add(egui::Shape::convex_polygon(body, if selected { t.text_strong } else { t.icon }, Stroke::new(1.0, outline)));
let chip = Rect::from_min_size(pos2(x - 4.0, top + 6.0), vec2(8.0, 7.0));
ui.painter().rect_filled(chip, 0.0, super::c32(color));
}
/// The stops after a colour swatch was dropped on the ramp `bar`, and the stop to select.
fn dropped_swatch(app: &VectorcraftApp, ui: &Ui, g: &Gradient, area_resp: &egui::Response, bar: Rect) -> Option<(Vec<GradientStop>, usize)> {
let marker = |o: f32| Rect::from_min_size(pos2(bar.left() + o * bar.width() - 6.0, bar.bottom() + 2.0), vec2(12.0, 16.0));
let d = area_resp.dnd_release_payload::<PanelDrag>()?;
let c = d.color()?;
let p = ui.input(|i| i.pointer.latest_pos())?;
let link = match &*d {
PanelDrag::Paint { params, .. } => dropped_link(app, params),
_ => None,
};
Some(drop_color(g, g.stops.iter().position(|s| marker(s.offset).contains(p)), x_to_offset(p.x, bar.left(), bar.width()), c, link))
}
/// The gradient slider: ramp, stops below, midpoint diamonds above.
fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) {
let t = Tokens::get(ui.ctx());
let w = ui.available_width();
let (area, area_resp) = ui.allocate_exact_size(vec2(w, 50.0), Sense::hover());
let bar = Rect::from_min_size(area.min + vec2(8.0, 10.0), vec2(w - 16.0, 18.0));
paint_ramp(ui, g, bar);
// A colour swatch held over the ramp: outline it as a drop target.
let droppable = is_grad && area_resp.dnd_hover_payload::<PanelDrag>().is_some_and(|d| d.color().is_some());
let edge = if droppable { Stroke::new(2.0, t.accent) } else { Stroke::new(1.0, t.border) };
@@ -616,7 +648,7 @@ fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) {
let origin = if origin.is_empty() { stops.clone() } else { origin };
// The stop of `origin` other than `i` whose marker is under `p`.
let stop_under = |p: Pos2, i: usize| origin.iter().enumerate().position(|(j, s)| j != i && marker(s.offset).contains(p));
let mut changed: Option<(Vec<GradientStop>, Live)> = None;
let mut changed: Option<StopEdit> = None;
// The stop to select once `changed` is applied.
let mut select: Option<usize> = None;
let mut pick_mid: Option<usize> = None;
@@ -645,8 +677,7 @@ fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) {
let rect = Rect::from_center_size(c, vec2(10.0, 10.0));
let resp = ui.interact(rect, ui.scope_id().with(("grad-mid", i)), Sense::click_and_drag());
let lit = drag == Drag::Mid(i) || mid_sel == Some(i) || resp.hovered();
let pts = vec![c + vec2(0.0, -4.0), c + vec2(4.0, 0.0), c + vec2(0.0, 4.0), c + vec2(-4.0, 0.0)];
ui.painter().add(egui::Shape::convex_polygon(pts, if lit { Color32::WHITE } else { t.icon }, Stroke::new(1.0, t.border)));
paint_diamond(ui, &t, c, lit);
if !is_grad {
continue;
}
@@ -683,11 +714,8 @@ fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) {
let off_ramp = |p: Pos2| !copy && p.y > bar.bottom() + REMOVE_DISTANCE && origin.len() > MIN_STOPS;
let off = dragging_this && resp.interact_pointer_pos().is_some_and(off_ramp);
let outline = if sel == Some(i) { t.accent } else { t.border };
let body = vec![pos2(x, top), pos2(x + 6.0, top + 5.0), pos2(x + 6.0, top + 15.0), pos2(x - 6.0, top + 15.0), pos2(x - 6.0, top + 5.0)];
if !off {
ui.painter().add(egui::Shape::convex_polygon(body, if sel == Some(i) { t.text_strong } else { t.icon }, Stroke::new(1.0, outline)));
let chip = Rect::from_min_size(pos2(x - 4.0, top + 6.0), vec2(8.0, 7.0));
ui.painter().rect_filled(chip, 0.0, super::c32(&s.color));
paint_stop(ui, &t, (x, top), (sel == Some(i), outline), &s.color);
}
if !is_grad {
continue;
@@ -727,20 +755,26 @@ fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) {
}
}
// A colour swatch dropped on the ramp.
if is_grad
&& let Some(d) = area_resp.dnd_release_payload::<PanelDrag>()
&& let Some(c) = d.color()
&& let Some(p) = ui.input(|i| i.pointer.latest_pos())
{
let link = match &*d {
PanelDrag::Paint { params, .. } => dropped_link(app, params),
_ => None,
};
let (v, i) = drop_color(g, stops.iter().position(|s| marker(s.offset).contains(p)), offset_at(p), c, link);
if is_grad && let Some((v, i)) = dropped_swatch(app, ui, g, &area_resp, bar) {
select = Some(i);
changed = Some((v, Live::Released));
}
set_pstate(ui.ctx(), "grad-drag", drag);
apply_ramp_edit(app, ui, sel, (pick_mid, select), changed, popover);
}
/// New stops and how live the edit is.
type StopEdit = (Vec<GradientStop>, Live);
/// What the ramp's gestures this frame asked for: the midpoint or stop to select, the new stops, the stop popover.
fn apply_ramp_edit(
app: &mut VectorcraftApp,
ui: &Ui,
sel: Option<usize>,
(pick_mid, select): (Option<usize>, Option<usize>),
changed: Option<StopEdit>,
popover: Option<(usize, Pos2)>,
) {
if let Some(i) = pick_mid {
set_pstate(ui.ctx(), MID_SELECTED, Some((i, first_id(app))));
if sel.is_some() {
+17 -10
View File
@@ -319,16 +319,9 @@ fn slider(ui: &mut Ui, label: &str, v: &mut f64, range: std::ops::RangeInclusive
out
}
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let all = presets(app);
let mut st: TraceUi = pstate(ui.ctx(), "image-trace");
if st.params.is_null() {
st.preset = "Default".into();
st.params = all.first().map(|p| p.1.clone()).unwrap_or_default();
}
let (is_trace, is_image, stored) = target(app);
let any_trace = any_trace(app);
let object = target_key(app);
/// Bring the panel's settings `st` in line with the selected object `object`, whose trace settings are `stored`;
/// whether the object came back with the settings that waited for it.
fn sync_with_object(st: &mut TraceUi, stored: Option<Value>, object: Option<(u64, NodeId)>) -> bool {
let same = |a: &Value, b: &Value| a["preset"] == b["preset"] && a["params"] == b["params"];
let mut restored = false;
// Another object: the settings that wait for the one left are kept until it is selected again,
@@ -366,6 +359,20 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
st.synced = t;
st.object = object;
}
restored
}
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let all = presets(app);
let mut st: TraceUi = pstate(ui.ctx(), "image-trace");
if st.params.is_null() {
st.preset = "Default".into();
st.params = all.first().map(|p| p.1.clone()).unwrap_or_default();
}
let (is_trace, is_image, stored) = target(app);
let any_trace = any_trace(app);
let object = target_key(app);
let restored = sync_with_object(&mut st, stored, object);
let mut retrace = false;
ui.horizontal(|ui| {
+4 -1
View File
@@ -1,5 +1,8 @@
use super::model::{ObjectKind, RowState, names, parse_names, toggled};
use super::*;
use super::{
Document, HashSet, LayersDrag, Node, NodeId, NodeKind, PanelDrag, Sense, Stroke, StrokeKind, Ui, Value, VectorcraftApp, View, json,
last_selected, see_selection, vec2, widgets,
};
/// The Filter button, lit while a filter is on, and its popup: Clear All, the object kinds under
/// By Object and the row states under By Layer, each ticked or not. A tick, or Clear All, runs
+1 -1
View File
@@ -1,5 +1,5 @@
use super::rows::key;
use super::*;
use super::{Arc, Color32, Document, LiveShape, Node, NodeKind, OpenRows, PanelOptions, Ui, Value, VectorcraftApp};
/// Where a dragged row goes relative to the row it is dropped on.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+42 -28
View File
@@ -1,7 +1,10 @@
use super::controls::collect_for_export_menu;
use super::model::{Place, Row, opens, painted_name};
use super::thumbs::{node_arc, real_thumb, thumb};
use super::*;
use super::{
Arc, COLUMN, Color32, ColumnDrag, Document, INDENT, LayersDrag, Node, NodeId, NodeKind, Out, PanelDrag, SQUARE_X, Sense, Stroke, StrokeKind,
TARGET_X, Tokens, Ui, Value, View, icons, json, vec2, widgets,
};
/// Stable id for the layers panel's per-name UI state.
pub(super) fn key(name: &str) -> egui::Id {
@@ -162,6 +165,42 @@ fn guide_drop(ui: &Ui, view: &View, guides: &[usize], layer: NodeId, r: egui::Re
}
}
/// The row's fill (selected, current or hovered), its bottom edge and the current layer's marker.
fn row_background(ui: &Ui, view: &View, n: &Node, r: egui::Rect, resp: &egui::Response) {
let t = &view.t;
// Highlighted rows (else the current layer's).
let highlighted = if view.rows.is_empty() { n.is_layer() && Some(n.id) == view.current } else { view.rows.contains(&n.id) };
if highlighted {
ui.painter().rect_filled(r, 0.0, t.row_selected);
} else if resp.hovered() {
ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.6));
}
ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.input_border));
if n.is_layer() && Some(n.id) == view.current {
// Current-layer marker: a small triangle in the top-right corner.
let c = r.right_top();
ui.painter().add(egui::Shape::convex_polygon(vec![c, c + vec2(-6.0, 0.0), c + vec2(0.0, 6.0)], t.text, Stroke::NONE));
}
}
/// The disclosure triangle of a row at `(r, x)`; Alt opens or closes everything inside too.
fn disclosure(ui: &Ui, view: &View, n: &Node, (r, x): (egui::Rect, f32), open: bool, out: &mut Out) {
let t = &view.t;
let dr = egui::Rect::from_min_size(egui::pos2(x, r.center().y - 8.0), vec2(14.0, 16.0));
let dresp = ui.interact(dr, ui.scope_id().with(("disc", n.id.0)), Sense::click());
icons::paint(ui, if open { "chevron-down" } else { "chevron-right" }, dr, t.text_dim);
if dresp.clicked() {
out.toggle.push((n.id, !open));
if ui.input(|i| i.modifiers.alt) {
n.walk(&mut |c| {
if c.id != n.id && opens(c, &view.opts) {
out.toggle.push((c.id, !open));
}
});
}
}
}
/// The widgets of row `item`.
pub(super) fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) {
if let Some(i) = item.guide {
@@ -183,39 +222,14 @@ pub(super) fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) {
n.walk(&mut |c| any |= c.id != n.id && view.sel.contains(&c.id));
any
});
// Highlighted rows (else the current layer's).
let highlighted = if view.rows.is_empty() { n.is_layer() && Some(n.id) == view.current } else { view.rows.contains(&n.id) };
if highlighted {
ui.painter().rect_filled(r, 0.0, t.row_selected);
} else if resp.hovered() {
ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.6));
}
ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.input_border));
if n.is_layer() && Some(n.id) == view.current {
// Current-layer marker: a small triangle in the top-right corner.
let c = r.right_top();
ui.painter().add(egui::Shape::convex_polygon(vec![c, c + vec2(-6.0, 0.0), c + vec2(0.0, 6.0)], t.text, Stroke::NONE));
}
row_background(ui, view, n, r, &resp);
eye_and_lock(ui, view, n, r, out);
// Layer colour bar (every row shows the colour of its layer).
let mut x = r.left() + 2.0 * COLUMN + 2.0;
colour_bar(ui, n, r, x, color);
x += 6.0 + depth as f32 * INDENT;
// Disclosure triangle; Alt opens or closes everything inside too.
if has_children {
let dr = egui::Rect::from_min_size(egui::pos2(x, r.center().y - 8.0), vec2(14.0, 16.0));
let dresp = ui.interact(dr, ui.scope_id().with(("disc", n.id.0)), Sense::click());
icons::paint(ui, if open { "chevron-down" } else { "chevron-right" }, dr, t.text_dim);
if dresp.clicked() {
out.toggle.push((n.id, !open));
if ui.input(|i| i.modifiers.alt) {
n.walk(&mut |c| {
if c.id != n.id && opens(c, &view.opts) {
out.toggle.push((c.id, !open));
}
});
}
}
disclosure(ui, view, n, (r, x), open, out);
}
x += 16.0;
// Thumbnail.
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Arc, Color32, Document, HashMap, Node, NodeId, Settle, ThumbWorker, Ui, VectorcraftApp, Weak, vec2};
/// Where row thumbnails come from: document `uid` at `revision`, and whether a drag is in
/// progress (the thumbnails shown then stay until it ends).
+37 -20
View File
@@ -828,6 +828,20 @@ fn artboard_history_key(app: &mut VectorcraftApp, ctx: &egui::Context, key: egui
}
}
/// Duplicate the artboards, or add a new one, the way the test's history step needs.
fn add_board(app: &mut crate::VectorcraftApp, ctx: &egui::Context, duplicate: bool) {
if duplicate {
let commands = app.run_count;
artboard_deletion_click(app, ctx, super::super::artboards::menu, Some("Duplicate Artboards"), egui::Modifiers::NONE);
assert_eq!(app.run_count, commands.wrapping_add(1));
assert_eq!(app.session.doc().unwrap().doc.artboards.len(), 3);
assert_eq!(app.session.tool_options()["active"], json!(2));
assert_eq!(app.view().unwrap().artboard, 2);
} else {
artboard_history_new(app, ctx);
}
}
fn artboard_history_delete_after_undo(key: Option<egui::Key>, duplicate: bool) {
let mut app = artboard_deletion_app(2);
app.session.set_tool_option("moveArt", &json!(false));
@@ -839,16 +853,7 @@ fn artboard_history_delete_after_undo(key: Option<egui::Key>, duplicate: bool) {
assert!(!selection.is_empty(), "selected artwork catches a Clear fallthrough");
let undo = st.history.undo.len();
let ctx = egui::Context::default();
if duplicate {
let commands = app.run_count;
artboard_deletion_click(&mut app, &ctx, super::super::artboards::menu, Some("Duplicate Artboards"), egui::Modifiers::NONE);
assert_eq!(app.run_count, commands.wrapping_add(1));
assert_eq!(app.session.doc().unwrap().doc.artboards.len(), 3);
assert_eq!(app.session.tool_options()["active"], json!(2));
assert_eq!(app.view().unwrap().artboard, 2);
} else {
artboard_history_new(&mut app, &ctx);
}
add_board(&mut app, &ctx, duplicate);
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1);
artboard_history_key(&mut app, &ctx, egui::Key::Z, egui::Modifiers::COMMAND);
let st = app.session.doc().unwrap();
@@ -856,20 +861,32 @@ fn artboard_history_delete_after_undo(key: Option<egui::Key>, duplicate: bool) {
assert_eq!(st.selection, selection);
assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo, 1));
assert!(!st.is_dirty());
delete_again_after_undo(&mut app, &ctx, key, &before, &selection, undo);
}
/// Delete the normalized shown board after Undo, then verify its own Undo and Redo.
fn delete_again_after_undo(
app: &mut crate::VectorcraftApp,
ctx: &egui::Context,
key: Option<egui::Key>,
before: &std::sync::Arc<vectorcraft_doc::Document>,
selection: &vectorcraft_doc::Selection,
undo: usize,
) {
// Capture the state before another panel or pointer can repair it. Still exercise deletion
// on RED: keyboard deletion used to do nothing; Properties already clamped its own target.
let after_undo = (app.view().unwrap().artboard, app.session.tool_options());
if let Some(key) = key {
artboard_history_key(&mut app, &ctx, key, egui::Modifiers::NONE);
artboard_history_key(app, ctx, key, egui::Modifiers::NONE);
} else {
let texts = panel_frame(&mut app, &ctx, vec![], show);
let texts = panel_frame(app, ctx, vec![], show);
assert!(texts.iter().any(|(label, _)| label == &before.artboards[1].name));
artboard_deletion_click(&mut app, &ctx, show, Some("Delete Artboard"), egui::Modifiers::NONE);
artboard_deletion_click(app, ctx, show, Some("Delete Artboard"), egui::Modifiers::NONE);
}
let st = app.session.doc().unwrap();
assert_eq!(st.doc.artboards, vec![before.artboards[0].clone()], "deletion after Undo removes the shown second board");
assert_eq!(st.doc.layers, before.layers, "ordinary artwork survives without geometry or style changes");
assert_eq!(st.selection, selection, "deletion never falls through to Clear");
assert_eq!(&st.selection, selection, "deletion never falls through to Clear");
assert_eq!(after_undo.0, 1, "Undo normalizes the navigator before another gesture");
assert_eq!(after_undo.1, json!({"active": 1, "selected": [1], "moveArt": false, "scaleArt": true}));
assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo + 1, 0));
@@ -879,18 +896,18 @@ fn artboard_history_delete_after_undo(key: Option<egui::Key>, duplicate: bool) {
assert_eq!(app.session.tool_options(), json!({"active": 0, "selected": [0], "moveArt": false, "scaleArt": true}));
let deleted = st.doc.clone();
artboard_history_key(&mut app, &ctx, egui::Key::Z, egui::Modifiers::COMMAND);
artboard_history_key(app, ctx, egui::Key::Z, egui::Modifiers::COMMAND);
let st = app.session.doc().unwrap();
assert_eq!(st.doc, before);
assert_eq!(st.selection, selection);
assert_eq!(st.doc.as_ref(), before.as_ref());
assert_eq!(&st.selection, selection);
assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo, 1));
assert!(!st.is_dirty());
// Valid current selection stays put; history does not restore an earlier artboard selection.
assert_eq!(app.session.tool_options()["selected"], json!([0]));
artboard_history_key(&mut app, &ctx, egui::Key::Z, egui::Modifiers::COMMAND | egui::Modifiers::SHIFT);
artboard_history_key(app, ctx, egui::Key::Z, egui::Modifiers::COMMAND | egui::Modifiers::SHIFT);
let st = app.session.doc().unwrap();
assert_eq!(st.doc, deleted);
assert_eq!(st.selection, selection);
assert_eq!(st.doc.as_ref(), deleted.as_ref());
assert_eq!(&st.selection, selection);
assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo + 1, 0));
assert!(st.is_dirty());
}
@@ -1,6 +1,6 @@
use super::dnd::{group_of, selection};
use super::libraries::SwatchLibraries;
use super::*;
use super::{Kind, LibraryKind, Ui, Value, VectorcraftApp, View, active_paint, json, library_panel, menu_item, pstate, set_pstate, widgets};
/// The selected swatches and groups that can be deleted (all but None and Registration).
pub(super) fn deletable(sel: &[String]) -> Vec<String> {
+1 -1
View File
@@ -3,7 +3,7 @@ use super::dnd::{TileEvents, apply_drop, click_selection, highlighted, selection
use super::tiles::{
draw_folder, draw_registration, folder_tile, global_mark, list_icons, list_row, list_row_name, pattern_thumb, tile_grid, tile_id,
};
use super::*;
use super::{Entry, Kind, Paint, Sense, Ui, VectorcraftApp, View, active_paint, apply, entries, json, pstate, set_pstate, swatch_tile, widgets};
/// The id of the find field's text (shown with the panel menu's Show Find Field).
pub(super) fn find_id() -> egui::Id {
+4 -1
View File
@@ -1,5 +1,8 @@
use super::tiles::{describe, draw_registration, pattern_thumb};
use super::*;
use super::{
Document, Entry, Paint, PanelDrag, REGISTRATION, Rect, Response, Sense, Stroke, StrokeKind, SwatchRows, Tokens, Ui, Value, VectorcraftApp, json,
pos2, pstate, set_pstate, swatch_params, swatch_tile, vec2,
};
/// The swatches and colour groups selected in the panel, in click order, without names that no
/// longer exist (deleted, renamed or undone).
@@ -1,7 +1,10 @@
use super::actions::deletable;
use super::dnd::selection;
use super::tiles::{describe, global_mark, list_icons};
use super::*;
use super::{
Arc, BuiltinLibrary, GRADIENT_LIBRARIES, Item, LibraryKind, LibraryRef, Rect, Row, SWATCH_LIBRARIES, Swatch, SwatchLibrary, Ui, Value,
VectorcraftApp, json, library_panel, menu_item, swatch_tile, swatchlib,
};
fn library_refs(libs: Vec<swatchlib::LibraryInfo>) -> Vec<LibraryRef> {
libs.into_iter().map(|l| LibraryRef { submenu: library_panel::submenu(l.category), id: l.id, name: l.name }).collect()
+3 -1
View File
@@ -1,4 +1,6 @@
use super::*;
use super::{
Color, Color32, Entry, GradientKind, Paint, Rect, Response, Sense, Shape, Stroke, StrokeKind, Tokens, Ui, VectorcraftApp, View, icons, pos2, vec2,
};
/// A pattern swatch drawn as a rendered tile (cached by the definition's identity and size).
pub(super) fn pattern_thumb(app: &VectorcraftApp, ui: &Ui, r: Rect, paint: &Paint) {
+89 -49
View File
@@ -319,24 +319,79 @@ fn click_new_style_footer_range(ctx: &egui::Context, app: &mut VectorcraftApp, i
assert_eq!(text_editing(app), Some((id, start, end)), "footer capture preserves the Type range");
}
fn capture_during_live_typing(redefine: bool, footer: bool) {
let (mut app, id, ctx) = capture_app();
fn create_redefined_texts(app: &mut VectorcraftApp, ctx: &egui::Context, id: NodeId) -> (Option<NodeId>, Option<NodeId>) {
let mut follower = None;
let mut local_override = None;
if redefine {
app.run("charStyle.new", json!({"name": "Body", "attrs": {"size": 24}})).unwrap();
click_style_ui(&ctx, &mut app, id, None, false, "Body");
for (slot, text, y, size) in [(&mut follower, "Follower", 130, 24), (&mut local_override, "Override", 160, 48)] {
let user = NodeId(app.run("text.create", json!({"x": 20, "y": y, "text": text, "size": 24})).unwrap()["id"].as_u64().unwrap());
app.run("charStyle.apply", json!({"name": "Body", "id": user.0})).unwrap();
app.run("text.setRangeStyle", json!({"id": user.0, "start": 0, "end": text.len(), "size": size})).unwrap();
*slot = Some(user);
}
app.run("select.set", json!({"ids": [id.0]})).unwrap();
// This override is already the captured size, so Redefine does not alter the edited
// runs and accidentally make TextTool::replace restart the typing interaction.
app.run("text.setRangeStyle", json!({"id": id.0, "start": 0, "end": 6, "size": 18})).unwrap();
app.run("charStyle.new", json!({"name": "Body", "attrs": {"size": 24}})).unwrap();
click_style_ui(ctx, app, id, None, false, "Body");
for (slot, text, y, size) in [(&mut follower, "Follower", 130, 24), (&mut local_override, "Override", 160, 48)] {
let user = NodeId(app.run("text.create", json!({"x": 20, "y": y, "text": text, "size": 24})).unwrap()["id"].as_u64().unwrap());
app.run("charStyle.apply", json!({"name": "Body", "id": user.0})).unwrap();
app.run("text.setRangeStyle", json!({"id": user.0, "start": 0, "end": text.len(), "size": size})).unwrap();
*slot = Some(user);
}
app.run("select.set", json!({"ids": [id.0]})).unwrap();
// This override is already the captured size, so Redefine does not alter the edited
// runs and accidentally make TextTool::replace restart the typing interaction.
app.run("text.setRangeStyle", json!({"id": id.0, "start": 0, "end": 6, "size": 18})).unwrap();
(follower, local_override)
}
/// Undo and Redo each commit one distinct typing or style-capture history entry.
fn assert_typing_undo_redo(
app: &mut VectorcraftApp,
undo_states: [&std::sync::Arc<vectorcraft_doc::Document>; 3],
redo_states: [&std::sync::Arc<vectorcraft_doc::Document>; 3],
selection: &vectorcraft_doc::Selection,
undo: usize,
) {
assert_typing_undo(app, undo_states, selection, undo);
assert_typing_redo(app, redo_states, selection, undo);
}
fn assert_typing_undo(
app: &mut VectorcraftApp,
states: [&std::sync::Arc<vectorcraft_doc::Document>; 3],
selection: &vectorcraft_doc::Selection,
undo: usize,
) {
for (step, expected) in states.into_iter().enumerate() {
let result = app.run("edit.undo", json!({})).unwrap();
if step != 1 {
assert_eq!(result["undone"], json!("Typing"));
}
let st = app.session.doc().unwrap();
assert_eq!(st.doc.as_ref(), expected.as_ref());
assert_eq!(&st.selection, selection);
assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo + 2 - step, step + 1));
assert_eq!(st.is_dirty(), step != 2);
assert!(!app.session.in_interaction());
assert_eq!(app.session.tool_options()["typing"], json!(false));
}
}
fn assert_typing_redo(
app: &mut VectorcraftApp,
states: [&std::sync::Arc<vectorcraft_doc::Document>; 3],
selection: &vectorcraft_doc::Selection,
undo: usize,
) {
for (step, expected) in states.into_iter().enumerate() {
let result = app.run("edit.redo", json!({})).unwrap();
if step != 1 {
assert_eq!(result["redone"], json!("Typing"));
}
let st = app.session.doc().unwrap();
assert_eq!(st.doc.as_ref(), expected.as_ref());
assert_eq!(&st.selection, selection);
assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo + step + 1, 2 - step));
assert!(st.is_dirty());
}
}
fn capture_during_live_typing(redefine: bool, footer: bool) {
let (mut app, id, ctx) = capture_app();
let (follower, local_override) = if redefine { create_redefined_texts(&mut app, &ctx, id) } else { (None, None) };
let link = redefine.then(|| "Body".to_string());
app.session.doc_mut().unwrap().mark_saved();
let before = app.session.doc().unwrap().doc.clone();
@@ -366,12 +421,7 @@ fn capture_during_live_typing(redefine: bool, footer: bool) {
assert!(app.session.in_interaction());
assert_eq!(app.session.tool_options()["typing"], json!(true));
assert_eq!(app.session.doc().unwrap().selection, selection);
let label = if redefine { "Redefine Character Style" } else { "New Character Style…" };
if footer {
click_new_style_footer(&ctx, &mut app, id);
} else {
click_style_ui(&ctx, &mut app, id, Some((0, 5)), true, label);
}
capture_style_from_typing(&mut app, &ctx, id, redefine, footer);
let captured = app.session.doc().unwrap().doc.clone();
let capture_history = (app.session.doc().unwrap().history.undo.len(), app.session.doc().unwrap().history.redo.len());
let capture_settled = !app.session.in_interaction() && app.session.tool_options()["typing"] == json!(false);
@@ -386,12 +436,7 @@ fn capture_during_live_typing(redefine: bool, footer: bool) {
assert_eq!(captured.node(node.id), Some(node));
}
});
if let Some(user) = follower {
assert_eq!(text_runs(&app, user), vec![("Follower".into(), 18.0, Some("Body".into()))]);
}
if let Some(user) = local_override {
assert_eq!(text_runs(&app, user), vec![("Override".into(), 48.0, Some("Body".into()))]);
}
assert_related_text_styles(&app, follower, local_override);
// Check persistence before the history assertions: RED must expose the lost definition,
// not merely report that typing was still live after capture.
@@ -414,29 +459,24 @@ fn capture_during_live_typing(redefine: bool, footer: bool) {
// Undo itself commits the new live typing interaction. Three separate snapshots prove
// that typing, capture and further typing can each be undone/redone independently.
for (step, expected) in [&captured, &typed, &before].into_iter().enumerate() {
let result = app.run("edit.undo", json!({})).unwrap();
if step != 1 {
assert_eq!(result["undone"], json!("Typing"));
}
let st = app.session.doc().unwrap();
assert_eq!(&st.doc, expected);
assert_eq!(st.selection, selection);
assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo + 2 - step, step + 1));
assert_eq!(st.is_dirty(), step != 2);
assert!(!app.session.in_interaction());
assert_eq!(app.session.tool_options()["typing"], json!(false));
assert_typing_undo_redo(&mut app, [&captured, &typed, &before], [&typed, &captured, &after], &selection, undo);
}
fn capture_style_from_typing(app: &mut VectorcraftApp, ctx: &egui::Context, id: NodeId, redefine: bool, footer: bool) {
let label = if redefine { "Redefine Character Style" } else { "New Character Style…" };
if footer {
click_new_style_footer(ctx, app, id);
} else {
click_style_ui(ctx, app, id, Some((0, 5)), true, label);
}
for (step, expected) in [&typed, &captured, &after].into_iter().enumerate() {
let result = app.run("edit.redo", json!({})).unwrap();
if step != 1 {
assert_eq!(result["redone"], json!("Typing"));
}
let st = app.session.doc().unwrap();
assert_eq!(&st.doc, expected);
assert_eq!(st.selection, selection);
assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo + step + 1, 2 - step));
assert!(st.is_dirty());
}
fn assert_related_text_styles(app: &VectorcraftApp, follower: Option<NodeId>, local_override: Option<NodeId>) {
if let Some(user) = follower {
assert_eq!(text_runs(app, user), vec![("Follower".into(), 18.0, Some("Body".into()))]);
}
if let Some(user) = local_override {
assert_eq!(text_runs(app, user), vec![("Override".into(), 48.0, Some("Body".into()))]);
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Arc, ClipboardProbeFactory, Mutex, SystemClipboard, background, native_menu, picks, place, print, screen_color, stylus};
/// A file type a dialog filters by: its label, which shows translated, and its extensions without
/// the dot.
+205 -173
View File
@@ -269,49 +269,166 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
if app.session.tool_wants_text() {
ctx.input_mut(drop_typing_presses);
}
// Type tool editing: text, IME composition and editing keys go to the tool.
if app.session.tool_wants_text() {
// Route-level loss (no current target or caret geometry) retires a live guard before any
// marker in this pass can be considered. The finalizer may re-arm on a later activation.
crate::ime::suspend_if_route_lost(app, ctx);
let old_composition_retired = type_text(app, ctx);
// Keys of a frame that began composing are the IME's too (a Backspace that empties the
// marked text must not delete the committed character before it).
if composing && !old_composition_retired || app.session.tool_composing() {
// The IME owns the keyboard until it commits: no editing keys, clipboard or shortcuts.
ctx.input_mut(|i| {
i.events.retain(|e| !matches!(e, egui::Event::Key { .. } | egui::Event::Copy | egui::Event::Cut | egui::Event::Paste(_)))
});
return;
}
// A deferred pointer/navigation notice has not established its destination yet. Its
// clipboard suffix must not cut or paste at the old caret; only the notice's own actual
// handler (kept in the raw vector) may run.
if crate::ime::deferred(app, ctx) {
ctx.input_mut(|input| {
input.events.retain(|event| match event {
egui::Event::Cut | egui::Event::Paste(_) => false,
egui::Event::Key { key: egui::Key::X | egui::Key::V, pressed: true, modifiers, .. }
if modifiers.command && !modifiers.shift && !modifiers.alt =>
{
false
}
_ => true,
})
});
}
// Editing keys with modifiers and the clipboard (Cmd+A is Select All's, below: the text).
crate::panels::character::route_type_input(app, ctx);
// Enter was already delivered above as ToolKey::Enter (newline). Only chords with the
// command key run commands: the rest type or edit.
let hits = dispatch(app, ctx, |hit| hit.trigger.chord.mods.cmd);
for c in hits {
run_hit(app, ctx, &c);
}
settings_on_a_mac(app, ctx, mac(ctx));
type_tool_input(app, ctx, composing);
return;
}
crate::ime::controls_without_editor(app, ctx);
handle_clipboard(app, ctx, textless_paste);
if busy {
busy_tool_keys(app, ctx, view);
return;
}
if claimed_tool_keys(app, ctx, view) {
return;
}
// Command, panel and tool shortcuts. Keys that type a character without Cmd/Alt/Ctrl are
// handled below as text (tool shortcuts, X, D, /, `,` and `.`).
let hits = dispatch(app, ctx, |hit| !(hit.trigger.chord.is_plain() && types_text(hit.trigger.chord.key)));
let ran = !hits.is_empty();
for c in hits {
run_hit(app, ctx, &c);
}
if settings_on_a_mac(app, ctx, mac(ctx)) || ran {
return;
}
if alt_type_arrows(app, ctx) {
return;
}
nudge_and_clear(app, ctx);
single_key_shortcuts(app, ctx);
}
/// Dispatch unmodified text keys to command bindings or tools.
fn single_key_shortcuts(app: &mut VectorcraftApp, ctx: &egui::Context) {
// Single-key tool shortcuts (no Cmd/Ctrl/Alt). With a layout without Latin letters (Persian,
// Arabic, Russian, Greek…) the key's position stands in for what it types (#793).
let events: Vec<(String, Modifiers)> = ctx.input(|i| {
let mut physical = None;
i.events
.iter()
.filter_map(|e| match e {
egui::Event::Key { physical_key, pressed: true, .. } => {
physical = *physical_key;
None
}
egui::Event::Text(t) => Some((latin(t, physical), i.modifiers)),
_ => None,
})
.collect()
});
let keymap = crate::shortcut_editor::current();
for (text, m) in events {
if m.command || m.alt || m.ctrl {
continue;
}
let upper = text.to_uppercase();
let key = if m.shift && text.chars().all(|c| c.is_alphabetic()) { format!("Shift+{upper}") } else { upper.clone() };
// A shifted punctuation character can also be written with its Shift (the Curvature tool's
// Shift+~ arrives as `~`).
let shifted = m.shift.then(|| format!("Shift+{text}"));
let hits: Vec<CommandRef> = [Some(key), Some(text), shifted]
.into_iter()
.flatten()
.filter_map(|t| crate::keymap::vc_chord(&t).and_then(|c| crate::keymap::lookup(&keymap.keymap, &c)))
.collect();
// Single-key command shortcuts (X, Shift+X, D, /, Shift+D, F, Shift+F by default) before
// tools.
let Some(hit) = hits.iter().find(|c| !c.id.starts_with("tool:")).or(hits.first()) else { continue };
match hit.id.strip_prefix("tool:") {
Some(tool) => app.select_tool(tool),
None if hit.params.is_null() => {
let _ = app.run(&hit.id, json!({}));
}
None => crate::menus::invoke(app, &hit.id, hit.params.clone()),
}
}
}
/// Route Alt+arrows to spacing controls when text objects are selected.
fn alt_type_arrows(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool {
let m = ctx.input(|i| i.modifiers);
if !m.alt
|| !app.session.active().is_some_and(|st| {
st.selection.guides.is_empty()
&& !st.selection.objects.is_empty()
&& st.selection.objects.iter().all(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(vectorcraft_doc::NodeKind::Text(_))))
})
{
return false;
}
let keys = [(Key::ArrowLeft, ToolKey::Left), (Key::ArrowRight, ToolKey::Right), (Key::ArrowUp, ToolKey::Up), (Key::ArrowDown, ToolKey::Down)];
for (key, tool_key) in keys {
if ctx.input_mut(|i| i.consume_key(m, key)) {
if let Some((attribute, by)) = vectorcraft_tools::text::step_key(tool_key, m.shift, false) {
let by = if m.command { by * 5.0 } else { by };
let _ = app.run("type.step", json!({"attribute": attribute, "by": by}));
}
return true;
}
}
false
}
/// Route text-tool input and command chords while preserving IME composition ownership.
fn type_tool_input(app: &mut VectorcraftApp, ctx: &egui::Context, composing: bool) {
// Route-level loss (no current target or caret geometry) retires a live guard before any
// marker in this pass can be considered. The finalizer may re-arm on a later activation.
crate::ime::suspend_if_route_lost(app, ctx);
let old_composition_retired = type_text(app, ctx);
// Keys of a frame that began composing are the IME's too (a Backspace that empties the
// marked text must not delete the committed character before it).
if composing && !old_composition_retired || app.session.tool_composing() {
// The IME owns the keyboard until it commits: no editing keys, clipboard or shortcuts.
ctx.input_mut(|i| i.events.retain(|e| !matches!(e, egui::Event::Key { .. } | egui::Event::Copy | egui::Event::Cut | egui::Event::Paste(_))));
return;
}
// A deferred pointer/navigation notice has not established its destination yet. Its
// clipboard suffix must not cut or paste at the old caret; only the notice's own actual
// handler (kept in the raw vector) may run.
if crate::ime::deferred(app, ctx) {
ctx.input_mut(|input| {
input.events.retain(|event| match event {
egui::Event::Cut | egui::Event::Paste(_) => false,
egui::Event::Key { key: egui::Key::X | egui::Key::V, pressed: true, modifiers, .. }
if modifiers.command && !modifiers.shift && !modifiers.alt =>
{
false
}
_ => true,
})
});
}
// Editing keys with modifiers and the clipboard (Cmd+A is Select All's, below: the text).
crate::panels::character::route_type_input(app, ctx);
// Enter was already delivered above as ToolKey::Enter (newline). Only chords with the
// command key run commands: the rest type or edit.
let hits = dispatch(app, ctx, |hit| hit.trigger.chord.mods.cmd);
for c in hits {
run_hit(app, ctx, &c);
}
settings_on_a_mac(app, ctx, mac(ctx));
}
/// Apply arrow-key movement and clear selected art when no text editor owns input.
fn nudge_and_clear(app: &mut VectorcraftApp, ctx: &egui::Context) {
// Arrow nudges (Shift = ×10, Alt = copy).
let arrows = [(Key::ArrowLeft, -1.0, 0.0), (Key::ArrowRight, 1.0, 0.0), (Key::ArrowUp, 0.0, -1.0), (Key::ArrowDown, 0.0, 1.0)];
for (k, dx, dy) in arrows {
let m = ctx.input(|i| i.modifiers);
if ctx.input_mut(|i| i.consume_key(m, k)) && app.session.active().is_some_and(|d| d.selection.has_objects_or_guides()) {
let _ = app.run("object.nudge", json!({"dx": dx, "dy": dy, "big": m.shift, "copy": m.alt}));
}
}
// Delete / Backspace clear the selection.
if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Backspace)) && app.session.active().is_some_and(|d| d.selection.has_objects_or_guides())
{
let _ = app.run("edit.clear", json!({}));
}
}
/// Route clipboard events to the system clipboard or the app's rich-format paste path.
fn handle_clipboard(app: &mut VectorcraftApp, ctx: &egui::Context, textless_paste: Option<Modifiers>) {
// A clipboard image (eframe's `clipboard` feature, 0.37): the host's text paste path never
// carries it. With the app's rich-format `SystemClipboard`, it is only the signal that the
// clipboard holds no text — the next tick invokes Paste, whose `adopt_system_clipboard` reads
@@ -387,53 +504,53 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
app.clipboard_in = text.map(|t| Flavour { mime: TEXT, data: t.into_bytes() });
crate::menus::invoke(app, id, json!({}));
}
if busy {
// The arrows while dragging: a polygon's sides, a grid's rows (↑/↓) and columns (←/→)…
let arrows =
[(Key::ArrowUp, ToolKey::Up), (Key::ArrowDown, ToolKey::Down), (Key::ArrowLeft, ToolKey::Left), (Key::ArrowRight, ToolKey::Right)];
for (k, tk) in arrows {
if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)) {
let _ = app.session.tool_key(tk, Mods::default(), view);
}
}
/// Route arrows, digits and letters to a tool while a gesture is active.
fn busy_tool_keys(app: &mut VectorcraftApp, ctx: &egui::Context, view: ViewInfo) {
// The arrows while dragging: a polygon's sides, a grid's rows (↑/↓) and columns (←/→)…
let arrows = [(Key::ArrowUp, ToolKey::Up), (Key::ArrowDown, ToolKey::Down), (Key::ArrowLeft, ToolKey::Left), (Key::ArrowRight, ToolKey::Right)];
for (k, tk) in arrows {
if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)) {
let _ = app.session.tool_key(tk, Mods::default(), view);
}
// Digits (5 while dragging with the Perspective Selection tool), once per press.
let digits: Vec<u8> = ctx.input(|i| {
i.events
.iter()
.filter_map(|e| match e {
egui::Event::Key { key, pressed: true, repeat: false, .. } => digit_of(*key),
_ => None,
})
.collect()
});
for d in digits {
let _ = app.session.tool_key(ToolKey::Digit(d), Mods::default(), view);
}
// The tool gets each letter it takes (E while dragging a selection marquee) once per press,
// without Cmd, Ctrl or Alt. The key is the one egui reports, which on a layout without Latin
// letters is the key at that position on a US layout.
let letters: Vec<char> = ctx.input(|i| {
i.events
.iter()
.filter_map(|e| match e {
egui::Event::Key { key, pressed: true, repeat: false, modifiers, .. }
if !(modifiers.command || modifiers.alt || modifiers.ctrl) =>
{
letter_of(*key)
}
_ => None,
})
.collect()
});
for c in letters {
if app.session.tool_claims_key(ToolKey::Letter(c), view) {
let _ = app.session.tool_key(ToolKey::Letter(c), Mods::default(), view);
}
}
return;
}
// Keys the active tool claims ahead of their shortcuts (the Gradient tool's selected stop:
// Delete/Backspace remove it, ←/→ nudge it; the loaded place cursor: the arrows cycle files).
// Digits (5 while dragging with the Perspective Selection tool), once per press.
let digits: Vec<u8> = ctx.input(|i| {
i.events
.iter()
.filter_map(|e| match e {
egui::Event::Key { key, pressed: true, repeat: false, .. } => digit_of(*key),
_ => None,
})
.collect()
});
for d in digits {
let _ = app.session.tool_key(ToolKey::Digit(d), Mods::default(), view);
}
// The tool gets each letter it takes (E while dragging a selection marquee) once per press,
// without Cmd, Ctrl or Alt. The key is the one egui reports, which on a layout without Latin
// letters is the key at that position on a US layout.
let letters: Vec<char> = ctx.input(|i| {
i.events
.iter()
.filter_map(|e| match e {
egui::Event::Key { key, pressed: true, repeat: false, modifiers, .. } if !(modifiers.command || modifiers.alt || modifiers.ctrl) => {
letter_of(*key)
}
_ => None,
})
.collect()
});
for c in letters {
if app.session.tool_claims_key(ToolKey::Letter(c), view) {
let _ = app.session.tool_key(ToolKey::Letter(c), Mods::default(), view);
}
}
}
/// Give keys claimed by the active tool precedence over their command shortcuts.
fn claimed_tool_keys(app: &mut VectorcraftApp, ctx: &egui::Context, view: ViewInfo) -> bool {
let m = ctx.input(|i| i.modifiers);
for (k, tk) in [
(Key::Delete, ToolKey::Delete),
@@ -456,7 +573,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
v.artboard = i;
}
crate::canvas::apply_requests(app, r);
return;
return true;
}
}
// Digits the active tool or the perspective grid takes (1–4 pick the plane while it shows).
@@ -477,7 +594,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
ctx.input_mut(|i| i.events.retain(|e| !matches!(e, egui::Event::Text(t) if *t == text)));
let r = app.session.tool_key(ToolKey::Digit(d), crate::canvas::mods(m, false), view);
crate::canvas::apply_requests(app, r);
return;
return true;
}
}
}
@@ -510,95 +627,10 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
}
}
if taken {
return;
}
}
// Command, panel and tool shortcuts. Keys that type a character without Cmd/Alt/Ctrl are
// handled below as text (tool shortcuts, X, D, /, `,` and `.`).
let hits = dispatch(app, ctx, |hit| !(hit.trigger.chord.is_plain() && types_text(hit.trigger.chord.key)));
let ran = !hits.is_empty();
for c in hits {
run_hit(app, ctx, &c);
}
if settings_on_a_mac(app, ctx, mac(ctx)) || ran {
return;
}
// Alt+arrows on selected type objects step their spacing as the Type tool does (Cmd/Ctrl:
// five steps); Alt+arrow copies and nudges every other selection.
let m = ctx.input(|i| i.modifiers);
if m.alt
&& app.session.active().is_some_and(|st| {
st.selection.guides.is_empty()
&& !st.selection.objects.is_empty()
&& st.selection.objects.iter().all(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(vectorcraft_doc::NodeKind::Text(_))))
})
{
let keys = [(Key::ArrowLeft, ToolKey::Left), (Key::ArrowRight, ToolKey::Right), (Key::ArrowUp, ToolKey::Up), (Key::ArrowDown, ToolKey::Down)];
for (key, tool_key) in keys {
if ctx.input_mut(|i| i.consume_key(m, key)) {
if let Some((attribute, by)) = vectorcraft_tools::text::step_key(tool_key, m.shift, false) {
let by = if m.command { by * 5.0 } else { by };
let _ = app.run("type.step", json!({"attribute": attribute, "by": by}));
}
return;
}
}
}
// Arrow nudges (Shift = ×10, Alt = copy).
let arrows = [(Key::ArrowLeft, -1.0, 0.0), (Key::ArrowRight, 1.0, 0.0), (Key::ArrowUp, 0.0, -1.0), (Key::ArrowDown, 0.0, 1.0)];
for (k, dx, dy) in arrows {
let m = ctx.input(|i| i.modifiers);
if ctx.input_mut(|i| i.consume_key(m, k)) && app.session.active().is_some_and(|d| d.selection.has_objects_or_guides()) {
let _ = app.run("object.nudge", json!({"dx": dx, "dy": dy, "big": m.shift, "copy": m.alt}));
}
}
// Delete / Backspace clear the selection.
if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Backspace)) && app.session.active().is_some_and(|d| d.selection.has_objects_or_guides())
{
let _ = app.run("edit.clear", json!({}));
}
// Single-key tool shortcuts (no Cmd/Ctrl/Alt). With a layout without Latin letters (Persian,
// Arabic, Russian, Greek…) the key's position stands in for what it types (#793).
let events: Vec<(String, Modifiers)> = ctx.input(|i| {
let mut physical = None;
i.events
.iter()
.filter_map(|e| match e {
egui::Event::Key { physical_key, pressed: true, .. } => {
physical = *physical_key;
None
}
egui::Event::Text(t) => Some((latin(t, physical), i.modifiers)),
_ => None,
})
.collect()
});
let keymap = crate::shortcut_editor::current();
for (text, m) in events {
if m.command || m.alt || m.ctrl {
continue;
}
let upper = text.to_uppercase();
let key = if m.shift && text.chars().all(|c| c.is_alphabetic()) { format!("Shift+{upper}") } else { upper.clone() };
// A shifted punctuation character can also be written with its Shift (the Curvature tool's
// Shift+~ arrives as `~`).
let shifted = m.shift.then(|| format!("Shift+{text}"));
let hits: Vec<CommandRef> = [Some(key), Some(text), shifted]
.into_iter()
.flatten()
.filter_map(|t| crate::keymap::vc_chord(&t).and_then(|c| crate::keymap::lookup(&keymap.keymap, &c)))
.collect();
// Single-key command shortcuts (X, Shift+X, D, /, Shift+D, F, Shift+F by default) before
// tools.
let Some(hit) = hits.iter().find(|c| !c.id.starts_with("tool:")).or(hits.first()) else { continue };
match hit.id.strip_prefix("tool:") {
Some(tool) => app.select_tool(tool),
None if hit.params.is_null() => {
let _ = app.run(&hit.id, json!({}));
}
None => crate::menus::invoke(app, &hit.id, hit.params.clone()),
return true;
}
}
false
}
/// Runs this frame's key presses against the keymap in effect; `accept` declines a hit, leaving
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Key, Modifiers, all_shortcuts};
/// Keys that paste with Cmd, or alone. (Shift+Insert is left out: Ctrl+Insert copies.)
const PASTE_KEYS: [Key; 2] = [Key::V, Key::Paste];
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Key, VectorcraftApp, is_modifier, json};
/// The second press of Escape pressed twice comes less than this many seconds after the first.
const ESCAPE_PAIR_SECONDS: f64 = 1.0;
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{EngineResult, Key, Mods, SurroundingUnit, ToolKey, UiRequest, VectorcraftApp, ViewInfo};
/// Drop each key press made with Ctrl and Alt held that is followed by the text it typed. Firefox
/// on Windows reports AltGr as Ctrl and Alt held, so AltGr+L on a Polish layout arrives as a
+1 -1
View File
@@ -66,7 +66,7 @@ pub const ROTATE_VIEW_ANGLES: [(&str, f64); 12] = [
("-90°", -90.0),
];
use super::*;
use super::{ControlResponse, Sender};
use std::sync::atomic::{AtomicBool, Ordering};
/// Zoom stop stepping reads this (set each UI frame and before commands) so canvas wheel zoom
+1 -1
View File
@@ -2,7 +2,7 @@ use super::color::{Live, pointer_phase};
use super::dropdowns::combo;
use super::labels::dim_label;
use super::numeric::{range_field, slider};
use super::*;
use super::{BlendMode, Rect, Sense, Stroke, StrokeKind, Tokens, Ui, Vec2, icons, id_with, vec2};
/// Whether the blend-mode list draws a separator above `BlendMode::ALL[i]`: where a
/// [`BlendMode::group`] starts.
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Color32, CornerRadius, Rect, Response, Sense, Stroke, Tokens, Ui, Vec2, icons, pos2, theme, vec2};
/// Square icon button; `selected` draws the pressed well.
pub fn icon_button(ui: &mut Ui, icon: &str, tip: &str, selected: bool, size: f32) -> Response {
+1 -1
View File
@@ -1,5 +1,5 @@
use super::drag::{PanelDrag, drag_source};
use super::*;
use super::{Color32, Paint, Rect, Response, Sense, Stroke, StrokeKind, Tokens, Ui, Vec2, icons, pos2, vec2};
/// Draw a paint preview (swatch chip) into `rect`.
pub fn paint_chip(ui: &Ui, rect: Rect, paint: &Paint) {
+1 -1
View File
@@ -1,7 +1,7 @@
use super::labels::{label_field, name_field, take_previous_label};
use super::numeric::{STEP_DECIMALS, field_text, round_to, step_to_multiple};
use super::text::{after_shift_enter, select_all, shift_enter_keeps_focus};
use super::*;
use super::{Color, Color32, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Tokens, Ui, Vec2, id_with, pos2, vec2};
/// Drag phase of a live slider/handle: previews while dragging, commits on release.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Paint, Response, Stroke, StrokeKind, Tokens, Ui, Vec2};
/// What panels drag onto art and onto each other. One payload type, so the canvas has one drop
/// handler for all of them (`canvas::panel_drop`); a chip of a dragged paint follows the pointer.
+1 -1
View File
@@ -1,6 +1,6 @@
use super::drag::PanelDrag;
use super::labels::{PreviousLabel, label_field, name_field, take_previous_label};
use super::*;
use super::{CornerRadius, HashOnly, Rect, Response, Sense, Stroke, StrokeKind, Tokens, Ui, Vec2, id_with, vec2};
/// A compact dropdown of interface labels (shown in the UI language). Returns the chosen index.
pub fn dropdown(ui: &mut Ui, id: impl std::hash::Hash, current: &str, options: &[&str], width: f32) -> Option<usize> {
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Pos2, Rect, Response, Sense, Stroke, Tokens, Ui, icons, pos2, scrub, theme, vec2};
/// Bold section header (Properties panel).
pub fn section_header(ui: &mut Ui, text: &str) {
+1 -1
View File
@@ -1,7 +1,7 @@
use super::labels::{PreviousLabel, label_field, name_field, take_previous_label};
use super::panel_chrome::chevron_cell;
use super::text::{after_shift_enter, recessed_text, select_all, select_all_on_focus, shift_enter_keeps_focus, take_dialog_focus};
use super::*;
use super::{CornerRadius, RangeInclusive, Rect, Response, Sense, Stroke, StrokeKind, Tokens, Ui, Unit, id_with, pos2, scrub, vec2};
/// Preferences › Units › Numbers Without Units Are Points (#394; on until set): the app sets it
/// when the preference changes (`prefs_dialog::apply_runtime`), the length fields read it.
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Color32, CornerRadius, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Tokens, Ui, Vec2, icons, pos2, theme, vec2};
/// A bordered list box (Swatches tiles, Appearance rows, Artboards list).
pub fn list_box<R>(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R {
+1 -1
View File
@@ -1,7 +1,7 @@
use super::labels::{PreviousLabel, label_field, name_field, take_previous_label};
use super::numeric::mixed_field_named;
use super::panel_chrome::chevron_cell;
use super::*;
use super::{Rect, Response, Stroke, StrokeKind, Tokens, Ui, id_with, vec2};
/// The flag `dialogs::show` raises while it draws the body of a dialog that has just opened; the
/// first enabled field drawn takes it ([`take_dialog_focus`]).
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Color32, CornerRadius, Rect, Response, Sense, Stroke, StrokeKind, Tokens, Ui, Vec2, pos2, vec2};
/// The row of a checkbox or radio button: allocates a 13 pt box plus `text` (already translated),
/// draws the text and returns the box, the response and the box's border colour.