From d116f76e4dbd8a469b910663a9f569c615da368b Mon Sep 17 00:00:00 2001 From: atagen Date: Wed, 20 May 2026 16:16:38 +1000 Subject: [PATCH] fix: track by display rows, not line count --- Cargo.lock | 1 + Cargo.toml | 1 + crates/rom-core/Cargo.toml | 1 + crates/rom-core/src/display.rs | 113 ++++++++++++++++++++++++++++++--- 4 files changed, 108 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 62ab7fd..d7ab2d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1404,6 +1404,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tracing", + "unicode-width", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 7d365b3..c26cc8a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ eyre = "0.6.12" dirs = "6.0.0" tempfile = "3.27.0" has-nerd-font = "0.20260228.0" +unicode-width = "0.2.2" [profile.release] opt-level = 3 diff --git a/crates/rom-core/Cargo.toml b/crates/rom-core/Cargo.toml index ed7da8d..db31760 100644 --- a/crates/rom-core/Cargo.toml +++ b/crates/rom-core/Cargo.toml @@ -21,6 +21,7 @@ tracing.workspace = true eyre.workspace = true dirs.workspace = true has-nerd-font.workspace = true +unicode-width.workspace = true [dev-dependencies] tempfile.workspace = true diff --git a/crates/rom-core/src/display.rs b/crates/rom-core/src/display.rs index 4d537d3..bd9eb83 100644 --- a/crates/rom-core/src/display.rs +++ b/crates/rom-core/src/display.rs @@ -8,7 +8,9 @@ use crossterm::{ cursor, execute, style::{Color, ResetColor, SetForegroundColor}, + terminal, }; +use unicode_width::UnicodeWidthChar; use crate::{ icons::Icons, @@ -57,8 +59,8 @@ impl Default for DisplayConfig { pub struct Display { writer: W, config: DisplayConfig, - /// Number of graph lines printed in the last render (cleared on next render) - last_lines: usize, + /// Number of terminal screen rows printed in the last render. + last_rows: usize, /// Total log lines already printed (they scroll naturally, never cleared) printed_log_lines: usize, } @@ -73,7 +75,7 @@ impl Display { Ok(Self { writer, config, - last_lines: 0, + last_rows: 0, printed_log_lines: 0, }) } @@ -85,19 +87,21 @@ impl Display { } pub fn clear_previous(&mut self) -> io::Result<()> { - if self.last_lines > 0 { + if self.last_rows > 0 { // Move up in a single escape sequence, then clear to end of screen. // This is much cheaper than calling MoveUp(1) in a loop because it // produces one write + one flush instead of N. + let rows = self.last_rows.min(u16::MAX as usize) as u16; execute!( self.writer, cursor::MoveToColumn(0), - cursor::MoveUp(self.last_lines as u16), + cursor::MoveUp(rows), cursor::MoveToColumn(0), crossterm::terminal::Clear( crossterm::terminal::ClearType::FromCursorDown ) )?; + self.last_rows = 0; } Ok(()) } @@ -116,7 +120,6 @@ impl Display { } self.writer.write_all(log_out.as_bytes())?; self.printed_log_lines = logs.len(); - self.last_lines = 0; // graph was cleared above } // Clear only the graph from the previous render @@ -141,7 +144,7 @@ impl Display { graph_lines.truncate(self.config.max_visible_lines); } - self.last_lines = graph_lines.len(); + self.last_rows = Self::rendered_rows(&graph_lines); let mut out = String::with_capacity(graph_lines.len() * 80); for line in &graph_lines { @@ -180,7 +183,7 @@ impl Display { tracing::debug!("render_final: {} lines to print", lines.len()); - // Print final output (don't track last_lines since this is final) + // Print final output (don't track last_rows since this is final) for line in lines { writeln!(self.writer, "{line}")?; } @@ -191,6 +194,31 @@ impl Display { Ok(()) } + fn rendered_rows(lines: &[String]) -> usize { + let width = terminal::size() + .ok() + .map(|(cols, _)| cols as usize) + .filter(|&cols| cols > 0); + + Self::rendered_rows_for_width(lines, width) + } + + fn rendered_rows_for_width(lines: &[String], width: Option) -> usize { + let Some(width) = width else { + return lines.len(); + }; + + lines + .iter() + .map(|line| Self::screen_rows_for_line(line, width)) + .sum() + } + + fn screen_rows_for_line(line: &str, width: usize) -> usize { + let visible_width = visible_width(line); + visible_width.div_ceil(width).max(1) + } + fn render_final_summary(&self, state: &State) -> Vec { match self.config.summary_style { SummaryStyle::Concise => self.render_finished_line(state), @@ -1696,6 +1724,54 @@ impl Display { } } +fn visible_width(text: &str) -> usize { + let mut width = 0; + let mut chars = text.chars().peekable(); + + while let Some(ch) = chars.next() { + if ch == '\x1b' { + skip_ansi_escape(&mut chars); + } else { + width += UnicodeWidthChar::width(ch).unwrap_or(0); + } + } + + width +} + +fn skip_ansi_escape(chars: &mut std::iter::Peekable) +where + I: Iterator, +{ + match chars.peek() { + Some('[') => { + chars.next(); + for ch in chars.by_ref() { + if ('@'..='~').contains(&ch) { + break; + } + } + }, + Some(']') => { + chars.next(); + let mut saw_escape = false; + for ch in chars.by_ref() { + if saw_escape && ch == '\\' { + break; + } + if ch == '\x07' { + break; + } + saw_escape = ch == '\x1b'; + } + }, + Some(_) => { + chars.next(); + }, + None => {}, + } +} + fn format_size(bytes: u64) -> String { if bytes < 1024 { format!("{bytes} B") @@ -1707,3 +1783,24 @@ fn format_size(bytes: u64) -> String { format!("{:.1} GiB", bytes as f64 / (1024.0 * 1024.0 * 1024.0)) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rendered_rows_counts_wrapped_screen_rows() { + let lines = vec!["abc".to_string(), "abcdef".to_string(), String::new()]; + + assert_eq!( + Display::>::rendered_rows_for_width(&lines, Some(3)), + 4 + ); + } + + #[test] + fn visible_width_ignores_ansi_escape_sequences() { + assert_eq!(visible_width("\x1b[31mabcdef\x1b[0m"), 6); + assert_eq!(visible_width("\x1b[1mwide 你\x1b[0m"), 7); + } +}