diff --git a/Cargo.lock b/Cargo.lock index d64763d..564cf91 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1839,9 +1839,9 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook" -version = "0.3.18" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d" dependencies = [ "libc", "signal-hook-registry", @@ -2041,6 +2041,7 @@ dependencies = [ "serde_json", "signal-hook", "tinyharness-lib", + "unicode-width", ] [[package]] diff --git a/tinyharness-ui/Cargo.toml b/tinyharness-ui/Cargo.toml index 3e616e3..9bb053f 100644 --- a/tinyharness-ui/Cargo.toml +++ b/tinyharness-ui/Cargo.toml @@ -11,7 +11,8 @@ rustyline = { version = "18.0.0", features = ["derive"] } serde_json = "1.0.149" regex = "1.11.1" libc = "0.2" -signal-hook = "0.3" +signal-hook = "0.4" +unicode-width = "0.2" [features] default = [] diff --git a/tinyharness-ui/src/tui/app.rs b/tinyharness-ui/src/tui/app.rs index cf5700a..b629181 100644 --- a/tinyharness-ui/src/tui/app.rs +++ b/tinyharness-ui/src/tui/app.rs @@ -10,7 +10,7 @@ use std::time::Duration; use super::TuiAgentEvent; use super::backend::Backend; -use super::cell::Style; +use super::cell::{Cell, Style}; use super::event::{Event, EventParser, Key, KeyEvent, MouseButton, MouseEvent}; use super::layout::{Constraint, Direction, Layout, Rect}; use super::screen::Screen; @@ -19,7 +19,6 @@ use super::widget::{Action, Widget}; use super::widgets::conversation::{ContextWarningLevel, ConversationLine, ConversationWidget}; use super::widgets::input_bar::InputBarWidget; use super::widgets::sidebar::SidebarWidget; -use super::widgets::spinner::SpinnerWidget; use super::widgets::status_bar::StatusBarWidget; use super::widgets::tool_output::{ToolOutputWidget, ToolResult, ToolStatus}; @@ -98,7 +97,6 @@ pub struct TuiApp { sidebar: SidebarWidget, input_bar: InputBarWidget, tool_output: ToolOutputWidget, - spinner: SpinnerWidget, // State focus: Focus, @@ -160,7 +158,6 @@ impl TuiApp { sidebar: SidebarWidget::new(), input_bar: InputBarWidget::new("agent", "unknown"), tool_output: ToolOutputWidget::new(), - spinner: SpinnerWidget::new("thinking"), focus: Focus::InputBar, state: TuiState::default(), @@ -324,10 +321,11 @@ impl TuiApp { }); } - /// Set the streaming state (shows/hides spinner). + /// Set the streaming state (shows spinner in input bar, blocks input). pub fn set_streaming(&mut self, streaming: bool) { self.state.streaming = streaming; self.status_bar.set_streaming(streaming); + self.input_bar.set_streaming(streaming); } // ── Layout ─────────────────────────────────────────────────────────── @@ -339,11 +337,20 @@ impl TuiApp { let size = self.terminal.size(); let total = Rect::new(0, 0, size.cols, size.rows); + // Input bar grows up to a maximum based on content but always keeps + // room for the status bar, the conversation, and (optionally) the + // tool output panel. + let max_input_rows = (size.rows / 4).clamp(3, 10); + let input_rows = self + .input_bar + .content_height(size.cols) + .clamp(2, max_input_rows); + // Vertical split: status bar | main area | input bar let vertical = Layout::new(Direction::Vertical).constraints(vec![ - Constraint::Length(1), // status bar - Constraint::Percentage(100), // main area (takes remaining) - Constraint::Length(3), // input bar + Constraint::Length(1), // status bar + Constraint::Percentage(100), // main area (takes remaining) + Constraint::Length(input_rows), // input bar ]); let vertical_areas = vertical.split(total); let status_area = vertical_areas[0]; @@ -366,9 +373,10 @@ impl TuiApp { if self.state.sidebar_visible { // Horizontal split of main area: conversation | sidebar // The sidebar shares the full main area height (including tool output) + let sidebar_width = self.sidebar.desired_width.min(size.cols / 2).max(15); let horizontal = Layout::new(Direction::Horizontal).constraints(vec![ - Constraint::Percentage(100), // conversation - Constraint::Length(25), // sidebar + Constraint::Percentage(100), // conversation + Constraint::Length(sidebar_width), // sidebar ]); let horizontal_areas = horizontal.split(conv_area); let inner_conv = horizontal_areas[0]; @@ -397,6 +405,12 @@ impl TuiApp { // ── Event handling ──────────────────────────────────────────────────── + /// Dismiss the help overlay (close and reset scroll). + fn dismiss_help(&mut self) { + self.help_visible = false; + self.help_scroll = 0; + } + /// Handle a single event and return any action. fn handle_event(&mut self, event: &Event) -> Action { // If help overlay is visible, handle scrolling and dismiss keys @@ -408,45 +422,39 @@ impl TuiApp { key: Key::Char('h'), modifiers, } if modifiers.ctrl => { - self.help_visible = false; - self.help_scroll = 0; + self.dismiss_help(); return Action::None; } KeyEvent { key: Key::F(1), modifiers, } if !modifiers.ctrl && !modifiers.alt => { - self.help_visible = false; - self.help_scroll = 0; + self.dismiss_help(); return Action::None; } - // Escape closes the overlay (explicit, not via catch-all) + // Escape closes the overlay KeyEvent { key: Key::Escape, .. } => { - self.help_visible = false; - self.help_scroll = 0; + self.dismiss_help(); return Action::None; } - // Ctrl+C passes through as quit/interrupt even when help is open + // Ctrl+C and Ctrl+D pass through (close help first, then fall through) KeyEvent { key: Key::Char('c'), modifiers, } if modifiers.ctrl => { - self.help_visible = false; - self.help_scroll = 0; + self.dismiss_help(); // Fall through to global handler below } - // Ctrl+D passes through as quit even when help is open KeyEvent { key: Key::Char('d'), modifiers, } if modifiers.ctrl => { - self.help_visible = false; - self.help_scroll = 0; + self.dismiss_help(); // Fall through to global handler below } - // Scroll up + // Scroll up (Up arrow or 'k') KeyEvent { key: Key::Up, modifiers, @@ -461,7 +469,7 @@ impl TuiApp { self.help_scroll = self.help_scroll.saturating_sub(1); return Action::None; } - // Scroll down + // Scroll down (Down arrow or 'j') KeyEvent { key: Key::Down, modifiers, @@ -476,26 +484,23 @@ impl TuiApp { self.help_scroll = self.help_scroll.saturating_add(1); return Action::None; } - // Page up + // Page up / Page down / Home / End KeyEvent { key: Key::PageUp, .. } => { self.help_scroll = self.help_scroll.saturating_sub(10); return Action::None; } - // Page down KeyEvent { key: Key::PageDown, .. } => { self.help_scroll = self.help_scroll.saturating_add(10); return Action::None; } - // Home — scroll to top KeyEvent { key: Key::Home, .. } => { self.help_scroll = 0; return Action::None; } - // End — scroll to bottom KeyEvent { key: Key::End, .. } => { let content_height = self.help_content_height(); let max_scroll = self.help_line_count.saturating_sub(content_height); @@ -504,8 +509,7 @@ impl TuiApp { } // Any other key dismisses the overlay _ => { - self.help_visible = false; - self.help_scroll = 0; + self.dismiss_help(); return Action::None; } } @@ -522,7 +526,6 @@ impl TuiApp { modifiers, } if modifiers.ctrl => { if self.state.streaming { - // Interrupt streaming — notify the agent loop self.set_streaming(false); return Action::Interrupt; } @@ -559,13 +562,11 @@ impl TuiApp { modifiers, } if modifiers.ctrl => { self.conversation.toggle_search(); - if self.conversation.is_search_active() { - // Enter conversation focus for search input - self.set_focus(Focus::Conversation); + self.set_focus(if self.conversation.is_search_active() { + Focus::Conversation } else { - // Search closed — return to input bar - self.set_focus(Focus::InputBar); - } + Focus::InputBar + }); return Action::None; } // F1 or Ctrl+H: toggle help overlay @@ -643,36 +644,39 @@ impl TuiApp { // Scroll-related key events go to the focused scrollable widget if let Event::Key(key) = event { + let sidebar_focused = matches!(self.focus, Focus::Sidebar | Focus::Structure); match key { KeyEvent { key: Key::PageUp, .. } => { - match self.focus { - Focus::Sidebar | Focus::Structure => self.sidebar.scroll_up(10), - _ => self.conversation.scroll_up(20), + if sidebar_focused { + self.sidebar.scroll_up(10) + } else { + self.conversation.scroll_up(20) } return Action::None; } KeyEvent { key: Key::PageDown, .. } => { - match self.focus { - Focus::Sidebar | Focus::Structure => self.sidebar.scroll_down(10), - _ => self.conversation.scroll_down(20), + if sidebar_focused { + self.sidebar.scroll_down(10) + } else { + self.conversation.scroll_down(20) } return Action::None; } KeyEvent { key: Key::Home, .. } => { - match self.focus { - Focus::Sidebar | Focus::Structure => self.sidebar.scroll_home(), - _ => self.conversation.scroll_home(), + if sidebar_focused { + self.sidebar.scroll_home() + } else { + self.conversation.scroll_home() } return Action::None; } KeyEvent { key: Key::End, .. } => { - match self.focus { - Focus::Sidebar => { /* sidebar has no scroll-to-bottom */ } - _ => self.conversation.scroll_to_bottom(), + if !sidebar_focused { + self.conversation.scroll_to_bottom() } return Action::None; } @@ -896,19 +900,6 @@ impl TuiApp { } self.input_bar.render(input_area, &mut self.screen); - // Render spinner if streaming - if self.state.streaming { - // Put spinner in the bottom-right of the conversation area. - // Clip to conv_area bounds so it doesn't overflow into the sidebar. - let spinner_width = 12u16; // frame(1) + space(1) + label up to ~10 chars - let spinner_x = conv_area.x + conv_area.width.saturating_sub(spinner_width); - let spinner_y = conv_area.y + conv_area.height.saturating_sub(1); - // Ensure we don't extend past the conversation area - let actual_width = spinner_width.min(conv_area.right().saturating_sub(spinner_x)); - let spinner_area = Rect::new(spinner_x, spinner_y, actual_width, 1); - self.spinner.render(spinner_area, &mut self.screen); - } - // Render help overlay if visible if self.help_visible { self.render_help_overlay(conv_area); @@ -1038,70 +1029,21 @@ impl TuiApp { let dim_fg = Color::Ansi(244); // Draw background fill - for row in box_y..box_y + box_height { - for col in box_x..box_x + box_width { - if let Some(cell) = self.screen.get_mut(row, col) { - cell.char = ' '; - cell.fg = desc_fg; - cell.bg = overlay_bg; - cell.style = Style::default(); - } - } - } + let box_rect = Rect::new(box_x, box_y, box_width, box_height); + self.screen.fill_rect( + box_rect, + Cell { + char: ' ', + fg: desc_fg, + bg: overlay_bg, + style: Style::default(), + wide: false, + }, + ); - // Draw border - // Top - if let Some(cell) = self.screen.get_mut(box_y, box_x) { - cell.char = '┌'; - cell.fg = border_fg; - cell.bg = overlay_bg; - } - for col in box_x + 1..box_x + box_width - 1 { - if let Some(cell) = self.screen.get_mut(box_y, col) { - cell.char = '─'; - cell.fg = border_fg; - cell.bg = overlay_bg; - } - } - if let Some(cell) = self.screen.get_mut(box_y, box_x + box_width - 1) { - cell.char = '┐'; - cell.fg = border_fg; - cell.bg = overlay_bg; - } - // Bottom - if let Some(cell) = self.screen.get_mut(box_y + box_height - 1, box_x) { - cell.char = '└'; - cell.fg = border_fg; - cell.bg = overlay_bg; - } - for col in box_x + 1..box_x + box_width - 1 { - if let Some(cell) = self.screen.get_mut(box_y + box_height - 1, col) { - cell.char = '─'; - cell.fg = border_fg; - cell.bg = overlay_bg; - } - } - if let Some(cell) = self - .screen - .get_mut(box_y + box_height - 1, box_x + box_width - 1) - { - cell.char = '┘'; - cell.fg = border_fg; - cell.bg = overlay_bg; - } - // Sides - for row in box_y + 1..box_y + box_height - 1 { - if let Some(cell) = self.screen.get_mut(row, box_x) { - cell.char = '│'; - cell.fg = border_fg; - cell.bg = overlay_bg; - } - if let Some(cell) = self.screen.get_mut(row, box_x + box_width - 1) { - cell.char = '│'; - cell.fg = border_fg; - cell.bg = overlay_bg; - } - } + // Draw border using the screen's draw_box method + self.screen + .draw_box(box_rect, border_fg, overlay_bg, Style::default()); // Draw text lines (scrolled) let content_x = box_x + 1; @@ -1185,6 +1127,7 @@ impl TuiApp { // (one column inside from the right border │) if let Some(cell) = self.screen.get_mut(indicator_y, box_x + box_width - 2) { cell.char = scroll_char; + cell.wide = false; cell.fg = border_fg; cell.bg = overlay_bg; cell.style = Style::default(); @@ -1193,57 +1136,16 @@ impl TuiApp { } /// Diff the current screen against the previous frame and write changes. + /// + /// Uses `Screen::diff_from` and `Screen::render_diff` which correctly + /// handle wide (CJK/fullwidth) characters and cursor position tracking. fn flush_diff(&mut self) -> io::Result<()> { let width = self.screen.width(); - let height = self.screen.height(); - - let mut last_pos: Option<(u16, u16)> = None; - - for row in 0..height { - for col in 0..width { - let curr = self.screen.get(row, col); - let prev = self.prev_screen.get(row, col); - - if curr != prev { - // Move cursor if not adjacent - if last_pos != Some((row, col.saturating_sub(1))) { - write!(self.terminal, "\x1b[{};{}H", row + 1, col + 1)?; - } - - // Write the cell - let cell = curr.unwrap(); - // Apply foreground color - write!(self.terminal, "{}", cell.fg.fg_escape())?; - // Apply background color - write!(self.terminal, "{}", cell.bg.bg_escape())?; - // Apply style - if cell.style.bold { - write!(self.terminal, "\x1b[1m")?; - } - if cell.style.dim { - write!(self.terminal, "\x1b[2m")?; - } - if cell.style.italic { - write!(self.terminal, "\x1b[3m")?; - } - if cell.style.underline { - write!(self.terminal, "\x1b[4m")?; - } - if cell.style.blink { - write!(self.terminal, "\x1b[5m")?; - } - // Write character - if cell.char != '\0' { - write!(self.terminal, "{}", cell.char)?; - } else { - write!(self.terminal, " ")?; - } - // Reset style - write!(self.terminal, "\x1b[0m")?; + let ops = self.screen.diff_from(&self.prev_screen); + let output = Screen::render_diff(&ops, width); - last_pos = Some((row, col)); - } - } + if !output.is_empty() { + self.terminal.write_all(output.as_bytes())?; } // Swap buffers @@ -1298,7 +1200,7 @@ impl TuiApp { // Update spinner animation if self.state.streaming { - self.spinner.tick(); + self.input_bar.tick_streaming(); } // Render and flush @@ -1413,6 +1315,18 @@ impl TuiApp { } } + /// Finalize any in-progress thinking block, updating the conversation + /// with the accumulated thinking text and clearing internal state. + fn finalize_thinking(&mut self) { + if self.is_thinking { + self.is_thinking = false; + if let Some(ConversationLine::Thinking { text: t }) = self.conversation.last_mut() { + *t = self.thinking_text.clone(); + } + self.thinking_text.clear(); + } + } + /// Process an agent event received from the background task. fn handle_agent_event(&mut self, event: TuiAgentEvent) { match event { @@ -1421,8 +1335,8 @@ impl TuiApp { self.streaming_text.clear(); self.is_thinking = false; self.thinking_text.clear(); - self.spinner.set_label("Thinking"); - self.spinner.start(); + self.input_bar.set_streaming_label("Thinking"); + self.input_bar.set_streaming(true); self.set_streaming(true); // Don't push a Thinking placeholder here — push it lazily // when the first StreamingThinking event arrives. This avoids @@ -1431,16 +1345,10 @@ impl TuiApp { } TuiAgentEvent::StreamingText(text) => { // If we were thinking, finalize the thinking block first - if self.is_thinking { - self.is_thinking = false; - // Update the thinking line with accumulated text - if let Some(ConversationLine::Thinking { text: t }) = - self.conversation.last_mut() - { - *t = self.thinking_text.clone(); - } - self.thinking_text.clear(); - self.spinner.set_label("Responding"); + let was_thinking = self.is_thinking; + self.finalize_thinking(); + if was_thinking { + self.input_bar.set_streaming_label("Responding"); } self.streaming_text.push_str(&text); // Update the last assistant message or add a new one @@ -1476,16 +1384,7 @@ impl TuiApp { self.conversation.scroll_to_bottom(); } TuiAgentEvent::StreamingDone => { - // Finalize thinking if still active - if self.is_thinking { - self.is_thinking = false; - if let Some(ConversationLine::Thinking { text: t }) = - self.conversation.last_mut() - { - *t = self.thinking_text.clone(); - } - self.thinking_text.clear(); - } + self.finalize_thinking(); // Finalize the streaming text as a complete assistant message if !self.streaming_text.is_empty() { // The streaming text was already being displayed incrementally @@ -1493,23 +1392,12 @@ impl TuiApp { self.streaming_text.clear(); } self.is_streaming = false; - self.spinner.stop(); self.set_streaming(false); } TuiAgentEvent::Error(msg) => { - // Finalize thinking if still active - if self.is_thinking { - self.is_thinking = false; - if let Some(ConversationLine::Thinking { text: t }) = - self.conversation.last_mut() - { - *t = self.thinking_text.clone(); - } - self.thinking_text.clear(); - } + self.finalize_thinking(); self.push_system_message(&format!("⚠ Error: {}", msg)); self.is_streaming = false; - self.spinner.stop(); self.set_streaming(false); } TuiAgentEvent::ToolCall { name, args_summary } => { @@ -1581,18 +1469,8 @@ impl TuiApp { self.pending_question_answers = answers; } TuiAgentEvent::Done => { - // Finalize thinking if still active - if self.is_thinking { - self.is_thinking = false; - if let Some(ConversationLine::Thinking { text: t }) = - self.conversation.last_mut() - { - *t = self.thinking_text.clone(); - } - self.thinking_text.clear(); - } + self.finalize_thinking(); self.is_streaming = false; - self.spinner.stop(); self.set_streaming(false); } } @@ -1849,7 +1727,8 @@ mod tests { let app = make_app(); let (status, conv, sidebar, input, _main, _tool) = app.compute_layout(); assert_eq!(status.height, 1); - assert_eq!(input.height, 3); + // Input bar is now at least 2 rows: top border + one content row. + assert!(input.height >= 2); assert!(conv.height > 0); assert!(sidebar.width > 0); } @@ -2277,7 +2156,7 @@ mod tests { #[test] fn test_tool_output_layout_without_panel() { - let mut app = make_app(); + let app = make_app(); assert!(!app.tool_output_visible); let (.., tool_area) = app.compute_layout(); assert!(tool_area.is_empty()); diff --git a/tinyharness-ui/src/tui/cell.rs b/tinyharness-ui/src/tui/cell.rs index 917acba..d84e88e 100644 --- a/tinyharness-ui/src/tui/cell.rs +++ b/tinyharness-ui/src/tui/cell.rs @@ -171,12 +171,22 @@ impl Style { /// Each cell stores a character, foreground color, background color, /// and style flags. When the screen is rendered, only cells that changed /// from the previous frame are written to the terminal. +/// +/// For wide (CJK/fullwidth) characters that occupy 2 columns, the first +/// column holds the character and `wide` is false. The second column +/// is a "continuation" cell with `wide = true`, which tells the renderer +/// not to write it separately (the terminal already rendered it as part +/// of the wide character). #[derive(Clone, Debug, PartialEq)] pub struct Cell { pub char: char, pub fg: Color, pub bg: Color, pub style: Style, + /// Whether this cell is the continuation (second column) of a wide + /// character. Continuation cells are skipped during rendering because + /// the terminal already drew them as part of the wide character. + pub wide: bool, } impl Default for Cell { @@ -186,6 +196,7 @@ impl Default for Cell { fg: Color::Default, bg: Color::Default, style: Style::default(), + wide: false, } } } @@ -206,6 +217,20 @@ impl Cell { fg, bg, style, + wide: false, + } + } + + /// Create a continuation cell for a wide character's second column. + /// These cells are skipped during rendering since the terminal + /// already drew them as part of the wide character. + pub fn wide_continuation(fg: Color, bg: Color, style: Style) -> Self { + Cell { + char: ' ', + fg, + bg, + style, + wide: true, } } } diff --git a/tinyharness-ui/src/tui/screen.rs b/tinyharness-ui/src/tui/screen.rs index 118b044..2391220 100644 --- a/tinyharness-ui/src/tui/screen.rs +++ b/tinyharness-ui/src/tui/screen.rs @@ -6,9 +6,33 @@ use std::fmt; +use unicode_width::UnicodeWidthChar; + use super::cell::{Cell, Color, Style}; use super::layout::Rect; +// ── Wrap configuration ──────────────────────────────────────────────────────── + +/// Configuration for wrapped text rendering. +/// +/// Controls wrapping, clipping, and skipping behavior for the +/// [`Screen::write_wrapped`] method. +struct WrapConfig { + /// Maximum column number; text wraps when `col + char_width > wrap_col`. + wrap_col: u16, + /// Column where wrapped lines start (left margin for continuation lines). + left_margin: u16, + /// Maximum screen row; text stops when `screen_row > max_row`. + max_row: u16, + /// Number of visual rows to skip before rendering (for scroll offset). + skip_rows: usize, + /// If true, don't wrap — truncate at `wrap_col` instead. + no_wrap: bool, + /// If true, use screen-bounded wrapping (old `write_str_wrapped` semantics): + /// wraps at screen width and clips at screen height. + screen_bounded: bool, +} + // ── Screen ────────────────────────────────────────────────────────────────── /// A double-buffered screen of cells. @@ -80,9 +104,39 @@ impl Screen { } } + /// Merge a zero-width combining mark into the previous cell. + /// + /// Does nothing if `col` is at the start of the current rendering run + /// or if `in_view` is false. + fn merge_combining_mark( + &mut self, + row: u16, + col: u16, + start_col: u16, + ch: char, + fg: Color, + bg: Color, + style: Style, + in_view: bool, + ) { + if !in_view || col <= start_col { + return; + } + if let Some(prev) = self.get_mut(row, col - 1) { + prev.char = ch; + prev.fg = fg; + prev.bg = bg; + prev.style = style; + } + } + /// Write a string starting at the given position, with the given style. /// - /// Characters that exceed the screen width are truncated. + /// Characters that exceed the screen width are truncated. Each character + /// is placed according to its Unicode display width; zero-width chars + /// (e.g. combining marks) overwrite the previous cell. Wide (CJK/ + /// fullwidth) characters that occupy 2 columns get a continuation + /// cell marked at `col+1` so the renderer can skip it. pub fn write_str( &mut self, row: u16, @@ -97,6 +151,11 @@ impl Screen { if c >= self.width { break; } + let width = ch.width().unwrap_or(1); + if width == 0 { + self.merge_combining_mark(row, c, col, ch, fg, bg, style, c < self.width); + continue; + } self.set_cell( row, c, @@ -105,16 +164,23 @@ impl Screen { fg, bg, style, + wide: false, }, ); - c += 1; + if width > 1 && c + 1 < self.width { + self.set_cell(row, c + 1, Cell::wide_continuation(fg, bg, style)); + } + c += width as u16; } } /// Write a string starting at the given position, truncating or wrapping. /// /// If `wrap` is true, text wraps to the next line. If false, text is - /// truncated at the right edge. + /// truncated at the right edge. Uses Unicode display widths. + /// + /// This is a convenience wrapper around [`Self::write_wrapped`] with + /// simple wrapping bounded by the screen dimensions. pub fn write_str_wrapped( &mut self, start_row: u16, @@ -125,37 +191,22 @@ impl Screen { style: Style, wrap: bool, ) -> u16 { - let mut row = start_row; - let mut col = start_col; - - for ch in text.chars() { - if col >= self.width { - if wrap && row + 1 < self.height { - row += 1; - col = 0; - } else { - break; - } - } - if ch == '\n' { - row += 1; - col = 0; - continue; - } - self.set_cell( - row, - col, - Cell { - char: ch, - fg, - bg, - style, - }, - ); - col += 1; - } - - row + self.write_wrapped( + start_row, + start_col, + text, + fg, + bg, + style, + WrapConfig { + wrap_col: self.width, + left_margin: 0, + max_row: self.height.saturating_sub(1), + skip_rows: 0, + no_wrap: !wrap, + screen_bounded: true, + }, + ) } /// Write a string with wrapping, but clip rendering at the given maximum row @@ -163,7 +214,10 @@ impl Screen { /// /// `wrap_col` is the maximum column number; text wraps when `col >= wrap_col`. /// `max_row` is the maximum row; text stops when `row > max_row`. - /// `left_margin` is the column where wrapped lines start. + /// `left_margin` is the column where wrapped lines start. Uses Unicode display widths. + /// + /// This is a convenience wrapper around [`Self::write_wrapped`] with + /// `skip_rows = 0` and wrapping enabled. pub fn write_str_wrapped_clipped( &mut self, start_row: u16, @@ -176,41 +230,22 @@ impl Screen { max_row: u16, wrap_col: u16, ) -> u16 { - let mut row = start_row; - let mut col = start_col; - - for ch in text.chars() { - if col >= wrap_col { - // Wrap to next line - row += 1; - col = left_margin; - } - // Stop if we've exceeded the max row - if row > max_row { - break; - } - if ch == '\n' { - row += 1; - col = left_margin; - if row > max_row { - break; - } - continue; - } - self.set_cell( - row, - col, - Cell { - char: ch, - fg, - bg, - style, - }, - ); - col += 1; - } - - row + self.write_wrapped( + start_row, + start_col, + text, + fg, + bg, + style, + WrapConfig { + wrap_col, + left_margin, + max_row, + skip_rows: 0, + no_wrap: false, + screen_bounded: false, + }, + ) } /// Write a string with wrapping, skip the first `skip_rows` visual rows, @@ -219,7 +254,10 @@ impl Screen { /// `wrap_col` is the maximum column number; text wraps when `col >= wrap_col`. /// `skip_rows` is the number of visual rows to skip before rendering. /// `max_row` is the maximum row; text stops when `row > max_row`. - /// `left_margin` is the column where wrapped lines start. + /// `left_margin` is the column where wrapped lines start. Uses Unicode display widths. + /// + /// This is a convenience wrapper around [`Self::write_wrapped`] with + /// `skip_rows > 0`. pub fn write_str_wrapped_skip_clipped( &mut self, start_row: u16, @@ -233,40 +271,95 @@ impl Screen { wrap_col: u16, skip_rows: usize, ) { + self.write_wrapped( + start_row, + start_col, + text, + fg, + bg, + style, + WrapConfig { + wrap_col, + left_margin, + max_row, + skip_rows, + no_wrap: false, + screen_bounded: false, + }, + ); + } + + /// Unified wrapped text writing method. + /// + /// Handles all wrapping scenarios through a single [`WrapConfig`] struct. + /// The method tracks both a visual row counter (for skip/clipping) and a + /// screen row counter (for actual cell placement). + /// + /// Returns the final screen row where text ended. + fn write_wrapped( + &mut self, + start_row: u16, + start_col: u16, + text: &str, + fg: Color, + bg: Color, + style: Style, + cfg: WrapConfig, + ) -> u16 { let mut visual_row: usize = 0; let mut col = start_col; let mut screen_row = start_row; for ch in text.chars() { - // Check if we need to wrap before placing this character - if ch != '\n' && col >= wrap_col { - // Wrap to next visual line + let width = ch.width().unwrap_or(1); + if width == 0 { + let in_view = if cfg.skip_rows > 0 { + visual_row >= cfg.skip_rows && screen_row <= cfg.max_row + } else if cfg.screen_bounded { + col < self.width && screen_row < self.height + } else { + screen_row <= cfg.max_row + }; + self.merge_combining_mark(screen_row, col, start_col, ch, fg, bg, style, in_view); + continue; + } + let width_u16 = width as u16; + + // Handle newline + if ch == '\n' { visual_row += 1; - col = left_margin; - // Only advance screen_row if we're past the renderable zone - if visual_row > skip_rows { + col = cfg.left_margin; + if cfg.skip_rows == 0 || visual_row > cfg.skip_rows { screen_row += 1; } - if screen_row > max_row { + if screen_row > cfg.max_row { break; } + continue; } - if ch == '\n' { - // Newline — advance to next visual row + // Handle wrap + if col + width_u16 > cfg.wrap_col { + if cfg.no_wrap { + break; + } visual_row += 1; - col = left_margin; - if visual_row > skip_rows { + col = cfg.left_margin; + if cfg.skip_rows == 0 || visual_row > cfg.skip_rows { screen_row += 1; } - if screen_row > max_row { + if screen_row > cfg.max_row { + break; + } + // For screen_bounded mode (old write_str_wrapped), check screen height + if cfg.screen_bounded && screen_row >= self.height { break; } - continue; } - // Only write the cell if we're past the skip zone - if visual_row >= skip_rows && screen_row <= max_row { + // Only write the cell if we're past the skip zone and within bounds + let past_skip = cfg.skip_rows == 0 || visual_row >= cfg.skip_rows; + if past_skip && screen_row <= cfg.max_row { self.set_cell( screen_row, col, @@ -275,12 +368,18 @@ impl Screen { fg, bg, style, + wide: false, }, ); + if width > 1 && col + 1 < self.width { + self.set_cell(screen_row, col + 1, Cell::wide_continuation(fg, bg, style)); + } } - col += 1; + col += width_u16; } + + screen_row } /// Fill a rectangular area with the given cell. @@ -313,6 +412,7 @@ impl Screen { fg, bg, style: Style::default(), + wide: false, }, ); } @@ -337,6 +437,7 @@ impl Screen { fg, bg, style: Style::default(), + wide: false, }, ); } @@ -362,6 +463,7 @@ impl Screen { fg, bg, style, + wide: false, }, ); self.set_cell( @@ -372,6 +474,7 @@ impl Screen { fg, bg, style, + wide: false, }, ); self.set_cell( @@ -382,6 +485,7 @@ impl Screen { fg, bg, style, + wide: false, }, ); self.set_cell( @@ -392,6 +496,7 @@ impl Screen { fg, bg, style, + wide: false, }, ); @@ -405,6 +510,7 @@ impl Screen { fg, bg, style, + wide: false, }, ); self.set_cell( @@ -415,6 +521,7 @@ impl Screen { fg, bg, style, + wide: false, }, ); } @@ -429,6 +536,7 @@ impl Screen { fg, bg, style, + wide: false, }, ); self.set_cell( @@ -439,6 +547,7 @@ impl Screen { fg, bg, style, + wide: false, }, ); } @@ -500,40 +609,101 @@ impl Screen { /// /// This is the core of the efficient rendering: we only write cells /// that actually changed, and we batch cursor movements. + /// + /// Handles wide (CJK/fullwidth) characters correctly by skipping + /// continuation cells and tracking display width for cursor position. + /// + /// Optimizations for terminal throughput: + /// - Consecutive cells on the same row skip the cursor move (cursor + /// naturally advances after writing a character). + /// - Style reset (`\x1b[0m`) + re-application is only emitted when the + /// style actually changes from the previous cell, not per-cell. + /// - Cells with default style and colors (typically spaces) skip the + /// style/fg/bg escape sequences entirely. pub fn render_diff(ops: &[DiffOp], width: u16) -> String { + use unicode_width::UnicodeWidthChar; + if ops.is_empty() { return String::new(); } - let mut output = String::with_capacity(ops.len() * 20); + let mut output = String::with_capacity(ops.len() * 24); let mut last_row: Option = None; let mut last_col: Option = None; + // Track the currently active style on the terminal so we only emit + // changes, not a full reset+reapply for every cell. + let mut active_fg: Option = None; + let mut active_bg: Option = None; + let mut active_style: Option