Compare commits

...
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 b426859b15 Fix #833: isolation mode locks and dims the art around the group
While a group or layer was isolated, clicks, hovers and drags still
reached the art outside it, and that art drew as usual. Now only the
isolated container's contents hit: the art around it is locked and
clicks pass through it. On the canvas it draws at half opacity, above
the group as well as below, each run of it as one dimmed layer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:27:57 +07:00
bflatasticandClaude Opus 5.5 61d372bb7f Fix #775: the Properties panel's Paragraph section justifies and hyphenates
With type selected, the Properties panel's Paragraph section had only
Align Left, Center and Right. It now has the Paragraph panel's seven
alignments (the justified ones too), its Hyphenate checkbox and a "…"
button that opens the Paragraph panel, as Illustrator's does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:27:56 +07:00
bflatasticandClaude Opus 5.5 82dc5f8182 Fix #789: every align button aligns to the Align To choice
Align to Key Object and Align to Artboard worked only from the Align
panel's own buttons: the Control bar's and the Properties panel's align
buttons always aligned to the selection (one object to the artboard).
They are now one set of buttons that follow Align To (and the key
object), and the Properties panel's Align section gets the "…" button
that opens the Align panel for Distribute and Align To.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:27:54 +07:00
bflatasticandClaude Opus 5.5 79c81d754c Fix #835: a recent colour recolours the selected gradient stop
In the gradient stop popover (and the Color panel), clicking a recent
colour replaced the whole gradient with that colour, while the sliders,
spectrum and Black and White chips recoloured the selected stop. The
recent colours now go through the Color panel's target like the chips.
The Swatches panel's recent colours still paint the fill or stroke, as
its swatches do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:27:54 +07:00
bflatasticandClaude Opus 5.5 97318c188b Fix #862: the gradient stop popover opens beside the art
Opened from a stop on the Gradient tool's annotator, the colour popover
appeared just below and right of the stop, over the very art whose
colour it changes. It now opens beside the selected art, on the first
side with room for it (right, left, below, above), and beside the stop
only when there is none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:27:53 +07:00
bflatasticandClaude Opus 5.5 94141eca82 Fix #826: rulers cover their length in a rotated view
With Rotate View, each ruler measured document x (or y) along a line
that crossed both axes: its ticks covered only a short stretch and the
numbers bunched up. Each ruler now measures along its own edge in the
canvas's coordinates turned with the view, so it is ticked over its
whole length at every angle, and reads x and y as before at 0°.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:27:53 +07:00
bflatasticandClaude Opus 5.5 5df4ddd4b6 Fix #839: Save for Web accepts a typed width or height
The dialog's W and H fields send decimal numbers (300.0) and the settings
wanted a whole u32, so the preview failed with "invalid type: floating
point". Pixel sizes now take any number and round it to whole pixels;
negative, huge and non-numeric sizes are still refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:27:52 +07:00
bflatasticandClaude Opus 5.5 021a9474f8 Fix #824: the Artboard tool names its artboard once
With the Artboard tool, the active artboard's name was drawn twice: by
the canvas, as for every artboard, and by the tool, in blue and a little
higher. The canvas now leaves that one to the tool, which draws it where
the canvas names the others.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:27:52 +07:00
bflatasticandClaude Opus 5.5 de564bc24f Fix #806: Windows doesn't load Vulkan drivers unless asked
Creating a Vulkan instance loads every installed Vulkan driver into the
process, and a faulty one (Intel's igvk64.dll) crashed VectorCraft
before its window appeared. On Windows the window now starts with
Direct3D 12, falling back to OpenGL, as PdfCraft does; WGPU_BACKEND
(e.g. vulkan) still chooses others.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:27:51 +07:00
bflatasticandClaude Opus 5.5 495694e1ac Part of #758: .ai import doesn't weigh text objects when the layers can't be used
Before deciding to use the layers of an Illustrator file, the import
drew them again for each text object made from the file's text
document, to see whether the page draws it. When the layers differ from
the page by more than the check allows even with every such object
gone (taking one out only changes what is drawn in its box), the
layers aren't used whatever is taken out: that weighing is now
skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:27:50 +07:00
bflatasticandClaude Opus 5.5 584f08ae70 Fix #755: blurred type no longer shifts as the view moves
Gaussian Blur and Feather drew through a filter layer whose blur works on
a coarser grid tied to the canvas: the blurred art moved by up to about
a pixel, and its steps showed, as the view moved by parts of a pixel
(scrolling, zooming about the pointer). They now blur the content's own
pixels on the CPU, as the Photoshop-style effects already do: the same
look wherever the view is, cached while only the view moves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:27:49 +07:00
bflatasticandClaude Opus 5.5 db8be89052 Fix #830: the control channel's app.quit says when it asks first
Raw app.quit closed the window and answered null at once, so a client
couldn't tell that a save prompt was waiting. It now runs the menu's
Quit: {"pending": "saveChanges"} while a modified document is asked
about, as file.close and file.closeAll answer and the protocol
documentation says; otherwise null, and the window closes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:27:47 +07:00
33 changed files with 652 additions and 148 deletions

No files matched your search

+22 -1
View File
@@ -31,7 +31,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, OnceLock};
use eframe::egui_wgpu::NativeAdapterSelectorMethod;
use eframe::wgpu::{self, Backend, DeviceType, PowerPreference};
use eframe::wgpu::{self, Backend, Backends, DeviceType, PowerPreference};
/// The adapters a restart leaves out, those the window failed on, as comma-separated [`key`]s
/// (`Vulkan:1002:164e`, `Metal:Intel Iris Pro Graphics`). Set by the app itself when it starts
@@ -81,6 +81,15 @@ pub fn preferred_displays(pref: Option<&str>, env: Option<PowerPreference>) -> V
if automatic(pref, env) { display_gpus() } else { Vec::new() }
}
/// The graphics backends the window starts with: on Windows Direct3D 12, falling back to OpenGL,
/// unless `WGPU_BACKEND` names others (`env_set`; `default` then holds them). Creating a Vulkan
/// instance loads every installed Vulkan driver into the process, and a faulty one (Intel's
/// `igvk64.dll`, #806) crashed the app before its window appeared. DX12 is Windows' own backend,
/// as in PdfCraft. Elsewhere, `default`.
pub fn backends(default: Backends, env_set: bool) -> Backends {
if cfg!(windows) && !env_set { Backends::DX12 | Backends::GL } else { default }
}
/// One adapter, as [`adapter_order`] sees it.
#[derive(Clone, Debug)]
pub struct Candidate {
@@ -809,6 +818,18 @@ mod tests {
assert_eq!(adapter_order(&c, PowerPreference::LowPower, &[], None, &[c[1].key.clone()]), [0, 3, 2], "DX12 failed: Vulkan next");
}
#[test]
fn windows_never_loads_vulkan_drivers_unless_asked() {
let all = Backends::all();
let start = backends(all, false);
if cfg!(windows) {
assert_eq!(start, Backends::DX12 | Backends::GL, "#806");
} else {
assert_eq!(start, all);
}
assert_eq!(backends(Backends::VULKAN, true), Backends::VULKAN, "WGPU_BACKEND=vulkan still picks it");
}
#[test]
fn power_preference_follows_the_environment_then_the_preference() {
assert_eq!(power_preference(None, None), AUTOMATIC);
+1
View File
@@ -467,6 +467,7 @@ fn main() -> std::process::ExitCode {
// Metal on an Iris Pro, #651). `WGPU_VALIDATION_INDIRECT_CALL=1` turns it back on.
create.instance_descriptor.flags =
(eframe::wgpu::InstanceFlags::from_build_config() - eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
create.instance_descriptor.backends = gpu::backends(create.instance_descriptor.backends, std::env::var_os("WGPU_BACKEND").is_some());
}
options
};
+20 -5
View File
@@ -86,6 +86,19 @@ pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeI
/// [`hit_test`] passing over the nodes `skip` names (and their contents).
fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(NodeId) -> bool) -> Option<Hit> {
let finish = |mut h: Hit| {
h.contents_of = h.ancestry.iter().rev().copied().find(|a| doc.node(*a).is_some_and(edits_contents));
h.layers = h.ancestry.iter().take_while(|a| doc.node(**a).is_some_and(Node::is_layer)).count();
h
};
// Isolation mode: only what is inside the isolated container hits. The art around it is
// locked, and clicks pass through it (#833).
if let Some(scope) = opt.scope
&& let Some(container) = doc.node(scope).filter(|n| n.is_container())
&& let Some(mut chain) = doc.ancestry(scope)
{
return hit_children(container, p, opt, skip, &mut chain).map(finish);
}
let mut chain = Vec::new();
for layer in doc.layers.iter().rev() {
if !layer.visible || layer.locked {
@@ -100,10 +113,8 @@ fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(No
}
chain.push(layer.id);
let opt = if matches!(layer.kind, NodeKind::Layer { preview: false, .. }) { HitOptions { outline: true, ..opt } } else { opt };
if let Some(mut h) = hit_children(layer, p, opt, skip, &mut chain) {
h.contents_of = h.ancestry.iter().rev().copied().find(|a| doc.node(*a).is_some_and(edits_contents));
h.layers = h.ancestry.iter().take_while(|a| doc.node(**a).is_some_and(Node::is_layer)).count();
return Some(h);
if let Some(h) = hit_children(layer, p, opt, skip, &mut chain) {
return Some(finish(h));
}
chain.pop();
}
@@ -560,7 +571,11 @@ world",
d.insert(Some(l), 9, far).unwrap();
let at = |x: f64, scope| objects_at(&d, Point::new(x, 25.0), HitOptions::default(), scope);
assert_eq!(at(30.0, None), vec![g, a_id]);
assert_eq!(at(30.0, Some(g))[..2], [c_id, b_id], "isolated: the group's own objects");
assert_eq!(at(30.0, Some(g)), [c_id, b_id], "isolated: the group's own objects");
// Isolated, the art around the group is locked: clicks pass through it (#833).
assert!(at(5.0, Some(g)).is_empty());
assert!(at(130.0, Some(g)).is_empty());
assert!(hit_test(&d, Point::new(5.0, 25.0), HitOptions { scope: Some(g), ..HitOptions::default() }).is_none());
assert_eq!(at(5.0, None), vec![a_id]);
assert!(at(90.0, None).is_empty());
}
+13
View File
@@ -142,6 +142,17 @@ fn de_key<'de, T: Keyed, D: Deserializer<'de>>(d: D) -> std::result::Result<T, D
T::parse(&s).ok_or_else(|| D::Error::custom(format!("`{s}`: {}", T::KEYS)))
}
/// A size in pixels: a number, rounded to whole pixels (`300.0` is 300).
fn de_pixels<'de, D: Deserializer<'de>>(d: D) -> std::result::Result<Option<u32>, D::Error> {
let Some(v) = Option::<f64>::deserialize(d)? else { return Ok(None) };
let px = v.round();
if !(px.is_finite() && px >= 0.0 && px <= f64::from(u32::MAX)) {
return Err(D::Error::custom(format!("{v} is not a size in pixels")));
}
// In range and whole: the cast is exact.
Ok(Some(px as u32))
}
/// The file format.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -361,8 +372,10 @@ pub struct WebSettings {
/// JPEG: embed the sRGB profile.
pub embed_profile: bool,
/// The image width in pixels (the height follows).
#[serde(deserialize_with = "de_pixels")]
pub width: Option<u32>,
/// The image height in pixels (the width follows).
#[serde(deserialize_with = "de_pixels")]
pub height: Option<u32>,
/// The image size in percent of the art's size in points.
pub percent: Option<f64>,
+7
View File
@@ -285,9 +285,16 @@ fn image_size_region_and_metadata() {
assert_eq!(size(&mut s, json!({"width": 80})), (80, 60));
assert_eq!(size(&mut s, json!({"height": 15})), (20, 15));
assert_eq!(size(&mut s, json!({"percent": 150})), (60, 45));
// The dialog's fields send decimal numbers: rounded to whole pixels.
assert_eq!(size(&mut s, json!({"width": 80.0})), (80, 60));
assert_eq!(size(&mut s, json!({"height": 14.6})), (20, 15));
for bad in [
json!({"percent": 0}),
json!({"width": 0}),
json!({"width": 0.2}),
json!({"width": -5}),
json!({"height": 1e300}),
json!({"height": "tall"}),
json!({"format": "bmp"}),
json!({"colors": "many"}),
json!({"matte": "plaid"}),
+8
View File
@@ -936,6 +936,14 @@ fn prune_unseen(frame: &Frame<'_>, view: &mut PageView, doc: &mut Document) -> u
return 0;
}
let mut with = view.difference(doc, rect).any;
// Taking a text object out changes only what is drawn in its box. When the art would still
// differ from the page by more than `compare` allows with every candidate gone, the layers
// aren't used whatever is pruned: don't draw them again for each one (#758).
let margin = OUTLINE_REACH + 1.0;
let boxes: f64 = found.iter().filter_map(|(_, b)| *b).map(|b| b.inflate(margin, margin).intersect(rect).area().max(0.0)).sum();
if with - boxes / rect.area().max(1e-9) > MAX_DIFFERENCE {
return 0;
}
let mut removed = 0;
for (id, bounds) in found {
if bounds.is_none_or(|b| b.intersect(rect).is_zero_area()) {
+5 -41
View File
@@ -187,10 +187,6 @@ fn shadow_filter(dx: f64, dy: f64, blur: f64, color: peniko::Color) -> Filter {
})
}
fn blur_filter(sigma: f64) -> Filter {
Filter::from_primitive(FilterPrimitive::GaussianBlur { std_deviation: sigma.max(0.0) as f32, edge_mode: EdgeMode::None })
}
fn pcolor(ink: Ink, c: &vectorcraft_doc::color::Color) -> peniko::Color {
let [r, g, b] = ink.rgb(c);
peniko::Color::new([r, g, b, 1.0])
@@ -495,44 +491,12 @@ impl Renderer {
c.pop_layer();
});
}
// The content itself (blurred / feathered / filtered).
let blur: f64 = rfx
.iter()
.map(|fx| match fx {
RasterFx::Feather { radius } | RasterFx::GaussianBlur { radius } => radius.max(0.0),
_ => 0.0,
})
.sum();
if rfx.iter().any(|fx| matches!(fx, RasterFx::Pixel(_))) {
// The content itself (blurred / feathered / filtered), on its pixels: a filter layer's blur
// works on a coarser grid tied to the canvas, so the blurred art shifted by up to a pixel
// and looked blocky as the view moved by parts of a pixel (#755).
let blurs = rfx.iter().any(|fx| matches!(fx, RasterFx::Feather { radius } | RasterFx::GaussianBlur { radius } if *radius > 0.0));
if blurs || rfx.iter().any(|fx| matches!(fx, RasterFx::Pixel(_))) {
self.pixel_content(ctx, f, content, rfx, paint);
} else if blur > 0.0 {
self.with_filters(ctx, f, reach, blur, None, |r, c, fr, _| {
let mut layers = 0;
for fx in rfx {
match fx {
RasterFx::Feather { radius } if *radius > 0.0 => {
c.set_transform(fr.view);
if let Some((g, rule)) = content.outline {
c.set_fill_rule(fill_rule(rule));
c.push_clip_layer(g);
layers += 1;
}
c.push_layer(None, None, None, None, Some(blur_filter(radius / 2.0)));
layers += 1;
}
RasterFx::GaussianBlur { radius } if *radius > 0.0 => {
c.set_transform(fr.view);
c.push_layer(None, None, None, None, Some(blur_filter(radius / 2.0)));
layers += 1;
}
_ => {}
}
}
paint(r, c, fr);
for _ in 0..layers {
c.pop_layer();
}
});
} else {
paint(self, ctx, f);
}
+72
View File
@@ -107,8 +107,14 @@ pub struct RenderOptions {
/// 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,
/// Screen view in isolation mode: the isolated group or layer. Everything around it draws
/// dimmed ([`ISOLATION_DIM`]).
pub isolated: Option<NodeId>,
}
/// The opacity of the art around an isolated group or layer.
pub const ISOLATION_DIM: f32 = 0.5;
/// How edges are rasterized (raster export option).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum AntiAlias {
@@ -174,6 +180,7 @@ impl Default for RenderOptions {
progressive_placed: false,
trace_views: false,
smooth_images: true,
isolated: None,
}
}
}
@@ -355,6 +362,11 @@ pub struct Renderer {
/// Inline graphics being drawn inside inline graphics (a symbol whose art holds text showing
/// it): drawing stops at [`MAX_INLINE_DEPTH`].
inline_depth: u32,
/// [`RenderOptions::isolated`]'s layers and groups, from the top layer down to it, for the
/// frame (`stamp`) and document they were found in.
isolation: Option<(u64, usize, NodeId, Vec<NodeId>)>,
/// Drawing inside the isolated container or a dimmed object: nothing further dims.
isolation_settled: bool,
}
/// How deep inline graphics nest (text in a symbol shown inline in text…) before they draw nothing.
@@ -441,6 +453,8 @@ impl Renderer {
adjusted: Default::default(),
dim_images: None,
inline_depth: 0,
isolation: None,
isolation_settled: false,
}
}
@@ -876,6 +890,46 @@ impl Renderer {
/// [`Self::draw_node`] after culling and Layer Options.
fn draw_layer_node(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node) {
// Isolation mode: the art around the isolated container draws dimmed (#833).
if let Some((isolated, path)) = self.isolation_path(f) {
// A container on the way down to it draws as usual: its members decide.
if path.contains(&n.id) && n.id != isolated {
return self.draw_layer_node_now(ctx, f, n);
}
self.isolation_settled = true;
if n.id == isolated {
self.draw_layer_node_now(ctx, f, n);
} else {
self.dimmed(ctx, f, &mut |r, c, fr| r.draw_layer_node_now(c, fr, n));
}
self.isolation_settled = false;
return;
}
self.draw_layer_node_now(ctx, f, n);
}
/// In isolation mode, while drawing outside the isolated container and the art dimmed around
/// it: the isolated container and its layers and groups, from the top layer down to it.
fn isolation_path(&mut self, f: &Frame) -> Option<(NodeId, Vec<NodeId>)> {
let isolated = f.opts.isolated.filter(|_| !self.isolation_settled)?;
let doc = std::ptr::from_ref(f.doc) as usize;
if !self.isolation.as_ref().is_some_and(|(stamp, d, id, _)| (*stamp, *d, *id) == (self.stamp, doc, isolated)) {
self.isolation = Some((self.stamp, doc, isolated, f.doc.ancestry(isolated).unwrap_or_default()));
}
let path = self.isolation.as_ref().map(|(.., path)| path.clone()).filter(|p| !p.is_empty())?;
Some((isolated, path))
}
/// `draw` at [`ISOLATION_DIM`], with nothing inside dimmed again.
fn dimmed(&mut self, ctx: &mut RenderContext, f: &Frame, draw: &mut group::Content) {
let settled = std::mem::replace(&mut self.isolation_settled, true);
let comp = Composite { opacity: ISOLATION_DIM, ..Default::default() };
self.group(ctx, f, comp, draw);
self.isolation_settled = settled;
}
/// [`Self::draw_layer_node`] past isolation mode's dimming.
fn draw_layer_node_now(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node) {
if fx::has_object_fx(n) {
return self.draw_object_fx(ctx, f, &Arc::new(n.clone()), false);
}
@@ -998,6 +1052,22 @@ impl Renderer {
fn draw_children(&mut self, ctx: &mut RenderContext, f: &Frame, children: &[Arc<Node>], knockout: bool) {
if knockout {
self.draw_knockout(ctx, f, children);
} else if let Some((_, path)) = self.isolation_path(f) {
// Isolation mode, in a container on the way down to the isolated one: each run of
// members around it draws dimmed as one (#833).
let mut rest = children;
while let Some(i) = rest.iter().position(|c| path.contains(&c.id)) {
let (around, from) = rest.split_at(i);
if !around.is_empty() {
self.dimmed(ctx, f, &mut |r, c, fr| around.iter().for_each(|n| r.draw_arc(c, fr, n)));
}
let Some((on_path, after)) = from.split_first() else { break };
self.draw_arc(ctx, f, on_path);
rest = after;
}
if !rest.is_empty() {
self.dimmed(ctx, f, &mut |r, c, fr| rest.iter().for_each(|n| r.draw_arc(c, fr, n)));
}
} else {
for c in children {
self.draw_arc(ctx, f, c);
@@ -1760,6 +1830,8 @@ mod tests_fontchange;
#[cfg(test)]
mod tests_freeform;
#[cfg(test)]
mod tests_fxzoom;
#[cfg(test)]
mod tests_isolation;
#[cfg(test)]
mod tests_knockout;
+142
View File
@@ -0,0 +1,142 @@
//! Raster effects stay where they are, and look the same, at every zoom and pan (#755).
use serde_json::json;
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::text::{CharStyle, TextObject};
use vectorcraft_doc::{Appearance, Effect, Node, NodeKind};
use vectorcraft_geom::{Point, shapes};
use super::*;
/// Type blurred by `radius` points (Gaussian Blur, or Feather when `feather`) over a rectangle
/// that overlays at half opacity; without the type when `radius` is `None`.
fn scene(radius: Option<f64>, feather: bool) -> Document {
let mut d = Document::new(200.0, 100.0);
let l = d.layers[0].id;
let mut back = Node::path(
d.alloc_id(),
shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 100.0)),
Appearance::basic(Paint::solid(Color::rgb(0.2, 0.4, 0.9)), Paint::None, 0.0),
);
back.blend = BlendMode::Overlay;
back.opacity = 0.5;
d.insert(Some(l), 0, back).unwrap();
if let Some(radius) = radius {
let style = CharStyle { size: 36.0, ..CharStyle::default() };
let mut t = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(30.0, 60.0), "Hello", style))));
let id = if feather { "stylize.feather" } else { "blur.gaussian" };
t.appearance.effects.push(Effect { id: id.into(), params: json!({"radius": radius}), visible: true });
d.insert(Some(l), 1, t).unwrap();
}
d
}
/// `d` with its layer's art in one group: the overlay and the type grouped, as in #755's report.
fn grouped(mut d: Document) -> Document {
let l = d.layers[0].id;
let id = d.alloc_id();
let members = d.children_mut(Some(l)).map(std::mem::take).unwrap();
d.insert(Some(l), 0, Node::new(id, NodeKind::Group { children: members, clip: false })).unwrap();
d
}
/// The canvas's options: art on nothing, no artboards.
fn screen() -> RenderOptions {
RenderOptions { background: None, artboards: false, ..Default::default() }
}
fn render(d: &Document, threads: u16, view: Affine, (w, h): (u32, u32)) -> Rendered {
let mut r = Renderer::new();
r.threads = threads;
r.render(d, w, h, view, &screen())
}
/// Where the type's ink is on the document (its centroid), rendered `threads`-threaded through
/// `view` on a `w` × `h` canvas.
fn ink_centre(radius: f64, threads: u16, view: Affine, size: (u32, u32)) -> Point {
let with = render(&scene(Some(radius), false), threads, view, size);
let without = render(&scene(None, false), threads, view, size);
let (mut sx, mut sy, mut sw) = (0.0, 0.0, 0.0);
for (i, (p, q)) in with.pixels.chunks(4).zip(without.pixels.chunks(4)).enumerate() {
let d: f64 = (0..4).map(|c| f64::from(p[c].abs_diff(q[c]))).sum();
let (x, y) = ((i % size.0 as usize) as f64 + 0.5, (i / size.0 as usize) as f64 + 0.5);
sx += d * x;
sy += d * y;
sw += d;
}
assert!(sw > 0.0, "the type shows");
view.inverse() * Point::new(sx / sw, sy / sw)
}
#[test]
fn blurred_type_stays_in_place_at_every_zoom_and_pan() {
for (radius, threads) in [(3.0, 0), (3.0, 4), (12.0, 0), (12.0, 4)] {
let base = ink_centre(radius, threads, Affine::IDENTITY, (200, 100));
// The whole document in view, at other zooms and moved by parts of a pixel.
for (zoom, pan) in
[(1.0, (13.3, 7.6)), (2.0, (41.7, 20.2)), (3.3, (15.25, 9.5)), (0.5, (10.4, 5.1)), (6.0, (30.6, 25.3)), (1.37, (0.5, 0.25))]
{
let view = Affine::translate(pan) * Affine::scale(zoom);
let size = ((200.0 * zoom + 2.0 * pan.0).ceil() as u32, (100.0 * zoom + 2.0 * pan.1).ceil() as u32);
let at = ink_centre(radius, threads, view, size);
assert!(
(at.x - base.x).abs() < 1.0 && (at.y - base.y).abs() < 1.0,
"radius {radius}, threads {threads}, zoom {zoom}, pan {pan:?}: {at:?} vs {base:?}"
);
}
}
}
/// Scrolling moves the blurred type with the page and nothing else: two views a whole number of
/// pixels apart draw the same pixels where they overlap, whether the type fits on the canvas or
/// runs off it.
#[test]
fn scrolling_moves_blurred_type_without_changing_it() {
for (feather, group) in [(false, false), (true, false), (false, true)] {
let d = scene(Some(12.0), feather);
let d = if group { grouped(d) } else { d };
for threads in [0, 4] {
for (zoom, (w, h)) in [(1.0, (240, 140)), (4.0, (500, 300)), (8.0, (400, 300))] {
let at = |dx: f64, dy: f64| render(&d, threads, Affine::translate((-dx, -dy)) * Affine::scale(zoom), (w, h));
let a = at(20.0, 10.0);
for (sx, sy) in [(1u32, 0u32), (3, 2), (37, 23), (101, 57)] {
let b = at(20.0 + f64::from(sx), 10.0 + f64::from(sy));
let mut worst = 0;
for y in sy..h {
for x in sx..w {
let i = ((y * w + x) * 4) as usize;
let j = (((y - sy) * w + x - sx) * 4) as usize;
for c in 0..4 {
worst = worst.max(a.pixels[i + c].abs_diff(b.pixels[j + c]));
}
}
}
assert!(
worst <= 3,
"feather {feather}, grouped {group}, threads {threads}, zoom {zoom}, scrolled by ({sx}, {sy}): a pixel changed by {worst}"
);
}
}
}
}
}
/// Scrolling by parts of a pixel (a trackpad, a zoom about the pointer) moves heavily blurred type
/// by just that: its ink stays put on the page within a tenth of a pixel, at any zoom.
#[test]
fn blurred_type_doesnt_wobble_as_the_view_moves_by_parts_of_a_pixel() {
for threads in [0, 4] {
for zoom in [1.0, 4.0] {
let at = |p: f64| {
ink_centre(30.0, threads, Affine::translate((400.0 - 75.0 * zoom + p, 330.0 - 48.0 * zoom + p)) * Affine::scale(zoom), (800, 660))
};
let base = at(0.0);
for k in 1..10 {
let c = at(f64::from(k) * 0.1);
// How far it moved on the screen, besides the view's own move.
let (dx, dy) = ((c.x - base.x) * zoom, (c.y - base.y) * zoom);
assert!(dx.abs() < 0.1 && dy.abs() < 0.1, "threads {threads}, zoom {zoom}, moved by {}: off by ({dx}, {dy}) px", f64::from(k) * 0.1);
}
}
}
}
+27
View File
@@ -218,3 +218,30 @@ fn copying_the_backdrop_changes_nothing_without_blending() {
let worst = a.iter().zip(&b).map(|(x, y)| (*x as i32 - *y as i32).abs()).max().unwrap();
assert!(worst <= 1, "differs by {worst}/255");
}
/// Isolation mode (#833): the art around the isolated group draws at half opacity, above it as
/// well as below; the group's own art as usual.
#[test]
fn isolation_mode_dims_the_art_around_the_isolated_group() {
let mut d = Document::new(100.0, 100.0);
let l = d.layers[0].id;
let below = rect(&mut d, Rect::new(0.0, 0.0, 40.0, 40.0), [0.0, 0.0, 0.0]);
let inside = rect(&mut d, Rect::new(60.0, 60.0, 100.0, 100.0), [0.0, 0.0, 0.0]);
let above = rect(&mut d, Rect::new(60.0, 0.0, 100.0, 40.0), [0.0, 0.0, 0.0]);
let g = group(&mut d, vec![inside], 1.0, Blend::Normal, false);
let gid = g.id;
for n in [below, g, above] {
d.insert(Some(l), usize::MAX, n).unwrap();
}
let alpha = |img: &Rendered, x: usize, y: usize| img.pixels[(y * 100 + x) * 4 + 3];
let plain = render(&d);
let isolated = |id| render_with(&mut Renderer::new(), &d, &RenderOptions { isolated: Some(id), ..Default::default() });
let dimmed = isolated(gid);
assert_eq!(alpha(&dimmed, 80, 80), 255, "the isolated group's art");
for (x, y) in [(20, 20), (80, 20)] {
assert_eq!(alpha(&plain, x, y), 255);
assert!((120..=135).contains(&alpha(&dimmed, x, y)), "({x}, {y}): {}", alpha(&dimmed, x, y));
}
// An object that isn't in the document dims nothing.
assert_eq!(isolated(NodeId(u64::MAX)).pixels, plain.pixels);
}
+4 -1
View File
@@ -346,7 +346,10 @@ impl Tool for ArtboardTool {
for h in Handle::ALL {
o.push(Overlay::Anchor { p: h.pos(r), color: BLUE, filled: false, size: 7.0 });
}
o.push(Overlay::Label { p: Point::new(r.x0, r.y0 - cx.tol(14.0)), text: format!("{:02} - {}", self.active + 1, ab.name), color: BLUE });
// Where the canvas names the other artboards: on the top edge, flush with the left one
// (labels are drawn 8 px right of and 14 px above their point).
let at = Point::new(r.x0 - cx.tol(8.0), r.y0 - cx.tol(4.0));
o.push(Overlay::Label { p: at, text: format!("{:02} - {}", self.active + 1, ab.name), color: BLUE });
if let (Some(Drag::Resize { began: true, .. }), true) = (self.drag, cx.measurement_labels) {
o.push(Overlay::Measure {
p: Point::new(r.x1, r.y1) + Vec2::new(cx.tol(12.0), cx.tol(12.0)),
+82 -37
View File
@@ -249,6 +249,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
placed: vectorcraft_render::placed_document::generation(),
pixel,
smooth_images,
isolated: st.isolation.map(|id| id.0),
};
// Placed documents' bitmaps are being made: draw again when they are ready.
if vectorcraft_render::placed_document::busy() {
@@ -298,6 +299,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
progressive_placed: true,
trace_views: true,
smooth_images,
// Isolation mode: the art around the isolated group or layer is dimmed (#833).
isolated: key.isolated.map(vectorcraft_doc::NodeId),
..opts
};
// Light documents render synchronously (no lag vs overlays); heavy ones go to the worker.
@@ -349,8 +352,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
if app.ui.view.pixel_preview && v.zoom >= PIXEL_GRID_ZOOM && app.session.prefs.show_pixel_grid {
grid(&painter, &xf, 1.0, 1, LineLook { color: PIXEL_GRID, dots: false });
}
// Artboard edges and names.
// Artboard edges and names. The Artboard tool labels its active artboard itself (following
// a move or resize): that one isn't named twice.
let active_ab = 0;
let artboard_tool = app.session.tool_id() == "artboard";
let tool_labelled = artboard_tool.then(|| app.session.tool_options()["active"].as_u64()).flatten();
for (i, ab) in doc.artboards.iter().enumerate() {
let r = xf.rect_to_screen(ab.rect);
let c = if i == active_ab { Color32::from_gray(0) } else { Color32::from_gray(120) };
@@ -359,7 +365,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
if doc.setup.has_bleed() {
painter.add(Shape::closed_line(xf.quad(doc.setup.bleed_rect(ab.rect)), Stroke::new(1.0, t.bleed)));
}
if app.session.tool_id() == "artboard" || doc.artboards.len() > 1 {
if (artboard_tool || doc.artboards.len() > 1) && tool_labelled != u64::try_from(i).ok() {
painter.text(
r.left_top() - vec2(0.0, 4.0),
egui::Align2::LEFT_BOTTOM,
@@ -1078,47 +1084,46 @@ fn rulers(ui: &Ui, full: egui::Rect, xf: &Xf, hover: Option<Point>, unit: Unit,
let step = unit.ruler_step(xf.zoom);
let minor = step / 10.0;
let font = egui::FontId::proportional(9.5);
let a = xf.to_doc(top.left_top());
let b = xf.to_doc(top.right_top());
let clip_top = p.with_clip_rect(top);
let mut x = (a.x / per / minor).floor() * minor;
while x * per <= b.x {
let sx = xf.to_screen(Point::new(x * per, 0.0)).x;
let is_major = ((x / step).round() * step - x).abs() < minor * 0.01;
let is_mid = ((x / (step / 2.0)).round() * (step / 2.0) - x).abs() < minor * 0.01;
let len = if is_major {
let near = |v: f64, every: f64| ((v / every).round() * every - v).abs() < minor * 0.01;
// A tick's length: the labelled ones longest, the halves between them shorter.
let tick = |v: f64| {
if near(v, step) {
RULER
} else if is_mid {
} else if near(v, step / 2.0) {
7.0
} else {
4.0
};
clip_top.line_segment([pos2(sx, top.bottom() - len), pos2(sx, top.bottom())], Stroke::new(1.0, t.ruler_tick));
if is_major {
}
};
// Each ruler measures along its own edge, in the canvas's coordinates turned with the view
// (#826): at 0° the top one reads x and the left one y. `draw` gets each tick's distance
// (screen points) from `from` along the ruler, and its value in `unit`.
let ticks = |from: Pos2, to: Pos2, axis: vectorcraft_geom::Vec2, draw: &mut dyn FnMut(f32, f64)| {
let along = |s: Pos2| xf.to_doc(s).to_vec2().dot(axis);
let (a, b) = (along(from), along(to));
let mut v = (a / per / minor).floor() * minor;
while v * per <= b {
draw(((v * per - a) * xf.zoom) as f32, v);
v += minor;
}
};
// The canvas's directions along the screen's x and y.
let (sn, cs) = xf.rot.sin_cos();
let clip_top = p.with_clip_rect(top);
ticks(top.left_top(), top.right_top(), vectorcraft_geom::Vec2::new(cs, -sn), &mut |d, x| {
let sx = top.left() + d;
clip_top.line_segment([pos2(sx, top.bottom() - tick(x)), pos2(sx, top.bottom())], Stroke::new(1.0, t.ruler_tick));
if near(x, step) {
clip_top.text(pos2(sx + 2.0, top.top() + 1.0), egui::Align2::LEFT_TOP, ruler_label(x, step), font.clone(), t.ruler_tick);
}
x += minor;
}
let a = xf.to_doc(left.left_top());
let b = xf.to_doc(left.left_bottom());
});
let clip_left = p.with_clip_rect(left);
let mut y = (a.y / per / minor).floor() * minor;
while y * per <= b.y {
let sy = xf.to_screen(Point::new(0.0, y * per)).y;
let is_major = ((y / step).round() * step - y).abs() < minor * 0.01;
let is_mid = ((y / (step / 2.0)).round() * (step / 2.0) - y).abs() < minor * 0.01;
let len = if is_major {
RULER
} else if is_mid {
7.0
} else {
4.0
};
clip_left.line_segment([pos2(left.right() - len, sy), pos2(left.right(), sy)], Stroke::new(1.0, t.ruler_tick));
if is_major {
ticks(left.left_top(), left.left_bottom(), vectorcraft_geom::Vec2::new(sn, cs), &mut |d, y| {
let sy = left.top() + d;
clip_left.line_segment([pos2(left.right() - tick(y), sy), pos2(left.right(), sy)], Stroke::new(1.0, t.ruler_tick));
if near(y, step) {
// Vertical labels read top-to-bottom, one digit per line like Illustrator.
let s = ruler_label(y, step);
for (k, ch) in s.chars().enumerate() {
for (k, ch) in ruler_label(y, step).chars().enumerate() {
clip_left.text(
pos2(left.left() + 4.0, sy + 2.0 + k as f32 * 8.5),
egui::Align2::LEFT_TOP,
@@ -1128,8 +1133,7 @@ fn rulers(ui: &Ui, full: egui::Rect, xf: &Xf, hover: Option<Point>, unit: Unit,
);
}
}
y += minor;
}
});
if let Some(h) = hover {
let s = xf.to_screen(h);
clip_top.line_segment([pos2(s.x, top.top()), pos2(s.x, top.bottom())], Stroke::new(1.0, t.text));
@@ -2050,6 +2054,47 @@ mod tests {
use vectorcraft_engine::Session;
use vectorcraft_geom::Shape as _;
/// #826: in a turned view each ruler still has ticks along its whole length; at 0° the top one
/// reads x where the canvas has it.
#[test]
fn rulers_cover_their_length_at_any_rotation() {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let full = egui::Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0));
let [top, left, _] = ruler_rects(full);
for deg in [0.0_f64, 30.0, 90.0, 135.0, 200.0] {
let xf = Xf { rect: full, zoom: 1.0, center: Point::new(300.0, 200.0), rot: deg.to_radians() };
let mut out = ctx.run_ui(Default::default(), |ui| rulers(ui, full, &xf, None, Unit::Points, &Tokens::get(ui.ctx())));
out.textures_delta.clear();
// The ticks' positions along each ruler.
let along = |ruler: egui::Rect, x: bool| -> Vec<f32> {
let mut at: Vec<f32> = out
.shapes
.iter()
.filter(|c| c.clip_rect == ruler)
.filter_map(|c| match &c.shape {
Shape::LineSegment { points: [a, b], .. } if x && a.x == b.x => Some(a.x),
Shape::LineSegment { points: [a, b], .. } if !x && a.y == b.y => Some(a.y),
_ => None,
})
.collect();
at.sort_by(f32::total_cmp);
at
};
for (ruler, x) in [(top, true), (left, false)] {
let at = along(ruler, x);
let (start, end) = if x { (ruler.left(), ruler.right()) } else { (ruler.top(), ruler.bottom()) };
let (Some(first), Some(last)) = (at.first(), at.last()) else { panic!("{deg}°: no ticks") };
assert!(first - start < 20.0 && end - last < 20.0, "{deg}°: ticks from {first} to {last} on {start}..{end}");
assert!(at.windows(2).all(|w| w[1] - w[0] < 20.0), "{deg}°: a gap in the ticks");
}
if deg == 0.0 {
let origin = xf.to_screen(Point::ZERO).x;
assert!(along(top, true).iter().any(|x| (x - origin).abs() < 0.01), "a tick at x = 0");
}
}
}
#[test]
fn shaper_selection_highlights_only_the_visible_result() {
let mut s = Session::new();
+1 -17
View File
@@ -265,23 +265,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
}
return;
}
// Align buttons.
for (icon, tip, p) in [
("align-start-vertical", "Horizontal Align Left", json!({"horizontal": "left"})),
("align-center-vertical", "Horizontal Align Center", json!({"horizontal": "center"})),
("align-end-vertical", "Horizontal Align Right", json!({"horizontal": "right"})),
("align-start-horizontal", "Vertical Align Top", json!({"vertical": "top"})),
("align-center-horizontal", "Vertical Align Center", json!({"vertical": "center"})),
("align-end-horizontal", "Vertical Align Bottom", json!({"vertical": "bottom"})),
] {
if widgets::icon_button(ui, icon, tl!(tip), false, 24.0).clicked() {
let mut p = p;
if sel.len() == 1 {
p["to"] = json!("artboard");
}
app.run("object.align", p).ok();
}
}
crate::panels::align::align_buttons(app, ui, 24.0);
ui.separator();
// The Transform link opens the whole Transform panel (reference point, rotate,
// shear, options) in a popover; X/Y/W/H follow inline while the bar has room.
+3 -4
View File
@@ -335,10 +335,9 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
// No save dialog for an agent: the bytes come back, as in headless mode.
None => wrap(app.run("document.export", p.clone())),
},
"app.quit" => {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
ok(Value::Null)
}
// As the menu's Quit: `{"pending": "saveChanges"}` while a modified document is asked
// about; otherwise null, and the host closes the window (#830).
"app.quit" => wrap(app.run("app.quit", json!({}))),
other => err(format!("unknown method `{other}`")),
}
}
+60 -3
View File
@@ -90,12 +90,19 @@ fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
let screen =
d.fields.get("screen").and_then(Value::as_array).and_then(|a| Some(egui::pos2(a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32)));
let chip = Point::new(d.f64("x", 0.0), d.f64("y", 0.0));
let pos = screen.unwrap_or_else(|| {
app.canvas_rect.zip(app.view().copied()).map_or(ctx.content_rect().center(), |(r, v)| Xf::new(r, &v).to_screen(chip) + vec2(14.0, 14.0))
let area = egui::Id::new("gradient-stop-popover");
let size = ctx.memory(|m| m.area_rect(area)).map_or(POPOVER_SIZE, |r| r.size());
let pos = screen.unwrap_or_else(|| match app.canvas_rect.zip(app.view().copied()) {
Some((canvas, v)) => {
let xf = Xf::new(canvas, &v);
let at = xf.to_screen(chip);
selection_on_screen(app, &xf).map_or(at + vec2(14.0, 14.0), |art| beside_art(at, art, size, canvas))
}
None => ctx.content_rect().center(),
});
let swatches = d.str("tab") == "swatches";
let mut tab = None;
let resp = egui::Area::new(egui::Id::new("gradient-stop-popover"))
let resp = egui::Area::new(area)
.order(egui::Order::Foreground)
.fixed_pos(pos)
.constrain(true)
@@ -150,6 +157,34 @@ fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
}
}
/// The popover's size before it has been drawn.
const POPOVER_SIZE: egui::Vec2 = vec2(272.0, 360.0);
/// The selected art's box on the screen.
fn selection_on_screen(app: &VectorcraftApp, xf: &Xf) -> Option<egui::Rect> {
let st = app.session.active()?;
let b = st.doc.bounds_of(&st.selection.objects, false)?;
Some(egui::Rect::from_points(&xf.quad(b)))
}
/// Where the popover opened from a stop's chip at `chip` goes: beside the selected `art` on the
/// first side of it with room for a popover of `size` in `room` (right, left, below, above), so
/// the art it recolours stays in view (#862); else beside the chip.
fn beside_art(chip: egui::Pos2, art: egui::Rect, size: egui::Vec2, room: egui::Rect) -> egui::Pos2 {
const GAP: f32 = 12.0;
let x = (chip.x - size.x / 2.0).clamp(room.left(), (room.right() - size.x).max(room.left()));
let y = (chip.y - size.y / 2.0).clamp(room.top(), (room.bottom() - size.y).max(room.top()));
[
egui::pos2(art.right() + GAP, y),
egui::pos2(art.left() - GAP - size.x, y),
egui::pos2(x, art.bottom() + GAP),
egui::pos2(x, art.top() - GAP - size.y),
]
.into_iter()
.find(|p| room.contains_rect(egui::Rect::from_min_size(*p, size)))
.unwrap_or(chip + vec2(14.0, 14.0))
}
/// Document swatches as tiles: a click gives the stop (or point) that colour.
fn swatch_grid(app: &mut VectorcraftApp, ui: &mut egui::Ui, target: &Target) {
let colors = swatch_colors(app);
@@ -248,6 +283,28 @@ mod tests {
app
}
/// #862: the popover goes beside the art, on the first side with room, not over it.
#[test]
fn the_popover_goes_beside_the_art_it_edits() {
let room = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(1200.0, 800.0));
let size = egui::vec2(272.0, 360.0);
let place = |art: egui::Rect| egui::Rect::from_min_size(beside_art(art.center(), art, size, room), size);
// Room on the right.
let art = egui::Rect::from_min_max(egui::pos2(300.0, 300.0), egui::pos2(500.0, 450.0));
assert!(place(art).left() > art.right() && room.contains_rect(place(art)));
// Against the right edge: on the left.
let art = egui::Rect::from_min_max(egui::pos2(900.0, 300.0), egui::pos2(1150.0, 450.0));
assert!(place(art).right() < art.left());
// As wide as the room: below it, or above it near the bottom.
let art = egui::Rect::from_min_max(egui::pos2(100.0, 20.0), egui::pos2(1100.0, 300.0));
assert!(place(art).top() > art.bottom());
let art = egui::Rect::from_min_max(egui::pos2(100.0, 420.0), egui::pos2(1100.0, 780.0));
assert!(place(art).bottom() < art.top());
// Nowhere to go: beside the chip, as before.
let art = room.shrink(10.0);
assert_eq!(beside_art(art.center(), art, size, room), art.center() + egui::vec2(14.0, 14.0));
}
fn stops(app: &VectorcraftApp) -> Vec<GradientStop> {
match selected(app) {
Some(Target::Stop(stops, _)) => stops,
+2
View File
@@ -1497,6 +1497,8 @@
Monospaced Monoespaciada
More Más
More Pathfinder options Más opciones de Buscatrazos
More Align options Más opciones de alineación
More Paragraph options Más opciones de párrafo
More Settings Más ajustes
Move Mover
Move All Objects Mover todos los objetos
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -1496,6 +1496,8 @@
Monospaced À chasse fixe
More Plus
More Pathfinder options Plus d’options Pathfinder
More Align options Plus d’options d’alignement
More Paragraph options Plus d’options de paragraphe
More Settings Plus de réglages
Move Déplacer
Move All Objects Déplacer tous les objets
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -1497,6 +1497,8 @@
Monospaced Monospaziato
More Altro
More Pathfinder options Altre opzioni di Elaborazione tracciati
More Align options Altre opzioni di allineamento
More Paragraph options Altre opzioni di paragrafo
More Settings Altre impostazioni
Move Sposta
Move All Objects Sposta tutti gli oggetti
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -1639,6 +1639,8 @@
Monochrome images モノクロイメージ
More 多く
More Pathfinder options その他のパスファインダーオプション
More Align options その他の整列オプション
More Paragraph options その他の段落オプション
More Settings その他の設定
Move 移動
Move Anchors アンカーポイントを移動
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -1313,6 +1313,8 @@
Monochrome images Imagens monocromáticas
More Mais
More Pathfinder options Mais opções de Localizador de Caminhos
More Align options Mais opções de alinhamento
More Paragraph options Mais opções de parágrafo
More Settings Mais Configurações
Move Mover
Move Anchors Mover Âncoras
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -1495,6 +1495,8 @@
Monospaced Моноширинный
More Больше
More Pathfinder options Дополнительные параметры обработки контуров
More Align options Дополнительные параметры выравнивания
More Paragraph options Дополнительные параметры абзаца
More Settings Дополнительные настройки
Move Переместить
Move All Objects Переместить все объекты
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -1491,6 +1491,8 @@
Monospaced Моноширинний
More Більше
More Pathfinder options Додаткові параметри обробки контурів
More Align options Додаткові параметри вирівнювання
More Paragraph options Додаткові параметри абзацу
More Settings Додаткові налаштування
Move Перемістити
Move All Objects Перемістити всі об’єкти
Can't render this file because it contains an unexpected character in line 632 and column 40.
+2
View File
@@ -1455,6 +1455,8 @@
Monochrome images 单色图像
More 较多
More Pathfinder options 更多路径查找器选项
More Align options 更多对齐选项
More Paragraph options 更多段落选项
More Settings 更多设置
Move 移动
Move All Objects 移动所有对象
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -1497,6 +1497,8 @@
Monospaced 等寬
More 更多
More Pathfinder options 更多路徑管理員選項
More Align options 更多對齊選項
More Paragraph options 更多段落選項
More Settings 更多設定
Move 移動
Move All Objects 移動所有物件
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -293,6 +293,8 @@ pub struct CacheKey {
pub pixel: Option<[i64; 4]>,
/// Images sampled smoothly ([`vectorcraft_render::RenderOptions::smooth_images`]).
pub smooth_images: bool,
/// Isolation mode's group or layer ([`vectorcraft_render::RenderOptions::isolated`]).
pub isolated: Option<u64>,
}
#[derive(Clone, Copy, Debug, Default)]
+51
View File
@@ -52,6 +52,26 @@ pub const DISTRIBUTE: [(&str, &str, &str, &str); 6] = [
("dc-dist-right", "Horizontal Distribute Right", "horizontal", "right"),
];
/// The six align buttons of the Control bar and the Properties panel, `size` points square.
/// They align to what the Align panel's Align To says (the key object while there is one), as
/// its own buttons do (#789).
pub fn align_buttons(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) {
let n = selection_len(app);
let to = align_to(app, ui.ctx());
for (icon, tip, axis, v) in [
("align-start-vertical", "Horizontal Align Left", "horizontal", "left"),
("align-center-vertical", "Horizontal Align Center", "horizontal", "center"),
("align-end-vertical", "Horizontal Align Right", "horizontal", "right"),
("align-start-horizontal", "Vertical Align Top", "vertical", "top"),
("align-center-horizontal", "Vertical Align Center", "vertical", "center"),
("align-end-horizontal", "Vertical Align Bottom", "vertical", "bottom"),
] {
if widgets::icon_button(ui, icon, tl!(tip), false, size).clicked() {
app.run("object.align", align_params(json!({axis: v}), to, n)).ok();
}
}
}
/// Whether the selection has a key object.
fn has_key(app: &VectorcraftApp) -> bool {
app.session.active().is_some_and(|st| st.selection.key.is_some())
@@ -183,4 +203,35 @@ mod tests {
app.run("select.key", json!({})).unwrap();
assert_eq!(align_to(&app, &ctx), AlignTo::Selection, "no key: the choice again");
}
/// #789: the Control bar's and the Properties panel's align buttons follow Align To too.
#[test]
fn the_other_align_buttons_align_to_the_align_to_choice() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
let a = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap()["id"].clone();
let b = app.run("shape.rectangle", json!({"x": 80, "y": 50, "width": 30, "height": 30})).unwrap()["id"].clone();
app.run("select.set", json!({"ids": [a, b]})).unwrap();
let ctx = egui::Context::default();
set_pstate(&ctx, "align-to", AlignTo::Artboard);
// One frame finds the first button (Horizontal Align Right is third); the next clicks it.
let at = std::cell::Cell::new(egui::Pos2::ZERO);
let frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| {
ui.horizontal(|ui| {
at.set(ui.cursor().min);
align_buttons(app, ui, 24.0);
});
});
out.textures_delta.clear();
};
frame(&mut app, vec![]);
let right = at.get() + egui::vec2(2.0 * (24.0 + ctx.global_style().spacing.item_spacing.x) + 12.0, 12.0);
let press = |pressed| egui::Event::PointerButton { pos: right, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
frame(&mut app, vec![egui::Event::PointerMoved(right), press(true)]);
frame(&mut app, vec![press(false)]);
let st = app.session.active().unwrap();
let x1 = |id: &serde_json::Value| st.doc.bounds_of(&[vectorcraft_doc::NodeId(id.as_u64().unwrap())], false).unwrap().x1;
assert_eq!((x1(&a), x1(&b)), (200.0, 200.0), "both to the artboard's right edge");
}
}
+38 -2
View File
@@ -496,7 +496,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
(Some(c), _) => components(mode, &c),
(None, _) => vec![0.0; mode.labels().len()],
};
super::recent_colors_row(app, ui);
let recent = super::recent_colors_row(app, ui);
widgets::divider(ui);
// An edit of the active colour with its displayed components (`new`), or a colour clicked with
// Alt held for the inactive proxy (`behind`).
@@ -585,6 +585,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
*new = Some((c, phase, components(mode, &c)));
}
};
// A recent colour, like the chips, recolours the selected gradient stop (or point).
if let Some(c) = recent {
pick(c, Live::Released, &mut new);
}
// None / Black / White chips and the hex field.
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
@@ -691,12 +695,23 @@ mod tests {
/// Run the panel for one frame with `events` and `modifiers`; returns the spectrum's rect (when
/// the options are shown) and the panel's height.
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>, modifiers: egui::Modifiers) -> (Option<Rect>, f32) {
frame_with(app, ctx, events, modifiers, "color-spectrum")
}
/// [`frame`] returning the rect of the panel's widget `id` instead of the spectrum's.
fn frame_with(
app: &mut VectorcraftApp,
ctx: &egui::Context,
events: Vec<egui::Event>,
modifiers: egui::Modifiers,
id: impl std::hash::Hash + std::fmt::Debug,
) -> (Option<Rect>, f32) {
let mut out = (None, 0.0);
let events = std::iter::once(egui::Event::ModifiersChanged(modifiers)).chain(events).collect();
let input = egui::RawInput { events, ..Default::default() };
let mut full = ctx.run_ui(input, |ui| {
show(app, ui);
out = (ctx.read_response(ui.id().with("color-spectrum")).map(|r| r.rect), ui.min_rect().height());
out = (ctx.read_response(ui.id().with(&id)).map(|r| r.rect), ui.min_rect().height());
});
full.textures_delta.clear();
out
@@ -712,6 +727,27 @@ mod tests {
frame(app, ctx, vec![], egui::Modifiers::NONE);
}
#[test]
fn a_recent_colour_recolours_the_selected_gradient_stop() {
// #835: it replaced the whole gradient with the colour.
let mut app = app();
app.run("paint.setFill", json!({"color": "#ff0000"})).unwrap();
let stops = json!([{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#000000"}]);
app.run("paint.setFill", json!({"gradient": {"stops": stops, "start": [10, 35], "end": [60, 35]}})).unwrap();
app.select_tool("gradient");
app.run("gradient.selectStop", json!({"index": 1})).unwrap();
let red = app.session.recent_colors.iter().position(|c| c.to_hex() == "#ff0000").expect("red is a recent colour");
let ctx = egui::Context::default();
let none = egui::Modifiers::NONE;
let chip = frame_with(&mut app, &ctx, vec![], none, ("recent", red)).0.expect("the chip is drawn").center();
let button = |pressed| egui::Event::PointerButton { pos: chip, button: egui::PointerButton::Primary, pressed, modifiers: none };
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(chip), button(true)], none);
frame(&mut app, &ctx, vec![button(false)], none);
let Paint::Gradient(g) = crate::panels::current_paints(&app).0 else { panic!("still a gradient") };
let hex: Vec<String> = g.gradient.stops.iter().map(|s| s.color.to_hex()).collect();
assert_eq!(hex, ["#ffffff", "#ff0000"]);
}
fn paints_hex(app: &VectorcraftApp) -> (String, String) {
let (f, s) = crate::panels::current_paints(app);
(f.color().map(|c| c.to_hex()).unwrap_or_default(), s.color().map(|c| c.to_hex()).unwrap_or_default())
+3 -5
View File
@@ -412,8 +412,8 @@ pub(crate) fn label_or_name(s: &str, built_in: bool) -> &str {
}
/// "Recent Colors" header + a row of chips (the Session's recent colours, which every paint
/// command feeds); clicking one applies it to the active proxy (Alt: the inactive one).
pub(crate) fn recent_colors_row(app: &mut VectorcraftApp, ui: &mut Ui) {
/// command feeds); returns the one clicked, for the caller to apply.
pub(crate) fn recent_colors_row(app: &VectorcraftApp, ui: &mut Ui) -> Option<Color> {
let t = Tokens::get(ui.ctx());
crate::widgets::subheader(ui, "Recent Colors");
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::hover());
@@ -430,9 +430,7 @@ pub(crate) fn recent_colors_row(app: &mut VectorcraftApp, ui: &mut Ui) {
chosen = Some(*c);
}
}
if let Some(c) = chosen {
apply_click(app, ui, json!({"color": color_json(&c)}));
}
chosen
}
/// A colour as command JSON, keeping its model.
+20 -9
View File
@@ -60,6 +60,24 @@ fn shown_alignment(justify: Justify, rtl: bool) -> Justify {
}
}
/// The seven alignment buttons, `size` points square, the one of `para`'s alignment on (the
/// Paragraph panel's and the Properties panel's).
pub fn alignment_buttons(app: &mut VectorcraftApp, ui: &mut Ui, para: &ParaStyle, size: f32) {
let shown = shown_alignment(para.justify, resolved_rtl(app, para));
for (j, icon, tip, id) in ALIGNMENTS {
if widgets::icon_button(ui, icon, tip, shown == j, size).clicked() {
para_cmd(app, "text.setStyle", json!({"justify": id}));
}
}
}
/// The Hyphenate checkbox of `para`'s paragraphs.
pub fn hyphenate_check(app: &mut VectorcraftApp, ui: &mut Ui, para: &ParaStyle) {
if widgets::check(ui, tl!("Hyphenate"), para.hyphenate, true) {
format(app, json!({"hyphenate": !para.hyphenate}));
}
}
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let Some((_, para)) = text_style(app) else {
@@ -67,14 +85,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
return;
};
let rtl = resolved_rtl(app, &para);
let shown = shown_alignment(para.justify, rtl);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 3.0;
for (j, icon, tip, id) in ALIGNMENTS {
if widgets::icon_button(ui, icon, tip, shown == j, 28.0).clicked() {
para_cmd(app, "text.setStyle", json!({"justify": id}));
}
}
alignment_buttons(app, ui, &para, 28.0);
// Paragraph direction, with the Middle Eastern (Indic) options.
if app.session.prefs.show_indic_options {
ui.add_space(6.0);
@@ -127,9 +140,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
if pstate::<bool>(ui.ctx(), "pa-hide-options") {
return;
}
if widgets::check(ui, tl!("Hyphenate"), para.hyphenate, true) {
format(app, json!({"hyphenate": !para.hyphenate}));
}
hyphenate_check(app, ui, &para);
// Japanese composition: how punctuation is spaced (JLREQ 3.1), with the East Asian options.
if app.session.prefs.show_east_asian_options {
ui.horizontal(|ui| {
+28 -21
View File
@@ -57,21 +57,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
divider(ui);
section_header(ui, tl!("Align"));
ui.horizontal(|ui| {
for (icon, tip, p) in [
("align-start-vertical", tl!("Horizontal Align Left"), json!({"horizontal": "left"})),
("align-center-vertical", tl!("Horizontal Align Center"), json!({"horizontal": "center"})),
("align-end-vertical", tl!("Horizontal Align Right"), json!({"horizontal": "right"})),
("align-start-horizontal", tl!("Vertical Align Top"), json!({"vertical": "top"})),
("align-center-horizontal", tl!("Vertical Align Center"), json!({"vertical": "center"})),
("align-end-horizontal", tl!("Vertical Align Bottom"), json!({"vertical": "bottom"})),
] {
if widgets::icon_button(ui, icon, tip, false, 26.0).clicked() {
let mut p = p;
if n_sel == 1 {
p["to"] = json!("artboard");
}
app.run("object.align", p).ok();
}
super::align::align_buttons(app, ui, 26.0);
// The rest (Distribute Objects, Distribute Spacing, Align To) is the Align panel's.
if widgets::icon_button(ui, "ellipsis", tl!("More Align options"), false, 26.0).clicked() {
app.ui.open_panel = Some("align".into());
}
});
if n_sel > 1 {
@@ -534,13 +523,17 @@ pub fn type_sections(app: &mut VectorcraftApp, ui: &mut Ui) {
super::character::font_pickers(app, ui, &s, ("font", "font-style"), (w - 4.0, w - 4.0));
super::character::metrics_grid(app, ui, "props-char", &s, w, false);
section_header(ui, tl!("Paragraph"));
ui.horizontal(|ui| {
for (icon, j) in [("align-start-vertical", "left"), ("align-center-vertical", "center"), ("align-end-vertical", "right")] {
if widgets::icon_button(ui, icon, j, false, 24.0).clicked() {
app.run("text.setStyle", json!({"justify": j})).ok();
// As in the Paragraph panel: the seven alignments and Hyphenate (#775); the rest is there.
if let Some((_, para)) = super::character::text_style(app) {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 3.0;
super::paragraph::alignment_buttons(app, ui, &para, 24.0);
if widgets::icon_button(ui, "ellipsis", tl!("More Paragraph options"), false, 24.0).clicked() {
app.ui.open_panel = Some("paragraph".into());
}
}
});
});
super::paragraph::hyphenate_check(app, ui, &para);
}
}
#[cfg(test)]
@@ -603,6 +596,20 @@ mod tests {
}
/// #530: Edit Artboards shows the active artboard, with New Artboard, Delete Artboard and Exit.
/// #775: type's Paragraph section has Hyphenate, and its checkbox turns it on.
#[test]
fn type_paragraph_section_hyphenates() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
app.run("text.create", json!({"x": 10, "y": 50, "text": "Paragraph"})).unwrap();
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
let texts = frame(&mut app, &ctx, vec![]);
click(&mut app, &ctx, &texts, "Hyphenate");
let hyphenate = |app: &VectorcraftApp| crate::panels::character::text_style(app).unwrap().1.hyphenate;
assert!(hyphenate(&app));
}
#[test]
fn edit_artboards_adds_artboards_and_exits() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
+4 -1
View File
@@ -642,7 +642,10 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, salt: &'static str) {
}
});
ui.add_space(4.0);
super::recent_colors_row(app, ui);
// A recent colour paints the active proxy (Alt: the inactive one), as a swatch does.
if let Some(c) = super::recent_colors_row(app, ui) {
super::apply_click(app, ui, json!({"color": super::color_json(&c)}));
}
widgets::divider(ui);
widgets::subheader(ui, tl!("Swatch Tiles"));
let query = if pstate(ui.ctx(), "swatch-show-find") { widgets::search_field(ui, find_id(), tl!("Find")) } else { String::new() };
+19
View File
@@ -206,4 +206,23 @@ mod tests {
app.run("app.quit", json!({})).unwrap();
assert_eq!(app.ui.status, "quit");
}
#[test]
fn the_control_channels_quit_says_when_it_asks_first() {
// #830: it answered null at once, before the question.
let (mut app, _) = app();
let ctx = egui::Context::default();
let quit = |app: &mut VectorcraftApp| {
let (req, _rx) = crate::control::ControlRequest::new("app.quit", json!({}));
let crate::control::Outcome::Done(r) = crate::control::handle(app, &ctx, &req) else { panic!("not done") };
r
};
new_doc(&mut app, true);
assert_eq!(quit(&mut app), json!({"ok": true, "result": {"pending": KIND}}));
assert_eq!((pending(&app), app.ui.status.as_str()), (Some(0), ""));
app.ui.dialog = None;
app.run("file.save", json!({"path": "/docs/saved.vectorcraft"})).unwrap();
assert_eq!(quit(&mut app), json!({"ok": true, "result": null}));
assert_eq!(app.ui.status, "quit");
}
}
+2 -1
View File
@@ -45,7 +45,8 @@ How the web shell (`apps/vectorcraft-web/src/web.rs`) differs from desktop:
The canvas is rasterized on the CPU (`vectorcraft-render`, vello_cpu); the GPU (wgpu, through eframe) only composites the canvas texture and draws the UI, so any GPU that can show the window will do. The desktop app picks the window's adapter itself (`apps/vectorcraft/src/gpu.rs`, eframe's `native_adapter_selector`) and logs every adapter it found and the one it uses.
- **Order:** adapters that report they can't present to the window are never tried; then the one `WGPU_ADAPTER_NAME` names; hardware before software (llvmpipe, WARP); the native backends (Vulkan, Metal, DX12) before OpenGL; with Automatic, the GPU that drives a display, and among those the one that drives the primary display; then the power preference; then, on Windows, where each GPU is listed under both, a GPU's DX12 adapter before its Vulkan one (Intel's Vulkan driver made the whole window flicker black, #545), keeping the system's order among equals. `WGPU_BACKEND=vulkan` still picks Vulkan.
- **Order:** adapters that report they can't present to the window are never tried; then the one `WGPU_ADAPTER_NAME` names; hardware before software (llvmpipe, WARP); the native backends (Vulkan, Metal, DX12) before OpenGL; with Automatic, the GPU that drives a display, and among those the one that drives the primary display; then the power preference; then a GPU's DX12 adapter before its Vulkan one (Intel's Vulkan driver made the whole window flicker black, #545), keeping the system's order among equals.
- **Windows backends:** Direct3D 12, falling back to OpenGL. Vulkan isn't started at all unless `WGPU_BACKEND` asks for it (`WGPU_BACKEND=vulkan`): creating a Vulkan instance loads every installed Vulkan driver into the process, and a faulty one (Intel's `igvk64.dll`, #806) crashed the app before its window appeared.
- **The GPU that drives the display.** A GPU without a monitor is the wrong one even when it says it can show the window: on a desktop with a Ryzen's integrated GPU (no monitor) and an NVIDIA card driving both monitors, power saving picked the integrated GPU, Windows had to present every frame across adapters, and the integrated GPU's driver reset under that: the graphics device was lost, or the desktop's compositor went down and every monitor stayed black for minutes (pdfcraft#378). So Automatic asks the system which GPU drives which display and puts that GPU first; the power preference only orders the rest, and decides alone where the system can't say.
- **Windows:** `EnumDisplayDevices` (the display devices attached to the desktop, and the primary one), through the `winsafe` crate; the PCI ids in each device id are matched against wgpu's adapters. The registry doesn't tell a user which adapter drives a display, and WMI reports a resolution for a GPU without a monitor, so neither is used.
- **Linux:** the connected connectors in `/sys/class/drm` and their cards' PCI ids; a built-in panel (`eDP`, `LVDS`, `DSI`) counts as the primary display.