From 24ca6f0262cb12b59499c6058cc402378125f2b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Sun, 9 Nov 2025 04:12:15 +0100 Subject: [PATCH] Fix text positioning by calculating visual width without ANSI codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The motion effects were appearing with incorrect row offsets because the text width calculation included ANSI color escape sequences in the byte length, causing misaligned centering. Changes: - Added utils/ansi.rs module with strip_ansi() and visual_width() - Updated renderer to use visual_width() for offset positioning - Updated TerminalManager::print_centered() to use visual_width() - All text positioning now correctly ignores ANSI escape sequences This fixes the "shifted in the rows" issue where colored text was not properly centered due to escape sequence byte counts. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/animation/renderer.rs | 4 +-- src/utils/ansi.rs | 54 +++++++++++++++++++++++++++++++++++++++ src/utils/mod.rs | 1 + src/utils/terminal.rs | 6 +++-- 4 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 src/utils/ansi.rs diff --git a/src/animation/renderer.rs b/src/animation/renderer.rs index d2ec3bf..6feba70 100644 --- a/src/animation/renderer.rs +++ b/src/animation/renderer.rs @@ -1,6 +1,6 @@ use crate::animation::{easing::EasingFunction, effects::Effect, timeline::Timeline}; use crate::color::{apply, ColorEngine}; -use crate::utils::{ascii::AsciiArt, terminal::TerminalManager}; +use crate::utils::{ansi, ascii::AsciiArt, terminal::TerminalManager}; use anyhow::Result; use tokio::time::sleep; @@ -62,7 +62,7 @@ impl<'a> Renderer<'a> { let (width, height) = terminal.get_size(); let lines: Vec<&str> = colored_text.lines().collect(); let text_height = lines.len() as i32; - let text_width = lines.iter().map(|l| l.len()).max().unwrap_or(0) as i32; + let text_width = lines.iter().map(|l| ansi::visual_width(l)).max().unwrap_or(0) as i32; let base_x = (width as i32 - text_width) / 2; let base_y = (height as i32 - text_height) / 2; diff --git a/src/utils/ansi.rs b/src/utils/ansi.rs new file mode 100644 index 0000000..0c12d5f --- /dev/null +++ b/src/utils/ansi.rs @@ -0,0 +1,54 @@ +/// Strip ANSI escape sequences from a string to get visual width +pub fn strip_ansi(text: &str) -> String { + let mut result = String::new(); + let mut chars = text.chars().peekable(); + + while let Some(ch) = chars.next() { + if ch == '\x1b' { + // Skip ANSI escape sequence + if chars.peek() == Some(&'[') { + chars.next(); // consume '[' + // Skip until we hit a letter (the command character) + while let Some(&c) = chars.peek() { + chars.next(); + if c.is_ascii_alphabetic() { + break; + } + } + } + } else { + result.push(ch); + } + } + + result +} + +/// Get the visual width of a string (excluding ANSI codes) +pub fn visual_width(text: &str) -> usize { + strip_ansi(text).chars().count() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_strip_ansi() { + let text = "\x1b[38;2;255;87;51mHello\x1b[0m"; + assert_eq!(strip_ansi(text), "Hello"); + } + + #[test] + fn test_visual_width() { + let text = "\x1b[38;2;255;87;51mHi\x1b[0m"; + assert_eq!(visual_width(text), 2); + } + + #[test] + fn test_no_ansi() { + let text = "Plain text"; + assert_eq!(strip_ansi(text), "Plain text"); + assert_eq!(visual_width(text), 10); + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index ebbd1ef..e7ba92a 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,2 +1,3 @@ +pub mod ansi; pub mod ascii; pub mod terminal; diff --git a/src/utils/terminal.rs b/src/utils/terminal.rs index 3c6d54e..14e5537 100644 --- a/src/utils/terminal.rs +++ b/src/utils/terminal.rs @@ -5,6 +5,8 @@ use crossterm::{ }; use std::io::{stdout, Write}; +use super::ansi; + pub struct TerminalManager { width: u16, height: u16, @@ -67,14 +69,14 @@ impl TerminalManager { pub fn print_centered(&self, text: &str) -> Result<()> { let lines: Vec<&str> = text.lines().collect(); - let max_width = lines.iter().map(|l| l.len()).max().unwrap_or(0) as u16; + let max_width = lines.iter().map(|l| ansi::visual_width(l)).max().unwrap_or(0) as u16; let height = lines.len() as u16; let start_x = (self.width.saturating_sub(max_width)) / 2; let start_y = (self.height.saturating_sub(height)) / 2; for (i, line) in lines.iter().enumerate() { - let line_width = line.len() as u16; + let line_width = ansi::visual_width(line) as u16; let x = start_x + (max_width.saturating_sub(line_width)) / 2; let y = start_y + i as u16; self.print_at(x, y, line)?;