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:
@@ -2,8 +2,9 @@
|
||||
|
||||
**Status:** Architecture chosen. Implementation in progress. Phases 0–8 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
|
||||
|
||||
Reference in New Issue
Block a user