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