diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 86b75c03d..a708a70c1 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -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; diff --git a/crates/ui-egui/src/app/clipboard.rs b/crates/ui-egui/src/app/clipboard.rs index b6260b661..5efe2db30 100644 --- a/crates/ui-egui/src/app/clipboard.rs +++ b/crates/ui-egui/src/app/clipboard.rs @@ -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 diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index b31f42d85..0f136e65e 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -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. diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index 13eca92db..bbdf60bef 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -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 { diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 03bcc240b..867494253 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -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 diff --git a/crates/ui-egui/src/app/fonts.rs b/crates/ui-egui/src/app/fonts.rs index 54fd9f86d..fd34eafc6 100644 --- a/crates/ui-egui/src/app/fonts.rs +++ b/crates/ui-egui/src/app/fonts.rs @@ -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; diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index 19374770f..5fd9dd8d0 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -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 diff --git a/crates/ui-egui/src/app/ime.rs b/crates/ui-egui/src/app/ime.rs index 05222c39f..4a020d407 100644 --- a/crates/ui-egui/src/app/ime.rs +++ b/crates/ui-egui/src/app/ime.rs @@ -1,4 +1,4 @@ -use super::*; +use super::VectorcraftApp; use crate::ime; impl VectorcraftApp { diff --git a/crates/ui-egui/src/app/input.rs b/crates/ui-egui/src/app/input.rs index 8b9f96760..f8b42f406 100644 --- a/crates/ui-egui/src/app/input.rs +++ b/crates/ui-egui/src/app/input.rs @@ -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 diff --git a/crates/ui-egui/src/app/links.rs b/crates/ui-egui/src/app/links.rs index e4c6fc925..7c8c4abd6 100644 --- a/crates/ui-egui/src/app/links.rs +++ b/crates/ui-egui/src/app/links.rs @@ -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…). diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index 39b202330..24288594c 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -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, diff --git a/crates/ui-egui/src/app/screenshot.rs b/crates/ui-egui/src/app/screenshot.rs index a680f11b2..ce97fc747 100644 --- a/crates/ui-egui/src/app/screenshot.rs +++ b/crates/ui-egui/src/app/screenshot.rs @@ -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 { diff --git a/crates/ui-egui/src/app/view.rs b/crates/ui-egui/src/app/view.rs index 2306d1f45..6aac1150e 100644 --- a/crates/ui-egui/src/app/view.rs +++ b/crates/ui-egui/src/app/view.rs @@ -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). diff --git a/crates/ui-egui/src/caches.rs b/crates/ui-egui/src/caches.rs index d5b2e6e1d..b32c64c4f 100644 --- a/crates/ui-egui/src/caches.rs +++ b/crates/ui-egui/src/caches.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{canvas, cursors, render_worker}; /// Cached canvas raster. pub struct CanvasCache { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d590c01f6..400a32d03 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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::>(); - 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_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_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, 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_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, + 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, + 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::>(); + 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 { // 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_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_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)] diff --git a/crates/ui-egui/src/canvas/artboards.rs b/crates/ui-egui/src/canvas/artboards.rs index 986be51aa..4f803ef0c 100644 --- a/crates/ui-egui/src/canvas/artboards.rs +++ b/crates/ui-egui/src/canvas/artboards.rs @@ -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. diff --git a/crates/ui-egui/src/canvas/context.rs b/crates/ui-egui/src/canvas/context.rs index 534321242..8db01d422 100644 --- a/crates/ui-egui/src/canvas/context.rs +++ b/crates/ui-egui/src/canvas/context.rs @@ -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 diff --git a/crates/ui-egui/src/canvas/cursor.rs b/crates/ui-egui/src/canvas/cursor.rs index b67276df4..abd4e0cf8 100644 --- a/crates/ui-egui/src/canvas/cursor.rs +++ b/crates/ui-egui/src/canvas/cursor.rs @@ -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 diff --git a/crates/ui-egui/src/canvas/drag.rs b/crates/ui-egui/src/canvas/drag.rs index e17690dd3..bfc3a070f 100644 --- a/crates/ui-egui/src/canvas/drag.rs +++ b/crates/ui-egui/src/canvas/drag.rs @@ -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. diff --git a/crates/ui-egui/src/canvas/grid.rs b/crates/ui-egui/src/canvas/grid.rs index a36380df0..15576b9ae 100644 --- a/crates/ui-egui/src/canvas/grid.rs +++ b/crates/ui-egui/src/canvas/grid.rs @@ -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. diff --git a/crates/ui-egui/src/canvas/home.rs b/crates/ui-egui/src/canvas/home.rs index 1d23d9882..2987a03c5 100644 --- a/crates/ui-egui/src/canvas/home.rs +++ b/crates/ui-egui/src/canvas/home.rs @@ -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) { diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index 319e9c84b..e07ba12dc 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -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_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::(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 = ui.data(|dd| dd.get_temp(tool_drag_doc_id())); - ui.data_mut(|dd| { - dd.remove::(drag_id()); - dd.remove::(drag_pos_id()); - dd.remove::(tool_drag_doc_id()); - dd.remove::(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) { + 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, at: Option) { + 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_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::(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 = ui.data(|dd| dd.get_temp(tool_drag_doc_id())); + ui.data_mut(|dd| { + dd.remove::(drag_id()); + dd.remove::(drag_pos_id()); + dd.remove::(tool_drag_doc_id()); + dd.remove::(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) { diff --git a/crates/ui-egui/src/canvas/outline.rs b/crates/ui-egui/src/canvas/outline.rs index 907f33612..b5d14e151 100644 --- a/crates/ui-egui/src/canvas/outline.rs +++ b/crates/ui-egui/src/canvas/outline.rs @@ -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> { // Flatten for drawing: a polyline per subpath (tolerance ~0.25 screen px). diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index 6722c4761..704745f8a 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -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 }; diff --git a/crates/ui-egui/src/canvas/raster.rs b/crates/ui-egui/src/canvas/raster.rs index e2fc0f165..321ed2acf 100644 --- a/crates/ui-egui/src/canvas/raster.rs +++ b/crates/ui-egui/src/canvas/raster.rs @@ -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. diff --git a/crates/ui-egui/src/canvas/task_bar.rs b/crates/ui-egui/src/canvas/task_bar.rs index 273fce5e6..d7cc1c4df 100644 --- a/crates/ui-egui/src/canvas/task_bar.rs +++ b/crates/ui-egui/src/canvas/task_bar.rs @@ -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 { diff --git a/crates/ui-egui/src/canvas/tool_overlays.rs b/crates/ui-egui/src/canvas/tool_overlays.rs index c3f1b8007..bbb8bf5f0 100644 --- a/crates/ui-egui/src/canvas/tool_overlays.rs +++ b/crates/ui-egui/src/canvas/tool_overlays.rs @@ -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`]). diff --git a/crates/ui-egui/src/canvas/view.rs b/crates/ui-egui/src/canvas/view.rs index f4ca00008..d1b3cff27 100644 --- a/crates/ui-egui/src/canvas/view.rs +++ b/crates/ui-egui/src/canvas/view.rs @@ -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) { diff --git a/crates/ui-egui/src/chrome/control_bar.rs b/crates/ui-egui/src/chrome/control_bar.rs index a0d063b07..2f4f8c7b9 100644 --- a/crates/ui-egui/src/chrome/control_bar.rs +++ b/crates/ui-egui/src/chrome/control_bar.rs @@ -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>); diff --git a/crates/ui-egui/src/chrome/hints.rs b/crates/ui-egui/src/chrome/hints.rs index 947f0dcf2..7fc2e749b 100644 --- a/crates/ui-egui/src/chrome/hints.rs +++ b/crates/ui-egui/src/chrome/hints.rs @@ -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 { diff --git a/crates/ui-egui/src/chrome/status_bar.rs b/crates/ui-egui/src/chrome/status_bar.rs index 92d718fc9..9a1ffbfbb 100644 --- a/crates/ui-egui/src/chrome/status_bar.rs +++ b/crates/ui-egui/src/chrome/status_bar.rs @@ -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) { diff --git a/crates/ui-egui/src/chrome/tabs.rs b/crates/ui-egui/src/chrome/tabs.rs index 649030172..a53062be8 100644 --- a/crates/ui-egui/src/chrome/tabs.rs +++ b/crates/ui-egui/src/chrome/tabs.rs @@ -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)". diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index 8a340d9f5..362854a5f 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -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, 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) { 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, 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, + 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::>() - }; - (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!["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, Arc); +/// The undo and redo entries of a document. +type HistoryDump = (Vec, Vec); + +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::>(); + (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, + 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!["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); +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index cd6efdf7c..14d6fe831 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -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) +} diff --git a/crates/ui-egui/src/dialogs/tests_warp.rs b/crates/ui-egui/src/dialogs/tests_warp.rs index 8c6ffa66d..e7cbfcc5b 100644 --- a/crates/ui-egui/src/dialogs/tests_warp.rs +++ b/crates/ui-egui/src/dialogs/tests_warp.rs @@ -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, &std::sync::Arc), + (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) { + 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); diff --git a/crates/ui-egui/src/font_menu.rs b/crates/ui-egui/src/font_menu.rs index c23035794..93ae778e5 100644 --- a/crates/ui-egui/src/font_menu.rs +++ b/crates/ui-egui/src/font_menu.rs @@ -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, h: Listed<'_>, sample: Option<&str>, look: MenuLook) -> Option { @@ -490,19 +518,7 @@ fn rows(ui: &mut Ui, range: std::ops::Range, 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, 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, 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()); } diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 0ad8e2ace..a34d6184b 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -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 diff --git a/crates/ui-egui/src/io/export.rs b/crates/ui-egui/src/io/export.rs index 05df48941..d7bb70977 100644 --- a/crates/ui-egui/src/io/export.rs +++ b/crates/ui-egui/src/io/export.rs @@ -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"; diff --git a/crates/ui-egui/src/io/open.rs b/crates/ui-egui/src/io/open.rs index 35da8b1b3..c8261ea85 100644 --- a/crates/ui-egui/src/io/open.rs +++ b/crates/ui-egui/src/io/open.rs @@ -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"]; diff --git a/crates/ui-egui/src/io/save.rs b/crates/ui-egui/src/io/save.rs index b5489b33d..b9e2ddeed 100644 --- a/crates/ui-egui/src/io/save.rs +++ b/crates/ui-egui/src/io/save.rs @@ -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 diff --git a/crates/ui-egui/src/layout_migration/tests.rs b/crates/ui-egui/src/layout_migration/tests.rs index a717783bd..d97567eb1 100644 --- a/crates/ui-egui/src/layout_migration/tests.rs +++ b/crates/ui-egui/src/layout_migration/tests.rs @@ -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) { 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] diff --git a/crates/ui-egui/src/menus/bar.rs b/crates/ui-egui/src/menus/bar.rs index b00d500d9..8ad27053a 100644 --- a/crates/ui-egui/src/menus/bar.rs +++ b/crates/ui-egui/src/menus/bar.rs @@ -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. diff --git a/crates/ui-egui/src/menus/context.rs b/crates/ui-egui/src/menus/context.rs index 8a6bfbf44..9a4284bb1 100644 --- a/crates/ui-egui/src/menus/context.rs +++ b/crates/ui-egui/src/menus/context.rs @@ -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. diff --git a/crates/ui-egui/src/menus/entries.rs b/crates/ui-egui/src/menus/entries.rs index fab26507c..fa78fef80 100644 --- a/crates/ui-egui/src/menus/entries.rs +++ b/crates/ui-egui/src/menus/entries.rs @@ -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> { diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index 9b4dd262f..a0e7acae6 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -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 +} diff --git a/crates/ui-egui/src/menus/labels.rs b/crates/ui-egui/src/menus/labels.rs index 12afaf058..c95a17c46 100644 --- a/crates/ui-egui/src/menus/labels.rs +++ b/crates/ui-egui/src/menus/labels.rs @@ -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 { 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 { + 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 { + 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 { + 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 diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 57137bee5..8e2e7a93a 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -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 Option> { 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 return None, + }; + Some(r) +} + +/// UI commands, part 2. +fn run_ui_part2(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option> { + 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 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> { + 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 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> { + 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 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> { + 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 return None, + }; + Some(r) +} + +/// UI commands, part 6. +fn run_ui_part6(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option> { + 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 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> { + 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 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> { + 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 return None, + }; + Some(r) +} + +/// UI commands, part 9. +fn run_ui_part9(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option> { + 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 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> { + 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 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> { + 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 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> { + 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 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> { + 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 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) diff --git a/crates/ui-egui/src/menus/slots.rs b/crates/ui-egui/src/menus/slots.rs index 8ecc79b9f..2dee19dcf 100644 --- a/crates/ui-egui/src/menus/slots.rs +++ b/crates/ui-egui/src/menus/slots.rs @@ -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 { diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index 3ca86c0a3..0c6bb8206 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -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 { diff --git a/crates/ui-egui/src/menus/submenus.rs b/crates/ui-egui/src/menus/submenus.rs index 56544260c..00637705e 100644 --- a/crates/ui-egui/src/menus/submenus.rs +++ b/crates/ui-egui/src/menus/submenus.rs @@ -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] = [ diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs index d806eac4a..2d3a81725 100644 --- a/crates/ui-egui/src/menus/tree.rs +++ b/crates/ui-egui/src/menus/tree.rs @@ -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`). diff --git a/crates/ui-egui/src/panels/gradient.rs b/crates/ui-egui/src/panels/gradient.rs index 08336673d..813700105 100644 --- a/crates/ui-egui/src/panels/gradient.rs +++ b/crates/ui-egui/src/panels/gradient.rs @@ -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, 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::()?; + 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::().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, Live)> = None; + let mut changed: Option = None; // The stop to select once `changed` is applied. let mut select: Option = None; let mut pick_mid: Option = 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::() - && 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, 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, + (pick_mid, select): (Option, Option), + changed: Option, + 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() { diff --git a/crates/ui-egui/src/panels/image_trace.rs b/crates/ui-egui/src/panels/image_trace.rs index f46124c8b..e19b2591b 100644 --- a/crates/ui-egui/src/panels/image_trace.rs +++ b/crates/ui-egui/src/panels/image_trace.rs @@ -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, 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| { diff --git a/crates/ui-egui/src/panels/layers/controls.rs b/crates/ui-egui/src/panels/layers/controls.rs index a8b0b3abe..f911c66ad 100644 --- a/crates/ui-egui/src/panels/layers/controls.rs +++ b/crates/ui-egui/src/panels/layers/controls.rs @@ -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 diff --git a/crates/ui-egui/src/panels/layers/model.rs b/crates/ui-egui/src/panels/layers/model.rs index 642aec542..722391911 100644 --- a/crates/ui-egui/src/panels/layers/model.rs +++ b/crates/ui-egui/src/panels/layers/model.rs @@ -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)] diff --git a/crates/ui-egui/src/panels/layers/rows.rs b/crates/ui-egui/src/panels/layers/rows.rs index 7c92566aa..5ed4c44ba 100644 --- a/crates/ui-egui/src/panels/layers/rows.rs +++ b/crates/ui-egui/src/panels/layers/rows.rs @@ -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. diff --git a/crates/ui-egui/src/panels/layers/thumbs.rs b/crates/ui-egui/src/panels/layers/thumbs.rs index cd9850212..adcadd6a2 100644 --- a/crates/ui-egui/src/panels/layers/thumbs.rs +++ b/crates/ui-egui/src/panels/layers/thumbs.rs @@ -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). diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index f82c9db91..1dc860dd8 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -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, 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, 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, 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, + before: &std::sync::Arc, + 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, 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()); } diff --git a/crates/ui-egui/src/panels/swatches/actions.rs b/crates/ui-egui/src/panels/swatches/actions.rs index 33a4078cd..6e9a56fd9 100644 --- a/crates/ui-egui/src/panels/swatches/actions.rs +++ b/crates/ui-egui/src/panels/swatches/actions.rs @@ -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 { diff --git a/crates/ui-egui/src/panels/swatches/body.rs b/crates/ui-egui/src/panels/swatches/body.rs index 259a48809..7f9c5d296 100644 --- a/crates/ui-egui/src/panels/swatches/body.rs +++ b/crates/ui-egui/src/panels/swatches/body.rs @@ -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 { diff --git a/crates/ui-egui/src/panels/swatches/dnd.rs b/crates/ui-egui/src/panels/swatches/dnd.rs index e7916fe8b..22b542a3f 100644 --- a/crates/ui-egui/src/panels/swatches/dnd.rs +++ b/crates/ui-egui/src/panels/swatches/dnd.rs @@ -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). diff --git a/crates/ui-egui/src/panels/swatches/libraries.rs b/crates/ui-egui/src/panels/swatches/libraries.rs index 3f0200398..02f149987 100644 --- a/crates/ui-egui/src/panels/swatches/libraries.rs +++ b/crates/ui-egui/src/panels/swatches/libraries.rs @@ -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) -> Vec { libs.into_iter().map(|l| LibraryRef { submenu: library_panel::submenu(l.category), id: l.id, name: l.name }).collect() diff --git a/crates/ui-egui/src/panels/swatches/tiles.rs b/crates/ui-egui/src/panels/swatches/tiles.rs index d2d36efa9..99d813620 100644 --- a/crates/ui-egui/src/panels/swatches/tiles.rs +++ b/crates/ui-egui/src/panels/swatches/tiles.rs @@ -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) { diff --git a/crates/ui-egui/src/panels/text_styles/tests.rs b/crates/ui-egui/src/panels/text_styles/tests.rs index 46007dcbc..57c5d03dd 100644 --- a/crates/ui-egui/src/panels/text_styles/tests.rs +++ b/crates/ui-egui/src/panels/text_styles/tests.rs @@ -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, Option) { 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; 3], + redo_states: [&std::sync::Arc; 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; 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; 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, local_override: Option) { + 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()))]); } } diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index 1b0488754..45f363eda 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -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. diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 251617931..2af7dd845 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -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 = [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) { // 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 = 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 = 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 = 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 = 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 = [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 diff --git a/crates/ui-egui/src/shortcuts/paste.rs b/crates/ui-egui/src/shortcuts/paste.rs index 9f43e76c5..f13cf0244 100644 --- a/crates/ui-egui/src/shortcuts/paste.rs +++ b/crates/ui-egui/src/shortcuts/paste.rs @@ -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]; diff --git a/crates/ui-egui/src/shortcuts/presentation.rs b/crates/ui-egui/src/shortcuts/presentation.rs index 691922ab7..c93ca762e 100644 --- a/crates/ui-egui/src/shortcuts/presentation.rs +++ b/crates/ui-egui/src/shortcuts/presentation.rs @@ -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; diff --git a/crates/ui-egui/src/shortcuts/typing.rs b/crates/ui-egui/src/shortcuts/typing.rs index c42e5d57d..4ed15a6b3 100644 --- a/crates/ui-egui/src/shortcuts/typing.rs +++ b/crates/ui-egui/src/shortcuts/typing.rs @@ -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 diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 8e357a340..f4e90f6d8 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -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 diff --git a/crates/ui-egui/src/widgets/blend.rs b/crates/ui-egui/src/widgets/blend.rs index 32e2712ae..5fdf065b1 100644 --- a/crates/ui-egui/src/widgets/blend.rs +++ b/crates/ui-egui/src/widgets/blend.rs @@ -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. diff --git a/crates/ui-egui/src/widgets/buttons.rs b/crates/ui-egui/src/widgets/buttons.rs index d759dcd29..44ee3c477 100644 --- a/crates/ui-egui/src/widgets/buttons.rs +++ b/crates/ui-egui/src/widgets/buttons.rs @@ -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 { diff --git a/crates/ui-egui/src/widgets/chips.rs b/crates/ui-egui/src/widgets/chips.rs index 068bd4e7d..a215b3ee8 100644 --- a/crates/ui-egui/src/widgets/chips.rs +++ b/crates/ui-egui/src/widgets/chips.rs @@ -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) { diff --git a/crates/ui-egui/src/widgets/color.rs b/crates/ui-egui/src/widgets/color.rs index 4ffdaf6a9..80c43e2e8 100644 --- a/crates/ui-egui/src/widgets/color.rs +++ b/crates/ui-egui/src/widgets/color.rs @@ -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)] diff --git a/crates/ui-egui/src/widgets/drag.rs b/crates/ui-egui/src/widgets/drag.rs index 4a73214b9..177e830e5 100644 --- a/crates/ui-egui/src/widgets/drag.rs +++ b/crates/ui-egui/src/widgets/drag.rs @@ -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. diff --git a/crates/ui-egui/src/widgets/dropdowns.rs b/crates/ui-egui/src/widgets/dropdowns.rs index db391edec..70e6920c0 100644 --- a/crates/ui-egui/src/widgets/dropdowns.rs +++ b/crates/ui-egui/src/widgets/dropdowns.rs @@ -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 { diff --git a/crates/ui-egui/src/widgets/labels.rs b/crates/ui-egui/src/widgets/labels.rs index 76c69053e..a71a0ec6a 100644 --- a/crates/ui-egui/src/widgets/labels.rs +++ b/crates/ui-egui/src/widgets/labels.rs @@ -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) { diff --git a/crates/ui-egui/src/widgets/numeric.rs b/crates/ui-egui/src/widgets/numeric.rs index 655f3f978..f2a9cc2ec 100644 --- a/crates/ui-egui/src/widgets/numeric.rs +++ b/crates/ui-egui/src/widgets/numeric.rs @@ -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. diff --git a/crates/ui-egui/src/widgets/panel_chrome.rs b/crates/ui-egui/src/widgets/panel_chrome.rs index f92114448..0dcad26fe 100644 --- a/crates/ui-egui/src/widgets/panel_chrome.rs +++ b/crates/ui-egui/src/widgets/panel_chrome.rs @@ -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(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R { diff --git a/crates/ui-egui/src/widgets/text.rs b/crates/ui-egui/src/widgets/text.rs index f3f922dfa..b4e531a52 100644 --- a/crates/ui-egui/src/widgets/text.rs +++ b/crates/ui-egui/src/widgets/text.rs @@ -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`]). diff --git a/crates/ui-egui/src/widgets/toggles.rs b/crates/ui-egui/src/widgets/toggles.rs index 26ee2e125..8dac694ec 100644 --- a/crates/ui-egui/src/widgets/toggles.rs +++ b/crates/ui-egui/src/widgets/toggles.rs @@ -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.