collective

Browse / github.com/storytold/wordcraft / patch

Navigator: organic tree (Modelo 2) and a functional review language

Navigator: organic tree (Modelo 2) and a functional review language ## What Two changes that make the outline and the proofing language behave like the rest of the editor: - **Navigator › Headings — Modelo 2 (organic tree):** the tree is drawn with continuous vertical trunks (no cuts between rows), 25% thicker lines, a 6 px rounded elbow into each row, a 4.5 px articulation node before the triangle (filled expanded, hollow collapsed) and the active branch lit from the top of the outline down to the selected heading. The pane's title bar and its Dock Left / Dock Right / Float / Close menu are unchanged. - **Review language:** `File › Options › Language` now h

Status
open
Score
0.00
Adopters
0 other operators
Author
lucasmelo-mike01 (imported from pull request #4504, open)
Base
f54ea731cc584002786498b603383c85f2e7ee89
Size
+1057 −77 in 16 files
Review
pending
Shared
2026-10-28
Id
6d959c1061f44a639d1a2097

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/cmd/tools.rs crates/engine/src/session.rs crates/engine/src/session/documents.rs crates/fonts/src/fontdb.rs crates/fonts/src/lib.rs crates/fonts/src/script.rs crates/formats/src/odt.rs crates/layout/src/para.rs crates/render/src/emoji.rs crates/render/src/lib.rs crates/render/src/tests_color_glyphs.rs crates/ui-egui/src/chrome.rs crates/ui-egui/src/options.rs crates/ui-egui/src/panes.rs crates/ui-egui/src/ribbon.rs crates/ui-egui/src/theme.rs

diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs--- a/crates/engine/src/cmd/tools.rs+++ b/crates/engine/src/cmd/tools.rs@@ -767,6 +767,8 @@ pub fn specs() -> Vec<CommandSpec> {         CommandSpec::new("review.language", "Language", "Review › Language", language).params(             r#"{"lang"?: "en-US|en-GB|pl-PL|fr-FR|…" (BCP 47; default en-US), "noProof"?: bool, "detect"?: bool (detect language automatically, a preference), "default"?: bool (also the document's default and the saved language for future blank documents)}"#,         ),+        CommandSpec::new("review.defaultLanguage", "Review Language", "File › Options › Language", default_language)+            .params(r#"{"value"?: "auto"|"off"|<tag> (a proofing language such as pt-BR), "apply"?: bool (also the open document's default language, true by default)}"#),         CommandSpec::new("review.showMarkup", "Show Markup", "Review › Tracking", |s, v| {             s.view.show_markup = p::bool(v, "value").unwrap_or(!s.view.show_markup);             s.relayout();@@ -865,6 +867,53 @@ fn language(s: &mut Session, v: &Value) -> CmdResult {     Ok(result) } +/// The proofing default shown by File › Options › Language (and used for text that names no+/// language): the saved default, else the interface's editing language, else US English.+fn proofing_default(s: &Session) -> String {+    s.prefs.default_language.clone().or_else(|| s.editing_lang.clone()).unwrap_or_else(|| "en-US".to_string())+}++/// File › Options › Language › Review language: set the app's proofing default. `auto` follows+/// the interface language, `off` turns the live check off, a tag is used as it is; `apply` (the+/// default) also changes the open document's default language, which clears the live squiggles+/// at once. Persisted with the other preferences in `ui.json`.+fn default_language(s: &mut Session, v: &Value) -> CmdResult {+    let Some(value) = p::str(v, "value").map(str::trim).filter(|v| !v.is_empty()) else {+        let checking = s.view.proofing;+        let value = if !checking { "off".to_string() } else { s.prefs.default_language.clone().unwrap_or_else(|| "auto".to_string()) };+        return Ok(json!({"value": value, "resolved": proofing_default(s), "checking": checking}));+    };+    if value.eq_ignore_ascii_case("off") {+        s.view.proofing = false;+        s.prefs.check_spelling = false;+        return Ok(json!({"value": "off", "resolved": proofing_default(s), "checking": false}));+    }+    let (value, resolved) = if value.eq_ignore_ascii_case("auto") {+        // Automatic follows the interface language (a pt-br interface writes pt-BR).+        s.prefs.default_language = None;+        ("auto".to_string(), proofing_default(s))+    } else {+        let tag = wordcraft_proof::lang::normalize_tag(value)+            .ok_or_else(|| CmdError::Params(format!("`value` must be auto, off or a language tag, not {value:?}")))?;+        s.prefs.default_language = Some(tag.clone());+        s.view.proofing = true;+        s.prefs.check_spelling = true;+        (tag.clone(), tag)+    };+    if p::bool(v, "apply").unwrap_or(true) {+        // The open document's own default language: text that names no language now resolves in+        // the chosen one, and the live check follows without reopening the file.+        s.doc.styles.default_chr.lang = Some(resolved.clone());+        if let Some(normal) = s.doc.styles.styles.iter_mut().find(|x| x.id == "Normal")+            && normal.chr.lang.is_some()+        {+            normal.chr.lang = Some(resolved.clone());+        }+        s.relayout();+    }+    Ok(json!({"value": value, "resolved": resolved, "checking": s.view.proofing}))+}+ fn macros(s: &mut Session, v: &Value) -> CmdResult {     if let Some(d) = v.get("define") {         let name = d.get("name").and_then(Value::as_str).unwrap_or("Macro").to_string();@@ -2395,6 +2444,51 @@ mod tests {         assert!(s.run("review.language", &json!({"lang": "<script>"})).is_err());     } +    /// File › Options › Language: the review language is a persisted app default; Automatic+    /// follows the interface language, Off turns the live check off, and applying it changes the+    /// open document's default so the squiggles follow at once.+    #[test]+    fn review_language_default_is_applied_and_persisted() {+        let mut s = Session::new(Document::from_text("Texto"));+        s.set_editing_language(Some("pt-BR"));+        let r = s.run("review.defaultLanguage", &json!({"value": "auto"})).unwrap();+        assert_eq!((r["value"].as_str(), r["resolved"].as_str()), (Some("auto"), Some("pt-BR")));+        assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("pt-BR"));+        assert!(s.view.proofing && s.prefs.default_language.is_none());++        let r = s.run("review.defaultLanguage", &json!({"value": "pt-br"})).unwrap();+        assert_eq!(r["resolved"], "pt-BR");+        assert_eq!(s.prefs.default_language.as_deref(), Some("pt-BR"));++        let r = s.run("review.defaultLanguage", &json!({"value": "off"})).unwrap();+        assert_eq!(r["value"], "off");+        assert!(!s.view.proofing && !s.prefs.check_spelling);+        assert_eq!(s.prefs.default_language.as_deref(), Some("pt-BR"), "Off keeps the language saved");+        assert_eq!(s.run("review.defaultLanguage", &json!({})).unwrap()["value"], "off");++        assert!(s.run("review.defaultLanguage", &json!({"value": "<script>"})).is_err());+        let r = s.run("review.defaultLanguage", &json!({"value": "en-US"})).unwrap();+        assert_eq!(r["resolved"], "en-US");+        assert!(s.view.proofing && s.prefs.check_spelling, "choosing a language checks it again");+        assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("en-US"));+    }++    /// A document that names no language adopts the app's proofing default when opened; a+    /// document that names its own keeps it.+    #[test]+    fn documents_without_a_language_adopt_the_proofing_default() {+        let mut s = Session::new(Document::from_text(""));+        s.set_editing_language(Some("pt-BR"));+        let mut plain = Document::from_text("Sem idioma");+        plain.styles.default_chr.lang = None;+        s.set_document(plain);+        assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("pt-BR"));+        let mut english = Document::from_text("With language");+        english.styles.default_chr.lang = Some("en-US".into());+        s.set_document(english);+        assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("en-US"), "its own language wins");+    }+     /// The inspector's removals delete straight from the document, so they aren't tracked: on a     /// document that tracks changes the text would go with no revision to show for it, and the     /// revision trail would be broken behind the user's back. The removal is refused instead,diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs--- a/crates/engine/src/session.rs+++ b/crates/engine/src/session.rs@@ -1622,6 +1622,9 @@ impl Session {         self.note_body_replaced();         self.bib_style = doc_bib_style(&doc);         self.doc = doc;+        // A file that names no language uses the app's proofing default, not English.+        let tag = self.prefs.default_language.clone().or_else(|| self.editing_lang.clone());+        adopt_default_language(&mut self.doc, tag.as_deref());         self.doc.ensure_nonempty();         self.password = None;         self.file_name = None;@@ -2431,6 +2434,17 @@ impl Session { }  /// A new blank document on `region`'s paper (see [`Session::blank_document`]).+/// A document that names no default text language of its own adopts `tag`, so text without a+/// language is checked (and shown in the status bar) in the app's language instead of English.+/// A document that carries a language — from its own styles — is left as it is.+pub(crate) fn adopt_default_language(doc: &mut Document, tag: Option<&str>) {+    let Some(tag) = tag else { return };+    let named = doc.styles.resolve_char(None, &wordcraft_doc::props::CharProps::default()).lang.is_some();+    if !named {+        doc.styles.default_chr.lang = Some(tag.to_string());+    }+}+ pub fn blank_document(region: &wordcraft_geom::Region) -> Document {     let mut d = Document::new();     let (_, w, h) = region.paper();diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs--- a/crates/engine/src/session/documents.rs+++ b/crates/engine/src/session/documents.rs@@ -456,6 +456,10 @@ impl Session {     }      fn add(&mut self, mut doc: Document, untitled: u32) -> u64 {+        // A document opened beside another (or a new one) adopts the app's proofing language when+        // it names none of its own; see `adopt_default_language`.+        let tag = self.prefs.default_language.clone().or_else(|| self.editing_lang.clone());+        super::adopt_default_language(&mut doc, tag.as_deref());         doc.ensure_nonempty();         let mut view = self.view.clone();         view.outline.collapsed.clear();diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs--- a/crates/fonts/src/fontdb.rs+++ b/crates/fonts/src/fontdb.rs@@ -1757,13 +1757,17 @@ impl FontDb {     /// loaded face covers a character. `None` when no single face covers them all, and the caller     /// keeps the per-character fallback.     ///+    /// Default-ignorables (variation selectors, joiners) take no glyph of their own: they neither+    /// choose a face nor disqualify one, so an emoji sequence can be given a colour emoji face+    /// even though that face maps no variation selector.+    ///     /// Cached per (character set, excluded face, bold, italic) and dropped when the loaded faces     /// change, like [`Self::fallback_for`], so relayout doesn't repeat the search.     pub fn fallback_for_run(&self, chars: &[char], exclude: u32, bold: bool, italic: bool) -> Option<Arc<FontFace>> {-        if chars.is_empty() {+        let mut set: Vec<char> = chars.iter().copied().filter(|c| !crate::is_default_ignorable(*c)).collect();+        if set.is_empty() {             return None;         }-        let mut set: Vec<char> = chars.to_vec();         set.sort_unstable();         set.dedup();         let key = (set, exclude, bold, italic);@@ -1791,6 +1795,33 @@ impl FontDb {         found     } +    /// A face drawing an emoji presentation sequence (`1️⃣`, `⚖️`, `👩‍💻`) as the one emoji it is:+    /// the first colour emoji family that maps every character of the sequence the shaper draws+    /// (the variation selectors and joiners are the shaper's own), else the run's fallback face.+    /// `None` when no loaded face covers the characters.+    ///+    /// Not cached: the colour families are tried first, a handful of lookups, and the run cache+    /// under them already keeps the search cheap.+    pub fn color_emoji_face(&self, chars: &[char], exclude: u32, bold: bool, italic: bool) -> Option<Arc<FontFace>> {+        let need: Vec<char> = chars.iter().copied().filter(|c| !c.is_control() && !c.is_whitespace() && !crate::is_default_ignorable(*c)).collect();+        if need.is_empty() {+            return None;+        }+        let style = match (bold, italic) {+            (true, true) => "Bold Italic",+            (true, false) => "Bold",+            (false, true) => "Italic",+            (false, false) => "Regular",+        };+        for family in crate::COLOR_EMOJI {+            let face = self.face(family, style);+            if face.family.eq_ignore_ascii_case(family) && need.iter().all(|c| face.covers(*c)) {+                return Some(face);+            }+        }+        self.fallback_for_run(&need, exclude, bold, italic)+    }+     /// The face for a run, and the number of loaded faces the search last chose from.     fn search_run(&self, chars: &[char], exclude: u32, bold: bool, italic: bool) -> (Option<Arc<FontFace>>, usize) {         #[cfg_attr(target_arch = "wasm32", allow(unused_mut))]@@ -2156,6 +2187,31 @@ mod tests {         assert!(db.run_fallbacks.read().unwrap().map.is_empty());     } +    #[test]+    #[cfg(not(target_arch = "wasm32"))]+    fn an_emoji_sequence_prefers_a_colour_emoji_face() {+        let db = FontDb::global();+        let colour = crate::COLOR_EMOJI.iter().find(|f| db.face(f, "Regular").family.eq_ignore_ascii_case(f)).copied();+        let Some(colour) = colour else {+            eprintln!("skipped: no colour emoji font installed");+            return;+        };+        let primary = db.face(FALLBACK_FAMILY, "Regular");+        // "1️⃣": the keycap is the digit and the enclosing mark; the variation selector takes no+        // glyph of its own, so a face that maps the two characters draws it as one emoji.+        let keycap: Vec<char> = "1\u{FE0F}\u{20E3}".chars().collect();+        let face = db.color_emoji_face(&keycap, primary.id(), false, false).expect("a colour emoji face");+        assert!(face.family.eq_ignore_ascii_case(colour), "{} is not {colour}", face.family);+        assert!(face.covers('1') && face.covers('\u{20E3}'));+        // The variation selector neither chooses a face nor disqualifies one.+        assert_eq!(+            db.fallback_for_run(&['1', '\u{FE0F}'], primary.id(), false, false).map(|f| f.id()),+            db.fallback_for_run(&['1'], primary.id(), false, false).map(|f| f.id())+        );+        // A text-presentation cluster is not an emoji sequence and keeps the ordinary fallback.+        assert!(!crate::emoji_sequence(&"\u{2764}\u{FE0E}".chars().collect::<Vec<_>>()));+    }+     /// The issue's run (#1549): Thai letters and digits, Latin letters, digits and the baht sign.     const THAI_RUN: &str = "[D] น้ำ ค่ำ ปั้น ฝั่ง ปู่ ๑๒๓๔๕๖๗๘๙๐ ฿1,250.50"; diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs--- a/crates/fonts/src/lib.rs+++ b/crates/fonts/src/lib.rs@@ -21,7 +21,7 @@ pub use fontdb::{ pub use harfrust::Feature; use harfrust::{Direction, ShapeOptions, Tag, UnicodeBuffer}; pub use kurbo::BezPath;-pub use script::{fallback_rank, is_emoji, needs_layout_tables, script_fallbacks};+pub use script::{COLOR_EMOJI, emoji_sequence, fallback_rank, is_default_ignorable, is_emoji, needs_layout_tables, script_fallbacks}; use skrifa::MetadataProvider; use skrifa::instance::Size; pub use ui_fallback::{SystemUiFont, is_cjk, system_cjk_ui_font, system_cjk_ui_font_for, ui_needs_system_cjk, ui_uncovered_cjk};diff --git a/crates/fonts/src/script.rs b/crates/fonts/src/script.rs--- a/crates/fonts/src/script.rs+++ b/crates/fonts/src/script.rs@@ -94,7 +94,32 @@ pub fn script_fallbacks(c: char) -> &'static [&'static str] {  /// The platforms' colour emoji fonts. Emoji prefer them over the monochrome symbol fonts that /// also cover many emoji (Windows' Segoe UI Symbol has ✅, so it drew in black and white).-const COLOR_EMOJI: &[&str] = &["Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji"];+pub const COLOR_EMOJI: &[&str] = &["Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji"];++/// Characters a shaper draws no glyph for of its own: variation selectors, the joiners, the tags+/// and the other default-ignorables. A face that lacks one is not missing a glyph, so they never+/// choose a face and never disqualify one ([`FontFace::covers`](crate::FontFace::covers)).+pub fn is_default_ignorable(c: char) -> bool {+    matches!(+        u32::from(c),+        0x00AD // soft hyphen+            | 0x034F // combining grapheme joiner+            | 0x061C // Arabic letter mark+            | 0x115F..=0x1160 // Hangul fillers+            | 0x17B4..=0x17B5 // Khmer inherent vowels+            | 0x180B..=0x180F // Mongolian free variation selectors, vowel separator+            | 0x200B..=0x200F // zero-width space, joiners, direction marks+            | 0x202A..=0x202E // bidi embedding and override controls+            | 0x2060..=0x206F // word joiner, invisible operators, bidi isolates+            | 0x3164 // Hangul filler+            | 0xFE00..=0xFE0F // variation selectors 1–16+            | 0xFEFF // zero-width no-break space+            | 0xFFA0 // halfwidth Hangul filler+            | 0x1BCA0..=0x1BCA3 // shorthand format controls+            | 0x1D173..=0x1D17A // musical format controls+            | 0xE0000..=0xE0FFF // tags and variation selectors supplement+    )+}  /// Characters drawn as emoji by default (Unicode `Emoji_Presentation`): the BMP ones listed in /// emoji-data.txt, and the pictograph blocks from U+1F000.@@ -139,6 +164,38 @@ pub fn is_emoji(c: char) -> bool {     ) } +/// Whether these characters, one grapheme cluster, are an emoji **presentation sequence**: the+/// text asks for an emoji drawing of characters that are not emoji by themselves — a variation+/// selector-16 (`⚖️`), a keycap (`1️⃣`), a ZWJ sequence, a flag or a skin-toned emoji. Such a+/// cluster is shaped as one emoji in a colour emoji font, whose ligature draws the whole thing;+/// the text font would draw the base and leave the keycap or the joiner without a glyph.+///+/// `false` for text presentation (`❤︎`, U+FE0E) and for a lone emoji, which+/// [`is_emoji`] already sends to a colour emoji face character by character.+pub fn emoji_sequence(chars: &[char]) -> bool {+    let mut selectors = 0u8;+    let mut emoji = false;+    let mut regional = 0usize;+    for c in chars.iter().copied() {+        emoji |= is_emoji(c);+        match u32::from(c) {+            0xFE0F => selectors |= 1,             // VARIATION SELECTOR-16: emoji presentation+            0x20E3 => selectors |= 2,             // COMBINING ENCLOSING KEYCAP+            0x200D => selectors |= 4,             // ZERO WIDTH JOINER+            0x1F3FB..=0x1F3FF => selectors |= 8,  // emoji modifier (skin tone)+            0xE0020..=0xE007F => selectors |= 16, // tag characters (subdivision flags)+            0x1F1E6..=0x1F1FF => regional += 1,   // regional indicators (flags)+            _ => {}+        }+    }+    selectors & 1 != 0+        || selectors & 2 != 0+        || (selectors & 4 != 0 && emoji)+        || regional >= 2+        || (selectors & 8 != 0 && emoji)+        || (selectors & 16 != 0 && emoji)+}+ /// How well `family` suits `c` as a fallback: 0 for the first preferred family, then the others /// in order; then families named after the script ("Noto Sans Tamil"); then everything else. pub fn fallback_rank(c: char, family: &str) -> usize {@@ -195,4 +252,33 @@ mod tests {             assert!(!is_emoji(c), "{c}");         }     }++    #[test]+    fn emoji_presentation_sequences_are_recognised() {+        let seq = |s: &str| emoji_sequence(&s.chars().collect::<Vec<_>>());+        // Keycaps (with and without the variation selector), emoji presentation of a text-default+        // symbol, a ZWJ sequence, a flag, a skin tone and a tag sequence.+        assert!(seq("1\u{FE0F}\u{20E3}"));+        assert!(seq("1\u{20E3}"));+        assert!(seq("\u{2696}\u{FE0F}"));+        assert!(seq("\u{1F469}\u{200D}\u{1F4BB}"));+        assert!(seq("\u{1F1E7}\u{1F1F7}"));+        assert!(seq("\u{1F44D}\u{1F3FD}"));+        assert!(seq("\u{1F3F4}\u{E0067}\u{E0062}\u{E0073}\u{E0063}\u{E0074}\u{E007F}"));+        // Text presentation, plain text and a lone joiner are not sequences.+        assert!(!seq("\u{2764}\u{FE0E}"));+        assert!(!seq("1"));+        assert!(!seq("Tema central"));+        assert!(!seq("\u{1F4D8}"));+        assert!(!seq("\u{200D}"));+    }++    #[test]+    fn default_ignorables_never_choose_a_face() {+        assert!(is_default_ignorable('\u{FE0F}') && is_default_ignorable('\u{FE0E}'));+        assert!(is_default_ignorable('\u{200D}'));+        // The enclosing keycap is drawn by the emoji's ligature: it does need a face.+        assert!(!is_default_ignorable('\u{20E3}'));+        assert!(!is_default_ignorable('1') && !is_default_ignorable('\u{1F4D8}'));+    } }diff --git a/crates/formats/src/odt.rs b/crates/formats/src/odt.rs--- a/crates/formats/src/odt.rs+++ b/crates/formats/src/odt.rs@@ -2638,6 +2638,11 @@ pub fn import(bytes: &[u8]) -> Result<Document, String> {     if let Some(t) = track {         doc.settings.track_changes = t;     }+    // ODT's text language is not read yet (a known limit of this importer): don't let the built-in+    // sheet's `en-US` look like the file's own language, or a Portuguese document would be+    // proofed as English. With no language the app's proofing default applies (see+    // `Session`/`adopt_default_language`).+    doc.styles.default_chr.lang = None;     Ok(doc) } @@ -2746,6 +2751,16 @@ fn apply_layout(a: &Attrs, sec: &mut SectionProps) { mod tests {     use super::*; +    /// An ODT that names no language does not inherit the built-in sheet's en-US, so the app's+    /// proofing default (pt-BR on a Portuguese interface) applies instead of English.+    #[test]+    fn imported_odt_has_no_default_language_of_its_own() {+        let mut d = Document::new();+        d.ensure_nonempty();+        let back = super::import(&super::export(&d).unwrap()).unwrap();+        assert_eq!(back.styles.default_chr.lang, None, "the file names no language");+    }+     #[test]     fn escaper_drops_noncharacters() {         assert_eq!(x("a\u{FFFF}b\u{FFFE}c\u{1}d\te"), "abcd\te");diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs--- a/crates/layout/src/para.rs+++ b/crates/layout/src/para.rs@@ -626,11 +626,17 @@ struct SegKey {     complex: bool,     /// Odd bidi level: shaped right to left.     rtl: bool,+    /// A cluster drawn as one emoji in a colour emoji face (see [`emoji_sequence`]).+    emoji: bool, }  impl SegKey {     fn same(&self, o: &SegKey) -> bool {-        self.face.map(|f| f.id()) == o.face.map(|f| f.id()) && self.small == o.small && self.complex == o.complex && self.rtl == o.rtl+        self.face.map(|f| f.id()) == o.face.map(|f| f.id())+            && self.small == o.small+            && self.complex == o.complex+            && self.rtl == o.rtl+            && self.emoji == o.emoji     } } @@ -981,40 +987,66 @@ impl<'a> Builder<'a> {             }         };         // Split by font coverage (fallback faces), small caps case, script formatting and direction.+        // Emoji presentation sequences ("1️⃣", "⚖️", "👩‍💻") are drawn as the one emoji they are:+        // the whole cluster takes a colour emoji face covering it, where the text face would draw+        // its base and leave the keycap (U+20E3) or the joined emoji without a glyph.+        let clusters = wordcraft_doc::cluster::cluster_starts(text);+        let mut cluster = 0usize;+        let mut cluster_end = 0usize;+        let mut cluster_face: Option<FaceRef> = None;         let mut seg_start = 0;         let mut cur: Option<SegKey> = None;         let mut segs: Vec<(usize, usize, SegKey)> = Vec::new();         for (i, c) in text.char_indices() {             let rtl = use_levels && levels.get(lbase + i).is_some_and(|l| l % 2 == 1);+            if i >= cluster_end {+                while clusters.get(cluster + 1).is_some_and(|s| *s <= i) {+                    cluster += 1;+                }+                let start = clusters.get(cluster).copied().unwrap_or(i);+                cluster_end = clusters.get(cluster + 1).copied().unwrap_or(text.len());+                let chars: Vec<char> = text.get(start..cluster_end).unwrap_or("").chars().collect();+                cluster_face = wordcraft_fonts::emoji_sequence(&chars)+                    .then(|| wordcraft_fonts::FontDb::global().color_emoji_face(&chars, primary.id(), rc.bold, rc.italic))+                    .flatten()+                    .map(|f| FaceRef::of(&f));+            }+            // An emoji cluster keeps its own face and its digits as typed: a numeral mode that+            // rewrote them would break the keycap the shaper draws.+            let emoji = cluster_face.filter(|_| i < cluster_end);+            let prev_emoji = cur.is_some_and(|k| k.emoji);             // A symbol-font character becomes its Unicode symbol (#776).             let c = show(c);             // Displayed form (Word › Numeral): one char in, one char out. Coverage and the             // fallback face are picked for the displayed char, so Hindi digits land in an             // Arabic-capable face and Western digits in a Latin one.-            let dc = self.numeral_char(&context, i, c, rtl);+            let dc = if emoji.is_some() { c } else { self.numeral_char(&context, i, c, rtl) };             let mark = is_mark(c);             let complex = match cur {                 Some(k) if mark => k.complex,                 _ => rc_cs.is_some() && rc.uses_complex(dc),             };             let prim = if complex { primary_cs.unwrap_or(primary) } else { primary };-            let face = match cur {-                Some(k) if mark => k.face,-                _ if !complex && east_asian(c) && primary_ea.is_some_and(|f| f.covers(c)) => primary_ea,-                // The run's one substitute face for a missing Thai font (#1549).-                _ if !c.is_control() && c != SOFT_HYPHEN && run_face.is_some_and(|f| f.covers(dc)) => run_face,-                _ if prim.covers(dc) || c.is_whitespace() || c.is_control() || c == SOFT_HYPHEN => None,-                // Stay in the fallback face the word started in while it covers the letters.-                Some(SegKey { face: Some(f), .. }) if f.covers(dc) => Some(f),-                // The run's unified East Asian face, when one face covers the whole run.-                _ if !complex && ea_face.is_some() && crate::kinsoku::is_east_asian(c) => ea_face,-                _ => {-                    let (bold, italic) = if complex { (rc.bold_cs, rc.italic_cs) } else { (rc.bold, rc.italic) };-                    wordcraft_fonts::FontDb::global().fallback_styled(dc, prim.id(), bold, italic).map(|f| FaceRef::of(&f))-                }+            let face = match emoji {+                Some(f) => Some(f),+                None => match cur {+                    Some(k) if mark => k.face,+                    _ if !complex && east_asian(c) && primary_ea.is_some_and(|f| f.covers(c)) => primary_ea,+                    // The run's one substitute face for a missing Thai font (#1549).+                    _ if !c.is_control() && c != SOFT_HYPHEN && run_face.is_some_and(|f| f.covers(dc)) => run_face,+                    _ if prim.covers(dc) || c.is_whitespace() || c.is_control() || c == SOFT_HYPHEN => None,+                    // Stay in the fallback face the word started in while it covers the letters.+                    Some(SegKey { face: Some(f), .. }) if !prev_emoji && f.covers(dc) => Some(f),+                    // The run's unified East Asian face, when one face covers the whole run.+                    _ if !complex && ea_face.is_some() && crate::kinsoku::is_east_asian(c) => ea_face,+                    _ => {+                        let (bold, italic) = if complex { (rc.bold_cs, rc.italic_cs) } else { (rc.bold, rc.italic) };+                        wordcraft_fonts::FontDb::global().fallback_styled(dc, prim.id(), bold, italic).map(|f| FaceRef::of(&f))+                    }+                },             };             let small = rc.small_caps && !rc.caps && c.is_lowercase();-            let k = SegKey { face, small, complex, rtl };+            let k = SegKey { face, small, complex, rtl, emoji: emoji.is_some() };             match cur {                 Some(p) if p.same(&k) => {}                 Some(p) => {@@ -1047,7 +1079,7 @@ impl<'a> Builder<'a> {             // Displayed text: numeral substitution is 1 char → 1 char and a capital may be             // several; `map_starts` holds where each document char's displayed form starts and             // `orig_of` maps it back to the char's original byte offset (+ a `sub.len()` sentinel).-            let substituted = expands || sub.char_indices().any(|(i, c)| self.numeral_char(&context, a + i, c, key.rtl) != c);+            let substituted = !key.emoji && (expands || sub.char_indices().any(|(i, c)| self.numeral_char(&context, a + i, c, key.rtl) != c));             let mut mapped = String::new();             let mut map_starts: Vec<usize> = Vec::new();             let mut orig_of: Vec<usize> = Vec::new();diff --git a/crates/render/src/emoji.rs b/crates/render/src/emoji.rsnew file mode 100644--- /dev/null+++ b/crates/render/src/emoji.rs@@ -0,0 +1,192 @@+//! Colour emoji for the interface: the same emoji fonts and colour-glyph path the page draws with+//! ([`crate::color_glyph`]), rasterised into a small straight-alpha RGBA image that the UI can show+//! as a texture. The Navigation pane uses it for the emoji in heading labels, which the interface+//! fonts only have monochrome.++use std::sync::Arc;++use vello_cpu::kurbo::{Affine, Rect};+use vello_cpu::{PixmapMut, Resources};++use crate::color_glyph::{self, Drawable};+use crate::context_pool::CONTEXTS;++/// A colour emoji raster: straight (not premultiplied) RGBA, row-major, top-down.+#[derive(Clone, Debug, PartialEq, Eq)]+pub struct EmojiImage {+    pub width: u32,+    pub height: u32,+    pub rgba: Vec<u8>,+}++/// The largest side an emoji raster may have, pixels: the UI asks for a line's height, not more.+const MAX_SIDE: u32 = 512;++/// `text` (an emoji or an emoji sequence such as `⚖️` or `👩‍💻`) drawn from a colour emoji font,+/// about `px` pixels tall (the em square), cropped to its ink, in straight-alpha RGBA. `None` when+/// the system has no colour emoji font or the glyphs carry no colour data, so the caller keeps the+/// interface font's monochrome glyph.+pub fn emoji_image(text: &str, px: u32) -> Option<EmojiImage> {+    let px = px.clamp(4, MAX_SIDE) as f64;+    let chars: Vec<char> = text.chars().collect();+    if chars.iter().all(|c| c.is_whitespace() || c.is_control()) {+        return None;+    }+    let face = wordcraft_fonts::FontDb::global().color_emoji_face(&chars, u32::MAX, false, false)?;+    // A monochrome face (the run fallback when no colour emoji family is installed) would draw a+    // flat silhouette: the interface font's own glyph is better than that.+    if !wordcraft_fonts::COLOR_EMOJI.contains(&face.family.as_str()) {+        return None;+    }+    let glyphs = wordcraft_fonts::shape(&face, text, &[], |c| c);+    if glyphs.is_empty() {+        return None;+    }+    let upem = face.upem;+    if !(upem.is_finite() && upem > 0.0) {+        return None;+    }+    let scale = px / upem;++    // Drawables with their pen positions (font units, y down from the baseline), and the union of+    // their boxes: the image is sized from that, then cropped to the ink.+    let mut pen = 0.0f64;+    let mut placed: Vec<(Arc<Drawable>, (f64, f64))> = Vec::new();+    let mut bounds: Option<Rect> = None;+    for g in &glyphs {+        let origin = (pen + g.x_offset as f64, g.y_offset as f64);+        if let Some(d) = color_glyph::drawable(&face, g.gid, px, true) {+            let b = match &*d {+                Drawable::Bitmap { rect, .. } => Rect::new(rect.x0 + origin.0, rect.y0 + origin.1, rect.x1 + origin.0, rect.y1 + origin.1),+                // COLR glyphs have no bitmap box: take the em square, trimmed later by the ink.+                Drawable::Layers(_) => Rect::new(origin.0 - 0.1 * upem, origin.1 - 0.95 * upem, origin.0 + 1.1 * upem, origin.1 + 0.1 * upem),+            };+            bounds = Some(match bounds {+                Some(u) => u.union(b),+                None => b,+            });+            placed.push((d, origin));+        }+        pen += g.x_advance as f64;+    }+    let b = bounds?;+    // A wide sequence (a ZWJ family at 512 px) can need more than MAX_SIDE: shrink the drawing so+    // its ink fits the image instead of being clipped at the right or bottom edge.+    let raw_w = b.width() * scale + 2.0;+    let raw_h = b.height() * scale + 2.0;+    let shrink = (MAX_SIDE as f64 / raw_w).min(MAX_SIDE as f64 / raw_h).min(1.0);+    let scale = scale * shrink;+    let w = (b.width() * scale).ceil() + 2.0;+    let h = (b.height() * scale).ceil() + 2.0;+    if !(w.is_finite() && h.is_finite() && w >= 1.0 && h >= 1.0) {+        return None;+    }+    let (w, h) = ((w as u32).clamp(1, MAX_SIDE), (h as u32).clamp(1, MAX_SIDE));++    let mut ctx = CONTEXTS.take(w as u16, h as u16);+    ctx.set_transform(Affine::IDENTITY);+    // Font units (pen at the origin) → the image, with one pixel of margin.+    let to_image = Affine::translate((1.0 - b.x0 * scale, 1.0 - b.y0 * scale)) * Affine::scale(scale);+    for (d, origin) in &placed {+        color_glyph::draw(&mut ctx, d, to_image * Affine::translate(*origin), upem, vello_cpu::peniko::Color::WHITE, 1.0);+    }+    ctx.flush();+    let mut pixels = vec![0u8; w as usize * h as usize * 4];+    let mut res = Resources::new();+    if let Some(pm) = PixmapMut::new(w as u16, h as u16, &mut pixels) {+        ctx.render(pm, &mut res);+    }+    CONTEXTS.give_back(ctx);++    crop_straight(&pixels, w, h)+}++/// Crop premultiplied RGBA rows to the ink and un-premultiply them.+fn crop_straight(pixels: &[u8], w: u32, h: u32) -> Option<EmojiImage> {+    let (mut x0, mut y0, mut x1, mut y1) = (u32::MAX, u32::MAX, 0u32, 0u32);+    for y in 0..h {+        for x in 0..w {+            if pixels[((y * w + x) * 4 + 3) as usize] != 0 {+                x0 = x0.min(x);+                y0 = y0.min(y);+                x1 = x1.max(x);+                y1 = y1.max(y);+            }+        }+    }+    if x0 == u32::MAX {+        return None;+    }+    let (cw, ch) = (x1 - x0 + 1, y1 - y0 + 1);+    let mut rgba = Vec::with_capacity((cw * ch * 4) as usize);+    for y in y0..=y1 {+        for x in x0..=x1 {+            let i = ((y * w + x) * 4) as usize;+            let a = pixels[i + 3] as u32;+            let straight = |v: u8| if a == 0 || a == 255 { v } else { ((v as u32 * 255 + a / 2) / a).min(255) as u8 };+            rgba.extend_from_slice(&[straight(pixels[i]), straight(pixels[i + 1]), straight(pixels[i + 2]), a as u8]);+        }+    }+    Some(EmojiImage { width: cw, height: ch, rgba })+}++#[cfg(test)]+mod tests {+    use super::*;++    fn has_colour_emoji() -> bool {+        wordcraft_fonts::FontDb::global().color_emoji_face(&['📘'], u32::MAX, false, false).is_some()+    }++    /// A colour emoji font, when the system has one, gives a non-empty image with actual colour in+    /// it — what the Navigation pane paints over the interface font's monochrome glyph.+    #[test]+    fn a_colour_emoji_rasterises_in_colour() {+        if !has_colour_emoji() {+            return;+        }+        let img = emoji_image("📘", 32).expect("the blue book rasterises");+        assert!(img.width > 4 && img.height > 4, "cropped to the ink: {img:?}");+        assert_eq!(img.rgba.len(), (img.width * img.height * 4) as usize);+        let colourful = img.rgba.chunks(4).any(|p| {+            p[3] > 200 && {+                let (hi, lo) = (*p[0..3].iter().max().unwrap(), *p[0..3].iter().min().unwrap());+                hi - lo > 40+            }+        });+        assert!(colourful, "the book is blue, not grey");+    }++    #[test]+    fn several_markers_rasterise() {+        if !has_colour_emoji() {+            return;+        }+        for m in ["📘", "🔄", "⚖️", "📊", "📝"] {+            assert!(emoji_image(m, 20).is_some(), "{m} must rasterise");+        }+    }++    /// Junk input and text-only strings give no image: the caller keeps the monochrome glyph.+    #[test]+    fn text_and_blank_strings_have_no_image() {+        assert!(emoji_image("   ", 16).is_none());+        assert!(emoji_image("", 16).is_none());+        if has_colour_emoji() {+            assert!(emoji_image("conceito", 16).is_none(), "letters are not a colour emoji");+        }+    }++    /// The size asked for is respected (the em square, about), and nonsense sizes are clamped+    /// instead of asking vello for a huge pixmap.+    #[test]+    fn the_requested_size_is_respected() {+        if !has_colour_emoji() {+            return;+        }+        let small = emoji_image("📘", 8).expect("small");+        let big = emoji_image("📘", 96).expect("big");+        assert!(big.height > small.height, "bigger size, taller image: {small:?} {big:?}");+        assert!(emoji_image("📘", 0).is_some(), "clamped, not zero");+    }+}diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs--- a/crates/render/src/lib.rs+++ b/crates/render/src/lib.rs@@ -17,6 +17,7 @@ use wordcraft_layout::display::{DisplayOptions, Draw, Stroke, page_layers}; use wordcraft_metafile::{Item, Picture, PlacedItem, Seg};  mod color_glyph;+mod emoji; pub mod icon_lib;  /// Worker threads for rasterising (0 on the web, where there are no threads).@@ -575,6 +576,8 @@ pub struct RenderOptions {     pub read_paper: Option<Rgb>, } +pub use emoji::{EmojiImage, emoji_image};+ /// Default grey for a dark page's paper. pub const DARK_PAPER: u8 = 0x33; diff --git a/crates/render/src/tests_color_glyphs.rs b/crates/render/src/tests_color_glyphs.rs--- a/crates/render/src/tests_color_glyphs.rs+++ b/crates/render/src/tests_color_glyphs.rs@@ -98,3 +98,52 @@ fn emoji_page_with(opts: &RenderOptions, cp: wordcraft_doc::CharProps) -> Option     assert_eq!(boxes.len(), 2, "both emoji use the colour emoji font");     Some((img, boxes)) }++/// A keycap sequence (`1️⃣`) is drawn as the one colour emoji it is: the emoji font's ligature+/// draws digit and enclosing keycap together, where the text font leaves a missing-glyph box+/// after the digit. Skips when no colour emoji font is installed.+#[cfg(not(target_arch = "wasm32"))]+#[test]+fn a_keycap_is_drawn_as_a_colour_emoji() {+    let keycap = "1\u{FE0F}\u{20E3}";+    let chars: Vec<char> = keycap.chars().collect();+    if FontDb::global().color_emoji_face(&chars, u32::MAX, false, false).is_none() {+        eprintln!("skipped: no colour emoji font installed");+        return;+    }+    let mut d = Document::from_text("x");+    d.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&format!("{keycap} Tema"), wordcraft_doc::CharProps::default()))];+    let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());+    let page = &l.pages[0];+    let scale = 2.0;+    let img = render_page(&d, page, scale, &RenderOptions::default());+    let mut box_of = None;+    for it in page_display(&d, page, &DisplayOptions::default()) {+        if let Draw::Glyphs { face, size, glyphs, text, .. } = it+            && COLOR_EMOJI.contains(&face.family.as_str())+        {+            // One glyph draws the whole keycap: its ligature, not the digit alone.+            assert_eq!(glyphs.len(), 1, "keycap drawn with {} glyphs in {}", glyphs.len(), face.family);+            assert_eq!(text, keycap, "the colour emoji draws the keycap text");+            let (_, x, y) = glyphs[0];+            let pad = 6.0;+            box_of = Some((+                ((x * scale - pad).max(0.0) as u32),+                (((y - size) * scale - pad).max(0.0) as u32),+                ((x + size) * scale + pad) as u32,+                (y * scale + pad) as u32,+            ));+        }+    }+    let (x0, y0, x1, y1) = box_of.expect("the keycap is drawn from the colour emoji font");+    // The keycap's badge is blue: its blue channel runs well above its red one, where the paper+    // and the black text are grey.+    let blue = (x0..x1)+        .flat_map(|x| (y0..y1).map(move |y| (x, y)))+        .filter(|(x, y)| {+            let p = img.pixel(*x, *y);+            i32::from(p[2]) - i32::from(p[0]) > 20+        })+        .count();+    assert!(blue > 30, "only {blue} blue pixels in the keycap box {x0},{y0}..{x1},{y1}");+}diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs--- a/crates/ui-egui/src/chrome.rs+++ b/crates/ui-egui/src/chrome.rs@@ -604,14 +604,20 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) {                 let lang = crate::ribbon::caret_language(app);                 let missing = crate::ribbon::caret_missing_dictionary(app);                 let unreadable = crate::ribbon::caret_unreadable_dictionary(app);-                let shown = if unreadable.is_some() {+                let shown = if !app.session.view.proofing {+                    tl!("Do not check spelling or grammar").to_string()+                } else if unreadable.is_some() {                     crate::i18n::fmt(tl!("{language} (dictionary unreadable)"), &[("language", &lang)])                 } else if missing.is_some() {                     crate::i18n::fmt(tl!("{language} (no dictionary)"), &[("language", &lang)])                 } else {                     lang.clone()                 };-                let tip = dictionary_problem(&lang, &unreadable, missing).unwrap_or_else(|| tl!("Language").to_string());+                let tip = if app.session.view.proofing {+                    dictionary_problem(&lang, &unreadable, missing).unwrap_or_else(|| tl!("Language").to_string())+                } else {+                    tl!("Spelling and grammar are not checked.").to_string()+                };                 let resp = st(ui, &shown).on_hover_text(tip);                 egui::Popup::menu(&resp).show(|ui| {                     ui.set_min_width(200.0);diff --git a/crates/ui-egui/src/options.rs b/crates/ui-egui/src/options.rs--- a/crates/ui-egui/src/options.rs+++ b/crates/ui-egui/src/options.rs@@ -472,11 +472,14 @@ fn save(app: &mut WordApp, ui: &mut Ui) { fn language(app: &mut WordApp, ui: &mut Ui) {     section(ui, "Interface language");     language_picker(app, ui);-    // #414 (detect the document's language) goes here.     ui.add_space(8.0);     section(ui, "Proofing language");+    review_language_picker(app, ui);+    note(ui, "Automatic follows the interface language. It sets the spelling and grammar dictionary for your documents.");     let lang = crate::ribbon::caret_language(app);-    let text = if crate::ribbon::caret_no_proof(app) {+    let text = if !app.session.view.proofing {+        tl!("Spelling and grammar are not checked: Review language is Off.").to_string()+    } else if crate::ribbon::caret_no_proof(app) {         tl!("The text at the insertion point is marked Do not check spelling or grammar.").to_string()     } else if !crate::ribbon::caret_language_proofed(app) {         crate::i18n::fmt(tl!("{language}: WordCraft doesn't check spelling or grammar in this language yet."), &[("language", &lang)])@@ -720,6 +723,51 @@ fn animations_picker(app: &mut WordApp, ui: &mut Ui) {     } } +/// Review language: the app's proofing default for text that names no language. The same shapes+/// as the interface picker above (Dropdown + helper line); Automatic follows the interface, Off+/// turns the live check off, and a language is used as it is. The choice is persisted in+/// `ui.json` and applies to the open document's default language at once.+fn review_language_picker(app: &mut WordApp, ui: &mut Ui) {+    use crate::ribbon::{PROOFING_LANGUAGES, language_name};+    let checking = app.session.view.proofing;+    let saved = app.session.prefs.default_language.clone();+    let resolved = saved.clone().or_else(|| app.session.editing_lang.clone()).unwrap_or_else(|| "en-US".to_string());+    let resolved_name = tl!(&language_name(Some(&resolved))).to_string();+    let automatic = crate::i18n::fmt(tl!("Automatic ({language})"), &[("language", &resolved_name)]);+    let shown = if !checking {+        tl!("Do not check spelling or grammar").to_string()+    } else if saved.is_none() {+        automatic.clone()+    } else {+        tl!(&language_name(Some(&resolved))).to_string()+    };+    let mut chosen: Option<&str> = None;+    ui.horizontal(|ui| {+        let label = ui.label(tl!("Review language:"));+        let combo = egui::ComboBox::from_id_salt("review_language").selected_text(shown).width(260.0).show_ui(ui, |ui| {+            if ui.selectable_label(checking && saved.is_none(), automatic.as_str()).clicked() {+                chosen = Some("auto");+            }+            for (tag, _) in PROOFING_LANGUAGES.iter().filter(|(t, _)| ["pt-BR", "pt-PT", "en-US", "es-ES"].contains(t)) {+                let name = tl!(&language_name(Some(tag))).to_string();+                let on = checking && saved.as_deref().is_some_and(|s| s.eq_ignore_ascii_case(tag));+                if ui.selectable_label(on, name).clicked() {+                    chosen = Some(tag);+                }+            }+            if ui.selectable_label(!checking, tl!("Do not check spelling or grammar")).clicked() {+                chosen = Some("off");+            }+        });+        combo.response.labelled_by(label.id);+    });+    if let Some(value) = chosen+        && let Err(e) = app.run("review.defaultLanguage", json!({"value": value}))+    {+        app.status(e);+    }+}+ /// Interface language: follow the system (the default) or pick one (#8). fn language_picker(app: &mut WordApp, ui: &mut Ui) {     use crate::i18n::{AUTO, Lang};@@ -897,6 +945,43 @@ mod tests {         h     } +    /// File › Options › Language: "Review language" is a dropdown (not static text). The choice+    /// is persisted, applied to the open document at once, and the status bar reads it back.+    #[test]+    fn review_language_picker_persists_and_applies() {+        use egui_kittest::kittest::Queryable;+        let mut a = app();+        a.run("ui.language", json!({"value": "en"})).unwrap();+        a.run("file.options", json!({"pane": "language"})).unwrap();+        let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(+            |ui, app: &mut crate::WordApp| {+                let ctx = ui.ctx().clone();+                app.logic(&ctx);+                app.ui(ui);+            },+            a,+        );+        h.run_steps(4);+        let combo_text =+            |h: &egui_kittest::Harness<'static, crate::WordApp>| h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Review language:").value();+        assert!(combo_text(&h).as_deref().is_some_and(|v| v.contains("Automatic")), "{:?}", combo_text(&h));+        h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Review language:").click();+        h.run_steps(3);+        h.get_by_label("Portuguese (Brazil)").click();+        h.run_steps(4);+        assert_eq!(h.state().session.prefs.default_language.as_deref(), Some("pt-BR"));+        assert_eq!(h.state().session.doc.styles.default_chr.lang.as_deref(), Some("pt-BR"), "applied to the document");+        assert!(h.state().session.view.proofing && h.state().session.prefs.check_spelling);+        assert_eq!(crate::ribbon::caret_language(h.state()), "Portuguese (Brazil)", "the status bar follows");+        // Off turns the live check off and the dropdown (and the bar) say so.+        h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Review language:").click();+        h.run_steps(3);+        h.get_by_label("Do not check spelling or grammar").click();+        h.run_steps(4);+        assert!(!h.state().session.view.proofing && !h.state().session.prefs.check_spelling);+        assert_eq!(combo_text(&h).as_deref(), Some("Do not check spelling or grammar"));+    }+     /// File › Options must not offer a way around the protected document's author check.     #[test]     fn user_name_field_disables_when_protection_starts() {diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs--- a/crates/ui-egui/src/panes.rs+++ b/crates/ui-egui/src/panes.rs@@ -7,7 +7,7 @@ use std::collections::BTreeMap;  use craft_ui::panes::{Edge, PaneContent, PaneHost, PaneLabels, PaneRequest, PaneState, Place, SidePolicy};-use egui::{Stroke, Ui, vec2};+use egui::{Stroke, Ui, pos2, vec2}; use serde_json::{Value, json}; use wordcraft_doc::numbering::{Counters, LevelSuffix}; use wordcraft_doc::section::NumFormat;@@ -520,6 +520,9 @@ fn nav(app: &mut WordApp, ui: &mut Ui) {             }         }         _ => {+            // Modelo 2: the rows touch, so the vertical trunks have no gaps between them.+            ui.scope(|ui| {+            ui.spacing_mut().item_spacing.y = 0.0;             let caret = app.session.sel.focus.clone();             let items = nav_headings(&app.session.doc);             let current = items.iter().rposition(|(path, ..)| *path <= caret.path);@@ -531,25 +534,57 @@ fn nav(app: &mut WordApp, ui: &mut Ui) {             let collapsed_flags: Vec<bool> = items.iter().enumerate().map(|(i, (p, _, _))| has_children[i] && app.ui.nav_collapsed.contains(p)).collect();             let levels: Vec<u8> = items.iter().map(|(_, lv, _)| *lv).collect();             let visible = visible_headings(&levels, &collapsed_flags);-            for (i, (path, lv, txt)) in items.into_iter().enumerate() {+            let chain = active_chain(&levels, &visible, current);+            let current_index = current;+            // The levels this heading nests under, for the tree's connector lines.+            let mut ancestors: Vec<u8> = Vec::new();+            for (i, (path, lv, txt)) in items.iter().enumerate() {                 if !visible[i] {                     continue;                 }+                while ancestors.last().is_some_and(|a| *a >= *lv) {+                    ancestors.pop();+                }+                // The last child of its parent closes the guide line under it at the row's middle.+                let last = items+                    .iter()+                    .enumerate()+                    .skip(i + 1)+                    .find(|(j, _)| visible[*j])+                    .is_none_or(|(_, (_, next, _))| *next <= *lv);                 let collapsed = collapsed_flags[i];-                let (toggle, r) = nav_heading(ui, lv, &txt, current == Some(i), has_children[i].then_some(collapsed));+                let row_id = ui.make_persistent_id(("nav_row", path));+                // The trunks of the active branch: each ancestor level whose parent row is on the+                // chain and which lies above (or at) the selected row.+                let active_guides = branch_guide_levels(&ancestors, &chain, &levels, i, current_index);+                let on_chain = chain.contains(&i);+                let (toggle, r) = nav_heading(+                    ui,+                    row_id,+                    *lv,+                    txt,+                    current == Some(i),+                    has_children[i].then_some(collapsed),+                    &ancestors,+                    last,+                    on_chain,+                    &active_guides,+                );                 if toggle {-                    if !app.ui.nav_collapsed.remove(&path) {-                        app.ui.nav_collapsed.insert(path);+                    if !app.ui.nav_collapsed.remove(path) {+                        app.ui.nav_collapsed.insert(path.clone());                     }-                } else if r.clicked() {-                    let _ = app.run("caret.set", json!({"pos": Pos { story: StoryRef::Body, path, off: 0 }}));+                } else if r.clicked() || (r.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter) || i.key_pressed(egui::Key::Space))) {+                    let _ = app.run("caret.set", json!({"pos": Pos { story: StoryRef::Body, path: path.clone(), off: 0 }}));                     // Align the chosen heading's page immediately against this pane, rather                     // than the indeterminate minimum movement from scroll_to_rect.                     app.canvas.nav_to_caret = true;                     app.canvas.scroll_to_caret = false;                     app.canvas.want_focus = true;                 }+                ancestors.push(*lv);             }+            });         }     }); }@@ -603,38 +638,301 @@ fn nav_headings(doc: &Document) -> Vec<(Path, u8, String)> {     items } -/// Constrain each heading to the available pane width so long document text cannot-/// enlarge the panel. A heading too long for the width wraps onto further lines — a word-/// wider than the pane is broken too — and the row grows with it, so the label reads whole-/// instead of ending in an ellipsis (#3015).-fn nav_heading(ui: &mut Ui, level: u8, text: &str, selected: bool, collapsed: Option<bool>) -> (bool, egui::Response) {-    ui.horizontal(|ui| {-        ui.add_space(level as f32 * 14.0);-        // The triangle is drawn in code; headings without children keep an empty slot so text aligns.-        let mut toggled = false;-        let (rect, tri) = ui.allocate_exact_size(vec2(14.0, ui.spacing().interact_size.y), egui::Sense::click());-        if let Some(c) = collapsed {-            let ctr = rect.center();-            let col = ui.visuals().text_color();-            let pts = if c {-                vec![ctr + vec2(-2.5, -4.0), ctr + vec2(-2.5, 4.0), ctr + vec2(3.0, 0.0)]-            } else {-                vec![ctr + vec2(-4.0, -2.5), ctr + vec2(4.0, -2.5), ctr + vec2(0.0, 3.0)]-            };-            ui.painter().add(egui::Shape::convex_polygon(pts, col, Stroke::NONE));-            toggled = tri.clicked();+/// Distance one heading level is indented under its parent, points.+const NAV_INDENT: f32 = 14.0;++/// Modelo 2 — Curva Orgânica Fluida: the tree's connector lines are 25% thicker than the old+/// single pixel, the active branch is drawn heavier (with a glow) and the elbow into a row is a+/// quarter circle of this radius; a parent whose children branch off gets a small node there.+const NAV_GUIDE_WIDTH: f32 = 1.25;+const NAV_ACTIVE_WIDTH: f32 = 1.5;+const NAV_ELBOW_RADIUS: f32 = 6.0;+const NAV_NODE_RADIUS: f32 = 2.25;+/// The fork slot before the triangle: the articulation node sits in its middle, and the trunk to+/// the children leaves from there (so the node is visible, as ● ▾ in the Modelo 2 diagram).+const NAV_FORK_SLOT: f32 = 8.0;++/// The row indices on the caret's branch, from the root down to the selected heading.+fn active_chain(levels: &[u8], visible: &[bool], current: Option<usize>) -> Vec<usize> {+    let mut chain = Vec::new();+    let Some(mut i) = current else { return chain };+    if !visible.get(i).copied().unwrap_or(false) {+        return chain;+    }+    chain.push(i);+    let mut level = levels[i];+    loop {+        let parent = (0..i).rev().find(|&j| visible.get(j).copied().unwrap_or(false) && levels[j] < level);+        let Some(j) = parent else { break };+        chain.push(j);+        i = j;+        level = levels[j];+    }+    chain.reverse();+    chain+}++/// The guide levels of row `i` whose trunk belongs to the active branch: an ancestor level is lit+/// when its parent row is on the chain and the row lies between that parent and the selected row,+/// which carries the light through the siblings passed on the way down.+fn branch_guide_levels(ancestors: &[u8], chain: &[usize], levels: &[u8], i: usize, current: Option<usize>) -> Vec<u8> {+    let Some(current) = current else { return Vec::new() };+    ancestors.iter().copied().filter(|a| chain.iter().any(|&k| levels.get(k) == Some(a) && k <= i) && i <= current).collect()+}++/// The colour of the keycap emoji the page draws (Noto Color Emoji's light steel blue): the pane+/// reproduces the badge it makes, so the heading looks the same in both places.+const KEYCAP_BADGE: egui::Color32 = egui::Color32::from_rgb(140, 175, 191);++/// A heading that starts with a keycap (`1️⃣`, `#️⃣`): the digit and the text after the space+/// that follows it. The interface fonts have no keycap ligature (they would draw the digit and+/// drop the enclosing mark), so the pane draws the keycap as the badge the page shows.+fn keycap_prefix(text: &str) -> Option<(char, &str)> {+    let mut it = text.char_indices();+    let (_, digit) = it.next()?;+    if !(digit.is_ascii_digit() || digit == '#' || digit == '*') {+        return None;+    }+    for (i, c) in it {+        match c {+            // The variation selector is optional (a keycap may be digit + enclosing keycap).+            '\u{FE0E}' | '\u{FE0F}' => {}+            '\u{20E3}' => {+                let end = i + c.len_utf8();+                let rest = text.get(end..).unwrap_or("");+                return Some((digit, rest.strip_prefix(' ').unwrap_or(rest)));+            }+            _ => return None,         }-        let label = egui::RichText::new(text).font(if level == 0 { semibold(12.5) } else { regular(12.5) });-        let width = ui.available_width();-        let r = ui.add(-            // The trailing grow atom takes the spare width, so the text starts flush left-            // under its parent instead of centred, as in Word (#1199). A minimum height-            // keeps short headings on one row; a wrapped one becomes as tall as it needs.-            egui::Button::selectable(selected, (label, egui::Atom::grow())).wrap().min_size(vec2(width, ui.spacing().interact_size.y)),-        );-        (toggled, r)-    })-    .inner+    }+    None+}++/// Byte ranges of the emoji sequences in a heading's text, in order.+fn emoji_clusters(text: &str) -> Vec<(usize, usize)> {+    let starts = wordcraft_doc::cluster::cluster_starts(text);+    let mut out = Vec::new();+    for (i, &s) in starts.iter().enumerate() {+        let e = starts.get(i + 1).copied().unwrap_or(text.len());+        if let Some(c) = text.get(s..e) {+            // Presentation sequences (`⚖️`, keycaps, ZWJ) and characters that are emoji by+            // themselves (`📘`): the pane paints both from the colour emoji font.+            let chars: Vec<char> = c.chars().collect();+            if wordcraft_fonts::emoji_sequence(&chars) || chars.first().is_some_and(|&f| wordcraft_fonts::is_emoji(f)) {+                out.push((s, e));+            }+        }+    }+    out+}++/// The interface's colour image for an emoji, kept between frames (rasterising it again every+/// frame would burn the CPU for nothing: the pane redraws while typing).+fn emoji_texture(ui: &Ui, text: &str, px: u32) -> Option<egui::TextureHandle> {+    let id = egui::Id::unique(("nav_emoji", text, px));+    if let Some(t) = ui.ctx().data(|d| d.get_temp::<egui::TextureHandle>(id)) {+        return Some(t);+    }+    let img = wordcraft_render::emoji_image(text, px)?;+    let color = egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.rgba);+    let tex = ui.ctx().load_texture(format!("nav-emoji-{text}"), color, egui::TextureOptions::LINEAR);+    ui.ctx().data_mut(|d| d.insert_temp(id, tex.clone()));+    Some(tex)+}++/// Paint the colour images of the emoji in `text` over the glyphs the pane's (transparent) text+/// found for them. The interface fonts have emoji only in monochrome; the page draws them from the+/// colour font, and the Navigator follows the page rather than showing grey shapes.+fn paint_emoji(ui: &Ui, pos: egui::Pos2, galley: &egui::Galley, text: &str, em: f32) {+    let ppp = ui.ctx().pixels_per_point();+    let mut glyphs = galley.rows.iter().flat_map(|r| r.glyphs.iter());+    for (s, e) in emoji_clusters(text) {+        let Some(first) = text.get(s..e).and_then(|c| c.chars().next()) else { continue };+        // One glyph per drawn character, in order: the cluster's first character finds its box.+        let Some(g) = glyphs.find(|g| g.chr == first) else { continue };+        let baseline = pos.y + g.pos.y;+        // An emoji fills its em square, not the (taller) line box: size it like the label's text so+        // it sits with the words instead of swelling past them.+        let target_h = (em * 1.05).max(1.0);+        let top = baseline - target_h * 0.86;+        let Some(tex) = emoji_texture(ui, &text[s..e], (target_h * ppp).round().max(1.0) as u32) else { continue };+        let size = tex.size_vec2();+        let aspect = (size.x / size.y.max(1.0)).clamp(0.2, 5.0);+        let rect = egui::Rect::from_min_size(egui::pos2(pos.x + g.pos.x, top), vec2(target_h * aspect, target_h));+        ui.painter().image(tex.id(), rect, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE);+    }+}++/// One heading in the pane: its level's indent, the expand/collapse triangle, the keycap badge+/// when the heading starts with one, and the label — one line, ended with an ellipsis when it+/// doesn't fit, as Writer's Navigator does (the whole heading shows on hover). `guides` are the+/// levels of the headings this one nests under: connector lines run down their slots and across+/// to this row, and `last` closes the line under its parent at the row's middle.+///+/// The row paints itself rather than using a Button: the label mixes text with colour emoji+/// images, which a single widget's text cannot. `id` keeps its hover/selection and click stable.+#[allow(clippy::too_many_arguments)]+fn nav_heading(+    ui: &mut Ui,+    id: egui::Id,+    level: u8,+    text: &str,+    selected: bool,+    collapsed: Option<bool>,+    guides: &[u8],+    last: bool,+    active: bool,+    active_guides: &[u8],+) -> (bool, egui::Response) {+    let t = Tokens::get(ui.ctx());+    let row_h = ui.spacing().interact_size.y;+    let keycap = keycap_prefix(text);+    let label_text = keycap.map_or(text, |(_, rest)| rest);+    // How wide the label may be: the row less the indent, the triangle slot, the keycap badge+    // and the gaps between them. Budgeted here rather than from the cursor part-way through the+    // row, so the label can never make the row wider than the pane (which used to widen it).+    let gap = ui.spacing().item_spacing.x;+    let badge_w = keycap.map_or(0.0, |_| (row_h * 0.78).max(9.0) * 1.08 + 5.0);+    let row_w = ui.available_width().max(0.0);+    let label_w = (row_w - level as f32 * NAV_INDENT - NAV_FORK_SLOT - NAV_INDENT - badge_w - 3.0 * gap).max(24.0);++    // The row goes in first so its hover/selection fill is painted behind the text.+    let (row, _) = ui.allocate_exact_size(vec2(row_w, row_h), egui::Sense::hover());+    let hovered = ui.ctx().read_response(id).is_some_and(|r| r.hovered());+    if selected {+        ui.painter().rect_filled(row, 4.0, ui.visuals().selection.bg_fill);+    } else if hovered {+        ui.painter().rect_filled(row, 4.0, ui.visuals().widgets.hovered.weak_bg_fill);+    }++    // The label: text, with the emoji laid out transparent so their colour images can sit on top.+    let ink = egui::TextFormat { font_id: regular(12.5), color: ui.visuals().text_color(), ..Default::default() };+    let clear = egui::TextFormat { font_id: regular(12.5), color: egui::Color32::TRANSPARENT, ..Default::default() };+    let mut job = egui::text::LayoutJob::default();+    let mut prev = 0;+    for (s, e) in emoji_clusters(label_text) {+        if prev < s {+            job.append(&label_text[prev..s], 0.0, ink.clone());+        }+        job.append(&label_text[s..e], 0.0, clear.clone());+        prev = e;+    }+    if prev < label_text.len() {+        job.append(&label_text[prev..], 0.0, ink.clone());+    }+    job.wrap = egui::text::TextWrapping { max_width: label_w, max_rows: 1, break_anywhere: true, overflow_character: Some('\u{2026}') };+    let galley = ui.painter().layout_job(job);++    let mut toggled = false;+    let label = ui+        .scope_builder(egui::UiBuilder::new().max_rect(row).layout(egui::Layout::left_to_right(egui::Align::Center)), |ui| {+            ui.add_space(level as f32 * NAV_INDENT);+            // The articulation node's slot: the Modelo 2 diagram puts ●/○ before the triangle, and+            // the trunk to the children leaves from its middle; rows without children keep it empty+            // so every label aligns.+            let (fork, _) = ui.allocate_exact_size(vec2(NAV_FORK_SLOT, row_h), egui::Sense::hover());+            let _ = fork;+            // The triangle is drawn in code; headings without children keep an empty slot so text aligns.+            let (rect, tri) = ui.allocate_exact_size(vec2(NAV_INDENT, row_h), egui::Sense::click());+            if let Some(c) = collapsed {+                let ctr = rect.center();+                let col = ui.visuals().text_color();+                let pts = if c {+                    vec![ctr + vec2(-2.5, -4.0), ctr + vec2(-2.5, 4.0), ctr + vec2(3.0, 0.0)]+                } else {+                    vec![ctr + vec2(-4.0, -2.5), ctr + vec2(4.0, -2.5), ctr + vec2(0.0, 3.0)]+                };+                ui.painter().add(egui::Shape::convex_polygon(pts, col, Stroke::NONE));+                toggled = tri.clicked();+            }+            if let Some((digit, _)) = keycap {+                // The keycap the page draws: a rounded square in the accent colour with the digit on+                // it, at the row's text height.+                let h = (row_h * 0.78).max(9.0);+                let (slot, _) = ui.allocate_exact_size(vec2(h * 1.08, row_h), egui::Sense::hover());+                let badge = egui::Rect::from_center_size(slot.center(), vec2(h * 1.08, h));+                ui.painter().rect_filled(badge, 2.5, KEYCAP_BADGE);+                ui.painter().text(badge.center(), egui::Align2::CENTER_CENTER, digit, semibold(h * 0.62), egui::Color32::WHITE);+                ui.add_space(5.0);+            }+            let width = ui.available_width().max(0.0);+            let (label_rect, resp) = ui.allocate_exact_size(vec2(width, row_h), egui::Sense::click());+            let pos = label_rect.left_top() + vec2(0.0, ((row_h - galley.size().y) * 0.5).max(0.0));+            ui.painter().galley(pos, galley.clone(), ui.visuals().text_color());+            paint_emoji(ui, pos, &galley, label_text, 12.5);+            // The whole heading stays the row's accessible name, and a click anywhere on the label+            // jumps to it (the caller handles it).+            resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, text));+            ui.interact(label_rect, id, egui::Sense::click())+        })+        .inner;++    if ui.ctx().memory(|m| m.has_focus(id)) {+        ui.painter().rect_stroke(row.shrink(1.0), 4.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);+    }+    // The tree's connector lines, painted under the indent and the triangle only so they never+    // run over the label. Modelo 2 — Curva Orgânica Fluida: one continuous trunk per ancestor+    // level (the rows are laid out with no vertical spacing, so there are no cuts between them),+    // a quarter-circle elbow into this row, and a small node where a parent's children branch.+    let mid = row.center().y;+    let left = row.left();+    // The trunk of a level runs through the middle of that level's fork slot; a row's own connector+    // ends there (at the node when it has children).+    let fork_x = |a: u8| left + a as f32 * NAV_INDENT + NAV_FORK_SLOT * 0.5;+    let end = fork_x(level);+    let normal = Stroke::new(NAV_GUIDE_WIDTH, t.tree_line);+    if let Some(innermost) = guides.last().copied() {+        let painter = ui.painter();+        let radius = NAV_ELBOW_RADIUS.min((row.height() * 0.5 - 1.0).max(2.0));+        // Each trunk of the active branch is lit from the root down to the selected heading: a+        // segment belongs to the branch when the selected row is inside that ancestor's subtree+        // and the segment lies above it, which includes the siblings passed on the way.+        let lit = |a: u8| active_guides.contains(&a);+        let glow = Stroke::new(NAV_ACTIVE_WIDTH + 2.0, t.accent.gamma_multiply(0.35));+        for &a in guides {+            let bottom = if last && a == innermost { mid - radius } else { row.bottom() };+            if lit(a) {+                painter.vline(fork_x(a), row.top()..=bottom, glow);+            }+            painter.vline(fork_x(a), row.top()..=bottom, if lit(a) { Stroke::new(NAV_ACTIVE_WIDTH, t.accent) } else { normal });+        }+        // The organic elbow: the trunk turns into the row through a quarter circle, never a hard+        // right angle.+        let x = fork_x(innermost);+        let elbow = if active { Stroke::new(NAV_ACTIVE_WIDTH, t.accent) } else { normal };+        // A row without children has no node or triangle; the connector runs on to where its label+        // (or keycap badge) starts, so the branch reads as joined instead of stopping short.+        let target = if collapsed.is_some() { end } else { left + level as f32 * NAV_INDENT + NAV_FORK_SLOT + NAV_INDENT };+        if active {+            painter.hline(x + radius..=target, mid, glow);+        }+        if target > x {+            let c = radius * 0.552_284_8;+            painter.add(egui::epaint::CubicBezierShape::from_points_stroke(+                [pos2(x, mid - radius), pos2(x, mid - radius + c), pos2(x + radius - c, mid), pos2(x + radius, mid)],+                false,+                egui::Color32::TRANSPARENT,+                elbow,+            ));+            painter.hline(x + radius..=target, mid, elbow);+        }+    }+    // The articulation node: filled while the group is open, hollow while it is collapsed. A stub+    // reaches from it to the first child's row, so the branch has no cut at the parent's bottom half.+    if let Some(collapsed) = collapsed {+        let centre = pos2(end, mid);+        let colour = if active { t.accent } else { t.tree_line };+        if !collapsed {+            ui.painter().vline(end, mid + NAV_NODE_RADIUS..=row.bottom(), Stroke::new(NAV_GUIDE_WIDTH, if active { t.accent } else { t.tree_line }));+        }+        if collapsed {+            ui.painter().circle_filled(centre, NAV_NODE_RADIUS, t.panel);+            ui.painter().circle_stroke(centre, NAV_NODE_RADIUS, Stroke::new(1.35, colour));+        } else {+            ui.painter().circle_filled(centre, NAV_NODE_RADIUS, colour);+        }+    }+    (toggled, label.on_hover_text(text)) }  /// Items collected by Copy and Cut, newest first; clicking one pastes it at the caret.@@ -1330,6 +1628,33 @@ mod tests {         }     } +    /// Modelo 2: every ancestor level's trunk is lit from its parent down to the selected row,+    /// including the siblings passed on the way (the CSS `.is-active-branch` path).+    #[test]+    fn branch_guides_light_the_way_from_the_root_to_the_selection() {+        let levels = [0u8, 1, 1, 2, 2, 1];+        let chain = super::active_chain(&levels, &[true; 6], Some(4));+        assert_eq!(chain, vec![0, 2, 4]);+        let g = |ancestors: &[u8], i: usize| super::branch_guide_levels(ancestors, &chain, &levels, i, Some(4));+        assert_eq!(g(&[0], 1), vec![0], "a sibling above the branch lights the root trunk");+        assert_eq!(g(&[0], 2), vec![0]);+        assert_eq!(g(&[0, 1], 3), vec![0, 1]);+        assert_eq!(g(&[0, 1], 4), vec![0, 1], "the selected row closes the path");+    }++    /// Modelo 2: the caret's branch is the chain from the root down to the selected heading, so+    /// the trunks of every ancestor level are lit through the siblings passed on the way.+    #[test]+    fn active_chain_runs_from_the_root_to_the_selected_heading() {+        let levels = [1u8, 2, 2, 3, 1];+        let visible = [true; 5];+        assert_eq!(super::active_chain(&levels, &visible, Some(3)), vec![0, 2, 3]);+        assert_eq!(super::active_chain(&levels, &visible, Some(0)), vec![0]);+        assert!(super::active_chain(&levels, &visible, None).is_empty());+        let hidden = [true, false, false, false, true];+        assert!(super::active_chain(&levels, &hidden, Some(1)).is_empty(), "a hidden heading lights nothing");+    }+     /// Ctrl and F-key shortcuts run while F6 has put the keyboard on a pane's title bar; a pane's     /// text field keeps Ctrl+H for itself.     #[test]@@ -1684,7 +2009,7 @@ mod navigation_tests {     }      #[test]-    fn long_headings_wrap_without_stretching_navigation() {+    fn long_headings_elide_without_stretching_navigation() {         // A heading that runs well past the width, and a single word wider than the pane.         let headings = ["An extremely long heading with words that must stay inside the navigation pane ".repeat(20), "x".repeat(400)];         // Draw one heading in a pane of `width`: the pane rect, the heading row's rect, the label's@@ -1696,10 +2021,11 @@ mod navigation_tests {             let mut panel_rect = egui::Rect::NOTHING;             let mut heading_rect = egui::Rect::NOTHING;             let output = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| {-                let result = egui::Panel::left("navigation_test")-                    .default_size(width)-                    .resizable(true)-                    .show(ui, |ui| egui::ScrollArea::vertical().show(ui, |ui| nav_heading(ui, level, text, true, None).1).inner);+                let result = egui::Panel::left("navigation_test").default_size(width).resizable(true).show(ui, |ui| {+                    egui::ScrollArea::vertical()+                        .show(ui, |ui| nav_heading(ui, egui::Id::unique(("t", level)), level, text, true, None, &[], true, false, &[]).1)+                        .inner+                });                 panel_rect = result.response.rect;                 heading_rect = result.inner.rect;             });@@ -1720,11 +2046,14 @@ mod navigation_tests {                 let (panel_rect, heading_rect, rows, elided) = render(width, level, text);                 assert!(panel_rect.width() < width + 20.0, "long heading expanded the {width}px pane to {panel_rect:?}");                 assert!(heading_rect.max.x <= panel_rect.max.x, "heading escaped pane: {heading_rect:?} / {panel_rect:?}");-                // The heading wraps onto further lines instead of ending in an ellipsis (#3015).-                assert!(rows > 1, "long heading must wrap onto further lines at {width}px, got {rows}");-                assert!(!elided, "long heading must not end in an ellipsis at {width}px");-                // ... and its row grows with the text, where a short heading keeps one row.-                assert!(heading_rect.height() > one_line.height(), "a wrapped heading row is taller at {width}px: {heading_rect:?} vs {one_line:?}");+                // Writer's Navigator keeps the row on one line and ends it in an ellipsis (the+                // 0.10 look); the whole heading shows on hover.+                assert_eq!(rows, 1, "long heading must stay on one line at {width}px, got {rows}");+                assert!(elided, "long heading must end in an ellipsis at {width}px");+                assert!(+                    (heading_rect.height() - one_line.height()).abs() < 0.5,+                    "an elided heading keeps one row's height at {width}px: {heading_rect:?} vs {one_line:?}"+                );             }         }     }@@ -1738,7 +2067,9 @@ mod navigation_tests {             let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(900.0, 600.0));             let mut panel_left = 0.0;             let output = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| {-                let result = egui::Panel::left("navigation_test").default_size(260.0).show(ui, |ui| nav_heading(ui, level, "Kilns", false, None));+                let result = egui::Panel::left("navigation_test")+                    .default_size(260.0)+                    .show(ui, |ui| nav_heading(ui, egui::Id::unique("t2"), level, "Kilns", false, None, &[], true, false, &[]));                 panel_left = result.response.rect.min.x;             });             let x = output@@ -1753,7 +2084,9 @@ mod navigation_tests {             x - panel_left         };         let (top, second) = (text_left(0), text_left(1));-        assert!(top < 40.0, "a top-level heading starts at the pane's left edge, not centred: {top}px in");+        // The Modelo 2 fork slot (the articulation node) sits before the triangle, so the label+        // starts a little further in than the old 22 px; the indent per level is unchanged.+        assert!(top < 56.0, "a top-level heading starts at the pane's left edge, not centred: {top}px in");         assert!((second - top - 14.0).abs() < 0.5, "a level-2 heading is indented 14px under its parent: {top} / {second}");     } diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs--- a/crates/ui-egui/src/ribbon.rs+++ b/crates/ui-egui/src/ribbon.rs@@ -800,13 +800,14 @@ fn mi_text(ui: &mut Ui, app: &mut WordApp, text: &str, id: &str, params: Value) }  /// The proofing languages the Language menu offers: tag and English name (translated when drawn).-pub(crate) const PROOFING_LANGUAGES: [(&str, &str); 16] = [+pub(crate) const PROOFING_LANGUAGES: [(&str, &str); 17] = [     ("en-US", "English (United States)"),     ("en-GB", "English (United Kingdom)"),     ("en-AU", "English (Australia)"),     ("en-CA", "English (Canada)"),     ("pl-PL", "Polish"),     ("pt-BR", "Portuguese (Brazil)"),+    ("pt-PT", "Portuguese (Portugal)"),     ("it-IT", "Italian"),     ("hr-HR", "Croatian"),     ("tr-TR", "Turkish"),diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs--- a/crates/ui-egui/src/theme.rs+++ b/crates/ui-egui/src/theme.rs@@ -264,6 +264,9 @@ pub struct Tokens {     /// Find › Reading Highlight.     pub find_highlight: Color32,     pub page_shadow: Color32,+    /// The Navigation pane's tree connectors (the guides joining a heading to its parent): thin+    /// lines that must read on the pane without competing with the headings themselves.+    pub tree_line: Color32,     /// Selection-frame handles (pictures, shapes, text boxes) and their shadow.     pub handle: Color32,     pub handle_shadow: Color32,@@ -332,6 +335,7 @@ impl Tokens {             selection: Color32::from_rgba_unmultiplied(0x3B, 0x5B, 0xDB, 0x48),             find_highlight: Color32::from_rgba_unmultiplied(0xFF, 0xE0, 0x3D, 0x90),             page_shadow: Color32::from_rgba_unmultiplied(0, 0, 0, 0x22),+            tree_line: Color32::from_rgb(0xBD, 0xBD, 0xC2),             handle: Color32::WHITE,             handle_shadow: Color32::from_rgba_unmultiplied(0, 0, 0, 0x28),             guide: Color32::from_rgb(0xC0, 0x28, 0x8C),@@ -399,6 +403,9 @@ impl Tokens {             selection: Color32::from_rgba_unmultiplied(0x7A, 0x93, 0xF0, 0x55),             find_highlight: Color32::from_rgba_unmultiplied(0xC8, 0xA8, 0x00, 0x70),             page_shadow: Color32::from_rgba_unmultiplied(0, 0, 0, 0x60),+            // Clearer than `border` on the pane (#262626): the tree's thin connectors were almost+            // invisible in the dark theme.+            tree_line: Color32::from_rgb(0x55, 0x55, 0x5E),             handle: Color32::from_rgb(0xF2, 0xF2, 0xF2),             handle_shadow: Color32::from_rgba_unmultiplied(0, 0, 0, 0x5A),             guide: Color32::from_rgb(0xE0, 0x4C, 0xB0),@@ -1053,6 +1060,9 @@ mod tests {         let exempt = |c: char, file: &str| match file {             "equation_tab.rs" => matches!(c, '≜' | '┬'),             "ribbon.rs" => c == '➢',+            // The Navigation pane paints the emoji in heading labels from the colour emoji font;+            // its variation selectors and keycap marks are painted by that image too.+            "panes.rs" => wordcraft_fonts::is_emoji(c) || wordcraft_fonts::is_default_ignorable(c),             _ => false,         };         symbols.retain(|(c, file)| !exempt(*c, file));