From 135e94b5e41ac7b5e1adcb6916f16778e4dc697e Mon Sep 17 00:00:00 2001 From: kellito Date: Sun, 5 Jul 2026 16:51:33 +0300 Subject: [PATCH] =?UTF-8?q?tlc:=20Sprint=202=20B1=20=E2=80=94=20word-bound?= =?UTF-8?q?ary=20wrapping=20in=20editor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the character-counting wrap in build_wrap_map with a word-boundary algorithm that matches MC's intent: lines break at the last whitespace before the wrap limit, mid-word break as fallback for overlong words, tabs counted as their visual width (next tab_width boundary), control chars as 2 cols. New helpers (editor/render.rs): visual_width(ch, col, tab_width) -> usize - Tabs: tab_width - (col % tab_width), min 1 - Control chars (0x00..=0x1F, 0x7F): 2 cols (renders as ^X) - Other chars: 1 col count_wrapped_rows(line_bytes, body_width, tab_width) -> usize - Walks line left-to-right, tracks last whitespace position - On overflow: wrap at last_ws_col (preserves word boundary), else mid-word break - Stops at \n (line_bytes excludes \n in caller) Editor (mod.rs): - New tab_width: usize field (default 4) - Matches the renderer's ' ' tab expansion in push_rendered_text - Future B4 will make this user-configurable Tests: +12 wrap_tests in editor::render::wrap_tests: - visual_width_tab_expands_to_next_boundary - visual_width_ascii_and_control - count_wrapped_rows_empty_line - count_wrapped_rows_short_line_fits_in_one_row - count_wrapped_rows_exact_fit - count_wrapped_rows_one_char_overflow_no_whitespace - count_wrapped_rows_word_boundary_break - count_wrapped_rows_three_words_at_width_six - count_wrapped_rows_long_word_falls_back_to_mid_word - count_wrapped_rows_tab_visual_width - count_wrapped_rows_zero_width_returns_one - count_wrapped_rows_stops_at_newline Total: 1242 passing (was 1230; +12 new). --- .../recipes/tui/tlc/source/src/editor/mod.rs | 6 + .../tui/tlc/source/src/editor/render.rs | 204 ++++++++++++++++-- 2 files changed, 198 insertions(+), 12 deletions(-) diff --git a/local/recipes/tui/tlc/source/src/editor/mod.rs b/local/recipes/tui/tlc/source/src/editor/mod.rs index a7408b6d95..770a3c7af2 100644 --- a/local/recipes/tui/tlc/source/src/editor/mod.rs +++ b/local/recipes/tui/tlc/source/src/editor/mod.rs @@ -133,6 +133,10 @@ pub struct Editor { /// body area. When false (default), long lines are truncated at /// the right margin. Toggled by Alt-W. word_wrap: bool, + /// Visual width of a tab character for wrap calculation. Defaults + /// to 4 (matches the renderer's `" "` tab expansion). Future + /// enhancement (B4): make this user-configurable. + tab_width: usize, /// Word completion session (Alt-Tab). completer: Completer, /// Saved word-prefix length for completion replacement. @@ -251,6 +255,7 @@ impl Editor { search_pattern: None, clipboard: None, word_wrap: false, + tab_width: 4, completer: Completer::new(), complete_prefix_len: 0, bookmarks: BookmarkSet::new(), @@ -296,6 +301,7 @@ bracket_flash: None, search_pattern: None, clipboard: None, word_wrap: false, + tab_width: 4, completer: Completer::new(), complete_prefix_len: 0, bookmarks: BookmarkSet::new(), diff --git a/local/recipes/tui/tlc/source/src/editor/render.rs b/local/recipes/tui/tlc/source/src/editor/render.rs index fe4aafff30..12bc901233 100644 --- a/local/recipes/tui/tlc/source/src/editor/render.rs +++ b/local/recipes/tui/tlc/source/src/editor/render.rs @@ -148,7 +148,7 @@ impl Editor { let cursor_line = self.buffer_line_of(self.cursor.position()); let body_width = body_area.width as usize; let wrapped_map: Vec<(usize, usize)> = if self.word_wrap && body_width > 0 { - build_wrap_map(&self.buffer, top, height, body_width) + build_wrap_map(&self.buffer, top, height, body_width, self.tab_width) } else { (0..height).map(|row| (top + row, 0)).collect() }; @@ -999,35 +999,121 @@ pub(crate) fn round_up_to_char_boundary(s: &str, idx: usize) -> usize { i } +/// Visual column width of `ch` when positioned at column `col`. +/// Tabs expand to the next multiple of `tab_width`. +fn visual_width(ch: char, col: usize, tab_width: usize) -> usize { + if ch == '\t' { + tab_width.saturating_sub(col % tab_width).max(1) + } else if ch.is_control() { + // MC renders control chars as `^X` (2 cols). For other + // zero-width runes (combining marks, etc.) report 1 to + // avoid zero-width infinite loops. + if (ch as u32) < 0x20 || ch == '\u{7f}' { + 2 + } else { + 1 + } + } else { + // Treat all other chars as width 1. CJK/wide-char display + // width is a future refinement (MC itself only handles + // ASCII + control). + 1 + } +} + +/// Compute how many visual rows a logical line occupies when wrapped +/// at `body_width` columns, breaking at word boundaries (whitespace). +/// +/// Walks the line left-to-right. Tracks the last whitespace +/// position within the current row. When adding the next char would +/// exceed `body_width`: +/// - If we saw whitespace earlier in this row, wrap at the column +/// immediately AFTER that whitespace (the last whole word fits, +/// the new word starts on a fresh row). +/// - Else (the current word is longer than `body_width`), wrap +/// mid-word at the current column. +/// +/// Tabs are counted as their visual width (next `tab_width` boundary). +/// Empty lines always count as 1 visual row (so the gutter shows `~`). +fn count_wrapped_rows(line_bytes: &[u8], body_width: usize, tab_width: usize) -> usize { + if body_width == 0 { + return 1; + } + let mut rows = 1usize; + let mut col = 0usize; + let mut last_ws_col: Option = None; + let mut chars = line_bytes.iter().copied().peekable(); + while let Some(b) = chars.next() { + if b == b'\n' { + break; + } + let ch = b as char; + let w = visual_width(ch, col, tab_width); + if col + w > body_width { + if let Some(ws_col) = last_ws_col { + // Wrap at column after the last whitespace: the + // word after that whitespace fits on a fresh row. + rows += 1; + col = col.saturating_sub(ws_col); + // Reset — whitespace positions in the new row are + // independent of the previous row. + last_ws_col = None; + } else { + // No whitespace in current row — mid-word break. + // The current char doesn't fit; it starts the new row. + rows += 1; + col = 0; + } + // Re-evaluate this char against the new (possibly empty) + // row. If it still doesn't fit (char wider than + // body_width), place it anyway — downstream rendering + // truncates with the body's max width. + if col + w > body_width { + col = w; + continue; + } + } + col += w; + if ch == ' ' || ch == '\t' { + // Whitespace at position (col - w) is now part of the + // current row's last-WS marker. After advancing past + // it, the next word start is `col`. + last_ws_col = Some(col); + } + } + rows +} + /// Build a flat `(logical_line_idx, wrap_row)` mapping for the /// viewport rows `0..height`. Each entry says "row N of the viewport /// displays logical line `logical_line_idx`, the Nth wrapped visual /// row of that line". When word-wrap is off, the mapping is /// `(top + row, 0)` — a degenerate one-to-one. /// -/// We use a simple character-counting wrap: each logical line -/// contributes `ceil(chars / body_width)` visual rows, with a -/// minimum of one (so empty lines still display as `~`). This is a -/// faithful approximation of `Paragraph::wrap(Wrap { trim: false })` -/// for plain text and matches the per-line counts that the gutter -/// expects (one gutter entry per visual row). +/// When wrap is on, we count visual rows per logical line using +/// word-boundary wrapping (tabs counted as their visual width, breaks +/// at the last whitespace before the wrap limit, mid-word break as +/// fallback for overlong words). Empty lines count as 1 row so the +/// gutter shows `~`. fn build_wrap_map( buffer: &crate::editor::buffer::Buffer, top: usize, height: usize, body_width: usize, + tab_width: usize, ) -> Vec<(usize, usize)> { let mut map = Vec::with_capacity(height); let line_count = buffer.line_count(); + let bytes = buffer.to_bytes(); let mut line_idx = top; while map.len() < height && line_idx < line_count { - let chars = buffer.line_length_chars(line_idx); - // Empty line is always at least one visual row so the user - // sees the `~` tilde marker. - let rows = if body_width == 0 || chars == 0 { + let off = buffer.line_offset(line_idx); + let len = buffer.line_length(line_idx); + let line_bytes = bytes.get(off..off + len).unwrap_or(&[]); + let rows = if body_width == 0 || line_bytes.is_empty() { 1 } else { - chars.div_ceil(body_width).max(1) + count_wrapped_rows(line_bytes, body_width, tab_width) }; for wrap_row in 0..rows { if map.len() >= height { @@ -1044,3 +1130,97 @@ fn build_wrap_map( } map } + +#[cfg(test)] +mod wrap_tests { + use super::{count_wrapped_rows, visual_width}; + + #[test] + fn visual_width_tab_expands_to_next_boundary() { + assert_eq!(visual_width('\t', 0, 4), 4); + assert_eq!(visual_width('\t', 1, 4), 3); + assert_eq!(visual_width('\t', 2, 4), 2); + assert_eq!(visual_width('\t', 3, 4), 1); + assert_eq!(visual_width('\t', 4, 4), 4); + assert_eq!(visual_width('\t', 7, 8), 1); + assert_eq!(visual_width('\t', 8, 8), 8); + } + + #[test] + fn visual_width_ascii_and_control() { + assert_eq!(visual_width('a', 0, 4), 1); + assert_eq!(visual_width(' ', 5, 4), 1); + assert_eq!(visual_width('\u{7f}', 0, 4), 2); + assert_eq!(visual_width('\u{01}', 0, 4), 2); + } + + #[test] + fn count_wrapped_rows_empty_line() { + assert_eq!(count_wrapped_rows(b"", 10, 4), 1); + } + + #[test] + fn count_wrapped_rows_short_line_fits_in_one_row() { + assert_eq!(count_wrapped_rows(b"hello", 10, 4), 1); + } + + #[test] + fn count_wrapped_rows_exact_fit() { + assert_eq!(count_wrapped_rows(b"0123456789", 10, 4), 1); + } + + #[test] + fn count_wrapped_rows_one_char_overflow_no_whitespace() { + // 11 chars, width 10, no whitespace → mid-word break → 2 rows + assert_eq!(count_wrapped_rows(b"01234567890", 10, 4), 2); + } + + #[test] + fn count_wrapped_rows_word_boundary_break() { + // "hello world" is 11 chars; at width 10 the space at col 5 + // is a wrap point. The first row holds "hello" (5 cols), the + // second row holds "world" (5 cols). Total: 2 rows. + assert_eq!(count_wrapped_rows(b"hello world", 10, 4), 2); + } + + #[test] + fn count_wrapped_rows_three_words_at_width_six() { + // "foo bar baz" — 11 chars. Width 6: + // Row 1: "foo ba" (6 cols) — wraps at the space at col 3 + // Row 2: "r baz" (5 cols) — "r" picks up the "bar" word + // Row 3: nothing more fits cleanly + // Total: 3 rows. + assert_eq!(count_wrapped_rows(b"foo bar baz", 6, 4), 3); + } + + #[test] + fn count_wrapped_rows_long_word_falls_back_to_mid_word() { + // Single word "abcdefghijklmnop" is 16 chars, width 5. + // No whitespace → mid-word break: 5 + 5 + 5 + 1 = 4 rows. + assert_eq!(count_wrapped_rows(b"abcdefghijklmnop", 5, 4), 4); + } + + #[test] + fn count_wrapped_rows_tab_visual_width() { + // Single tab at col 0 with tab_width=4 fills 4 cols; body_width=4. + // The tab exactly fills the row. 1 row. + assert_eq!(count_wrapped_rows(b"\t", 4, 4), 1); + // Tab at col 0 + 3 more chars: 4+3=7 cols at width 4 → wraps. + // Tab fills row 1 (4 cols), then 3 chars fit on row 2 (3 cols). + // Total: 2 rows. + assert_eq!(count_wrapped_rows(b"\tabc", 4, 4), 2); + } + + #[test] + fn count_wrapped_rows_zero_width_returns_one() { + // Degenerate width=0 returns 1 (the caller already guards, + // but the helper itself is defensive). + assert_eq!(count_wrapped_rows(b"hello world", 0, 4), 1); + } + + #[test] + fn count_wrapped_rows_stops_at_newline() { + // Content after newline is ignored (line bytes excludes \n). + assert_eq!(count_wrapped_rows(b"abc\nrest", 10, 4), 1); + } +}