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:
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user