From 0b7b78f5ab89259d2eadf4ab5c6d523f82ce0db0 Mon Sep 17 00:00:00 2001 From: evalstate Date: Sat, 11 Jul 2026 23:11:09 +0100 Subject: [PATCH 1/2] feat: wrap long source lines --- README.md | 3 +- src/app.rs | 25 +++++- src/render.rs | 227 +++++++++++++++++++++++++++++++++++++------------- src/runner.rs | 45 ++++++---- 4 files changed, 220 insertions(+), 80 deletions(-) diff --git a/README.md b/README.md index 93115dc..c932e8b 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ Mouse controls: - Click a comment to edit it. - Use the wheel to scroll the document or active editor. - Saved ranges keep a green rail beside every source line they cover. +- Long source lines wrap to the available terminal width. Keyboard controls: @@ -81,7 +82,7 @@ Keyboard controls: | `Esc` | Cancel selection or editing | | `e` / `d` | Edit or delete a focused comment or one on the cursor line | | `[` / `]` | Jump to the previous/next comment | -| `h` / `l` | Scroll long source lines horizontally | +| `Alt-Z` | Toggle source-line word wrapping (on by default) | | `q` | Write output and quit | Pass `--no-mouse` when terminal mouse capture is undesirable. Native terminal text diff --git a/src/app.rs b/src/app.rs index fb56f44..60e132d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -68,6 +68,12 @@ impl CommentEditor { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WordWrap { + On, + Off, +} + #[derive(Debug)] pub struct App { pub source: SourceBuffer, @@ -77,7 +83,7 @@ pub struct App { pub editor: Option, pub active_comment_id: Option, pub scroll_row: usize, - pub horizontal_scroll: usize, + pub word_wrap: WordWrap, pub should_quit: bool, pub follow_cursor: bool, pub mouse_drag_anchor: Option, @@ -97,7 +103,7 @@ impl App { editor: None, active_comment_id: None, scroll_row: 0, - horizontal_scroll: 0, + word_wrap: WordWrap::On, should_quit: false, follow_cursor: true, mouse_drag_anchor: None, @@ -107,6 +113,21 @@ impl App { } } + pub fn toggle_word_wrap(&mut self) { + self.word_wrap = match self.word_wrap { + WordWrap::On => WordWrap::Off, + WordWrap::Off => WordWrap::On, + }; + self.follow_cursor = true; + self.status = Some( + match self.word_wrap { + WordWrap::On => "Word wrap on", + WordWrap::Off => "Word wrap off", + } + .into(), + ); + } + pub fn move_cursor(&mut self, delta: isize) { let maximum = self.source.line_count(); self.cursor_line = self diff --git a/src/render.rs b/src/render.rs index e44044d..91ee129 100644 --- a/src/render.rs +++ b/src/render.rs @@ -11,18 +11,24 @@ use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; use crate::{ - app::App, + app::{App, WordWrap}, input::{HitArea, HitTarget}, }; const EDITOR_HEIGHT: usize = 5; const MINIMUM_HEIGHT: u16 = 7; const TAB_STOP: usize = 4; +// fast-agent declares Black on Gray for prompt_toolkit's bottom toolbar, whose +// built-in `reverse` style renders the effective terminal colors as Gray on Black. +const FOOTER_FOREGROUND: Color = Color::Gray; +const FOOTER_BACKGROUND: Color = Color::Black; #[derive(Debug, Clone, PartialEq, Eq)] enum DocumentRow { Source { line_number: usize, + text: String, + first: bool, commented: bool, active: bool, }, @@ -86,14 +92,18 @@ fn render_header(frame: &mut Frame<'_>, app: &App, area: Rect) { } fn render_document(frame: &mut Frame<'_>, app: &mut App, area: Rect) -> Vec { - let rows = document_rows(app); + let line_digits = app.source.line_count().to_string().len(); + let text_width = match app.word_wrap { + WordWrap::On => source_text_width(area.width, line_digits), + WordWrap::Off => usize::MAX, + }; + let rows = document_rows(app, text_width); update_scroll(app, &rows, usize::from(area.height)); let visible = rows .iter() .enumerate() .skip(app.scroll_row) .take(usize::from(area.height)); - let line_digits = app.source.line_count().to_string().len(); let mut hits = Vec::new(); let mut editor_rect: Option = None; @@ -106,20 +116,8 @@ fn render_document(frame: &mut Frame<'_>, app: &mut App, area: Rect) -> Vec { - render_source_row( - frame, - app, - rect, - *line_number, - line_digits, - *commented, - *active, - ); + DocumentRow::Source { line_number, .. } => { + render_source_row(frame, app, rect, row, line_digits); hits.push(HitArea::new(rect, HitTarget::SourceLine(*line_number))); } DocumentRow::Comment { id, text, first } => { @@ -157,7 +155,7 @@ fn render_document(frame: &mut Frame<'_>, app: &mut App, area: Rect) -> Vec Vec { +fn document_rows(app: &App, text_width: usize) -> Vec { let mut rows = Vec::new(); let editing_id = app.editor.as_ref().and_then(|editor| editor.comment_id); let editor_end = app.editor.as_ref().map(|editor| editor.end_line); @@ -199,12 +197,22 @@ fn document_rows(app: &App) -> Vec { comments_covering_line = comments_covering_line.saturating_sub(ends); comments_covering_line = comments_covering_line.saturating_add(starts); } - rows.push(DocumentRow::Source { - line_number, - commented: comments_covering_line > 0, - active: active_range - .is_some_and(|(start, end)| start <= line_number && line_number <= end), - }); + let commented = comments_covering_line > 0; + let active = + active_range.is_some_and(|(start, end)| start <= line_number && line_number <= end); + for (index, text) in + wrapped_line(app.source.line(line_number).unwrap_or_default(), text_width) + .into_iter() + .enumerate() + { + rows.push(DocumentRow::Source { + line_number, + text, + first: index == 0, + commented, + active, + }); + } if let Some(comments) = comments_by_end.get(&line_number) { for comment in comments { for (index, body_line) in comment.body.lines().enumerate() { @@ -292,22 +300,32 @@ fn render_source_row( frame: &mut Frame<'_>, app: &App, area: Rect, - line_number: usize, + row: &DocumentRow, line_digits: usize, - commented: bool, - active: bool, ) { + let DocumentRow::Source { + line_number, + text, + first, + commented, + active, + } = row + else { + return; + }; let selected = app .selection - .is_some_and(|selection| selection.contains(line_number)); - let cursor = - app.cursor_line == line_number && app.editor.is_none() && app.active_comment_id.is_none(); + .is_some_and(|selection| selection.contains(*line_number)); + let cursor = *first + && app.cursor_line == *line_number + && app.editor.is_none() + && app.active_comment_id.is_none(); let marker = if cursor { "▶" } else { " " }; - let number = format!("{marker}{line_number:>line_digits$} "); - let text = horizontally_scrolled( - app.source.line(line_number).unwrap_or_default(), - app.horizontal_scroll, - ); + let number = if *first { + format!("{marker}{line_number:>line_digits$} ") + } else { + format!(" {continuation:>line_digits$} ", continuation = "·") + }; let style = if selected { Style::default().fg(Color::White).bg(Color::DarkGray) } else { @@ -318,13 +336,13 @@ fn render_source_row( } else { Style::default().fg(Color::DarkGray) }; - let rail = if commented { "┃" } else { "│" }; - let rail_style = if active { + let rail = if *commented { "┃" } else { "│" }; + let rail_style = if *active { Style::default() .fg(Color::Black) .bg(Color::Green) .add_modifier(Modifier::BOLD) - } else if commented { + } else if *commented { Style::default() .fg(Color::Green) .add_modifier(Modifier::BOLD) @@ -335,7 +353,7 @@ fn render_source_row( Span::styled(number, number_style), Span::styled(rail, rail_style), Span::styled(" ", style), - Span::styled(text, style), + Span::styled(text.to_owned(), style), ]); frame.render_widget(Paragraph::new(line).style(style), area); } @@ -373,19 +391,54 @@ fn extend_editor_rect(editor_rect: &mut Option, row: Rect) { } } -fn horizontally_scrolled(text: &str, columns: usize) -> String { +fn source_text_width(area_width: u16, line_digits: usize) -> usize { + usize::from(area_width) + .saturating_sub(line_digits + 4) + .max(1) +} + +fn wrapped_line(text: &str, width: usize) -> Vec { let expanded = expand_tabs(text); - let mut skipped = 0; - expanded - .graphemes(true) - .skip_while(|grapheme| { - if skipped >= columns { - return false; + let graphemes = expanded.graphemes(true).collect::>(); + if graphemes.is_empty() { + return vec![String::new()]; + } + + let mut lines = Vec::new(); + let mut start = 0; + while start < graphemes.len() { + let mut end = start; + let mut columns = 0usize; + let mut overflowed = false; + while end < graphemes.len() { + let grapheme_width = graphemes[end].width(); + if end > start && columns.saturating_add(grapheme_width) > width { + overflowed = true; + break; } - skipped += grapheme.width(); - true - }) - .collect() + columns = columns.saturating_add(grapheme_width); + end += 1; + if columns >= width { + break; + } + } + + if overflowed { + if let Some(break_index) = (start + 1..end) + .rev() + .find(|index| graphemes[*index].chars().all(char::is_whitespace)) + { + end = break_index; + } + } + + lines.push(graphemes[start..end].concat().trim_end().to_owned()); + start = end; + while start < graphemes.len() && graphemes[start].chars().all(char::is_whitespace) { + start += 1; + } + } + lines } fn expand_tabs(text: &str) -> String { @@ -420,7 +473,7 @@ fn render_footer(frame: &mut Frame<'_>, app: &App, area: Rect) { &format!(" Lines {start}–{end} selected · {finish} to comment · Esc cancel "), ); } else { - " drag or Shift-↑/↓ select · Enter comment · e edit · d delete · [/] comments · q quit " + " drag or Shift-↑/↓ select · Enter comment · e edit · d delete · Alt-Z wrap · q quit " }; let text = app.status.as_deref().unwrap_or(help); render_footer_text(frame, area, text); @@ -428,7 +481,7 @@ fn render_footer(frame: &mut Frame<'_>, app: &App, area: Rect) { fn render_footer_text(frame: &mut Frame<'_>, area: Rect, text: &str) { frame.render_widget( - Paragraph::new(text).style(Style::default().fg(Color::Black).bg(Color::Gray)), + Paragraph::new(text).style(Style::default().fg(FOOTER_FOREGROUND).bg(FOOTER_BACKGROUND)), area, ); } @@ -464,6 +517,23 @@ mod tests { .any(|hit| hit.target == HitTarget::SourceLine(3))); } + #[test] + fn footer_matches_fast_agent_effective_status_bar_colors() { + let source = SourceBuffer::from_bytes("sample", b"one\n").unwrap(); + let review = ReviewDocument::empty(source.source_ref()); + let mut app = App::new(source, review); + let backend = TestBackend::new(80, 8); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal + .draw(|frame| drop(render_app(frame, &mut app))) + .unwrap(); + + let footer_cell = &terminal.backend().buffer()[(0, 7)]; + assert_eq!(footer_cell.fg, Color::Gray); + assert_eq!(footer_cell.bg, Color::Black); + } + #[test] fn cramped_terminal_shows_a_clear_requirement() { let source = SourceBuffer::from_bytes("sample", b"one\n").unwrap(); @@ -538,7 +608,7 @@ mod tests { } #[test] - fn following_cursor_scrolls_long_documents_and_horizontal_text() { + fn following_cursor_scrolls_long_documents() { let text = (1..=30) .map(|line| format!("line {line}")) .collect::>() @@ -547,16 +617,53 @@ mod tests { let review = ReviewDocument::empty(source.source_ref()); let mut app = App::new(source, review); app.move_to_line(30); - app.horizontal_scroll = 5; let backend = TestBackend::new(40, 8); let mut terminal = Terminal::new(backend).unwrap(); terminal .draw(|frame| drop(render_app(frame, &mut app))) .unwrap(); assert!(app.scroll_row > 0); - assert_eq!(horizontally_scrolled("abcdef", 3), "def"); - assert_eq!(horizontally_scrolled("界abc", 2), "abc"); - assert_eq!(horizontally_scrolled("e\u{301}abc", 1), "abc"); + } + + #[test] + fn source_lines_word_wrap_with_logical_line_hit_targets() { + let source = + SourceBuffer::from_bytes("sample", b"alpha beta gamma delta epsilon\nsecond line\n") + .unwrap(); + let review = ReviewDocument::empty(source.source_ref()); + let mut app = App::new(source, review); + let backend = TestBackend::new(24, 10); + let mut terminal = Terminal::new(backend).unwrap(); + let mut hits = Vec::new(); + + terminal + .draw(|frame| hits = render_app(frame, &mut app)) + .unwrap(); + + assert!(terminal.backend().to_string().contains("· │")); + assert!( + hits.iter() + .filter(|hit| hit.target == HitTarget::SourceLine(1)) + .count() + > 1 + ); + assert_eq!( + wrapped_line("alpha beta gamma", 10), + ["alpha beta", "gamma"] + ); + assert_eq!(wrapped_line("界界界", 4), ["界界", "界"]); + assert_eq!(wrapped_line("e\u{301}abc", 2), ["e\u{301}a", "bc"]); + + app.toggle_word_wrap(); + terminal + .draw(|frame| hits = render_app(frame, &mut app)) + .unwrap(); + assert_eq!( + hits.iter() + .filter(|hit| hit.target == HitTarget::SourceLine(1)) + .count(), + 1 + ); } #[test] @@ -582,7 +689,7 @@ mod tests { .draw(|frame| drop(render_app(frame, &mut app))) .unwrap(); - let focused_row = document_rows(&app) + let focused_row = document_rows(&app, 35) .iter() .position(|row| { matches!( @@ -605,10 +712,10 @@ mod tests { } #[test] - fn tabs_expand_to_four_column_stops_before_scrolling() { + fn tabs_expand_to_four_column_stops_before_wrapping() { assert_eq!(expand_tabs("\talpha"), " alpha"); assert_eq!(expand_tabs("a\tb"), "a b"); assert_eq!(expand_tabs("界\tb"), "界 b"); - assert_eq!(horizontally_scrolled("a\tb", 4), "b"); + assert_eq!(wrapped_line("a\tb", 4), ["a", "b"]); } } diff --git a/src/runner.rs b/src/runner.rs index 9d01c50..dd3d3a7 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -313,6 +313,9 @@ fn handle_editor_key(key: KeyEvent, app: &mut App) { fn handle_browse_key(key: KeyEvent, app: &mut App) { match key.code { + KeyCode::Char('z') if key.modifiers.contains(KeyModifiers::ALT) => { + app.toggle_word_wrap(); + } KeyCode::Char('q') => app.should_quit = true, KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { app.should_quit = true; @@ -350,12 +353,6 @@ fn handle_browse_key(key: KeyEvent, app: &mut App) { } KeyCode::Char(']') => app.jump_comment(true), KeyCode::Char('[') => app.jump_comment(false), - KeyCode::Char('h') | KeyCode::Left => { - app.horizontal_scroll = app.horizontal_scroll.saturating_sub(4); - } - KeyCode::Char('l') | KeyCode::Right => { - app.horizontal_scroll = app.horizontal_scroll.saturating_add(4); - } _ => {} } } @@ -390,9 +387,10 @@ fn handle_mouse(mouse: MouseEvent, app: &mut App, hit_areas: &[HitArea]) { app.open_selected_editor(); } } - MouseEventKind::Up(_) => { - app.mouse_drag_anchor = None; - } + MouseEventKind::Up(_) + | MouseEventKind::Down(_) + | MouseEventKind::ScrollLeft + | MouseEventKind::ScrollRight => app.mouse_drag_anchor = None, MouseEventKind::ScrollDown => { if let Some(editor) = app.editor.as_mut() { editor.textarea.scroll(Scrolling::PageDown); @@ -409,13 +407,6 @@ fn handle_mouse(mouse: MouseEvent, app: &mut App, hit_areas: &[HitArea]) { app.follow_cursor = false; } } - MouseEventKind::ScrollLeft => { - app.horizontal_scroll = app.horizontal_scroll.saturating_sub(4); - } - MouseEventKind::ScrollRight => { - app.horizontal_scroll = app.horizontal_scroll.saturating_add(4); - } - MouseEventKind::Down(_) => app.mouse_drag_anchor = None, MouseEventKind::Moved | MouseEventKind::Drag(_) => {} } } @@ -427,7 +418,7 @@ mod tests { use ratatui::layout::Rect; use tempfile::tempdir; - use crate::{domain::ReviewDocument, source::SourceBuffer}; + use crate::{app::WordWrap, domain::ReviewDocument, source::SourceBuffer}; use super::*; @@ -549,6 +540,26 @@ mod tests { assert_eq!(app.review.comments.len(), 1); } + #[test] + fn alt_z_toggles_word_wrap() { + let mut app = app(); + assert_eq!(app.word_wrap, WordWrap::On); + + handle_key( + KeyEvent::new(KeyCode::Char('z'), KeyModifiers::ALT), + &mut app, + ); + assert_eq!(app.word_wrap, WordWrap::Off); + assert_eq!(app.status.as_deref(), Some("Word wrap off")); + + handle_key( + KeyEvent::new(KeyCode::Char('z'), KeyModifiers::ALT), + &mut app, + ); + assert_eq!(app.word_wrap, WordWrap::On); + assert_eq!(app.status.as_deref(), Some("Word wrap on")); + } + #[test] fn arrows_and_enter_open_an_existing_comment_for_keyboard_editing() { let mut app = app(); From 5fc2443f0fc28d6830a334348b2f582af086efc8 Mon Sep 17 00:00:00 2001 From: evalstate Date: Sat, 11 Jul 2026 23:29:39 +0100 Subject: [PATCH 2/2] word wrap/toggle, status bar colours --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 8fe8517..0cc5ca8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ /target/ /mutants.out*/ -*.tmp-* +.fast-agent/