From d4cb65fcff7adeb8dffca5dcf2af21a6e297d74f Mon Sep 17 00:00:00 2001 From: vasilito Date: Sat, 20 Jun 2026 18:41:10 +0300 Subject: [PATCH] =?UTF-8?q?tlc:=20phase=2019=20=E2=80=94=20column=20(recta?= =?UTF-8?q?ngular)=20block=20operations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (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. --- local/recipes/tui/tlc/PLAN.md | 9 +- .../tui/tlc/source/src/editor/cursor.rs | 154 +++++++++++++++++- .../tui/tlc/source/src/editor/handlers.rs | 36 ++++ .../recipes/tui/tlc/source/src/editor/mod.rs | 46 ++++++ .../tui/tlc/source/src/editor/render.rs | 22 ++- 5 files changed, 260 insertions(+), 7 deletions(-) diff --git a/local/recipes/tui/tlc/PLAN.md b/local/recipes/tui/tlc/PLAN.md index c08a1a2a8d..93fd6d180e 100644 --- a/local/recipes/tui/tlc/PLAN.md +++ b/local/recipes/tui/tlc/PLAN.md @@ -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`; `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. diff --git a/local/recipes/tui/tlc/source/src/editor/cursor.rs b/local/recipes/tui/tlc/source/src/editor/cursor.rs index 1354d65d66..77aa82f00d 100644 --- a/local/recipes/tui/tlc/source/src/editor/cursor.rs +++ b/local/recipes/tui/tlc/source/src/editor/cursor.rs @@ -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 { + 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()); + } } diff --git a/local/recipes/tui/tlc/source/src/editor/handlers.rs b/local/recipes/tui/tlc/source/src/editor/handlers.rs index 0d374268e6..342e2892cb 100644 --- a/local/recipes/tui/tlc/source/src/editor/handlers.rs +++ b/local/recipes/tui/tlc/source/src/editor/handlers.rs @@ -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 diff --git a/local/recipes/tui/tlc/source/src/editor/mod.rs b/local/recipes/tui/tlc/source/src/editor/mod.rs index 59b3b987f5..7920c436b5 100644 --- a/local/recipes/tui/tlc/source/src/editor/mod.rs +++ b/local/recipes/tui/tlc/source/src/editor/mod.rs @@ -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"); + } } diff --git a/local/recipes/tui/tlc/source/src/editor/render.rs b/local/recipes/tui/tlc/source/src/editor/render.rs index b8f1117df4..19adf15e94 100644 --- a/local/recipes/tui/tlc/source/src/editor/render.rs +++ b/local/recipes/tui/tlc/source/src/editor/render.rs @@ -218,6 +218,7 @@ impl Editor { let mut body_lines: Vec = 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 = 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, +) -> 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