fix(tui): align table emoji widths with terminals

Compute markdown table cell widths with per-character terminal cell
widths so boxed table padding matches xterm.js for emoji presentation,
keycap, and ZWJ sequences.

This keeps emoji tables rendered as boxed tables while preventing border
columns from drifting during resize in xterm-based terminals.
This commit is contained in:
Felipe Coury
2026-04-15 11:54:16 -03:00
parent 96ea12ec91
commit fd6585e863
2 changed files with 40 additions and 10 deletions
+27 -5
View File
@@ -63,7 +63,7 @@ use std::ops::Range;
use std::path::Path;
use std::path::PathBuf;
use std::sync::LazyLock;
use unicode_width::UnicodeWidthStr;
use unicode_width::UnicodeWidthChar;
use url::Url;
struct MarkdownStyles {
@@ -1172,7 +1172,7 @@ where
if word_count > 0 {
total_words += word_count;
total_cells += 1;
total_cell_width += plain.width();
total_cell_width += Self::terminal_cell_width(&plain);
}
}
@@ -1182,7 +1182,7 @@ where
total_words as f64 / total_cells as f64
};
let avg_cell_width = if total_cells == 0 {
header_plain.width() as f64
Self::terminal_cell_width(&header_plain) as f64
} else {
total_cell_width as f64 / total_cells as f64
};
@@ -1504,7 +1504,10 @@ where
#[inline]
fn spans_display_width(spans: &[Span<'_>]) -> usize {
spans.iter().map(|span| span.content.width()).sum()
spans
.iter()
.map(|span| Self::terminal_cell_width(&span.content))
.sum()
}
#[inline]
@@ -1523,7 +1526,17 @@ where
#[inline]
fn longest_token_width(text: &str) -> usize {
text.split_whitespace().map(str::width).max().unwrap_or(0)
text.split_whitespace()
.map(Self::terminal_cell_width)
.max()
.unwrap_or(0)
}
#[inline]
fn terminal_cell_width(text: &str) -> usize {
text.chars()
.map(|ch| UnicodeWidthChar::width(ch).unwrap_or(0))
.sum()
}
fn push_inline_style(&mut self, style: Style) {
@@ -2176,6 +2189,15 @@ mod tests {
}
}
#[test]
fn terminal_cell_width_matches_xterm_table_cases() {
assert_eq!(W::terminal_cell_width("🅰️"), 1);
assert_eq!(W::terminal_cell_width("↔️"), 1);
assert_eq!(W::terminal_cell_width("1️⃣"), 1);
assert_eq!(W::terminal_cell_width("👩‍💻"), 4);
assert_eq!(W::terminal_cell_width("🔤"), 2);
}
// ===== Column-metrics unit tests =====
#[test]
+13 -5
View File
@@ -4,7 +4,7 @@ use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::text::Text;
use std::path::Path;
use unicode_width::UnicodeWidthStr;
use unicode_width::UnicodeWidthChar;
use crate::markdown_render::COLON_LOCATION_SUFFIX_RE;
use crate::markdown_render::HASH_LOCATION_SUFFIX_RE;
@@ -16,6 +16,12 @@ fn render_markdown_text_for_cwd(input: &str, cwd: &Path) -> Text<'static> {
render_markdown_text_with_width_and_cwd(input, /*width*/ None, Some(cwd))
}
fn terminal_cell_width(text: &str) -> usize {
text.chars()
.map(|ch| UnicodeWidthChar::width(ch).unwrap_or(0))
.sum()
}
#[test]
fn empty() {
assert_eq!(render_markdown_text(""), Text::default());
@@ -1480,13 +1486,15 @@ fn table_with_emoji_sequence_cells_has_consistent_display_width() {
})
.map(String::as_str)
.collect();
let expected_width = table_lines
let first_table_line = table_lines
.first()
.expect("expected unicode table to render as a box")
.width();
.expect("expected unicode table to render as a box");
let expected_width = terminal_cell_width(first_table_line);
assert!(
table_lines.iter().all(|line| line.width() == expected_width),
table_lines
.iter()
.all(|line| terminal_cell_width(line) == expected_width),
"expected every rendered table row to have width {expected_width}: {table_lines:?}"
);
assert!(