From bf82f7ebf9e5f1be1a1e379bb709b380abe66f20 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 12 Oct 2026 01:30:11 +0700 Subject: [PATCH] M4: text lists, hanging punctuation and decoration styles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tail of the text area, from the public documentation of the target app's text settings, with a table-driven behaviour suite (each case derived in one line; "silent" cases pin today's behaviour). - **Lists per paragraph** (`TextData::paragraphs`, one `ParaList {list, indent}` per paragraph; empty when the layer has one list style at level 0, so old files load and save unchanged). The list buttons, Shift+Cmd+8 / Shift+Cmd+7, `text.listStyle` and Type settings act on the paragraphs the selection touches while editing (the whole layer otherwise). The per-paragraph list follows edits: a new paragraph copies the one it split from, joined paragraphs keep the first one's. - **Nesting** up to 8 levels: Tab nests a list item, Shift+Tab moves it out; Enter on an empty item and Backspace at an item's start move it a level out, then out of the list. Typing `- `, `* ` or `1. ` at the start of a plain paragraph starts a list. Numbers count per level (1. a. i., then round again), bullets go • ◦ ▪; a plain paragraph, a shallower item or the other list kind starts the count again. Each level indents by one marker gutter; a nested bullet a font lacks comes from a fallback font. - **List spacing** (space between two items of a list, in place of paragraph spacing), **hanging lists** (the outer gutter outside the box, item text lined up with plain paragraphs) and **hanging punctuation** (opening quotes and brackets outside the start edge of left-aligned and justified lines, closing punctuation outside the end edge of right-aligned and justified ones; hung punctuation takes no room when lines wrap). - **Decoration look** per character (`TextStyle::decoration_look`, boxed, None by default): style (solid, dotted, wavy), thickness and underline offset (auto, px or % of the font size), skip ink (underlines break around descenders) and colour (its own style index in the layout, so canvas, PNG and PDF paint it without renderer changes). Decorations are drawn from `Decoration::path()` (bar, dots or wave) on the canvas, in PNG, PDF and outlined SVG. - `node.set` keys: `listStyle` and `indentation` (range-aware with start/end), `listSpacing`, `hangingPunctuation`, `hangingList`, `textDecorationStyle`, `textDecorationThickness`, `textDecorationOffset`, `textDecorationSkipInk`, `textDecorationColor`; documented in `KEYS` and docs/mcp.md. `text.edit.key` documents Tab. - Inspect: `listStyle`, or `lineTypes` and `lineIndentations` per paragraph; `listSpacing`, `hangingPunctuation`, `hangingList`, the decoration keys. CSS: `text-decoration-style`, `-thickness`, `-color`, `text-underline-offset`, `text-decoration-skip-ink: none` (browsers skip ink by default). Dev Mode rows: decoration style, list spacing. - REST import: `lineTypes` with `lineIndentations` per paragraph (they collapsed to one list style for the layer before), `listSpacing`, `hangingPunctuation`, `hangingList`, the decoration style, thickness, offset, skip ink and colour; instance override names for them. - Type settings ▸ Basics: decoration style, thickness and offset fields (with Auto), Skip ink, decoration colour; List spacing; Hanging punctuation and Hanging list checkboxes; the list control acts on the edited paragraphs. New strings in en/es. - Components carry the new text fields in the characters and text-style override groups; scaling scales list spacing and px decoration lengths. - `TextData::path` is boxed (`Option>`) to keep `NodeKind` under clippy's enum size limit after the new fields. --- ROADMAP.md | 9 +- crates/doc/src/text.rs | 144 +++++++++- crates/engine/src/cmd/arrange.rs | 33 ++- crates/engine/src/cmd/props.rs | 67 ++++- crates/engine/src/cmd/text.rs | 49 +++- crates/engine/src/codegen.rs | 28 ++ crates/engine/src/components.rs | 9 + crates/engine/src/devinspect.rs | 12 +- crates/engine/src/draw_tools_tests.rs | 2 +- crates/engine/src/inspect.rs | 24 ++ crates/engine/src/lib.rs | 2 + crates/engine/src/tests.rs | 2 + crates/engine/src/text_tail_tests.rs | 302 ++++++++++++++++++++ crates/engine/src/textedit.rs | 51 +++- crates/format/src/rest/mod.rs | 10 +- crates/format/src/rest/text.rs | 65 ++++- crates/pdf/src/text.rs | 4 +- crates/render/src/draw.rs | 4 +- crates/render/src/lod.rs | 2 + crates/svg/src/import/text.rs | 2 +- crates/text/src/layout.rs | 395 +++++++++++++++++++++----- crates/text/src/lib.rs | 2 + crates/text/src/onpath_tests.rs | 2 +- crates/text/src/tail_tests.rs | 261 +++++++++++++++++ crates/ui-egui/locales/en.toml | 10 + crates/ui-egui/locales/es.toml | 10 + crates/ui-egui/src/typography.rs | 113 +++++++- docs/mcp.md | 13 + docs/target-app-parity.md | 34 ++- 29 files changed, 1518 insertions(+), 143 deletions(-) create mode 100644 crates/engine/src/text_tail_tests.rs create mode 100644 crates/text/src/tail_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 745ef59..abed2ee 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~170–305 h away (one Opus 5.5 agent; ~35–70 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -17,9 +17,9 @@ start with [`docs/gaps.md`](docs/gaps.md). | Feature breadth | **~82%** (core editor ~93%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) | | Ready for real work | **~51%** | estimated, same doc (written dimension weights and scores) | | Essentials user | **~57%** | estimated: core features 77.8% × 0.92 launch/stability × 0.92 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) | -| Mainstream practitioner | **~49%** | estimated: weekly areas 71.0% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) | +| Mainstream practitioner | **~49%** | estimated: weekly areas 71.2% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) | | Remaining to beta | **~170–305 h** one agent | estimated | -| Remaining to full parity | **~306–594 h** one agent (~86–150 h wall-clock with 5 agents) | estimated | +| Remaining to full parity | **~304–591 h** one agent (~86–150 h wall-clock with 5 agents) | estimated | ### Readiness by audience @@ -81,7 +81,7 @@ Figma's own languages on the landed localization layer (15–25 h). | Effects | 100% | 75% | 2–5 | [target-app-parity](docs/target-app-parity.md) | | Frames, groups, sections, masks | 97% | 80% | 1–3 | [target-app-parity](docs/target-app-parity.md) | | Auto layout & constraints | 98% | 74% | 3–6 | [auto-layout-parity](docs/auto-layout-parity.md) | -| Text | 87% | 68% | 5–10 | [target-app-parity](docs/target-app-parity.md) | +| Text | 90% | 70% | 3–7 | [target-app-parity](docs/target-app-parity.md) | | Components & variants | 96% | 72% | 4–9 | [components-parity](docs/components-parity.md) | | Styles & variables | 94% | 76% | 3–5 | [components-parity](docs/components-parity.md) | | Prototyping | 90% | 74% | 3–6 | [prototyping-parity](docs/prototyping-parity.md) | @@ -213,6 +213,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Text lists, hanging punctuation and decoration styles, with a behaviour suite written from the public documentation (7 layout tests and 7 command tests, about 60 table rows): lists are set per paragraph (the list buttons, shortcuts and Type settings act on the paragraphs the selection touches, the whole layer when it isn't being edited), nest up to 8 levels (Tab and Shift+Tab; numbers go 1. a. i., bullets • ◦ ▪, counted per level), Enter on an empty item and Backspace at an item's start step out a level and then out of the list, typing `- `, `* ` or `1. ` at a paragraph's start starts one; list spacing (between items, in place of paragraph spacing), hanging lists (markers outside the box) and hanging punctuation (opening punctuation outside a start-aligned edge, closing punctuation outside an end-aligned one, taking no room when lines wrap); underline and strikethrough style (solid, dotted, wavy), thickness, underline offset, skip ink and colour. Canvas, PNG, SVG and PDF; `node.set` keys (`indentation`, `listSpacing`, `hangingPunctuation`, `hangingList`, `textDecorationStyle`/`Thickness`/`Offset`/`SkipInk`/`Color`), inspect (`lineTypes`, `lineIndentations`), CSS (`text-decoration-style`/`-thickness`/`-color`, `text-underline-offset`, `text-decoration-skip-ink`) and the REST import (`lineTypes` with `lineIndentations` per paragraph, which collapsed to one list style before). Text 87%/68% → 90%/70%, 5–10 h → 3–7 h; mainstream weekly areas 71.0% → 71.2%; other percentages unchanged at this rounding; full parity ~306–595 h → ~304–592 h | | 2026-10-12 | minor | Comments and version history behaviour suite written from the public documentation (15 tests, about 80 table rows plus stepwise checks; 4 tests failed before the fixes) and its fixes: in a live session, threads made in the session couldn't be replied to, edited, reacted to, resolved, moved, deleted or opened (their ids, from their creator's id space, were refused as out of range); restoring a version brought back that version's comments, losing newer threads and replies (comments now stay, those on layers the version lacks staying where they were); restoring now keeps the replaced design in the history as an autosave first, unless a version of it was just saved or restored; giving an autosave a title makes it a named version (it could be dropped to make room); merging a branch adds the main file as it was and then a `Merged “”` version to its history. Pinned where the documentation is silent: pins go to the innermost frame under them, copies, duplicates and duplicated pages come without comments, replying reopens a resolved thread, versions stay with whoever saved them in a live session (a shared history needs accounts). Left: a branch's own version history, comparing two versions. Comments, versions, misc 85%/60% → 85%/62%, 2–5 h → 2–4 h (this table still showed 80%/58%/4–8 h from before branches landed); percentages unchanged at this rounding; full parity ~306–595 h → ~306–594 h | | 2026-10-11 | minor | Canvas navigation, rulers, guides, layout grids, outline mode and measuring behaviour suite written from the public documentation (18 tests, about 120 table rows and checks) and its fixes: the pixel grid shows from 400% (it waited for 800%); guides belong to the page or a top-level frame (a guide dropped over a nested frame goes to the frame holding it, and a frame moved into another keeps its guides but they stop showing, snapping and measuring); a guide can be selected, deleted with Delete, nudged with the arrows and removed from the context menu (`guide.select`, `guide.remove` without an index); Alt-dragging a guide drops a copy and holding Alt while dragging one measures to the nearest edges in the frame it crosses; columns and rows count Auto (as many fixed-size ones as fit); layout grids don't show or snap on a rotated frame; in outline mode shapes are clicked on their outlines, reaching layers behind. Left: outline mode's Include hidden layers and Include object bounds. Canvas & navigation 97%/72% → 98%/74%, 2–5 h → 1–4 h (this table still showed 95%/72%/3–6 h from before the outlines landed); percentages unchanged at this rounding; full parity ~320–615 h → ~319–614 h | | 2026-10-11 | minor | Export and inspect behaviour suite written from the public documentation (139 cases in 19 tests; 47 failed before the fixes) and its fixes: an export covers what the layer paints, so a drop shadow adds room only where it falls, strokes on boxes and ellipses reach their real width (they added up to four times as much), hidden layers take no room and a mask group exports the mask's area; a slice exports everything under it; slashes in layer names export into folders; Copy as PNG copies the whole selection. Added: the export settings' contents only and include bounding box, and for SVG outline text (off writes ``), unique id attributes and simplified strokes (stroke attributes where exact); Inspect colour formats (hex, RGB, CSS, HSL, HSB), CSS in rem, a Blend row, positions measured from the frame around a layer (groups aren't frames), CSS for rotation, blend modes, gradient directions and kinds, text alignment, case and decoration; icons and images in the asset list (`file.exportLayers {assets}`). Import/export 80%/50% → 82%/53% (the row still showed the figures from before `.fig` import); Dev Mode 90%/64% → 93%/70%; mainstream weekly areas 68.1% → 68.4%; percentages and full parity hours unchanged at this rounding | diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index e10fc8a..8018609 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -1,6 +1,6 @@ //! Text node data: characters plus a base style and styled runs. -use figcraft_color::Paint; +use figcraft_color::{Color, Paint}; use serde::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] @@ -73,6 +73,33 @@ pub enum TextDecoration { Strikethrough, } +/// How an underline or strikethrough is drawn. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum DecorationStyle { + #[default] + Solid, + Dotted, + Wavy, +} + +/// A decoration length: `Auto` (the font's own value), pixels, or a percentage of the font size. +pub type DecorationLength = LineHeight; + +/// How a decoration line looks; the default is the font's own solid line in the text's colour. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct DecorationLook { + pub style: DecorationStyle, + pub thickness: DecorationLength, + /// Underlines: from the baseline down to the line's top edge (negative: above the baseline). + pub offset: DecorationLength, + /// Underlines break where descenders cross them. + pub skip_ink: bool, + /// The line's own colour (None: the text's fills). + pub color: Option, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub enum TextCase { @@ -178,6 +205,18 @@ pub enum ListStyle { Numbered, } +/// Deepest list nesting (levels 0..=MAX_LIST_INDENT). +pub const MAX_LIST_INDENT: u8 = 8; + +/// One paragraph's list settings. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct ParaList { + pub list: ListStyle, + /// Nesting level (0 = outermost; plain paragraphs are always 0). + pub indent: u8, +} + /// Character style. In runs, every field is an override; `None` means "inherit the base". #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] @@ -189,6 +228,9 @@ pub struct TextStyle { pub line_height: LineHeight, pub letter_spacing: LetterSpacing, pub decoration: TextDecoration, + /// How the decoration looks (None: the default look; boxed to keep styles small). + #[serde(skip_serializing_if = "Option::is_none")] + pub decoration_look: Option>, pub case: TextCase, /// Run fills (None = node fills). pub fills: Option>, @@ -213,6 +255,7 @@ impl Default for TextStyle { line_height: LineHeight::Auto, letter_spacing: LetterSpacing::default(), decoration: TextDecoration::None, + decoration_look: None, case: TextCase::Original, fills: None, link: None, @@ -253,6 +296,7 @@ impl TextStyle { && self.line_height == def.line_height && self.letter_spacing == def.letter_spacing && self.decoration == def.decoration + && self.decoration_look == def.decoration_look && self.case == def.case && self.variations == def.variations } @@ -262,6 +306,18 @@ impl TextStyle { TextStyle { style_id: None, link: None, ..self.clone() } } + /// How the decoration looks. + pub fn look(&self) -> DecorationLook { + self.decoration_look.as_deref().copied().unwrap_or_default() + } + + /// Change how the decoration looks (the default look is stored as None). + pub fn set_look(&mut self, f: impl FnOnce(&mut DecorationLook)) { + let mut look = self.look(); + f(&mut look); + self.decoration_look = (look != DecorationLook::default()).then(|| Box::new(look)); + } + /// Switch to the named style, dropping custom values of the axes the name implies. pub fn set_font_style(&mut self, name: impl Into) { self.font_style = name.into(); @@ -327,8 +383,22 @@ pub struct TextData { pub paragraph_spacing: f64, #[serde(skip_serializing_if = "crate::node::is_default")] pub paragraph_indent: f64, + /// The list style of paragraphs `paragraphs` doesn't cover (all of them when it's empty). #[serde(skip_serializing_if = "crate::node::is_default")] pub list: ListStyle, + /// Per-paragraph list settings, by paragraph index; empty when every paragraph is `list` at + /// level 0. Kept in step with the characters by [`TextData::replace_range`]. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub paragraphs: Vec, + /// Space between two items of a list (px), in place of the paragraph spacing. + #[serde(skip_serializing_if = "crate::node::is_default")] + pub list_spacing: f64, + /// Opening and closing punctuation at a line's aligned edge sits outside the box. + #[serde(skip_serializing_if = "crate::node::is_default")] + pub hanging_punctuation: bool, + /// List bullets and numbers sit outside the box; item text lines up with plain paragraphs. + #[serde(skip_serializing_if = "crate::node::is_default")] + pub hanging_list: bool, /// Vertical trim (`capHeight`: the box runs from the first line's cap height to the last /// line's baseline). #[serde(skip_serializing_if = "crate::node::is_default")] @@ -339,9 +409,10 @@ pub struct TextData { /// OpenType feature toggles (e.g. "liga": false). #[serde(skip_serializing_if = "crate::node::is_default")] pub features: std::collections::BTreeMap, - /// The path the text follows (text on a path); `None`: lines in the box. + /// The path the text follows (text on a path); `None`: lines in the box. Boxed to keep text + /// data small. #[serde(skip_serializing_if = "Option::is_none")] - pub path: Option, + pub path: Option>, } /// Text on a path: the baseline follows `d`. The text starts at `start` (aligned there by the @@ -508,6 +579,17 @@ impl TextData { let (start, end) = (start.min(n), end.min(n).max(start.min(n))); let byte = |i: usize| self.characters.char_indices().nth(i).map(|(b, _)| b).unwrap_or(self.characters.len()); let (bs, be) = (byte(start), byte(end)); + if !self.paragraphs.is_empty() { + // Paragraphs merged by removed newlines take the first one's list; new ones copy it. + let p = self.para_of(start); + let removed = self.characters.get(bs..be).unwrap_or("").matches('\n').count(); + let added = text.matches('\n').count(); + self.fill_paragraphs(); + let at = self.para_list(p); + let tail = (p + 1).min(self.paragraphs.len()); + self.paragraphs.drain(tail..(p + 1 + removed).min(self.paragraphs.len())); + self.paragraphs.splice(tail..tail, std::iter::repeat_n(at, added)); + } let mut s = String::with_capacity(self.characters.len() + text.len()); s.push_str(self.characters.get(..bs).unwrap_or("")); s.push_str(text); @@ -534,6 +616,62 @@ impl TextData { self.normalize(); } + pub fn paragraph_count(&self) -> usize { + self.characters.matches('\n').count() + 1 + } + + /// The paragraph char index `i` is in. + pub fn para_of(&self, i: usize) -> usize { + self.characters.chars().take(i).filter(|c| *c == '\n').count() + } + + /// Paragraph `p`'s list settings. + pub fn para_list(&self, p: usize) -> ParaList { + self.paragraphs.get(p).copied().unwrap_or(ParaList { list: self.list, indent: 0 }) + } + + /// Spell out every paragraph's list settings in `paragraphs` (and drop extra entries). + fn fill_paragraphs(&mut self) { + let n = self.paragraph_count(); + let fill = ParaList { list: self.list, indent: 0 }; + self.paragraphs.resize(n, fill); + } + + /// The paragraphs chars `start..end` touch (a caret: its paragraph; a selection ending just + /// after a newline leaves the next paragraph out). + pub fn para_span(&self, start: usize, end: usize) -> (usize, usize) { + let (s, e) = (start.min(end), start.max(end)); + let (a, b) = (self.para_of(s), self.para_of(e)); + let ends_at_start = e > s && b > a && self.characters.chars().nth(e - 1) == Some('\n'); + (a, if ends_at_start { b - 1 } else { b }) + } + + /// Apply `f` to the list settings of the paragraphs chars `start..end` touch (plain + /// paragraphs stay at level 0, levels stop at [`MAX_LIST_INDENT`]); one style for all with + /// no nesting folds back into `list`. + pub fn set_para_lists(&mut self, start: usize, end: usize, f: impl Fn(&mut ParaList)) { + let (a, b) = self.para_span(start, end); + self.fill_paragraphs(); + for pl in self.paragraphs.get_mut(a..=b).unwrap_or(&mut []) { + f(pl); + pl.indent = if pl.list == ListStyle::None { 0 } else { pl.indent.min(MAX_LIST_INDENT) }; + } + if let Some(first) = self.paragraphs.first().copied() + && first.indent == 0 + && self.paragraphs.iter().all(|p| *p == first) + { + self.list = first.list; + self.paragraphs.clear(); + } + } + + /// The list settings shared by the paragraphs chars `start..end` touch, or None when they differ. + pub fn common_para_list(&self, start: usize, end: usize) -> Option { + let (a, b) = self.para_span(start, end); + let first = self.para_list(a); + (a..=b).all(|p| self.para_list(p) == first).then_some(first) + } + /// Link chars `start..end` (all of them when the range is empty or the whole text) to text /// style `id`, defined as `def`. pub fn link_text_style(&mut self, start: usize, end: usize, def: &TextStyle, id: &str) { diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index 080e16e..260fbff 100644 --- a/crates/engine/src/cmd/arrange.rs +++ b/crates/engine/src/cmd/arrange.rs @@ -403,24 +403,29 @@ pub fn scale_node(doc: &mut Document, id: NodeId, k: f64) -> Result<()> { } } if let NodeKind::Text(t) = &mut n.kind { - t.style.font_size *= k; - if let LineHeight::Pixels(v) = &mut t.style.line_height { - *v *= k; - } - if let LetterSpacing::Pixels(v) = &mut t.style.letter_spacing { - *v *= k; - } + let scale = |st: &mut TextStyle| { + st.font_size *= k; + if let LineHeight::Pixels(v) = &mut st.line_height { + *v *= k; + } + if let LetterSpacing::Pixels(v) = &mut st.letter_spacing { + *v *= k; + } + st.set_look(|l| { + for m in [&mut l.thickness, &mut l.offset] { + if let LineHeight::Pixels(v) = m { + *v *= k; + } + } + }); + }; + scale(&mut t.style); for r in &mut t.runs { - r.style.font_size *= k; - if let LineHeight::Pixels(v) = &mut r.style.line_height { - *v *= k; - } - if let LetterSpacing::Pixels(v) = &mut r.style.letter_spacing { - *v *= k; - } + scale(&mut r.style); } t.paragraph_spacing *= k; t.paragraph_indent *= k; + t.list_spacing *= k; } if let NodeKind::Vector { network } = &mut n.kind { network.scale(k, k); diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 27d978c..5bbd335 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -21,8 +21,11 @@ columnGap / rowGap (aliases of gap / crossGap), gridColumns, gridRows, columnTra gridColumn / gridRow (0-based cell anchor, null = auto), gridColumnSpan, gridRowSpan, gridAlignH / gridAlignV (a grid child's alignment in its cell: min|center|max, auto/null = the frame's), sizingH / sizingV (fixed|hug|fill), \ absolute (ignore auto layout; follows constraints), minWidth, maxWidth, minHeight, maxHeight (null removes), constraints {horizontal, vertical}, grids, exports, characters, fontFamily, fontStyle, \ fontSize, lineHeight (auto|px|\"150%\"), letterSpacing (px|\"2%\"), textAlign (left|center|right|justified), textAlignVertical, textAutoResize \ -(widthAndHeight|height|none|truncate), textDecoration, textCase (original|upper|lower|title|smallCaps), paragraphSpacing, paragraphIndent, \ -listStyle (none|bulleted|numbered), leadingTrim (vertical trim: none|capHeight), maxLines, features ({tag: bool}, e.g. {\"liga\": false, \"tnum\": true}), \ +(widthAndHeight|height|none|truncate), textDecoration, textDecorationStyle (solid|dotted|wavy), textDecorationThickness / textDecorationOffset \ +(auto|px|\"10%\" of the font size; the offset runs from the baseline down to an underline's top edge), textDecorationSkipInk (underlines break around descenders), \ +textDecorationColor (colour, or auto/null: the text's), textCase (original|upper|lower|title|smallCaps), paragraphSpacing, paragraphIndent, \ +listStyle (none|bulleted|numbered; with start/end: the paragraphs those chars touch), indentation (list nesting level 0-8, same range rule), \ +listSpacing (px between list items, in place of paragraphSpacing), hangingPunctuation, hangingList (markers outside the box), leadingTrim (vertical trim: none|capHeight), maxLines, features ({tag: bool}, e.g. {\"liga\": false, \"tnum\": true}), \ fontVariations / variationSettings ({axis: number}, e.g. {\"wght\": 550, \"wdth\": 85}; null = none), pointCount, innerRadius, arcStart, arcSweep, \ arcInner, booleanOperation (a boolean group's operation, or an operand's own; null: the group's), operandOperation (a layer in a boolean \ group: how it combines with the operands below it; null: the group's), constrainProportions, description, interactions, overflow, pluginData {k: v}"; @@ -586,8 +589,23 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map { + "fontFamily" + | "fontStyle" + | "fontWeight" + | "fontSize" + | "lineHeight" + | "letterSpacing" + | "textDecoration" + | "textCase" + | "hyperlink" + | "textFills" + | "fontVariations" + | "variationSettings" + | "textDecorationStyle" + | "textDecorationThickness" + | "textDecorationOffset" + | "textDecorationSkipInk" + | "textDecorationColor" => { let n = doc.get_mut(id)?; let t = text_mut(cmd, n)?; let (start, end) = range_of(props, t.char_count()); @@ -605,7 +623,23 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map text_mut(cmd, doc.get_mut(id)?)?.paragraph_spacing = f64_of(v).unwrap_or(0.0).max(0.0), "paragraphIndent" => text_mut(cmd, doc.get_mut(id)?)?.paragraph_indent = f64_of(v).unwrap_or(0.0).max(0.0), - "listStyle" => text_mut(cmd, doc.get_mut(id)?)?.list = enum_of(v).ok_or_else(|| bad(cmd, "listStyle: none|bulleted|numbered"))?, + "listStyle" | "indentation" => { + let t = text_mut(cmd, doc.get_mut(id)?)?; + let (start, end) = range_of(props, t.char_count()); + if k == "listStyle" { + let l: ListStyle = enum_of(v).ok_or_else(|| bad(cmd, "listStyle: none|bulleted|numbered"))?; + t.set_para_lists(start, end, |p| p.list = l); + } else { + let d = f64_of(v).filter(|d| d.is_finite()).ok_or_else(|| bad(cmd, "indentation: a list level 0-8"))?; + t.set_para_lists(start, end, |p| p.indent = d.clamp(0.0, f64::from(MAX_LIST_INDENT)) as u8); + } + } + "listSpacing" => text_mut(cmd, doc.get_mut(id)?)?.list_spacing = f64_of(v).filter(|s| s.is_finite()).unwrap_or(0.0).clamp(0.0, 10_000.0), + "hangingPunctuation" | "hangingList" => { + let on = v.as_bool().ok_or_else(|| bad(cmd, format!("`{k}` must be true or false")))?; + let t = text_mut(cmd, doc.get_mut(id)?)?; + *if k == "hangingList" { &mut t.hanging_list } else { &mut t.hanging_punctuation } = on; + } "leadingTrim" => text_mut(cmd, doc.get_mut(id)?)?.leading_trim = enum_of(v).ok_or_else(|| bad(cmd, "leadingTrim: none|capHeight"))?, "maxLines" => text_mut(cmd, doc.get_mut(id)?)?.max_lines = f64_of(v).unwrap_or(0.0).clamp(0.0, 10_000.0) as u32, "features" | "textFeatures" | "fontFeatures" | "openTypeFeatures" => { @@ -755,7 +789,8 @@ pub fn override_group(k: &str) -> &'static str { "visible" => "visible", "name" => "name", "characters" | "text" => "characters", - "features" | "fontFeatures" | "openTypeFeatures" | "listStyle" | "maxLines" | "variationSettings" => "textStyle", + "features" | "fontFeatures" | "openTypeFeatures" | "listStyle" | "indentation" | "listSpacing" | "maxLines" | "variationSettings" + | "hangingPunctuation" | "hangingList" => "textStyle", "cornerRadius" | "cornerSmoothing" | "topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => "corners", "width" | "height" | "w" | "h" | "sizingH" | "sizingV" => "size", "gap" | "itemSpacing" | "columnGap" | "crossGap" | "counterAxisSpacing" | "rowGap" => "gap", @@ -842,6 +877,26 @@ pub fn apply_text_style(cmd: &str, t: &mut TextData, start: usize, end: usize, k let d: TextDecoration = enum_of(v).ok_or_else(|| bad(cmd, "textDecoration: none|underline|strikethrough"))?; t.restyle(start, end, |s| s.decoration = d); } + "textDecorationStyle" => { + let d: DecorationStyle = enum_of(v).ok_or_else(|| bad(cmd, "textDecorationStyle: solid|dotted|wavy"))?; + t.restyle(start, end, |s| s.set_look(|l| l.style = d)); + } + "textDecorationThickness" | "textDecorationOffset" => { + let m = line_height(v) + .filter(|m| !matches!(m, LineHeight::Pixels(x) | LineHeight::Percent(x) if !x.is_finite() || x.abs() > 10_000.0)) + .ok_or_else(|| bad(cmd, format!("{k}: auto | px | \"10%\"")))?; + let thickness = k == "textDecorationThickness"; + t.restyle(start, end, |s| s.set_look(|l| *if thickness { &mut l.thickness } else { &mut l.offset } = m)); + } + "textDecorationSkipInk" => { + let on = v.as_bool().ok_or_else(|| bad(cmd, "textDecorationSkipInk must be true or false"))?; + t.restyle(start, end, |s| s.set_look(|l| l.skip_ink = on)); + } + "textDecorationColor" => { + let auto = v.is_null() || v.as_str().is_some_and(|s| s.eq_ignore_ascii_case("auto")); + let c = if auto { None } else { Some(color_of(v).ok_or_else(|| bad(cmd, "textDecorationColor: a colour, or auto"))?) }; + t.restyle(start, end, |s| s.set_look(|l| l.color = c)); + } "textCase" => { let c: TextCase = enum_of(v).ok_or_else(|| bad(cmd, "textCase: original|upper|lower|title|smallCaps"))?; t.restyle(start, end, |s| s.case = c); diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 0062b05..40fde81 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -60,6 +60,31 @@ fn para(s: &mut Session, p: &Value, f: impl Fn(&mut TextData)) -> Result ok() } +/// Run `f` on each targeted text with the chars whose paragraphs a list command changes: the +/// edited paragraphs while editing, else all of them. +fn para_range(s: &mut Session, p: &Value, f: impl Fn(&mut TextData, usize, usize)) -> Result { + let range = textedit::selected_range(s); + para(s, p, |t| { + let (a, b) = range.map_or((0, t.char_count()), |(_, a, b)| (a, b)); + f(t, a, b) + }) +} + +/// A list command: every targeted paragraph becomes `l`, or (`toggle`) plain when all already are. +fn set_list(s: &mut Session, p: &Value, l: ListStyle, toggle: bool) -> Result { + let range = textedit::selected_range(s); + let ids = range.map_or_else(|| targets(s, p), |(id, _, _)| vec![id]); + let doc = s.doc()?; + let all = ids.iter().all(|i| { + doc.node(*i).and_then(|n| n.kind.text()).is_some_and(|t| { + let (a, b) = range.map_or((0, t.char_count()), |(_, a, b)| (a, b)); + t.common_para_list(a, b).is_some_and(|pl| pl.list == l) + }) + }); + let l = if toggle && all { ListStyle::None } else { l }; + para_range(s, p, |t, a, b| t.set_para_lists(a, b, |pl| pl.list = l)) +} + /// Whether every targeted char has `pred` (to decide toggle direction). fn all_have(s: &Session, p: &Value, pred: impl Fn(&TextStyle) -> bool) -> bool { let Ok(doc) = s.doc() else { return false }; @@ -126,7 +151,7 @@ fn on_path(s: &mut Session, p: &Value) -> Result { n.transform = transform; (n.width, n.height) = (w, h); if let NodeKind::Text(t) = &mut n.kind { - t.path = Some(tp); + t.path = Some(Box::new(tp)); } s.set_selection(vec![text]); Ok(json!({ "id": text })) @@ -172,10 +197,18 @@ pub fn specs() -> Vec { let url = req_string("text.link", p, "url")?; style(s, p, |st| st.link = Some(url.clone())) }), - spec!("text.bulletList", "Bulleted list", "{ids?}", true, |s, p| para(s, p, |t| t.list = - if t.list == ListStyle::Bulleted { ListStyle::None } else { ListStyle::Bulleted })), - spec!("text.numberList", "Numbered list", "{ids?}", true, |s, p| para(s, p, |t| t.list = - if t.list == ListStyle::Numbered { ListStyle::None } else { ListStyle::Numbered })), + spec!("text.bulletList", "Bulleted list", "{ids?} while editing: the selected paragraphs", true, |s, p| set_list( + s, + p, + ListStyle::Bulleted, + true + )), + spec!("text.numberList", "Numbered list", "{ids?} while editing: the selected paragraphs", true, |s, p| set_list( + s, + p, + ListStyle::Numbered, + true + )), spec!("text.alignLeft", "Align left", "{ids?}", true, |s, p| para(s, p, |t| t.align_h = TextAlignH::Left)), spec!("text.alignCenter", "Align center", "{ids?}", true, |s, p| para(s, p, |t| t.align_h = TextAlignH::Center)), spec!("text.alignRight", "Align right", "{ids?}", true, |s, p| para(s, p, |t| t.align_h = TextAlignH::Right)), @@ -222,12 +255,12 @@ pub fn specs() -> Vec { .ok_or_else(|| bad("text.decoration", "`decoration`: none|underline|strikethrough"))?; style(s, p, |st| st.decoration = d) }), - spec!("text.listStyle", "List style", "{style: none|bulleted|numbered, ids?}", true, |s, p| { + spec!("text.listStyle", "List style", "{style: none|bulleted|numbered, ids?} while editing: the selected paragraphs", true, |s, p| { let l: ListStyle = get(p, "style") .or_else(|| get(p, "list")) .and_then(super::props::enum_of) .ok_or_else(|| bad("text.listStyle", "`style`: none|bulleted|numbered"))?; - para(s, p, |t| t.list = l) + set_list(s, p, l, false) }), spec!("text.paragraphIndent", "Paragraph indent", "{indent (px), ids?}", true, |s, p| { let v = finite(req_num("text.paragraphIndent", p, "indent")?).clamp(0.0, 10_000.0); @@ -361,7 +394,7 @@ pub fn specs() -> Vec { spec!( "text.edit.key", "Text key", - "{key: Backspace|Delete|Left|Right|Up|Down|Home|End|Enter|Escape|SelectAll, shift?, alt?, cmd?}", + "{key: Backspace|Delete|Left|Right|Up|Down|Home|End|Enter|Tab|Escape|SelectAll, shift?, alt?, cmd?} in a list, Tab nests the item and Shift+Tab, Enter on an empty item or Backspace at its start move it out", true, |s, p| { let k = req_string("text.edit.key", p, "key")?; diff --git a/crates/engine/src/codegen.rs b/crates/engine/src/codegen.rs index 989a666..41bf7bb 100644 --- a/crates/engine/src/codegen.rs +++ b/crates/engine/src/codegen.rs @@ -446,6 +446,34 @@ pub(crate) fn css_decls(doc: &Document, n: &Node, t: &mut Tokens, hug: (bool, bo TextDecoration::Underline => put("text-decoration", "underline".into()), TextDecoration::Strikethrough => put("text-decoration", "line-through".into()), } + if s.decoration != TextDecoration::None { + let l = s.look(); + let len = |m: LineHeight| match m { + LineHeight::Auto => None, + LineHeight::Pixels(v) => Some(px(v)), + LineHeight::Percent(p) => Some(format!("{}%", num(p))), + }; + match l.style { + figcraft_doc::DecorationStyle::Solid => {} + figcraft_doc::DecorationStyle::Dotted => put("text-decoration-style", "dotted".into()), + figcraft_doc::DecorationStyle::Wavy => put("text-decoration-style", "wavy".into()), + } + if let Some(v) = len(l.thickness) { + put("text-decoration-thickness", v); + } + if let Some(c) = l.color { + put("text-decoration-color", c.to_css()); + } + if s.decoration == TextDecoration::Underline { + if let Some(v) = len(l.offset) { + put("text-underline-offset", v); + } + // Browsers skip ink by default; the canvas only when asked. + if !l.skip_ink { + put("text-decoration-skip-ink", "none".into()); + } + } + } if tx.leading_trim == figcraft_doc::LeadingTrim::CapHeight { put("text-box", "trim-both cap alphabetic".into()); } diff --git a/crates/engine/src/components.rs b/crates/engine/src/components.rs index b895e6e..c96155d 100644 --- a/crates/engine/src/components.rs +++ b/crates/engine/src/components.rs @@ -346,6 +346,7 @@ pub(crate) fn copy_group(from: &Node, to: &mut Node, g: &str) { if let (NodeKind::Text(a), NodeKind::Text(b)) = (&from.kind, &mut to.kind) { b.characters = a.characters.clone(); b.runs = a.runs.clone(); + b.paragraphs = a.paragraphs.clone(); } } "textStyle" => { @@ -358,6 +359,10 @@ pub(crate) fn copy_group(from: &Node, to: &mut Node, g: &str) { b.paragraph_spacing = a.paragraph_spacing; b.paragraph_indent = a.paragraph_indent; b.list = a.list; + b.paragraphs = a.paragraphs.clone(); + b.list_spacing = a.list_spacing; + b.hanging_punctuation = a.hanging_punctuation; + b.hanging_list = a.hanging_list; b.max_lines = a.max_lines; b.features = a.features.clone(); } @@ -1025,6 +1030,10 @@ fn text_style_differs(x: &TextData, y: &TextData) -> bool { || x.paragraph_spacing != y.paragraph_spacing || x.paragraph_indent != y.paragraph_indent || x.list != y.list + || x.paragraphs != y.paragraphs + || x.list_spacing != y.list_spacing + || x.hanging_punctuation != y.hanging_punctuation + || x.hanging_list != y.hanging_list || x.max_lines != y.max_lines || x.features != y.features } diff --git a/crates/engine/src/devinspect.rs b/crates/engine/src/devinspect.rs index 1cf89fc..7ddca48 100644 --- a/crates/engine/src/devinspect.rs +++ b/crates/engine/src/devinspect.rs @@ -605,7 +605,17 @@ fn typography(doc: &Document, n: &Node, t: &TextData) -> Section { }; rows.push(prop("Align", align)); if s.decoration != TextDecoration::None { - rows.push(prop("Decoration", if s.decoration == TextDecoration::Underline { "underline" } else { "strikethrough" })); + let l = s.look(); + let style = match l.style { + figcraft_doc::DecorationStyle::Solid => "", + figcraft_doc::DecorationStyle::Dotted => ", dotted", + figcraft_doc::DecorationStyle::Wavy => ", wavy", + }; + let kind = if s.decoration == TextDecoration::Underline { "underline" } else { "strikethrough" }; + rows.push(prop("Decoration", format!("{kind}{style}"))); + } + if t.list_spacing > 0.0 { + rows.push(prop("List spacing", num(t.list_spacing))); } if variable(doc, n, "characters").is_some() { rows.push(prop("Content", t.characters.chars().take(80).collect::()).token(variable(doc, n, "characters"))); diff --git a/crates/engine/src/draw_tools_tests.rs b/crates/engine/src/draw_tools_tests.rs index 23b4ea7..d67b0af 100644 --- a/crates/engine/src/draw_tools_tests.rs +++ b/crates/engine/src/draw_tools_tests.rs @@ -14,7 +14,7 @@ fn id(v: Value) -> NodeId { fn text_path(s: &Session, t: NodeId) -> Option { match &s.doc().unwrap().node(t)?.kind { - NodeKind::Text(td) => td.path.clone(), + NodeKind::Text(td) => td.path.as_deref().cloned(), _ => None, } } diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 46ec52e..cc0d8bc 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -180,6 +180,30 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value { if t.style.decoration != TextDecoration::None { o.insert("textDecoration".into(), json!(t.style.decoration)); } + if let Some(l) = t.style.decoration_look.as_deref() { + o.insert("textDecorationStyle".into(), json!(l.style)); + o.insert("textDecorationThickness".into(), json!(l.thickness.label())); + o.insert("textDecorationOffset".into(), json!(l.offset.label())); + o.insert("textDecorationSkipInk".into(), json!(l.skip_ink)); + o.insert("textDecorationColor".into(), l.color.map_or(json!("auto"), |c| json!(format!("#{}", c.to_hex())))); + } + // Lists: one style for the layer, or each paragraph's type and nesting level. + if t.paragraphs.is_empty() { + if t.list != figcraft_doc::ListStyle::None { + o.insert("listStyle".into(), json!(t.list)); + } + } else { + o.insert("lineTypes".into(), t.paragraphs.iter().map(|p| json!(p.list)).collect()); + o.insert("lineIndentations".into(), t.paragraphs.iter().map(|p| json!(p.indent)).collect()); + } + if t.list_spacing != 0.0 { + o.insert("listSpacing".into(), json!(t.list_spacing)); + } + for (k, on) in [("hangingPunctuation", t.hanging_punctuation), ("hangingList", t.hanging_list)] { + if on { + o.insert(k.into(), json!(true)); + } + } if t.leading_trim != figcraft_doc::LeadingTrim::None { o.insert("leadingTrim".into(), json!(t.leading_trim)); } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 401fae0..d6e1a09 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1399,6 +1399,8 @@ mod text_motion_tests; #[cfg(test)] mod text_style_picker_tests; #[cfg(test)] +mod text_tail_tests; +#[cfg(test)] mod tiff_tests; #[cfg(test)] mod transform_behaviour_tests; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6be3892..5f39cf4 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -256,6 +256,8 @@ fn every_command_survives_junk_params() { json!({"id": "nope", "x": "NaN", "props": 5}), json!([1, 2, 3]), json!({"variations": {"wght": 1e308, "": "x", "slnt": null}, "props": {"fontVariations": [1], "variationSettings": {"wdth": "-1e9"}}, "text": 7, "cursor": -3}), + json!({"style": 3, "props": {"listStyle": 5, "indentation": 1e308, "listSpacing": "NaN", "hangingList": "yes", "start": -5, "end": 1e12, + "textDecorationThickness": {"unit": "pixels", "value": "x"}, "textDecorationOffset": -1e308, "textDecorationColor": [1]}}), ]; for spec in all_commands() { if matches!( diff --git a/crates/engine/src/text_tail_tests.rs b/crates/engine/src/text_tail_tests.rs new file mode 100644 index 0000000..b951bfc --- /dev/null +++ b/crates/engine/src/text_tail_tests.rs @@ -0,0 +1,302 @@ +//! Text behaviour suite, the tail, through commands: lists per paragraph (the list buttons and +//! shortcuts, typed list starts, Tab / Shift+Tab / Enter / Backspace at list items), list spacing, +//! hanging punctuation and lists, and decoration styles through `node.set`, inspect, CSS and the +//! REST import. Each case gives the rule and a one-line derivation from the public documentation; +//! "silent" marks a case where the documentation says nothing and today's behaviour is pinned. +//! Everything here is font-free. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; +use crate::textedit::{begin, insert, key, set_caret}; +use figcraft_doc::{DecorationLength, DecorationStyle, ListStyle, NodeId, ParaList, TextData, color::Color}; + +fn text(s: &mut Session, chars: &str) -> NodeId { + id_of(&s.execute("text.create", &json!({"text": chars, "x": 0, "y": 0, "props": {"fontSize": 20}})).unwrap()["id"]).unwrap() +} + +fn data(s: &Session, t: NodeId) -> TextData { + s.doc().unwrap().node(t).unwrap().kind.text().unwrap().clone() +} + +/// Each paragraph's (list, level). +fn lists(s: &Session, t: NodeId) -> Vec<(ListStyle, u8)> { + let d = data(s, t); + (0..d.paragraph_count()).map(|p| d.para_list(p)).map(|p| (p.list, p.indent)).collect() +} + +use ListStyle::{Bulleted as B, None as P, Numbered as N}; + +#[test] +fn list_buttons_act_on_the_selected_paragraphs() { + // The list buttons and shortcuts turn the paragraphs the selection touches into list items + // (the whole layer when it's selected but not being edited); again turns them back. + let mut s = Session::new(); + let t = text(&mut s, "one\ntwo\nthree"); + begin(&mut s, t, Some(5), false).unwrap(); + // (command, selection, lists after, derivation) + let cases = [ + ("text.bulletList", (5, 5), [(P, 0), (B, 0), (P, 0)], "a caret: its paragraph"), + ("text.bulletList", (5, 5), [(P, 0); 3], "again: back to plain"), + ("text.numberList", (1, 8), [(N, 0), (N, 0), (P, 0)], "a selection: every paragraph it touches (not one it ends at the start of)"), + ("text.bulletList", (0, 13), [(B, 0); 3], "mixed: all become bulleted"), + ("text.numberList", (2, 2), [(N, 0), (B, 0), (B, 0)], "the other list kind replaces it"), + ]; + for (cmd, (a, b), want, why) in cases { + set_caret(&mut s, a, false).unwrap(); + set_caret(&mut s, b, true).unwrap(); + s.execute(cmd, &json!({})).unwrap(); + assert_eq!(lists(&s, t), want, "{why}"); + } + crate::textedit::end(&mut s).unwrap(); + s.execute("text.listStyle", &json!({"ids": [t], "style": "bulleted"})).unwrap(); + assert_eq!(lists(&s, t), [(B, 0); 3], "not editing: the whole layer"); + assert!(data(&s, t).paragraphs.is_empty(), "one style for all folds into the layer's list style"); + s.execute("text.bulletList", &json!({"ids": [t]})).unwrap(); + assert_eq!(data(&s, t).list, P, "toggled off"); +} + +#[test] +fn typed_list_starts() { + // Typing "- " or "* " at the start of a paragraph starts a bulleted list, "1. " a numbered one + // (the typed marker goes); elsewhere they're plain text. + // (typed, chars after, list, derivation) + let cases = [ + ("- ", "", B, "hyphen and space: bullets"), + ("* ", "", B, "asterisk and space: bullets"), + ("1. ", "", N, "one, full stop, space: numbers"), + ("2. ", "2. ", P, "silent: only 1. starts a numbered list"), + ("a- ", "a- ", P, "not at the paragraph's start: text"), + ("-x ", "-x ", P, "the space must follow the marker"), + ]; + for (typed, want, list, why) in cases { + let mut s = Session::new(); + let t = text(&mut s, "x"); + s.execute("node.set", &json!({"id": t, "props": {"characters": ""}})).unwrap(); + begin(&mut s, t, Some(0), false).unwrap(); + for c in typed.chars() { + insert(&mut s, &c.to_string()).unwrap(); + } + assert_eq!((data(&s, t).characters.as_str(), lists(&s, t)[0].0), (want, list), "{why}"); + } + // In the second paragraph, and not again inside a list item. + let mut s = Session::new(); + let t = text(&mut s, "Intro\n"); + begin(&mut s, t, Some(6), false).unwrap(); + for c in "- - ".chars() { + insert(&mut s, &c.to_string()).unwrap(); + } + assert_eq!((data(&s, t).characters.as_str(), lists(&s, t)), ("Intro\n- ", vec![(P, 0), (B, 0)]), "an item's own text stays text"); +} + +#[test] +fn list_keys_at_list_items() { + // (chars, lists before, caret, key, shift, chars after, lists after, derivation) + let cases = [ + ("a\nb", vec![(B, 0), (B, 0)], 3, "Tab", false, "a\nb", vec![(B, 0), (B, 1)], "Tab nests the item one level"), + ("a\nb", vec![(B, 0), (B, 1)], 3, "Tab", true, "a\nb", vec![(B, 0), (B, 0)], "Shift+Tab moves it out a level"), + ("a\nb", vec![(B, 0), (B, 0)], 3, "Tab", true, "a\nb", vec![(B, 0), (P, 0)], "silent: Shift+Tab at the top leaves the list"), + ("a\nb", vec![(N, 0), (N, 0)], 1, "Enter", false, "a\n\nb", vec![(N, 0), (N, 0), (N, 0)], "Enter in an item starts the next item"), + ("a\n", vec![(N, 0), (N, 0)], 2, "Enter", false, "a\n", vec![(N, 0), (P, 0)], "silent: Enter on an empty item ends the list"), + ("a\n", vec![(N, 0), (N, 1)], 2, "Enter", false, "a\n", vec![(N, 0), (N, 0)], "silent: ...a nested one moves out a level first"), + ( + "a\nb", + vec![(B, 0), (B, 0)], + 2, + "Backspace", + false, + "a\nb", + vec![(B, 0), (P, 0)], + "silent: Backspace at an item's start removes its bullet", + ), + ("a\nbc", vec![(B, 0), (B, 0)], 4, "Backspace", false, "a\nb", vec![(B, 0), (B, 0)], "Backspace inside an item deletes text"), + ("a\nb", vec![(P, 0), (P, 0)], 3, "Tab", false, "a\nb\t", vec![(P, 0), (P, 0)], "outside a list Tab types a tab"), + ("a", vec![(B, 8)], 1, "Tab", false, "a", vec![(B, 8)], "silent: nesting stops at 8 levels"), + ]; + for (chars, before, caret, k, shift, after, want, why) in cases { + let mut s = Session::new(); + let t = text(&mut s, chars); + let ps: Vec = before.iter().map(|(list, indent)| ParaList { list: *list, indent: *indent }).collect(); + if let figcraft_doc::NodeKind::Text(td) = &mut s.doc_mut().unwrap().get_mut(t).unwrap().kind { + td.paragraphs = ps; + } + begin(&mut s, t, Some(caret), false).unwrap(); + key(&mut s, k, shift, false, false).unwrap(); + assert_eq!((data(&s, t).characters.as_str(), lists(&s, t)), (after, want), "{why}"); + } + // A whole editing session is one undo step. + let mut s = Session::new(); + let t = text(&mut s, "a\nb"); + s.execute("text.listStyle", &json!({"ids": [t], "style": "bulleted"})).unwrap(); + begin(&mut s, t, Some(3), false).unwrap(); + key(&mut s, "Tab", false, false, false).unwrap(); + crate::textedit::end(&mut s).unwrap(); + s.undo().unwrap(); + assert_eq!(lists(&s, t), [(B, 0), (B, 0)]); +} + +#[test] +fn paragraph_lists_follow_edits() { + // A paragraph's list stays with it as text is typed, pasted and deleted: new paragraphs copy + // the one they split from; joined paragraphs keep the first one's. + let mut t = TextData::new("ab\ncd\nef"); + t.paragraphs = vec![ParaList { list: B, indent: 1 }, ParaList::default(), ParaList { list: N, indent: 0 }]; + let get = |t: &TextData| (0..t.paragraph_count()).map(|p| (t.para_list(p).list, t.para_list(p).indent)).collect::>(); + t.replace_range(1, 1, "x\ny"); + assert_eq!(get(&t), [(B, 1), (B, 1), (P, 0), (N, 0)], "a pasted newline: a new item like it"); + t.replace_range(2, 7, ""); + assert_eq!((t.characters.as_str(), get(&t)), ("axd\nef", vec![(B, 1), (N, 0)]), "joined: the first paragraph's list"); + t.replace_range(0, t.char_count(), "z"); + assert_eq!(get(&t), [(B, 1)]); + // A deterministic edit sweep: the per-paragraph list always matches the paragraph count. + let mut seed = 7u64; + let mut rnd = |n: usize| { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + (seed >> 33) as usize % n.max(1) + }; + let mut t = TextData::new("a\nb\nc\nd"); + t.paragraphs = vec![ParaList { list: B, indent: 2 }; 4]; + for _ in 0..500 { + let n = t.char_count(); + let (a, b) = (rnd(n + 2), rnd(n + 2)); + let ins = ["", "\n", "q", "\n\n", "x\ny"][rnd(5)]; + t.replace_range(a.min(b), a.max(b), ins); + if !t.paragraphs.is_empty() { + assert_eq!(t.paragraphs.len(), t.paragraph_count(), "{:?}", t.characters); + } + t.set_para_lists(rnd(n + 2), rnd(n + 2), |p| p.indent = 200); + assert!((0..t.paragraph_count()).all(|p| t.para_list(p).indent <= figcraft_doc::MAX_LIST_INDENT)); + } +} + +#[test] +fn node_set_keys_for_agents() { + let mut s = Session::new(); + let t = text(&mut s, "one\ntwo\nthree"); + let set = |s: &mut Session, props: Value| s.execute("node.set", &json!({"id": t, "props": props})); + // (props, derivation) + set(&mut s, json!({"listStyle": "numbered", "start": 4, "end": 13})).unwrap(); + assert_eq!(lists(&s, t), [(P, 0), (N, 0), (N, 0)], "start/end: the paragraphs those chars touch"); + set(&mut s, json!({"indentation": 2, "start": 9, "end": 9})).unwrap(); + assert_eq!(lists(&s, t), [(P, 0), (N, 0), (N, 2)], "indentation: the list level"); + set(&mut s, json!({"indentation": 3})).unwrap(); + assert_eq!(lists(&s, t), [(P, 0), (N, 3), (N, 3)], "plain paragraphs don't nest"); + set(&mut s, json!({"listSpacing": 6, "hangingPunctuation": true, "hangingList": true})).unwrap(); + let d = data(&s, t); + assert_eq!((d.list_spacing, d.hanging_punctuation, d.hanging_list), (6.0, true, true)); + set( + &mut s, + json!({"textDecoration": "underline", "textDecorationStyle": "wavy", "textDecorationThickness": 2, "textDecorationOffset": "10%", + "textDecorationSkipInk": true, "textDecorationColor": "#FF0000", "start": 0, "end": 3}), + ) + .unwrap(); + let d = data(&s, t); + let look = d.style_at(0).look(); + assert_eq!( + (look.style, look.thickness, look.offset, look.skip_ink, look.color), + (DecorationStyle::Wavy, DecorationLength::Pixels(2.0), DecorationLength::Percent(10.0), true, Some(Color::new(1.0, 0.0, 0.0, 1.0))) + ); + assert!(d.style_at(5).decoration_look.is_none(), "only the range"); + set(&mut s, json!({"textDecorationThickness": "auto", "textDecorationColor": "auto", "textDecorationStyle": "solid", "textDecorationSkipInk": false, "textDecorationOffset": "auto"})).unwrap(); + assert!(data(&s, t).spans().iter().all(|(_, _, st)| st.decoration_look.is_none()), "auto everywhere: the default look, stored as nothing"); + // Junk is refused, nothing changes. + let before = data(&s, t); + for junk in [ + json!({"listStyle": "zigzag"}), + json!({"indentation": "deep"}), + json!({"hangingList": 3}), + json!({"textDecorationStyle": "dashed"}), + json!({"textDecorationThickness": "thick"}), + json!({"textDecorationOffset": 1e300}), + json!({"textDecorationSkipInk": "yes"}), + json!({"textDecorationColor": "nope"}), + ] { + assert!(set(&mut s, junk.clone()).is_err(), "{junk}"); + assert_eq!(data(&s, t), before, "{junk}"); + } + // The keys are documented for agents. + for k in + ["textDecorationStyle", "textDecorationSkipInk", "textDecorationColor", "indentation", "listSpacing", "hangingPunctuation", "hangingList"] + { + assert!(crate::cmd::props::KEYS.contains(k), "{k}"); + } +} + +#[test] +fn inspect_css_and_files() { + let mut s = Session::new(); + let t = text(&mut s, "one\ntwo"); + s.execute("node.set", &json!({"id": t, "props": {"listStyle": "bulleted", "start": 4, "end": 4, "listSpacing": 4, "hangingList": true}})) + .unwrap(); + s.execute( + "node.set", + &json!({"id": t, "props": {"textDecoration": "underline", "textDecorationStyle": "dotted", "textDecorationThickness": 2, "textDecorationOffset": 3, "textDecorationColor": "#00FF00"}}), + ) + .unwrap(); + let doc = s.doc().unwrap(); + let j = crate::inspect::node_json(doc, t, 0); + // (key, value, derivation) + for (k, v, why) in [ + ("lineTypes", json!(["none", "bulleted"]), "each paragraph's list"), + ("lineIndentations", json!([0, 0]), "and its level"), + ("listSpacing", json!(4.0), "list spacing"), + ("hangingList", json!(true), "hanging list"), + ("textDecorationStyle", json!("dotted"), "decoration style"), + ("textDecorationThickness", json!("2"), "thickness"), + ("textDecorationOffset", json!("3"), "offset"), + ("textDecorationColor", json!("#00FF00"), "colour"), + ] { + assert_eq!(j[k], v, "{why}"); + } + let n = doc.node(t).unwrap(); + let css = crate::codegen::css_decls(doc, n, &mut Default::default(), (true, true)); + for want in [ + "text-decoration-style: dotted", + "text-decoration-thickness: 2px", + "text-underline-offset: 3px", + "text-decoration-color: #00ff00", + "text-decoration-skip-ink: none", + ] { + assert!(css.to_lowercase().contains(want), "{want} in {css}"); + } + // Saved files keep it all and stay free of it when unused. + let td = doc.node(t).unwrap().kind.text().unwrap(); + let back: TextData = serde_json::from_str(&serde_json::to_string(td).unwrap()).unwrap(); + assert_eq!(&back, td); + let plain = serde_json::to_string(&TextData::new("x")).unwrap(); + for k in ["paragraphs", "list_spacing", "hanging", "decoration_look"] { + assert!(!plain.contains(k), "{k} in {plain}"); + } + // Renders and exports draw dotted, wavy and skipped-ink lines without trouble. + for style in ["dotted", "wavy"] { + s.execute("node.set", &json!({"id": t, "props": {"textDecorationStyle": style, "textDecorationSkipInk": true}})).unwrap(); + let doc = s.doc().unwrap(); + assert!(figcraft_render::render_node(doc, t, 1.0).is_some()); + assert!(figcraft_svg::export_svg(doc, &[t], &figcraft_svg::SvgOptions::default()).is_some_and(|svg| !svg.contains("NaN"))); + assert!(figcraft_pdf::export_nodes_pdf(doc, &[t]).is_ok()); + } +} + +#[test] +fn rest_import_lists_and_decorations() { + let file = json!({"document": {"type": "DOCUMENT", "children": [{"type": "CANVAS", "name": "P", "children": [{ + "type": "TEXT", "name": "T", "characters": "a\nb\nc", + "absoluteBoundingBox": {"x": 0, "y": 0, "width": 100, "height": 60}, + "style": {"fontFamily": "Inter", "fontSize": 16, "listSpacing": 5, "hangingPunctuation": true, "hangingList": true, + "textDecoration": "UNDERLINE", "textDecorationStyle": "WAVY", "textDecorationSkipInk": true, + "textDecorationThickness": {"value": 2, "unit": "PIXELS"}, "textDecorationOffset": {"unit": "AUTO"}, + "textDecorationColor": {"value": {"type": "SOLID", "color": {"r": 0, "g": 0, "b": 1, "a": 1}}}}, + "lineTypes": ["ORDERED", "ORDERED", "UNORDERED"], "lineIndentations": [0, 1, 0] + }]}]}}); + let (doc, _) = figcraft_format::rest::import(&file, &figcraft_format::rest::no_images).unwrap(); + let t = doc.nodes().find_map(|n| n.kind.text()).unwrap(); + let got: Vec<(ListStyle, u8)> = (0..3).map(|p| (t.para_list(p).list, t.para_list(p).indent)).collect(); + assert_eq!(got, [(N, 0), (N, 1), (B, 0)], "each paragraph's list type and level"); + assert_eq!((t.list_spacing, t.hanging_punctuation, t.hanging_list), (5.0, true, true)); + let look = t.style.look(); + assert_eq!( + (look.style, look.thickness, look.offset, look.skip_ink, look.color), + (DecorationStyle::Wavy, DecorationLength::Pixels(2.0), DecorationLength::Auto, true, Some(Color::new(0.0, 0.0, 1.0, 1.0))) + ); +} diff --git a/crates/engine/src/textedit.rs b/crates/engine/src/textedit.rs index beeb04e..6d1cf15 100644 --- a/crates/engine/src/textedit.rs +++ b/crates/engine/src/textedit.rs @@ -1,6 +1,6 @@ //! On-canvas text editing: caret, selection, typing. A whole editing session is one undo step. -use figcraft_doc::{Document, NodeId, NodeKind, TextData}; +use figcraft_doc::{Document, ListStyle, NodeId, NodeKind, TextData}; use crate::{EngineError, Preedit, Result, Session, TextEdit}; @@ -93,12 +93,58 @@ pub fn insert(s: &mut Session, text: &str) -> Result<()> { } edit(s, |t, te| { let (a, b) = range(te); + // "- ", "* " or "1. " typed at the start of a plain paragraph starts a list. + if text == " " && a == b && t.para_list(t.para_of(a)).list == ListStyle::None { + let chars: Vec = t.characters.chars().collect(); + let start = line_start(&chars, a); + let typed: String = chars.get(start..a).unwrap_or(&[]).iter().collect(); + let list = match typed.as_str() { + "-" | "*" => ListStyle::Bulleted, + "1." => ListStyle::Numbered, + _ => ListStyle::None, + }; + if list != ListStyle::None { + t.replace_range(start, a, ""); + t.set_para_lists(start, start, |pl| pl.list = list); + (te.caret, te.anchor) = (start, start); + return; + } + } t.replace_range(a, b, &text); te.caret = a + text.chars().count(); te.anchor = te.caret; }) } +/// List keys: Tab nests the selected list items a level deeper; Shift+Tab, Backspace at an item's +/// start and Enter on an empty item move it a level out (at the top: out of the list). Returns +/// whether the key was taken. +fn list_key(s: &mut Session, key: &str, shift: bool) -> Result { + let Some(te) = s.ds()?.text_edit.clone() else { return Ok(false) }; + let t = text_of(s, te.id)?; + let (a, b) = range(&te); + let chars: Vec = t.characters.chars().collect(); + let at_start = a == b && line_start(&chars, a) == a; + let item = t.para_list(t.para_of(a)).list != ListStyle::None; + let (pa, pb) = t.para_span(a, b); + let any_item = (pa..=pb).any(|p| t.para_list(p).list != ListStyle::None); + let deeper = match key { + "Tab" if any_item => !shift, + "Backspace" if item && at_start => false, + "Enter" if item && at_start && chars.get(a).is_none_or(|c| *c == '\n') => false, + _ => return Ok(false), + }; + edit(s, |t, _| { + t.set_para_lists(a, b, |pl| match pl.list { + ListStyle::None => {} + _ if deeper => pl.indent = pl.indent.saturating_add(1), + _ if pl.indent > 0 => pl.indent -= 1, + _ => pl.list = ListStyle::None, + }) + })?; + Ok(true) +} + /// The kind of word char `i` belongs to: 0 for spaces and punctuation, else its script, so a word /// ends where the script changes (Han, hiragana, katakana and Hangul runs are words of their own in /// text without spaces). Combining marks and voicing marks belong to the char before them. @@ -166,6 +212,9 @@ pub fn key(s: &mut Session, key: &str, shift: bool, word: bool, line: bool) -> R let t = text_of(s, te.id)?; let chars: Vec = t.characters.chars().collect(); let n = chars.len(); + if matches!(key, "Tab" | "Backspace" | "Enter") && !word && !line && list_key(s, key, shift)? { + return Ok(()); + } match key { "Escape" => return end(s), "Enter" => return insert(s, "\n"), diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index 78afb92..39311f2 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -899,7 +899,15 @@ fn override_group(field: &str) -> Option<&'static str> { | "textAlignVertical" | "textAutoResize" | "paragraphSpacing" - | "paragraphIndent" => "textStyle", + | "paragraphIndent" + | "listSpacing" + | "hangingPunctuation" + | "hangingList" + | "textDecorationStyle" + | "textDecorationThickness" + | "textDecorationOffset" + | "textDecorationSkipInk" + | "textDecorationColor" => "textStyle", "width" | "height" | "size" => "size", "paddingLeft" | "paddingRight" | "paddingTop" | "paddingBottom" | "horizontalPadding" | "verticalPadding" => "padding", "itemSpacing" | "counterAxisSpacing" => "gap", diff --git a/crates/format/src/rest/text.rs b/crates/format/src/rest/text.rs index a4ee8a6..5fbb368 100644 --- a/crates/format/src/rest/text.rs +++ b/crates/format/src/rest/text.rs @@ -4,7 +4,8 @@ use std::collections::BTreeMap; use figcraft_doc::{ - LetterSpacing, LineHeight, ListStyle, StyleRun, TextAlignH, TextAlignV, TextAutoResize, TextCase, TextData, TextDecoration, TextStyle, + DecorationLength, DecorationStyle, LetterSpacing, LineHeight, ListStyle, ParaList, StyleRun, TextAlignH, TextAlignV, TextAutoResize, TextCase, + TextData, TextDecoration, TextStyle, }; use serde_json::Value; @@ -27,7 +28,7 @@ pub(super) fn text_data(cx: &mut Ctx, v: &Value) -> TextData { paragraph(style_json, &mut td); td.runs = runs(cx, v, &td); td.normalize(); - td.list = list_style(v); + lists(v, &mut td); td } @@ -57,6 +58,9 @@ fn paragraph(s: &Value, td: &mut TextData) { td.max_lines = num(s, "maxLines").filter(|n| *n >= 1.0).map_or(0, |n| n.min(10_000.0) as u32); td.paragraph_spacing = len(s, "paragraphSpacing").unwrap_or(0.0); td.paragraph_indent = len(s, "paragraphIndent").unwrap_or(0.0); + td.list_spacing = len(s, "listSpacing").unwrap_or(0.0).min(10_000.0); + td.hanging_punctuation = flag(s, "hangingPunctuation").unwrap_or(false); + td.hanging_list = flag(s, "hangingList").unwrap_or(false); if text(s, "leadingTrim") == Some("CAP_HEIGHT") { td.leading_trim = figcraft_doc::LeadingTrim::CapHeight; } @@ -116,6 +120,7 @@ fn type_style(cx: &mut Ctx, s: &Value, base: &TextStyle) -> TextStyle { Some("STRIKETHROUGH") => t.decoration = TextDecoration::Strikethrough, _ => {} } + decoration_look(s, &mut t); if let Some(f) = paints(cx, s, "fills") { t.fills = Some(f); } @@ -200,14 +205,52 @@ fn runs(cx: &mut Ctx, v: &Value, td: &TextData) -> Vec { out } -/// A list style for the whole node from `lineTypes` (bullets win over numbers). -fn list_style(v: &Value) -> ListStyle { +/// Each paragraph's list type and nesting level from `lineTypes` and `lineIndentations`. +fn lists(v: &Value, td: &mut TextData) { let types = list(v, "lineTypes"); - if types.iter().any(|t| t.as_str() == Some("UNORDERED")) { - ListStyle::Bulleted - } else if types.iter().any(|t| t.as_str() == Some("ORDERED")) { - ListStyle::Numbered - } else { - ListStyle::None - } + let indents = list(v, "lineIndentations"); + td.paragraphs = types + .iter() + .enumerate() + .map(|(i, t)| ParaList { + list: match t.as_str() { + Some("UNORDERED") => ListStyle::Bulleted, + Some("ORDERED") => ListStyle::Numbered, + _ => ListStyle::None, + }, + indent: indents.get(i).and_then(Value::as_f64).filter(|d| d.is_finite()).map_or(0, |d| d.clamp(0.0, 255.0) as u8), + }) + .collect(); + // Clamp the levels and fold one style for all back into the layer's list style. + td.set_para_lists(0, td.char_count(), |_| {}); +} + +/// The decoration's style, thickness, offset, skip ink and colour (`{value, unit}` lengths with +/// `AUTO`, `PIXELS` or `PERCENT`; a colour as a solid paint, or `AUTO`). +fn decoration_look(s: &Value, t: &mut TextStyle) { + let length = |k: &str| { + let m = s.get(k)?; + let v = num(m, "value").filter(|x| x.abs() <= 10_000.0); + match text(m, "unit") { + Some("PIXELS") => v.map(DecorationLength::Pixels), + Some("PERCENT") => v.map(DecorationLength::Percent), + Some("AUTO") => Some(DecorationLength::Auto), + _ => None, + } + }; + let style = match text(s, "textDecorationStyle") { + Some("DOTTED") => Some(DecorationStyle::Dotted), + Some("WAVY") => Some(DecorationStyle::Wavy), + Some("SOLID") => Some(DecorationStyle::Solid), + _ => None, + }; + let (thickness, offset, skip) = (length("textDecorationThickness"), length("textDecorationOffset"), flag(s, "textDecorationSkipInk")); + let color = s.get("textDecorationColor").map(|c| c.get("value").and_then(|p| p.get("color")).and_then(super::props::color)); + t.set_look(|l| { + l.style = style.unwrap_or(l.style); + l.thickness = thickness.unwrap_or(l.thickness); + l.offset = offset.unwrap_or(l.offset); + l.skip_ink = skip.unwrap_or(l.skip_ink); + l.color = color.unwrap_or(l.color); + }); } diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index d9546bf..87a1430 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -6,7 +6,7 @@ use std::ops::Range; use std::sync::Arc; use figcraft_doc::{Node, NodeKind, StrokeAlign, StrokeProps}; -use figcraft_geom::{Affine, BezPath, Shape}; +use figcraft_geom::{Affine, BezPath}; use figcraft_text::fontdb::FontBytes; use figcraft_text::{Face, Glyph, TextLayout}; use krilla::geom::Point; @@ -53,7 +53,7 @@ pub(crate) fn draw_text(ex: &mut Exporter, s: &mut Surface, n: &Node, xf: Affine let fills = layout.styles.get(k).and_then(|st| st.fills.as_ref()).unwrap_or(&n.fills); let mut decorations = BezPath::new(); for d in layout.decorations.iter().filter(|d| d.style == k) { - decorations.extend(d.rect.to_path(0.1)); + decorations.extend(d.path()); } let mut outlines = BezPath::new(); for g in layout.glyphs.iter().filter(|g| g.style == k) { diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 56e4233..dbb2b01 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -489,9 +489,7 @@ fn draw_content(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize, by_style.entry(g.style).or_default().extend(g.path().elements().iter().copied()); } for d in &layout.decorations { - let mut r = d.rect.to_path(0.1); - r.apply_affine(Affine::IDENTITY); - by_style.entry(d.style).or_default().extend(r.elements().iter().copied()); + by_style.entry(d.style).or_default().extend(d.path().elements().iter().copied()); } let mut keys: Vec = by_style.keys().copied().collect(); keys.sort_unstable(); diff --git a/crates/render/src/lod.rs b/crates/render/src/lod.rs index b277b96..6d74258 100644 --- a/crates/render/src/lod.rs +++ b/crates/render/src/lod.rs @@ -215,6 +215,8 @@ fn single_line(t: &TextData) -> bool { t.auto_resize == TextAutoResize::WidthAndHeight && t.runs.is_empty() && t.list == ListStyle::None + && t.paragraphs.is_empty() + && !t.hanging_punctuation && t.paragraph_indent == 0.0 && !t.characters.contains(['\n', '\r', '\u{2028}', '\u{2029}']) } diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs index fad753b..32d37bf 100644 --- a/crates/svg/src/import/text.rs +++ b/crates/svg/src/import/text.rs @@ -269,7 +269,7 @@ fn on_path(cx: &mut Ctx, t: &usvg::Text, world: Affine, parent: NodeId) -> bool td.align_h = align(line.anchor); let mut local = path.clone(); local.apply_affine(Affine::translate((-bb.x0, -bb.y0))); - td.path = Some(figcraft_doc::TextPath { start: f64::from(tp.start_offset()) / len, ..figcraft_doc::TextPath::new(&local) }); + td.path = Some(Box::new(figcraft_doc::TextPath { start: f64::from(tp.start_offset()) / len, ..figcraft_doc::TextPath::new(&local) })); let transform = world * Affine::translate((bb.x0, bb.y0)); add_layer(cx, t, &[line], td, transform, (bb.width().max(1.0), bb.height().max(1.0)), world, 1.0, parent); true diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 3a31a88..cc1a2cc 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -3,7 +3,10 @@ use std::sync::Arc; -use figcraft_doc::{LeadingTrim, ListStyle, TextAlignH, TextAutoResize, TextCase, TextData, TextDecoration, TextStyle, valid_feature_tag}; +use figcraft_doc::{ + DecorationLength, DecorationStyle, LeadingTrim, ListStyle, ParaList, TextAlignH, TextAutoResize, TextCase, TextData, TextDecoration, TextStyle, + valid_feature_tag, +}; use figcraft_geom::{Affine, BezPath, Rect}; use harfrust::{Direction, Feature, ShapeOptions, ShaperInstance, Tag, UnicodeBuffer, Variation}; @@ -62,8 +65,102 @@ pub struct Line { /// A decoration line (underline/strikethrough) to paint with the style's fills. #[derive(Clone, Debug)] pub struct Decoration { + /// Everything the line paints (a wavy line's band includes its swing). pub rect: Rect, pub style: usize, + pub kind: DecorationStyle, + pub thickness: f64, +} + +/// Most dots or wave samples drawn for one decoration. +const MAX_DECO_STEPS: usize = 20_000; + +impl Decoration { + /// The line's outline: a bar, a row of dots or a wave, `thickness` thick. + pub fn path(&self) -> BezPath { + use figcraft_geom::kurbo::Shape; + let (r, th) = (self.rect, self.thickness.max(0.01)); + let cy = r.center().y; + match self.kind { + DecorationStyle::Solid => r.to_path(0.1), + DecorationStyle::Dotted => { + let mut p = BezPath::new(); + let mut x = r.x0 + th / 2.0; + for _ in 0..MAX_DECO_STEPS { + if x > r.x1 - th / 2.0 + 1e-9 && !p.elements().is_empty() { + break; + } + p.extend(figcraft_geom::kurbo::Circle::new((x, cy), th / 2.0).to_path(0.05)); + x += th * 2.0; + } + p + } + DecorationStyle::Wavy => { + let (amp, period) = wave(th); + let n = (((r.width() / period) * 8.0).ceil() as usize).clamp(2, MAX_DECO_STEPS); + let at = |i: usize, side: f64| { + let x = r.x0 + r.width() * i as f64 / n as f64; + figcraft_geom::Point::new(x, cy + amp * (std::f64::consts::TAU * (x - r.x0) / period).sin() + side * th / 2.0) + }; + let mut p = BezPath::new(); + p.move_to(at(0, -1.0)); + for i in 1..=n { + p.line_to(at(i, -1.0)); + } + for i in (0..=n).rev() { + p.line_to(at(i, 1.0)); + } + p.close_path(); + p + } + } + } +} + +/// A wavy line's swing either side of its centre and its wavelength, for a thickness. +fn wave(th: f64) -> (f64, f64) { + let amp = th * 0.75 + 0.5; + (amp, amp * 6.0) +} + +/// A decoration length in px (`auto`: what the font says). +fn deco_len(m: DecorationLength, size: f64, auto: f64) -> f64 { + let v = match m { + DecorationLength::Auto => auto, + DecorationLength::Pixels(v) => v, + DecorationLength::Percent(p) => size * p / 100.0, + }; + if v.is_finite() { v } else { auto } +} + +/// Opening punctuation, which hangs outside a line's start. +fn opens(c: char) -> bool { + matches!(c, '"' | '\'' | '\u{201C}' | '\u{2018}' | '\u{201E}' | '\u{201A}' | '\u{00AB}' | '\u{2039}' | '(' | '[' | '{' | '\u{00BF}' | '\u{00A1}') +} + +/// Closing punctuation and stops, which hang outside a line's end. +fn closes(c: char) -> bool { + matches!( + c, + '.' | ',' + | ':' + | ';' + | '!' + | '?' + | '"' + | '\'' + | '\u{201D}' + | '\u{2019}' + | '\u{00BB}' + | '\u{203A}' + | ')' + | ']' + | '}' + | '-' + | '\u{2013}' + | '\u{2014}' + | '\u{2026}' + ) } #[derive(Clone, Debug, Default)] @@ -258,48 +355,72 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) } } - // List markers sit in a gutter and wrapped lines hang at the text start; paragraph indent + // List markers sit in a gutter (one more per nesting level) and wrapped lines hang at the + // text start; a hanging list puts the outermost gutter outside the box. Paragraph indent // applies to plain paragraphs only. Right-to-left paragraphs mirror both: the gutter and the // indent are on the right, the text ends where they start. - let marker_width = list_gutter(text.list, &chars, styles.first(), faces.first().cloned().flatten().as_ref()); + let lists: Vec = (0..text.paragraph_count()).map(|p| text.para_list(p)).collect(); + let markers = list_markers(&lists); + let step = list_step(&lists, &markers, styles.first(), faces.first().cloned().flatten().as_ref()); + let hang_list = if text.hanging_list { step } else { 0.0 }; // Paragraph indent applies to start-aligned text only (left, or right for a right-to-left paragraph). - let indent_px = if text.list == ListStyle::None && text.paragraph_indent.is_finite() { text.paragraph_indent.clamp(0.0, 10_000.0) } else { 0.0 }; + let indent_px = if text.paragraph_indent.is_finite() { text.paragraph_indent.clamp(0.0, 10_000.0) } else { 0.0 }; let indent_at = |char_idx: usize| { let start_aligned = if rtl_para_at(char_idx) { text.align_h == TextAlignH::Right } else { text.align_h == TextAlignH::Left }; if start_aligned { indent_px } else { 0.0 } }; + let list_of = |p: usize| lists.get(p).copied().unwrap_or_default(); + // The start indent of a line of paragraph `p` (`first`: its first line, starting at char `ci`). + let line_indent_of = |p: usize, first: bool, ci: usize| { + let pl = list_of(p); + match pl.list { + ListStyle::None if first => indent_at(ci), + ListStyle::None => 0.0, + _ => step * (f64::from(pl.indent) + 1.0) - hang_list, + } + }; + // Hanging punctuation: at the start edge of left-aligned and justified lines, at the end edge + // of right-aligned and justified ones (left-to-right paragraphs). + let hang_start = text.hanging_punctuation && matches!(text.align_h, TextAlignH::Left | TextAlignH::Justified); + let hang_end = text.hanging_punctuation && matches!(text.align_h, TextAlignH::Right | TextAlignH::Justified); // Line breaking. let wrap = match text.auto_resize { TextAutoResize::WidthAndHeight => None, _ => box_width.filter(|w| w.is_finite() && *w > 0.0), }; - let mut lines: Vec<(usize, usize, bool)> = Vec::new(); // item range, paragraph end + let mut lines: Vec<(usize, usize, bool, usize)> = Vec::new(); // item range, paragraph end, paragraph let mut line_start = 0; let mut i = 0; let mut x = 0.0; let mut last_break: Option = None; let mut para_first_line = true; + let mut para = 0; while i < items.len() { let Some(it) = items.get(i) else { break }; if it.ch == '\n' { - lines.push((line_start, i + 1, true)); + lines.push((line_start, i + 1, true, para)); i += 1; line_start = i; x = 0.0; last_break = None; para_first_line = true; + para += 1; continue; } - let line_indent = marker_width + if para_first_line { indent_at(it.char_start) } else { 0.0 }; + let line_indent = line_indent_of(para, para_first_line, it.char_start); let w = it.adv; + // Hanging punctuation takes no room inside the box. + let ltr = !rtl_para_at(it.char_start); + let hung = items.get(line_start).filter(|f| hang_start && ltr && opens(f.ch)).map_or(0.0, |f| f.adv) + + if hang_end && ltr && closes(it.ch) { w } else { 0.0 }; if let Some(maxw) = wrap - && x + w > maxw - line_indent + 1e-6 + && x + w > maxw - line_indent + hung + 1e-6 && i > line_start && !it.is_space() { let brk = last_break.filter(|b| *b >= line_start).map(|b| b + 1).unwrap_or(i); - lines.push((line_start, brk, false)); + lines.push((line_start, brk, false, para)); line_start = brk; i = brk; x = 0.0; @@ -314,7 +435,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) i += 1; } if line_start < items.len() || lines.is_empty() || lines.last().is_some_and(|l| l.2) { - lines.push((line_start, items.len(), true)); + lines.push((line_start, items.len(), true, para)); } // Line metrics and positions. @@ -338,7 +459,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) } }; let mut first_cap = 0.0; - for (li, (s, e, para_end)) in lines.iter().enumerate() { + for (li, (s, e, para_end, para)) in lines.iter().enumerate() { if li >= max_lines { out.truncated = true; break; @@ -360,7 +481,10 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) first_cap = cap; } if para_start_line && li > 0 { - y += text.paragraph_spacing.max(0.0); + // Between two items of a list: the list spacing. + let in_list = |p: usize| list_of(p).list != ListStyle::None; + let gap = if in_list(*para) && para.checked_sub(1).is_some_and(in_list) { text.list_spacing } else { text.paragraph_spacing }; + y += if gap.is_finite() { gap.clamp(0.0, 10_000.0) } else { 0.0 }; } let baseline = y + (lh - (asc + desc)) / 2.0 + asc; // Trailing spaces don't count for width/alignment. @@ -371,12 +495,16 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) let width: f64 = items.get(*s..vis_end).unwrap_or(&[]).iter().map(|it| it.adv).sum(); let first_char = items.get(*s).map(|it| it.char_start).unwrap_or(0); let rtl_line = rtl_para_at(first_char); - let line_indent = marker_width + if para_start_line { indent_at(first_char) } else { 0.0 }; + let line_indent = line_indent_of(*para, para_start_line, first_char); + // Hanging punctuation at the line's edges (the visible text's first and last chars). + let hang_l = items.get(*s).filter(|it| hang_start && !rtl_line && vis_end > *s && opens(it.ch)).map_or(0.0, |it| it.adv); + let hang_r = + items.get(vis_end.wrapping_sub(1)).filter(|it| hang_end && !rtl_line && vis_end > *s + 1 && closes(it.ch)).map_or(0.0, |it| it.adv); let gstart = out.glyphs.len(); // Justify: extra space per gap. let gaps = items.get(*s..vis_end).unwrap_or(&[]).iter().filter(|it| it.is_space()).count(); let justify_extra = match (text.align_h, wrap) { - (TextAlignH::Justified, Some(maxw)) if !para_end && gaps > 0 => ((maxw - line_indent - width) / gaps as f64).max(0.0), + (TextAlignH::Justified, Some(maxw)) if !para_end && gaps > 0 => ((maxw - line_indent + hang_l + hang_r - width) / gaps as f64).max(0.0), _ => 0.0, }; // Where the visible text ends once justified. @@ -390,39 +518,45 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) .filter(|it| it.ch != '\n') .map(|it| it.adv + if it.is_space() { justify_extra } else { 0.0 }) .sum(); - let mut pen = if rtl_line { -trailing } else { line_indent }; + let mut pen = if rtl_line { -trailing } else { line_indent - hang_l }; // List marker (before the line's text; zero-length char range so carets skip it). - if para_start_line && text.list != ListStyle::None { - let marker = match text.list { - ListStyle::Bulleted => "•".to_string(), - _ => format!("{}.", count_paragraphs_before(&chars, items.get(*s).map(|it| it.char_start).unwrap_or(0)) + 1), - }; + let pl = list_of(*para); + if para_start_line && pl.list != ListStyle::None { + let marker = markers.get(*para).cloned().unwrap_or_default(); let st_idx = items.get(*s).map(|it| it.style).unwrap_or(0); let at = items.get(*s).map(|it| it.char_start).unwrap_or(chars.len()); // The marker takes the paragraph style's own font (not a fallback the line's first - // char needed, e.g. for Hebrew), so every bullet and number of a list matches. - let own = faces.get(st_idx).cloned().flatten().filter(|f| marker.chars().all(|c| f.has_char(c))); + // char needed, e.g. for Hebrew), so every bullet and number of a list matches; a + // nested bullet the font lacks comes from a fallback font. + let has = |f: &Arc| marker.chars().all(|c| f.has_char(c)); + let own = faces.get(st_idx).cloned().flatten().filter(has); let marker_face = match (own, styles.get(st_idx)) { (Some(f), Some(st)) => { let coords = f.coords(&f.axis_values(st)); Some((f, coords)) } - _ => items.get(*s).map(|it| (it.face.clone(), it.coords.clone())).or_else(|| default_face.clone().map(|f| (f, no_coords()))), + _ => fallbacks + .iter() + .find(|f| has(f)) + .map(|f| (f.clone(), no_coords())) + .or_else(|| items.get(*s).map(|it| (it.face.clone(), it.coords.clone()))) + .or_else(|| default_face.clone().map(|f| (f, no_coords()))), }; if let (Some(st), Some((face, coords))) = (styles.get(st_idx), marker_face) { let size = sanitize_size(st.font_size); let k = size / face.upem; - let numbered = text.list == ListStyle::Numbered; + let numbered = pl.list == ListStyle::Numbered; // Right to left, the number reads "1." with its full stop on the left (".1"). let marker = if rtl_line && numbered { format!(".{}", marker.trim_end_matches('.')) } else { marker }; let advance = marker_advance(&face, &coords, &marker, size); // Numbers end (start, right to left) half an em from the text; bullets sit at the // gutter's outer edge. + let gutter = step * f64::from(pl.indent) - hang_list; let mut mx = match (rtl_line, numbered) { - (false, true) => (marker_width - size * 0.5 - advance).max(0.0), - (false, false) => 0.0, + (false, true) => gutter + (step - size * 0.5 - advance).max(0.0), + (false, false) => gutter, (true, true) => text_end + size * 0.5, - (true, false) => text_end + (marker_width - advance).max(0.0), + (true, false) => text_end + (step - advance).max(0.0), }; for c in marker.chars() { let gid = face.glyph_for(c); @@ -491,7 +625,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) top: y, height: lh, baseline, - width: width + line_indent, + width: width + line_indent - hang_l - hang_r, x: 0.0, para_end: *para_end, rtl: rtl_line, @@ -556,8 +690,10 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) } } - // Decorations per contiguous glyph run of the same style on a line. + // Decorations per contiguous glyph run of the same style on a line; a decoration colour + // paints with a style of its own, added after the glyphs' styles. let mut decos = Vec::new(); + let mut deco_styles: Vec = Vec::new(); for line in &out.lines { let gl = out.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]); let mut j = 0; @@ -581,38 +717,139 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) // The style's own font and size (not a fallback face or a small-cap scale), so the // line runs at one height and weight through mixed scripts. let f = faces.get(g0.style).and_then(Option::as_ref).unwrap_or(&g0.face); - let s = sanitize_size(st.font_size) / f.upem; - let th = (f.underline_thickness * s).max(0.5); - let yy = match st.decoration { - TextDecoration::Underline => line.baseline - f.underline_pos * s, - _ => line.baseline - f.strike_pos * s, + let size = sanitize_size(st.font_size); + let s = size / f.upem; + let look = st.look(); + let th = deco_len(look.thickness, size, (f.underline_thickness * s).max(0.5)).clamp(0.1, size); + let underline = st.decoration == TextDecoration::Underline; + // The line's top edge (an underline's own offset is measured from the baseline). + let top = match look.offset { + DecorationLength::Auto => line.baseline - if underline { f.underline_pos } else { f.strike_pos } * s - th / 2.0, + m if underline => line.baseline + deco_len(m, size, 0.0).clamp(-size, size), + _ => line.baseline - f.strike_pos * s - th / 2.0, }; - decos.push(Decoration { rect: Rect::new(x0, yy - th / 2.0, x1, yy + th / 2.0), style: g0.style }); + let style = match look.color { + Some(c) => { + let own = TextStyle { fills: Some(vec![figcraft_doc::color::Paint::solid(c)]), ..st.clone() }; + let k = deco_styles.iter().position(|d| *d == own).unwrap_or_else(|| { + deco_styles.push(own); + deco_styles.len() - 1 + }); + out.styles.len() + k + } + None => g0.style, + }; + let swing = if look.style == DecorationStyle::Wavy { wave(th).0 } else { 0.0 }; + // Skip ink: the underline stops a little before each glyph crossing it. + let gap = th.max(1.0); + let cuts = if underline && look.skip_ink { + ink_cuts(gl.get(j..k).unwrap_or(&[]), top - gap / 2.0 - swing, top + th + gap / 2.0 + swing, gap) + } else { + Vec::new() + }; + let mut from = x0; + for (c0, c1) in cuts.into_iter().chain([(x1, x1)]) { + if c0.min(x1) > from + 0.5 { + let rect = Rect::new(from, top - swing, c0.min(x1), top + th + swing); + decos.push(Decoration { rect, style, kind: look.style, thickness: th }); + } + from = from.max(c1); + } } j = k; } } out.decorations = decos; + out.styles.extend(deco_styles); if let Some(tp) = &text.path { crate::onpath::place(&mut out, tp, text.align_h); } out } -/// Width of the list marker gutter: room for the widest marker plus a gap. -fn list_gutter(list: ListStyle, chars: &[char], style: Option<&TextStyle>, face: Option<&Arc>) -> f64 { - let size = style.map(|s| sanitize_size(s.font_size)).unwrap_or(12.0); - match list { - ListStyle::None => 0.0, - ListStyle::Bulleted => size * 1.5, - ListStyle::Numbered => { - let paras = chars.iter().filter(|c| **c == '\n').count() + 1; - let widest = face - .map(|f| marker_advance(f, &no_coords(), &format!("{paras}."), size)) - .unwrap_or(size * 0.6 * (paras.to_string().len() + 1) as f64); - (widest + size * 0.5).max(size * 1.5) +/// The markers of a text's paragraphs ("" for plain ones). Numbers count the items of one list at +/// one level, restarting after a plain paragraph, a shallower item or the other kind of list; +/// levels go 1. a. i. and • ◦ ▪, then round again. +pub(crate) fn list_markers(lists: &[ParaList]) -> Vec { + const BULLETS: [&str; 3] = ["\u{2022}", "\u{25E6}", "\u{25AA}"]; + let mut count = [(ListStyle::None, 0usize); figcraft_doc::MAX_LIST_INDENT as usize + 1]; + lists + .iter() + .map(|pl| { + if pl.list == ListStyle::None { + count.fill((ListStyle::None, 0)); + return String::new(); + } + let d = usize::from(pl.indent).min(count.len() - 1); + for c in count.iter_mut().skip(d + 1) { + *c = (ListStyle::None, 0); + } + let Some(c) = count.get_mut(d) else { return String::new() }; + *c = if c.0 == pl.list { (pl.list, c.1 + 1) } else { (pl.list, 1) }; + match (pl.list, d % 3) { + (ListStyle::Numbered, 1) => format!("{}.", alpha(c.1)), + (ListStyle::Numbered, 2) => format!("{}.", roman(c.1)), + (ListStyle::Numbered, _) => format!("{}.", c.1), + _ => BULLETS.get(d % 3).copied().unwrap_or("\u{2022}").to_string(), + } + }) + .collect() +} + +/// a, b, ... z, aa, ab, ... +fn alpha(mut n: usize) -> String { + let mut out = Vec::new(); + while n > 0 { + n -= 1; + out.push(char::from(b'a' + (n % 26) as u8)); + n /= 26; + } + out.iter().rev().collect() +} + +/// Lower-case roman numerals (past 3999: the number). +fn roman(mut n: usize) -> String { + if n == 0 || n > 3999 { + return n.to_string(); + } + let mut out = String::new(); + for (v, r) in [ + (1000, "m"), + (900, "cm"), + (500, "d"), + (400, "cd"), + (100, "c"), + (90, "xc"), + (50, "l"), + (40, "xl"), + (10, "x"), + (9, "ix"), + (5, "v"), + (4, "iv"), + (1, "i"), + ] { + while n >= v { + out.push_str(r); + n -= v; } } + out +} + +/// The width of one list level's gutter: room for the widest number plus half an em, at least one +/// and a half ems (0 without lists). +fn list_step(lists: &[ParaList], markers: &[String], style: Option<&TextStyle>, face: Option<&Arc>) -> f64 { + if lists.iter().all(|l| l.list == ListStyle::None) { + return 0.0; + } + let size = style.map(|s| sanitize_size(s.font_size)).unwrap_or(12.0); + let widest = lists + .iter() + .zip(markers) + .filter(|(l, _)| l.list == ListStyle::Numbered) + .map(|(_, m)| face.map(|f| marker_advance(f, &no_coords(), m, size)).unwrap_or(size * 0.6 * m.chars().count() as f64)) + .fold(0.0, f64::max); + (widest + size * 0.5).max(size * 1.5) } fn marker_advance(face: &Face, coords: &Coords, s: &str, size: f64) -> f64 { @@ -620,8 +857,45 @@ fn marker_advance(face: &Face, coords: &Coords, s: &str, size: f64) -> f64 { s.chars().map(|c| face.advance_at(face.glyph_for(c), coords) * k).sum() } -fn count_paragraphs_before(chars: &[char], pos: usize) -> usize { - chars.iter().take(pos).filter(|c| **c == '\n').count() +/// Where glyphs' outlines cross the band `y0..y1`: one x range per glyph, widened by `gap`. +fn ink_cuts(glyphs: &[Glyph], y0: f64, y1: f64, gap: f64) -> Vec<(f64, f64)> { + let mut cuts: Vec<(f64, f64)> = Vec::new(); + for g in glyphs { + let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY); + let mut prev: Option = None; + figcraft_geom::kurbo::flatten(g.path().iter(), 0.25, |el| { + use figcraft_geom::kurbo::PathEl; + let p = match el { + PathEl::MoveTo(p) => { + prev = Some(p); + return; + } + PathEl::LineTo(p) => p, + _ => return, + }; + if let Some(a) = prev { + // The part of segment a-p inside the band. + let (t0, t1) = if (p.y - a.y).abs() < 1e-12 { + if (y0..=y1).contains(&a.y) { (0.0, 1.0) } else { (1.0, 0.0) } + } else { + let (ta, tb) = ((y0 - a.y) / (p.y - a.y), (y1 - a.y) / (p.y - a.y)); + (ta.min(tb).max(0.0), ta.max(tb).min(1.0)) + }; + if t0 <= t1 { + for t in [t0, t1] { + let x = a.x + (p.x - a.x) * t; + (lo, hi) = (lo.min(x), hi.max(x)); + } + } + } + prev = Some(p); + }); + if lo <= hi { + cuts.push((lo - gap, hi + gap)); + } + } + cuts.sort_by(|a, b| a.0.total_cmp(&b.0)); + cuts } fn add_ellipsis(out: &mut TextLayout, wrap: Option) { @@ -953,29 +1227,8 @@ impl TextLayout { p.extend(g.path()); } for d in &self.decorations { - p.extend(figcraft_geom::rounded_rect(d.rect.width(), d.rect.height(), [0.0; 4], 0.0).iter().map(|el| { - let mut e = el; - e.apply_affine(Affine::translate((d.rect.x0, d.rect.y0))); - e - })); + p.extend(d.path()); } p } } - -trait ApplyAffine { - fn apply_affine(&mut self, a: Affine); -} - -impl ApplyAffine for figcraft_geom::kurbo::PathEl { - fn apply_affine(&mut self, a: Affine) { - use figcraft_geom::kurbo::PathEl::*; - *self = match *self { - MoveTo(p) => MoveTo(a * p), - LineTo(p) => LineTo(a * p), - QuadTo(p1, p2) => QuadTo(a * p1, a * p2), - CurveTo(p1, p2, p3) => CurveTo(a * p1, a * p2, a * p3), - ClosePath => ClosePath, - }; - } -} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 9654615..b233f06 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -154,4 +154,6 @@ mod onpath_tests; #[cfg(test)] mod rtl_list_tests; #[cfg(test)] +mod tail_tests; +#[cfg(test)] mod tests; diff --git a/crates/text/src/onpath_tests.rs b/crates/text/src/onpath_tests.rs index 35a4380..f3bb999 100644 --- a/crates/text/src/onpath_tests.rs +++ b/crates/text/src/onpath_tests.rs @@ -57,7 +57,7 @@ fn laid_out_glyphs_sit_on_the_path() { path.line_to((0.0, 1000.0)); let mut t = TextData::new("Hello"); t.style.font_size = 20.0; - t.path = Some(TextPath::new(&path)); + t.path = Some(Box::new(TextPath::new(&path))); let l = crate::layout(&t, None, None); // Down the vertical line: every glyph is turned 90° and lies along x = 0. let ink = l.ink_bounds().unwrap(); diff --git a/crates/text/src/tail_tests.rs b/crates/text/src/tail_tests.rs new file mode 100644 index 0000000..9f771b8 --- /dev/null +++ b/crates/text/src/tail_tests.rs @@ -0,0 +1,261 @@ +//! Text behaviour suite, the tail: lists (numbering and nesting, list spacing, hanging lists), +//! hanging punctuation and decoration styles, written from the public documentation of the target +//! app's text settings. Each case gives the rule and a one-line derivation; where the +//! documentation is silent the case says so and pins today's behaviour. +//! +//! Numbering and decoration shapes are font-free; layout geometry reads the default face and skips +//! on a machine without fonts. + +use figcraft_doc::{ + DecorationLength, DecorationStyle, ListStyle, ParaList, TextAlignH, TextAutoResize, TextData, TextDecoration, color::Color, color::Paint, +}; +use figcraft_geom::{Rect, Shape}; + +use crate::layout::list_markers; +use crate::{Decoration, TextLayout, db, layout, scan_system_fonts}; + +const EPS: f64 = 1e-6; + +/// A 20 px text in the default face, or None (skip) without fonts. +fn text(s: &str) -> Option { + scan_system_fonts(); + let f = db().write().ok()?.default_face()?; + let mut t = TextData::new(s); + (t.style.font_family, t.style.font_style) = (f.family.clone(), f.style.clone()); + t.style.font_size = 20.0; + Some(t) +} + +fn laid(t: &TextData) -> Option { + Some(layout(t, Some(400.0), None)).filter(|l| !l.glyphs.is_empty()) +} + +/// The x of each line's first text glyph and of its marker (glyphs covering no char). +fn starts(l: &TextLayout) -> Vec<(f64, Option)> { + l.lines + .iter() + .map(|ln| { + let gl = l.glyphs.get(ln.glyph_start..ln.glyph_end).unwrap_or(&[]); + let text = gl.iter().find(|g| g.char_end > g.char_start).map_or(f64::NAN, |g| g.x); + (text, gl.iter().find(|g| g.char_end == g.char_start).map(|g| g.x)) + }) + .collect() +} + +fn pl(list: ListStyle, indent: u8) -> ParaList { + ParaList { list, indent } +} + +#[test] +fn list_numbering_by_level() { + use ListStyle::{Bulleted as B, None as P, Numbered as N}; + let many: Vec = std::iter::once(pl(N, 0)).chain(std::iter::repeat_n(pl(N, 1), 27)).collect(); + // (paragraphs, markers, derivation) + let cases: Vec<(Vec, Vec<&str>, &str)> = vec![ + (vec![pl(N, 0), pl(N, 0), pl(N, 0)], vec!["1.", "2.", "3."], "a numbered list counts its items"), + ( + vec![pl(N, 0), pl(N, 1), pl(N, 1), pl(N, 0)], + vec!["1.", "a.", "b.", "2."], + "nested items count on their own level; the outer count goes on", + ), + ( + vec![pl(N, 0), pl(N, 1), pl(N, 2), pl(N, 3)], + vec!["1.", "a.", "i.", "1."], + "each level numbers its own way (silent on which: numbers, letters, roman numerals, round again)", + ), + ( + vec![pl(B, 0), pl(B, 1), pl(B, 2), pl(B, 3)], + vec!["\u{2022}", "\u{25E6}", "\u{25AA}", "\u{2022}"], + "silent: bullets change shape per level, round again", + ), + (vec![pl(N, 0), pl(N, 0), pl(P, 0), pl(N, 0)], vec!["1.", "2.", "", "1."], "silent: a plain paragraph ends a list, the next starts at 1"), + (vec![pl(N, 0), pl(B, 0), pl(N, 0)], vec!["1.", "\u{2022}", "1."], "silent: the other kind of list at the same level starts the count again"), + (vec![pl(N, 0), pl(N, 1), pl(N, 0), pl(N, 1)], vec!["1.", "a.", "2.", "a."], "a shallower item starts the deeper count again"), + (vec![pl(N, 2), pl(N, 2), pl(N, 2), pl(N, 2)], vec!["i.", "ii.", "iii.", "iv."], "roman numerals at the third level"), + ]; + for (lists, want, why) in cases { + assert_eq!(list_markers(&lists), want, "{why}"); + } + assert_eq!(list_markers(&many).last().map(String::as_str), Some("aa."), "silent: letters go on past z as aa"); +} + +#[test] +fn nested_items_indent_by_one_gutter() { + // Tab nests an item: each level moves the item and its marker in by one marker gutter (bullets: + // one and a half ems, 30 px at 20 px); a hanging list puts the outer gutter outside the box so + // item text lines up with plain paragraphs. + let Some(mut t) = text("One\nTwo\nThree") else { return }; + t.paragraphs = vec![pl(ListStyle::Bulleted, 0), pl(ListStyle::Bulleted, 1), pl(ListStyle::Bulleted, 2)]; + let Some(l) = laid(&t) else { return }; + // (hanging, text x per level, marker x per level, derivation) + for (hanging, text_x, marker_x, why) in [ + (false, [30.0, 60.0, 90.0], [0.0, 30.0, 60.0], "inside the box: level n's text at (n + 1) gutters"), + (true, [0.0, 30.0, 60.0], [-30.0, 0.0, 30.0], "hanging list: one gutter less, the outer bullet left of the box"), + ] { + t.hanging_list = hanging; + let l = if hanging { laid(&t).unwrap() } else { l.clone() }; + for (i, (x, m)) in starts(&l).into_iter().enumerate() { + assert!((x - text_x[i]).abs() < EPS, "{why}: line {i} text at {x}"); + assert!(m.is_some_and(|m| (m - marker_x[i]).abs() < EPS), "{why}: line {i} marker at {m:?}"); + } + assert_eq!(l.ink_bounds().is_some_and(|b| b.x0 <= -30.0), hanging, "{why}: exports reach the hanging bullet"); + } + // A plain paragraph next to a hanging list starts where the items' text does. + t.paragraphs.push(pl(ListStyle::None, 0)); + t.characters.push_str("\nPlain"); + let l = laid(&t).unwrap(); + assert!(starts(&l)[3].0.abs() < EPS && starts(&l)[0].0.abs() < EPS, "hanging list: items line up with plain text"); +} + +#[test] +fn list_spacing_between_items_only() { + // List spacing is the space between items of a list; paragraphs around it keep the paragraph + // spacing (silent on both set at once: between two items the list spacing replaces it). + let Some(mut t) = text("Intro\nA\nB\nOutro") else { return }; + t.style.line_height = figcraft_doc::LineHeight::Pixels(30.0); + t.paragraphs = vec![pl(ListStyle::None, 0), pl(ListStyle::Bulleted, 0), pl(ListStyle::Bulleted, 0), pl(ListStyle::None, 0)]; + (t.paragraph_spacing, t.list_spacing) = (10.0, 4.0); + let Some(l) = laid(&t) else { return }; + let tops: Vec = l.lines.iter().map(|ln| ln.top).collect(); + assert_eq!(tops, [0.0, 40.0, 74.0, 114.0], "plain→item +10, item→item +4, item→plain +10"); + assert!((l.height - 144.0).abs() < EPS); +} + +#[test] +fn hanging_punctuation_outside_the_aligned_edge() { + // Hanging punctuation: opening quotes and brackets at a line's start sit left of a left-aligned + // (or justified) box; closing punctuation at its end right of a right-aligned (or justified) + // one, so the letters line up. Silent on centred text and right-to-left paragraphs: no hang. + let Some(mut t) = text("\u{201C}Quote\u{201D} here.") else { return }; + t.auto_resize = TextAutoResize::Height; + let Some(plain) = laid(&t) else { return }; + let open = plain.glyphs[0].advance; + t.hanging_punctuation = true; + let l = laid(&t).unwrap(); + assert!((l.glyphs[0].x + open).abs() < EPS && l.glyphs[1].x.abs() < EPS, "left: the quote hangs, Q starts the line"); + t.align_h = TextAlignH::Right; + let l = laid(&t).unwrap(); + let last = l.glyphs.last().unwrap(); + let before = &l.glyphs[l.glyphs.len() - 2]; + assert!((before.x + before.advance - 400.0).abs() < EPS && last.x >= 400.0 - EPS, "right: the full stop hangs past the edge"); + t.align_h = TextAlignH::Center; + let (a, b) = (laid(&t).unwrap(), { + t.hanging_punctuation = false; + laid(&t).unwrap() + }); + assert!((a.glyphs[0].x - b.glyphs[0].x).abs() < EPS, "centred: nothing hangs"); + // Hanging punctuation takes no room in the box: a line that fits only without its opening + // quote stays on one line. + let mut t = text("\u{201C}ab cd").unwrap(); + t.auto_resize = TextAutoResize::Height; + let w = layout(&t, None, None).width; + let room = w - plain.glyphs[0].advance + 0.01; + assert_eq!(layout(&t, Some(room), None).lines.len(), 2, "without hanging it wraps"); + t.hanging_punctuation = true; + assert_eq!(layout(&t, Some(room), None).lines.len(), 1, "with hanging it fits"); +} + +/// (decoration, look change, top edge, height, derivation) +type Case<'a> = (TextDecoration, &'a dyn Fn(&mut figcraft_doc::DecorationLook), Option, f64, &'a str); + +#[test] +fn decoration_style_thickness_offset_and_colour() { + let Some(mut t) = text("Hello") else { return }; + t.style.decoration = TextDecoration::Underline; + let Some(base) = laid(&t) else { return }; + let baseline = base.lines[0].baseline; + let red = Color::new(1.0, 0.0, 0.0, 1.0); + let set = |t: &mut TextData, d: TextDecoration, f: &dyn Fn(&mut figcraft_doc::DecorationLook)| { + t.style.decoration = d; + t.style.decoration_look = None; + t.style.set_look(f); + }; + let auto = base.decorations[0].rect; + let cases: [Case; 5] = [ + (TextDecoration::Underline, &|l| l.thickness = DecorationLength::Pixels(3.0), None, 3.0, "thickness in px"), + (TextDecoration::Underline, &|l| l.thickness = DecorationLength::Percent(10.0), None, 2.0, "thickness in % of the font size: 10% of 20"), + ( + TextDecoration::Underline, + &|l| l.offset = DecorationLength::Pixels(4.0), + Some(baseline + 4.0), + auto.height(), + "offset: baseline to the line's top", + ), + ( + TextDecoration::Underline, + &|l| l.offset = DecorationLength::Percent(-10.0), + Some(baseline - 2.0), + auto.height(), + "a negative offset lifts it above the baseline", + ), + ( + TextDecoration::Strikethrough, + &|l| l.offset = DecorationLength::Pixels(4.0), + None, + auto.height(), + "silent: strikethrough ignores the offset", + ), + ]; + for (d, f, top, h, why) in cases { + set(&mut t, d, f); + let r = laid(&t).unwrap().decorations[0].rect; + assert!((r.height() - h).abs() < EPS, "{why}: height {}", r.height()); + if let Some(y) = top { + assert!((r.y0 - y).abs() < EPS, "{why}: top {}", r.y0); + } + } + let mut strike = t.clone(); + strike.style.decoration_look = None; + let plain_strike = laid(&strike).unwrap().decorations[0].rect; + assert!((laid(&t).unwrap().decorations[0].rect.y0 - plain_strike.y0).abs() < EPS, "strikethrough stays at the font's position"); + // Wavy swings either side; dotted is a row of dots; both inside their rect. + for style in [DecorationStyle::Wavy, DecorationStyle::Dotted] { + set(&mut t, TextDecoration::Underline, &|l| l.style = style); + let d = laid(&t).unwrap().decorations[0].clone(); + assert_eq!(d.kind, style); + let b = d.path().bounding_box(); + assert!(b.x0 >= d.rect.x0 - EPS && b.x1 <= d.rect.x1 + EPS && b.y0 >= d.rect.y0 - EPS && b.y1 <= d.rect.y1 + EPS, "{style:?} inside {b:?}"); + assert!(style != DecorationStyle::Wavy || d.rect.height() > d.thickness, "a wave is taller than its thickness"); + } + // Colour: the line paints with its own colour, the glyphs keep theirs. + set(&mut t, TextDecoration::Underline, &|l| l.color = Some(red)); + let l = laid(&t).unwrap(); + let d = &l.decorations[0]; + assert!(l.glyphs.iter().all(|g| g.style != d.style)); + assert_eq!(l.styles[d.style].fills, Some(vec![Paint::solid(red)])); + assert_eq!(l.styles[l.glyphs[0].style].fills, None); +} + +#[test] +fn skip_ink_breaks_under_descenders() { + // Skip ink: an underline stops short of descenders that cross it (silent on the gap: one line + // thickness, at least 1 px, each side). + let Some(mut t) = text("gypsy") else { return }; + t.style.decoration = TextDecoration::Underline; + let Some(l) = laid(&t) else { return }; + assert_eq!(l.decorations.len(), 1, "off: one line"); + t.style.set_look(|l| l.skip_ink = true); + let l = laid(&t).unwrap(); + assert!(l.decorations.len() > 1, "on: broken under g, y and p"); + let t2 = TextData { characters: "mmm".into(), ..t.clone() }; + let l2 = laid(&t2).unwrap(); + // Only when the line and its gap clear the baseline: some fonts put the underline so close + // that it touches every glyph's foot, and then it breaks under those too. + let clear = l2.decorations.first().zip(l2.lines.first()).is_some_and(|(d, ln)| d.rect.y0 - d.thickness.max(1.0) / 2.0 > ln.baseline); + if clear { + assert_eq!(l2.decorations.len(), 1, "no descenders: one line"); + } +} + +#[test] +fn decoration_shapes() { + // Font-free: the path of each style for a 100 px line, 2 px thick. + let d = |kind| Decoration { rect: Rect::new(0.0, 10.0, 100.0, 12.0), style: 0, kind, thickness: 2.0 }; + assert_eq!(d(DecorationStyle::Solid).path().bounding_box(), Rect::new(0.0, 10.0, 100.0, 12.0), "solid: the bar"); + let dots = d(DecorationStyle::Dotted).path(); + let count = dots.elements().iter().filter(|e| matches!(e, figcraft_geom::kurbo::PathEl::MoveTo(_))).count(); + assert_eq!(count, 25, "dotted: a 2 px dot every 4 px"); + let wave = Decoration { rect: Rect::new(0.0, 7.0, 100.0, 15.0), ..d(DecorationStyle::Wavy) }; + let b = wave.path().bounding_box(); + assert!((b.x0, b.x1) == (0.0, 100.0) && b.y0 >= 7.0 - EPS && b.y1 <= 15.0 + EPS, "wavy: across, within the band {b:?}"); +} diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 6e57d0f..e3781de 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -118,6 +118,7 @@ name = "English" "Auto layout" = "Auto layout" "Auto rows" = "Auto rows" "Auto width" = "Auto width" +"Back to the font's value" = "Back to the font's value" "Back" = "Back" "Back to Ready for dev" = "Back to Ready for dev" "Back to Ready for dev: the next change compares with how it is now" = "Back to Ready for dev: the next change compares with how it is now" @@ -303,6 +304,7 @@ name = "English" "Dark" = "Dark" "Darken" = "Darken" "Dash" = "Dash" +"Decoration style" = "Decoration style" "Decoration" = "Decoration" "Deep select" = "Deep select" "Default" = "Default" @@ -356,6 +358,7 @@ name = "English" "Done" = "Done" "Done editing" = "Done editing" "Dots" = "Dots" +"Dotted" = "Dotted" "Down" = "Down" "Drafts" = "Drafts" "Drag the stroke to add a width point" = "Drag the stroke to add a width point" @@ -490,6 +493,8 @@ name = "English" "Groups" = "Groups" "Gutter" = "Gutter" "Hand tool" = "Hand tool" +"Hanging list" = "Hanging list" +"Hanging punctuation" = "Hanging punctuation" "Hard light" = "Hard light" "Height" = "Height" "Help" = "Help" @@ -616,6 +621,7 @@ name = "English" "Link" = "Link" "Link copied" = "Link copied" "Link this component to its code component: instances then show your real snippet with their values." = "Link this component to its code component: instances then show your real snippet with their values." +"List spacing" = "List spacing" "List style" = "List style" "Live session" = "Live session" "Live · {people}" = "Live · {people}" @@ -1038,6 +1044,7 @@ name = "English" "Simplify stroke" = "Simplify stroke" "Size" = "Size" "Size jitter" = "Size jitter" +"Skip ink" = "Skip ink" "Slashed zero" = "Slashed zero" "Slice" = "Slice" "Slices" = "Slices" @@ -1130,6 +1137,7 @@ name = "English" "The selection uses several text styles: click to apply one" = "The selection uses several text styles: click to apply one" "Theme" = "Theme" "There's no destination for that action" = "There's no destination for that action" +"Thickness" = "Thickness" "This branch can't be reviewed: {e}" = "This branch can't be reviewed: {e}" "This branch was merged or archived." = "This branch was merged or archived." "This condition is edited as an expression" = "This condition is edited as an expression" @@ -1224,6 +1232,7 @@ name = "English" "Vivid" = "Vivid" "Warm" = "Warm" "Watch" = "Watch" +"Wavy" = "Wavy" "Web" = "Web" "WebSocket URL" = "WebSocket URL" "WebSocket URL copied" = "WebSocket URL copied" @@ -1334,6 +1343,7 @@ name = "English" "{profile} (current)" = "{profile} (current)" "{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." "{tool} hold {key}" = "{tool} hold {key}" +"{value} isn't a colour" = "{value} isn't a colour" "{when} · edited" = "{when} · edited" "← ↑ → ↓" = "← ↑ → ↓" "⇥ / ⇧⇥" = "⇥ / ⇧⇥" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 9103f8e..a6b8b11 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -119,6 +119,7 @@ status = "draft — needs native review" "Auto layout" = "Diseño automático" "Auto rows" = "Filas automáticas" "Auto width" = "Ancho automático" +"Back to the font's value" = "Volver al valor de la fuente" "Back" = "Atrás" "Back to Ready for dev" = "Volver a Listo para desarrollo" "Back to Ready for dev: the next change compares with how it is now" = "Vuelve a Listo para desarrollo: el próximo cambio se compara con cómo está ahora" @@ -304,6 +305,7 @@ status = "draft — needs native review" "Dark" = "Oscuro" "Darken" = "Oscurecer" "Dash" = "Discontinuo" +"Decoration style" = "Estilo de decoración" "Decoration" = "Decoración" "Deep select" = "Selección profunda" "Default" = "Predeterminado" @@ -357,6 +359,7 @@ status = "draft — needs native review" "Done" = "Listo" "Done editing" = "Terminar edición" "Dots" = "Puntos" +"Dotted" = "Punteado" "Down" = "Abajo" "Drafts" = "Borradores" "Drag the stroke to add a width point" = "Arrastra el trazo para añadir un punto de ancho" @@ -491,6 +494,8 @@ status = "draft — needs native review" "Groups" = "Grupos" "Gutter" = "Medianil" "Hand tool" = "Herramienta mano" +"Hanging list" = "Lista colgante" +"Hanging punctuation" = "Puntuación colgante" "Hard light" = "Luz fuerte" "Height" = "Alto" "Help" = "Ayuda" @@ -617,6 +622,7 @@ status = "draft — needs native review" "Link" = "Enlace" "Link copied" = "Enlace copiado" "Link this component to its code component: instances then show your real snippet with their values." = "Vincula este componente a su componente de código: así las instancias muestran tu fragmento real con sus valores." +"List spacing" = "Espaciado de lista" "List style" = "Estilo de lista" "Live session" = "Sesión en vivo" "Live · {people}" = "En vivo · {people}" @@ -1039,6 +1045,7 @@ status = "draft — needs native review" "Simplify stroke" = "Simplificar trazo" "Size" = "Tamaño" "Size jitter" = "Variación de tamaño" +"Skip ink" = "Saltar trazos" "Slashed zero" = "Cero barrado" "Slice" = "Sector" "Slices" = "Sectores" @@ -1131,6 +1138,7 @@ status = "draft — needs native review" "The selection uses several text styles: click to apply one" = "La selección usa varios estilos de texto: haz clic para aplicar uno" "Theme" = "Tema" "There's no destination for that action" = "No hay destino para esa acción" +"Thickness" = "Grosor" "This branch can't be reviewed: {e}" = "Esta rama no se puede revisar: {e}" "This branch was merged or archived." = "Esta rama se fusionó o archivó." "This condition is edited as an expression" = "Esta condición se edita como expresión" @@ -1225,6 +1233,7 @@ status = "draft — needs native review" "Vivid" = "Vívido" "Warm" = "Cálido" "Watch" = "Reloj" +"Wavy" = "Ondulado" "Web" = "Web" "WebSocket URL" = "URL de WebSocket" "WebSocket URL copied" = "URL de WebSocket copiada" @@ -1335,6 +1344,7 @@ status = "draft — needs native review" "{profile} (current)" = "{profile} (actual)" "{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} es su valor; {property ? \"a\" : \"b\"} elige un texto. Cualquier otro {…} se queda tal cual." "{tool} hold {key}" = "{tool} mantén {key}" +"{value} isn't a colour" = "{value} no es un color" "{when} · edited" = "{when} · editado" "← ↑ → ↓" = "← ↑ → ↓" "⇥ / ⇧⇥" = "⇥ / ⇧⇥" diff --git a/crates/ui-egui/src/typography.rs b/crates/ui-egui/src/typography.rs index 899bcd5..8fc4e2c 100644 --- a/crates/ui-egui/src/typography.rs +++ b/crates/ui-egui/src/typography.rs @@ -406,6 +406,9 @@ fn type_settings( if let Some(i) = text_seg(ui, "deco", &items, active, w) { apply_style(app, "textDecoration", json!((["none", "underline", "strikethrough"].get(i).copied().unwrap_or("none")))); } + if let Some(d @ (TextDecoration::Underline | TextDecoration::Strikethrough)) = deco { + decoration_details(app, ui, styles, apply_style, d == TextDecoration::Underline, w); + } caption(ui, tr("Letter case")); let case = uniform(styles, |s| s.case); let active = case.map(|c| match c { @@ -426,7 +429,15 @@ fn type_settings( apply_style(app, "textCase", json!((["original", "upper", "lower", "title", "smallCaps"].get(i).copied().unwrap_or("original")))); } caption(ui, tr("List style")); - let list = common(doc, ids, |n| n.kind.text().map(|t| t.list)).flatten(); + // While editing: the selected paragraphs' list. + let range = figcraft_engine::textedit::selected_range(&app.session); + let list = common(doc, ids, |n| { + n.kind.text().and_then(|t| { + let (a, b) = range.filter(|r| r.0 == n.id).map_or((0, t.char_count()), |(_, a, b)| (a, b)); + t.common_para_list(a, b).map(|p| p.list) + }) + }) + .flatten(); let active = list.map(|l| match l { ListStyle::None => 0, ListStyle::Bulleted => 1, @@ -434,9 +445,18 @@ fn type_settings( }); let items = [("—", tr_noop("No list")), ("•", tr_noop("Bulleted list")), ("1.", tr_noop("Numbered list"))]; if let Some(i) = text_seg(ui, "list", &items, active, w) { - app.set_props(ids, json!({"listStyle": (["none", "bulleted", "numbered"].get(i).copied().unwrap_or("none"))})); + app.run("text.listStyle", json!({"ids": ids, "style": (["none", "bulleted", "numbered"].get(i).copied().unwrap_or("none"))})); } ui.add_space(6.0); + let spacing = common(doc, ids, |n| n.kind.text().map(|t| t.list_spacing.to_bits())).flatten().map(f64::from_bits); + field_row(ui, tr("List spacing"), |ui, fw| { + if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) = + widgets::number_field(ui, Id::new("ty_list_spacing"), Lead::None, spacing, "", fw, 1.0) + { + app.set_props(ids, json!({"listSpacing": v.clamp(0.0, 10_000.0)})); + } + }); + ui.add_space(4.0); let indent = common(doc, ids, |n| n.kind.text().map(|t| t.paragraph_indent.to_bits())).flatten().map(f64::from_bits); // Indent applies to start-aligned text only; elsewhere the field stays visible but off. let start_aligned = @@ -452,6 +472,15 @@ fn type_settings( .response .on_disabled_hover_text(tr("Paragraph indent applies to left-aligned text")); }); + ui.add_space(4.0); + for (key, label) in [("hangingPunctuation", tr("Hanging punctuation")), ("hangingList", tr("Hanging list"))] { + let all_on = ids.iter().all(|i| { + doc.node(*i).and_then(|n| n.kind.text()).is_some_and(|t| if key == "hangingList" { t.hanging_list } else { t.hanging_punctuation }) + }); + if widgets::checkbox(ui, all_on, label) { + app.set_props(ids, json!({ key: !all_on })); + } + } caption(ui, tr("Vertical trim")); let trim = common(doc, ids, |n| n.kind.text().map(|t| usize::from(t.leading_trim == LeadingTrim::CapHeight))).flatten(); let items = [(tr("Standard"), tr_noop("Standard")), ("Ag", tr_noop("Cap height to baseline"))]; @@ -506,6 +535,86 @@ fn type_settings( } } +/// A Type settings row: a label, then `add` with the rest of the width. +fn field_row(ui: &mut Ui, label: &str, add: impl FnOnce(&mut Ui, f32) -> R) -> R { + let t = Tokens::get(ui.ctx()); + let w = ui.available_width(); + ui.horizontal(|ui| { + ui.add_sized(vec2(110.0, FIELD_H), egui::Label::new(egui::RichText::new(label).font(regular(11.0)).color(t.text))); + add(ui, w - 118.0) + }) + .inner +} + +/// How the decoration looks: style, thickness, (underlines) offset and skip ink, and colour. +fn decoration_details(app: &mut FigApp, ui: &mut Ui, styles: &[TextStyle], apply_style: &dyn Fn(&mut FigApp, &str, Value), underline: bool, w: f32) { + caption(ui, tr("Decoration style")); + let active = uniform(styles, |s| s.look().style as usize); + let items = [(tr("Solid"), tr_noop("Solid")), (tr("Dotted"), tr_noop("Dotted")), (tr("Wavy"), tr_noop("Wavy"))]; + if let Some(i) = text_seg(ui, "deco_style", &items, active, w) { + apply_style(app, "textDecorationStyle", json!((["solid", "dotted", "wavy"].get(i).copied().unwrap_or("solid")))); + } + ui.add_space(4.0); + let mut lengths = vec![("textDecorationThickness", tr("Thickness"), uniform(styles, |s| s.look().thickness))]; + if underline { + lengths.push(("textDecorationOffset", tr("Offset"), uniform(styles, |s| s.look().offset))); + } + for (key, label, value) in lengths { + field_row(ui, label, |ui, fw| { + let (shown, empty) = match value { + Some(DecorationLength::Pixels(v)) => (Some(v), String::new()), + Some(DecorationLength::Percent(p)) => (None, format!("{}%", trim_num(p))), + Some(DecorationLength::Auto) => (None, tr("Auto").to_string()), + None => (None, tr(MIXED).to_string()), + }; + let auto = value == Some(DecorationLength::Auto); + let field_w = if auto { fw } else { fw - 36.0 }; + if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) = + widgets::number_field_or(ui, Id::new(("ty_deco", key)), shown, &empty, field_w, 0.5) + { + // A thickness of 0 or less goes back to the font's. + let v = if key == "textDecorationThickness" && v <= 0.0 { json!("auto") } else { json!(v.clamp(-1000.0, 1000.0)) }; + apply_style(app, key, v); + } + let tip = tr("Back to the font's value"); + if !auto + && ui + .add( + egui::Label::new(egui::RichText::new(tr("Auto")).font(regular(11.0)).color(Tokens::get(ui.ctx()).accent)) + .sense(Sense::click()), + ) + .on_hover_text(tip) + .clicked() + { + apply_style(app, key, json!("auto")); + } + }); + } + if underline { + let skip = uniform(styles, |s| s.look().skip_ink); + if widgets::checkbox(ui, skip == Some(true), tr("Skip ink")) { + apply_style(app, "textDecorationSkipInk", json!(skip != Some(true))); + } + } + let color = uniform(styles, |s| s.look().color.map(|c| format!("#{}", c.to_hex()))); + field_row(ui, tr("Colour"), |ui, fw| { + let (value, hint) = match &color { + Some(c) => (c.clone().unwrap_or_default(), tr("Auto")), + None => (String::new(), tr(MIXED)), + }; + if let Some(s) = widgets::text_field(ui, Id::new("ty_deco_color"), &value, fw, hint) { + let s = s.trim(); + if s.is_empty() || s.eq_ignore_ascii_case("auto") { + apply_style(app, "textDecorationColor", json!("auto")); + } else if let Some(c) = figcraft_color::Color::parse_css(s).or_else(|| figcraft_color::Color::from_hex(s)) { + apply_style(app, "textDecorationColor", json!(format!("#{}", c.to_hex()))); + } else { + app.toast(trf("{value} isn't a colour", &[("value", &s)])); + } + } + }); +} + /// Type settings ▸ Variable: the font's named instances, then a slider and a field per visible /// axis (values the style name implies show until changed; "Mixed" when the shown styles differ). /// The canvas previews a hovered instance and a dragged slider or field live; letting go commits diff --git a/docs/mcp.md b/docs/mcp.md index 7bbbbc3..0929106 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -141,6 +141,19 @@ Or in a project's `.mcp.json`: `text.setVariations {variations, replace?}` merges instead. `text.fonts {family, style?, axes: true}` lists a font's axes and named instances (named instances are also valid `fontStyle` values). OpenType features: `features {"liga": false, "tnum": true}`. + Decorations: `textDecorationStyle` (`solid`, `dotted`, `wavy`), `textDecorationThickness` and + `textDecorationOffset` (`auto`, px, or `"10%"` of the font size; the offset runs from the baseline + down to an underline's top edge), `textDecorationSkipInk` (underlines break around descenders) and + `textDecorationColor` (a colour, or `auto` for the text's) are character props too. +- **Lists** are per paragraph: `listStyle` (`none`, `bulleted`, `numbered`) and `indentation` (the + nesting level, 0–8) apply to the paragraphs chars `start`..`end` touch (all of them without a + range); `get_node` reports one `listStyle` for the layer, or `lineTypes` and `lineIndentations` + per paragraph. `text.bulletList`, `text.numberList` and `text.listStyle` act on the selected + paragraphs while text is edited. Layer props: `listSpacing` (px between list items, in place of + `paragraphSpacing`), `hangingList` (markers outside the box) and `hangingPunctuation`. While + editing, `key` Tab / Shift+Tab nest an item or move it out, Enter on an empty item and Backspace + at an item's start step out of the list, and typing `- `, `* ` or `1. ` at a paragraph's start + starts one. - **Text styles** link chars, not just layers: `style.apply {style, ids?, start?, end?}` with a text style links chars `start`..`end` (while text is edited, the selected chars; else whole layers), and `style.detach {which: "text", start?, end?}` unlinks them (values stay). A layer's `styles.text` is diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d315548..588fb2b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -19,9 +19,9 @@ summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu c | Feature breadth (does each Figma Design feature exist, weighted) | **~82%** (core editor areas 1–16: ~93%) | estimated from the area table below | | Ready for real work | **~51%** | estimated (dimension table below: written weights × written per-dimension scores) | | Essentials user | **~57%** | estimated ([below](#essentials-user): core features 77.8% × discounts 0.92 × 0.92 × 0.88) | -| Mainstream practitioner | **~49%** | estimated ([below](#mainstream-practitioner): weekly areas 71.0% × discounts 0.9 × 0.9 × 0.85) | +| Mainstream practitioner | **~49%** | estimated ([below](#mainstream-practitioner): weekly areas 71.2% × discounts 0.9 × 0.9 × 0.85) | | Remaining to beta | **~170–305 h** one Opus 5.5 agent (~35–70 h wall-clock with 5 parallel agents) | estimated | -| Remaining to full parity | **~306–594 h** one agent (~86–150 h wall-clock with 5 parallel agents) | estimated | +| Remaining to full parity | **~304–591 h** one agent (~86–150 h wall-clock with 5 parallel agents) | estimated | | Stage | **alpha**: the alpha gate passes ([roadmap](roadmap.md#alpha-gate)); multiplayer and checking the best-effort `.fig` import on real files are beta gaps (see [ROADMAP](../ROADMAP.md#stage)) | | The previous figures (ROADMAP of 2026-10-10, "feature depth ~79%", "full parity 115–195 h") were @@ -103,7 +103,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 7 | Effects | 4 | 100% | 75% | Glass and progressive blur tuned side by side; inner shadow and background blur on groups (behaviour suite from the documentation, shadows hidden behind translucent layers on the canvas and in every export landed) | 2–5 | | 8 | Frames, groups, sections, masks | 5 | 97% | 80% | Visual QA with real files; a one-step frame/group convert (behaviour suite from the documentation landed: masked-out areas don't take clicks, mask group bounds and exports follow the mask, sections kept out of frames and groups, frames and sections drawn around layers take them, section resizing leaves contents in place, X/Y measured in the frame) | 1–3 | | 9 | Auto layout & constraints | 9 | 98% | 74% | Behaviour against real Figma files; wrapped-line content alignment (behaviour suite from the documentation, `fr` tracks like CSS, vertical wrap, auto gaps evenly/around and cell alignment landed) ([auto-layout-parity](auto-layout-parity.md)) | 3–6 | -| 10 | Text | 8 | 87% | 68% | Spell check, real-file text metrics, list spacing, hanging punctuation, underline styles (behaviour suite with CJK line breaking, vertical trim and carets by grapheme cluster landed) | 5–10 | +| 10 | Text | 8 | 90% | 70% | Spell check, real-file text metrics (behaviour suite with CJK line breaking, vertical trim and carets by grapheme cluster landed; lists per paragraph with nesting and list spacing, hanging punctuation and lists, decoration style, thickness, offset, skip ink and colour landed) | 3–7 | | 11 | Components & variants | 9 | 96% | 72% | Real (not synthetic) libraries, `.fig` libraries, component playground (behaviour suite from the documentation, overrides of padding, gap, grids, exports and interactions, refused geometry overrides, variant-switch and Assets-swap override rules, top-left default variant landed) ([components-parity](components-parity.md)) | 4–9 | | 12 | Styles & variables | 5 | 94% | 76% | Variables inside styles, new library variables reaching extended collections on their own, extending an extended collection, real-library QA (behaviour suite from the documentation, style folders and order, mode order, code syntax, extended collections and variables import/export landed) ([components-parity](components-parity.md)) | 3–5 | | 13 | Prototyping | 6 | 90% | 74% | Video triggers and media actions, gamepad triggers, delays on mouse triggers, manual overlay offsets, real prototype QA (behaviour suite from the documentation, key combinations, interactive components playing their variants, drag-driven animations, physical springs landed) ([prototyping-parity](prototyping-parity.md)) | 3–6 | @@ -115,14 +115,14 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 19 | Team libraries & organization | 2 | 70% | 40% | Libraries kept on `figcraft-server` under their file and enabled by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names. Left: team-wide library lists, accounts and permissions, analytics over time, pushed update notices | 4–8 | | 20 | Figma Draw extras | 2 | 80% | 55% | Have: variable width, lasso, shape builder, noise/texture, text on a path, pattern fills, stretch and scatter brushes, pen pressure (Windows pens, browser styluses). Missing: multi-edit, pressure from macOS/Linux tablets | 2–5 | | 21 | AI features | 3 | 0% | 0% | First draft, rename layers, image edit/remove background, visual search, Make: needs local models (owner decision) | 30–60 | -| | **Weighted (÷113)** | 113 | **~82%** | **~61%** | | **148–308** | -| | Core editor only (1–16, ÷94) | 94 | ~93% | ~70% | | 46–94 | +| | **Weighted (÷113)** | 113 | **~82%** | **~61%** | | **146–305** | +| | Core editor only (1–16, ÷94) | 94 | ~93% | ~70% | | 44–91 | ## By dimension (ready for real work) | Dimension | Weight | Ready | Hours | Evidence | Doc | |---|---|---|---|---|---| -| Features (depth) | 35 | 61% | 46–93 (areas 1–13, 15, 16, 20) | The table above; core editor ~70% | this file | +| Features (depth) | 35 | 61% | 44–90 (areas 1–13, 15, 16, 20) | The table above; core editor ~70% | this file | | UI/UX fidelity | 15 | 58% | 40–60 | Look & feel compared side by side with UI3 (~65% by the builders); tool-by-tool interaction (cursors, modifiers while dragging, hover states, popovers) not yet compared; accessibility names and keyboard navigation landed | [ui-parity](ui-parity.md) | | File formats | 12 | 59% | 5–10 | `.fig` read, best effort, with editable vector networks (tested on generated fixtures, not real files), no `.fig` write; REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid, with a behaviour suite from the documentation | [file-format-parity](file-format-parity.md) | | Collaboration | 10 | 13% | 55–110 | Local comments, version history and branches with review and merge; the multiplayer core (sync model, server ordering, client reconciliation, convergence tests) landed in #103, and live sessions (preview) co-edit a file hosted by a desktop app or kept on a self-hosted `figcraft-server` with edit and view links, joined from the app or the browser (`docs/collab-protocol.md`) | [collaboration-parity](collaboration-parity.md) | @@ -133,7 +133,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | Platforms | 3 | 75% | 10–20 | macOS, Windows (x64/x86/arm64), Linux, FreeBSD, web all packaged (v0.2.0); Figma also has iOS/Android apps; non-macOS QA thin | [hardware-parity](hardware-parity.md) | | Ecosystem/plugins | 3 | 28% | 15–40 | Own WASM plugins with a Rust SDK and two examples (#101), plugin panels and settings (ABI v2); no Figma plugin compatibility, no widgets, no distribution or community files; the CLI, control channel and MCP server are ahead of Figma for agents | [gaps](gaps.md) | | AI features | 2 | 0% | 30–60 | None | [gaps](gaps.md) | -| **Ready for real work** | 100 | **~51%** | **~306–594** | | | +| **Ready for real work** | 100 | **~51%** | **~304–591** | | | Dimension hours add up to the full-parity total without double counting: the feature-area rows for import/export (14), multiplayer and team libraries (17, 19), plugins (18) and AI (21) are @@ -187,7 +187,7 @@ libraries (19, cloud/team admin), Figma Draw extras (20, not weekly for a produc localization beyond English, specialist hardware. Real-time multiplayer (17) is cloud collaboration and is left out as an area, but its effect on working with Figma users is counted in discount 3. -**2. Average depth** ("Ready" column, weighted): **71.1%**. +**2. Average depth** ("Ready" column, weighted): **71.3%**. **3. Discounts for what still stops real work:** @@ -197,7 +197,7 @@ and is left out as an area, but its effect on working with Figma users is counte | Stability on real machines | ×0.90 | Never-crash lints, command guard with rollback, recovery copies and 963 tests, but no field record at all: the repository is private, v0.2.0 has had no outside users or issue reports, CI tests run on Linux only and hands-on QA is on macOS. Harsher than VectorCraft's ×0.92 because there is no outside evidence either way | | File exchange with Figma users | ×0.85 | A Figma designer's work lives in shared Figma files. FigCraft opens a `.fig` (best effort, not yet checked on real files) but writes nothing Figma opens as a document; incoming work can come through the REST API JSON (one way, needs an access token), outgoing only as SVG, PDF, PNG or code. Teammates, reviewers and developers would all need FigCraft ([file-format-parity](file-format-parity.md), [collaboration-parity](collaboration-parity.md)) | -71.1% × 0.90 × 0.90 × 0.85 = **~49%** (estimated). +71.3% × 0.90 × 0.90 × 0.85 = **~49%** (estimated). **4. User evidence (2026-10-10):** 0 issues, 0 praise or "switched from Figma" reports, 0 comments from anyone but the maintainers (`gh issue list`, `gh api .../issues/comments`; the repository is @@ -289,9 +289,16 @@ Hours in this narrative are the builders' estimates; the tables above supersede break inside Latin words, kinsoku for closing and opening punctuation, small kana and the prolonged sound mark); a style's metrics from its own font, not a fallback; carets, Delete and Backspace by grapheme cluster; double-click and word moves by script run; IME composition with - CJK; vertical trim (cap height to baseline). *Left:* visual QA; list spacing, hanging quotes and - lists, underline style/offset/thickness/skip ink (documented, not modelled); Korean keeps - breaking by syllable and Han runs aren't split by a dictionary (documentation silent). 0.5 h. + CJK; vertical trim (cap height to baseline). The tail suite (`crates/text/src/tail_tests.rs`, + `crates/engine/src/text_tail_tests.rs`): lists per paragraph, nested up to 8 levels with Tab and + Shift+Tab (1. a. i. and • ◦ ▪ per level, counts restarting after plain paragraphs), Enter on an + empty item and Backspace at an item's start stepping out, `- `/`* `/`1. ` starting a list; list + spacing between items; hanging lists and hanging punctuation; decoration style (solid, dotted, + wavy), thickness, underline offset, skip ink and colour, with inspect, CSS and the REST import. + *Left:* visual QA; hanging punctuation in right-to-left paragraphs and centred text, list + numbering that continues across a plain paragraph and a list's own start number (documentation + silent); decoration colour in SVG exports (drawn with the text's fill, like run fills); Korean + keeps breaking by syllable and Han runs aren't split by a dictionary (documentation silent). 0.5 h. 3. **Auto layout and sections depth**: *landed:* on-canvas padding/gap handles (also wrapped-row and grid column/row gaps, grid tracks outlined), min/max width/height (UI, Hug/Fill/Fixed, hugging text wraps at its max), ignore auto layout (absolute position) with constraints, text @@ -605,6 +612,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Text lists, hanging punctuation and decoration styles, with a behaviour suite written from the public documentation (7 layout tests and 7 command tests, about 60 table rows): lists are set per paragraph (the list buttons, shortcuts and Type settings act on the paragraphs the selection touches, the whole layer when it isn't being edited), nest up to 8 levels (Tab and Shift+Tab; numbers go 1. a. i., bullets • ◦ ▪, counted per level), Enter on an empty item and Backspace at an item's start step out a level and then out of the list, typing `- `, `* ` or `1. ` at a paragraph's start starts one; list spacing (between items, in place of paragraph spacing), hanging lists (markers outside the box) and hanging punctuation (opening punctuation outside a start-aligned edge, closing punctuation outside an end-aligned one, taking no room when lines wrap); underline and strikethrough style (solid, dotted, wavy), thickness, underline offset, skip ink and colour. Canvas, PNG, SVG and PDF; `node.set` keys (`indentation`, `listSpacing`, `hangingPunctuation`, `hangingList`, `textDecorationStyle`/`Thickness`/`Offset`/`SkipInk`/`Color`), inspect (`lineTypes`, `lineIndentations`), CSS (`text-decoration-style`/`-thickness`/`-color`, `text-underline-offset`, `text-decoration-skip-ink`) and the REST import (`lineTypes` with `lineIndentations` per paragraph, which collapsed to one list style before). Text 87%/68% → 90%/70%, 5–10 h → 3–7 h; mainstream weekly areas 71.0% → 71.2%; other percentages unchanged at this rounding; full parity ~306–595 h → ~304–592 h | | 2026-10-12 | minor | Comments and version history behaviour suite written from the public documentation (15 tests, about 80 table rows plus stepwise checks; 4 tests failed before the fixes) and its fixes: in a live session, threads made in the session couldn't be replied to, edited, reacted to, resolved, moved, deleted or opened (their ids, from their creator's id space, were refused as out of range); restoring a version brought back that version's comments, losing newer threads and replies (comments now stay, those on layers the version lacks staying where they were); restoring now keeps the replaced design in the history as an autosave first, unless a version of it was just saved or restored; giving an autosave a title makes it a named version (it could be dropped to make room); merging a branch adds the main file as it was and then a `Merged “”` version to its history. Pinned where the documentation is silent: pins go to the innermost frame under them, copies, duplicates and duplicated pages come without comments, replying reopens a resolved thread, versions stay with whoever saved them in a live session (a shared history needs accounts). Left: a branch's own version history, comparing two versions. Comments, versions, find, misc 85%/60% → 85%/62%, 2–5 h → 2–4 h; mainstream weekly areas 71.0% → 71.1%; percentages unchanged at this rounding; full parity ~306–595 h → ~306–594 h | | 2026-10-11 | minor | Canvas navigation, rulers, guides, layout grids, outline mode and measuring behaviour suite written from the public documentation (18 tests, about 120 table rows and checks) and its fixes: the pixel grid shows from 400% (it waited for 800%); guides belong to the page or a top-level frame (a guide dropped over a nested frame goes to the frame holding it, and a frame moved into another keeps its guides but they stop showing, snapping and measuring); a guide can be selected, deleted with Delete, nudged with the arrows and removed from the context menu (`guide.select`, `guide.remove` without an index); Alt-dragging a guide drops a copy and holding Alt while dragging one measures to the nearest edges in the frame it crosses; columns and rows count Auto (as many fixed-size ones as fit); layout grids don't show or snap on a rotated frame; in outline mode shapes are clicked on their outlines, reaching layers behind. Left: outline mode's Include hidden layers and Include object bounds. Canvas & navigation 97%/72% → 98%/74%, 2–5 h → 1–4 h; feature breadth (~80%), ready for real work (~49%), core editor (~68%), essentials (~57%) and mainstream (~46%) unchanged at this rounding; full parity ~320–615 h → ~319–614 h | | 2026-10-11 | minor | Export and inspect behaviour suite written from the public documentation (139 cases in 19 tests; 47 failed before the fixes) and its fixes: an export covers what the layer paints, so a drop shadow adds room only where it falls, strokes on boxes and ellipses reach their real width (they added up to four times as much), hidden layers take no room and a mask group exports the mask's area; a slice exports everything under it; slashes in layer names export into folders; Copy as PNG copies the whole selection. Added: the export settings' contents only and include bounding box, and for SVG outline text (off writes ``), unique id attributes and simplified strokes (stroke attributes where exact); Inspect colour formats (hex, RGB, CSS, HSL, HSB), CSS in rem, a Blend row, positions measured from the frame around a layer (groups aren't frames), CSS for rotation, blend modes, gradient directions and kinds, text alignment, case and decoration; icons and images in the asset list (`file.exportLayers {assets}`). Import/export 80%/50% → 82%/53%; Dev Mode 90%/64% → 93%/70%; file formats 55% → 56%; mainstream weekly areas 68.1% → 68.4%; feature breadth (~80%), ready for real work (~49%), essentials (~57%), mainstream (~46%) and full parity hours unchanged at this rounding |