From 639d2478cc2e16d6ca715952d2e726a3aecc024e Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 22 Sep 2026 04:22:15 +0000 Subject: [PATCH] Render aligned equations and optimization notation in TUI math (#47191) ## Why Aligned equations and common optimization notation previously fell back to TeX source. Scripts without Unicode equivalents also prevented otherwise supported expressions from rendering. ## What changed - Add display layouts for `aligned` and `\boxed`, preserving fraction geometry and alignment with surrounding math. - Stack sum limits and `\underset`/`\overset` annotations in display math, with compact forms for inline math and nested arguments. - Support `\substack`, uppercase `\mathcal`, and single-grapheme `\widehat`. - Preserve scripts without Unicode equivalents using explicit grouping, such as `x_{gpt}` and `x^{q+1}`. - Keep layouts bounded and retain source fallback for unsupported input or expressions that do not fit. ## Testing Add snapshots and regression tests for optimization models, display limits, grouped scripts, fraction alignment, narrow-width fallback, malformed input, and layout bounds. Add streaming coverage comparing character-by-character output with complete rendering at wide and narrow widths in rich and raw modes. GitOrigin-RevId: 83937880a309375f472228368ebe885d4f50bf9c --- .../tui/src/markdown_render/math/render.rs | 65 +++-- .../markdown_render/math/render/structured.rs | 267 ++++++++++++++++++ .../src/markdown_render/math_display_tests.rs | 191 +++++++++++++ .../tui/src/markdown_render/math_tests.rs | 1 - ..._aligned_fraction_and_narrow_fallback.snap | 25 ++ ...h_aligned_preserves_fraction_geometry.snap | 19 ++ ...ay_tests__unicode_math_display_limits.snap | 25 ++ ..._math_grouped_scripts_and_annotations.snap | 7 + ...ests__unicode_math_optimization_model.snap | 22 ++ codex-rs/tui/src/streaming/math_tests.rs | 34 +++ 10 files changed, 638 insertions(+), 18 deletions(-) create mode 100644 codex-rs/tui/src/markdown_render/math/render/structured.rs create mode 100644 codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__math__display_tests__unicode_math_aligned_fraction_and_narrow_fallback.snap create mode 100644 codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__math__display_tests__unicode_math_aligned_preserves_fraction_geometry.snap create mode 100644 codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__math__display_tests__unicode_math_display_limits.snap create mode 100644 codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__math__display_tests__unicode_math_grouped_scripts_and_annotations.snap create mode 100644 codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__math__display_tests__unicode_math_optimization_model.snap diff --git a/codex-rs/tui/src/markdown_render/math/render.rs b/codex-rs/tui/src/markdown_render/math/render.rs index 8c4181f22f..3897315423 100644 --- a/codex-rs/tui/src/markdown_render/math/render.rs +++ b/codex-rs/tui/src/markdown_render/math/render.rs @@ -1,9 +1,12 @@ //! Bounded Unicode layout for a deliberately small TeX math subset. //! Accents apply only to single graphemes so their scope survives terminal rendering. +//! Unsupported Unicode scripts use explicit grouping; structured layouts remain bounded. use crate::width::display_width; use unicode_segmentation::UnicodeSegmentation; +mod structured; + const MAX_ROWS: usize = 16; const MAX_COLUMNS: usize = 256; @@ -66,8 +69,9 @@ pub(super) fn render(source: &str, display: bool) -> Option { remaining: source.trim(), depth: 0, display, + stack_annotations: display, }; - let result = parser.sequence(/*group*/ false)?; + let result = parser.sequence(SequenceEnd::Input)?; (!result.rows.iter().all(|row| row.trim().is_empty())).then(|| result.rows.join("\n")) } @@ -75,6 +79,14 @@ struct MathParser<'a> { remaining: &'a str, depth: usize, display: bool, + stack_annotations: bool, +} + +#[derive(Clone, Copy, PartialEq)] +enum SequenceEnd { + Input, + Group, + Cell, } impl MathParser<'_> { @@ -84,7 +96,7 @@ impl MathParser<'_> { Some(ch) } - fn sequence(&mut self, group: bool) -> Option { + fn sequence(&mut self, end: SequenceEnd) -> Option { if self.depth >= 32 { return None; } @@ -93,8 +105,18 @@ impl MathParser<'_> { let mut scripts = 0; let mut has_base = false; while let Some(ch) = self.remaining.chars().next() { + let remaining = self.remaining.trim_start(); + if end == SequenceEnd::Cell + && (remaining.starts_with(['&', '}']) + || remaining.starts_with(r"\\") + || remaining.starts_with(r"\end{")) + { + self.remaining = remaining; + self.depth -= 1; + return Some(result); + } if ch == '}' { - if !group { + if end != SequenceEnd::Group { return None; } self.take(); @@ -120,7 +142,7 @@ impl MathParser<'_> { result = result.join(atom)?; } self.depth -= 1; - (!group).then_some(result) + (end == SequenceEnd::Input).then_some(result) } fn argument(&mut self) -> Option { @@ -144,10 +166,10 @@ impl MathParser<'_> { fn atom_inner(&mut self) -> Option { let ch = self.take()?; match ch { - '{' => self.sequence(/*group*/ true), + '{' => self.sequence(SequenceEnd::Group), '\\' => self.command(), '^' | '_' => { - let arg = self.argument()?; + let arg = self.compact_argument()?; let (plain, alphabet) = if ch == '^' { ( "0123456789+-=()abcdefghijklmnoprstuvwxyz", @@ -159,12 +181,17 @@ impl MathParser<'_> { "₀₁₂₃₄₅₆₇₈₉₊₋₌₍₎ₐₑₕᵢⱼₖₗₘₙₒₚᵣₛₜᵤᵥₓ", ) }; - let mut output = String::new(); - for value in arg.single()?.chars() { - let index = plain.chars().position(|ch| ch == value)?; - output.push(alphabet.chars().nth(index)?); - } - Some(Layout::text(output)) + let text = arg.single()?; + let output: Option = text + .chars() + .map(|value| { + let index = plain.chars().position(|ch| ch == value)?; + alphabet.chars().nth(index) + }) + .collect(); + Some(Layout::text( + output.unwrap_or_else(|| format!("{ch}{{{text}}}")), + )) } '}' | '$' | '%' | '#' | '&' | '`' => None, ch if ch.is_whitespace() => { @@ -195,9 +222,13 @@ impl MathParser<'_> { let name = &self.remaining[..length]; self.remaining = &self.remaining[length..]; match name { + "sum" if self.stack_annotations => self.display_sum(), + "begin" | "boxed" | "underset" | "overset" | "substack" | "mathcal" => { + self.structured_command(name) + } "frac" | "dfrac" | "tfrac" => { - let numerator = self.argument()?; - let denominator = self.argument()?; + let numerator = self.compact_argument()?; + let denominator = self.compact_argument()?; if !self.display { return Some(Layout::text(format!( "(({})/({}))", @@ -233,7 +264,7 @@ impl MathParser<'_> { if self.remaining.trim_start().starts_with('[') { return None; } - let radicand = self.argument()?; + let radicand = self.compact_argument()?; Some(Layout::text(format!("√({})", radicand.single()?))) } "mathbb" => { @@ -249,7 +280,7 @@ impl MathParser<'_> { }; Some(Layout::text(text)) } - "hat" | "bar" | "tilde" | "vec" | "dot" | "ddot" => { + "hat" | "widehat" | "bar" | "tilde" | "vec" | "dot" | "ddot" => { let arg = self.argument()?; let text = arg.single()?; if text.graphemes(/*is_extended*/ true).count() != 1 @@ -259,7 +290,7 @@ impl MathParser<'_> { return None; } let accent = match name { - "hat" => '\u{0302}', + "hat" | "widehat" => '\u{0302}', "bar" => '\u{0304}', "tilde" => '\u{0303}', "vec" => '\u{20d7}', diff --git a/codex-rs/tui/src/markdown_render/math/render/structured.rs b/codex-rs/tui/src/markdown_render/math/render/structured.rs new file mode 100644 index 0000000000..463f85d4d5 --- /dev/null +++ b/codex-rs/tui/src/markdown_render/math/render/structured.rs @@ -0,0 +1,267 @@ +//! Small, bounded layouts for aligned equations and common optimization notation. +//! Cell parsing shares the parent parser's depth budget and preserves nested groups. +//! Aligned blocks compose with surrounding math at their first row's baseline. +//! Display annotations and sum limits are centered above or below their base. + +use super::Layout; +use super::MAX_COLUMNS; +use super::MAX_ROWS; +use super::MathParser; +use super::SequenceEnd; +use crate::width::display_width; + +enum AnnotationPosition { + Above, + Below, +} + +impl Layout { + fn annotated(self, annotation: Self, position: AnnotationPosition) -> Option { + let width = self.width().max(annotation.width()); + if self.rows.len() + annotation.rows.len() > MAX_ROWS || width > MAX_COLUMNS { + return None; + } + let baseline = match position { + AnnotationPosition::Above => self.baseline + annotation.rows.len(), + AnnotationPosition::Below => self.baseline, + }; + let parts = match position { + AnnotationPosition::Above => [annotation, self], + AnnotationPosition::Below => [self, annotation], + }; + let mut rows = Vec::new(); + for part in parts { + let padding = " ".repeat((width - part.width()) / 2); + rows.extend(part.rows.into_iter().map(|row| format!("{padding}{row}"))); + } + Some(Self { rows, baseline }) + } +} + +impl MathParser<'_> { + // Single-line child layouts retain compact limits without flattening nested fractions. + pub(super) fn compact_argument(&mut self) -> Option { + let stack_annotations = self.stack_annotations; + self.stack_annotations = false; + let argument = self.argument(); + self.stack_annotations = stack_annotations; + argument + } + + pub(super) fn display_sum(&mut self) -> Option { + let mut lower = None; + let mut upper = None; + loop { + let remaining = self.remaining.trim_start(); + let limit = match remaining.chars().next() { + Some('_') => &mut lower, + Some('^') => &mut upper, + _ => break, + }; + if limit.is_some() { + return None; + } + self.remaining = &remaining[1..]; + *limit = Some(self.compact_argument()?); + } + // Fix the shared width before centering either limit to avoid cumulative rounding. + let width = lower + .iter() + .chain(&upper) + .map(Layout::width) + .max() + .unwrap_or(1) + .max(1); + let mut base = Layout::text(format!("{:^width$}", "∑")); + if let Some(lower) = lower { + base = base.annotated(lower, AnnotationPosition::Below)?; + } + if let Some(upper) = upper { + base = base.annotated(upper, AnnotationPosition::Above)?; + } + Some(base) + } + + pub(super) fn structured_command(&mut self, name: &str) -> Option { + match name { + "begin" => { + self.remaining = self.remaining.trim_start().strip_prefix("{aligned}")?; + if self.remaining.trim_start().starts_with('[') { + return None; + } + self.aligned() + } + "boxed" => { + if !self.display { + return None; + } + let inner = self.argument()?; + let width = inner.width(); + if inner.rows.len() + 2 > MAX_ROWS || width + 4 > MAX_COLUMNS { + return None; + } + let border = "─".repeat(width + 2); + let mut rows = vec![format!("┌{border}┐")]; + for row in &inner.rows { + let padding = " ".repeat(width - display_width(row)); + rows.push(format!("│ {row}{padding} │")); + } + rows.push(format!("└{border}┘")); + Some(Layout { + rows, + baseline: inner.baseline + 1, + }) + } + "underset" | "overset" => { + let annotation = self.compact_argument()?; + let base = self.argument()?; + if self.stack_annotations { + let position = if name == "underset" { + AnnotationPosition::Below + } else { + AnnotationPosition::Above + }; + return base.annotated(annotation, position); + } + let annotation = annotation.single()?; + let base = base.single()?; + let marker = if name == "underset" { '_' } else { '^' }; + // Parenthesize the base so an annotation cannot attach to only its last atom. + Some(Layout::text(format!("({base}){marker}{{{annotation}}}"))) + } + "substack" => { + self.remaining = self.remaining.trim_start().strip_prefix('{')?; + let mut rows = Vec::new(); + loop { + if rows.len() == MAX_ROWS { + return None; + } + self.remaining = self.remaining.trim_start(); + let row = self.sequence(SequenceEnd::Cell)?; + rows.push(row.single()?.trim().to_owned()); + if let Some(remaining) = self.remaining.strip_prefix('}') { + self.remaining = remaining; + break; + } + self.remaining = self.remaining.strip_prefix(r"\\")?; + } + Some(Layout::text(format!("({})", rows.join("; ")))) + } + "mathcal" => { + let arg = self.argument()?; + let text: Option = arg + .single()? + .chars() + .map(|ch| { + let index = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".find(ch)?; + "𝒜ℬ𝒞𝒟ℰℱ𝒢ℋℐ𝒥𝒦ℒℳ𝒩𝒪𝒫𝒬ℛ𝒮𝒯𝒰𝒱𝒲𝒳𝒴𝒵".chars().nth(index) + }) + .collect(); + Some(Layout::text(text?)) + } + _ => None, + } + } + + fn aligned(&mut self) -> Option { + if !self.display { + return None; + } + let mut rows = Vec::new(); + let mut cells = Vec::new(); + let mut widths = Vec::::new(); + loop { + self.remaining = self.remaining.trim_start(); + // A trailing row separator does not introduce an extra blank row. + if cells.is_empty() + && let Some(remaining) = self.remaining.strip_prefix(r"\end{aligned}") + { + self.remaining = remaining; + break; + } + // Bound empty columns and rows as well as their eventual rendered size. + if rows.len() == MAX_ROWS || cells.len() == 16 { + return None; + } + let mut cell = self.sequence(SequenceEnd::Cell)?; + if cell.rows.len() == 1 { + cell.rows[0] = cell.rows[0].trim().to_owned(); + } + if widths.len() == cells.len() { + widths.push(cell.width()); + } else { + widths[cells.len()] = widths[cells.len()].max(cell.width()); + } + cells.push(cell); + if let Some(remaining) = self.remaining.strip_prefix('&') { + self.remaining = remaining; + continue; + } + if let Some(remaining) = self.remaining.strip_prefix(r"\end{aligned}") { + self.remaining = remaining; + rows.push(cells); + break; + } + self.remaining = self.remaining.strip_prefix(r"\\")?.trim_start(); + if let Some(spacing) = self.remaining.strip_prefix('[') { + let (distance, remaining) = spacing.split_once(']')?; + let number = [ + "pt", "pc", "in", "bp", "cm", "mm", "dd", "cc", "sp", "em", "ex", + ] + .iter() + .find_map(|unit| distance.trim().strip_suffix(unit))?; + if !number.trim().parse::().ok()?.is_finite() { + return None; + } + // Physical TeX spacing has no terminal equivalent; retain the row break. + self.remaining = remaining; + } + rows.push(std::mem::take(&mut cells)); + } + let mut output = Vec::new(); + let mut layout_baseline = 0; + for cells in rows { + let baseline = cells.iter().map(|cell| cell.baseline).max()?; + if output.is_empty() { + layout_baseline = baseline; + } + let height = cells + .iter() + .map(|cell| baseline + cell.rows.len() - cell.baseline) + .max()?; + if output.len() + height > MAX_ROWS { + return None; + } + for line_index in 0..height { + let mut line = String::new(); + for (index, cell) in cells.iter().enumerate() { + let text = line_index + .checked_sub(baseline - cell.baseline) + .and_then(|row| cell.rows.get(row)) + .map(String::as_str) + .unwrap_or(""); + // Align the cell rectangle, preserving numerator/denominator positions. + let leading = if index % 2 == 0 { + widths[index] - cell.width() + } else { + 0 + }; + line.push_str(&" ".repeat(leading)); + line.push_str(text); + line.push_str(&" ".repeat(widths[index] - leading - display_width(text))); + if index + 1 < cells.len() { + line.push_str(if index % 2 == 0 { " " } else { " " }); + } + } + if display_width(&line) > MAX_COLUMNS { + return None; + } + output.push(line.trim_end().to_owned()); + } + } + Some(Layout { + rows: output, + baseline: layout_baseline, + }) + } +} 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 25b2734243..64d4572928 100644 --- a/codex-rs/tui/src/markdown_render/math_display_tests.rs +++ b/codex-rs/tui/src/markdown_render/math_display_tests.rs @@ -70,6 +70,197 @@ i\hbar \frac{\partial}{\partial t}\Psi(\mathbf r,t) )); } +#[test] +fn unicode_math_optimization_model() { + let source = r"Let \(X_{gpt}=x_{gpt}\), with \(X_{gp,-1}=I_{gp}\). + +\[ +\boxed{ +\begin{aligned} +\underset{z,u,s,m\ge0}{\operatorname{minimize}}\quad +&\sum_{g,t}w_{\pi_{gt}}u_{gt} ++\lambda\sum_t\sum_{(g,p)\in\mathcal J_t}\kappa_{pt}m_{gpt} ++\mu\sum_{(g,t)\in\mathcal Q}s_{gt} +\\[2mm] +\text{subject to}\quad +&\widehat F_{gt}+u_{gt}-s_{gt}=D_{gt} +&&\forall g,t +\\ +&\sum_gx_{gpt}\le S_{pt}-B_{pt} +&&\forall p,t +\\ +&\sum_{g,p,k}z_{gpk\ell t}\le C_{\ell t} +&&\forall \ell,t +\\ +&\sum_{\substack{g\in G_r\\p:\operatorname{region}(p)\in M_r}}x_{gpt} +\ge\sum_{g\in G_r,p}\alpha_{rg}x_{gpt} +&&\forall r,t +\\ +&q_aL_{adt}\le L_{at} +&&\forall a,d,t +\\ +&m_{gpt}\ge X_{gp,t-1}-X_{gpt} +&&\forall (g,p)\in\mathcal J_t +\end{aligned}} +\] + +After the model: \(\alpha^2\)."; + let rendered = plain(source, /*width*/ 240); + assert!(!rendered.contains('\\'), "{rendered}"); + insta::assert_snapshot!(rendered); +} + +#[test] +fn unicode_math_display_limits() { + insta::assert_snapshot!(plain( + r"\[ +\underset{x,u,m\ge0}{\operatorname{minimize}}\quad +\sum_{g,t}x_{gpt}+\lambda\sum^{N}_{i=1}y_i +\] + +\[ +\sum_{i=1}^N\frac{x_i}{y_i}\overset{\text{def}}{=}\underset{j\in J}{\min} z_j +\] + +\[ +\sum_{j=\sum_i^n i}^N x_j +\] + +\[ +\sum_{ij}^{100}x+\sum^{ij}_{100}y +\] + +\[ +\frac{\sum_i x_i}{\sum_j y_j}+\sqrt{\sum_i x_i}+x^{\sum_i a_i}+x_{\underset{i}{\min}}+\sum_k z_k +\] + +Inline: \(\sum_{i=1}^N x_i\), \(\underset{x\ge0}{\min} x\).", + /*width*/ 100 + )); + assert_eq!( + render(r"\sum_i^N x_i", /*display*/ true), + render(r"\sum^N_i x_i", /*display*/ true) + ); + for source in [r"\sum_i_j", r"\sum^i^j", r"\sum_", r"\sum^_i"] { + assert_eq!(render(source, /*display*/ true), None, "{source}"); + } + let source = r"\[\sum_{i,j,k=1}^{123456789}x_i\]"; + assert_eq!( + plain(source, /*width*/ 12), + plain(&format!("`{source}`"), /*width*/ 12) + ); + assert_eq!( + render( + &format!( + r"\begin{{aligned}}{}\end{{aligned}}", + r"\sum_i x_i\\".repeat(/*n*/ 9) + ), + /*display*/ true + ), + None + ); +} + +#[test] +fn unicode_math_grouped_scripts_and_annotations() { + insta::assert_snapshot!(plain( + r"Indices: \(x_{gpt} + x_{i,j} + w_{\pi_{gt}} + x^{q+1} + x_{g}^2\). +Native scripts: \(x_i^2 + y_{10}\). Sets: \(\mathcal Q_t\). +Annotations: \(\underset{i\in I}{\min} x_i\), \(\overset{\text{def}}{=}\).", + /*width*/ 100 + )); +} + +#[test] +fn unicode_math_aligned_fraction_and_narrow_fallback() { + let formula = r"\[ +\begin{aligned} +\frac{x}{z}&=\frac{1}{2} &&\text{first}\\ +y_{gpt}&=3 &&\text{second}\\ +\end{aligned} +\]"; + insta::assert_snapshot!(format!( + "Wide:\n{}\n\nNarrow:\n{}", + plain(formula, /*width*/ 80), + plain(formula, /*width*/ 12) + )); +} + +#[test] +fn unicode_math_aligned_preserves_fraction_geometry() { + let source = r"\[ +\begin{aligned} +\frac{x}{y}+\frac{1}{123456789}&=0\\ +\frac{123456789}{1}+\frac{y}{x}&=1 +\end{aligned} +\] + +\[ +\begin{aligned}\frac{a}{b}\end{aligned}=c +\] + +\[ +\begin{aligned}x&=\begin{aligned}\frac{a}{b}\end{aligned}\\y&=2\end{aligned} +\]"; + let rendered = plain(source, /*width*/ 80); + insta::assert_snapshot!(rendered); + let spaced = source + .replace('&', " \n &") + .replace(r"\\", " \n \\\\") + .replace(r"\end{aligned}", " \n \\end{aligned}"); + assert_eq!(plain(&spaced, /*width*/ 80), rendered); +} + +#[test] +fn unicode_math_structured_bounds_and_invalid_input() { + let source = format!( + r"\begin{{aligned}}\frac{{a}}{{b}}{}&=0\end{{aligned}}", + "x".repeat(/*n*/ 252) + ); + let rendered = render(&source, /*display*/ true).expect("fits the layout width limit"); + assert_eq!( + render(&source.replace('&', " \n &"), /*display*/ true), + Some(rendered) + ); + assert_eq!( + render( + &format!( + r"\begin{{aligned}}{}\end{{aligned}}", + "x&=1\\\\".repeat(/*n*/ 16) + ), + /*display*/ true + ), + Some(vec!["x =1"; 16].join("\n")) + ); + for source in [ + r"\begin{aligned}x&=1", + r"\begin{aligned}x&=1\end{matrix}", + r"\begin{aligned}[b]x&=1\end{aligned}", + r"\begin{aligned}x&={a&b}\end{aligned}", + r"\begin{aligned}x&=1\\[bad]y&=2\end{aligned}", + r"\begin{aligned}x&=1\\[NaNmm]y&=2\end{aligned}", + r"\underset{x}", + r"\substack{x&y}", + ] { + assert_eq!(render(source, /*display*/ true), None, "{source}"); + } + for source in [ + format!( + r"\begin{{aligned}}{}\end{{aligned}}", + "x&=1\\\\".repeat(/*n*/ 17) + ), + format!( + r"\begin{{aligned}}{}x\end{{aligned}}", + "x&".repeat(/*n*/ 17) + ), + format!(r"\boxed{{{}}}", "x".repeat(/*n*/ 254)), + format!("{}x{}", r"\boxed{".repeat(/*n*/ 40), "}".repeat(/*n*/ 40)), + format!(r"\substack{{{}}}", "x\\\\".repeat(/*n*/ 17)), + ] { + assert_eq!(render(&source, /*display*/ true), None, "{source}"); + } +} + #[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 09244bfb5b..86517f0fb1 100644 --- a/codex-rs/tui/src/markdown_render/math_tests.rs +++ b/codex-rs/tui/src/markdown_render/math_tests.rs @@ -122,7 +122,6 @@ fn unicode_math_bounds_and_unsupported_input() { r"\frac{a}", "{x", "x}", - "x^{q}", "^2", "x^2^3", "{a+b}^2", diff --git a/codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__math__display_tests__unicode_math_aligned_fraction_and_narrow_fallback.snap b/codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__math__display_tests__unicode_math_aligned_fraction_and_narrow_fallback.snap new file mode 100644 index 0000000000..56035f5016 --- /dev/null +++ b/codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__math__display_tests__unicode_math_aligned_fraction_and_narrow_fallback.snap @@ -0,0 +1,25 @@ +--- +source: tui/src/markdown_render/math_display_tests.rs +expression: "format!(\"Wide:\\n{}\\n\\nNarrow:\\n{}\", plain(formula, 80), plain(formula, 12))" +--- +Wide: + x 1 + ─ =─ first + z 2 +y_{gpt} =3 second + +Narrow: +\[ +\begin{align +ed} +\frac{x}{z} +&=\frac{1} +{2} +&&\text{firs +t}\\ +y_{gpt}&=3 +&&\text{seco +nd}\\ +\end{aligned +} +\] diff --git a/codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__math__display_tests__unicode_math_aligned_preserves_fraction_geometry.snap b/codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__math__display_tests__unicode_math_aligned_preserves_fraction_geometry.snap new file mode 100644 index 0000000000..4e1598cac3 --- /dev/null +++ b/codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__math__display_tests__unicode_math_aligned_preserves_fraction_geometry.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/markdown_render/math_display_tests.rs +expression: "plain(r\"\\[\n\\begin{aligned}\n\\frac{x}{y}+\\frac{1}{123456789}&=0\\\\\n\\frac{123456789}{1}+\\frac{y}{x}&=1\n\\end{aligned}\n\\]\n\n\\[\n\\begin{aligned}\\frac{a}{b}\\end{aligned}=c\n\\]\n\n\\[\n\\begin{aligned}x&=\\begin{aligned}\\frac{a}{b}\\end{aligned}\\\\y&=2\\end{aligned}\n\\]\",\n80)" +--- +x 1 +─+ ───────── =0 +y 123456789 +123456789 y +─────────+ ─ =1 + 1 x + +a +─=c +b + + a +x =─ + b +y =2 diff --git a/codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__math__display_tests__unicode_math_display_limits.snap b/codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__math__display_tests__unicode_math_display_limits.snap new file mode 100644 index 0000000000..de78671f59 --- /dev/null +++ b/codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__math__display_tests__unicode_math_display_limits.snap @@ -0,0 +1,25 @@ +--- +source: tui/src/markdown_render/math_display_tests.rs +expression: "plain(r\"\\[\n\\underset{x,u,m\\ge0}{\\operatorname{minimize}}\\quad\n\\sum_{g,t}x_{gpt}+\\lambda\\sum^{N}_{i=1}y_i\n\\]\n\n\\[\n\\sum_{i=1}^N\\frac{x_i}{y_i}\\overset{\\text{def}}{=}\\underset{j\\in J}{\\min} z_j\n\\]\n\n\\[\n\\sum_{j=\\sum_i^n i}^N x_j\n\\]\n\n\\[\n\\sum_{ij}^{100}x+\\sum^{ij}_{100}y\n\\]\n\n\\[\n\\frac{\\sum_i x_i}{\\sum_j y_j}+\\sqrt{\\sum_i x_i}+x^{\\sum_i a_i}+x_{\\underset{i}{\\min}}+\\sum_k z_k\n\\]\n\nInline: \\(\\sum_{i=1}^N x_i\\), \\(\\underset{x\\ge0}{\\min} x\\).\",\n100)" +--- + N +minimize ∑ x_{gpt}+λ ∑ yᵢ +x,u,m≥0 g,t i=1 + + N xᵢ def + ∑ ── = min zⱼ +i=1 yᵢ j∈ J + + N + ∑ xⱼ +j=∑ᵢⁿ i + +100 ij + ∑ x+ ∑ y +ij 100 + +∑ᵢ xᵢ +─────+√(∑ᵢ xᵢ)+x^{∑ᵢ aᵢ}+x_{(min)_{i}}+ ∑ zₖ +∑ⱼ yⱼ k + +Inline: ∑ᵢ₌₁^{N} xᵢ, (min)_{x≥0} x. diff --git a/codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__math__display_tests__unicode_math_grouped_scripts_and_annotations.snap b/codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__math__display_tests__unicode_math_grouped_scripts_and_annotations.snap new file mode 100644 index 0000000000..7e0c1fe578 --- /dev/null +++ b/codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__math__display_tests__unicode_math_grouped_scripts_and_annotations.snap @@ -0,0 +1,7 @@ +--- +source: tui/src/markdown_render/math_display_tests.rs +expression: "plain(r\"Indices: \\(x_{gpt} + x_{i,j} + w_{\\pi_{gt}} + x^{q+1} + x_{g}^2\\).\nNative scripts: \\(x_i^2 + y_{10}\\). Sets: \\(\\mathcal Q_t\\).\nAnnotations: \\(\\underset{i\\in I}{\\min} x_i\\), \\(\\overset{\\text{def}}{=}\\).\",\n100)" +--- +Indices: x_{gpt} + x_{i,j} + w_{π_{gt}} + x^{q+1} + x_{g}². +Native scripts: xᵢ² + y₁₀. Sets: 𝒬ₜ. +Annotations: (min)_{i∈ I} xᵢ, (=)^{def}. diff --git a/codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__math__display_tests__unicode_math_optimization_model.snap b/codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__math__display_tests__unicode_math_optimization_model.snap new file mode 100644 index 0000000000..e4efd781b4 --- /dev/null +++ b/codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__math__display_tests__unicode_math_optimization_model.snap @@ -0,0 +1,22 @@ +--- +source: tui/src/markdown_render/math_display_tests.rs +assertion_line: 110 +expression: rendered +--- +Let X_{gpt}=x_{gpt}, with X_{gp,-1}=I_{gp}. + +┌───────────────────────────────────────────────────────────────────────────────────────┐ +│ minimize ∑ w_{π_{gt}}u_{gt} +λ ∑ ∑ κₚₜm_{gpt} +μ ∑ s_{gt} │ +│ z,u,s,m≥0 g,t t (g,p)∈𝒥ₜ (g,t)∈𝒬 │ +│ subject to F̂_{gt}+u_{gt}-s_{gt}=D_{gt} ∀ g,t │ +│ ∑x_{gpt}≤ Sₚₜ-Bₚₜ ∀ p,t │ +│ g │ +│ ∑ z_{gpkℓ t}≤ C_{ℓ t} ∀ ℓ,t │ +│ g,p,k │ +│ ∑ x_{gpt} ≥ ∑ α_{rg}x_{gpt} ∀ r,t │ +│ (g∈ Gᵣ; p:region(p)∈ Mᵣ) g∈ Gᵣ,p │ +│ qₐL_{adt}≤ Lₐₜ ∀ a,d,t │ +│ m_{gpt}≥ X_{gp,t-1}-X_{gpt} ∀ (g,p)∈𝒥ₜ │ +└───────────────────────────────────────────────────────────────────────────────────────┘ + +After the model: α². diff --git a/codex-rs/tui/src/streaming/math_tests.rs b/codex-rs/tui/src/streaming/math_tests.rs index ca4019959f..0a4f55625b 100644 --- a/codex-rs/tui/src/streaming/math_tests.rs +++ b/codex-rs/tui/src/streaming/math_tests.rs @@ -4,6 +4,40 @@ use crate::history_cell::HistoryRenderMode; use crate::markdown::render_streaming_markdown_agent_with_links_and_cwd; use pretty_assertions::assert_eq; +#[test] +fn unicode_math_aligned_stream_matches_complete_render() { + let cwd = std::env::temp_dir(); + let source = "Before.\n\n\\[\n\\begin{aligned}\n\\underset{x}{\\min}&=\\sum_{i=1}^N x_{gpt}\\\\[2mm]\ny&=2\n\\end{aligned}\n\\]\n\nAfter $\\alpha$.\n"; + for width in [80, 12] { + for mode in [HistoryRenderMode::Rich, HistoryRenderMode::Raw] { + let mut stream = StreamCore::new( + Some(width), + &cwd, + mode, + /*inline_visualization_context*/ None, + ); + let mut emitted = Vec::new(); + for ch in source.chars() { + stream.push_delta(&ch.to_string()); + emitted.extend(stream.tick_batch(usize::MAX)); + } + let (remaining, raw) = stream.finalize_remaining(); + emitted.extend(remaining); + assert_eq!(raw, source); + assert_eq!( + emitted, + render_source( + source, + Some(width), + &cwd, + mode, + /*inline_visualization_context*/ None + ) + ); + } + } +} + #[test] fn unicode_math_stream_holds_display_until_closed_and_preserves_source() { let cwd = std::env::temp_dir();