Compare commits

...
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 b3ed7df2a4 Fix #921: the Properties panel's Gradient section with Type and Draw
With the Gradient tool active, the Properties panel shows a Gradient
section: the type buttons (Linear, Radial, Freeform) and, for a
freeform gradient, Draw: Points or Lines as radio buttons. The Gradient
panel and the Properties panel share the type row and the draw mode
change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:50:23 +07:00
BFlat 93fb184939 Merge pull request #932 from storytold/fix/924-tab-leader-typing
Fix #924: the Tabs panel's Leader and Align On keep what is typed
2026-10-10 15:50:20 +07:00
bflatasticandClaude Opus 5.5 2ef32bb1a2 Fix #924: the Tabs panel's Leader and Align On keep what is typed
Both fields read their text from the tab stop again every frame, so each
keystroke was lost before Enter or a click away could apply it: the
field seemed to clear itself while typing. They now use the panels' text
field, which keeps the text being typed until it is committed. The
leader keeps spaces (". " for spaced dots), up to eight characters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:49:49 +07:00
BFlat 5ccbbcca3f Merge pull request #931 from storytold/fix/925-links-on-focus
Fix #925: linked files changed in another app update when the window comes back
2026-10-10 15:49:46 +07:00
bflatasticandClaude Opus 5.5 11beb8fff1 Fix #925: linked files changed in another app update when the window comes back
Preferences › File Handling › Update Links only acted when a document
opened, so a linked image edited in another app while VectorCraft stayed
open kept its old pixels until Update Link in the Links panel, even with
Automatically chosen.

When the window comes back to the front, the active document's links
are now checked. With Automatically, changed files are read again; with
Ask When Modified, they are offered for update, once per change of a
file (keyed by path, size and modification time, since two saves can
share a time); with Manually, the Links panel shows them as modified.
Nothing is asked while a dialog is open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:49:08 +07:00
BFlat 04eb5e52c8 Merge pull request #930 from storytold/feat/pixelate-effects
Part of gap 2: Effect › Pixelate (Color Halftone, Crystallize, Mezzotint, Pointillize)
2026-10-10 15:47:10 +07:00
9 changed files with 253 additions and 28 deletions

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
_Last updated: 2026-10-10 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
_Last updated: 2026-10-10 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
## Where we are
+89 -1
View File
@@ -2,7 +2,8 @@
//! (`links.relink`), and with Apply to All the other missing files are looked for by name in its
//! folder; Ignore keeps the images showing their saved preview, and with Apply to All ignores the
//! rest. Afterwards, with Preferences › Update Links: Ask When Modified, the modified links are
//! offered for update (`links.update`).
//! offered for update (`links.update`). Files changed while the app was in the background are
//! checked when its window comes back to the front ([`after_focus`]).
//!
//! Fields: `missing` (`[{name, path, ids}]`, the one asked about first), `modified` (the ids of
//! images whose file was modified), `applyToAll`, `path` (the replacement file; picked when empty)
@@ -74,6 +75,39 @@ fn ask_update(app: &mut VectorcraftApp, ids: Vec<Value>) {
super::confirm::ask(app, tl!("Update Modified Links"), &detail, "links.update", json!({ "ids": ids }));
}
/// A linked file as last seen: its path, and its size and modification time when it exists.
pub(crate) type FileStamp = (String, Option<(u64, Option<std::time::SystemTime>)>);
/// The window came back to the front (#925): linked files of the active document changed
/// meanwhile are read again with Update Links: Automatically, offered for update with Ask When
/// Modified (once per change of a file), and left to the Links panel with Manually. Nothing is
/// asked while a dialog is open.
pub fn after_focus(app: &mut VectorcraftApp) {
if cfg!(target_arch = "wasm32") || app.session.prefs.update_links == "manually" || app.ui.dialog.is_some() || app.session.active().is_none() {
return;
}
let Ok(r) = app.session.execute("links.check", &json!({})) else { return };
let rows: Vec<Value> = r["links"].as_array().into_iter().flatten().filter(|l| l["status"] == "modified").cloned().collect();
if rows.is_empty() {
return;
}
if app.session.prefs.update_links == "automatically" {
// A failure shows in the status bar, as the Links panel's Update Link does.
let _ = app.run("links.update", json!({ "ids": ids(&rows) }));
return;
}
// The file each link reads (one found elsewhere after a move), its size and when it was last
// changed (two changes can share a time on file systems that keep it coarsely).
let stamp = |l: &Value| -> FileStamp {
let path = l["found"].as_str().or(l["path"].as_str()).unwrap_or_default().to_string();
let meta = std::fs::metadata(&path).ok();
let at = meta.as_ref().map(|m| (m.len(), m.modified().ok()));
(path, at)
};
let new: Vec<Value> = rows.into_iter().filter(|l| app.links_asked.insert(stamp(l))).collect();
ask_update(app, ids(&new));
}
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
let missing = d.fields.get("missing").and_then(Value::as_array).map_or(&[][..], Vec::as_slice);
@@ -262,4 +296,58 @@ mod tests {
assert!(app.ui.dialog.is_none());
let _ = std::fs::remove_dir_all(dir);
}
/// #925: a linked file changed while the app was in the background follows Update Links when
/// the window comes back to the front.
#[test]
fn a_link_changed_in_the_background_follows_update_links_on_focus() {
let dir = std::env::temp_dir().join(format!("vectorcraft-focus-links-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string())));
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
let pic = dir.join("a.png");
// Each version a different size, so no two share a length and modification time.
let mut version = 0;
let mut change = || {
version += 1;
let mut out = vec![];
image::RgbaImage::from_pixel(100 + version, 50, image::Rgba([40 * version as u8, 0, 0, 255]))
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
.unwrap();
std::fs::write(&pic, out).unwrap();
};
change();
app.run("file.place", json!({"path": pic.to_string_lossy()})).unwrap();
let modified = |app: &mut VectorcraftApp| app.run("links.check", json!({})).unwrap()["modified"].as_u64().unwrap();
// The window coming to the front asks for the check.
let mut raw = egui::RawInput { events: vec![egui::Event::WindowFocused(true)], ..Default::default() };
app.raw_input_hook(&mut raw);
assert!(app.links_check);
// Ask When Modified (the default): asked once per change.
change();
after_focus(&mut app);
let d = app.ui.dialog.take().expect("asks");
assert_eq!(d.str("__command"), "links.update");
after_focus(&mut app);
assert!(app.ui.dialog.is_none(), "not twice for the same change");
change();
after_focus(&mut app);
assert!(app.ui.dialog.is_some(), "a new change asks again");
super::super::confirm(&mut app).unwrap();
assert_eq!(modified(&mut app), 0);
// Automatically: read again without asking.
app.run("prefs.set", json!({"key": "updateLinks", "value": "automatically"})).unwrap();
change();
after_focus(&mut app);
assert!(app.ui.dialog.is_none());
assert_eq!(modified(&mut app), 0);
// Manually: left to the Links panel.
app.run("prefs.set", json!({"key": "updateLinks", "value": "manually"})).unwrap();
change();
after_focus(&mut app);
assert!(app.ui.dialog.is_none());
assert_eq!(modified(&mut app), 1);
let _ = std::fs::remove_dir_all(dir);
}
}
+15 -1
View File
@@ -371,6 +371,12 @@ pub struct VectorcraftApp {
/// The look for fonts installed or removed while the app was in the background, running
/// ([`Self::refresh_installed_fonts`]): whether they were.
font_check: Option<std::sync::mpsc::Receiver<bool>>,
/// The window came back to the front: check the active document's linked files next frame
/// ([`dialogs::missing_links::after_focus`]).
pub(crate) links_check: bool,
/// The changed linked files already offered for update (path, size and modification time), so
/// coming back to the window doesn't ask about the same change twice.
pub(crate) links_asked: std::collections::HashSet<dialogs::missing_links::FileStamp>,
/// Keyboard pastes of something other than text (see [`shortcuts::PasteChord`]).
pub(crate) paste_chord: shortcuts::PasteChord,
/// Saves and exports running in the background (Preferences → File Handling).
@@ -468,6 +474,8 @@ impl VectorcraftApp {
clipboard_probe: None,
picks: picks::Picks::default(),
font_check: None,
links_check: false,
links_asked: Default::default(),
paste_chord: Default::default(),
background: Default::default(),
recovery: Default::default(),
@@ -881,6 +889,9 @@ impl VectorcraftApp {
self.system_paste = wanted && self.system_clipboard_pasteable();
}
self.poll_font_check(ctx);
if std::mem::take(&mut self.links_check) {
dialogs::missing_links::after_focus(self);
}
picks::poll(self, ctx);
background::poll(self);
if !self.background.jobs.is_empty() {
@@ -1014,7 +1025,10 @@ impl VectorcraftApp {
match e {
egui::Event::ModifiersChanged(m) => self.host_modifiers = *m,
egui::Event::WindowFocused(false) => self.host_modifiers = egui::Modifiers::NONE,
egui::Event::WindowFocused(true) => self.refresh_installed_fonts(),
egui::Event::WindowFocused(true) => {
self.refresh_installed_fonts();
self.links_check = true;
}
_ => {}
}
}
+39 -13
View File
@@ -95,6 +95,43 @@ fn dragged_stops(origin: &[GradientStop], i: usize, offset: f32, copy: bool, ove
// ---------- shared edits ----------
/// The Type row: Linear, Radial and Freeform for the paint behind the active proxy (one of them
/// lit when it is a gradient). The Gradient panel's and the Properties panel's.
pub(crate) fn type_row(app: &mut VectorcraftApp, ui: &mut Ui) {
let kind = current(app).map(|g| g.gradient.kind);
ui.horizontal(|ui| {
widgets::dim_label(ui, tl!("Type:"));
for (k, icon, tip) in [
(GradientKind::Linear, "dc-grad-linear", tl!("Linear Gradient")),
(GradientKind::Radial, "dc-grad-radial", tl!("Radial Gradient")),
(GradientKind::Freeform, "dc-grad-freeform", tl!("Freeform Gradient")),
] {
if widgets::icon_button(ui, icon, tip, kind == Some(k), 24.0).clicked() {
edit(app, json!({"kind": k.label().to_lowercase()}), Live::Released);
}
}
});
}
/// Set how the Gradient tool adds the points of the freeform gradient behind the active proxy.
fn set_draw_mode(app: &mut VectorcraftApp, mode: FreeformMode) {
edit(app, json!({ "mode": mode.label().to_lowercase() }), Live::Released);
}
/// The Properties panel's Draw row for a freeform gradient: Points and Lines as radio buttons.
pub(crate) fn draw_radios(app: &mut VectorcraftApp, ui: &mut Ui) {
let Some(g) = current(app).filter(|g| g.gradient.kind == GradientKind::Freeform) else { return };
let mode = shown_points(&g).mode;
ui.horizontal(|ui| {
widgets::dim_label(ui, tl!("Draw:"));
for (m, label) in [(FreeformMode::Points, tl!("Points")), (FreeformMode::Lines, tl!("Lines"))] {
if widgets::radio(ui, label, mode == m, true) && mode != m {
set_draw_mode(app, m);
}
}
});
}
/// The gradient behind the active proxy.
fn current(app: &VectorcraftApp) -> Option<GradientPaint> {
match active_paint(app) {
@@ -184,18 +221,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
ui.add_space(4.0);
ui.vertical(|ui| {
ui.horizontal(|ui| {
widgets::dim_label(ui, tl!("Type:"));
for (k, icon, tip) in [
(GradientKind::Linear, "dc-grad-linear", tl!("Linear Gradient")),
(GradientKind::Radial, "dc-grad-radial", tl!("Radial Gradient")),
(GradientKind::Freeform, "dc-grad-freeform", tl!("Freeform Gradient")),
] {
if widgets::icon_button(ui, icon, tip, is_grad && kind == k, 24.0).clicked() {
edit(app, json!({"kind": k.label().to_lowercase()}), Live::Released);
}
}
});
type_row(app, ui);
if widgets::flat_button(ui, tl!("Edit Gradient"), 96.0).clicked() {
app.select_tool("gradient");
}
@@ -297,7 +323,7 @@ fn freeform_section(app: &mut VectorcraftApp, ui: &mut Ui, g: &GradientPaint) {
(FreeformMode::Lines, "spline", tl!("Lines: clicks add points joined by a line")),
] {
if widgets::icon_button(ui, icon, tip, mode == m, 24.0).clicked() && mode != m {
edit(app, json!({ "mode": m.label().to_lowercase() }), Live::Released);
set_draw_mode(app, m);
}
}
});
+32
View File
@@ -40,6 +40,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
transform_section(app, ui);
divider(ui);
// The Gradient tool: the gradient's type and, for a freeform one, how clicks add points (#921).
if app.session.tool_id() == "gradient" {
section_header(ui, tl!("Gradient"));
super::gradient::type_row(app, ui);
super::gradient::draw_radios(app, ui);
divider(ui);
}
let is_image = n_sel == 1 && matches!(first.as_ref().map(|n| &n.kind), Some(NodeKind::Image(_)));
if is_image {
image_section(app, ui);
@@ -563,6 +570,31 @@ mod tests {
frame(app, ctx, vec![b(false)]);
}
/// #921: with the Gradient tool, a Gradient section: the type, and for a freeform gradient
/// Draw as Points or Lines radio buttons.
#[test]
fn the_gradient_tool_shows_the_gradient_type_and_draw() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap();
app.run("paint.setFill", json!({"gradient": {}})).unwrap();
let ctx = egui::Context::default();
let has = |texts: &[(String, Rect)], s: &str| texts.iter().any(|(t, _)| t == s);
let texts = frame(&mut app, &ctx, vec![]);
assert!(!has(&texts, "Gradient"), "only with the Gradient tool");
app.select_tool("gradient");
let texts = frame(&mut app, &ctx, vec![]);
assert!(has(&texts, "Gradient") && has(&texts, "Type:") && !has(&texts, "Draw:"), "{texts:?}");
app.run("paint.editGradient", json!({"kind": "freeform"})).unwrap();
frame(&mut app, &ctx, vec![]);
let texts = frame(&mut app, &ctx, vec![]);
assert!(has(&texts, "Draw:") && has(&texts, "Points") && has(&texts, "Lines"), "{texts:?}");
click(&mut app, &ctx, &texts, "Lines");
let fill = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.fill_paint();
let vectorcraft_color::Paint::Gradient(g) = fill else { panic!("{fill:?}") };
assert_eq!(super::super::gradient::shown_points(&g).mode, vectorcraft_color::FreeformMode::Lines);
}
/// #696: the number fields are all as wide: the Transform fields, the rotation, the corner
/// radius, Opacity and the stroke weight.
#[test]
+64 -10
View File
@@ -141,22 +141,20 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
ui.end_row();
ui.label(egui::RichText::new(tl!("Leader:")).color(t.text));
let mut leader = stops.get(sel).map(|s| s.leader.clone()).unwrap_or_default();
if ui.add_enabled(stops.get(sel).is_some(), egui::TextEdit::singleline(&mut leader).desired_width(90.0)).lost_focus()
&& let Some(s) = stops.get_mut(sel)
&& s.leader != leader
{
// The fields keep what is typed until Enter or a click away commits it (#924).
let leader = stops.get(sel).map(|s| s.leader.clone()).unwrap_or_default();
let typed = ui.add_enabled_ui(stops.get(sel).is_some(), |ui| widgets::exact_text_field(ui, "tabs-leader", &leader, 90.0)).inner;
if let (Some(leader), Some(s)) = (typed, stops.get_mut(sel)) {
// Up to eight characters repeat in the leader.
s.leader = leader.chars().take(8).collect();
apply(app, &stops);
}
ui.end_row();
ui.label(egui::RichText::new(tl!("Align On:")).color(t.text));
let decimal = stops.get(sel).is_some_and(|s| s.align == TabAlign::Decimal);
let mut on = stops.get(sel).map(|s| s.align_on.to_string()).unwrap_or_else(|| ".".into());
if ui.add_enabled(decimal, egui::TextEdit::singleline(&mut on).desired_width(30.0)).lost_focus()
&& let (Some(s), Some(c)) = (stops.get_mut(sel), on.chars().next())
&& s.align_on != c
{
let on = stops.get(sel).map(|s| s.align_on.to_string()).unwrap_or_else(|| ".".into());
let typed = ui.add_enabled_ui(decimal, |ui| widgets::exact_text_field(ui, "tabs-align-on", &on, 30.0)).inner;
if let (Some(c), Some(s)) = (typed.and_then(|t| t.chars().next()), stops.get_mut(sel)) {
s.align_on = c;
apply(app, &stops);
}
@@ -305,6 +303,62 @@ mod tests {
assert_eq!(back[1]["align"], json!("decimal"));
}
/// #924: Leader and Align On keep what is typed, frame after frame, until Enter applies it
/// (spaces kept, eight characters at most).
#[test]
fn leader_and_align_on_keep_what_is_typed_until_enter() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({})).unwrap();
app.session.execute("text.create", &json!({"x": 10, "y": 20, "text": "a b", "area": {"width": 300, "height": 100}})).unwrap();
app.session.execute("text.tabs.set", &json!({"stops": [{"position": 50, "align": "decimal"}]})).unwrap();
let ctx = egui::Context::default();
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(320.0, 400.0));
// One frame with `events` → the texts painted and where.
let frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| show(app, ui));
out.textures_delta.clear();
out.shapes
.iter()
.filter_map(|c| match &c.shape {
egui::Shape::Text(t) => Some((t.galley.text().to_string(), t.visual_bounding_rect())),
_ => None,
})
.collect::<Vec<_>>()
};
let key = |key| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() };
let click = |at| {
[true, false].map(|pressed| egui::Event::PointerButton {
pos: at,
button: egui::PointerButton::Primary,
pressed,
modifiers: Default::default(),
})
};
let stop = |app: &VectorcraftApp| current(app).unwrap().1[0].clone();
for (label, typed, want) in [("Leader:", [". ", "_", "123456789"], ". _12345"), ("Align On:", [",", "", ""], ",")] {
let texts = frame(&mut app, vec![]);
let r = texts.iter().find(|(t, _)| t == label).unwrap_or_else(|| panic!("{label} in {texts:?}")).1;
let at = egui::pos2(r.right() + 30.0, r.center().y);
let [down, up] = click(at);
frame(&mut app, vec![egui::Event::PointerMoved(at), down]);
frame(&mut app, vec![up]);
// Select what is there, then type in pieces over several frames.
frame(
&mut app,
vec![egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }],
);
for piece in typed.iter().filter(|p| !p.is_empty()) {
frame(&mut app, vec![egui::Event::Text(piece.to_string())]);
frame(&mut app, vec![]);
}
frame(&mut app, vec![key(egui::Key::Enter)]);
frame(&mut app, vec![]);
let s = stop(&app);
let got = if label == "Leader:" { s.leader } else { s.align_on.to_string() };
assert_eq!(got, want, "{label}");
}
}
/// Position Panel Above Text floats the panel over the text, its ruler's 0 on the frame's left
/// edge and a stop's mark over the column it sets, at any zoom (#729).
#[test]
+8
View File
@@ -364,6 +364,14 @@ pub fn text_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value
(resp.lost_focus() && buf.trim() != shown).then(|| buf.trim().to_string())
}
/// A one-line [`text_field`] that keeps the text exactly as typed, spaces included (a tab
/// leader such as ". "). Returns it when a change is committed (Enter or focus loss).
pub fn exact_text_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: &str, width: f32) -> Option<String> {
let id = ui.id().with(id);
let (buf, resp, _) = recessed_text(ui, id, value, width, 1);
(resp.lost_focus() && buf != value).then_some(buf)
}
/// A form row: `label` in a column `label_width` wide, then what `add` draws.
pub fn label_row(ui: &mut Ui, label: &str, label_width: f32, add: impl FnOnce(&mut Ui)) {
ui.horizontal(|ui| {
+3 -1
View File
@@ -404,7 +404,9 @@ Missing linked files: `app.open` of a document whose linked images' files can't
folder; `ui.dialog.set {field: "discard", value: true}` then confirm ignores it (the images keep their preview), with
`applyToAll` the rest too. The next missing file is asked about after each answer; `ui.dialog.cancel` stops asking.
Then, with the preference `updateLinks: "askWhenModified"`, modified linked files are offered for update in a
`confirm` dialog whose `ui.dialog.confirm` runs `links.update`.
`confirm` dialog whose `ui.dialog.confirm` runs `links.update`. The same happens when the app's window comes back to
the front and a linked file of the active document changed meanwhile (asked once per change of a file); with
`"automatically"` the files are read again at once, with `"manually"` the Links panel shows them as modified.
Missing fonts: once the missing linked file questions are answered, or at once when there are none, `app.open` of a
document whose type uses fonts that aren't available (`text.missingFonts`) opens the `missingFonts` dialog, one document
+2 -1
View File
@@ -1809,7 +1809,8 @@ the preview instead of the pixels, plus the path relative to the saved file. `do
the linked files again, looking for each at its path, then at its relative path and by name in the document's folder
(so a folder moved with its links still opens): the result lists `missingLinks` (their images show the preview),
`modifiedLinks` (left as they were; read again only with the preference `updateLinks: "automatically"`, then they are
in `updatedLinks`), each as `{name, path, ids}`. `links.check` reports every link's `status` (`ok`, `modified`,
in `updatedLinks`), each as `{name, path, ids}`. The desktop app also checks the active document's links when its window
comes back to the front, following the same preference. `links.check` reports every link's `status` (`ok`, `modified`,
`missing`), `links.update {ids?}` reads modified files again and `links.relink {ids?, path | folder}` points images at
another file (or each at the file of its name in a folder); images keep their bounds, one undo step each. Without a
file system (the web), linked images show their previews.