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:
commit
349668a1e6
10
ROADMAP.md
10
ROADMAP.md
File diff suppressed because one or more lines are too long
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue