Merge pull request #672 from storytold/pr/land-394

Land #664, #669: Animated Zoom, Large Tabs, Scale Cursor, anti-aliased bitmaps, Spacing Guides (part of #394)
This commit is contained in:
BFlat 2026-10-09 11:32:46 +07:00 committed by GitHub
commit 349668a1e6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 561 additions and 46 deletions

File diff suppressed because one or more lines are too long

View File

@ -282,6 +282,33 @@ fn snap_to_point_lands_a_dragged_selection_on_anchors() {
assert_eq!(drag(&mut s, ViewInfo { snap_to_point: false, ..v }), (276.5, 76.0), "View › Snap to Point off");
}
/// Smart Guides › Spacing Guides (#394): a square dragged 21 pt to the right of a row of two
/// squares 20 pt apart lands 20 pt from it, with the gaps marked; off, it stays at 21.
#[test]
fn spacing_guides_space_a_dragged_square_like_its_row() {
use vectorcraft_tools::PointerKind::{Down, Drag, Up};
let mut s = new_doc();
square(&mut s, 20.0, 300.0);
square(&mut s, 90.0, 300.0);
let m = square(&mut s, 200.0, 305.0);
let v = ViewInfo::default();
s.select_tool("selection", v).unwrap();
// `m` grabbed by its centre and dragged so its left edge is 21 pt from the second square.
let drag = |s: &mut Session| {
gesture(s, v, &[(Down, 225.0, 330.0), (Drag, 200.0, 330.0), (Drag, 186.0, 330.0)]);
let marks = s.overlays(v).iter().filter(|o| matches!(o, vectorcraft_tools::Overlay::Line { a, b, .. } if a.y == b.y)).count();
gesture(s, v, &[(Up, 186.0, 330.0)]);
let at = top_left(s, m).0;
s.execute("edit.undo", &json!({})).unwrap();
(at, marks)
};
let (at, marks) = drag(&mut s);
assert_eq!(at, 160.0, "20 pt from it");
assert!(marks >= 2, "both gaps marked");
set_pref(&mut s, "spacingGuides", json!(false));
assert_eq!(drag(&mut s), (161.0, 0), "off: where it was dragged");
}
/// Preferences › Smart Guides (#394): Color, Alignment Guides, Anchor/Path Labels, Measurement
/// Labels, Transform Tools and Snapping Tolerance change what the Selection tool shows while a
/// square is dragged into line with another, and how far the pull reaches; a hidden guide still

View File

@ -223,6 +223,7 @@ impl Session {
anchor_path_labels: self.prefs.anchor_path_labels,
measurement_labels: self.prefs.measurement_labels,
transform_tools_guides: self.prefs.transform_tools_guides,
spacing_guides: self.prefs.spacing_guides,
snapping_tolerance: self.prefs.snapping_tolerance,
construction_angles: if self.prefs.construction_guides {
vectorcraft_tools::guides::construction_angles(&self.prefs.construction_angles)

View File

@ -103,6 +103,9 @@ pub struct RenderOptions {
/// Screen view: Image Trace objects draw as their View asks (outlines, the source image…);
/// off, they draw their tracing result, as exports and printing do.
pub trace_views: bool,
/// Images are sampled smoothly when scaled or rotated; off, each pixel takes its nearest image
/// pixel (Pixel Preview with File Handling › Display Bitmaps as Anti-aliased Images off).
pub smooth_images: bool,
}
/// How edges are rasterized (raster export option).
@ -169,6 +172,7 @@ impl Default for RenderOptions {
anti_alias: AntiAlias::Art,
progressive_placed: false,
trace_views: false,
smooth_images: true,
}
}
}
@ -1419,7 +1423,9 @@ impl Renderer {
let pm = if outline { pm } else { self.ink_image(&cache_key, &pm, f.ink, f.doc.images.get(&im.key)) };
let sx = im.width as f64 / pm.width().max(1) as f64;
let sy = im.height as f64 / pm.height().max(1) as f64;
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler: peniko::ImageSampler::default() });
let quality = if f.opts.smooth_images { peniko::ImageQuality::Medium } else { peniko::ImageQuality::Low };
let sampler = peniko::ImageSampler { quality, ..Default::default() };
ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler });
match area {
Some((bp, rule)) => {
ctx.set_transform(f.view);

View File

@ -362,3 +362,26 @@ fn invalid_dash_pattern_strokes_solid() {
assert_eq!(img.pixels, solid.pixels, "dash {pattern:?}");
}
}
/// An image drawn larger than its pixels samples smoothly, or with `smooth_images` off each screen
/// pixel takes its nearest image pixel (Pixel Preview with File Handling › Display Bitmaps as
/// Anti-aliased Images off, #394).
#[test]
fn images_take_their_nearest_pixel_without_smoothing() {
use vectorcraft_doc::{ImageBlob, ImageObject, NodeKind};
let img = image::RgbaImage::from_fn(2, 1, |x, _| image::Rgba(if x == 0 { [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 mut d = Document::new(40.0, 20.0);
d.images.insert("bw".into(), ImageBlob::new("image/png", png));
let id = d.alloc_id();
let im = ImageObject { key: "bw".into(), width: 2, height: 1, xf: Affine::scale(20.0), link: None, placement: Default::default() };
d.insert(Some(d.layers[0].id), 0, Node::new(id, NodeKind::Image(im))).unwrap();
// How many pixels across the image are neither black nor white.
let greys = |smooth_images| {
let r = Renderer::new().render(&d, 40, 20, Affine::IDENTITY, &RenderOptions { smooth_images, ..Default::default() });
(0..40).filter(|x| (16..240).contains(&r.pixel(*x, 10)[0])).count()
};
assert!(greys(true) >= 4, "smooth: a ramp from black to white");
assert_eq!(greys(false), 0, "nearest: black, then white");
}

View File

@ -79,6 +79,9 @@ pub struct Targets {
segments: Vec<(Rect, PathSeg)>,
/// Ruler guides, which pull a point into line where they run (Snap to Point).
rulers: Vec<Ruler>,
/// The bounds of the objects a moved selection spaces itself evenly with (Spacing Guides):
/// gathered only for moves ([`Self::for_move`]).
boxes: Vec<Rect>,
/// How the guides these targets produce look.
style: GuideStyle,
}
@ -145,11 +148,47 @@ fn lines_near(lines: &[(f64, Point, Kind)], v: f64, tol: f64) -> &[(f64, Point,
within(lines, |l| l.0, v, tol)
}
/// `r`'s extent along the x axis (`x`) or the y axis.
fn span(r: &Rect, x: bool) -> (f64, f64) {
if x { (r.x0, r.x1) } else { (r.y0, r.y1) }
}
/// The objects a moved selection (`moving`) spaces itself evenly with, by their bounds: each
/// visible object on a layer as a whole (a group too), and inside a group holding some of the
/// moving objects its other objects. At most [`BUDGET`].
fn object_boxes(doc: &Document, moving: &[NodeId]) -> Vec<Rect> {
fn push(nodes: &[Arc<Node>], moving: &HashSet<NodeId>, holders: &HashSet<NodeId>, out: &mut Vec<Rect>) {
for n in nodes.iter().filter(|n| n.visible && !moving.contains(&n.id)) {
if out.len() >= BUDGET {
return;
}
match n.children() {
Some(ch) if n.is_layer() || holders.contains(&n.id) => push(ch, moving, holders, out),
_ => out.extend(n.geometric_bounds()),
}
}
}
let holders: HashSet<NodeId> = moving.iter().filter_map(|id| doc.ancestry(*id)).flatten().collect();
let mut out = vec![];
push(&doc.layers, &moving.iter().copied().collect(), &holders, &mut out);
out
}
impl Targets {
pub fn collect(doc: &Document, exclude: &[NodeId], visible: Option<Rect>) -> Self {
Self::collect_inner(doc, exclude, None, visible, Gather::Bounds)
}
/// Targets for moving the selection of `cx`: [`Self::collect`]'s, styled as `cx` asks, and with
/// Smart Guides › Spacing Guides the objects it spaces itself evenly with ([`Self::snap_rect`]).
pub fn for_move(cx: &ToolContext) -> Self {
let mut t = Self::collect(cx.doc, &cx.selection.objects, None).styled(cx);
if cx.spacing_guides {
t.boxes = object_boxes(cx.doc, &cx.selection.objects);
}
t
}
/// Targets for a drawn point: the art in `visible` (all of it for None) but `exclude`, its
/// anchors, centres, edges and path segments, its anchors in line too. The anchors and
/// segments of `exclude` (the path being drawn, whose bounds grow with it) stay targets.
@ -508,7 +547,7 @@ impl Targets {
let rects: Vec<Rect> = std::iter::once(Vec2::ZERO).chain(offsets.iter().copied()).map(|o| Rect::from_points(p + o, p + o)).collect();
let (adj, mut ov, aligned) = self.align_rects(&rects, tol);
let out = p + adj;
if aligned {
if aligned.contains(&true) {
ov.extend(self.label(out, "align"));
}
(out, ov)
@ -520,15 +559,107 @@ impl Targets {
}
/// Snap rectangles that move together (an artboard and its bleed): the edge or centre of any
/// of them nearest a target, per axis. Returns the shift and the guides.
/// of them nearest a target, per axis. A single moved selection with Spacing Guides
/// ([`Self::for_move`]) is spaced evenly along an axis that lines up with nothing
/// ([`Self::even_spacing`]). Returns the shift and the guides.
pub fn snap_rects(&self, rects: &[Rect], tol: f64) -> (Vec2, Vec<Overlay>) {
let (d, ov, _) = self.align_rects(rects, tol);
let (mut d, mut ov, aligned) = self.align_rects(rects, tol);
if let [r] = rects
&& !self.boxes.is_empty()
{
let moved = *r + d;
let mut spaced = false;
for (x, lined_up) in [true, false].into_iter().zip(aligned) {
if let Some((shift, _)) = self.even_spacing(moved, x, tol).filter(|_| !lined_up) {
*(if x { &mut d.x } else { &mut d.y }) += shift;
spaced = true;
}
}
// The equal gaps, marked where the rectangle ends up.
if spaced {
let done = *r + d;
for (x, lined_up) in [true, false].into_iter().zip(aligned) {
let gaps = if lined_up { None } else { self.even_spacing(done, x, tol) };
ov.extend(gaps.into_iter().flat_map(|(_, gaps)| gaps).flat_map(|g| self.gap_marks(g, x, tol)));
}
}
}
(d, ov)
}
/// [`Self::snap_rects`], also saying whether an axis lined up (even with Alignment Guides
/// off, or already in line, when there is no line or shift to tell).
fn align_rects(&self, rects: &[Rect], tol: f64) -> (Vec2, Vec<Overlay>, bool) {
/// Spacing Guides along the x axis (`x`) or the y axis for the moved rectangle `r`, among the
/// objects of its row (column): the shift (within `tol`) that leaves it as far from its
/// neighbour before or after it as two other objects of the row are apart, or as far from both
/// neighbours; and the equal gaps then, as (start, end, where across) along that axis.
fn even_spacing(&self, r: Rect, x: bool, tol: f64) -> Option<(f64, Vec<[f64; 3]>)> {
let (r0, r1) = span(&r, x);
let len = r1 - r0;
let (c0, c1) = span(&r, !x);
// The objects beside it, before or after, overlapping it across, by where they start.
let mut row: Vec<(Rect, f64, f64)> = self
.boxes
.iter()
.map(|b| (*b, span(b, x).0, span(b, x).1))
.filter(|(b, a0, a1)| span(b, !x).0 < c1 && span(b, !x).1 > c0 && (*a1 <= r0 + tol || *a0 >= r1 - tol))
.collect();
row.sort_by(|a, b| a.1.total_cmp(&b.1));
// Where two boxes overlap across: the middle of that (or of the space between them).
let mid = |a: &Rect, b: &Rect| (span(a, !x).0.max(span(b, !x).0) + span(a, !x).1.min(span(b, !x).1)) / 2.0;
// Each object and the nearest one after it, unless the moved rectangle sits between them.
let pairs: Vec<(f64, [f64; 3])> = row
.iter()
.filter_map(|(p, _, p1)| {
let next = row.partition_point(|(_, a0, _)| *a0 < *p1);
let (q, q0, _) = row.get(next)?;
let straddles = *p1 <= r0 + tol && *q0 >= r1 - tol;
(*q0 > *p1 && !straddles).then(|| (q0 - p1, [*p1, *q0, mid(p, q)]))
})
.collect();
let before = row.iter().filter(|(_, _, a1)| *a1 <= r0 + tol).max_by(|a, b| a.2.total_cmp(&b.2));
let after = row.iter().filter(|(_, a0, _)| *a0 >= r1 - tol).min_by(|a, b| a.1.total_cmp(&b.1));
// Where it could start, and the gap it repeats (None: halfway between its neighbours).
let mut starts: Vec<(f64, Option<f64>)> = vec![];
if let (Some(a), Some(b)) = (before, after)
&& b.1 - a.2 > len
{
starts.push(((a.2 + b.1 - len) / 2.0, None));
}
for (g, _) in &pairs {
starts.extend(before.map(|a| (a.2 + g, Some(*g))));
starts.extend(after.map(|b| (b.1 - g - len, Some(*g))));
}
let (start, repeats) =
starts.into_iter().filter(|(t, _)| (t - r0).abs() <= tol).min_by(|a, b| (a.0 - r0).abs().total_cmp(&(b.0 - r0).abs()))?;
let at = r + if x { Vec2::new(start - r0, 0.0) } else { Vec2::new(0.0, start - r0) };
let (end, mut gaps) = (start + len, vec![]);
if let Some((a, _, a1)) = before
&& repeats.is_none_or(|g| (start - a1 - g).abs() <= 1e-9)
{
gaps.push([*a1, start, mid(a, &at)]);
}
if let Some((b, b0, _)) = after
&& repeats.is_none_or(|g| (b0 - end - g).abs() <= 1e-9)
{
gaps.push([end, *b0, mid(b, &at)]);
}
if let Some(g) = repeats {
gaps.extend(pairs.iter().filter(|(pg, _)| (pg - g).abs() <= 1e-9).map(|(_, gap)| *gap));
}
Some((start - r0, gaps))
}
/// A Spacing Guides mark: a line across the gap (start, end, where across) along the x axis
/// (`x`) or the y axis, with a tick `tick` long either side of it at each end.
fn gap_marks(&self, [u0, u1, c]: [f64; 3], x: bool, tick: f64) -> [Overlay; 3] {
let pt = |u: f64, c: f64| if x { Point::new(u, c) } else { Point::new(c, u) };
let line = |a: Point, b: Point| Overlay::Line { a, b, color: self.style.color, dashed: false };
[line(pt(u0, c), pt(u1, c)), line(pt(u0, c - tick), pt(u0, c + tick)), line(pt(u1, c - tick), pt(u1, c + tick))]
}
/// [`Self::snap_rects`] before Spacing Guides, also saying whether each axis (x, y) lined up
/// (even with Alignment Guides off, or already in line, when there is no line or shift to
/// tell).
fn align_rects(&self, rects: &[Rect], tol: f64) -> (Vec2, Vec<Overlay>, [bool; 2]) {
let nearest = |targets: &[(f64, Point, Kind)], along: fn(&Rect) -> [f64; 3]| {
rects
.iter()
@ -553,7 +684,7 @@ impl Targets {
let (x0, x1) = (from.x.min(r.x0), from.x.max(r.x1));
ov.extend(self.line(Point::new(x0, y), Point::new(x1, y)));
}
(d, ov, best_x.is_some() || best_y.is_some())
(d, ov, [best_x.is_some(), best_y.is_some()])
}
/// Snap a bounding-box resize. `a` is the scale [`crate::bbox::scale_for_drag`] gave for
@ -1064,6 +1195,53 @@ mod tests {
assert!(anchor(&ov), "{ov:?}");
}
/// Smart Guides › Spacing Guides (#394): a moved selection lands as far from its neighbour as
/// two other objects of its row are apart, or halfway between its two neighbours, and the
/// equal gaps are marked; up and down the same in a column. Off, it stays where it is dragged.
#[test]
fn spacing_guides_space_a_moved_selection_evenly() {
let mut d = Document::new(500.0, 500.0);
let l = d.layers[0].id;
// A and B 20 apart in a row at y 300–340, C and D 30 apart in a column at x 400–440.
for r in [
Rect::new(20.0, 300.0, 60.0, 340.0),
Rect::new(80.0, 300.0, 120.0, 340.0),
Rect::new(400.0, 20.0, 440.0, 50.0),
Rect::new(400.0, 80.0, 440.0, 110.0),
] {
let id = d.alloc_id();
d.insert(Some(l), 0, Node::path(id, vectorcraft_geom::shapes::rectangle(r), Default::default())).unwrap();
}
let (s, p) = (Selection::default(), paint());
let lines = |ov: &[Overlay]| ov.iter().filter(|o| matches!(o, Overlay::Line { color, .. } if *color == MAGENTA)).count();
let t = Targets::for_move(&cx(&d, &s, &p));
// 21 after B: 20, as A and B, with both gaps marked (a line and two ticks each).
let (dv, ov) = t.snap_rect(Rect::new(141.0, 305.0, 181.0, 345.0), 4.0);
assert_eq!(dv, Vec2::new(-1.0, 0.0));
assert_eq!(lines(&ov), 6, "{ov:?}");
assert!(ov.iter().any(|o| matches!(o, Overlay::Line { a, b, .. } if (a.x, b.x) == (120.0, 140.0) && a.y == b.y)), "{ov:?}");
assert!(ov.iter().any(|o| matches!(o, Overlay::Line { a, b, .. } if (a.x, b.x) == (60.0, 80.0) && a.y == b.y)), "{ov:?}");
// Halfway between B and a box at 220–260: 50 either side.
let mut wide = d.clone();
let id = wide.alloc_id();
wide.insert(Some(l), 0, Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(220.0, 300.0, 260.0, 340.0)), Default::default()))
.unwrap();
let (dv, ov) = Targets::for_move(&cx(&wide, &s, &p)).snap_rect(Rect::new(148.0, 305.0, 188.0, 345.0), 4.0);
assert_eq!(dv, Vec2::new(2.0, 0.0));
assert!(lines(&ov) >= 6, "{ov:?}");
// A column: 28 under D, as C and D are 30 apart.
let (dv, ov) = t.snap_rect(Rect::new(405.0, 138.0, 445.0, 168.0), 4.0);
assert_eq!(dv, Vec2::new(0.0, 2.0));
assert!(ov.iter().any(|o| matches!(o, Overlay::Line { a, b, .. } if (a.y, b.y) == (110.0, 140.0) && a.x == b.x)), "{ov:?}");
// Above C, 32 over it: 30.
let (dv, _) = t.snap_rect(Rect::new(405.0, -42.0, 445.0, -12.0), 4.0);
assert_eq!(dv, Vec2::new(0.0, 2.0));
// Off: nowhere to go, and no marks.
let off = ToolContext { spacing_guides: false, ..cx(&d, &s, &p) };
let (dv, ov) = Targets::for_move(&off).snap_rect(Rect::new(141.0, 305.0, 181.0, 345.0), 4.0);
assert_eq!((dv, lines(&ov)), (Vec2::ZERO, 0));
}
/// Preferences › Smart Guides › Display Options (#394): the colour, and Alignment Guides and
/// Anchor/Path Labels off, change what shows and never where a point lands.
#[test]

View File

@ -322,6 +322,10 @@ pub struct ToolContext<'a> {
pub measurement_labels: bool,
/// Smart Guides → Transform Tools: the readouts while scaling, rotating and shearing.
pub transform_tools_guides: bool,
/// Smart Guides → Spacing Guides: a moved selection snaps to spacing equal to the gap between
/// two other objects in its row or column, or evenly between its two neighbours, and the
/// equal gaps show.
pub spacing_guides: bool,
/// Smart Guides → Snapping Tolerance (screen pixels): how near a smart guide target pulls the
/// pointer, a dragged edge or a drawn point.
pub snapping_tolerance: f64,
@ -695,6 +699,7 @@ pub(crate) mod testutil {
anchor_path_labels: true,
measurement_labels: true,
transform_tools_guides: true,
spacing_guides: true,
snapping_tolerance: 4.0,
construction_angles: guides::construction_angles(guides::DEFAULT_CONSTRUCTION_ANGLES),
screen: None,

View File

@ -87,7 +87,7 @@ impl MoveSnap {
pub(crate) fn new(cx: &ToolContext) -> Self {
Self {
bounds: selection_bounds(cx),
targets: cx.smart_guides.then(|| Targets::collect(cx.doc, &cx.selection.objects, None).styled(cx)),
targets: cx.smart_guides.then(|| Targets::for_move(cx)),
points: Targets::snap_to_point(cx, &cx.selection.objects),
}
}

View File

@ -69,9 +69,17 @@ enum Drag {
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,
},
RotateView {
start_angle: f64,
start_rot: f64,
@ -221,6 +229,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
// 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;
let key = CacheKey {
doc: st.uid as usize,
revision: st.revision,
@ -237,6 +248,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
anti_alias: app.session.prefs.anti_aliased_artwork,
placed: vectorcraft_render::placed_document::generation(),
pixel,
smooth_images,
};
// Placed documents' bitmaps are being made: draw again when they are ready.
if vectorcraft_render::placed_document::busy() {
@ -285,6 +297,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
anti_alias: if app.session.prefs.anti_aliased_artwork { vectorcraft_render::AntiAlias::Art } else { vectorcraft_render::AntiAlias::None },
progressive_placed: true,
trace_views: true,
smooth_images,
..opts
};
// Light documents render synchronously (no lag vs overlays); heavy ones go to the worker.
@ -438,15 +451,18 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
// Caps Lock gives precise (crosshair) cursors, like Illustrator; env opt-out for system cursors.
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.
Some(_) if custom && crate::cursors::OS_CURSORS => {
let ppp = ui.ctx().native_pixels_per_point().unwrap_or_else(|| ui.ctx().pixels_per_point());
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(&painter, c, hp) => egui::CursorIcon::None,
Some(hp) if custom && crate::cursors::paint(&painter, c, hp, 1.0 / ui_scale) => egui::CursorIcon::None,
_ => cursor_icon(c),
}
} else {
@ -522,11 +538,7 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
if (factor - 1.0).abs() > 1e-6
&& let (Some(p), Some(vm)) = (hover, app.view_mut())
{
let before = xf.to_doc(p);
vm.zoom = (vm.zoom * factor).clamp(0.0313, 640.0);
let nx = Xf { rect, zoom: vm.zoom, center: vm.center, rot: vm.rotation.to_radians() };
let after = nx.to_doc(p);
vm.center += before - after;
zoom_about(vm, rect, p, vm.zoom * factor);
}
if scroll != egui::Vec2::ZERO
&& let Some(vm) = app.view_mut()
@ -552,7 +564,7 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
ui.ctx().memory_mut(|mem| mem.stop_text_input());
app.ui.flyout = None;
let d = if zoom_mode {
Drag::ZoomBox { start: p }
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 tool == "rotateView" {
@ -599,7 +611,26 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
vm.rotation = vectorcraft_geom::normalize_deg(deg);
}
}
Drag::ZoomBox { .. } | Drag::Art | Drag::Gesture => {}
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 }));
}
Drag::Art | Drag::Gesture => {}
Drag::Tool if drag_art_out(app, ui, resp, p, view) => {
ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::Art));
}
@ -632,13 +663,17 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
vm.center = Point::new((a.x + b.x) / 2.0, (a.y + b.y) / 2.0);
vm.zoom = (rect.width() as f64 / (b.x - a.x)).min(rect.height() as f64 / (b.y - a.y)).clamp(0.0313, 640.0);
} else {
let before = xf.to_doc(p);
vm.zoom = crate::state::next_zoom(vm.zoom, !m.alt);
let nx = Xf { rect, zoom: vm.zoom, center: vm.center, rot: vm.rotation.to_radians() };
vm.center += before - nx.to_doc(p);
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 => {
let ev = PointerEvent { kind: PointerKind::Up, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, false) };
dispatch(app, &ev, view);
@ -674,6 +709,36 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
/// How much one point of wheel motion zooms (as `exp(points × WHEEL_ZOOM)`).
const WHEEL_ZOOM: f64 = 0.01;
/// 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 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);
}
/// A dashed marquee around screen rectangle `r`.
fn marquee(p: &egui::Painter, r: egui::Rect) {
let pts = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.left_top()];
for w in pts.windows(2) {
p.extend(Shape::dashed_line(&[w[0], w[1]], Stroke::new(1.0, Color32::from_gray(90)), 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
@ -1635,13 +1700,7 @@ fn names_an_artboard(text: &str) -> bool {
fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: &Tokens, look: HandleLook) {
for o in overlays {
match o {
Overlay::Marquee(r) => {
let sr = xf.rect_to_screen(*r);
let pts = [sr.left_top(), sr.right_top(), sr.right_bottom(), sr.left_bottom(), sr.left_top()];
for w in pts.windows(2) {
p.extend(Shape::dashed_line(&[w[0], w[1]], Stroke::new(1.0, Color32::from_gray(90)), 3.0, 3.0));
}
}
Overlay::Marquee(r) => marquee(p, xf.rect_to_screen(*r)),
Overlay::Path { path, color, width, dashed } => {
let s = Stroke::new(*width, c32(*color));
if *dashed {
@ -2410,6 +2469,28 @@ mod tests {
assert!(partial(&alphas(&mut app).expect("re-rendered")) > 50, "smooth again");
}
/// 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");
}
/// 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.
@ -2722,6 +2803,77 @@ mod tests {
}
}
/// 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}");
}
}
/// 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};

View File

@ -319,10 +319,12 @@ fn tab_title(d: &vectorcraft_engine::DocState, zoom: f64, outline: bool) -> Stri
format!("{}{} @ {} ({mode})", d.title(), if d.is_dirty() { "*" } else { "" }, zoom_label(zoom).replace('%', " %"))
}
/// Document tab strip: "Name* @ 66.67% (RGB/Preview)".
/// Document tab strip: "Name* @ 66.67% (RGB/Preview)". User Interface › Large Tabs makes the tabs
/// taller, with larger titles.
pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 35.0), Sense::hover());
let (height, title_size) = if app.session.prefs.large_tabs { (44.0, 14.0) } else { (35.0, 12.5) };
let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), height), Sense::hover());
ui.painter().rect_filled(strip, 0.0, t.tab_strip);
ui.painter().line_segment([strip.left_bottom(), strip.right_bottom()], Stroke::new(1.0, t.border));
let mut x = strip.left();
@ -334,7 +336,7 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
let title = tab_title(d, zoom, app.ui.view.outline);
// On the Home screen no tab is the current one.
let is_active = Some(i) == active && app.ui.home.is_none();
let galley = ui.painter().layout_no_wrap(title, theme::semibold(12.5), if is_active { t.text_strong } else { t.text_dim });
let galley = ui.painter().layout_no_wrap(title, theme::semibold(title_size), if is_active { t.text_strong } else { t.text_dim });
let w = galley.size().x + 50.0;
let r = egui::Rect::from_min_size(egui::pos2(x, strip.top()), vec2(w, strip.height() - 1.0));
let resp = ui.interact(r, ui.id().with(("tab", i)), Sense::click());
@ -517,6 +519,9 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
/// Contextual hint for the active tool: segments of (text, bold). Keys in bold segments are
/// written as shortcuts are ("Alt+Click"); [`hint_segments`] names them for the platform.
/// The hint of the Zoom tool with Performance › Animated Zoom on.
const ANIMATED_ZOOM_HINT: &str = "zoom.animated";
fn hint_for(tool: &str) -> Option<&'static [(&'static str, bool)]> {
Some(match tool {
"selection" => &[
@ -588,6 +593,16 @@ fn hint_for(tool: &str) -> Option<&'static [(&'static str, bool)]> {
("Drag", true),
(" to zoom into an area", false),
],
ANIMATED_ZOOM_HINT => &[
("Click", true),
(" to zoom in | ", false),
("Alt+Click", true),
(" to zoom out | ", false),
("Drag", true),
(" right or left to zoom in or out | ", false),
("Hold", true),
(" to keep zooming", false),
],
"eyedropper" => &[
("Click", true),
(" an object to copy its attributes | ", false),
@ -674,8 +689,8 @@ fn hint_for(tool: &str) -> Option<&'static [(&'static str, bool)]> {
/// can insist a complete language covers them.
pub fn hint_strings() -> Vec<&'static str> {
let mut v = Vec::new();
for tool in vectorcraft_tools::catalog::all_tools() {
for &(text, bold) in hint_for(tool.id).unwrap_or(&[]) {
for tool in vectorcraft_tools::catalog::all_tools().map(|t| t.id).chain([ANIMATED_ZOOM_HINT]) {
for &(text, bold) in hint_for(tool).unwrap_or(&[]) {
let text = if bold { text.rsplit_once('+').map_or(text, |(_, k)| k) } else { text };
if !text.is_empty() && !text.chars().all(|c| c.is_ascii_digit()) && !v.contains(&text) {
v.push(text);
@ -729,7 +744,10 @@ pub fn hint_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, "?", theme::semibold(11.0), t.text);
ui.add_space(6.0);
let mut job = egui::text::LayoutJob::default();
for (txt, bold) in hint_segments(app.session.tool_id()) {
let tool = app.session.tool_id();
// The Zoom tool's drag zooms to an area, or with Animated Zoom zooms as it goes.
let hint = if tool == "zoom" && crate::canvas::animated_zoom(&app.session.prefs) { ANIMATED_ZOOM_HINT } else { tool };
for (txt, bold) in hint_segments(hint) {
let font = if bold { theme::semibold(12.5) } else { egui::FontId::proportional(12.5) };
job.append(&txt, 0.0, egui::TextFormat { font_id: font, color: if bold { t.text_strong } else { t.text }, ..Default::default() });
}
@ -832,4 +850,33 @@ mod tests {
s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap();
assert!(title(&s).ends_with("(RGB/Preview)"));
}
/// The Zoom tool's hint follows Performance › Animated Zoom (#394): a drag zooms as it goes,
/// or (off) zooms into the area dragged across.
#[test]
fn the_zoom_hint_follows_animated_zoom() {
fn texts(s: &egui::Shape, out: &mut String) {
match s {
egui::Shape::Text(t) => out.push_str(t.galley.text()),
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
_ => {}
}
}
let mut app = crate::VectorcraftApp::new(Session::new(), Default::default());
app.select_tool("zoom");
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let hint = |app: &mut crate::VectorcraftApp| {
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::hint_bar(app, ui));
out.textures_delta.clear();
let mut shown = String::new();
out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown));
shown
};
let on = hint(&mut app);
assert!(on.contains(" right or left to zoom in or out") && on.contains("Hold"), "{on}");
app.run("prefs.set", json!({"key": "animatedZoom", "value": false})).unwrap();
let off = hint(&mut app);
assert!(off.contains(" to zoom into an area") && !off.contains("Hold"), "{off}");
}
}

View File

@ -288,10 +288,19 @@ fn glyph(c: Cursor, p: Pos2) -> Option<Vec<Shape>> {
Some(std::mem::take(&mut ink.0))
}
/// Paint cursor `c` at `p` on the given (foreground) painter, where the window can't show it as an
/// OS cursor ([`OS_CURSORS`]). Returns false for cursors that stay system cursors.
pub fn paint(painter: &Painter, c: Cursor, p: Pos2) -> bool {
glyph(c, p).map(|shapes| painter.extend(shapes)).is_some()
/// Paint cursor `c` at `p`, `scale` times its size, on the given (foreground) painter, where the
/// window can't show it as an OS cursor ([`OS_CURSORS`]). Returns false for cursors that stay
/// system cursors.
pub fn paint(painter: &Painter, c: Cursor, p: Pos2, scale: f32) -> bool {
let place = egui::emath::TSTransform::new(p.to_vec2(), scale);
glyph(c, Pos2::ZERO)
.map(|shapes| {
painter.extend(shapes.into_iter().map(|mut s| {
s.transform(place);
s
}))
})
.is_some()
}
/// Does the window show the glyphs as OS cursors ([`Images`])? Not on macOS, which sizes a cursor
@ -555,6 +564,43 @@ mod tests {
assert!(off.platform_output.cursor_image.is_none());
}
/// User Interface › Scale Cursor Proportional to UI (#394): off, the tool cursors keep their
/// size whatever the UI Scaling; on, they grow with it.
#[test]
fn tool_cursors_scale_with_the_ui_only_when_asked() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
app.run("prefs.set", json!({"key": "uiScaling", "value": 2.0})).unwrap();
let ctx = egui::Context::default();
for _ in 0..3 {
frame(&mut app, &ctx, vec![]);
}
assert_eq!(ctx.zoom_factor(), 2.0);
let over = app.canvas_rect.unwrap().center();
let shown =
|app: &mut VectorcraftApp, d: f32| frame(app, &ctx, vec![egui::Event::PointerMoved(over + vec2(d, 0.0))]).platform_output.cursor_image;
if OS_CURSORS {
assert_eq!(shown(&mut app, 0.0).unwrap(), app.canvas.cursors.get(Cursor::Arrow, 1.0).unwrap(), "off: its own size");
app.run("prefs.set", json!({"key": "scaleCursorWithUi", "value": true})).unwrap();
assert_eq!(shown(&mut app, 4.0).unwrap(), app.canvas.cursors.get(Cursor::Arrow, 2.0).unwrap(), "on: twice as big");
}
// Painted into the window (macOS, the web), the glyph is drawn at the scale asked for, its
// hotspot where the pointer is.
let painted = |scale: f32| {
let ctx = egui::Context::default();
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
let layer = egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("probe"));
assert!(paint(&ui.ctx().layer_painter(layer), Cursor::Arrow, pos2(100.0, 100.0), scale));
});
out.textures_delta.clear();
out.textures_delta.clear();
out.shapes.iter().fold(egui::Rect::NOTHING, |r, c| r.union(c.shape.visual_bounding_rect()))
};
let (full, half) = (painted(1.0), painted(0.5));
assert!((half.height() * 2.0 - full.height()).abs() < 1.5 && full.height() > 15.0, "{full:?} {half:?}");
assert!(full.contains(pos2(100.0, 100.0)) && half.contains(pos2(100.0, 100.0)), "{full:?} {half:?}");
}
#[test]
fn arrow_hotspot_is_tip() {
let pts = arrow_points(pos2(10.0, 20.0));

View File

@ -2987,6 +2987,9 @@
to zoom in | para acercar |
to zoom out | para alejar |
to zoom into an area para acercar un área
Hold Mantener pulsado
right or left to zoom in or out | a la derecha o a la izquierda para acercar o alejar |
to keep zooming para seguir haciendo zoom
an object to copy its attributes | en un objeto para copiar sus atributos |
to apply the selection's to it | para aplicarle los de la selección |
to sample a color para muestrear un color

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -2984,6 +2984,9 @@
to zoom in | pour effectuer un zoom avant |
to zoom out | pour effectuer un zoom arrière |
to zoom into an area pour zoomer sur une zone
Hold Maintenir
right or left to zoom in or out | vers la droite ou la gauche pour effectuer un zoom avant ou arrière |
to keep zooming pour continuer le zoom
an object to copy its attributes | sur un objet pour copier ses attributs |
to apply the selection's to it | pour lui appliquer ceux de la sélection |
to sample a color pour prélever une couleur

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -2987,6 +2987,9 @@
to zoom in | per ingrandire |
to zoom out | per ridurre |
to zoom into an area per ingrandire un'area
Hold Tieni premuto
right or left to zoom in or out | a destra o a sinistra per ingrandire o ridurre |
to keep zooming per continuare lo zoom
an object to copy its attributes | su un oggetto per copiarne gli attributi |
to apply the selection's to it | per applicargli quelli della selezione |
to sample a color per campionare un colore

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -675,6 +675,9 @@
to transform | :変形 |
to zoom in | :拡大 |
to zoom into an area :範囲を拡大
Hold 長押し
right or left to zoom in or out | :右で拡大、左で縮小 |
to keep zooming :拡大または縮小し続ける
to zoom out | :縮小 |
while dragging to move perpendicular to the plane | :ドラッグ中に面と垂直に移動 |
(closest) (最も近い)

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -78,6 +78,9 @@
to transform | para transformar |
to zoom in | para ampliar |
to zoom into an area para ampliar uma área
Hold Manter pressionado
right or left to zoom in or out | para a direita ou a esquerda para ampliar ou reduzir |
to keep zooming para continuar o zoom
to zoom out | para reduzir |
while dragging to move perpendicular to the plane | ao arrastar para mover perpendicularmente ao plano |
(closest) (mais próximo)

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2983,6 +2983,9 @@
to zoom in | для увеличения |
to zoom out | для уменьшения |
to zoom into an area для увеличения области
Hold Удерживать
right or left to zoom in or out | вправо или влево для увеличения или уменьшения |
to keep zooming для непрерывного масштабирования
an object to copy its attributes | в объект для копирования его атрибутов |
to apply the selection's to it | для применения атрибутов выделения к нему |
to sample a color для взятия образца цвета

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -2927,6 +2927,9 @@
to zoom in | 以放大 |
to zoom out | 以缩小 |
to zoom into an area 以放大指定区域
Hold 按住
right or left to zoom in or out | 向右以放大、向左以缩小 |
to keep zooming 以持续缩放
an object to copy its attributes | 选择对象以复制其属性 |
to apply the selection's to it | 以将所选对象属性应用到它 |
to sample a color 以取样颜色

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3012,6 +3012,9 @@
to zoom in | 可放大 |
to zoom out | 可縮小 |
to zoom into an area 可放大至指定區域
Hold 按住
right or left to zoom in or out | 向右可放大、向左可縮小 |
to keep zooming 可持續縮放
an object to copy its attributes | 物件可複製其屬性 |
to apply the selection's to it | 可將選取範圍的屬性套用至該物件 |
to sample a color 可取樣顏色

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -291,6 +291,8 @@ pub struct CacheKey {
/// View › Pixel Preview: the document pixels rendered (x0, y0, x1, y1), one per point, shown
/// with hard edges. None: the art is rendered for the screen.
pub pixel: Option<[i64; 4]>,
/// Images sampled smoothly ([`vectorcraft_render::RenderOptions::smooth_images`]).
pub smooth_images: bool,
}
#[derive(Clone, Copy, Debug, Default)]

View File

@ -244,6 +244,10 @@ fn category_fields(ui: &mut egui::Ui, d: &mut Dialog, cat: &str) {
PrefKind::Bool if sp.key == "numbersWithoutUnitsArePoints" => {
ui.add_enabled_ui(picas_in_use(d), |ui| bool_row(ui, d, sp.key, sp.label));
}
// Performance › Animated Zoom needs GPU Performance: dimmed while it is off.
PrefKind::Bool if sp.key == "animatedZoom" => {
ui.add_enabled_ui(d.bool("gpuPerformance"), |ui| bool_row(ui, d, sp.key, sp.label));
}
PrefKind::Bool => bool_row(ui, d, sp.key, sp.label),
PrefKind::Num { min, max, unit } => {
labeled(ui, sp.label, |ui| {

View File

@ -78,7 +78,7 @@ fn slice_tool_cursors_are_drawn_in_code() {
let ctx = egui::Context::default();
let mut out = ctx.run_ui(Default::default(), |ui| {
for c in [vectorcraft_tools::Cursor::Slice, vectorcraft_tools::Cursor::SliceSelect] {
assert!(crate::cursors::paint(ui.painter(), c, egui::pos2(50.0, 50.0)), "{c:?}");
assert!(crate::cursors::paint(ui.painter(), c, egui::pos2(50.0, 50.0), 1.0), "{c:?}");
}
});
out.textures_delta.clear();

View File

@ -12,7 +12,7 @@ fn the_width_tool_has_its_own_cursors() {
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
let painter = ui.painter().clone();
for c in [Cursor::Width, Cursor::WidthAdd, Cursor::WidthPoint] {
assert!(crate::cursors::paint(&painter, c, egui::pos2(50.0, 50.0)), "{c:?} is drawn");
assert!(crate::cursors::paint(&painter, c, egui::pos2(50.0, 50.0), 1.0), "{c:?} is drawn");
}
});
out.textures_delta.clear();