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
This commit is contained in:
Eric Traut
2026-09-22 04:33:58 +00:00
committed by copyberry
parent d1092865f8
commit 639d2478cc
10 changed files with 638 additions and 18 deletions
+48 -17
View File
@@ -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<String> {
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<Layout> {
fn sequence(&mut self, end: SequenceEnd) -> Option<Layout> {
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<Layout> {
@@ -144,10 +166,10 @@ impl MathParser<'_> {
fn atom_inner(&mut self) -> Option<Layout> {
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<String> = 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}',
@@ -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<Self> {
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<Layout> {
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<Layout> {
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<Layout> {
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<String> = arg
.single()?
.chars()
.map(|ch| {
let index = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".find(ch)?;
"𝒜ℬ𝒞𝒟ℰℱ𝒢ℋℐ𝒥𝒦ℒℳ𝒩𝒪𝒫𝒬ℛ𝒮𝒯𝒰𝒱𝒲𝒳𝒴𝒵".chars().nth(index)
})
.collect();
Some(Layout::text(text?))
}
_ => None,
}
}
fn aligned(&mut self) -> Option<Layout> {
if !self.display {
return None;
}
let mut rows = Vec::new();
let mut cells = Vec::new();
let mut widths = Vec::<usize>::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::<f64>().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,
})
}
}
@@ -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(
@@ -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",
@@ -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
}
\]
@@ -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
@@ -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.
@@ -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}.
@@ -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: α².
+34
View File
@@ -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();