diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index 952bc30f6..9c1c49a68 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -20,7 +20,7 @@ pub fn specs() -> Vec { "Stroke Options", ["Window", "Stroke"], None, - "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\"} Without a stroke item, type takes weight, cap, join, miterLimit, align and the dash options as its characters' stroke (align: around the glyph outlines, the type staying live) (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does)", + "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\"} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does)", has_doc, stroke_set ), @@ -275,8 +275,8 @@ impl Session { impl DocState { /// Which Stroke panel values the selected objects' strokes (type: every run's) don't share, - /// and whether Align Stroke applies to them (no open path among them). One walk of the selection, so callers that ask - /// every frame cache it by revision. + /// and whether Align Stroke applies to them (no open path among them). One walk of the + /// selection, so callers that ask every frame cache it by revision. pub fn stroke_mixed(&self) -> StrokeMixed { let mut nodes = vec![]; for id in &self.selection.objects { diff --git a/crates/engine/src/tests_charstroke.rs b/crates/engine/src/tests_charstroke.rs index 815b7265f..5ccdedce4 100644 --- a/crates/engine/src/tests_charstroke.rs +++ b/crates/engine/src/tests_charstroke.rs @@ -136,7 +136,7 @@ fn character_stroke_options_round_trip_natively_and_old_files_load() { /// Align Stroke on live type (Reddit redd.it/1dwhos6): the characters' stroke goes inside or /// outside the glyphs without creating outlines, for every run or a range, and is kept by the -/// native format, the Eyedropper and Create Outlines. +/// native format and Create Outlines. #[test] fn align_stroke_reaches_the_characters_stroke() { let mut s = session(); @@ -173,17 +173,16 @@ fn align_stroke_reaches_the_characters_stroke() { assert!(aligns.contains(&StrokeAlign::Inside) && aligns.contains(&StrokeAlign::Outside), "{aligns:?}"); } +/// Copying type's appearance onto a path keeps its characters' stroke alignment (the Eyedropper's +/// own stroke attributes have none, on paths as on type). #[test] -fn the_eyedropper_carries_the_characters_stroke_alignment() { +fn a_path_copying_types_appearance_takes_its_stroke_alignment() { let mut s = session(); let id = stroked_text(&mut s); run(&mut s, "stroke.set", json!({"align": "inside"})); let rect = run(&mut s, "shape.rectangle", json!({"x": 10, "y": 150, "width": 50, "height": 50}))["id"].as_u64().unwrap(); run(&mut s, "appearance.copyFrom", json!({"source": id.0, "ids": [rect]})); assert_eq!(s.doc().unwrap().doc.node(NodeId(rect)).unwrap().appearance.stroke().unwrap().align, StrokeAlign::Inside); - run(&mut s, "stroke.set", json!({"ids": [rect], "align": "outside"})); - run(&mut s, "appearance.copyFrom", json!({"source": rect, "ids": [id.0]})); - assert_eq!(text(&s, id).runs[0].style.stroke_align, StrokeAlign::Outside); } #[test] diff --git a/crates/ui-egui/src/panels/tests_strokeux.rs b/crates/ui-egui/src/panels/tests_strokeux.rs index b10b80da9..df836118e 100644 --- a/crates/ui-egui/src/panels/tests_strokeux.rs +++ b/crates/ui-egui/src/panels/tests_strokeux.rs @@ -1,5 +1,6 @@ //! Headless frames of the Stroke panel's reach: Units > Stroke in the panel, the Control bar and -//! the Properties panel, mixed values shown blank, Align Stroke for open paths and type, and the +//! the Properties panel, mixed values shown blank, Align Stroke for open paths and type (also +//! while the Type tool edits it), and the //! weight presets. use serde_json::json; @@ -82,7 +83,7 @@ fn mixed_weights_show_blank() { } #[test] -fn inside_and_outside_are_off_for_open_paths_and_type() { +fn inside_and_outside_are_off_for_open_paths_and_on_for_type() { let ctx = egui::Context::default(); let mut app = app_with_rect(); let align = |app: &VectorcraftApp| current_stroke(app).unwrap().align; @@ -95,9 +96,29 @@ fn inside_and_outside_are_off_for_open_paths_and_type() { let id = run(&mut app, "text.create", json!({"x": 10, "y": 190, "text": "Hi"}))["id"].as_u64().unwrap(); run(&mut app, "select.set", json!({ "ids": [id] })); run(&mut app, "paint.setStroke", json!({"color": "#000000"})); - let undo = app.session.active().unwrap().history.undo.len(); click(&ctx, &mut app, at, stroke::show); - assert_eq!(app.session.active().unwrap().history.undo.len(), undo, "type doesn't"); + assert_eq!(align(&app), vectorcraft_doc::StrokeAlign::Inside, "type's characters do, around their glyphs"); +} + +/// While the Type tool edits text, Align Stroke styles the selected characters' stroke. +#[test] +fn align_stroke_styles_the_characters_the_type_tool_selects() { + let ctx = egui::Context::default(); + let mut app = app_with_rect(); + let at = row_button(&ctx, &texts(&ctx, &mut app, stroke::show), "Align Stroke:", 2); + app.session.select_tool("type", Default::default()).unwrap(); + let id = run(&mut app, "text.create", json!({"x": 10, "y": 190, "text": "Hello"}))["id"].as_u64().unwrap(); + run(&mut app, "paint.setStroke", json!({"color": "#000000"})); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + app.session.set_tool_option("select", &json!({"start": 0, "end": 2})); + assert!(character::text_editing(&app).is_some()); + assert!(stroke_mixed(&app, &ctx).can_align, "the buttons are on"); + click(&ctx, &mut app, at, stroke::show); + let vectorcraft_doc::NodeKind::Text(t) = &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind else { + panic!("not type") + }; + let aligns: Vec<_> = t.runs.iter().map(|r| r.style.stroke_align).collect(); + assert_eq!(aligns, [vectorcraft_doc::StrokeAlign::Outside, vectorcraft_doc::StrokeAlign::Center], "only the selected characters"); } #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 38d0a0e8a..afa8c836d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1323,12 +1323,14 @@ groups naming items that aren't there dropped. Files written before groups load ## Strokes on type -`stroke.set` without an `item` gives type its characters' stroke: weight, cap, join, miter limit and the dash -options go to every run (the object keeps no stroke of its own); an `item` still edits an object-level stroke added +`stroke.set` without an `item` gives type its characters' stroke: weight, cap, join, miter limit, `align` and the +dash options go to every run (the object keeps no stroke of its own); an `item` still edits an object-level stroke added with `appearance.addStroke`, which takes every stroke option (dashes, profile, opacity, blend) like a path's and paints under or over the characters as its place relative to the Characters row says. To stroke -some characters, use `text.setRangeStyle {id, start, end, strokeOptions: {weight?, cap?, join?, miterLimit?, dash?, -dashOffset?, alignDashes?}}`. `inspect_document` reports each object's stroke as `strokeOptions` (type: its first +some characters, use `text.setRangeStyle {id, start, end, strokeOptions: {weight?, cap?, join?, miterLimit?, align?, +dash?, dashOffset?, alignDashes?}}`. `align: "inside"|"outside"` keeps the characters' stroke on that side of the +glyph outlines, drawn at twice the weight and clipped, while the type stays editable (beyond Illustrator); SVG export +writes the text live and that stroke as glyph outlines over it. `inspect_document` reports each object's stroke as `strokeOptions` (type: its first run's) in `stroke.set` terms. `stroke.set` on a group leaves the images and symbol instances in it alone. ## Inline graphics in type diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7f94b53dd..5d8be640a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -373,6 +373,7 @@ since 2026-10-01. | **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); on macOS the menus are in the system menu bar, laid out as a Mac app's, with the in-window menus as a fallback (`--in-window-menus`; verified on Windows and by cross-compilation, not yet by hand on a Mac); hybrid-graphics laptops render on the power-saving GPU by default on Windows and macOS (#306), and Windows starts on Direct3D 12, falling back to OpenGL, and loads no Vulkan driver unless `WGPU_BACKEND` asks for it (#545: Vulkan flickered black on an Intel GPU, DX12 and OpenGL didn't; #806: a faulty Vulkan driver crashed the app before its window appeared; verified by unit tests and the reporter's runs with `WGPU_BACKEND`), and Linux on the GPU the desktop runs on, since a Wayland compositor may refuse frames from the other one; a window that fails on its GPU while starting up starts again on the next one instead of crashing (#502; the dual-GPU Wayland case is verified by unit tests and cross-compilation, not on such hardware); web drops land in the document they were dropped on (#359); desktop drops carry no pointer position (winit 0.30), so a dropped document opens as a tab of its own and a picture or text is placed in the middle of the view, while the web places anything dropped on the canvas and opens what is dropped off it, as Illustrator does (#472); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under native Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only), so Preferences › Performance › Linux Display Server › X11 (XWayland) opens the window through XWayland, where drops work (#1151; verified by unit tests and cross-compilation, not on a Wayland desktop); on Linux the Paste menu checks the system clipboard on a background thread, so a clipboard owner that never answers no longer freezes the window, while Windows and macOS keep their quick in-line check (#510; the X11 hang verified by the contributor, here by headless tests); on KDE Plasma 6.3+ under Wayland, pens and pen displays reach the app only under XWayland (`WAYLAND_DISPLAY=`) or with KWin's `KWIN_WAYLAND_EMULATE_TABLET=1`, since winit 0.30 has no Wayland tablet input (#491, not verified on hardware); Windows/Linux packaging and accessibility are pending; an experimental, unsupported Windows 7 x64 OpenGL build exists (community-contributed, not verified by maintainers) | | **Agent automation** | beyond Illustrator | Every command, gesture and dialog is drivable over MCP, the CLI and the control channel; synthetic-input counts, pending events and per-frame control requests are bounded (#1709); what an agent reads and writes on disk can be confined to chosen folders (`--automation-read-root` / `--automation-write-root`, as in PhotoCraft, #832) | | **Scrubby numeric fields** | beyond Illustrator | As in Photoshop, dragging a numeric field's label (`W:`, `Opacity:`, the Character panel's icons) left or right steps its value live; one undo step per drag, Escape cancels (#400) | +| **Align Stroke on live type** | beyond Illustrator | The Stroke panel's Align Stroke works on type's character stroke: inside or outside the glyphs without Create Outlines, the type staying editable (as InDesign does; users have asked Illustrator for it since 2010, e.g. [redd.it/1dwhos6](https://redd.it/1dwhos6)). Canvas, PDF and EPS clip it to its side of the glyphs; SVG keeps the text live and draws the stroke on the glyph outlines (#2294) | ## Evidence: what ships today, by area @@ -677,6 +678,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Align Stroke on live type (beyond Illustrator): the characters' stroke goes inside or outside the glyphs without creating outlines (#2294); scores and hours unchanged | | 2026-10-16 | minor | G2: Effect › Sketch › Halftone Pattern (Size, Contrast, Pattern Type Circle, Dot or Line; black ink on white paper, Size in points at any Document Raster Effects resolution); measured Photoshop filters 30/57, 27 remaining (22–38 h). | | 2026-10-16 | minor | Symbols: Symbol Options › Export Type (Graphic / Movie Clip) with Registration; Symbols score 85% → 87% (estimated) | | 2026-10-14 | minor | Localization: Brazilian Portuguese (`pt-br`) catalog completed under the tests (#1914); Localization 71% → 74% (measured), hours 55–90 → 50–85; full readiness unchanged (~62%) | diff --git a/docs/type-parity.md b/docs/type-parity.md index 16b61c618..dcbc5897c 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -51,6 +51,7 @@ against Illustrator's public documentation; **estimated** unless noted. | CJK: Proportional Metrics (`palt` in horizontal type, `vpal` in vertical type) | done | #966 | | CJK: ruby, manual tate-chu-yoko | missing | #633 | | Inline symbols in text | beyond Illustrator | not scored | +| Align Stroke (inside, outside) on live type's character stroke | beyond Illustrator | not scored; `stroke.set {align}` and `text.setRangeStyle {strokeOptions: {align}}`; saved natively, drawn around the glyphs on the canvas and in PDF, EPS and SVG (live text, stroke on the glyph outlines); visual bounds grow with it (#2294) | Character Styles menu New/Redefine and footer New capture the first selected character of a nonempty Type selection, including exact style-run and UTF-8 boundaries; New adds a definition without applying it. New/Redefine commands accept optional `end` for range capture. Start-only command affinity, existing UI caret/object behavior and paragraph capture at `start` remain unchanged. Pointer regressions assert captured definitions, linked-text updates and local overrides, complete-document Undo/Redo and saved state, with whole-object, collapsed-caret and range-Apply controls. Capture closes prior typing while preserving the Type selection. Live text and Select All regressions exercise menu New/Redefine and footer New, asserting that further typing retains the captured definition and that typing, capture and further typing have separate Undo/Redo steps. Focused engine and egui input tests pass for boundary capture, all three live-typing capture routes and the existing compatibility controls. Full workspace CI passes. @@ -58,6 +59,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Align Stroke on live type: the characters' stroke goes inside or outside the glyphs without creating outlines, for every run or a range; kept in the native file and in canvas, PDF, EPS and SVG output (#2294); beyond Illustrator, scores and hours unchanged | | 2026-10-14 | minor | Cmd+Shift+C (Align center) works on Windows and Linux too, where egui reported it as a copy (#1940); scores and hours unchanged | | 2026-10-14 | minor | Cmd+Shift+C (Align center) works on Windows and Linux too, where egui reported it as a copy; a double-click on area type's bottom middle handle turns on Auto Size (#1940); scores and hours unchanged | | 2026-10-14 | minor | MCP `add_text` exposes the existing `vertical` option for point, area and path type; horizontal remains the default; scores and hours unchanged. |