Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3ed7df2a4 | ||
|
|
93fb184939 | ||
|
|
2ef32bb1a2 | ||
|
|
5ccbbcca3f | ||
|
|
11beb8fff1 | ||
|
|
04eb5e52c8 |
No files matched your search
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user