Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b426859b15 | ||
|
|
61d372bb7f | ||
|
|
82dc5f8182 | ||
|
|
79c81d754c | ||
|
|
97318c188b | ||
|
|
94141eca82 | ||
|
|
5df4ddd4b6 | ||
|
|
021a9474f8 | ||
|
|
de564bc24f | ||
|
|
495694e1ac | ||
|
|
584f08ae70 | ||
|
|
db8be89052 |
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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"}),
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}`")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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)]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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, ¶);
|
||||
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, ¶, 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, ¶);
|
||||
// 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| {
|
||||
|
||||
@@ -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, ¶, 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, ¶);
|
||||
}
|
||||
}
|
||||
|
||||
#[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());
|
||||
|
||||
@@ -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() };
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user