mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 04:07:12 -06:00
10616 lines
595 KiB
Rust
10616 lines
595 KiB
Rust
//! The document canvas: rendering, rulers, navigation, pointer routing to tools and on-canvas
|
||
//! selection visuals (bounding box, anchors, handles, smart-guide style labels).
|
||
|
||
use egui::{Color32, CornerRadius, Pos2, Sense, Shape, Stroke, StrokeKind, Ui, Vec2, pos2, vec2};
|
||
use serde_json::json;
|
||
use vectorcraft_doc::text::TextKind;
|
||
use vectorcraft_doc::{Node, NodeKind, SymbolBoxes, Unit};
|
||
use vectorcraft_geom::{Affine, BezPath, PathEl, Point, Rect};
|
||
use vectorcraft_tools::{Cursor, Mods, Overlay, PointerEvent, PointerKind};
|
||
|
||
use crate::opening;
|
||
use crate::state::View;
|
||
use crate::stylus::StylusFeed;
|
||
use crate::theme::{self, Tokens};
|
||
use crate::{CacheKey, VectorcraftApp, now_ms, widgets};
|
||
|
||
const RULER: f32 = 16.0;
|
||
/// The scroll bars' thickness along the window's right and bottom edges.
|
||
const SCROLL_BAR: f32 = 12.0;
|
||
|
||
/// Screen ↔ document mapping for one frame.
|
||
#[derive(Clone, Copy, Debug)]
|
||
pub struct Xf {
|
||
pub rect: egui::Rect,
|
||
pub zoom: f64,
|
||
pub center: Point,
|
||
/// View rotation in radians (Rotate View), clockwise on screen.
|
||
pub rot: f64,
|
||
}
|
||
|
||
impl Xf {
|
||
pub fn new(rect: egui::Rect, v: &View) -> Self {
|
||
Self { rect, zoom: v.zoom, center: v.center, rot: v.rotation.to_radians() }
|
||
}
|
||
pub fn to_screen(&self, p: Point) -> Pos2 {
|
||
let c = self.rect.center();
|
||
let (dx, dy) = ((p.x - self.center.x) * self.zoom, (p.y - self.center.y) * self.zoom);
|
||
let (sn, cs) = self.rot.sin_cos();
|
||
pos2(c.x + (dx * cs - dy * sn) as f32, c.y + (dx * sn + dy * cs) as f32)
|
||
}
|
||
pub fn to_doc(&self, p: Pos2) -> Point {
|
||
let c = self.rect.center();
|
||
let (dx, dy) = ((p.x - c.x) as f64, (p.y - c.y) as f64);
|
||
let (sn, cs) = self.rot.sin_cos();
|
||
let (ux, uy) = (dx * cs + dy * sn, -dx * sn + dy * cs);
|
||
Point::new(self.center.x + ux / self.zoom, self.center.y + uy / self.zoom)
|
||
}
|
||
/// Screen-space corners of a document rect (a rotated quad when the view is rotated).
|
||
pub fn quad(&self, r: Rect) -> Vec<Pos2> {
|
||
[Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)].iter().map(|p| self.to_screen(*p)).collect()
|
||
}
|
||
/// Convert a screen-space delta to a document delta.
|
||
pub fn delta_to_doc(&self, d: egui::Vec2) -> vectorcraft_geom::Vec2 {
|
||
let (sn, cs) = self.rot.sin_cos();
|
||
let (dx, dy) = (d.x as f64, d.y as f64);
|
||
vectorcraft_geom::Vec2::new((dx * cs + dy * sn) / self.zoom, (-dx * sn + dy * cs) / self.zoom)
|
||
}
|
||
pub fn rect_to_screen(&self, r: Rect) -> egui::Rect {
|
||
egui::Rect::from_two_pos(self.to_screen(Point::new(r.x0, r.y0)), self.to_screen(Point::new(r.x1, r.y1)))
|
||
}
|
||
/// Affine mapping document points to screen points.
|
||
pub fn affine(&self) -> Affine {
|
||
let c = self.rect.center();
|
||
Affine::translate((c.x as f64, c.y as f64)) * Affine::rotate(self.rot) * Affine::scale(self.zoom) * Affine::translate(-self.center.to_vec2())
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
enum Drag {
|
||
Tool,
|
||
/// Hand tool / Space drag, or a middle-button drag with any tool (`middle`).
|
||
Pan {
|
||
start: Pos2,
|
||
center: Point,
|
||
middle: bool,
|
||
},
|
||
/// A Zoom tool drag without Animated Zoom: the area to zoom to.
|
||
ZoomBox {
|
||
start: Pos2,
|
||
},
|
||
/// A Zoom tool press with Animated Zoom ([`animated_zoom`]): dragged sideways it zooms about
|
||
/// `start`, held still (for `held` seconds so far) it zooms on, else it's a click.
|
||
ZoomScrub {
|
||
start: Pos2,
|
||
held: f32,
|
||
moved: bool,
|
||
},
|
||
/// A Rotate View tool drag, or a Space+Shift drag with any tool. With `snaps` (the Rotate
|
||
/// View tool), Shift rounds the angle to 15°.
|
||
RotateView {
|
||
start_angle: f64,
|
||
start_rot: f64,
|
||
snaps: bool,
|
||
},
|
||
/// A Selection tool move over a panel or toolbar: the art waits where it last moved on the
|
||
/// canvas, and the panels get it ([`widgets::PanelDrag::Art`]) if it is released there. Back
|
||
/// on the canvas the move goes on, as in Illustrator (#2826).
|
||
Art,
|
||
/// Fingers making a touch gesture ([`crate::touch`]): the first one's press does nothing.
|
||
Gesture,
|
||
/// A press in Presentation Mode: released as a click, it goes to the next artboard.
|
||
Present,
|
||
}
|
||
|
||
/// 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.
|
||
fn tool_holds(d: Drag) -> bool {
|
||
matches!(d, Drag::Tool | Drag::Art)
|
||
}
|
||
|
||
/// What a hover depends on besides the pointer's screen position.
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
struct HoverContext {
|
||
/// The active tool: a tool switched to under a still pointer is hovered at once.
|
||
tool: &'static str,
|
||
/// The canvas, and the view's centre, zoom and rotation.
|
||
canvas: egui::Rect,
|
||
center: Point,
|
||
zoom: f64,
|
||
rotation: f64,
|
||
/// The active document's id and revision (which moves with every edit, undo and selection
|
||
/// change).
|
||
doc: Option<(u64, u64)>,
|
||
}
|
||
|
||
/// Clear canvas interaction state before a tab transfer takes ownership of the artwork drag.
|
||
pub(crate) fn end_document_drag(ctx: &egui::Context) {
|
||
ctx.data_mut(|d| {
|
||
d.remove::<Drag>(drag_id());
|
||
d.remove::<Pos2>(drag_pos_id());
|
||
});
|
||
}
|
||
|
||
/// Temp-memory key for the active canvas drag state.
|
||
fn drag_id() -> egui::Id {
|
||
egui::Id::unique("canvas-drag")
|
||
}
|
||
/// Where the pointer was last seen during a press on the canvas.
|
||
fn drag_pos_id() -> egui::Id {
|
||
egui::Id::unique("canvas-drag-pos")
|
||
}
|
||
/// The layer the canvas was last drawn on: a release over another layer is over a panel (#2826).
|
||
fn drag_layer_id() -> egui::Id {
|
||
egui::Id::unique("canvas-drag-layer")
|
||
}
|
||
/// Whether `p` is over the canvas itself, not a panel or a floating window over it.
|
||
fn on_canvas(ui: &Ui, resp: &egui::Response, p: Pos2) -> bool {
|
||
resp.rect.contains(p) && ui.clip_rect().contains(p) && ui.ctx().layer_id_at(p).is_none_or(|l| l == resp.layer_id)
|
||
}
|
||
/// The document point last sent to the tool as a drag during [`Drag::Tool`]; none until the press
|
||
/// has dragged.
|
||
fn tool_drag_doc_id() -> egui::Id {
|
||
egui::Id::unique("canvas-tool-drag-doc")
|
||
}
|
||
/// The document point of the press that began [`Drag::Tool`], sent to the tool as its Down.
|
||
fn tool_press_doc_id() -> egui::Id {
|
||
egui::Id::unique("canvas-tool-press-doc")
|
||
}
|
||
/// Whether the tool should get another drag event, and if so records `p` as the last position
|
||
/// sent: the pointer moved this frame, or its document position changed without a delta (#834).
|
||
/// egui's frame delta is zero when some Wayland compositors coalesce motion until release, and in
|
||
/// the frame the pointer comes back into the window after leaving it (`PointerGone` forgot where
|
||
/// it was), though it is somewhere new; judged on the delta alone, the preview stayed behind.
|
||
fn tool_drag_moved(ui: &Ui, xf: &Xf, p: Pos2, pointer: &egui::PointerState) -> bool {
|
||
let at = xf.to_doc(p);
|
||
let id = tool_drag_doc_id();
|
||
// Before the press has dragged, the last position the tool got is the press itself: a press
|
||
// held still is not a drag.
|
||
let prev = ui.data(|d| d.get_temp::<Point>(id).or_else(|| d.get_temp::<Point>(tool_press_doc_id())));
|
||
if pointer.delta() == egui::Vec2::ZERO && prev == Some(at) {
|
||
return false;
|
||
}
|
||
ui.data_mut(|d| d.insert_temp(id, at));
|
||
true
|
||
}
|
||
|
||
/// The pen pressure (0..1) of the press in progress: the force of this frame's pen or touch
|
||
/// input, else the pressure in the host's feed while a pen is the pointer that moved last
|
||
/// ([`crate::Services::stylus`]: [`StylusFeed::press`] for a new press, [`StylusFeed::held`]
|
||
/// after it), else the last one seen since the press (`pressed`: a new press, whose mouse has none
|
||
/// until a pen reports it). A mouse presses fully (1).
|
||
fn pen_pressure(ui: &Ui, app: &VectorcraftApp, pressed: bool) -> f32 {
|
||
let stylus = app.services.stylus.as_ref();
|
||
let id = egui::Id::unique("canvas-pressure");
|
||
let force = ui.input(|i| {
|
||
i.events.iter().rev().find_map(|e| match e {
|
||
egui::Event::Touch { force: Some(f), .. } if f.is_finite() => Some(f.clamp(0.0, 1.0)),
|
||
_ => None,
|
||
})
|
||
});
|
||
let force = force.or_else(|| stylus.and_then(if pressed { StylusFeed::press } else { StylusFeed::held }));
|
||
let p = force.or_else(|| if pressed { None } else { ui.data(|d| d.get_temp::<f32>(id)) }).unwrap_or(1.0);
|
||
ui.data_mut(|d| d.insert_temp(id, p));
|
||
p
|
||
}
|
||
|
||
/// The positions this frame's pointer moves passed through before its last one, each with the
|
||
/// pen force a touch event gave just before it, or the pressure the host's feed kept for that move
|
||
/// ([`StylusFeed::take_moves`], macOS), if any. The last move is the pointer's position. Only for
|
||
/// pens and fingers (frames with touch events or with the feed's moves): a mouse's moves are left
|
||
/// as they were.
|
||
fn frame_moves(ui: &Ui, stylus: Option<&StylusFeed>) -> Vec<(Pos2, Option<f32>)> {
|
||
let pressures = stylus.map(StylusFeed::take_moves).unwrap_or_default();
|
||
ui.input(|i| {
|
||
let mut moves = vec![];
|
||
if i.events.iter().any(|e| matches!(e, egui::Event::Touch { .. })) {
|
||
let mut force = None;
|
||
for e in &i.events {
|
||
match e {
|
||
egui::Event::Touch { force: Some(f), .. } if f.is_finite() && *f > 0.0 => force = Some(f.clamp(0.0, 1.0)),
|
||
egui::Event::PointerMoved(p) => moves.push((*p, force)),
|
||
_ => {}
|
||
}
|
||
}
|
||
} else if !pressures.is_empty() {
|
||
// The feed's last pressure is the last move's: pair them from the end. A lift's 0 is
|
||
// no pressure (the press keeps its last touch).
|
||
let points: Vec<Pos2> = i.events.iter().filter_map(|e| if let egui::Event::PointerMoved(p) = e { Some(*p) } else { None }).collect();
|
||
let forces = pressures.iter().rev().map(|f| (*f > 0.0).then_some(*f)).chain(std::iter::repeat(None));
|
||
moves = points.into_iter().rev().zip(forces).collect();
|
||
moves.reverse();
|
||
}
|
||
moves.pop();
|
||
// A burst of events (a stalled frame) is thinned to a bounded count.
|
||
const MAX: usize = 64;
|
||
if moves.len() > MAX {
|
||
let step = moves.len().div_ceil(MAX);
|
||
moves = moves.into_iter().step_by(step).collect();
|
||
}
|
||
moves
|
||
})
|
||
}
|
||
|
||
pub fn mods(m: egui::Modifiers, space: bool) -> Mods {
|
||
Mods { shift: m.shift, alt: m.alt, cmd: m.command, ctrl: m.ctrl, space }
|
||
}
|
||
|
||
/// Fit the view (View → Fit Artboard / Fit All / Actual Size).
|
||
pub fn fit(app: &mut VectorcraftApp, how: &str) {
|
||
let Some(rect) = app.canvas_rect else {
|
||
if let Some(v) = app.view_mut() {
|
||
v.fitted = false;
|
||
}
|
||
return;
|
||
};
|
||
let current = app.view().map_or(0, |v| v.artboard);
|
||
let Some(st) = app.session.active() else { return };
|
||
let target = match how {
|
||
"view.fitAll" => {
|
||
st.doc.art_bounds().map(|a| st.doc.artboards.iter().fold(a, |r, ab| r.union(ab.rect))).or(st.doc.artboards.first().map(|a| a.rect))
|
||
}
|
||
// The navigator's artboard (the first if it has gone).
|
||
_ => st.doc.artboards.get(current).or(st.doc.artboards.first()).map(|a| a.rect),
|
||
};
|
||
let Some(target) = target else { return };
|
||
let zoom = if how == "view.actualSize" {
|
||
actual_size_zoom(app.session.prefs.display_print_size)
|
||
} else {
|
||
// The width and height of the box round the target turned with the view, at zoom 1.
|
||
let (sn, cs) = app.view().map_or(0.0, |v| v.rotation.to_radians()).sin_cos();
|
||
let (w, h) = (target.width(), target.height());
|
||
let zx = (rect.width() as f64 - 60.0) / (w * cs.abs() + h * sn.abs()).max(1.0);
|
||
let zy = (rect.height() as f64 - 60.0) / (w * sn.abs() + h * cs.abs()).max(1.0);
|
||
zx.min(zy).clamp(0.0313, 640.0)
|
||
};
|
||
let Some(v) = app.view_mut() else { return };
|
||
v.center = target.center();
|
||
v.fitted = true;
|
||
v.zoom = zoom;
|
||
}
|
||
|
||
/// Paint the document canvas, complete or cached artwork, navigation and tool overlays.
|
||
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||
let t = Tokens::get(ui.ctx());
|
||
let full = ui.available_rect_before_wrap();
|
||
// `hidden_chars` holds the marks Show Hidden Characters kept and the text nodes they were laid
|
||
// out from. Only the assignment right before the overlay call below puts them back. A frame
|
||
// that does not draw the overlay drops them, for example with the option off, with the edges
|
||
// hidden, on the Home screen or with no document open.
|
||
let hidden_chars = std::mem::take(&mut app.canvas.hidden_chars);
|
||
// A release ends the Image Trace panel's eye-button hold, also when the panel is no longer
|
||
// drawn, so a later press elsewhere does not show the source image.
|
||
if app.ui.trace_hold_source.is_some() && !ui.input(|i| i.pointer.any_down()) {
|
||
app.ui.trace_hold_source = None;
|
||
}
|
||
// The IME geometry this pass, if the Type tool gets one; `ime::end_of_pass` consumes it after
|
||
// the dialogs and panels draw.
|
||
app.ime_caret_rect = None;
|
||
// A document that opened, closed or became active since Home was chosen replaces it.
|
||
if app.ui.home.is_some_and(|k| k != crate::menus::home_key(app)) {
|
||
app.ui.home = None;
|
||
}
|
||
if crate::menus::home_showing(app) {
|
||
// No IME owner this pass (`ime_caret_rect` stays None): the end-of-pass observation marks
|
||
// the context absent and keeps any marked text as typed.
|
||
home(app, ui, full);
|
||
return;
|
||
}
|
||
if app.session.active().is_none() {
|
||
// No document and Show The Home Screen When No Documents Are Open off: an empty window.
|
||
ui.painter().rect_filled(full, 0.0, t.panel_darker);
|
||
return;
|
||
}
|
||
// Normal Screen Mode and Full Screen Mode with Menu Bar keep the scroll bars along the right
|
||
// and bottom edges; Full Screen and Presentation Mode have none.
|
||
let outer = full;
|
||
let bars = app.ui.screen_mode < 2;
|
||
let full = if bars { egui::Rect::from_min_max(full.min, full.max - vec2(SCROLL_BAR, SCROLL_BAR)) } else { full };
|
||
let rect = if app.ui.view.rulers && app.ui.screen_mode < 3 { egui::Rect::from_min_max(full.min + vec2(RULER, RULER), full.max) } else { full };
|
||
app.canvas_rect = Some(rect);
|
||
let fitted = app.view().is_some_and(|v| v.fitted);
|
||
if !fitted {
|
||
fit(app, "view.fitArtboard");
|
||
}
|
||
// Before the canvas's own interaction, so the view the canvas draws is the one they moved to.
|
||
if bars {
|
||
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));
|
||
}
|
||
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.
|
||
let wait = hover_wait(&mut app.canvas.view_moved, ViewKey { rect, zoom: v.zoom, center: v.center, rotation: v.rotation }, ui.input(|i| i.time));
|
||
if let Some(secs) = wait {
|
||
ui.ctx().request_repaint_after_secs(secs as f32);
|
||
}
|
||
let hover_still = wait.is_none();
|
||
let origin_preview = if app.ui.view.rulers && app.ui.screen_mode < 3 { ruler_input(app, ui, full, rect, &xf) } else { None };
|
||
crate::document_drag::drop_on_canvas(app, &resp, &xf);
|
||
panel_drop(app, ui, &resp, &xf);
|
||
// In Presentation Mode a right click or a long touch opens no context menu.
|
||
if app.ui.screen_mode < 3 {
|
||
context_menu(app, &resp, &xf);
|
||
}
|
||
let painter = ui.painter_at(rect);
|
||
let Some(st) = app.session.active() else { return };
|
||
let doc = st.doc.clone();
|
||
let mask_view = st.shown_mask();
|
||
|
||
// Pasteboard, artboard shadows and paper (Document Setup: the transparency grid's look, the
|
||
// simulated paper colour; a white Background Contents hides the grid).
|
||
// 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 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;
|
||
if let Some(look) = grid_look.filter(|_| grids_in_back) {
|
||
grid(&painter, &xf, doc.grid.spacing, doc.grid.subdivisions, look);
|
||
}
|
||
|
||
// Artwork raster.
|
||
let ppp = ui.ctx().pixels_per_point();
|
||
// Match Renderer::render_as before recording the cache extent or building its view.
|
||
let (w, h) = (
|
||
((rect.width() * ppp).round().max(1.0) as u32).min(u32::from(u16::MAX)),
|
||
((rect.height() * ppp).round().max(1.0) as u32).min(u32::from(u16::MAX)),
|
||
);
|
||
// View › Pixel Preview: the art as it rasterizes, one pixel per point, while a document pixel
|
||
// is bigger than a screen pixel (below that the screen render already shows it).
|
||
let pixel = (app.ui.view.pixel_preview && v.zoom * ppp as f64 > 1.0).then(|| pixel_region(&xf)).flatten();
|
||
// 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;
|
||
// 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 {
|
||
guide: ([guide_look.color.r(), guide_look.color.g(), guide_look.color.b()], guide_look.dots),
|
||
doc: st.uid as usize,
|
||
revision: st.revision,
|
||
interacting: st.interaction.is_some(),
|
||
zoom: v.zoom,
|
||
cx: v.center.x,
|
||
cy: v.center.y,
|
||
w,
|
||
h,
|
||
outline: app.ui.view.outline,
|
||
trim: app.ui.view.trim_view,
|
||
templates: app.ui.view.templates,
|
||
guides: app.ui.view.guides,
|
||
ppp,
|
||
hidden: vec![],
|
||
rot: v.rotation,
|
||
anti_alias: app.session.prefs.anti_aliased_artwork,
|
||
highlight_alternate_glyphs: app.session.prefs.highlight_alternate_glyphs,
|
||
highlight_substituted_fonts: app.session.prefs.highlight_substituted_fonts,
|
||
placed: vectorcraft_render::placed_document::generation(),
|
||
pixel,
|
||
smooth_images,
|
||
isolated: st.isolation.map(|id| id.0),
|
||
// Appearance of Black › On Screen: Display All Blacks as Rich Black.
|
||
rich_black: app.session.prefs.black_on_screen == "rich",
|
||
// A tool's drag on the canvas, during an interaction (not a dialog's preview).
|
||
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));
|
||
}
|
||
if !app.canvas.worker_started {
|
||
app.canvas.worker_started = true;
|
||
if std::env::var_os("VECTORCRAFT_SYNC_RENDER").is_none() {
|
||
app.canvas.worker = crate::render_worker::Worker::spawn(ui.ctx().clone());
|
||
}
|
||
}
|
||
// Upload finished background renders.
|
||
if let Some(done) = app.canvas.worker.as_mut().and_then(|w| w.poll())
|
||
&& done.key.doc == key.doc
|
||
{
|
||
upload(app, ui.ctx(), &done.img, done.key.pixel.is_some());
|
||
app.canvas.key = Some(done.key);
|
||
app.canvas.shown = Some((done.doc, done.opts));
|
||
app.perf.render_ms = done.ms;
|
||
app.canvas.last_ms = done.ms;
|
||
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));
|
||
}
|
||
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,
|
||
// 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;
|
||
}
|
||
}
|
||
}
|
||
if let (Some(tex), Some(k)) = (&app.canvas.texture, &app.canvas.key)
|
||
&& 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*
|
||
// rect stretches the art to the new size until the worker's frame arrives, then it snaps
|
||
// back. Rebuild the rect it was rendered for from the key's own pixel size instead.
|
||
let old_rect = {
|
||
let ppp = if k.ppp > 0.0 { k.ppp } else { 1.0 };
|
||
egui::Rect::from_center_size(rect.center(), vec2(k.w as f32 / ppp, k.h as f32 / ppp))
|
||
};
|
||
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));
|
||
}
|
||
None if (k.rot - v.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));
|
||
let b = xf.to_screen(old.to_doc(old_rect.max));
|
||
painter.image(tex.id(), egui::Rect::from_min_max(a, b), egui::Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||
}
|
||
// The view turned after the last frame was rendered, and the worker renders the frame at
|
||
// the new angle. Until it arrives, the last frame is drawn turned into this view: each
|
||
// corner goes where this view puts the document point that the corner showed.
|
||
None => {
|
||
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));
|
||
}
|
||
}
|
||
}
|
||
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 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());
|
||
let artboard_tool = app.session.tool_id() == "artboard";
|
||
let tool_labelled = artboard_tool.then(|| app.session.tool_options()["active"].as_u64()).flatten();
|
||
let editing_name = ui.data(|d| d.get_temp::<ArtboardNameEdit>(name_edit_id()).map(|e| e.index));
|
||
for (i, ab) in doc.artboards.iter().enumerate() {
|
||
let c = if i == active_ab { Color32::from_gray(0) } else { Color32::from_gray(120) };
|
||
painter.add(Shape::closed_line(xf.quad(ab.rect), Stroke::new(if i == active_ab { 1.0 } else { 0.6 }, c)));
|
||
// The bleed (Document Setup) as a red outline around the artboard.
|
||
if doc.setup.has_bleed() {
|
||
painter.add(Shape::closed_line(xf.quad(doc.setup.bleed_rect(ab.rect)), Stroke::new(1.0, t.bleed)));
|
||
}
|
||
// 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);
|
||
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);
|
||
}
|
||
}
|
||
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));
|
||
}
|
||
}
|
||
}
|
||
|
||
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();
|
||
// 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)
|
||
} else {
|
||
vec![]
|
||
};
|
||
// Gap Options' Preview shows the gaps it finds.
|
||
overlays.extend(crate::dialogs::live_paint_gaps::overlays(app));
|
||
if app.ui.view.edges && !revolve_edit {
|
||
if hover_still {
|
||
hover_highlight(app, &painter, &xf);
|
||
}
|
||
selection_overlay(app, &painter, &xf, overlays.iter().any(|o| matches!(o, Overlay::CurvePoint { .. })));
|
||
mirror_axis_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);
|
||
threads.append(&mut overlays);
|
||
overlays = threads;
|
||
}
|
||
if app.ui.view.live_paint_gaps {
|
||
overlays.extend(live_paint_gap_overlays(app));
|
||
}
|
||
if app.ui.view.hidden_chars {
|
||
app.canvas.hidden_chars = hidden_chars;
|
||
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);
|
||
}
|
||
}
|
||
|
||
/// 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
|
||
/// would hit-test the art under the pointer at every frame of the move (#2452).
|
||
fn hover_cursor(app: &mut VectorcraftApp, mods: Mods, view: vectorcraft_engine::ViewInfo, still: bool) -> Option<Cursor> {
|
||
let id = app.session.tool_id();
|
||
if !still
|
||
&& let Some((was, with, c)) = app.canvas.hover_cursor
|
||
&& (was, with) == (id, mods)
|
||
{
|
||
return Some(c);
|
||
}
|
||
let c = app.session.cursor(app.hover_doc?, mods, view);
|
||
app.canvas.hover_cursor = Some((id, mods, c));
|
||
Some(c)
|
||
}
|
||
|
||
/// How long (seconds) the view must stay still before a canvas with
|
||
/// [`crate::CanvasCache::waits_for_still`] set renders a heavy document again.
|
||
pub const STILL_SECS: f64 = 0.2;
|
||
|
||
/// How long (seconds) the view must stay still before the canvas hit-tests the art under the
|
||
/// pointer again, for Object Highlighting and the tool's cursor. While the view pans, zooms or
|
||
/// turns under a resting pointer, the document point under it changes every frame, and hit-testing
|
||
/// detailed art each time slowed every frame of the move (#2452).
|
||
pub const HOVER_STILL_SECS: f64 = 0.15;
|
||
|
||
/// The view [`hover_wait`] watches: the canvas's rectangle and the view's zoom, centre and rotation.
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
pub struct ViewKey {
|
||
rect: egui::Rect,
|
||
zoom: f64,
|
||
center: Point,
|
||
rotation: f64,
|
||
}
|
||
|
||
/// Note the view `key` at time `now` (seconds, egui's input time) → how many more seconds the
|
||
/// canvas leaves the art under the pointer alone: the view changed less than [`HOVER_STILL_SECS`]
|
||
/// ago. The first view noted counts as still.
|
||
fn hover_wait(moved: &mut Option<(ViewKey, f64)>, key: ViewKey, now: f64) -> Option<f64> {
|
||
match moved {
|
||
Some((k, at)) if *k != key => (*k, *at) = (key, now),
|
||
Some(_) => {}
|
||
None => *moved = Some((key, f64::NEG_INFINITY)),
|
||
}
|
||
let waited = moved.map_or(f64::INFINITY, |(_, at)| now - at);
|
||
// The clock went back (a new egui context): don't wait for it to catch up.
|
||
(0.0..HOVER_STILL_SECS).contains(&waited).then_some(HOVER_STILL_SECS - waited)
|
||
}
|
||
|
||
/// Note that the canvas asks for `key` at time `now` (seconds, egui's input time) → how many more
|
||
/// seconds it draws the frame `shown` reprojected instead of rendering `key`: while the last
|
||
/// render was `slow`, only the view differs between the two, and the canvas has asked for `key`
|
||
/// for less than [`STILL_SECS`]. `still` holds the key last asked for and when the canvas first
|
||
/// asked for it. None: the canvas renders `key` now, unless it is the frame shown.
|
||
fn still_wait(still: &mut Option<(CacheKey, f64)>, shown: Option<&CacheKey>, key: &CacheKey, slow: bool, now: f64) -> Option<f64> {
|
||
if still.as_ref().is_none_or(|(k, _)| k != key) {
|
||
*still = Some((key.clone(), now));
|
||
}
|
||
let waited = still.as_ref().map_or(0.0, |(_, at)| now - at);
|
||
// The clock went back (a new egui context): don't wait for it to catch up.
|
||
let waiting = (0.0..STILL_SECS).contains(&waited);
|
||
(slow && waiting && shown.is_some_and(|s| s != key && only_view_differs(s, key))).then_some(STILL_SECS - waited)
|
||
}
|
||
|
||
/// Whether two cache keys differ only in the view (zoom, center, rotation, canvas size, pixel
|
||
/// density and Pixel Preview's document pixels), with Pixel Preview on in both or off in both.
|
||
fn only_view_differs(a: &CacheKey, b: &CacheKey) -> bool {
|
||
a.pixel.is_some() == b.pixel.is_some()
|
||
&& *a == CacheKey { zoom: a.zoom, cx: a.cx, cy: a.cy, w: a.w, h: a.h, ppp: a.ppp, rot: a.rot, pixel: a.pixel, ..b.clone() }
|
||
}
|
||
|
||
fn cursor_icon(c: Cursor) -> egui::CursorIcon {
|
||
use egui::CursorIcon as C;
|
||
match c {
|
||
Cursor::Arrow
|
||
| Cursor::ArrowHollow
|
||
| Cursor::DirectAnchor
|
||
| Cursor::DirectAnchorSelected
|
||
| Cursor::ReshapeSegment
|
||
| Cursor::GroupSelection
|
||
| Cursor::CornerRadius
|
||
| Cursor::PathBracket
|
||
| Cursor::TypeWidget => C::Default,
|
||
Cursor::SelectionCopy => C::Copy,
|
||
Cursor::Move => C::Move,
|
||
Cursor::Crosshair => C::Crosshair,
|
||
Cursor::ResizeH | Cursor::ShearH => C::ResizeHorizontal,
|
||
Cursor::ResizeV | Cursor::ShearV => C::ResizeVertical,
|
||
Cursor::ResizeNwSe => C::ResizeNwSe,
|
||
Cursor::ResizeNeSw => C::ResizeNeSw,
|
||
Cursor::Rotate | Cursor::RotateToward(_) => C::Alias,
|
||
Cursor::Pen
|
||
| Cursor::PenDrawing
|
||
| Cursor::PenAdd
|
||
| Cursor::PenDelete
|
||
| Cursor::PenClose
|
||
| Cursor::PenContinue
|
||
| Cursor::PenJoin
|
||
| Cursor::PenConvert
|
||
| Cursor::AnchorPoint
|
||
| Cursor::Curvature => C::Crosshair,
|
||
Cursor::Text => C::Text,
|
||
Cursor::Hand => C::Grab,
|
||
Cursor::HandGrab => C::Grabbing,
|
||
Cursor::ZoomIn => C::ZoomIn,
|
||
Cursor::ZoomOut => C::ZoomOut,
|
||
Cursor::Eyedropper => C::Crosshair,
|
||
Cursor::NotAllowed => C::NotAllowed,
|
||
Cursor::Busy => C::Progress,
|
||
Cursor::AddStop => C::Copy,
|
||
Cursor::RemoveStop => C::NotAllowed,
|
||
Cursor::Slice => C::Crosshair,
|
||
Cursor::SliceSelect => C::Default,
|
||
Cursor::Width | Cursor::WidthAdd => C::Crosshair,
|
||
Cursor::WidthPoint => C::Move,
|
||
Cursor::PencilNew => C::Crosshair,
|
||
Cursor::Blend | Cursor::BlendObject | Cursor::BlendAnchor | Cursor::ShapeBuilder | Cursor::ShapeBuilderErase => C::Crosshair,
|
||
Cursor::PencilClose | Cursor::PencilStraight | Cursor::PencilContinue | Cursor::PaintbrushClose => C::Crosshair,
|
||
Cursor::TypeVertical => C::VerticalText,
|
||
// Not drawn yet: the cursor each borrows.
|
||
Cursor::Lasso
|
||
| Cursor::MagicWand
|
||
| Cursor::Pencil
|
||
| Cursor::Paintbrush
|
||
| Cursor::BlobBrush
|
||
| Cursor::Eraser
|
||
| Cursor::Smooth
|
||
| Cursor::PathEraser
|
||
| Cursor::Join
|
||
| Cursor::Knife
|
||
| Cursor::Shaper
|
||
| Cursor::LivePaintBucket
|
||
| Cursor::Measure
|
||
| Cursor::PrintTiling
|
||
| Cursor::SymbolSprayer
|
||
| Cursor::SymbolShifter
|
||
| Cursor::SymbolScruncher
|
||
| Cursor::SymbolSizer
|
||
| Cursor::SymbolSpinner
|
||
| Cursor::SymbolStainer
|
||
| Cursor::SymbolScreener
|
||
| Cursor::SymbolStyler
|
||
| Cursor::ObjectsOnPath
|
||
| Cursor::Scissors
|
||
| Cursor::ScissorsNotAllowed
|
||
| Cursor::MeshAdd
|
||
| Cursor::PuppetPin
|
||
| Cursor::LoadedText => cursor_icon(c.stand_in().unwrap_or(Cursor::Crosshair)),
|
||
}
|
||
}
|
||
|
||
/// Dispatch canvas pointer, keyboard and drop events through the active document view.
|
||
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
|
||
&& let Some(vm) = app.view_mut()
|
||
{
|
||
let d = Xf { rect, zoom: vm.zoom, center: vm.center, rot: vm.rotation.to_radians() }.delta_to_doc(scroll);
|
||
vm.center -= d;
|
||
}
|
||
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 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`),
|
||
// so a tap whose press and lift come in one frame has no hover position: it presses (and
|
||
// 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();
|
||
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.
|
||
if resp.long_touched() {
|
||
drop_press(app, ui, view);
|
||
}
|
||
let drag: Option<Drag> = ui.data(|d| d.get_temp(drag_id()));
|
||
// A modifier pressed or released over the canvas re-hovers the tool, so what it changes shows
|
||
// without moving the mouse (Alt switches the Shape Builder to erase mode).
|
||
let mods_changed = ui.data_mut(|d| {
|
||
let id = egui::Id::unique("canvas-mods");
|
||
let prev = d.get_temp::<egui::Modifiers>(id);
|
||
d.insert_temp(id, m);
|
||
prev.is_some_and(|prev| prev != m)
|
||
});
|
||
// Where the tool hovers: the pointer over the canvas itself, not over a floating panel, a menu,
|
||
// a popup or a dialog drawn on top of it ([`on_canvas`]).
|
||
let hover_tool = hover.filter(|p| on_canvas(ui, resp, *p));
|
||
// Whether the tool hovered last frame: coming onto the canvas re-hovers it, even after a single
|
||
// jump egui doesn't count as moving, and leaving it ends the tool's hover (below).
|
||
let was_over = ui.data_mut(|d| {
|
||
let id = egui::Id::unique("canvas-hovered");
|
||
let prev = d.get_temp::<bool>(id).unwrap_or(false);
|
||
d.insert_temp(id, hover_tool.is_some());
|
||
prev
|
||
});
|
||
// What a hover means besides where the pointer is on screen: the tool, the canvas, the view
|
||
// (scrolled, zoomed or turned by the wheel, a gesture, keys or a command) and the document and
|
||
// selection as they are (another tab, an edit, an undo). Under a still pointer, a tool switched
|
||
// to is hovered at once, and when the rest changes, a tool that asks for it
|
||
// ([`Session::tool_wants_rehover`]) is hovered again where the pointer is.
|
||
let context = HoverContext {
|
||
tool: app.session.tool_id(),
|
||
canvas: rect,
|
||
center: v.center,
|
||
zoom: v.zoom,
|
||
rotation: v.rotation,
|
||
doc: app.session.active().map(|st| (st.uid, st.revision)),
|
||
};
|
||
let (tool_switched, context_changed) = ui.data_mut(|d| {
|
||
let id = egui::Id::unique("canvas-hover-context");
|
||
let prev = d.get_temp::<HoverContext>(id);
|
||
d.insert_temp(id, context);
|
||
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));
|
||
}
|
||
return;
|
||
}
|
||
|
||
// A pen turned to its eraser end selects the Eraser, and a pen turned back to its tip selects
|
||
// the tool before it. The switch runs only while the pointer is in this window. It runs before
|
||
// this frame's press, which then uses the new tool; while a press is held, it waits for the
|
||
// release. A turn made over another window takes effect when the pointer comes in.
|
||
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.
|
||
if pointer.primary_pressed()
|
||
&& resp.hovered()
|
||
&& !middle_pan
|
||
&& let Some(p) = at
|
||
{
|
||
ui.ctx().memory_mut(|mem| mem.stop_text_input());
|
||
app.ui.flyout = None;
|
||
// The artboard's name sits above its corner. A press there renames on a double-click
|
||
// and does not start a tool gesture (a fitted artboard, a new one, Artboard Options).
|
||
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));
|
||
});
|
||
}
|
||
} else if drag.is_none()
|
||
&& resp.hovered()
|
||
&& pointer.button_pressed(egui::PointerButton::Middle)
|
||
&& let Some(start) = hover
|
||
{
|
||
// Middle-button drag pans the view whatever the tool.
|
||
ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::Pan { start, center: v.center, middle: true }));
|
||
app.session.tool_hover_ended(view);
|
||
} else if let Some(d) = drag {
|
||
// The pointer gone (a pen lifted with its press), the drag ends where it was last seen.
|
||
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 d = xf.delta_to_doc(p - start);
|
||
if let Some(vm) = app.view_mut() {
|
||
vm.center = center - d;
|
||
}
|
||
}
|
||
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 => {
|
||
if tool_drag_moved(ui, &xf, p, &pointer) {
|
||
// In a frame with touch events (a pen on Windows or the web, a finger) or a
|
||
// macOS pen's moves, every position the pen passed, not just the last: a fast
|
||
// stroke keeps its curve (a pen reports far more often than frames come).
|
||
// Mouse events, a pen's on X11 among them, give the last position only, with
|
||
// the host's latest pressure.
|
||
for (at, force) in frame_moves(ui, app.services.stylus.as_ref()) {
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
} 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 { .. } => {}
|
||
}
|
||
}
|
||
} else if let Some(p) = hover_tool
|
||
&& (pointer.is_moving() || mods_changed || !was_over || tool_switched || (context_changed && app.session.tool_wants_rehover()))
|
||
{
|
||
let ev = PointerEvent { kind: PointerKind::Move, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 };
|
||
dispatch(app, &ev, view);
|
||
}
|
||
// The pointer left the canvas (for a panel, or out of the window) or went under a floating
|
||
// panel, menu, popup or dialog, with no press the tool holds: the tool's hover ends, which no
|
||
// hover event would clear (Alt's Distance Guides).
|
||
if was_over && hover_tool.is_none() && !drag.is_some_and(tool_holds) {
|
||
app.session.tool_hover_ended(view);
|
||
ui.ctx().request_repaint();
|
||
}
|
||
// The tool works on another thread (a large selection's planar map): it checks each frame
|
||
// whether the work is done, and then shows the highlight under the pointer.
|
||
if app.session.tool_waiting() {
|
||
if let Err(e) = app.session.tool_poll(view, false) {
|
||
app.status(e.to_string());
|
||
}
|
||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(16));
|
||
}
|
||
if pointer.primary_released()
|
||
&& let Some(i) = ui.data_mut(|d| d.remove_temp::<usize>(name_press_id()))
|
||
{
|
||
select_named_artboard(app, i);
|
||
}
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if drag.is_some() || pointer.is_moving() || mods_changed {
|
||
ui.ctx().request_repaint();
|
||
}
|
||
}
|
||
|
||
/// How much one point of wheel motion zooms (as `exp(points × WHEEL_ZOOM)`).
|
||
const WHEEL_ZOOM: f64 = 0.01;
|
||
/// How fast a wheel notch's zoom glides in with Zoom with Mouse Wheel: the time constant (s) of
|
||
/// the part still to come.
|
||
const WHEEL_GLIDE: f64 = 0.05;
|
||
|
||
/// What the wheel did across frames ([`wheel`]).
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||
struct WheelTurn {
|
||
/// The last wheel turn was an Alt-wheel one.
|
||
alt: bool,
|
||
/// The zoom (natural log) wheel notches still have to glide in.
|
||
glide: f64,
|
||
}
|
||
/// Animated Zoom: how much one point of sideways drag zooms (100 points to the right double it).
|
||
const SCRUB_ZOOM: f64 = std::f64::consts::LN_2 / 100.0;
|
||
/// Animated Zoom: how far the pointer moves before a press is a drag, in points.
|
||
const SCRUB_SLOP: f32 = 3.0;
|
||
/// Animated Zoom: how long a press is held still before it zooms on, in seconds…
|
||
const HOLD_DELAY: f32 = 0.3;
|
||
/// …and how fast it zooms then (as `exp(seconds × HOLD_ZOOM)`: twice as close each second).
|
||
const HOLD_ZOOM: f64 = std::f64::consts::LN_2;
|
||
|
||
/// Performance › Animated Zoom, which needs GPU Performance as in Illustrator: the Zoom tool zooms
|
||
/// as it is dragged sideways or held, instead of zooming to the area dragged across.
|
||
pub(crate) fn animated_zoom(p: &vectorcraft_engine::Prefs) -> bool {
|
||
p.animated_zoom && p.gpu_performance
|
||
}
|
||
|
||
/// Zoom for View → Actual Size (`view.actualSize`). With Preferences › General › Display Print
|
||
/// Size at 100% Zoom off, one document point is one screen point. On, one document inch (72 pt)
|
||
/// fills an inch of the screen as the system counts it: 96 screen points, the reference density
|
||
/// that display scaling is set against (a CSS inch), whatever the scale factor.
|
||
pub(crate) fn actual_size_zoom(display_print_size: bool) -> f64 {
|
||
if display_print_size { 96.0 / 72.0 } else { 1.0 }
|
||
}
|
||
|
||
/// Zoom view `vm` of the canvas `rect` to `zoom` (clamped to the zoom range), keeping the document
|
||
/// point under screen point `p` where it is.
|
||
fn zoom_about(vm: &mut View, rect: egui::Rect, p: Pos2, zoom: f64) {
|
||
let before = Xf::new(rect, vm).to_doc(p);
|
||
vm.zoom = zoom.clamp(0.0313, 640.0);
|
||
vm.center += before - Xf::new(rect, vm).to_doc(p);
|
||
}
|
||
|
||
/// Turn view `vm` of the canvas `rect` by `degrees` clockwise, keeping the document point under
|
||
/// screen point `p` where it is.
|
||
fn rotate_about(vm: &mut View, rect: egui::Rect, p: Pos2, degrees: f64) {
|
||
let before = Xf::new(rect, vm).to_doc(p);
|
||
vm.rotation = vectorcraft_geom::normalize_deg(vm.rotation + degrees);
|
||
vm.center += before - Xf::new(rect, vm).to_doc(p);
|
||
}
|
||
|
||
/// How far a trackpad rotation turns before it turns the view, in degrees.
|
||
const ROTATE_DEAD_ZONE: f64 = 5.0;
|
||
/// How long after its last rotation event a trackpad rotation ends, in seconds.
|
||
const ROTATE_GESTURE_END: f64 = 0.25;
|
||
|
||
/// A trackpad rotation under way ([`trackpad_turn`]).
|
||
#[derive(Clone, Copy, Debug)]
|
||
struct TrackpadTurn {
|
||
/// Degrees it has turned so far, clockwise.
|
||
total: f64,
|
||
/// When its last rotation event came (egui's input time, in seconds).
|
||
last: f64,
|
||
/// It has reached [`ROTATE_DEAD_ZONE`] and turns the view.
|
||
turning: bool,
|
||
}
|
||
|
||
/// General › Trackpad Gesture to Rotate View (`on`): the degrees, clockwise, that a two-finger
|
||
/// rotation on a Mac trackpad turns the view this frame. egui sends the rotation as
|
||
/// [`egui::Event::Rotate`], from winit on macOS and from Safari's gesture events in the web build.
|
||
/// A rotation does not turn the view until it reaches [`ROTATE_DEAD_ZONE`]; it then turns the view
|
||
/// by all of it, and by each later event as it comes. It ends [`ROTATE_GESTURE_END`] after its last
|
||
/// event. Rotation events that come while fingers are on a touch screen do not turn the view.
|
||
fn trackpad_turn(ui: &Ui, on: bool) -> Option<f64> {
|
||
if !on {
|
||
return None;
|
||
}
|
||
let (deg, now) = ui.input(|i| {
|
||
let rad: f32 =
|
||
if i.multi_touch().is_some() { 0.0 } else { i.events.iter().map(|e| if let egui::Event::Rotate(r) = e { *r } else { 0.0 }).sum() };
|
||
(f64::from(rad).to_degrees(), i.time)
|
||
});
|
||
if deg == 0.0 || !deg.is_finite() {
|
||
return None;
|
||
}
|
||
let id = egui::Id::unique("canvas-trackpad-turn");
|
||
let kept: Option<TrackpadTurn> = ui.data(|d| d.get_temp(id));
|
||
let mut t = kept.filter(|t| now - t.last <= ROTATE_GESTURE_END).unwrap_or(TrackpadTurn { total: 0.0, last: now, turning: false });
|
||
t.total += deg;
|
||
t.last = now;
|
||
let turn = if t.turning {
|
||
Some(deg)
|
||
} else if t.total.abs() >= ROTATE_DEAD_ZONE {
|
||
t.turning = true;
|
||
Some(t.total)
|
||
} else {
|
||
None
|
||
};
|
||
ui.data_mut(|d| d.insert_temp(id, t));
|
||
turn
|
||
}
|
||
|
||
/// One scroll bar along its axis, in screen points: the span it scrolls over (`lo..hi`) and the
|
||
/// part of it the canvas shows (`v0..v1`).
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
struct Span {
|
||
lo: f32,
|
||
hi: f32,
|
||
v0: f32,
|
||
v1: f32,
|
||
}
|
||
|
||
impl Span {
|
||
/// The thumb on a track `len` long: where it starts and how long it is.
|
||
fn thumb(self, len: f32) -> (f32, f32) {
|
||
let total = (self.hi - self.lo).max(1.0);
|
||
let size = (len * (self.v1 - self.v0) / total).clamp(MIN_THUMB.min(len), len);
|
||
let room = total - (self.v1 - self.v0);
|
||
let at = if room > 0.0 { (self.v0 - self.lo) / room * (len - size) } else { 0.0 };
|
||
(at.clamp(0.0, (len - size).max(0.0)), size)
|
||
}
|
||
/// Screen points the view moves for each point the thumb moves on a track `len` long.
|
||
fn ratio(self, len: f32) -> f32 {
|
||
let room = (self.hi - self.lo) - (self.v1 - self.v0);
|
||
let free = len - self.thumb(len).1;
|
||
if room > 0.0 && free > 0.0 { room / free } else { 0.0 }
|
||
}
|
||
}
|
||
|
||
const MIN_THUMB: f32 = 20.0;
|
||
|
||
/// The scroll bars' spans, horizontal then vertical: the art and the artboards (`content`) as the
|
||
/// view turns them on screen, half a window past each side, and the view itself, so the thumb is
|
||
/// always on its track. The canvas has no edge of its own (no drawing area), so the content sets it.
|
||
fn scroll_spans(xf: &Xf, content: Option<Rect>) -> [Span; 2] {
|
||
let r = xf.rect;
|
||
let around = content.map(|c| egui::Rect::from_points(&xf.quad(c)).expand2(r.size() / 2.0)).filter(|b| b.is_finite());
|
||
let b = around.map_or(r, |b| b.union(r));
|
||
[Span { lo: b.left(), hi: b.right(), v0: r.left(), v1: r.right() }, Span { lo: b.top(), hi: b.bottom(), v0: r.top(), v1: r.bottom() }]
|
||
}
|
||
|
||
/// The tracks of the horizontal and the vertical scroll bar, along the bottom and the right edge
|
||
/// of the window (`outer`), as long as the canvas (`rect`) beside them.
|
||
fn scroll_tracks(rect: egui::Rect, outer: egui::Rect) -> [egui::Rect; 2] {
|
||
let corner = outer.max - vec2(SCROLL_BAR, SCROLL_BAR);
|
||
[
|
||
egui::Rect::from_min_max(pos2(rect.left(), corner.y), pos2(corner.x, outer.bottom())),
|
||
egui::Rect::from_min_max(pos2(corner.x, rect.top()), pos2(outer.right(), corner.y)),
|
||
]
|
||
}
|
||
|
||
/// The window's scroll bars ([`scroll_spans`]): a thumb dragged scrolls the view along the
|
||
/// screen's axis (a turned view too), a click on the track pages towards it, and the wheel over a
|
||
/// bar scrolls along it.
|
||
fn scroll_bars(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, outer: egui::Rect, t: &Tokens) {
|
||
let content = app.content_bounds();
|
||
let Some(v) = app.view().copied() else { return };
|
||
let xf = Xf::new(rect, &v);
|
||
let painter = ui.painter();
|
||
painter.rect_filled(egui::Rect::from_min_max(outer.max - vec2(SCROLL_BAR, SCROLL_BAR), outer.max), 0.0, t.ruler);
|
||
let hover = ui.input(|i| i.pointer.hover_pos());
|
||
// Screen points the view moves this frame, or the centre a dragged thumb puts it at.
|
||
let mut moved = egui::Vec2::ZERO;
|
||
let mut center = None;
|
||
for (k, (track, span)) in scroll_tracks(rect, outer).into_iter().zip(scroll_spans(&xf, content)).enumerate() {
|
||
let horizontal = k == 0;
|
||
let along = |p: Pos2| if horizontal { p.x - track.left() } else { p.y - track.top() };
|
||
let axis = if horizontal { vec2(1.0, 0.0) } else { vec2(0.0, 1.0) };
|
||
let len = if horizontal { track.width() } else { track.height() };
|
||
let (at, size) = span.thumb(len);
|
||
let on_thumb = |p: Pos2| (at..=at + size).contains(&along(p));
|
||
let id = egui::Id::unique(("canvas-scroll-bar", k));
|
||
let resp = ui.interact(track, id, Sense::click_and_drag());
|
||
if resp.drag_started()
|
||
&& let Some(p) = ui.input(|i| i.pointer.press_origin())
|
||
&& on_thumb(p)
|
||
{
|
||
// The span moves with the view: the drag keeps the one it started with.
|
||
let d = ThumbDrag { from: along(p), center: v.center, ratio: span.ratio(len), min: span.lo - span.v0, max: span.hi - span.v1 };
|
||
ui.data_mut(|dd| dd.insert_temp(id, d));
|
||
}
|
||
let drag: Option<ThumbDrag> = ui.data(|dd| dd.get_temp(id));
|
||
if let Some(d) = drag {
|
||
if resp.dragged()
|
||
&& let Some(p) = resp.interact_pointer_pos()
|
||
{
|
||
let by = ((along(p) - d.from) * d.ratio).clamp(d.min, d.max);
|
||
center = Some(d.center + xf.delta_to_doc(axis * by));
|
||
}
|
||
if !resp.dragged() || resp.drag_stopped() {
|
||
ui.data_mut(|dd| dd.remove::<ThumbDrag>(id));
|
||
}
|
||
} else if resp.clicked()
|
||
&& let Some(p) = resp.interact_pointer_pos()
|
||
{
|
||
// A page is most of the window, and never past the end of the span.
|
||
let page = 0.9 * (span.v1 - span.v0);
|
||
if along(p) < at {
|
||
moved -= axis * page.min(span.v0 - span.lo);
|
||
} else if along(p) > at + size {
|
||
moved += axis * page.min(span.hi - span.v1);
|
||
}
|
||
}
|
||
if resp.hovered() {
|
||
let d = ui.input(|i| i.smooth_scroll_delta);
|
||
moved -= axis * if horizontal { d.x + d.y } else { d.y };
|
||
}
|
||
painter.rect_filled(track, 0.0, t.ruler);
|
||
let edge = if horizontal { [track.left_top(), track.right_top()] } else { [track.left_top(), track.left_bottom()] };
|
||
painter.line_segment(edge, Stroke::new(1.0, t.border));
|
||
let thumb = if horizontal {
|
||
egui::Rect::from_min_size(pos2(track.left() + at, track.top()), vec2(size, track.height())).shrink2(vec2(1.0, 3.0))
|
||
} else {
|
||
egui::Rect::from_min_size(pos2(track.left(), track.top() + at), vec2(track.width(), size)).shrink2(vec2(3.0, 1.0))
|
||
};
|
||
let hot = drag.is_some() || hover.is_some_and(|p| thumb.contains(p));
|
||
painter.rect_filled(thumb, CornerRadius::same(3), if hot { t.text_dim } else { t.text_disabled });
|
||
}
|
||
let Some(vm) = app.view_mut() else { return };
|
||
if let Some(c) = center {
|
||
vm.center = c;
|
||
}
|
||
if moved != egui::Vec2::ZERO {
|
||
vm.center += xf.delta_to_doc(moved);
|
||
}
|
||
}
|
||
|
||
/// A scroll bar thumb being dragged: where along the track it was pressed, and the view's centre,
|
||
/// the span's travel ratio ([`Span::ratio`]) and how far the view could move either way then.
|
||
#[derive(Clone, Copy, Debug)]
|
||
struct ThumbDrag {
|
||
from: f32,
|
||
center: Point,
|
||
ratio: f32,
|
||
min: f32,
|
||
max: f32,
|
||
}
|
||
|
||
/// 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) {
|
||
marquee_through(p, &[r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()]);
|
||
}
|
||
|
||
/// A [`marquee`] through the screen corners `q` in turn, back to the first: a document rectangle's
|
||
/// [`Xf::quad`] in a rotated view.
|
||
fn marquee_through(p: &egui::Painter, q: &[Pos2]) {
|
||
let pts: Vec<Pos2> = q.iter().chain(q.first()).copied().collect();
|
||
p.add(Shape::line(pts.clone(), Stroke::new(1.0, Color32::from_white_alpha(230))));
|
||
for w in pts.windows(2) {
|
||
p.extend(Shape::dashed_line(&[w[0], w[1]], Stroke::new(1.0, Color32::from_gray(40)), 3.0, 3.0));
|
||
}
|
||
}
|
||
|
||
/// What the wheel, a pinch and a two-finger drag on a touch screen did over the canvas this frame:
|
||
/// a zoom factor (about the pointer) and a scroll (screen points the content moves). The wheel scrolls, Cmd- and Alt-wheel (Option
|
||
/// on the Mac) zoom. With General › Zoom with Mouse Wheel (`wheel_zooms`) the wheel and Alt-wheel
|
||
/// zoom, Shift-wheel scrolls up and down and Cmd/Ctrl-wheel sideways. `line` and `page`: points
|
||
/// per wheel line and page. `turn`: kept by the caller across frames. A mouse wheel's notches zoom
|
||
/// in a glide over a few frames, as scrolling does (#888); a trackpad's fine steps at once.
|
||
fn wheel(i: &egui::InputState, wheel_zooms: bool, line: f32, page: f32, turn: &mut WheelTurn) -> (f64, egui::Vec2) {
|
||
// Fingers on a touch screen pan the content with them (a trackpad sends scrolls instead).
|
||
let pan = i.multi_touch().map_or(egui::Vec2::ZERO, |t| t.translation_delta);
|
||
if !wheel_zooms {
|
||
// egui makes Cmd-wheel (and a pinch) its zoom and the rest a scroll it spreads over a few
|
||
// frames: the rest of an Alt-wheel turn zooms too, however soon Alt is let go.
|
||
if let Some(m) = i.events.iter().rev().find_map(|e| if let egui::Event::MouseWheel { modifiers, .. } = e { Some(modifiers) } else { None }) {
|
||
turn.alt = m.alt && !m.command;
|
||
}
|
||
let (zoom, scroll) = (f64::from(i.zoom_delta()), i.smooth_scroll_delta);
|
||
return if turn.alt && scroll != egui::Vec2::ZERO {
|
||
(zoom * (f64::from(scroll.x + scroll.y) * WHEEL_ZOOM).exp(), pan)
|
||
} else {
|
||
(zoom, scroll + pan)
|
||
};
|
||
}
|
||
// The wheel events themselves: egui's own handling turns Cmd-wheel into a zoom.
|
||
let mut zoom = i.multi_touch().map_or(1.0, |t| f64::from(t.zoom_delta));
|
||
let mut scroll = pan;
|
||
for e in &i.events {
|
||
match e {
|
||
egui::Event::MouseWheel { unit, delta, modifiers, .. } => {
|
||
let d = match unit {
|
||
egui::MouseWheelUnit::Point => *delta,
|
||
egui::MouseWheelUnit::Line => *delta * line,
|
||
egui::MouseWheelUnit::Page => *delta * page,
|
||
};
|
||
if modifiers.command {
|
||
scroll.x += d.x + d.y;
|
||
} else if modifiers.shift {
|
||
scroll.y += d.x + d.y;
|
||
} else if *unit == egui::MouseWheelUnit::Point {
|
||
zoom *= (f64::from(d.y) * WHEEL_ZOOM).exp();
|
||
scroll.x += d.x;
|
||
} else {
|
||
turn.glide += f64::from(d.y) * WHEEL_ZOOM;
|
||
scroll.x += d.x;
|
||
}
|
||
}
|
||
egui::Event::Zoom(f) => zoom *= f64::from(*f),
|
||
_ => {}
|
||
}
|
||
}
|
||
// This frame's part of the glide: what is left shrinks by e every WHEEL_GLIDE seconds.
|
||
let part = if turn.glide.abs() < 1e-3 { 1.0 } else { 1.0 - (-f64::from(i.stable_dt.min(0.1)) / WHEEL_GLIDE).exp() };
|
||
let step = turn.glide * part;
|
||
turn.glide -= step;
|
||
(zoom * step.exp(), scroll)
|
||
}
|
||
|
||
/// Enable Touch Gestures: this frame's finger contacts on the canvas ([`crate::touch`]). A second
|
||
/// finger drops the press the first one began (its tool interaction rolled back; the rest of the
|
||
/// gesture presses nothing), and a tap of two fingers undoes, one of three redoes.
|
||
fn touch_gestures(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, view: vectorcraft_engine::ViewInfo) {
|
||
if !app.session.prefs.touch_gestures {
|
||
return;
|
||
}
|
||
let (touches, now): (Vec<_>, f64) = ui.input(|i| {
|
||
let t = i.events.iter().filter_map(|e| match e {
|
||
egui::Event::Touch { id, phase, pos, .. } => Some((*id, *phase, *pos)),
|
||
_ => None,
|
||
});
|
||
(t.collect(), i.time)
|
||
});
|
||
if touches.is_empty() {
|
||
return;
|
||
}
|
||
let key = egui::Id::unique("canvas-touch");
|
||
let mut taps: crate::touch::Taps = ui.data(|d| d.get_temp(key)).unwrap_or_default();
|
||
let mut tap = None;
|
||
for (id, phase, pos) in touches {
|
||
// A gesture begins on the canvas.
|
||
if phase == egui::TouchPhase::Start && !taps.is_down() && !rect.contains(pos) {
|
||
continue;
|
||
}
|
||
// A cancelled contact (a palm the shell drops when a pen lands) keeps nothing it began.
|
||
if phase == egui::TouchPhase::Cancel {
|
||
drop_press(app, ui, view);
|
||
}
|
||
match taps.feed(id, phase, pos, now) {
|
||
Some(crate::touch::Touch::Fingers) => {
|
||
drop_press(app, ui, view);
|
||
ui.data_mut(|d| d.insert_temp(drag_id(), Drag::Gesture));
|
||
}
|
||
Some(crate::touch::Touch::Tap(n)) => tap = crate::touch::tap_command(n),
|
||
None => {}
|
||
}
|
||
}
|
||
ui.data_mut(|d| d.insert_temp(key, taps));
|
||
// In Presentation Mode a tap of two or three fingers undoes or redoes nothing.
|
||
if let Some(cmd) = tap
|
||
&& app.ui.screen_mode < 3
|
||
{
|
||
crate::menus::invoke(app, cmd, serde_json::json!({}));
|
||
}
|
||
}
|
||
|
||
/// Drop the tool press in progress, if any: nothing it began is kept, and the rest of the press
|
||
/// does nothing ([`Drag::Gesture`]).
|
||
fn drop_press(app: &mut VectorcraftApp, ui: &Ui, view: vectorcraft_engine::ViewInfo) {
|
||
match ui.data(|d| d.get_temp::<Drag>(drag_id())) {
|
||
Some(Drag::Tool | Drag::Art) => {
|
||
// The tool's mouse-up returns it to rest. A move waiting over a panel is still live
|
||
// and would otherwise outlive the drag that owns it (#2826).
|
||
// Nothing to roll back without a document; the tool still gets its mouse-up.
|
||
let _ = app.session.cancel_interaction();
|
||
dispatch(app, &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), pressure: 1.0 }, view);
|
||
egui::DragAndDrop::clear_payload(ui.ctx());
|
||
}
|
||
Some(_) => {}
|
||
None => return,
|
||
}
|
||
ui.data_mut(|d| d.insert_temp(drag_id(), Drag::Gesture));
|
||
}
|
||
|
||
/// A Selection tool move dragged off the canvas (to `p`, over a panel) becomes a panel drag of
|
||
/// the selected art ([`widgets::PanelDrag::Art`]): the art waits where it last moved on the
|
||
/// canvas, and a panel it is released on takes it (the Graphic Styles panel makes a style of it).
|
||
/// Back on the canvas, [`Drag::Art`] takes the move up again where the pointer is (#2826).
|
||
fn drag_art_out(app: &VectorcraftApp, ui: &Ui, resp: &egui::Response, p: Pos2) -> bool {
|
||
let Some(st) = app.session.active().filter(|_| app.session.tool_id() == "selection") else { return false };
|
||
// The Selection tool's interaction while it moves (or Alt-copies) the selection.
|
||
if !st.interaction.as_ref().is_some_and(|i| i.label == "Move" || i.label == "Copy") {
|
||
return false;
|
||
}
|
||
// A floating panel in an OS window of its own takes the art too: craft-ui hands that window
|
||
// the pointer while it is over it, and this window sees the pointer go.
|
||
craft_ui::docking::route_app_drag(ui.ctx(), crate::panel_docking::area_id());
|
||
if on_canvas(ui, resp, p) && ui.input(|i| i.pointer.has_pointer()) {
|
||
return false;
|
||
}
|
||
let ids = st.selection.objects.clone();
|
||
egui::DragAndDrop::set_payload(ui.ctx(), widgets::PanelDrag::Art(ids));
|
||
true
|
||
}
|
||
|
||
/// A Selection tool move released over a panel: end it before the panels see the release, so a
|
||
/// panel that takes the art ([`widgets::PanelDrag::Art`]) takes it where it was, and no move is
|
||
/// left open (#2826). The art stays where it last moved while the pointer is over the panel.
|
||
pub(crate) fn release_over_panel(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||
crate::document_drag::cleanup(app, ctx);
|
||
let art = matches!(ctx.data(|d| d.get_temp::<Drag>(drag_id())), Some(Drag::Art));
|
||
// Over a floating panel's own OS window the pointer is gone from this window, and the release
|
||
// comes here later, at a point of this window that may lie on the canvas: that panel takes it.
|
||
let gone_id = drag_id().with("art-gone");
|
||
if !ctx.input(|i| i.pointer.primary_released()) {
|
||
if art {
|
||
let gone = ctx.input(|i| !i.pointer.has_pointer());
|
||
ctx.data_mut(|d| d.insert_temp(gone_id, gone));
|
||
}
|
||
return;
|
||
}
|
||
let gone = ctx.data_mut(|d| d.remove_temp::<bool>(gone_id)).unwrap_or(false);
|
||
let p = ctx.input(|i| i.pointer.interact_pos().or(i.pointer.latest_pos()));
|
||
if !art {
|
||
return;
|
||
}
|
||
let layer = ctx.data(|d| d.get_temp::<egui::LayerId>(drag_layer_id()));
|
||
let on = !gone && p.is_some_and(|p| app.canvas_rect.is_some_and(|r| r.contains(p)) && ctx.layer_id_at(p).is_none_or(|l| Some(l) == layer));
|
||
if on {
|
||
// The canvas's release finishes the move.
|
||
return;
|
||
}
|
||
end_art_move(app, ctx);
|
||
}
|
||
|
||
/// A release in a floating panel's own OS window, during an art drag from the canvas: the move
|
||
/// ends before that panel takes the art. The main window would only see it on its next pass.
|
||
pub(crate) fn release_art_in_window(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||
let art = matches!(ctx.data(|d| d.get_temp::<Drag>(drag_id())), Some(Drag::Art));
|
||
if art && ctx.input(|i| i.pointer.primary_released()) {
|
||
end_art_move(app, ctx);
|
||
}
|
||
}
|
||
|
||
/// Roll back the art move dragged off the canvas; a panel takes the art instead.
|
||
fn end_art_move(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||
// With no document open there is nothing to roll back; the tool still gets its mouse-up.
|
||
let _ = app.session.cancel_interaction();
|
||
let view = app.view_info();
|
||
dispatch(app, &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), pressure: 1.0 }, view);
|
||
ctx.data_mut(|d| {
|
||
d.remove::<Drag>(drag_id());
|
||
d.remove::<bool>(drag_id().with("art-gone"));
|
||
});
|
||
}
|
||
|
||
/// Send a pointer event to the active tool and act on UI requests (dialogs, tool switches).
|
||
pub fn dispatch(app: &mut VectorcraftApp, ev: &PointerEvent, view: vectorcraft_engine::ViewInfo) {
|
||
let r = app.session.pointer(ev, view);
|
||
// The artboard the Artboard tool makes active is the active one (the navigator's, the
|
||
// Artboards panel's).
|
||
if app.session.tool_id() == "artboard"
|
||
&& let Some(i) = app.session.tool_options()["active"].as_u64().and_then(|i| usize::try_from(i).ok())
|
||
&& let Some(v) = app.view_mut()
|
||
{
|
||
v.artboard = i;
|
||
}
|
||
// A press with a selection tool inside an artboard makes it the active one (#693), as the
|
||
// Artboard tool's does: Paste in Place and in Front or Back then paste onto it, Align to
|
||
// Artboard aligns to it, and All on Active Artboard selects on it.
|
||
if ev.kind == PointerKind::Down
|
||
&& vectorcraft_tools::catalog::is_selection_tool(app.session.tool_id())
|
||
&& let Some(i) = app.session.active().and_then(|d| d.doc.artboard_at(ev.pos))
|
||
&& let Some(v) = app.view_mut()
|
||
{
|
||
v.artboard = i;
|
||
}
|
||
apply_requests(app, r);
|
||
}
|
||
|
||
/// Act on what a tool event asked the UI for (dialogs, tool switches), or show its error.
|
||
pub fn apply_requests(app: &mut VectorcraftApp, r: vectorcraft_engine::Result<Vec<vectorcraft_engine::UiRequest>>) {
|
||
match r {
|
||
Ok(reqs) => {
|
||
for r in reqs {
|
||
match r {
|
||
vectorcraft_engine::UiRequest::Dialog(kind, p) => crate::dialogs::open_tool_dialog(app, &kind, p),
|
||
vectorcraft_engine::UiRequest::SwitchTool(t) => app.select_tool(&t),
|
||
vectorcraft_engine::UiRequest::Status(msg) => app.status(msg),
|
||
vectorcraft_engine::UiRequest::Place(p) => crate::place::place_from_cursor(app, &p),
|
||
}
|
||
}
|
||
}
|
||
Err(e) => app.status(e.to_string()),
|
||
}
|
||
}
|
||
|
||
/// The transparency grid's 2 × 2-cell tile in the document's colours (Document Setup), cached per
|
||
/// look, and its cell size in screen points.
|
||
pub(crate) fn checker_texture(ctx: &egui::Context, setup: &vectorcraft_doc::DocSetup) -> (egui::TextureHandle, f32) {
|
||
let colors = setup.grid_colors.map(|c| crate::panels::c32(&c));
|
||
let id = egui::Id::unique("transparency-grid-tile");
|
||
let cached = ctx.data(|d| d.get_temp::<([Color32; 2], egui::TextureHandle)>(id)).filter(|(c, _)| *c == colors);
|
||
let tex = cached.map(|(_, t)| t).unwrap_or_else(|| {
|
||
let [a, b] = colors;
|
||
let img = egui::ColorImage::new([2, 2], vec![a, b, b, a]);
|
||
let opts = egui::TextureOptions { wrap_mode: egui::TextureWrapMode::Repeat, ..egui::TextureOptions::NEAREST };
|
||
let t = ctx.load_texture(TRANSPARENCY_GRID, img, opts);
|
||
ctx.data_mut(|d| d.insert_temp(id, (colors, t.clone())));
|
||
t
|
||
});
|
||
(tex, setup.grid_size.cell())
|
||
}
|
||
|
||
/// Name of the transparency grid's texture.
|
||
pub(crate) const TRANSPARENCY_GRID: &str = "transparency grid";
|
||
|
||
/// The transparency grid over the artboard `ab` (screen corners `quad`): one textured quad whose
|
||
/// cells stay `cell` screen points at every zoom and turn with a rotated view.
|
||
fn checker(p: &egui::Painter, quad: &[Pos2], ab: Rect, zoom: f64, tex: egui::TextureId, cell: f32) {
|
||
// Two cells per texture repeat.
|
||
let per = (2.0 * cell as f64 / zoom).max(1e-9);
|
||
textured_quad(p, tex, quad, ((ab.width() / per) as f32, (ab.height() / per) as f32));
|
||
}
|
||
|
||
/// One quad drawn with `tex`: its corners `quad` (from the top left, clockwise) show the texture
|
||
/// from (0, 0) to (`u`, `v`).
|
||
fn textured_quad(p: &egui::Painter, tex: egui::TextureId, quad: &[Pos2], (u, v): (f32, f32)) {
|
||
let mut mesh = egui::Mesh::with_texture(tex);
|
||
for (pos, uv) in quad.iter().zip([pos2(0.0, 0.0), pos2(u, 0.0), pos2(u, v), pos2(0.0, v)]) {
|
||
mesh.vertices.push(egui::epaint::Vertex { pos: *pos, uv, color: Color32::WHITE });
|
||
}
|
||
mesh.indices.extend([0, 1, 2, 0, 2, 3]);
|
||
p.add(Shape::mesh(mesh));
|
||
}
|
||
|
||
/// The zoom the pixel grid shows from in Pixel Preview (600%), and its colour: a translucent grey,
|
||
/// so the art's pixels read through it.
|
||
const PIXEL_GRID_ZOOM: f64 = 6.0;
|
||
const PIXEL_GRID: Color32 = Color32::from_rgba_premultiplied(64, 64, 64, 96);
|
||
|
||
/// The look of the grid or of the guides (Preferences › Guides & Grid › Color and Style).
|
||
#[derive(Clone, Copy)]
|
||
struct LineLook {
|
||
color: Color32,
|
||
dots: bool,
|
||
}
|
||
|
||
impl LineLook {
|
||
fn grid(prefs: &vectorcraft_engine::Prefs) -> Self {
|
||
Self { color: pref_color(&prefs.grid_color, Color32::from_gray(200)), dots: prefs.grid_style == "dots" }
|
||
}
|
||
|
||
fn guides(prefs: &vectorcraft_engine::Prefs, t: &Tokens) -> Self {
|
||
Self { color: pref_color(&prefs.guide_color, t.guide), dots: prefs.guide_style == "dots" }
|
||
}
|
||
|
||
/// A guide from `a` to `b`: a line, or dots.
|
||
fn guide(self, p: &egui::Painter, a: Pos2, b: Pos2) {
|
||
if self.dots {
|
||
p.extend(Shape::dotted_line(&[a, b], self.color, 4.0, 0.75));
|
||
} else {
|
||
p.line_segment([a, b], Stroke::new(1.0, self.color));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A `#rrggbb` colour preference, `fallback` when it doesn't parse.
|
||
fn pref_color(hex: &str, fallback: Color32) -> Color32 {
|
||
vectorcraft_color::Color::from_hex(hex).map_or(fallback, |c| crate::panels::c32(&c))
|
||
}
|
||
|
||
/// The view in document space: the box round the canvas's corners (the view may be rotated).
|
||
fn view_bounds(xf: &Xf) -> Rect {
|
||
let r = xf.rect;
|
||
let corners = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()].map(|c| xf.to_doc(c));
|
||
corners.iter().fold(Rect::from_points(corners[0], corners[0]), |b, c| b.union_pt(*c))
|
||
}
|
||
|
||
/// The part of the segment from `a` to `b` inside `r`, none when the segment misses it.
|
||
fn clip_segment(a: Pos2, b: Pos2, r: egui::Rect) -> Option<(Pos2, Pos2)> {
|
||
let d = b - a;
|
||
let (mut t0, mut t1) = (0.0_f32, 1.0_f32);
|
||
// Each edge as (the run toward it, the room before it).
|
||
for (run, room) in [(-d.x, a.x - r.left()), (d.x, r.right() - a.x), (-d.y, a.y - r.top()), (d.y, r.bottom() - a.y)] {
|
||
if run == 0.0 {
|
||
if room < 0.0 {
|
||
return None;
|
||
}
|
||
} else if run < 0.0 {
|
||
t0 = t0.max(room / run);
|
||
} else {
|
||
t1 = t1.min(room / run);
|
||
}
|
||
}
|
||
(t0 <= t1).then(|| (a + d * t0, a + d * t1))
|
||
}
|
||
|
||
/// The grid (View → Show Grid): a gridline every `spacing` and `subdiv` lighter lines between
|
||
/// (left out when they would crowd). In the Dots style, a dot where gridlines cross the
|
||
/// subdivisions instead.
|
||
fn grid(p: &egui::Painter, xf: &Xf, spacing: f64, subdiv: u32, look: LineLook) {
|
||
// The painter clips to the canvas.
|
||
let view = view_bounds(xf);
|
||
let sub = spacing / subdiv.max(1) as f64;
|
||
let step = if sub * xf.zoom >= 6.0 { sub } else { spacing };
|
||
// Dots crowd sooner than lines.
|
||
if step * xf.zoom < 4.0 || (look.dots && spacing * xf.zoom < 8.0) {
|
||
return;
|
||
}
|
||
let major = |v: f64| (v / spacing - (v / spacing).round()).abs() < 1e-6;
|
||
// The lines across the view (where along their axis), at least 4 px apart (and capped).
|
||
let lines = |from: f64, to: f64| {
|
||
let first = (from / step).floor();
|
||
(0..10_000u32).map(move |i| (first + f64::from(i)) * step).take_while(move |v| *v <= to)
|
||
};
|
||
if look.dots {
|
||
let mut mesh = egui::Mesh::default();
|
||
for x in lines(view.x0, view.x1) {
|
||
for y in lines(view.y0, view.y1).filter(|y| major(x) || major(*y)) {
|
||
mesh.add_colored_rect(egui::Rect::from_center_size(xf.to_screen(Point::new(x, y)), vec2(1.5, 1.5)), look.color);
|
||
}
|
||
}
|
||
p.add(Shape::mesh(mesh));
|
||
return;
|
||
}
|
||
let minor = look.color.gamma_multiply(0.4);
|
||
let stroke = |major: bool| Stroke::new(1.0, if major { look.color } else { minor });
|
||
for x in lines(view.x0, view.x1) {
|
||
p.line_segment([xf.to_screen(Point::new(x, view.y0)), xf.to_screen(Point::new(x, view.y1))], stroke(major(x)));
|
||
}
|
||
for y in lines(view.y0, view.y1) {
|
||
p.line_segment([xf.to_screen(Point::new(view.x0, y)), xf.to_screen(Point::new(view.x1, y))], stroke(major(y)));
|
||
}
|
||
}
|
||
|
||
/// A ruler label for `v` (in the ruler's unit) on a ruler whose labels are `step` apart: whole
|
||
/// numbers, or the fewest decimals that write `step` exactly, so a binary step keeps its value
|
||
/// (an inch ruler's 1/16 reads `0.0625`, not the `0.06` that rounding the step's magnitude gave).
|
||
/// How far down the left ruler a label of `chars` characters reaches, counting from its top: it
|
||
/// is spelled one character per line, [`RULER_CHAR`] apart, and the last one is a line tall.
|
||
pub(crate) fn vertical_label_height(chars: usize) -> f32 {
|
||
chars.saturating_sub(1) as f32 * RULER_CHAR + RULER_LINE
|
||
}
|
||
|
||
/// A ruler label's line spacing and line height on the left ruler.
|
||
const RULER_CHAR: f32 = 8.5;
|
||
const RULER_LINE: f32 = 9.5;
|
||
|
||
pub(crate) fn ruler_label(v: f64, step: f64) -> String {
|
||
let exact = |d: u32| (step * 10f64.powi(d as i32)).fract().abs() < 1e-9;
|
||
let decimals = (0..=7).find(|d| exact(*d)).unwrap_or(7) as usize;
|
||
let s = format!("{v:.decimals$}");
|
||
// Trailing zeros go, as `Unit::number` drops them: a sixteenth-inch ruler labels the inch `1`,
|
||
// not `1.0000`, which the vertical ruler would spell down six lines.
|
||
let s = if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.') } else { &s };
|
||
if s.trim_start_matches('-').chars().all(|c| c == '0' || c == '.') { "0".into() } else { s.into() }
|
||
}
|
||
|
||
/// The rulers, numbered in `unit` (the General unit).
|
||
/// The top ruler, the left ruler and the box where they meet.
|
||
fn ruler_rects(full: egui::Rect) -> [egui::Rect; 3] {
|
||
let top = egui::Rect::from_min_max(pos2(full.left() + RULER, full.top()), pos2(full.right(), full.top() + RULER));
|
||
let left = egui::Rect::from_min_max(pos2(full.left(), full.top() + RULER), pos2(full.left() + RULER, full.bottom()));
|
||
[top, left, egui::Rect::from_min_size(full.min, vec2(RULER, RULER))]
|
||
}
|
||
|
||
/// A drag from a ruler onto the canvas makes a guide where the button is released (the engine's
|
||
/// `Session::ruler_guide`): a horizontal one from the top ruler, a vertical one from the left
|
||
/// ruler, snapped as a moved guide is (with Shift to the ruler's ticks). Released anywhere else,
|
||
/// it makes none. The corner drags the rulers' origin or resets it on a double-click.
|
||
fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, canvas: egui::Rect, xf: &Xf) -> Option<Point> {
|
||
let [top, left, corner] = ruler_rects(full);
|
||
// Right-click on a ruler or the origin box: the document units, to swap between them as
|
||
// Preferences ▸ Units ▸ General does ([`crate::menus::ruler_menu_body`]).
|
||
let mut unit_clicked = None;
|
||
for (r, vertical, id) in [(top, false, "ruler-top"), (left, true, "ruler-left")] {
|
||
// Click-and-drag so the same widget drags out guides (left) and opens the unit menu (right).
|
||
let resp = ui.interact(r, egui::Id::unique(id), Sense::click_and_drag());
|
||
resp.context_menu(|ui| crate::menus::ruler_menu_body(app, ui, &mut unit_clicked));
|
||
let kind = if resp.drag_stopped() {
|
||
PointerKind::Up
|
||
} else if resp.dragged() {
|
||
PointerKind::Drag
|
||
} else {
|
||
continue;
|
||
};
|
||
let p = resp.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.latest_pos()));
|
||
let on_canvas = p.is_some_and(|p| canvas.contains(p));
|
||
if on_canvas {
|
||
// The new guide shows even if guides were hidden.
|
||
app.ui.view.guides = true;
|
||
}
|
||
let ev = PointerEvent { kind, pos: p.map_or(Point::ZERO, |p| xf.to_doc(p)), mods: mods(ui.input(|i| i.modifiers), false), pressure: 1.0 };
|
||
if let Err(e) = app.session.ruler_guide(vertical, &ev, on_canvas, app.view_info()) {
|
||
app.status(e.to_string());
|
||
}
|
||
}
|
||
let corner = ui.interact(corner, egui::Id::unique("ruler-corner"), Sense::click_and_drag());
|
||
corner.context_menu(|ui| crate::menus::ruler_menu_body(app, ui, &mut unit_clicked));
|
||
if let Some((id, p)) = unit_clicked {
|
||
crate::menus::invoke(app, &id, p);
|
||
}
|
||
if corner.double_clicked_by(egui::PointerButton::Primary) {
|
||
let artboard = app.view().map_or(0, |v| v.artboard);
|
||
if let Err(e) = app.run("document.resetRulerOrigin", json!({"artboard": artboard})) {
|
||
app.status(e.to_string());
|
||
}
|
||
}
|
||
// Keep previews in the UI: only a release on the canvas edits the document, once. Cancelling
|
||
// never adds an undo step or interferes with the drawing tool's interaction.
|
||
let drag_id = ruler_origin_drag_id();
|
||
let uid = app.session.active().map(|d| d.uid);
|
||
if corner.drag_started_by(egui::PointerButton::Primary) {
|
||
ui.data_mut(|d| d.insert_temp(drag_id, (uid, false)));
|
||
}
|
||
let mut preview = None;
|
||
if let Some((started_in, mut cancelled)) = ui.data(|d| d.get_temp::<(Option<u64>, bool)>(drag_id)) {
|
||
cancelled |= started_in != uid || ui.input(|i| !i.focused || i.key_pressed(egui::Key::Escape));
|
||
let at = corner.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.latest_pos())).filter(|p| canvas.contains(*p)).map(|p| xf.to_doc(p));
|
||
if corner.drag_stopped_by(egui::PointerButton::Primary) {
|
||
ui.data_mut(|d| d.remove::<(Option<u64>, bool)>(drag_id));
|
||
if !cancelled
|
||
&& let Some(at) = at
|
||
&& let Err(e) = app.run("document.setRulerOrigin", json!({"x": at.x, "y": at.y, "artboard": app.view().map_or(0, |v| v.artboard)}))
|
||
{
|
||
app.status(e.to_string());
|
||
}
|
||
} else if !ui.input(|i| i.pointer.primary_down()) {
|
||
ui.data_mut(|d| d.remove::<(Option<u64>, bool)>(drag_id));
|
||
} else {
|
||
ui.data_mut(|d| d.insert_temp(drag_id, (started_in, cancelled)));
|
||
if !cancelled {
|
||
preview = at;
|
||
}
|
||
}
|
||
}
|
||
preview
|
||
}
|
||
|
||
/// The key of the ruler origin drag's state in egui's temporary data.
|
||
fn ruler_origin_drag_id() -> egui::Id {
|
||
egui::Id::unique("ruler-origin-drag")
|
||
}
|
||
|
||
/// Is a drag from the rulers' corner in progress? It stays true after Escape cancels the move, until
|
||
/// the button is released.
|
||
pub(crate) fn ruler_origin_dragging(ctx: &egui::Context) -> bool {
|
||
ctx.data(|d| d.get_temp::<(Option<u64>, bool)>(ruler_origin_drag_id()).is_some())
|
||
}
|
||
|
||
/// What the rulers count, and from where: `unit`, from `origin` (document points), with each
|
||
/// unit stretched `stretch` times along the document's x and y, which a video ruler's pixel aspect
|
||
/// ratio makes differ.
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
pub(crate) struct RulerScale {
|
||
pub(crate) unit: Unit,
|
||
pub(crate) origin: Point,
|
||
pub(crate) stretch: [f64; 2],
|
||
}
|
||
|
||
impl RulerScale {
|
||
/// The rulers in `unit` (the General unit), from `origin` (the ruler origin).
|
||
pub(crate) fn document(unit: Unit, origin: Point) -> Self {
|
||
Self { unit, origin, stretch: [1.0, 1.0] }
|
||
}
|
||
|
||
/// View › Rulers › Show Video Rulers on artboard `ab`: pixels from its top-left corner, each
|
||
/// pixel as many points wide as its Video Ruler Pixel Aspect Ratio and one point tall, so the
|
||
/// rulers read the artboard's size in video pixels
|
||
/// (https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/set-video-display-options-for-artboards.html).
|
||
pub(crate) fn video(ab: &vectorcraft_doc::Artboard) -> Self {
|
||
Self { unit: Unit::Pixels, origin: Point::new(ab.rect.x0, ab.rect.y0), stretch: [ab.video_ruler_pixel_aspect_ratio, 1.0] }
|
||
}
|
||
|
||
/// How far document point `p` lies from the origin along `axis` (a unit direction in the
|
||
/// document), in points of this scale: each document coordinate divided by its stretch.
|
||
/// Divided by the unit's points, it is the value the ruler reads there.
|
||
pub(crate) fn along(&self, p: Point, axis: vectorcraft_geom::Vec2) -> f64 {
|
||
let d = p - self.origin;
|
||
d.x / self.stretch[0] * axis.x + d.y / self.stretch[1] * axis.y
|
||
}
|
||
|
||
/// The screen points one point of this scale spans along `axis` at `zoom` (screen points per
|
||
/// document point): `zoom` itself when nothing is stretched.
|
||
pub(crate) fn zoom_along(&self, zoom: f64, axis: vectorcraft_geom::Vec2) -> f64 {
|
||
zoom / (axis.x * axis.x / self.stretch[0] + axis.y * axis.y / self.stretch[1])
|
||
}
|
||
}
|
||
|
||
fn rulers(ui: &Ui, full: egui::Rect, xf: &Xf, hover: Option<Point>, scale: RulerScale, t: &Tokens) {
|
||
let p = ui.painter();
|
||
let [top, left, corner] = ruler_rects(full);
|
||
for r in [top, left, corner] {
|
||
p.rect_filled(r, 0.0, t.ruler);
|
||
}
|
||
p.line_segment([top.left_bottom(), top.right_bottom()], Stroke::new(1.0, t.border));
|
||
p.line_segment([left.right_top(), left.right_bottom()], Stroke::new(1.0, t.border));
|
||
// Crosshair in the origin box.
|
||
p.line_segment([corner.center() - vec2(4.0, 0.0), corner.center() + vec2(4.0, 0.0)], Stroke::new(1.0, t.ruler_tick));
|
||
p.line_segment([corner.center() - vec2(0.0, 4.0), corner.center() + vec2(0.0, 4.0)], Stroke::new(1.0, t.ruler_tick));
|
||
let font = egui::FontId::proportional(9.5);
|
||
// Each ruler measures along its own edge, in the canvas's coordinates turned with the view
|
||
// (#826): at 0° the top one reads x and the left one y. `draw` gets each tick's distance
|
||
// (screen points) from `from` along the ruler, its length, and the label of a labelled one.
|
||
let ticks = |from: Pos2, to: Pos2, axis: vectorcraft_geom::Vec2, draw: &mut dyn FnMut(f32, f32, Option<String>)| {
|
||
// Pick a label step (in the unit) that gives ≥ 50 px between labels on this ruler, at its
|
||
// own scale; positions below are in the unit (`per` points of the scale each).
|
||
let zoom = scale.zoom_along(xf.zoom, axis);
|
||
let (per, step) = (scale.unit.points(), scale.unit.ruler_step(zoom));
|
||
let minor = scale.unit.ruler_minor(step);
|
||
let near = |v: f64, every: f64| ((v / every).round() * every - v).abs() < minor * 0.01;
|
||
let (a, b) = (scale.along(xf.to_doc(from), axis), scale.along(xf.to_doc(to), axis));
|
||
let mut v = (a / per / minor).floor() * minor;
|
||
while v * per <= b {
|
||
// A tick's length: the labelled ones longest, the halves between them shorter.
|
||
let (len, label) = if near(v, step) {
|
||
(RULER, Some(ruler_label(v, step)))
|
||
} else if near(v, step / 2.0) {
|
||
(7.0, None)
|
||
} else {
|
||
(4.0, None)
|
||
};
|
||
draw(((v * per - a) * zoom) as f32, len, label);
|
||
v += minor;
|
||
}
|
||
};
|
||
// The canvas's directions along the screen's x and y.
|
||
let (sn, cs) = xf.rot.sin_cos();
|
||
let clip_top = p.with_clip_rect(top);
|
||
ticks(top.left_top(), top.right_top(), vectorcraft_geom::Vec2::new(cs, -sn), &mut |d, len, label| {
|
||
let sx = top.left() + d;
|
||
clip_top.line_segment([pos2(sx, top.bottom() - len), pos2(sx, top.bottom())], Stroke::new(1.0, t.ruler_tick));
|
||
if let Some(text) = label {
|
||
clip_top.text(pos2(sx + 2.0, top.top() + 1.0), egui::Align2::LEFT_TOP, text, font.clone(), t.ruler_tick);
|
||
}
|
||
});
|
||
let clip_left = p.with_clip_rect(left);
|
||
// A label spelled down the ruler is tall, and a long one can reach the next label's place:
|
||
// "-0.0625" is seven characters, 60 pt of ruler, where a sixteenth-inch step leaves 54 pt at
|
||
// zoom 12. The ticks all stay; a label that would run into the one above it is left out.
|
||
let mut filled_to = f32::NEG_INFINITY;
|
||
ticks(left.left_top(), left.left_bottom(), vectorcraft_geom::Vec2::new(sn, cs), &mut |d, len, label| {
|
||
let sy = left.top() + d;
|
||
clip_left.line_segment([pos2(left.right() - len, sy), pos2(left.right(), sy)], Stroke::new(1.0, t.ruler_tick));
|
||
if let Some(text) = label {
|
||
// Vertical labels read top-to-bottom, one digit per line like Illustrator.
|
||
let top = sy + 2.0;
|
||
if top < filled_to {
|
||
return;
|
||
}
|
||
filled_to = top + vertical_label_height(text.chars().count());
|
||
for (k, ch) in text.chars().enumerate() {
|
||
clip_left.text(
|
||
pos2(left.left() + 4.0, top + k as f32 * RULER_CHAR),
|
||
egui::Align2::LEFT_TOP,
|
||
ch.to_string(),
|
||
font.clone(),
|
||
t.ruler_tick,
|
||
);
|
||
}
|
||
}
|
||
});
|
||
if let Some(h) = hover {
|
||
let s = xf.to_screen(h);
|
||
clip_top.line_segment([pos2(s.x, top.top()), pos2(s.x, top.bottom())], Stroke::new(1.0, t.text));
|
||
clip_left.line_segment([pos2(left.left(), s.y), pos2(left.right(), s.y)], Stroke::new(1.0, t.text));
|
||
}
|
||
}
|
||
|
||
fn to_screen_path(bp: &BezPath, xf: &Xf) -> Vec<Vec<Pos2>> {
|
||
// Flatten for drawing: a polyline per subpath (tolerance ~0.25 screen px).
|
||
let mut out: Vec<Vec<Pos2>> = vec![];
|
||
let a = xf.affine();
|
||
let mut t = bp.clone();
|
||
t.apply_affine(a);
|
||
let mut cur: Vec<Pos2> = vec![];
|
||
kurbo_flatten(&t, 0.25, &mut |el| match el {
|
||
PathEl::MoveTo(p) => {
|
||
if cur.len() > 1 {
|
||
out.push(std::mem::take(&mut cur));
|
||
}
|
||
cur.clear();
|
||
cur.push(pos2(p.x as f32, p.y as f32));
|
||
}
|
||
PathEl::LineTo(p) => cur.push(pos2(p.x as f32, p.y as f32)),
|
||
PathEl::ClosePath => {
|
||
if let Some(f) = cur.first().copied() {
|
||
cur.push(f);
|
||
}
|
||
}
|
||
_ => {}
|
||
});
|
||
if cur.len() > 1 {
|
||
out.push(cur);
|
||
}
|
||
out
|
||
}
|
||
|
||
fn stroke_path(p: &egui::Painter, bp: &BezPath, xf: &Xf, s: Stroke) {
|
||
for line in to_screen_path(bp, xf) {
|
||
p.add(Shape::line(line, s));
|
||
}
|
||
}
|
||
|
||
fn c32(rgb: [u8; 3]) -> Color32 {
|
||
Color32::from_rgb(rgb[0], rgb[1], rgb[2])
|
||
}
|
||
|
||
/// Outline of a node for highlighting (paths, compound children, area type frames, other
|
||
/// text/image bounds, symbol instances' bounds measured by their art from `symbols`; a clip
|
||
/// group's clipping path only, as [`walk_drawn`] visits it).
|
||
fn node_outline(n: &Node, symbols: &SymbolBoxes) -> BezPath {
|
||
let mut bp = BezPath::new();
|
||
walk_drawn(n, &mut |c| match &c.kind {
|
||
NodeKind::Path { path, .. } => bp.extend(path.to_bezpath()),
|
||
// Area type shows its frame: the type area Direct Selection reshapes; type on a path its
|
||
// path, which the selection tools' brackets sit on; point type shows its baseline(s).
|
||
NodeKind::Text(t) => {
|
||
if c.extras().perspective.is_none() {
|
||
if let Some(path) = t.area_frame().or_else(|| t.type_path()) {
|
||
bp.extend(path.to_bezpath());
|
||
} else if matches!(t.kind, vectorcraft_doc::TextKind::Point) {
|
||
for (a, b) in t.baselines() {
|
||
bp.move_to(t.xf * a);
|
||
bp.line_to(t.xf * b);
|
||
}
|
||
}
|
||
} else if let Some(b) = c.geometric_bounds_with(symbols) {
|
||
bp.extend(vectorcraft_geom::shapes::rectangle(b).to_bezpath());
|
||
}
|
||
}
|
||
NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => {
|
||
if let Some(b) = c.geometric_bounds_with(symbols) {
|
||
bp.extend(vectorcraft_geom::shapes::rectangle(b).to_bezpath());
|
||
}
|
||
}
|
||
// A placed document shows its box, turned with it.
|
||
NodeKind::PlacedDocument(p) => {
|
||
let [a, b, c, d] = p.corners();
|
||
bp.move_to(a);
|
||
[b, c, d].into_iter().for_each(|q| bp.line_to(q));
|
||
bp.close_path();
|
||
}
|
||
// A gradient mesh shows its mesh lines (#1111).
|
||
NodeKind::Mesh(m) => bp.extend(m.lines().to_bezpath()),
|
||
// An envelope shows its mesh (or top object), not its content.
|
||
NodeKind::Envelope { .. } => {
|
||
if let Some((lines, _)) = vectorcraft_doc::live::envelope_overlay(c) {
|
||
bp.extend(lines.to_bezpath());
|
||
}
|
||
}
|
||
_ => {}
|
||
});
|
||
bp
|
||
}
|
||
|
||
/// The width of the key object's outline (Align to Key Object), thicker than the selection's.
|
||
const KEY_OUTLINE: f32 = 2.5;
|
||
|
||
/// The most points ([`overlay_points`]) the selection's outlines and anchors, and the hover
|
||
/// highlight, are drawn for: an Image Trace of a photo selects hundreds of thousands of paths at
|
||
/// once, and selecting all of an opened PDF can select as many type objects, images and gradient
|
||
/// mesh pieces. Their outlines would make a mesh larger than the GPU takes in one buffer (#525,
|
||
/// #2460).
|
||
const OVERLAY_MAX_ANCHORS: usize = 100_000;
|
||
|
||
/// Whether `nodes` have more than [`OVERLAY_MAX_ANCHORS`] points to outline ([`overlay_points`];
|
||
/// counting stops there).
|
||
fn too_many_anchors<'a>(nodes: impl IntoIterator<Item = &'a Node>) -> bool {
|
||
fn over(n: &Node, left: &mut usize) -> bool {
|
||
match left.checked_sub(overlay_points(n)) {
|
||
Some(l) => *left = l,
|
||
None => return true,
|
||
}
|
||
// A clip group counts only its clipping path, the one child `walk_drawn` visits.
|
||
if n.clips() {
|
||
return n.children().and_then(|children| children.first()).is_some_and(|clip| over(clip, left));
|
||
}
|
||
!matches!(n.kind, NodeKind::Envelope { .. }) && n.editing_children().iter().any(|c| over(c, left))
|
||
}
|
||
let mut left = OVERLAY_MAX_ANCHORS;
|
||
nodes.into_iter().any(|n| over(n, &mut left))
|
||
}
|
||
|
||
/// The points counted for `n` itself, without its children, toward [`OVERLAY_MAX_ANCHORS`]: a
|
||
/// path's anchors; the anchors of area type's frame or of type on a path's path; the four corners
|
||
/// of the box that area type or type on a path in perspective, images, symbol instances and
|
||
/// placed documents show; six for point type, its box's corners and the ends of the baseline
|
||
/// marker a selected one shows; and the points and the ends of the mesh lines of a gradient mesh
|
||
/// or an envelope.
|
||
fn overlay_points(n: &Node) -> usize {
|
||
match &n.kind {
|
||
NodeKind::Path { path, .. } => path.anchor_count(),
|
||
NodeKind::Text(t) => match &t.kind {
|
||
vectorcraft_doc::TextKind::Point => 6,
|
||
_ if n.extras().perspective.is_some() => 4,
|
||
vectorcraft_doc::TextKind::Area { frame } => frame.anchor_count(),
|
||
vectorcraft_doc::TextKind::OnPath { path, .. } => path.anchor_count(),
|
||
},
|
||
NodeKind::Image(_) | NodeKind::SymbolInstance { .. } | NodeKind::PlacedDocument(_) => 4,
|
||
NodeKind::Mesh(m) => m.lines().anchor_count() + m.points.len(),
|
||
NodeKind::Envelope { .. } => {
|
||
vectorcraft_doc::live::envelope_overlay(n).map_or(0, |(lines, grid)| lines.anchor_count() + grid.map_or(0, |g| g.points.len()))
|
||
}
|
||
_ => 0,
|
||
}
|
||
}
|
||
|
||
/// [`Node::walk`] over what a selection highlight shows: an envelope's content is left out (the
|
||
/// envelope shows its mesh instead), and a clip group or clipping layer shows only its clipping
|
||
/// path, its first child, not the art the path clips (#2422).
|
||
fn walk_drawn<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) {
|
||
f(n);
|
||
if matches!(n.kind, NodeKind::Envelope { .. }) {
|
||
return;
|
||
}
|
||
if n.clips() {
|
||
if let Some(clip) = n.children().and_then(|children| children.first()) {
|
||
walk_drawn(clip, f);
|
||
}
|
||
return;
|
||
}
|
||
for c in n.editing_children().iter() {
|
||
walk_drawn(c, f);
|
||
}
|
||
}
|
||
|
||
/// The topmost editable object under document point `p` at `zoom`, as what is drawn there
|
||
/// ([`hit_with`]): a swatch or a symbol dropped on art.
|
||
fn hit_at(app: &VectorcraftApp, p: Point, zoom: f64) -> Option<vectorcraft_doc::hit::Hit> {
|
||
hit_with(app, p, zoom, vectorcraft_doc::hit::hit_test)
|
||
}
|
||
|
||
/// The topmost editable object under document point `p` at `zoom`, as the selection tools pick
|
||
/// it: [`hit_at`], and a clipping path on its outline before the art it clips
|
||
/// ([`vectorcraft_doc::hit::HitOptions::clip_outlines`]). Object Highlighting and a right-click's
|
||
/// selection show and take what a click of those tools would.
|
||
fn selection_hit_at(app: &VectorcraftApp, p: Point, zoom: f64) -> Option<vectorcraft_doc::hit::Hit> {
|
||
hit_with(app, p, zoom, |doc, p, opt| vectorcraft_doc::hit::hit_test(doc, p, vectorcraft_doc::hit::HitOptions { clip_outlines: true, ..opt }))
|
||
}
|
||
|
||
/// `test` on the active document at document point `p`, with the options the selection tools hit
|
||
/// objects with at `zoom` (Selection & Anchor Display › Tolerance and Object Selection by Path Only,
|
||
/// Type › Type Object Selection by Path Only, isolation mode).
|
||
fn hit_with<T>(
|
||
app: &VectorcraftApp,
|
||
p: Point,
|
||
zoom: f64,
|
||
test: impl FnOnce(&vectorcraft_doc::Document, Point, vectorcraft_doc::hit::HitOptions) -> Option<T>,
|
||
) -> Option<T> {
|
||
let prefs = &app.session.prefs;
|
||
let opt = vectorcraft_doc::hit::HitOptions {
|
||
tol: prefs.selection_tolerance / zoom,
|
||
outline: app.ui.view.outline,
|
||
path_only: prefs.object_selection_by_path_only,
|
||
type_path_only: prefs.type_selection_by_path_only,
|
||
scope: app.session.active().and_then(|st| st.isolation),
|
||
skip_guides: !app.ui.view.guides,
|
||
..Default::default()
|
||
};
|
||
test(&app.session.active()?.doc, p, opt)
|
||
}
|
||
|
||
/// 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
|
||
/// objects under the pointer ([`vectorcraft_doc::hit::locked_objects_at`]) are kept in
|
||
/// `ui.context_locked` while the menu is open, for its Unlock submenu.
|
||
fn context_menu(app: &mut VectorcraftApp, resp: &egui::Response, xf: &Xf) {
|
||
if resp.secondary_clicked()
|
||
&& let Some(p) = resp.interact_pointer_pos()
|
||
{
|
||
let p = xf.to_doc(p);
|
||
let locked = hit_with(app, p, xf.zoom, |doc, p, opt| Some(vectorcraft_doc::hit::locked_objects_at(doc, p, opt)));
|
||
app.ui.context_locked = app.session.active().map(|st| st.uid).zip(locked);
|
||
if let Some(st) = app.session.active()
|
||
&& let Some(top) = selection_hit_at(app, p, xf.zoom).map(|h| h.top_object(st.isolation))
|
||
&& !st.selection.contains(top)
|
||
{
|
||
// A locked or hidden object can't be selected; the menu is then for the selection as is.
|
||
let _ = app.run("select.set", json!({ "ids": [top.0] }));
|
||
}
|
||
}
|
||
let mut clicked = None;
|
||
if resp.context_menu(|ui| crate::menus::context_menu_body(app, ui, &mut clicked)).is_none() {
|
||
app.ui.context_locked = None;
|
||
}
|
||
if let Some((id, p)) = clicked {
|
||
crate::menus::invoke(app, &id, p);
|
||
}
|
||
}
|
||
|
||
/// A panel drag ([`widgets::PanelDrag`]) dropped on art acts on the object under the pointer,
|
||
/// selected or not: a paint (swatches, a Fill/Stroke proxy, the Gradient panel's thumbnail) goes
|
||
/// to its active proxy (`paint.setFill`/`paint.setStroke` with its `ids`; a gradient fits it), the
|
||
/// Appearance panel's thumbnail gives the object (the topmost one hit) the appearance it carries
|
||
/// (`appearance.copyFrom`), a graphic style is applied to it (`graphicStyle.apply`; with Alt, on
|
||
/// top of its appearance). A chip follows the pointer meanwhile.
|
||
fn panel_drop(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, xf: &Xf) {
|
||
crate::panels::swatches::drag_preview(app, ui.ctx());
|
||
let Some(pos) = ui.input(|i| i.pointer.interact_pos()) else { return };
|
||
// Not through a floating panel over the canvas.
|
||
if ui.ctx().layer_id_at(pos).is_some_and(|l| l != resp.layer_id) {
|
||
return;
|
||
}
|
||
let Some(d) = resp.dnd_release_payload::<widgets::PanelDrag>() else { return };
|
||
// A Libraries panel graphic: a copy centred where it is dropped.
|
||
if let widgets::PanelDrag::LibraryGraphic { library, item } = &*d {
|
||
let at = xf.to_doc(pos);
|
||
if let Err(e) = app.run("library.use", json!({"library": library, "kind": "graphic", "item": item, "center": [at.x, at.y]})) {
|
||
app.status(e);
|
||
}
|
||
return;
|
||
}
|
||
// A symbol from the Symbols panel: an instance centred where it is dropped, on art or not.
|
||
if let widgets::PanelDrag::Symbol(name) = &*d {
|
||
let at = xf.to_doc(pos);
|
||
if let Err(e) = app.run("symbol.place", json!({"name": name, "x": at.x, "y": at.y})) {
|
||
app.status(e);
|
||
}
|
||
return;
|
||
}
|
||
// A symbol from a symbol library panel: added to the document's Symbols and placed there.
|
||
if let widgets::PanelDrag::LibrarySymbol { library, name } = &*d {
|
||
let at = xf.to_doc(pos);
|
||
if let Err(e) = app.run("symbol.addFromLibrary", json!({"library": library, "name": name, "place": true, "x": at.x, "y": at.y})) {
|
||
app.status(e);
|
||
}
|
||
return;
|
||
}
|
||
let Some(hit) = hit_at(app, xf.to_doc(pos), xf.zoom) else { return };
|
||
let Some(st) = app.session.active() else { return };
|
||
let (cmd, params) = match &*d {
|
||
widgets::PanelDrag::Paint { params, .. } => {
|
||
// Colour groups paint nothing.
|
||
if params.is_null() {
|
||
return;
|
||
}
|
||
let mut params = params.clone();
|
||
params["ids"] = json!([vectorcraft_tools::xform::paint_owner(&st.doc, hit.leaf).0]);
|
||
params["focus"] = json!(false);
|
||
(crate::panels::proxy_cmd(app, false), params)
|
||
}
|
||
widgets::PanelDrag::Appearance(source) => {
|
||
let target = hit.top_object(st.isolation);
|
||
if target == *source {
|
||
return;
|
||
}
|
||
("appearance.copyFrom", json!({"source": source.0, "ids": [target.0]}))
|
||
}
|
||
widgets::PanelDrag::GraphicStyle(name) => {
|
||
let add = ui.input(|i| i.modifiers.alt);
|
||
("graphicStyle.apply", json!({"name": name, "ids": [hit.top_object(st.isolation).0], "add": add}))
|
||
}
|
||
// A brush from the Brushes panel: the path it lands on takes it (a compound path as a whole).
|
||
widgets::PanelDrag::Brush { name, .. } => {
|
||
("brush.apply", json!({"name": name, "ids": [vectorcraft_tools::xform::paint_owner(&st.doc, hit.leaf).0]}))
|
||
}
|
||
// Art dragged back onto the canvas: its move was already dropped. (A symbol was placed
|
||
// above.)
|
||
widgets::PanelDrag::Art(_)
|
||
| widgets::PanelDrag::Symbol(_)
|
||
| widgets::PanelDrag::LibrarySymbol { .. }
|
||
| widgets::PanelDrag::LibraryGraphic { .. } => return,
|
||
// A Libraries panel style or group only moves within that panel.
|
||
widgets::PanelDrag::LibraryItem { .. } | widgets::PanelDrag::LibraryGroup { .. } => return,
|
||
};
|
||
if let Err(e) = app.run(cmd, params) {
|
||
app.status(e);
|
||
}
|
||
}
|
||
|
||
fn hover_highlight(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||
let Some(h) = app.hover_doc else { return };
|
||
// Smart Guides › Object Highlighting: a Smart Guides display option, so it needs them on and Snap to Grid and Pixel Preview off.
|
||
if !app.ui.view.smart_guides || !app.session.prefs.object_highlighting || app.ui.view.snap_to_grid || app.ui.view.pixel_preview {
|
||
return;
|
||
}
|
||
if app.session.tool_busy() || !matches!(app.session.tool_id(), "selection" | "directSelection" | "groupSelection") {
|
||
return;
|
||
}
|
||
let Some(st) = app.session.active() else { return };
|
||
let Some(hit) = selection_hit_at(app, h, xf.zoom) else { return };
|
||
let id = if app.session.tool_id() == "selection" { hit.top_object(st.isolation) } else { hit.leaf };
|
||
if st.selection.contains(id) {
|
||
return;
|
||
}
|
||
if let Some(n) = st.doc.node(id).filter(|n| !too_many_anchors([*n])) {
|
||
let color = c32(st.doc.layer_color(id));
|
||
stroke_path(p, &node_outline(n, &st.doc.symbol_boxes()), xf, Stroke::new(1.5, color));
|
||
}
|
||
}
|
||
|
||
/// Selected anchors are drawn slightly deeper than the layer colour (#4f80ff → #3d82ff for Layer 1).
|
||
fn selected_anchor(c: Color32) -> Color32 {
|
||
if c == Color32::from_rgb(0x4f, 0x80, 0xff) { Color32::from_rgb(0x3d, 0x82, 0xff) } else { c }
|
||
}
|
||
|
||
/// How anchors and handles look: Selection & Anchor Display › Size and Handles.
|
||
#[derive(Clone, Copy)]
|
||
struct HandleLook<'a> {
|
||
/// Points bigger (or smaller) than the default size 3, per step of Size (1–7).
|
||
grow: f32,
|
||
/// `solid` (the default), `hollow` or `large`.
|
||
style: &'a str,
|
||
}
|
||
|
||
impl<'a> HandleLook<'a> {
|
||
fn of(prefs: &'a vectorcraft_engine::Prefs) -> Self {
|
||
Self { grow: prefs.anchor_size.clamp(1, 7) as f32 - 3.0, style: &prefs.handle_style }
|
||
}
|
||
|
||
/// A direction handle's end at `c`: a solid dot, a hollow one or a larger solid one.
|
||
fn draw(self, p: &egui::Painter, c: Pos2, color: Color32) {
|
||
let r = 2.75 + self.grow / 2.0;
|
||
let hollow = self.style == "hollow";
|
||
p.circle_filled(c, if self.style == "large" { r + 1.5 } else { r }, if hollow { Color32::WHITE } else { color });
|
||
if hollow {
|
||
p.circle_stroke(c, r, Stroke::new(1.0, color));
|
||
}
|
||
}
|
||
}
|
||
|
||
fn anchor_square(p: &egui::Painter, c: Pos2, color: Color32, filled: bool, size: f32) {
|
||
let r = egui::Rect::from_center_size(c, vec2(size, size));
|
||
if filled {
|
||
p.rect_filled(r, 0.0, color);
|
||
} else {
|
||
p.rect_filled(r, 0.0, Color32::WHITE);
|
||
p.rect_stroke(r, 0.0, Stroke::new(1.0, color), StrokeKind::Inside);
|
||
}
|
||
}
|
||
|
||
fn curve_point(p: &egui::Painter, c: Pos2, color: Color32, filled: bool, size: f32) {
|
||
p.circle(c, size / 2.0, if filled { color } else { Color32::WHITE }, Stroke::new(1.0, color));
|
||
}
|
||
|
||
/// 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`]).
|
||
fn hidden_chars_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||
let Some(st) = app.session.active() else { return };
|
||
let clip = p.clip_rect();
|
||
let color = Color32::from_rgb(0x4f, 0x9d, 0xff);
|
||
let font = egui::FontId::proportional(((10.0 * xf.zoom) as f32).clamp(7.0, 18.0));
|
||
let kept = &mut app.canvas.hidden_chars;
|
||
// `last` holds the marks of the last frame. The marks of the objects this frame doesn't draw
|
||
// are dropped at the end.
|
||
let mut last = std::mem::take(&mut kept.marks);
|
||
// The inputs are read before any layout: when fonts load during this frame, the next frame
|
||
// lays all type out again.
|
||
let inputs = (vectorcraft_text::FontDb::global().generation(), vectorcraft_text::hyphenation_exceptions());
|
||
if kept.inputs != inputs {
|
||
last.clear();
|
||
kept.inputs = inputs;
|
||
}
|
||
// `walk` visits each node's `Arc` in the order `Document::walk` visits the nodes (depth first,
|
||
// in paint order).
|
||
fn walk(nodes: &[std::sync::Arc<Node>], f: &mut impl FnMut(&std::sync::Arc<Node>)) {
|
||
for n in nodes {
|
||
f(n);
|
||
if let Some(children) = n.children() {
|
||
walk(children, f);
|
||
}
|
||
}
|
||
}
|
||
walk(&st.doc.layers, &mut |n| {
|
||
let NodeKind::Text(tx) = &n.kind else { return };
|
||
// The box round the object's corners on screen, turned with the view.
|
||
if !n.visible || n.geometric_bounds().is_none_or(|b| !egui::Rect::from_points(&xf.quad(b)).intersects(clip)) {
|
||
return;
|
||
}
|
||
let marks = match last.remove(&n.id) {
|
||
Some((was, marks)) if std::sync::Arc::ptr_eq(&was, n) => marks,
|
||
_ => std::sync::Arc::new(HiddenMarks::of(tx)),
|
||
};
|
||
for &at in &marks.spaces {
|
||
p.circle_filled(xf.to_screen(at), 1.2, color);
|
||
}
|
||
for &(at, glyph) in &marks.ends {
|
||
p.text(xf.to_screen(at), egui::Align2::LEFT_BOTTOM, glyph, font.clone(), color);
|
||
}
|
||
kept.marks.insert(n.id, (n.clone(), marks));
|
||
});
|
||
}
|
||
|
||
/// The Show Hidden Characters marks of the text objects drawn in the last frame
|
||
/// ([`crate::CanvasCache::hidden_chars`]). Each is kept with the object it was laid out from: an
|
||
/// edited object is a new `Arc` and is laid out again, and all type is laid out again when the
|
||
/// fonts or the hyphenation exceptions change.
|
||
#[derive(Default)]
|
||
pub struct HiddenChars {
|
||
/// The fonts' generation ([`vectorcraft_text::FontDb::generation`]) and the hyphenation
|
||
/// exceptions the marks were laid out with.
|
||
inputs: (u64, String),
|
||
/// Each text object drawn, by id: the node it was laid out from and its marks.
|
||
marks: std::collections::HashMap<vectorcraft_doc::NodeId, (std::sync::Arc<Node>, std::sync::Arc<HiddenMarks>)>,
|
||
}
|
||
|
||
/// One text object's marks in document space: a dot over each space, and ¶ or # after the lines
|
||
/// that end a paragraph or the story. Type on a path has none.
|
||
#[derive(Default)]
|
||
struct HiddenMarks {
|
||
spaces: Vec<Point>,
|
||
ends: Vec<(Point, &'static str)>,
|
||
}
|
||
|
||
impl HiddenMarks {
|
||
/// Lay out `tx` and place its marks.
|
||
fn of(tx: &vectorcraft_doc::TextObject) -> Self {
|
||
let mut marks = Self::default();
|
||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), tx);
|
||
if lay.on_path {
|
||
return marks;
|
||
}
|
||
let text = tx.plain_text();
|
||
let to = |q: Point| tx.xf * q;
|
||
// A list marker's glyphs (length 0) stand for no character.
|
||
for g in lay.glyphs.iter().filter(|g| g.len > 0) {
|
||
if text.get(g.byte..).is_some_and(|s| s.starts_with(' ')) {
|
||
marks.spaces.push(to(Point::new(g.origin.x + g.advance / 2.0, g.origin.y - 3.0)));
|
||
}
|
||
}
|
||
for (i, line) in lay.lines.iter().enumerate() {
|
||
let last = i + 1 == lay.lines.len();
|
||
let breaks = text.get(line.end..).is_some_and(|s| s.starts_with('\n'));
|
||
let (glyph, at) = if last && line.end >= text.len() {
|
||
("#", true)
|
||
} else if breaks {
|
||
("¶", true)
|
||
} else {
|
||
("", false)
|
||
};
|
||
if at {
|
||
marks.ends.push((to(Point::new(line.x1 + 1.0, line.baseline)), glyph));
|
||
}
|
||
}
|
||
marks
|
||
}
|
||
}
|
||
|
||
/// View › Show Text Threads: for each thread with a selected frame, a line in its first frame's
|
||
/// layer colour from each frame's out port to the next frame's in port, both showing the arrow of a
|
||
/// threaded port, where the tools draw the ports of selected type ([`vectorcraft_tools::text`]).
|
||
/// Only frames with ports ([`vectorcraft_tools::text::ported`]) are joined: a link to or from a
|
||
/// frame in perspective, which has none, gets no line. With more than
|
||
/// [`vectorcraft_tools::text::PORTS_MAX`] such frames selected the tools draw no ports, and only
|
||
/// the lines are drawn here. `zoom`: screen pixels to the document unit.
|
||
fn thread_overlays(app: &VectorcraftApp, zoom: f64) -> Vec<Overlay> {
|
||
use vectorcraft_tools::text::{Port, PortState, port_centre, ported, ports_shown, push_port};
|
||
let Some(st) = app.session.active() else { return vec![] };
|
||
let px = 1.0 / zoom.max(1e-9);
|
||
let boxes = ports_shown(&st.doc, &st.selection.objects);
|
||
let mut o = vec![];
|
||
for thread in &st.doc.text_threads {
|
||
if !thread.iter().any(|id| st.selection.objects.contains(id)) {
|
||
continue;
|
||
}
|
||
let color = st.doc.layer_color(thread[0]);
|
||
for w in thread.windows(2) {
|
||
let [a, b] = [w[0], w[1]];
|
||
let (Some(from), Some(to)) = (ported(&st.doc, a), ported(&st.doc, b)) else { continue };
|
||
let (Some(out), Some(input)) = (port_centre(from, Port::Out, px), port_centre(to, Port::In, px)) else { continue };
|
||
o.push(Overlay::Line { a: out, b: input, color, dashed: false });
|
||
if boxes {
|
||
push_port(&mut o, out, PortState::Threaded, st.doc.layer_color(a), px);
|
||
push_port(&mut o, input, PortState::Threaded, st.doc.layer_color(b), px);
|
||
}
|
||
}
|
||
}
|
||
o
|
||
}
|
||
|
||
/// View → Show Live Paint Gaps: the gaps of the selected Live Paint groups in each group's Gap
|
||
/// Preview Color ([`crate::dialogs::live_paint_gaps::selected_gaps`]). Cached per revision.
|
||
fn live_paint_gap_overlays(app: &mut VectorcraftApp) -> Vec<Overlay> {
|
||
let Some(st) = app.session.active() else { return vec![] };
|
||
let key = (st.uid, st.revision);
|
||
let gaps = match &app.canvas.live_paint_gaps {
|
||
Some((k, gaps)) if *k == key => gaps.clone(),
|
||
_ => {
|
||
let gaps = std::sync::Arc::new(crate::dialogs::live_paint_gaps::selected_gaps(app));
|
||
app.canvas.live_paint_gaps = Some((key, gaps.clone()));
|
||
gaps
|
||
}
|
||
};
|
||
gaps.to_vec()
|
||
}
|
||
|
||
/// Simplify's Show Original Path: the paths its preview ran on, as they were before it, in a band
|
||
/// 3 screen points wide of their layer's color at 40%, under the selection's outlines. Nothing is drawn
|
||
/// when they have more than [`OVERLAY_MAX_ANCHORS`] anchor points.
|
||
fn original_path_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||
let paths = crate::dialogs::path_ops::original_paths(app);
|
||
let Some(st) = app.session.active().filter(|_| paths.iter().map(|(_, path)| path.anchor_count()).sum::<usize>() <= OVERLAY_MAX_ANCHORS) else {
|
||
return;
|
||
};
|
||
for (id, path) in paths {
|
||
stroke_path(p, &path.to_bezpath(), xf, Stroke::new(3.0, c32(st.doc.layer_color(id)).gamma_multiply(0.4)));
|
||
}
|
||
}
|
||
|
||
/// The slices (unless View → Hide Slices): user and object slices outlined in the Slices
|
||
/// preferences' line colour, auto slices dashed and dimmer, each numbered at its top left (Show
|
||
/// Slice Numbers); selected slices have a bolder outline. The layout is cached per revision.
|
||
fn slice_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||
let Some(st) = app.session.active() else { return };
|
||
let key = (st.uid, st.revision);
|
||
let slices = match &app.canvas.slices {
|
||
Some((k, s)) if *k == key => s.clone(),
|
||
_ => {
|
||
let s = std::sync::Arc::new(st.doc.slice_layout());
|
||
app.canvas.slices = Some((key, s.clone()));
|
||
s
|
||
}
|
||
};
|
||
if slices.is_empty() {
|
||
return;
|
||
}
|
||
let prefs = &app.session.prefs;
|
||
let line = pref_color(&prefs.slice_line_color, Color32::from_rgb(0xff, 0x3f, 0x3f));
|
||
let dim = line.gamma_multiply(0.55);
|
||
let selected = &st.selection.slices;
|
||
let objects = &st.selection.objects;
|
||
for a in slices.iter() {
|
||
let auto = a.source == vectorcraft_doc::SliceSource::Auto;
|
||
let on = a.id.is_some_and(|id| selected.contains(&id) || objects.contains(&id));
|
||
let q = xf.quad(a.rect);
|
||
let ring: Vec<Pos2> = q.iter().chain(q.first()).copied().collect();
|
||
if auto {
|
||
p.extend(Shape::dashed_line(&ring, Stroke::new(1.0, dim), 3.0, 3.0));
|
||
} else {
|
||
p.add(Shape::line(ring, Stroke::new(if on { 2.0 } else { 1.0 }, line)));
|
||
}
|
||
if prefs.show_slice_numbers
|
||
&& let Some(tl) = q.first()
|
||
{
|
||
let font = egui::FontId::proportional(9.0);
|
||
let galley = p.layout_no_wrap(format!("{:02}", a.number), font, Color32::WHITE);
|
||
let badge = egui::Rect::from_min_size(*tl + vec2(1.0, 1.0), galley.size() + vec2(6.0, 2.0));
|
||
p.rect_filled(badge, CornerRadius::ZERO, if auto { dim } else { line });
|
||
p.galley(badge.min + vec2(3.0, 1.0), galley, Color32::WHITE);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The print tiling (View → Show Print Tiling, or the Print Tiling tool): each page of the
|
||
/// document's print settings as `print.preview` lays it out, its paper edge and its imageable
|
||
/// area dashed, numbered at its top left; tiles outside the tile range dimmer. Cached per revision.
|
||
fn print_tiling_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||
let Some(st) = app.session.active() else { return };
|
||
let key = (st.uid, st.revision);
|
||
let pages = match &app.canvas.print_tiling {
|
||
Some((k, pages)) if *k == key => pages.clone(),
|
||
_ => {
|
||
// Settings that can't print have no pages to show.
|
||
let pages = std::sync::Arc::new(vectorcraft_engine::cmd::printtiling::pages(&st.doc).unwrap_or_default());
|
||
app.canvas.print_tiling = Some((key, pages.clone()));
|
||
pages
|
||
}
|
||
};
|
||
let font = egui::FontId::proportional(10.0);
|
||
let ring = |r: [f64; 4]| {
|
||
let q = xf.quad(Rect::new(r[0], r[1], r[2], r[3]));
|
||
q.iter().chain(q.first()).copied().collect::<Vec<Pos2>>()
|
||
};
|
||
for page in pages.iter() {
|
||
let color = if page.printed { PRINT_TILING } else { PRINT_TILING.gamma_multiply(0.45) };
|
||
p.add(Shape::line(ring(page.page), Stroke::new(0.75, color)));
|
||
let inner = ring(page.imageable);
|
||
p.extend(Shape::dashed_line(&inner, Stroke::new(1.0, color), 4.0, 3.0));
|
||
if let Some(corner) = inner.iter().copied().reduce(|a, b| pos2(a.x.min(b.x), a.y.min(b.y))) {
|
||
p.text(corner + vec2(3.0, 2.0), egui::Align2::LEFT_TOP, page.number.to_string(), font.clone(), color);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The print tiling's lines: a neutral grey that reads on the paper and on the pasteboard.
|
||
const PRINT_TILING: Color32 = Color32::from_gray(96);
|
||
|
||
/// A Shaper Group's mode widget (Shaper and Selection tools), drawn with selection edges shown or
|
||
/// hidden: an arrow pointing right in Face Selection mode and left in Construction mode.
|
||
fn shaper_widget_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||
let Some(st) = app.session.active() else { return };
|
||
if matches!(app.session.tool_id(), "shaper" | "selection")
|
||
&& let Some(w) = vectorcraft_tools::shaperwidget::ShaperWidget::of(&st.doc, &st.selection, st.isolation, xf.zoom)
|
||
{
|
||
let (c, r) = (xf.to_screen(w.at), vectorcraft_tools::shaperwidget::RADIUS_PX);
|
||
let color = c32(st.doc.layer_color(w.group));
|
||
p.circle_filled(c, r, Color32::WHITE);
|
||
p.circle_stroke(c, r, Stroke::new(1.0, color));
|
||
let d = if w.construction { -1.0 } else { 1.0 };
|
||
let arrow = vec![c + vec2(3.0 * d, 0.0), c + vec2(-2.0 * d, -3.0), c + vec2(-2.0 * d, 3.0)];
|
||
p.add(Shape::convex_polygon(arrow, color, Stroke::NONE));
|
||
}
|
||
}
|
||
|
||
/// High-contrast live controls, using the same geometry as the Selection tool's hit testing.
|
||
fn mirror_axis_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) {
|
||
if app.session.tool_id() != "selection" || !app.ui.view.bounding_box {
|
||
return;
|
||
}
|
||
let Some(st) = app.session.active() else { return };
|
||
let Some(axis) = vectorcraft_tools::mirror::MirrorAxis::of(&st.doc, &st.selection, xf.zoom) else { return };
|
||
let t = Tokens::get(p.ctx());
|
||
let ends = axis.ends.map(|q| xf.to_screen(q));
|
||
let center = xf.to_screen(axis.center);
|
||
if !center.is_finite() || !ends.iter().all(|p| p.is_finite()) {
|
||
return;
|
||
}
|
||
if let Some((a, b)) = clip_segment(ends[0], ends[1], xf.rect) {
|
||
p.line_segment([a, b], Stroke::new(5.0, t.measure_bg));
|
||
p.line_segment([a, b], Stroke::new(2.0, t.selection));
|
||
}
|
||
for end in ends {
|
||
p.circle_filled(end, vectorcraft_tools::mirror::HANDLE_RADIUS, t.measure_bg);
|
||
p.circle_stroke(end, vectorcraft_tools::mirror::HANDLE_RADIUS, Stroke::new(2.0, t.selection));
|
||
}
|
||
let handle = egui::Rect::from_center_size(center, vec2(12.0, 12.0));
|
||
p.rect_filled(handle, 2.0, t.selection);
|
||
p.rect_stroke(handle, 2.0, Stroke::new(2.0, t.measure_bg), StrokeKind::Outside);
|
||
}
|
||
|
||
/// The radius of the Rotate View dial, in screen points.
|
||
const DIAL_RADIUS: f32 = 40.0;
|
||
|
||
/// The dial a Rotate View drag shows, centered on `c`: a tick every 15° (longer every 90°), a
|
||
/// needle pointing where the document's top points in a view turned by `rotation` degrees, and the
|
||
/// angle under the center, rounded to whole degrees as in the status bar.
|
||
fn rotate_view_dial(p: &egui::Painter, c: Pos2, rotation: f64, t: &Tokens) {
|
||
// `dir(deg)` is the screen direction `deg` degrees clockwise from straight up.
|
||
let dir = |deg: f32| vec2(deg.to_radians().sin(), -deg.to_radians().cos());
|
||
p.circle(c, DIAL_RADIUS, t.panel.gamma_multiply(0.85), Stroke::new(1.0, t.border));
|
||
for i in 0..24u8 {
|
||
let len = if i % 6 == 0 { 8.0 } else { 4.0 };
|
||
let d = dir(f32::from(i) * 15.0);
|
||
p.line_segment([c + d * (DIAL_RADIUS - len), c + d * DIAL_RADIUS], Stroke::new(1.0, t.text_dim));
|
||
}
|
||
p.line_segment([c, c + dir(rotation as f32) * DIAL_RADIUS], Stroke::new(2.0, t.accent));
|
||
p.circle_filled(c, 2.5, t.accent);
|
||
p.text(c + vec2(0.0, 8.0), egui::Align2::CENTER_TOP, format!("{rotation:.0}°"), egui::FontId::proportional(11.0), t.text);
|
||
}
|
||
|
||
fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf, curve_points_from_tool: bool) {
|
||
// The Selection tool's bounding box (rotated with the objects after a rotation). It hides while
|
||
// a drag moves or resizes the selection, so only the art is seen going with the pointer (#712).
|
||
let show_box = app.session.tool_id() == "selection"
|
||
&& !app.session.tool_transforming()
|
||
&& app.ui.view.bounding_box
|
||
&& app.session.active().is_some_and(|st| !st.selection.is_empty() && st.selection.anchors.is_empty());
|
||
let bbox = if show_box { app.selection_box() } else { None };
|
||
let st = app.session.active();
|
||
let big = st.is_some_and(|st| too_many_anchors(st.selection.objects.iter().filter_map(|id| st.doc.node(*id))));
|
||
let big_bounds = if big { app.selection_bounds() } else { None };
|
||
let app = &*app;
|
||
let Some(st) = app.session.active() else { return };
|
||
let symbols = st.doc.symbol_boxes();
|
||
let tool = app.session.tool_id();
|
||
let direct = vectorcraft_tools::catalog::edits_anchors(tool);
|
||
let curvature = tool == "curvature";
|
||
// Selection & Anchor Display › Size (1–7, 3 the default): anchors, handles and the bounding
|
||
// box's handles a point bigger or smaller per step.
|
||
let look = HandleLook::of(&app.session.prefs);
|
||
let grow = look.grow;
|
||
let anchor = |direct: bool| grow + if direct { 5.0 } else { 4.0 };
|
||
// The selected anchors' handles (drawn once they are counted: Show handles when multiple
|
||
// anchors are selected off shows them for a single one only).
|
||
let (mut handles, mut with_handles) = (vec![], 0);
|
||
// Too many paths to outline: the selection's bounds stand for them.
|
||
if let Some((b, id)) = big_bounds.zip(st.selection.objects.first()) {
|
||
stroke_path(p, &vectorcraft_geom::shapes::rectangle(b).to_bezpath(), xf, Stroke::new(1.0, c32(st.doc.layer_color(*id))));
|
||
}
|
||
// The key object (Align to Key Object): its outline drawn thicker, or its bounds when it has
|
||
// too many paths to outline.
|
||
if let Some((k, n)) = st.selection.key.and_then(|k| Some((k, st.doc.node(k)?))) {
|
||
let outline = if too_many_anchors([n]) {
|
||
n.geometric_bounds_with(&symbols).map(|b| vectorcraft_geom::shapes::rectangle(b).to_bezpath())
|
||
} else {
|
||
Some(node_outline(n, &symbols))
|
||
};
|
||
if let Some(bp) = outline {
|
||
stroke_path(p, &bp, xf, Stroke::new(KEY_OUTLINE, c32(st.doc.layer_color(k))));
|
||
}
|
||
}
|
||
for id in st.selection.objects.iter().filter(|_| !big) {
|
||
let Some(n) = st.doc.node(*id) else { continue };
|
||
let color = c32(st.doc.layer_color(*id));
|
||
let partial = st.selection.partial(*id);
|
||
// Path outlines (and area type / placed document / image / mesh / symbol outlines;
|
||
// point type baselines and origin are drawn below).
|
||
if !matches!(n.kind, NodeKind::Text(ref tx) if matches!(tx.kind, vectorcraft_doc::TextKind::Point)) {
|
||
stroke_path(p, &node_outline(n, &symbols), xf, Stroke::new(1.0, color));
|
||
}
|
||
// Anchors (and handles for selected anchors in direct mode); a gradient mesh's or a mesh
|
||
// envelope's points.
|
||
walk_drawn(n, &mut |c| {
|
||
let envelope = vectorcraft_doc::live::envelope_overlay(c).and_then(|(_, grid)| grid);
|
||
let grid = match &c.kind {
|
||
NodeKind::Mesh(m) => Some(m),
|
||
_ => envelope.as_ref(),
|
||
};
|
||
if let Some(grid) = grid {
|
||
for q in &grid.points {
|
||
anchor_square(p, xf.to_screen(q.p), color, false, anchor(direct));
|
||
}
|
||
return;
|
||
}
|
||
let NodeKind::Path { path, .. } = &c.kind else { return };
|
||
// The Curvature tool owns its points while drawing; existing paths use this overlay.
|
||
if curvature && curve_points_from_tool {
|
||
return;
|
||
}
|
||
let selected_segments = st.selection.segments.get(&c.id);
|
||
if let Some(segments) = selected_segments {
|
||
for &(si, seg) in segments {
|
||
let Some(sp) = path.subpaths.get(si).filter(|sp| seg < sp.segment_count()) else { continue };
|
||
let curve = sp.segment(seg);
|
||
let mut outline = vectorcraft_geom::BezPath::new();
|
||
outline.move_to(curve.p0);
|
||
if sp.segment_is_line(seg) {
|
||
outline.line_to(curve.p3);
|
||
} else {
|
||
outline.curve_to(curve.p1, curve.p2, curve.p3);
|
||
}
|
||
stroke_path(p, &outline, xf, Stroke::new(2.0, color));
|
||
}
|
||
}
|
||
for (si, ai, a) in path.anchors() {
|
||
// An object selected whole has all its anchors selected, and so do the paths inside a
|
||
// group, compound path or clip group selected whole. Direction handles show only on a
|
||
// path that is selected itself: those are the handles that `handle_anchors` in
|
||
// `vectorcraft-tools` lets the tools drag.
|
||
let (sel, handles_shown) = match partial {
|
||
Some(set) => {
|
||
let sel = set.contains(&(si, ai));
|
||
(sel, sel)
|
||
}
|
||
None => (true, direct && c.id == *id),
|
||
};
|
||
let sp = xf.to_screen(a.p);
|
||
if !curvature && handles_shown {
|
||
with_handles += 1;
|
||
let sides = path.subpaths.get(si).map(|subpath| st.selection.handle_sides(c.id, si, ai, subpath)).unwrap_or((true, true));
|
||
handles.extend(
|
||
[(a.h_in, sides.0), (a.h_out, sides.1)]
|
||
.into_iter()
|
||
.filter(|(h, show)| *show && h.distance(a.p) > 1e-6)
|
||
.map(|(h, _)| (sp, xf.to_screen(h), color)),
|
||
);
|
||
}
|
||
let anchor_selected = sel && selected_segments.is_none();
|
||
let ink = if anchor_selected && partial.is_some() { selected_anchor(color) } else { color };
|
||
if curvature {
|
||
curve_point(p, sp, ink, anchor_selected && partial.is_some(), grow + 6.0);
|
||
} else {
|
||
anchor_square(p, sp, ink, anchor_selected, anchor(partial.is_some() || direct));
|
||
}
|
||
}
|
||
});
|
||
// Centre point (Attributes panel → Show Center).
|
||
if n.shows_center()
|
||
&& let Some(b) = n.geometric_bounds_with(&symbols)
|
||
{
|
||
anchor_square(p, xf.to_screen(b.center()), color, true, anchor(false));
|
||
}
|
||
// Point type: each line's baseline, where a click selects it (`hit`), whatever its
|
||
// alignment and rotation, and the anchor (area type shows its frame instead, which may
|
||
// not be a rectangle, and type on a path its path).
|
||
if let NodeKind::Text(tx) = &n.kind
|
||
&& matches!(tx.kind, vectorcraft_doc::TextKind::Point)
|
||
{
|
||
for (a, b) in tx.baselines() {
|
||
p.line_segment([xf.to_screen(tx.xf * a), xf.to_screen(tx.xf * b)], Stroke::new(1.0, color));
|
||
}
|
||
p.circle_filled(xf.to_screen(tx.xf * Point::ZERO), 2.5, color);
|
||
}
|
||
}
|
||
if app.session.prefs.show_handles_multiple_anchors || with_handles <= 1 {
|
||
for (sp, hp, color) in handles {
|
||
p.line_segment([sp, hp], Stroke::new(1.0, color));
|
||
look.draw(p, hp, color);
|
||
}
|
||
}
|
||
// The spine of each selected blend (or of the blend a selected key object belongs to).
|
||
let mut spines = vec![];
|
||
for id in &st.selection.objects {
|
||
let is_blend = |b: &vectorcraft_doc::NodeId| st.doc.node(*b).is_some_and(|n| matches!(n.kind, NodeKind::Blend { .. }));
|
||
let Some(b) = [Some(*id), st.doc.parent_of(*id)].into_iter().flatten().find(is_blend).filter(|b| !spines.contains(b)) else { continue };
|
||
spines.push(b);
|
||
let Some(NodeKind::Blend { children, spec }) = st.doc.node(b).map(|n| &n.kind) else { continue };
|
||
let Some((path, _)) = vectorcraft_doc::live::blend_spine(children, spec) else { continue };
|
||
let color = c32(st.doc.layer_color(b));
|
||
stroke_path(p, &path.to_bezpath(), xf, Stroke::new(1.0, color));
|
||
for (_, _, a) in path.anchors() {
|
||
anchor_square(p, xf.to_screen(a.p), color, false, anchor(direct));
|
||
}
|
||
}
|
||
// Live Corners widgets (Selection on a live rectangle or polygon, Direct Selection on any path).
|
||
if matches!(tool, "selection" | "directSelection")
|
||
&& let Some(w) = vectorcraft_tools::corners::CornerWidgets::showing(
|
||
&st.doc,
|
||
&st.selection,
|
||
xf.zoom,
|
||
tool == "directSelection",
|
||
app.ui.view.corner_widgets,
|
||
app.session.prefs.hide_corner_widget_above,
|
||
)
|
||
{
|
||
for (id, q) in w.visible_on() {
|
||
let (sp, color) = (xf.to_screen(q), c32(st.doc.layer_color(id)));
|
||
p.circle_filled(sp, 3.0, Color32::WHITE);
|
||
p.circle_stroke(sp, 3.0, Stroke::new(1.0, color));
|
||
p.circle_filled(sp, 1.0, color);
|
||
}
|
||
}
|
||
// Bounding box with handles (Selection tool).
|
||
if let Some(b) = bbox {
|
||
let color = c32(st.doc.layer_color(st.selection.objects[0]));
|
||
p.add(Shape::closed_line(b.corners().iter().map(|q| xf.to_screen(*q)).collect(), Stroke::new(1.0, color)));
|
||
for h in vectorcraft_tools::bbox::Handle::ALL {
|
||
let c = xf.to_screen(b.to_doc() * h.pos(b.rect));
|
||
let hr = egui::Rect::from_center_size(c, vec2(6.0 + grow, 6.0 + grow));
|
||
p.rect_filled(hr, 0.0, Color32::WHITE);
|
||
p.rect_stroke(hr, 0.0, Stroke::new(1.0, color), StrokeKind::Inside);
|
||
}
|
||
// The type widget beside it: hollow on point type, filled on area type.
|
||
if let Some(w) = vectorcraft_tools::typewidget::TypeWidget::of(&st.doc, &st.selection, &b, xf.zoom) {
|
||
let (c, r) = (xf.to_screen(w.at), vectorcraft_tools::typewidget::RADIUS_PX);
|
||
p.circle_filled(c, r, if w.area { color } else { Color32::WHITE });
|
||
p.circle_stroke(c, r, Stroke::new(1.0, color));
|
||
}
|
||
}
|
||
// Pie widgets (Selection tool, with View › Show Bounding Box, on live ellipses): each joined
|
||
// by a line to the outline's point at its angle. They stay while one is dragged.
|
||
if tool == "selection" && app.ui.view.bounding_box {
|
||
for w in vectorcraft_tools::pie::PieWidget::of(&st.doc, &st.selection, xf.zoom) {
|
||
let (on, c, r, color) = (xf.to_screen(w.on), xf.to_screen(w.at), vectorcraft_tools::pie::RADIUS_PX, c32(st.doc.layer_color(w.id)));
|
||
p.line_segment([on, c], Stroke::new(1.0, color));
|
||
p.circle_filled(c, r, Color32::WHITE);
|
||
p.circle_stroke(c, r, Stroke::new(1.0, color));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Record where the Type tool's IME candidate window goes this pass, if it has an editor.
|
||
///
|
||
/// The descriptor itself is observed once at the end of the pass (`crate::ime::end_of_pass`),
|
||
/// after dialogs, floating panels, progress windows and the palette draw: any of them can open
|
||
/// over the canvas, take the keyboard or run a command that changes the edited text, so a
|
||
/// descriptor written here would carry a stale owner, surrounding text or cause.
|
||
fn ime_geometry(app: &mut VectorcraftApp, ctx: &egui::Context, xf: &Xf) {
|
||
app.ime_caret_rect = None;
|
||
// A hidden viewport cannot compose; the end-of-pass observation clears the owner.
|
||
if !app.session.tool_wants_text() || app.viewport_hidden(ctx) {
|
||
return;
|
||
}
|
||
let Some((a, b)) = app.session.tool_ime_caret(app.view_info()) else { return };
|
||
let r = egui::Rect::from_two_pos(xf.to_screen(a), xf.to_screen(b)).expand2(vec2(1.0, 0.0));
|
||
app.ime_caret_rect = Some((r, r));
|
||
}
|
||
|
||
/// 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.
|
||
const MIN_LABELLED_ARTBOARD: f32 = 24.0;
|
||
|
||
/// Temp-memory key recording an artboard-name press before its edit field opens.
|
||
fn name_press_id() -> egui::Id {
|
||
egui::Id::unique("canvas-artboard-name-press")
|
||
}
|
||
|
||
/// Temp-memory key for the artboard name field currently being edited.
|
||
fn name_edit_id() -> egui::Id {
|
||
egui::Id::unique("canvas-artboard-name-edit")
|
||
}
|
||
|
||
/// A name being typed above an artboard. `fresh`: this frame opened it, so a focus loss from the
|
||
/// press that started it is not the user leaving the field.
|
||
#[derive(Clone)]
|
||
struct ArtboardNameEdit {
|
||
index: usize,
|
||
text: String,
|
||
fresh: bool,
|
||
}
|
||
|
||
/// The canvas label for `name`, laid out as [`artboard_label`] draws it.
|
||
fn canvas_name_galley(painter: &egui::Painter, corner: Pos2, width: f32, name: &str, color: Color32) -> Option<(std::sync::Arc<egui::Galley>, Pos2)> {
|
||
if !width.is_finite() || width < MIN_LABELLED_ARTBOARD {
|
||
return None;
|
||
}
|
||
let mut job = egui::text::LayoutJob::simple_singleline(name.to_owned(), egui::FontId::proportional(11.0), color);
|
||
job.wrap = egui::text::TextWrapping { max_width: width, max_rows: 1, break_anywhere: true, overflow_character: Some('…') };
|
||
let galley = painter.layout_job(job);
|
||
let at = corner - vec2(0.0, 4.0 + galley.size().y);
|
||
Some((galley, at))
|
||
}
|
||
|
||
/// The length on screen, in points, of each arm of an artboard's center mark (a cross), at any zoom.
|
||
const CENTER_MARK_ARM: f32 = 6.0;
|
||
|
||
/// Artboard `ab`'s display marks in `color`, each line 1 point wide on screen at any zoom and
|
||
/// turned with the view: with Show Cross Hairs, a line from the middle of each side through the
|
||
/// center to the middle of the opposite side; with Show Center Mark, a cross at the center whose
|
||
/// arms run along the artboard's sides, [`CENTER_MARK_ARM`] long on screen
|
||
/// (https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/modify-display-settings-of-artboards.html);
|
||
/// with Show Video Safe Areas, the outlines of its action-safe and title-safe areas
|
||
/// ([`vectorcraft_doc::Artboard::video_safe_areas`],
|
||
/// https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/set-video-display-options-for-artboards.html).
|
||
fn artboard_marks(painter: &egui::Painter, xf: &Xf, ab: &vectorcraft_doc::Artboard, color: Color32) {
|
||
let (r, stroke) = (ab.rect, Stroke::new(1.0, color));
|
||
let c = r.center();
|
||
let at = |x: f64, y: f64| xf.to_screen(Point::new(x, y));
|
||
if ab.show_cross_hairs {
|
||
painter.line_segment([at(r.x0, c.y), at(r.x1, c.y)], stroke);
|
||
painter.line_segment([at(c.x, r.y0), at(c.x, r.y1)], stroke);
|
||
}
|
||
if ab.show_center_mark {
|
||
let mid = at(c.x, c.y);
|
||
for side in [at(c.x + 1.0, c.y), at(c.x, c.y + 1.0)] {
|
||
let arm = (side - mid).normalized() * CENTER_MARK_ARM;
|
||
painter.line_segment([mid - arm, mid + arm], stroke);
|
||
}
|
||
}
|
||
if ab.show_video_safe_areas {
|
||
for area in ab.video_safe_areas() {
|
||
painter.add(Shape::closed_line(xf.quad(area), stroke));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Artboard `name` above its top-left `corner` on screen, no wider than the artboard (`width` on
|
||
/// screen): a longer name ends in "…", and an artboard too narrow shows none (#949).
|
||
fn artboard_label(painter: &egui::Painter, corner: Pos2, width: f32, name: &str, color: Color32) {
|
||
if let Some((galley, at)) = canvas_name_galley(painter, corner, width, name, color) {
|
||
painter.galley(at, galley, color);
|
||
}
|
||
}
|
||
|
||
fn canvas_name_rect(painter: &egui::Painter, corner: Pos2, width: f32, name: &str) -> Option<egui::Rect> {
|
||
canvas_name_galley(painter, corner, width, name, Color32::WHITE).map(|(galley, at)| egui::Rect::from_min_size(at, galley.size()).expand(2.0))
|
||
}
|
||
|
||
/// Where the Artboard tool draws "01 - name" for its active artboard (see its overlay label).
|
||
fn tool_name_rect(painter: &egui::Painter, xf: &Xf, rect: Rect, index: usize, name: &str) -> egui::Rect {
|
||
let z = xf.zoom.max(1e-9);
|
||
let anchor = Point::new(rect.x0 - 8.0 / z, rect.y0 - 4.0 / z);
|
||
let sp = xf.to_screen(anchor) + vec2(8.0, -14.0);
|
||
let galley = painter.layout(format!("{:02} - {name}", index + 1), egui::FontId::proportional(11.0), Color32::WHITE, f32::INFINITY);
|
||
egui::Rect::from_min_size(sp, galley.size()).expand(2.0)
|
||
}
|
||
|
||
/// The artboard whose on-canvas name contains `screen`, if that name is being drawn.
|
||
fn artboard_name_at(app: &VectorcraftApp, ui: &Ui, xf: &Xf, screen: Pos2) -> Option<usize> {
|
||
let st = app.session.active()?;
|
||
let artboard_tool = app.session.tool_id() == "artboard";
|
||
if !artboard_tool && st.doc.artboards.len() <= 1 {
|
||
return None;
|
||
}
|
||
let tool_labelled = artboard_tool.then(|| app.session.tool_options()["active"].as_u64()).flatten();
|
||
let painter = ui.painter();
|
||
let mut hit = None;
|
||
for (i, ab) in st.doc.artboards.iter().enumerate() {
|
||
if tool_labelled == u64::try_from(i).ok() {
|
||
continue;
|
||
}
|
||
let corner = xf.to_screen(Point::new(ab.rect.x0, ab.rect.y0));
|
||
let width = (ab.rect.width() * xf.zoom) as f32;
|
||
let text = format!("{:02} - {}", i + 1, ab.name);
|
||
if canvas_name_rect(painter, corner, width, &text).is_some_and(|r| r.contains(screen)) {
|
||
hit = Some(i);
|
||
}
|
||
}
|
||
if let Some(i) = tool_labelled.and_then(|i| usize::try_from(i).ok())
|
||
&& let Some(ab) = st.doc.artboards.get(i)
|
||
&& tool_name_rect(painter, xf, ab.rect, i, &ab.name).contains(screen)
|
||
{
|
||
return Some(i);
|
||
}
|
||
hit
|
||
}
|
||
|
||
fn select_named_artboard(app: &mut VectorcraftApp, index: usize) {
|
||
let n = app.session.active().map(|st| st.doc.artboards.len()).unwrap_or(0);
|
||
if index >= n {
|
||
return;
|
||
}
|
||
if let Some(v) = app.view_mut() {
|
||
v.artboard = index;
|
||
}
|
||
if app.session.tool_id() == "artboard" {
|
||
app.session.set_tool_option("active", &json!(index));
|
||
}
|
||
}
|
||
|
||
/// Start editing artboard `index`'s name on the canvas, unless the artboard is locked (the
|
||
/// Artboards panel still renames it).
|
||
fn begin_artboard_name_edit(ui: &Ui, app: &VectorcraftApp, index: usize) {
|
||
let Some(name) = app.session.active().and_then(|st| st.doc.artboards.get(index).filter(|a| !a.locked).map(|a| a.name.clone())) else { return };
|
||
ui.data_mut(|d| d.insert_temp(name_edit_id(), ArtboardNameEdit { index, text: name, fresh: true }));
|
||
}
|
||
|
||
fn commit_artboard_name(app: &mut VectorcraftApp, index: usize, text: &str) {
|
||
let trimmed = text.trim();
|
||
if trimmed.is_empty() {
|
||
return;
|
||
}
|
||
let current = app.session.active().and_then(|st| st.doc.artboards.get(index).map(|a| a.name.clone()));
|
||
if current.as_deref() == Some(trimmed) {
|
||
return;
|
||
}
|
||
// A refusal is reported by the command; the label just closes.
|
||
app.run("artboard.setProps", json!({ "index": index, "name": trimmed })).ok();
|
||
}
|
||
|
||
/// The text field over an artboard name a double-click opened.
|
||
fn artboard_name_editor(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
|
||
let Some(mut edit) = ui.data(|d| d.get_temp::<ArtboardNameEdit>(name_edit_id())) else { return };
|
||
let alive = app.session.active().is_some_and(|st| edit.index < st.doc.artboards.len());
|
||
if !alive {
|
||
ui.data_mut(|d| d.remove::<ArtboardNameEdit>(name_edit_id()));
|
||
return;
|
||
}
|
||
let Some(rect) = artboard_name_field_rect(app, ui, xf, edit.index) else {
|
||
ui.data_mut(|d| d.remove::<ArtboardNameEdit>(name_edit_id()));
|
||
return;
|
||
};
|
||
let fresh = edit.fresh;
|
||
edit.fresh = false;
|
||
let te = ui.put(
|
||
rect,
|
||
egui::TextEdit::singleline(&mut edit.text)
|
||
.id(name_edit_id().with("field"))
|
||
.font(egui::FontId::proportional(11.0))
|
||
// Keep the original inset so theme button padding cannot move the canvas label.
|
||
.margin(egui::Margin::symmetric(4, 2)),
|
||
);
|
||
if fresh || (!te.has_focus() && !te.lost_focus()) {
|
||
te.request_focus();
|
||
}
|
||
let done = !fresh && te.lost_focus();
|
||
let escape = ui.input(|i| i.key_pressed(egui::Key::Escape));
|
||
let (index, text) = (edit.index, edit.text.clone());
|
||
if done {
|
||
ui.data_mut(|d| d.remove::<ArtboardNameEdit>(name_edit_id()));
|
||
if !escape {
|
||
commit_artboard_name(app, index, &text);
|
||
}
|
||
} else {
|
||
ui.data_mut(|d| d.insert_temp(name_edit_id(), edit));
|
||
}
|
||
}
|
||
|
||
/// Where the rename field sits: the name label's rectangle, at least wide enough to type in.
|
||
fn artboard_name_field_rect(app: &VectorcraftApp, ui: &Ui, xf: &Xf, index: usize) -> Option<egui::Rect> {
|
||
let st = app.session.active()?;
|
||
let ab = st.doc.artboards.get(index)?;
|
||
let artboard_tool = app.session.tool_id() == "artboard";
|
||
let tool_labelled = artboard_tool.then(|| app.session.tool_options()["active"].as_u64()).flatten();
|
||
let painter = ui.painter();
|
||
let rect = if tool_labelled == u64::try_from(index).ok() {
|
||
tool_name_rect(painter, xf, ab.rect, index, &ab.name)
|
||
} else {
|
||
let corner = xf.to_screen(Point::new(ab.rect.x0, ab.rect.y0));
|
||
let width = (ab.rect.width() * xf.zoom) as f32;
|
||
canvas_name_rect(painter, corner, width, &format!("{:02} - {}", index + 1, ab.name))?
|
||
};
|
||
Some(egui::Rect::from_min_size(rect.min, vec2(rect.width().max(80.0), rect.height().max(16.0))))
|
||
}
|
||
|
||
/// Is overlay label `text` the Artboard tool's "01 - <artboard name>"? It holds a name, so it is
|
||
/// shown as it is; the tools' other labels ("anchor", "path", Puppet Warp's warning) are ours.
|
||
fn names_an_artboard(text: &str) -> bool {
|
||
text.split_once(" - ").is_some_and(|(n, _)| n.len() >= 2 && n.bytes().all(|b| b.is_ascii_digit()))
|
||
}
|
||
|
||
fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: &Tokens, look: HandleLook) {
|
||
for o in overlays {
|
||
match o {
|
||
// Where the tools select: the document rectangle, turned with the view.
|
||
Overlay::Marquee(r) => marquee_through(p, &xf.quad(*r)),
|
||
Overlay::Path { path, color, width, dashed } => {
|
||
let s = Stroke::new(*width, c32(*color));
|
||
if *dashed {
|
||
for line in to_screen_path(path, xf) {
|
||
p.extend(Shape::dashed_line(&line, s, 4.0, 3.0));
|
||
}
|
||
} else {
|
||
stroke_path(p, path, xf, s);
|
||
}
|
||
}
|
||
Overlay::Line { a, b, color, dashed } => {
|
||
let s = Stroke::new(1.0, c32(*color));
|
||
let (a, b) = (xf.to_screen(*a), xf.to_screen(*b));
|
||
if *dashed {
|
||
p.extend(Shape::dashed_line(&[a, b], s, 4.0, 3.0));
|
||
} else {
|
||
p.line_segment([a, b], s);
|
||
}
|
||
}
|
||
Overlay::Anchor { p: pt, color, filled, size } => anchor_square(p, xf.to_screen(*pt), c32(*color), *filled, *size),
|
||
Overlay::CurvePoint { p: pt, color, filled, size } => curve_point(p, xf.to_screen(*pt), c32(*color), *filled, *size + look.grow),
|
||
Overlay::Handle { p: pt, color } => look.draw(p, xf.to_screen(*pt), c32(*color)),
|
||
Overlay::Label { p: pt, text, color } => {
|
||
let sp = xf.to_screen(*pt) + vec2(8.0, -14.0);
|
||
p.text(
|
||
sp,
|
||
egui::Align2::LEFT_TOP,
|
||
crate::panels::label_or_name(text, !names_an_artboard(text)),
|
||
egui::FontId::proportional(11.0),
|
||
c32(*color),
|
||
);
|
||
}
|
||
Overlay::Highlight { quad, color } => {
|
||
let c = Color32::from_rgba_unmultiplied(color[0], color[1], color[2], color[3]);
|
||
p.add(Shape::convex_polygon(quad.iter().map(|q| xf.to_screen(*q)).collect(), c, Stroke::NONE));
|
||
}
|
||
Overlay::Measure { p: pt, text } => {
|
||
let sp = xf.to_screen(*pt) + vec2(14.0, 14.0);
|
||
let galley = p.layout(text.clone(), egui::FontId::proportional(11.0), Color32::WHITE, 200.0);
|
||
let r = egui::Rect::from_min_size(sp, galley.size() + vec2(12.0, 8.0));
|
||
p.rect_filled(r, CornerRadius::same(3), t.measure_bg);
|
||
p.galley(sp + vec2(6.0, 4.0), galley, Color32::WHITE);
|
||
}
|
||
Overlay::Distance { a, b, text, color } => distance_guide(p, xf.to_screen(*a), xf.to_screen(*b), text, c32(*color)),
|
||
Overlay::GridLine { a, b, color } => {
|
||
let c = Color32::from_rgba_unmultiplied(color[0], color[1], color[2], color[3]);
|
||
p.line_segment([xf.to_screen(*a), xf.to_screen(*b)], Stroke::new(1.0, c));
|
||
}
|
||
Overlay::Swatch { p: pt, color, selected } => {
|
||
// A white disc under the colour shows its opacity; a dark rim keeps it readable on
|
||
// any art, and the accent ring marks the selected stop.
|
||
let c = xf.to_screen(*pt);
|
||
p.circle_filled(c, 6.0, Color32::WHITE);
|
||
p.circle_filled(c, 5.0, Color32::from_rgba_unmultiplied(color[0], color[1], color[2], color[3]));
|
||
p.circle_stroke(c, 6.0, Stroke::new(1.0, Color32::from_gray(32)));
|
||
if *selected {
|
||
p.circle_stroke(c, 8.0, Stroke::new(2.0, t.accent));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A Distance Guides measurement on screen ([`Overlay::Distance`]): the line from `a` to `b` in the
|
||
/// smart guide colour `color` with a 4 px tick across each end, and `text` in a pill of that colour
|
||
/// on its middle, in white or black, whichever reads on it.
|
||
fn distance_guide(p: &egui::Painter, a: Pos2, b: Pos2, text: &str, color: Color32) {
|
||
let s = Stroke::new(1.0, color);
|
||
p.line_segment([a, b], s);
|
||
let along = b - a;
|
||
let len = along.length();
|
||
if len > 0.0 && len.is_finite() {
|
||
let across = vec2(-along.y, along.x) / len * 4.0;
|
||
for end in [a, b] {
|
||
p.line_segment([end - across, end + across], s);
|
||
}
|
||
}
|
||
let luma = 0.299 * f32::from(color.r()) + 0.587 * f32::from(color.g()) + 0.114 * f32::from(color.b());
|
||
let ink = if luma > 150.0 { Color32::BLACK } else { Color32::WHITE };
|
||
let galley = p.layout_no_wrap(text.to_owned(), egui::FontId::proportional(11.0), ink);
|
||
let r = egui::Rect::from_center_size(a + along / 2.0, galley.size() + vec2(8.0, 4.0));
|
||
p.rect_filled(r, CornerRadius::same(3), color);
|
||
p.galley(r.min + vec2(4.0, 2.0), galley, ink);
|
||
}
|
||
|
||
/// The Home screen (`app.home`; with no document open, unless its preference is off).
|
||
fn home(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) {
|
||
let t = Tokens::get(ui.ctx());
|
||
ui.painter().rect_filled(rect, 0.0, t.panel_darker);
|
||
// Taller than the window (a small window, many recent files): it scrolls. Widgets scrolled out
|
||
// of view are clipped, so they take no pointer.
|
||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect).layout(egui::Layout::top_down(egui::Align::Min)));
|
||
egui::ScrollArea::vertical().id_salt("home-scroll").auto_shrink([false, false]).show(&mut child, |ui| {
|
||
let side = (ui.available_width() - 820.0).max(40.0) / 2.0;
|
||
let width = (ui.available_width() - 2.0 * side).max(0.0);
|
||
ui.add_space(60.0);
|
||
ui.horizontal_top(|ui| {
|
||
ui.add_space(side);
|
||
ui.allocate_ui_with_layout(vec2(width, 0.0), egui::Layout::top_down(egui::Align::Min), |ui| {
|
||
ui.set_width(width);
|
||
home_content(app, ui);
|
||
});
|
||
});
|
||
ui.add_space(60.0);
|
||
});
|
||
}
|
||
|
||
/// The Home screen's column: welcome, New / Open, Recent Files, presets and community links.
|
||
fn home_content(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||
let t = Tokens::get(ui.ctx());
|
||
ui.label(egui::RichText::new(tl!("Welcome to VectorCraft")).font(theme::semibold(26.0)).color(t.text));
|
||
ui.add_space(4.0);
|
||
ui.label(egui::RichText::new(tl!("Vector illustration — fast, open, scriptable.")).size(14.0).color(t.text_dim));
|
||
ui.add_space(22.0);
|
||
ui.horizontal(|ui| {
|
||
if widgets::primary_button(ui, tl!("New file")).clicked() {
|
||
app.run("file.newDialog", json!({})).ok();
|
||
}
|
||
ui.add_space(8.0);
|
||
if widgets::secondary_button(ui, tl!("Open")).clicked() {
|
||
app.run("file.open", json!({})).ok();
|
||
}
|
||
});
|
||
recent(app, ui);
|
||
ui.add_space(28.0);
|
||
ui.label(egui::RichText::new(tl!("Quickly start a new file")).font(theme::semibold(14.0)).color(t.text));
|
||
ui.add_space(10.0);
|
||
// A few of New Document's presets (`file.newPresets`), as its cards.
|
||
let presets = ["Letter", "A4", "Web 1920×1080", "Phone 390×844", "Postcard", "Social Square Post 1080×1080"];
|
||
ui.horizontal_wrapped(|ui| {
|
||
ui.spacing_mut().item_spacing = vec2(14.0, 14.0);
|
||
let cards: Vec<_> = presets.iter().filter_map(|name| vectorcraft_engine::cmd::newdoc::find(&app.session, name)).collect();
|
||
for s in cards {
|
||
if crate::dialogs::preset_card(ui, &s, false).clicked() {
|
||
app.run("file.new", json!({ "preset": s.name })).ok();
|
||
}
|
||
}
|
||
});
|
||
ui.add_space(28.0);
|
||
ui.label(egui::RichText::new(tl!("Community")).font(theme::semibold(14.0)).color(t.text));
|
||
ui.add_space(10.0);
|
||
crate::community::links(app, ui);
|
||
}
|
||
|
||
/// The Home screen's Recent Files (#663): the first of File › Open Recent Files, each its name and
|
||
/// folder; a click opens it. Nothing when there are none.
|
||
fn recent(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||
const SHOWN: usize = 6;
|
||
let files: Vec<String> = crate::io::recent_files(app).iter().take(SHOWN).cloned().collect();
|
||
if files.is_empty() {
|
||
return;
|
||
}
|
||
let t = Tokens::get(ui.ctx());
|
||
ui.add_space(28.0);
|
||
ui.label(egui::RichText::new(tl!("Recent Files")).font(theme::semibold(14.0)).color(t.text));
|
||
ui.add_space(8.0);
|
||
for path in files {
|
||
if recent_row(ui, &path).clicked() {
|
||
// A failure is reported to the user there.
|
||
let _ = crate::io::open_recent_reporting(app, &path);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// One Recent Files row across the Home screen: history icon, name (accent) and folder (dim,
|
||
/// truncated). The whole row is one button that lights up under the pointer; its tooltip is the
|
||
/// full path.
|
||
fn recent_row(ui: &mut Ui, path: &str) -> egui::Response {
|
||
const PAD: f32 = 6.0;
|
||
const ICON: f32 = 16.0;
|
||
let t = Tokens::get(ui.ctx());
|
||
let p = std::path::Path::new(path);
|
||
let name = p.file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().into_owned());
|
||
let folder = p.parent().map(|f| f.to_string_lossy().into_owned()).unwrap_or_default();
|
||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), egui::Sense::click());
|
||
if ui.is_rect_visible(rect) {
|
||
if resp.hovered() {
|
||
ui.painter().rect_filled(rect, CornerRadius::same(4), t.hover);
|
||
}
|
||
let icon = egui::Rect::from_center_size(egui::pos2(rect.left() + PAD + ICON / 2.0, rect.center().y), vec2(ICON, ICON));
|
||
crate::icons::paint(ui, "history", icon, t.icon);
|
||
let x = icon.right() + 8.0;
|
||
let name_galley = egui::WidgetText::from(egui::RichText::new(&name).size(13.0).color(t.accent)).into_galley(
|
||
ui,
|
||
Some(egui::TextWrapMode::Truncate),
|
||
(rect.right() - PAD - x).max(0.0),
|
||
egui::TextStyle::Body,
|
||
);
|
||
let folder_x = x + name_galley.size().x + 10.0;
|
||
ui.painter().galley(egui::pos2(x, rect.center().y - name_galley.size().y / 2.0), name_galley, t.accent);
|
||
let room = rect.right() - PAD - folder_x;
|
||
if room > 0.0 && !folder.is_empty() {
|
||
let folder_galley = egui::WidgetText::from(egui::RichText::new(&folder).size(12.0).color(t.text_dim)).into_galley(
|
||
ui,
|
||
Some(egui::TextWrapMode::Truncate),
|
||
room,
|
||
egui::TextStyle::Body,
|
||
);
|
||
ui.painter().galley(egui::pos2(folder_x, rect.center().y - folder_galley.size().y / 2.0), folder_galley, t.text_dim);
|
||
}
|
||
}
|
||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &name));
|
||
resp.on_hover_text(path).on_hover_cursor(egui::CursorIcon::PointingHand)
|
||
}
|
||
|
||
fn kurbo_flatten(p: &BezPath, tol: f64, f: &mut impl FnMut(PathEl)) {
|
||
// Bounded per curve: a document's far-out handles, zoomed onto the screen, made kurbo
|
||
// reserve gigabytes for the selection outline.
|
||
vectorcraft_geom::flatten(p.elements().iter().copied(), tol, f);
|
||
}
|
||
|
||
/// 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.
|
||
/// A raster whose bytes don't match its size gives an empty image of that size.
|
||
pub(crate) fn color_image(img: &vectorcraft_render::Rendered) -> egui::ColorImage {
|
||
let size = [img.width as usize, img.height as usize];
|
||
let mut pixels = vec![egui::Color32::TRANSPARENT; size[0] * size[1]];
|
||
let bytes: &mut [u8] = bytemuck::cast_slice_mut(&mut pixels);
|
||
if bytes.len() == img.pixels.len() {
|
||
bytes.copy_from_slice(&img.pixels);
|
||
}
|
||
egui::ColorImage::new(size, pixels)
|
||
}
|
||
|
||
/// The pixels of the `w`×`h` frame through `view` to render again for `doc`, when only the document
|
||
/// changed since the frame shown (its key `key` but for the revision, and its render options
|
||
/// `opts`), which this thread rendered, and no frame is on its way from the worker: those it
|
||
/// changed in (`Some(None)`: none), grown to whole eighths of the frame, as long as they are at
|
||
/// most half of it. None: render the whole frame. Frames the worker renders stay
|
||
/// whole: its renderer keeps the rasters of the art's effects, this thread's doesn't.
|
||
fn changed_pixels(
|
||
app: &VectorcraftApp,
|
||
key: &CacheKey,
|
||
doc: &vectorcraft_doc::Document,
|
||
opts: &vectorcraft_render::RenderOptions,
|
||
view: Affine,
|
||
w: u32,
|
||
h: u32,
|
||
) -> Option<Option<[u32; 4]>> {
|
||
let (shown, _) = app.canvas.shown.as_ref().filter(|(_, o)| o == opts)?;
|
||
// A viewed opacity mask shows its coverage wherever its art reaches, past its object's bounds.
|
||
if opts.mask_view.is_some() {
|
||
return None;
|
||
}
|
||
let same_view = app.canvas.key.as_ref().is_some_and(|k| *k == CacheKey { revision: k.revision, ..key.clone() });
|
||
if !same_view || app.canvas.texture.is_none() || app.canvas.from_worker || app.canvas.worker.as_ref().is_some_and(|w| w.pending.is_some()) {
|
||
return None;
|
||
}
|
||
match vectorcraft_render::changed(shown, doc) {
|
||
vectorcraft_render::Changed::Nothing => Some(None),
|
||
vectorcraft_render::Changed::Area(area) => match vectorcraft_render::view_pixels(area, view, w, h) {
|
||
None => Some(None),
|
||
Some([x0, y0, x1, y1]) => {
|
||
let ((x0, x1), (y0, y1)) = (in_steps(x0, x1, w), in_steps(y0, y1, h));
|
||
(u64::from(x1 - x0) * u64::from(y1 - y0) * 2 <= u64::from(w) * u64::from(h)).then_some(Some([x0, y0, x1, y1]))
|
||
}
|
||
},
|
||
vectorcraft_render::Changed::Everything => None,
|
||
}
|
||
}
|
||
|
||
/// `a..b` of `0..n`, grown to whole eighths of `n` and moved back into `0..n`: the parts of a
|
||
/// frame rendered again come in few sizes, which keep the contexts the renderer keeps.
|
||
fn in_steps(a: u32, b: u32, n: u32) -> (u32, u32) {
|
||
let step = n.div_ceil(8).max(1);
|
||
let len = (b - a).max(1).div_ceil(step).saturating_mul(step).min(n);
|
||
let a = a.min(n - len);
|
||
(a, a + len)
|
||
}
|
||
|
||
/// Upload the art's raster; Pixel Preview's document pixels keep hard edges when magnified.
|
||
fn upload(app: &mut VectorcraftApp, ctx: &egui::Context, img: &vectorcraft_render::Rendered, pixel: bool) {
|
||
let color = color_image(img);
|
||
let options = if pixel { egui::TextureOptions::NEAREST } else { egui::TextureOptions::LINEAR };
|
||
match &mut app.canvas.texture {
|
||
Some(tex) => tex.set(color, options),
|
||
None => app.canvas.texture = Some(ctx.load_texture("canvas", color, options)),
|
||
}
|
||
}
|
||
|
||
/// Pixel Preview: the whole document pixels (x0, y0, x1, y1) under the canvas, rotated view
|
||
/// included. None when the view is degenerate.
|
||
fn pixel_region(xf: &Xf) -> Option<[i64; 4]> {
|
||
let r = xf.rect;
|
||
let corners = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom()].map(|p| xf.to_doc(p));
|
||
let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
|
||
for p in corners {
|
||
(x0, y0, x1, y1) = (x0.min(p.x), y0.min(p.y), x1.max(p.x), y1.max(p.y));
|
||
}
|
||
let (x0, y0, x1, y1) = (x0.floor(), y0.floor(), x1.ceil(), y1.ceil());
|
||
// A document pixel is bigger than a screen pixel here, so the region is about canvas-sized.
|
||
let ok = [x0, y0, x1, y1].iter().all(|c| c.is_finite() && c.abs() < 1e9) && x1 > x0 && y1 > y0 && x1 - x0 <= 16384.0 && y1 - y0 <= 16384.0;
|
||
ok.then_some([x0 as i64, y0 as i64, x1 as i64, y1 as i64])
|
||
}
|
||
|
||
/// The Contextual Task Bar's area.
|
||
fn task_bar_id() -> egui::Id {
|
||
egui::Id::unique("task-bar")
|
||
}
|
||
|
||
/// Where the Contextual Task Bar was last drawn (none while it isn't shown).
|
||
pub(crate) fn task_bar_rect(ctx: &egui::Context) -> Option<egui::Rect> {
|
||
let id = task_bar_id();
|
||
ctx.memory(|m| m.areas().is_visible(&egui::LayerId::new(egui::Order::Middle, id)).then(|| m.area_rect(id)).flatten())
|
||
}
|
||
|
||
/// Whether the objects `ids` (or anything inside them) are painted with a colour: a solid colour,
|
||
/// a gradient's stops or a mesh's points, which Recolor Artwork can change.
|
||
pub(crate) fn has_colors(doc: &vectorcraft_doc::Document, ids: &[vectorcraft_doc::NodeId]) -> bool {
|
||
ids.iter().filter_map(|id| doc.node(*id)).any(|n| {
|
||
let mut found = false;
|
||
vectorcraft_doc::swatches::node_colors(n, &mut |_, _| found = true);
|
||
found
|
||
})
|
||
}
|
||
|
||
/// The bounds of the artboards the Artboard tool has selected ([`crate::panels::artboards::picked`]).
|
||
fn picked_artboards_bounds(app: &VectorcraftApp) -> Option<Rect> {
|
||
let st = app.session.active()?;
|
||
let picked = crate::panels::artboards::picked(app, st.doc.artboards.len());
|
||
picked.iter().filter_map(|i| st.doc.artboards.get(*i)).map(|a| a.rect).reduce(|a, b| a.union(b))
|
||
}
|
||
|
||
/// The Contextual Task Bar: a floating pill under the selection with the most likely next actions.
|
||
/// With the Artboard tool it sits under the selected artboards and offers Resize (Artboard
|
||
/// Options), Duplicate, Rearrange All (with two or more artboards) and Export (Export for Screens).
|
||
/// Its handle drags it anywhere on the canvas; unpinned it keeps that offset as it follows the
|
||
/// selection, pinned it stays put ([`crate::state::TaskBarPlace`]).
|
||
fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
|
||
let t = Tokens::get(ui.ctx());
|
||
let on_artboards = app.session.tool_id() == "artboard";
|
||
if !on_artboards
|
||
&& (app.session.active().is_none_or(|st| st.selection.is_empty())
|
||
|| !matches!(app.session.tool_id(), "selection" | "directSelection" | "groupSelection"))
|
||
{
|
||
return;
|
||
}
|
||
let Some(b) = (if on_artboards { picked_artboards_bounds(app) } else { app.selection_bounds() }) else { return };
|
||
let Some(st) = app.session.active() else { return };
|
||
let n = st.selection.len();
|
||
let first = st.selection.objects.first().and_then(|id| st.doc.node(*id)).cloned();
|
||
let is_group = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Group { .. }));
|
||
let is_image = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Image(_)));
|
||
let is_clip_group = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Group { clip: true, .. }));
|
||
let is_compound = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Compound { .. }));
|
||
let open_path = first.as_ref().is_some_and(|f| matches!(&f.kind, NodeKind::Path { path, .. } if !path.is_closed()));
|
||
let text = first.as_ref().and_then(|f| match &f.kind {
|
||
NodeKind::Text(t) => Some(&t.kind),
|
||
_ => None,
|
||
});
|
||
// Images and vector art selected together: a clipping mask is the likely next step.
|
||
let mixed = n > 1 && {
|
||
let images = st.selection.objects.iter().filter_map(|id| st.doc.node(*id)).filter(|o| matches!(o.kind, NodeKind::Image(_))).count();
|
||
images > 0 && images < n
|
||
};
|
||
// An Image Trace object: the group the engine names "Image Trace", its source image first.
|
||
let is_trace = is_group
|
||
&& first.as_ref().is_some_and(|f| {
|
||
f.name.as_deref() == Some("Image Trace") && f.children().is_some_and(|c| c.first().is_some_and(|i| matches!(i.kind, NodeKind::Image(_))))
|
||
});
|
||
let mut items: Vec<(&str, &str, &str, serde_json::Value)> = vec![]; // (label, icon, command, params)
|
||
let none = || json!({});
|
||
if on_artboards {
|
||
// As in the reference app, the Artboard tool offers to resize, duplicate and export the
|
||
// artboards (<https://community.adobe.com/questions-652/contextual-task-bar-in-illustrator-2025-quick-actions-when-you-need-them-1546580>),
|
||
// and to rearrange them (<https://creativepro.com/managing-artboards-in-illustrator/>).
|
||
items.push((tl!("Resize"), "move-diagonal-2", TASK_BAR_ARTBOARD_OPTIONS, none()));
|
||
items.push((tl!("Duplicate"), "copy", TASK_BAR_DUPLICATE_ARTBOARDS, none()));
|
||
if st.doc.artboards.len() > 1 {
|
||
items.push((tl!("Rearrange All"), "dc-rearrange", TASK_BAR_REARRANGE_ARTBOARDS, none()));
|
||
}
|
||
items.push((tl!("Export"), "share-2", "file.exportForScreens", none()));
|
||
} else {
|
||
// Edit Path: the Direct Selection tool, to edit the path's anchors.
|
||
let edit_path = || (tl!("Edit Path"), "dc-direct", "tool.select", json!({"tool": "directSelection"}));
|
||
// As in the reference app, an image offers Image Trace and Crop Image, and traced art offers
|
||
// Expand (<https://community.adobe.com/announcements-651/contextual-task-bar-in-illustrator-2025-quick-actions-when-you-need-them-1546580>).
|
||
if n == 1 && is_image {
|
||
items.push((tl!("Image Trace"), "image", TASK_BAR_IMAGE_TRACE, none()));
|
||
items.push((tl!("Crop Image"), "dc-pf-crop", "ui.cropImage", none()));
|
||
}
|
||
// Direct-selected anchors take the place of a path's Offset Path and Simplify. For them the bar
|
||
// offers Remove, Connect, Cut and Convert to corner or smooth, as the reference app does
|
||
// (<https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/contextual-task-bar-overview.html>).
|
||
// These run the same commands as the Control bar's anchor buttons.
|
||
let anchors = !st.selection.anchors.is_empty();
|
||
if anchors {
|
||
items.push((tl!("Remove Anchor Points"), "pen-tool-delete", "path.removeAnchors", none()));
|
||
items.push((tl!("Connect"), "dc-join", "path.join", none()));
|
||
items.push((tl!("Cut Path"), "scissors", "path.cutAtAnchors", none()));
|
||
items.push((tl!("Corner"), "dc-anchor", TASK_BAR_TO_CORNER, none()));
|
||
items.push((tl!("Smooth"), "dc-anchor-smooth", TASK_BAR_TO_SMOOTH, none()));
|
||
}
|
||
// As in the reference app, open paths offer Join and Edit Path, compound paths and clipping
|
||
// masks their Release, images selected with vector art Make Clipping Mask, and type converts
|
||
// between point and area type (<https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/contextual-task-bar-overview.html>).
|
||
if n > 1 {
|
||
items.push((tl!("Group"), "group", "object.group", none()));
|
||
if mixed {
|
||
items.push((tl!("Make Clipping Mask"), "dc-pf-crop", "object.clippingMask.make", none()));
|
||
} else {
|
||
items.push((tl!("Unite"), "squares-unite", "object.pathfinder.unite", none()));
|
||
}
|
||
} else if is_trace {
|
||
items.push((tl!("Expand"), "maximize-2", "imageTrace.expand", none()));
|
||
} else if is_clip_group {
|
||
items.push((tl!("Edit Clipping Path"), "dc-direct", "object.clippingMask.editMask", none()));
|
||
items.push((tl!("Release Clipping Mask"), "ungroup", "object.clippingMask.release", none()));
|
||
} else if is_group {
|
||
items.push((tl!("Ungroup"), "ungroup", "object.ungroup", none()));
|
||
items.push((tl!("Isolate"), "square-dashed", "object.isolate", none()));
|
||
} else if is_compound {
|
||
items.push(edit_path());
|
||
items.push((tl!("Release Compound Path"), "ungroup", "object.compoundPath.release", none()));
|
||
} else if let Some(kind) = text {
|
||
items.push((tl!("Create Outlines"), "type", "type.createOutlines", none()));
|
||
// Point type converts to area type and back; type on a path doesn't.
|
||
match kind {
|
||
TextKind::Area { .. } => items.push((tl!("Convert To Point Type"), "type", "type.convertToPointType", none())),
|
||
TextKind::Point => items.push((tl!("Convert To Area Type"), "dc-type-area", "type.convertToAreaType", none())),
|
||
TextKind::OnPath { .. } => {}
|
||
}
|
||
} else if is_image {
|
||
// Offset Path and Simplify don't apply to an image.
|
||
} else if !anchors {
|
||
if open_path {
|
||
items.push((tl!("Join"), "dc-join", "path.join", none()));
|
||
}
|
||
items.push(edit_path());
|
||
items.push((tl!("Offset Path"), "square-dashed", "object.path.offsetPath", none()));
|
||
items.push((tl!("Simplify"), "spline", "object.path.simplify", none()));
|
||
}
|
||
items.push((tl!("Duplicate"), "copy", "edit.duplicate", none()));
|
||
}
|
||
let fill = first.as_ref().map(|f| f.appearance.fill_paint()).unwrap_or_default();
|
||
let colored = has_colors(&st.doc, &st.selection.objects);
|
||
let doc = st.uid;
|
||
// Under the selection's lowest corner on screen, centered on the selection (the view may be
|
||
// rotated).
|
||
let lowest = xf.quad(b).iter().map(|c| c.y).fold(f32::NEG_INFINITY, f32::max);
|
||
let anchor = pos2(xf.to_screen(b.center()).x, lowest);
|
||
let est_w = 118.0 + items.iter().map(|(l, _, _, _)| l.len() as f32 * 7.2 + 44.0).sum::<f32>();
|
||
// Its place under the selection, then where the handle moved it, kept on the canvas (with the
|
||
// bar's size last frame, the estimate before it first shows).
|
||
let under = pos2(anchor.x - est_w / 2.0, anchor.y + 28.0);
|
||
let id = task_bar_id();
|
||
let size = egui::AreaState::load(ui.ctx(), id).and_then(|s| s.size).unwrap_or(vec2(est_w, 44.0));
|
||
let bounds = xf.rect.shrink(8.0);
|
||
let keep_in = |p: Pos2| p.clamp(bounds.min, (bounds.max - size).max(bounds.min));
|
||
let place = &mut app.ui.task_bar_place;
|
||
// Just unpinned: follow the selection from the pinned spot.
|
||
if !place.pinned
|
||
&& let Some(at) = place.pin_at.take()
|
||
{
|
||
place.offset = Some((doc, xf.rect.min + at - under));
|
||
}
|
||
let pos = keep_in(match place.pin_at {
|
||
Some(at) => xf.rect.min + at,
|
||
None => under + place.offset.filter(|(d, _)| *d == doc).map_or(Vec2::ZERO, |(_, o)| o),
|
||
});
|
||
place.shown_at = Some(pos - xf.rect.min);
|
||
// Pinned before it ever showed: hold it here.
|
||
if place.pinned && place.pin_at.is_none() {
|
||
place.pin_at = place.shown_at;
|
||
}
|
||
let pinned = place.pinned;
|
||
let mut moved = Vec2::ZERO;
|
||
let mut run: Option<(&str, serde_json::Value)> = None;
|
||
egui::Area::new(id).order(egui::Order::Middle).fixed_pos(pos).show(ui.ctx(), |ui| {
|
||
egui::Frame::NONE
|
||
.fill(t.panel)
|
||
.stroke(Stroke::new(1.0, t.tool_active))
|
||
.corner_radius(CornerRadius::same(5))
|
||
.inner_margin(egui::Margin::symmetric(8, 6))
|
||
.shadow(egui::epaint::Shadow { offset: [0, 3], blur: 10, spread: 0, color: Color32::from_black_alpha(70) })
|
||
.show(ui, |ui| {
|
||
ui.horizontal(|ui| {
|
||
ui.spacing_mut().item_spacing.x = 6.0;
|
||
let (g, grip) = ui.allocate_exact_size(vec2(4.0, 28.0), Sense::drag());
|
||
let active = grip.hovered() || grip.dragged();
|
||
ui.painter().rect_filled(g.shrink2(vec2(0.5, 4.0)), CornerRadius::same(2), if active { t.text_dim } else { t.button_border });
|
||
if grip.dragged() {
|
||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
|
||
moved = grip.drag_delta();
|
||
} else if grip.hovered() {
|
||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
|
||
}
|
||
for (label, icon, cmd, params) in &items {
|
||
let galley = ui.painter().layout_no_wrap(label.to_string(), egui::FontId::proportional(13.0), t.text_strong);
|
||
let (r, resp) = ui.allocate_exact_size(vec2(galley.size().x + 38.0, 30.0), Sense::click());
|
||
if resp.hovered() {
|
||
ui.painter().rect_filled(r, CornerRadius::same(3), t.hover);
|
||
}
|
||
ui.painter().rect_stroke(r, CornerRadius::same(3), Stroke::new(1.0, t.button_border), StrokeKind::Inside);
|
||
crate::icons::paint(ui, icon, egui::Rect::from_min_size(r.min + vec2(8.0, 7.0), vec2(16.0, 16.0)), t.icon);
|
||
ui.painter().galley(pos2(r.left() + 30.0, r.center().y - galley.size().y / 2.0), galley, t.text_strong);
|
||
if resp.clicked() {
|
||
run = Some((*cmd, params.clone()));
|
||
}
|
||
}
|
||
// The selected objects' Fill, Recolor Artwork and Lock, left out with the Artboard tool.
|
||
if !on_artboards {
|
||
let (r, resp) = ui.allocate_exact_size(vec2(26.0, 30.0), Sense::click());
|
||
widgets::paint_chip(ui, egui::Rect::from_center_size(r.center(), vec2(16.0, 16.0)), &fill);
|
||
ui.painter().rect_stroke(
|
||
egui::Rect::from_center_size(r.center(), vec2(16.0, 16.0)),
|
||
0.0,
|
||
Stroke::new(1.0, t.button_border),
|
||
StrokeKind::Outside,
|
||
);
|
||
if resp.on_hover_text(tl!("Fill")).clicked() {
|
||
app.session.fill_active = true;
|
||
app.ui.open_panel = Some("swatches".into());
|
||
}
|
||
// Recolor Artwork, when the selection has colours to recolour.
|
||
if colored && widgets::icon_button(ui, "palette", tl!("Recolor Artwork"), false, 30.0).clicked() {
|
||
run = Some(("ui.recolorDialog", json!({})));
|
||
}
|
||
if widgets::icon_button(ui, "lock", tl!("Lock (⌘2)"), false, 30.0).clicked() {
|
||
run = Some(("object.lock", json!({})));
|
||
}
|
||
}
|
||
let more = widgets::icon_button(ui, "ellipsis", tl!("More Options"), false, 30.0);
|
||
egui::Popup::menu(&more).show(|ui| {
|
||
ui.set_min_width(170.0);
|
||
let bar = [
|
||
(tl!("Hide Bar"), "window.taskBar", false),
|
||
(tl!("Pin Bar Position"), "window.taskBar.pin", pinned),
|
||
(tl!("Reset Bar Position"), "window.taskBar.reset", false),
|
||
];
|
||
for (label, cmd, checked) in bar {
|
||
if widgets::menu_item(ui, label, true, checked) {
|
||
run = Some((cmd, json!({})));
|
||
}
|
||
}
|
||
ui.separator();
|
||
if widgets::menu_item(ui, tl!("Show Properties Panel"), true, false) {
|
||
run = Some(("window.panel", json!({"panel": "properties"})));
|
||
}
|
||
});
|
||
});
|
||
});
|
||
});
|
||
if moved != Vec2::ZERO {
|
||
let to = keep_in(pos + moved);
|
||
let place = &mut app.ui.task_bar_place;
|
||
if place.pinned {
|
||
place.pin_at = Some(to - xf.rect.min);
|
||
} else {
|
||
place.offset = Some((doc, to - under));
|
||
}
|
||
ui.ctx().request_repaint();
|
||
}
|
||
match run {
|
||
// Image Trace traces the image at once with the Default preset. As a quick trace from
|
||
// Properties or the Control bar, it leaves the Image Trace panel as it was.
|
||
Some((TASK_BAR_IMAGE_TRACE, _)) => crate::menus::invoke(app, "imageTrace.make", json!({"preset": "Default"})),
|
||
// The Artboard tool's buttons: Artboard Options for the active artboard, as Properties'
|
||
// icon opens it; Duplicate for every selected artboard; Rearrange All Artboards.
|
||
Some((TASK_BAR_ARTBOARD_OPTIONS, _)) => crate::menus::invoke(app, "tool.options", json!({"tool": "artboard"})),
|
||
Some((TASK_BAR_DUPLICATE_ARTBOARDS, _)) => crate::panels::artboards::duplicate_picked(app),
|
||
Some((TASK_BAR_REARRANGE_ARTBOARDS, _)) => crate::panels::artboards::open_rearrange(app),
|
||
// Corner and Smooth convert the direct-selected anchors, as the Control bar's buttons do.
|
||
Some((TASK_BAR_TO_CORNER, _)) => crate::menus::invoke(app, "path.convertAnchors", json!({"to": "corner"})),
|
||
Some((TASK_BAR_TO_SMOOTH, _)) => crate::menus::invoke(app, "path.convertAnchors", json!({"to": "smooth"})),
|
||
Some((c, p)) => crate::menus::invoke(app, c, p),
|
||
None => {}
|
||
}
|
||
}
|
||
|
||
/// The Contextual Task Bar's Image Trace button: a trace with the Default preset.
|
||
const TASK_BAR_IMAGE_TRACE: &str = "taskBar.imageTrace";
|
||
/// The Contextual Task Bar's Resize button with the Artboard tool: Artboard Options.
|
||
const TASK_BAR_ARTBOARD_OPTIONS: &str = "taskBar.artboardOptions";
|
||
/// The Contextual Task Bar's Duplicate button with the Artboard tool.
|
||
const TASK_BAR_DUPLICATE_ARTBOARDS: &str = "taskBar.duplicateArtboards";
|
||
/// The Contextual Task Bar's Rearrange All button with the Artboard tool.
|
||
const TASK_BAR_REARRANGE_ARTBOARDS: &str = "taskBar.rearrangeArtboards";
|
||
/// The Contextual Task Bar's Corner button for direct-selected anchors.
|
||
const TASK_BAR_TO_CORNER: &str = "taskBar.toCorner";
|
||
/// The Contextual Task Bar's Smooth button for direct-selected anchors.
|
||
const TASK_BAR_TO_SMOOTH: &str = "taskBar.toSmooth";
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use vectorcraft_engine::Session;
|
||
use vectorcraft_geom::Shape as _;
|
||
|
||
/// A rendered raster becomes the image egui's own conversion makes of it, half-transparent
|
||
/// edges and all; one whose bytes don't match its size becomes an empty image of that size.
|
||
#[test]
|
||
fn rendered_rasters_become_the_same_egui_images() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &serde_json::json!({})).unwrap();
|
||
s.execute("shape.ellipse", &serde_json::json!({"x": 10, "y": 10, "width": 60, "height": 40})).unwrap();
|
||
s.execute("paint.setFill", &serde_json::json!({"color": "#3366cc"})).unwrap();
|
||
let doc = s.active().unwrap().doc.clone();
|
||
let img = vectorcraft_render::Renderer::new().render(&doc, 97, 61, Affine::IDENTITY, &vectorcraft_render::RenderOptions::default());
|
||
assert!(img.pixels.as_chunks::<4>().0.iter().any(|p| p[3] != 0 && p[3] != 255), "partly covered pixels");
|
||
let egui = egui::ColorImage::from_rgba_premultiplied([97, 61], &img.pixels);
|
||
assert_eq!(color_image(&img), egui);
|
||
let short = vectorcraft_render::Rendered { width: 3, height: 2, pixels: vec![255; 5] };
|
||
assert_eq!(color_image(&short), egui::ColorImage::new([3, 2], vec![egui::Color32::TRANSPARENT; 6]));
|
||
}
|
||
|
||
/// With VECTORCRAFT_SYSTEM_CURSORS set the glyphs aren't drawn and the system's cursors stand in: those of
|
||
/// the Pen and the other drawing tools are the system crosshair already, so Use Precise Cursors makes no
|
||
/// difference to them there.
|
||
#[test]
|
||
fn system_cursors_show_the_same_crosshair_with_precise_cursors_on_or_off() {
|
||
for c in [
|
||
Cursor::Pen,
|
||
Cursor::PenDrawing,
|
||
Cursor::PenAdd,
|
||
Cursor::PenDelete,
|
||
Cursor::PenClose,
|
||
Cursor::PenContinue,
|
||
Cursor::PenJoin,
|
||
Cursor::PenConvert,
|
||
Cursor::Eyedropper,
|
||
Cursor::Slice,
|
||
Cursor::Blend,
|
||
Cursor::BlendObject,
|
||
Cursor::BlendAnchor,
|
||
Cursor::ShapeBuilder,
|
||
Cursor::ShapeBuilderErase,
|
||
Cursor::PencilNew,
|
||
Cursor::PencilStraight,
|
||
Cursor::PencilContinue,
|
||
] {
|
||
assert_eq!(cursor_icon(c), egui::CursorIcon::Crosshair, "{c:?}");
|
||
assert_eq!(cursor_icon(c), cursor_icon(c.precise()), "{c:?}");
|
||
}
|
||
}
|
||
|
||
/// #826: in a turned view each ruler still has ticks along its whole length; at 0° the top one
|
||
/// reads x where the canvas has it.
|
||
#[test]
|
||
fn rulers_cover_their_length_at_any_rotation() {
|
||
let ctx = egui::Context::default();
|
||
crate::theme::install_fonts(&ctx);
|
||
let full = egui::Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0));
|
||
let [top, left, _] = ruler_rects(full);
|
||
for deg in [0.0_f64, 30.0, 90.0, 135.0, 200.0] {
|
||
let xf = Xf { rect: full, zoom: 1.0, center: Point::new(300.0, 200.0), rot: deg.to_radians() };
|
||
let scale = RulerScale::document(Unit::Points, Point::ZERO);
|
||
let mut out = ctx.run_ui(Default::default(), |ui| rulers(ui, full, &xf, None, scale, &Tokens::get(ui.ctx())));
|
||
out.textures_delta.clear();
|
||
// The ticks' positions along each ruler.
|
||
let along = |ruler: egui::Rect, x: bool| -> Vec<f32> {
|
||
let mut at: Vec<f32> = out
|
||
.shapes
|
||
.iter()
|
||
.filter(|c| c.clip_rect == ruler)
|
||
.filter_map(|c| match &c.shape {
|
||
Shape::LineSegment { points: [a, b], .. } if x && a.x == b.x => Some(a.x),
|
||
Shape::LineSegment { points: [a, b], .. } if !x && a.y == b.y => Some(a.y),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
at.sort_by(f32::total_cmp);
|
||
at
|
||
};
|
||
for (ruler, x) in [(top, true), (left, false)] {
|
||
let at = along(ruler, x);
|
||
let (start, end) = if x { (ruler.left(), ruler.right()) } else { (ruler.top(), ruler.bottom()) };
|
||
let (Some(first), Some(last)) = (at.first(), at.last()) else { panic!("{deg}°: no ticks") };
|
||
assert!(first - start < 20.0 && end - last < 20.0, "{deg}°: ticks from {first} to {last} on {start}..{end}");
|
||
assert!(at.windows(2).all(|w| w[1] - w[0] < 20.0), "{deg}°: a gap in the ticks");
|
||
}
|
||
if deg == 0.0 {
|
||
let origin = xf.to_screen(Point::ZERO).x;
|
||
assert!(along(top, true).iter().any(|x| (x - origin).abs() < 0.01), "a tick at x = 0");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn ruler_origin_zero_ticks_follow_the_origin_at_any_rotation_and_unit() {
|
||
let ctx = egui::Context::default();
|
||
crate::theme::install_fonts(&ctx);
|
||
let full = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
|
||
let [top, left, _] = ruler_rects(full);
|
||
let origin = Point::new(350.25, 250.5);
|
||
for deg in [0.0_f64, 30.0, 90.0, 200.0] {
|
||
for unit in [Unit::Points, Unit::Millimeters] {
|
||
let xf = Xf { rect: full, zoom: 1.7, center: Point::new(300.0, 200.0), rot: deg.to_radians() };
|
||
let scale = RulerScale::document(unit, origin);
|
||
let mut out = ctx.run_ui(Default::default(), |ui| rulers(ui, full, &xf, None, scale, &Tokens::get(ui.ctx())));
|
||
out.textures_delta.clear();
|
||
let at = xf.to_screen(origin);
|
||
for (r, horizontal) in [(top, true), (left, false)] {
|
||
let zeros: Vec<_> = out
|
||
.shapes
|
||
.iter()
|
||
.filter(|c| c.clip_rect == r)
|
||
.filter_map(|c| match &c.shape {
|
||
Shape::Text(t) if t.galley.text() == "0" => Some(t.pos),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
// Left-ruler labels are separate digits: a zero in "100" isn't the zero label.
|
||
assert!(
|
||
zeros.iter().any(|zero| {
|
||
let delta = if horizontal { zero.x - 2.0 - at.x } else { zero.y - 2.0 - at.y };
|
||
delta.abs() < 0.02
|
||
}),
|
||
"{deg}°, {unit:?}: zeros at {zeros:?}, origin {at:?}"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// View › Rulers › Show Video Rulers counts the artboard in video pixels from its top-left
|
||
/// corner, a pixel as wide as the artboard's Video Ruler Pixel Aspect Ratio. At 0.9 a
|
||
/// 720 × 480 pt artboard is 800 pixels wide and 480 tall: the top ruler reads 0 at its left
|
||
/// edge and 800 at its right, labelled every 100 pixels (90 points on screen at 100%), and the
|
||
/// left ruler counts a pixel a point, from 0 at the top edge to a tick at the bottom one. At 1
|
||
/// (square pixels) the artboard is 720 pixels wide.
|
||
#[test]
|
||
fn video_rulers_count_the_artboard_in_pixels_of_its_aspect_ratio() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width": 720, "height": 480})).unwrap();
|
||
s.execute("artboard.setProps", &json!({"index": 0, "videoRulerPixelAspectRatio": 0.9})).unwrap();
|
||
let ab = s.active().unwrap().doc.artboards[0].clone();
|
||
let r = ab.rect;
|
||
let scale = RulerScale::video(&ab);
|
||
let (x, y) = (vectorcraft_geom::Vec2::new(1.0, 0.0), vectorcraft_geom::Vec2::new(0.0, 1.0));
|
||
let close = |a: f64, b: f64| (a - b).abs() < 1e-9;
|
||
assert_eq!((scale.unit, scale.origin), (Unit::Pixels, Point::new(r.x0, r.y0)));
|
||
assert!(close(scale.along(Point::new(r.x1, r.y1), x), 800.0) && close(scale.along(Point::new(r.x1, r.y1), y), 480.0));
|
||
assert!(close(scale.along(Point::new(r.x0 + 9.0, r.y0), x), 10.0), "a pixel is 0.9 pt wide");
|
||
assert!(close(scale.zoom_along(2.0, x), 1.8) && close(scale.zoom_along(2.0, y), 2.0));
|
||
let square = RulerScale { stretch: [1.0, 1.0], ..scale };
|
||
assert!(close(square.along(Point::new(r.x1, r.y0), x), 720.0));
|
||
// Drawn at 100%.
|
||
let ctx = egui::Context::default();
|
||
crate::theme::install_fonts(&ctx);
|
||
let full = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
|
||
let [top, left, _] = ruler_rects(full);
|
||
let xf = Xf { rect: full, zoom: 1.0, center: r.center(), rot: 0.0 };
|
||
let mut out = ctx.run_ui(Default::default(), |ui| rulers(ui, full, &xf, None, scale, &Tokens::get(ui.ctx())));
|
||
out.textures_delta.clear();
|
||
let labels = |ruler: egui::Rect| -> Vec<(String, Pos2)> {
|
||
out.shapes
|
||
.iter()
|
||
.filter(|c| c.clip_rect == ruler)
|
||
.filter_map(|c| match &c.shape {
|
||
Shape::Text(t) => Some((t.galley.text().to_string(), t.pos)),
|
||
_ => None,
|
||
})
|
||
.collect()
|
||
};
|
||
let (l, rgt) = (xf.to_screen(Point::new(r.x0, r.y0)), xf.to_screen(Point::new(r.x1, r.y1)));
|
||
let at = |labels: &[(String, Pos2)], text: &str, x: f32| labels.iter().any(|(t, p)| t == text && (p.x - 2.0 - x).abs() < 0.02);
|
||
let tops = labels(top);
|
||
assert!(at(&tops, "0", l.x) && at(&tops, "800", rgt.x), "{tops:?} from {l:?} to {rgt:?}");
|
||
assert!(at(&tops, "100", l.x + 90.0) && !tops.iter().any(|(t, _)| t == "50"), "{tops:?}");
|
||
assert!(!tops.iter().any(|(t, _)| t == "720"), "points aren't pixels: {tops:?}");
|
||
let lefts = labels(left);
|
||
assert!(lefts.iter().any(|(t, p)| t == "0" && (p.y - 2.0 - l.y).abs() < 0.02), "{lefts:?}");
|
||
let ticks: Vec<f32> = out
|
||
.shapes
|
||
.iter()
|
||
.filter(|c| c.clip_rect == left)
|
||
.filter_map(|c| match &c.shape {
|
||
Shape::LineSegment { points: [a, b], .. } if a.y == b.y => Some(a.y),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
assert!(ticks.iter().any(|y| (y - rgt.y).abs() < 0.02), "a tick at 480 px: {ticks:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn shaper_selection_highlights_only_the_visible_result() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({})).unwrap();
|
||
s.execute("shape.ellipse", &json!({"x":100,"y":100,"width":100,"height":100})).unwrap();
|
||
let line = vectorcraft_doc::NodeId(s.execute("shape.line", &json!({"x1":50,"y1":150,"x2":250,"y2":150})).unwrap()["id"].as_u64().unwrap());
|
||
for x in [0.0, 150.0] {
|
||
s.execute("shaper.scribble", &json!({"points":[[60.0+x,140],[70.0+x,160],[80.0+x,140],[90.0+x,160]]})).unwrap();
|
||
}
|
||
let st = s.active().unwrap();
|
||
let g = st.doc.node(st.selection.objects[0]).unwrap();
|
||
assert!(g.extras().shaper.is_some());
|
||
let symbols = st.doc.symbol_boxes();
|
||
let bounds = node_outline(g, &symbols).bounding_box();
|
||
assert!((bounds.x0 - 100.0).abs() < 0.01 && (bounds.x1 - 200.0).abs() < 0.01, "{bounds:?}");
|
||
assert_eq!(node_outline(st.doc.node(line).unwrap(), &symbols).bounding_box().x0, 50.0, "an isolated original still shows its full outline");
|
||
}
|
||
|
||
/// A clip group made from scratch: a 320 × 40 image and a 300 × 200 ellipse, both reaching
|
||
/// outside a 100 × 100 square at (100, 100) that clips them, and a rectangle beside them. The
|
||
/// task bar and rulers are off. → the app, the clip group, the ellipse and the rectangle.
|
||
fn clip_group_app() -> (VectorcraftApp, serde_json::Value, serde_json::Value, serde_json::Value) {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||
app.ui.task_bar = false;
|
||
app.ui.view.rulers = false;
|
||
let img = image::RgbaImage::from_pixel(320, 40, image::Rgba([200, 120, 40, 255]));
|
||
let mut png = vec![];
|
||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||
let data = vectorcraft_format::base64_encode(&png);
|
||
app.run("file.place", json!({"name": "texture.png", "dataBase64": data, "link": false, "at": [150, 150]})).unwrap();
|
||
let image = json!(app.session.active().unwrap().selection.objects[0].0);
|
||
let ellipse = app.run("shape.ellipse", json!({"x": 0, "y": 50, "width": 300, "height": 200})).unwrap()["id"].clone();
|
||
let square = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].clone();
|
||
app.run("select.set", json!({"ids": [image, ellipse, square]})).unwrap();
|
||
let group = app.run("object.clippingMask.make", json!({})).unwrap()["id"].clone();
|
||
let beside = app.run("shape.rectangle", json!({"x": 330, "y": 20, "width": 50, "height": 50})).unwrap()["id"].clone();
|
||
(app, group, ellipse, beside)
|
||
}
|
||
|
||
/// The screen points of what one frame draws in `color` with lines `width` px wide: outlines and
|
||
/// the bounding box, and with `width` 1 the anchors and the bounding box's handles too.
|
||
fn drawn_in(app: &mut VectorcraftApp, ctx: &egui::Context, color: Color32, width: f32) -> Vec<Pos2> {
|
||
let solid = egui::epaint::ColorMode::Solid(color);
|
||
let mut at = vec![];
|
||
for s in shapes(app, ctx) {
|
||
match s {
|
||
Shape::Path(ps) if ps.stroke.color == solid && ps.stroke.width == width => at.extend(ps.points),
|
||
Shape::Rect(r) if width == 1.0 && (r.fill == color || (r.stroke.color == color && r.stroke.width == width)) => {
|
||
at.extend([r.rect.min, r.rect.max]);
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
at
|
||
}
|
||
|
||
/// #2422: a selected clip group shows its clipping path's outline and anchors, the bounding
|
||
/// box of what shows, and not the art the path clips; so do its hover highlight and its key
|
||
/// object outline.
|
||
#[test]
|
||
fn a_selected_clip_group_shows_its_clipping_path_not_its_hidden_art() {
|
||
let (mut app, group, _, beside) = clip_group_app();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let color = c32(app.session.active().unwrap().doc.layer_color(vectorcraft_doc::NodeId(group.as_u64().unwrap())));
|
||
// The square on screen. `inside` allows 8 px around it for the anchors and the bounding box's handles.
|
||
let clip = egui::Rect::from_two_pos(xf.to_screen(Point::new(100.0, 100.0)), xf.to_screen(Point::new(200.0, 200.0)));
|
||
// Each case that draws nothing, or draws outside the square.
|
||
let mut wrong = vec![];
|
||
let mut inside = |at: Vec<Pos2>, what: &str| {
|
||
if at.is_empty() || !at.iter().all(|q| clip.expand(8.0).contains(*q)) {
|
||
wrong.push(format!("{what}: {at:?}"));
|
||
}
|
||
};
|
||
for tool in ["selection", "directSelection", "groupSelection"] {
|
||
app.select_tool(tool);
|
||
app.run("select.set", json!({"ids": [group]})).unwrap();
|
||
inside(drawn_in(&mut app, &ctx, color, 1.0), tool);
|
||
}
|
||
app.select_tool("selection");
|
||
app.run("select.set", json!({"ids": [group, beside]})).unwrap();
|
||
app.run("select.key", json!({"id": group})).unwrap();
|
||
inside(drawn_in(&mut app, &ctx, color, KEY_OUTLINE), "the key object");
|
||
app.run("select.none", json!({})).unwrap();
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(xf.to_screen(Point::new(150.0, 150.0)))]);
|
||
inside(drawn_in(&mut app, &ctx, color, 1.5), "the hover highlight");
|
||
assert!(wrong.is_empty(), "drawn outside {clip:?}:\n{}", wrong.join("\n"));
|
||
}
|
||
|
||
/// A group of four clip groups, each a placed image larger than the rounded square that clips
|
||
/// it: one click with the Selection tool selects the group, which shows the four squares'
|
||
/// outlines and no image's rectangle, and a bounding box that is the union of the squares.
|
||
#[test]
|
||
fn a_group_of_clipped_images_shows_its_masks_and_their_bounds() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width": 600, "height": 300})).unwrap();
|
||
app.ui.task_bar = false;
|
||
app.ui.view.rulers = false;
|
||
let img = image::RgbaImage::from_pixel(160, 160, image::Rgba([40, 120, 200, 255]));
|
||
let mut png = vec![];
|
||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||
let data = vectorcraft_format::base64_encode(&png);
|
||
// Masks 100 × 100 at x = 20, 150, 280, 410; each image reaches 30 past its mask on every side.
|
||
let masks: Vec<Rect> = (0..4).map(|i| Rect::new(20.0 + 130.0 * i as f64, 100.0, 120.0 + 130.0 * i as f64, 200.0)).collect();
|
||
let mut clips = vec![];
|
||
for m in &masks {
|
||
app.run("file.place", json!({"name": "photo.png", "dataBase64": data, "link": false, "at": [m.center().x, m.center().y]})).unwrap();
|
||
let image = json!(app.session.active().unwrap().selection.objects[0].0);
|
||
let mask = app.run("shape.rectangle", json!({"x": m.x0, "y": m.y0, "width": 100, "height": 100, "radius": 16})).unwrap()["id"].clone();
|
||
app.run("select.set", json!({"ids": [image, mask]})).unwrap();
|
||
clips.push(app.run("object.clippingMask.make", json!({})).unwrap()["id"].clone());
|
||
}
|
||
app.run("select.set", json!({"ids": clips})).unwrap();
|
||
let group = vectorcraft_doc::NodeId(app.run("object.group", json!({})).unwrap()["id"].as_u64().unwrap());
|
||
app.run("select.none", json!({})).unwrap();
|
||
let union = masks.iter().copied().reduce(|a, b| a.union(b)).unwrap();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
// One click on a visible part of the second image selects the whole group.
|
||
app.select_tool("selection");
|
||
let view = app.view_info();
|
||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||
app.session.pointer(&PointerEvent::new(kind, 200.0, 150.0), view).unwrap();
|
||
}
|
||
assert_eq!(app.session.active().unwrap().selection.objects, vec![group]);
|
||
// The bounding box, the bounds and the transform box fit the masks, not the images.
|
||
let close =
|
||
|r: Option<Rect>| r.is_some_and(|r| [r.x0 - union.x0, r.y0 - union.y0, r.x1 - union.x1, r.y1 - union.y1].iter().all(|d| d.abs() < 1e-9));
|
||
let st = app.session.active().unwrap();
|
||
assert!(close(st.doc.node(group).unwrap().geometric_bounds()), "the group's bounds");
|
||
assert!(close(app.selection_box().map(|b| b.rect)), "the bounding box");
|
||
// Everything drawn in the layer's colour lies on a mask: on its outline, its anchors or the
|
||
// bounding box round them (8 px for the anchors and the box's handles).
|
||
let color = c32(app.session.active().unwrap().doc.layer_color(group));
|
||
let screen = |r: Rect| egui::Rect::from_two_pos(xf.to_screen(Point::new(r.x0, r.y0)), xf.to_screen(Point::new(r.x1, r.y1)));
|
||
let drawn = drawn_in(&mut app, &ctx, color, 1.0);
|
||
assert!(!drawn.is_empty());
|
||
let near_a_mask = |q: &Pos2| masks.iter().any(|m| screen(*m).expand(8.0).contains(*q));
|
||
let on_the_box = |q: &Pos2| {
|
||
let b = screen(union);
|
||
b.expand(8.0).contains(*q) && !b.shrink(8.0).contains(*q)
|
||
};
|
||
let stray: Vec<_> = drawn.iter().filter(|q| !near_a_mask(q) && !on_the_box(q)).collect();
|
||
assert!(stray.is_empty(), "drawn off the masks: {stray:?}");
|
||
// Each mask's outline is drawn: points on its left and right edges.
|
||
for m in &masks {
|
||
let r = screen(*m);
|
||
let on = |x: f32| drawn.iter().any(|q| (q.x - x).abs() < 1.0 && q.y > r.min.y + 16.0 && q.y < r.max.y - 16.0);
|
||
assert!(on(r.min.x) && on(r.max.x), "the outline of {m:?}");
|
||
}
|
||
// No image's rectangle: nothing along the images' top edge, 30 points above the masks.
|
||
let top = xf.to_screen(Point::new(0.0, union.y0 - 30.0)).y;
|
||
assert!(!drawn.iter().any(|q| (q.y - top).abs() < 0.5), "the images' top edge at y {top}");
|
||
}
|
||
|
||
/// #2422: a clip group whose hidden art has more anchors than the overlay draws (#525) still
|
||
/// shows its clipping path's anchors and hover highlight.
|
||
#[test]
|
||
fn a_clip_group_over_a_huge_path_shows_its_clipping_path() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||
app.ui.task_bar = false;
|
||
app.ui.view.rulers = false;
|
||
let n = OVERLAY_MAX_ANCHORS + 10;
|
||
let points = (0..n).map(|i| format!("L{} {}", 20.0 + 360.0 * i as f64 / n as f64, 100 + (i % 2) * 50)).collect::<String>();
|
||
let huge = app.run("path.create", json!({"d": format!("M20 20 {points} Z")})).unwrap()["id"].clone();
|
||
let square = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].clone();
|
||
app.run("select.set", json!({"ids": [huge, square]})).unwrap();
|
||
let group = app.run("object.clippingMask.make", json!({})).unwrap()["id"].clone();
|
||
let color = c32(app.session.active().unwrap().doc.layer_color(vectorcraft_doc::NodeId(group.as_u64().unwrap())));
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
app.select_tool("selection");
|
||
app.run("select.set", json!({"ids": [group]})).unwrap();
|
||
let anchors = shapes(&mut app, &ctx).iter().filter(|s| matches!(s, Shape::Rect(r) if r.fill == color)).count();
|
||
app.run("select.none", json!({})).unwrap();
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(xf.to_screen(Point::new(150.0, 150.0)))]);
|
||
let hovered = !drawn_in(&mut app, &ctx, color, 1.5).is_empty();
|
||
assert_eq!((anchors, hovered), (4, true), "the square's four anchors, and the hover highlight");
|
||
}
|
||
|
||
/// #2422: the art inside a clip group still shows its whole outline when it is picked on its
|
||
/// own: by Edit Contents, by a Direct Selection or Group Selection click, and in isolation mode;
|
||
/// so does its Group Selection hover highlight.
|
||
#[test]
|
||
fn the_art_a_clip_group_clips_shows_whole_when_picked() {
|
||
let (mut app, group, ellipse, _) = clip_group_app();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let color = c32(app.session.active().unwrap().doc.layer_color(vectorcraft_doc::NodeId(group.as_u64().unwrap())));
|
||
// Something is drawn at the ellipse's left and right ends, outside the square.
|
||
let reaches = |at: &[Pos2], what: &str| {
|
||
let (left, right) = (xf.to_screen(Point::new(0.0, 150.0)).x, xf.to_screen(Point::new(300.0, 150.0)).x);
|
||
assert!(at.iter().any(|q| (q.x - left).abs() < 2.0) && at.iter().any(|q| (q.x - right).abs() < 2.0), "{what}: {at:?}");
|
||
};
|
||
app.select_tool("selection");
|
||
app.run("select.set", json!({"ids": [group]})).unwrap();
|
||
app.run("object.clippingMask.editContents", json!({})).unwrap();
|
||
reaches(&drawn_in(&mut app, &ctx, color, 1.0), "Edit Contents");
|
||
app.run("select.none", json!({})).unwrap();
|
||
app.select_tool("directSelection");
|
||
let view = app.view_info();
|
||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||
app.session.pointer(&PointerEvent::new(kind, 150.0, 120.0), view).unwrap();
|
||
}
|
||
assert_eq!(app.session.active().unwrap().selection.objects, vec![vectorcraft_doc::NodeId(ellipse.as_u64().unwrap())]);
|
||
reaches(&drawn_in(&mut app, &ctx, color, 1.0), "a Direct Selection click");
|
||
app.run("select.none", json!({})).unwrap();
|
||
app.select_tool("groupSelection");
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(xf.to_screen(Point::new(150.0, 120.0)))]);
|
||
reaches(&drawn_in(&mut app, &ctx, color, 1.5), "the Group Selection hover highlight");
|
||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||
app.session.pointer(&PointerEvent::new(kind, 150.0, 120.0), view).unwrap();
|
||
}
|
||
assert_eq!(app.session.active().unwrap().selection.objects, vec![vectorcraft_doc::NodeId(ellipse.as_u64().unwrap())]);
|
||
reaches(&drawn_in(&mut app, &ctx, color, 1.0), "a Group Selection click");
|
||
app.select_tool("selection");
|
||
app.run("object.isolate", json!({"id": group})).unwrap();
|
||
app.run("select.all", json!({})).unwrap();
|
||
reaches(&drawn_in(&mut app, &ctx, color, 1.0), "isolation mode");
|
||
}
|
||
|
||
/// A Shaper Group's mode widget, drawn for the Shaper and Selection tools with selection edges
|
||
/// shown or hidden: its arrow points right in Face Selection mode and left in Construction mode.
|
||
#[test]
|
||
fn the_shaper_widget_points_to_the_other_mode() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
for x in [0, 50] {
|
||
app.session.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 100, "height": 100})).unwrap();
|
||
}
|
||
let points = json!({"points": [[25, 20], [75, 35], [30, 50], [125, 65]]});
|
||
let g = app.session.execute("shaper.scribble", &points).unwrap()["id"].as_u64().unwrap();
|
||
let ctx = egui::Context::default();
|
||
// For each widget drawn, whether its arrow's tip is right of the circle's center.
|
||
let right = |app: &mut VectorcraftApp| -> Vec<bool> {
|
||
let drawn = shapes(app, &ctx);
|
||
let r = vectorcraft_tools::shaperwidget::RADIUS_PX;
|
||
let centers: Vec<egui::Pos2> = drawn
|
||
.iter()
|
||
.filter_map(|s| if let Shape::Circle(c) = s { (c.radius == r && c.fill == Color32::WHITE).then_some(c.center) } else { None })
|
||
.collect();
|
||
drawn
|
||
.iter()
|
||
.filter_map(|s| match s {
|
||
Shape::Path(p) if p.points.len() == 3 => centers.iter().find(|c| c.distance(p.points[0]) <= r).map(|c| p.points[0].x > c.x),
|
||
_ => None,
|
||
})
|
||
.collect()
|
||
};
|
||
app.select_tool("shaper");
|
||
assert_eq!(right(&mut app), vec![true], "the scribble selects the group");
|
||
app.session.execute("object.isolate", &json!({"id": g})).unwrap();
|
||
assert_eq!(right(&mut app), vec![false]);
|
||
app.select_tool("selection");
|
||
assert_eq!(right(&mut app), vec![false]);
|
||
// View › Hide Edges keeps the widget, which still answers clicks.
|
||
app.ui.view.edges = false;
|
||
assert_eq!(right(&mut app), vec![false]);
|
||
app.select_tool("pen");
|
||
assert!(right(&mut app).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn a_symbol_instance_is_outlined_around_its_art() {
|
||
// A symbol holding the square of the SVG import tests' `Star` symbol, 20 × 20 from (0, 0),
|
||
// and an instance of it at (20, 30).
|
||
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="120"><defs><symbol id="Star"><path d="M0 0H20V20H0Z" fill="#e0a000"/></symbol></defs><use href="#Star" x="20" y="30"/></svg>"##;
|
||
let mut s = Session::new();
|
||
s.execute("document.open", &json!({"name": "star.svg", "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())})).unwrap();
|
||
let st = s.active().unwrap();
|
||
let mut inst = None;
|
||
st.doc.walk(|n| {
|
||
if matches!(n.kind, NodeKind::SymbolInstance { .. }) {
|
||
inst = Some(n);
|
||
}
|
||
});
|
||
assert_eq!(node_outline(inst.unwrap(), &st.doc.symbol_boxes()).bounding_box(), Rect::new(20.0, 30.0, 40.0, 50.0));
|
||
}
|
||
|
||
/// One headless canvas frame on an 800 × 600 window.
|
||
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) {
|
||
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() };
|
||
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
|
||
out.textures_delta.clear();
|
||
}
|
||
|
||
/// The Image Trace panel's eye-button hold ends when the pointer is released, even when the
|
||
/// panel stopped drawing while the button was held, so a later press on the canvas does not
|
||
/// show the source image.
|
||
#[test]
|
||
fn a_release_ends_the_image_trace_hold_without_the_panel() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||
let made = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
|
||
let id = vectorcraft_doc::NodeId(made["id"].as_u64().unwrap());
|
||
let ctx = egui::Context::default();
|
||
let at = pos2(400.0, 300.0);
|
||
let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), press(true)]);
|
||
app.ui.trace_hold_source = Some(std::sync::Arc::new(vec![id]));
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert!(app.ui.trace_hold_source.is_some(), "held while the button is down");
|
||
frame(&mut app, &ctx, vec![press(false)]);
|
||
assert_eq!(app.ui.trace_hold_source, None, "the release ends the hold");
|
||
frame(&mut app, &ctx, vec![press(true)]);
|
||
assert_eq!(app.ui.trace_hold_source, None, "a later press does not show the source image");
|
||
frame(&mut app, &ctx, vec![press(false)]);
|
||
}
|
||
|
||
/// The baseline marker of selected point type runs under the line, wherever its alignment
|
||
/// puts the anchor: under centred and right-aligned type too, not from the anchor rightwards
|
||
/// (#2879).
|
||
#[test]
|
||
fn point_type_baselines_run_under_the_line_whatever_its_alignment() {
|
||
for justify in ["left", "center", "right"] {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width": 600, "height": 300})).unwrap();
|
||
app.ui.task_bar = false;
|
||
app.ui.view.rulers = false;
|
||
let made = app.run("text.create", json!({"x": 300, "y": 150, "text": "Baseline marker", "size": 24, "justify": justify})).unwrap();
|
||
let id = vectorcraft_doc::NodeId(made["id"].as_u64().unwrap());
|
||
app.run("select.set", json!({"ids": [id.0]})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let st = app.session.active().unwrap();
|
||
let b = st.doc.node(id).unwrap().geometric_bounds().unwrap();
|
||
let color = c32(st.doc.layer_color(id));
|
||
let y = xf.to_screen(Point::new(0.0, 150.0)).y;
|
||
let (left, right) = (xf.to_screen(Point::new(b.x0, 150.0)).x, xf.to_screen(Point::new(b.x1, 150.0)).x);
|
||
let on: Vec<f32> = shapes(&mut app, &ctx)
|
||
.into_iter()
|
||
.filter_map(|s| match s {
|
||
Shape::LineSegment { points, stroke } if stroke.color == color => Some(points),
|
||
_ => None,
|
||
})
|
||
.flatten()
|
||
.filter(|q| (q.y - y).abs() < 0.5)
|
||
.map(|q| q.x)
|
||
.collect();
|
||
let (lo, hi) = on.iter().fold((f32::MAX, f32::MIN), |(lo, hi), x| (lo.min(*x), hi.max(*x)));
|
||
assert!((lo - left).abs() < 3.0 && (hi - right).abs() < 3.0, "{justify}: the baseline runs {lo}..{hi}, the type {left}..{right}");
|
||
}
|
||
}
|
||
|
||
/// A click with the Selection tool inside an artboard makes it the active one, and Paste in
|
||
/// Place then pastes onto it, where the objects were on their own artboard (#693).
|
||
#[test]
|
||
fn a_click_activates_its_artboard_and_paste_in_place_goes_onto_it() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
|
||
let boards: Vec<_> = app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect).collect();
|
||
app.session.execute("shape.rectangle", &json!({"x": boards[0].x0 + 10.0, "y": boards[0].y0 + 15.0, "width": 20, "height": 20})).unwrap();
|
||
app.run("edit.copy", json!({})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
app.select_tool("selection");
|
||
let view = app.view_info();
|
||
let at = boards[2].center();
|
||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||
dispatch(&mut app, &PointerEvent { kind, pos: at, mods: Default::default(), pressure: 1.0 }, view);
|
||
}
|
||
assert_eq!(app.view().unwrap().artboard, 2, "the clicked artboard is the active one");
|
||
let ids: Vec<vectorcraft_doc::NodeId> = app.run("edit.pasteInPlace", json!({})).unwrap()["ids"]
|
||
.as_array()
|
||
.unwrap()
|
||
.iter()
|
||
.map(|v| vectorcraft_doc::NodeId(v.as_u64().unwrap()))
|
||
.collect();
|
||
let b = app.session.active().unwrap().doc.bounds_of(&ids, false).unwrap();
|
||
assert_eq!((b.x0, b.y0), (boards[2].x0 + 10.0, boards[2].y0 + 15.0));
|
||
}
|
||
|
||
/// A Shift-click with the Selection tool on an empty spot inside an artboard makes it the active
|
||
/// one and keeps the selection, and Align to Artboard then aligns the objects to it unless the
|
||
/// call names another artboard.
|
||
#[test]
|
||
fn a_shift_click_activates_its_artboard_and_align_to_artboard_aligns_to_it() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
|
||
let boards: Vec<_> = app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect).collect();
|
||
let mut rect = |x: f64, y: f64| {
|
||
let r = app.session.execute("shape.rectangle", &json!({"x": boards[0].x0 + x, "y": boards[0].y0 + y, "width": 20, "height": 20}));
|
||
vectorcraft_doc::NodeId(r.unwrap()["id"].as_u64().unwrap())
|
||
};
|
||
let ids = [rect(10.0, 15.0), rect(50.0, 40.0)];
|
||
app.session.execute("select.set", &json!({"ids": [ids[0].0, ids[1].0]})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
app.select_tool("selection");
|
||
let view = app.view_info();
|
||
let shift = Mods { shift: true, ..Default::default() };
|
||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||
dispatch(&mut app, &PointerEvent { kind, pos: boards[2].center(), mods: shift, pressure: 1.0 }, view);
|
||
}
|
||
assert_eq!(app.view().unwrap().artboard, 2, "the clicked artboard is the active one");
|
||
assert_eq!(app.session.active().unwrap().selection.objects, ids, "the selection stays");
|
||
let lefts = |app: &VectorcraftApp| ids.map(|id| app.session.active().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0);
|
||
app.run("object.align", json!({"horizontal": "left", "to": "artboard"})).unwrap();
|
||
assert_eq!(lefts(&app), [boards[2].x0; 2], "the left edges are on the active artboard's");
|
||
app.run("object.align", json!({"horizontal": "left", "to": "artboard", "artboard": 1})).unwrap();
|
||
assert_eq!(lefts(&app), [boards[1].x0; 2], "an artboard the caller names is kept");
|
||
}
|
||
|
||
/// The canvas outlines the active artboard in black, 1 px wide, and outlines the other
|
||
/// artboards in gray, 0.6 px wide. In the test, the active artboard is Artboard 1 at first,
|
||
/// then the one Go to Artboard went to, and then, with several artboards selected by the
|
||
/// Artboard tool, the tool's active one (the one Shift-clicked last).
|
||
#[test]
|
||
fn the_active_artboard_is_outlined_in_black() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
// → each artboard's edge as drawn: (width, color).
|
||
let edges = |app: &mut VectorcraftApp| {
|
||
let s = shapes(app, &ctx);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let on = |p: &[Pos2], q: &[Pos2]| p.len() == q.len() && p.iter().zip(q).all(|(a, b)| (*a - *b).length() < 0.01);
|
||
let boards: Vec<Rect> = app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect).collect();
|
||
boards
|
||
.iter()
|
||
.map(|r| {
|
||
let q = xf.quad(*r);
|
||
s.iter()
|
||
.find_map(|s| match s {
|
||
Shape::Path(p) if p.closed && p.fill == Color32::TRANSPARENT && on(&p.points, &q) => {
|
||
Some((p.stroke.width, p.stroke.color.clone()))
|
||
}
|
||
_ => None,
|
||
})
|
||
.expect("the artboard's edge")
|
||
})
|
||
.collect::<Vec<_>>()
|
||
};
|
||
let (black, gray) = (egui::epaint::ColorMode::Solid(Color32::from_gray(0)), egui::epaint::ColorMode::Solid(Color32::from_gray(120)));
|
||
let active = |i: usize| -> Vec<(f32, egui::epaint::ColorMode)> {
|
||
(0..3).map(|j| if j == i { (1.0, black.clone()) } else { (0.6, gray.clone()) }).collect()
|
||
};
|
||
assert_eq!(edges(&mut app), active(0));
|
||
app.run("view.goToArtboard", json!({"index": 1})).unwrap();
|
||
assert_eq!(edges(&mut app), active(1), "Artboard 2 is active: its edge is black and Artboard 1's edge is gray");
|
||
// The Artboard tool starts on Artboard 2; a Shift-click on Artboard 3 selects it too and
|
||
// makes it the active one.
|
||
app.select_tool("artboard");
|
||
let at = app.session.active().unwrap().doc.artboards[2].rect.center();
|
||
let view = app.view_info();
|
||
let shift = Mods { shift: true, ..Default::default() };
|
||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||
dispatch(&mut app, &PointerEvent { kind, pos: at, mods: shift, pressure: 1.0 }, view);
|
||
}
|
||
assert_eq!(app.session.tool_options()["selected"], json!([2, 1]));
|
||
assert_eq!(edges(&mut app), active(2), "Artboard 3 is the active one of the two selected");
|
||
}
|
||
|
||
// Regression test for #824: with the Artboard tool, the active artboard's name was drawn twice,
|
||
// once by the canvas as for every artboard and once, a little higher and in blue, by the tool.
|
||
// Now each artboard is named once, and the active one's name sits where the others' do.
|
||
#[test]
|
||
fn the_artboard_tool_names_its_active_artboard_once() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap();
|
||
app.select_tool("artboard");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let drawn = shapes(&mut app, &ctx);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
// Each name drawn: how far its left edge is right of the artboard's corner, and its bottom above it.
|
||
let named = |name: &str, board: usize| -> Vec<(f32, f32)> {
|
||
let r = app.session.active().unwrap().doc.artboards[board].rect;
|
||
let corner = xf.to_screen(Point::new(r.x0, r.y0));
|
||
drawn
|
||
.iter()
|
||
.filter_map(|s| match s {
|
||
Shape::Text(t) if t.galley.text() == name => Some((t.pos.x - corner.x, corner.y - (t.pos.y + t.galley.size().y))),
|
||
_ => None,
|
||
})
|
||
.collect()
|
||
};
|
||
let (active, other) = (named("01 - Artboard 1", 0), named("02 - Artboard 2", 1));
|
||
assert_eq!(other.len(), 1, "the other artboard is named once: {other:?}");
|
||
assert_eq!(active.len(), 1, "the active artboard is named once: {active:?}");
|
||
assert!((active[0].0 - other[0].0).abs() < 0.6, "flush with the artboard's left edge like the other name: {active:?} vs {other:?}");
|
||
assert!((active[0].1 - other[0].1).abs() < 2.0, "on the artboard's top edge like the other name: {active:?} vs {other:?}");
|
||
}
|
||
|
||
/// Enable Touch Gestures (#585): a two-finger tap undoes and a three-finger one redoes, and the
|
||
/// first finger's press (the pointer egui makes of it) draws nothing. Off, a tap does nothing.
|
||
#[test]
|
||
fn two_finger_tap_undoes_and_three_finger_tap_redoes() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 40, "height": 40})).unwrap();
|
||
app.select_tool("rectangle");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let count = |app: &VectorcraftApp| app.session.active().unwrap().doc.layers[0].children().unwrap().len();
|
||
let c = app.canvas_rect.unwrap().center();
|
||
let touch = |n: u64, phase, pos| egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(n), phase, pos, force: None };
|
||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
// `fingers` fingers tap at the centre, the first also pressing as the pointer and moving a
|
||
// little (as a rectangle drag would begin).
|
||
let tap = |app: &mut VectorcraftApp, fingers: u64| {
|
||
let at = |n: u64| c + vec2(n as f32 * 50.0, 0.0);
|
||
frame(app, &ctx, vec![touch(0, egui::TouchPhase::Start, at(0)), egui::Event::PointerMoved(at(0)), button(at(0), true)]);
|
||
frame(app, &ctx, vec![touch(0, egui::TouchPhase::Move, at(0) + vec2(6.0, 6.0)), egui::Event::PointerMoved(at(0) + vec2(6.0, 6.0))]);
|
||
frame(app, &ctx, (1..fingers).map(|n| touch(n, egui::TouchPhase::Start, at(n))).collect());
|
||
frame(app, &ctx, vec![touch(0, egui::TouchPhase::End, at(0)), button(at(0) + vec2(6.0, 6.0), false), egui::Event::PointerGone]);
|
||
frame(app, &ctx, (1..fingers).map(|n| touch(n, egui::TouchPhase::End, at(n))).collect());
|
||
frame(app, &ctx, vec![]);
|
||
};
|
||
tap(&mut app, 2);
|
||
assert_eq!(count(&app), 0, "the rectangle undone, none drawn");
|
||
tap(&mut app, 3);
|
||
assert_eq!(count(&app), 1, "redone");
|
||
app.session.prefs.touch_gestures = false;
|
||
tap(&mut app, 2);
|
||
assert_eq!(count(&app), 2, "off: the first finger draws as the pointer, and nothing is undone");
|
||
}
|
||
|
||
/// The Artboard tool's label holds the artboard's name (never translated); the tools' own
|
||
/// labels are interface text.
|
||
#[test]
|
||
fn artboard_labels_name_the_artboard() {
|
||
assert!(names_an_artboard("01 - Layers") && names_an_artboard("12 - Artboard 12 - copy"));
|
||
assert!(!names_an_artboard("anchor") && !names_an_artboard("1 - x") && !names_an_artboard("Off the mesh - move closer"));
|
||
}
|
||
|
||
/// Double-clicking the name drawn above an artboard edits that name and does not fit a new
|
||
/// artboard or open Artboard Options.
|
||
#[test]
|
||
fn double_clicking_an_artboard_name_edits_it() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("artboard.new", &json!({"x": 500, "y": 0, "width": 100, "height": 80, "name": "Far"})).unwrap();
|
||
app.session.execute("shape.rectangle", &json!({"x": 40, "y": 40, "width": 30, "height": 20})).unwrap();
|
||
app.select_tool("artboard");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let rect = app.canvas_rect.unwrap();
|
||
let xf = Xf::new(rect, app.view().unwrap());
|
||
let mut target = None;
|
||
let _ =
|
||
ctx.run_ui(egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }, |ui| {
|
||
let ab = &app.session.active().unwrap().doc.artboards[0];
|
||
target = Some(tool_name_rect(ui.painter(), &xf, ab.rect, 0, &ab.name).center());
|
||
});
|
||
let at = target.unwrap();
|
||
assert!(rect.contains(at), "the name is in the working area, at {at:?} canvas {rect:?}");
|
||
let before = app.session.active().unwrap().doc.artboards.len();
|
||
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
let click = |app: &mut VectorcraftApp| {
|
||
frame(app, &ctx, vec![egui::Event::PointerMoved(at)]);
|
||
frame(app, &ctx, vec![button(true)]);
|
||
frame(app, &ctx, vec![button(false)]);
|
||
};
|
||
click(&mut app);
|
||
click(&mut app);
|
||
assert!(ctx.data(|d| d.get_temp::<ArtboardNameEdit>(name_edit_id()).is_some()), "the name is being edited");
|
||
assert_eq!(app.session.active().unwrap().doc.artboards.len(), before, "clicking the name does not fit an artboard");
|
||
assert!(app.ui.dialog.is_none(), "Artboard Options stays closed");
|
||
commit_artboard_name(&mut app, 0, "Poster");
|
||
assert_eq!(app.session.active().unwrap().doc.artboards[0].name, "Poster");
|
||
commit_artboard_name(&mut app, 0, " ");
|
||
assert_eq!(app.session.active().unwrap().doc.artboards[0].name, "Poster", "an empty name is left as it is");
|
||
}
|
||
|
||
/// Double-clicking the name drawn above a locked artboard doesn't edit it (the Artboards panel
|
||
/// still renames the artboard), fit an artboard or open Artboard Options.
|
||
#[test]
|
||
fn double_clicking_a_locked_artboards_name_leaves_it() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("shape.rectangle", &json!({"x": 40, "y": 40, "width": 30, "height": 20})).unwrap();
|
||
app.session.execute("artboard.setProps", &json!({"index": 0, "locked": true})).unwrap();
|
||
app.select_tool("artboard");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let rect = app.canvas_rect.unwrap();
|
||
let xf = Xf::new(rect, app.view().unwrap());
|
||
let mut target = None;
|
||
let _ =
|
||
ctx.run_ui(egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }, |ui| {
|
||
let ab = &app.session.active().unwrap().doc.artboards[0];
|
||
target = Some(tool_name_rect(ui.painter(), &xf, ab.rect, 0, &ab.name).center());
|
||
});
|
||
let at = target.unwrap();
|
||
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
for _ in 0..2 {
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at)]);
|
||
frame(&mut app, &ctx, vec![button(true)]);
|
||
frame(&mut app, &ctx, vec![button(false)]);
|
||
}
|
||
assert!(ctx.data(|d| d.get_temp::<ArtboardNameEdit>(name_edit_id()).is_none()), "the name isn't edited");
|
||
assert_eq!(app.session.active().unwrap().doc.artboards.len(), 1);
|
||
assert!(app.ui.dialog.is_none(), "Artboard Options stays closed");
|
||
}
|
||
|
||
/// The canvas artboard name field keeps its own inset: egui 0.37 insets a text field by the theme's button
|
||
/// padding unless told otherwise, but this field sits over the canvas label, so its glyphs must not move when
|
||
/// that padding changes (an ordinary themed field's do, as the probe shows).
|
||
#[test]
|
||
fn artboard_name_field_glyphs_stay_put_when_button_padding_changes() {
|
||
/// Where the frame paints `text` (a field's text starts at its galley's top left).
|
||
fn text_at(shapes: &[Shape], text: &str) -> Pos2 {
|
||
shapes
|
||
.iter()
|
||
.find_map(|s| match s {
|
||
Shape::Text(t) if t.galley.text() == text => Some(t.pos),
|
||
_ => None,
|
||
})
|
||
.unwrap_or_else(|| panic!("no {text:?} text"))
|
||
}
|
||
/// Build raw input with a fixed 800 by 600 screen so headless frames keep a stable canvas texture
|
||
/// size.
|
||
fn raw() -> egui::RawInput {
|
||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }
|
||
}
|
||
/// The name field's text origin relative to its rectangle with the theme's button padding `padding`: the
|
||
/// field opened as a double click opens it, measured in the steady frame after it takes focus.
|
||
fn editor_inset(padding: Vec2) -> Vec2 {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("artboard.setProps", &json!({"index": 0, "name": "Inset"})).unwrap();
|
||
app.select_tool("artboard");
|
||
app.ui.task_bar = false;
|
||
app.ui.view.rulers = false;
|
||
let ctx = egui::Context::default();
|
||
crate::theme::install_fonts(&ctx);
|
||
crate::theme::apply(&ctx, crate::theme::Brightness::default());
|
||
ctx.global_style_mut(|s| s.spacing.button_padding = padding);
|
||
ctx.run_ui(raw(), |ui| begin_artboard_name_edit(ui, &app, 0)).textures_delta.clear();
|
||
// The first frame opens and focuses the field; the next one is its steady state.
|
||
frame(&mut app, &ctx, vec![]);
|
||
let (shapes, mut delta) = frame_output(&mut app, &ctx);
|
||
delta.clear();
|
||
let origin = text_at(&shapes, "Inset");
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let mut field = None;
|
||
ctx.run_ui(raw(), |ui| field = artboard_name_field_rect(&app, ui, &xf, 0).map(|r| r.min)).textures_delta.clear();
|
||
origin - field.unwrap()
|
||
}
|
||
/// An ordinary themed field's text origin relative to its rectangle: the padding moves it.
|
||
fn plain_inset(padding: Vec2) -> Vec2 {
|
||
let ctx = egui::Context::default();
|
||
crate::theme::install_fonts(&ctx);
|
||
crate::theme::apply(&ctx, crate::theme::Brightness::default());
|
||
ctx.global_style_mut(|s| s.spacing.button_padding = padding);
|
||
let mut text = String::from("Probe");
|
||
let rect = egui::Rect::from_min_size(pos2(40.0, 40.0), vec2(140.0, 18.0));
|
||
let mut out = ctx.run_ui(raw(), |ui| {
|
||
ui.put(rect, egui::TextEdit::singleline(&mut text).id(egui::Id::unique("inset-probe")).font(egui::FontId::proportional(11.0)));
|
||
});
|
||
out.textures_delta.clear();
|
||
let shapes: Vec<Shape> = out.shapes.into_iter().map(|c| c.shape).collect();
|
||
text_at(&shapes, "Probe") - rect.min
|
||
}
|
||
let (old, themed) = (vec2(4.0, 2.0), vec2(6.0, 2.0));
|
||
let (editor_old, editor_themed) = (editor_inset(old), editor_inset(themed));
|
||
let (plain_old, plain_themed) = (plain_inset(old), plain_inset(themed));
|
||
assert!((plain_themed.x - plain_old.x).abs() > 1.0, "the probe field follows the padding: {plain_old:?} → {plain_themed:?}");
|
||
assert!((editor_themed - editor_old).length() < 0.01, "the artboard name moves with the button padding: {editor_old:?} → {editor_themed:?}");
|
||
}
|
||
|
||
/// #949: an artboard's name stays within the artboard's width on screen, ending in "…" when
|
||
/// longer, and an artboard too narrow shows none.
|
||
#[test]
|
||
fn artboard_labels_fit_their_artboard() {
|
||
let ctx = egui::Context::default();
|
||
let drawn = |width: f32| {
|
||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||
artboard_label(ui.painter(), pos2(50.0, 50.0), width, "01 - A long artboard name", Color32::WHITE)
|
||
});
|
||
out.textures_delta.clear();
|
||
out.shapes
|
||
.into_iter()
|
||
.filter_map(|c| match c.shape {
|
||
egui::Shape::Text(t) => Some((t.galley.text().to_string(), t.galley.size().x)),
|
||
_ => None,
|
||
})
|
||
.collect::<Vec<_>>()
|
||
};
|
||
let full = drawn(400.0);
|
||
assert_eq!(full.first().map(|(s, _)| s.as_str()), Some("01 - A long artboard name"));
|
||
// Laid out no wider than the artboard (the galley keeps the whole text; it draws "…").
|
||
let (_, w) = drawn(30.0).first().cloned().unwrap();
|
||
assert!(full[0].1 > 30.0 && w <= 30.0 + 0.5, "{full:?} {w}");
|
||
assert!(drawn(10.0).is_empty());
|
||
}
|
||
|
||
fn middle(pos: Pos2, pressed: bool) -> egui::Event {
|
||
egui::Event::PointerButton { pos, button: egui::PointerButton::Middle, pressed, modifiers: Default::default() }
|
||
}
|
||
|
||
/// One headless frame with the canvas under a 40-point bar, as it is under the document tabs.
|
||
fn frame_under_bar(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) {
|
||
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() };
|
||
let mut out = ctx.run_ui(raw, |ui| {
|
||
ui.add_space(40.0);
|
||
show(app, ui);
|
||
});
|
||
out.textures_delta.clear();
|
||
}
|
||
|
||
#[test]
|
||
fn a_click_just_outside_the_canvas_does_not_reach_the_tool() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
// A square in the middle of the artboard, where the view is centred.
|
||
let id = app.session.execute("shape.rectangle", &json!({"x": 180, "y": 130, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap();
|
||
app.session.execute("select.none", &json!({})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
frame_under_bar(&mut app, &ctx, vec![]);
|
||
let rect = app.canvas_rect.unwrap();
|
||
let click = |app: &mut VectorcraftApp, p: Pos2| {
|
||
let button =
|
||
|pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
frame_under_bar(app, &ctx, vec![egui::Event::PointerMoved(p)]);
|
||
frame_under_bar(app, &ctx, vec![button(true)]);
|
||
frame_under_bar(app, &ctx, vec![button(false)]);
|
||
};
|
||
let selected = |app: &VectorcraftApp| app.session.active().unwrap().selection.objects.clone();
|
||
// Two pixels above the canvas, within egui's interaction radius: nothing happens.
|
||
click(&mut app, pos2(rect.center().x, rect.top() - 2.0));
|
||
assert!(selected(&app).is_empty(), "a click above the canvas selected {:?}", selected(&app));
|
||
// At the canvas's centre: the square.
|
||
click(&mut app, rect.center());
|
||
assert_eq!(selected(&app), vec![vectorcraft_doc::NodeId(id)]);
|
||
}
|
||
|
||
#[test]
|
||
fn a_drag_from_a_ruler_onto_the_canvas_makes_a_guide() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.ui.view.rulers = true;
|
||
app.ui.view.guides = false;
|
||
// A drawing tool: the drag must not draw.
|
||
app.select_tool("rectangle");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let rect = app.canvas_rect.unwrap();
|
||
let xf = Xf::new(rect, app.view().unwrap());
|
||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
let drag = |app: &mut VectorcraftApp, from: Pos2, to: Pos2| {
|
||
frame(app, &ctx, vec![egui::Event::PointerMoved(from)]);
|
||
frame(app, &ctx, vec![button(from, true)]);
|
||
frame(app, &ctx, vec![egui::Event::PointerMoved(to)]);
|
||
frame(app, &ctx, vec![button(to, false)]);
|
||
};
|
||
let guides = |app: &VectorcraftApp| app.session.active().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect::<Vec<_>>();
|
||
// From the top ruler: a horizontal guide where the button was released.
|
||
let to = pos2(rect.center().x, rect.top() + 120.0);
|
||
drag(&mut app, pos2(rect.center().x, rect.top() - RULER / 2.0), to);
|
||
assert_eq!(guides(&app), [(false, xf.to_doc(to).y)]);
|
||
assert!(app.ui.view.guides, "the new guide shows");
|
||
// From the left ruler: a vertical one.
|
||
let to2 = pos2(rect.left() + 200.0, rect.center().y);
|
||
drag(&mut app, pos2(rect.left() - RULER / 2.0, rect.center().y), to2);
|
||
assert_eq!(guides(&app), [(false, xf.to_doc(to).y), (true, xf.to_doc(to2).x)]);
|
||
// Released back on the ruler: no guide.
|
||
drag(&mut app, pos2(rect.center().x, rect.top() - RULER / 2.0), pos2(rect.center().x + 40.0, rect.top() - 4.0));
|
||
assert_eq!(guides(&app).len(), 2);
|
||
assert_eq!(app.session.active().unwrap().doc.art_bounds(), None, "the tool drew nothing");
|
||
}
|
||
|
||
#[test]
|
||
fn new_ruler_guides_follow_alt_through_preview_release_and_undo() {
|
||
use egui::{Event, Modifiers};
|
||
for source_vertical in [false, true] {
|
||
for initially_alt in [false, true] {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("document.setRulerOrigin", &json!({"x": 17, "y": 23})).unwrap();
|
||
app.ui.view.rulers = true;
|
||
app.ui.view.smart_guides = false;
|
||
app.ui.view.snap_to_point = false;
|
||
app.select_tool("rectangle");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let rect = app.canvas_rect.unwrap();
|
||
let xf = Xf::new(rect, app.view().unwrap());
|
||
let from =
|
||
if source_vertical { pos2(rect.left() - RULER / 2.0, rect.center().y) } else { pos2(rect.center().x, rect.top() - RULER / 2.0) };
|
||
let at = xf.to_screen(Point::new(80.0, 120.0));
|
||
assert!(rect.contains(at));
|
||
let held = |alt| if alt { Modifiers::ALT } else { Modifiers::NONE };
|
||
let button = |pos, pressed, alt| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: held(alt) };
|
||
let before = app.session.active().unwrap().doc.clone();
|
||
let undo = app.session.active().unwrap().history.undo.len();
|
||
frame(&mut app, &ctx, vec![Event::ModifiersChanged(held(initially_alt)), Event::PointerMoved(from)]);
|
||
frame(&mut app, &ctx, vec![button(from, true, initially_alt)]);
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(at)]);
|
||
let assert_guide = |app: &VectorcraftApp, alt: bool, pos: Point| {
|
||
let guides = &app.session.active().unwrap().doc.guides;
|
||
assert_eq!(guides.len(), 1);
|
||
assert_eq!(guides[0].vertical, source_vertical != alt);
|
||
let expected = if source_vertical != alt { pos.x } else { pos.y };
|
||
assert!((guides[0].pos - expected).abs() < 1e-4, "{:?}, expected {expected}", guides[0]);
|
||
assert_eq!(guides[0].artboard, None);
|
||
};
|
||
assert_guide(&app, initially_alt, Point::new(80.0, 120.0));
|
||
// Modifier changes with no pointer movement refresh the existing preview.
|
||
for alt in [!initially_alt, initially_alt] {
|
||
frame(&mut app, &ctx, vec![Event::ModifiersChanged(held(alt))]);
|
||
assert_guide(&app, alt, Point::new(80.0, 120.0));
|
||
assert_eq!(app.session.active().unwrap().history.undo.len(), undo);
|
||
}
|
||
// Equal coordinates must not hide an orientation change in the preview.
|
||
let equal = xf.to_screen(Point::new(120.0, 120.0));
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(equal)]);
|
||
frame(&mut app, &ctx, vec![Event::ModifiersChanged(held(!initially_alt))]);
|
||
assert_guide(&app, !initially_alt, Point::new(120.0, 120.0));
|
||
let release = Point::new(147.0, 183.0);
|
||
let to = xf.to_screen(release);
|
||
frame(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::ALT), Event::PointerMoved(to), button(to, false, true)]);
|
||
assert_guide(&app, true, release);
|
||
let st = app.session.active().unwrap();
|
||
assert_eq!(st.history.undo.len(), undo + 1);
|
||
assert_eq!(st.history.undo.last().unwrap().label, "New Guide");
|
||
assert_eq!(st.doc.ruler_origin, before.ruler_origin);
|
||
assert_eq!(st.doc.art_bounds(), before.art_bounds(), "the rectangle tool never drew");
|
||
let committed = st.doc.clone();
|
||
// Canvas guides still span the viewport after changing orientation.
|
||
let painted = shapes(&mut app, &ctx);
|
||
let window = app.canvas_rect.unwrap();
|
||
let position = Xf::new(window, app.view().unwrap()).to_screen(release);
|
||
let ends = if source_vertical {
|
||
[pos2(window.left(), position.y), pos2(window.right(), position.y)]
|
||
} else {
|
||
[pos2(position.x, window.top()), pos2(position.x, window.bottom())]
|
||
};
|
||
assert!(painted.iter().any(|s| matches!(s, Shape::LineSegment { points, .. }
|
||
if (points[0] - ends[0]).length() < 0.01 && (points[1] - ends[1]).length() < 0.01)));
|
||
app.session.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(&*app.session.active().unwrap().doc, &*before);
|
||
app.session.execute("edit.redo", &json!({})).unwrap();
|
||
assert_eq!(&*app.session.active().unwrap().doc, &*committed);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn new_ruler_guide_alt_cancel_keeps_document_and_redo() {
|
||
use egui::{Event, Modifiers};
|
||
for source_vertical in [false, true] {
|
||
for tool in ["rectangle", "artboard"] {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("document.setRulerOrigin", &json!({"x": 17, "y": 23})).unwrap();
|
||
app.session.execute("shape.rectangle", &json!({"x": 40, "y": 50, "width": 20, "height": 30})).unwrap();
|
||
app.session.execute("edit.undo", &json!({})).unwrap();
|
||
app.ui.view.rulers = true;
|
||
app.ui.view.smart_guides = false;
|
||
app.ui.view.snap_to_point = false;
|
||
app.select_tool(tool);
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let rect = app.canvas_rect.unwrap();
|
||
let xf = Xf::new(rect, app.view().unwrap());
|
||
let from =
|
||
if source_vertical { pos2(rect.left() - RULER / 2.0, rect.center().y) } else { pos2(rect.center().x, rect.top() - RULER / 2.0) };
|
||
let at = xf.to_screen(Point::new(80.0, 120.0));
|
||
let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::ALT };
|
||
let before = app.session.active().unwrap().doc.clone();
|
||
let history = |app: &VectorcraftApp| {
|
||
let st = app.session.active().unwrap();
|
||
(st.history.undo.len(), st.history.redo.len(), st.is_dirty())
|
||
};
|
||
let before_history = history(&app);
|
||
assert_eq!(before_history.1, 1);
|
||
frame(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::ALT), Event::PointerMoved(from)]);
|
||
frame(&mut app, &ctx, vec![button(from, true)]);
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(at)]);
|
||
let guides = &app.session.active().unwrap().doc.guides;
|
||
assert_eq!(guides.len(), 1);
|
||
assert_eq!(guides[0].vertical, !source_vertical);
|
||
let artboard = (tool == "artboard").then_some(before.artboards[0].id);
|
||
assert_eq!(guides[0].artboard, artboard);
|
||
// Returning to the source ruler cancels the preview and keeps the existing Redo.
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(from), button(from, false)]);
|
||
assert_eq!(&*app.session.active().unwrap().doc, &*before);
|
||
assert_eq!(history(&app), before_history);
|
||
app.session.execute("edit.redo", &json!({})).unwrap();
|
||
assert!(app.session.active().unwrap().doc.art_bounds().is_some());
|
||
assert!(app.session.active().unwrap().doc.guides.is_empty());
|
||
}
|
||
}
|
||
}
|
||
|
||
/// #451: a guide dragged out of a ruler lands on the art's side midpoints (halving an
|
||
/// artboard-sized rectangle) and on the artboard's edges; an artboard guide is drawn across
|
||
/// its artboard only.
|
||
#[test]
|
||
fn guides_from_the_rulers_snap_and_artboard_guides_span_their_artboard() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 400, "height": 300})).unwrap();
|
||
app.session.execute("select.none", &json!({})).unwrap();
|
||
app.ui.view.rulers = true;
|
||
app.select_tool("selection");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let rect = app.canvas_rect.unwrap();
|
||
let xf = Xf::new(rect, app.view().unwrap());
|
||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
let drag = |app: &mut VectorcraftApp, from: Pos2, to: Pos2| {
|
||
frame(app, &ctx, vec![egui::Event::PointerMoved(from)]);
|
||
frame(app, &ctx, vec![button(from, true)]);
|
||
frame(app, &ctx, vec![egui::Event::PointerMoved(to)]);
|
||
frame(app, &ctx, vec![button(to, false)]);
|
||
};
|
||
let guides = |app: &VectorcraftApp| app.session.active().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect::<Vec<_>>();
|
||
// 3 px off the middle of the top and bottom sides: onto the line through them.
|
||
let mid = xf.to_screen(Point::new(200.0, 150.0));
|
||
drag(&mut app, pos2(rect.left() - RULER / 2.0, mid.y), mid + vec2(3.0, 40.0));
|
||
// 3 px inside the artboard's bottom edge: onto it.
|
||
let bottom = xf.to_screen(Point::new(100.0, 300.0));
|
||
drag(&mut app, pos2(bottom.x, rect.top() - RULER / 2.0), bottom - vec2(0.0, 3.0));
|
||
assert_eq!(guides(&app), [(true, 200.0), (false, 300.0)]);
|
||
assert_eq!(app.session.active().unwrap().history.undo.last().unwrap().label, "New Guide");
|
||
// An artboard guide runs from the artboard's left edge to its right edge.
|
||
app.session.execute("guide.add", &json!({"vertical": false, "pos": 75, "artboard": 0})).unwrap();
|
||
let s = shapes(&mut app, &ctx);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let (a, b) = (xf.to_screen(Point::new(0.0, 75.0)), xf.to_screen(Point::new(400.0, 75.0)));
|
||
let at = |p: Pos2, q: Pos2| (p - q).length() < 0.01;
|
||
assert!(s.iter().any(|s| matches!(s, Shape::LineSegment { points, .. } if at(points[0], a) && at(points[1], b))), "across the artboard");
|
||
// A canvas guide crosses the whole window.
|
||
let y = xf.to_screen(Point::new(0.0, 300.0)).y;
|
||
let window = app.canvas_rect.unwrap();
|
||
assert!(
|
||
s.iter().any(
|
||
|s| matches!(s, Shape::LineSegment { points, .. } if points[0] == pos2(window.left(), y) && points[1] == pos2(window.right(), y))
|
||
)
|
||
);
|
||
}
|
||
|
||
/// #414: guides dragged out of the rulers are picked and dragged with the Selection tool
|
||
/// (highlighted while selected), go with Delete or Backspace, and dragged back onto their
|
||
/// ruler.
|
||
#[test]
|
||
fn the_selection_tool_moves_and_deletes_ruler_guides() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
// A square under the guides: the press takes the guide.
|
||
app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 300, "height": 200})).unwrap();
|
||
app.session.execute("select.none", &json!({})).unwrap();
|
||
let art = app.session.active().unwrap().doc.art_bounds();
|
||
app.ui.view.rulers = true;
|
||
app.select_tool("selection");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let rect = app.canvas_rect.unwrap();
|
||
let xf = Xf::new(rect, app.view().unwrap());
|
||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
let drag = |app: &mut VectorcraftApp, from: Pos2, to: Pos2| {
|
||
frame(app, &ctx, vec![egui::Event::PointerMoved(from)]);
|
||
frame(app, &ctx, vec![button(from, true)]);
|
||
frame(app, &ctx, vec![egui::Event::PointerMoved(to)]);
|
||
frame(app, &ctx, vec![button(to, false)]);
|
||
};
|
||
let guides = |app: &VectorcraftApp| app.session.active().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect::<Vec<_>>();
|
||
// Two guides out of the rulers.
|
||
let (h, v) = (pos2(rect.center().x + 30.0, rect.center().y + 20.0), pos2(rect.center().x - 40.0, rect.center().y));
|
||
drag(&mut app, pos2(h.x, rect.top() - RULER / 2.0), h);
|
||
drag(&mut app, pos2(rect.left() - RULER / 2.0, v.y), v);
|
||
assert_eq!(guides(&app), [(false, xf.to_doc(h).y), (true, xf.to_doc(v).x)]);
|
||
// Drag the horizontal one down 30 px (across the vertical one: the nearer is picked).
|
||
let to = pos2(h.x + 10.0, h.y + 30.0);
|
||
drag(&mut app, h, to);
|
||
let st = app.session.active().unwrap();
|
||
assert_eq!(guides(&app), [(false, xf.to_doc(to).y), (true, xf.to_doc(v).x)]);
|
||
assert_eq!((st.selection.guides.clone(), st.selection.objects.clone()), (vec![0], vec![]));
|
||
assert_eq!(st.history.undo.last().unwrap().label, "Move Guide");
|
||
let picked = Tokens::get(&ctx).selection;
|
||
let s = shapes(&mut app, &ctx);
|
||
assert!(s.iter().any(|s| matches!(s, Shape::LineSegment { points, stroke } if stroke.color == picked && points[0].y == to.y)), "highlighted");
|
||
// Backspace deletes the selected guide; Delete too.
|
||
let press = |app: &mut VectorcraftApp, key| {
|
||
let ev = egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE };
|
||
let mut out = ctx.run_ui(egui::RawInput { events: vec![ev], ..Default::default() }, |ui| crate::shortcuts::handle(app, ui.ctx()));
|
||
out.textures_delta.clear();
|
||
};
|
||
press(&mut app, egui::Key::Backspace);
|
||
assert_eq!(guides(&app), [(true, xf.to_doc(v).x)]);
|
||
app.session.execute("guide.select", &json!({"indexes": [0]})).unwrap();
|
||
press(&mut app, egui::Key::Delete);
|
||
assert!(guides(&app).is_empty());
|
||
app.session.execute("edit.undo", &json!({})).unwrap();
|
||
// Hide Guides deselects them.
|
||
app.session.execute("guide.select", &json!({"indexes": [0]})).unwrap();
|
||
app.run("view.guides", json!({})).unwrap();
|
||
assert!(app.session.active().unwrap().selection.guides.is_empty());
|
||
app.run("view.guides", json!({})).unwrap();
|
||
// Dragged back onto its ruler, the vertical guide goes.
|
||
drag(&mut app, v, pos2(rect.left() - RULER / 2.0, v.y + 20.0));
|
||
assert!(guides(&app).is_empty(), "{:?}", guides(&app));
|
||
assert_eq!(app.session.active().unwrap().doc.art_bounds(), art, "the square stayed");
|
||
}
|
||
|
||
#[test]
|
||
fn cmd_with_another_tool_drags_with_the_selection_tool_used_last() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 50})).unwrap()["id"].as_u64().unwrap();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let cmd_frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
|
||
let screen = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
|
||
let events = std::iter::once(egui::Event::ModifiersChanged(egui::Modifiers::COMMAND)).chain(events).collect();
|
||
let raw = egui::RawInput { screen_rect: Some(screen), events, ..Default::default() };
|
||
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
|
||
out.textures_delta.clear();
|
||
};
|
||
let button =
|
||
|pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::COMMAND };
|
||
// A Cmd drag from `a` to `b` with the Pen tool; → the tool that did it.
|
||
let cmd_drag = |app: &mut VectorcraftApp, a: Pos2, b: Pos2| {
|
||
app.select_tool("pen");
|
||
cmd_frame(app, vec![egui::Event::PointerMoved(a), button(a, true)]);
|
||
let used = app.session.tool_id().to_string();
|
||
cmd_frame(app, vec![egui::Event::PointerMoved(b)]);
|
||
cmd_frame(app, vec![button(b, false)]);
|
||
assert_eq!(app.session.tool_id(), "pen", "back to the Pen tool");
|
||
used
|
||
};
|
||
let bounds = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap();
|
||
// Direct Selection used last: the drag moves the rectangle's bottom-right corner only (once
|
||
// the rectangle isn't selected whole, which would move all its anchors).
|
||
app.select_tool("directSelection");
|
||
app.session.execute("select.none", &json!({})).unwrap();
|
||
let corner = xf.to_screen(Point::new(200.0, 150.0));
|
||
assert_eq!(cmd_drag(&mut app, corner, corner + vec2(20.0, 20.0)), "directSelection");
|
||
let b = bounds(&app);
|
||
assert_eq!((b.x0, b.y0), (100.0, 100.0));
|
||
assert!((b.x1 - xf.to_doc(corner + vec2(20.0, 20.0)).x).abs() < 1e-6, "{b:?}");
|
||
// Group Selection, then Selection: each is the one used.
|
||
app.select_tool("groupSelection");
|
||
let inside = xf.to_screen(Point::new(130.0, 120.0));
|
||
assert_eq!(cmd_drag(&mut app, inside, inside), "groupSelection");
|
||
app.select_tool("selection");
|
||
assert_eq!(cmd_drag(&mut app, inside, inside), "selection");
|
||
}
|
||
|
||
/// #525: a selection with more anchors than the overlay draws (a traced photo) shows its bounds,
|
||
/// not every outline and anchor, which made a mesh larger than the GPU takes; an ordinary one
|
||
/// still shows its outline and anchors.
|
||
#[test]
|
||
fn a_huge_selection_shows_its_bounds_instead_of_every_outline() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let points = |n: usize| (0..n).map(|i| format!("L{} {}", 20.0 + 360.0 * i as f64 / n as f64, 100 + (i % 2) * 50)).collect::<String>();
|
||
let ctx = egui::Context::default();
|
||
let mut drawn = |n: usize| {
|
||
app.session.execute("path.create", &json!({"d": format!("M20 20 {} Z", points(n))})).unwrap();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let shapes = shapes(&mut app, &ctx);
|
||
let points: usize = shapes.iter().map(|s| if let Shape::Path(ps) = s { ps.points.len() } else { 0 }).sum();
|
||
let squares = shapes.iter().filter(|s| matches!(s, Shape::Rect(_))).count();
|
||
(points, squares)
|
||
};
|
||
let (points, squares) = drawn(OVERLAY_MAX_ANCHORS + 10);
|
||
assert!(points < 100 && squares < 100, "{points} outline points, {squares} squares");
|
||
let (points, squares) = drawn(200);
|
||
assert!(points > 200 && squares > 200, "{points} outline points, {squares} squares");
|
||
}
|
||
|
||
/// #2460: type, images, symbol instances, gradient meshes and envelopes count toward the
|
||
/// points the overlay outlines, as paths' anchors do. 32,768 of one kind (an opened PDF can
|
||
/// hold as many type lines, images or mesh pieces) add only the selection's bounds to the
|
||
/// frame's mesh, under 100 vertices; one alone adds its outline.
|
||
#[test]
|
||
fn a_huge_selection_of_type_images_symbols_or_meshes_shows_its_bounds() {
|
||
for kind in ["point type", "area type", "type on a path", "image", "symbol", "mesh", "envelope"] {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.ui.task_bar = false;
|
||
// The Rectangle tool draws nothing of its own (no bounding box, no type on a path
|
||
// brackets): only the selection's overlay changes the frame.
|
||
app.select_tool("rectangle");
|
||
match kind {
|
||
"point type" => {
|
||
app.session.execute("text.create", &json!({"x": 20, "y": 40, "text": "Type"})).unwrap();
|
||
}
|
||
"area type" => {
|
||
let area = json!({"width": 150, "height": 20});
|
||
app.session.execute("text.create", &json!({"x": 20, "y": 40, "text": "Type", "area": area})).unwrap();
|
||
}
|
||
"type on a path" => {
|
||
let path = app.session.execute("path.create", &json!({"d": "M20 100 L140 100 L260 100 L380 100"})).unwrap()["id"].clone();
|
||
app.session.execute("text.createInPath", &json!({"path": path, "mode": "onPath", "text": "Type"})).unwrap();
|
||
}
|
||
"image" => {
|
||
let data = vectorcraft_format::base64_encode(&crate::tests_sysclip::blue_png());
|
||
app.run("file.place", json!({"name": "blue.png", "dataBase64": data, "link": false, "at": [200, 150]})).unwrap();
|
||
}
|
||
"symbol" => {
|
||
app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 20})).unwrap();
|
||
app.session.execute("symbol.new", &json!({})).unwrap();
|
||
}
|
||
"mesh" => {
|
||
app.session.execute("shape.rectangle", &json!({"x": 150, "y": 50, "width": 30, "height": 20})).unwrap();
|
||
app.session.execute("object.mesh.create", &json!({"rows": 1, "cols": 1})).unwrap();
|
||
}
|
||
_ => {
|
||
app.session.execute("shape.rectangle", &json!({"x": 150, "y": 50, "width": 30, "height": 20})).unwrap();
|
||
app.session.execute("object.envelope.makeWithMesh", &json!({"rows": 1, "cols": 1})).unwrap();
|
||
}
|
||
}
|
||
let ctx = egui::Context::default();
|
||
// The vertices that selecting everything adds to the frame's mesh.
|
||
let overlay = |app: &mut VectorcraftApp| {
|
||
app.session.execute("select.none", &json!({})).unwrap();
|
||
let none = frame_vertices(app, &ctx);
|
||
app.session.execute("select.all", &json!({})).unwrap();
|
||
frame_vertices(app, &ctx).saturating_sub(none)
|
||
};
|
||
let one = overlay(&mut app);
|
||
assert!(one > 0, "{kind}: one shows its outline");
|
||
for _ in 0..15 {
|
||
app.session.execute("select.all", &json!({})).unwrap();
|
||
app.run("edit.duplicate", json!({})).unwrap();
|
||
}
|
||
let many = overlay(&mut app);
|
||
assert_eq!(app.session.active().unwrap().selection.objects.len(), 32_768, "{kind}");
|
||
assert!(many < 100, "{kind}: 32,768 show the selection's bounds, not {many} vertices");
|
||
}
|
||
}
|
||
|
||
/// #2460: a group of 32,768 images is too much to outline: under the pointer it is not
|
||
/// highlighted, and as the key object its outline is its bounds.
|
||
#[test]
|
||
fn a_group_of_too_many_images_has_no_hover_outline_and_a_key_outline_of_its_bounds() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let data = vectorcraft_format::base64_encode(&crate::tests_sysclip::blue_png());
|
||
app.run("file.place", json!({"name": "blue.png", "dataBase64": data, "link": false, "at": [200, 150]})).unwrap();
|
||
for _ in 0..15 {
|
||
app.session.execute("select.all", &json!({})).unwrap();
|
||
app.run("edit.duplicate", json!({})).unwrap();
|
||
}
|
||
app.session.execute("select.all", &json!({})).unwrap();
|
||
let group = app.session.execute("object.group", &json!({})).unwrap()["id"].clone();
|
||
app.session.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 30, "height": 30})).unwrap();
|
||
app.select_tool("selection");
|
||
app.session.execute("select.none", &json!({})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let over = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(200.0, 150.0));
|
||
let lines = |app: &mut VectorcraftApp, width: f32| {
|
||
frame(app, &ctx, vec![egui::Event::PointerMoved(over)]);
|
||
shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Path(ps) if ps.stroke.width == width)).count()
|
||
};
|
||
assert_eq!(lines(&mut app, 1.5), 0, "no hover outline");
|
||
app.session.execute("select.all", &json!({})).unwrap();
|
||
app.session.execute("select.key", &json!({"id": group})).unwrap();
|
||
assert_eq!(lines(&mut app, KEY_OUTLINE), 1, "the key object's bounds");
|
||
}
|
||
|
||
/// #1111: a selected gradient mesh shows its mesh lines and points to the selection tools, not
|
||
/// only to the Mesh tool.
|
||
#[test]
|
||
fn a_selected_gradient_mesh_shows_its_lines_and_points() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 200, "height": 100})).unwrap();
|
||
app.session.execute("object.mesh.create", &json!({"rows": 2, "cols": 2})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
let drawn = |app: &mut VectorcraftApp| {
|
||
frame(app, &ctx, vec![]);
|
||
let shapes = shapes(app, &ctx);
|
||
let lines = shapes.iter().filter(|s| matches!(s, Shape::Path(ps) if ps.points.len() > 1)).count();
|
||
(lines, shapes.iter().filter(|s| matches!(s, Shape::Rect(_))).count())
|
||
};
|
||
let (none_lines, none_points) = {
|
||
app.session.execute("select.none", &json!({})).unwrap();
|
||
drawn(&mut app)
|
||
};
|
||
for tool in ["selection", "directSelection"] {
|
||
app.select_tool(tool);
|
||
app.session.execute("select.all", &json!({})).unwrap();
|
||
let (lines, points) = drawn(&mut app);
|
||
// The outline, two inner lines each way; the 3 × 3 points.
|
||
assert!(lines >= none_lines + 3 && points >= none_points + 9, "{tool}: {lines} lines, {points} points");
|
||
}
|
||
}
|
||
|
||
/// #541: the key object's outline is drawn thicker than the rest of the selection's.
|
||
#[test]
|
||
fn the_key_object_has_a_thicker_outline() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let a = app.session.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 50, "height": 50})).unwrap()["id"].clone();
|
||
let b = app.session.execute("shape.ellipse", &json!({"x": 200, "y": 100, "width": 60, "height": 40})).unwrap()["id"].clone();
|
||
app.session.execute("select.set", &json!({"ids": [a, b]})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let thick =
|
||
|app: &mut VectorcraftApp| shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Path(ps) if ps.stroke.width == KEY_OUTLINE)).count();
|
||
assert_eq!(thick(&mut app), 0, "no key yet");
|
||
app.session.execute("select.key", &json!({"id": b})).unwrap();
|
||
assert!(thick(&mut app) > 0, "the key's outline");
|
||
}
|
||
|
||
/// One headless canvas frame → the shapes drawn, `Shape::Vec`s flattened.
|
||
fn shapes(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec<Shape> {
|
||
let (shapes, mut delta) = frame_output(app, ctx);
|
||
delta.clear();
|
||
shapes
|
||
}
|
||
|
||
/// Two headless canvas frames → the vertices of the second one's tessellated mesh, which the
|
||
/// GPU takes in one buffer.
|
||
fn frame_vertices(app: &mut VectorcraftApp, ctx: &egui::Context) -> usize {
|
||
frame(app, ctx, vec![]);
|
||
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() };
|
||
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
|
||
out.textures_delta.clear();
|
||
let mesh = |p: &egui::ClippedPrimitive| if let egui::epaint::Primitive::Mesh(m) = &p.primitive { m.vertices.len() } else { 0 };
|
||
ctx.tessellate(out.shapes, out.pixels_per_point).iter().map(mesh).sum()
|
||
}
|
||
|
||
/// One headless canvas frame → the shapes drawn (`Shape::Vec`s flattened) and the textures
|
||
/// uploaded by it (the art's raster among them, when it was re-rendered).
|
||
fn frame_output(app: &mut VectorcraftApp, ctx: &egui::Context) -> (Vec<Shape>, egui::TexturesDelta) {
|
||
fn flat(s: Shape, out: &mut Vec<Shape>) {
|
||
match s {
|
||
Shape::Vec(v) => v.into_iter().for_each(|s| flat(s, out)),
|
||
s => out.push(s),
|
||
}
|
||
}
|
||
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() };
|
||
let out = ctx.run_ui(raw, |ui| show(app, ui));
|
||
let mut v = vec![];
|
||
out.shapes.into_iter().for_each(|c| flat(c.shape, &mut v));
|
||
(v, out.textures_delta)
|
||
}
|
||
|
||
/// An edit of one object renders the canvas again only where it changed, into its texture; a
|
||
/// change of the view renders all of it.
|
||
#[test]
|
||
fn an_edit_renders_only_its_part_of_the_canvas() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width":400,"height":300})).unwrap();
|
||
app.ui.task_bar = false;
|
||
app.ui.view.rulers = false;
|
||
app.canvas.worker_started = true; // every frame on this thread, however long it takes
|
||
let id = app.run("shape.rectangle", json!({"x":50,"y":50,"width":40,"height":30})).unwrap()["id"].as_u64().unwrap();
|
||
app.run("shape.rectangle", json!({"x":250,"y":200,"width":40,"height":30})).unwrap();
|
||
app.run("select.none", json!({})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
// Where the canvas texture changed in a frame, and how much of it.
|
||
let canvas = |app: &VectorcraftApp, mut delta: egui::TexturesDelta| {
|
||
let tex = app.canvas.texture.as_ref().map(|t| t.id());
|
||
let set = delta.set.iter().find(|(t, _)| tex == Some(**t)).and_then(|(_, d)| d.last()).map(|d| (d.pos, d.image.size()));
|
||
delta.clear();
|
||
set
|
||
};
|
||
let (_, delta) = frame_output(&mut app, &ctx);
|
||
assert!(canvas(&app, delta).is_some_and(|(pos, _)| pos.is_none()), "the first frame renders all of it");
|
||
app.run("paint.setFill", json!({"color": "#ff0000", "ids": [id]})).unwrap();
|
||
let (_, delta) = frame_output(&mut app, &ctx);
|
||
let (pos, size) = canvas(&app, delta).expect("the edit shows");
|
||
let whole = app.canvas.key.as_ref().map(|k| (k.w as usize, k.h as usize)).unwrap();
|
||
assert!(pos.is_some() && size[0] * size[1] * 10 < whole.0 * whole.1, "a small part of it: {pos:?} {size:?} of {whole:?}");
|
||
// An edit with other render options than the frame shown's renders all of it.
|
||
app.session.prefs.pattern_tile_edge_color = "#ff00ff".into();
|
||
app.run("paint.setFill", json!({"color": "#00ff00", "ids": [id]})).unwrap();
|
||
let (_, delta) = frame_output(&mut app, &ctx);
|
||
assert!(canvas(&app, delta).is_some_and(|(pos, _)| pos.is_none()), "other options render all of it");
|
||
app.run("view.setZoom", json!({"zoom": 2})).unwrap();
|
||
let (_, delta) = frame_output(&mut app, &ctx);
|
||
assert!(canvas(&app, delta).is_some_and(|(pos, _)| pos.is_none()), "a new view renders all of it");
|
||
}
|
||
|
||
/// While an opacity mask is viewed, an edit renders all of the canvas: the mask view shows the
|
||
/// mask's coverage wherever its art reaches, past its object's bounds.
|
||
#[test]
|
||
fn an_edit_while_a_mask_is_viewed_renders_all_of_the_canvas() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width":400,"height":300})).unwrap();
|
||
app.ui.task_bar = false;
|
||
app.ui.view.rulers = false;
|
||
app.canvas.worker_started = true; // every frame on this thread, however long it takes
|
||
let rect =
|
||
|app: &mut VectorcraftApp, x: f64| app.run("shape.rectangle", json!({"x": x, "y": 50, "width": 60, "height": 40})).unwrap()["id"].clone();
|
||
let (obj, mask) = (rect(&mut app, 50.0), rect(&mut app, 70.0));
|
||
app.run("select.set", json!({"ids": [obj, mask]})).unwrap();
|
||
app.run("transparency.makeOpacityMask", json!({})).unwrap();
|
||
app.run("transparency.viewOpacityMask", json!({})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
let (_, mut delta) = frame_output(&mut app, &ctx);
|
||
delta.clear();
|
||
app.run("paint.setFill", json!({"color": "#ff0000", "ids": [obj]})).unwrap();
|
||
let (_, mut delta) = frame_output(&mut app, &ctx);
|
||
let tex = app.canvas.texture.as_ref().map(|t| t.id());
|
||
let pos = delta.set.iter().find(|(t, _)| tex == Some(**t)).and_then(|(_, d)| d.last()).map(|d| d.pos);
|
||
delta.clear();
|
||
assert_eq!(pos, Some(None), "all of it");
|
||
}
|
||
|
||
/// #1278: a segment click marks the edge, not its endpoint anchors or neighboring handles.
|
||
#[test]
|
||
fn a_segment_pick_has_hollow_anchors_and_only_its_own_handles() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width":400,"height":300})).unwrap();
|
||
app.ui.task_bar = false;
|
||
app.ui.view.rulers = false;
|
||
let id = app.run("shape.rectangle", json!({"x":50,"y":50,"width":200,"height":150})).unwrap()["id"].as_u64().unwrap();
|
||
app.run("select.none", json!({})).unwrap();
|
||
app.select_tool("directSelection");
|
||
let view = app.view_info();
|
||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||
app.session.pointer(&PointerEvent::new(kind, 150.0, 50.0), view).unwrap();
|
||
}
|
||
let before = app.session.doc().unwrap().doc.clone();
|
||
let color = c32(before.layer_color(vectorcraft_doc::NodeId(id)));
|
||
let selected = selected_anchor(color);
|
||
let ctx = egui::Context::default();
|
||
let painted = shapes(&mut app, &ctx);
|
||
assert_eq!(painted.iter().filter(|s| matches!(s, Shape::Rect(r) if r.fill == selected)).count(), 0);
|
||
assert_eq!(
|
||
painted.iter().filter(|s| matches!(s, Shape::Path(p) if p.stroke.width == 2.0 && p.points.len() == 2)).count(),
|
||
1,
|
||
"only the chosen straight edge has emphasis"
|
||
);
|
||
assert!(!painted.iter().any(|s| matches!(s, Shape::Circle(_))), "segment picks have no corner widgets");
|
||
assert_eq!(app.session.doc().unwrap().doc, before);
|
||
app.run("select.anchors", json!({"id":id,"anchors":[[0,0],[0,1]]})).unwrap();
|
||
let painted = shapes(&mut app, &ctx);
|
||
assert_eq!(painted.iter().filter(|s| matches!(s, Shape::Rect(r) if r.fill == selected)).count(), 2);
|
||
app.run(
|
||
"path.create",
|
||
json!({"anchors":[{"x":50,"y":260,"out":[100,220]},{"x":200,"y":260,"in":[150,220],"out":[250,300]},{"x":330,"y":260,"in":[280,300]}]}),
|
||
)
|
||
.unwrap();
|
||
app.run("select.none", json!({})).unwrap();
|
||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||
app.session.pointer(&PointerEvent::new(kind, 125.0, 230.0), view).unwrap();
|
||
}
|
||
let before = app.session.doc().unwrap().doc.clone();
|
||
let painted = shapes(&mut app, &ctx);
|
||
assert_eq!(
|
||
painted.iter().filter(|s| matches!(s, Shape::LineSegment {stroke,..} if stroke.color == color)).count(),
|
||
2,
|
||
"only the chosen curve's two controls show"
|
||
);
|
||
app.ui.view.edges = false;
|
||
let painted = shapes(&mut app, &ctx);
|
||
assert!(!painted.iter().any(|s| matches!(s, Shape::LineSegment {stroke,..} if stroke.color == color)));
|
||
assert_eq!(app.session.doc().unwrap().doc, before);
|
||
}
|
||
|
||
/// A group, a compound path or a clip group selected whole has every anchor selected, and Direct
|
||
/// Selection, the Pen and the anchor point tools draw its anchors filled, as they draw the anchors
|
||
/// of a path selected whole. They do the same after Group Selection clicks select a path and then its group.
|
||
/// Direction handles still show only on a path selected itself.
|
||
#[test]
|
||
fn the_anchors_of_a_container_selected_whole_draw_selected() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||
app.ui.task_bar = false;
|
||
app.ui.view.rulers = false;
|
||
let rect = |app: &mut VectorcraftApp, x: i32, y: i32| {
|
||
app.run("shape.rectangle", json!({"x": x, "y": y, "width": 60, "height": 60})).unwrap()["id"].clone()
|
||
};
|
||
let in_group = rect(&mut app, 20, 20);
|
||
let ellipse = app.run("shape.ellipse", json!({"x": 100, "y": 20, "width": 60, "height": 60})).unwrap()["id"].clone();
|
||
app.run("select.set", json!({"ids": [in_group, ellipse]})).unwrap();
|
||
let group = app.run("object.group", json!({})).unwrap()["id"].clone();
|
||
let (c1, c2) = (rect(&mut app, 200, 20), rect(&mut app, 220, 40));
|
||
app.run("select.set", json!({"ids": [c1, c2]})).unwrap();
|
||
let compound = app.run("object.compoundPath.make", json!({})).unwrap()["id"].clone();
|
||
let hidden = app.run("shape.ellipse", json!({"x": 20, "y": 150, "width": 200, "height": 100})).unwrap()["id"].clone();
|
||
let square = rect(&mut app, 60, 170);
|
||
app.run("select.set", json!({"ids": [hidden, square]})).unwrap();
|
||
let clip = app.run("object.clippingMask.make", json!({})).unwrap()["id"].clone();
|
||
let lone = app.run("shape.ellipse", json!({"x": 300, "y": 150, "width": 60, "height": 60})).unwrap()["id"].clone();
|
||
let color = c32(app.session.active().unwrap().doc.layer_color(vectorcraft_doc::NodeId(lone.as_u64().unwrap())));
|
||
let ctx = egui::Context::default();
|
||
// Filled and hollow anchor squares, and direction handle lines, drawn in the layer color.
|
||
let looks = |app: &mut VectorcraftApp| {
|
||
let painted = shapes(app, &ctx);
|
||
let small = |r: &egui::epaint::RectShape| r.rect.width() < 12.0;
|
||
let filled = painted.iter().filter(|s| matches!(s, Shape::Rect(r) if small(r) && r.fill == color)).count();
|
||
let hollow = painted.iter().filter(|s| matches!(s, Shape::Rect(r) if small(r) && r.fill == Color32::WHITE)).count();
|
||
let handles = painted.iter().filter(|s| matches!(s, Shape::LineSegment { stroke, .. } if stroke.color == color)).count();
|
||
(filled, hollow, handles)
|
||
};
|
||
let mut wrong = vec![];
|
||
for tool in ["directSelection", "pen", "addAnchor", "deleteAnchor", "anchorPoint"] {
|
||
app.select_tool(tool);
|
||
// What is selected, and what it shows: the lone ellipse selected whole is the reference
|
||
// (four anchors with two handles each, and its center point, filled too).
|
||
for (what, ids, want) in [
|
||
("a path", json!([lone]), (5, 0, 8)),
|
||
("a group", json!([group]), (8, 0, 0)),
|
||
("a compound path", json!([compound]), (8, 0, 0)),
|
||
("a clip group", json!([clip]), (4, 0, 0)),
|
||
] {
|
||
app.run("select.set", json!({ "ids": ids })).unwrap();
|
||
let got = looks(&mut app);
|
||
if got != want {
|
||
wrong.push(format!("{tool}, {what}: (filled, hollow, handles) {got:?}, want {want:?}"));
|
||
}
|
||
}
|
||
}
|
||
app.run("select.none", json!({})).unwrap();
|
||
app.select_tool("groupSelection");
|
||
let view = app.view_info();
|
||
for _ in 0..2 {
|
||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||
app.session.pointer(&PointerEvent::new(kind, 20.0, 50.0), view).unwrap();
|
||
}
|
||
}
|
||
let id = |v: &serde_json::Value| vectorcraft_doc::NodeId(v.as_u64().unwrap());
|
||
assert_eq!(app.session.active().unwrap().selection.objects, vec![id(&in_group), id(&group)]);
|
||
app.select_tool("directSelection");
|
||
// The rectangle's anchors and center point, then the group's eight anchors, four of them over the rectangle's.
|
||
let got = looks(&mut app);
|
||
if got != (13, 0, 0) {
|
||
wrong.push(format!("a Group Selection click on a path, then on its group: {got:?}, want (13, 0, 0)"));
|
||
}
|
||
assert!(wrong.is_empty(), "{}", wrong.join("\n"));
|
||
}
|
||
|
||
/// View › Pixel Preview: magnified, the art shows as the document pixels it rasterizes to (one
|
||
/// per point, hard-edged), not as smooth vectors; zoomed out or out of Pixel Preview the canvas
|
||
/// renders for the screen again.
|
||
#[test]
|
||
fn pixel_preview_shows_the_document_pixels() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
// Its 1 pt stroke covers a quarter of document pixel 99 and most of pixel 100.
|
||
app.session.execute("shape.rectangle", &json!({"x": 100.25, "y": 50, "width": 200, "height": 200})).unwrap();
|
||
app.canvas.worker_started = true; // render on this thread
|
||
let ctx = egui::Context::default();
|
||
// The art's raster uploaded by one frame: (width, height, hard-edged, pixels).
|
||
let upload = |app: &mut VectorcraftApp| {
|
||
let (_, mut delta) = frame_output(app, &ctx);
|
||
let art = app.canvas.texture.as_ref().unwrap().id();
|
||
let up = delta.set.iter().find(|e| e.0.eq(&art)).and_then(|e| e.1.last()).map(|d| {
|
||
let egui::ImageData::Color(img) = &d.image;
|
||
(img.width(), img.height(), d.options.magnification == egui::TextureFilter::Nearest, img.pixels.clone())
|
||
});
|
||
delta.clear();
|
||
up.expect("the art re-rendered")
|
||
};
|
||
let screen = upload(&mut app); // the first frame fits the view to the artboard
|
||
assert!(screen.0 > 600 && !screen.2, "out of Pixel Preview: the screen's pixels, smoothed");
|
||
app.ui.view.pixel_preview = true;
|
||
let view = app.view_mut().unwrap();
|
||
(view.zoom, view.center) = (8.0, Point::new(100.0, 150.0));
|
||
let (w, h, hard, px) = upload(&mut app);
|
||
assert!(hard, "hard-edged pixels");
|
||
assert!(w <= screen.0 / 8 + 2 && h <= screen.1 / 8 + 2, "one pixel per point: {w} × {h}");
|
||
let [x0, y0, x1, y1] = app.canvas.key.as_ref().and_then(|k| k.pixel).expect("the pixels rendered");
|
||
assert_eq!(((x1 - x0) as usize, (y1 - y0) as usize), (w, h));
|
||
// Each document pixel is one texel, anti-aliased by how much of it the art covers.
|
||
let alpha = |x: i64| px[((150 - y0) * w as i64 + x - x0) as usize].a();
|
||
assert!((40..=90).contains(&alpha(99)), "a quarter of pixel 99: {}", alpha(99));
|
||
assert!(alpha(100) > 200, "most of pixel 100: {}", alpha(100));
|
||
assert_eq!((alpha(98), alpha(101)), (0, 255));
|
||
// The pixels are placed over the document rect they cover.
|
||
let s = shapes(&mut app, &ctx);
|
||
let art = app.canvas.texture.as_ref().unwrap().id();
|
||
let Some(Shape::Mesh(mesh)) = s.iter().find(|s| matches!(s, Shape::Mesh(m) if m.texture_id == art)) else { panic!("the art") };
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let at = xf.to_screen(Point::new(x0 as f64, y0 as f64));
|
||
assert!((mesh.vertices[0].pos - at).length() < 0.01, "{:?} at {at:?}", mesh.vertices[0].pos);
|
||
// Zoomed out, a document pixel is no bigger than a screen pixel: rendered for the screen.
|
||
app.view_mut().unwrap().zoom = 0.5;
|
||
let (w, _, hard, _) = upload(&mut app);
|
||
assert!(w > 600 && !hard, "zoomed out: the screen's pixels");
|
||
}
|
||
|
||
/// View › Hide Template (#2000): a template layer's art leaves the canvas, and Show Template
|
||
/// draws it again, dimmed as before; each re-renders at once.
|
||
#[test]
|
||
fn hide_template_leaves_template_layers_off_the_canvas() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.canvas.worker_started = true; // render on this thread
|
||
app.session.execute("shape.rectangle", &json!({"x": 100, "y": 50, "width": 200, "height": 200})).unwrap();
|
||
let layer = app.session.active().unwrap().doc.layers[0].id.0;
|
||
app.session.execute("layer.template", &json!({"ids": [layer], "on": true})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
// How many pixels of the art's raster one frame uploads are painted, None when it was not
|
||
// re-rendered.
|
||
let painted = |app: &mut VectorcraftApp| -> Option<usize> {
|
||
let (_, mut delta) = frame_output(app, &ctx);
|
||
let art = app.canvas.texture.as_ref().unwrap().id();
|
||
let n = delta.set.iter().find(|e| e.0.eq(&art)).and_then(|e| e.1.last()).map(|d| {
|
||
let egui::ImageData::Color(img) = &d.image;
|
||
img.pixels.iter().filter(|c| c.a() != 0).count()
|
||
});
|
||
delta.clear();
|
||
n
|
||
};
|
||
let shown = painted(&mut app).expect("the first frame renders the art");
|
||
assert!(shown > 1000, "the template layer's art is drawn: {shown} pixels");
|
||
assert_eq!(app.run("view.template", json!({})).unwrap(), json!(false));
|
||
assert_eq!(painted(&mut app), Some(0), "Hide Template re-renders without the template layer");
|
||
assert_eq!(painted(&mut app), None, "nothing changed: no re-render");
|
||
assert_eq!(app.run("view.template", json!({})).unwrap(), json!(true));
|
||
assert_eq!(painted(&mut app), Some(shown), "Show Template draws it again as before");
|
||
}
|
||
|
||
/// General › Anti-aliased Artwork (#394): on (the default), the art's edges are smoothed on
|
||
/// screen; off, every pixel is either painted or not, and turning it either way re-renders.
|
||
#[test]
|
||
fn anti_aliased_artwork_preference_smooths_the_canvas() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
// Render on this thread: the worker's frame would land later than the test looks.
|
||
app.canvas.worker_started = true;
|
||
// A disc: its edge crosses pixels at every angle.
|
||
app.session.execute("shape.ellipse", &json!({"x": 100, "y": 50, "width": 200, "height": 200})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
// The alphas of the art's raster uploaded by one frame, None when it was not re-rendered.
|
||
let alphas = |app: &mut VectorcraftApp| -> Option<Vec<u8>> {
|
||
let (_, mut delta) = frame_output(app, &ctx);
|
||
let art = app.canvas.texture.as_ref().unwrap().id();
|
||
let alphas = delta.set.iter().find(|e| e.0.eq(&art)).and_then(|e| e.1.last()).map(|d| {
|
||
let egui::ImageData::Color(img) = &d.image;
|
||
img.pixels.iter().map(|c| c.a()).collect()
|
||
});
|
||
delta.clear();
|
||
alphas
|
||
};
|
||
let partial = |a: &[u8]| a.iter().filter(|&&a| a != 0 && a != 255).count();
|
||
let on = alphas(&mut app).expect("the first frame renders the art");
|
||
assert!(partial(&on) > 50, "anti-aliased: edge pixels partly covered ({} of them)", partial(&on));
|
||
assert!(alphas(&mut app).is_none(), "nothing changed: no re-render");
|
||
app.session.execute("prefs.set", &json!({"key": "antiAliasedArtwork", "value": false})).unwrap();
|
||
let off = alphas(&mut app).expect("the preference change re-renders");
|
||
assert_eq!(partial(&off), 0, "hard edges: every pixel painted or not");
|
||
assert!(off.iter().filter(|&&a| a == 255).count() > 1000, "the disc is still painted");
|
||
app.session.execute("prefs.set", &json!({"key": "antiAliasedArtwork", "value": true})).unwrap();
|
||
assert!(partial(&alphas(&mut app).expect("re-rendered")) > 50, "smooth again");
|
||
}
|
||
|
||
/// Type › Highlight Substituted Fonts: on (the default), type in a missing font has pink
|
||
/// behind it on the canvas; off, it does not, and turning it either way re-renders.
|
||
#[test]
|
||
fn highlight_substituted_fonts_preference_reaches_the_canvas() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.canvas.worker_started = true; // render on this thread
|
||
app.session.execute("text.create", &json!({"x": 20, "y": 120, "text": "Hamburg", "size": 60, "font": "Missing Family"})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
// How many pink pixels the art's raster uploaded by one frame has, None when it was not
|
||
// re-rendered.
|
||
let pink = |app: &mut VectorcraftApp| -> Option<usize> {
|
||
let (_, mut delta) = frame_output(app, &ctx);
|
||
let art = app.canvas.texture.as_ref().unwrap().id();
|
||
let n = delta.set.iter().find(|e| e.0.eq(&art)).and_then(|e| e.1.last()).map(|d| {
|
||
let egui::ImageData::Color(img) = &d.image;
|
||
img.pixels.iter().filter(|c| c.a() != 0 && c.r() > c.g().saturating_add(30) && c.b() > c.g()).count()
|
||
});
|
||
delta.clear();
|
||
n
|
||
};
|
||
let on = pink(&mut app).expect("the first frame renders the art");
|
||
assert!(on > 100, "on by default: the text has pink behind it ({on} pixels)");
|
||
assert!(app.canvas.shown.as_ref().is_some_and(|(_, o)| o.highlight_substituted_fonts));
|
||
app.session.execute("prefs.set", &json!({"key": "highlightSubstitutedFonts", "value": false})).unwrap();
|
||
assert_eq!(pink(&mut app), Some(0), "the preference off re-renders without the highlight");
|
||
assert!(app.canvas.key.as_ref().is_some_and(|k| !k.highlight_substituted_fonts));
|
||
app.session.execute("prefs.set", &json!({"key": "highlightSubstitutedFonts", "value": true})).unwrap();
|
||
assert_eq!(pink(&mut app), Some(on), "on again, as before");
|
||
}
|
||
|
||
/// A drag of an object with raster effects (#1257): the drag's frames render while
|
||
/// interacting and draw the object's drop shadow and filtered content from the rasters of the
|
||
/// frame before; the frame after the drag renders without `interacting`, on the render worker,
|
||
/// and draws what a fresh renderer draws.
|
||
#[test]
|
||
fn dragging_an_object_with_raster_effects_draws_its_rasters_moved() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.canvas.worker_started = true; // render on this thread
|
||
let id = app.run("shape.rectangle", json!({"x": 100, "y": 80, "width": 120, "height": 90})).unwrap()["id"].clone();
|
||
for effect in ["stylize.dropShadow", "texture.craquelure"] {
|
||
app.run("effect.apply", json!({"effect": effect, "ids": [id]})).unwrap();
|
||
}
|
||
let ctx = egui::Context::default();
|
||
// The art's raster uploaded by one frame, None when it was not re-rendered.
|
||
let upload = |app: &mut VectorcraftApp| -> Option<Vec<Color32>> {
|
||
let (_, mut delta) = frame_output(app, &ctx);
|
||
let art = app.canvas.texture.as_ref().unwrap().id();
|
||
let px = delta.set.iter().find(|e| e.0.eq(&art)).and_then(|e| e.1.last()).map(|d| {
|
||
let egui::ImageData::Color(img) = &d.image;
|
||
img.pixels.clone()
|
||
});
|
||
delta.clear();
|
||
px
|
||
};
|
||
upload(&mut app).expect("the first frame renders the art");
|
||
app.select_tool("selection");
|
||
let view = app.view_info();
|
||
app.session.pointer(&PointerEvent::new(PointerKind::Down, 160.0, 125.0), view).unwrap();
|
||
for step in [1.0, 2.0, 3.0] {
|
||
app.session.pointer(&PointerEvent::new(PointerKind::Drag, 160.0 + 7.3 * step, 125.0 + 2.1 * step), view).unwrap();
|
||
upload(&mut app).expect("each frame of the drag renders");
|
||
assert!(app.canvas.key.as_ref().is_some_and(|k| k.interacting), "step {step}");
|
||
assert_eq!(app.canvas.renderer.stats.moved, 2, "the shadow and the filtered content, step {step}");
|
||
assert!(app.canvas.moved);
|
||
}
|
||
// The frame after the drag runs the effects again: on the worker, while the canvas shows
|
||
// the drag's last frame.
|
||
app.canvas.worker = crate::render_worker::Worker::spawn(ctx.clone());
|
||
app.session.pointer(&PointerEvent::new(PointerKind::Up, 181.9, 131.3), view).unwrap();
|
||
assert!(upload(&mut app).is_none(), "not rendered on this thread");
|
||
assert!(app.canvas.worker.as_ref().and_then(|w| w.pending.as_ref()).is_some_and(|k| !k.interacting), "sent to the worker");
|
||
let until = std::time::Instant::now() + std::time::Duration::from_secs(60);
|
||
let mut after = None;
|
||
while after.is_none() && std::time::Instant::now() < until {
|
||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||
after = upload(&mut app);
|
||
}
|
||
let after = after.expect("the worker's frame arrives");
|
||
assert!(app.canvas.key.as_ref().is_some_and(|k| !k.interacting));
|
||
assert!(!app.canvas.moved);
|
||
app.canvas.worker = None;
|
||
app.canvas.renderer = vectorcraft_render::Renderer::new();
|
||
app.canvas.key = None;
|
||
assert!(upload(&mut app) == Some(after), "the frame after the drag is what a fresh renderer draws");
|
||
}
|
||
|
||
/// A tool's drag on the canvas renders with `dragging` (#2558). At 3200% a rectangle with
|
||
/// Texturizer covers more than the raster budget, so no raster is kept for it, and each frame
|
||
/// of a Selection tool drag draws its filtered content at the drag's lower resolution. The
|
||
/// frame after the drag draws what a fresh renderer draws. A dialog's preview, an interaction
|
||
/// without a drag, renders without `dragging` and draws the same.
|
||
#[test]
|
||
fn a_drag_on_the_canvas_draws_raster_effects_at_a_lower_resolution() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.canvas.worker_started = true; // render on this thread
|
||
let id = app.run("shape.rectangle", json!({"x": 100, "y": 80, "width": 200, "height": 150})).unwrap()["id"].clone();
|
||
app.run("effect.apply", json!({"effect": "texture.texturizer", "ids": [id]})).unwrap();
|
||
app.select_tool("selection");
|
||
let ctx = egui::Context::default();
|
||
// One headless frame on a 1920 × 1200 window (a view larger than the drag's budget) with
|
||
// `events` → the art's raster it uploaded, None when it was not re-rendered.
|
||
let run = |app: &mut VectorcraftApp, events: Vec<egui::Event>| -> Option<Vec<Color32>> {
|
||
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(1920.0, 1200.0))), events, ..Default::default() };
|
||
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
|
||
let art = app.canvas.texture.as_ref().map(|t| t.id());
|
||
let px = out.textures_delta.set.iter().find(|e| Some(*e.0) == art).and_then(|e| e.1.last()).map(|d| {
|
||
let egui::ImageData::Color(img) = &d.image;
|
||
img.pixels.clone()
|
||
});
|
||
out.textures_delta.clear();
|
||
px
|
||
};
|
||
run(&mut app, vec![]);
|
||
if let Some(vm) = app.view_mut() {
|
||
(vm.zoom, vm.center) = (32.0, Point::new(200.0, 155.0));
|
||
}
|
||
let still = run(&mut app, vec![]).expect("the zoomed view renders");
|
||
assert_eq!(app.canvas.renderer.stats.previewed, 0, "a still frame");
|
||
let at = app.canvas_rect.unwrap().center();
|
||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
run(&mut app, vec![egui::Event::PointerMoved(at)]);
|
||
run(&mut app, vec![button(at, true)]);
|
||
for step in [1.0, 2.0] {
|
||
let drawn = run(&mut app, vec![egui::Event::PointerMoved(at + vec2(13.0, 7.0) * step)]).expect("each frame of the drag renders");
|
||
assert!(app.canvas.key.as_ref().is_some_and(|k| k.interacting && k.dragging), "step {step}");
|
||
assert_eq!(app.canvas.renderer.stats.previewed, 1, "the filtered content, step {step}");
|
||
assert!(app.canvas.moved);
|
||
assert!(drawn != still, "the rectangle moved, step {step}");
|
||
}
|
||
let after = run(&mut app, vec![button(at + vec2(26.0, 14.0), false)]).expect("the frame after the drag renders");
|
||
assert!(app.canvas.key.as_ref().is_some_and(|k| !k.interacting && !k.dragging));
|
||
assert_eq!(app.canvas.renderer.stats.previewed, 0, "the frame after the drag");
|
||
assert!(!app.canvas.moved);
|
||
app.canvas.renderer = vectorcraft_render::Renderer::new();
|
||
app.canvas.key = None;
|
||
assert!(run(&mut app, vec![]) == Some(after.clone()), "the frame after the drag is what a fresh renderer draws");
|
||
// A dialog's preview: an interaction, and no drag.
|
||
app.session.begin_interaction("Texturizer").unwrap();
|
||
assert!(run(&mut app, vec![]) == Some(after), "a dialog's preview draws the effect at full resolution");
|
||
assert!(app.canvas.key.as_ref().is_some_and(|k| k.interacting && !k.dragging));
|
||
assert_eq!(app.canvas.renderer.stats.previewed, 0);
|
||
}
|
||
|
||
/// Dragging a small square over an A4 page filled by a rectangle blurred by 500 pt, at 200%:
|
||
/// the blur reaches far past the raster budget, so the still frame keeps the backdrop's raster
|
||
/// cropped to the view, and each frame of a Selection tool drag draws it from there instead of
|
||
/// previewing it at the drag's lower resolution: on this thread, and on the worker, where the
|
||
/// frames of a heavy document render.
|
||
#[test]
|
||
fn a_drag_over_a_wide_blur_draws_the_blur_from_the_still_frame() {
|
||
for on_worker in [false, true] {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 595, "height": 842})).unwrap();
|
||
app.canvas.worker_started = true; // no worker unless `on_worker`
|
||
let back = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 595, "height": 842})).unwrap()["id"].clone();
|
||
app.run("paint.setFill", json!({"ids": [back], "color": "#1a4ce6"})).unwrap();
|
||
app.run("effect.apply", json!({"effect": "blur.gaussian", "params": {"radius": 500}, "ids": [back]})).unwrap();
|
||
let square = app.run("shape.rectangle", json!({"x": 260, "y": 380, "width": 60, "height": 60})).unwrap()["id"].clone();
|
||
app.run("select.set", json!({"ids": [square]})).unwrap();
|
||
app.select_tool("selection");
|
||
let ctx = egui::Context::default();
|
||
// One headless frame with `events`, then, on the worker, frames until the frame it renders
|
||
// for them is shown.
|
||
let run = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
|
||
let frame = |app: &mut VectorcraftApp, events| {
|
||
let raw = egui::RawInput {
|
||
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 870.0))),
|
||
events,
|
||
..Default::default()
|
||
};
|
||
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
|
||
out.textures_delta.clear();
|
||
};
|
||
frame(app, events);
|
||
let until = std::time::Instant::now() + std::time::Duration::from_secs(60);
|
||
while app.canvas.worker.as_ref().is_some_and(|w| w.pending.is_some()) && std::time::Instant::now() < until {
|
||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||
frame(app, vec![]);
|
||
}
|
||
};
|
||
let moved = |app: &VectorcraftApp| if on_worker { app.canvas.moved } else { app.canvas.renderer.stats.moved > 0 };
|
||
run(&mut app, vec![]);
|
||
if let Some(vm) = app.view_mut() {
|
||
(vm.zoom, vm.center) = (2.0, Point::new(290.0, 410.0));
|
||
}
|
||
if on_worker {
|
||
app.canvas.worker = crate::render_worker::Worker::spawn(ctx.clone());
|
||
app.canvas.last_ms = 50.0;
|
||
}
|
||
run(&mut app, vec![]);
|
||
assert_eq!(app.canvas.from_worker, on_worker);
|
||
assert!(!moved(&app), "a still frame, on the worker: {on_worker}");
|
||
let at = app.canvas_rect.unwrap().center();
|
||
let button =
|
||
|pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
run(&mut app, vec![egui::Event::PointerMoved(at)]);
|
||
run(&mut app, vec![button(at, true)]);
|
||
for step in [1.0, 2.0, 3.0] {
|
||
run(&mut app, vec![egui::Event::PointerMoved(at + vec2(13.0, 7.0) * step)]);
|
||
assert!(app.canvas.key.as_ref().is_some_and(|k| k.interacting && k.dragging), "step {step}, on the worker: {on_worker}");
|
||
assert_eq!(app.canvas.from_worker, on_worker, "step {step}");
|
||
assert!(
|
||
!moved(&app),
|
||
"the blur drawn from the still frame's raster, step {step}, on the worker: {on_worker} ({} ms)",
|
||
app.perf.render_ms
|
||
);
|
||
}
|
||
run(&mut app, vec![button(at + vec2(39.0, 21.0), false)]);
|
||
let info = app.run("document.inspect", json!({})).unwrap();
|
||
let children = info["layers"][0]["children"].as_array().unwrap().clone();
|
||
let x0 = |id: &serde_json::Value| children.iter().find(|c| c["id"] == *id).unwrap()["bounds"]["x"].as_f64().unwrap();
|
||
assert!(x0(&square) > 270.0 && x0(&back) == 0.0, "the square moved, the backdrop did not");
|
||
app.canvas.worker = None;
|
||
}
|
||
}
|
||
|
||
/// An interaction's frames stay on the worker once it drew the frame shown, however fast that
|
||
/// frame was: the worker's renderer keeps the effect rasters that made it fast, and this
|
||
/// thread's renderer, without them, would draw the next frame slowly. Without a frame from the
|
||
/// worker, a fast frame renders on this thread as before.
|
||
#[test]
|
||
fn an_interaction_stays_on_the_worker_that_drew_the_frame_shown() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("shape.rectangle", &json!({"x": 100, "y": 50, "width": 200, "height": 200})).unwrap();
|
||
app.canvas.worker_started = true; // the first frame renders on this thread
|
||
let ctx = egui::Context::default();
|
||
shapes(&mut app, &ctx);
|
||
for from_worker in [true, false] {
|
||
app.canvas.worker = crate::render_worker::Worker::spawn(ctx.clone());
|
||
(app.canvas.from_worker, app.canvas.last_ms) = (from_worker, 1.0);
|
||
app.session.begin_interaction("Preview").unwrap();
|
||
shapes(&mut app, &ctx);
|
||
let pending = app.canvas.worker.as_ref().and_then(|w| w.pending.as_ref()).is_some_and(|k| k.interacting);
|
||
assert_eq!(pending, from_worker, "sent to the worker: {from_worker}");
|
||
if !from_worker {
|
||
assert!(app.canvas.key.as_ref().is_some_and(|k| k.interacting), "rendered on this thread");
|
||
}
|
||
app.session.cancel_interaction().unwrap();
|
||
app.canvas.worker = None;
|
||
shapes(&mut app, &ctx);
|
||
}
|
||
}
|
||
|
||
/// The frame after an interaction renders without `interacting` also when the interaction
|
||
/// leaves the document as it was (#1257): here a drag back to where it started.
|
||
#[test]
|
||
fn a_drag_back_to_its_start_renders_again_after_it() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.canvas.worker_started = true; // render on this thread
|
||
let id = app.run("shape.rectangle", json!({"x": 100, "y": 80, "width": 120, "height": 90})).unwrap()["id"].clone();
|
||
app.run("effect.apply", json!({"effect": "texture.craquelure", "ids": [id]})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
shapes(&mut app, &ctx);
|
||
app.select_tool("selection");
|
||
let view = app.view_info();
|
||
app.session.pointer(&PointerEvent::new(PointerKind::Down, 160.0, 125.0), view).unwrap();
|
||
for (x, y) in [(173.3, 129.1), (160.0, 125.0)] {
|
||
app.session.pointer(&PointerEvent::new(PointerKind::Drag, x, y), view).unwrap();
|
||
shapes(&mut app, &ctx);
|
||
assert_eq!(app.canvas.renderer.stats.moved, 1, "the filtered content at ({x}, {y})");
|
||
}
|
||
let revision = app.session.active().unwrap().revision;
|
||
app.session.pointer(&PointerEvent::new(PointerKind::Up, 160.0, 125.0), view).unwrap();
|
||
assert_eq!(app.session.active().unwrap().revision, revision, "the drag left the document as it was");
|
||
shapes(&mut app, &ctx);
|
||
assert!(app.canvas.key.as_ref().is_some_and(|k| !k.interacting), "rendered again");
|
||
assert_eq!(app.canvas.renderer.stats.moved, 0);
|
||
assert!(!app.canvas.moved);
|
||
}
|
||
|
||
/// Cache metadata, worker requests and view transforms use the renderer's bounded raster size.
|
||
/// Keep the worker controlled so huge virtual viewports never allocate huge test images.
|
||
#[test]
|
||
fn canvas_render_jobs_use_renderer_limited_dimensions() {
|
||
let ctx = egui::Context::default();
|
||
crate::theme::install_fonts(&ctx);
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.canvas.worker_started = true;
|
||
app.ui.view.rulers = false;
|
||
let frame = |app: &mut VectorcraftApp, size, ppp| {
|
||
let mut input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(pos2(0.0, 0.0), size)), ..Default::default() };
|
||
input.viewports.get_mut(&egui::ViewportId::ROOT).unwrap().native_pixels_per_point = Some(ppp);
|
||
let mut output = ctx.run_ui(input, |ui| show(app, ui));
|
||
output.textures_delta.clear();
|
||
};
|
||
frame(&mut app, vec2(400.0, 300.0), 1.0);
|
||
let (worker, jobs, _completed) = crate::render_worker::Worker::test_channel();
|
||
app.canvas.worker = Some(worker);
|
||
app.canvas.last_ms = 50.0;
|
||
for (size, ppp, wide) in [(vec2(70000.25, 32.0), 1.0, true), (vec2(32.0, 70000.25), 1.0, false), (vec2(40000.25, 32.0), 2.0, true)] {
|
||
frame(&mut app, size, ppp);
|
||
let job = jobs.try_recv().expect("oversized viewport queued on controlled worker");
|
||
assert_eq!((job.key.w, job.key.h), (job.w, job.h));
|
||
assert_eq!(if wide { job.w } else { job.h }, u32::from(u16::MAX));
|
||
assert!((1..=u32::from(u16::MAX)).contains(&job.w));
|
||
assert!((1..=u32::from(u16::MAX)).contains(&job.h));
|
||
let center = job.view * Point::new(job.key.cx, job.key.cy);
|
||
assert!((center.x - f64::from(job.w) / 2.0).abs() < 0.001);
|
||
assert!((center.y - f64::from(job.h) / 2.0).abs() < 0.001);
|
||
}
|
||
}
|
||
|
||
/// A cached texture keeps the renderer's document mapping when the viewport changes. The
|
||
/// controlled worker cannot finish early, even if egui repeats a layout pass.
|
||
#[test]
|
||
fn cached_frames_keep_their_scale_during_resize_rotation_and_dpi_changes() {
|
||
for (old_rot, new_rot, old_ppp, new_ppp, pixel, resize_only) in [
|
||
// Isolate the original panel/window resize: zoom, pan, rotation and DPI stay fixed.
|
||
(0.0, 0.0, 1.0, 1.0, false, true),
|
||
(30.0, 30.0, 1.25, 1.25, false, true),
|
||
(0.0, 0.0, 1.0, 1.0, false, false),
|
||
(30.0, 30.0, 1.25, 1.25, false, false),
|
||
(30.0, 75.0, 1.25, 1.25, false, false),
|
||
(30.0, 75.0, 1.25, 2.0, false, false),
|
||
(30.0, 30.0, 2.0, 1.25, false, false),
|
||
(30.0, 75.0, 1.25, 2.0, true, false),
|
||
] {
|
||
let ctx = egui::Context::default();
|
||
crate::theme::install_fonts(&ctx);
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.canvas.worker_started = true;
|
||
app.ui.view.pixel_preview = pixel;
|
||
let frame = |app: &mut VectorcraftApp, size: egui::Vec2, ppp: f32| {
|
||
let mut input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(pos2(31.25, 19.5), size)), ..Default::default() };
|
||
// Monitor DPI is viewport input, not egui's UI zoom (which rescales the last
|
||
// screen rect and would override the simultaneous resize under test).
|
||
input.viewports.get_mut(&egui::ViewportId::ROOT).unwrap().native_pixels_per_point = Some(ppp);
|
||
let mut out = ctx.run_ui(input, |ui| show(app, ui));
|
||
assert!((out.pixels_per_point - ppp).abs() < 0.001);
|
||
out.textures_delta.clear();
|
||
let tex = app.canvas.texture.as_ref().expect("rendered canvas texture").id();
|
||
out.shapes
|
||
.into_iter()
|
||
.find_map(|s| match s.shape {
|
||
Shape::Mesh(m) if m.texture_id == tex => Some(m),
|
||
_ => None,
|
||
})
|
||
.expect("the canvas texture is drawn")
|
||
};
|
||
frame(&mut app, vec2(800.25, 600.35), old_ppp);
|
||
{
|
||
let v = app.view_mut().unwrap();
|
||
v.rotation = old_rot;
|
||
v.zoom = 2.5;
|
||
v.center = Point::new(210.0, 160.0);
|
||
}
|
||
frame(&mut app, vec2(800.25, 600.35), old_ppp);
|
||
let old = app.canvas.key.clone().unwrap();
|
||
let (worker, jobs, completed) = crate::render_worker::Worker::test_channel();
|
||
app.canvas.worker = Some(worker);
|
||
app.canvas.last_ms = 50.0;
|
||
{
|
||
let v = app.view_mut().unwrap();
|
||
v.rotation = new_rot;
|
||
if !resize_only {
|
||
v.zoom = 3.25;
|
||
v.center = Point::new(225.0, 170.0);
|
||
}
|
||
}
|
||
let size = vec2(602.5, 720.1);
|
||
let mesh = frame(&mut app, size, new_ppp);
|
||
assert_eq!(app.canvas.key.as_ref(), Some(&old), "the cached frame is still displayed");
|
||
let job = jobs.try_recv().expect("the replacement frame was submitted");
|
||
assert_eq!(job.key.ppp, new_ppp);
|
||
assert_eq!(job.key.rot, new_rot);
|
||
if resize_only {
|
||
assert_eq!((job.key.zoom, job.key.cx, job.key.cy, job.key.rot, job.key.ppp), (old.zoom, old.cx, old.cy, old.rot, old.ppp));
|
||
}
|
||
assert_ne!((job.key.w, job.key.h), (old.w, old.h));
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let render_view = Affine::translate((old.w as f64 / 2.0, old.h as f64 / 2.0))
|
||
* Affine::rotate(old.rot.to_radians())
|
||
* Affine::scale(old.zoom * old.ppp as f64)
|
||
* Affine::translate((-old.cx, -old.cy));
|
||
assert_eq!(mesh.vertices.len(), 4);
|
||
for vertex in &mesh.vertices {
|
||
assert!([pos2(0.0, 0.0), pos2(1.0, 0.0), pos2(1.0, 1.0), pos2(0.0, 1.0)].contains(&vertex.uv));
|
||
let doc = if let Some([x0, y0, x1, y1]) = old.pixel {
|
||
Point::new(x0 as f64 + vertex.uv.x as f64 * (x1 - x0) as f64, y0 as f64 + vertex.uv.y as f64 * (y1 - y0) as f64)
|
||
} else {
|
||
render_view.inverse() * Point::new(vertex.uv.x as f64 * old.w as f64, vertex.uv.y as f64 * old.h as f64)
|
||
};
|
||
assert!(
|
||
(vertex.pos - xf.to_screen(doc)).length() < 0.002,
|
||
"{old_rot}° -> {new_rot}°, DPI {old_ppp} -> {new_ppp}, pixel={pixel}: {vertex:?}"
|
||
);
|
||
}
|
||
// Deliver the actual replacement and verify that it replaces the cached key.
|
||
let img = app.canvas.renderer.render(&job.doc, job.w, job.h, job.view, &job.opts);
|
||
let expected = job.key.clone();
|
||
completed.send(crate::render_worker::Done { key: job.key, doc: job.doc, opts: job.opts, img, ms: 1.0, moved: 0 }).unwrap();
|
||
frame(&mut app, size, new_ppp);
|
||
assert_eq!(app.canvas.key.as_ref(), Some(&expected), "the worker's frame arrived");
|
||
}
|
||
}
|
||
|
||
/// When the view turns while the worker renders the frame at the new angle (Reset Rotate View on
|
||
/// a heavy document), the canvas draws the last frame turned until that frame arrives: each
|
||
/// corner goes where the new view puts the document point that the corner showed. The new frame
|
||
/// is then drawn unturned. In Pixel Preview the last frame's pixels stay over the document rect
|
||
/// they cover.
|
||
#[test]
|
||
fn a_turned_view_draws_the_last_frame_turned_until_its_own_arrives() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("shape.rectangle", &json!({"x": 100, "y": 50, "width": 200, "height": 200})).unwrap();
|
||
app.canvas.worker_started = true; // render on this thread
|
||
let ctx = egui::Context::default();
|
||
shapes(&mut app, &ctx); // the first frame fits the view to the artboard
|
||
app.view_mut().unwrap().rotation = 30.0;
|
||
shapes(&mut app, &ctx);
|
||
let rect = app.canvas_rect.unwrap();
|
||
let old = Xf::new(rect, app.view().unwrap());
|
||
// `art` runs one frame and finds the mesh that draws the art's texture.
|
||
let art = |app: &mut VectorcraftApp| {
|
||
let s = shapes(app, &ctx);
|
||
let tex = app.canvas.texture.as_ref().unwrap().id();
|
||
s.into_iter().find_map(|s| match s {
|
||
Shape::Mesh(m) if m.texture_id == tex => Some(m),
|
||
_ => None,
|
||
})
|
||
};
|
||
// With a worker and a last frame that took 50 ms, the next frame renders on the worker.
|
||
app.canvas.worker = crate::render_worker::Worker::spawn(ctx.clone());
|
||
app.canvas.last_ms = 50.0;
|
||
app.run("view.rotateReset", json!({})).unwrap();
|
||
let mesh = art(&mut app).expect("the last frame, turned");
|
||
assert!(app.canvas.worker.as_ref().and_then(|w| w.pending.as_ref()).is_some_and(|k| k.rot == 0.0), "sent to the worker");
|
||
let new = Xf::new(rect, app.view().unwrap());
|
||
let corners = [rect.left_top(), rect.right_top(), rect.right_bottom(), rect.left_bottom()];
|
||
assert_eq!(mesh.vertices.len(), 4);
|
||
for ((v, corner), uv) in mesh.vertices.iter().zip(corners).zip([pos2(0.0, 0.0), pos2(1.0, 0.0), pos2(1.0, 1.0), pos2(0.0, 1.0)]) {
|
||
let at = new.to_screen(old.to_doc(corner));
|
||
assert!((v.pos - at).length() < 0.01 && v.uv == uv, "{v:?}: texture corner {uv:?} at {at:?}");
|
||
}
|
||
// The point where the last frame drew the rectangle's top-left corner lands where the new view puts that corner.
|
||
let p = Point::new(100.0, 50.0);
|
||
let uv = (old.to_screen(p) - rect.min) / rect.size();
|
||
let [a, b, _, d] = [0, 1, 2, 3].map(|i| mesh.vertices[i].pos);
|
||
assert!((a + (b - a) * uv.x + (d - a) * uv.y - new.to_screen(p)).length() < 0.01, "the rectangle's corner");
|
||
// The worker's frame at 0° then covers the canvas unturned.
|
||
let until = std::time::Instant::now() + std::time::Duration::from_secs(60);
|
||
while app.canvas.key.as_ref().is_some_and(|k| k.rot != 0.0) && std::time::Instant::now() < until {
|
||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||
shapes(&mut app, &ctx);
|
||
}
|
||
assert!(app.canvas.key.as_ref().is_some_and(|k| k.rot == 0.0), "the worker's frame arrives");
|
||
let b = art(&mut app).expect("the worker's frame").calc_bounds();
|
||
assert!((b.min - rect.min).length() < 0.01 && (b.max - rect.max).length() < 0.01, "{b:?} over {rect:?}");
|
||
// In Pixel Preview the last frame's pixels are drawn over the document rect they cover.
|
||
app.canvas.worker = None;
|
||
app.ui.view.pixel_preview = true;
|
||
app.view_mut().unwrap().zoom = 8.0;
|
||
shapes(&mut app, &ctx);
|
||
let [x0, y0, ..] = app.canvas.key.as_ref().and_then(|k| k.pixel).expect("the pixels rendered");
|
||
app.canvas.worker = crate::render_worker::Worker::spawn(ctx.clone());
|
||
app.canvas.last_ms = 50.0;
|
||
app.view_mut().unwrap().rotation = 30.0;
|
||
let mesh = art(&mut app).expect("the last frame's pixels");
|
||
let at = Xf::new(rect, app.view().unwrap()).to_screen(Point::new(x0 as f64, y0 as f64));
|
||
assert!((mesh.vertices[0].pos - at).length() < 0.01, "{:?} at {at:?}", mesh.vertices[0].pos);
|
||
app.canvas.worker = None;
|
||
}
|
||
|
||
/// With the web build's setting (`waits_for_still`, no render worker), a heavy document's canvas
|
||
/// is not rendered while the view moves: each frame draws the frame shown moved and scaled with
|
||
/// the view and asks for a frame within STILL_SECS. The canvas renders once the view has been
|
||
/// still for STILL_SECS, and at once after an edit. With a worker's build settings the same frames render at once, as
|
||
/// before.
|
||
#[test]
|
||
fn without_a_worker_a_heavy_canvas_is_reprojected_until_the_view_is_still() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("shape.rectangle", &json!({"x": 100, "y": 50, "width": 200, "height": 200})).unwrap();
|
||
assert!(!app.canvas.waits_for_still, "off the web build");
|
||
app.canvas.worker_started = true; // no worker, as in the web build
|
||
app.canvas.waits_for_still = true;
|
||
let ctx = egui::Context::default();
|
||
// One frame → (the bounds of the mesh that draws the art's texture, the repaint delay asked for).
|
||
let frame = |app: &mut VectorcraftApp| {
|
||
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() };
|
||
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
|
||
out.textures_delta.clear();
|
||
let tex = app.canvas.texture.as_ref().unwrap().id();
|
||
let art = out.shapes.iter().find_map(|c| match &c.shape {
|
||
Shape::Mesh(m) if m.texture_id == tex => Some(m.calc_bounds()),
|
||
_ => None,
|
||
});
|
||
(art.expect("the art's texture is drawn"), out.viewport_output[&egui::ViewportId::ROOT].repaint_delay)
|
||
};
|
||
frame(&mut app); // the first frame fits the view to the artboard
|
||
frame(&mut app);
|
||
let rect = app.canvas_rect.unwrap();
|
||
// A light document renders every change of view at once.
|
||
app.canvas.last_ms = 1.0;
|
||
app.view_mut().unwrap().center.x += 10.0;
|
||
frame(&mut app);
|
||
let shown = app.canvas.key.clone().unwrap();
|
||
assert_eq!(shown.cx, app.view().unwrap().center.x, "rendered at once");
|
||
// A heavy one draws the frame shown moved and scaled with the view.
|
||
app.canvas.last_ms = 50.0;
|
||
let old = Xf::new(rect, app.view().unwrap());
|
||
for (dx, zoom) in [(10.0, 1.0), (10.0, 1.5), (-25.0, 1.5)] {
|
||
let v = app.view_mut().unwrap();
|
||
v.center.x += dx;
|
||
v.zoom *= zoom;
|
||
let (b, delay) = frame(&mut app);
|
||
assert_eq!(app.canvas.key.as_ref(), Some(&shown), "not rendered while the view moves");
|
||
let new = Xf::new(rect, app.view().unwrap());
|
||
let (a, z) = (new.to_screen(old.to_doc(rect.min)), new.to_screen(old.to_doc(rect.max)));
|
||
assert!((b.min - a).length() < 0.01 && (b.max - z).length() < 0.01, "{b:?}: the frame shown at {a:?} to {z:?}");
|
||
assert!(delay <= std::time::Duration::from_secs_f64(STILL_SECS), "a frame within STILL_SECS: {delay:?}");
|
||
}
|
||
// The view stays still: frames at 60 fps (egui's input time without a clock) wait until
|
||
// STILL_SECS has passed, and the next one renders.
|
||
let mut frames = 0;
|
||
let b = loop {
|
||
frames += 1;
|
||
let (b, _) = frame(&mut app);
|
||
if app.canvas.key.as_ref() != Some(&shown) || frames == 60 {
|
||
break b;
|
||
}
|
||
};
|
||
assert!((12..=13).contains(&frames), "rendered after about 12 frames: {frames}");
|
||
let now = app.canvas.key.clone().unwrap();
|
||
assert_eq!((now.cx, now.zoom), (app.view().unwrap().center.x, app.view().unwrap().zoom), "rendered for the view");
|
||
assert!((b.min - rect.min).length() < 0.01 && (b.max - rect.max).length() < 0.01, "{b:?} over {rect:?}");
|
||
// An edit renders at once, even while the view moves.
|
||
app.canvas.last_ms = 50.0;
|
||
app.view_mut().unwrap().center.y += 10.0;
|
||
frame(&mut app);
|
||
assert_eq!(app.canvas.key.as_ref(), Some(&now), "waiting");
|
||
app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
|
||
frame(&mut app);
|
||
let k = app.canvas.key.clone().unwrap();
|
||
assert_eq!((k.revision, k.cy), (app.session.active().unwrap().revision, app.view().unwrap().center.y), "rendered after the edit");
|
||
// With a worker's build settings and no worker (VECTORCRAFT_SYNC_RENDER), a heavy document
|
||
// renders every change of view at once.
|
||
app.canvas.waits_for_still = false;
|
||
app.canvas.last_ms = 50.0;
|
||
app.view_mut().unwrap().center.x += 10.0;
|
||
frame(&mut app);
|
||
assert_eq!(app.canvas.key.as_ref().map(|k| k.cx), Some(app.view().unwrap().center.x), "rendered at once");
|
||
}
|
||
|
||
/// [`still_wait`] waits while only the view changes on a slow canvas: each change starts
|
||
/// STILL_SECS again. It does not wait for a light document, a still view, a clock that went
|
||
/// back, an edit, a switch into Pixel Preview or a canvas with no frame shown.
|
||
#[test]
|
||
fn the_canvas_waits_only_while_the_view_of_a_slow_frame_changes() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.canvas.worker_started = true; // render on this thread
|
||
shapes(&mut app, &egui::Context::default());
|
||
let shown = app.canvas.key.clone().unwrap();
|
||
let mut still = Some((shown.clone(), 0.0));
|
||
let moved = CacheKey { cx: shown.cx + 5.0, zoom: shown.zoom * 2.0, ..shown.clone() };
|
||
assert_eq!(still_wait(&mut still, Some(&shown), &moved, true, 10.0), Some(STILL_SECS), "the view moved");
|
||
assert_eq!(still_wait(&mut still, Some(&shown), &moved, true, 10.0625), Some(STILL_SECS - 0.0625), "still for 62.5 ms");
|
||
let turned = CacheKey { rot: 15.0, w: shown.w + 40, ppp: 2.0, ..moved.clone() };
|
||
assert_eq!(still_wait(&mut still, Some(&shown), &turned, true, 11.0), Some(STILL_SECS), "turned and resized: again");
|
||
assert_eq!(still_wait(&mut still, Some(&shown), &turned, true, 11.25), None, "still for longer than STILL_SECS");
|
||
assert_eq!(still_wait(&mut still, Some(&shown), &turned, false, 11.0), None, "a light document");
|
||
assert_eq!(still_wait(&mut still, Some(&shown), &turned, true, 10.5), None, "the clock went back");
|
||
let edited = CacheKey { revision: shown.revision + 1, ..turned.clone() };
|
||
assert_eq!(still_wait(&mut still, Some(&shown), &edited, true, 12.0), None, "an edit");
|
||
let pixels = CacheKey { pixel: Some([0, 0, 10, 10]), ..moved.clone() };
|
||
assert_eq!(still_wait(&mut still, Some(&shown), &pixels, true, 13.0), None, "into Pixel Preview");
|
||
let shown_pixels = CacheKey { pixel: Some([5, 5, 20, 20]), ..shown.clone() };
|
||
assert_eq!(still_wait(&mut still, Some(&shown_pixels), &pixels, true, 13.0), Some(STILL_SECS), "Pixel Preview, moved");
|
||
assert_eq!(still_wait(&mut still, None, &moved, true, 14.0), None, "no frame shown");
|
||
assert_eq!(still_wait(&mut still, Some(&shown), &shown, true, 15.0), None, "the frame shown");
|
||
}
|
||
|
||
/// File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (#394): off, Pixel
|
||
/// Preview draws images with each pixel taking its nearest image pixel; on, smoothly, as the
|
||
/// canvas always does out of Pixel Preview.
|
||
#[test]
|
||
fn pixel_preview_smooths_bitmaps_only_when_asked() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.canvas.worker_started = true; // render on this thread
|
||
let ctx = egui::Context::default();
|
||
let smooth = |app: &mut VectorcraftApp| {
|
||
let (_, mut delta) = frame_output(app, &ctx);
|
||
delta.clear();
|
||
app.canvas.key.as_ref().map(|k| k.smooth_images).unwrap()
|
||
};
|
||
assert!(smooth(&mut app), "out of Pixel Preview");
|
||
app.ui.view.pixel_preview = true;
|
||
app.view_mut().unwrap().zoom = 8.0;
|
||
assert!(!smooth(&mut app), "Pixel Preview, off by default");
|
||
app.run("prefs.set", json!({"key": "antiAliasedBitmaps", "value": true})).unwrap();
|
||
assert!(smooth(&mut app), "Pixel Preview, on");
|
||
}
|
||
|
||
/// Appearance of Black › On Screen (#394): Display All Blacks Accurately (the default) shows
|
||
/// pure CMYK black through the CMYK profile, a dark grey; Display All Blacks as Rich Black
|
||
/// shows it as RGB black. A rich black shows the same either way.
|
||
#[test]
|
||
fn appearance_of_black_on_screen_shows_pure_black_as_rgb_black() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300, "colorMode": "cmyk"})).unwrap();
|
||
app.canvas.worker_started = true; // render on this thread
|
||
// Larger than the view: the middle of the art's raster is its fill.
|
||
let id = app.run("shape.rectangle", json!({"x": -5000, "y": -5000, "width": 10000, "height": 10000})).unwrap()["id"].clone();
|
||
let fill = |app: &mut VectorcraftApp, cmyk: [u8; 4]| {
|
||
let [c, m, y, k] = cmyk;
|
||
app.run("paint.setFill", json!({"ids": [id], "color": {"c": c, "m": m, "y": y, "k": k}})).unwrap();
|
||
};
|
||
let ctx = egui::Context::default();
|
||
// The middle pixel of the art's raster uploaded by one frame, None when it was not re-rendered.
|
||
let middle = |app: &mut VectorcraftApp| -> Option<[u8; 4]> {
|
||
let (_, mut delta) = frame_output(app, &ctx);
|
||
let art = app.canvas.texture.as_ref().unwrap().id();
|
||
let px = delta.set.iter().find(|e| e.0.eq(&art)).and_then(|e| e.1.last()).map(|d| {
|
||
let egui::ImageData::Color(img) = &d.image;
|
||
let [w, h] = img.size;
|
||
img.pixels[h / 2 * w + w / 2].to_array()
|
||
});
|
||
delta.clear();
|
||
px
|
||
};
|
||
let shown = |cmyk: [f32; 4]| vectorcraft_color::Color::cmyk(cmyk[0], cmyk[1], cmyk[2], cmyk[3]).to_rgba8(1.0);
|
||
let near = |a: [u8; 4], b: [u8; 4]| a.iter().zip(b).all(|(x, y)| x.abs_diff(y) <= 1);
|
||
|
||
fill(&mut app, [0, 0, 0, 100]);
|
||
let accurate = middle(&mut app).expect("the first frame renders the art");
|
||
assert!(near(accurate, shown([0.0, 0.0, 0.0, 1.0])), "accurate: through the CMYK profile, {accurate:?}");
|
||
assert!(accurate[..3].iter().all(|v| *v > 10), "accurate: a dark grey, {accurate:?}");
|
||
app.run("prefs.set", json!({"key": "blackOnScreen", "value": "rich"})).unwrap();
|
||
assert_eq!(middle(&mut app).expect("the preference change re-renders"), [0, 0, 0, 255], "rich: RGB black");
|
||
|
||
fill(&mut app, [60, 40, 40, 100]);
|
||
let rich = middle(&mut app).expect("re-rendered");
|
||
assert!(near(rich, shown([0.6, 0.4, 0.4, 1.0])), "a rich black is not pure black: {rich:?}");
|
||
|
||
fill(&mut app, [0, 0, 0, 100]);
|
||
app.run("prefs.set", json!({"key": "blackOnScreen", "value": "accurate"})).unwrap();
|
||
assert!(near(middle(&mut app).expect("re-rendered"), accurate), "accurate again");
|
||
}
|
||
|
||
/// Guides & Grid › Show Pixel Grid (Above 600% Zoom) (#394): in Pixel Preview at 600% zoom and
|
||
/// above, a line at every document pixel over the art; none below 600%, out of Pixel Preview,
|
||
/// or with the option off.
|
||
#[test]
|
||
fn pixel_grid_shows_in_pixel_preview_from_600_percent() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("shape.rectangle", &json!({"x": 100, "y": 50, "width": 200, "height": 200})).unwrap();
|
||
app.canvas.worker_started = true; // render on this thread
|
||
let ctx = egui::Context::default();
|
||
shapes(&mut app, &ctx); // the first frame fits the view to the artboard
|
||
// → (the pixel grid's lines, whether the first of them is drawn over the art).
|
||
let pixel_lines = |app: &mut VectorcraftApp| {
|
||
let s = shapes(app, &ctx);
|
||
let art = app.canvas.texture.as_ref().unwrap().id();
|
||
let image = s.iter().position(|s| matches!(s, Shape::Mesh(m) if m.texture_id == art)).expect("the art");
|
||
let is_line = |s: &Shape| matches!(s, Shape::LineSegment { stroke, .. } if stroke.color == PIXEL_GRID);
|
||
let lines: Vec<usize> = s.iter().enumerate().filter(|(_, s)| is_line(s)).map(|(i, _)| i).collect();
|
||
(lines.len(), lines.first().is_some_and(|&i| i > image))
|
||
};
|
||
app.ui.view.pixel_preview = true;
|
||
app.view_mut().unwrap().zoom = 8.0;
|
||
let (n, over) = pixel_lines(&mut app);
|
||
assert!(n >= 150, "a line every 8 px across the 800 × 600 canvas: {n}");
|
||
assert!(over, "over the art");
|
||
app.view_mut().unwrap().zoom = 5.0;
|
||
assert_eq!(pixel_lines(&mut app).0, 0, "below 600%: none");
|
||
app.view_mut().unwrap().zoom = 6.0;
|
||
assert!(pixel_lines(&mut app).0 >= 200, "from 600%");
|
||
app.ui.view.pixel_preview = false;
|
||
assert_eq!(pixel_lines(&mut app).0, 0, "out of Pixel Preview: none");
|
||
app.ui.view.pixel_preview = true;
|
||
app.session.execute("prefs.set", &json!({"key": "showPixelGrid", "value": false})).unwrap();
|
||
assert_eq!(pixel_lines(&mut app).0, 0, "the option off: none");
|
||
// A rotated view: the lines turn with the pixels, across the whole canvas.
|
||
app.session.execute("prefs.set", &json!({"key": "showPixelGrid", "value": true})).unwrap();
|
||
app.view_mut().unwrap().rotation = 30.0;
|
||
let s = shapes(&mut app, &ctx);
|
||
let turned: Vec<_> = s
|
||
.iter()
|
||
.filter_map(|s| match s {
|
||
Shape::LineSegment { points: [a, b], stroke } if stroke.color == PIXEL_GRID => Some((b.x - a.x).atan2(b.y - a.y).to_degrees().abs()),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
assert!(turned.len() >= 200, "{}", turned.len());
|
||
assert!(turned.iter().all(|a| [30.0, 60.0, 120.0, 150.0].iter().any(|t| (a - t).abs() < 0.5)), "turned 30°: {turned:?}");
|
||
}
|
||
|
||
/// Guides & Grid (#394): the grid in Grid Color, behind the art or, with Grids In Back off,
|
||
/// over it, as dots in the Dots style; the guides in Guides Color and Style.
|
||
#[test]
|
||
fn grid_and_guides_take_their_preferences() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("guide.add", &json!({"vertical": true, "pos": 100})).unwrap();
|
||
app.ui.view.grid = true;
|
||
app.ui.view.guides = true;
|
||
let (red, green) = (Color32::from_rgb(255, 0, 0), Color32::from_rgb(0, 255, 0));
|
||
app.session.execute("prefs.set", &json!({"values": {"gridColor": "#ff0000", "guideColor": "#00ff00"}})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
let red_line = |s: &Shape| matches!(s, Shape::LineSegment { stroke, .. } if stroke.color == red);
|
||
// → (index of the first red gridline, index of the art's image).
|
||
let order = |app: &mut VectorcraftApp| {
|
||
let s = shapes(app, &ctx);
|
||
let art = app.canvas.texture.as_ref().unwrap().id();
|
||
let image = s.iter().position(|s| matches!(s, Shape::Mesh(m) if m.texture_id == art)).expect("the art");
|
||
(s.iter().position(red_line).expect("a gridline in Grid Color"), image, s)
|
||
};
|
||
let (grid, art, s) = order(&mut app);
|
||
assert!(grid < art, "Grids In Back: under the art");
|
||
assert!(s.iter().any(|s| matches!(s, Shape::LineSegment { stroke, .. } if stroke.color == green)), "the guide in Guides Color");
|
||
app.session.execute("prefs.set", &json!({"key": "gridsInBack", "value": false})).unwrap();
|
||
let (grid, art, _) = order(&mut app);
|
||
assert!(grid > art, "Grids In Back off: over the art");
|
||
app.session.execute("prefs.set", &json!({"values": {"gridStyle": "dots", "guideStyle": "dots"}})).unwrap();
|
||
let s = shapes(&mut app, &ctx);
|
||
assert!(!s.iter().any(red_line), "no gridlines in the Dots style");
|
||
let red_dots = |s: &Shape| matches!(s, Shape::Mesh(m) if m.vertices.len() > 40 && m.vertices.iter().all(|v| v.color == red));
|
||
assert!(s.iter().any(red_dots), "the grid's dots");
|
||
assert!(s.iter().any(|s| matches!(s, Shape::Circle(c) if c.fill == green)), "the guide dotted");
|
||
}
|
||
|
||
/// A canvas guide (on no artboard) is drawn along its document line in a rotated view, where it
|
||
/// snaps and is picked: both ends of each guide's line are on x = 100 or y = 120. At 0° the
|
||
/// vertical one runs from the canvas's top to its bottom.
|
||
#[test]
|
||
fn canvas_guides_turn_with_the_view() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("guide.add", &json!({"vertical": true, "pos": 100})).unwrap();
|
||
app.session.execute("guide.add", &json!({"vertical": false, "pos": 120})).unwrap();
|
||
app.session.execute("prefs.set", &json!({"key": "guideColor", "value": "#123456"})).unwrap();
|
||
app.ui.view.guides = true;
|
||
let color = Color32::from_rgb(0x12, 0x34, 0x56);
|
||
let ctx = egui::Context::default();
|
||
let lines = |app: &mut VectorcraftApp| -> Vec<[Pos2; 2]> {
|
||
shapes(app, &ctx)
|
||
.into_iter()
|
||
.filter_map(|s| if let Shape::LineSegment { points, stroke } = s { (stroke.color == color).then_some(points) } else { None })
|
||
.collect()
|
||
};
|
||
let s = lines(&mut app);
|
||
let rect = app.canvas_rect.unwrap();
|
||
let vertical = s.iter().find(|[a, b]| (a.x - b.x).abs() < 1e-3).expect("the vertical guide");
|
||
assert!((vertical[0].y - rect.top()).abs() < 0.5 && (vertical[1].y - rect.bottom()).abs() < 0.5, "0°: {vertical:?} in {rect:?}");
|
||
app.run("view.rotate", json!({"angle": 30})).unwrap();
|
||
let s = lines(&mut app);
|
||
let xf = Xf::new(rect, app.view().unwrap());
|
||
assert_eq!(s.len(), 2, "{s:?}");
|
||
let on = |v: f64, along_x: bool| {
|
||
s.iter().any(|ends| ends.iter().all(|e| ((if along_x { xf.to_doc(*e).x } else { xf.to_doc(*e).y }) - v).abs() < 1e-3))
|
||
};
|
||
assert!(on(100.0, true), "x = 100: {s:?}");
|
||
assert!(on(120.0, false), "y = 120: {s:?}");
|
||
}
|
||
|
||
/// Selection & Anchor Display › Size (#394): anchors and the bounding box's handles grow.
|
||
#[test]
|
||
fn anchor_size_scales_anchors_and_handles() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 100})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
// The sizes of the squares drawn (anchors and handles are squares).
|
||
let sizes = |app: &mut VectorcraftApp| {
|
||
let mut v: Vec<f32> = shapes(app, &ctx).iter().filter_map(|s| if let Shape::Rect(r) = s { Some(r.rect.width()) } else { None }).collect();
|
||
v.sort_by(f32::total_cmp);
|
||
v.dedup();
|
||
v
|
||
};
|
||
let small = sizes(&mut app);
|
||
assert!(small.contains(&4.0) && small.contains(&6.0), "anchors 4, handles 6 by default: {small:?}");
|
||
app.session.execute("prefs.set", &json!({"key": "anchorSize", "value": 7})).unwrap();
|
||
let big = sizes(&mut app);
|
||
assert!(big.contains(&8.0) && big.contains(&10.0) && !big.contains(&4.0), "4 points bigger at 7: {big:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn mirror_axis_tracks_selection_options_and_view_without_editing_art() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||
app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 60, "height": 80})).unwrap();
|
||
let id = app.run("object.repeat.mirror", json!({})).unwrap()["id"].as_u64().unwrap();
|
||
let ctx = egui::Context::default();
|
||
let axis = |app: &mut VectorcraftApp| {
|
||
let color = Tokens::get(&ctx).selection;
|
||
shapes(app, &ctx)
|
||
.into_iter()
|
||
.filter_map(|s| match s {
|
||
Shape::LineSegment { points, stroke } if stroke.color == color && stroke.width == 2.0 => Some(points),
|
||
_ => None,
|
||
})
|
||
.collect::<Vec<_>>()
|
||
};
|
||
let before = serde_json::to_value(&app.session.active().unwrap().doc).unwrap();
|
||
let lines = axis(&mut app);
|
||
assert_eq!(lines.len(), 1);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
assert!(lines[0][0].distance(xf.to_screen(Point::new(170.0, 100.0)) - vec2(0.0, 24.0)) < 0.01);
|
||
assert!(lines[0][1].distance(xf.to_screen(Point::new(170.0, 180.0)) + vec2(0.0, 24.0)) < 0.01);
|
||
assert_eq!(serde_json::to_value(&app.session.active().unwrap().doc).unwrap(), before);
|
||
app.ui.view.edges = false;
|
||
assert!(axis(&mut app).is_empty());
|
||
app.ui.view.edges = true;
|
||
app.run("select.set", json!({"ids": []})).unwrap();
|
||
assert!(axis(&mut app).is_empty());
|
||
app.run("select.set", json!({"ids": [id]})).unwrap();
|
||
assert_eq!(axis(&mut app), lines);
|
||
app.run("object.repeat.options", json!({"angle": 0, "center": [180, 140]})).unwrap();
|
||
let horizontal = axis(&mut app);
|
||
assert_eq!(horizontal.len(), 1);
|
||
assert!((horizontal[0][0].y - horizontal[0][1].y).abs() < 0.01);
|
||
app.run("edit.undo", json!({})).unwrap();
|
||
assert_eq!(axis(&mut app), lines);
|
||
app.view_mut().unwrap().rotation = 45.0;
|
||
let rotated = axis(&mut app);
|
||
let d = rotated[0][1] - rotated[0][0];
|
||
assert!((d.x.abs() - d.y.abs()).abs() < 0.01);
|
||
app.run("object.repeat.release", json!({})).unwrap();
|
||
assert!(axis(&mut app).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn mirror_axis_drag_uses_canvas_coordinates_in_a_zoomed_rotated_view() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||
app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 60, "height": 80})).unwrap();
|
||
app.run("object.repeat.mirror", json!({})).unwrap();
|
||
app.ui.task_bar = false;
|
||
app.ui.view.rulers = false;
|
||
app.view_mut().unwrap().zoom = 1.5;
|
||
app.view_mut().unwrap().rotation = 30.0;
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let axis = |app: &VectorcraftApp| {
|
||
let st = app.session.active().unwrap();
|
||
vectorcraft_tools::mirror::MirrorAxis::of(&st.doc, &st.selection, app.view().unwrap().zoom).unwrap()
|
||
};
|
||
let initial = axis(&app);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let from = xf.to_screen(initial.center);
|
||
let to = from + vec2(40.0, 15.0);
|
||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from), button(from, true)]);
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(to)]);
|
||
assert!(axis(&app).center.distance(xf.to_doc(to)) < 0.001, "the mirrored artwork previews during the drag");
|
||
frame(&mut app, &ctx, vec![button(to, false)]);
|
||
assert!(axis(&app).center.distance(xf.to_doc(to)) < 0.001);
|
||
app.run("edit.undo", json!({})).unwrap();
|
||
assert_eq!(axis(&app).center, initial.center);
|
||
// Both rotation handles remain a usable size at this zoom and view angle.
|
||
let drawn = shapes(&mut app, &ctx);
|
||
for end in initial.ends {
|
||
assert!(drawn.iter().any(|s| matches!(s, Shape::Circle(c)
|
||
if c.center.distance(xf.to_screen(end)) < 0.01 && c.radius == vectorcraft_tools::mirror::HANDLE_RADIUS)));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn top_bounding_box_handle_scales_a_default_mirror_repeat() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||
app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap();
|
||
app.run("object.repeat.mirror", json!({})).unwrap();
|
||
app.ui.task_bar = false;
|
||
app.ui.view.rulers = false;
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
|
||
let initial = app.selection_bounds().unwrap();
|
||
let bx = app.selection_box().unwrap();
|
||
let top = bx.to_doc() * vectorcraft_tools::bbox::Handle::Top.pos(bx.rect);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let from = xf.to_screen(top);
|
||
let to = from + vec2(0.0, -30.0);
|
||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from), button(from, true)]);
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(to)]);
|
||
frame(&mut app, &ctx, vec![button(to, false)]);
|
||
|
||
let scaled = app.selection_bounds().unwrap();
|
||
assert!(scaled.height() > initial.height() + 10.0, "Top handle should scale the repeat: {initial:?} -> {scaled:?}");
|
||
assert!((scaled.width() - initial.width()).abs() < 0.01, "Top handle should not scale width: {initial:?} -> {scaled:?}");
|
||
}
|
||
|
||
/// #1236: Curvature edits points, so existing paths show one circular marker per point and
|
||
/// no direction handles; switching back to Direct Selection keeps its normal handles.
|
||
#[test]
|
||
fn curvature_points_of_an_existing_path_have_no_direction_handles() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id = vectorcraft_doc::NodeId(
|
||
app.session
|
||
.execute(
|
||
"path.curvature",
|
||
&json!({"points": [
|
||
{"x": 60, "y": 200}, {"x": 120, "y": 80}, {"x": 210, "y": 160}, {"x": 280, "y": 60}, {"x": 350, "y": 180}
|
||
]}),
|
||
)
|
||
.unwrap()["id"]
|
||
.as_u64()
|
||
.unwrap(),
|
||
);
|
||
let document = app.session.active().unwrap().doc.clone();
|
||
let ctx = egui::Context::default();
|
||
app.select_tool("curvature");
|
||
let drawn = shapes(&mut app, &ctx);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let points: Vec<_> = document.node(id).unwrap().path_data().unwrap().anchors().map(|(_, _, a)| xf.to_screen(a.p)).collect();
|
||
let marker = |shape: &Shape| matches!(shape, Shape::Circle(c) if points.iter().any(|p| p.distance(c.center) < 0.01));
|
||
assert_eq!(drawn.iter().filter(|s| marker(s)).count(), 5, "one circular marker at every curve point");
|
||
assert!(
|
||
!drawn.iter().any(|s| matches!(s, Shape::Rect(r) if points.iter().any(|p| p.distance(r.rect.center()) < 0.01))),
|
||
"no square at a curve point"
|
||
);
|
||
let color = c32(document.layer_color(id));
|
||
let handles = |shapes: &[Shape]| shapes.iter().filter(|s| matches!(s, Shape::LineSegment { stroke, .. } if stroke.color == color)).count();
|
||
assert_eq!(handles(&drawn), 0, "Curvature hides the direction lines");
|
||
app.session.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 2]], "mode": "set"})).unwrap();
|
||
let partial = shapes(&mut app, &ctx);
|
||
assert_eq!(partial.iter().filter(|s| marker(s)).count(), 5, "other curve points stay visible");
|
||
assert_eq!(handles(&partial), 0, "a single selected point still shows no direction lines");
|
||
app.ui.view.edges = false;
|
||
assert_eq!(shapes(&mut app, &ctx).iter().filter(|s| marker(s)).count(), 0, "Hide Edges still hides an existing path's markers");
|
||
app.ui.view.edges = true;
|
||
app.select_tool("directSelection");
|
||
assert_eq!(handles(&shapes(&mut app, &ctx)), 2, "Direct Selection keeps the selected point's two handles");
|
||
app.select_tool("pen");
|
||
assert_eq!(handles(&shapes(&mut app, &ctx)), 2, "Pen keeps the selected point's two handles");
|
||
assert_eq!(
|
||
serde_json::to_value(&app.session.active().unwrap().doc).unwrap(),
|
||
serde_json::to_value(&document).unwrap(),
|
||
"tool switches and painting don't change the curve"
|
||
);
|
||
}
|
||
|
||
/// While drawing, Curvature's current-point feedback takes the place of generic selection
|
||
/// markers: no duplicate squares or handles, and the path is unchanged by drawing a frame.
|
||
#[test]
|
||
fn curvature_drawing_shows_its_points_once() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
shapes(&mut app, &ctx);
|
||
app.select_tool("curvature");
|
||
for (x, y) in [(60.0, 200.0), (120.0, 80.0), (210.0, 160.0), (280.0, 60.0), (350.0, 180.0)] {
|
||
let view = app.view_info();
|
||
for kind in [vectorcraft_tools::PointerKind::Down, vectorcraft_tools::PointerKind::Up] {
|
||
app.session.pointer(&vectorcraft_tools::PointerEvent::new(kind, x, y), view).unwrap();
|
||
}
|
||
}
|
||
let st = app.session.active().unwrap();
|
||
let id = st.selection.objects[0];
|
||
let document = st.doc.clone();
|
||
let selection = st.selection.clone();
|
||
let drawn = shapes(&mut app, &ctx);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let points: Vec<_> = document.node(id).unwrap().path_data().unwrap().anchors().map(|(_, _, a)| xf.to_screen(a.p)).collect();
|
||
let markers: Vec<_> = drawn
|
||
.iter()
|
||
.filter_map(|s| match s {
|
||
Shape::Circle(c) if points.iter().any(|p| p.distance(c.center) < 0.01) => Some(c),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
assert_eq!(markers.len(), 5, "one marker per point, including the current point");
|
||
assert_eq!(markers.iter().filter(|c| c.fill != Color32::WHITE).count(), 1, "only the last touched point is filled");
|
||
assert!(
|
||
!drawn.iter().any(|s| matches!(s, Shape::Rect(r) if points.iter().any(|p| p.distance(r.rect.center()) < 0.01))),
|
||
"neither tool nor selection draws anchor squares"
|
||
);
|
||
let color = c32(document.layer_color(id));
|
||
assert!(!drawn.iter().any(|s| matches!(s, Shape::LineSegment { stroke, .. } if stroke.color == color)), "no direction lines");
|
||
app.session.execute("prefs.set", &json!({"key": "anchorSize", "value": 7})).unwrap();
|
||
assert_eq!(
|
||
shapes(&mut app, &ctx)
|
||
.iter()
|
||
.filter(|s| matches!(s, Shape::Circle(c) if c.radius == 5.0 && points.iter().any(|p| p.distance(c.center) < 0.01)))
|
||
.count(),
|
||
5,
|
||
"point markers respect Anchor Size"
|
||
);
|
||
assert_eq!(app.session.active().unwrap().selection, selection);
|
||
assert_eq!(serde_json::to_value(&app.session.active().unwrap().doc).unwrap(), serde_json::to_value(&document).unwrap());
|
||
}
|
||
|
||
/// Selection & Anchor Display › Handles and Show handles when multiple anchors are selected
|
||
/// (#394): handle ends drawn solid, hollow or large; with the latter off, a second selected
|
||
/// anchor hides the handles.
|
||
#[test]
|
||
fn handle_style_and_handles_of_multiple_anchors() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.session.execute("shape.ellipse", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].clone();
|
||
app.session.select_tool("directSelection", app.view_info()).unwrap();
|
||
let ctx = egui::Context::default();
|
||
let select = |app: &mut VectorcraftApp, anchors: serde_json::Value| {
|
||
app.session.execute("select.anchors", &json!({"id": id, "anchors": anchors, "mode": "set"})).unwrap();
|
||
};
|
||
// The handle ends: (radius, filled white).
|
||
let dots = |app: &mut VectorcraftApp| -> Vec<(f32, bool)> {
|
||
shapes(app, &ctx)
|
||
.iter()
|
||
.filter_map(|s| match s {
|
||
Shape::Circle(c) if c.fill != Color32::TRANSPARENT => Some((c.radius, c.fill == Color32::WHITE)),
|
||
_ => None,
|
||
})
|
||
.collect()
|
||
};
|
||
select(&mut app, json!([[0, 0]]));
|
||
assert_eq!(dots(&mut app), [(2.75, false); 2], "solid by default");
|
||
app.session.execute("prefs.set", &json!({"key": "handleStyle", "value": "hollow"})).unwrap();
|
||
assert_eq!(dots(&mut app), [(2.75, true); 2]);
|
||
app.session.execute("prefs.set", &json!({"key": "handleStyle", "value": "large"})).unwrap();
|
||
assert_eq!(dots(&mut app), [(4.25, false); 2]);
|
||
select(&mut app, json!([[0, 0], [0, 1]]));
|
||
assert_eq!(dots(&mut app).len(), 4, "both anchors' handles");
|
||
app.session.execute("prefs.set", &json!({"key": "showHandlesMultipleAnchors", "value": false})).unwrap();
|
||
assert!(dots(&mut app).is_empty(), "off: none for two anchors");
|
||
select(&mut app, json!([[0, 1]]));
|
||
assert_eq!(dots(&mut app).len(), 2, "one anchor still shows them");
|
||
}
|
||
|
||
/// #3219: a selected path made into a guide is deselected when guides are hidden, so the
|
||
/// selection overlay cannot reveal the hidden path.
|
||
#[test]
|
||
fn hide_guides_deselects_selected_guide_paths() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||
app.run("view.guides.make", json!({})).unwrap();
|
||
app.run("select.set", json!({"ids": [id]})).unwrap();
|
||
assert_eq!(app.session.active().unwrap().selection.objects, vec![vectorcraft_doc::NodeId(id)]);
|
||
|
||
app.run("view.guides", json!({})).unwrap();
|
||
|
||
assert!(app.session.active().unwrap().selection.objects.is_empty());
|
||
}
|
||
|
||
/// Smart Guides › Object Highlighting (#394): the outline of the object under the pointer does not
|
||
/// show with View › Snap to Grid or View › Pixel Preview on, even with Smart Guides and the option on.
|
||
/// #3219: guide paths hidden by View > Hide Guides are not hittable and cannot reveal
|
||
/// themselves through Smart Guides object highlighting.
|
||
#[test]
|
||
fn hidden_guide_paths_have_no_hover_highlight() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||
app.run("view.guides.make", json!({})).unwrap();
|
||
app.run("select.none", json!({})).unwrap();
|
||
app.select_tool("selection");
|
||
app.ui.view.guides = false;
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let color = c32(app.session.active().unwrap().doc.layer_color(vectorcraft_doc::NodeId(id)));
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(xf.to_screen(Point::new(100.0, 150.0)))]);
|
||
assert!(drawn_in(&mut app, &ctx, color, 1.5).is_empty(), "a hidden guide has no hover highlight");
|
||
}
|
||
|
||
#[test]
|
||
fn snap_to_grid_and_pixel_preview_hide_the_hover_outline() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||
app.session.execute("select.none", &json!({})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let rect = app.canvas_rect.unwrap();
|
||
let over = Xf::new(rect, app.view().unwrap()).to_screen(Point::new(150.0, 150.0));
|
||
let layer = egui::epaint::ColorMode::Solid(c32(app.session.active().unwrap().doc.layer_color(vectorcraft_doc::NodeId(id))));
|
||
let outlines = |app: &mut VectorcraftApp| {
|
||
frame(app, &ctx, vec![egui::Event::PointerMoved(over)]);
|
||
shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Path(ps) if ps.stroke.width == 1.5 && ps.stroke.color == layer)).count()
|
||
};
|
||
assert!(app.ui.view.smart_guides && app.session.prefs.object_highlighting);
|
||
assert!(outlines(&mut app) > 0, "highlighted with Snap to Grid and Pixel Preview off");
|
||
app.run("view.snapToGrid", json!({})).unwrap();
|
||
assert!(app.ui.view.snap_to_grid);
|
||
assert_eq!(outlines(&mut app), 0, "Snap to Grid on");
|
||
app.run("view.snapToGrid", json!({})).unwrap();
|
||
assert!(outlines(&mut app) > 0, "Snap to Grid off again");
|
||
app.run("view.pixelPreview", json!({})).unwrap();
|
||
assert!(app.ui.view.pixel_preview);
|
||
assert_eq!(outlines(&mut app), 0, "Pixel Preview on");
|
||
app.run("view.pixelPreview", json!({})).unwrap();
|
||
assert!(outlines(&mut app) > 0, "Pixel Preview off again");
|
||
}
|
||
|
||
/// #2452: while the view scrolls under a resting pointer, the art under it isn't hit-tested:
|
||
/// no hover outline, and the tool's last cursor stays. Once the view has been still for
|
||
/// [`HOVER_STILL_SECS`], the outline comes back without the pointer moving.
|
||
#[test]
|
||
fn a_moving_view_leaves_the_art_under_the_pointer_unhighlighted_until_it_stops() {
|
||
fn flat(s: &Shape, out: &mut Vec<Shape>) {
|
||
match s {
|
||
Shape::Vec(v) => v.iter().for_each(|s| flat(s, out)),
|
||
s => out.push(s.clone()),
|
||
}
|
||
}
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 400, "height": 300})).unwrap()["id"].as_u64().unwrap();
|
||
app.session.execute("select.none", &json!({})).unwrap();
|
||
let layer = egui::epaint::ColorMode::Solid(c32(app.session.active().unwrap().doc.layer_color(vectorcraft_doc::NodeId(id))));
|
||
let ctx = egui::Context::default();
|
||
let screen = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
|
||
let mut time = 0.0;
|
||
let mut outlines = |app: &mut VectorcraftApp, events: Vec<egui::Event>, dt: f64| {
|
||
time += dt;
|
||
let mut out =
|
||
ctx.run_ui(egui::RawInput { time: Some(time), screen_rect: Some(screen), events, ..Default::default() }, |ui| show(app, ui));
|
||
out.textures_delta.clear();
|
||
let mut shapes = vec![];
|
||
out.shapes.iter().for_each(|c| flat(&c.shape, &mut shapes));
|
||
shapes.iter().filter(|s| matches!(s, Shape::Path(ps) if ps.stroke.width == 1.5 && ps.stroke.color == layer)).count()
|
||
};
|
||
outlines(&mut app, vec![], 0.0);
|
||
let at = app.canvas_rect.unwrap().center();
|
||
assert!(outlines(&mut app, vec![egui::Event::PointerMoved(at)], 0.016) > 0, "the art under the resting pointer is highlighted");
|
||
let wheel = |dy: f32| egui::Event::MouseWheel {
|
||
unit: egui::MouseWheelUnit::Point,
|
||
delta: vec2(0.0, dy),
|
||
phase: egui::TouchPhase::Move,
|
||
modifiers: Default::default(),
|
||
source: egui::MouseWheelSource::Unknown,
|
||
};
|
||
for k in 0..12 {
|
||
let before = app.view().unwrap().center;
|
||
let shown = outlines(&mut app, vec![wheel(if k % 2 == 0 { 4.0 } else { -4.0 })], 0.016);
|
||
assert_ne!(app.view().unwrap().center, before, "frame {k}: the wheel scrolls the view");
|
||
assert_eq!(shown, 0, "frame {k}: no hover outline while the view moves");
|
||
}
|
||
assert_eq!(outlines(&mut app, vec![], HOVER_STILL_SECS / 2.0), 0, "the view has only just stopped");
|
||
assert!(outlines(&mut app, vec![], HOVER_STILL_SECS) > 0, "still again: the outline comes back");
|
||
// Selected, the rectangle gives the move cursor near its top edge. Scrolled up past the
|
||
// pointer, it stays the move cursor until the view stops, then the arrow.
|
||
app.session.execute("select.all", &json!({})).unwrap();
|
||
outlines(&mut app, vec![egui::Event::PointerMoved(at - vec2(0.0, 200.0))], 0.016);
|
||
let cursor = |app: &VectorcraftApp| app.canvas.hover_cursor.map(|(_, _, c)| c);
|
||
assert_eq!(cursor(&app), Some(Cursor::Move));
|
||
for k in 0..12 {
|
||
let before = app.view().unwrap().center;
|
||
outlines(&mut app, vec![wheel(-80.0)], 0.016);
|
||
assert_ne!(app.view().unwrap().center, before, "frame {k}: the wheel scrolls the view");
|
||
assert_eq!(cursor(&app), Some(Cursor::Move), "frame {k}: the cursor stays while the view moves");
|
||
}
|
||
// The wheel's smooth scrolling runs on for some frames; then the view rests.
|
||
for _ in 0..200 {
|
||
let before = app.view().unwrap().center;
|
||
outlines(&mut app, vec![], 0.016);
|
||
if app.view().unwrap().center == before {
|
||
break;
|
||
}
|
||
}
|
||
assert_eq!(cursor(&app), Some(Cursor::Move), "the view has only just stopped");
|
||
outlines(&mut app, vec![], HOVER_STILL_SECS + 0.01);
|
||
assert_eq!(cursor(&app), Some(Cursor::Arrow), "still again: the pointer at {:?} is past the rectangle", app.hover_doc);
|
||
}
|
||
|
||
/// View › Snap to Glyph toggles through the View menu command (#3124).
|
||
#[test]
|
||
fn snap_to_glyph_menu_command_toggles_the_view_flag() {
|
||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||
assert!(!app.ui.view.snap_to_glyph);
|
||
app.run("view.snapToGlyph", json!({})).unwrap();
|
||
assert!(app.ui.view.snap_to_glyph);
|
||
app.run("view.snapToGlyph", json!({})).unwrap();
|
||
assert!(!app.ui.view.snap_to_glyph);
|
||
}
|
||
|
||
/// Smart Guides › Object Highlighting (#394): the outline of the object under the pointer
|
||
/// shows with Smart Guides on and the option on, and not otherwise.
|
||
#[test]
|
||
fn object_highlighting_preference_hides_the_hover_outline() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||
app.session.execute("select.none", &json!({})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let rect = app.canvas_rect.unwrap();
|
||
let over = Xf::new(rect, app.view().unwrap()).to_screen(Point::new(150.0, 150.0));
|
||
let layer = egui::epaint::ColorMode::Solid(c32(app.session.active().unwrap().doc.layer_color(vectorcraft_doc::NodeId(id))));
|
||
// The hover outline: 1.5 px lines in the layer colour.
|
||
let outlines = |app: &mut VectorcraftApp| {
|
||
frame(app, &ctx, vec![egui::Event::PointerMoved(over)]);
|
||
shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Path(ps) if ps.stroke.width == 1.5 && ps.stroke.color == layer)).count()
|
||
};
|
||
assert!(outlines(&mut app) > 0, "highlighted by default");
|
||
app.session.execute("prefs.set", &json!({"key": "objectHighlighting", "value": false})).unwrap();
|
||
assert_eq!(outlines(&mut app), 0, "the option off");
|
||
app.session.execute("prefs.set", &json!({"key": "objectHighlighting", "value": true})).unwrap();
|
||
app.ui.view.smart_guides = false;
|
||
assert_eq!(outlines(&mut app), 0, "Smart Guides off");
|
||
}
|
||
|
||
/// Hide Corner Widget for angles greater than (#394): a rectangle's 90° corners lose their
|
||
/// widgets below 90°.
|
||
#[test]
|
||
fn corner_widgets_hide_above_the_preference_angle() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
let widgets = |app: &mut VectorcraftApp| {
|
||
shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Circle(c) if c.radius == 3.0 && c.fill == Color32::WHITE)).count()
|
||
};
|
||
assert_eq!(widgets(&mut app), 4);
|
||
app.session.execute("prefs.set", &json!({"key": "hideCornerWidgetAbove", "value": 80})).unwrap();
|
||
assert_eq!(widgets(&mut app), 0);
|
||
}
|
||
|
||
/// View › Hide Edges hides a live rectangle's corner widgets, and the Selection tool then
|
||
/// neither shows the corner cursor over one's place nor rounds the corners from it. With
|
||
/// edges shown, a drag from the same widget rounds them.
|
||
#[test]
|
||
fn hidden_corner_widgets_do_not_answer_the_pointer() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||
let id = vectorcraft_doc::NodeId(id);
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
app.select_tool("selection");
|
||
let widget = |app: &VectorcraftApp| {
|
||
let st = app.session.active().unwrap();
|
||
vectorcraft_tools::corners::CornerWidgets::of(&st.doc, &st.selection, app.view_info().zoom, false).unwrap().visible().next().unwrap()
|
||
};
|
||
let radius = |app: &VectorcraftApp| {
|
||
let c = vectorcraft_doc::LiveCorners::of(app.session.active().unwrap().doc.node(id).unwrap()).unwrap();
|
||
c.style(&c.all()).0
|
||
};
|
||
let drag = |app: &mut VectorcraftApp, from: Point| {
|
||
let (view, to) = (app.view_info(), from + vectorcraft_geom::Vec2::new(10.0, 10.0));
|
||
for (kind, at) in [(PointerKind::Down, from), (PointerKind::Drag, to), (PointerKind::Up, to)] {
|
||
app.session.pointer(&PointerEvent::new(kind, at.x, at.y), view).unwrap();
|
||
}
|
||
};
|
||
app.ui.view.edges = false;
|
||
let at = widget(&app);
|
||
let view = app.view_info();
|
||
assert_ne!(app.session.cursor(at, Mods::default(), view), Cursor::CornerRadius);
|
||
drag(&mut app, at);
|
||
assert_eq!(radius(&app), Some(0.0), "a press on a hidden widget does not round the corners");
|
||
app.ui.view.edges = true;
|
||
let at = widget(&app);
|
||
let view = app.view_info();
|
||
assert_eq!(app.session.cursor(at, Mods::default(), view), Cursor::CornerRadius);
|
||
drag(&mut app, at);
|
||
assert!(radius(&app).is_some_and(|r| r > 0.0), "{:?}", radius(&app));
|
||
}
|
||
|
||
/// View › Hide Edges hides the bounding box and the type widget beside it, and the Selection
|
||
/// tool then answers neither: a double-click on the widget's place leaves point type as it is,
|
||
/// and a drag from a corner handle's place does not scale. With edges shown, both answer.
|
||
#[test]
|
||
fn a_hidden_bounding_box_and_type_widget_do_not_answer_the_pointer() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.session.execute("text.create", &json!({"x": 100, "y": 100, "text": "Type"})).unwrap()["id"].as_u64().unwrap();
|
||
let id = vectorcraft_doc::NodeId(id);
|
||
app.session.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
app.select_tool("selection");
|
||
let bx = |app: &VectorcraftApp| {
|
||
let st = app.session.active().unwrap();
|
||
st.doc.oriented_bounds(&st.selection.objects, app.session.prefs.use_preview_bounds).unwrap()
|
||
};
|
||
let widget = |app: &VectorcraftApp| {
|
||
let st = app.session.active().unwrap();
|
||
vectorcraft_tools::typewidget::TypeWidget::of(&st.doc, &st.selection, &bx(app), app.view_info().zoom).unwrap()
|
||
};
|
||
let area = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(id).unwrap().kind {
|
||
NodeKind::Text(t) => matches!(t.kind, vectorcraft_doc::TextKind::Area { .. }),
|
||
k => panic!("not type: {k:?}"),
|
||
};
|
||
let double_click = |app: &mut VectorcraftApp, at: Point| {
|
||
let view = app.view_info();
|
||
for kind in [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::Up, PointerKind::DoubleClick] {
|
||
app.session.pointer(&PointerEvent::new(kind, at.x, at.y), view).unwrap();
|
||
}
|
||
};
|
||
let width = |app: &VectorcraftApp| bx(app).rect.width();
|
||
let scale = |app: &mut VectorcraftApp| {
|
||
let b = bx(app);
|
||
let corner = b.to_doc() * Point::new(b.rect.x1, b.rect.y1);
|
||
let (view, to) = (app.view_info(), corner + vectorcraft_geom::Vec2::new(30.0, 0.0));
|
||
for (kind, at) in [(PointerKind::Down, corner), (PointerKind::Drag, to), (PointerKind::Up, to)] {
|
||
app.session.pointer(&PointerEvent::new(kind, at.x, at.y), view).unwrap();
|
||
}
|
||
app.session.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||
};
|
||
let w = widget(&app);
|
||
app.ui.view.edges = false;
|
||
let view = app.view_info();
|
||
assert_ne!(app.session.cursor(w.at, Mods::default(), view), Cursor::TypeWidget);
|
||
double_click(&mut app, w.at);
|
||
assert!(!area(&app), "a double-click on the hidden widget leaves point type");
|
||
app.session.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||
let before = width(&app);
|
||
scale(&mut app);
|
||
assert!((width(&app) - before).abs() < 1e-6, "a drag from a hidden handle does not scale: {before} -> {}", width(&app));
|
||
app.ui.view.edges = true;
|
||
let w = widget(&app);
|
||
let view = app.view_info();
|
||
assert_eq!(app.session.cursor(w.at, Mods::default(), view), Cursor::TypeWidget);
|
||
scale(&mut app);
|
||
assert!(width(&app) > before + 1.0, "a drag from the handle scales: {before} -> {}", width(&app));
|
||
let w = widget(&app);
|
||
double_click(&mut app, w.at);
|
||
assert!(area(&app), "a double-click on the widget converts to area type");
|
||
}
|
||
|
||
/// View → Show Live Paint Gaps draws the gaps of each selected Live Paint group in the group's
|
||
/// Gap Preview Color, and nothing for a group or path that is not a selected Live Paint group;
|
||
/// Hide Live Paint Gaps and Hide Edges hide them.
|
||
#[test]
|
||
fn show_live_paint_gaps_draws_the_selected_groups_gaps_in_their_preview_color() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
// Open paths round a square, each ending 2 pt short of its start: one Small Gap each.
|
||
let square = |app: &mut VectorcraftApp, x: i64| {
|
||
let anchors = [(0, 0), (100, 0), (100, 100), (0, 100), (0, 2)].map(|(dx, y)| json!({"x": x + dx, "y": y}));
|
||
app.session.execute("path.create", &json!({ "anchors": anchors })).unwrap()["id"].as_u64().unwrap()
|
||
};
|
||
let mut groups = vec![];
|
||
for (x, color) in [(0, "#123456"), (150, "#654321")] {
|
||
let p = square(&mut app, x);
|
||
app.session.execute("select.set", &json!({"ids": [p]})).unwrap();
|
||
let g = app.session.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||
app.session.execute("livePaint.gapOptions", &json!({"id": g, "color": color})).unwrap();
|
||
groups.push(g);
|
||
}
|
||
let plain = square(&mut app, 300);
|
||
let ctx = egui::Context::default();
|
||
let gaps = |app: &mut VectorcraftApp, rgb: [u8; 3]| {
|
||
let solid = egui::epaint::ColorMode::Solid(c32(rgb));
|
||
shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Path(ps) if ps.stroke.color == solid && ps.stroke.width == 2.0)).count()
|
||
};
|
||
let (first, second) = ([0x12, 0x34, 0x56], [0x65, 0x43, 0x21]);
|
||
// The color the plain path's gaps would be drawn in.
|
||
let plain_color = vectorcraft_doc::GapOptions::default().color;
|
||
app.session.execute("select.set", &json!({"ids": [groups[0], plain]})).unwrap();
|
||
assert_eq!((gaps(&mut app, first), gaps(&mut app, second)), (0, 0), "off until the item is chosen");
|
||
// View → Show Live Paint Gaps runs the command.
|
||
let tree = crate::menus::menu_tree();
|
||
let (_, view) = tree.iter().find(|(t, _)| *t == "View").unwrap();
|
||
assert!(view.iter().any(|i| matches!(i, crate::menus::Item::Cmd("Show Live Paint Gaps", "view.livePaintGaps", _))), "{view:?}");
|
||
crate::menus::invoke(&mut app, "view.livePaintGaps", json!({}));
|
||
assert!(app.ui.view.live_paint_gaps);
|
||
assert_eq!(
|
||
(gaps(&mut app, first), gaps(&mut app, second), gaps(&mut app, plain_color)),
|
||
(1, 0, 0),
|
||
"the selected group only; the plain path shows none"
|
||
);
|
||
app.session.execute("select.set", &json!({"ids": [groups[1]]})).unwrap();
|
||
assert_eq!((gaps(&mut app, first), gaps(&mut app, second)), (0, 1));
|
||
// The lines follow the group's Gap Preview Color and Paint Stops At size.
|
||
app.session.execute("livePaint.gapOptions", &json!({"id": groups[1], "color": "#123456"})).unwrap();
|
||
assert_eq!((gaps(&mut app, first), gaps(&mut app, second)), (1, 0));
|
||
app.session.execute("livePaint.gapOptions", &json!({"id": groups[1], "size": 1})).unwrap();
|
||
assert_eq!(gaps(&mut app, first), 0, "a 2 pt gap is wider than 1 pt");
|
||
app.session.execute("livePaint.gapOptions", &json!({"id": groups[1], "size": "small"})).unwrap();
|
||
app.ui.view.edges = false;
|
||
assert_eq!(gaps(&mut app, first), 0, "Hide Edges hides them");
|
||
app.ui.view.edges = true;
|
||
assert_eq!(gaps(&mut app, first), 1);
|
||
app.run("view.livePaintGaps", json!({})).unwrap();
|
||
assert!(!app.ui.view.live_paint_gaps);
|
||
assert_eq!(gaps(&mut app, first), 0, "View → Hide Live Paint Gaps");
|
||
// The setting is kept with the interface state; a state saved before it existed loads with it off.
|
||
app.ui.view.live_paint_gaps = true;
|
||
let mut saved = serde_json::to_value(&app.ui).unwrap();
|
||
assert_eq!(saved["view"]["live_paint_gaps"], json!(true));
|
||
saved["view"].as_object_mut().unwrap().remove("live_paint_gaps");
|
||
assert!(!serde_json::from_value::<crate::UiState>(saved).unwrap().view.live_paint_gaps);
|
||
}
|
||
|
||
/// #511: a star shows a widget in each of its ten corners with Direct Selection (none with
|
||
/// the Selection tool: it isn't a live shape); View → Hide Corner Widget hides them.
|
||
#[test]
|
||
fn a_star_shows_corner_widgets_with_direct_selection() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("shape.star", &json!({"cx": 200, "cy": 150, "radius1": 80, "radius2": 40})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
let widgets = |app: &mut VectorcraftApp| {
|
||
shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Circle(c) if c.radius == 3.0 && c.fill == Color32::WHITE)).count()
|
||
};
|
||
app.select_tool("selection");
|
||
assert_eq!(widgets(&mut app), 0);
|
||
app.select_tool("directSelection");
|
||
assert_eq!(widgets(&mut app), 10);
|
||
app.run("view.cornerWidget", json!({})).unwrap();
|
||
assert_eq!(widgets(&mut app), 0);
|
||
}
|
||
|
||
/// The type widget beside selected type's bounding box: hollow (white) on point type, filled
|
||
/// in the layer colour on area type.
|
||
#[test]
|
||
fn the_type_widget_shows_which_kind_the_type_is() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.session.execute("text.create", &json!({"x": 100, "y": 100, "text": "Type"})).unwrap()["id"].as_u64().unwrap();
|
||
app.session.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||
let layer = c32(app.session.active().unwrap().doc.layer_color(vectorcraft_doc::NodeId(id)));
|
||
let ctx = egui::Context::default();
|
||
let widget = |app: &mut VectorcraftApp| -> Vec<Color32> {
|
||
let r = vectorcraft_tools::typewidget::RADIUS_PX;
|
||
shapes(app, &ctx)
|
||
.iter()
|
||
.filter_map(|s| if let Shape::Circle(c) = s { (c.radius == r && c.fill != Color32::TRANSPARENT).then_some(c.fill) } else { None })
|
||
.collect()
|
||
};
|
||
assert_eq!(widget(&mut app), vec![Color32::WHITE]);
|
||
app.session.execute("type.convertToAreaType", &json!({})).unwrap();
|
||
assert_eq!(widget(&mut app), vec![layer]);
|
||
app.ui.view.bounding_box = false;
|
||
assert!(widget(&mut app).is_empty(), "no bounding box, no widget");
|
||
}
|
||
|
||
/// A selected live ellipse shows its two pie widgets with the Selection tool while the
|
||
/// bounding box shows.
|
||
#[test]
|
||
fn a_selected_ellipse_shows_its_pie_widgets() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("shape.ellipse", &json!({"x": 100, "y": 100, "width": 200, "height": 100})).unwrap();
|
||
app.session.execute("object.setLiveShape", &json!({"pieStart": 0, "pieEnd": 90})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
let widgets = |app: &mut VectorcraftApp| {
|
||
let r = vectorcraft_tools::pie::RADIUS_PX;
|
||
shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Circle(c) if c.radius == r && c.fill == Color32::WHITE)).count()
|
||
};
|
||
app.select_tool("selection");
|
||
assert_eq!(widgets(&mut app), 2);
|
||
app.select_tool("directSelection");
|
||
assert_eq!(widgets(&mut app), 0);
|
||
app.select_tool("selection");
|
||
app.run("view.boundingBox", json!({})).unwrap();
|
||
assert_eq!(widgets(&mut app), 0, "no bounding box, no widgets");
|
||
}
|
||
|
||
/// The shapes Type → Show Hidden Characters draws in one frame, seen through `xf`.
|
||
fn hidden_chars(app: &mut VectorcraftApp, ctx: &egui::Context, xf: &Xf) -> Vec<Shape> {
|
||
let raw = egui::RawInput { screen_rect: Some(xf.rect), ..Default::default() };
|
||
let mut out = ctx.run_ui(raw, |ui| hidden_chars_overlay(app, ui.painter(), xf));
|
||
out.textures_delta.clear();
|
||
out.shapes.into_iter().map(|c| c.shape).collect()
|
||
}
|
||
|
||
/// Show Hidden Characters lays out type once: a frame keeps the marks of the frame before, and
|
||
/// after an edit only the object edited is laid out again.
|
||
#[test]
|
||
fn show_hidden_characters_lays_out_only_type_that_changed() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let ids: Vec<_> = (0..3)
|
||
.map(|i| app.session.execute("text.create", &json!({"x": 20, "y": 40 + 80 * i, "text": "a b\nc d"})).unwrap()["id"].as_u64().unwrap())
|
||
.map(vectorcraft_doc::NodeId)
|
||
.collect();
|
||
let ctx = egui::Context::default();
|
||
let xf = Xf { rect: egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)), zoom: 1.0, center: Point::new(200.0, 150.0), rot: 0.0 };
|
||
// `frame` draws one frame and returns each object's marks and whether that frame found the
|
||
// fonts and hyphenation exceptions of the frame before.
|
||
let frame = |app: &mut VectorcraftApp| {
|
||
let inputs = app.canvas.hidden_chars.inputs.clone();
|
||
hidden_chars(app, &ctx, &xf);
|
||
let kept = &app.canvas.hidden_chars;
|
||
(kept.inputs == inputs, ids.iter().map(|id| kept.marks[id].1.clone()).collect::<Vec<_>>())
|
||
};
|
||
// `frames` draws a frame, runs `edit` and draws the next frame. Tests running alongside add
|
||
// fonts and set hyphenation exceptions, which lays all type out again: `frames` draws both
|
||
// frames again until neither the fonts nor the exceptions changed in between.
|
||
let frames = |app: &mut VectorcraftApp, edit: &dyn Fn(&mut VectorcraftApp)| {
|
||
(0..100)
|
||
.find_map(|_| {
|
||
let (_, before) = frame(app);
|
||
edit(app);
|
||
let (same, after) = frame(app);
|
||
same.then_some((before, after))
|
||
})
|
||
.unwrap()
|
||
};
|
||
let same = |a: &std::sync::Arc<HiddenMarks>, b: &std::sync::Arc<HiddenMarks>| std::sync::Arc::ptr_eq(a, b);
|
||
let (before, after) = frames(&mut app, &|_| {});
|
||
assert!(before.iter().zip(&after).all(|(a, b)| same(a, b)), "a frame lays out no type");
|
||
let (before, after) = frames(&mut app, &|app| {
|
||
app.session.execute("text.setText", &json!({"id": ids[1].0, "text": "a b c"})).unwrap();
|
||
});
|
||
assert!(!same(&before[1], &after[1]), "the edited object is laid out again");
|
||
assert!(same(&before[0], &after[0]) && same(&before[2], &after[2]), "the others are not");
|
||
}
|
||
|
||
/// The marks Show Hidden Characters keeps follow the type: after an edit, a zoom and a font
|
||
/// that arrives, a frame draws what a new overlay draws, and the marks moved.
|
||
#[test]
|
||
fn show_hidden_characters_follows_edits_zooms_and_fonts() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
// The type is set in Bold of a family that is not installed and whose name starts with a
|
||
// dot; the font menus do not list such names. Source Sans 3 Bold stands in for it until the
|
||
// family arrives below with a Regular face only.
|
||
let text = json!({"x": 20, "y": 60, "text": "one two\nthree four", "font": ".Hidden Marks", "style": "Bold"});
|
||
let id = app.session.execute("text.create", &text).unwrap()["id"].clone();
|
||
let ctx = egui::Context::default();
|
||
let mut xf = Xf { rect: egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)), zoom: 1.0, center: Point::new(200.0, 150.0), rot: 0.0 };
|
||
// `drawn` draws a frame with what the overlay kept and checks it against a new overlay.
|
||
let drawn = |app: &mut VectorcraftApp, xf: &Xf| {
|
||
let kept = hidden_chars(app, &ctx, xf);
|
||
app.canvas.hidden_chars = Default::default();
|
||
assert_eq!(hidden_chars(app, &ctx, xf), kept, "as a new overlay draws them");
|
||
kept
|
||
};
|
||
let start = drawn(&mut app, &xf);
|
||
assert!(start.len() > 2, "{start:?}");
|
||
app.session.execute("text.setText", &json!({"id": id, "text": "one two three\nfour"})).unwrap();
|
||
let edited = drawn(&mut app, &xf);
|
||
assert_ne!(edited, start, "the edit moved the marks");
|
||
xf.zoom = 2.0;
|
||
let zoomed = drawn(&mut app, &xf);
|
||
assert_ne!(zoomed, edited, "zoomed in");
|
||
assert!(vectorcraft_text::FontDb::global().add_font(vectorcraft_testkit::fonts::renamed(".Hidden Marks")) > 0, "the family is new");
|
||
assert_ne!(drawn(&mut app, &xf), zoomed, "set in the family that arrived");
|
||
}
|
||
|
||
/// In a rotated view Show Hidden Characters draws the marks of type whose turned outline reaches
|
||
/// into the canvas, also when the box between two of its corners on screen does not.
|
||
#[test]
|
||
fn show_hidden_characters_draws_type_partly_on_a_rotated_canvas() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.session.execute("text.create", &json!({"x": 20, "y": 60, "text": "a b c d e\nf g h"})).unwrap()["id"].as_u64().unwrap();
|
||
let b = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap();
|
||
let ctx = egui::Context::default();
|
||
let mut xf = Xf { rect: egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)), zoom: 2.0, center: b.center(), rot: 45f64.to_radians() };
|
||
// The screen moves so that the canvas's left edge falls halfway between the right side of
|
||
// the box and the outline's rightmost corner, level with that corner.
|
||
let q = xf.quad(b);
|
||
let right = q.iter().copied().max_by(|a, c| a.x.total_cmp(&c.x)).unwrap();
|
||
let shift = vec2(-(xf.rect_to_screen(b).right() + right.x) / 2.0, xf.rect.center().y - right.y);
|
||
xf.center -= xf.delta_to_doc(shift);
|
||
assert!(!xf.rect_to_screen(b).intersects(xf.rect), "the box is off the canvas");
|
||
assert!(xf.quad(b).iter().any(|c| xf.rect.contains(*c)), "a corner is on it");
|
||
assert!(!hidden_chars(&mut app, &ctx, &xf).is_empty(), "the marks are drawn");
|
||
}
|
||
|
||
/// The shapes Type → Show Hidden Characters draws in one canvas frame ([`show`]).
|
||
fn hidden_char_marks(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec<Shape> {
|
||
let blue = Color32::from_rgb(0x4f, 0x9d, 0xff);
|
||
shapes(app, ctx)
|
||
.into_iter()
|
||
.filter(|s| matches!(s, Shape::Circle(c) if c.fill == blue) || matches!(s, Shape::Text(t) if t.fallback_color == blue))
|
||
.collect()
|
||
}
|
||
|
||
/// Show Hidden Characters puts a dot over each space of a list paragraph and none over its
|
||
/// bullet, which stands for no character.
|
||
#[test]
|
||
fn hidden_characters_mark_no_space_at_a_bullet() {
|
||
let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, " Item two", vectorcraft_doc::CharStyle::default());
|
||
let plain = HiddenMarks::of(&t).spaces.len();
|
||
t.para.list = Some(vectorcraft_doc::text::ParaList::bulleted(vectorcraft_doc::text::Bullet::Dot));
|
||
assert_eq!((plain, HiddenMarks::of(&t).spaces.len()), (2, 2));
|
||
}
|
||
|
||
/// While Show Hidden Characters is on, a canvas frame keeps the marks of the frame before.
|
||
/// Turning it off drops the marks with their text nodes, and turning it on again draws the
|
||
/// same marks.
|
||
#[test]
|
||
fn show_hidden_characters_off_releases_the_text_nodes() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let text = json!({"x": 20, "y": 60, "text": "one two\nthree four"});
|
||
let id = vectorcraft_doc::NodeId(app.session.execute("text.create", &text).unwrap()["id"].as_u64().unwrap());
|
||
let ctx = egui::Context::default();
|
||
app.run("type.hiddenCharacters", json!({})).unwrap();
|
||
let on = hidden_char_marks(&mut app, &ctx);
|
||
assert!(on.len() > 2, "{on:?}");
|
||
// Tests running alongside add fonts and set hyphenation exceptions, which lays all type out
|
||
// again: frames are drawn until neither changed between two of them.
|
||
let kept = (0..100).any(|_| {
|
||
let (inputs, marks) = (app.canvas.hidden_chars.inputs.clone(), app.canvas.hidden_chars.marks[&id].1.clone());
|
||
hidden_char_marks(&mut app, &ctx);
|
||
let after = &app.canvas.hidden_chars;
|
||
after.inputs == inputs && std::sync::Arc::ptr_eq(&after.marks[&id].1, &marks)
|
||
});
|
||
assert!(kept, "a frame keeps the marks of the frame before");
|
||
app.run("type.hiddenCharacters", json!({})).unwrap();
|
||
assert!(hidden_char_marks(&mut app, &ctx).is_empty(), "off");
|
||
assert!(app.canvas.hidden_chars.marks.is_empty(), "no node is kept");
|
||
app.run("type.hiddenCharacters", json!({})).unwrap();
|
||
assert_eq!(hidden_char_marks(&mut app, &ctx), on, "on again: the same marks");
|
||
}
|
||
|
||
/// Closing the document drops the marks Show Hidden Characters kept with its text nodes, and
|
||
/// the same type in a new document shows the same marks.
|
||
#[test]
|
||
fn show_hidden_characters_releases_the_text_nodes_of_a_closed_document() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
let ctx = egui::Context::default();
|
||
crate::theme::install_fonts(&ctx);
|
||
app.run("type.hiddenCharacters", json!({})).unwrap();
|
||
let open = |app: &mut VectorcraftApp| {
|
||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("text.create", &json!({"x": 20, "y": 60, "text": "one two\nthree four"})).unwrap();
|
||
hidden_char_marks(app, &ctx)
|
||
};
|
||
let first = open(&mut app);
|
||
assert!(first.len() > 2, "{first:?}");
|
||
assert_eq!(app.canvas.hidden_chars.marks.len(), 1, "the marks are kept with the text object's node");
|
||
app.session.execute("file.close", &json!({})).unwrap();
|
||
assert!(hidden_char_marks(&mut app, &ctx).is_empty(), "the Home screen");
|
||
assert!(app.canvas.hidden_chars.marks.is_empty(), "no node of the closed document is kept");
|
||
assert_eq!(open(&mut app), first, "the same type in a new document");
|
||
}
|
||
|
||
/// General › Zoom with Mouse Wheel (#394), through the control channel's `ui.wheel`: off,
|
||
/// the wheel scrolls and Cmd-wheel zooms; on, the wheel zooms about the pointer, Shift-wheel
|
||
/// scrolls up and down and Cmd/Ctrl-wheel sideways. Alt-wheel (Option on the Mac) and a
|
||
/// trackpad pinch zoom about the pointer either way.
|
||
#[test]
|
||
fn zoom_with_mouse_wheel_preference() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
// A canvas frame through the host's input hook (it feeds the control channel's input).
|
||
let run = |app: &mut VectorcraftApp| {
|
||
let mut raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() };
|
||
app.raw_input_hook(&ctx, &mut raw);
|
||
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
|
||
out.textures_delta.clear();
|
||
};
|
||
run(&mut app);
|
||
let rect = app.canvas_rect.unwrap();
|
||
let at = rect.center() + vec2(60.0, 40.0);
|
||
// One notch up at `at` with `mods` held, or a pinch: → (zoom ratio, scroll in points, how
|
||
// far the point under `at` moved).
|
||
let turn = |app: &mut VectorcraftApp, mods: &str| {
|
||
let before = *app.view().unwrap();
|
||
if mods == "pinch" {
|
||
app.synthetic.extend([egui::Event::PointerMoved(at), egui::Event::Zoom(1.25)]);
|
||
} else {
|
||
let mut p = json!({"x": at.x, "y": at.y, "dy": 1});
|
||
for m in mods.split('+').filter(|m| !m.is_empty()) {
|
||
p[m] = json!(true);
|
||
}
|
||
crate::tests_synthetic::control(app, &ctx, "ui.wheel", p);
|
||
}
|
||
// egui spreads a notch over a few frames.
|
||
for _ in 0..40 {
|
||
run(app);
|
||
}
|
||
let after = *app.view().unwrap();
|
||
let moved = Xf::new(rect, &after).to_doc(at).distance(Xf::new(rect, &before).to_doc(at));
|
||
(after.zoom / before.zoom, (after.center - before.center) * after.zoom, moved)
|
||
};
|
||
let zooms_in = |(zoom, _, moved): (f64, _, f64)| zoom > 1.01 && moved < 1e-6;
|
||
let (zoom, scroll, _) = turn(&mut app, "");
|
||
assert!((zoom - 1.0).abs() < 1e-9 && scroll.x == 0.0 && scroll.y < -1.0, "off: the wheel scrolls up ({zoom}, {scroll:?})");
|
||
assert!(zooms_in(turn(&mut app, "cmd")), "off: Cmd-wheel zooms in about the pointer");
|
||
assert!(zooms_in(turn(&mut app, "alt")), "off: Alt-wheel zooms in about the pointer");
|
||
assert!(zooms_in(turn(&mut app, "pinch")), "off: a pinch zooms in about the pointer");
|
||
app.session.execute("prefs.set", &json!({"key": "zoomWithMouseWheel", "value": true})).unwrap();
|
||
assert!(zooms_in(turn(&mut app, "")), "on: the wheel zooms in about the pointer");
|
||
assert!(zooms_in(turn(&mut app, "alt")), "on: so does Alt-wheel");
|
||
assert!(zooms_in(turn(&mut app, "pinch")), "on: and a pinch");
|
||
let (zoom, scroll, _) = turn(&mut app, "shift");
|
||
assert!((zoom - 1.0).abs() < 1e-9 && scroll.x == 0.0 && scroll.y < -1.0, "on: Shift-wheel scrolls up ({zoom}, {scroll:?})");
|
||
let (zoom, scroll, _) = turn(&mut app, "cmd");
|
||
assert!((zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0, "on: Cmd-wheel scrolls sideways ({zoom}, {scroll:?})");
|
||
}
|
||
|
||
/// #888: with Zoom with Mouse Wheel, a wheel notch's zoom glides in over a few frames, as a
|
||
/// scroll does, instead of jumping on the frame it arrives; it ends at the same zoom.
|
||
#[test]
|
||
fn wheel_notches_zoom_in_a_glide() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("prefs.set", &json!({"key": "zoomWithMouseWheel", "value": true})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
let run = |app: &mut VectorcraftApp| {
|
||
let mut raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() };
|
||
app.raw_input_hook(&ctx, &mut raw);
|
||
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
|
||
out.textures_delta.clear();
|
||
};
|
||
run(&mut app);
|
||
let at = app.canvas_rect.unwrap().center();
|
||
let before = app.view().unwrap().zoom;
|
||
crate::tests_synthetic::control(&mut app, &ctx, "ui.wheel", json!({"x": at.x, "y": at.y, "dy": 1}));
|
||
// The first frame the zoom moves in (the event may land a frame later).
|
||
let mut first = 1.0;
|
||
for _ in 0..3 {
|
||
run(&mut app);
|
||
first = app.view().unwrap().zoom / before;
|
||
if first != 1.0 {
|
||
break;
|
||
}
|
||
}
|
||
for _ in 0..60 {
|
||
run(&mut app);
|
||
}
|
||
let done = app.view().unwrap().zoom / before;
|
||
assert!(done > 1.01, "the notch zooms in ({done})");
|
||
assert!(first > 1.0 && first < done, "the first frame takes only part of it ({first} of {done})");
|
||
run(&mut app);
|
||
assert_eq!(app.view().unwrap().zoom / before, done, "and it stops");
|
||
}
|
||
|
||
/// In each frame where a wheel scroll moves the view, the hover point (read by the rulers'
|
||
/// pointer marks, the Info panel, the hover highlight and the tool cursor) is the document point
|
||
/// under the pointer in the view the canvas draws in that frame.
|
||
#[test]
|
||
fn the_hover_point_follows_a_wheel_scroll_in_the_frame_it_moves() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let rect = app.canvas_rect.unwrap();
|
||
let at = rect.center() + vec2(60.0, 40.0);
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at)]);
|
||
let under = |app: &VectorcraftApp| Xf::new(rect, app.view().unwrap()).to_doc(at);
|
||
let wheel = egui::Event::MouseWheel {
|
||
unit: egui::MouseWheelUnit::Point,
|
||
delta: vec2(0.0, -60.0),
|
||
phase: egui::TouchPhase::Move,
|
||
modifiers: Default::default(),
|
||
source: egui::MouseWheelSource::Unknown,
|
||
};
|
||
let mut events = vec![wheel];
|
||
let mut moved = 0;
|
||
for k in 0..30 {
|
||
let before = app.view().unwrap().center;
|
||
frame(&mut app, &ctx, std::mem::take(&mut events));
|
||
moved += usize::from(app.view().unwrap().center != before);
|
||
let hover = app.hover_doc.unwrap();
|
||
assert!(hover.distance(under(&app)) < 1e-9, "frame {k}: {hover:?}, under the pointer {:?}", under(&app));
|
||
}
|
||
assert!(moved > 0, "the wheel scrolls the view");
|
||
}
|
||
|
||
/// While a middle-button pan or a Rotate View drag moves the view, the hover point is the
|
||
/// document point under the pointer in the view the canvas draws in that frame.
|
||
#[test]
|
||
fn the_hover_point_follows_a_view_drag_in_the_frame_it_moves() {
|
||
for (tool, button) in [("rectangle", egui::PointerButton::Middle), ("rotateView", egui::PointerButton::Primary)] {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.select_tool(tool);
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let rect = app.canvas_rect.unwrap();
|
||
let press = |pos, pressed| egui::Event::PointerButton { pos, button, pressed, modifiers: Default::default() };
|
||
let a = rect.center() + vec2(100.0, 20.0);
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(a)]);
|
||
frame(&mut app, &ctx, vec![press(a, true)]);
|
||
let mut p = a;
|
||
for k in 1..=3 {
|
||
p = a + vec2(-20.0, 30.0) * k as f32;
|
||
let before = *app.view().unwrap();
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p)]);
|
||
let after = *app.view().unwrap();
|
||
assert!(after.center != before.center || after.rotation != before.rotation, "{tool}: the drag moves the view");
|
||
let under = Xf::new(rect, &after).to_doc(p);
|
||
let hover = app.hover_doc.unwrap();
|
||
assert!(hover.distance(under) < 1e-9, "{tool}, step {k}: {hover:?}, under the pointer {under:?}");
|
||
}
|
||
frame(&mut app, &ctx, vec![press(p, false)]);
|
||
}
|
||
}
|
||
|
||
/// A tool drag goes on at the document point under the pointer in the view a wheel scroll moved
|
||
/// to, in the frame the wheel scrolls.
|
||
#[test]
|
||
fn a_tool_drag_follows_a_wheel_scroll_in_the_frame_it_moves() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
// With Zoom with Mouse Wheel on, Shift-wheel scrolls up and down in the frame it comes in.
|
||
app.session.execute("prefs.set", &json!({"key": "zoomWithMouseWheel", "value": true})).unwrap();
|
||
app.ui.view.smart_guides = false;
|
||
app.select_tool("rectangle");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let rect = app.canvas_rect.unwrap();
|
||
let (a, b) = (rect.center() - vec2(80.0, 60.0), rect.center() + vec2(40.0, 30.0));
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(a)]);
|
||
frame(&mut app, &ctx, vec![primary(a, true)]);
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(b)]);
|
||
let before = *app.view().unwrap();
|
||
let wheel = egui::Event::MouseWheel {
|
||
unit: egui::MouseWheelUnit::Point,
|
||
delta: vec2(0.0, -40.0),
|
||
phase: egui::TouchPhase::Move,
|
||
modifiers: egui::Modifiers::SHIFT,
|
||
source: egui::MouseWheelSource::Unknown,
|
||
};
|
||
frame(&mut app, &ctx, vec![wheel]);
|
||
let after = *app.view().unwrap();
|
||
assert!(after.center != before.center, "Shift-wheel scrolls the view");
|
||
let want = Xf::new(rect, &after).to_doc(b);
|
||
let bounds = |app: &VectorcraftApp| app.session.active().unwrap().doc.layers[0].children().unwrap()[0].geometric_bounds().unwrap();
|
||
let r = bounds(&app);
|
||
assert!((r.x1 - want.x).abs() < 1e-6 && ((r.y0 - want.y).abs() < 1e-6 || (r.y1 - want.y).abs() < 1e-6), "{r:?} reaches {want:?}");
|
||
frame(&mut app, &ctx, vec![primary(b, false)]);
|
||
assert_eq!(bounds(&app), r, "the rectangle drawn");
|
||
}
|
||
|
||
/// Two fingers dragged together on a touch screen pan the canvas with them, under either
|
||
/// Zoom with Mouse Wheel setting, and draw nothing (#449).
|
||
#[test]
|
||
fn two_fingers_dragged_together_pan_the_canvas() {
|
||
for wheel_zooms in [false, true] {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("prefs.set", &json!({"key": "zoomWithMouseWheel", "value": wheel_zooms})).unwrap();
|
||
app.select_tool("rectangle");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let c = app.canvas_rect.unwrap().center();
|
||
let touch = |n: u64, phase, pos| egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(n), phase, pos, force: None };
|
||
let fingers = |phase, d: egui::Vec2| vec![touch(1, phase, c - vec2(40.0, 0.0) + d), touch(2, phase, c + vec2(40.0, 0.0) + d)];
|
||
let before = *app.view().unwrap();
|
||
// egui-winit moves the pointer with the first finger, as here.
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(c - vec2(40.0, 0.0))]);
|
||
frame(&mut app, &ctx, fingers(egui::TouchPhase::Start, Vec2::ZERO));
|
||
for k in 1..=3 {
|
||
frame(&mut app, &ctx, fingers(egui::TouchPhase::Move, vec2(10.0, 8.0) * k as f32));
|
||
}
|
||
frame(&mut app, &ctx, fingers(egui::TouchPhase::End, vec2(30.0, 24.0)));
|
||
let after = *app.view().unwrap();
|
||
let moved = (after.center - before.center) * after.zoom;
|
||
assert!((after.zoom / before.zoom - 1.0).abs() < 1e-9, "no zoom: {wheel_zooms}");
|
||
assert!((moved.x + 30.0).abs() < 0.5 && (moved.y + 24.0).abs() < 0.5, "the content follows the fingers ({wheel_zooms}): {moved:?}");
|
||
assert_eq!(app.session.active().unwrap().doc.layers[0].children().unwrap().len(), 0, "nothing drawn");
|
||
}
|
||
}
|
||
|
||
/// A canvas frame at input time `t` (seconds) with `events`, for the trackpad rotation tests.
|
||
fn timed_frame(app: &mut VectorcraftApp, ctx: &egui::Context, t: f64, events: Vec<egui::Event>) {
|
||
let raw = egui::RawInput {
|
||
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))),
|
||
time: Some(t),
|
||
events,
|
||
..Default::default()
|
||
};
|
||
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
|
||
out.textures_delta.clear();
|
||
}
|
||
|
||
/// General › Trackpad Gesture to Rotate View, on by default: a two-finger rotation on a Mac
|
||
/// trackpad (egui's `Event::Rotate`, clockwise radians) turns the view about the pointer, also
|
||
/// in the frame of a pinch, which zooms about the same point. With the preference off, the view
|
||
/// does not turn.
|
||
#[test]
|
||
fn a_trackpad_rotation_turns_the_view_about_the_pointer() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
timed_frame(&mut app, &ctx, 0.0, vec![]);
|
||
let rect = app.canvas_rect.unwrap();
|
||
let at = rect.center() + vec2(60.0, 40.0);
|
||
timed_frame(&mut app, &ctx, 0.1, vec![egui::Event::PointerMoved(at)]);
|
||
let under = |app: &VectorcraftApp| Xf::new(rect, app.view().unwrap()).to_doc(at);
|
||
let before = (*app.view().unwrap(), under(&app));
|
||
timed_frame(&mut app, &ctx, 0.2, vec![egui::Event::Rotate(0.25)]);
|
||
let after = *app.view().unwrap();
|
||
assert!((after.rotation - before.0.rotation - 0.25f64.to_degrees()).abs() < 1e-4, "turned by 14.32°: {}", after.rotation);
|
||
assert!((after.zoom - before.0.zoom).abs() < 1e-12, "no zoom");
|
||
assert!(under(&app).distance(before.1) < 1e-6, "the point under the pointer stays: {:?}", under(&app));
|
||
// A pinch and a rotation in one frame, in a new gesture.
|
||
let before = (*app.view().unwrap(), under(&app));
|
||
timed_frame(&mut app, &ctx, 1.0, vec![egui::Event::Zoom(1.25), egui::Event::Rotate(0.25)]);
|
||
let after = *app.view().unwrap();
|
||
assert!((after.zoom / before.0.zoom - 1.25).abs() < 1e-6, "the pinch zooms: {}", after.zoom / before.0.zoom);
|
||
assert!((after.rotation - before.0.rotation - 0.25f64.to_degrees()).abs() < 1e-4, "and the rotation turns: {}", after.rotation);
|
||
assert!(under(&app).distance(before.1) < 1e-6, "about the same point: {:?}", under(&app));
|
||
app.session.execute("prefs.set", &json!({"key": "trackpadRotateView", "value": false})).unwrap();
|
||
let before = *app.view().unwrap();
|
||
timed_frame(&mut app, &ctx, 2.0, vec![egui::Event::Rotate(0.25)]);
|
||
timed_frame(&mut app, &ctx, 2.1, vec![egui::Event::Rotate(0.25)]);
|
||
let after = *app.view().unwrap();
|
||
assert!(after.rotation == before.rotation && after.center == before.center, "off: the view does not turn ({})", after.rotation);
|
||
}
|
||
|
||
/// A trackpad rotation turns the view only once it reaches 5°, so a pinch that twists a little
|
||
/// only zooms. It then turns the view by all of it and follows each later rotation event, until
|
||
/// a quarter of a second passes without one.
|
||
#[test]
|
||
fn a_trackpad_rotation_turns_nothing_until_it_reaches_five_degrees() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
timed_frame(&mut app, &ctx, 0.0, vec![]);
|
||
let at = app.canvas_rect.unwrap().center() + vec2(60.0, 40.0);
|
||
timed_frame(&mut app, &ctx, 0.1, vec![egui::Event::PointerMoved(at)]);
|
||
let rotate = |deg: f32| egui::Event::Rotate(deg.to_radians());
|
||
let zoom = app.view().unwrap().zoom;
|
||
timed_frame(&mut app, &ctx, 1.0, vec![egui::Event::Zoom(1.25), rotate(1.0)]);
|
||
assert!(app.view().unwrap().zoom > zoom * 1.2, "the pinch zooms");
|
||
timed_frame(&mut app, &ctx, 1.1, vec![egui::Event::Zoom(1.25), rotate(1.0)]);
|
||
timed_frame(&mut app, &ctx, 1.2, vec![egui::Event::Zoom(1.25), rotate(1.0)]);
|
||
assert_eq!(app.view().unwrap().rotation, 0.0, "3° in all does not turn the view");
|
||
timed_frame(&mut app, &ctx, 1.3, vec![rotate(3.0)]);
|
||
assert!((app.view().unwrap().rotation - 6.0).abs() < 1e-4, "6° in all turns by 6°: {}", app.view().unwrap().rotation);
|
||
timed_frame(&mut app, &ctx, 1.4, vec![rotate(-1.0)]);
|
||
assert!((app.view().unwrap().rotation - 5.0).abs() < 1e-4, "then each event turns it: {}", app.view().unwrap().rotation);
|
||
timed_frame(&mut app, &ctx, 2.0, vec![rotate(3.0)]);
|
||
assert!((app.view().unwrap().rotation - 5.0).abs() < 1e-4, "a new rotation starts under 5° again: {}", app.view().unwrap().rotation);
|
||
}
|
||
|
||
/// Rotation events that come while two fingers are on a touch screen (Safari on an iPad sends
|
||
/// them with the touches) do not turn the view.
|
||
#[test]
|
||
fn a_rotation_with_fingers_on_a_touch_screen_turns_nothing() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
timed_frame(&mut app, &ctx, 0.0, vec![]);
|
||
let c = app.canvas_rect.unwrap().center();
|
||
let touch = |n: u64, phase, pos| egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(n), phase, pos, force: None };
|
||
timed_frame(&mut app, &ctx, 0.1, vec![egui::Event::PointerMoved(c - vec2(40.0, 0.0))]);
|
||
let before = app.view().unwrap().rotation;
|
||
let start = vec![touch(1, egui::TouchPhase::Start, c - vec2(40.0, 0.0)), touch(2, egui::TouchPhase::Start, c + vec2(40.0, 0.0))];
|
||
timed_frame(&mut app, &ctx, 0.2, [start, vec![egui::Event::Rotate(0.25)]].concat());
|
||
assert_eq!(app.view().unwrap().rotation, before, "the view does not turn");
|
||
}
|
||
|
||
/// General › Display Print Size at 100% Zoom (#394): `view.actualSize` is one document point per
|
||
/// screen point with it off, and 96 screen points per document inch with it on.
|
||
#[test]
|
||
fn view_actual_size_follows_display_print_size() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
|
||
app.canvas_rect = Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(460.0, 260.0)));
|
||
app.run("view.setZoom", json!({"zoom": 250})).unwrap();
|
||
app.run("view.actualSize", json!({})).unwrap();
|
||
assert!((app.view().unwrap().zoom - 1.0).abs() < 1e-12, "off by default: 100% is 1:1");
|
||
app.session.execute("prefs.set", &json!({"key": "displayPrintSize", "value": true})).unwrap();
|
||
app.run("view.actualSize", json!({})).unwrap();
|
||
assert!((app.view().unwrap().zoom - 96.0 / 72.0).abs() < 1e-12, "an inch is 96 screen points");
|
||
}
|
||
|
||
/// Fit Artboard in Window fits the artboard as a rotated view shows it: at 45° every corner is
|
||
/// on the canvas and one is within the 30-point margin of an edge. At 0° the zoom is unchanged.
|
||
#[test]
|
||
fn fit_artboard_fits_the_artboard_turned_with_the_view() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 612, "height": 792})).unwrap();
|
||
let rect = egui::Rect::from_min_size(Pos2::ZERO, vec2(1000.0, 800.0));
|
||
app.canvas_rect = Some(rect);
|
||
app.run("view.fitArtboard", json!({})).unwrap();
|
||
assert!((app.view().unwrap().zoom - (940.0 / 612.0_f64).min(740.0 / 792.0)).abs() < 1e-12, "0°");
|
||
app.run("view.rotate", json!({"angle": 45})).unwrap();
|
||
app.run("view.fitArtboard", json!({})).unwrap();
|
||
let board = app.session.active().unwrap().doc.artboards[0].rect;
|
||
let q = Xf::new(rect, app.view().unwrap()).quad(board);
|
||
assert!(q.iter().all(|c| rect.contains(*c)), "the turned artboard is on the canvas: {q:?}");
|
||
let gap = q
|
||
.iter()
|
||
.map(|c| (c.x - rect.left()).min(rect.right() - c.x).min(c.y - rect.top()).min(rect.bottom() - c.y))
|
||
.fold(f32::INFINITY, f32::min);
|
||
assert!(gap < 31.0, "and fills it but for the margin: {gap}");
|
||
}
|
||
|
||
/// Performance › Animated Zoom (#394): the Zoom tool dragged sideways zooms about where it was
|
||
/// pressed (right in, left out), held still it zooms on, and a click still steps. Off, or with
|
||
/// GPU Performance off, a drag zooms to the area dragged across.
|
||
#[test]
|
||
fn animated_zoom_scrubs_and_holds_else_the_zoom_tool_zooms_to_an_area() {
|
||
use egui::Event;
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.select_tool("zoom");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let rect = app.canvas_rect.unwrap();
|
||
let at = rect.center() - vec2(100.0, 50.0);
|
||
let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
// Press at `at`, move by `by` and hold for `frames` frames (a 60th of a second each), let go.
|
||
let press = |app: &mut VectorcraftApp, by: egui::Vec2, frames: usize| {
|
||
frame(app, &ctx, vec![Event::PointerMoved(at)]);
|
||
frame(app, &ctx, vec![button(at, true)]);
|
||
frame(app, &ctx, vec![Event::PointerMoved(at + by)]);
|
||
for _ in 0..frames {
|
||
frame(app, &ctx, vec![]);
|
||
}
|
||
frame(app, &ctx, vec![button(at + by, false)]);
|
||
};
|
||
let doc_at = |app: &VectorcraftApp, p: Pos2| Xf::new(rect, app.view().unwrap()).to_doc(p);
|
||
let pinned = doc_at(&app, at);
|
||
let z0 = app.view().unwrap().zoom;
|
||
press(&mut app, vec2(100.0, 0.0), 0);
|
||
let z1 = app.view().unwrap().zoom;
|
||
assert!((z1 / z0 - 2.0).abs() < 1e-6, "100 points right doubles the zoom: {z0} → {z1}");
|
||
assert!(doc_at(&app, at).distance(pinned) < 1e-6, "about the press");
|
||
press(&mut app, vec2(-100.0, 0.0), 0);
|
||
assert!((app.view().unwrap().zoom - z0).abs() < 1e-6, "and back to the left");
|
||
press(&mut app, vec2(0.0, 0.0), 0);
|
||
assert_eq!(app.view().unwrap().zoom, crate::state::next_zoom(z0, true), "a click steps");
|
||
let z2 = app.view().unwrap().zoom;
|
||
press(&mut app, vec2(0.0, 0.0), 40);
|
||
assert!(app.view().unwrap().zoom > z2 * 1.2, "held, it zooms on");
|
||
assert!(doc_at(&app, at).distance(pinned) < 1e-6, "about the press");
|
||
// Off (or without GPU Performance): the area dragged across fills the view.
|
||
for key in ["animatedZoom", "gpuPerformance"] {
|
||
app.run("prefs.reset", json!({})).unwrap();
|
||
app.run("prefs.set", json!({"key": key, "value": false})).unwrap();
|
||
app.view_mut().unwrap().zoom = z0;
|
||
press(&mut app, vec2(100.0, 50.0), 0);
|
||
let z = app.view().unwrap().zoom;
|
||
assert!((z - z0 * (rect.width() / 100.0).min(rect.height() / 50.0) as f64).abs() < 1e-6, "{key} off: {z0} → {z}");
|
||
}
|
||
}
|
||
|
||
/// With Animated Zoom off, the Zoom tool zooms a rotated view to the area dragged across: at 90°
|
||
/// and 180° a 100 × 50-point marquee fills the canvas as it does at 0°, centered on the canvas.
|
||
#[test]
|
||
fn the_zoom_tool_zooms_a_rotated_view_to_the_area_dragged_across() {
|
||
use egui::Event;
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.run("prefs.set", json!({"key": "animatedZoom", "value": false})).unwrap();
|
||
app.select_tool("zoom");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let rect = app.canvas_rect.unwrap();
|
||
let at = rect.center() - vec2(100.0, 50.0);
|
||
let to = at + vec2(100.0, 50.0);
|
||
let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
for angle in [90.0, 180.0] {
|
||
app.run("view.rotate", json!({ "angle": angle })).unwrap();
|
||
let z0 = app.view().unwrap().zoom;
|
||
let middle = Xf::new(rect, app.view().unwrap()).to_doc(at + vec2(50.0, 25.0));
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(at)]);
|
||
frame(&mut app, &ctx, vec![button(at, true)]);
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(to)]);
|
||
frame(&mut app, &ctx, vec![button(to, false)]);
|
||
let v = app.view().unwrap();
|
||
let want = z0 * f64::from((rect.width() / 100.0).min(rect.height() / 50.0));
|
||
assert!((v.zoom - want).abs() < 1e-6, "{angle}°: {z0} → {}, not {want}", v.zoom);
|
||
assert!(v.center.distance(middle) < 1e-6, "{angle}°: the marquee's center goes to the canvas's center");
|
||
app.view_mut().unwrap().zoom = z0;
|
||
}
|
||
}
|
||
|
||
/// In a rotated view the Selection tool's marquee is drawn where the tool selects: through the
|
||
/// screen corners of the document rectangle between the press and the pointer.
|
||
#[test]
|
||
fn the_selection_marquee_is_drawn_where_it_selects_in_a_rotated_view() {
|
||
use egui::Event;
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.select_tool("selection");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
app.run("view.rotate", json!({"angle": 45})).unwrap();
|
||
let rect = app.canvas_rect.unwrap();
|
||
let (from, to) = (rect.center() - vec2(60.0, 20.0), rect.center() + vec2(60.0, 40.0));
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(from)]);
|
||
frame(
|
||
&mut app,
|
||
&ctx,
|
||
vec![Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }],
|
||
);
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(to)]);
|
||
let xf = Xf::new(rect, app.view().unwrap());
|
||
let want = xf.quad(Rect::from_points(xf.to_doc(from), xf.to_doc(to)));
|
||
let drawn: Vec<Vec<Pos2>> = shapes(&mut app, &ctx)
|
||
.into_iter()
|
||
.filter_map(|s| match s {
|
||
Shape::Path(p) if p.stroke.color == egui::epaint::ColorMode::Solid(Color32::from_white_alpha(230)) => Some(p.points),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
let [line] = &drawn[..] else { panic!("one marquee: {drawn:?}") };
|
||
assert_eq!(line.len(), 5, "closed: {line:?}");
|
||
assert!(line.iter().zip(&want).all(|(a, b)| a.distance(*b) < 0.5), "{line:?} is not {want:?}");
|
||
assert_eq!(line[4], line[0]);
|
||
}
|
||
|
||
/// User Interface › Large Tabs (#394): the document tabs are taller, so the canvas starts lower.
|
||
#[test]
|
||
fn large_tabs_make_the_document_tabs_taller() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
crate::theme::install_fonts(&ctx);
|
||
let canvas_top = |app: &mut VectorcraftApp| {
|
||
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() };
|
||
let mut out = ctx.run_ui(raw, |ui| {
|
||
crate::chrome::doc_tabs(app, ui);
|
||
show(app, ui);
|
||
});
|
||
out.textures_delta.clear();
|
||
app.canvas_rect.unwrap().top()
|
||
};
|
||
let small = canvas_top(&mut app);
|
||
app.run("prefs.set", json!({"key": "largeTabs", "value": true})).unwrap();
|
||
assert_eq!(canvas_top(&mut app) - small, 9.0, "44 points instead of 35");
|
||
}
|
||
|
||
#[test]
|
||
fn cmd_space_zooms_and_space_pans_whatever_the_tool() {
|
||
use egui::{Event, Modifiers};
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.select_tool("rectangle");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let at = app.canvas_rect.unwrap().center();
|
||
let space = |pressed, modifiers| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers };
|
||
let button = |pos, pressed, modifiers| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers };
|
||
// A drag from `at` to `to` with Space and `held` down.
|
||
let drag = |app: &mut VectorcraftApp, held: Modifiers, to: Pos2| {
|
||
frame(app, &ctx, vec![Event::ModifiersChanged(held), space(true, held), Event::PointerMoved(at)]);
|
||
frame(app, &ctx, vec![button(at, true, held)]);
|
||
frame(app, &ctx, vec![Event::PointerMoved(to)]);
|
||
frame(app, &ctx, vec![button(to, false, held)]);
|
||
frame(app, &ctx, vec![space(false, held), Event::ModifiersChanged(Modifiers::NONE)]);
|
||
};
|
||
let z0 = app.view().unwrap().zoom;
|
||
drag(&mut app, Modifiers::COMMAND, at);
|
||
let z1 = app.view().unwrap().zoom;
|
||
assert_eq!(z1, crate::state::next_zoom(z0, true), "Cmd+Space click zooms in");
|
||
drag(&mut app, Modifiers::COMMAND | Modifiers::ALT, at);
|
||
assert_eq!(app.view().unwrap().zoom, crate::state::next_zoom(z1, false), "Cmd+Alt+Space click zooms out");
|
||
assert_eq!(app.session.active().unwrap().doc.art_bounds(), None, "the Rectangle tool drew nothing");
|
||
// Space alone pans, with the Zoom tool too.
|
||
app.select_tool("zoom");
|
||
let before = *app.view().unwrap();
|
||
drag(&mut app, Modifiers::NONE, at + vec2(30.0, 0.0));
|
||
let after = *app.view().unwrap();
|
||
assert_eq!(after.zoom, before.zoom);
|
||
assert!((after.center.x - (before.center.x - 30.0 / before.zoom)).abs() < 1e-6, "{before:?} → {after:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn space_shift_drag_rotates_the_view_whatever_the_tool() {
|
||
use egui::{Event, Modifiers};
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.select_tool("rectangle");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let c = app.canvas_rect.unwrap().center();
|
||
let space = |pressed, modifiers| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers };
|
||
let button = |pos, pressed, modifiers| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers };
|
||
// `drag` drags from `from` to `to` with `held` down, and with Space as well when `with_space`.
|
||
let drag = |app: &mut VectorcraftApp, held: Modifiers, with_space: bool, from: Pos2, to: Pos2| {
|
||
let keys = |pressed| if with_space { vec![space(pressed, held)] } else { vec![] };
|
||
frame(app, &ctx, [vec![Event::ModifiersChanged(held)], keys(true), vec![Event::PointerMoved(from)]].concat());
|
||
frame(app, &ctx, vec![button(from, true, held)]);
|
||
frame(app, &ctx, vec![Event::PointerMoved(to)]);
|
||
frame(app, &ctx, vec![button(to, false, held)]);
|
||
frame(app, &ctx, [keys(false), vec![Event::ModifiersChanged(Modifiers::NONE)]].concat());
|
||
};
|
||
// `at(deg)` is the point 100 points from the canvas center, `deg` degrees clockwise from the right.
|
||
let at = |deg: f32| c + 100.0 * vec2(deg.to_radians().cos(), deg.to_radians().sin());
|
||
let before = *app.view().unwrap();
|
||
drag(&mut app, Modifiers::SHIFT, true, at(0.0), at(90.0));
|
||
let after = *app.view().unwrap();
|
||
assert!((after.rotation - 90.0).abs() < 1e-3, "{after:?}");
|
||
assert_eq!((after.center, after.zoom), (before.center, before.zoom), "the view turns about its center");
|
||
assert_eq!(app.session.active().unwrap().doc.art_bounds(), None, "the Rectangle tool drew nothing");
|
||
// Shift does not round the angle: 90° and 20° more make 110°, not 105°.
|
||
drag(&mut app, Modifiers::SHIFT, true, at(0.0), at(20.0));
|
||
assert!((app.view().unwrap().rotation - 110.0).abs() < 1e-3, "{:?}", app.view());
|
||
// Space alone still pans, and Cmd+Shift+Space still zooms.
|
||
let v = *app.view().unwrap();
|
||
drag(&mut app, Modifiers::NONE, true, c, c + vec2(30.0, 0.0));
|
||
let panned = *app.view().unwrap();
|
||
assert_eq!((panned.rotation, panned.zoom), (v.rotation, v.zoom));
|
||
assert_ne!(panned.center, v.center, "Space pans");
|
||
drag(&mut app, Modifiers::COMMAND | Modifiers::SHIFT, true, c, c);
|
||
assert_eq!(app.view().unwrap().zoom, crate::state::next_zoom(v.zoom, true), "Cmd+Shift+Space click zooms in");
|
||
assert_eq!(app.view().unwrap().rotation, v.rotation);
|
||
// The Rotate View tool's Shift still rounds to 15°.
|
||
app.select_tool("rotateView");
|
||
app.view_mut().unwrap().rotation = 0.0;
|
||
drag(&mut app, Modifiers::SHIFT, false, at(0.0), at(20.0));
|
||
assert_eq!(app.view().unwrap().rotation, 15.0);
|
||
drag(&mut app, Modifiers::NONE, false, at(0.0), at(20.0));
|
||
assert!((app.view().unwrap().rotation - 35.0).abs() < 1e-3, "{:?}", app.view());
|
||
}
|
||
|
||
#[test]
|
||
fn the_rotate_cursor_shows_for_space_shift_and_the_rotate_view_tool() {
|
||
use egui::{CursorIcon, Event, Modifiers};
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.select_tool("rectangle");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let c = app.canvas_rect.unwrap().center();
|
||
// `shown` returns the system cursor the canvas sets over a window without the focus. On macOS
|
||
// the window paints the glyph only with the focus, and on the other desktop builds (not
|
||
// Wayland, which the tests do not set) the window sets the system cursor next to the bitmap.
|
||
let shown = |app: &mut VectorcraftApp, events: Vec<Event>| {
|
||
let screen_rect = Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)));
|
||
let mut out = ctx.run_ui(egui::RawInput { screen_rect, focused: false, events, ..Default::default() }, |ui| show(app, ui));
|
||
out.textures_delta.clear();
|
||
out.platform_output.cursor_icon
|
||
};
|
||
let space = |pressed, modifiers| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers };
|
||
let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::SHIFT };
|
||
assert_ne!(shown(&mut app, vec![Event::PointerMoved(c)]), CursorIcon::Alias);
|
||
assert_eq!(shown(&mut app, vec![Event::ModifiersChanged(Modifiers::SHIFT), space(true, Modifiers::SHIFT)]), CursorIcon::Alias);
|
||
// Shift let go during the drag keeps the rotate cursor; after it, Space shows the hand.
|
||
shown(&mut app, vec![button(c, true)]);
|
||
assert_eq!(shown(&mut app, vec![Event::ModifiersChanged(Modifiers::NONE), Event::PointerMoved(c + vec2(20.0, 30.0))]), CursorIcon::Alias);
|
||
assert_eq!(shown(&mut app, vec![button(c + vec2(20.0, 30.0), false)]), CursorIcon::Grab);
|
||
// Cmd+Shift+Space zooms.
|
||
assert_eq!(shown(&mut app, vec![Event::ModifiersChanged(Modifiers::COMMAND | Modifiers::SHIFT)]), CursorIcon::ZoomIn);
|
||
assert_ne!(shown(&mut app, vec![space(false, Modifiers::NONE), Event::ModifiersChanged(Modifiers::NONE)]), CursorIcon::Alias);
|
||
app.select_tool("rotateView");
|
||
assert_eq!(shown(&mut app, vec![Event::PointerMoved(c)]), CursorIcon::Alias, "the Rotate View tool");
|
||
}
|
||
|
||
#[test]
|
||
fn a_rotate_view_drag_shows_a_dial_with_the_angle() {
|
||
use egui::{Event, Modifiers};
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.select_tool("rotateView");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let c = app.canvas_rect.unwrap().center();
|
||
let space = |pressed, modifiers| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers };
|
||
let button = |pos, pressed, modifiers| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers };
|
||
// `dial` returns, from the shapes of one frame, the center of the dial's circle, the needle's
|
||
// direction in whole degrees clockwise from straight up, and the angle text.
|
||
let dial = |app: &mut VectorcraftApp| {
|
||
let s = shapes(app, &ctx);
|
||
let circle = s.iter().find_map(|s| if let Shape::Circle(k) = s { (k.radius == DIAL_RADIUS).then_some(k.center) } else { None });
|
||
let needle = s.iter().find_map(|s| match s {
|
||
Shape::LineSegment { points: [a, b], stroke } if stroke.width == 2.0 && *a == c => {
|
||
Some((b.x - a.x).atan2(a.y - b.y).to_degrees().round() as i32)
|
||
}
|
||
_ => None,
|
||
});
|
||
let text =
|
||
s.iter().find_map(|s| if let Shape::Text(t) = s { Some(t.galley.text().to_string()).filter(|t| t.ends_with('°')) } else { None });
|
||
(circle, needle, text)
|
||
};
|
||
assert_eq!(dial(&mut app), (None, None, None), "no dial before a drag");
|
||
// The Rotate View tool: a drag from 0° to 30° about the canvas center.
|
||
let (right, at_30, below) = (c + vec2(100.0, 0.0), c + vec2(86.6, 50.0), c + vec2(0.0, 100.0));
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(right)]);
|
||
frame(&mut app, &ctx, vec![button(right, true, Modifiers::NONE)]);
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(at_30)]);
|
||
assert_eq!(dial(&mut app), (Some(c), Some(30), Some("30°".to_string())));
|
||
frame(&mut app, &ctx, vec![button(at_30, false, Modifiers::NONE)]);
|
||
assert_eq!(dial(&mut app), (None, None, None), "the dial goes with the release");
|
||
assert!((app.view().unwrap().rotation - 30.0).abs() < 1e-2, "{:?}", app.view());
|
||
// A Space+Shift drag with another tool shows it too: 30° and 90° more make 120°.
|
||
app.select_tool("rectangle");
|
||
frame(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::SHIFT), space(true, Modifiers::SHIFT), Event::PointerMoved(right)]);
|
||
frame(&mut app, &ctx, vec![button(right, true, Modifiers::SHIFT)]);
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(below)]);
|
||
assert_eq!(dial(&mut app), (Some(c), Some(120), Some("120°".to_string())));
|
||
assert!((app.view().unwrap().rotation - 120.0).abs() < 1e-2, "{:?}", app.view());
|
||
// Presentation Mode turned on during the drag hides the dial.
|
||
let mode = app.ui.screen_mode;
|
||
app.ui.screen_mode = 3;
|
||
assert_eq!(dial(&mut app), (None, None, None), "no dial in Presentation Mode");
|
||
app.ui.screen_mode = mode;
|
||
frame(&mut app, &ctx, vec![button(below, false, Modifiers::SHIFT)]);
|
||
frame(&mut app, &ctx, vec![space(false, Modifiers::NONE), Event::ModifiersChanged(Modifiers::NONE)]);
|
||
assert_eq!(dial(&mut app), (None, None, None), "the dial goes with the release");
|
||
}
|
||
|
||
#[test]
|
||
fn space_pressed_mid_drag_moves_what_the_tool_draws() {
|
||
use egui::{Event, Modifiers};
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.ui.view.smart_guides = false;
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let space = |pressed| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE };
|
||
let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
|
||
// Press at `a`, drag to `b`, then hold Space while moving on by `by` and let go of both.
|
||
let draw = |app: &mut VectorcraftApp, a: Pos2, b: Pos2, by: egui::Vec2| {
|
||
frame(app, &ctx, vec![Event::PointerMoved(a)]);
|
||
frame(app, &ctx, vec![button(a, true)]);
|
||
frame(app, &ctx, vec![Event::PointerMoved(b)]);
|
||
frame(app, &ctx, vec![space(true)]);
|
||
frame(app, &ctx, vec![Event::PointerMoved(b + by)]);
|
||
frame(app, &ctx, vec![space(false)]);
|
||
frame(app, &ctx, vec![button(b + by, false)]);
|
||
};
|
||
let near = |p: Point, q: Point| p.distance(q) < 1e-3;
|
||
let at = app.canvas_rect.unwrap().center() - vec2(100.0, 60.0);
|
||
let center = app.view().unwrap().center;
|
||
// The rectangle moves at its size instead of growing, and the view doesn't pan.
|
||
app.select_tool("rectangle");
|
||
draw(&mut app, at, at + vec2(80.0, 40.0), vec2(30.0, 20.0));
|
||
assert_eq!(app.view().unwrap().center, center, "Space during a drag doesn't pan");
|
||
let st = app.session.active().unwrap();
|
||
let r = st.doc.node(st.selection.objects[0]).unwrap().path_data().unwrap().bounds().unwrap();
|
||
assert!(
|
||
near(Point::new(r.x0, r.y0), xf.to_doc(at + vec2(30.0, 20.0))) && near(Point::new(r.x1, r.y1), xf.to_doc(at + vec2(110.0, 60.0))),
|
||
"{r:?}"
|
||
);
|
||
// The Pen's anchor being dragged out moves, handles and all.
|
||
app.session.execute("select.none", &json!({})).unwrap();
|
||
app.select_tool("pen");
|
||
let a = at + vec2(0.0, 120.0);
|
||
draw(&mut app, a, a + vec2(40.0, 0.0), vec2(20.0, 10.0));
|
||
let st = app.session.active().unwrap();
|
||
let anchor = st.doc.node(st.selection.objects[0]).unwrap().path_data().unwrap().subpaths[0].anchors[0];
|
||
assert!(near(anchor.p, xf.to_doc(a + vec2(20.0, 10.0))) && near(anchor.h_out, xf.to_doc(a + vec2(60.0, 10.0))), "{anchor:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn double_clicking_type_with_the_selection_tool_puts_the_caret_there() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.session.execute("text.create", &json!({"x": 100, "y": 150, "text": "Hello world", "size": 24})).unwrap()["id"].as_u64().unwrap();
|
||
app.session.execute("select.none", &json!({})).unwrap();
|
||
app.select_tool("selection");
|
||
let ctx = egui::Context::default();
|
||
let timed = |app: &mut VectorcraftApp, time: f64, events: Vec<egui::Event>| {
|
||
let screen = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
|
||
let mut out =
|
||
ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(time), events, ..Default::default() }, |ui| show(app, ui));
|
||
out.textures_delta.clear();
|
||
};
|
||
timed(&mut app, 0.0, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let text = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind {
|
||
NodeKind::Text(t) => t.plain_text(),
|
||
_ => panic!("not text"),
|
||
};
|
||
// Double-click the middle of the text.
|
||
let b = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap();
|
||
let at = xf.to_screen(b.center());
|
||
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
timed(&mut app, 1.0, vec![egui::Event::PointerMoved(at), button(true), button(false), button(true), button(false)]);
|
||
timed(&mut app, 1.1, vec![]);
|
||
// The Type tool edits the text, with the caret inside it, where it was clicked: typing goes
|
||
// into the text, not to tool shortcuts (X would swap fill and stroke).
|
||
assert_eq!(app.session.tool_id(), "type");
|
||
assert!(app.session.tool_wants_text());
|
||
let o = app.session.tool_options();
|
||
assert_eq!(o["editing"], json!(id));
|
||
let caret = o["caret"].as_u64().unwrap();
|
||
assert!((1..11).contains(&caret), "caret {caret}");
|
||
app.session.tool_text("X", app.view_info()).unwrap();
|
||
let t = text(&app);
|
||
assert_eq!((t.len(), t.find('X')), (12, Some(caret as usize)), "{t}");
|
||
}
|
||
|
||
/// #2119: type copied with an Alt-drag, double-clicked, all of its text selected and typed over
|
||
/// keeps its font, size and colour: through an IME (the marked text cleared, then committed by
|
||
/// Return, as Windows sends them) and with Backspace then typing. Whole app frames, one input
|
||
/// event per frame.
|
||
fn retype_all_of_a_copied_type(ime: bool) {
|
||
use egui::{Event, Key, Modifiers};
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let original = app
|
||
.session
|
||
.execute("text.create", &json!({"x": 60, "y": 100, "text": "Chair 123", "size": 36, "font": "Georgia", "color": "#808080"}))
|
||
.unwrap()["id"]
|
||
.as_u64()
|
||
.unwrap();
|
||
app.session.execute("select.none", &json!({})).unwrap();
|
||
app.select_tool("selection");
|
||
let ctx = egui::Context::default();
|
||
let time = std::cell::Cell::new(0.0);
|
||
let frame = |app: &mut VectorcraftApp, events: Vec<Event>| {
|
||
time.set(time.get() + 0.03);
|
||
let screen = egui::Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 850.0));
|
||
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(time.get()), events, ..Default::default() }, |ui| {
|
||
app.logic(ui.ctx());
|
||
app.ui(ui);
|
||
});
|
||
out.textures_delta.clear();
|
||
};
|
||
for _ in 0..3 {
|
||
frame(&mut app, vec![]);
|
||
}
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let node = |app: &VectorcraftApp, id: u64| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().clone();
|
||
let styles = |app: &VectorcraftApp, id: u64| match &node(app, id).kind {
|
||
NodeKind::Text(t) => {
|
||
t.runs.iter().map(|r| (r.text.clone(), r.style.font_family.clone(), r.style.size, r.style.fill.clone())).collect::<Vec<_>>()
|
||
}
|
||
_ => panic!("not text"),
|
||
};
|
||
let button = |pos, pressed, modifiers| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers };
|
||
let key = |key, pressed, modifiers| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers };
|
||
// 1. Alt-drag the type: a copy, selected.
|
||
let from = xf.to_screen(node(&app, original).geometric_bounds().unwrap().center());
|
||
let to = from + vec2(0.0, 120.0 * xf.zoom as f32);
|
||
frame(&mut app, vec![Event::PointerMoved(from), Event::ModifiersChanged(Modifiers::ALT)]);
|
||
frame(&mut app, vec![button(from, true, Modifiers::ALT)]);
|
||
for k in 1..=6 {
|
||
frame(&mut app, vec![Event::PointerMoved(from + (to - from) * (k as f32 / 6.0))]);
|
||
}
|
||
frame(&mut app, vec![button(to, false, Modifiers::ALT)]);
|
||
frame(&mut app, vec![Event::ModifiersChanged(Modifiers::NONE)]);
|
||
let copy = app.session.active().unwrap().selection.objects[0].0;
|
||
assert_ne!(copy, original);
|
||
let style = styles(&app, original)[0].clone();
|
||
// 2. Double-click it at once: the Type tool edits it.
|
||
let at = xf.to_screen(node(&app, copy).geometric_bounds().unwrap().center());
|
||
frame(&mut app, vec![Event::PointerMoved(at)]);
|
||
for pressed in [true, false, true, false] {
|
||
frame(&mut app, vec![button(at, pressed, Modifiers::NONE)]);
|
||
}
|
||
frame(&mut app, vec![]);
|
||
assert_eq!(app.session.tool_options()["editing"], json!(copy));
|
||
// 3. Select all of its text and type over it.
|
||
frame(&mut app, vec![key(Key::A, true, Modifiers::COMMAND)]);
|
||
frame(&mut app, vec![key(Key::A, false, Modifiers::COMMAND)]);
|
||
if ime {
|
||
frame(&mut app, vec![preedit("t", 1)]);
|
||
frame(&mut app, vec![preedit("table", 5)]);
|
||
let marked = styles(&app, copy);
|
||
assert_eq!(marked.iter().map(|r| (&r.1, r.2, &r.3)).collect::<Vec<_>>(), vec![(&style.1, style.2, &style.3)], "composing: {marked:?}");
|
||
// Return commits the composition.
|
||
frame(&mut app, vec![preedit("", 0), Event::Ime(egui::ImeEvent::Commit("table".into()))]);
|
||
} else {
|
||
frame(&mut app, vec![key(Key::Backspace, true, Modifiers::NONE)]);
|
||
frame(&mut app, vec![key(Key::Backspace, false, Modifiers::NONE)]);
|
||
frame(&mut app, vec![Event::Text("table".into())]);
|
||
frame(&mut app, vec![key(Key::Enter, true, Modifiers::NONE)]);
|
||
frame(&mut app, vec![key(Key::Enter, false, Modifiers::NONE)]);
|
||
}
|
||
frame(&mut app, vec![]);
|
||
let after = styles(&app, copy);
|
||
assert!(after.iter().any(|r| r.0.starts_with("table")), "ime={ime}: typed over: {after:?}");
|
||
assert!(
|
||
after.iter().all(|r| (&r.1, r.2, &r.3) == (&style.1, style.2, &style.3)),
|
||
"ime={ime}: the copy keeps {:?} {} {:?}, got {after:?}",
|
||
style.1,
|
||
style.2,
|
||
style.3
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn retyping_all_of_a_copied_type_through_an_ime_keeps_its_style() {
|
||
retype_all_of_a_copied_type(true);
|
||
}
|
||
|
||
#[test]
|
||
fn retyping_all_of_a_copied_type_after_backspace_keeps_its_style() {
|
||
retype_all_of_a_copied_type(false);
|
||
}
|
||
|
||
#[test]
|
||
fn double_clicking_type_with_the_direct_selection_tool_puts_the_caret_there() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.session.execute("text.create", &json!({"x": 100, "y": 150, "text": "Hello world", "size": 24})).unwrap()["id"].as_u64().unwrap();
|
||
app.session.execute("select.none", &json!({})).unwrap();
|
||
app.select_tool("directSelection");
|
||
let ctx = egui::Context::default();
|
||
let timed = |app: &mut VectorcraftApp, time: f64, events: Vec<egui::Event>| {
|
||
let screen = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
|
||
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(time), events, ..Default::default() }, |ui| {
|
||
crate::shortcuts::handle(app, ui.ctx());
|
||
show(app, ui)
|
||
});
|
||
out.textures_delta.clear();
|
||
};
|
||
timed(&mut app, 0.0, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let text = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind {
|
||
NodeKind::Text(t) => t.plain_text(),
|
||
_ => panic!("not text"),
|
||
};
|
||
// Double-click the middle of the text with Direct Selection.
|
||
let b = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap();
|
||
let at = xf.to_screen(b.center());
|
||
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
timed(&mut app, 1.0, vec![egui::Event::PointerMoved(at), button(true), button(false), button(true), button(false)]);
|
||
timed(&mut app, 1.1, vec![]);
|
||
// The Type tool edits the text, with the caret inside it, where it was clicked: typing goes
|
||
// into the text, not to tool shortcuts.
|
||
assert_eq!(app.session.tool_id(), "type");
|
||
assert!(app.session.tool_wants_text());
|
||
let o = app.session.tool_options();
|
||
assert_eq!(o["editing"], json!(id));
|
||
let caret = o["caret"].as_u64().unwrap();
|
||
assert!((1..11).contains(&caret), "caret {caret}");
|
||
app.session.tool_text("X", app.view_info()).unwrap();
|
||
let t = text(&app);
|
||
assert_eq!((t.len(), t.find('X')), (12, Some(caret as usize)), "{t}");
|
||
|
||
// Pressing Escape exits text editing and returns to Direct Selection tool.
|
||
let esc = |pressed| egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed, repeat: false, modifiers: Default::default() };
|
||
timed(&mut app, 1.2, vec![esc(true), esc(false)]);
|
||
assert_eq!(app.session.tool_id(), "directSelection");
|
||
assert!(!app.session.tool_wants_text());
|
||
}
|
||
|
||
#[test]
|
||
fn escape_while_editing_in_isolation_returns_to_selection_tool_and_keeps_isolation_until_second_escape() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let tid =
|
||
app.session.execute("text.create", &json!({"x": 100, "y": 150, "text": "Nested hello", "size": 24})).unwrap()["id"].as_u64().unwrap();
|
||
// Group the text inside a group.
|
||
app.session.execute("select.all", &json!({})).unwrap();
|
||
app.session.execute("object.group", &json!({})).unwrap();
|
||
app.select_tool("selection");
|
||
let ctx = egui::Context::default();
|
||
let timed = |app: &mut VectorcraftApp, time: f64, events: Vec<egui::Event>| {
|
||
let screen = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
|
||
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(time), events, ..Default::default() }, |ui| {
|
||
crate::shortcuts::handle(app, ui.ctx());
|
||
show(app, ui)
|
||
});
|
||
out.textures_delta.clear();
|
||
};
|
||
timed(&mut app, 0.0, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let b = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(tid)).unwrap().geometric_bounds().unwrap();
|
||
let at = xf.to_screen(b.center());
|
||
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
let esc = |pressed| egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed, repeat: false, modifiers: Default::default() };
|
||
|
||
// 1st double-click on group enters isolation mode.
|
||
timed(&mut app, 1.0, vec![egui::Event::PointerMoved(at), button(true), button(false), button(true), button(false)]);
|
||
timed(&mut app, 1.1, vec![]);
|
||
assert!(app.session.active().unwrap().isolation.is_some(), "should be in isolation mode");
|
||
assert_eq!(app.session.tool_id(), "selection");
|
||
|
||
// 2nd double-click on the text inside the isolated group enters text editing.
|
||
timed(&mut app, 2.0, vec![egui::Event::PointerMoved(at), button(true), button(false), button(true), button(false)]);
|
||
timed(&mut app, 2.1, vec![]);
|
||
assert_eq!(app.session.tool_id(), "type");
|
||
assert!(app.session.tool_wants_text());
|
||
assert!(app.session.active().unwrap().isolation.is_some(), "isolation mode still active while editing text");
|
||
|
||
// 1st Escape exits text edit mode, returns to Selection tool, but keeps isolation mode.
|
||
timed(&mut app, 3.0, vec![esc(true), esc(false)]);
|
||
timed(&mut app, 3.1, vec![]);
|
||
assert_eq!(app.session.tool_id(), "selection");
|
||
assert!(!app.session.tool_wants_text());
|
||
assert!(app.session.active().unwrap().isolation.is_some(), "1st Escape must NOT end isolation mode");
|
||
|
||
// 2nd Escape exits isolation mode.
|
||
timed(&mut app, 4.0, vec![esc(true), esc(false)]);
|
||
timed(&mut app, 4.1, vec![]);
|
||
assert_eq!(app.session.tool_id(), "selection");
|
||
assert!(app.session.active().unwrap().isolation.is_none(), "2nd Escape must exit isolation mode");
|
||
}
|
||
|
||
#[test]
|
||
fn a_symbol_dropped_on_the_canvas_is_placed_there() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 20})).unwrap();
|
||
let name = app.session.execute("symbol.new", &json!({})).unwrap()["name"].as_str().unwrap().to_string();
|
||
app.session.execute("select.none", &json!({})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
// Released over empty canvas: an instance centred there.
|
||
let at = xf.to_screen(Point::new(250.0, 180.0));
|
||
egui::DragAndDrop::set_payload(&ctx, widgets::PanelDrag::Symbol(name.clone()));
|
||
let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() };
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), up]);
|
||
let st = app.session.active().unwrap();
|
||
let placed = st.selection.objects.first().copied().unwrap();
|
||
let n = st.doc.node(placed).unwrap();
|
||
assert!(matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if *symbol == name));
|
||
let c = n.geometric_bounds().unwrap().center();
|
||
assert!((c - xf.to_doc(at)).hypot() < 1e-6, "{c:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn a_library_symbol_dropped_on_the_canvas_is_added_and_placed_there_in_one_step() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
// Dropped on art too, it is placed, not applied to the art.
|
||
app.session.execute("shape.rectangle", &json!({"x": 200, "y": 150, "width": 100, "height": 60})).unwrap();
|
||
app.session.execute("select.none", &json!({})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let undo = app.session.active().unwrap().history.undo.len();
|
||
let at = xf.to_screen(Point::new(250.0, 180.0));
|
||
egui::DragAndDrop::set_payload(&ctx, widgets::PanelDrag::LibrarySymbol { library: "basic-shapes".into(), name: "Star".into() });
|
||
let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() };
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), up]);
|
||
let st = app.session.active().unwrap();
|
||
assert_eq!(st.doc.symbols.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["Star"]);
|
||
assert_eq!(st.history.undo.len(), undo + 1, "added and placed as one undo step");
|
||
assert_eq!(st.history.undo.last().unwrap().label, "Add to Symbols");
|
||
let placed = st.selection.objects.first().copied().unwrap();
|
||
let n = st.doc.node(placed).unwrap();
|
||
assert!(matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if symbol == "Star"));
|
||
// The registration point (the centre) is where it was dropped, in document coordinates
|
||
// (the pointer's screen position, which rounds 250, 180 a little).
|
||
let size = st.doc.unknown.get(vectorcraft_doc::SYMBOL_SIZES).and_then(|m| m.get("Star")).cloned().unwrap();
|
||
let b = n.geometric_bounds().unwrap();
|
||
assert!((b.width() - size[0].as_f64().unwrap()).abs() < 1e-6, "{b:?} {size}");
|
||
assert!((b.center() - xf.to_doc(at)).hypot() < 1e-6 && (b.center() - Point::new(250.0, 180.0)).hypot() < 0.1, "{b:?}");
|
||
app.run("edit.undo", json!({})).unwrap();
|
||
let st = app.session.active().unwrap();
|
||
assert!(st.doc.symbols.is_empty() && st.doc.node(placed).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn a_brush_dropped_on_a_path_is_applied_to_it() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id =
|
||
app.session.execute("path.create", &json!({"anchors": [{"x": 100, "y": 150}, {"x": 300, "y": 150}]})).unwrap()["id"].as_u64().unwrap();
|
||
app.session.execute("select.none", &json!({})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let brush = |app: &VectorcraftApp| {
|
||
let st = app.session.active().unwrap();
|
||
st.doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.stroke().and_then(|s| s.brush.clone())
|
||
};
|
||
let drop = |app: &mut VectorcraftApp, at: Point| {
|
||
let at = xf.to_screen(at);
|
||
egui::DragAndDrop::set_payload(&ctx, widgets::PanelDrag::Brush { name: "Arrow".into(), def: json!({}) });
|
||
let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() };
|
||
frame(app, &ctx, vec![egui::Event::PointerMoved(at), up]);
|
||
};
|
||
// Off the path: nothing.
|
||
drop(&mut app, Point::new(200.0, 250.0));
|
||
assert_eq!(brush(&app), None);
|
||
// On it: the path's stroke takes the brush.
|
||
drop(&mut app, Point::new(200.0, 150.0));
|
||
assert_eq!(brush(&app).as_deref(), Some("Arrow"));
|
||
}
|
||
|
||
#[test]
|
||
fn middle_drag_pans_the_view_with_any_tool() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
// A drawing tool: the middle button must pan, never draw.
|
||
app.select_tool("rectangle");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos2(400.0, 300.0))]);
|
||
let before = *app.view().unwrap();
|
||
frame(&mut app, &ctx, vec![middle(pos2(400.0, 300.0), true)]);
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos2(450.0, 330.0))]);
|
||
frame(&mut app, &ctx, vec![middle(pos2(450.0, 330.0), false)]);
|
||
let after = *app.view().unwrap();
|
||
assert_eq!(after.zoom, before.zoom);
|
||
assert!((after.center.x - (before.center.x - 50.0 / before.zoom)).abs() < 1e-6);
|
||
assert!((after.center.y - (before.center.y - 30.0 / before.zoom)).abs() < 1e-6);
|
||
// Released: moving no longer pans, and nothing was drawn.
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos2(300.0, 200.0))]);
|
||
assert_eq!(app.view().unwrap().center, after.center);
|
||
assert_eq!(app.session.active().unwrap().doc.art_bounds(), None);
|
||
}
|
||
|
||
#[test]
|
||
fn swatches_and_proxy_paints_dropped_on_art_fill_the_object_hit() {
|
||
use vectorcraft_color::{Color, GradientGeom, GradientPaint, Paint};
|
||
use widgets::{PanelDrag, SwatchRows};
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||
app.session.execute("select.none", &json!({})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let drop = |app: &mut VectorcraftApp, d: PanelDrag, at: Point| {
|
||
let at = xf.to_screen(at);
|
||
egui::DragAndDrop::set_payload(&ctx, d);
|
||
let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() };
|
||
frame(app, &ctx, vec![egui::Event::PointerMoved(at), up]);
|
||
};
|
||
let fill = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.fill_paint();
|
||
let red = Paint::solid(Color::from_hex("#ed1c24").unwrap());
|
||
let rows = SwatchRows { grabbed: "Red".into(), names: vec!["Red".into()], groups: false };
|
||
let swatch = PanelDrag::Paint { paint: red.clone(), params: json!({"swatch": "Red"}), rows: Some(rows), library: None };
|
||
// Off the art nothing happens.
|
||
drop(&mut app, swatch.clone(), Point::new(300.0, 250.0));
|
||
assert_eq!(fill(&app), Paint::solid(Color::WHITE));
|
||
drop(&mut app, swatch, Point::new(100.0, 100.0));
|
||
assert_eq!(fill(&app), red, "paint.setFill with the hit id");
|
||
assert!(app.session.active().unwrap().selection.is_empty(), "the selection stays as it was");
|
||
// A proxy's paint works the same.
|
||
drop(&mut app, PanelDrag::paint(Paint::solid(Color::rgb(0.0, 0.0, 1.0))), Point::new(60.0, 60.0));
|
||
assert_eq!(fill(&app), Paint::solid(Color::rgb(0.0, 0.0, 1.0)));
|
||
// A dragged gradient (the Gradient panel's thumbnail, a proxy) fits the object it lands on.
|
||
let mut g = GradientPaint::new(Default::default());
|
||
g.geom = Some(GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(10.0, 0.0), aspect: 1.0, focal: None });
|
||
drop(&mut app, PanelDrag::paint(Paint::Gradient(Box::new(g))), Point::new(100.0, 100.0));
|
||
assert!(matches!(fill(&app), Paint::Gradient(g) if g.geom.is_none()));
|
||
}
|
||
|
||
#[test]
|
||
fn dropping_the_appearance_thumbnail_on_art_copies_the_appearance() {
|
||
use vectorcraft_doc::NodeId;
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap();
|
||
run(&mut app, "file.new", json!({"width": 400, "height": 300}));
|
||
let a = run(&mut app, "shape.rectangle", json!({"x": 20, "y": 20, "width": 100, "height": 100}))["id"].as_u64().unwrap();
|
||
run(&mut app, "effect.apply", json!({"effect": "distort.twist"}));
|
||
run(&mut app, "transparency.set", json!({"opacity": 40}));
|
||
let b = run(&mut app, "shape.rectangle", json!({"x": 200, "y": 20, "width": 100, "height": 100}))["id"].as_u64().unwrap();
|
||
run(&mut app, "select.set", json!({ "ids": [a] }));
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
// The Appearance panel's thumbnail (its drag payload) released over the second rectangle.
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let drop = |app: &mut VectorcraftApp, at: Point| {
|
||
let at = xf.to_screen(at);
|
||
egui::DragAndDrop::set_payload(&ctx, widgets::PanelDrag::Appearance(NodeId(a)));
|
||
frame(app, &ctx, vec![egui::Event::PointerMoved(at)]);
|
||
let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() };
|
||
frame(app, &ctx, vec![up]);
|
||
};
|
||
drop(&mut app, Point::new(250.0, 70.0));
|
||
let doc = &app.session.active().unwrap().doc;
|
||
let (na, nb) = (doc.node(NodeId(a)).unwrap(), doc.node(NodeId(b)).unwrap());
|
||
assert_eq!((&nb.appearance, nb.opacity), (&na.appearance, na.opacity));
|
||
assert_eq!(nb.appearance.effects[0].id, "distort.twist");
|
||
// Dropped on empty canvas or on the source itself: nothing changes.
|
||
let undo_len = app.session.active().unwrap().history.undo.len();
|
||
drop(&mut app, Point::new(350.0, 250.0));
|
||
drop(&mut app, Point::new(70.0, 70.0));
|
||
assert_eq!(app.session.active().unwrap().history.undo.len(), undo_len);
|
||
}
|
||
|
||
#[test]
|
||
fn a_graphic_style_dropped_on_art_applies_to_the_object_hit() {
|
||
use vectorcraft_doc::NodeId;
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap();
|
||
run(&mut app, "file.new", json!({"width": 400, "height": 300}));
|
||
let a = NodeId(run(&mut app, "shape.rectangle", json!({"x": 20, "y": 20, "width": 100, "height": 100}))["id"].as_u64().unwrap());
|
||
let b = NodeId(run(&mut app, "shape.rectangle", json!({"x": 200, "y": 20, "width": 100, "height": 100}))["id"].as_u64().unwrap());
|
||
run(&mut app, "select.set", json!({ "ids": [a.0] }));
|
||
let name = app.session.active().unwrap().doc.graphic_styles[2].name.clone();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let at = xf.to_screen(Point::new(250.0, 70.0));
|
||
egui::DragAndDrop::set_payload(&ctx, widgets::PanelDrag::GraphicStyle(name.clone()));
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at)]);
|
||
let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() };
|
||
frame(&mut app, &ctx, vec![up]);
|
||
let st = app.session.active().unwrap();
|
||
let g = st.doc.graphic_style(&name).unwrap();
|
||
// `graphicStyle.apply` with the hit object's id: linked, the selection left alone.
|
||
assert_eq!(st.doc.node(b).unwrap().extras().graphic_style, Some(g.id));
|
||
assert_eq!(st.doc.node(b).unwrap().appearance, g.appearance);
|
||
assert_eq!(st.doc.node(a).unwrap().extras().graphic_style, None);
|
||
assert_eq!(st.selection.objects, [a]);
|
||
}
|
||
|
||
#[test]
|
||
fn art_moved_off_the_canvas_waits_and_drops_on_the_panel() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||
let id = vectorcraft_doc::NodeId(id);
|
||
app.select_tool("selection");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let before = app.session.active().unwrap().doc.clone();
|
||
let undo = app.session.active().unwrap().history.undo.len();
|
||
let button = |pos: Pos2, pressed: bool| egui::Event::PointerButton {
|
||
pos,
|
||
button: egui::PointerButton::Primary,
|
||
pressed,
|
||
modifiers: Default::default(),
|
||
};
|
||
let start = xf.to_screen(Point::new(100.0, 100.0));
|
||
let left = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0;
|
||
let moved_to = |screen: Pos2| 50.0 + xf.to_doc(screen).x - 100.0;
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start), button(start, true)]);
|
||
// Moving on the canvas moves the art...
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start + vec2(40.0, 0.0))]);
|
||
assert!(app.session.active().unwrap().interaction.is_some());
|
||
// ...over a panel it waits where it was, and the panels get the art.
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos2(850.0, 100.0))]);
|
||
assert!(app.session.active().unwrap().interaction.is_some(), "the move waits, not cancels");
|
||
assert!((left(&app) - moved_to(start + vec2(40.0, 0.0))).abs() < 0.01, "the art stays where it last moved");
|
||
assert_eq!(egui::DragAndDrop::payload::<widgets::PanelDrag>(&ctx).as_deref(), Some(&widgets::PanelDrag::Art(vec![id])));
|
||
// Released over the panel, the move goes back and nothing is recorded.
|
||
frame(&mut app, &ctx, vec![button(pos2(850.0, 100.0), false)]);
|
||
let st = app.session.active().unwrap();
|
||
assert!(st.interaction.is_none());
|
||
assert_eq!(*st.doc, *before);
|
||
assert_eq!(st.history.undo.len(), undo);
|
||
}
|
||
|
||
/// #2826: a move taken over a panel waits where it left the canvas, and goes on with the
|
||
/// pointer when it comes back — it isn't dropped or put back.
|
||
#[test]
|
||
fn art_moved_off_the_canvas_resumes_when_the_pointer_returns() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||
app.select_tool("selection");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let undo = app.session.active().unwrap().history.undo.len();
|
||
let button = |pos: Pos2, pressed: bool| egui::Event::PointerButton {
|
||
pos,
|
||
button: egui::PointerButton::Primary,
|
||
pressed,
|
||
modifiers: Default::default(),
|
||
};
|
||
let start = xf.to_screen(Point::new(100.0, 100.0));
|
||
let left = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap().x0;
|
||
let moved_to = |screen: Pos2| 50.0 + xf.to_doc(screen).x - 100.0;
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start), button(start, true)]);
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start + vec2(40.0, 0.0))]);
|
||
assert!((left(&app) - moved_to(start + vec2(40.0, 0.0))).abs() < 0.01, "the move is live");
|
||
// Over a panel the move waits where it last moved, and stays live.
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos2(850.0, 100.0))]);
|
||
assert!(app.session.active().unwrap().interaction.is_some(), "the move waits, not cancels");
|
||
assert!((left(&app) - moved_to(start + vec2(40.0, 0.0))).abs() < 0.01, "the art stays where it last moved");
|
||
// Back on the canvas it goes on with the pointer, 60 pt right.
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start + vec2(60.0, 0.0))]);
|
||
assert!(app.session.active().unwrap().interaction.is_some(), "the move is live again");
|
||
assert!((left(&app) - moved_to(start + vec2(60.0, 0.0))).abs() < 0.01, "the move resumes with the pointer");
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start + vec2(70.0, 0.0))]);
|
||
frame(&mut app, &ctx, vec![button(start + vec2(70.0, 0.0), false)]);
|
||
let st = app.session.active().unwrap();
|
||
assert!((left(&app) - moved_to(start + vec2(70.0, 0.0))).abs() < 0.01, "released where the pointer was");
|
||
assert_eq!(st.history.undo.len(), undo + 1, "one move, one undo step");
|
||
}
|
||
|
||
/// One frame of keyboard handling, canvas and the end-of-pass IME observation, as the app runs
|
||
/// them; returns the IME output.
|
||
fn typing_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) -> Option<egui::output::IMEOutput> {
|
||
VectorcraftApp::install_ime_reset_hooks(ctx);
|
||
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() };
|
||
let mut out = ctx.run_ui(raw, |ui| {
|
||
crate::ime::begin_ui(app, ui.ctx());
|
||
crate::shortcuts::handle(app, ui.ctx());
|
||
show(app, ui);
|
||
crate::ime::end_of_pass(app, ui.ctx());
|
||
crate::ime::finish_ui(app, ui.ctx());
|
||
});
|
||
out.textures_delta.clear();
|
||
out.platform_output.ime
|
||
}
|
||
|
||
fn preedit(t: &str, chars: usize) -> egui::Event {
|
||
egui::Event::Ime(egui::ImeEvent::Preedit { text: t.into(), active_range_chars: Some(chars..chars) })
|
||
}
|
||
|
||
fn plain(app: &VectorcraftApp, id: u64) -> String {
|
||
match &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind {
|
||
NodeKind::Text(t) => t.plain_text(),
|
||
_ => panic!("not text"),
|
||
}
|
||
}
|
||
|
||
/// #2826: the app ends a move released over a panel before the panels draw, so a panel takes
|
||
/// the art as it was, and no move is left open.
|
||
#[test]
|
||
fn a_move_released_over_a_panel_ends_before_the_panels_draw() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||
app.select_tool("selection");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let before = app.session.active().unwrap().doc.clone();
|
||
let undo = app.session.active().unwrap().history.undo.len();
|
||
let button = |pos: Pos2, pressed: bool| egui::Event::PointerButton {
|
||
pos,
|
||
button: egui::PointerButton::Primary,
|
||
pressed,
|
||
modifiers: Default::default(),
|
||
};
|
||
let start = xf.to_screen(Point::new(100.0, 100.0));
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start), button(start, true)]);
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start + vec2(40.0, 0.0))]);
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos2(850.0, 100.0))]);
|
||
assert!(app.session.active().unwrap().interaction.is_some());
|
||
let raw = egui::RawInput {
|
||
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))),
|
||
events: vec![button(pos2(850.0, 100.0), false)],
|
||
..Default::default()
|
||
};
|
||
let mut out = ctx.run_ui(raw, |ui| {
|
||
crate::canvas::release_over_panel(&mut app, ui.ctx());
|
||
assert!(app.session.active().unwrap().interaction.is_none(), "the move is ended");
|
||
assert_eq!(*app.session.active().unwrap().doc, *before, "the art is back where it was");
|
||
assert_eq!(
|
||
egui::DragAndDrop::payload::<widgets::PanelDrag>(ui.ctx()).as_deref(),
|
||
Some(&widgets::PanelDrag::Art(vec![vectorcraft_doc::NodeId(id)])),
|
||
"the panel still gets the art"
|
||
);
|
||
assert!(ui.ctx().data(|d| d.get_temp::<Drag>(drag_id())).is_none(), "no drag is left");
|
||
});
|
||
out.textures_delta.clear();
|
||
assert_eq!(app.session.active().unwrap().history.undo.len(), undo);
|
||
}
|
||
|
||
#[test]
|
||
fn japanese_ime_composes_on_the_canvas_with_its_window_at_the_caret() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
// The type placed starts empty (Fill New Type Objects With Placeholder Text off).
|
||
app.session.prefs.placeholder_text = false;
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
// Not editing: the IME stays off (single-key tool shortcuts keep working).
|
||
assert!(typing_frame(&mut app, &ctx, vec![]).is_none());
|
||
app.select_tool("type");
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let at = xf.to_screen(Point::new(100.0, 100.0));
|
||
let click =
|
||
|p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(at, true)]);
|
||
typing_frame(&mut app, &ctx, vec![click(at, false)]);
|
||
assert!(app.session.tool_wants_text());
|
||
let id = app.session.active().unwrap().selection.objects[0].0;
|
||
// Editing: the IME is allowed, its window at the caret (the text's baseline at y = 100).
|
||
let ime = typing_frame(&mut app, &ctx, vec![]).expect("IME allowed while editing");
|
||
assert!(ime.rect.contains(pos2(ime.rect.center().x, at.y)) && (ime.rect.center().x - at.x).abs() < 2.0, "{:?} vs {at:?}", ime.rect);
|
||
assert!(!ime.should_interrupt_composition);
|
||
// gagaku → がが → 雅楽, then commit (the macOS sequence).
|
||
typing_frame(&mut app, &ctx, vec![preedit("g", 1)]);
|
||
typing_frame(&mut app, &ctx, vec![preedit("が", 1), preedit("がg", 2)]);
|
||
typing_frame(&mut app, &ctx, vec![preedit("ががく", 3)]);
|
||
assert_eq!(plain(&app, id), "ががく");
|
||
// While composing: plain text events, the clipboard and Undo (the native menu's ⌘Z
|
||
// reaches `menus::invoke`) all wait.
|
||
typing_frame(&mut app, &ctx, vec![egui::Event::Text("x".into()), egui::Event::Paste("y".into())]);
|
||
crate::menus::invoke(&mut app, "edit.undo", json!({}));
|
||
assert!(!crate::menus::enabled(&app, "edit.undo"));
|
||
assert_eq!(plain(&app, id), "ががく");
|
||
let ime2 =
|
||
typing_frame(&mut app, &ctx, vec![egui::Event::Ime(egui::ImeEvent::Preedit { text: "雅楽".into(), active_range_chars: Some(0..2) })])
|
||
.unwrap();
|
||
assert!(ime2.rect.center().x <= ime.rect.center().x + 1.0, "the window stays at the clause being converted");
|
||
typing_frame(&mut app, &ctx, vec![preedit("", 0), egui::Event::Ime(egui::ImeEvent::Commit("雅楽".into()))]);
|
||
assert_eq!(plain(&app, id), "雅楽");
|
||
assert!(!app.session.tool_composing());
|
||
// After the commit, text and Undo work again.
|
||
typing_frame(&mut app, &ctx, vec![egui::Event::Text("!".into())]);
|
||
assert_eq!(plain(&app, id), "雅楽!");
|
||
crate::menus::invoke(&mut app, "edit.undo", json!({}));
|
||
assert_eq!(plain(&app, id), "");
|
||
}
|
||
|
||
/// Vietnamese Telex via the Windows IME (TSF): the preedit grows digit by digit, then an empty
|
||
/// preedit plus a single Commit lands the amount once, not duplicated.
|
||
#[test]
|
||
fn vietnamese_telex_digits_commit_once() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.prefs.placeholder_text = false;
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
typing_frame(&mut app, &ctx, vec![]);
|
||
app.select_tool("type");
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let at = xf.to_screen(Point::new(100.0, 100.0));
|
||
let click =
|
||
|p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(at, true)]);
|
||
typing_frame(&mut app, &ctx, vec![click(at, false)]);
|
||
let id = app.session.active().unwrap().selection.objects[0].0;
|
||
typing_frame(&mut app, &ctx, vec![]);
|
||
typing_frame(&mut app, &ctx, vec![preedit("3", 1)]);
|
||
typing_frame(&mut app, &ctx, vec![preedit("31", 2)]);
|
||
typing_frame(&mut app, &ctx, vec![preedit("316", 3)]);
|
||
typing_frame(&mut app, &ctx, vec![preedit("3160", 4)]);
|
||
typing_frame(&mut app, &ctx, vec![preedit("", 0), egui::Event::Ime(egui::ImeEvent::Commit("3160".into()))]);
|
||
assert_eq!(plain(&app, id), "3160");
|
||
assert!(!app.session.tool_composing());
|
||
}
|
||
|
||
/// Keyboard-hook IMEs (UniKey/OpenKey) type Vietnamese by injecting VK_PACKET characters and
|
||
/// Backspaces, which egui surfaces as interleaved Event::Text and Event::Key(Backspace). When a
|
||
/// tone key fires, the Backspaces and the re-injected characters arrive in one frame; the Type
|
||
/// tool must apply them in order to build "tiếng" (captured from a real UniKey session), not
|
||
/// insert every character first and delete afterwards.
|
||
#[test]
|
||
fn hook_ime_backspace_and_reinject_builds_vietnamese() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.prefs.placeholder_text = false;
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
typing_frame(&mut app, &ctx, vec![]);
|
||
app.select_tool("type");
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let at = xf.to_screen(Point::new(100.0, 100.0));
|
||
let click =
|
||
|p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(at, true)]);
|
||
typing_frame(&mut app, &ctx, vec![click(at, false)]);
|
||
let id = app.session.active().unwrap().selection.objects[0].0;
|
||
typing_frame(&mut app, &ctx, vec![]);
|
||
let txt = |s: &str| egui::Event::Text(s.into());
|
||
let bs = || egui::Event::Key { key: egui::Key::Backspace, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() };
|
||
// When a tone key fires, the Backspaces and the re-injected VK_PACKET characters land
|
||
// together in one poll (one egui frame).
|
||
typing_frame(&mut app, &ctx, vec![txt("t")]);
|
||
typing_frame(&mut app, &ctx, vec![txt("i")]);
|
||
typing_frame(&mut app, &ctx, vec![txt("e")]);
|
||
typing_frame(&mut app, &ctx, vec![bs(), txt("ê")]); // 2nd "e" -> ê
|
||
typing_frame(&mut app, &ctx, vec![txt("n")]);
|
||
typing_frame(&mut app, &ctx, vec![txt("g")]);
|
||
typing_frame(&mut app, &ctx, vec![bs(), bs(), bs(), txt("ế"), txt("n"), txt("g")]); // "s" tone
|
||
assert_eq!(plain(&app, id), "tiếng");
|
||
}
|
||
|
||
/// Clear History waits while an IME composes, as Undo does: the marked text and the steps stay.
|
||
#[test]
|
||
fn clear_history_waits_while_the_ime_composes() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.prefs.placeholder_text = false;
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("shape.rectangle", &json!({"x": 300, "y": 200, "width": 20, "height": 20})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
typing_frame(&mut app, &ctx, vec![]);
|
||
app.select_tool("type");
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let at = xf.to_screen(Point::new(100.0, 100.0));
|
||
let click =
|
||
|p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(at, true)]);
|
||
typing_frame(&mut app, &ctx, vec![click(at, false)]);
|
||
let id = app.session.active().unwrap().selection.objects[0].0;
|
||
typing_frame(&mut app, &ctx, vec![preedit("が", 1)]);
|
||
assert!(app.session.tool_composing());
|
||
assert!(!crate::menus::enabled(&app, "edit.clearHistory"));
|
||
crate::menus::invoke(&mut app, "edit.clearHistory", json!({}));
|
||
assert!(app.session.tool_composing(), "the composition goes on");
|
||
assert!(!app.session.active().unwrap().history.undo.is_empty(), "the steps stay");
|
||
typing_frame(&mut app, &ctx, vec![preedit("", 0), egui::Event::Ime(egui::ImeEvent::Commit("雅".into()))]);
|
||
assert_eq!(plain(&app, id), "雅");
|
||
assert!(crate::menus::enabled(&app, "edit.clearHistory"));
|
||
}
|
||
|
||
#[test]
|
||
fn clicking_away_mid_composition_keeps_the_text_and_interrupts_the_ime() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let other = app.session.execute("text.create", &json!({"x": 300, "y": 250, "text": "Other", "size": 24})).unwrap()["id"].as_u64().unwrap();
|
||
let ctx = egui::Context::default();
|
||
typing_frame(&mut app, &ctx, vec![]);
|
||
app.select_tool("type");
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let (a, b) = (xf.to_screen(Point::new(100.0, 100.0)), xf.to_screen(Point::new(310.0, 245.0)));
|
||
let click =
|
||
|p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(a), click(a, true)]);
|
||
typing_frame(&mut app, &ctx, vec![click(a, false)]);
|
||
let id = app.session.active().unwrap().selection.objects[0].0;
|
||
typing_frame(&mut app, &ctx, vec![preedit("しょうこ", 4)]);
|
||
// macOS sends nothing for a click away; the tool keeps the marked text and the IME is
|
||
// told to drop its composition. A press on other type edits that type at once.
|
||
let ime = typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(b), click(b, true)]);
|
||
assert_eq!(plain(&app, id), "しょうこ");
|
||
assert!(!app.session.tool_composing());
|
||
assert_eq!(app.session.active().unwrap().selection.objects, vec![vectorcraft_doc::NodeId(other)]);
|
||
let ime = ime.expect("editing the other text");
|
||
assert!(ime.should_interrupt_composition);
|
||
let request_id = ime.reset_request.expect("the final destination publishes its reset request");
|
||
assert!(app.ime_marked.is_none());
|
||
assert!(app.take_ime_discard(), "the host tells the system IME to drop its marked text");
|
||
typing_frame(&mut app, &ctx, vec![click(b, false)]);
|
||
// Interrupted once, not every frame.
|
||
assert!(!typing_frame(&mut app, &ctx, vec![]).unwrap().should_interrupt_composition);
|
||
assert!(!app.take_ime_discard());
|
||
// A click on blank canvas ends the editing (#2871): the marked text stays, the frame asks
|
||
// for no IME, and the host tells the system IME to drop its composition.
|
||
let blank = xf.to_screen(Point::new(150.0, 200.0));
|
||
typing_frame(&mut app, &ctx, vec![egui::Event::ImeResetCompleted { request_id }, preedit("が", 1)]);
|
||
typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(blank), click(blank, true)]);
|
||
let ime = typing_frame(&mut app, &ctx, vec![click(blank, false)]);
|
||
assert!(plain(&app, other).contains('が'), "{}", plain(&app, other));
|
||
assert!(!app.session.tool_composing() && !app.session.tool_wants_text());
|
||
assert!(ime.is_none());
|
||
assert!(app.ime_marked.is_none());
|
||
assert!(app.take_ime_discard());
|
||
}
|
||
|
||
#[test]
|
||
fn ime_edge_orders_never_eat_committed_text_or_lose_the_composition() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
typing_frame(&mut app, &ctx, vec![]);
|
||
app.select_tool("type");
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let at = xf.to_screen(Point::new(100.0, 100.0));
|
||
let click =
|
||
|p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(at, true)]);
|
||
typing_frame(&mut app, &ctx, vec![click(at, false)]);
|
||
let id = app.session.active().unwrap().selection.objects[0].0;
|
||
typing_frame(&mut app, &ctx, vec![egui::Event::Text("雅".into())]);
|
||
let backspace =
|
||
|pressed| egui::Event::Key { key: egui::Key::Backspace, physical_key: None, pressed, repeat: false, modifiers: Default::default() };
|
||
// The IME empties its marked text and the Backspace comes in the same frame.
|
||
typing_frame(&mut app, &ctx, vec![preedit("が", 1)]);
|
||
typing_frame(&mut app, &ctx, vec![preedit("", 0), backspace(true), backspace(false)]);
|
||
assert_eq!(plain(&app, id), "雅", "the committed character stays");
|
||
// A bare line-break commit ends the composition with the marked text, no newline.
|
||
typing_frame(&mut app, &ctx, vec![preedit("がく", 2)]);
|
||
typing_frame(&mut app, &ctx, vec![egui::Event::Ime(egui::ImeEvent::Commit("\n".into()))]);
|
||
assert!(!app.session.tool_composing());
|
||
assert_eq!(plain(&app, id), "雅がく");
|
||
assert!(app.take_ime_discard());
|
||
// Switching apps mid-composition: the composition goes on when the window comes back.
|
||
typing_frame(&mut app, &ctx, vec![preedit("らく", 2)]);
|
||
typing_frame(&mut app, &ctx, vec![egui::Event::WindowFocused(false)]);
|
||
typing_frame(&mut app, &ctx, vec![egui::Event::WindowFocused(true), preedit("らくか", 3)]);
|
||
assert!(app.session.tool_composing());
|
||
assert!(!app.take_ime_discard());
|
||
assert_eq!(plain(&app, id), "雅がくらくか");
|
||
}
|
||
|
||
/// Reset to a blank document, select the Type tool and click document point (100, 100), returning
|
||
/// the screen position used by IME fixtures.
|
||
fn type_at(app: &mut VectorcraftApp, ctx: &egui::Context) -> egui::Pos2 {
|
||
app.session.prefs.placeholder_text = false;
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
typing_frame(app, ctx, vec![]);
|
||
app.select_tool("type");
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let at = xf.to_screen(Point::new(100.0, 100.0));
|
||
let click =
|
||
|p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
typing_frame(app, ctx, vec![egui::Event::PointerMoved(at), click(at, true)]);
|
||
typing_frame(app, ctx, vec![click(at, false)]);
|
||
at
|
||
}
|
||
|
||
/// The outgoing descriptor carries the committed logical text and UTF-8 offsets, keeps a
|
||
/// reversed selection reversed, and labels only accepted IME mutations `InputMethod`.
|
||
#[test]
|
||
fn ime_output_carries_text_offsets_and_explicit_cause() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
let ctx = egui::Context::default();
|
||
type_at(&mut app, &ctx);
|
||
let empty = typing_frame(&mut app, &ctx, vec![]).expect("the Type tool owns the IME");
|
||
let payload = empty.surrounding_text.available().expect("an empty document is available");
|
||
assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("", 0, 0));
|
||
assert_eq!(empty.change_cause, egui::output::ImeChangeCause::Other);
|
||
|
||
let typed = typing_frame(&mut app, &ctx, vec![egui::Event::Text("Héllo".into())]).unwrap();
|
||
let payload = typed.surrounding_text.available().unwrap();
|
||
assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("Héllo", 6, 6));
|
||
assert_eq!(typed.change_cause, egui::output::ImeChangeCause::Other, "ordinary typing is application-origin");
|
||
|
||
let shift_left =
|
||
|| egui::Event::Key { key: egui::Key::ArrowLeft, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::SHIFT };
|
||
// "Héllo" has five characters: four Shift+Lefts stop at the start of its two-byte é,
|
||
// leaving cursor 1 < anchor 6.
|
||
let mut reversed = None;
|
||
for _ in 0..4 {
|
||
reversed = typing_frame(&mut app, &ctx, vec![shift_left()]);
|
||
}
|
||
let reversed = reversed.unwrap();
|
||
let payload = reversed.surrounding_text.available().unwrap();
|
||
assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("Héllo", 1, 6), "cursor > anchor stays reversed");
|
||
|
||
// One accepted IME deletion (one byte before the selection) then the commit: both receipts
|
||
// chain, and the commit still replaces the preserved selection ("éllo").
|
||
let ime = typing_frame(
|
||
&mut app,
|
||
&ctx,
|
||
vec![
|
||
egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 1, after_bytes: 0 }),
|
||
egui::Event::Ime(egui::ImeEvent::Commit("Z".into())),
|
||
],
|
||
)
|
||
.unwrap();
|
||
let payload = ime.surrounding_text.available().unwrap();
|
||
assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("Z", 1, 1));
|
||
assert_eq!(ime.change_cause, egui::output::ImeChangeCause::InputMethod);
|
||
|
||
let ordinary = typing_frame(&mut app, &ctx, vec![egui::Event::Text("!".into())]).unwrap();
|
||
assert_eq!(ordinary.change_cause, egui::output::ImeChangeCause::Other, "typing after a commit is Other");
|
||
}
|
||
|
||
/// A pre-edit is excluded from the outgoing text; committing the composition is the accepted
|
||
/// IME mutation that earns `InputMethod`.
|
||
#[test]
|
||
fn ime_output_excludes_the_preedit_and_marks_the_commit() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
let ctx = egui::Context::default();
|
||
type_at(&mut app, &ctx);
|
||
typing_frame(&mut app, &ctx, vec![egui::Event::Text("ab".into())]);
|
||
let marked = typing_frame(&mut app, &ctx, vec![preedit("xy", 2)]).unwrap();
|
||
let payload = marked.surrounding_text.available().unwrap();
|
||
assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("ab", 2, 2), "the marked text is not committed text");
|
||
let committed = typing_frame(&mut app, &ctx, vec![preedit("", 0), egui::Event::Ime(egui::ImeEvent::Commit("xy".into()))]).unwrap();
|
||
let payload = committed.surrounding_text.available().unwrap();
|
||
assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("abxy", 4, 4));
|
||
assert_eq!(committed.change_cause, egui::output::ImeChangeCause::InputMethod);
|
||
}
|
||
|
||
/// An application edit anywhere in the frame dominates an IME receipt in it.
|
||
#[test]
|
||
fn ime_output_marks_mixed_application_and_ime_mutations_other() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
let ctx = egui::Context::default();
|
||
type_at(&mut app, &ctx);
|
||
typing_frame(&mut app, &ctx, vec![egui::Event::Text("ab".into())]);
|
||
let ime = typing_frame(
|
||
&mut app,
|
||
&ctx,
|
||
vec![
|
||
egui::Event::Text("x".into()),
|
||
egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 1, after_bytes: 0 }),
|
||
egui::Event::Ime(egui::ImeEvent::Commit("y".into())),
|
||
],
|
||
)
|
||
.unwrap();
|
||
let payload = ime.surrounding_text.available().unwrap();
|
||
assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("aby", 3, 3));
|
||
assert_eq!(ime.change_cause, egui::output::ImeChangeCause::Other, "application edit before the IME dominates");
|
||
|
||
let pasted = typing_frame(&mut app, &ctx, vec![egui::Event::Paste("c".into())]).unwrap();
|
||
let payload = pasted.surrounding_text.available().unwrap();
|
||
assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("abyc", 4, 4));
|
||
assert_eq!(pasted.change_cause, egui::output::ImeChangeCause::Other, "paste is application-origin");
|
||
|
||
// The other order: an accepted IME commit followed by an application edit in the same
|
||
// frame is still `Other`.
|
||
let ime_then_text =
|
||
typing_frame(&mut app, &ctx, vec![egui::Event::Ime(egui::ImeEvent::Commit("y".into())), egui::Event::Text("x".into())]).unwrap();
|
||
let payload = ime_then_text.surrounding_text.available().unwrap();
|
||
assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("abycyx", 6, 6));
|
||
assert_eq!(ime_then_text.change_cause, egui::output::ImeChangeCause::Other, "application edit after the IME dominates");
|
||
}
|
||
|
||
/// Leaving and re-entering the same text object keeps the owner but starts a new generation.
|
||
#[test]
|
||
fn ime_owner_replacement_starts_a_new_generation() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.prefs.placeholder_text = false;
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.session.execute("text.create", &json!({"x": 100, "y": 100, "text": "Same", "size": 24})).unwrap()["id"].as_u64().unwrap();
|
||
let ctx = egui::Context::default();
|
||
typing_frame(&mut app, &ctx, vec![]);
|
||
app.select_tool("type");
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let center = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap().center();
|
||
let at = xf.to_screen(center);
|
||
let click =
|
||
|p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(at, true)]);
|
||
typing_frame(&mut app, &ctx, vec![click(at, false)]);
|
||
let first = typing_frame(&mut app, &ctx, vec![]).expect("the text is being edited");
|
||
assert_eq!(first.owner, egui::Id::unique(("vectorcraft-type-ime", app.session.active().unwrap().uid, id)));
|
||
let escape = |pressed| egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed, repeat: false, modifiers: Default::default() };
|
||
typing_frame(&mut app, &ctx, vec![escape(true)]);
|
||
typing_frame(&mut app, &ctx, vec![escape(false)]);
|
||
assert!(typing_frame(&mut app, &ctx, vec![]).is_none(), "Escape ends the edit context");
|
||
app.select_tool("type");
|
||
typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(at, true)]);
|
||
typing_frame(&mut app, &ctx, vec![click(at, false)]);
|
||
let second = typing_frame(&mut app, &ctx, vec![]).expect("re-entering starts another context");
|
||
assert_eq!(second.owner, first.owner, "the owner key is the document and text object");
|
||
assert_ne!(second.context_generation, first.context_generation, "a replacement epoch resets once");
|
||
}
|
||
|
||
/// One tracker serves several native viewports: each gets its own generation for the same
|
||
/// editor, and an unchanged second pass in either keeps it.
|
||
#[test]
|
||
fn ime_context_is_scoped_per_viewport() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
let ctx = egui::Context::default();
|
||
type_at(&mut app, &ctx);
|
||
let aux = egui::ViewportId::from_hash_of("vectorcraft-ime-aux");
|
||
let frame = |app: &mut VectorcraftApp, viewport: egui::ViewportId| {
|
||
let mut raw = egui::RawInput {
|
||
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))),
|
||
viewport_id: viewport,
|
||
..Default::default()
|
||
};
|
||
raw.viewports.entry(viewport).or_default();
|
||
let mut out = ctx.run_ui(raw, |ui| {
|
||
crate::shortcuts::handle(app, ui.ctx());
|
||
show(app, ui);
|
||
crate::ime::end_of_pass(app, ui.ctx());
|
||
});
|
||
out.textures_delta.clear();
|
||
out.platform_output.ime.expect("the Type tool owns the IME in both viewports")
|
||
};
|
||
let root = egui::ViewportId::ROOT;
|
||
let root_first = frame(&mut app, root);
|
||
let aux_first = frame(&mut app, aux);
|
||
assert_ne!(root_first.context_generation, aux_first.context_generation, "viewport state must not collide");
|
||
let root_second = frame(&mut app, root);
|
||
let aux_second = frame(&mut app, aux);
|
||
assert_eq!(root_second.context_generation, root_first.context_generation);
|
||
assert_eq!(aux_second.context_generation, aux_first.context_generation);
|
||
}
|
||
|
||
/// A minimized/occluded viewport has no IME owner: the descriptor disappears, and the next
|
||
/// visible pass starts a new generation instead of inheriting the old context.
|
||
#[test]
|
||
fn hidden_viewport_observes_no_ime_owner() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
let ctx = egui::Context::default();
|
||
type_at(&mut app, &ctx);
|
||
let root = egui::ViewportId::ROOT;
|
||
let visible = typing_frame(&mut app, &ctx, vec![]).unwrap();
|
||
let mut raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() };
|
||
raw.viewports.entry(root).or_default().minimized = Some(true);
|
||
let mut out = ctx.run_ui(raw, |ui| {
|
||
crate::shortcuts::handle(&mut app, ui.ctx());
|
||
show(&mut app, ui);
|
||
crate::ime::end_of_pass(&mut app, ui.ctx());
|
||
});
|
||
out.textures_delta.clear();
|
||
assert!(out.platform_output.ime.is_none(), "a hidden viewport has no IME owner");
|
||
let again = typing_frame(&mut app, &ctx, vec![]).expect("the visible editor comes back");
|
||
assert_ne!(again.context_generation, visible.context_generation, "refocus starts a new generation");
|
||
}
|
||
|
||
/// A frame that is discarded and drawn again sees the mutation in both passes, and its
|
||
/// unchanged second pass keeps the first pass's `InputMethod` cause and generation.
|
||
#[test]
|
||
fn ime_observation_runs_in_every_pass_of_a_discarded_frame() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
let ctx = egui::Context::default();
|
||
type_at(&mut app, &ctx);
|
||
let mut first = true;
|
||
let mut seen = vec![];
|
||
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() };
|
||
let mut out = ctx.run_ui(raw, |ui| {
|
||
if first {
|
||
first = false;
|
||
let view = app.view_info();
|
||
let target = crate::ime::target(&mut app.session, view);
|
||
let before = crate::ime::stamp(&mut app.session, view);
|
||
app.session.tool_text("x", view).unwrap();
|
||
crate::ime::note_ime_apply(&mut app, ui.ctx(), view, target, before);
|
||
ui.ctx().request_discard("ime observation second pass");
|
||
}
|
||
crate::shortcuts::handle(&mut app, ui.ctx());
|
||
show(&mut app, ui);
|
||
crate::ime::end_of_pass(&mut app, ui.ctx());
|
||
seen.push(ui.ctx().output(|o| o.ime.clone()).expect("IME output in this pass"));
|
||
});
|
||
out.textures_delta.clear();
|
||
assert_eq!(seen.len(), 2, "the frame really ran two passes");
|
||
assert_eq!(seen[0].context_generation, seen[1].context_generation, "the discarded pass is not a new context");
|
||
assert_eq!(seen[0].change_cause, egui::output::ImeChangeCause::InputMethod);
|
||
assert_eq!(seen[1].change_cause, egui::output::ImeChangeCause::InputMethod, "an unchanged pass retains the frame cause");
|
||
let payload = seen[1].surrounding_text.available().unwrap();
|
||
assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("x", 1, 1));
|
||
}
|
||
|
||
#[test]
|
||
fn tool_drag_moved_without_pointer_delta() {
|
||
let ctx = egui::Context::default();
|
||
let rect = egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0));
|
||
let v = View::default();
|
||
let xf = Xf::new(rect, &v);
|
||
let a = pos2(100.0, 100.0);
|
||
let b = pos2(150.0, 120.0);
|
||
let mut first = false;
|
||
let mut second = false;
|
||
let mut out =
|
||
ctx.run_ui(egui::RawInput { screen_rect: Some(rect), events: vec![egui::Event::PointerMoved(a)], ..Default::default() }, |ui| {
|
||
let pointer = ui.input(|i| i.pointer.clone());
|
||
first = tool_drag_moved(ui, &xf, a, &pointer);
|
||
second = tool_drag_moved(ui, &xf, a, &pointer);
|
||
});
|
||
out.textures_delta.clear();
|
||
assert!(first, "the first drag sample at a position runs");
|
||
assert!(!second, "the same position without a delta is not sent again");
|
||
let mut moved = false;
|
||
out = ctx.run_ui(egui::RawInput { screen_rect: Some(rect), events: vec![egui::Event::PointerMoved(b)], ..Default::default() }, |ui| {
|
||
let pointer = ui.input(|i| i.pointer.clone());
|
||
moved = tool_drag_moved(ui, &xf, b, &pointer);
|
||
});
|
||
out.textures_delta.clear();
|
||
assert!(moved, "a new document position runs even when egui reports no delta");
|
||
}
|
||
|
||
/// #834 review: a press held still is not a drag. Before the press has dragged, the first held
|
||
/// frame without a delta at the press's own position sends nothing, so a click stays a click;
|
||
/// the first move after it does.
|
||
#[test]
|
||
fn stationary_press_is_not_a_drag() {
|
||
let ctx = egui::Context::default();
|
||
let rect = egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0));
|
||
let v = View::default();
|
||
let xf = Xf::new(rect, &v);
|
||
let a = pos2(100.0, 100.0);
|
||
let b = pos2(150.0, 120.0);
|
||
let mut out =
|
||
ctx.run_ui(egui::RawInput { screen_rect: Some(rect), events: vec![egui::Event::PointerMoved(a)], ..Default::default() }, |ui| {
|
||
ui.data_mut(|d| {
|
||
d.remove::<Point>(tool_drag_doc_id());
|
||
d.insert_temp(tool_press_doc_id(), xf.to_doc(a));
|
||
});
|
||
});
|
||
out.textures_delta.clear();
|
||
let mut held = true;
|
||
out = ctx.run_ui(egui::RawInput { screen_rect: Some(rect), ..Default::default() }, |ui| {
|
||
let pointer = ui.input(|i| i.pointer.clone());
|
||
assert_eq!(pointer.delta(), egui::Vec2::ZERO);
|
||
held = tool_drag_moved(ui, &xf, a, &pointer);
|
||
});
|
||
out.textures_delta.clear();
|
||
assert!(!held, "a press held still at its own position is not sent as a drag");
|
||
let mut moved = false;
|
||
out = ctx.run_ui(egui::RawInput { screen_rect: Some(rect), events: vec![egui::Event::PointerMoved(b)], ..Default::default() }, |ui| {
|
||
let pointer = ui.input(|i| i.pointer.clone());
|
||
moved = tool_drag_moved(ui, &xf, b, &pointer);
|
||
});
|
||
out.textures_delta.clear();
|
||
assert!(moved, "the first move after the press is a drag");
|
||
}
|
||
|
||
/// #834: a bezier drag's preview follows every position the pointer is seen at. egui's frame
|
||
/// delta is zero in the frame the pointer comes back into the window after leaving it
|
||
/// (`PointerGone` forgets where it was), so a drag gated on it kept the preview where the
|
||
/// pointer left until the next move; a move in the release's own frame was lost the same way,
|
||
/// and the release committed the stale shape. A click's jitter in its release frame stays a
|
||
/// click.
|
||
#[test]
|
||
fn a_bezier_drag_preview_follows_the_pointer_back_into_the_window_and_to_the_release() {
|
||
use egui::{Event, Modifiers};
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.ui.view.smart_guides = false;
|
||
app.ui.view.snap_to_point = false;
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
|
||
let near = |p: Point, q: Pos2| p.distance(xf.to_doc(q)) < 1e-3;
|
||
let first_anchor = |app: &VectorcraftApp| {
|
||
let st = app.session.active().unwrap();
|
||
st.doc.node(st.selection.objects[0]).unwrap().path_data().unwrap().subpaths[0].anchors[0]
|
||
};
|
||
// The Pen pulls a handle out of a new anchor: the pointer leaves the window mid-drag, comes
|
||
// back further on and stops there, then moves once more in the frame it lets go.
|
||
app.select_tool("pen");
|
||
let a = app.canvas_rect.unwrap().center() - vec2(100.0, 60.0);
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(a)]);
|
||
frame(&mut app, &ctx, vec![button(a, true)]);
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(a + vec2(40.0, 0.0))]);
|
||
assert!(near(first_anchor(&app).h_out, a + vec2(40.0, 0.0)), "{:?}", first_anchor(&app));
|
||
frame(&mut app, &ctx, vec![Event::PointerGone]);
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(a + vec2(80.0, 0.0))]);
|
||
assert!(near(first_anchor(&app).h_out, a + vec2(80.0, 0.0)), "back in the window: {:?}", first_anchor(&app));
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert!(near(first_anchor(&app).h_out, a + vec2(80.0, 0.0)), "still: {:?}", first_anchor(&app));
|
||
let end = a + vec2(100.0, 20.0);
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(end), button(end, false)]);
|
||
assert!(near(first_anchor(&app).h_out, end), "released: {:?}", first_anchor(&app));
|
||
assert!(app.session.active().unwrap().interaction.is_none(), "committed");
|
||
// Direct Selection moving that anchor: the same path through the engine's previews.
|
||
app.select_tool("directSelection");
|
||
let anchor_at = xf.to_screen(first_anchor(&app).p);
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(anchor_at)]);
|
||
frame(&mut app, &ctx, vec![button(anchor_at, true)]);
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(anchor_at + vec2(30.0, 30.0))]);
|
||
assert!(near(first_anchor(&app).p, anchor_at + vec2(30.0, 30.0)), "{:?}", first_anchor(&app));
|
||
frame(&mut app, &ctx, vec![Event::PointerGone]);
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(anchor_at + vec2(60.0, 30.0))]);
|
||
assert!(near(first_anchor(&app).p, anchor_at + vec2(60.0, 30.0)), "back in the window: {:?}", first_anchor(&app));
|
||
let end = anchor_at + vec2(60.0, 50.0);
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(end), button(end, false)]);
|
||
assert!(near(first_anchor(&app).p, end), "released: {:?}", first_anchor(&app));
|
||
// A click whose release frame brings a little jitter is still a click: the Pen places a
|
||
// corner anchor (no handle pulled out by the jitter) and the anchor stays where pressed.
|
||
app.session.execute("select.none", &json!({})).unwrap();
|
||
app.select_tool("pen");
|
||
let c = app.canvas_rect.unwrap().center() + vec2(120.0, 80.0);
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(c)]);
|
||
frame(&mut app, &ctx, vec![button(c, true), Event::PointerMoved(c + vec2(0.6, 0.2)), button(c + vec2(0.6, 0.2), false)]);
|
||
frame(&mut app, &ctx, vec![]);
|
||
// (egui hands the press the frame's last pointer position, so it lands within the jitter.)
|
||
let a = first_anchor(&app);
|
||
assert!(a.p.distance(xf.to_doc(c)) < 1.0 && a.h_out == a.p && a.h_in == a.p, "corner anchor where pressed: {a:?}");
|
||
assert!(app.session.active().unwrap().interaction.is_none(), "committed");
|
||
}
|
||
|
||
#[test]
|
||
fn frame_moves_keeps_touch_samples_and_pressure_but_leaves_mouse_unchanged() {
|
||
let ctx = egui::Context::default();
|
||
let a = pos2(10.0, 20.0);
|
||
let b = pos2(20.0, 30.0);
|
||
let c = pos2(30.0, 40.0);
|
||
let events = vec![egui::Event::PointerMoved(a), egui::Event::PointerMoved(b), egui::Event::PointerMoved(c)];
|
||
let mut mouse = vec![];
|
||
let mut out = ctx.run_ui(egui::RawInput { events: events.clone(), ..Default::default() }, |ui| mouse = frame_moves(ui, None));
|
||
out.textures_delta.clear();
|
||
assert!(mouse.is_empty());
|
||
// A macOS pen: the feed kept a pressure for each move, the last for the last.
|
||
let feed = StylusFeed::default();
|
||
for p in [0.9, 0.3, 0.0, 0.5] {
|
||
feed.set_move(crate::stylus::PenSample { pressure: p, eraser: false });
|
||
}
|
||
let mut samples = vec![];
|
||
let mut out = ctx.run_ui(egui::RawInput { events: events.clone(), ..Default::default() }, |ui| samples = frame_moves(ui, Some(&feed)));
|
||
out.textures_delta.clear();
|
||
assert_eq!(samples, [(a, Some(0.3)), (b, None)], "paired from the end; a lift's 0 is no pressure");
|
||
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| samples = frame_moves(ui, Some(&feed)));
|
||
out.textures_delta.clear();
|
||
assert!(samples.is_empty(), "the moves were taken: a mouse's moves again");
|
||
let events = [pen(egui::TouchPhase::Move, a, 0.2), pen(egui::TouchPhase::Move, b, 0.8), pen(egui::TouchPhase::Move, c, 0.6)].concat();
|
||
let mut samples = vec![];
|
||
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| samples = frame_moves(ui, None));
|
||
out.textures_delta.clear();
|
||
assert_eq!(samples, [(a, Some(0.2)), (b, Some(0.8))], "the last sample follows the normal pointer path");
|
||
let events = (0..200).flat_map(|n| pen(egui::TouchPhase::Move, pos2(n as f32, 0.0), 0.5)).collect();
|
||
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| samples = frame_moves(ui, None));
|
||
out.textures_delta.clear();
|
||
assert!(samples.len() <= 64, "a stalled frame stays bounded");
|
||
}
|
||
|
||
#[test]
|
||
fn long_touch_drops_a_tool_press_without_committing_art() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width":800,"height":600})).unwrap();
|
||
app.select_tool("rectangle");
|
||
let ctx = egui::Context::default();
|
||
let tick = |app: &mut VectorcraftApp, time, events| {
|
||
let raw = egui::RawInput {
|
||
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))),
|
||
time: Some(time),
|
||
events,
|
||
..Default::default()
|
||
};
|
||
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
|
||
out.textures_delta.clear();
|
||
};
|
||
tick(&mut app, 0.0, vec![]);
|
||
let pos = app.canvas_rect.unwrap().center();
|
||
tick(&mut app, 0.1, pen(egui::TouchPhase::Start, pos, 0.5));
|
||
assert!(matches!(ctx.data(|d| d.get_temp::<Drag>(drag_id())), Some(Drag::Tool)));
|
||
tick(&mut app, 1.5, vec![]);
|
||
assert!(matches!(ctx.data(|d| d.get_temp::<Drag>(drag_id())), Some(Drag::Gesture)), "long touch drops the tool drag");
|
||
assert!(app.session.active().unwrap().interaction.is_none());
|
||
tick(&mut app, 1.6, pen(egui::TouchPhase::End, pos, 0.5));
|
||
assert!(app.session.active().unwrap().doc.art_bounds().is_none(), "releasing cannot commit the cancelled press");
|
||
}
|
||
|
||
/// #2826: a second finger drops a move waiting over a panel, so its interaction doesn't
|
||
/// outlive the drag that began it.
|
||
#[test]
|
||
fn a_second_finger_drops_a_move_waiting_over_a_panel() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.prefs.touch_gestures = true;
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap();
|
||
app.select_tool("selection");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let before = app.session.active().unwrap().doc.clone();
|
||
let start = xf.to_screen(Point::new(100.0, 100.0));
|
||
// One finger presses on the art, moves it, and leaves the canvas for a panel.
|
||
frame(&mut app, &ctx, pen(egui::TouchPhase::Start, start, 0.5));
|
||
frame(&mut app, &ctx, pen(egui::TouchPhase::Move, start + vec2(40.0, 0.0), 0.5));
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos2(850.0, 100.0))]);
|
||
assert!(matches!(ctx.data(|d| d.get_temp::<Drag>(drag_id())), Some(Drag::Art)));
|
||
assert!(app.session.active().unwrap().interaction.is_some());
|
||
assert!(egui::DragAndDrop::payload::<widgets::PanelDrag>(&ctx).is_some());
|
||
// A second finger lands: the move is dropped, not left live without its pointer.
|
||
let second = egui::Event::Touch {
|
||
device_id: egui::TouchDeviceId(1),
|
||
id: egui::TouchId(2),
|
||
phase: egui::TouchPhase::Start,
|
||
pos: pos2(400.0, 300.0),
|
||
force: Some(0.5),
|
||
};
|
||
frame(&mut app, &ctx, vec![second]);
|
||
assert!(matches!(ctx.data(|d| d.get_temp::<Drag>(drag_id())), Some(Drag::Gesture)));
|
||
let st = app.session.active().unwrap();
|
||
assert!(st.interaction.is_none(), "the move is dropped");
|
||
assert_eq!(*st.doc, *before, "the art is back where it was");
|
||
assert!(egui::DragAndDrop::payload::<widgets::PanelDrag>(&ctx).is_none(), "no panel drag is left");
|
||
// Lifting the first finger commits nothing.
|
||
frame(&mut app, &ctx, pen(egui::TouchPhase::End, start + vec2(40.0, 0.0), 0.5));
|
||
assert_eq!(*app.session.active().unwrap().doc, *before);
|
||
}
|
||
|
||
/// The events eframe's winit integration sends for a pen (Windows Ink) or a finger: a touch
|
||
/// with its pressure, and the mouse it stands in for (#491).
|
||
fn pen(phase: egui::TouchPhase, pos: Pos2, force: f32) -> Vec<egui::Event> {
|
||
let touch = egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(1), phase, pos, force: Some(force) };
|
||
let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
match phase {
|
||
egui::TouchPhase::Start => vec![touch, egui::Event::PointerMoved(pos), button(true)],
|
||
egui::TouchPhase::Move => vec![touch, egui::Event::PointerMoved(pos)],
|
||
egui::TouchPhase::End | egui::TouchPhase::Cancel => vec![touch, button(false), egui::Event::PointerGone],
|
||
}
|
||
}
|
||
|
||
/// A pen stroke across `pts` (document points), one frame per sample, with its pressures.
|
||
fn pen_stroke(app: &mut VectorcraftApp, ctx: &egui::Context, pts: &[(f64, f64, f32)]) {
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let last = pts.len() - 1;
|
||
for (i, &(x, y, force)) in pts.iter().enumerate() {
|
||
let phase = if i == 0 { egui::TouchPhase::Start } else { egui::TouchPhase::Move };
|
||
frame(app, ctx, pen(phase, xf.to_screen(Point::new(x, y)), force));
|
||
if i == last {
|
||
frame(app, ctx, pen(egui::TouchPhase::End, xf.to_screen(Point::new(x, y)), force));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// #491: a pen or a finger draws as the mouse does, and its pressure reaches the tools that
|
||
/// use it; the pointer leaving with the lift doesn't move the stroke's end.
|
||
#[test]
|
||
fn pen_and_touch_input_drive_the_tools_with_their_pressure() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
app.select_tool("pencil");
|
||
pen_stroke(&mut app, &ctx, &[(100.0, 100.0, 0.5), (140.0, 120.0, 0.6), (180.0, 150.0, 0.7), (220.0, 200.0, 0.4)]);
|
||
let b = app.session.active().unwrap().doc.art_bounds().expect("the pen drew a path");
|
||
assert!(b.x0 > 90.0 && b.y0 > 90.0 && b.x1 < 230.0 && b.y1 < 210.0 && b.x1 > 200.0, "the stroke stays where the pen went: {b:?}");
|
||
|
||
// Bloat with Use Pressure Pen: each sample carries the pen's pressure.
|
||
app.session.execute("shape.rectangle", &json!({"x": 300, "y": 300, "width": 200, "height": 200})).unwrap();
|
||
app.select_tool("bloat");
|
||
app.session.set_tool_option("usePressure", &json!(true));
|
||
pen_stroke(&mut app, &ctx, &[(500.0, 350.0, 0.2), (500.0, 400.0, 0.5), (500.0, 420.0, 0.8)]);
|
||
let (cmd, p) = app.session.journal.last().unwrap().clone();
|
||
assert_eq!(cmd, "object.liquify");
|
||
let pressures: Vec<f64> = p["points"].as_array().unwrap().iter().map(|s| (s[2].as_f64().unwrap() * 100.0).round() / 100.0).collect();
|
||
assert_eq!(pressures.first(), Some(&0.2), "the press's own pressure: {pressures:?}");
|
||
assert!(pressures.contains(&0.5) && pressures.contains(&0.8), "{pressures:?}");
|
||
}
|
||
|
||
/// #764: on Linux under X11 a pen reaches egui as a mouse, and the host reads its pressure
|
||
/// itself (`Services::stylus`): the Paintbrush and Liquify's Use Pressure Pen get that
|
||
/// pressure, and once a mouse moves (`None`) a stroke presses fully.
|
||
#[test]
|
||
fn the_hosts_pen_pressure_drives_the_tools_under_mouse_events() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
let feed = StylusFeed::default();
|
||
app.services.stylus = Some(feed.clone());
|
||
app.session.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
// One frame per sample: the reader has set the pressure before the frame's mouse events.
|
||
let stroke = |app: &mut VectorcraftApp, pts: &[(f64, f64, Option<f32>)]| {
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let button =
|
||
|pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
let mut at = Pos2::ZERO;
|
||
for (i, &(x, y, pressure)) in pts.iter().enumerate() {
|
||
feed.set(pressure);
|
||
at = xf.to_screen(Point::new(x, y));
|
||
let mut events = vec![egui::Event::PointerMoved(at)];
|
||
if i == 0 {
|
||
events.push(button(at, true));
|
||
}
|
||
frame(app, &ctx, events);
|
||
}
|
||
frame(app, &ctx, vec![button(at, false)]);
|
||
};
|
||
let pressures = |p: &serde_json::Value| -> Vec<f64> {
|
||
// A point without a third value was pressed fully.
|
||
let pressure = |s: &serde_json::Value| s.get(2).and_then(serde_json::Value::as_f64).map_or(1.0, |f| (f * 100.0).round() / 100.0);
|
||
p["points"].as_array().unwrap().iter().map(pressure).collect()
|
||
};
|
||
|
||
app.select_tool("paintbrush");
|
||
stroke(&mut app, &[(100.0, 100.0, Some(0.3)), (140.0, 120.0, Some(0.6)), (180.0, 150.0, Some(0.9))]);
|
||
let (cmd, p) = app.session.journal.last().unwrap().clone();
|
||
assert_eq!(cmd, "path.freehand");
|
||
assert_eq!(pressures(&p), [0.3, 0.6, 0.9], "{p}");
|
||
// The lift reaches the feed before the frame of the stroke's last move: that move keeps the
|
||
// pressure the pen last touched with, not the lift's 0.
|
||
stroke(&mut app, &[(100.0, 300.0, Some(0.3)), (140.0, 320.0, Some(0.5)), (180.0, 350.0, Some(0.0))]);
|
||
let (cmd, p) = app.session.journal.last().unwrap().clone();
|
||
assert_eq!(cmd, "path.freehand");
|
||
assert_eq!(pressures(&p), [0.3, 0.5, 0.5], "{p}");
|
||
|
||
app.session.execute("shape.rectangle", &json!({"x": 300, "y": 300, "width": 200, "height": 200})).unwrap();
|
||
app.select_tool("bloat");
|
||
app.session.set_tool_option("usePressure", &json!(true));
|
||
stroke(&mut app, &[(500.0, 350.0, Some(0.2)), (500.0, 400.0, Some(0.5)), (500.0, 420.0, Some(0.8))]);
|
||
let (cmd, p) = app.session.journal.last().unwrap().clone();
|
||
assert_eq!(cmd, "object.liquify");
|
||
let pen = pressures(&p);
|
||
assert_eq!(pen.first(), Some(&0.2), "the press's own pressure: {pen:?}");
|
||
assert!(pen.contains(&0.5) && pen.contains(&0.8), "{pen:?}");
|
||
|
||
// A mouse moved last: no pen pressure.
|
||
stroke(&mut app, &[(350.0, 350.0, None), (350.0, 400.0, None), (350.0, 420.0, None)]);
|
||
let (cmd, p) = app.session.journal.last().unwrap().clone();
|
||
assert_eq!(cmd, "object.liquify");
|
||
assert!(pressures(&p).iter().all(|f| *f == 1.0), "{p}");
|
||
|
||
// A tap whose touch and lift both reach the feed before the frame that handles its press
|
||
// presses with the tap's pressure, not the lift's 0.
|
||
for p in [0.0, 0.4, 0.7, 0.0] {
|
||
feed.set(Some(p));
|
||
}
|
||
let at = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(450.0, 400.0));
|
||
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), button(true)]);
|
||
frame(&mut app, &ctx, vec![button(false)]);
|
||
let (cmd, p) = app.session.journal.last().unwrap().clone();
|
||
assert_eq!(cmd, "object.liquify");
|
||
assert_eq!(pressures(&p).first(), Some(&0.7), "{p}");
|
||
}
|
||
|
||
/// An app with an 800 × 600 document whose host reads the pen itself (`Services::stylus`),
|
||
/// after its first frame, with the pointer in the window.
|
||
fn pen_app() -> (VectorcraftApp, StylusFeed, egui::Context) {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
let feed = StylusFeed::default();
|
||
app.services.stylus = Some(feed.clone());
|
||
app.session.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(Pos2::new(400.0, 300.0))]);
|
||
(app, feed, ctx)
|
||
}
|
||
|
||
/// The pen turned to its eraser end, with `pressure`.
|
||
fn eraser_end(pressure: f32) -> Option<crate::stylus::PenSample> {
|
||
Some(crate::stylus::PenSample { pressure, eraser: true })
|
||
}
|
||
|
||
/// #764: turning the pen to its eraser end selects the Eraser, and turning it back to the tip
|
||
/// selects the tool before it, as the reference app's help describes ("Erase parts of a path
|
||
/// using a stylus pen"). Each turn acts once.
|
||
#[test]
|
||
fn the_eraser_end_selects_the_eraser_and_the_tip_brings_back_the_tool() {
|
||
let (mut app, feed, ctx) = pen_app();
|
||
app.select_tool("paintbrush");
|
||
feed.set(Some(0.0));
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert_eq!(app.session.tool_id(), "paintbrush", "the tip hovers");
|
||
feed.set_sample(eraser_end(0.0));
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert_eq!(app.session.tool_id(), "eraser", "the eraser end hovers");
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert_eq!(app.session.tool_id(), "eraser");
|
||
feed.set(Some(0.0));
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert_eq!(app.session.tool_id(), "paintbrush", "the tip brings back the tool before");
|
||
assert_eq!(app.ui.group_tool.get(vectorcraft_tools::catalog::group_of("paintbrush").unwrap()).map(String::as_str), Some("paintbrush"));
|
||
|
||
// A turn acts once: a tool that becomes active while the eraser end stays in stays, and
|
||
// the tip doesn't replace a tool other than the Eraser.
|
||
feed.set_sample(eraser_end(0.0));
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert_eq!(app.session.tool_id(), "eraser");
|
||
app.session.select_tool("pencil", app.view_info()).unwrap();
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert_eq!(app.session.tool_id(), "pencil");
|
||
feed.set(Some(0.0));
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert_eq!(app.session.tool_id(), "pencil");
|
||
}
|
||
|
||
/// #764: the eraser end erases what it crosses, also when it comes in and presses in one
|
||
/// frame; the tool before it does not paint a stroke.
|
||
#[test]
|
||
fn the_eraser_end_erases() {
|
||
let (mut app, feed, ctx) = pen_app();
|
||
app.session.execute("shape.rectangle", &json!({"x": 300, "y": 200, "width": 100, "height": 100})).unwrap();
|
||
app.select_tool("paintbrush");
|
||
let steps = app.session.journal.len();
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
let [a, b, c] = [280.0, 350.0, 420.0].map(|x| xf.to_screen(Point::new(x, 250.0)));
|
||
feed.set_sample(eraser_end(0.6));
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(a), button(a, true)]);
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(b)]);
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(c)]);
|
||
frame(&mut app, &ctx, vec![button(c, false)]);
|
||
let cmds: Vec<&str> = app.session.journal.since(steps).iter().map(|(c, _)| c.as_str()).collect();
|
||
assert_eq!(cmds, ["path.eraseRegion"], "one Eraser step, no Paintbrush stroke");
|
||
assert_eq!(app.session.active().unwrap().doc.layers[0].children().unwrap().len(), 2, "the rectangle cut in two");
|
||
}
|
||
|
||
/// #764: a turn of the pen while a press is held takes effect after the release.
|
||
#[test]
|
||
fn a_flip_during_a_press_waits_for_the_release() {
|
||
let (mut app, feed, ctx) = pen_app();
|
||
app.select_tool("paintbrush");
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
let [a, b, c] = [100.0, 150.0, 200.0].map(|x| xf.to_screen(Point::new(x, 100.0)));
|
||
feed.set(Some(0.5));
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(a), button(a, true)]);
|
||
feed.set_sample(eraser_end(0.5));
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(b)]);
|
||
assert_eq!(app.session.tool_id(), "paintbrush");
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(c), button(c, false)]);
|
||
assert_eq!(app.session.tool_id(), "paintbrush");
|
||
assert_eq!(app.session.journal.last().unwrap().0, "path.freehand", "the stroke ends with the Paintbrush");
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert_eq!(app.session.tool_id(), "eraser", "the frame after the release");
|
||
}
|
||
|
||
/// #764: a tool chosen while the eraser end stands in (the toolbar and shortcuts call
|
||
/// `select_tool`; menus and MCP run `tool.select`) stays when the tip comes back.
|
||
#[test]
|
||
fn a_tool_chosen_while_the_eraser_end_stands_in_stays() {
|
||
let (mut app, feed, ctx) = pen_app();
|
||
app.select_tool("paintbrush");
|
||
feed.set_sample(eraser_end(0.0));
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert_eq!(app.session.tool_id(), "eraser");
|
||
app.select_tool("rectangle");
|
||
feed.set(Some(0.0));
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert_eq!(app.session.tool_id(), "rectangle");
|
||
|
||
feed.set_sample(eraser_end(0.0));
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert_eq!(app.session.tool_id(), "eraser");
|
||
app.run("tool.select", json!({"tool": "eraser"})).unwrap();
|
||
feed.set(Some(0.0));
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert_eq!(app.session.tool_id(), "eraser", "the Eraser chosen again stays too");
|
||
}
|
||
|
||
/// #764: a mouse moving while the eraser end stands in does not change the tool, and an Eraser
|
||
/// chosen before the eraser end comes in stays when the tip comes back.
|
||
#[test]
|
||
fn a_mouse_and_an_explicit_eraser_change_nothing() {
|
||
let (mut app, feed, ctx) = pen_app();
|
||
app.select_tool("paintbrush");
|
||
feed.set(None);
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert_eq!(app.session.tool_id(), "paintbrush", "a mouse");
|
||
feed.set_sample(eraser_end(0.0));
|
||
frame(&mut app, &ctx, vec![]);
|
||
feed.set(None);
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert_eq!(app.session.tool_id(), "eraser", "a mouse between the turns");
|
||
feed.set(Some(0.0));
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert_eq!(app.session.tool_id(), "paintbrush");
|
||
|
||
app.select_tool("eraser");
|
||
feed.set_sample(eraser_end(0.0));
|
||
frame(&mut app, &ctx, vec![]);
|
||
feed.set(Some(0.0));
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert_eq!(app.session.tool_id(), "eraser", "chosen before the eraser end came in");
|
||
}
|
||
|
||
/// #764: the host reads the pen over every window. A turn made while the pointer is outside
|
||
/// this window takes effect when the pointer comes in, and a turn there and back does not
|
||
/// change the tool.
|
||
#[test]
|
||
fn a_turn_over_another_window_waits_for_the_pointer() {
|
||
let (mut app, feed, ctx) = pen_app();
|
||
app.select_tool("paintbrush");
|
||
feed.set(Some(0.0));
|
||
frame(&mut app, &ctx, vec![]);
|
||
let inside = egui::Event::PointerMoved(Pos2::new(400.0, 300.0));
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerGone]);
|
||
feed.set_sample(eraser_end(0.0));
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert_eq!(app.session.tool_id(), "paintbrush", "the pointer is in another window");
|
||
frame(&mut app, &ctx, vec![inside.clone()]);
|
||
assert_eq!(app.session.tool_id(), "eraser", "the pointer came in");
|
||
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerGone]);
|
||
feed.set(Some(0.0));
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert_eq!(app.session.tool_id(), "eraser");
|
||
frame(&mut app, &ctx, vec![inside.clone()]);
|
||
assert_eq!(app.session.tool_id(), "paintbrush");
|
||
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerGone]);
|
||
feed.set_sample(eraser_end(0.0));
|
||
frame(&mut app, &ctx, vec![]);
|
||
feed.set(Some(0.0));
|
||
frame(&mut app, &ctx, vec![inside]);
|
||
assert_eq!(app.session.tool_id(), "paintbrush", "turned there and back outside");
|
||
}
|
||
|
||
/// #764: the tip brings back the Perspective Grid tool as it was, with a grid hidden by Hide
|
||
/// Grid still hidden (choosing the tool afresh shows the grid).
|
||
#[test]
|
||
fn the_tip_brings_back_the_perspective_grid_tool_without_showing_the_grid() {
|
||
let (mut app, feed, ctx) = pen_app();
|
||
let visible =
|
||
|app: &VectorcraftApp| vectorcraft_tools::distort::perspective::PerspectiveGrid::current(&app.session.active().unwrap().doc).visible;
|
||
app.select_tool("perspectiveGrid");
|
||
assert!(visible(&app));
|
||
app.session.execute("perspective.grid.show", &json!({"visible": false})).unwrap();
|
||
feed.set(Some(0.0));
|
||
frame(&mut app, &ctx, vec![]);
|
||
feed.set_sample(eraser_end(0.0));
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert_eq!(app.session.tool_id(), "eraser");
|
||
feed.set(Some(0.0));
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert_eq!(app.session.tool_id(), "perspectiveGrid");
|
||
assert!(!visible(&app), "the grid stays hidden");
|
||
}
|
||
|
||
/// #491: a pen tap whose press and lift come in one frame selects what it tapped, and the
|
||
/// lift (the pointer gone with it) doesn't drag the selection to the canvas's centre.
|
||
#[test]
|
||
fn a_pen_tap_within_one_frame_clicks_where_it_tapped() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||
let id = app.session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||
app.session.execute("select.none", &json!({})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
app.select_tool("selection");
|
||
let before = app.session.active().unwrap().doc.art_bounds();
|
||
let at = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(150.0, 150.0));
|
||
let mut tap = pen(egui::TouchPhase::Start, at, 0.5);
|
||
tap.extend(pen(egui::TouchPhase::End, at, 0.5));
|
||
frame(&mut app, &ctx, tap);
|
||
frame(&mut app, &ctx, vec![]);
|
||
let doc = app.session.active().unwrap();
|
||
assert_eq!(doc.selection.objects, vec![vectorcraft_doc::NodeId(id)]);
|
||
assert_eq!(doc.doc.art_bounds(), before, "the tap moved nothing");
|
||
}
|
||
|
||
/// The Contextual Task Bar shows Recolor Artwork (a palette) when the selection has colours,
|
||
/// and it opens the Recolor Artwork dialog; art painted with None has no button.
|
||
#[test]
|
||
fn the_task_bar_recolors_coloured_art() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.session.execute("shape.rectangle", &json!({"x": 180, "y": 130, "width": 40, "height": 40})).unwrap()["id"].clone();
|
||
app.session.execute("paint.setFill", &json!({"ids": [id], "color": "#cc3322"})).unwrap();
|
||
app.select_tool("selection");
|
||
let ctx = egui::Context::default();
|
||
let click = |app: &mut VectorcraftApp| {
|
||
frame(app, &ctx, vec![]);
|
||
frame(app, &ctx, vec![]);
|
||
let bar = task_bar_rect(&ctx).expect("the task bar shows");
|
||
// Right to left: the frame's margin, More Options, Lock, then Recolor.
|
||
let at = pos2(bar.right() - 8.0 - 30.0 - 6.0 - 30.0 - 6.0 - 15.0, bar.center().y);
|
||
let button =
|
||
|pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
frame(app, &ctx, vec![egui::Event::PointerMoved(at)]);
|
||
frame(app, &ctx, vec![button(true)]);
|
||
frame(app, &ctx, vec![button(false)]);
|
||
};
|
||
let st = app.session.active().unwrap();
|
||
assert!(has_colors(&st.doc, &st.selection.objects));
|
||
click(&mut app);
|
||
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(crate::dialogs::recolor::KIND));
|
||
|
||
app.ui.dialog = None;
|
||
app.session.execute("paint.setFill", &json!({"ids": [id], "none": true})).unwrap();
|
||
app.session.execute("paint.setStroke", &json!({"ids": [id], "none": true})).unwrap();
|
||
let st = app.session.active().unwrap();
|
||
assert!(!has_colors(&st.doc, &st.selection.objects), "None paints have no colours");
|
||
click(&mut app);
|
||
assert!(app.ui.dialog.is_none(), "no Recolor button without colours");
|
||
}
|
||
|
||
/// The Contextual Task Bar shows Image Trace first for a selected image: it traces the image
|
||
/// with the Default preset and leaves the Image Trace panel as it was. For the Image Trace
|
||
/// object it made, the first button is Expand, which keeps only the traced paths.
|
||
#[test]
|
||
fn the_task_bar_traces_an_image_then_expands_the_trace() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let img = image::RgbaImage::from_fn(20, 20, |x, _| image::Rgba(if x < 10 { [0, 0, 0, 255] } else { [255, 255, 255, 255] }));
|
||
let mut png = vec![];
|
||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||
let data = vectorcraft_format::base64_encode(&png);
|
||
app.run("file.place", json!({"name": "half.png", "dataBase64": data, "link": false, "at": [200, 150]})).unwrap();
|
||
app.select_tool("selection");
|
||
let ctx = egui::Context::default();
|
||
let click_first = |app: &mut VectorcraftApp| {
|
||
frame(app, &ctx, vec![]);
|
||
frame(app, &ctx, vec![]);
|
||
let bar = task_bar_rect(&ctx).expect("the task bar shows");
|
||
// Left to right: the frame's margin, the handle and its spacing, then the first button.
|
||
let at = pos2(bar.left() + 8.0 + 4.0 + 6.0 + 12.0, bar.center().y);
|
||
let button =
|
||
|pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
frame(app, &ctx, vec![egui::Event::PointerMoved(at)]);
|
||
frame(app, &ctx, vec![button(true)]);
|
||
frame(app, &ctx, vec![button(false)]);
|
||
};
|
||
let first = |app: &VectorcraftApp| {
|
||
let st = app.session.active().unwrap();
|
||
st.selection.objects.first().and_then(|id| st.doc.node(*id)).cloned().unwrap()
|
||
};
|
||
assert!(matches!(first(&app).kind, NodeKind::Image(_)));
|
||
click_first(&mut app);
|
||
assert_eq!(first(&app).name.as_deref(), Some("Image Trace"), "the image was traced");
|
||
assert_eq!(app.ui.open_panel, None, "the Image Trace panel is left as it was");
|
||
|
||
click_first(&mut app);
|
||
let expanded = first(&app);
|
||
assert_ne!(expanded.name.as_deref(), Some("Image Trace"), "the trace was expanded");
|
||
let mut images = 0;
|
||
expanded.walk(&mut |n| images += usize::from(matches!(n.kind, NodeKind::Image(_))));
|
||
assert_eq!(images, 0, "only the traced paths are kept");
|
||
}
|
||
|
||
/// The labels the Contextual Task Bar draws in a headless frame (after one to lay it out), with
|
||
/// their rects; none while it isn't shown. The SVG loaders are installed so the icons draw as
|
||
/// images instead of egui's ⚠ placeholder, which the label list would otherwise pick up.
|
||
fn task_bar_labels(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec<(String, egui::Rect)> {
|
||
egui_extras::install_image_loaders(ctx);
|
||
frame(app, ctx, vec![]);
|
||
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() };
|
||
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
|
||
out.textures_delta.clear();
|
||
let Some(bar) = task_bar_rect(ctx) else { return vec![] };
|
||
out.shapes
|
||
.iter()
|
||
.filter_map(|c| match &c.shape {
|
||
egui::Shape::Text(t) if bar.contains(t.visual_bounding_rect().center()) => {
|
||
Some((t.galley.text().to_string(), t.visual_bounding_rect()))
|
||
}
|
||
_ => None,
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// With the Artboard tool, the Contextual Task Bar sits under the selected artboards and offers
|
||
/// Resize, Duplicate, Rearrange All (with two or more artboards) and Export instead of the
|
||
/// selected objects' actions. With the Selection tool it offers the objects' actions again, and
|
||
/// with nothing selected it doesn't show.
|
||
#[test]
|
||
fn the_task_bar_offers_the_artboard_tools_actions() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
|
||
app.select_tool("selection");
|
||
let ctx = egui::Context::default();
|
||
let names = |app: &mut VectorcraftApp| task_bar_labels(app, &ctx).into_iter().map(|(t, _)| t).collect::<Vec<_>>();
|
||
assert!(names(&mut app).is_empty() && task_bar_rect(&ctx).is_none(), "nothing selected: no bar");
|
||
|
||
app.select_tool("artboard");
|
||
crate::panels::artboards::select(&mut app, 1);
|
||
// Artboard 2 in the middle of the window, at 100%.
|
||
let b = app.session.active().unwrap().doc.artboards[1].rect;
|
||
let view = app.view_mut().unwrap();
|
||
(view.center, view.zoom) = (b.center(), 1.0);
|
||
assert_eq!(names(&mut app), ["Resize", "Duplicate", "Rearrange All", "Export"]);
|
||
// Under artboard 2.
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let bar = task_bar_rect(&ctx).unwrap();
|
||
let center = xf.to_screen(b.center());
|
||
assert!(bar.top() > xf.to_screen(Point::new(b.x0, b.y1)).y && bar.x_range().contains(center.x), "{bar:?} under {b:?}");
|
||
|
||
// A selected object with the Selection tool: its own actions, none of the artboards'.
|
||
app.select_tool("selection");
|
||
app.session.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 40, "height": 40})).unwrap();
|
||
assert_eq!(names(&mut app), ["Edit Path", "Offset Path", "Simplify", "Duplicate"]);
|
||
|
||
// One artboard: nothing to rearrange.
|
||
app.session.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
|
||
app.select_tool("artboard");
|
||
assert_eq!(names(&mut app), ["Resize", "Duplicate", "Export"]);
|
||
}
|
||
|
||
/// The Artboard tool's Contextual Task Bar buttons act on its selected artboards: Resize opens
|
||
/// Artboard Options for the active one, Rearrange All opens Rearrange All Artboards, Export opens
|
||
/// Export for Screens with the selected artboards checked, and Duplicate copies each selected
|
||
/// artboard in one undo step and selects the copies.
|
||
#[test]
|
||
fn the_artboard_tools_task_bar_buttons_act_on_the_selected_artboards() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 3})).unwrap();
|
||
app.select_tool("artboard");
|
||
crate::panels::artboards::select(&mut app, 2);
|
||
app.session.set_tool_option("selected", &json!([2, 0]));
|
||
let ctx = egui::Context::default();
|
||
let press = |app: &mut VectorcraftApp, label: &str| {
|
||
let labels = task_bar_labels(app, &ctx);
|
||
let at = labels.iter().find(|(t, _)| t == label).unwrap_or_else(|| panic!("{label} in {labels:?}")).1.center();
|
||
let button =
|
||
|pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
frame(app, &ctx, vec![egui::Event::PointerMoved(at)]);
|
||
frame(app, &ctx, vec![button(true)]);
|
||
frame(app, &ctx, vec![button(false)]);
|
||
app.ui.dialog.take()
|
||
};
|
||
let d = press(&mut app, "Resize").expect("Artboard Options");
|
||
assert_eq!((d.kind.as_str(), d.f64("index", 9.0)), ("artboardOptions", 2.0));
|
||
let d = press(&mut app, "Rearrange All").expect("Rearrange All Artboards");
|
||
assert_eq!(d.kind, crate::dialogs::rearrange_artboards::KIND);
|
||
let d = press(&mut app, "Export").expect("Export for Screens");
|
||
assert_eq!((d.kind.as_str(), &d.fields["boards"]), (crate::dialogs::EXPORT_FOR_SCREENS, &json!([true, false, true])));
|
||
|
||
let boards =
|
||
|app: &VectorcraftApp| app.session.active().unwrap().doc.artboards.iter().map(|a| (a.name.clone(), a.rect.size())).collect::<Vec<_>>();
|
||
let before = boards(&app);
|
||
assert!(press(&mut app, "Duplicate").is_none());
|
||
let after = boards(&app);
|
||
assert_eq!(after[..3], before[..]);
|
||
assert_eq!(after[3..], [("Artboard 1 copy".to_string(), before[0].1), ("Artboard 3 copy".to_string(), before[2].1)]);
|
||
assert_eq!(app.session.tool_options()["selected"], json!([4, 3]), "the copies are selected, artboard 3's copy active");
|
||
app.run("edit.undo", json!({})).unwrap();
|
||
assert_eq!(boards(&app), before, "one undo step");
|
||
}
|
||
|
||
/// With anchors direct-selected, the Contextual Task Bar offers Remove Anchor Points, Connect,
|
||
/// Cut Path, Corner and Smooth. Smooth and Corner convert the selected anchor, Cut Path opens
|
||
/// the path there and Connect closes it again, as the Control bar's anchor buttons do.
|
||
#[test]
|
||
fn the_task_bar_connects_cuts_and_converts_direct_selected_anchors() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.session.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 80})).unwrap()["id"].as_u64().unwrap();
|
||
app.select_tool("directSelection");
|
||
app.run("select.anchors", json!({"id": id, "anchors": [[0, 1]]})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
let names = |app: &mut VectorcraftApp| task_bar_labels(app, &ctx).into_iter().map(|(t, _)| t).collect::<Vec<_>>();
|
||
assert_eq!(names(&mut app), ["Remove Anchor Points", "Connect", "Cut Path", "Corner", "Smooth", "Duplicate"]);
|
||
let press = |app: &mut VectorcraftApp, label: &str| {
|
||
let labels = task_bar_labels(app, &ctx);
|
||
let at = labels.iter().find(|(t, _)| t == label).unwrap_or_else(|| panic!("{label} in {labels:?}")).1.center();
|
||
let button =
|
||
|pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
frame(app, &ctx, vec![egui::Event::PointerMoved(at)]);
|
||
frame(app, &ctx, vec![button(true)]);
|
||
frame(app, &ctx, vec![button(false)]);
|
||
};
|
||
let subpath = |app: &VectorcraftApp| {
|
||
app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().path_data().unwrap().subpaths[0].clone()
|
||
};
|
||
let last_undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.last().unwrap().label.clone();
|
||
press(&mut app, "Smooth");
|
||
let a = subpath(&app).anchors[1];
|
||
assert!(a.has_in() && a.has_out(), "smooth: {a:?}");
|
||
assert_eq!(last_undo(&app), "Convert Anchor Points");
|
||
press(&mut app, "Corner");
|
||
let a = subpath(&app).anchors[1];
|
||
assert!(!a.has_in() && !a.has_out(), "corner: {a:?}");
|
||
press(&mut app, "Cut Path");
|
||
assert_eq!(last_undo(&app), "Cut Path");
|
||
let sp = subpath(&app);
|
||
assert!(!sp.closed && sp.anchors.len() == 5, "the rectangle opens at the anchor: {sp:?}");
|
||
press(&mut app, "Connect");
|
||
assert_eq!(last_undo(&app), "Join");
|
||
let sp = subpath(&app);
|
||
assert!(sp.closed && sp.anchors.len() == 4, "the ends join again: {sp:?}");
|
||
}
|
||
|
||
/// The Contextual Task Bar's handle drags it: unpinned it keeps that offset as it follows the
|
||
/// selection, pinned it stays put when the selection changes, unpinned again it follows from
|
||
/// there, and Reset Bar Position puts it back under the selection. It never leaves the canvas,
|
||
/// also when the window shrinks.
|
||
#[test]
|
||
fn the_task_bar_is_dragged_by_its_handle_pinned_and_reset() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let mut square =
|
||
|x: f64, y: f64| app.session.execute("shape.rectangle", &json!({"x": x, "y": y, "width": 40, "height": 40})).unwrap()["id"].clone();
|
||
let (a, b) = (square(120.0, 40.0), square(160.0, 150.0));
|
||
app.select_tool("selection");
|
||
let ctx = egui::Context::default();
|
||
let bar = || task_bar_rect(&ctx).expect("the task bar shows");
|
||
let select = |app: &mut VectorcraftApp, id: &serde_json::Value| {
|
||
app.session.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||
frame(app, &ctx, vec![]);
|
||
frame(app, &ctx, vec![]);
|
||
bar().min
|
||
};
|
||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
let drag = |app: &mut VectorcraftApp, by: Vec2| {
|
||
let grip = pos2(bar().left() + 11.0, bar().center().y);
|
||
frame(app, &ctx, vec![egui::Event::PointerMoved(grip)]);
|
||
frame(app, &ctx, vec![button(grip, true)]);
|
||
frame(app, &ctx, vec![egui::Event::PointerMoved(grip + by)]);
|
||
frame(app, &ctx, vec![button(grip + by, false)]);
|
||
frame(app, &ctx, vec![]);
|
||
bar().min
|
||
};
|
||
let near = |p: Pos2, q: Pos2| assert!((p - q).length() < 1.5, "{p:?} is not at {q:?}");
|
||
let (under_a, under_b) = (select(&mut app, &a), select(&mut app, &b));
|
||
assert!(under_b.y > under_a.y + 100.0, "each bar sits under its selection");
|
||
|
||
// Dragged unpinned: it keeps its offset from the selection.
|
||
select(&mut app, &a);
|
||
let by = vec2(40.0, -30.0);
|
||
near(drag(&mut app, by), under_a + by);
|
||
near(select(&mut app, &b), under_b + by);
|
||
assert!(!app.ui.task_bar_place.pinned);
|
||
|
||
// Pinned: it stays where it is across selection changes, and the handle still moves it.
|
||
assert_eq!(app.run("window.taskBar.pin", json!({})).unwrap(), json!(true));
|
||
assert_eq!(crate::menus::checked(&app, "window.taskBar.pin", &json!({})), Some(true));
|
||
near(select(&mut app, &a), under_b + by);
|
||
let spot = drag(&mut app, vec2(-20.0, 10.0));
|
||
near(spot, under_b + by + vec2(-20.0, 10.0));
|
||
near(select(&mut app, &b), spot);
|
||
|
||
// Unpinned: it follows the selection from where it is, kept on the canvas.
|
||
assert_eq!(app.run("window.taskBar.pin", json!({"pinned": false})).unwrap(), json!(false));
|
||
near(select(&mut app, &a), spot);
|
||
let canvas = app.canvas_rect.unwrap();
|
||
let last = canvas.max - vec2(8.0, 8.0) - bar().size();
|
||
near(select(&mut app, &b), (under_b + (spot - under_a)).min(last));
|
||
assert!(app.run("window.taskBar.pin", json!({"pinned": "yes"})).is_err());
|
||
|
||
// Kept on the canvas: dragged far off, then pinned in the corner of a shrinking window.
|
||
near(drag(&mut app, vec2(-2000.0, -2000.0)), canvas.min + vec2(8.0, 8.0));
|
||
app.run("window.taskBar.pin", json!({"pinned": true})).unwrap();
|
||
drag(&mut app, vec2(2000.0, 2000.0));
|
||
near(bar().max, canvas.max - vec2(8.0, 8.0));
|
||
let small = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(640.0, 480.0))), ..Default::default() };
|
||
for _ in 0..2 {
|
||
let mut out = ctx.run_ui(small.clone(), |ui| show(&mut app, ui));
|
||
out.textures_delta.clear();
|
||
}
|
||
let canvas = app.canvas_rect.unwrap();
|
||
assert!(canvas.width() < 700.0, "the canvas shrank: {canvas:?}");
|
||
near(bar().max, canvas.max - vec2(8.0, 8.0));
|
||
|
||
// Reset: back under the selection, unpinned.
|
||
frame(&mut app, &ctx, vec![]);
|
||
app.run("window.taskBar.reset", json!({})).unwrap();
|
||
assert!(!app.ui.task_bar_place.pinned);
|
||
near(select(&mut app, &a), under_a);
|
||
near(select(&mut app, &b), under_b);
|
||
}
|
||
|
||
/// In a view turned 180° the Contextual Task Bar shows under the selection's lowest corner on
|
||
/// screen, not over the selection.
|
||
#[test]
|
||
fn the_task_bar_stays_under_the_selection_in_a_rotated_view() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
let id = app.session.execute("shape.rectangle", &json!({"x": 170, "y": 90, "width": 60, "height": 120})).unwrap()["id"].clone();
|
||
app.session.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||
app.select_tool("selection");
|
||
app.run("view.rotate", json!({"angle": 180})).unwrap();
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
frame(&mut app, &ctx, vec![]);
|
||
let bar = task_bar_rect(&ctx).expect("the task bar shows");
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let lowest = xf.quad(app.selection_bounds().unwrap()).iter().map(|c| c.y).fold(f32::NEG_INFINITY, f32::max);
|
||
assert!(bar.top() > lowest, "the bar {bar:?} starts below {lowest}");
|
||
}
|
||
|
||
fn ruler_guide_origin_ui(alt: bool, cancel: bool) {
|
||
use egui::{Event, Modifiers};
|
||
for (origin, preview, release) in [
|
||
(Point::new(17.0, 23.0), Point::new(222.0, 238.0), Point::new(232.0, 243.0)),
|
||
(Point::new(-17.0, -23.0), Point::new(223.0, 237.0), Point::new(233.0, 242.0)),
|
||
(Point::ZERO, Point::new(225.0, 235.0), Point::new(230.0, 245.0)),
|
||
] {
|
||
for source_vertical in [false, true] {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||
app.run("document.setRulerOrigin", json!({"x": origin.x, "y": origin.y})).unwrap();
|
||
app.run("shape.rectangle", json!({"x": 40, "y": 50, "width": 20, "height": 30})).unwrap();
|
||
let redo = app.session.active().unwrap().doc.clone();
|
||
app.run("edit.undo", json!({})).unwrap();
|
||
app.session.active_mut().unwrap().mark_saved();
|
||
app.ui.view.rulers = true;
|
||
app.ui.view.smart_guides = false;
|
||
app.ui.view.snap_to_point = false;
|
||
app.ui.view.snap_to_grid = false;
|
||
app.ui.view.snap_to_pixel = false;
|
||
app.select_tool("rectangle");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let view = app.view_mut().unwrap();
|
||
view.zoom = 1.0;
|
||
view.rotation = 0.0;
|
||
view.center = Point::new(200.0, 150.0);
|
||
frame(&mut app, &ctx, vec![]);
|
||
let rect = app.canvas_rect.unwrap();
|
||
let xf = Xf::new(rect, app.view().unwrap());
|
||
let from =
|
||
if source_vertical { pos2(rect.left() - RULER / 2.0, rect.center().y) } else { pos2(rect.center().x, rect.top() - RULER / 2.0) };
|
||
let to = xf.to_screen(Point::new(223.0, 237.0));
|
||
let end = xf.to_screen(Point::new(231.0, 244.0));
|
||
assert!(rect.contains(to) && rect.contains(end));
|
||
let mods = if alt { Modifiers::SHIFT | Modifiers::ALT } else { Modifiers::SHIFT };
|
||
let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: mods };
|
||
let before = app.session.active().unwrap().doc.clone();
|
||
let selection = app.session.active().unwrap().selection.clone();
|
||
let history = |app: &VectorcraftApp| {
|
||
let st = app.session.active().unwrap();
|
||
(st.history.undo.len(), st.history.redo.len(), st.is_dirty())
|
||
};
|
||
let original_history = history(&app);
|
||
assert_eq!(original_history.1, 1);
|
||
frame(&mut app, &ctx, vec![Event::ModifiersChanged(mods), Event::PointerMoved(from)]);
|
||
frame(&mut app, &ctx, vec![button(from, true)]);
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(to)]);
|
||
let check = |app: &VectorcraftApp, switched: bool, expected: Point| {
|
||
let guides = &app.session.active().unwrap().doc.guides;
|
||
assert_eq!(guides.len(), 1);
|
||
let vertical = source_vertical != switched;
|
||
assert_eq!(guides[0].vertical, vertical);
|
||
let want = if vertical { expected.x } else { expected.y };
|
||
assert!((guides[0].pos - want).abs() < 1e-4, "origin={origin:?} vertical={vertical}: {:?}, want {want}", guides[0]);
|
||
};
|
||
assert_eq!(history(&app).0, original_history.0, "preview has no undo entry");
|
||
if cancel {
|
||
// Cancellation is a baseline positive control, independent of snap correctness.
|
||
assert_eq!(app.session.active().unwrap().doc.guides.len(), 1);
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(from), button(from, false)]);
|
||
assert_eq!(app.session.active().unwrap().doc, before);
|
||
assert_eq!(app.session.active().unwrap().selection, selection);
|
||
assert_eq!(history(&app), original_history);
|
||
app.run("edit.redo", json!({})).unwrap();
|
||
assert_eq!(app.session.active().unwrap().doc, redo);
|
||
continue;
|
||
}
|
||
check(&app, alt, preview);
|
||
if alt {
|
||
// Used only by the private #1782 compatibility regression, not standalone RED.
|
||
frame(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::SHIFT)]);
|
||
check(&app, false, preview);
|
||
frame(&mut app, &ctx, vec![Event::ModifiersChanged(mods)]);
|
||
check(&app, true, preview);
|
||
}
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(end), button(end, false)]);
|
||
check(&app, alt, release);
|
||
let st = app.session.active().unwrap();
|
||
assert_eq!(st.doc.ruler_origin_for(0), origin);
|
||
assert_eq!(st.doc.layers, before.layers, "the rectangle tool draws no artwork");
|
||
assert_eq!(st.selection, selection);
|
||
assert_eq!(history(&app), (original_history.0 + 1, 0, true));
|
||
assert_eq!(st.history.undo.last().unwrap().label, "New Guide");
|
||
let after = st.doc.clone();
|
||
app.run("edit.undo", json!({})).unwrap();
|
||
assert_eq!(app.session.active().unwrap().doc, before);
|
||
assert_eq!(history(&app), (original_history.0, 1, false));
|
||
app.run("edit.redo", json!({})).unwrap();
|
||
assert_eq!(app.session.active().unwrap().doc, after);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn ruler_guide_shift_origin_preview_release_and_history() {
|
||
ruler_guide_origin_ui(false, false);
|
||
}
|
||
|
||
#[test]
|
||
fn ruler_guide_shift_origin_cancel_preserves_redo() {
|
||
ruler_guide_origin_ui(false, true);
|
||
}
|
||
|
||
#[test]
|
||
fn ruler_guide_origin_display_fallback_accepts_only_bounded_finite_pairs() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({})).unwrap();
|
||
// The global zero point as stored (artboard rulers check their own, see `ruler_origin_for`).
|
||
app.run("view.globalRulers", json!({"global": true})).unwrap();
|
||
let max = vectorcraft_engine::MAX_COORD;
|
||
for (origin, expected) in [
|
||
(Point::ZERO, Point::ZERO),
|
||
(Point::new(17.0, -23.0), Point::new(17.0, -23.0)),
|
||
(Point::new(max, -max), Point::new(max, -max)),
|
||
(Point::new(f64::NAN, 23.0), Point::ZERO),
|
||
(Point::new(17.0, f64::INFINITY), Point::ZERO),
|
||
(Point::new(max + 1.0, 23.0), Point::ZERO),
|
||
(Point::new(17.0, -max - 1.0), Point::ZERO),
|
||
] {
|
||
std::sync::Arc::make_mut(&mut app.session.active_mut().unwrap().doc).ruler_origin = origin;
|
||
assert_eq!(app.ruler_origin(), expected);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn ruler_guide_origin_tooltip_matches_selected_guide_fields() {
|
||
use egui::Event;
|
||
for (vertical, target, shown) in [(true, 222.0, 205.0), (false, 238.0, 215.0)] {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||
app.run("document.setRulerOrigin", json!({"x": 17, "y": 23})).unwrap();
|
||
app.run("guide.add", json!({"vertical": vertical, "pos": 100})).unwrap();
|
||
app.ui.view.guides = true;
|
||
app.ui.view.smart_guides = false;
|
||
app.ui.view.snap_to_point = false;
|
||
app.ui.view.snap_to_grid = false;
|
||
app.ui.view.snap_to_pixel = false;
|
||
app.select_tool("selection");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let view = app.view_mut().unwrap();
|
||
view.zoom = 1.0;
|
||
view.rotation = 0.0;
|
||
view.center = Point::new(200.0, 150.0);
|
||
frame(&mut app, &ctx, vec![]);
|
||
app.session.active_mut().unwrap().mark_saved();
|
||
let before = app.session.active().unwrap().doc.clone();
|
||
let undo = app.session.active().unwrap().history.undo.len();
|
||
let rect = app.canvas_rect.unwrap();
|
||
let xf = Xf::new(rect, app.view().unwrap());
|
||
let from = xf.to_screen(if vertical { Point::new(100.0, 50.0) } else { Point::new(50.0, 100.0) });
|
||
let to = xf.to_screen(if vertical { Point::new(target, 50.0) } else { Point::new(50.0, target) });
|
||
assert!(rect.contains(from) && rect.contains(to));
|
||
let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(from)]);
|
||
frame(&mut app, &ctx, vec![button(from, true)]);
|
||
frame(&mut app, &ctx, vec![Event::PointerMoved(to)]);
|
||
assert!((app.session.active().unwrap().doc.guides[0].pos - target).abs() < 1e-4);
|
||
assert_eq!(app.session.active().unwrap().selection.guides, vec![0]);
|
||
// Observe the real selected-guide field; no expected strings are inserted into UI state.
|
||
let fields = egui::Context::default();
|
||
crate::theme::install_fonts(&fields);
|
||
let mut texts = vec![];
|
||
for _ in 0..3 {
|
||
let input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0))), ..Default::default() };
|
||
let mut out = fields.run_ui(input, |ui| crate::panels::transform::show(&mut app, ui));
|
||
out.textures_delta.clear();
|
||
fn collect(shape: &egui::Shape, texts: &mut Vec<String>) {
|
||
match shape {
|
||
egui::Shape::Text(t) => texts.push(t.galley.text().to_string()),
|
||
egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| collect(s, texts)),
|
||
_ => {}
|
||
}
|
||
}
|
||
texts.clear();
|
||
out.shapes.iter().for_each(|s| collect(&s.shape, &mut texts));
|
||
}
|
||
assert!(texts.iter().any(|t| t == &Unit::Points.format(shown)), "{texts:?}");
|
||
let view = app.view_info();
|
||
let overlays = app.session.overlays(view);
|
||
let want = format!("{}: {shown:.2} pt", if vertical { "X" } else { "Y" });
|
||
assert!(
|
||
overlays.iter().any(|o| matches!(o, vectorcraft_tools::Overlay::Measure { text, .. } if text == &want)),
|
||
"{overlays:?}, field={shown}"
|
||
);
|
||
assert_eq!(app.session.active().unwrap().history.undo.len(), undo);
|
||
frame(&mut app, &ctx, vec![button(to, false)]);
|
||
assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1);
|
||
assert!((app.session.active().unwrap().doc.guides[0].pos - target).abs() < 1e-4, "stored coordinate remains absolute");
|
||
app.run("edit.undo", json!({})).unwrap();
|
||
assert_eq!(app.session.active().unwrap().doc, before);
|
||
}
|
||
}
|
||
|
||
/// Distance Guides on a canvas at 100%: a 400 × 300 pt document with a 50 pt square at (50, 50),
|
||
/// selected, and with `other` another 100 pt to its right, the Selection tool and Smart Guides
|
||
/// on → the app, its context, the view, the screen point over the middle of where the other
|
||
/// square is (or would be) and the selected square's id.
|
||
fn distance_scene(other: bool) -> (VectorcraftApp, egui::Context, Xf, Pos2, serde_json::Value) {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||
let a = app.run("shape.rectangle", json!({"x": 50, "y": 50, "width": 50, "height": 50})).unwrap()["id"].clone();
|
||
if other {
|
||
app.run("shape.rectangle", json!({"x": 200, "y": 50, "width": 50, "height": 50})).unwrap();
|
||
}
|
||
app.run("select.set", json!({"ids": [a]})).unwrap();
|
||
app.ui.view.smart_guides = true;
|
||
app.select_tool("selection");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let view = app.view_mut().unwrap();
|
||
view.zoom = 1.0;
|
||
view.rotation = 0.0;
|
||
view.center = Point::new(200.0, 150.0);
|
||
frame(&mut app, &ctx, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let over = xf.to_screen(Point::new(225.0, 75.0));
|
||
(app, ctx, xf, over, a)
|
||
}
|
||
|
||
/// One frame with `events` → the shapes drawn, flattened. The modifiers held stay from one
|
||
/// frame to the next until an `Event::ModifiersChanged`.
|
||
fn distance_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) -> Vec<Shape> {
|
||
distance_frame_covered(app, ctx, events, None)
|
||
}
|
||
|
||
/// What opens over the canvas in [`distance_frame_covered`].
|
||
#[derive(Clone, Copy)]
|
||
enum Cover {
|
||
/// A floating window (a floating panel, a menu, a popup) over this rectangle.
|
||
Panel(egui::Rect),
|
||
/// A modal dialog, with the backdrop that keeps the rest of the app from the pointer.
|
||
Dialog,
|
||
}
|
||
|
||
/// [`distance_frame`] with `cover` drawn over the canvas.
|
||
fn distance_frame_covered(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>, cover: Option<Cover>) -> Vec<Shape> {
|
||
let raw = egui::RawInput { screen_rect: Some(WINDOW), events, ..Default::default() };
|
||
let mut out = ctx.run_ui(raw, |ui| {
|
||
show(app, ui);
|
||
match cover {
|
||
Some(Cover::Panel(r)) => {
|
||
egui::Area::new(egui::Id::unique("test-floating-panel")).order(egui::Order::Foreground).fixed_pos(r.min).show(ui.ctx(), |ui| {
|
||
ui.allocate_space(r.size());
|
||
});
|
||
}
|
||
Some(Cover::Dialog) => {
|
||
crate::dialogs::modal::show(ui.ctx(), "Test", egui::Id::unique("test-dialog"), 0.0, 8, |ui| ui.label("A dialog"));
|
||
}
|
||
None => {}
|
||
}
|
||
});
|
||
out.textures_delta.clear();
|
||
fn flat(s: Shape, out: &mut Vec<Shape>) {
|
||
match s {
|
||
Shape::Vec(v) => v.into_iter().for_each(|s| flat(s, out)),
|
||
s => out.push(s),
|
||
}
|
||
}
|
||
let mut v = vec![];
|
||
out.shapes.into_iter().for_each(|c| flat(c.shape, &mut v));
|
||
v
|
||
}
|
||
|
||
/// The texts drawn in `s`.
|
||
fn texts(s: &[Shape]) -> Vec<String> {
|
||
s.iter().filter_map(|s| if let Shape::Text(t) = s { Some(t.galley.text().to_string()) } else { None }).collect()
|
||
}
|
||
|
||
/// The label "100.00 pt" drawn in `s` and the pill of the smart guide colour it sits in, if any.
|
||
fn gap_label(s: &[Shape]) -> Option<(egui::Rect, Option<egui::Rect>)> {
|
||
let magenta = c32(vectorcraft_tools::guides::MAGENTA);
|
||
let text =
|
||
s.iter().find_map(|s| if let Shape::Text(t) = s { (t.galley.text() == "100.00 pt").then(|| t.visual_bounding_rect()) } else { None })?;
|
||
let pill = s
|
||
.iter()
|
||
.find_map(|s| if let Shape::Rect(r) = s { (r.fill == magenta && r.rect.contains(text.center())).then_some(r.rect) } else { None });
|
||
Some((text, pill))
|
||
}
|
||
|
||
/// Distance Guides on screen: with a square selected and Alt held over another to its right,
|
||
/// the frame draws the measured line between their facing edges with the gap's length in a
|
||
/// pill of the smart guide colour on its middle; without Alt, or with Alt let go, nothing.
|
||
#[test]
|
||
fn alt_over_another_object_draws_the_distance_guide_label() {
|
||
use egui::{Event, Modifiers};
|
||
let (mut app, ctx, xf, over, _) = distance_scene(true);
|
||
let mut run = |events| distance_frame(&mut app, &ctx, events);
|
||
run(vec![Event::PointerMoved(over)]);
|
||
assert_eq!(gap_label(&run(vec![])), None, "without Alt");
|
||
run(vec![Event::ModifiersChanged(Modifiers::ALT)]);
|
||
let s = run(vec![]);
|
||
let (text, pill) = gap_label(&s).expect("the gap's length is drawn");
|
||
let pill = pill.expect("in a pill of the smart guide colour");
|
||
let mid = xf.to_screen(Point::new(150.0, 75.0));
|
||
assert!(pill.center().distance(mid) < 1.0 && pill.contains_rect(text), "{pill:?} on {mid:?}");
|
||
let (from, to) = (xf.to_screen(Point::new(100.0, 75.0)), xf.to_screen(Point::new(200.0, 75.0)));
|
||
let magenta = c32(vectorcraft_tools::guides::MAGENTA);
|
||
let measured = s.iter().any(|s| matches!(s, Shape::LineSegment { points, stroke } if *points == [from, to] && stroke.color == magenta));
|
||
assert!(measured, "the measured line between the facing edges");
|
||
run(vec![Event::ModifiersChanged(Modifiers::NONE)]);
|
||
assert_eq!(gap_label(&run(vec![])), None, "Alt let go");
|
||
}
|
||
|
||
/// The Distance Guides go when the pointer stops hovering for the tool with Alt still held: it
|
||
/// leaves the canvas for a panel or out of the window, or a press pans (Alt+Space) and takes it
|
||
/// over the panels; they come back when it hovers the other square again.
|
||
#[test]
|
||
fn distance_guides_go_when_the_hover_ends() {
|
||
use egui::{Event, Modifiers};
|
||
let (mut app, ctx, xf, over, _) = distance_scene(true);
|
||
let outside = pos2(xf.rect.max.x + 10.0, xf.rect.center().y);
|
||
assert!(WINDOW.contains(outside) && !xf.rect.contains(outside), "a panel beside the canvas");
|
||
let mut run = |events| distance_frame(&mut app, &ctx, events);
|
||
run(vec![Event::ModifiersChanged(Modifiers::ALT), Event::PointerMoved(over)]);
|
||
assert!(gap_label(&run(vec![])).is_some(), "shown over the other square");
|
||
run(vec![Event::PointerMoved(outside)]);
|
||
assert_eq!(gap_label(&run(vec![])), None, "gone over a panel, Alt still held");
|
||
run(vec![Event::PointerMoved(over)]);
|
||
assert!(gap_label(&run(vec![])).is_some(), "back over the other square");
|
||
run(vec![Event::PointerGone]);
|
||
assert_eq!(gap_label(&run(vec![])), None, "gone out of the window");
|
||
// Back over the square, Space held with Alt pans: they go at the press, and stay gone while
|
||
// the pan takes the pointer out over a panel.
|
||
let space = |pressed| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Modifiers::ALT };
|
||
let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::ALT };
|
||
run(vec![Event::PointerMoved(over)]);
|
||
run(vec![space(true)]);
|
||
assert!(gap_label(&run(vec![])).is_some(), "Space held: still shown");
|
||
run(vec![button(over, true)]);
|
||
assert_eq!(gap_label(&run(vec![])), None, "gone at the pan's press");
|
||
run(vec![Event::PointerMoved(over + vec2(20.0, 0.0))]);
|
||
run(vec![Event::PointerMoved(outside)]);
|
||
assert_eq!(gap_label(&run(vec![])), None, "still gone after panning out over a panel");
|
||
run(vec![button(outside, false), space(false)]);
|
||
}
|
||
|
||
/// With Alt held and the pointer still, the Distance Guides follow what lies under it: they go
|
||
/// under a floating panel or a modal dialog and come back when it closes; scrolled so that
|
||
/// empty artboard lies under the pointer, they measure to the artboard; when an edit puts an
|
||
/// object under the pointer, they measure to it; and the Selection tool switched to shows them
|
||
/// at once.
|
||
#[test]
|
||
fn distance_guides_follow_what_lies_under_a_still_pointer() {
|
||
use egui::{Event, Modifiers};
|
||
let (mut app, ctx, _, over, _) = distance_scene(true);
|
||
let run = |app: &mut VectorcraftApp, events, cover| distance_frame_covered(app, &ctx, events, cover);
|
||
run(&mut app, vec![Event::ModifiersChanged(Modifiers::ALT), Event::PointerMoved(over)], None);
|
||
assert!(gap_label(&run(&mut app, vec![], None)).is_some(), "shown over the other square");
|
||
// A floating panel, then a modal dialog, opens over the still pointer.
|
||
for cover in [Cover::Panel(egui::Rect::from_center_size(over, vec2(120.0, 120.0))), Cover::Dialog] {
|
||
run(&mut app, vec![], Some(cover));
|
||
run(&mut app, vec![], Some(cover));
|
||
assert_eq!(gap_label(&run(&mut app, vec![], Some(cover))), None, "gone under it");
|
||
// A frame for egui to drop the closed window, one for the hover to come back.
|
||
run(&mut app, vec![], None);
|
||
run(&mut app, vec![], None);
|
||
assert!(gap_label(&run(&mut app, vec![], None)).is_some(), "back once it closes");
|
||
}
|
||
// Another tool and back to the Selection tool (its shortcut, the toolbar): a new Selection
|
||
// tool, hovered at once.
|
||
app.select_tool("rectangle");
|
||
run(&mut app, vec![], None);
|
||
assert_eq!(gap_label(&run(&mut app, vec![], None)), None, "the Rectangle tool shows none");
|
||
app.select_tool("selection");
|
||
run(&mut app, vec![], None);
|
||
assert!(gap_label(&run(&mut app, vec![], None)).is_some(), "the Selection tool again");
|
||
// The view scrolls 150 pt (the wheel, a gesture, the keys): empty artboard is under the
|
||
// pointer, and the selected square is 300 pt from the artboard's right edge.
|
||
app.view_mut().unwrap().center.x += 150.0;
|
||
run(&mut app, vec![], None);
|
||
let s = run(&mut app, vec![], None);
|
||
assert_eq!(gap_label(&s), None, "no longer over the other square");
|
||
assert!(texts(&s).iter().any(|t| t == "300.00 pt"), "to the artboard: {:?}", texts(&s));
|
||
|
||
// A square made under the still pointer, the first selected again (an edit, an undo).
|
||
let (mut app, ctx, _, over, a) = distance_scene(false);
|
||
let run = |app: &mut VectorcraftApp, events| distance_frame(app, &ctx, events);
|
||
run(&mut app, vec![Event::ModifiersChanged(Modifiers::ALT), Event::PointerMoved(over)]);
|
||
let s = run(&mut app, vec![]);
|
||
assert!(gap_label(&s).is_none() && texts(&s).iter().any(|t| t == "300.00 pt"), "to the artboard: {:?}", texts(&s));
|
||
app.run("shape.rectangle", json!({"x": 200, "y": 50, "width": 50, "height": 50})).unwrap();
|
||
app.run("select.set", json!({"ids": [a]})).unwrap();
|
||
run(&mut app, vec![]);
|
||
assert!(gap_label(&run(&mut app, vec![])).is_some(), "to the new square");
|
||
// Another document (a new tab) under the still pointer: nothing selected there, none.
|
||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||
run(&mut app, vec![]);
|
||
assert_eq!(gap_label(&run(&mut app, vec![])), None, "another document");
|
||
}
|
||
|
||
/// The 800 × 600 window [`frame`] draws the canvas in.
|
||
const WINDOW: egui::Rect = egui::Rect { min: Pos2::ZERO, max: Pos2 { x: 800.0, y: 600.0 } };
|
||
|
||
/// A 400 × 300 pt document at 400%, turned `rotation` degrees, with the Rectangle tool: a press
|
||
/// that reached the tool would draw.
|
||
fn zoomed_in(rotation: f64) -> (VectorcraftApp, egui::Context) {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.select_tool("rectangle");
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let v = app.view_mut().unwrap();
|
||
v.zoom = 4.0;
|
||
v.rotation = rotation;
|
||
frame(&mut app, &ctx, vec![]);
|
||
(app, ctx)
|
||
}
|
||
|
||
/// Where the document's point `p` is on screen.
|
||
fn on_screen(app: &VectorcraftApp, p: Point) -> Pos2 {
|
||
Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(p)
|
||
}
|
||
|
||
/// Scroll bar `k` (0 horizontal, 1 vertical): its track, its span and the thumb's centre.
|
||
fn scroll_bar(app: &mut VectorcraftApp, k: usize) -> (egui::Rect, Span, Pos2) {
|
||
let rect = app.canvas_rect.unwrap();
|
||
let track = scroll_tracks(rect, WINDOW)[k];
|
||
let span = scroll_spans(&Xf::new(rect, app.view().unwrap()), app.content_bounds())[k];
|
||
let len = if k == 0 { track.width() } else { track.height() };
|
||
let (at, size) = span.thumb(len);
|
||
let c = if k == 0 { pos2(track.left() + at + size / 2.0, track.center().y) } else { pos2(track.center().x, track.top() + at + size / 2.0) };
|
||
(track, span, c)
|
||
}
|
||
|
||
fn primary(pos: Pos2, pressed: bool) -> egui::Event {
|
||
egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }
|
||
}
|
||
|
||
/// #2322: the Normal and Full Screen with Menu Bar modes keep the scroll bars along the right
|
||
/// and bottom edges, beside the rulers; Full Screen and Presentation Mode give the canvas the
|
||
/// whole window.
|
||
#[test]
|
||
fn scroll_bars_take_the_right_and_bottom_edges_in_the_windowed_screen_modes() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||
app.ui.view.rulers = true;
|
||
let ctx = egui::Context::default();
|
||
for (mode, max) in
|
||
[(0, WINDOW.max - vec2(SCROLL_BAR, SCROLL_BAR)), (1, WINDOW.max - vec2(SCROLL_BAR, SCROLL_BAR)), (2, WINDOW.max), (3, WINDOW.max)]
|
||
{
|
||
app.ui.screen_mode = mode;
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert_eq!(app.canvas_rect.unwrap().max, max, "screen mode {mode}");
|
||
}
|
||
}
|
||
|
||
/// #2322: the thumb is as long, against its track, as the view against the art and the
|
||
/// artboards with half a window around them; zoomed in it is shorter.
|
||
#[test]
|
||
fn a_scroll_bar_thumb_is_as_long_as_the_view_is_against_the_content() {
|
||
assert_eq!(Span { lo: 0.0, hi: 100.0, v0: 0.0, v1: 100.0 }.thumb(50.0), (0.0, 50.0), "all in view: the whole track");
|
||
assert_eq!(Span { lo: 0.0, hi: 400.0, v0: 150.0, v1: 250.0 }.thumb(100.0), (37.5, 25.0));
|
||
assert_eq!(Span { lo: 0.0, hi: 1000.0, v0: 450.0, v1: 550.0 }.thumb(100.0), (40.0, 20.0), "never shorter than MIN_THUMB");
|
||
let (mut app, ctx) = zoomed_in(0.0);
|
||
let len = |app: &mut VectorcraftApp| {
|
||
let (track, span, _) = scroll_bar(app, 0);
|
||
span.thumb(track.width()).1
|
||
};
|
||
let at_400 = len(&mut app);
|
||
app.view_mut().unwrap().zoom = 8.0;
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert!(len(&mut app) < at_400, "zoomed in, the thumb is shorter");
|
||
}
|
||
|
||
/// #2322: dragging a thumb scrolls the view along the screen's axis, in a turned view too, by
|
||
/// as much as the thumb's travel stands for; the drag never reaches the tool under the bar.
|
||
#[test]
|
||
fn dragging_a_scroll_bar_thumb_scrolls_the_view_and_draws_nothing() {
|
||
for rotation in [0.0, 90.0, 30.0] {
|
||
for k in 0..2 {
|
||
let (mut app, ctx) = zoomed_in(rotation);
|
||
let p = Point::new(200.0, 150.0);
|
||
let before = on_screen(&app, p);
|
||
let (track, span, from) = scroll_bar(&mut app, k);
|
||
let len = if k == 0 { track.width() } else { track.height() };
|
||
let step = if k == 0 { vec2(40.0, 0.0) } else { vec2(0.0, 40.0) };
|
||
let moved = (40.0 * span.ratio(len)).clamp(span.lo - span.v0, span.hi - span.v1);
|
||
assert!(moved > 40.0, "at 400% the view moves further than the thumb: {moved}");
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from)]);
|
||
frame(&mut app, &ctx, vec![primary(from, true)]);
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from + step / 2.0)]);
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(from + step)]);
|
||
frame(&mut app, &ctx, vec![primary(from + step, false)]);
|
||
frame(&mut app, &ctx, vec![]);
|
||
assert_eq!(app.session.active().unwrap().doc.art_bounds(), None, "the Rectangle tool under the bar drew nothing");
|
||
let after = on_screen(&app, p);
|
||
let want = before - step.normalized() * moved;
|
||
assert!((after - want).length() < 0.5, "rotation {rotation}, bar {k}: {before:?} -> {after:?}, want {want:?}");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// #2322: a click on the track beyond the thumb scrolls a page (most of the window) towards it.
|
||
#[test]
|
||
fn a_click_on_a_scroll_bar_track_scrolls_a_page() {
|
||
let (mut app, ctx) = zoomed_in(0.0);
|
||
let p = Point::new(200.0, 150.0);
|
||
let before = on_screen(&app, p);
|
||
let (track, _, _) = scroll_bar(&mut app, 0);
|
||
let page = 0.9 * app.canvas_rect.unwrap().width();
|
||
let at = pos2(track.right() - 3.0, track.center().y);
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at)]);
|
||
frame(&mut app, &ctx, vec![primary(at, true)]);
|
||
frame(&mut app, &ctx, vec![primary(at, false)]);
|
||
frame(&mut app, &ctx, vec![]);
|
||
let after = on_screen(&app, p);
|
||
assert!((after.x - (before.x - page)).abs() < 0.5 && (after.y - before.y).abs() < 0.5, "{before:?} -> {after:?}, page {page}");
|
||
assert_eq!(app.session.active().unwrap().doc.art_bounds(), None, "the Rectangle tool drew nothing");
|
||
}
|
||
|
||
/// #2322: the wheel over the horizontal bar scrolls sideways, over the vertical one up and
|
||
/// down (over the canvas it scrolls up and down).
|
||
#[test]
|
||
fn the_wheel_over_a_scroll_bar_scrolls_along_it() {
|
||
for k in 0..2 {
|
||
let (mut app, ctx) = zoomed_in(0.0);
|
||
let p = Point::new(200.0, 150.0);
|
||
let before = on_screen(&app, p);
|
||
let (track, _, _) = scroll_bar(&mut app, k);
|
||
let at = track.center();
|
||
let wheel = egui::Event::MouseWheel {
|
||
unit: egui::MouseWheelUnit::Point,
|
||
delta: vec2(0.0, -30.0),
|
||
phase: egui::TouchPhase::Move,
|
||
modifiers: Default::default(),
|
||
source: egui::MouseWheelSource::Unknown,
|
||
};
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at)]);
|
||
frame(&mut app, &ctx, vec![wheel]);
|
||
for _ in 0..30 {
|
||
frame(&mut app, &ctx, vec![]);
|
||
}
|
||
let after = on_screen(&app, p);
|
||
let want = if k == 0 { before - vec2(30.0, 0.0) } else { before - vec2(0.0, 30.0) };
|
||
assert!((after - want).length() < 0.5, "bar {k}: {before:?} -> {after:?}, want {want:?}");
|
||
}
|
||
}
|
||
#[test]
|
||
fn live_trace_selection_highlight_excludes_the_retained_source_rectangle() {
|
||
use std::sync::Arc;
|
||
use vectorcraft_doc::{Appearance, ImageObject, NodeId, TraceView};
|
||
let mut source = Node::new(
|
||
NodeId(2),
|
||
NodeKind::Image(Box::new(ImageObject {
|
||
key: "synthetic".into(),
|
||
width: 200,
|
||
height: 200,
|
||
xf: Affine::IDENTITY,
|
||
link: None,
|
||
placement: Default::default(),
|
||
pixels: None,
|
||
})),
|
||
);
|
||
source.visible = false;
|
||
let art =
|
||
Node::path(NodeId(3), vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(20.0, 30.0, 80.0, 90.0)), Appearance::default());
|
||
let mut group = Node::group(NodeId(1), vec![Arc::new(source), Arc::new(art)]);
|
||
let doc = vectorcraft_doc::Document::new(300.0, 300.0);
|
||
let symbols = doc.symbol_boxes();
|
||
for view in TraceView::ALL {
|
||
group.extras_mut().trace = Some(Box::new(json!({"view":view.id()})));
|
||
let mut visited = vec![];
|
||
walk_drawn(&group, &mut |n| visited.push(n.id));
|
||
assert_eq!(visited.contains(&NodeId(2)), view.shows_source());
|
||
let want = if view.shows_source() {
|
||
vectorcraft_geom::Rect::new(0.0, 0.0, 200.0, 200.0)
|
||
} else {
|
||
vectorcraft_geom::Rect::new(20.0, 30.0, 80.0, 90.0)
|
||
};
|
||
assert_eq!(node_outline(&group, &symbols).bounding_box(), want);
|
||
}
|
||
group.extras_mut().trace = None;
|
||
assert_eq!(node_outline(&group, &symbols).bounding_box(), vectorcraft_geom::Rect::new(0.0, 0.0, 200.0, 200.0));
|
||
}
|
||
|
||
/// Hovering over point type shows only its baseline (not the transform bounding box), while
|
||
/// selecting it shows both the bounding box with handles and the baseline.
|
||
#[test]
|
||
fn point_type_hover_shows_baseline_only_and_selection_shows_both_box_and_baseline() {
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width": 600, "height": 300})).unwrap();
|
||
app.ui.task_bar = false;
|
||
app.ui.view.rulers = false;
|
||
let made = app.run("text.create", json!({"x": 200, "y": 150, "text": "Lorem ipsum", "size": 36})).unwrap();
|
||
let id = vectorcraft_doc::NodeId(made["id"].as_u64().unwrap());
|
||
app.run("select.none", json!({})).unwrap();
|
||
app.select_tool("selection");
|
||
|
||
let ctx = egui::Context::default();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||
let color = c32(app.session.active().unwrap().doc.layer_color(id));
|
||
let layer_mode = egui::epaint::ColorMode::Solid(color);
|
||
|
||
// Hover over the text: hover highlight shows the baseline (1.5 px stroke), NOT a bounding box.
|
||
let over = xf.to_screen(Point::new(220.0, 140.0));
|
||
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(over)]);
|
||
let hover_shapes = shapes(&mut app, &ctx);
|
||
|
||
// There should be a 1.5px path outline for the hover highlight.
|
||
let hover_outlines: Vec<_> =
|
||
hover_shapes.iter().filter(|s| matches!(s, Shape::Path(ps) if ps.stroke.width == 1.5 && ps.stroke.color == layer_mode)).collect();
|
||
assert!(!hover_outlines.is_empty(), "hover highlight shows baseline");
|
||
|
||
let is_handle = |s: &Shape| matches!(s, Shape::Rect(r) if (r.rect.width() - 6.0).abs() < 1.0 && (r.rect.height() - 6.0).abs() < 1.0 && r.fill == Color32::WHITE);
|
||
|
||
// There should be NO bounding box resize handles (size 6x6 white squares) while just hovering.
|
||
let handles: Vec<_> = hover_shapes.iter().filter(|s| is_handle(s)).collect();
|
||
assert!(handles.is_empty(), "hovering does not show bounding box handles: {handles:?}");
|
||
|
||
// Now select the text (click): both the bounding box (with handles) and the baseline are shown.
|
||
app.run("select.set", json!({"ids": [id.0]})).unwrap();
|
||
frame(&mut app, &ctx, vec![]);
|
||
let sel_shapes = shapes(&mut app, &ctx);
|
||
|
||
// Bounding box handles (8 handles around the text) should now be present.
|
||
let handles: Vec<_> = sel_shapes.iter().filter(|s| is_handle(s)).collect();
|
||
assert_eq!(handles.len(), 8, "selected text shows 8 bounding box handles");
|
||
|
||
// Baseline (Shape::LineSegment at y=150) should also be present.
|
||
let y = xf.to_screen(Point::new(0.0, 150.0)).y;
|
||
let baselines: Vec<_> = sel_shapes
|
||
.iter()
|
||
.filter(|s| matches!(s, Shape::LineSegment { points, stroke } if stroke.color == color && (points[0].y - y).abs() < 0.5))
|
||
.collect();
|
||
assert!(!baselines.is_empty(), "selected text shows baseline alongside bounding box");
|
||
}
|
||
|
||
/// #2572: Show Text Threads links a thread's frames from the out port of one to the in port of
|
||
/// the next, where the tools draw those ports, each with the arrow of a threaded port; nothing
|
||
/// is drawn while no frame of the thread is selected.
|
||
#[test]
|
||
fn text_threads_run_from_each_out_port_to_the_next_in_port() {
|
||
use vectorcraft_tools::text::{Port, port_centre};
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width": 600, "height": 400})).unwrap();
|
||
let text = "Words that fill more than the frame holds. ".repeat(10);
|
||
let a = app.run("text.create", json!({"x": 20, "y": 20, "text": text, "area": {"width": 150, "height": 60}})).unwrap()["id"].clone();
|
||
let b = app.run("text.thread.add", json!({"from": a, "at": [300, 200]})).unwrap()["id"].clone();
|
||
let frame = |app: &VectorcraftApp, id: &serde_json::Value| match &app
|
||
.session
|
||
.active()
|
||
.unwrap()
|
||
.doc
|
||
.node(vectorcraft_doc::NodeId(id.as_u64().unwrap()))
|
||
.unwrap()
|
||
.kind
|
||
{
|
||
NodeKind::Text(t) => (**t).clone(),
|
||
_ => panic!("not text"),
|
||
};
|
||
let zoom = 2.0;
|
||
let (out, input) = (port_centre(&frame(&app, &a), Port::Out, 0.5).unwrap(), port_centre(&frame(&app, &b), Port::In, 0.5).unwrap());
|
||
let o = thread_overlays(&app, zoom);
|
||
assert!(o.iter().any(|x| matches!(x, Overlay::Line { a, b, .. } if *a == out && *b == input)), "the thread line: {o:?}");
|
||
let boxes: Vec<Point> =
|
||
o.iter().filter_map(|x| if let Overlay::Highlight { quad, .. } = x { Some(quad[0].midpoint(quad[2])) } else { None }).collect();
|
||
assert_eq!(boxes.len(), 2);
|
||
assert!(boxes[0].distance(out) < 1e-9 && boxes[1].distance(input) < 1e-9, "{boxes:?}");
|
||
// Two arrows: three strokes each, beside the thread line.
|
||
assert_eq!(o.iter().filter(|x| matches!(x, Overlay::Line { .. })).count(), 7);
|
||
app.run("select.none", json!({})).unwrap();
|
||
assert!(thread_overlays(&app, zoom).is_empty());
|
||
}
|
||
|
||
/// With more than `PORTS_MAX` type objects selected the tools draw no ports, and Show Text
|
||
/// Threads draws only its lines, no port boxes.
|
||
#[test]
|
||
fn text_threads_of_a_huge_selection_have_lines_but_no_ports() {
|
||
use vectorcraft_tools::text::PORTS_MAX;
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width": 600, "height": 400})).unwrap();
|
||
let text = "Words that fill more than the frame holds. ".repeat(10);
|
||
let a = app.run("text.create", json!({"x": 20, "y": 20, "text": text, "area": {"width": 150, "height": 60}})).unwrap()["id"].clone();
|
||
app.run("text.thread.add", json!({"from": a, "at": [300, 200]})).unwrap();
|
||
app.run("text.create", json!({"x": 20, "y": 300, "text": "x", "area": {"width": 50, "height": 20}})).unwrap();
|
||
while app.session.active().unwrap().doc.layers[0].children().map_or(0, |c| c.len()) <= PORTS_MAX {
|
||
app.run("select.all", json!({})).unwrap();
|
||
app.run("edit.duplicate", json!({})).unwrap();
|
||
}
|
||
app.run("select.all", json!({})).unwrap();
|
||
let o = thread_overlays(&app, 1.0);
|
||
assert!(o.iter().any(|x| matches!(x, Overlay::Line { .. })), "the thread lines");
|
||
assert!(!o.iter().any(|x| matches!(x, Overlay::Highlight { .. } | Overlay::Path { .. })), "no port boxes");
|
||
}
|
||
|
||
/// A frame of a thread in perspective has no ports: Show Text Threads draws no port box on it
|
||
/// and no line to or from it; the links between frames with ports keep theirs.
|
||
#[test]
|
||
fn text_threads_skip_the_links_of_a_frame_in_perspective() {
|
||
use vectorcraft_tools::text::{Port, port_centre};
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
app.run("file.new", json!({"width": 600, "height": 400})).unwrap();
|
||
let text = "Words that fill more than the frame holds. ".repeat(20);
|
||
let id = |v: serde_json::Value| vectorcraft_doc::NodeId(v["id"].as_u64().unwrap());
|
||
let a = id(app.run("text.create", json!({"x": 20, "y": 20, "text": text, "area": {"width": 150, "height": 60}})).unwrap());
|
||
let b = id(app.run("text.thread.add", json!({"from": a.0, "at": [200, 20]})).unwrap());
|
||
let c = id(app.run("text.thread.add", json!({"from": b.0, "at": [20, 200]})).unwrap());
|
||
let d = id(app.run("text.thread.add", json!({"from": c.0, "at": [200, 200]})).unwrap());
|
||
app.session
|
||
.edit("Perspective", |doc, _| {
|
||
if let Some(n) = doc.node_mut(b) {
|
||
n.extras_mut().perspective = Some(Box::new(vectorcraft_doc::PerspectiveAttachment::new("left", 0.0)));
|
||
}
|
||
Ok(())
|
||
})
|
||
.unwrap();
|
||
app.run("select.set", json!({"ids": [a.0, b.0, c.0, d.0]})).unwrap();
|
||
let st = app.session.active().unwrap();
|
||
assert_eq!(st.doc.text_threads, [vec![a, b, c, d]], "b is still threaded");
|
||
let frame = |id| match &st.doc.node(id).unwrap().kind {
|
||
NodeKind::Text(t) => (**t).clone(),
|
||
_ => panic!("not text"),
|
||
};
|
||
let (out, input) = (port_centre(&frame(c), Port::Out, 1.0).unwrap(), port_centre(&frame(d), Port::In, 1.0).unwrap());
|
||
let o = thread_overlays(&app, 1.0);
|
||
let boxes: Vec<Point> =
|
||
o.iter().filter_map(|x| if let Overlay::Highlight { quad, .. } = x { Some(quad[0].midpoint(quad[2])) } else { None }).collect();
|
||
assert_eq!(boxes.len(), 2, "only c's out port and d's in port: {boxes:?}");
|
||
assert!(boxes[0].distance(out) < 1e-9 && boxes[1].distance(input) < 1e-9, "{boxes:?}");
|
||
// One thread line (c to d) and its two arrows of three strokes each.
|
||
assert_eq!(o.iter().filter(|x| matches!(x, Overlay::Line { .. })).count(), 7);
|
||
}
|
||
}
|