From bd0bac8aeeaf1e81265bae379e6ad978cc880155 Mon Sep 17 00:00:00 2001 From: trigrou Date: Tue, 9 Jun 2026 01:08:17 +0200 Subject: [PATCH] fix: calculate display width with unicode-width to handle emoji String width was computed with grapheme cluster count, but a single emoji is one grapheme yet occupies two terminal columns. This made the layout underestimate width by one column per emoji, shifting/overlapping following spans. Use unicode-width's display width in layout_span, the text() helper and the overflow/truncation logic, and make truncation accumulate display width instead of grapheme count. --- Cargo.toml | 2 +- src/screen/mod.rs | 36 ++++++++++++++++++++++-------------- src/ui.rs | 3 ++- src/ui/layout/mod.rs | 21 ++++++++++++++++++--- 4 files changed, 43 insertions(+), 19 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 95e7c0947d..02e94e7162 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,6 @@ pretty_assertions = "1.4.1" temp-dir = "0.1.16" criterion = "0.8.1" insta = "1.46.0" -unicode-width = "0.2.0" temp-env = "0.3.6" stdext = "0.3.3" url = "2.5.7" @@ -71,6 +70,7 @@ tree-sitter-elixir = "=0.3.4" regex = "1.12.2" strip-ansi-escapes = "0.2.1" unicode-segmentation = "1.12.0" +unicode-width = "0.2.0" cached = "0.56.0" strum = { version = "0.26.3", features = ["strum_macros"] } tinyvec = "1.10.0" diff --git a/src/screen/mod.rs b/src/screen/mod.rs index d7ded5a596..338e9b1962 100644 --- a/src/screen/mod.rs +++ b/src/screen/mod.rs @@ -4,6 +4,7 @@ use crate::ui::{UiTree, layout_span}; use crate::{item_data::ItemData, ui}; use ratatui::{layout::Size, style::Style, text::Line}; use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; use crate::{Res, config::Config, items::hash}; @@ -473,23 +474,30 @@ pub(crate) fn layout_screen<'a>( line.display.spans.into_iter().for_each(|span| { let style = bg.patch(line.display.style).patch(span.style); - let span_width = span.content.graphemes(true).count(); + let span_width = UnicodeWidthStr::width(span.content.as_ref()); if line_end + span_width >= size.width as usize { - // Truncate the span and insert an ellipsis to indicate overflow - let overflow = line_end + span_width - size.width as usize; + // Truncate the span and insert an ellipsis to indicate overflow. + // Keep as many graphemes as fit in the remaining columns, leaving + // one column for the ellipsis. Accumulate display width (not + // grapheme count) so wide characters like emoji are handled. + let budget = (size.width as usize).saturating_sub(line_end + 1); + let mut used = 0; + let truncated: String = span + .content + .graphemes(true) + .take_while(|g| { + let next = used + UnicodeWidthStr::width(*g); + if next <= budget { + used = next; + true + } else { + false + } + }) + .collect(); line_end = size.width as usize; - ui::layout_span( - layout, - ( - span.content - .graphemes(true) - .take(span_width.saturating_sub(overflow + 1)) - .collect::() - .into(), - style, - ), - ); + ui::layout_span(layout, (truncated.into(), style)); layout_span(layout, ("…".into(), bg)); } else { // Insert the span as normal diff --git a/src/ui.rs b/src/ui.rs index 6a8a5426b0..822960c9d5 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -10,6 +10,7 @@ use ratatui::Frame; use ratatui::prelude::*; use tui_prompts::State as _; use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; pub(crate) mod layout; mod menu; @@ -127,7 +128,7 @@ pub(crate) fn layout_line<'a>(layout: &mut UiTree<'a>, line: Line<'a>) { } pub(crate) fn layout_span<'a>(layout: &mut UiTree<'a>, span: (Cow<'a, str>, Style)) { - let width = span.0.graphemes(true).count() as u16; + let width = UnicodeWidthStr::width(span.0.as_ref()) as u16; layout.leaf_with_size(span, [width, 1]); } diff --git a/src/ui/layout/mod.rs b/src/ui/layout/mod.rs index 4438a9372f..04c7bb1571 100644 --- a/src/ui/layout/mod.rs +++ b/src/ui/layout/mod.rs @@ -4,7 +4,7 @@ mod vec2; use std::iter; -use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; use direction::Direction; use node::*; @@ -121,7 +121,7 @@ impl LayoutTree<&'static str> { /// Add a text leaf, calculating size based on string length #[allow(dead_code)] pub fn text(&mut self, text: &'static str) -> &mut Self { - let width = text.graphemes(true).count(); + let width = UnicodeWidthStr::width(text); self.leaf_with_size(text, [width as u16, 1]); self } @@ -542,7 +542,22 @@ mod tests { layout.compute([10, 1]); let items: Vec<_> = layout.iter().collect(); - assert_eq!(items[0].size, [4, 1]); // café has 4 graphemes + assert_eq!(items[0].size, [4, 1]); // café renders in 4 columns + } + + #[test] + fn emoji_display_width() { + let mut layout = LayoutTree::new(); + + layout.horizontal(None, OPTS, |layout| { + // A single emoji is one grapheme but occupies two terminal columns. + layout.text("🚀").text("x"); + }); + + layout.compute([10, 1]); + let items: Vec<_> = layout.iter().collect(); + assert_eq!(items[0].size, [2, 1]); // emoji is 2 columns wide, not 1 + assert_eq!(items[1].pos, [2, 0]); // following span starts after it } #[test]