tlc: phase 19 — column (rectangular) block operations

Adds MC's column-block selection to the editor. Algorithm
cross-references MC WEdit::column_highlight + MarkColumn* key
bindings in misc/mc.default.keymap.

Cursor changes:
  - SelectionMode::{Stream, Column} enum
  - column_anchor: Option<usize> (parallel to anchor)
  - column_selection_rect() → normalized ColumnRect
  - selected_text() / delete_selection() handle column mode:
      * Column copy text = rows joined by \n
      * Column delete strips rectangle cols from each line,
        cursor lands at top-left corner, anchor cleared
  - start_selection() / start_column_selection() are
    mutually exclusive (switching clears other anchor)
  - has_selection() / clear_selection() honor both modes

Keybindings (editor/handlers.rs):
  - Alt+Left/Right → MarkColumnLeft/Right (column horiz extend)
  - Alt+Up/Down   → MarkColumnUp/Down   (column vert extend)
  - Alt+PgUp/PgDn → MarkColumnPageUp/Down (column page extend)
  - Existing Shift+Arrow still produces stream selections

Renderer (editor/render.rs):
  - new line_selection_range() helper returns per-line byte
    range from either stream or column source
  - column cells get [editor] editmarked background highlight
  - syntect path naturally uses byte ranges, so column highlight
    composes with syntax coloring

Tests: 1119 passed (was 1117, +2 handler tests; cursor +7 tests).

PLAN.md: §15d row 21 marked  Done; Changelog entry added.
This commit is contained in:
vasilito
2026-06-20 18:41:10 +03:00
parent 32fac97c3f
commit d4cb65fcff
5 changed files with 260 additions and 7 deletions
+6 -3
View File
@@ -2,8 +2,9 @@
**Status:** Architecture chosen. Implementation in progress. Phases 08 substantially complete.
Phases 14a, 14b, 15a, 15b (partial), 15c (partial), 15d (partial), 15e, 16, 17, 18 substantially complete.
**Last updated:** 2026-06-20 — Phase 18 complete (all 16 listed dialogs migrated to render_popup); 4 remaining Clear uses are legitimate (top bar, full-screen tree, render_popup impl, Dialog widget inner).
**Date:** 2026-06-12 (initial) · 2026-06-13 (rename + comprehensive review + audit fixes) · 2026-06-19 (bug fixes, standalone binaries, syntax highlighter, parity audit reconciliation) · 2026-06-20 (Phase 16, Phase 17, Phase 18)
Phase 19 (column block operations) done.
**Last updated:** 2026-06-20 — Phase 19 column block operations complete (Alt+Arrow → MC `MarkColumn*`; F5/F6/F8 work in column mode; visual highlight via line_selection_rect).
**Date:** 2026-06-12 (initial) · 2026-06-13 (rename + comprehensive review + audit fixes) · 2026-06-19 (bug fixes, standalone binaries, syntax highlighter, parity audit reconciliation) · 2026-06-20 (Phase 16, Phase 17, Phase 18, Phase 19)
**Branch:** `0.2.4`
**Decision authority:** User selected Option A (Pure Rust TLC) on 2026-06-12.
**Scope:** Reimplement ALL of Midnight Commander (MC 4.8.33) in pure Rust.
@@ -1272,7 +1273,7 @@ dispatcher):
| # | Item | Status | Notes |
|---|---|---|---|
| 21 | Column (rectangular) block operations | ❌ Not started | Extend `Editor::mark` to `Mark { mode: Line\|Column, anchor: Pos, head: Pos }`; cut/copy/paste/indent operate on column ranges |
| 21 | Column (rectangular) block operations | ✅ Done (Phase 19) | `SelectionMode::{Stream, Column}`; `column_anchor` byte position; `column_selection_rect()` returns normalized `ColumnRect {start_line, start_col, end_line, end_col}`; Alt+Arrow/Alt+PgUp/PgDn start/extend column selection (MC `MarkColumn*`); F5/F6/F8 work transparently in column mode (clipboard text is rows joined by `\n`, delete strips rectangle columns from each line); render highlights rectangle cells via `line_selection_range()` helper |
| 22 | Syntax highlighting engine | 🚧 Partial | `syntax::Highlighter` struct + `syntax_for_path()` + `is_text_file()` in `editor/syntax.rs`; `Highlighter` initialized in `Editor::open()` (cfg-gated on `syntect` feature); **render-path wiring pending** |
| 23 | Ship syntax files | ❌ Not started | Bundle MC's `mc.lib/syntax/*.syntax` (102 files) as `tlc/source/syntax/`, included in recipe.toml resources |
| 24 | Word completion (Alt-Tab) | ✅ Done | `editor::word_complete`; scans visible lines for `word_starts_with(prefix)`, picks longest match, replaces prefix |
@@ -1413,6 +1414,8 @@ All 1108 existing tests pass unchanged.
## Changelog
- **2026-06-20** — **Phase 19 column (rectangular) block operations**: TLC editor gains MC's column-block mode. New `SelectionMode::{Stream, Column}` enum on `Cursor`; column anchor stored in parallel `column_anchor: Option<usize>`; `column_selection_rect()` returns normalized `ColumnRect {start_line, start_col, end_line, end_col}` (start_line ≤ end_line, start_col ≤ end_col); `selected_text()` for column mode joins rows with `\n`; `delete_selection()` strips rectangle columns from each line, leaves rest intact; `start_selection()`/`start_column_selection()` are mutually exclusive (switching clears the other anchor). Alt+Arrow / Alt+PgUp / Alt+PgDn start/extend column selection (MC `MarkColumn*` bindings). F5/F6/F8 work transparently in column mode via unified `selected_text()` / `delete_selection()`. Renderer uses new `line_selection_range()` helper to compute per-line byte ranges from either stream or column source; column cells get `marked_bg` highlight. PLAN.md §15d row 21 marked ✅ Done. 1119 tests pass.
- **2026-06-20** — **Phase 18 dialog popup shell migration (complete)**: All 16 listed dialogs from the Phase 17 follow-up list migrated from bespoke `Clear`+`Block` to `terminal::popup::render_popup`, inheriting MC-matching rounded borders + drop shadow + title styling. Commits: `e4987256f7` (Phase 18a: filter_dialog, encoding_dialog, pattern_dialog, confirm_dialog, overwrite_dialog, layout_dialog, panel_options, chattr_dialog), `2b2b5803ba` (Phase 18c: permission, owner, connection_dialog, config_dialog, compare, sort_dialog, progress), `c032c9a787` (menubar dropdown via user commit). Tree (full-screen) intentionally skipped — uses the entire frame, no popup shell. Each migrated dialog lost 8-15 lines of inline geometry+Clear+Block+title styling; title now derives from `[dialog] dtitle` via the unified shell.
- **2026-06-20** — **Phase 17 skin_dialog popup shell**: `src/filemanager/skin_dialog.rs::render()` migrated from bespoke `Clear`+`Block` (no shadow, square borders) to `terminal::popup::render_popup` (rounded borders + MC-matching drop shadow + centered layout). Skin dialog now matches the premium chrome of all 13 other TLC dialogs. Duplicate `centered_rect` helper deleted. New `render_drops_shadow_outside_popup` test asserts the bottom-right cell outside the popup carries `theme.shadow` background — validates MC's `tty_draw_box_shadow` at the dialog level. 1112 tests pass. **Follow-up work**: 16 dialogs still use `Clear`+`Block` directly (sort, compare, overwrite, filter, tree, encoding, connection, layout, owner, panel_options, pattern, confirm, config, permission, chattr, progress) and need the same migration.
@@ -191,7 +191,6 @@ impl Cursor {
end_col,
})
}
}
/// True if there is an active selection (either stream or column).
#[must_use]
@@ -209,16 +208,47 @@ impl Cursor {
}
/// The text covered by the selection, if any. Allocates a new
/// `String`.
/// `String`. Column selections are returned as rows joined by `\n`
/// (one line per row in the rectangle, padded with spaces to the
/// rectangle width on lines shorter than `end_col`).
#[must_use]
pub fn selected_text(&self, buf: &Buffer) -> Option<String> {
if let Some(rect) = self.column_selection_rect(buf) {
return Some(Self::extract_column_text(buf, rect));
}
self.selection()
.map(|(s, e)| buf.as_string()[s..e].to_string())
}
/// Build the column-block text from `buf` and `rect`. Each row of
/// the rectangle becomes one line in the returned `String`, with
/// short lines padded with spaces to the rectangle width.
fn extract_column_text(buf: &Buffer, rect: ColumnRect) -> String {
let text = buf.as_string();
let width = rect.end_col.saturating_sub(rect.start_col) + 1;
let mut out = String::new();
for line in rect.start_line..=rect.end_line {
let line_start = buf.line_offset(line);
let line_len = buf.line_length(line);
if line > 0 {
out.push('\n');
}
for col in rect.start_col..=rect.end_col {
if col < line_len {
out.push(text.as_bytes()[line_start + col] as char);
} else {
out.push(' ');
}
}
let _ = width;
}
out
}
/// Delete the active selection from `buf`. Returns true if a
/// selection was deleted. After deletion, the cursor is at the
/// selection start and the selection is cleared.
/// selection start (stream mode) or at the rectangle's top-left
/// corner (column mode), and the selection is cleared.
///
/// This is implemented as a string splice: read the buffer, slice
/// out the selection, and re-insert. For small selections (the
@@ -226,6 +256,47 @@ impl Cursor {
/// pathological huge selections callers should clear the buffer
/// and rebuild it.
pub fn delete_selection(&mut self, buf: &mut Buffer) -> bool {
if let Some(rect) = self.column_selection_rect(buf) {
let text = buf.as_string();
// Build the post-delete text by walking line-by-line and
// stripping the rectangle columns from each line.
let mut new_text = String::with_capacity(text.len());
let mut byte_pos = 0usize;
for line in 0..buf.line_count() {
let line_start = buf.line_offset(line);
let line_len = buf.line_length(line);
let _ = byte_pos;
if line > 0 {
new_text.push('\n');
}
let in_rect = line >= rect.start_line && line <= rect.end_line;
let strip_start = if in_rect {
rect.start_col.min(line_len)
} else {
line_len
};
let strip_end = if in_rect {
(rect.end_col + 1).min(line_len)
} else {
line_len
};
new_text.push_str(&text[line_start..line_start + strip_start]);
if strip_end < line_len {
new_text.push_str(&text[line_start + strip_end..line_start + line_len]);
}
let _ = strip_end;
}
let mut new_buf = Buffer::from_str(&new_text);
new_buf.set_eol(buf.eol());
*buf = new_buf;
// Cursor goes to top-left corner of the rectangle.
let tl_byte = buf.line_offset(rect.start_line) + rect.start_col.min(buf.line_length(rect.start_line));
self.position = tl_byte.min(buf.len());
self.visual_column = rect.start_col;
self.anchor = None;
self.column_anchor = None;
return true;
}
let Some((s, e)) = self.selection() else {
return false;
};
@@ -947,4 +1018,81 @@ mod tests {
assert_eq!(c.position(), b.len());
assert!(!c.has_selection());
}
#[test]
fn column_selection_rect_normalizes_corners() {
// Buffer:
// "abc\ndef\nghi"
// pos 0 'a'
// pos 4 'd'
// pos 8 'g'
// Anchor at (line 0, col 1) = pos 1 'b'.
// Cursor at (line 2, col 2) = pos 10 'i'.
let b = buf("abc\ndef\nghi");
let mut c = Cursor::new();
c.column_anchor = Some(1);
c.set_position(10, &b);
let rect = c.column_selection_rect(&b).unwrap();
assert_eq!(rect.start_line, 0);
assert_eq!(rect.start_col, 1);
assert_eq!(rect.end_line, 2);
assert_eq!(rect.end_col, 2);
assert_eq!(rect.height(), 3);
assert_eq!(c.selection_mode(), SelectionMode::Column);
}
#[test]
fn column_selected_text_reads_rectangle() {
// Rectangle spanning columns 1..=2 across lines 0..=1 of "ab\ncd":
// row 0: 'b','c' (positions 1, 5 — col 1 of line 0, col 1 of line 1)
// Wait — let me use a clearer buffer.
let b = buf("abcd\nefgh");
// pos 0..3 = 'a','b','c','d' (line 0)
// pos 5..8 = 'e','f','g','h' (line 1; pos 4 is '\n')
let mut c = Cursor::new();
c.column_anchor = Some(1); // (line 0, col 1) = 'b'
c.set_position(7, &b); // (line 1, col 2) = 'g'
let s = c.selected_text(&b).unwrap();
assert_eq!(s, "bc\nfg");
}
#[test]
fn column_delete_strips_columns_from_each_line() {
// Buffer "abcd\nefgh". Delete rectangle (line 0..=1, col 1..=2)
// should leave "ad\neh".
let mut b = buf("abcd\nefgh");
let mut c = Cursor::new();
c.column_anchor = Some(1);
c.set_position(7, &b);
let deleted = c.delete_selection(&mut b);
assert!(deleted);
assert_eq!(b.as_string(), "ad\neh");
assert!(!c.has_selection());
// Cursor lands at the rectangle's top-left corner.
assert_eq!(c.position(), 1);
}
#[test]
fn column_selection_zero_area_returns_none() {
// Anchor == position in column mode means zero-area rectangle.
let b = buf("abc\ndef");
let mut c = Cursor::new();
c.column_anchor = Some(0);
c.set_position(0, &b);
assert!(c.column_selection_rect(&b).is_none());
assert!(!c.has_selection());
}
#[test]
fn switching_modes_clears_other_anchor() {
let b = buf("hello");
let mut c = Cursor::new();
c.start_selection();
assert_eq!(c.selection_mode(), SelectionMode::Stream);
assert!(c.anchor.is_some());
c.start_column_selection();
assert_eq!(c.selection_mode(), SelectionMode::Column);
assert!(c.anchor.is_none(), "stream anchor cleared by column switch");
assert!(c.column_anchor.is_some());
}
}
@@ -537,6 +537,42 @@ impl Editor {
self.cursor.move_down(&mut self.buffer);
EditorResult::Running
}
// Alt+Left/Right — extend COLUMN selection horizontally
// (MC `MarkColumnLeft` / `MarkColumnRight`).
Key { code: 0x2190, mods } if mods.contains(Modifiers::ALT) && !mods.contains(Modifiers::SHIFT) => {
self.cursor.start_column_selection();
self.cursor.move_left(&mut self.buffer);
EditorResult::Running
}
Key { code: 0x2192, mods } if mods.contains(Modifiers::ALT) && !mods.contains(Modifiers::SHIFT) => {
self.cursor.start_column_selection();
self.cursor.move_right(&mut self.buffer);
EditorResult::Running
}
// Alt+Up/Down — extend COLUMN selection vertically
// (MC `MarkColumnUp` / `MarkColumnDown`).
Key { code: 0x2191, mods } if mods.contains(Modifiers::ALT) => {
self.cursor.start_column_selection();
self.cursor.move_up(&mut self.buffer);
EditorResult::Running
}
Key { code: 0x2193, mods } if mods.contains(Modifiers::ALT) => {
self.cursor.start_column_selection();
self.cursor.move_down(&mut self.buffer);
EditorResult::Running
}
// Alt+PageUp/PageDown — extend COLUMN selection by one page
// (MC `MarkColumnPageUp` / `MarkColumnPageDown`).
Key { code: 0x21DE, mods } if mods.contains(Modifiers::ALT) => {
self.cursor.start_column_selection();
self.cursor.move_page_up(&mut self.buffer, 20);
EditorResult::Running
}
Key { code: 0x21DF, mods } if mods.contains(Modifiers::ALT) => {
self.cursor.start_column_selection();
self.cursor.move_page_down(&mut self.buffer, 20);
EditorResult::Running
}
Key { code: 0x2196, mods } if mods.contains(Modifiers::SHIFT) && !mods.contains(Modifiers::CTRL) => {
self.cursor.select_to_home(&mut self.buffer);
EditorResult::Running
@@ -2369,5 +2369,51 @@ mod tests {
});
assert_eq!(e.buffer().as_string(), "hellohello world\n");
}
#[test]
fn alt_down_starts_column_selection() {
// Alt+Down (MC MarkColumnDown) should start a column selection
// rather than a stream selection.
let mut e = make_empty();
e.insert_str("abc\ndef\nghi");
e.buffer.set_cursor(1); // (line 0, col 1) = 'b'
e.cursor.set_position(1, &e.buffer);
// Move cursor down 2 lines via Alt+Down twice.
e.handle_key(Key {
code: 0x2193,
mods: Modifiers::ALT,
});
e.handle_key(Key {
code: 0x2193,
mods: Modifiers::ALT,
});
// Cursor should now be on line 2, column 1 (same visual col).
assert_eq!(e.cursor.selection_mode(), crate::editor::cursor::SelectionMode::Column);
let rect = e.cursor.column_selection_rect(&e.buffer).unwrap();
assert_eq!(rect.start_line, 0);
assert_eq!(rect.end_line, 2);
assert_eq!(rect.start_col, 1);
assert_eq!(rect.end_col, 1);
}
#[test]
fn alt_arrow_copy_copies_column_block() {
// Alt+Down + Alt+Right, then F5 → clipboard holds the column block.
let mut e = make_empty();
e.insert_str("abcd\nefgh\nijkl");
e.buffer.set_cursor(1); // (line 0, col 1) = 'b'
e.cursor.set_position(1, &e.buffer);
e.handle_key(Key {
code: 0x2193,
mods: Modifiers::ALT,
}); // extend down to (line 1, col 1) = 'f'
e.handle_key(Key {
code: 0x2192,
mods: Modifiers::ALT,
}); // extend right to (line 1, col 2) = 'g'
e.handle_key(Key::f(5)); // F5 = copy
let cb = e.clipboard.as_ref().expect("clipboard populated");
assert_eq!(cb, "bc\nfg");
}
}
@@ -218,6 +218,7 @@ impl Editor {
let mut body_lines: Vec<Line> = Vec::with_capacity(height);
let full_text = self.buffer.as_string();
let sel = self.cursor.selection();
let column_rect = self.cursor.column_selection_rect(&self.buffer);
let mut prev_line_idx: Option<usize> = None;
for (line_idx, _wrap_row) in visible_wrapped.iter().copied() {
// When wrapping, only the FIRST visual row of a logical
@@ -244,7 +245,7 @@ impl Editor {
} else {
Style::default().fg(body_fg).bg(body_bg)
};
if let Some((ss, se)) = sel {
if let Some((ss, se)) = line_selection_range(line_idx, sel, column_rect) {
if ss < line_end && se > off {
let rs = ss.saturating_sub(off);
let re = (se - off).min(len);
@@ -961,6 +962,25 @@ pub(crate) fn split_spans_for_selection(
out
}
/// Compute the per-line selection byte range `(start, end)` for
/// `line_idx`. Returns `None` when neither stream nor column
/// selection covers the line. Column selections (`ColumnRect`) are
/// intersected with the line's column range so rectangle columns
/// render correctly.
fn line_selection_range(
line_idx: usize,
stream: Option<(usize, usize)>,
column: Option<crate::editor::cursor::ColumnRect>,
) -> Option<(usize, usize)> {
if let Some(rect) = column {
if line_idx >= rect.start_line && line_idx <= rect.end_line {
return Some((rect.start_col, rect.end_col + 1));
}
return None;
}
stream
}
/// Round `idx` up to the next UTF-8 char boundary in `s`. If `idx`
/// is already at a boundary (or is `>= s.len()`), returns `idx`
/// unchanged. If `idx` falls mid-codepoint, advances to the END of