tlc: Sprint 2 B1 — word-boundary wrapping in editor

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).
This commit is contained in:
kellito
2026-07-05 16:51:33 +03:00
parent b1efd23faf
commit 135e94b5e4
2 changed files with 198 additions and 12 deletions
@@ -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(),
+192 -12
View File
@@ -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<usize> = 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);
}
}