diff --git a/ROADMAP.md b/ROADMAP.md index a1520e6f..020a87ff 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands. -_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394); see [Honest assessment](#honest-assessment-2026-10-05))._ +_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are @@ -133,7 +133,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Geometry and effects:** - 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) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), 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. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. +- **Type:** Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), 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. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). - **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 and Simplified Chinese, Japanese and Spanish (neutral international Spanish, for every `es-*` locale) cover the whole interface, Czech and Brazilian Portuguese every menu label, Brazilian Portuguese the shell's `tl!` literals too (tested); Spanish also translates status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech and Spanish ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). 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 an opening bracket starting a wrapped line flush with its start, and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline; Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline or the em box top/right, centre or bottom/left (ICF top and bottom not yet); 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 (stroke-only open paths and lines cut the regions they cross and enclose regions of their own; filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index c487f4ce..0c2cc0e6 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -180,6 +180,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { egui::Popup::menu(&resp).show(|ui| crate::panels::graphic_styles::picker(app, ui)); } ui.separator(); + crate::panels::character::control_bar(app, ui); if sel.is_empty() { if widgets::flat_button(ui, tl!("Document Setup"), 112.0).clicked() { app.run("file.documentSetup", json!({})).ok(); diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index fed45ff6..5b22f8a1 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2530,6 +2530,8 @@ Stroke (X), double-click for the Color Picker Trazo (X), doble clic para el selector de color Stroke Color Color de trazo Stroke Options Opciones de trazo + Character: Carácter: + Character options Opciones de carácter Stroke options Opciones de trazo Stroke Weight Grosor de trazo Stroke: Trazo: diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 4805643f..866a95a6 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2334,6 +2334,8 @@ Stop editing the opacity mask 不透明マスクの編集を終了 Strikethrough 打ち消し線 Stroke Options 線オプション + Character: 文字: + Character options 文字オプション Stroke options 線オプション Stroke: 線: Style スタイル diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index a89432e1..a5228d5e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2240,6 +2240,8 @@ Stroke Color Cor do Traçado Stroke Options Opções de Traçado Stroke Weight Peso do Traçado + Character: Caractere: + Character options Opções de caractere Stroke options Opções de traçado Stroke: Traçado: Style Estilo diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 79fff53e..47758a50 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2466,6 +2466,8 @@ Stroke (X), double-click for the Color Picker 描边 (X),双击打开拾色器 Stroke Color 描边颜色 Stroke Options 描边选项 + Character: 字符: + Character options 字符选项 Stroke options 描边选项 Stroke Weight 描边粗细 Stroke: 描边: diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index e4a21a7e..35e64040 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2548,6 +2548,8 @@ Stroke (X), double-click for the Color Picker 筆畫 (X),按兩下開啟檢色器 Stroke Color 筆畫顏色 Stroke Options 筆畫選項 + Character: 字元: + Character options 字元選項 Stroke options 筆畫選項 Stroke Weight 筆畫寬度 Stroke: 筆畫: diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 66298893..6cef59df 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -11,10 +11,13 @@ use vectorcraft_tools::{Mods, ToolKey}; use super::{first_selected, pstate, set_pstate}; use crate::VectorcraftApp; use crate::theme::Tokens; -use crate::widgets::{self, menu_item}; +use crate::widgets::{self, SpinPick, menu_item}; -pub const SIZE_PRESETS: [f64; 16] = [6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 18.0, 21.0, 24.0, 36.0, 48.0, 60.0, 72.0, 96.0]; -pub const TRACKING_PRESETS: [f64; 13] = [-100.0, -75.0, -50.0, -25.0, -10.0, -5.0, 0.0, 5.0, 10.0, 25.0, 50.0, 75.0, 100.0]; +/// The Font Size and Leading dropdowns' presets in points (Leading's after its Auto entry), shown +/// in the type unit. +pub const SIZE_PRESETS: [f64; 15] = [6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 18.0, 21.0, 24.0, 36.0, 48.0, 60.0, 72.0]; +/// The Kerning and Tracking dropdowns' presets in 1/1000 em. +pub const TRACKING_PRESETS: [f64; 14] = [-100.0, -75.0, -50.0, -25.0, -10.0, -5.0, 0.0, 5.0, 10.0, 25.0, 50.0, 75.0, 100.0, 200.0]; pub const SCALE_PRESETS: [f64; 8] = [25.0, 50.0, 75.0, 90.0, 100.0, 110.0, 125.0, 150.0]; /// The character style shown by the panel: the selected range's (or the caret's) while the Type @@ -32,6 +35,64 @@ pub(crate) fn text_style(app: &VectorcraftApp) -> Option<(CharStyle, vectorcraft } } +/// `f` of the character styles the panels act on: the runs the Type tool's selection covers (the +/// caret's style at a caret), else every run of the selected type, groups' included. `None` (a +/// blank field) where they differ, or with no type. +pub(crate) fn shared(app: &VectorcraftApp, f: impl Fn(&CharStyle) -> T) -> Option { + let st = app.session.active()?; + let text = |id: NodeId| match st.doc.node(id).map(|n| &n.kind) { + Some(NodeKind::Text(t)) => Some(t), + _ => None, + }; + let mut acc = Shared::None; + if let Some((id, a, b)) = text_editing(app) { + let t = text(id)?; + if b <= a { + return Some(f(&vectorcraft_text::edit::insertion_style(&t.runs, a, b))); + } + let mut end = 0; + for r in &t.runs { + let start = end; + end += r.text.len(); + if start < b && end > a { + acc.add(f(&r.style)); + } + } + } else { + for n in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) { + n.walk(&mut |c| { + if let NodeKind::Text(t) = &c.kind { + t.runs.iter().for_each(|r| acc.add(f(&r.style))); + } + }); + } + } + acc.value() +} + +/// The value a run of styles share so far ([`shared`]). +enum Shared { + None, + Same(T), + Differ, +} + +impl Shared { + fn add(&mut self, v: T) { + match self { + Shared::None => *self = Shared::Same(v), + Shared::Same(x) if *x != v => *self = Shared::Differ, + _ => {} + } + } + fn value(self) -> Option { + match self { + Shared::Same(v) => Some(v), + _ => None, + } + } +} + fn style(app: &mut VectorcraftApp, p: Value) { char_cmd(app, "text.setStyle", p); } @@ -234,74 +295,115 @@ fn cell(ui: &mut Ui, label: &str, tip: &str, add: impl FnOnce(&mut Ui)) { }); } +/// The Character panel's grid of numeric fields, `w` wide: Font Size, Leading, Kerning and +/// Tracking, then with `more` the scales, Baseline Shift and Character Rotation (`s`: the style +/// shown). The Properties panel shows its first rows. +pub(crate) fn metrics_grid(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, s: &CharStyle, w: f32, more: bool) { + let fw = ((w - 66.0) / 2.0).clamp(60.0, 100.0); + let unit = app.session.type_unit(); + egui::Grid::new(id).num_columns(2).spacing([6.0, 4.0]).show(ui, |ui| { + cell(ui, "T", tl!("Font Size"), |ui| size_field(app, ui, "ch-size", fw)); + cell(ui, "A↕", tl!("Leading"), |ui| { + let leading = shared(app, CharStyle::effective_leading); + let auto = shared(app, |s| s.leading.is_none()) == Some(true); + match widgets::spin_field_auto(ui, "ch-lead", leading, Some(auto), unit, fw, 1.0, 0.1, &SIZE_PRESETS) { + Some(SpinPick::Value(v)) => style(app, json!({"leading": v})), + Some(SpinPick::Auto) => style(app, json!({"leading": "auto"})), + None => {} + } + }); + ui.end_row(); + cell(ui, "VA", tl!("Kerning (0 = Auto)"), |ui| { + let kerning = shared(app, |s| s.kerning.unwrap_or(0.0)); + if let Some(v) = widgets::spin_plain(ui, "ch-kern", kerning, "", 0, fw, 10.0, -1000.0, &TRACKING_PRESETS) { + format(app, if v == 0.0 { json!({"kerning": "auto"}) } else { json!({"kerning": v}) }); + } + }); + cell(ui, "VA↔", tl!("Tracking"), |ui| { + if let Some(v) = widgets::spin_plain(ui, "ch-track", shared(app, |s| s.tracking), "", 0, fw, 10.0, -1000.0, &TRACKING_PRESETS) { + style(app, json!({"tracking": v})); + } + }); + ui.end_row(); + if !more { + return; + } + cell(ui, "IT", tl!("Vertical Scale %"), |ui| { + if let Some(v) = widgets::spin_plain(ui, "ch-vs", s.v_scale, "%", 1, fw, 1.0, 1.0, &SCALE_PRESETS) { + format(app, json!({"vScale": v})); + } + }); + cell(ui, "T↔", tl!("Horizontal Scale %"), |ui| { + if let Some(v) = widgets::spin_plain(ui, "ch-hs", s.h_scale, "%", 1, fw, 1.0, 1.0, &SCALE_PRESETS) { + format(app, json!({"hScale": v})); + } + }); + ui.end_row(); + cell(ui, "Aª", tl!("Baseline Shift"), |ui| { + if let Some(v) = widgets::spin_field(ui, "ch-bs", Some(s.baseline_shift), unit, fw, 1.0, -1296.0, &[]) { + format(app, json!({"baselineShift": v})); + } + }); + cell(ui, "⟲T", tl!("Character Rotation"), |ui| { + if let Some(v) = widgets::spin_plain(ui, "ch-rot", s.rotation, "°", 1, fw, 15.0, -360.0, &super::transform::ANGLE_PRESETS) { + format(app, json!({"rotation": v})); + } + }); + ui.end_row(); + }); +} + +/// The Font Size combo (Character and Properties panels, Control bar): typed, stepped, scrubbed or +/// picked from the presets, in the type unit, blank where the selected text's sizes differ. +pub(crate) fn size_field(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, width: f32) { + let size = shared(app, |s| s.size); + if let Some(v) = widgets::spin_field(ui, id, size, app.session.type_unit(), width, 1.0, 0.1, &SIZE_PRESETS) { + style(app, json!({"size": v})); + } +} + +/// The font family menu and the font style dropdown of style `s` (Character and Properties panels, +/// Control bar), with their `ids` and `widths`. +pub(crate) fn font_pickers(app: &mut VectorcraftApp, ui: &mut Ui, s: &CharStyle, ids: (&str, &str), widths: (f32, f32)) { + let sample = crate::font_menu::sample_text(app); + if let Some(pick) = crate::font_menu::font_menu(ui, ids.0, &s.font_family, widths.0, sample.as_deref(), crate::font_menu::MenuLook::of(app)) { + crate::font_menu::apply(app, ui.ctx(), pick); + } + let styles = vectorcraft_text::FontDb::global().styles(&s.font_family); + let names: Vec<&str> = styles.iter().map(String::as_str).collect(); + if let Some(name) = widgets::dropdown_names(ui, ids.1, &s.font_style, &names, widths.1).and_then(|i| names.get(i)) { + style(app, json!({"style": name})); + } +} + +/// The Control bar's type controls while type is selected or edited: the Character link (the +/// Character panel in a popover), the font family and style menus and the Font Size combo. +pub(crate) fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { + let Some((s, _)) = text_style(app) else { return }; + let link = ui + .link(egui::RichText::new(tl!("Character:")).size(12.0).color(Tokens::get(ui.ctx()).text).underline()) + .on_hover_text(tl!("Character options")); + widgets::popover(&link, link.clicked(), |ui| { + ui.set_width(260.0); + show(app, ui); + }); + font_pickers(app, ui, &s, ("cb-font", "cb-font-style"), (150.0, 96.0)); + size_field(app, ui, "cb-font-size", 96.0); + ui.add_space(4.0); + ui.separator(); +} + pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let Some((s, _)) = text_style(app) else { super::empty_state(ui, "type", tl!("No text selected"), tl!("Select a text object to edit its character attributes.")); return; }; let w = ui.available_width(); - let sample = crate::font_menu::sample_text(app); - if let Some(pick) = crate::font_menu::font_menu(ui, "ch-font", &s.font_family, w - 4.0, sample.as_deref(), crate::font_menu::MenuLook::of(app)) { - crate::font_menu::apply(app, ui.ctx(), pick); - } - let styles = vectorcraft_text::FontDb::global().styles(&s.font_family); - let snames: Vec<&str> = styles.iter().map(String::as_str).collect(); - if let Some(i) = widgets::dropdown_names(ui, "ch-style", &s.font_style, &snames, w - 4.0) { - style(app, json!({"style": snames[i]})); - } + font_pickers(app, ui, &s, ("ch-font", "ch-style"), (w - 4.0, w - 4.0)); ui.add_space(2.0); - let fw = ((w - 66.0) / 2.0).clamp(60.0, 100.0); - let unit = app.session.type_unit(); - egui::Grid::new("ch-grid").num_columns(2).spacing([6.0, 4.0]).show(ui, |ui| { - cell(ui, "T", tl!("Font Size"), |ui| { - if let Some(v) = widgets::spin_field(ui, "ch-size", Some(s.size), unit, fw, 1.0, 0.1, &SIZE_PRESETS) { - style(app, json!({"size": v})); - } - }); - cell(ui, "A↕", tl!("Leading"), |ui| { - let presets: Vec = SIZE_PRESETS.iter().map(|v| v * 1.2).collect(); - if let Some(v) = widgets::spin_field(ui, "ch-lead", Some(s.effective_leading()), unit, fw, 1.0, 0.1, &presets) { - style(app, json!({"leading": v})); - } - }); - ui.end_row(); - cell(ui, "VA", tl!("Kerning (0 = Auto)"), |ui| { - if let Some(v) = widgets::spin_plain(ui, "ch-kern", s.kerning.unwrap_or(0.0), "", 0, fw, 10.0, -1000.0, &TRACKING_PRESETS) { - format(app, if v == 0.0 { json!({"kerning": "auto"}) } else { json!({"kerning": v}) }); - } - }); - cell(ui, "VA↔", tl!("Tracking"), |ui| { - if let Some(v) = widgets::spin_plain(ui, "ch-track", s.tracking, "", 0, fw, 10.0, -1000.0, &TRACKING_PRESETS) { - style(app, json!({"tracking": v})); - } - }); - ui.end_row(); - if !pstate::(ui.ctx(), "ch-hide-options") { - cell(ui, "IT", tl!("Vertical Scale %"), |ui| { - if let Some(v) = widgets::spin_plain(ui, "ch-vs", s.v_scale, "%", 1, fw, 1.0, 1.0, &SCALE_PRESETS) { - format(app, json!({"vScale": v})); - } - }); - cell(ui, "T↔", tl!("Horizontal Scale %"), |ui| { - if let Some(v) = widgets::spin_plain(ui, "ch-hs", s.h_scale, "%", 1, fw, 1.0, 1.0, &SCALE_PRESETS) { - format(app, json!({"hScale": v})); - } - }); - ui.end_row(); - cell(ui, "Aª", tl!("Baseline Shift"), |ui| { - if let Some(v) = widgets::spin_field(ui, "ch-bs", Some(s.baseline_shift), unit, fw, 1.0, -1296.0, &[]) { - format(app, json!({"baselineShift": v})); - } - }); - cell(ui, "⟲T", tl!("Character Rotation"), |ui| { - if let Some(v) = widgets::spin_plain(ui, "ch-rot", s.rotation, "°", 1, fw, 15.0, -360.0, &super::transform::ANGLE_PRESETS) { - format(app, json!({"rotation": v})); - } - }); - ui.end_row(); - } - }); - if pstate::(ui.ctx(), "ch-hide-options") { + let hidden = pstate::(ui.ctx(), "ch-hide-options"); + metrics_grid(app, ui, "ch-grid", &s, w, !hidden); + if hidden { return; } ui.add_space(4.0); diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 000a568d..c955d01d 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -473,6 +473,8 @@ mod tests_css_properties; #[cfg(test)] mod tests_effectedit; #[cfg(test)] +mod tests_fontsize; +#[cfg(test)] mod tests_freeform; #[cfg(test)] mod tests_links; diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index db76f58b..5ba39c42 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -388,29 +388,10 @@ pub fn type_sections(app: &mut VectorcraftApp, ui: &mut Ui) { }; let s = tx.first_style(); section_header(ui, tl!("Character")); - let sample = crate::font_menu::sample_text(app); - if let Some(pick) = - crate::font_menu::font_menu(ui, "font", &s.font_family, ui.available_width() - 4.0, sample.as_deref(), crate::font_menu::MenuLook::of(app)) - { - crate::font_menu::apply(app, ui.ctx(), pick); - } - ui.horizontal(|ui| { - dim_label(ui, tl!("Size")); - let type_unit = app.session.type_unit(); - if let Some(v) = widgets::num_field(ui, "fsize", Some(s.size), type_unit, 70.0) { - app.run("text.setStyle", json!({"size": v})).ok(); - } - dim_label(ui, tl!("Leading")); - if let Some(v) = widgets::num_field(ui, "lead", Some(s.effective_leading()), type_unit, 70.0) { - app.run("text.setStyle", json!({"leading": v})).ok(); - } - }); - ui.horizontal(|ui| { - dim_label(ui, tl!("Tracking")); - if let Some(v) = widgets::plain_field(ui, "track", s.tracking, "", 0, 60.0) { - app.run("text.setStyle", json!({"tracking": v})).ok(); - } - }); + // The font, Font Size, Leading, Kerning and Tracking as in the Character panel. + let w = ui.available_width(); + super::character::font_pickers(app, ui, &s, ("font", "font-style"), (w - 4.0, w - 4.0)); + super::character::metrics_grid(app, ui, "props-char", &s, w, false); section_header(ui, tl!("Paragraph")); ui.horizontal(|ui| { for (icon, j) in [("align-start-vertical", "left"), ("align-center-vertical", "center"), ("align-end-vertical", "right")] { diff --git a/crates/ui-egui/src/panels/tests_fontsize.rs b/crates/ui-egui/src/panels/tests_fontsize.rs new file mode 100644 index 00000000..e8f8d57e --- /dev/null +++ b/crates/ui-egui/src/panels/tests_fontsize.rs @@ -0,0 +1,115 @@ +//! Headless frames of the Font Size and Leading combos (#458): a preset picked from the dropdown +//! applies in one undo step, the presets show in the type unit, Leading's Auto entry, sizes that +//! differ show blank, and the Control bar's Character controls. + +use std::cell::Cell; + +use serde_json::json; +use vectorcraft_doc::{CharStyle, NodeKind, Unit}; + +use super::tests_appearance::{app_with_rect, click, frame_events, run, text_rect}; +use super::*; + +type Texts = Vec<(String, egui::Rect)>; + +fn shows(t: &Texts, s: &str) -> bool { + t.iter().any(|(x, _)| x == s) +} + +/// [`app_with_rect`] with 12 pt type selected instead; returns its id. +fn app_with_text() -> (VectorcraftApp, u64) { + let mut app = app_with_rect(); + let id = run(&mut app, "text.create", json!({"x": 10, "y": 150, "text": "Hi", "size": 12}))["id"].as_u64().unwrap(); + run(&mut app, "select.set", json!({ "ids": [id] })); + (app, id) +} + +fn style(app: &VectorcraftApp, id: u64) -> CharStyle { + match &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.first_style(), + _ => panic!("not type"), + } +} + +fn undo_len(app: &VectorcraftApp) -> usize { + app.session.active().unwrap().history.undo.len() +} + +/// A 120 pt Font Size combo in a row; `at` keeps where it starts. +fn size_combo(at: &Cell) -> impl FnMut(&mut VectorcraftApp, &mut Ui) + '_ { + |app, ui| { + at.set(ui.max_rect().min); + ui.horizontal(|ui| character::size_field(app, ui, "size", 120.0)); + } +} + +#[test] +fn a_picked_font_size_preset_applies_in_one_undo_step() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let (mut app, id) = app_with_text(); + let at = Cell::new(egui::Pos2::ZERO); + let t = frame_events(&ctx, &mut app, vec![], size_combo(&at)); + assert!(shows(&t, "12 pt") && !shows(&t, "24 pt"), "closed: {t:?}"); + // The chevron is the combo's last 20 pt. + click(&ctx, &mut app, at.get() + egui::vec2(110.0, 13.0), size_combo(&at)); + let t = frame_events(&ctx, &mut app, vec![], size_combo(&at)); + assert!(shows(&t, "6 pt") && shows(&t, "24 pt") && shows(&t, "72 pt"), "open: {t:?}"); + let undo = undo_len(&app); + click(&ctx, &mut app, text_rect(&t, "24 pt").center(), size_combo(&at)); + assert_eq!(style(&app, id).size, 24.0); + assert_eq!(undo_len(&app), undo + 1, "one undo step"); +} + +#[test] +fn font_size_presets_show_in_the_type_unit() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let (mut app, _) = app_with_text(); + app.run("prefs.set", json!({"key": "unitsType", "value": "millimeters"})).unwrap(); + let at = Cell::new(egui::Pos2::ZERO); + frame_events(&ctx, &mut app, vec![], size_combo(&at)); + click(&ctx, &mut app, at.get() + egui::vec2(110.0, 13.0), size_combo(&at)); + let t = frame_events(&ctx, &mut app, vec![], size_combo(&at)); + assert!(shows(&t, &Unit::Millimeters.format(24.0)) && !shows(&t, "24 pt"), "{t:?}"); +} + +#[test] +fn leading_lists_auto_first() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let (mut app, id) = app_with_text(); + run(&mut app, "text.setStyle", json!({"leading": 30})); + let t = frame_events(&ctx, &mut app, vec![], character::show); + // The Leading cell: its 22 pt label, the spacing, then the spinner (16), field (100 - 36) + // and chevron (20) of a 100 pt combo. + let label = text_rect(&t, "A↕"); + let sp = ctx.global_style().spacing.item_spacing.x; + click(&ctx, &mut app, egui::pos2(label.center().x + 11.0 + sp + 90.0, label.center().y), character::show); + let t = frame_events(&ctx, &mut app, vec![], character::show); + click(&ctx, &mut app, text_rect(&t, "Auto").center(), character::show); + assert_eq!(style(&app, id).leading, None); +} + +#[test] +fn sizes_that_differ_show_blank() { + let ctx = egui::Context::default(); + let (mut app, a) = app_with_text(); + let b = run(&mut app, "text.create", json!({"x": 10, "y": 180, "text": "Ho", "size": 24}))["id"].as_u64().unwrap(); + run(&mut app, "select.set", json!({ "ids": [a, b] })); + let t = frame_events(&ctx, &mut app, vec![], character::show); + assert!(!shows(&t, "12 pt") && !shows(&t, "24 pt"), "{t:?}"); + run(&mut app, "select.set", json!({ "ids": [b] })); + assert!(shows(&frame_events(&ctx, &mut app, vec![], character::show), "24 pt")); +} + +#[test] +fn the_control_bar_shows_the_font_size_of_selected_type() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let (mut app, _) = app_with_text(); + let t = frame_events(&ctx, &mut app, vec![], crate::chrome::control_bar); + assert!(shows(&t, "Character:") && shows(&t, "12 pt"), "{t:?}"); + run(&mut app, "select.set", json!({ "ids": [] })); + assert!(!shows(&frame_events(&ctx, &mut app, vec![], crate::chrome::control_bar), "Character:")); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index f0117054..832dd3ca 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -830,7 +830,10 @@ pub fn radio(ui: &mut Ui, label: &str, selected: bool, enabled: bool) -> bool { } /// Numeric field with an up/down spinner on the left and a preset dropdown on the right -/// (Stroke weight, font size, leading…). `presets` empty = no dropdown. Returns the committed value. +/// (Stroke weight, font size…): an editable combo box whose field keeps typing, ↑/↓ stepping and +/// label scrubbing. `value` (points) shows in `unit`, blank when `None` (the selection's values +/// differ); `presets` (points) are listed in `unit` too, and empty = no dropdown. Returns the +/// committed, stepped or picked value. #[allow(clippy::too_many_arguments)] pub fn spin_field( ui: &mut Ui, @@ -842,15 +845,50 @@ pub fn spin_field( min: f64, presets: &[f64], ) -> Option { - spin_generic(ui, value, width, step, min, presets, &|v| unit.format(v), &mut |ui, fw| num_field(ui, id, value, unit, fw)) + spin_field_auto(ui, id, value, None, unit, width, step, min, presets).and_then(SpinPick::value) } -/// [`spin_field`] for unitless values (percent, degrees, 1/1000 em) with a display suffix. +/// What a [`spin_field_auto`] returns: a value (points), or its Auto entry. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum SpinPick { + Value(f64), + Auto, +} + +impl SpinPick { + /// The value, `None` for Auto. + pub fn value(self) -> Option { + match self { + SpinPick::Value(v) => Some(v), + SpinPick::Auto => None, + } + } +} + +/// [`spin_field`] whose dropdown starts with an Auto entry (Leading): `auto` is `Some(on)`, with +/// `on` checking it (no preset is then checked), or `None` for no Auto entry. +#[allow(clippy::too_many_arguments)] +pub fn spin_field_auto( + ui: &mut Ui, + id: impl std::hash::Hash + std::fmt::Debug + Copy, + value: Option, + auto: Option, + unit: Unit, + width: f32, + step: f64, + min: f64, + presets: &[f64], +) -> Option { + spin_generic(ui, value, auto, width, step, min, presets, &|v| unit.format(v), &mut |ui, fw| num_field(ui, id, value, unit, fw)) +} + +/// [`spin_field`] for unitless values (percent, degrees, 1/1000 em) with a display suffix; a +/// `None` value (the selection's values differ) shows blank. #[allow(clippy::too_many_arguments)] pub fn spin_plain( ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug + Copy, - value: f64, + value: impl Into>, suffix: &str, decimals: usize, width: f32, @@ -858,21 +896,24 @@ pub fn spin_plain( min: f64, presets: &[f64], ) -> Option { + let value = value.into(); let fmt = |v: f64| format!("{v}{suffix}"); - spin_generic(ui, Some(value), width, step, min, presets, &fmt, &mut |ui, fw| plain_field(ui, id, value, suffix, decimals, fw)) + spin_generic(ui, value, None, width, step, min, presets, &fmt, &mut |ui, fw| mixed_field(ui, id, value, suffix, decimals, fw)) + .and_then(SpinPick::value) } #[allow(clippy::too_many_arguments)] fn spin_generic( ui: &mut Ui, value: Option, + auto: Option, width: f32, step: f64, min: f64, presets: &[f64], fmt: &dyn Fn(f64) -> String, field: &mut dyn FnMut(&mut Ui, f32) -> Option, -) -> Option { +) -> Option { let t = Tokens::get(ui.ctx()); let mut out = None; let h = 26.0; @@ -898,24 +939,31 @@ fn spin_generic( ui.painter().line_segment([m + vec2(-3.0, -d), m + vec2(0.0, d)], Stroke::new(1.2, c)); ui.painter().line_segment([m + vec2(0.0, d), m + vec2(3.0, -d)], Stroke::new(1.2, c)); } + // A blank field (values that differ) has nothing to step from. if sresp.clicked() && let Some(p) = sresp.interact_pointer_pos() + && let Some(v) = value { - let v = value.unwrap_or(0.0); let nv = if up.contains(p) { v + step } else { v - step }; - out = Some(nv.max(min)); + out = Some(SpinPick::Value(nv.max(min))); } let fw = if presets.is_empty() { width - 16.0 } else { width - 36.0 }; if let Some(v) = field(ui, fw) { - out = Some(v.max(min)); + out = Some(SpinPick::Value(v.max(min))); } if !presets.is_empty() { let dresp = chevron_cell(ui, h); egui::Popup::menu(&dresp).show(|ui| { ui.set_min_width(width - 10.0); + if let Some(on) = auto + && ui.selectable_label(on, tl!("Auto")).clicked() + { + out = Some(SpinPick::Auto); + } + let current = value.filter(|_| auto != Some(true)); for p in presets { - if ui.selectable_label(value.is_some_and(|v| (v - p).abs() < 1e-6), fmt(*p)).clicked() { - out = Some(*p); + if ui.selectable_label(current.is_some_and(|v| (v - p).abs() < 1e-6), fmt(*p)).clicked() { + out = Some(SpinPick::Value(*p)); } } });