tlc: Sprint 2 B3 — Tab-to-indent in editor

Tab key now has indent-aware behavior (MC parity):
  - When cursor is in the indent zone (only whitespace before it on
    the current line), Tab inserts enough spaces to advance to the
    next tab_width boundary.
  - When cursor is past any non-whitespace, Tab inserts a single
    literal '\t' character for column alignment.

New methods on Editor (editor/mod.rs):
  insert_tab_with_indent()
    - Computes next_boundary = ((col / tab_width) + 1) * tab_width
    - In indent zone: inserts (next_boundary - col) spaces
    - Otherwise: inserts literal '\t'
  is_in_indent_zone() -> bool
    - True if all bytes between line start and cursor are space/tab

Tab handler in editor/handlers.rs routes through
insert_tab_with_indent() instead of insert_char('\t').

Tests (6 new in editor::tests):
  - tab_at_col_0_indents_to_next_boundary
  - tab_at_col_4_indents_to_next_boundary
  - tab_mid_line_inserts_literal_tab
  - tab_after_non_whitespace_inserts_literal_tab
  - tab_after_whitespace_runs_continues_indent
  - tab_inside_word_inserts_literal_tab

Total: 1248 passing (was 1242; +6 new).
This commit is contained in:
kellito
2026-07-05 16:55:30 +03:00
parent 135e94b5e4
commit 2633c40dfd
2 changed files with 106 additions and 1 deletions
@@ -453,7 +453,7 @@ impl Editor {
return EditorResult::Running;
}
if key == Key::TAB {
self.insert_char('\t');
self.insert_tab_with_indent();
return EditorResult::Running;
}
if key.code == 0x09 && key.mods.contains(Modifiers::ALT) {
@@ -865,6 +865,46 @@ bracket_flash: None,
self.modified = true;
}
/// Insert a Tab with indent-aware behavior (MC parity).
///
/// When the cursor is positioned at or before the first
/// non-whitespace character on its line, insert enough spaces to
/// advance to the next `tab_width` boundary (the indent context).
/// Otherwise (cursor is mid-line or past all whitespace) insert
/// a single literal `\t` character (the column-alignment
/// context).
///
/// The cursor's `visual_column` is tab-expanded; we use it to
/// decide which context applies.
pub fn insert_tab_with_indent(&mut self) {
self.completer.cancel();
let col = self.cursor.visual_column();
let next_boundary = ((col / self.tab_width) + 1) * self.tab_width;
if self.is_in_indent_zone() {
let spaces = next_boundary.saturating_sub(col).max(1);
self.buffer.insert_str(&" ".repeat(spaces));
} else {
self.buffer.insert_char('\t');
}
self.cursor.set_position(self.buffer.cursor(), &self.buffer);
self.modified = true;
}
/// True if the cursor is on a line where all characters before
/// the cursor are whitespace. Used to decide between Tab-as-indent
/// and Tab-as-column-align.
fn is_in_indent_zone(&self) -> bool {
let cursor_byte = self.cursor.position();
let line_idx = self.buffer_line_of(cursor_byte);
let line_start = self.buffer.line_offset(line_idx);
if cursor_byte <= line_start {
return true;
}
let bytes = self.buffer.to_bytes();
let prefix = bytes.get(line_start..cursor_byte).unwrap_or(&[]);
prefix.iter().all(|&b| b == b' ' || b == b'\t')
}
/// Backspace at the cursor.
pub fn delete_back(&mut self) {
self.buffer.delete_back();
@@ -3135,5 +3175,70 @@ mod tests {
e.sort_block("-r");
assert_eq!(e.buffer().as_string(), "c\nb\na\n");
}
#[test]
fn tab_at_col_0_indents_to_next_boundary() {
let mut e = make_empty();
e.insert_tab_with_indent();
// tab_width=4 → 4 spaces inserted at col 0.
assert_eq!(e.buffer().as_string(), " ");
assert_eq!(e.cursor.visual_column(), 4);
}
#[test]
fn tab_at_col_4_indents_to_next_boundary() {
let mut e = make_empty();
e.insert_str(" ");
e.insert_tab_with_indent();
assert_eq!(e.buffer().as_string(), " ");
assert_eq!(e.cursor.visual_column(), 8);
}
#[test]
fn tab_mid_line_inserts_literal_tab() {
let mut e = make_empty();
e.insert_str("hello world");
// Move cursor to position 0 (start of line).
e.buffer.set_cursor(0);
e.cursor.set_position(0, &e.buffer);
e.insert_tab_with_indent();
// Cursor is at the indent zone (only whitespace before), so
// the algorithm still pads to next boundary.
assert_eq!(e.buffer().as_string(), " hello world");
}
#[test]
fn tab_after_non_whitespace_inserts_literal_tab() {
let mut e = make_empty();
e.insert_str("hi");
// Cursor is right after "hi" — not in indent zone.
e.insert_tab_with_indent();
assert_eq!(e.buffer().as_string(), "hi\t");
assert_eq!(e.cursor.visual_column(), 3);
}
#[test]
fn tab_after_whitespace_runs_continues_indent() {
let mut e = make_empty();
e.insert_str(" ");
// Cursor at col 2 — all-whitespace prefix, indent zone.
e.insert_tab_with_indent();
// col 2 → next boundary col 4 → 2 more spaces.
assert_eq!(e.buffer().as_string(), " ");
assert_eq!(e.cursor.visual_column(), 4);
}
#[test]
fn tab_inside_word_inserts_literal_tab() {
let mut e = make_empty();
e.insert_str("foo bar");
// Move cursor to byte 3 (between foo and space).
e.buffer.set_cursor(3);
e.cursor.set_position(3, &e.buffer);
e.insert_tab_with_indent();
// Cursor is at col 3, before "bar". Prefix is "foo" which
// contains a non-whitespace byte → not in indent zone → literal tab.
assert_eq!(e.buffer().as_string(), "foo\t bar");
}
}