From 608825d51179388d9ee7e22ce6aeccce57f38693 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 17 Sep 2026 17:35:27 +0000 Subject: [PATCH] Expand Unicode math rendering with accents, symbols, and delimiters (#46266) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What changed - Render `\hat`, `\bar`, `\tilde`, `\vec`, `\dot`, and `\ddot` on single visible graphemes, preserving support for following subscripts and superscripts. - Add symbols for physics, relations, sets, logic, arrows, and integrals, including `\hbar`. - Support named delimiters, including with `\left` and `\right`, plus angle brackets written as `\left<` and `\right>`. - Preserve raw math when accent arguments are empty, invisible, or span multiple graphemes or layout rows. ## Testing Add snapshots for accents, symbols, named delimiters, and the Schrödinger equation, plus rejection tests for ambiguous accent arguments and unsupported delimiters. GitOrigin-RevId: 9dd4883cdf187835a4b4e873886c4299de488705 --- .../tui/src/markdown_render/math/render.rs | 117 +++++++++++++++++- .../src/markdown_render/math_display_tests.rs | 14 +++ .../tui/src/markdown_render/math_tests.rs | 56 +++++++++ ...de_math_schrodinger_equation_snapshot.snap | 7 ++ ...ode_math_accents_and_symbols_snapshot.snap | 17 +++ ...nicode_math_named_delimiters_snapshot.snap | 9 ++ 6 files changed, 216 insertions(+), 4 deletions(-) create mode 100644 codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__math__display_tests__unicode_math_schrodinger_equation_snapshot.snap create mode 100644 codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__math__tests__unicode_math_accents_and_symbols_snapshot.snap create mode 100644 codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__math__tests__unicode_math_named_delimiters_snapshot.snap diff --git a/codex-rs/tui/src/markdown_render/math/render.rs b/codex-rs/tui/src/markdown_render/math/render.rs index 6dc1659149..8c4181f22f 100644 --- a/codex-rs/tui/src/markdown_render/math/render.rs +++ b/codex-rs/tui/src/markdown_render/math/render.rs @@ -1,6 +1,8 @@ //! Bounded Unicode layout for a deliberately small TeX math subset. +//! Accents apply only to single graphemes so their scope survives terminal rendering. use crate::width::display_width; +use unicode_segmentation::UnicodeSegmentation; const MAX_ROWS: usize = 16; const MAX_COLUMNS: usize = 256; @@ -111,7 +113,9 @@ impl MathParser<'_> { let atom = self.atom()?; if !ch.is_whitespace() && ch != '^' && ch != '_' { // Flattening a compound base would change the scope of a following script. - has_base = atom.single().is_some_and(|text| text.chars().count() == 1); + has_base = atom + .single() + .is_some_and(|text| text.graphemes(/*is_extended*/ true).count() == 1); } result = result.join(atom)?; } @@ -245,6 +249,26 @@ impl MathParser<'_> { }; Some(Layout::text(text)) } + "hat" | "bar" | "tilde" | "vec" | "dot" | "ddot" => { + let arg = self.argument()?; + let text = arg.single()?; + if text.graphemes(/*is_extended*/ true).count() != 1 + || text.trim().is_empty() + || display_width(text) == 0 + { + return None; + } + let accent = match name { + "hat" => '\u{0302}', + "bar" => '\u{0304}', + "tilde" => '\u{0303}', + "vec" => '\u{20d7}', + "dot" => '\u{0307}', + "ddot" => '\u{0308}', + _ => unreachable!(), + }; + Some(Layout::text(format!("{text}{accent}"))) + } "mathrm" | "mathbf" | "mathit" => self.argument(), "text" | "operatorname" => { self.remaining = self.remaining.trim_start().strip_prefix('{')?; @@ -263,6 +287,19 @@ impl MathParser<'_> { match self.take()? { '.' => Some(Layout::text("")), ch @ ('(' | ')' | '[' | ']' | '|') => Some(Layout::text(ch.to_string())), + '<' => Some(Layout::text("⟨")), + '>' => Some(Layout::text("⟩")), + '\\' => { + let length = self + .remaining + .bytes() + .take_while(u8::is_ascii_alphabetic) + .count() + .max(/*other*/ 1); + let (name, remaining) = self.remaining.split_at_checked(length)?; + self.remaining = remaining; + delimiter(name).map(Layout::text) + } _ => None, } } @@ -289,13 +326,17 @@ fn symbol(name: &str) -> Option<&'static str> { "vartheta" => "ϑ", "iota" => "ι", "kappa" => "κ", + "varkappa" => "ϰ", "lambda" => "λ", "mu" => "μ", "nu" => "ν", "xi" => "ξ", "pi" => "π", + "varpi" => "ϖ", "rho" => "ρ", + "varrho" => "ϱ", "sigma" => "σ", + "varsigma" => "ς", "tau" => "τ", "upsilon" => "υ", "phi" => "ϕ", @@ -316,35 +357,103 @@ fn symbol(name: &str) -> Option<&'static str> { "Omega" => "Ω", "sum" => "∑", "prod" => "∏", + "coprod" => "∐", "int" => "∫", + "iint" => "∬", + "iiint" => "∭", + "oint" => "∮", "infty" => "∞", "partial" => "∂", "nabla" => "∇", + "hbar" => "ℏ", + "ell" => "ℓ", + "Re" => "ℜ", + "Im" => "ℑ", + "aleph" => "ℵ", + "imath" => "ı", + "jmath" => "ȷ", + "prime" => "′", + "angle" => "∠", + "dagger" => "†", + "ddagger" => "‡", "pm" => "±", "mp" => "∓", "times" => "×", "cdot" => "·", "div" => "÷", + "circ" => "∘", + "bullet" => "∙", + "oplus" => "⊕", + "otimes" => "⊗", + "odot" => "⊙", "le" | "leq" => "≤", "ge" | "geq" => "≥", "ne" | "neq" => "≠", "approx" => "≈", + "propto" => "∝", + "sim" => "∼", + "simeq" => "≃", + "cong" => "≅", + "lesssim" => "≲", + "gtrsim" => "≳", + "ll" => "≪", + "gg" => "≫", + "perp" => "⊥", + "parallel" => "∥", "equiv" => "≡", "in" => "∈", "notin" => "∉", // codespell:ignore notin + "ni" => "∋", "subset" => "⊂", "subseteq" => "⊆", + "supset" => "⊃", + "supseteq" => "⊇", "cup" => "∪", "cap" => "∩", - "emptyset" => "∅", + "bigcup" => "⋃", + "bigcap" => "⋂", + "setminus" => "∖", + "emptyset" | "varnothing" => "∅", + "land" | "wedge" => "∧", + "lor" | "vee" => "∨", + "neg" | "lnot" => "¬", + "top" => "⊤", + "bot" => "⊥", "forall" => "∀", "exists" => "∃", + "nexists" => "∄", "to" | "rightarrow" => "→", "leftarrow" => "←", - "Rightarrow" => "⇒", - "Leftrightarrow" => "⇔", + "leftrightarrow" => "↔", + "mapsto" => "↦", + "uparrow" => "↑", + "downarrow" => "↓", + "updownarrow" => "↕", + "Leftarrow" | "impliedby" => "⇐", + "Rightarrow" | "implies" => "⇒", + "Leftrightarrow" | "iff" => "⇔", "ldots" | "dots" => "…", "cdots" => "⋯", + "vdots" => "⋮", + "ddots" => "⋱", + _ => return delimiter(name), + }) +} + +fn delimiter(name: &str) -> Option<&'static str> { + Some(match name { + "langle" => "⟨", + "rangle" => "⟩", + "lbrace" | "{" => "{", + "rbrace" | "}" => "}", + "lbrack" => "[", + "rbrack" => "]", + "vert" | "lvert" | "rvert" => "|", + "Vert" | "lVert" | "rVert" | "|" => "‖", + "lfloor" => "⌊", + "rfloor" => "⌋", + "lceil" => "⌈", + "rceil" => "⌉", _ => return None, }) } diff --git a/codex-rs/tui/src/markdown_render/math_display_tests.rs b/codex-rs/tui/src/markdown_render/math_display_tests.rs index b0c2288661..25b2734243 100644 --- a/codex-rs/tui/src/markdown_render/math_display_tests.rs +++ b/codex-rs/tui/src/markdown_render/math_display_tests.rs @@ -56,6 +56,20 @@ $$\beta$$"; insta::assert_snapshot!(plain(source, /*width*/ 80)); } +#[test] +fn unicode_math_schrodinger_equation_snapshot() { + insta::assert_snapshot!(plain( + r"\[ +i\hbar \frac{\partial}{\partial t}\Psi(\mathbf r,t) += +\hat H\Psi(\mathbf r,t) += +\left[-\frac{\hbar^2}{2m}\nabla^2+V(\mathbf r,t)\right]\Psi(\mathbf r,t). +\]", + /*width*/ 100 + )); +} + #[test] fn unicode_math_narrow_layout_stays_meaningful() { insta::assert_snapshot!(plain( diff --git a/codex-rs/tui/src/markdown_render/math_tests.rs b/codex-rs/tui/src/markdown_render/math_tests.rs index dba122e882..09244bfb5b 100644 --- a/codex-rs/tui/src/markdown_render/math_tests.rs +++ b/codex-rs/tui/src/markdown_render/math_tests.rs @@ -31,6 +31,60 @@ fn unicode_math_inline_narrow_snapshot() { )); } +#[test] +fn unicode_math_accents_and_symbols_snapshot() { + insta::assert_snapshot!(plain( + r"Operators: $\hat H^2 + \hbar^2$, $\ell$, $A\dagger$, $B\ddagger$. +Accents: $\bar{x}_1$, $\tilde{x}^2$, $\vec{v}_i$, $\dot{x}$, $\ddot{x}$. +Greek accents: $\hat{\Psi}^2$, $\bar{\varrho}$, $\tilde{\varsigma}$. +Other symbols: $\varkappa+\varpi$, $\Re z+\Im z$, $\aleph_0$, $\hat{\imath}$. +Relations: $a\propto b\sim c\simeq d\ll e\gg f$. +Geometry: $a\perp b\parallel c$, $\angle ABC\cong\angle DEF$. +Bounds: $a\lesssim b\gtrsim c$; operators: $a\oplus b\otimes c\odot d$. +Sets: $A\supseteq B\supset C\ni x$, $A\setminus B=\varnothing$. +Logic: $\neg P\land Q\lor R\implies S\iff T\impliedby U$, $\nexists x$. +Arrows: $a\leftrightarrow b\mapsto c\Leftarrow d$, $\uparrow\downarrow\updownarrow$. +Integrals: $\iint f$, $\iiint g$, $\oint h$. +Collections: $\coprod A$, $\bigcup B$, $\bigcap C$. +Punctuation: $x\prime$, $a\circ b\bullet c$, $\vdots\ddots$.", + /*width*/ 80 + )); +} + +#[test] +fn unicode_math_named_delimiters_snapshot() { + insta::assert_snapshot!(plain( + r"Inner product: $\left\langle\hat H\right\rangle$. +Rounding: $\lfloor x\rfloor + \left\lceil y\right\rceil$. +Norms: $\left\lVert v\right\rVert$, $\lvert x\rvert$, $\left\|w\right\|$. +Sets: $\left\{x\right\}$, $\lbrace y\rbrace$, $\lbrack z\rbrack$. +Angles: $\left$.", + /*width*/ 80 + )); +} + +#[test] +fn unicode_math_accents_reject_ambiguous_arguments() { + for source in [ + r"\hat{xy}", + r"\bar{x+y}", + r"\tilde{x^2}", + r"\vec{\frac{x}{y}}", + r"\dot{}", + r"\ddot{ }", + r"\hat", + "\\hat{\u{0302}}", + ] { + for display in [false, true] { + assert_eq!(render(source, display), None, "{source}"); + } + assert_eq!( + plain(&format!("\\({source}\\)"), /*width*/ 80), + format!("\\({source}\\)") + ); + } +} + #[test] fn unicode_math_preserves_markdown_contexts() { for (source, expected) in [ @@ -77,6 +131,8 @@ fn unicode_math_bounds_and_unsupported_input() { r"\sqrt[3]{x}", r"\sqrt [3]{x}", r"\left x", + r"\left\alpha x\right\rangle", + r"\left\ ", r"\text{\alpha}", r"\begin{matrix}a&b\end{matrix}", ] { diff --git a/codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__math__display_tests__unicode_math_schrodinger_equation_snapshot.snap b/codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__math__display_tests__unicode_math_schrodinger_equation_snapshot.snap new file mode 100644 index 0000000000..dc89e9a9ef --- /dev/null +++ b/codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__math__display_tests__unicode_math_schrodinger_equation_snapshot.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/markdown_render/math_display_tests.rs +expression: "plain(r\"\\[\ni\\hbar \\frac{\\partial}{\\partial t}\\Psi(\\mathbf r,t)\n=\n\\hat H\\Psi(\\mathbf r,t)\n=\n\\left[-\\frac{\\hbar^2}{2m}\\nabla^2+V(\\mathbf r,t)\\right]\\Psi(\\mathbf r,t).\n\\]\",\n100)" +--- + ∂ ℏ² +iℏ ───Ψ(r,t) = ĤΨ(r,t) = [- ──∇²+V(r,t)]Ψ(r,t). + ∂ t 2m diff --git a/codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__math__tests__unicode_math_accents_and_symbols_snapshot.snap b/codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__math__tests__unicode_math_accents_and_symbols_snapshot.snap new file mode 100644 index 0000000000..e53fadb8d8 --- /dev/null +++ b/codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__math__tests__unicode_math_accents_and_symbols_snapshot.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/markdown_render/math_tests.rs +expression: "plain(r\"Operators: $\\hat H^2 + \\hbar^2$, $\\ell$, $A\\dagger$, $B\\ddagger$.\nAccents: $\\bar{x}_1$, $\\tilde{x}^2$, $\\vec{v}_i$, $\\dot{x}$, $\\ddot{x}$.\nGreek accents: $\\hat{\\Psi}^2$, $\\bar{\\varrho}$, $\\tilde{\\varsigma}$.\nOther symbols: $\\varkappa+\\varpi$, $\\Re z+\\Im z$, $\\aleph_0$, $\\hat{\\imath}$.\nRelations: $a\\propto b\\sim c\\simeq d\\ll e\\gg f$.\nGeometry: $a\\perp b\\parallel c$, $\\angle ABC\\cong\\angle DEF$.\nBounds: $a\\lesssim b\\gtrsim c$; operators: $a\\oplus b\\otimes c\\odot d$.\nSets: $A\\supseteq B\\supset C\\ni x$, $A\\setminus B=\\varnothing$.\nLogic: $\\neg P\\land Q\\lor R\\implies S\\iff T\\impliedby U$, $\\nexists x$.\nArrows: $a\\leftrightarrow b\\mapsto c\\Leftarrow d$, $\\uparrow\\downarrow\\updownarrow$.\nIntegrals: $\\iint f$, $\\iiint g$, $\\oint h$.\nCollections: $\\coprod A$, $\\bigcup B$, $\\bigcap C$.\nPunctuation: $x\\prime$, $a\\circ b\\bullet c$, $\\vdots\\ddots$.\",\n80)" +--- +Operators: Ĥ² + ℏ², ℓ, A†, B‡. +Accents: x̄₁, x̃², v⃗ᵢ, ẋ, ẍ. +Greek accents: Ψ̂², ϱ̄, ς̃. +Other symbols: ϰ+ϖ, ℜ z+ℑ z, ℵ₀, ı̂. +Relations: a∝ b∼ c≃ d≪ e≫ f. +Geometry: a⊥ b∥ c, ∠ ABC≅∠ DEF. +Bounds: a≲ b≳ c; operators: a⊕ b⊗ c⊙ d. +Sets: A⊇ B⊃ C∋ x, A∖ B=∅. +Logic: ¬ P∧ Q∨ R⇒ S⇔ T⇐ U, ∄ x. +Arrows: a↔ b↦ c⇐ d, ↑↓↕. +Integrals: ∬ f, ∭ g, ∮ h. +Collections: ∐ A, ⋃ B, ⋂ C. +Punctuation: x′, a∘ b∙ c, ⋮⋱. diff --git a/codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__math__tests__unicode_math_named_delimiters_snapshot.snap b/codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__math__tests__unicode_math_named_delimiters_snapshot.snap new file mode 100644 index 0000000000..da281cb20a --- /dev/null +++ b/codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__math__tests__unicode_math_named_delimiters_snapshot.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/markdown_render/math_tests.rs +expression: "plain(r\"Inner product: $\\left\\langle\\hat H\\right\\rangle$.\nRounding: $\\lfloor x\\rfloor + \\left\\lceil y\\right\\rceil$.\nNorms: $\\left\\lVert v\\right\\rVert$, $\\lvert x\\rvert$, $\\left\\|w\\right\\|$.\nSets: $\\left\\{x\\right\\}$, $\\lbrace y\\rbrace$, $\\lbrack z\\rbrack$.\nAngles: $\\left$.\",\n80)" +--- +Inner product: ⟨Ĥ⟩. +Rounding: ⌊ x⌋ + ⌈ y⌉. +Norms: ‖ v‖, | x|, ‖w‖. +Sets: {x}, { y}, [ z]. +Angles: ⟨x⟩.