diff --git a/ROADMAP.md b/ROADMAP.md index cd491ff31..ec0e3f664 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -33,7 +33,7 @@ since 2026-10-01. | Dimension | Estimate | Evidence and what's missing | |---|---|---| -| **Breadth:** menus, tools and panels exist | ~90% | 15 menu items still stubbed; every tool implemented except Touch Type; 51 panel modules | +| **Breadth:** menus, tools and panels exist | ~90% | 13 menu items still stubbed; every tool implemented except Touch Type; 51 panel modules | | **Depth:** each feature behaves like Illustrator | ~69–75% | Strong: paint, appearance and colour (M3), Pathfinder and booleans, selection, drawing, files (M4). Weak: advanced type (~45%), brushes and symbols (in progress), raster effects (~20%) | | **Large missing subsystems** | 0–20% | 3D & Materials (0%), Photoshop-style raster effects and the Effect Gallery (~1 of ~56 filters), SVG Filters, CJK composition (vertical type with kinsoku, tate-chu-yoko and the font's vertical metrics; ruby, mojikumi and proportional vertical metrics open), Variables (data merge), scripting | | **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first | @@ -89,14 +89,14 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command. - JSON-lines control channel with real egui pointer and keyboard injection. - MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. - - MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (identical reply bytes with one lookup instead of a full-tree serialize per read — ~437x faster leaf reads on a 2,000-object document, measured release-vs-release), and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads). + - MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads). - Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`). - Actions panel that records and plays back commands. - **UI:** Illustrator 2026 layout restyled to measured values: - Medium Dark theme, categorized and Advanced toolbars, 35 pt document tabs, 33 pt panel tabs. - Hint bar, contextual task bar, 19 dock panels with ≡ menus. - The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done. - - Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan) ships complete, and Czech, Japanese and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows and Linux, a VectorCraft ▸ Language menu and a Language preference. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins). + - Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan) and Japanese ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows and Linux, a VectorCraft ▸ Language menu and a Language preference. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins). - Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. - A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas. - Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close). @@ -105,7 +105,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - On Windows and Linux the app bar is the window's title bar (its own minimize, maximize and close buttons, drag to move, edges to resize), with the dragon app icon as the brand mark; dialogs and menus size to their content. As on a native menu bar, once one menu is open, moving the pointer onto another title opens that one. - **Tools:** - **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. - - **Saved selections:** Select ▸ Save Selection… asks for a name and keeps the selection in the document (saved with the file, undoable, up to 25); each one is listed at the bottom of the Select menu and a click selects its objects again; Edit Selection… lists them to rename or delete, applied together on OK. + - **Saved selections:** Select ▸ Save Selection… asks for a name and keeps the selection in the document (saved with the file, undoable, up to 25); each one is listed at the bottom of the Select menu and a click selects its objects again; Edit Selection… lists them to rename or delete, applied together on OK. A file's saved selections are capped and checked against its objects when it opens. Not yet: Update Selection and the Layers panel's saved-selection menu. - **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor. - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. Space held while dragging out a shape, line, arc, spiral or grid moves it at its current size. While a grid is dragged out, Up/Down change its rows (concentric dividers) and Left/Right its columns (radial dividers). - **Cutting:** Eraser, Scissors, Knife, Mirror & Cut, Line Cut and Rectangle Cut (real geometry, compound paths keep their holes). @@ -125,7 +125,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. - Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters). - **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity), Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline. Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`). Point type still scales. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. Variation axis sliders are not done. -- **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional Chinese and Japanese cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech ones from the bundled UI fonts. The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku (the strict set) applies to horizontal and vertical Japanese, and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph › Mojikumi: Line-end Punctuation Half Width (new type's default) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width, and puts a quarter em between Japanese and Latin letters or digits; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. +- **Interface languages, vertical type:** VectorCraft ▸ Language (or Preferences ▸ User Interface) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional Chinese and Japanese cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech ones from the bundled UI fonts. The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku (the strict set) applies to horizontal and vertical Japanese, and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width, and puts a quarter em between Japanese and Latin letters or digits; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. - **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `` and PDF soft masks. - **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder, Live Paint (every path an edge, open lines included), Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). - **Paint and appearance (M3):** @@ -217,7 +217,7 @@ With 4–6 agents working on disjoint crates (as the layering allows) the wall-c (integration, review and shared files such as `menus.rs` serialize some work): **~55–95 h** to feature parity, **~80–145 h** to full parity. -_Inventories (2026-10-07):_ 15 menu items still stubbed (`todo(…)` in `crates/ui-egui/src/menus.rs`); every tool +_Inventories (2026-10-07):_ 13 menu items still stubbed (`todo(…)` in `crates/ui-egui/src/menus.rs`); every tool implemented except Touch Type; 51 panel modules; Illustrator-style live effects ~44/54, Photoshop-style raster effects ~1/56, 3D 0/5; ~2,840 tests; ~221k lines of Rust. diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index e056528b8..3c5c488d6 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -347,6 +347,24 @@ pub struct SavedSelection { pub objects: Vec, } +impl SavedSelection { + /// Most saved selections a document keeps (the Select menu lists every one). + pub const MAX: usize = 25; + /// Longest name, in characters. + pub const MAX_NAME: usize = 255; + + /// `name` trimmed and cut to [`Self::MAX_NAME`] characters (`None` when blank). + pub fn clean_name(name: &str) -> Option { + let name: String = name.trim().chars().take(Self::MAX_NAME).collect(); + (!name.is_empty()).then_some(name) + } + + /// The first "Selection N" none of `saved` is named. + pub fn default_name(saved: &[SavedSelection]) -> String { + (1..=saved.len() + 1).map(|i| format!("Selection {i}")).find(|n| !saved.iter().any(|x| &x.name == n)).unwrap_or_default() + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Guide { /// true = vertical guide at `pos` (x), false = horizontal at `pos` (y). @@ -498,7 +516,7 @@ pub struct Document { /// View → New View… (up to 25, like Illustrator). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub views: Vec, - /// Select → Save Selection… (up to 25 in the engine); saved with the document. + /// Select → Save Selection… (at most [`SavedSelection::MAX`]); saved with the document. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub saved_selections: Vec, #[serde(default)] @@ -929,6 +947,33 @@ impl Document { } impl Document { + /// Saved selections as a file gives them, made safe to list and recall: at most + /// [`SavedSelection::MAX`], each with a clean, unique name and naming objects this document + /// has, each once. (An id it doesn't have would select whatever object takes that id later.) + pub fn tidy_saved_selections(&mut self) { + if self.saved_selections.is_empty() { + return; + } + let mut ids = std::collections::HashSet::new(); + self.walk(|n| { + ids.insert(n.id); + }); + let mut kept: Vec = vec![]; + for s in std::mem::take(&mut self.saved_selections) { + if kept.len() >= SavedSelection::MAX { + break; + } + let Some(name) = SavedSelection::clean_name(&s.name) else { continue }; + if kept.iter().any(|k| k.name == name) { + continue; + } + let mut seen = std::collections::HashSet::new(); + let objects = s.objects.into_iter().filter(|id| ids.contains(id) && seen.insert(*id)).collect(); + kept.push(SavedSelection { name, objects }); + } + self.saved_selections = kept; + } + /// Leave opacity-mask editing: drop the temporary editing layer, a working copy of art the /// mask already holds (the engine syncs it after every edit). pub fn drop_edit_modes(&mut self) { diff --git a/crates/engine/src/cmd/docmenu.rs b/crates/engine/src/cmd/docmenu.rs index c78228817..d35268365 100644 --- a/crates/engine/src/cmd/docmenu.rs +++ b/crates/engine/src/cmd/docmenu.rs @@ -93,7 +93,7 @@ pub fn specs() -> Vec { "Save Selection…", ["Select"], None, - "{name?} save the current selection under a name, in the document (default \"Selection N\"; an existing name is replaced; at most 25) → {name}", + "{name?} save the current selection under a name, in the document (default \"Selection N\"; an existing name is replaced; at most 25, names up to 255 characters) → {name}", has_selection, save_selection ), @@ -273,21 +273,15 @@ fn direction_handles(s: &mut Session, _: &Value) -> Result { Ok(json!({ "anchors": total })) } -/// Illustrator-style saved selections per document, as many as the Select menu lists. -pub const MAX_SAVED_SELECTIONS: usize = 25; - fn save_selection(s: &mut Session, p: &Value) -> Result { const C: &str = "select.save"; let st = s.doc()?; let ids = st.selection.objects.clone(); let saved = &st.doc.saved_selections; - let name = match str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) { - Some(n) => n.to_string(), - None => (1..).map(|i| format!("Selection {i}")).find(|n| !saved.iter().any(|x| &x.name == n)).unwrap_or_default(), - }; + let name = str_param(p, "name").and_then(SavedSelection::clean_name).unwrap_or_else(|| SavedSelection::default_name(saved)); let existing = saved.iter().position(|x| x.name == name); - if existing.is_none() && saved.len() >= MAX_SAVED_SELECTIONS { - return Err(bad(C, format!("a document keeps at most {MAX_SAVED_SELECTIONS} saved selections"))); + if existing.is_none() && saved.len() >= SavedSelection::MAX { + return Err(bad(C, format!("a document keeps at most {} saved selections", SavedSelection::MAX))); } s.edit("Save Selection", |d, _| { let entry = SavedSelection { name: name.clone(), objects: ids }; @@ -335,10 +329,10 @@ fn edit_saved(s: &mut Session, p: &Value) -> Result { let Some(slot) = after.get_mut(i) else { continue }; if bool_or(e, "delete", false) { *slot = None; - } else if let Some(n) = str_param(e, "newName").map(str::trim).filter(|n| !n.is_empty()) + } else if let Some(n) = str_param(e, "newName").and_then(SavedSelection::clean_name) && let Some(x) = slot { - x.name = n.to_string(); + x.name = n; } } let after: Vec = after.into_iter().flatten().collect(); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 1b7f72905..a1fd54841 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -15,7 +15,6 @@ mod cut; pub(crate) mod distortcmds; mod docinfo; mod docmenu; -pub use docmenu::MAX_SAVED_SELECTIONS; pub(crate) mod docsetup; mod draw2; mod edit; diff --git a/crates/engine/src/tests_menucmds.rs b/crates/engine/src/tests_menucmds.rs index 2ed16809f..5d7099d4b 100644 --- a/crates/engine/src/tests_menucmds.rs +++ b/crates/engine/src/tests_menucmds.rs @@ -793,3 +793,16 @@ fn every_new_command_has_params_doc_and_menu() { assert!(!c.params.is_empty() && !c.menu.is_empty(), "{id}"); } } + +#[test] +fn saved_selection_names_are_trimmed_and_capped() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + sel(&mut s, &[a]); + let long = "n".repeat(1000); + let name = s.execute("select.save", &json!({ "name": format!(" {long} ") })).unwrap()["name"].clone(); + assert_eq!(name.as_str().map(|n| n.chars().count()), Some(vectorcraft_doc::SavedSelection::MAX_NAME)); + s.execute("select.editSaved", &json!({"name": name, "newName": format!("{long}é")})).unwrap(); + let list = s.execute("select.savedList", &json!({})).unwrap(); + assert_eq!(list[0].as_str().map(|n| n.chars().count()), Some(vectorcraft_doc::SavedSelection::MAX_NAME)); +} diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index d8a97431a..3829fce8c 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -915,6 +915,7 @@ fn arb_ps_token() -> impl Strategy { "(*) {pop} 8 string /Category resourceforall", "/Generic /Category findresource dup length dict copy /Category defineresource", "dictstack", "8 array", "1183615869 internaldict", "cshow", "currentsystemparams", "currentuserparams", "flushfile", "xcheck", "systemdict", "/userdict", "SharedFontDirectory", "(1 2 add) 0 () /SubFileDecode filter cvx exec", "currentfile cvx exec", + "/p { (p) 0 () /SubFileDecode filter cvx exec } def p", "currentfile 0 () /SubFileDecode filter cvx exec", "cvlit", "{ (a) cshow }", "/ASCII85Decode", "/ASCIIHexDecode", "/FlateDecode", "/RunLengthDecode", "/LZWDecode", "/DCTDecode", "/SubFileDecode", "/DeviceRGB", "/DeviceCMYK", "/DeviceGray", "/Pattern", "/x", "x", "/Helvetica", "(abc)", "", "<~87cURD]i,\"Ebo80~>", "true", "false", "null", "[/Separation (S) /DeviceCMYK {dup dup dup}]", "[/Indexed /DeviceRGB 1 ]", diff --git a/crates/eps/src/import/interp.rs b/crates/eps/src/import/interp.rs index 9f0f862db..94d6b8a8b 100644 --- a/crates/eps/src/import/interp.rs +++ b/crates/eps/src/import/interp.rs @@ -267,7 +267,13 @@ impl<'a> Interp<'a> { } fn tick(&mut self) -> Res { - self.ops += 1; + self.spend(1) + } + + /// Count `ops` operations' worth of work against [`MAX_OPS`]: what an operator that costs + /// far more than one operation (laying out type) does in a loop has to end too. + pub fn spend(&mut self, ops: u64) -> Res { + self.ops = self.ops.saturating_add(ops); if self.ops > MAX_OPS { return Err(PsError::Limit("the program runs too long")); } diff --git a/crates/eps/src/import/tests.rs b/crates/eps/src/import/tests.rs index 1d72ba048..dfbc1b84c 100644 --- a/crates/eps/src/import/tests.rs +++ b/crates/eps/src/import/tests.rs @@ -609,6 +609,23 @@ fn flushfile_skips_the_data_of_a_filter() { blue("currentfile 0 (%%EndPacket) /SubFileDecode filter flushfile\n\n%%EndPacket\n0 0 1 setrgbcolor"); } +/// Executable files are untrusted programs too: running one inside itself stops at the nesting +/// limit, and the program's own file runs once. Type laid out in a loop (`cshow` on a long +/// string, `stringwidth` in `loop`) counts against the operation budget like the loop itself. +#[test] +fn hostile_executable_files_and_type_loops_are_refused() { + let refused = |body: &str, why: &str| { + let e = import(&eps(body)).err().unwrap_or_default(); + assert!(e.contains(why), "{body}: {e}"); + }; + refused("/p { (p) 0 () /SubFileDecode filter cvx exec } def p", "too deeply"); + refused("/p { (p) 0 () /SubFileDecode filter cvx exec } def { p } stopped pop p", "too deeply"); + refused("/a { (b) 0 () /SubFileDecode filter cvx exec } def /b { (a) 0 () /SubFileDecode filter cvx exec } def a", "too deeply"); + refused("/Helvetica findfont 10 scalefont setfont { pop pop pop } 1000000 string cshow", "too long"); + // The program's own file, made executable, is already running. + blue("currentfile cvx exec currentfile flushfile 0 0 1 setrgbcolor"); +} + #[test] fn cshow_runs_its_procedure_for_each_character() { check("/n 0 def { pop pop pop /n n 1 add def } (abc) cshow n 3 eq"); diff --git a/crates/eps/src/import/text.rs b/crates/eps/src/import/text.rs index 5450021d0..1eb2a8e87 100644 --- a/crates/eps/src/import/text.rs +++ b/crates/eps/src/import/text.rs @@ -238,6 +238,10 @@ impl Interp<'_> { /// `s` (read as Latin-1) set in the current font at user point `at`: point type placed there, /// its layout in text space, and how far it moves the current point (user space). fn set_type(&mut self, s: &[u8], at: Point) -> Res<(TextObject, TextLayout, Vec2)> { + // Laying out type costs about as much as 300 operations, and 64 more a character + // (measured), so `{ (a) stringwidth pop pop } loop` or `cshow` on a long string ends + // within the budget like any other loop instead of running for minutes. + self.spend((s.len() as u64).saturating_mul(64).saturating_add(300))?; let (name, f1) = self.font()?; // Text space (y down, `size` points an em) onto the document. let m = self.xf() * Affine::translate(at.to_vec2()) * f1; diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 423b085be..5407240c1 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -380,6 +380,8 @@ pub fn load_file(bytes: &[u8]) -> Result { // Saved before v3: the assigned profiles move out of `unknown`. doc.migrate_color_profiles(); doc.fix_next_id(); + // Untrusted: capped, named and pointing at objects the file has. + doc.tidy_saved_selections(); let profiles = f.profiles.into_iter().filter_map(|(name, icc)| Some((name, base64_decode(&icc.data)?))).collect(); Ok(NativeFile { doc, info: FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION }, profiles }) } diff --git a/crates/format/tests/prop_format.rs b/crates/format/tests/prop_format.rs index 639af5389..67159d784 100644 --- a/crates/format/tests/prop_format.rs +++ b/crates/format/tests/prop_format.rs @@ -203,3 +203,35 @@ fn bug_f64_bit_exact_roundtrip() { d.raster_effects_ppi = 0.1 + 0.2; check_native_roundtrip_exact(&d).unwrap(); } + +/// Saved selections come from the file: older files have none, and a hostile one is cut down to +/// what the Select menu can list, with names that fit and ids the document has. +#[test] +fn saved_selections_from_a_file_are_tidied() { + let d = rich_doc(); + let mut v: Value = serde_json::from_slice(&save(&d, false)).unwrap(); + assert!(v["document"].get("saved_selections").is_none(), "nothing written when there are none"); + assert!(load(&serde_json::to_vec(&v).unwrap()).unwrap().saved_selections.is_empty()); + let id = fixtures::all_ids(&d).into_iter().next().unwrap(); + let missing = fixtures::all_ids(&d).into_iter().map(|NodeId(i)| i).max().unwrap() + 1; + let mut list = vec![ + json!({"name": format!(" {} ", "x".repeat(1000)), "objects": [id.0, missing, id.0]}), + json!({"name": " ", "objects": [id.0]}), + json!({"name": "Dup", "objects": []}), + json!({"name": "Dup", "objects": [id.0]}), + ]; + list.extend((0..100).map(|i| json!({"name": format!("S{i}"), "objects": [id.0]}))); + v["document"]["saved_selections"] = json!(list); + let back = load(&serde_json::to_vec(&v).unwrap()).unwrap(); + let saved = &back.saved_selections; + assert_eq!(saved.len(), vectorcraft_doc::SavedSelection::MAX); + assert_eq!(saved[0].name, "x".repeat(vectorcraft_doc::SavedSelection::MAX_NAME)); + assert_eq!(saved[0].objects, vec![id], "a missing id is dropped, a repeated one kept once"); + assert_eq!((saved[1].name.as_str(), saved[1].objects.len()), ("Dup", 0), "blank names go, the first of a name stays"); + assert_eq!(saved[2].name, "S0"); + // What loads saves and loads again unchanged. + assert_eq!(&load(&save(&back, false)).unwrap().saved_selections, saved); + // Junk in the member is a format error, not a crash. + v["document"]["saved_selections"] = json!([{"name": 5, "objects": "x"}]); + assert!(load(&serde_json::to_vec(&v).unwrap()).is_err()); +} diff --git a/crates/mcp/src/resources.rs b/crates/mcp/src/resources.rs index 4db1d6dd3..8b94fd787 100644 --- a/crates/mcp/src/resources.rs +++ b/crates/mcp/src/resources.rs @@ -118,8 +118,6 @@ pub fn read(b: &mut dyn Backend, uri: &str) -> Result { return Err(ReadError::Invalid(format!("{} needs a value for `{}`", template.uri, variable_name(template.uri)))); } let found = match template.name { - // One direct `document.node` lookup through `engine.execute` (which reaches both - // backends), instead of pulling the whole layer tree out of `document.inspect`. "object" => read_object(b, &var).map_err(ReadError::Backend)?, // The command catalogue is a control method; the rest are engine commands. "command" => find_in(b.call("engine.commands", json!({})).map_err(ReadError::Backend)?, None, "id", &var), @@ -174,21 +172,19 @@ fn decode(s: &str) -> String { } /// The layer or object with this id in the active document, as its compact summary -/// (bounds, paint labels, subtree — the `document.inspect` shape for one node). +/// (bounds, paint labels, subtree: the `document.inspect` shape for one node). /// -/// One `document.node {summary: true}` lookup through `engine.execute` (which reaches -/// both backends), instead of pulling the whole layer tree out of `document.inspect` -/// and scanning it: the reply matches the old read field for field, without the -/// O(document) serialize-and-summarize on every call. -/// -/// A non-numeric id, or one that names nothing, is "not found" (the caller turns it into -/// `-32602`); backend failures are swallowed the same way, matching how [`crate::prompts`] -/// treats an unreachable catalogue (empty rather than an error). +/// One `document.node {summary: true}` lookup instead of summarizing the whole document +/// and searching it. A non-numeric id, or one that names nothing, is "not found" (the +/// caller turns it into `-32602`); other failures (no document, an unreachable app) stay +/// backend errors. fn read_object(b: &mut dyn Backend, id: &str) -> Result, String> { - let Ok(num) = id.parse::() else { return Ok(None) }; - match b.call("engine.execute", json!({"command": "document.node", "params": {"id": num, "summary": true}})) { + let Ok(id) = id.parse::() else { return Ok(None) }; + match b.call("engine.execute", json!({"command": "document.node", "params": {"id": id, "summary": true}})) { Ok(v) => Ok(Some(v)), - Err(_) => Ok(None), + // `EngineError::NoNode`, as both backends word it. + Err(e) if e.starts_with("no such object") => Ok(None), + Err(e) => Err(e), } } @@ -246,6 +242,12 @@ mod tests { assert!(read(b.as_mut(), "vectorcraft://object/").is_err()); assert!(read(b.as_mut(), "vectorcraft://object/99999").is_err()); assert!(read(b.as_mut(), DOC_URI).is_ok()); + // An id that names nothing is a bad value; without a document the backend says why. + for uri in ["vectorcraft://object/99999", "vectorcraft://object/abc"] { + assert!(matches!(read(b.as_mut(), uri), Err(ReadError::Invalid(_))), "{uri}"); + } + let mut empty = Box::new(crate::Headless::new()); + assert!(matches!(read(empty.as_mut(), "vectorcraft://object/1"), Err(ReadError::Backend(_)))); } #[test] diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 66050eedd..8f426fe8f 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -145,7 +145,7 @@ impl FontFace { .unwrap_or(self.upem * 0.5) } /// Glyph `gid` set upright in vertical type, from the font's vertical metrics: (its advance down - /// the column, the height of its vertical origin, the top of its cell, above the baseline), in + /// the column, the height above the baseline of its vertical origin, the top of its cell), in /// font units. The origin is the `VORG` table's, else the glyph's top plus its top side bearing. /// `None` when the font has no vertical metrics (`vhea`/`vmtx`), or they make no sense. pub fn vertical_glyph(&self, gid: u32) -> Option<(f64, f64)> { @@ -379,20 +379,9 @@ pub enum FontClass { } impl FontClass { + /// The kinds a font menu filters by (all but `Other`). pub const ALL: [FontClass; 6] = [FontClass::Serif, FontClass::Sans, FontClass::Rounded, FontClass::Script, FontClass::Monospaced, FontClass::Decorative]; - - pub fn label(self) -> &'static str { - match self { - FontClass::Serif => "Serif / Mincho", - FontClass::Sans => "Sans Serif / Gothic", - FontClass::Rounded => "Rounded", - FontClass::Script => "Script / Brush", - FontClass::Monospaced => "Monospaced", - FontClass::Decorative => "Decorative", - FontClass::Other => "Other", - } - } } /// What a font menu filters on: whether a family sets Japanese, and its kind. diff --git a/crates/ui-egui/src/dialogs/edit_selection.rs b/crates/ui-egui/src/dialogs/edit_selection.rs index 7a8cdbe12..55adb3b1e 100644 --- a/crates/ui-egui/src/dialogs/edit_selection.rs +++ b/crates/ui-egui/src/dialogs/edit_selection.rs @@ -6,7 +6,7 @@ //! `name` (as edited) and `deleted`; `selected` (the row picked). use serde_json::{Value, json}; -use vectorcraft_engine::cmd::MAX_SAVED_SELECTIONS; +use vectorcraft_engine::doc::SavedSelection; use super::{DialogSpec, form}; use crate::state::Dialog; @@ -34,7 +34,7 @@ pub fn open(app: &mut VectorcraftApp) -> Result { } fn count(d: &Dialog) -> usize { - d.fields.get("count").and_then(Value::as_u64).and_then(|n| usize::try_from(n).ok()).unwrap_or(0).min(MAX_SAVED_SELECTIONS) + d.fields.get("count").and_then(Value::as_u64).and_then(|n| usize::try_from(n).ok()).unwrap_or(0).min(SavedSelection::MAX) } /// The rows still in the list (not deleted). diff --git a/crates/ui-egui/src/font_menu.rs b/crates/ui-egui/src/font_menu.rs index 442e4a4eb..8acbf1d00 100644 --- a/crates/ui-egui/src/font_menu.rs +++ b/crates/ui-egui/src/font_menu.rs @@ -69,8 +69,11 @@ const STAR: f32 = 18.0; const NAME_SHARE: f32 = 0.5; /// Width of the disclosure triangle of a family with several styles. const DISCLOSURE: f32 = 14.0; -/// Samples rendered (or asked for) per frame, so a long list doesn't stall the UI. +/// Samples rendered (or asked for) per frame, across every menu, so a long list doesn't stall the UI. const SAMPLES_PER_FRAME: usize = 4; +/// Samples rendering at once (native). +#[cfg(not(target_arch = "wasm32"))] +const MAX_PENDING: usize = 8; /// What a sample shows when the font lacks the selected text's characters (or there is none). const FALLBACK_SAMPLE: &str = "Sample"; /// The script filter's choices: all, Japanese, Latin. @@ -120,6 +123,10 @@ impl Row { } } +/// What the rows were listed for: the query, the script, kind and favourites filters, the +/// expanded families, the favourites and the font list's generation. +type RowsKey = (String, usize, Option, bool, Vec, Vec, u64); + /// The menu's state between frames. #[derive(Clone, Default)] struct MenuState { @@ -138,6 +145,11 @@ struct MenuState { expanded: Vec, /// What was previewed last (None: nothing is previewed). previewed: Option<(String, Option)>, + /// The rows listed, and what for: built again only when that changes, not every frame. + rows: Arc<[Row]>, + rows_key: Option, + /// The list's scroll offset last frame. + scrolled: f32, } /// The font family menu showing `current`, `width` points wide, rows sampling `sample` (the @@ -157,9 +169,8 @@ pub fn font_menu( drawn = true; list(ui, state_id, &mut st, current, sample, look) }); - let pass = ui.ctx().cumulative_pass_nr(); let mut out = picked; - if !drawn && st.last + 1 < pass && st.previewed.take().is_some() && out.is_none() { + if !drawn && out.is_none() && st.previewed.take().is_some() { // Closed (Escape, a click outside) with a preview showing. out = Some(FontPick::EndPreview); } @@ -184,6 +195,8 @@ fn list(ui: &mut Ui, state_id: egui::Id, st: &mut MenuState, current: &str, samp st.query.clear(); } st.last = pass; + // For [`end_stale_preview`]: the menu is open, in this layer. + ui.ctx().data_mut(|d| d.insert_temp(open_id(), (pass, ui.layer_id()))); let field = state_id.with("query"); if opening_now { ui.data_mut(|d| d.insert_temp(field, String::new())); @@ -229,32 +242,37 @@ fn list(ui: &mut Ui, state_id: egui::Id, st: &mut MenuState, current: &str, samp }); let enter = ui.input(|i| i.key_pressed(egui::Key::Enter)); let db = vectorcraft_text::FontDb::global(); - let families = db.family_list(); // The current family by its own name (a document may name it in Japanese: ヒラギノ角ゴシック). let current = db.canonical(current, "").0; let current = current.as_str(); - // Families named with a leading dot are the system's own (hidden from font menus). let favorite = |f: &str| look.favorites.iter().any(|x| x.eq_ignore_ascii_case(f)); - let wanted = |f: &str| { - if f.starts_with('.') || !(query.is_empty() || f.to_lowercase().contains(&query)) { - return false; - } - if st.favorites_only && !favorite(f) { - return false; - } - if st.script == 0 && st.class.is_none() { - return true; - } - let traits = db.family_traits(f); - (st.script == 0 || (st.script == 1) == traits.japanese) && st.class.is_none_or(|c| c == traits.class) - }; - let mut rows: Vec = vec![]; - for f in families.iter().filter(|f| wanted(f)) { - rows.push(Row::Family(f.clone())); - if st.expanded.iter().any(|e| e.eq_ignore_ascii_case(f)) { - rows.extend(db.styles(f).into_iter().map(|s| Row::Style(f.clone(), s))); + // The generation is read first: fonts that load meanwhile make the next frame list them. + let key: RowsKey = (query.clone(), st.script, st.class, st.favorites_only, st.expanded.clone(), look.favorites.to_vec(), db.generation()); + if st.rows_key.as_ref() != Some(&key) { + let wanted = |f: &str| { + if !(query.is_empty() || f.to_lowercase().contains(&query)) { + return false; + } + if st.favorites_only && !favorite(f) { + return false; + } + if st.script == 0 && st.class.is_none() { + return true; + } + let traits = db.family_traits(f); + (st.script == 0 || (st.script == 1) == traits.japanese) && st.class.is_none_or(|c| c == traits.class) + }; + let mut rows: Vec = vec![]; + for f in db.menu_family_list().iter().filter(|f| wanted(f)) { + rows.push(Row::Family(f.clone())); + if st.expanded.iter().any(|e| e.eq_ignore_ascii_case(f)) { + rows.extend(db.styles(f).into_iter().map(|s| Row::Style(f.clone(), s))); + } } + st.rows = rows.into(); + st.rows_key = Some(key); } + let rows = st.rows.clone(); if query != st.query || refilter { st.query = query.clone(); st.highlight = None; @@ -288,20 +306,34 @@ fn list(ui: &mut Ui, state_id: egui::Id, st: &mut MenuState, current: &str, samp widgets::dim_label(ui, tl!("No matching fonts")); return None; } - // The rows: only those in view are laid out (there are thousands of families). + // The rows: only those in view are laid out (there are thousands of families). The list fills + // what the popup shows below the filters (its clip: the popup doesn't grow past its own size, + // and a taller list would scroll inside a scrolling popup), down to the window's bottom. const BOTTOM_GAP: f32 = 12.0; - let room = (ui.ctx().content_rect().bottom() - ui.next_widget_position().y - BOTTOM_GAP).max(120.0); + let bottom = ui.clip_rect().bottom().min(ui.ctx().content_rect().bottom() - BOTTOM_GAP); + let room = (bottom - ui.next_widget_position().y).max(120.0); let mut area = egui::ScrollArea::vertical().max_height(room).min_scrolled_height(room.min(row_h * rows.len() as f32)); - if let Some(i) = st.highlight.filter(|_| keyed || opening) { - area = area.vertical_scroll_offset((i as f32 * row_h - room * 0.5 + row_h * 0.5).max(0.0)); + // The highlight in view: centred as the menu opens, scrolled just enough as the keys move it. + if let Some(top) = st.highlight.map(|i| i as f32 * row_h) { + let target = if opening { + Some(top - (room - row_h) * 0.5) + } else if keyed && top < st.scrolled { + Some(top) + } else if keyed && top + row_h > st.scrolled + room { + Some(top + row_h - room) + } else { + None + }; + if let Some(y) = target { + area = area.vertical_scroll_offset(y.max(0.0)); + } } let mut chosen: Option<(String, Option)> = None; let mut hovered: Option = None; let mut toggled: Option = None; let mut starred: Option = None; - let mut budget = SAMPLES_PER_FRAME; ui.spacing_mut().item_spacing.y = 0.0; - area.show_rows(ui, row_h, rows.len(), |ui, range| { + let shown = area.show_rows(ui, row_h, rows.len(), |ui, range| { for i in range { let Some(row) = rows.get(i) else { continue }; let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click()); @@ -349,19 +381,15 @@ fn list(ui: &mut Ui, state_id: egui::Id, st: &mut MenuState, current: &str, samp ui.painter_at(name_rect).text(name_rect.left_center(), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(12.0), t.text); let sample_rect = egui::Rect::from_min_max(rect.min + vec2(name_w, 2.0), rect.max - vec2(STAR + 2.0, 2.0)); if !look.samples { - } else if let Some(tex) = sample_texture(ui.ctx(), row.family(), row.style(), sample, sample_rect.height(), &mut budget) { - let ppp = ui.ctx().pixels_per_point(); - let size = tex.size_vec2() / ppp; - let at = egui::Rect::from_min_size(sample_rect.left_center() - vec2(0.0, size.y * 0.5), size); - let shown_at = at.intersect(sample_rect); - let uv = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2((shown_at.width() / size.x).clamp(0.0, 1.0), 1.0)); - ui.painter_at(sample_rect).image(tex.id(), shown_at, uv, t.text); + } else if let Some(tex) = sample_texture(ui.ctx(), row.family(), row.style(), sample, sample_rect.height()) { + paint_sample(ui, sample_rect, &tex, t.text); } if resp.clicked() && !on_triangle && !on_star { chosen = Some(row.pick()); } } }); + st.scrolled = shown.state.offset.y; if let Some(f) = toggled { if st.expanded.contains(&f) { st.expanded.retain(|e| *e != f); @@ -399,23 +427,26 @@ fn list(ui: &mut Ui, state_id: egui::Id, st: &mut MenuState, current: &str, samp // ---------- samples ---------- /// Width of the sample drawn at the end of a Type → Font menu item. -const MENU_SAMPLE_WIDTH: f32 = 90.0; +/// The room a Type → Font menu item keeps for its sample, after the family's name. +pub(crate) const MENU_SAMPLE_SIZE: egui::Vec2 = vec2(90.0, 16.0); -/// A generic sample of `family` drawn at the right end of the menu item `item` (Type → Font in -/// the in-window menu bar; the native macOS menu shows names only). -pub(crate) fn menu_item_sample(ui: &Ui, item: egui::Rect, family: &str) { - let t = Tokens::get(ui.ctx()); - let mut budget = SAMPLES_PER_FRAME; - let area = egui::Rect::from_min_max(egui::pos2(item.max.x - MENU_SAMPLE_WIDTH, item.min.y + 2.0), item.max - vec2(4.0, 2.0)); - if let Some(tex) = sample_texture(ui.ctx(), family, None, None, area.height(), &mut budget) { - let size = tex.size_vec2() / ui.ctx().pixels_per_point(); - let at = egui::Rect::from_min_size(area.left_center() - vec2(0.0, size.y * 0.5), size); - let shown = at.intersect(area); - let uv = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2((shown.width() / size.x).clamp(0.0, 1.0), 1.0)); - ui.painter_at(area).image(tex.id(), shown, uv, t.text_dim); +/// A generic sample of `family` drawn in `slot` (the room a Type → Font item of the in-window +/// menu bar keeps for it; the native macOS menu shows names only). +pub(crate) fn menu_item_sample(ui: &Ui, slot: egui::Rect, family: &str) { + if let Some(tex) = sample_texture(ui.ctx(), family, None, None, slot.height()) { + paint_sample(ui, slot, &tex, Tokens::get(ui.ctx()).text_dim); } } +/// Draw sample `tex` (tinted `tint`) at the left of `area`, centred vertically, cut at its right. +fn paint_sample(ui: &Ui, area: egui::Rect, tex: &egui::TextureHandle, tint: Color32) { + let size = tex.size_vec2() / ui.ctx().pixels_per_point(); + let at = egui::Rect::from_min_size(area.left_center() - vec2(0.0, size.y * 0.5), size); + let shown = at.intersect(area); + let uv = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2((shown.width() / size.x).clamp(0.0, 1.0), 1.0)); + ui.painter_at(area).image(tex.id(), shown, uv, tint); +} + type Key = (String, String, String, u32); /// Samples kept before the cache starts over. @@ -426,6 +457,26 @@ struct Samples { ready: HashMap>, #[cfg(not(target_arch = "wasm32"))] pending: HashMap>>, + /// The pass the budget is for, and the samples started in it. + pass: u64, + started: usize, +} + +impl Samples { + /// Take one sample from this frame's budget ([`SAMPLES_PER_FRAME`]); `false` when it is spent + /// (another frame is asked for, to go on). + fn take_budget(&mut self, ctx: &egui::Context) -> bool { + let pass = ctx.cumulative_pass_nr(); + if self.pass != pass { + (self.pass, self.started) = (pass, 0); + } + if self.started >= SAMPLES_PER_FRAME { + ctx.request_repaint(); + return false; + } + self.started += 1; + true + } } thread_local! { @@ -434,17 +485,12 @@ thread_local! { /// The sample of `family` for a row `height` points high: the selected text set in it (else a /// generic sample), white with coverage as alpha (tinted when drawn). Rendered off the UI thread -/// on native, a few per frame; `None` until it is ready. -fn sample_texture( - ctx: &egui::Context, - family: &str, - style: Option<&str>, - sample: Option<&str>, - height: f32, - budget: &mut usize, -) -> Option { +/// on native (the worker asks for a frame when it is done), a few per frame; `None` until it is +/// ready. +fn sample_texture(ctx: &egui::Context, family: &str, style: Option<&str>, sample: Option<&str>, height: f32) -> Option { let px = (height * ctx.pixels_per_point()).round().clamp(8.0, 96.0) as u32; let key: Key = (family.to_string(), style.unwrap_or("Regular").to_string(), sample.unwrap_or_default().to_string(), px); + let texture = |img: egui::ColorImage| ctx.load_texture(format!("font-sample-{family}-{px}"), img, egui::TextureOptions::LINEAR); SAMPLES.with(|s| { let mut s = s.borrow_mut(); if let Some(t) = s.ready.get(&key) { @@ -457,52 +503,41 @@ fn sample_texture( #[cfg(not(target_arch = "wasm32"))] { if let Some(rx) = s.pending.get(&key) { - match rx.try_recv() { - Ok(img) => { - s.pending.remove(&key); - let tex = img.map(|img| ctx.load_texture(format!("font-sample-{}-{px}", key.0), img, egui::TextureOptions::LINEAR)); - s.ready.insert(key, tex.clone()); - return tex; - } - Err(std::sync::mpsc::TryRecvError::Empty) => { - ctx.request_repaint_after(std::time::Duration::from_millis(30)); - return None; - } - Err(std::sync::mpsc::TryRecvError::Disconnected) => { - s.pending.remove(&key); - s.ready.insert(key, None); - return None; - } - } + let img = match rx.try_recv() { + Ok(img) => img, + Err(std::sync::mpsc::TryRecvError::Empty) => return None, + Err(std::sync::mpsc::TryRecvError::Disconnected) => None, + }; + s.pending.remove(&key); + let tex = img.map(texture); + s.ready.insert(key, tex.clone()); + return tex; } - if *budget == 0 || s.pending.len() >= 8 { - ctx.request_repaint_after(std::time::Duration::from_millis(30)); + if s.pending.len() >= MAX_PENDING || !s.take_budget(ctx) { return None; } - *budget -= 1; let (tx, rx) = std::sync::mpsc::channel(); let (f, style, text) = (key.0.clone(), key.1.clone(), key.2.clone()); + let repaint = ctx.clone(); let spawned = std::thread::Builder::new().name("font-sample".into()).spawn(move || { // The receiver may be gone (the cache was cleared): nothing to do then. let _ = tx.send(render_sample(&f, &style, &text, px)); + repaint.request_repaint(); }); if spawned.is_ok() { s.pending.insert(key, rx); } else { s.ready.insert(key, None); } - ctx.request_repaint_after(std::time::Duration::from_millis(30)); None } + // The web has no threads: a few samples per frame, on this one. #[cfg(target_arch = "wasm32")] { - if *budget == 0 { - ctx.request_repaint(); + if !s.take_budget(ctx) { return None; } - *budget -= 1; - let tex = render_sample(&key.0, &key.1, &key.2, px) - .map(|img| ctx.load_texture(format!("font-sample-{}-{px}", key.0), img, egui::TextureOptions::LINEAR)); + let tex = render_sample(&key.0, &key.1, &key.2, px).map(texture); s.ready.insert(key, tex.clone()); tex } @@ -547,10 +582,40 @@ pub(crate) fn render_sample_for_test(family: &str, text: &str, px: u32) -> Optio // ---------- applying a pick ---------- +/// Is a font previewed on the text (an interaction is open for it)? +const PREVIEWING: &str = "font-previewing"; + +/// Where the open font menu was drawn last: (pass, its popup's layer). +fn open_id() -> egui::Id { + egui::Id::new("font-menu-open") +} + +/// Undo a preview whose menu is gone (its panel wasn't drawn: the text was deselected, the panel +/// closed) or is closing (Escape, a press outside the menu), before anything else sees this +/// frame's input: the canvas must not act inside the preview's interaction (a drag would be +/// rolled back with the preview). The shell calls it at the start of each frame. +pub(crate) fn end_stale_preview(app: &mut VectorcraftApp, ctx: &egui::Context) { + if !crate::panels::pstate::(ctx, PREVIEWING) { + return; + } + let drawn = ctx.data(|d| d.get_temp::<(u64, egui::LayerId)>(open_id())); + // Drawn in the last pass: still open. + let Some((_, layer)) = drawn.filter(|(pass, _)| pass + 1 >= ctx.cumulative_pass_nr()) else { + apply(app, ctx, FontPick::EndPreview); + return; + }; + // (`layer_id_at` outside `input`: egui's context lock isn't re-entrant.) + let (escape, press) = ctx.input(|i| (i.key_pressed(egui::Key::Escape), i.pointer.any_pressed().then(|| i.pointer.interact_pos()))); + let leaving = escape || press.is_some_and(|at| at.is_none_or(|p| ctx.layer_id_at(p) != Some(layer))); + if leaving { + apply(app, ctx, FontPick::EndPreview); + } +} + /// What the font menus preview on and apply to: the Type tool's selected range while it edits /// text (the whole text when nothing is selected), else the selected text objects. pub(crate) fn apply(app: &mut VectorcraftApp, ctx: &egui::Context, pick: FontPick) { - let previewing = crate::panels::pstate::(ctx, "font-previewing"); + let previewing = crate::panels::pstate::(ctx, PREVIEWING); match pick { FontPick::Preview(f, style) => { if !previewing { @@ -558,7 +623,7 @@ pub(crate) fn apply(app: &mut VectorcraftApp, ctx: &egui::Context, pick: FontPic if app.session.begin_interaction("Font").is_err() { return; } - crate::panels::set_pstate(ctx, "font-previewing", true); + crate::panels::set_pstate(ctx, PREVIEWING, true); } let (cmd, params) = font_command(app, &f, style.as_deref()); if let Err(e) = app.session.preview(cmd, ¶ms) { @@ -569,7 +634,7 @@ pub(crate) fn apply(app: &mut VectorcraftApp, ctx: &egui::Context, pick: FontPic FontPick::EndPreview => { if previewing { app.session.cancel_interaction().ok(); - crate::panels::set_pstate(ctx, "font-previewing", false); + crate::panels::set_pstate(ctx, PREVIEWING, false); app.sync_views(); } } @@ -585,7 +650,7 @@ pub(crate) fn apply(app: &mut VectorcraftApp, ctx: &egui::Context, pick: FontPic FontPick::Chosen(f, style) => { if previewing { app.session.cancel_interaction().ok(); - crate::panels::set_pstate(ctx, "font-previewing", false); + crate::panels::set_pstate(ctx, PREVIEWING, false); } crate::panels::character::end_typing(app); let (cmd, params) = font_command(app, &f, style.as_deref()); diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 84e12960d..f944330a1 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -16,7 +16,7 @@ Japanese 日本語 Join Our Discord Discord に参加 Line-end Punctuation Half Width 行末約物半角 - Mojikumi 文字組み + Mojikumi Set 文字組み Latin 欧文 Monospaced 等幅 Rounded 丸ゴシック @@ -267,8 +267,8 @@ Insert Break Character 分割文字を挿入 Forced Line Break 強制改行 Tab タブ - Convert To Area Type エリア内文字に切り換え - Convert To Point Type ポイント文字に切り換え + Convert To Area Type エリア内文字に切り替え + Convert To Point Type ポイント文字に切り替え Area Type Options… エリア内文字オプション… Type on a Path パス上文字オプション Rainbow 虹 @@ -733,6 +733,7 @@ Appearance Panel アピアランスパネル Appearance of Black ブラックのアピアランス Appearance panel アピアランスパネル + Applies the next time VectorCraft starts. VectorCraft の次回起動時に適用されます。 Applies when the linked file is relinked or updated. リンクファイルを再リンクまたは更新したときに適用されます。 Apply Brush ブラシを適用 Apply Character Style 文字スタイルを適用 @@ -752,7 +753,7 @@ Arrange: Bring to Front 重ね順: 最前面へ Arrowheads: 矢印: Art using a deleted global swatch keeps its colour. 削除したグローバルスウォッチを使っているアートは、そのカラーを保持します。 - ArtCraft website ArtCraft のウェブサイト + ArtCraft website ArtCraft の Web サイト Artboard Options アートボードオプション Artboard Options: edit with the Artboard tool アートボードオプション: アートボードツールで編集 Artboard Options… アートボードオプション… @@ -785,7 +786,7 @@ Baseline Shift ベースラインシフト Batch バッチ Begin Recording 記録を開始 - Bend: カーブ: + Bend: カーブ: Bevel Join ベベル結合 Bitmap ビットマップ Black ブラック @@ -805,7 +806,7 @@ Brick Offset: レンガのオフセット: Brightness 明るさ Browser ブラウザー - Brush Affects: ブラシの影響: + Brush Affects: ブラシの影響: Brush Definition ブラシの定義 Brush Libraries (on the roadmap) ブラシライブラリ (今後対応予定) Brush Options… ブラシオプション… @@ -835,7 +836,7 @@ Character Set: 文字セット: Character Style Options 文字スタイルオプション Check Links リンクを確認 - Choose a folder フォルダを選択 + Choose a folder フォルダーを選択 Choose… 選択… Circle 円 Clear All Tabs すべてのタブを消去 @@ -901,7 +902,7 @@ Compatibility: 互換性: Compatible Gradient and Gradient Mesh Printing 互換性のあるグラデーションとグラデーションメッシュのプリント Complement 補色 - Complexity: 複雑さ: + Complexity: 複雑さ: Composite colours are converted to it; separations separate with it when it is a CMYK profile. コンポジットカラーはこのプロファイルに変換されます。CMYK プロファイルの場合は、分版にも使用されます。 Compress (RLE) 圧縮 (RLE) Compress text and line art テキストとラインアートを圧縮 @@ -916,7 +917,7 @@ Convert All Spot Colors to Process すべての特色をプロセスカラーに変換 Convert All Strokes to Outlines すべての線をアウトラインに変換 Convert All Text to Outlines すべてのテキストをアウトラインに変換 - Convert Anchor Point アンカーポイントの切り換え + Convert Anchor Point アンカーポイントの切り替え Convert Anchor Points アンカーポイントを切り替え Convert Color カラーを変換 Convert Document Color Mode ドキュメントのカラーモードを変換 @@ -962,6 +963,8 @@ Curvature Tool カーブツール Custom カスタム Cut Path at Selected Anchor Points 選択したアンカーポイントでパスをカット + Dim Images to: 画像の濃さ: + Duplicate Selection 選択項目を複製 DXF Import Options DXF 読み込みオプション DXF Info DXF 情報 DXF Options DXF オプション @@ -1021,12 +1024,12 @@ Description 説明 Description: 説明: Destination: 出力先: - Detail: 詳細: + Detail: 詳細: Devices デバイス Dialog ダイアログ Diamond ひし形 Difference 差の絶対値 - Differences from {preset}: {preset} との違い: + Differences from {preset}: {preset} との違い: Dim Copies to: コピーの濃さ: Dimensions 寸法 Direct Selection Tool ダイレクト選択ツール @@ -1155,7 +1158,7 @@ Encryption: {name} (set by Compatibility) 暗号化:{name} (互換性の設定による) End arrowhead 終点の矢印 English: UK 英語 (英国) - English: USA 英語:米国 + English: USA 英語 (米国) Enter Isolation Mode 分離モードに入る Envelope Info エンベロープ情報 Envelope Mesh エンベロープメッシュ @@ -1174,10 +1177,11 @@ Expand Compound Shape 複合シェイプを拡張 Expand Gradient Mesh グラデーションメッシュを拡張 Expand Gradient To グラデーションの拡張先 + Expand Layers Panel Rows レイヤーパネルの行を展開 Expand Options 拡張オプション Expand Repeat リピートを拡張 Expand applies to compound shapes (Alt-click a shape mode) — on the roadmap 拡張は複合シェイプ (Alt キーを押しながらシェイプモードをクリック)に適用されます。今後対応予定です。 - Expand: 拡張: + Expand: 拡張: Export All… すべて書き出し… Export Artboard アートボードを書き出し Export As 形式を指定して書き出し @@ -1214,7 +1218,7 @@ Feet フィート Feet & Inches フィート・インチ Fewer Elements 要素を減らす - Fidelity: 精度: + Fidelity: 精度: Figure: 数字: File Format: ファイル形式: File Formats ファイル形式 @@ -1243,6 +1247,7 @@ Flare Tool フレアツール Flat Image 統合イメージ Flatness: フラットネス: + Flatten Artwork アートワークを統合 Flatten Transparency 透明部分を分割・統合 Flattener preset: 分割・統合プリセット: Flip Across 反転 (横方向) @@ -1303,6 +1308,8 @@ Graphic Style Options グラフィックスタイルオプション Graphic Style Options… グラフィックスタイルオプション… Graphic Styles Options グラフィックスタイルオプション + Graphics Processor グラフィックプロセッサー + Graphics: {adapter} グラフィックス: {adapter} Grays グレー Grayscale グレースケール Grayscale images グレースケールイメージ @@ -1311,11 +1318,11 @@ Grid Colors: グリッドのカラー: Grid Size: グリッドのサイズ: Gridline every グリッド線の間隔 - Gridline every: グリッド線の間隔: + Gridline every: グリッド線の間隔: Grids In Back グリッドを背面に表示 Group Selection Tool グループ選択ツール Guides & Grid ガイド・グリッド - H Spacing: 水平方向の間隔: + H Spacing: 水平方向の間隔: Hand Tool 手のひらツール Handles ハンドル Hard Light ハードライト @@ -1328,9 +1335,12 @@ Hide Corner Widget for angles greater than 次の角度より大きい場合はコーナーウィジェットを隠す Hide Note メモを隠す Hide Options オプションを隠す + Hide Others その他を隠す Hide Thumbnail サムネールを隠す Hide Thumbnails サムネールを隠す + High Performance (discrete) 高パフォーマンス (専用 GPU) Highlight Alternate Glyphs 代替字形をハイライト + Highlight Rows 行をハイライト Highlight Substituted Fonts 置換フォントをハイライト Highlight Substituted Glyphs 置換字形をハイライト Highlight anchors on mouse over マウスオーバー時にアンカーをハイライト @@ -1338,9 +1348,9 @@ History States ヒストリー数 Hollow 中空 Home ホーム - Horizon Height: 水平線の高さ: + Horizon Height: 水平線の高さ: Horizontal Distribute Space 水平方向等間隔に分布 - Horizontal Grid: 水平グリッド: + Horizontal Grid: 水平グリッド: Horizontal Plane 水平面 Horizontal Scale % 水平比率 % Horizontal: 水平方向: @@ -1411,6 +1421,9 @@ Keywords: キーワード: Knife ナイフ Knockout Group グループの抜き + Layer Options レイヤーオプション + Layers Panel Options レイヤーパネルオプション + Lock Others その他をロック LZW Compression LZW 圧縮 Lab Spot Colors: Lab スポットカラー: Landscape 横置き @@ -1425,7 +1438,7 @@ Layout: レイアウト: Leader: リーダー: Leading 行送り - Left Grid: 左グリッド: + Left Grid: 左グリッド: Left Indent 左インデント Left Plane 左面 Left: 左: @@ -1470,7 +1483,7 @@ List Slices スライス一覧 List View リスト表示 List Workspaces ワークスペースの一覧 - Live Paint Bucket ライブペイント選択ツール + Live Paint Bucket ライブペイントツール Live Paint Bucket (Stroke) ライブペイントツール (線) Live Paint Info ライブペイント情報 Live Paint Selection Tool ライブペイント選択ツール @@ -1520,6 +1533,7 @@ Merge Graphic Styles グラフィックスタイルを統合 Merge Graphic Styles… グラフィックスタイルを統合… Merge Layers レイヤーを統合 + Merge Selected 選択レイヤーを結合 Merge Swatches スウォッチを統合 Merge colors into one row カラーを 1 行に統合 Mesh Tool メッシュツール @@ -1561,6 +1575,7 @@ Move Locked and Hidden Artwork with Artboard ロックおよび非表示のアートワークをアートボードと一緒に移動 Move Mesh Point メッシュポイントを移動 Move Plane 面を移動 + Move Rows 行を移動 Move Slices スライスを移動 Move Spine Point スパインポイントを移動 Move Swatch スウォッチを移動 @@ -1587,6 +1602,7 @@ New Graphic Style 新規グラフィックスタイル New Graphic Style (Alt-click to name it) 新規グラフィックスタイル (Alt+クリックで名前を指定) New Graphic Style… 新規グラフィックスタイル… + New Layer Options… 新規レイヤーオプション… New Layer… 新規レイヤー… New Opacity Masks Are Clipping 新規不透明マスクをクリップに設定 New Opacity Masks Are Inverted 新規不透明マスクを反転に設定 @@ -1724,6 +1740,7 @@ Optimize for fast web view Web ビュー用に最適化 Optimized 最適化 Options オプション + Options for Selection… 選択項目のオプション… Options: オプション: Order: 順序: Ordinals 序数 @@ -1733,6 +1750,7 @@ Original: click to restore オリジナル: クリックで元に戻す Other その他 Out Tangent Handles 出る側のハンドル + Outline Others その他をアウトライン Outline Text テキストをアウトライン化 Output All Blacks Accurately すべてのブラックを正確に出力 Output All Blacks as Rich Black すべてのブラックをリッチブラックで出力 @@ -1760,6 +1778,7 @@ PDF Settings PDF 設定 PDF/A files carry their own output intent. PDF/A のファイルには独自の出力インテントが含まれます。 PDF/X-1a files are CMYK: every colour is converted to the destination (blank: the output intent's CMYK profile), untagged. PDF/X-1a は CMYK のファイルです。すべてのカラーを変換先 (空欄なら出力インテントの CMYK プロファイル) に変換し、プロファイルは埋め込みません。 + Power Saving (integrated) 省電力 (内蔵 GPU) PRESET DETAILS プリセットの詳細 Package パッケージ Package collects a saved document with its linked files and fonts. パッケージは、保存済みのドキュメントをリンクファイルやフォントとともに収集します。 @@ -1837,7 +1856,7 @@ Preserve Lightness 明度を保持 Preserve Overprints and Spot Colors オーバープリントと特色を保持 Preserve Paths パスを保持 - Preserve Shape Using: 形状を保持: + Preserve Shape Using: 形状を保持: Preserve Spot Colors スポットカラーを保持 Preserve Text Appearance テキストの外観を保持 Preserve Text Editability テキストの編集可能性を保持 @@ -1849,6 +1868,7 @@ Presets you save with the Save Preset button appear here. 「プリセットを保存」ボタンで保存したプリセットはここに表示されます。 Presets: プリセット: Press キー + Preview All Layers すべてのレイヤーをプレビュー Preview Mode プレビューモード Preview in Browser ブラウザーでプレビュー Preview the document as it is now 現在のドキュメントをプレビュー @@ -1903,7 +1923,7 @@ Rasterize Unsupported Art 非対応のアートをラスタライズ Rasters ラスター Rating: 評価: - Real World: 実寸: + Real World: 実寸: Real-time Drawing and Editing リアルタイム描画および編集 Rearrange All Artboards (on the roadmap) すべてのアートボードを再配置 (今後対応予定) Recall Selection 選択範囲を呼び出し @@ -1930,7 +1950,7 @@ Rectangle Cut Tool 長方形カットツール Rectangle Properties: 長方形のプロパティ: Rectangle Tool 長方形ツール - Rectangular Grid 矩形グリッド + Rectangular Grid 長方形グリッド Rectangular Grid Tool 長方形グリッドツール Red レッド Redefine Character Style 文字スタイルを再定義 @@ -1953,6 +1973,8 @@ Registry name: レジストリ名: Release Compound Shape 複合シェイプを解除 Release Opacity Mask 不透明マスクを解除 + Release to Layers (Build) レイヤーに分配 (ビルド) + Release to Layers (Sequence) レイヤーに分配 (シーケンス) Relink 再リンク Relink to Folder… フォルダーに再リンク… Relink… 再リンク… @@ -2015,7 +2037,7 @@ Reverse Path Direction Off パスの方向を反転: オフ Reverse Path Direction On パスの方向を反転: オン Revert to the saved version of “{name}”? 「{name}」を保存済みの状態に戻しますか? - Right Grid: 右グリッド: + Right Grid: 右グリッド: Right Indent 右インデント Right Plane 右面 Right: 右: @@ -2028,11 +2050,15 @@ Round Join ラウンド結合 Rounded Rectangle 角丸長方形 Rounded Rectangle Tool 角丸長方形ツール - Rows: 行数: + Row Size: 行のサイズ: + Rows: 行数: Rule: ルール: Rulers & Grids 定規とグリッド Run Plug-in プラグインを実行 SAVED 保存済み + Select Art アートを選択 + Show All Layers すべてのレイヤーを表示 + Show Layers Only レイヤーのみを表示 SVG Options SVG オプション SVG Profile: SVG プロファイル: Sample Color カラーをサンプリング @@ -2210,7 +2236,7 @@ Side 2: 辺 2: Sides: 辺の数: Simplify 単純化 - Simplify: 単純化: + Simplify: 単純化: Simulate Colored Paper 色紙をシミュレート Simulate Paper 紙色をシミュレート Single Quotes: 一重引用符: @@ -2297,13 +2323,13 @@ Swatch Options… スウォッチオプション… Swatch Tiles スウォッチタイル Switch workspace ワークスペースを切り替え - Symbol Libraries (on the roadmap) シンボルライブラリ (開発予定) + Symbol Libraries (on the roadmap) シンボルライブラリ (今後対応予定) Symbol Screener Tool シンボルスクリーナーツール Symbol Scruncher Tool シンボルスクランチャーツール Symbol Shifter Tool シンボルシフターツール Symbol Sizer Tool シンボルリサイザーツール Symbol Spinner Tool シンボルスピンツール - Symbol Sprayer シンボルスプレーヤーツール + Symbol Sprayer シンボルスプレー Symbol Sprayer Tool シンボルスプレーツール Symbol Stainer Tool シンボルステインツール Symbol Styler Tool シンボルスタイルツール @@ -2325,7 +2351,7 @@ The page as it prints プリントされるページ The selected objects have no CSS of their own (see below). 選択したオブジェクト自体には CSS がありません (下記を参照)。 The system's settings of this printer このプリンターのシステム設定 - Third Vanishing Point: 第 3 消失点: + Third Vanishing Point: 第 3 消失点: This document already has a swatch named “{name}” with another color. このドキュメントには、別のカラーの「{name}」という名前のスウォッチがすでにあります。 This library is empty. このライブラリは空です。 This panel is on the roadmap (see the parity plan). このパネルは今後対応予定です。 @@ -2338,6 +2364,7 @@ Three Point Perspective Preset 5 3 点遠近法プリセット 5 Threshold しきい値 Thumbnail View サムネール表示 + Thumbnails: サムネール: Tile Edge Color タイルの境界線の色 Tile Range: 分割範囲: Tile Type: タイルの種類: @@ -2378,7 +2405,7 @@ Trim mark weight: トリムマークの線幅: True colour needs version 2004 or later: the nearest of 256 colours is written. True Color にはバージョン 2004 以降が必要です。256 色のうち最も近い色で書き出します。 Turn off Data Recovery for complex documents 複雑なドキュメントではデータ復帰をオフにする - Twirl Rate: ひねりの割合: + Twirl Rate: ひねりの割合: Twirl Tool 旋回ツール Two Point 2 点遠近法 Two Point Perspective Preset 1 2 点遠近法プリセット 1 @@ -2407,6 +2434,7 @@ Unlink harmony colors ハーモニーカラーのリンクを解除 Unlink the mask マスクのリンクを解除 Unlock ロック解除 + Unlock All Layers すべてのレイヤーのロックを解除 Unnamed Objects 名前のないオブジェクト Unprintable margin: 印刷不可の余白: Unshift シフトを戻す @@ -2452,7 +2480,7 @@ User Swatch Library 7 ユーザースウォッチライブラリ 7 User Swatch Library 8 ユーザースウォッチライブラリ 8 User Swatch Library 9 ユーザースウォッチライブラリ 9 - V Spacing: 垂直方向の間隔: + V Spacing: 垂直方向の間隔: Variable Width Profile 可変幅プロファイル Variation: バリエーション: Vector Halftone ベクトルハーフトーン @@ -2470,8 +2498,8 @@ View Artboard Only アートボードのみ表示 View Opacity Mask 不透明マスクを表示 View PDF after saving 保存後に PDF を表示 - Viewing Angle: 視角: - Viewing Distance: 視距離: + Viewing Angle: 視角: + Viewing Distance: 視距離: Warnings 警告 Warp Options ワープオプション Warp Tool ワープツール @@ -2481,7 +2509,7 @@ Welcome to VectorCraft VectorCraft へようこそ When pasting text テキストをペーストするとき White 白 - Widget Position: ウィジェットの位置: + Widget Position: ウィジェットの位置: Width 幅 Width Point 幅ポイント Width Point Edit 幅ポイントを編集 @@ -2540,13 +2568,13 @@ ● Recording ● 記録中 : : CMYK CMYK - H: H: + H: H: OK OK PPI PPI - PostScript: PostScript: - URL: URL: - V: V: - W: W: + PostScript: PostScript: + URL: URL: + V: V: + W: W: Enter Enter Esc Esc PDF PDF @@ -2605,7 +2633,7 @@ Copy Links リンクをコピー Couldn't copy to the system clipboard: {e} システムのクリップボードにコピーできませんでした:{e} Couldn't paste from the clipboard: {e} クリップボードからペーストできませんでした:{e} - Couldn't save recovery data: {e} 復元用データを保存できませんでした:{e} + Couldn't save recovery data: {e} 復帰用データを保存できませんでした:{e} Couldn't {} {name}: {e} {name} を{}できませんでした:{e} Create Report レポートを作成 Ctrl Ctrl @@ -2648,7 +2676,7 @@ Fill (X), double-click for the Color Picker 塗り (X)、ダブルクリックでカラーピッカー Fill: click for swatches, Shift-click for the color mixer 塗り: クリックでスウォッチ、Shift キーを押しながらクリックでカラーミキサー Fit in View 表示に合わせる - Flare Tool Options 光彩ツールオプション + Flare Tool Options フレアツールオプション Flattener presets 分割・統合プリセット Focal Fill 焦点の塗り Focal Stroke 焦点の線 @@ -2781,13 +2809,13 @@ Rotate 90° CW 時計回りに 90°回転 Same As Source ソースと同じ Saved 保存済み - Saved recovery data for {title} {title} の復元用データを保存しました + Saved recovery data for {title} {title} の復帰用データを保存しました Saved to User Defined: {} ユーザー定義に保存しました:{} Saved {n} files beside {path} {path} の隣に {n} 個のファイルを保存しました Saved {path} {path} を保存しました Saved {path} with {} note(s): {first} {path} を保存しました (注意 {} 件:{first}) Saving 保存中 - Saving recovery data for {} {} の復元用データを保存中 + Saving recovery data for {} {} の復帰用データを保存中 Saving {} {} を保存中 Scatter 散布 Screen (72 ppi) スクリーン (72 ppi) @@ -2882,41 +2910,13 @@ @plural {n} glyph|{n} glyphs {n} 個の字形 @plural {n} item|{n} items {n} 項目 @plural {n} object highlighted|{n} objects highlighted {n} 個のオブジェクトを強調表示 -@plural {n} star|{n} stars {n} 個の光線 +@plural {n} star|{n} stars {n} つ星 @plural {n} step|{n} steps {n} ステップ @plural {n} stroke|{n} strokes {n} 本の線 # Strings added on main since the catalog was written (Layers panel, saved selections, Delete Anchor Points). Delete Anchor Points アンカーポイントを削除 - Duplicate Selection 選択項目を複製 - Expand Layers Panel Rows レイヤーパネルの行を展開 - Flatten Artwork アートワークを統合 - Graphics Processor グラフィックプロセッサー - Hide Others その他を隠す - High Performance (discrete) 高パフォーマンス(外部 GPU) - Highlight Rows 行をハイライト - Lock Others その他をロック - Merge Selected 選択項目を結合 - Move Rows 行を移動 - New Layer Options… 新規レイヤーオプション… - Options for Selection… 選択項目のオプション… - Outline Others その他をアウトライン表示 - Power Saving (integrated) 省電力(内蔵 GPU) - Preview All Layers すべてのレイヤーをプレビュー - Release to Layers (Build) レイヤーに分配(ビルド) - Release to Layers (Sequence) レイヤーに分配(シーケンス) - Select Art アートを選択 - Show All Layers すべてのレイヤーを表示 - Unlock All Layers すべてのレイヤーのロックを解除 - Applies the next time VectorCraft starts. VectorCraft の次回起動時に適用されます。 - Dim Images to: 画像の表示濃度: Edit Selection 選択範囲を編集 - Graphics: {adapter} グラフィック:{adapter} - Layer Options レイヤーオプション - Layers Panel Options レイヤーパネルオプション - Row Size: 行のサイズ: - Show Layers Only レイヤーのみを表示 - Thumbnails: サムネール: Saved Selection 1 保存した選択範囲 1 Saved Selection 2 保存した選択範囲 2 Saved Selection 3 保存した選択範囲 3 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 9c328668c..0c1c57abc 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1419,7 +1419,7 @@ Modified since it was read: {path} 讀取後已被修改:{path} Modified: 修改時間: Modify 修改 - Mojikumi 標點間距 + Mojikumi Set 標點間距 Monitor RGB 顯示器 RGB Mono 單色 Monochrome images 單色影像 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0bbbfe7d7..4d7e3195a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -842,6 +842,7 @@ impl VectorcraftApp { return; } let t0 = now_ms(); + font_menu::end_stale_preview(self, &ctx); let t = theme::Tokens::get(&ctx); if self.ui.screen_mode < 2 { chrome::app_bar(self, ui); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index fafee79cb..4d506879a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -736,26 +736,14 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ), ]; -/// Canonical panel id for `window.panel`: the dock tabs (`properties`, `layers`, `libraries`) -/// and every `ICON_PANELS` id, matched case-insensitively. Each icon panel's display label -/// is accepted too, so `"Layers"` and `"Swatches"` work the way agents write them. +/// Canonical panel id for `window.panel`: a dock tab's or an icon panel's id or display label, +/// matched case-insensitively (`"Layers"`, `"swatches"`, `"Color Guide"`). fn normalize_panel(input: &str) -> Option<&'static str> { let name = input.trim(); - if name.eq_ignore_ascii_case("properties") { - return Some("properties"); - } - if name.eq_ignore_ascii_case("layers") { - return Some("layers"); - } - if name.eq_ignore_ascii_case("libraries") { - return Some("libraries"); - } - for &(id, label, _) in ICON_PANELS.iter() { - if name.eq_ignore_ascii_case(id) || name.eq_ignore_ascii_case(label) { - return Some(id); - } - } - None + let tabs = DockTab::ALL.into_iter().map(|t| (t.info().0, t.info().1)); + tabs.chain(ICON_PANELS.iter().map(|&(id, label, _)| (id, label))) + .find(|(id, label)| name.eq_ignore_ascii_case(id) || name.eq_ignore_ascii_case(label)) + .map(|(id, _)| id) } /// Handle a UI command. `None` = not a UI command (the engine handles it). @@ -1562,7 +1550,7 @@ const PERSPECTIVE_SLOTS: [[&str; crate::dialogs::perspective_presets::SLOTS]; 3] ]; /// Select → saved selections: the n-th saved selection of the active document. -const SAVED_SELECTION_IDS: [&str; 25] = [ +const SAVED_SELECTION_IDS: [&str; vectorcraft_engine::doc::SavedSelection::MAX] = [ "select.recall1", "select.recall2", "select.recall3", @@ -2592,14 +2580,20 @@ fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], checks: Some(false) => format!(" {label}"), None => label, }; - let r = ui.add_enabled(en, egui::Button::new(text).shortcut_text(sc)); - // Type → Font: each family's sample beside its name (Enable in-menu font previews). - if *id == "text.setStyle" - && app.session.prefs.font_preview - && let Some(family) = p.get("font").and_then(Value::as_str) - { - crate::font_menu::menu_item_sample(ui, r.rect, family); - } + // Type → Font: each family's sample after its name (Enable in-menu font previews). + let sampled = p.get("font").and_then(Value::as_str).filter(|_| *id == "text.setStyle" && app.session.prefs.font_preview); + let r = match sampled { + Some(family) => { + let slot = ui.id().with(("font-sample", family)); + let button = egui::Button::new(text).right_text(egui::Atom::custom(slot, crate::font_menu::MENU_SAMPLE_SIZE)); + let out = ui.add_enabled_ui(en, |ui| button.atom_ui(ui)).inner; + if let Some(rect) = out.rect(slot) { + crate::font_menu::menu_item_sample(ui, rect, family); + } + out.response + } + None => ui.add_enabled(en, egui::Button::new(text).shortcut_text(sc)), + }; if r.clicked() { *clicked = Some(click_target(label_of(it), id, p)); ui.close(); @@ -2718,9 +2712,7 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) { } // Save Selection…: a name dialog, starting from the first free "Selection N". if id == "select.save" && p.as_object().is_none_or(|o| o.is_empty()) { - let taken = app.session.execute("select.savedList", &json!({})).ok().unwrap_or_default(); - let taken: Vec<&str> = taken.as_array().map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); - let name = (1..).map(|i| format!("Selection {i}")).find(|n| !taken.contains(&n.as_str())).unwrap_or_default(); + let name = app.session.active().map(|d| vectorcraft_engine::doc::SavedSelection::default_name(&d.doc.saved_selections)).unwrap_or_default(); let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Save Selection", "params": {"name": name}})); return; } diff --git a/crates/ui-egui/src/panels/paragraph.rs b/crates/ui-egui/src/panels/paragraph.rs index d919222e6..507421bec 100644 --- a/crates/ui-egui/src/panels/paragraph.rs +++ b/crates/ui-egui/src/panels/paragraph.rs @@ -82,19 +82,24 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ui.end_row(); }); ui.add_space(4.0); - if !pstate::(ui.ctx(), "pa-hide-options") && widgets::check(ui, tl!("Hyphenate"), para.hyphenate, true) { + if pstate::(ui.ctx(), "pa-hide-options") { + return; + } + if widgets::check(ui, tl!("Hyphenate"), para.hyphenate, true) { format(app, json!({"hyphenate": !para.hyphenate})); } - // Japanese composition: how punctuation is spaced (JLREQ 3.1). - ui.horizontal(|ui| { - widgets::dim_label(ui, tl!("Mojikumi")); - let sets = [tl!("None"), tl!("Line-end Punctuation Half Width")]; - let current = if para.mojikumi == vectorcraft_doc::Mojikumi::LineEndHalf { sets[1] } else { sets[0] }; - // Already translated: shown as they are. - if let Some(i) = widgets::dropdown_names(ui, "pa-mojikumi", current, &sets, ui.available_width() - 4.0) { - format(app, json!({"mojikumi": if i == 1 { "lineEndHalf" } else { "none" }})); - } - }); + // 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| { + widgets::dim_label(ui, tl!("Mojikumi Set")); + let sets = [tl!("None"), tl!("Line-end Punctuation Half Width")]; + let current = if para.mojikumi == vectorcraft_doc::Mojikumi::LineEndHalf { sets[1] } else { sets[0] }; + // Already translated: shown as they are. + if let Some(i) = widgets::dropdown_names(ui, "pa-mojikumi", current, &sets, ui.available_width() - 4.0) { + format(app, json!({"mojikumi": if i == 1 { "lineEndHalf" } else { "none" }})); + } + }); + } } pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { diff --git a/crates/ui-egui/src/tests_font_menu.rs b/crates/ui-egui/src/tests_font_menu.rs index 9c047bfc5..0dfc39421 100644 --- a/crates/ui-egui/src/tests_font_menu.rs +++ b/crates/ui-egui/src/tests_font_menu.rs @@ -116,6 +116,69 @@ fn preview_changes_the_text_live_and_only_a_choice_is_a_step() { assert_eq!(font(&app), "Inter"); } +/// A preview whose menu isn't drawn any more (the text was deselected, the panel closed) is undone +/// at the start of the next frame, before the canvas sees its input. +#[test] +fn a_preview_left_open_by_a_menu_that_is_gone_is_undone() { + let ctx = egui::Context::default(); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "Gagaku", "font": "Inter"})).unwrap()["id"].as_u64().unwrap(); + app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); + let steps = app.session.active().unwrap().history.undo.len(); + crate::font_menu::apply(&mut app, &ctx, FontPick::Preview("Source Serif 4".into(), None)); + assert!(app.session.in_interaction()); + crate::font_menu::end_stale_preview(&mut app, &ctx); + assert!(!app.session.in_interaction(), "no menu drawn: the preview is undone"); + let font = match &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.first_style().font_family, + _ => String::new(), + }; + assert_eq!((font.as_str(), app.session.active().unwrap().history.undo.len()), ("Inter", steps)); + // With nothing previewed it does nothing. + crate::font_menu::end_stale_preview(&mut app, &ctx); + assert_eq!(app.session.active().unwrap().history.undo.len(), steps); +} + +/// A press outside the open menu ends the preview in that frame, before the canvas could act on +/// it inside the preview's interaction (the menu itself closes on the release). +#[test] +fn a_press_outside_the_menu_ends_the_preview_at_once() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "Gagaku", "font": "Inter"})).unwrap()["id"].as_u64().unwrap(); + app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); + let mut frame = |events: Vec| { + let input = + egui::RawInput { events, screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 800.0))), ..Default::default() }; + let mut at = egui::Pos2::ZERO; + let mut out = ctx.run_ui(input, |ui| { + crate::font_menu::end_stale_preview(&mut app, ui.ctx()); + at = ui.next_widget_position(); + if let Some(pick) = font_menu(ui, "test-press", "Inter", 260.0, None, MenuLook::default()) { + crate::font_menu::apply(&mut app, ui.ctx(), pick); + } + }); + out.textures_delta.clear(); + (at, app.session.in_interaction()) + }; + let (at, _) = frame(vec![]); + let click = at + egui::vec2(40.0, 10.0); + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(vec![egui::Event::PointerMoved(click), button(click, true)]); + frame(vec![button(click, false)]); + for _ in 0..5 { + frame(vec![]); + } + assert!(frame(key(egui::Key::ArrowDown)).1, "↓ previews"); + assert!(frame(vec![]).1, "the preview lasts while the menu is open"); + let outside = egui::pos2(580.0, 780.0); + assert!(!frame(vec![egui::Event::PointerMoved(outside), button(outside, true)]).1, "the press ends the preview"); + assert!(!frame(vec![button(outside, false)]).1); +} + #[test] fn bundled_families_are_classified() { use vectorcraft_text::FontClass; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 86c80e1e6..7ce2e4cbe 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -179,6 +179,7 @@ forward (`paint.toggleActive {fill}`) and open a popover with the Swatches panel a swatch clicked there runs `paint.setFill` / `paint.setStroke`. Panel keys (Color F6, Color Guide Shift+F3, Appearance Shift+F6, Graphic Styles Shift+F5, Stroke Cmd+F10, Gradient Cmd+F9, Transparency Cmd+Shift+F10) run `window.panel {panel}` and can be pressed with `ui.key`; `ui.menu.list` shows them on the Window menu's items. +`window.panel` takes a panel id in any case or the panel's display label (`"Layers"`, `"Color Guide"`). Collapsing the dock: `window.collapseDock {collapsed?}` (the » at the top of the dock; omitted toggles) hides the Properties | Layers | Libraries group and puts its three panels as icons at the top of the icon column, under a « diff --git a/docs/development.md b/docs/development.md index 6a8ee083f..fdb0489a8 100644 --- a/docs/development.md +++ b/docs/development.md @@ -74,9 +74,9 @@ text at render time from one catalog per language (`i18n/.tsv`; the format always use the English ids and labels, so agents and scripts never see translated text. Languages shipped: English (`en`, the source), Traditional Chinese (`zh-hant`, complete, in the vocabulary used -in Taiwan; `zh-TW`, `zh-HK`, `zh-MO` and `zh-Hant-*` locales all resolve to it), Czech (`cs`), Japanese (`ja`) -and Brazilian Portuguese (`pt-br`): every menu -label for all three, and every `tl!` literal for Brazilian Portuguese. Untranslated text falls back to English until its rows are +in Taiwan; `zh-TW`, `zh-HK`, `zh-MO` and `zh-Hant-*` locales all resolve to it), Japanese (`ja`, complete), +Czech (`cs`, every menu label) and Brazilian Portuguese (`pt-br`, every menu label and every `tl!` literal). +Untranslated text falls back to English until its rows are added. Simplified Chinese locales (`zh-CN`, `zh-SG`, `zh-Hans`) fall back to English until a `zh-hans` catalog is registered: the resolver already tells the two scripts apart, so the Traditional catalog is never shown to a Simplified locale. diff --git a/docs/mcp.md b/docs/mcp.md index 1bd30938a..ed6f993eb 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -75,13 +75,18 @@ is thousands of lines an agent pays for again on every change. | `uriTemplate` | Reads | |---|---| -| `vectorcraft://object/{id}` | One layer or object with its children, bounds and paint | +| `vectorcraft://object/{id}` | One layer or object with its children, bounds and paint: `document.node {id, summary: true}`, the node as `document.inspect` lists it | | `vectorcraft://command/{id}` | One command: label, menu path, shortcut, parameter description, enablement | | `vectorcraft://effect/{id}` | One live effect with its parameters and defaults | | `vectorcraft://swatch/{name}` | One swatch, colour, gradient or pattern swatch (percent-encode spaces) | An unknown URI is `-32002`; a template with no value, or a value that names nothing, is `-32602`. +The same reads work as query commands through `run_command`: `document.node {id}` returns the object's full model +JSON (geometry, appearance, every attribute) and `document.node {id, summary: true}` the compact summary +`document.inspect` gives for it (id, name, kind, bounds, paint labels, children), without summarizing the whole +document. + ### Completions `completion/complete` answers for a prompt argument (`ref/prompt`) or a template variable @@ -157,7 +162,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr | `press_key` | `{key, mods?}` | Remote: a real key event. Headless: runs the command or tool bound to that shortcut, or sends the key to the busy tool (digits too: `5` while dragging with the Perspective Selection tool). | | `type_text` | `{text}` | Remote only. | | `invoke_menu` | `{command, params?}` | Invokes a menu item by command id. Includes UI commands such as `view.*` and `window.*` in remote mode. | -| `open_panel` | `{panel}` | Remote only. | +| `open_panel` | `{panel}` | Remote only. `panel` is a panel id (`layers`, `swatches`, `colorGuide`, …, as `window.panel` takes) or its display label (`"Color Guide"`), in any case. | | `screenshot` | `{path?, scale?, artboard?, window?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard; `window:true` captures the app window (remote only). | | `open_file` | `{path}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`, `.dxf`, `.emf`, `.wmf`, PNG, JPEG, GIF, WebP, TIFF, BMP (an image opens as a document of its pixel size). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. `run_command document.formats` lists the formats. | | `save_file` | `{path?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). | @@ -516,6 +521,18 @@ the one highlighted layer or group row, else the one selected group, else the cu so art added later is clipped too); called again it releases the mask. It returns `{clip}`, and the Layers panel underlines clipping-path names. +## Saved selections + +Select → Save Selection… keeps the selected objects under a name, in the document: `select.save {name?}` (default +the first free "Selection N"; an existing name is replaced with the current selection; at most 25 per document, +names up to 255 characters) → `{name}`, one undo step. `select.savedList` lists the names in menu order, and +`select.recall {name}` selects those objects again (ones deleted since are left out) → `{count}`. Edit Selection… +renames and deletes: `select.editSaved {name, newName?, delete?}`, or several at once as +`{edits: [{name, newName?, delete?}…]}` in one undo step, where every `name` is the name before the edit and the +names must stay unique. In the desktop app the saved selections are listed at the bottom of the Select menu +(`select.recall1` … `select.recall25`). A file's saved selections are checked when it opens: past 25, blank or +repeated names, and ids the document doesn't have are dropped. + ## Graphic styles A graphic style holds an appearance (fills, strokes, effects) plus opacity, blend mode, isolate and knockout.