Compare commits

..
Author SHA1 Message Date
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
25 changed files with 359 additions and 98 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
};
+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()) {
+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)),
+79 -37
View File
@@ -349,8 +349,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 +362,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 +1081,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 +1130,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 +2051,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.
+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.
+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() };
+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.