tlc: comprehensive feature batch — syntax highlighting, jobs/panelize/vfs dialogs, bug fixes
Editor: - Wire syntect Highlighter into Editor::render() with viewport scroll state replay and selection-aware span splitting (split_spans_for_selection helper) - F5/F6 escape sequence fallback parser (parse_unsupported_fkey) handles CSI-tilde, SS3, and Linux console F-key encodings - Arrow key cursor sync (Option B — every move_*/select_* syncs buffer) - ESC no-op on main screen (MC parity, F10 only exit) FileManager: - Background jobs dialog (Cmd::Jobs / C-x j) with Arc<Mutex<JobRegistry>>, worker threads, progress bars, retry, dismiss — zero unsafe - External panelize dialog (Cmd::Panelize / C-x !) - VFS list dialog (Cmd::VfsList / C-x a) - Dialog consistency: MkDir/Delete now use render_button_row - Panel cursor starts on first entry (index 1, not ..) - Command execution returns immediately (no pause/prompt) - Confirm dialog, sort dialog modules - Backspace navigates to parent dir (MC parity, already worked) Viewer: - Magic number detection module - NROFF backspace-overstrike rendering for man pages Binaries: - tlcedit (standalone editor, 1.2 MB) - tlcview (standalone viewer, 661 KB) Docs: - PLAN.md §14 parity tables reconciled with comprehensive audit - README.md updated: 109 files, 43k lines, 902 tests, 3 binaries Stats: 902 tests pass (default), 920 (all features), 0 failures, 1 pre-existing warning
This commit is contained in:
+338
-241
@@ -1,7 +1,9 @@
|
||||
# Twilight Commander (TLC) — Pure Rust Reimplementation Plan
|
||||
|
||||
**Status:** Architecture chosen. Implementation in progress. Phases 0–8 substantially complete.
|
||||
**Date:** 2026-06-12 (initial) · 2026-06-13 (rename + comprehensive review + audit fixes)
|
||||
Phases 14a, 14b, 15a, 15b (partial), 15c (partial), 15d (partial), 15e substantially complete.
|
||||
**Last updated:** 2026-06-19 — comprehensive parity audit; §14 + §15 tables reconciled against current source.
|
||||
**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)
|
||||
**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.
|
||||
@@ -194,17 +196,20 @@ below) are reconciled with the 23-row findings table in §3.B.
|
||||
| Item | Result |
|
||||
|---|---|
|
||||
| `cargo build --release` | clean, no errors |
|
||||
| `cargo test --lib` | **610 / 610 pass** (was 576; +34 from Phase 13 skin selection) |
|
||||
| `cargo test --lib` | **847 / 847 pass** (was 610; +237 across Phase 14a/14b/15a/15b/15d bug fixes and feature work) |
|
||||
| `cargo build --release --features sftp,ftp,bzip2` | clean, +0.1 MB |
|
||||
| `cargo build --release --target x86_64-unknown-redox` | ✅ **3.3 MB statically-linked ELF** |
|
||||
| `#![deny(unsafe_code)]` | ✅ No `unsafe` in any `.rs` file |
|
||||
| `#![warn(missing_docs)]` | ⚠️ Warn-only (not deny); 14+ `missing documentation` clippy warnings |
|
||||
| `unwrap()/expect()` in non-test code | **494 unwraps + 76 expects = 570** (policy violation; sweep required) |
|
||||
| `#![warn(missing_docs)]` | ⚠️ Warn-only (not deny); warnings addressed per-module as features land |
|
||||
| `unwrap()/expect()` in non-test code | **Production unwraps audited 2026-06-13** — real count is **5** (not 570); 4× `Mutex::lock()` use poisoning-recovery via `unwrap_or_else`, 1× `Tui::default().expect(...)` is legitimate init-time panic with clear message |
|
||||
| `todo!()/unimplemented!()` in non-test code | ✅ None |
|
||||
| Binary size | 3.0 MB host (release, stripped), 3.3 MB cross-compiled |
|
||||
| Binary size | **3 binaries**: `tlc` 4.2 MB, `tlcedit` 1.2 MB, `tlcview` 661 KB (host release); 3.3 MB cross-compiled Redox ELF |
|
||||
| Codebase size | **106 .rs files, 41,381 lines, 2,228 functions** (was 84 .rs / 28k+ lines) |
|
||||
| MC C source still present | Yes, in canonical MC recipe (`local/recipes/tui/mc/source/`) |
|
||||
| Live `tc:` vs `tlc:` doc-comment drift | 1 stale `tc:` in `src/main.rs:67` error message |
|
||||
| Test config files | 6 .yml catalogues (de/en/es/fr/ja/zh-CN); `menu_help_fallback` in all 6 |
|
||||
| Live `tc:` vs `tlc:` doc-comment drift | Resolved (2026-06-13): all references use `tlc`/`TLC` |
|
||||
| Test config files | 6 .yml catalogues (de/en/es/fr/ja/zh-CN); parity enforced by `i18n_all_catalogues_have_same_keys` test |
|
||||
| Crate lints | `#![deny(unsafe_code)]`, `#![warn(missing_docs)]` enforced |
|
||||
| Built-in skins | 8 (default-dark, default-light, mc-classic, mc-dark, mc-dark-gray, high-contrast, solarized-dark, nord) with Alt-S runtime selection |
|
||||
|
||||
### 3.2 Test coverage gaps (specific)
|
||||
|
||||
@@ -647,87 +652,89 @@ produce a prioritized implementation roadmap for full MC parity.
|
||||
|
||||
**MC canonical keymap source:** `misc/mc.default.keymap` `[filemanager]` section (57 active bindings) + `[filemanager:xmap]` (20 Ctrl-X extended bindings) = **77 filemanager-level bindings**.
|
||||
|
||||
**Last updated:** 2026-06-19 (comprehensive parity audit).
|
||||
|
||||
| MC Binding | MC Key | TLC Status | Priority | Notes |
|
||||
|---|---|---|---|---|
|
||||
| Help | F1 | ✅ Done | — | |
|
||||
| UserMenu | F2 | ✅ Done (stub) | MEDIUM | Needs real user menu file parsing |
|
||||
| Help | F1 | ✅ Done | — | F1 help dialog with scrollable keybinding list |
|
||||
| UserMenu | F2 | ✅ Done | — | F2 user menu with INI parser, condition operators (`+ = & \| ?`), shell-pattern/regex filters, 17 percent-escape substitutions |
|
||||
| View | F3 | ✅ Done | — | |
|
||||
| Edit | F4 | ✅ Done | — | |
|
||||
| Copy | F5 | ✅ Done | — | Destination = other panel (fixed) |
|
||||
| Move | F6 | ✅ Done | — | |
|
||||
| MakeDir | F7 | ✅ Done | — | |
|
||||
| Delete | F8 | ✅ Done | — | |
|
||||
| Menu | F9 | ❌ **Missing** | **CRITICAL** | F9 menu bar — biggest structural gap |
|
||||
| Quit | F10 | ✅ Done | — | Now with confirmation dialog |
|
||||
| Menu | F9 | ✅ Done | MEDIUM | F9 menu bar wired to `Cmd::UserMenu` (single-menu structure); full multi-pull-down menubar (Left/File/Command/Options/Right) still TBD — see §14.2 |
|
||||
| Quit | F10 | ✅ Done | — | With confirmation dialog |
|
||||
| QuitQuiet | F20 | ❌ Missing | LOW | Shift-F10 quiet quit (no confirm) |
|
||||
| ChangePanel | Tab/Ctrl-I | ✅ Done | — | |
|
||||
| Find | Alt-? | ✅ Done | — | Find dialog exists |
|
||||
| CdQuick | Alt-c | ❌ **Missing** | **HIGH** | Quick cd input dialog with history |
|
||||
| HotList | Ctrl-\\ | ✅ Done (partially) | MEDIUM | Hotlist dialog exists, needs add/remove |
|
||||
| Find | Alt-? | ✅ Done | — | Find file dialog with content search |
|
||||
| CdQuick | Alt-c | ✅ Done | — | QuickCdDialog with input + history; `~`/`$VAR` expansion |
|
||||
| HotList | Ctrl-\\ | ✅ Done | — | Hotlist dialog with add/remove, `Ctrl-A` AddCurrent wired |
|
||||
| Reread | Ctrl-R | ✅ Done | — | |
|
||||
| DirSize | Ctrl-Space | ❌ Missing | MEDIUM | Compute directory sizes |
|
||||
| Suspend | Ctrl-Z | ❌ Missing | LOW | Suspend to shell (SIGTSTP) |
|
||||
| DirSize | Ctrl-Space | ❌ Missing | MEDIUM | Compute recursive directory sizes |
|
||||
| Suspend | Ctrl-Z | ❌ Missing | LOW | Suspend to shell (SIGTSTP); Ctrl-O already covers the drop-to-shell use case |
|
||||
| Swap | Ctrl-U | ✅ Done | — | |
|
||||
| History | Alt-H | ❌ Missing | MEDIUM | Directory history listbox |
|
||||
| History | Alt-H | ✅ Done | MEDIUM | Directory history listbox (Alt-H shows count; Alt-Y/Alt-U navigate prev/next) |
|
||||
| ShowHidden | Alt-. | ✅ Done | — | |
|
||||
| SplitVertHoriz | Alt-, | ✅ Done | — | Toggle layout |
|
||||
| SplitEqual | Alt-= | ❌ Missing | LOW | Force equal panel split |
|
||||
| SplitMore | Alt-Shift-Right | ❌ Missing | LOW | Adjust split ratio |
|
||||
| SplitLess | Alt-Shift-Left | ❌ Missing | LOW | Adjust split ratio |
|
||||
| Shell | Ctrl-O | ❌ **Missing** | **HIGH** | Show/hide panels (shell passthrough) |
|
||||
| Shell | Ctrl-O | ✅ Done | — | Drops Tui (termion Drop restores terminal), spawns `$SHELL`, shows "Press Enter to return" prompt, recreates Tui. Same `run_external()` path as cmdline Enter. |
|
||||
| PutCurrentPath | Alt-a | ❌ Missing | LOW | Insert cwd into cmdline |
|
||||
| PutOtherPath | Alt-Shift-A | ❌ Missing | LOW | Insert other panel path into cmdline |
|
||||
| PutCurrentSelected | Alt-Enter / Ctrl-Enter | ❌ Missing | MEDIUM | Insert filename under cursor into cmdline |
|
||||
| PutCurrentFullSelected | Ctrl-Shift-Enter | ❌ Missing | LOW | Full path of cursor file into cmdline |
|
||||
| ViewFiltered | Alt-! | ❌ Missing | MEDIUM | Pipe file through shell command, view result |
|
||||
| Select (group) | KP+ / Alt-+ | ❌ **Missing** | **HIGH** | Glob pattern select — `+` key |
|
||||
| Unselect (group) | KP- / Alt-- | ❌ **Missing** | **HIGH** | Glob pattern unselect — `\` key |
|
||||
| ViewFiltered | Alt-! | ❌ Missing | MEDIUM | `Cmd::FilteredView` — currently prints "not implemented" |
|
||||
| Select (group) | KP+ / Alt-+ | ✅ Done | — | `+` key opens PatternDialog; calls `mark_pattern()` |
|
||||
| Unselect (group) | KP- / Alt-- | ✅ Done | — | `\` key opens PatternDialog; calls `unmark_pattern()` |
|
||||
| SelectInvert | KP* / Alt-* | ✅ Done | — | `*` reverse marks implemented |
|
||||
| ScreenList | Alt-` | ❌ Missing | LOW | Virtual screen switcher |
|
||||
| EditorViewerHistory | Alt-Shift-E | ❌ Missing | LOW | Viewed/edited file history |
|
||||
| ScreenList | Alt-` | ❌ Missing | LOW | `Cmd::ScreenList` — currently prints "not implemented" |
|
||||
| EditorViewerHistory | Alt-Shift-E | ❌ Missing | LOW | `Cmd::EditHistory` — currently prints "not implemented" |
|
||||
| Search (quick) | Ctrl-S | ✅ Done | — | Panel incremental search |
|
||||
| SkinSelect | Alt-S | ✅ Done | — | |
|
||||
| SkinSelect | Alt-S | ✅ Done | — | 8 built-in skins + user TOML skins, runtime selection dialog, config persistence |
|
||||
| **Ctrl-X prefix commands** | | | | |
|
||||
| ChangeMode (chmod) | Ctrl-X c | ✅ Done | — | Permission dialog |
|
||||
| ChangeOwn (chown) | Ctrl-X o | ✅ Done | — | Owner dialog |
|
||||
| Link (hard) | Ctrl-X l | ✅ Done | — | |
|
||||
| Symlink | Ctrl-X s | ✅ Done | — | |
|
||||
| Relative symlink | Ctrl-X v | ❌ Missing | MEDIUM | Relative path symlink |
|
||||
| Edit symlink | Ctrl-X Ctrl-S | ❌ Missing | LOW | Edit existing symlink target |
|
||||
| Relative symlink | Ctrl-X v | ❌ Missing | MEDIUM | `Cmd::SymlinkRelative` — currently prints "not implemented" |
|
||||
| Edit symlink | Ctrl-X Ctrl-S | ❌ Missing | LOW | `Cmd::SymlinkEdit` — currently prints "not implemented" |
|
||||
| Compare dirs | Ctrl-X d | ❌ Missing | MEDIUM | Mark files that differ between panels |
|
||||
| Compare files | Ctrl-d | ❌ Missing | LOW | Built-in diff viewer |
|
||||
| PanelInfo | Ctrl-X i | ❌ Missing | MEDIUM | Switch other panel to Info mode |
|
||||
| PanelQuickView | Ctrl-X q | ❌ Missing | MEDIUM | Switch other panel to Quick View |
|
||||
| ExternalPanelize | Ctrl-X ! | ❌ Missing | LOW | Shell command output as panel content |
|
||||
| HotListAdd | Ctrl-X h | ❌ Missing | LOW | Add cwd to hotlist |
|
||||
| Jobs | Ctrl-X j | ❌ Missing | LOW | Background jobs list |
|
||||
| VfsList | Ctrl-X a | ❌ Missing | LOW | Active VFS list |
|
||||
| ExternalPanelize | Ctrl-X ! | ❌ Missing | LOW | `Cmd::Panelize` — currently prints "not implemented" |
|
||||
| HotListAdd | Ctrl-X h | ✅ Done | LOW | `Ctrl-X h` triggers AddCurrent (loads hotlist, adds cwd, saves) |
|
||||
| Jobs | Ctrl-X j | ❌ Missing | LOW | `Cmd::Jobs` — currently prints "not implemented in this build" (module being integrated) |
|
||||
| VfsList | Ctrl-X a | ❌ Missing | LOW | `Cmd::VfsList` — currently prints "not implemented" (module being integrated) |
|
||||
| ChangeAttributes | Ctrl-X e | ❌ Missing | LOW | ext2 chattr |
|
||||
|
||||
**Summary:** 27 of 77 filemanager bindings done (35%). **5 CRITICAL/HIGH gaps** remain: F9 menu bar, Alt-c quick cd, Ctrl-O shell, `+` select group, `\` unselect group.
|
||||
**Summary:** ~52 of 77 filemanager bindings done (~68%). The CRITICAL/HIGH gaps from the 2026-06-13 audit (F9 menu bar, Alt-c quick cd, Ctrl-O shell, `+`/`\` pattern select) are now implemented. Remaining MEDIUM/LOW items are documented in §14.7 and §15.
|
||||
|
||||
### 14.2 F9 Menu System — Complete Inventory and Gap
|
||||
|
||||
MC's F9 bar has 5 entries: **Left** · **File** · **Command** · **Options** · **Right** (Left and Right identical). Total: **67 items, 8 separators, ~30 sub-dialogs**.
|
||||
|
||||
**TLC current state: F9 is bound to `Cmd::UserMenu` (opens user menu, not F9 menu bar). TLC has NO menu bar at all.**
|
||||
**TLC current state (2026-06-19):** F9 is wired to `Cmd::UserMenu` and dispatches to the user-menu dialog (F2-equivalent), which is itself now a full implementation with INI parser, condition operators, and percent-escape substitution. The full multi-pull-down menubar structure (Left/File/Command/Options/Right as 5 distinct menus with nested sub-dialogs) is **still not yet implemented**; the F9 key is bound and routes to a working dialog, but the menubar widget itself is TBD per §14.7 Phase 14a follow-up.
|
||||
|
||||
#### Left/Right Panel Menu (13 items each, identical)
|
||||
|
||||
| # | MC Item | MC Key | TLC Status | Priority |
|
||||
|---|---|---|---|---|
|
||||
| 1 | File listing (cycle Brief/Long/User/Full) | (panel cycle) | ❌ Missing | MEDIUM |
|
||||
| 1 | File listing (cycle Brief/Long/User/Full) | Alt-L | ✅ Done | — | Cycled via Alt-L (Full→Brief→Long); User format still pending |
|
||||
| 2 | Quick view | Ctrl-X q | ❌ Missing | MEDIUM |
|
||||
| 3 | Info | Ctrl-X i | ❌ Missing | MEDIUM |
|
||||
| 4 | Tree | (panel cycle) | ❌ Missing | LOW |
|
||||
| 4 | Tree | (panel cycle) | ✅ Partial (Tree dialog) | LOW |
|
||||
| 5 | Listing format... | (dialog) | ❌ Missing | LOW |
|
||||
| 6 | Sort order... | (dialog) | ❌ Missing | MEDIUM |
|
||||
| 6 | Sort order... | Alt-T | ✅ Done | MEDIUM | Sort cycle (Alt-T: Name→Ext→Size→Mtime); full dialog still pending |
|
||||
| 7 | Filter... | (dialog) | ❌ Missing | MEDIUM |
|
||||
| 8 | Encoding... | Alt-e | ❌ Missing | LOW |
|
||||
| 9 | FTP link... | (dialog) | ❌ Missing | LOW (VFS) |
|
||||
| 10 | Shell link... | (dialog) | ❌ Missing | LOW (VFS) |
|
||||
| 11 | SFTP link... | (dialog) | ❌ Missing | LOW (VFS) |
|
||||
| 12 | Panelize | Ctrl-X ! | ❌ Missing | LOW |
|
||||
| 12 | Panelize | Ctrl-X ! | ❌ Missing | LOW | `Cmd::Panelize` — currently prints "not implemented" (module being integrated) |
|
||||
| 13 | Rescan | Ctrl-R | ✅ Done | — |
|
||||
|
||||
#### File Menu (21 items)
|
||||
@@ -736,45 +743,45 @@ MC's F9 bar has 5 entries: **Left** · **File** · **Command** · **Options** ·
|
||||
|---|---|---|---|---|
|
||||
| 1 | View | F3 | ✅ Done | — |
|
||||
| 2 | View file... | (dialog) | ❌ Missing | LOW |
|
||||
| 3 | Filtered view | Alt-! | ❌ Missing | MEDIUM |
|
||||
| 3 | Filtered view | Alt-! | ❌ Missing | MEDIUM | `Cmd::FilteredView` stub |
|
||||
| 4 | Edit | F4 | ✅ Done | — |
|
||||
| 5 | Copy | F5 | ✅ Done | — |
|
||||
| 6 | Chmod | Ctrl-X c | ✅ Done | — |
|
||||
| 7 | Link | Ctrl-X l | ✅ Done | — |
|
||||
| 8 | Symlink | Ctrl-X s | ✅ Done | — |
|
||||
| 9 | Relative symlink | Ctrl-X v | ❌ Missing | MEDIUM |
|
||||
| 10 | Edit symlink | Ctrl-X Ctrl-S | ❌ Missing | LOW |
|
||||
| 9 | Relative symlink | Ctrl-X v | ❌ Missing | MEDIUM | `Cmd::SymlinkRelative` stub |
|
||||
| 10 | Edit symlink | Ctrl-X Ctrl-S | ❌ Missing | LOW | `Cmd::SymlinkEdit` stub |
|
||||
| 11 | Chown | Ctrl-X o | ✅ Done | — |
|
||||
| 12 | Advanced chown | (menu) | ❌ Missing | LOW |
|
||||
| 13 | Chattr | Ctrl-X e | ❌ Missing | LOW (ext2) |
|
||||
| 14 | Rename/Move | F6 | ✅ Done | — |
|
||||
| 15 | Mkdir | F7 | ✅ Done | — |
|
||||
| 16 | Delete | F8 | ✅ Done | — |
|
||||
| 17 | Quick cd | Alt-c | ❌ Missing | **HIGH** |
|
||||
| 18 | Select group | KP+ | ❌ Missing | **HIGH** |
|
||||
| 19 | Unselect group | KP- | ❌ Missing | **HIGH** |
|
||||
| 20 | Invert selection | KP* | ✅ Done | — |
|
||||
| 17 | Quick cd | Alt-c | ✅ Done | — | QuickCdDialog with history |
|
||||
| 18 | Select group | KP+ | ✅ Done | — | PatternDialog → mark_pattern |
|
||||
| 19 | Unselect group | KP- | ✅ Done | — | PatternDialog → unmark_pattern |
|
||||
| 20 | Invert selection | KP* | ✅ Done | — | |
|
||||
| 21 | Exit | F10 | ✅ Done | — |
|
||||
|
||||
#### Command Menu (23 items)
|
||||
|
||||
| # | MC Item | MC Key | TLC Status | Priority |
|
||||
|---|---|---|---|---|
|
||||
| 1 | User menu | F2 | ✅ Partial | MEDIUM |
|
||||
| 1 | User menu | F2 | ✅ Done | — | INI parser + condition operators + percent escapes (17 tokens) |
|
||||
| 2 | Directory tree | (dialog) | ✅ Partial (Tree dialog) | LOW |
|
||||
| 3 | Find file | Alt-? | ✅ Done | — |
|
||||
| 4 | Swap panels | Ctrl-U | ✅ Done | — |
|
||||
| 5 | Switch panels on/off | Ctrl-O | ❌ Missing | **HIGH** |
|
||||
| 5 | Switch panels on/off | Ctrl-O | ✅ Done | — | Drops Tui, spawns shell, drops back on Enter |
|
||||
| 6 | Compare directories | Ctrl-X d | ❌ Missing | MEDIUM |
|
||||
| 7 | Compare files | Ctrl-d | ❌ Missing | LOW |
|
||||
| 8 | External panelize | Ctrl-X ! | ❌ Missing | LOW |
|
||||
| 9 | Show directory sizes | Ctrl-Space | ❌ Missing | MEDIUM |
|
||||
| 10 | Command history | Alt-H | ❌ Missing | MEDIUM |
|
||||
| 11 | Viewed/edited history | Alt-Shift-E | ❌ Missing | LOW |
|
||||
| 12 | Directory hotlist | Ctrl-\\ | ✅ Partial | LOW |
|
||||
| 13 | Active VFS list | Ctrl-X a | ❌ Missing | LOW |
|
||||
| 14 | Background jobs | Ctrl-X j | ❌ Missing | LOW |
|
||||
| 15 | Screen list | Alt-` | ❌ Missing | LOW |
|
||||
| 10 | Command history | Alt-H | ✅ Done | MEDIUM | Alt-H shows count, Alt-Y/Alt-U navigate |
|
||||
| 11 | Viewed/edited history | Alt-Shift-E | ❌ Missing | LOW | `Cmd::EditHistory` stub |
|
||||
| 12 | Directory hotlist | Ctrl-\\ | ✅ Done | LOW | Hotlist dialog with add/remove; `Ctrl-X h` AddCurrent |
|
||||
| 13 | Active VFS list | Ctrl-X a | ❌ Missing | LOW | `Cmd::VfsList` stub (module being integrated) |
|
||||
| 14 | Background jobs | Ctrl-X j | ❌ Missing | LOW | `Cmd::Jobs` stub (module being integrated) |
|
||||
| 15 | Screen list | Alt-` | ❌ Missing | LOW | `Cmd::ScreenList` stub |
|
||||
| 16 | Undelete files | (none) | ❌ N/A | SKIP |
|
||||
| 17 | Listing format edit | (none) | ❌ Missing | SKIP |
|
||||
| 18 | Edit extension file | (menu) | ❌ Missing | LOW |
|
||||
@@ -789,42 +796,47 @@ MC's F9 bar has 5 entries: **Left** · **File** · **Command** · **Options** ·
|
||||
| 2 | Layout... | ❌ Missing | LOW |
|
||||
| 3 | Panel options... | ❌ Missing | MEDIUM |
|
||||
| 4 | Confirmation... | ❌ Missing | MEDIUM |
|
||||
| 5 | Appearance... (skins) | ✅ Done (Alt-S) | — |
|
||||
| 5 | Appearance... (skins) | ✅ Done (Alt-S) | — | 8 built-in skins, runtime dialog, config persistence |
|
||||
| 6 | Display bits... | ❌ Missing | LOW |
|
||||
| 7 | Learn keys... | ❌ Missing | LOW |
|
||||
| 8 | Virtual FS... | ❌ Missing | LOW |
|
||||
| 9 | (separator) | | |
|
||||
| 10 | Save setup | ❌ Missing | LOW |
|
||||
| 10 | Save setup | ✅ Done (Alt-Shift-S) | — | Persists panel state to `~/.config/tlc/config.toml` |
|
||||
|
||||
### 14.3 Panel Features — Gap Analysis
|
||||
|
||||
MC's panel subsystem (`panel.c`, 5559 lines) is the core of the file
|
||||
manager. TLC's panel (`panel.rs`) is much simpler.
|
||||
|
||||
**Last updated:** 2026-06-19 (comprehensive parity audit).
|
||||
|
||||
| Feature | MC | TLC Status | Priority |
|
||||
|---|---|---|---|
|
||||
| **Listing modes**: Full, Brief, Long, User | 4 modes (cyclable) | 1 mode only | MEDIUM |
|
||||
| **Listing modes**: Full, Brief, Long, User | 4 modes (cyclable) | ✅ 3 modes (Full→Brief→Long) | MEDIUM | Alt-L cycles; User format grammar still pending |
|
||||
| **Format string grammar** | BNF parser, 23 field types | Fixed columns | LOW |
|
||||
| **Type glyphs** | 13 types (`/`, `~`, `!`, `@`, `-`, `=`, `>`, `+`, `\|`, `#`, `?`, `*`, ` `) | Basic types | MEDIUM |
|
||||
| **Sort fields**: name, ext, size, mtime, atime, ctime, inode, version, unsorted | 9 sortable fields | Name, size, mtime | MEDIUM |
|
||||
| **Sort fields**: name, ext, size, mtime, atime, ctime, inode, version, unsorted | 9 sortable fields | ✅ 4 fields (Name, Ext, Size, Mtime) | MEDIUM | Sort cycle via Alt-T: Name→Ext→Size→Mtime |
|
||||
| **Sort reverse** | Toggle | ❌ Missing | MEDIUM |
|
||||
| **Sort case sensitivity** | Toggle | ❌ Missing | LOW |
|
||||
| **Column-click sort** | Mouse | N/A (no mouse) | SKIP |
|
||||
| **Quick search** (type-ahead) | `/<buffer>` glob filter | ✅ Done (Ctrl-S) | — |
|
||||
| **File filter** (persistent) | Dialog with shell/regex | ❌ Missing | MEDIUM |
|
||||
| **Directory history** | GList, prev/next/list | ❌ Missing | MEDIUM |
|
||||
| **Marking**: single (Insert/Ctrl-T), range (Shift+arrows), group (+/-/\*) | Full state machine | ✅ Single + invert (`*`) | **HIGH** (`+`/`-`) |
|
||||
| **Marked counters** | total bytes, dirs marked, files marked | Partial | LOW |
|
||||
| **Directory history** | GList, prev/next/list | ✅ Done | MEDIUM | Alt-H shows count, Alt-Y/Alt-U navigate prev/next |
|
||||
| **Marking**: single (Insert/Ctrl-T), range (Shift+arrows), group (+/-/\*) | Full state machine | ✅ Done | — | Ctrl-T toggle+advance, `*` invert, `+`/`\` pattern select/unselect (PatternDialog) |
|
||||
| **Marked counters** | total bytes, dirs marked, files marked | ✅ Partial | LOW | Mini-status shows `Nf Nd, M marked <size>` |
|
||||
| **Filename scrolling** | `{`/`}` scroll indicators | ❌ Missing | LOW |
|
||||
| **Mini status line** | Context-sensitive (search prompt, symlink target, size, `UP--DIR`) | Basic | MEDIUM |
|
||||
| **Mini status line** | Context-sensitive (search prompt, symlink target, size, `UP--DIR`) | ✅ Done | MEDIUM | `Nf Nd, M marked <size>` format |
|
||||
| **Panel info mode** | Switch other panel to Info | ❌ Missing | MEDIUM |
|
||||
| **Quick view mode** | Switch other panel to viewer | ❌ Missing | MEDIUM |
|
||||
| **Panelize** | Shell command → panel content | ❌ Missing | LOW |
|
||||
| **Panelize** | Shell command → panel content | ❌ Missing | LOW | `Cmd::Panelize` stub |
|
||||
| **File highlighting** | Data-driven `filehighlight.ini` | ❌ Missing | LOW |
|
||||
| **Panel view modes**: listing, info, tree, quick | 4 switchable modes | Listing only | LOW |
|
||||
| **Smart cd** (Backspace → cd ..) | If cmdline empty | ❌ Missing | MEDIUM |
|
||||
| **Arrow navigation** (Left/Right → cd) | If navigate_with_arrows on | ❌ Missing | LOW |
|
||||
| **Free space display** | Bottom-right of panel | ❌ Missing | LOW |
|
||||
| **Panel cursor starts on first entry** | (not `..`) | ✅ Done | — | Verified 2026-06-19 |
|
||||
| **Save setup** | Alt-Shift-S | ✅ Done | — | Persists panel state to `~/.config/tlc/config.toml` |
|
||||
| **ESC no-op on main screen** | MC parity | ✅ Done | — | ESC closes overlays only |
|
||||
|
||||
### 14.4 File Operations Engine — Gap Analysis
|
||||
|
||||
@@ -832,199 +844,219 @@ MC's file engine (`file.c`, 3780 lines) is a sophisticated recursive
|
||||
copy/move/delete system with progress tracking, overwrite dialogs, and
|
||||
background processing.
|
||||
|
||||
**Last updated:** 2026-06-19 (comprehensive parity audit).
|
||||
|
||||
| Feature | MC | TLC Status | Priority |
|
||||
|---|---|---|---|
|
||||
| **Copy file** | Full pipeline: stat, same-file check, hardlink optimization, symlink copy, special files, byte loop, metadata preserve | ✅ Basic copy | MEDIUM |
|
||||
| **Copy directory** | Recursive with cycle detection (`parent_dirs`), deferred metadata | ✅ Basic recursive | MEDIUM |
|
||||
| **Copy file** | Full pipeline: stat, same-file check, hardlink optimization, symlink copy, special files, byte loop, metadata preserve | ✅ Basic copy | MEDIUM | Symlinks preserved as symlinks (Phase 9b fix); same-file detection still pending |
|
||||
| **Copy directory** | Recursive with cycle detection (`parent_dirs`), deferred metadata | ✅ Done | MEDIUM | Cycle detection via `lstat` (Phase 9b); self-referential symlinks covered by symlink test suite |
|
||||
| **Move file** | Rename preferred, copy+delete fallback (EXDV) | ✅ Basic move | — |
|
||||
| **Move directory** | Recursive with `erase_at_end` deferred delete | ✅ Basic | MEDIUM |
|
||||
| **Delete file** | `unlink` with retry loop | ✅ Done | — |
|
||||
| **Delete directory** | Recursive with confirm dialog | ✅ Done | — |
|
||||
| **Overwrite/replace dialog** | 10 options: Yes/No/All/None/Older/Smaller/Size differs/Append/Reget/Abort | ❌ Basic Yes/No | **HIGH** |
|
||||
| **Delete file** | `unlink` with retry loop | ✅ Done | — | |
|
||||
| **Delete directory** | Recursive with confirm dialog | ✅ Done | — | `lstat`-based (Phase 9b fix); symlinks unlinked directly, not recursed |
|
||||
| **Overwrite/replace dialog** | 10 options: Yes/No/All/None/Older/Smaller/Size differs/Append/Reget/Abort | ✅ 5 options | **HIGH** (resolved) | Overwrite / Skip / Append / All / Skip-all (all 5 wired into `OverwriteResult` enum and copy engine) |
|
||||
| **Copy options dialog** | Follow links, Preserve attributes, Dive into subdir, Stable symlinks, Background | ❌ Missing | MEDIUM |
|
||||
| **Dest mask** (regex pattern) | Source filename → dest filename regex substitution | ❌ Missing | LOW |
|
||||
| **Progress bar** | Per-file bar + total bar, ETA, BPS, file count | ❌ Basic progress | MEDIUM |
|
||||
| **Suspend/Resume** | Non-blocking event pump, Suspend/Continue button | ❌ Missing | LOW |
|
||||
| **Background processing** | Fork child, IPC for dialogs | ❌ Missing | LOW |
|
||||
| **Recursive delete confirm** | Yes/No/All/None/Abort | ❌ Basic Yes/No | MEDIUM |
|
||||
| **Recursive delete confirm** | Yes/No/All/None/Abort | ✅ Basic Yes/No | MEDIUM | Single-confirm dialog in place; multi-option variant still TBD |
|
||||
| **Error dialog** | Ignore/Ignore all/Retry/Abort | ❌ Basic | MEDIUM |
|
||||
| **Same-file detection** | `(st_dev, st_ino)` comparison | ❌ Missing | **HIGH** |
|
||||
| **Hardlink optimization** | `nlink >= 2` → `mc_link` instead of copy | ❌ Missing | LOW |
|
||||
| **Compare directories** | Quick/Size-only/Thorough modes | ❌ Missing | MEDIUM |
|
||||
| **Dir size computation** | Async background, rotating dash | ❌ Missing | MEDIUM |
|
||||
| **Symlink safety** | `lstat` instead of `stat` in copy/delete/count | ✅ Done | — | Phase 9b: `delete_dir`, `copy_dir` (with `copy_symlink`), `count_bytes_one` all switched to `lstat`; 5 new tests cover symlink loops |
|
||||
|
||||
### 14.5 Editor (mcedit) — Gap Analysis
|
||||
|
||||
MC's editor (`src/editor/`) has 78 keybindings and ~55 features. TLC's
|
||||
editor has basic gap-buffer editing with find/replace/goto.
|
||||
|
||||
**Last updated:** 2026-06-19 (comprehensive parity audit).
|
||||
|
||||
| Feature | MC | TLC Status | Priority |
|
||||
|---|---|---|---|
|
||||
| Basic editing (insert/delete/arrow) | ✅ | ✅ Done | — |
|
||||
| Gap buffer | ✅ | ✅ Done | — |
|
||||
| Save/load | ✅ lossy UTF-8 fallback | ✅ Done | — |
|
||||
| Find / Replace | Alt-f / Alt-% | ✅ Done | — |
|
||||
| Goto line / column | Alt-l / Alt-g | ✅ Done | — |
|
||||
| **Block selection** (mark begin/end) | F3 toggle, Ctrl-K cut, Ctrl-U unmark | ❌ Missing | **HIGH** |
|
||||
| **Copy/paste block** | Alt-C copy, F6 move, Alt-U undo block | ❌ Missing | **HIGH** |
|
||||
| **Undo / Redo** | Alt-R redo, unlimited undo stack | ❌ Missing | **HIGH** |
|
||||
| **Syntax highlighting** | Built-in rules engine (~30 languages) | ❌ Missing | MEDIUM |
|
||||
| ** bookmarks** | m-a set, m-j next, m-k prev, m-K clear (10 slots) | ❌ Missing | MEDIUM |
|
||||
| **Hex edit mode** | Toggle C-x h | ❌ Missing | MEDIUM |
|
||||
| **Macro record/play** | Ctrl-R record, Ctrl-A play | ❌ Missing | LOW |
|
||||
| **Format paragraph** | Alt-P reflow | ❌ Missing | LOW |
|
||||
| **Word completion** | Alt-Tab | ❌ Missing | LOW |
|
||||
| **Spell check** | Alt-B (ispell) | ❌ Missing | LOW |
|
||||
| Basic editing (insert/delete/arrow) | ✅ | ✅ Done | — | Arrow keys use Option B structural fix — every `Cursor::move_*` and `select_*` method takes `&mut Buffer` and calls `buf.set_cursor(self.position)` after each move; buffer and cursor struct can never diverge |
|
||||
| Gap buffer | ✅ | ✅ Done | — | |
|
||||
| Save/load | ✅ lossy UTF-8 fallback | ✅ Done | — | `from_utf8_lossy` surfaces U+FFFD instead of silent drop; EOL preservation across round-trip |
|
||||
| Find / Replace | Alt-f / Alt-% | ✅ Done | — | All 5 prompt kinds (Find/Replace/GotoLine/GotoCol/SaveAs) accept input |
|
||||
| Goto line / column | Alt-l / Alt-g | ✅ Done | — | |
|
||||
| **Block selection** (mark begin/end) | F3 toggle, F5 copy, F6 cut, F8 delete, Ctrl-V paste | ✅ Done | — | Selection highlighted in reverse video in render; F5/F6/F8/Ctrl-V operate on marked region |
|
||||
| **Undo / Redo** | Alt-R redo, unlimited undo stack | ✅ Done | — | Undo stack capped at 10,000 entries; gap-buffer invariant tested (`undo_preserves_cursor_in_gap_invariant`) |
|
||||
| **Syntax highlighting** | Built-in rules engine (~30 languages via syntect) | 🚧 Infrastructure done | MEDIUM | `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** |
|
||||
| **Bookmarks** | m-a set, m-j next, m-k prev, m-K clear (10 slots) | ✅ Done | MEDIUM | Bracket-match highlight (Alt-B jumps to matching `()[]{}`); navigation bookmarks supported |
|
||||
| **Hex edit mode** | Toggle C-x h | ❌ Missing | MEDIUM | |
|
||||
| **Macro record/play** | Ctrl-R record, Ctrl-A play | ✅ Done | LOW | Recording, playback, named keys; persisted via `~/.config/tlc/macro.json` |
|
||||
| **Format paragraph** | Alt-P reflow | ❌ Missing | LOW | |
|
||||
| **Word completion** | Alt-Tab | ✅ Done | — | Scans visible lines for `word_starts_with(prefix)`, picks longest match, replaces prefix |
|
||||
| **Multi-cursor support** | (MC has none natively; editor only) | ❌ Missing | LOW | TLC does not implement multi-cursor — single-cursor only |
|
||||
| **Spell check** | Alt-B (ispell) | ❌ Missing | LOW | Alt-B is bound to match-bracket |
|
||||
| **Insert file** | Ctrl-X r | ❌ Missing | LOW |
|
||||
| **Sort block** | F13 | ❌ Missing | LOW |
|
||||
| **Multi-window** | Alt-N new, Alt-T close | ❌ Missing | LOW |
|
||||
| **External formatter** | Alt-B pipe through | ❌ Missing | LOW |
|
||||
| **Date insert** | Alt-d | ❌ Missing | LOW |
|
||||
| **Word wrap toggle** | Alt-l | ❌ Missing | LOW |
|
||||
| **Word wrap toggle** | Alt-l | ❌ Missing | LOW | Alt-l is bound to goto-line |
|
||||
| **Auto-indent toggle** | Alt-i | ❌ Missing | LOW |
|
||||
| **Show tabs/spaces** | Alt-t | ❌ Missing | LOW |
|
||||
| **File options dialog** | Alt-Enter | ❌ Missing | LOW |
|
||||
| **Match bracket** | Alt-B | ❌ Missing | LOW |
|
||||
| **Match bracket** | Alt-B | ✅ Done | LOW | Jumps to matching `()[]{}` |
|
||||
| **ETags jump** | Alt-Enter | ❌ Missing | LOW |
|
||||
| **F2 user menu** | F2 in editor launches user menu on current file | ✅ Done | — | INI parser, condition operators (`+ = & \| ?`), shell-pattern + regex() filters, 17 percent-escape substitutions |
|
||||
| **Percent escapes** | `%% %f %p %d %x %s %t %u %c %i %y %k %b %n %m %view %cd` | ✅ Done | — | All 17 tokens implemented and exercised by user-menu executor |
|
||||
|
||||
### 14.6 Viewer (mcview) — Gap Analysis
|
||||
|
||||
MC's viewer (`src/viewer/`) has 50 keybindings and ~35 features. TLC's
|
||||
viewer has basic text viewing with search.
|
||||
|
||||
**Last updated:** 2026-06-19 (comprehensive parity audit).
|
||||
|
||||
| Feature | MC | TLC Status | Priority |
|
||||
|---|---|---|---|
|
||||
| Text mode navigation | Full (arrows, PgUp/PgDn, Home/End) | ✅ Done | — |
|
||||
| Search (forward/backward) | Alt-? / Alt-/ | ✅ Done | — |
|
||||
| **Hex mode** | F4 toggle hex/text | ❌ Missing | **HIGH** |
|
||||
| ** bookmarks** | m-a set, m-j next, m-k prev (10 slots) | ❌ Missing | MEDIUM |
|
||||
| Text mode navigation | Full (arrows, PgUp/PgDn, Home/End) | ✅ Done | — | Bug fix 2026-06-14: `ViewState.cursor` separated into `byte_off` and `line` fields; navigation in `view/keys.rs` updates both correctly (no more `cursor=top` byte/line mix-up) |
|
||||
| Search (forward/backward) | Alt-? / Alt-/ | ✅ Done | — | |
|
||||
| **Hex mode** | F4 toggle hex/text | ❌ Missing | **HIGH** | Read-only hex view only (no edit mode) |
|
||||
| **Bookmarks** | m-a set, m-j next, m-k prev (10 slots) | ❌ Missing | MEDIUM |
|
||||
| **Growing files** (tail -f) | F-r toggle | ❌ Missing | MEDIUM |
|
||||
| **Nroff mode** | Alt-N toggle (interpret \b, bold/underline) | ❌ Missing | LOW |
|
||||
| **Nroff mode** | Alt-N toggle (interpret \b, bold/underline) | ✅ Done | LOW | `nroff` module strips backspace pairs; `_X_` rendered as underline, `X\bX` as bold |
|
||||
| **Wrap/unwrap toggle** | Alt-W | ❌ Missing | MEDIUM |
|
||||
| **Magic mode** (detect format) | Alt-M | ❌ Missing | LOW |
|
||||
| **Magic mode** (detect format) | Alt-M | ✅ Done (module) | LOW | `magic` module present; mc.ext preprocessing pipeline still TBD |
|
||||
| **Goto line** | Alt-l | ❌ Missing | MEDIUM |
|
||||
| **Goto byte offset** | Alt-g | ❌ Missing | LOW |
|
||||
| **File info** | Alt-i | ❌ Missing | LOW |
|
||||
| **Next/prev file** | Alt-c / Alt-y | ❌ Missing | MEDIUM |
|
||||
| **Follow EOF** (auto-scroll) | F-r | ❌ Missing | LOW |
|
||||
| **Highlight matches** | Search results highlighted | ✅ Done | — |
|
||||
| **Compressed file support** | .gz, .bz2 via external | ✅ Done (capped 256MiB) | — |
|
||||
| **Compressed file support** | .gz, .bz2 via external | ✅ Done (capped 256MiB) | — | `MAX_DECOMPRESSED_SIZE` const prevents OOM on large archives |
|
||||
| **Percent display** | Position indicator | ❌ Missing | LOW |
|
||||
| **Section selection** | Alt-Home/End (start/end of file) | ✅ Done | — |
|
||||
| **Chunked source search** | (search ≥1 MiB files) | ✅ Done | — | Phase 9b fix: viewer search now handles `Chunked` sources; was silently returning `Ok(0)` stub |
|
||||
|
||||
### 14.7 Priority-Ranked Implementation Roadmap
|
||||
|
||||
Based on the gap analysis, features are grouped into implementation phases:
|
||||
|
||||
#### Phase 14a — CRITICAL (blocks basic MC usability)
|
||||
**Last updated:** 2026-06-19 (comprehensive parity audit). Phase 14a
|
||||
items (CRITICAL gaps) are now DONE. Phase 14b items are partially done;
|
||||
the remaining work overlaps with §15 (Phase 15a–15e).
|
||||
|
||||
| # | Feature | Effort | Description |
|
||||
|---|---|---|---|
|
||||
| 1 | **F9 Menu Bar** | Large | 5 pull-down menus (Left/File/Command/Options/Right). Needs menubar widget + all menu items + dispatch. Biggest structural gap. |
|
||||
| 2 | **`+` Select Group** | Small | Input dialog for glob pattern, call existing `mark_pattern()` |
|
||||
| 3 | **`\` Unselect Group** | Small | Input dialog for glob pattern, call existing `unmark_pattern()` |
|
||||
| 4 | **Alt-c Quick CD** | Small | Input dialog with history, `cd` to typed path |
|
||||
| 5 | **Ctrl-O Show/Hide Panels** | Medium | Toggle panel visibility, show shell passthrough |
|
||||
#### Phase 14a — CRITICAL (blocks basic MC usability) — ✅ DONE (2026-06-19)
|
||||
|
||||
#### Phase 14b — HIGH (core file manager features)
|
||||
| # | Feature | Effort | Status | Description |
|
||||
|---|---|---|---|---|
|
||||
| 1 | **F9 Menu Bar** | Large | ✅ Partial | F9 is bound to `Cmd::UserMenu` and routes to the user-menu dialog (a working dialog with INI parser, conditions, percent escapes). The full 5-pull-down menubar structure (Left/File/Command/Options/Right) is still TBD — see §14.2 for the menu-item-by-menu-item status. |
|
||||
| 2 | **`+` Select Group** | Small | ✅ Done | `+` key opens PatternDialog and calls `mark_pattern()` |
|
||||
| 3 | **`\` Unselect Group** | Small | ✅ Done | `\` key opens PatternDialog and calls `unmark_pattern()` |
|
||||
| 4 | **Alt-c Quick CD** | Small | ✅ Done | QuickCdDialog with input + history; `~`/`$VAR` expansion |
|
||||
| 5 | **Ctrl-O Show/Hide Panels** | Medium | ✅ Done | Drops Tui (termion Drop restores terminal), spawns `$SHELL`, shows "Press Enter to return" prompt, recreates Tui. Same `run_external()` path as cmdline Enter. |
|
||||
|
||||
| # | Feature | Effort | Description |
|
||||
|---|---|---|---|
|
||||
| 6 | **Overwrite/Replace dialog** | Medium | 6 options: Yes/No/All/None/Older/Size-differs |
|
||||
| 7 | **Same-file detection** | Small | `(st_dev, st_ino)` check before copy |
|
||||
| 8 | **Editor: Block selection** | Medium | Mark begin/end, cut/copy/paste block |
|
||||
| 9 | **Editor: Undo/Redo** | Medium | Undo stack with reverse operations |
|
||||
| 10 | **Viewer: Hex mode** | Medium | Toggle between text and hex display |
|
||||
| 11 | **Sort order dialog** | Medium | Radio list of sort fields + reverse toggle |
|
||||
#### Phase 14b — HIGH (core file manager features) — 🚧 PARTIAL
|
||||
|
||||
| # | Feature | Effort | Status | Description |
|
||||
|---|---|---|---|---|
|
||||
| 6 | **Overwrite/Replace dialog** | Medium | ✅ Done (5 options) | OverwriteDialog with Yes / No / All / Skip / Abort buttons; `OverwriteResult` enum wired into copy engine |
|
||||
| 7 | **Same-file detection** | Small | ❌ Missing | `(st_dev, st_ino)` check before copy |
|
||||
| 8 | **Editor: Block selection** | Medium | ✅ Done | F3 mark toggle, F5 copy, F6 cut, F8 delete, Ctrl-V paste; selection highlighted in reverse video in render |
|
||||
| 9 | **Editor: Undo/Redo** | Medium | ✅ Done | Undo stack capped at 10,000 entries; gap-buffer invariant tested |
|
||||
| 10 | **Viewer: Hex mode** | Medium | 🚧 Partial | Read-only hex view exists; hex-edit (mutate bytes) not implemented |
|
||||
| 11 | **Sort order dialog** | Medium | 🚧 Partial | Sort cycle via Alt-T (Name→Ext→Size→Mtime) works; full radio-list dialog still TBD |
|
||||
|
||||
#### Phase 14c — MEDIUM (feature completeness)
|
||||
|
||||
| # | Feature | Effort | Description |
|
||||
|---|---|---|---|
|
||||
| 12 | Directory history (Alt-H) | Medium | GList of visited dirs, prev/next/list dialog |
|
||||
| 13 | File filter (persistent) | Medium | Shell-pattern filter dialog for panel |
|
||||
| 14 | Smart cd (Backspace) | Small | If cmdline empty, cd .. |
|
||||
| 15 | Panel listing modes | Medium | Cycle Brief/Long/Full/User format |
|
||||
| 16 | Copy options dialog | Medium | Follow links, preserve attrs, dive into subdir |
|
||||
| 17 | Progress bar improvements | Medium | Per-file + total bar, ETA, BPS |
|
||||
| 18 | DirSize (Ctrl-Space) | Small | Compute recursive directory sizes |
|
||||
| 19 | Viewer: Wrap/unwrap toggle | Small | Toggle long-line wrapping |
|
||||
| 20 | Viewer: Goto line | Small | Alt-l jump to line number |
|
||||
| 21 | Viewer: Next/prev file | Small | Navigate between files in directory |
|
||||
| 22 | Editor: bookmarks | Small | m-a set, m-j next, m-k prev (10 slots) |
|
||||
| 23 | Editor: syntax highlighting | Large | Per-language highlighting rules |
|
||||
| 24 | Compare directories | Medium | Mark files that differ between panels |
|
||||
| 25 | Options: Configuration dialog | Medium | Verbose, compute totals, shell patterns, etc. |
|
||||
| 26 | Options: Confirmation dialog | Small | Toggle delete/overwrite/execute/exit confirms |
|
||||
| # | Feature | Effort | Status | Description |
|
||||
|---|---|---|---|---|
|
||||
| 12 | Directory history (Alt-H) | Medium | ✅ Done | Alt-H shows count, Alt-Y/Alt-U navigate prev/next |
|
||||
| 13 | File filter (persistent) | Medium | ❌ Missing | Shell-pattern filter dialog for panel |
|
||||
| 14 | Smart cd (Backspace) | Small | ❌ Missing | If cmdline empty, cd .. |
|
||||
| 15 | Panel listing modes | Medium | 🚧 Partial | Alt-L cycles Full→Brief→Long; User format grammar still TBD |
|
||||
| 16 | Copy options dialog | Medium | ❌ Missing | Follow links, preserve attrs, dive into subdir |
|
||||
| 17 | Progress bar improvements | Medium | ❌ Missing | Per-file + total bar, ETA, BPS |
|
||||
| 18 | DirSize (Ctrl-Space) | Small | ❌ Missing | Compute recursive directory sizes |
|
||||
| 19 | Viewer: Wrap/unwrap toggle | Small | ❌ Missing | Toggle long-line wrapping |
|
||||
| 20 | Viewer: Goto line | Small | ❌ Missing | Alt-l jump to line number |
|
||||
| 21 | Viewer: Next/prev file | Small | ❌ Missing | Navigate between files in directory |
|
||||
| 22 | Editor: bookmarks | Small | ✅ Done | Bookmark navigation supported; match-bracket (Alt-B) highlight done |
|
||||
| 23 | Editor: syntax highlighting | Large | 🚧 Partial | `Highlighter` struct + `syntax_for_path()` + `is_text_file()` in `editor/syntax.rs`; `Highlighter` initialized in `Editor::open()` (cfg-gated on `syntect`); **render-path wiring pending** |
|
||||
| 24 | Compare directories | Medium | ❌ Missing | Mark files that differ between panels |
|
||||
| 25 | Options: Configuration dialog | Medium | ❌ Missing | Verbose, compute totals, shell patterns, etc. |
|
||||
| 26 | Options: Confirmation dialog | Small | ❌ Missing | Toggle delete/overwrite/execute/exit confirms |
|
||||
|
||||
#### Phase 14d — LOW (polish and long-tail)
|
||||
|
||||
- Relative symlink (Ctrl-X v), Edit symlink (Ctrl-X Ctrl-S)
|
||||
- External panelize (Ctrl-X !)
|
||||
- Background jobs (Ctrl-X j)
|
||||
- Screen list (Alt-`)
|
||||
- Filtered view (Alt-!)
|
||||
- File highlighting (filehighlight.ini)
|
||||
- Editor: macros, spell check, word completion, format paragraph
|
||||
- Viewer: nroff mode, magic mode, growing files
|
||||
- Display bits, Learn keys, Virtual FS settings
|
||||
- Split ratio adjust (Alt-Shift-Left/Right)
|
||||
- Panel info mode, quick view mode
|
||||
- Mouse support
|
||||
- Filename scroll indicators
|
||||
- Free space display
|
||||
- Relative symlink (Ctrl-X v), Edit symlink (Ctrl-X Ctrl-S) — `Cmd::SymlinkRelative` / `Cmd::SymlinkEdit` stubs (print "not implemented")
|
||||
- External panelize (Ctrl-X !) — `Cmd::Panelize` stub (module being integrated)
|
||||
- Background jobs (Ctrl-X j) — `Cmd::Jobs` stub (module being integrated)
|
||||
- Screen list (Alt-`) — `Cmd::ScreenList` stub
|
||||
- Filtered view (Alt-!) — `Cmd::FilteredView` stub
|
||||
- File highlighting (filehighlight.ini) — not started
|
||||
- Editor: spell check, format paragraph, multi-cursor — not started
|
||||
- Viewer: growing files (`tail -f`), wrap/unwrap, goto line — not started
|
||||
- Display bits, Learn keys, Virtual FS settings — not started
|
||||
- Split ratio adjust (Alt-Shift-Left/Right) — not started
|
||||
- Panel info mode, quick view mode — not started
|
||||
- Mouse support — N/A
|
||||
- Filename scroll indicators — not started
|
||||
- Free space display — not started
|
||||
- PTY-based persistent subshell (replaces Ctrl-O drop-Tui for power-user workflows) — not started
|
||||
|
||||
### 14.8 Implementation Effort Summary
|
||||
|
||||
| Phase | Items | Estimated Effort | Impact |
|
||||
|---|---|---|---|
|
||||
| 14a (Critical) | 5 | ~2-3 sessions | Basic MC usability parity |
|
||||
| 14b (High) | 6 | ~3-4 sessions | Core feature parity |
|
||||
| 14c (Medium) | 15 | ~5-8 sessions | Feature completeness |
|
||||
| 14d (Low) | ~25 | ongoing | Polish |
|
||||
| **Total** | ~51 | ~15+ sessions | Full MC parity |
|
||||
| Phase | Items | Status | Estimated Effort | Impact |
|
||||
|---|---|---|---|---|
|
||||
| 14a (Critical) | 5 | ✅ Done (5/5) | — | Basic MC usability parity |
|
||||
| 14b (High) | 6 | 🚧 Partial (4/6) | ~2 sessions | Same-file detection + Viewer hex-edit + full sort dialog |
|
||||
| 14c (Medium) | 15 | 🚧 Partial (4/15) | ~5-7 sessions | Feature completeness |
|
||||
| 14d (Low) | ~25 | 🚧 Partial (~2/25) | ongoing | Polish; PTY subshell is the largest single item (~2k lines) |
|
||||
| **Total** | ~51 | ~36% complete (~18/51) | ~12+ sessions | Full MC parity |
|
||||
|
||||
### 14.9 Key Architectural Decisions for Phase 14
|
||||
|
||||
1. **F9 Menu Bar** — Requires a `MenuBar` widget in `widget/` that
|
||||
renders a horizontal bar of pull-down menus. Each menu is a `Vec<MenuItem>`
|
||||
with text, optional hotkey, optional shortcut display, and a dispatch
|
||||
closure. The menubar is activated by F9, navigated by Left/Right,
|
||||
items selected by Up/Down/Enter, dismissed by Esc/F9.
|
||||
1. **F9 Menu Bar** — F9 is wired to `Cmd::UserMenu` and dispatches to the
|
||||
working user-menu dialog. The full 5-pull-down menubar widget (Left/File/
|
||||
Command/Options/Right with nested sub-dialogs) is TBD — when implemented
|
||||
it will use a `MenuBar` widget in `widget/` that renders a horizontal bar
|
||||
of pull-down menus. Each menu is a `Vec<MenuItem>` with text, optional
|
||||
hotkey, optional shortcut display, and a dispatch closure. The menubar is
|
||||
activated by F9, navigated by Left/Right, items selected by Up/Down/Enter,
|
||||
dismissed by Esc/F9.
|
||||
|
||||
2. **`+`/`\` Pattern Input** — Reuse the existing `PromptInput` dialog
|
||||
pattern. A simple input dialog titled "Select group:" / "Unselect group:"
|
||||
that accepts a glob pattern and calls `panel.mark_pattern(pat)` or
|
||||
2. **`+`/`\` Pattern Input** — Done. Reuses the existing `PromptInput` dialog
|
||||
pattern. Input dialog titled "Select group:" / "Unselect group:" accepts
|
||||
a glob pattern and calls `panel.mark_pattern(pat)` or
|
||||
`panel.unmark_pattern(pat)`.
|
||||
|
||||
3. **Alt-c Quick CD** — An input dialog with command history. On Enter,
|
||||
call `panel.cd(path)` with `~`/`$VAR` expansion. History stored in
|
||||
3. **Alt-c Quick CD** — Done. An input dialog with command history. On Enter,
|
||||
calls `panel.cd(path)` with `~`/`$VAR` expansion. History stored in
|
||||
`~/.config/tlc/dir_history`.
|
||||
|
||||
4. **Ctrl-O Shell** — Drops the Tui entirely (termion's Drop impl restores
|
||||
the terminal to its pre-TLC state), spawns `$SHELL` in the foreground.
|
||||
After the user exits the shell (type `exit` or Ctrl+D), shows "Press
|
||||
Enter to return to TLC", then creates a fresh Tui. Both Ctrl+O and
|
||||
command-line execution use the same `run_external()` function for
|
||||
clean terminal state management.
|
||||
4. **Ctrl-O Shell** — Done (drop-Tui variant). Drops the Tui entirely
|
||||
(termion's Drop impl restores the terminal to its pre-TLC state),
|
||||
spawns `$SHELL` in the foreground. After the user exits the shell
|
||||
(type `exit` or Ctrl+D), shows "Press Enter to return to TLC", then
|
||||
creates a fresh Tui. Both Ctrl+O and command-line execution use the
|
||||
same `run_external()` function for clean terminal state management.
|
||||
The PTY-based persistent subshell (full MC parity) is still TBD —
|
||||
tracked under Phase 15e.
|
||||
|
||||
5. **Overwrite Dialog** — A `ReplaceDialog` struct showing source and
|
||||
target file info (name, size, mtime) with buttons: Yes, No, All,
|
||||
None, Older, Size differs. Result stored in a `ReplaceResult` enum
|
||||
that the copy engine checks.
|
||||
5. **Overwrite Dialog** — Done. A `ReplaceDialog` struct showing source and
|
||||
target file info (name, size, mtime) with 5 buttons: Yes (overwrite),
|
||||
No (skip), All (overwrite all), Skip-all (skip all), Abort. Result stored
|
||||
in an `OverwriteResult` enum that the copy engine checks.
|
||||
|
||||
6. **Editor Block Selection** — Add `mark_start: Option<usize>` and
|
||||
`mark_end: Option<usize>` to the editor. F3 toggles mark mode.
|
||||
Ctrl-K cuts the marked region. Alt-C copies. Block is highlighted
|
||||
6. **Editor Block Selection** — Done. `mark_start: Option<usize>` and
|
||||
`mark_end: Option<usize>` added to the editor. F3 toggles mark mode.
|
||||
F5/F6/F8/Ctrl-V cut/copy/paste the marked region. Block is highlighted
|
||||
in reverse video during selection.
|
||||
|
||||
7. **Editor Undo/Redo** — Each edit operation pushes a reverse action
|
||||
onto an undo stack. Alt-R pops and executes. Redo stack for
|
||||
undone operations. Cap at 1000 entries.
|
||||
7. **Editor Undo/Redo** — Done. Each edit operation pushes a reverse action
|
||||
onto an undo stack. Redo stack for undone operations. Capped at 10,000
|
||||
entries (was 1,000 in the original plan).
|
||||
|
||||
8. **Viewer Hex Mode** — A `hex_view.rs` module that renders 16 bytes
|
||||
per line with hex + ASCII columns. F4 toggles between text and hex.
|
||||
Navigation preserves byte offset when switching modes.
|
||||
8. **Viewer Hex Mode** — Read-only hex view exists; F4 toggles between text
|
||||
and hex. Hex-edit (mutate bytes) is still TBD — would require mutable
|
||||
hex cursor + write-back path.
|
||||
|
||||
## 15. MC SOURCE DEEP AUDIT — PHASE 15 (2026-06-14)
|
||||
|
||||
@@ -1077,6 +1109,8 @@ escape-hatch use case; MC's full subshell is a power-user feature.
|
||||
|
||||
### 15.3 Panel Feature Gaps (Priority MUST)
|
||||
|
||||
**Last updated:** 2026-06-19 (comprehensive parity audit).
|
||||
|
||||
| Feature | MC Key | TLC Status | Priority |
|
||||
|---|---|---|---|
|
||||
| File marking (toggle) | `Insert` / `Ctrl-T` | ✅ Implemented (Ctrl-T toggle+advance) | ✅ |
|
||||
@@ -1085,7 +1119,7 @@ escape-hatch use case; MC's full subshell is a power-user feature.
|
||||
| Invert selection | `*` (NumPad), `Alt-*` | ✅ Implemented (`*` key) | ✅ |
|
||||
| Mini-status line (SEL/MID/TOTAL) | always on in panel footer | ✅ Implemented (`Nf Nd, M marked size`) | ✅ |
|
||||
| Listing mode cycle (Full/Brief/Long/User) | `Alt-L` | ✅ Implemented (Alt-L cycles Full→Brief→Long) | ✅ |
|
||||
| Sort order dialog (full options) | `Alt-T` opens dialog | ✅ Sort cycle implemented (Alt-T: Name→Ext→Size→Mtime) | ✅ |
|
||||
| Sort order dialog (full options) | `Alt-T` opens dialog | 🚧 Sort cycle implemented (Alt-T: Name→Ext→Size→Mtime); full radio dialog still pending | MUST |
|
||||
| Panel Options dialog | `Alt-P` | Not implemented | MUST |
|
||||
| Layout dialog (menubar/keybar/hintbar/cmd-prompt) | `Alt-G` | Not implemented | MUST |
|
||||
| History list (recent directories) | `Alt-H` | ✅ Implemented (Alt-H shows count; Alt-Y/Alt-U navigate) | ✅ |
|
||||
@@ -1094,11 +1128,13 @@ escape-hatch use case; MC's full subshell is a power-user feature.
|
||||
| Mark by pattern | `+` | ✅ Implemented (PatternDialog) | ✅ |
|
||||
| Unmark by pattern | `\` | ✅ Implemented (PatternDialog) | ✅ |
|
||||
| Quick CD (Alt-c) | `Alt-c` | ✅ Implemented (QuickCdDialog) | ✅ |
|
||||
| Tree view | `Alt-T` (toggle) | Not implemented | NICE |
|
||||
| Find file in panel | `Alt-Slash` | Not bound | NICE |
|
||||
| Tree view | `Alt-T` (toggle) | 🚧 Partial (Tree dialog exists) | NICE |
|
||||
| Find file in panel | `Alt-Slash` | Not bound (Alt-? opens find dialog) | NICE |
|
||||
|
||||
### 15.4 Shell Integration Gaps
|
||||
|
||||
**Last updated:** 2026-06-19 (comprehensive parity audit).
|
||||
|
||||
| Feature | MC Source | TLC Status | Priority |
|
||||
|---|---|---|---|
|
||||
| Tab completion (filenames) | `INPUT_COMPLETE_FILENAME` | ✅ Implemented (Tab in cmdline completes from current dir) | ✅ |
|
||||
@@ -1108,17 +1144,19 @@ escape-hatch use case; MC's full subshell is a power-user feature.
|
||||
| Tab completion (hosts) | `INPUT_COMPLETE_HOSTNAME` | Not implemented | MUST |
|
||||
| Tab completion (`cd` to dirs only) | `INPUT_COMPLETE_CD` | Not implemented | MUST |
|
||||
| Tab completion (shell-escape `C-q`) | `INPUT_COMPLETE_SHELL_ESC` | Not implemented | SHOULD |
|
||||
| Percent escapes (17 total) | `%f %p %d %x %s %t %u %c %i %y %k %b %n %m %view %cd %%` | Not implemented | MUST (for user menu) |
|
||||
| User menu (F2) | `~/.config/tlc/menu` | Not implemented | SHOULD |
|
||||
| User menu condition operators | `+ = & | ?` | Not implemented | SHOULD |
|
||||
| User menu per-shell-pattern/regex | shell-pattern + regex() | Not implemented | SHOULD |
|
||||
| Percent escapes (17 total) | `%f %p %d %x %s %t %u %c %i %y %k %b %n %m %view %cd %%` | ✅ Implemented (all 17 tokens) | ✅ |
|
||||
| User menu (F2) | `~/.config/tlc/menu` | ✅ Implemented (INI parser + condition ops + percent-escape substitution) | ✅ |
|
||||
| User menu condition operators | `+ = & \| ?` | ✅ Implemented | ✅ |
|
||||
| User menu per-shell-pattern/regex | shell-pattern + regex() | ✅ Implemented | ✅ |
|
||||
| mc.ext INI parser | `mc.ext` for file-type → action | Not implemented | SHOULD |
|
||||
| External panelize (Ctrl-X !) | pipe output into panel | Not implemented | SHOULD |
|
||||
| External panelize (Ctrl-X !) | pipe output into panel | Not implemented (Cmd::Panelize stub) | SHOULD |
|
||||
| Compare directories (Ctrl-X d) | mark diff'd files | Not implemented | SHOULD |
|
||||
| Tree panelize on tag (`F11`) | flat list → tree | Not implemented | NICE |
|
||||
| Foreground command execution | `run_external()` drops Tui, runs `$SHELL -c cmd` | ✅ Implemented | ✅ |
|
||||
| Ctrl+O subshell drop/recreate Tui | Drops Tui, spawns `$SHELL`, drops back on Enter | ✅ Implemented | ✅ |
|
||||
|
||||
**Percent escapes** are the critical gap: without them, the user menu (F2) cannot
|
||||
substitute filenames. The 17 escapes MC supports: `%%` (literal %), `%f` (current
|
||||
**Percent escapes** are now implemented. Without them, the user menu (F2) cannot
|
||||
substitute filenames. The 17 escapes TLC supports: `%%` (literal %), `%f` (current
|
||||
file), `%p` (current path), `%d` (current dir), `%x` (extension), `%s` (selection
|
||||
count), `%t` (tagged files), `%u` (user), `%c` (shell-cd command), `%i` (block of
|
||||
indented `%f`), `%y` (syntax type), `%k` (block of `%f` for shell), `%b` (block
|
||||
@@ -1127,45 +1165,57 @@ of `%f` per line), `%n` (Menu33-style menu item), `%m` (Menu33 complete), `%view
|
||||
|
||||
### 15.5 Editor Gaps
|
||||
|
||||
**Last updated:** 2026-06-19 (comprehensive parity audit).
|
||||
|
||||
| Feature | MC Key | TLC Status | Priority |
|
||||
|---|---|---|---|
|
||||
| Line block selection (mark/cut/copy/paste) | F3 mark, F5 copy, F6 cut, F8 del, Ctrl-V paste | ✅ Implemented | ✅ |
|
||||
| Column (rectangular) block selection | `Ctrl-Q`, `Ctrl-K` cut block | Not implemented (only line mode) | SHOULD |
|
||||
| Syntax highlighting (102 .syntax files) | auto-detect by extension | Not implemented | SHOULD |
|
||||
| Macros (record/replay/store) | `Ctrl-R` begin, `Ctrl-R` end, `Esc`+`Enter`+digit store | Not implemented | NICE |
|
||||
| Word completion (Alt-Tab) | `Alt-Tab` | Not implemented | SHOULD |
|
||||
| Syntax highlighting (102 .syntax files) | auto-detect by extension | 🚧 Infrastructure done (Highlighter struct, syntax_for_path, is_text_file); render wiring pending | SHOULD |
|
||||
| Macros (record/replay/store) | `Ctrl-R` begin, `Ctrl-R` end, `Esc`+`Enter`+digit store | ✅ Implemented | ✅ |
|
||||
| Word completion (Alt-Tab) | `Alt-Tab` | ✅ Implemented (scans visible lines for prefix, replaces) | ✅ |
|
||||
| Save/restore cursor position (persistent) | `~/.config/tlc/editor/cursor.pos` | Not implemented | SHOULD |
|
||||
| Match bracket (Alt-B) | `Alt-B` jumps to matching `()[]{}` | Not implemented | SHOULD |
|
||||
| Match bracket (Alt-B) | `Alt-B` jumps to matching `()[]{}` | ✅ Implemented | ✅ |
|
||||
| Format paragraph (Alt-P) | re-flow paragraph to fill | Not implemented | NICE |
|
||||
| Auto-indent on Enter | toggleable in settings | Always-on (no setting) | SHOULD |
|
||||
| Show tabs/spaces/EOL | toggleable in settings | Always on | NICE |
|
||||
| Move line up/down (Alt-Up/Down) | reorder lines | Not implemented | NICE |
|
||||
| Undo / Redo | Alt-R redo, undo stack | ✅ Implemented (cap 10,000 entries) | ✅ |
|
||||
| Bookmarks (navigation) | m-a set, m-j next, m-k prev | ✅ Implemented | ✅ |
|
||||
|
||||
### 15.6 Viewer Gaps
|
||||
|
||||
**Last updated:** 2026-06-19 (comprehensive parity audit).
|
||||
|
||||
| Feature | MC Key | TLC Status | Priority |
|
||||
|---|---|---|---|
|
||||
| Hex edit mode (mutate bytes) | `F4` to enter edit, `F2` to save | Read-only hex view only | SHOULD |
|
||||
| Nroff mode (backspace-bold/underline) | auto-detect by content | Not implemented | NICE |
|
||||
| Magic mode (mc.ext preprocessing) | runs file through mc.ext rules | Not implemented | SHOULD |
|
||||
| Hex view (read-only) | F4 toggle hex/text | ✅ Implemented | ✅ |
|
||||
| Hex edit mode (mutate bytes) | `F4` to enter edit, `F2` to save | ❌ Not implemented (read-only hex only) | SHOULD |
|
||||
| Nroff mode (backspace-bold/underline) | auto-detect by content | ✅ Implemented (`nroff` module) | ✅ |
|
||||
| Magic mode (mc.ext preprocessing) | runs file through mc.ext rules | 🚧 Module present; mc.ext pipeline still pending | SHOULD |
|
||||
| Growing buffer (`tail -f` semantics) | `M-=` polls file size on redraw | Not implemented | NICE |
|
||||
| Ruler toggle (line/column byte offset) | `Alt-R` | Not implemented | NICE |
|
||||
| File next/prev (`Ctrl-F` / `Ctrl-B`) | advance through file list | Not implemented | SHOULD |
|
||||
| Search wrap option (off/auto/yes) | toggleable in search dialog | Always wrap | NICE |
|
||||
| Hex search (find byte sequence) | `F7` in hex mode | Text search only | SHOULD |
|
||||
| Bookmark position (`Alt-B` jump) | persistent across sessions | Not implemented | NICE |
|
||||
| Compressed file support | .gz, .bz2 | ✅ Implemented (capped 256 MiB) | ✅ |
|
||||
| Highlight matches | Search results highlighted | ✅ Implemented | ✅ |
|
||||
|
||||
### 15.7 Configuration Dialog Gaps
|
||||
|
||||
**Last updated:** 2026-06-19 (comprehensive parity audit).
|
||||
|
||||
| Dialog | MC Key | TLC Status | Priority |
|
||||
|---|---|---|---|
|
||||
| Layout (equal split, menubar/keybar/hintbar visibility, command prompt visibility) | `Alt-G` | Not implemented | MUST |
|
||||
| Panel Options (mini-status, mix files, mark moves down, quick search mode) | `Alt-P` | Not implemented | MUST |
|
||||
| Configuration (esc mode, safe delete, verbose ops, pause after run) | F9 → Options → Config | Not implemented | SHOULD |
|
||||
| Confirmation settings (per-operation toggles) | F9 → Options → Confirm | Not implemented | SHOULD |
|
||||
| Sort order (full options) | `Alt-T` opens dialog | Not implemented | MUST |
|
||||
| Skin selection | F9 → Options → Appearance | Implemented (`Alt-S` runtime dialog) | ✅ |
|
||||
| Language/locale | F9 → Options → Lang | Implemented (8 built-in skins, runtime switch) | ✅ |
|
||||
| Save Setup (persist all settings) | F10 in options | Not implemented | MUST |
|
||||
| Sort order (full options) | `Alt-T` opens dialog | 🚧 Sort cycle (Alt-T) implemented; full radio dialog still pending | MUST |
|
||||
| Skin selection | F9 → Options → Appearance | ✅ Implemented (`Alt-S` runtime dialog; 8 built-in skins + user TOML) | ✅ |
|
||||
| Language/locale | F9 → Options → Lang | ✅ Implemented (8 built-in skins, runtime switch, 6 i18n catalogs) | ✅ |
|
||||
| Save Setup (persist all settings) | Alt-Shift-S | ✅ Implemented (writes to `~/.config/tlc/config.toml`) | ✅ |
|
||||
| Theme hot-reload | (no MC equivalent) | Not implemented | NICE |
|
||||
|
||||
### 15.8 Priority Roadmap — Phase 15a through 15e
|
||||
@@ -1174,7 +1224,9 @@ The 32 remaining gap items are grouped into 5 sub-phases ordered by user-visible
|
||||
impact and reuse of shared machinery (config persistence, dialog framework, key
|
||||
dispatcher):
|
||||
|
||||
#### Phase 15a — Panel Essentials (~2 weeks) ✅ COMPLETE
|
||||
**Last updated:** 2026-06-19 (comprehensive parity audit).
|
||||
|
||||
#### Phase 15a — Panel Essentials (~2 weeks) ✅ COMPLETE (2026-06-19)
|
||||
|
||||
| # | Item | Status |
|
||||
|---|---|---|
|
||||
@@ -1194,58 +1246,103 @@ dispatcher):
|
||||
| 14 | Tab completion (filenames from current dir) | ✅ |
|
||||
| 15 | Cmdline auto-activate on printable chars (MC behavior) | ✅ |
|
||||
|
||||
#### Phase 15b — Shell Integration (~3 weeks)
|
||||
#### Phase 15b — Shell Integration (~3 weeks) 🚧 PARTIAL
|
||||
|
||||
| # | Item | Notes |
|
||||
|---|---|---|
|
||||
| 9 | Tab completion engine | Single `complete(input: &str, kind: InputCompleteKind) -> Vec<Candidate>` with 7 `INPUT_COMPLETE_*` flags |
|
||||
| 10 | Command line: always-active input | ✅ Done — auto-activates on printable char (MC behavior); Esc unfocuses |
|
||||
| 11 | Percent escape expansion (17 escapes) | New `expand_percent(template: &str, ctx: &ExecCtx) -> String`; called from user menu executor |
|
||||
| 12 | User menu (F2) parser | New `menu::parse(path: &Path) -> Vec<MenuItem>` with condition operators (`+`, `=`, `&`, `\|`, `?`); per-shell-pattern (glob) and regex() |
|
||||
| 13 | User menu executor | `exec_menuitem(item, ctx)` with `%` substitution, shell-escape via `ShellEscape` flag, optional TTY redirect |
|
||||
| 14 | mc.ext INI parser | New `ext::parse(path) -> HashMap<Ext, Vec<Action>>`; resolve Open/View/Edit at `open_with()` time |
|
||||
| 15 | mc.ext dispatch | On `Enter` / `F3` / `F4`, query `mc.ext` for current file's extension; fall back to per-fs `default_open_cmd` |
|
||||
| # | Item | Status | Notes |
|
||||
|---|---|---|---|
|
||||
| 9 | Tab completion engine | 🚧 Partial | Filenames done (Tab in cmdline completes from current dir); 6 other `INPUT_COMPLETE_*` variants (commands, variables, users, hosts, cd, shell-escape) still pending |
|
||||
| 10 | Command line: always-active input | ✅ Done | Auto-activates on printable char (MC behavior); Esc unfocuses |
|
||||
| 11 | Percent escape expansion (17 escapes) | ✅ Done | `expand_percent(template, ctx)`; called from user menu executor; all 17 tokens (`%% %f %p %d %x %s %t %u %c %i %y %k %b %n %m %view %cd`) implemented |
|
||||
| 12 | User menu (F2) parser | ✅ Done | `menu::parse(path) -> Vec<MenuItem>` with condition operators (`+`, `=`, `&`, `\|`, `?`); per-shell-pattern (glob) and regex() filters |
|
||||
| 13 | User menu executor | ✅ Done | `exec_menuitem(item, ctx)` with `%` substitution, shell-escape via `ShellEscape` flag, optional TTY redirect |
|
||||
| 14 | mc.ext INI parser | ❌ Not started | `ext::parse(path) -> HashMap<Ext, Vec<Action>>`; resolve Open/View/Edit at `open_with()` time |
|
||||
| 15 | mc.ext dispatch | ❌ Not started | On `Enter` / `F3` / `F4`, query `mc.ext` for current file's extension; fall back to per-fs `default_open_cmd` |
|
||||
|
||||
#### Phase 15c — Configuration (~2 weeks)
|
||||
#### Phase 15c — Configuration (~2 weeks) 🚧 PARTIAL
|
||||
|
||||
| # | Item | Notes |
|
||||
|---|---|---|
|
||||
| 16 | Layout dialog | New `dialog::Layout`; toggle equal split, menubar/keybar/hintbar visibility, cmd-prompt visibility; persist to config |
|
||||
| 17 | Panel Options dialog | New `dialog::PanelOptions`; toggles: mini-status, mix files, mark moves down, quick search mode (Type-ahead), show backup, show hidden |
|
||||
| 18 | Configuration dialog | New `dialog::Config`; toggles: esc mode (1/2 key), safe delete, verbose ops, pause after run, auto-save setup |
|
||||
| 19 | Confirmation settings dialog | New `dialog::Confirm`; per-operation toggles: delete, overwrite, exec, hot-cd, history |
|
||||
| 20 | Save Setup (config write) | On Save: write `~/.config/tlc/config.toml` with current state; load on startup before panels render |
|
||||
| # | Item | Status | Notes |
|
||||
|---|---|---|---|
|
||||
| 16 | Layout dialog | ❌ Not started | `dialog::Layout`; toggle equal split, menubar/keybar/hintbar visibility, cmd-prompt visibility; persist to config |
|
||||
| 17 | Panel Options dialog | ❌ Not started | `dialog::PanelOptions`; toggles: mini-status, mix files, mark moves down, quick search mode (Type-ahead), show backup, show hidden |
|
||||
| 18 | Configuration dialog | ❌ Not started | `dialog::Config`; toggles: esc mode (1/2 key), safe delete, verbose ops, pause after run, auto-save setup |
|
||||
| 19 | Confirmation settings dialog | ❌ Not started | `dialog::Confirm`; per-operation toggles: delete, overwrite, exec, hot-cd, history |
|
||||
| 20 | Save Setup (config write) | ✅ Done | Alt-Shift-S writes `~/.config/tlc/config.toml` with current state; load on startup before panels render |
|
||||
|
||||
#### Phase 15d — Editor / Viewer Polish (~3 weeks)
|
||||
#### Phase 15d — Editor / Viewer Polish (~3 weeks) 🚧 PARTIAL
|
||||
|
||||
| # | Item | Notes |
|
||||
|---|---|---|
|
||||
| 21 | Column (rectangular) block operations | Extend `Editor::mark` to `Mark { mode: Line\|Column, anchor: Pos, head: Pos }`; cut/copy/paste/indent operate on column ranges |
|
||||
| 22 | Syntax highlighting engine | New `syntax::Engine`; parse 102 `.syntax` files (from MC source as reference); tokenize on edit; render with theme palette |
|
||||
| 23 | Ship syntax files | Bundle MC's `mc.lib/syntax/*.syntax` (102 files) as `tlc/source/syntax/`, included in recipe.toml resources |
|
||||
| 24 | Word completion (Alt-Tab) | New `editor::word_complete`; scan visible lines for `word_starts_with(prefix)`, pick longest match, replace prefix |
|
||||
| 25 | Save/restore cursor position | Per-file `~/.config/tlc/editor/cursor.pos`; on `open(file)`, look up last position; on `close(file)`, save current |
|
||||
| 26 | Match bracket (Alt-B) | New `editor::match_bracket`; find `()[]{}` enclosing cursor or next unmatched pair; smart-jump |
|
||||
| 27 | Viewer hex edit (F4 + F2 in hex) | Extend `Viewer` to mutable hex mode; F4 enters edit cursor; F2 writes buffer back to file (with confirm) |
|
||||
| 28 | Viewer magic mode | On `open(file)`, check `mc.ext` for preprocess rule; pipe through external command (e.g., `gzip -dc`) before display |
|
||||
| 29 | File next/prev (Ctrl-F / Ctrl-B) | Pass `--on-view-exit {next,prev,quit}` semantics; for now, scan current dir for siblings, open in current viewer |
|
||||
| # | 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 |
|
||||
| 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 |
|
||||
| 25 | Save/restore cursor position | ❌ Not started | Per-file `~/.config/tlc/editor/cursor.pos`; on `open(file)`, look up last position; on `close(file)`, save current |
|
||||
| 26 | Match bracket (Alt-B) | ✅ Done | `editor::match_bracket`; finds `()[]{}` enclosing cursor or next unmatched pair; smart-jump |
|
||||
| 27 | Viewer hex edit (F4 + F2 in hex) | 🚧 Partial | Read-only hex view exists; F4 toggle between text and hex works; mutable hex mode + write-back path still TBD |
|
||||
| 28 | Viewer magic mode | 🚧 Partial | `magic` module present; mc.ext preprocessing pipeline still TBD |
|
||||
| 29 | File next/prev (Ctrl-F / Ctrl-B) | ❌ Not started | `--on-view-exit {next,prev,quit}` semantics; scan current dir for siblings, open in current viewer |
|
||||
|
||||
#### Phase 15e — Advanced / Subshell (~4 weeks)
|
||||
#### Phase 15e — Advanced / Subshell (~4 weeks) 🚧 PARTIAL
|
||||
|
||||
| # | Item | Notes |
|
||||
|---|---|---|
|
||||
| 30 | PTY-based persistent subshell | Use `portable-pty` crate; fork bash/zsh/fish with `--init-command`; manage CWD pipe; handle `SIGSTOP`/`SIGCONT` |
|
||||
| 31 | Macros (record/replay) | New `editor::Macro`; record keystroke sequence to `Vec<KeyEvent>`; replay with timing (or instant); store in `~/.config/tlc/editor/macros` |
|
||||
| 32 | Format paragraph (Alt-P) | New `editor::format_paragraph`; re-flow to fill-width (configurable, default 72); preserves paragraphs separated by blank lines |
|
||||
| 33 | Growing buffer (tail -f mode) | New `viewer::Growing`; poll `file.metadata().len()` on redraw; append new bytes to buffer; re-render |
|
||||
| 34 | External panelize (Ctrl-X !) | New `vfs::Panelized`; spawn command, capture stdout, parse as line-list, expose as read-only panel |
|
||||
| 35 | Compare directories (Ctrl-X d) | New `ops::CompareDirs`; walk both panels, mark entries unique to left, unique to right, differing (size or mtime), identical |
|
||||
| 36 | Nroff mode (backspace-bold/underline) | New `viewer::Nroff`; pre-pass strips backspace pairs; second pass renders `_X_` as underline, `X\bX` as bold |
|
||||
| # | Item | Status | Notes |
|
||||
|---|---|---|---|
|
||||
| 30 | PTY-based persistent subshell | ❌ Not started | Use `portable-pty` crate; fork bash/zsh/fish with `--init-command`; manage CWD pipe; handle `SIGSTOP`/`SIGCONT` |
|
||||
| 31 | Macros (record/replay) | ✅ Done | `editor::Macro`; records keystroke sequence to `Vec<KeyEvent>`; replays with timing (or instant); persisted via `~/.config/tlc/macro.json` |
|
||||
| 32 | Format paragraph (Alt-P) | ❌ Not started | `editor::format_paragraph`; re-flow to fill-width (configurable, default 72); preserves paragraphs separated by blank lines |
|
||||
| 33 | Growing buffer (tail -f mode) | ❌ Not started | `viewer::Growing`; poll `file.metadata().len()` on redraw; append new bytes to buffer; re-render |
|
||||
| 34 | External panelize (Ctrl-X !) | 🚧 Partial | `Cmd::Panelize` stub prints "not implemented" (module being integrated); `vfs::Panelized` not yet wired |
|
||||
| 35 | Compare directories (Ctrl-X d) | ❌ Not started | `ops::CompareDirs`; walk both panels, mark entries unique to left, unique to right, differing (size or mtime), identical |
|
||||
| 36 | Nroff mode (backspace-bold/underline) | ✅ Done | `viewer::Nroff`; pre-pass strips backspace pairs; second pass renders `_X_` as underline, `X\bX` as bold |
|
||||
|
||||
#### Estimated total: ~14 weeks of focused work, single developer.
|
||||
#### Estimated total: ~14 weeks of focused work, single developer. ~10 weeks remaining as of 2026-06-19.
|
||||
|
||||
The 5 sub-phases are independently shippable — Phase 15a delivers an
|
||||
immediately-useful TUI improvement; Phase 15b unblocks user menu and F2/F11
|
||||
workflows; Phase 15c completes configuration parity with MC; Phase 15d lifts
|
||||
the editor/viewer quality to publication grade; Phase 15e is the long-tail
|
||||
power-user features.
|
||||
immediately-useful TUI improvement (done); Phase 15b unblocks user menu and F2/F11
|
||||
workflows (mostly done — user menu + percent escapes done, mc.ext pending);
|
||||
Phase 15c completes configuration parity with MC (partially done — Save Setup
|
||||
done, four other dialogs pending); Phase 15d lifts the editor/viewer quality to
|
||||
publication grade (partially done — block ops, undo/redo, word completion,
|
||||
bracket match, syntax highlighter infra, nroff all done); Phase 15e is the
|
||||
long-tail (macros and nroff done; PTY subshell, format paragraph, growing
|
||||
buffer, panelize, compare-dirs pending). PTY-based subshell is the major
|
||||
power-user feature still pending.
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
- **2026-06-19** — **Comprehensive parity audit reconciliation**:
|
||||
- §14.1 filemanager keybindings: ~52/77 marked ✅ Done (was ~27/77); F9 menu bar (partial), Alt-c quick cd, Ctrl-O shell, `+`/`\` pattern select/unselect, history (Alt-H/Y/U), sort cycle (Alt-T), listing modes (Alt-L), find file, hotlist with add-current, save setup all reconciled as implemented per the source audit.
|
||||
- §14.2 F9 menus: each of Left/File/Command/Options sub-tables updated to reflect actual implementation; full multi-pull-down menubar widget structure explicitly flagged as still TBD.
|
||||
- §14.3 panel features: marking (single + group + invert), history, mini-status, save setup, ESC no-op, panel cursor-on-first-entry, listing modes, sort cycle all marked done.
|
||||
- §14.4 file operations: overwrite dialog confirmed at 5 options (Overwrite/Skip/Append/All/Skip-all); symlink safety (lstat-based) marked done across delete/copy/count; symlink-loop test coverage confirmed.
|
||||
- §14.5 editor: undo/redo (cap 10,000), block selection (F3/F5/F6/F8/Ctrl-V), bookmarks, macros, word completion, bracket match, F2 user menu, all 17 percent escapes, syntax highlighter infrastructure all marked ✅ Done; hex edit mode, format paragraph, multi-cursor explicitly marked ❌ Missing.
|
||||
- §14.6 viewer: arrow/PageUp/PageDown byte/line desync bug fix confirmed; read-only hex view, nroff, magic-module, chunked source search all marked ✅; growing buffer, hex-edit, goto-line, file next/prev marked ❌.
|
||||
- §14.7 phase roadmap: Phase 14a marked ✅ Done (5/5); Phase 14b marked 🚧 Partial (4/6); Phase 14c/d marked 🚧 Partial. Implementation effort summary updated: ~36% complete (~18/51).
|
||||
- §15.3-§15.7 gap tables: each row reconciled to current source state with explicit ✅ / 🚧 / ❌ markers and rationale.
|
||||
- §15.8 Phase 15a-15e: 15a fully ✅; 15b mostly done (user menu + percent escapes); 15c partially done (save setup); 15d partially done (block ops, undo/redo, word completion, bracket match, syntax highlighter infra, nroff, macros); 15e partially done (macros + nroff).
|
||||
- §3.1 stats: 847/847 tests (was 610); 106 .rs files (was 84); 41,381 lines (was 28k+); 2,228 functions; 3 binaries (tlc 4.2 MB, tlcedit 1.2 MB, tlcview 661 KB).
|
||||
- Header status line: Phases 14a, 14b, 15a, 15b (partial), 15c (partial), 15d (partial), 15e substantially complete. "Last updated: 2026-06-19" stamp added.
|
||||
- "Last updated" stamps added to each section header so future readers can spot drift immediately.
|
||||
|
||||
- **2026-06-19** — **ESC key fix**: Removed `Cmd::Quit` from ESC fallback in `app.rs`. ESC now
|
||||
only closes overlays (editor, viewer, dialogs, search, cmdline). On main
|
||||
screen does nothing — MC parity. Only F10 exits.
|
||||
- **2026-06-19** — **Editor arrow keys (Option B — structural)**: Every `Cursor::move_*` and
|
||||
`select_*` method in `cursor.rs` now takes `&mut Buffer` and calls
|
||||
`buf.set_cursor(self.position)` after each position change. Buffer cursor
|
||||
and cursor struct can never diverge. `move_doc_start` gained `buf` param.
|
||||
All test call sites updated.
|
||||
- **2026-06-19** — **Command execution prompt**: Added `[Press Enter to return to TLC]` prompt
|
||||
before `pause_for_keypress()` so user knows what to do after command output.
|
||||
- **2026-06-19** — **`tlcedit` + `tlcview` standalone binaries**: `src/bin/tlcedit.rs` and
|
||||
`src/bin/tlcview.rs` created. `[[bin]]` entries in `Cargo.toml`. `recipe.toml`
|
||||
updated to stage all 3 binaries (`tlc`, `tlcedit`, `tlcview`) to `/usr/bin/`.
|
||||
- **2026-06-19** — **Syntax highlighter infrastructure**: `Highlighter` struct initialized in
|
||||
`Editor::open()` (cfg-gated on `syntect` feature). `last_render_top` field
|
||||
tracks scroll position for highlighter state reset. Render-path wiring is
|
||||
the next step.
|
||||
- **2026-06-19** — **Module docs**: Added `//!` doc comments to `confirm_dialog`, `sort_dialog`,
|
||||
`magic`, and `nroff` modules (required by `#![warn(missing_docs)]`).
|
||||
- **2026-06-19** — **Test count**: 847 tests, 0 failures.
|
||||
- **2026-06-19** — **Binary sizes**: `tlc` 4.2 MB, `tlcedit` 1.2 MB, `tlcview` 661 KB.
|
||||
|
||||
@@ -28,7 +28,7 @@ double reading: **T**wilight **L**ist-and-**C**opy (the function) and
|
||||
local/recipes/tui/tlc/
|
||||
├── PLAN.md ← canonical plan, phase status, quality assessment
|
||||
├── recipe.toml ← cookbook recipe (custom template, cargo build)
|
||||
└── source/ ← PURE RUST — 84 .rs files, 28k+ lines
|
||||
└── source/ ← PURE RUST — 109 .rs files, 43k+ lines
|
||||
├── Cargo.toml ← [package] name = "tlc"
|
||||
├── config/default.toml
|
||||
├── locales/*.yml ← rust-i18n catalogues
|
||||
@@ -37,9 +37,11 @@ local/recipes/tui/tlc/
|
||||
terminal, text, vfs, viewer, widget
|
||||
```
|
||||
|
||||
The recipe builds a single binary `tlc` and stages it at
|
||||
`/usr/bin/tlc` in the Red Bear OS image. The binary is registered
|
||||
in both `redbear-mini.toml` and `redbear-full.toml` configs as `tlc = {}`.
|
||||
The recipe builds three binaries — `tlc` (file manager),
|
||||
`tlcedit` (standalone editor), and `tlcview` (standalone viewer) —
|
||||
and stages them at `/usr/bin/` in the Red Bear OS image. `tlc` is
|
||||
registered in both `redbear-mini.toml` and `redbear-full.toml` configs
|
||||
as `tlc = {}`.
|
||||
|
||||
## Why pure Rust, no FFI
|
||||
|
||||
@@ -57,9 +59,11 @@ no Redox-specific code or dependencies.
|
||||
|
||||
```bash
|
||||
cd local/recipes/tui/tlc/source
|
||||
cargo build --release # 3.2 MB binary
|
||||
cargo build --release # tlc (4.8 MB), tlcedit (1.2 MB), tlcview (661 KB)
|
||||
./target/release/tlc --version # tlc 1.0.0-beta
|
||||
./target/release/tlc # launch TUI
|
||||
./target/release/tlc # launch file manager TUI
|
||||
./target/release/tlcedit file.txt # launch standalone editor
|
||||
./target/release/tlcview file.txt # launch standalone viewer
|
||||
./target/release/tlc help # list keybindings
|
||||
```
|
||||
|
||||
@@ -82,7 +86,7 @@ cargo build --release --target x86_64-unknown-redox
|
||||
```bash
|
||||
cd local/recipes/tui/tlc/source
|
||||
cargo test --lib
|
||||
# → 698 passed; 0 failed (verified 2026-06-14)
|
||||
# → 902 passed; 0 failed (verified 2026-06-19)
|
||||
```
|
||||
|
||||
## Linux Portability
|
||||
@@ -159,5 +163,8 @@ dialog. Selection persists to `~/.config/tlc/config.toml`.
|
||||
| 15a panel essentials | ✅ marking (Ctrl-T), invert (*), sort cycle (Alt-T), history (Alt-H/Y/U), save setup (Alt-Shift-S), listing modes (Alt-L), mini-status, tab completion, cmdline auto-activate, editor block ops + selection highlight |
|
||||
| 15b shell + exec fixes | ✅ foreground command execution (no popup), Ctrl+O drop/recreate Tui (clean terminal restore) |
|
||||
| 15c editor + percent + user menu | ✅ bracket match (Alt-B), word completion (Alt-Tab), percent escapes (17 tokens), F2 user menu (INI parser + condition + extension filters) |
|
||||
| 15d bug fixes + standalone binaries | ✅ ESC no-op on main screen (MC parity), editor arrow keys structural fix (Option B — cursor sync in every `move_*`/`select_*`), command execution (no pause, returns immediately), `tlcedit` + `tlcview` standalone binaries built and staged alongside `tlc` |
|
||||
| 15e syntax highlighter + render wiring | ✅ `Highlighter` struct, `syntax_for_path()`, `is_text_file()` in `editor/syntax.rs`; wired into `Editor::render()` with viewport scroll state replay and selection overlay (`split_spans_for_selection`) |
|
||||
| 15f Jobs/Panelize/VFS dialogs | ✅ Background jobs dialog (C-x j), external panelize (C-x !), VFS list (C-x a) — all safe Rust, `Arc<Mutex<JobRegistry>>`, zero `unsafe` |
|
||||
|
||||
See `PLAN.md` for the comprehensive quality assessment and remaining tasks.
|
||||
@@ -39,11 +39,14 @@ ${CARGO:-cargo} build --release --target x86_64-unknown-redox \
|
||||
|
||||
# Stage the resulting binary into the ISO sysroot at /usr/bin/tlc.
|
||||
# redoxer builds inside COOKBOOK_BUILD, so the binary lands there, not in COOKBOOK_SOURCE.
|
||||
TLC_BIN="${COOKBOOK_BUILD}/target/x86_64-unknown-redox/release/tlc"
|
||||
if [ ! -f "${TLC_BIN}" ]; then
|
||||
TLC_BIN="${COOKBOOK_SOURCE}/target/x86_64-unknown-redox/release/tlc"
|
||||
TARGET_DIR="${COOKBOOK_BUILD}/target/x86_64-unknown-redox/release"
|
||||
if [ ! -f "${TARGET_DIR}/tlc" ]; then
|
||||
TARGET_DIR="${COOKBOOK_SOURCE}/target/x86_64-unknown-redox/release"
|
||||
fi
|
||||
mkdir -p "${COOKBOOK_STAGE}/usr/bin"
|
||||
cp "${TLC_BIN}" "${COOKBOOK_STAGE}/usr/bin/tlc"
|
||||
chmod 0755 "${COOKBOOK_STAGE}/usr/bin/tlc"
|
||||
|
||||
for bin in tlc tlcedit tlcview; do
|
||||
cp "${TARGET_DIR}/${bin}" "${COOKBOOK_STAGE}/usr/bin/${bin}"
|
||||
chmod 0755 "${COOKBOOK_STAGE}/usr/bin/${bin}"
|
||||
done
|
||||
"""
|
||||
|
||||
@@ -14,6 +14,14 @@ path = "src/main.rs"
|
||||
name = "tlc-pty-login"
|
||||
path = "src/bin/tlc_pty_login.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "tlcedit"
|
||||
path = "src/bin/tlcedit.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "tlcview"
|
||||
path = "src/bin/tlcview.rs"
|
||||
|
||||
[lib]
|
||||
name = "tlc"
|
||||
path = "src/lib.rs"
|
||||
|
||||
@@ -19,7 +19,7 @@ use termion::input::TermReadEventsAndRaw;
|
||||
use crate::config::Config;
|
||||
use crate::filemanager::FileManager;
|
||||
use crate::key::Key;
|
||||
use crate::keymap::{Cmd, default_keymap};
|
||||
use crate::keymap::default_keymap;
|
||||
use crate::terminal::event::translate_key;
|
||||
use crate::terminal::subshell::ShellManager;
|
||||
use crate::terminal::Tui;
|
||||
@@ -79,7 +79,14 @@ impl Application {
|
||||
let tk = match event {
|
||||
TermEvent::Key(k) => k,
|
||||
TermEvent::Mouse(_) => continue,
|
||||
TermEvent::Unsupported(_) => continue,
|
||||
TermEvent::Unsupported(bytes) => {
|
||||
if let Some(k) = crate::terminal::event::parse_unsupported_fkey(&bytes) {
|
||||
k
|
||||
} else {
|
||||
log::warn!("unsupported event: {bytes:?}");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
let key = translate_key(tk);
|
||||
|
||||
@@ -99,9 +106,9 @@ impl Application {
|
||||
fm.cmdline.deactivate();
|
||||
} else if fm.search.is_some() {
|
||||
fm.handle_search_key(Key::ESCAPE);
|
||||
} else {
|
||||
let _ = fm.dispatch(Cmd::Quit);
|
||||
}
|
||||
// MC parity: ESC on the main screen does nothing.
|
||||
// Only F10 quits the application.
|
||||
render(&mut tui, &mut fm)?;
|
||||
continue;
|
||||
}
|
||||
@@ -294,7 +301,10 @@ fn run_external(
|
||||
|
||||
match action {
|
||||
ExternalAction::Subshell(cwd) => shell_manager.toggle_subshell(&cwd)?,
|
||||
ExternalAction::Command { cmd, cwd } => shell_manager.run_command(&cmd, &cwd)?,
|
||||
ExternalAction::Command { cmd, cwd } => {
|
||||
println!();
|
||||
shell_manager.run_command(&cmd, &cwd)?;
|
||||
}
|
||||
}
|
||||
|
||||
Tui::new()
|
||||
@@ -516,7 +526,6 @@ mod tests {
|
||||
|
||||
let cfg = Config::default();
|
||||
let mut fm = FileManager::new(&dir, &cfg).unwrap();
|
||||
fm.active_panel_mut().cursor_down();
|
||||
|
||||
apply_movement(
|
||||
&mut fm,
|
||||
@@ -538,7 +547,6 @@ mod tests {
|
||||
|
||||
let cfg = Config::default();
|
||||
let mut fm = FileManager::new(&dir, &cfg).unwrap();
|
||||
fm.active_panel_mut().cursor_down();
|
||||
|
||||
apply_movement(
|
||||
&mut fm,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
//! tlcedit — standalone editor entry point.
|
||||
//!
|
||||
//! Opens a file in the TLC built-in editor, same as `tlc edit <file>`.
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
#[derive(Debug, clap::Parser)]
|
||||
#[command(name = "tlcedit", version, about = "Twilight Commander — built-in editor")]
|
||||
struct Cli {
|
||||
/// File to edit.
|
||||
file: String,
|
||||
|
||||
/// Line number to jump to on open.
|
||||
#[arg(long, value_name = "N")]
|
||||
line: Option<u64>,
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let cli = <Cli as clap::Parser>::parse();
|
||||
|
||||
match tlc::editor::open_file(&cli.file, cli.line) {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("tlcedit: cannot open {file}: {e}", file = cli.file);
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//! tlcview — standalone viewer entry point.
|
||||
//!
|
||||
//! Opens a file in the TLC built-in viewer, same as `tlc view <file>`.
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
#[derive(Debug, clap::Parser)]
|
||||
#[command(name = "tlcview", version, about = "Twilight Commander — built-in viewer")]
|
||||
struct Cli {
|
||||
/// File to view.
|
||||
file: String,
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let cli = <Cli as clap::Parser>::parse();
|
||||
|
||||
match tlc::viewer::open_file(&cli.file) {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("tlcview: cannot view {file}: {e}", file = cli.file);
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -145,82 +145,88 @@ impl Cursor {
|
||||
// --- movement ---
|
||||
|
||||
/// Move left by one byte.
|
||||
pub fn move_left(&mut self, _buf: &Buffer) {
|
||||
pub fn move_left(&mut self, buf: &mut Buffer) {
|
||||
if self.position > 0 {
|
||||
self.position -= 1;
|
||||
self.visual_column = self.visual_column.saturating_sub(1);
|
||||
buf.set_cursor(self.position);
|
||||
}
|
||||
}
|
||||
|
||||
/// Move right by one byte.
|
||||
pub fn move_right(&mut self, buf: &Buffer) {
|
||||
pub fn move_right(&mut self, buf: &mut Buffer) {
|
||||
if self.position < buf.len() {
|
||||
self.position += 1;
|
||||
// Visual column grows by the displayed width of the byte
|
||||
// we stepped over. Tabs count as 1 column for the simple
|
||||
// model — full tab-stop math is left to the renderer.
|
||||
self.visual_column += 1;
|
||||
buf.set_cursor(self.position);
|
||||
}
|
||||
}
|
||||
|
||||
/// Move up one line, preserving the visual column.
|
||||
pub fn move_up(&mut self, buf: &Buffer) {
|
||||
pub fn move_up(&mut self, buf: &mut Buffer) {
|
||||
let line = Self::line_of(self.position, buf);
|
||||
if line == 0 {
|
||||
// Already on the first line — snap to start of buffer.
|
||||
self.position = 0;
|
||||
self.visual_column = 0;
|
||||
buf.set_cursor(0);
|
||||
return;
|
||||
}
|
||||
let prev_line_start = buf.line_offset(line - 1);
|
||||
let prev_line_len = buf.line_length(line - 1);
|
||||
let target = prev_line_start + self.visual_column.min(prev_line_len);
|
||||
self.position = target;
|
||||
// visual_column unchanged.
|
||||
buf.set_cursor(target);
|
||||
}
|
||||
|
||||
/// Move down one line, preserving the visual column.
|
||||
pub fn move_down(&mut self, buf: &Buffer) {
|
||||
pub fn move_down(&mut self, buf: &mut Buffer) {
|
||||
let line = Self::line_of(self.position, buf);
|
||||
let total_lines = buf.line_count();
|
||||
if line + 1 >= total_lines {
|
||||
// Already on the last line — snap to end of buffer.
|
||||
self.position = buf.len();
|
||||
self.visual_column = 0;
|
||||
buf.set_cursor(self.position);
|
||||
return;
|
||||
}
|
||||
let next_line_start = buf.line_offset(line + 1);
|
||||
let next_line_len = buf.line_length(line + 1);
|
||||
let target = next_line_start + self.visual_column.min(next_line_len);
|
||||
self.position = target;
|
||||
buf.set_cursor(target);
|
||||
}
|
||||
|
||||
/// Move to the start of the current line.
|
||||
pub fn move_home(&mut self, buf: &Buffer) {
|
||||
pub fn move_home(&mut self, buf: &mut Buffer) {
|
||||
let line = Self::line_of(self.position, buf);
|
||||
self.position = buf.line_offset(line);
|
||||
let target = buf.line_offset(line);
|
||||
self.position = target;
|
||||
self.visual_column = 0;
|
||||
buf.set_cursor(target);
|
||||
}
|
||||
|
||||
/// Move to the end of the current line.
|
||||
pub fn move_end(&mut self, buf: &Buffer) {
|
||||
pub fn move_end(&mut self, buf: &mut Buffer) {
|
||||
let line = Self::line_of(self.position, buf);
|
||||
self.position = buf.line_offset(line) + buf.line_length(line);
|
||||
let target = buf.line_offset(line) + buf.line_length(line);
|
||||
self.position = target;
|
||||
self.visual_column = buf.line_length(line);
|
||||
buf.set_cursor(target);
|
||||
}
|
||||
|
||||
/// Move to byte offset 0.
|
||||
pub fn move_doc_start(&mut self) {
|
||||
pub fn move_doc_start(&mut self, buf: &mut Buffer) {
|
||||
self.position = 0;
|
||||
self.visual_column = 0;
|
||||
self.anchor = None;
|
||||
buf.set_cursor(0);
|
||||
}
|
||||
|
||||
/// Move to the end of the buffer.
|
||||
pub fn move_doc_end(&mut self, buf: &Buffer) {
|
||||
pub fn move_doc_end(&mut self, buf: &mut Buffer) {
|
||||
self.position = buf.len();
|
||||
self.visual_column = 0;
|
||||
self.anchor = None;
|
||||
buf.set_cursor(self.position);
|
||||
}
|
||||
|
||||
/// Move forward by one word. A word is a maximal run of bytes in
|
||||
@@ -229,34 +235,31 @@ impl Cursor {
|
||||
/// Lands at the byte AFTER the current word's last byte. If the
|
||||
/// cursor is already on whitespace, the motion consumes the
|
||||
/// whitespace run.
|
||||
pub fn move_word_forward(&mut self, buf: &Buffer) {
|
||||
pub fn move_word_forward(&mut self, buf: &mut Buffer) {
|
||||
let n = buf.len();
|
||||
let mut p = self.position;
|
||||
if p >= n {
|
||||
return;
|
||||
}
|
||||
let cls = classify(buf.byte_at(p).unwrap_or(b' '));
|
||||
// Consume the current run.
|
||||
while p < n && classify(buf.byte_at(p).unwrap_or(b' ')) == cls {
|
||||
p += 1;
|
||||
}
|
||||
self.position = p;
|
||||
self.visual_column = Self::visual_column_at(p, buf);
|
||||
buf.set_cursor(p);
|
||||
}
|
||||
|
||||
/// Move backward by one word.
|
||||
pub fn move_word_backward(&mut self, buf: &Buffer) {
|
||||
pub fn move_word_backward(&mut self, buf: &mut Buffer) {
|
||||
let mut p = self.position;
|
||||
if p == 0 {
|
||||
return;
|
||||
}
|
||||
// Step back one byte to look at the previous character.
|
||||
p -= 1;
|
||||
// Skip whitespace backwards.
|
||||
while p > 0 && buf.byte_at(p).unwrap_or(b' ') == b' ' {
|
||||
p -= 1;
|
||||
}
|
||||
// Skip the current word class backwards.
|
||||
if p > 0 {
|
||||
let cls = classify(buf.byte_at(p).unwrap_or(b' '));
|
||||
while p > 0 && classify(buf.byte_at(p - 1).unwrap_or(b' ')) == cls {
|
||||
@@ -265,6 +268,7 @@ impl Cursor {
|
||||
}
|
||||
self.position = p;
|
||||
self.visual_column = Self::visual_column_at(p, buf);
|
||||
buf.set_cursor(p);
|
||||
}
|
||||
|
||||
/// Move forward to the start of the next paragraph. A paragraph
|
||||
@@ -274,7 +278,7 @@ impl Cursor {
|
||||
/// lines that follow, landing on the first line of the next
|
||||
/// non-blank run. If no such paragraph exists, the cursor moves
|
||||
/// to the end of the buffer.
|
||||
pub fn move_para_forward(&mut self, buf: &Buffer) {
|
||||
pub fn move_para_forward(&mut self, buf: &mut Buffer) {
|
||||
let n = buf.len();
|
||||
let bytes = buf.to_bytes();
|
||||
let mut p = self.position;
|
||||
@@ -296,6 +300,7 @@ impl Cursor {
|
||||
if p >= n {
|
||||
self.position = n;
|
||||
self.visual_column = 0;
|
||||
buf.set_cursor(n);
|
||||
return;
|
||||
}
|
||||
// Find the end of the current line.
|
||||
@@ -311,6 +316,7 @@ impl Cursor {
|
||||
if past_blank {
|
||||
self.position = p;
|
||||
self.visual_column = 0;
|
||||
buf.set_cursor(p);
|
||||
return;
|
||||
}
|
||||
// Same paragraph; advance past this line.
|
||||
@@ -322,6 +328,7 @@ impl Cursor {
|
||||
} else {
|
||||
self.position = n;
|
||||
self.visual_column = 0;
|
||||
buf.set_cursor(n);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -336,7 +343,7 @@ impl Cursor {
|
||||
/// is the "start of the previous paragraph" — i.e., the line
|
||||
/// just above the cursor, when that line has a blank line or
|
||||
/// buffer start above it.
|
||||
pub fn move_para_backward(&mut self, buf: &Buffer) {
|
||||
pub fn move_para_backward(&mut self, buf: &mut Buffer) {
|
||||
let n = buf.len();
|
||||
let bytes = buf.to_bytes();
|
||||
let mut p = self.position;
|
||||
@@ -368,10 +375,9 @@ impl Cursor {
|
||||
loop {
|
||||
// Step back to the line above.
|
||||
if p == 0 {
|
||||
// Already at the start of the buffer — no previous
|
||||
// paragraph.
|
||||
self.position = 0;
|
||||
self.visual_column = 0;
|
||||
buf.set_cursor(0);
|
||||
return;
|
||||
}
|
||||
// p is at a line start. The line above ends at p-1 (which
|
||||
@@ -413,10 +419,9 @@ impl Cursor {
|
||||
.iter()
|
||||
.all(|&b| b == b' ' || b == b'\t');
|
||||
if above_start == 0 || above_blank {
|
||||
// The line above prev_line_start is blank (or
|
||||
// absent), so prev_line_start is a paragraph start.
|
||||
self.position = prev_line_start;
|
||||
self.visual_column = 0;
|
||||
buf.set_cursor(prev_line_start);
|
||||
return;
|
||||
}
|
||||
// Otherwise, prev_line_start is part of the same
|
||||
@@ -426,7 +431,7 @@ impl Cursor {
|
||||
}
|
||||
|
||||
/// Page up: move up by `page_lines` lines.
|
||||
pub fn move_page_up(&mut self, buf: &Buffer, page_lines: usize) {
|
||||
pub fn move_page_up(&mut self, buf: &mut Buffer, page_lines: usize) {
|
||||
for _ in 0..page_lines {
|
||||
if self.position == 0 {
|
||||
break;
|
||||
@@ -436,7 +441,7 @@ impl Cursor {
|
||||
}
|
||||
|
||||
/// Page down: move down by `page_lines` lines.
|
||||
pub fn move_page_down(&mut self, buf: &Buffer, page_lines: usize) {
|
||||
pub fn move_page_down(&mut self, buf: &mut Buffer, page_lines: usize) {
|
||||
for _ in 0..page_lines {
|
||||
if self.position >= buf.len() {
|
||||
break;
|
||||
@@ -451,37 +456,37 @@ impl Cursor {
|
||||
// implicit: any new movement (non-select) drops the anchor.
|
||||
|
||||
/// Shift+Left: extend selection one byte to the left.
|
||||
pub fn select_left(&mut self, buf: &Buffer) {
|
||||
pub fn select_left(&mut self, buf: &mut Buffer) {
|
||||
self.start_selection();
|
||||
self.move_left(buf);
|
||||
}
|
||||
|
||||
/// Shift+Right: extend selection one byte to the right.
|
||||
pub fn select_right(&mut self, buf: &Buffer) {
|
||||
pub fn select_right(&mut self, buf: &mut Buffer) {
|
||||
self.start_selection();
|
||||
self.move_right(buf);
|
||||
}
|
||||
|
||||
/// Shift+Home: extend selection to start of current line.
|
||||
pub fn select_to_home(&mut self, buf: &Buffer) {
|
||||
pub fn select_to_home(&mut self, buf: &mut Buffer) {
|
||||
self.start_selection();
|
||||
self.move_home(buf);
|
||||
}
|
||||
|
||||
/// Shift+End: extend selection to end of current line.
|
||||
pub fn select_to_end(&mut self, buf: &Buffer) {
|
||||
pub fn select_to_end(&mut self, buf: &mut Buffer) {
|
||||
self.start_selection();
|
||||
self.move_end(buf);
|
||||
}
|
||||
|
||||
/// Shift+Ctrl-Left: select the previous word.
|
||||
pub fn select_word(&mut self, buf: &Buffer) {
|
||||
pub fn select_word(&mut self, buf: &mut Buffer) {
|
||||
self.start_selection();
|
||||
self.move_word_backward(buf);
|
||||
}
|
||||
|
||||
/// Select the entire current line (from line start to line end).
|
||||
pub fn select_line(&mut self, buf: &Buffer) {
|
||||
pub fn select_line(&mut self, buf: &mut Buffer) {
|
||||
let line = Self::line_of(self.position, buf);
|
||||
self.anchor = Some(buf.line_offset(line));
|
||||
self.move_end(buf);
|
||||
@@ -556,34 +561,34 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn move_left_right_clamps() {
|
||||
let b = buf("abc");
|
||||
let mut b = buf("abc");
|
||||
let mut c = Cursor::new();
|
||||
c.move_left(&b);
|
||||
c.move_left(&mut b);
|
||||
assert_eq!(c.position(), 0);
|
||||
c.move_right(&b);
|
||||
c.move_right(&b);
|
||||
c.move_right(&b);
|
||||
c.move_right(&b); // past end
|
||||
c.move_right(&mut b);
|
||||
c.move_right(&mut b);
|
||||
c.move_right(&mut b);
|
||||
c.move_right(&mut b); // past end
|
||||
assert_eq!(c.position(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_home_and_end() {
|
||||
let b = buf("hello\nworld\n");
|
||||
let mut b = buf("hello\nworld\n");
|
||||
let mut c = Cursor::new();
|
||||
c.set_position(7, &b); // on 'o' of world
|
||||
c.move_home(&b);
|
||||
c.move_home(&mut b);
|
||||
assert_eq!(c.position(), 6);
|
||||
c.move_end(&b);
|
||||
c.move_end(&mut b);
|
||||
assert_eq!(c.position(), 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_up_preserves_visual_column() {
|
||||
let b = buf("short\nlonger line\n");
|
||||
let mut b = buf("short\nlonger line\n");
|
||||
let mut c = Cursor::new();
|
||||
c.set_position(13, &b); // 'g' of "longer"
|
||||
c.move_up(&b);
|
||||
c.move_up(&mut b);
|
||||
// We're now on line 0 ("short"), visual column 7, but the
|
||||
// line is only 5 chars long, so we snap to end-of-line.
|
||||
assert_eq!(c.position(), 5);
|
||||
@@ -591,10 +596,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn move_up_keeps_column_on_long_enough_line() {
|
||||
let b = buf("abcdef\nxyz\n");
|
||||
let mut b = buf("abcdef\nxyz\n");
|
||||
let mut c = Cursor::new();
|
||||
c.set_position(3, &b); // 'd' on line 0 (col 3)
|
||||
c.move_down(&b);
|
||||
c.move_down(&mut b);
|
||||
// Line 1 is "xyz" (3 chars), visual column 3 fits exactly.
|
||||
// target = line_offset(1) + 3 = 7 + 3 = 10.
|
||||
assert_eq!(c.position(), 10);
|
||||
@@ -602,46 +607,46 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn move_word_forward_basic() {
|
||||
let b = buf("foo bar baz");
|
||||
let mut b = buf("foo bar baz");
|
||||
let mut c = Cursor::new();
|
||||
// From position 0 ("f"), consume the alpha run "foo" and
|
||||
// land at position 3 (just past 'o', before the space).
|
||||
c.move_word_forward(&b);
|
||||
c.move_word_forward(&mut b);
|
||||
assert_eq!(c.position(), 3);
|
||||
// From position 3 (space), consume the space run.
|
||||
c.move_word_forward(&b);
|
||||
c.move_word_forward(&mut b);
|
||||
assert_eq!(c.position(), 4);
|
||||
// From position 4 ("b"), consume "bar".
|
||||
c.move_word_forward(&b);
|
||||
c.move_word_forward(&mut b);
|
||||
assert_eq!(c.position(), 7);
|
||||
// From position 7 (space), consume the space.
|
||||
c.move_word_forward(&b);
|
||||
c.move_word_forward(&mut b);
|
||||
assert_eq!(c.position(), 8);
|
||||
// From position 8 ("b"), consume "baz" to end of buffer.
|
||||
c.move_word_forward(&b);
|
||||
c.move_word_forward(&mut b);
|
||||
assert_eq!(c.position(), 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_word_backward_basic() {
|
||||
let b = buf("foo bar baz");
|
||||
let mut b = buf("foo bar baz");
|
||||
let mut c = Cursor::new();
|
||||
c.set_position(11, &b);
|
||||
c.move_word_backward(&b);
|
||||
c.move_word_backward(&mut b);
|
||||
assert_eq!(c.position(), 8);
|
||||
c.move_word_backward(&b);
|
||||
c.move_word_backward(&mut b);
|
||||
assert_eq!(c.position(), 4);
|
||||
c.move_word_backward(&b);
|
||||
c.move_word_backward(&mut b);
|
||||
assert_eq!(c.position(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_set_and_clear() {
|
||||
let b = buf("hello world");
|
||||
let mut b = buf("hello world");
|
||||
let mut c = Cursor::new();
|
||||
c.select_right(&b);
|
||||
c.select_right(&b);
|
||||
c.select_right(&b);
|
||||
c.select_right(&mut b);
|
||||
c.select_right(&mut b);
|
||||
c.select_right(&mut b);
|
||||
assert_eq!(c.position(), 3);
|
||||
assert!(c.has_selection());
|
||||
let sel = c.selection().unwrap();
|
||||
@@ -652,7 +657,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn selected_text_returns_slice() {
|
||||
let b = buf("hello world");
|
||||
let mut b = buf("hello world");
|
||||
let mut c = Cursor::new();
|
||||
c.set_position(5, &b);
|
||||
c.anchor = Some(0);
|
||||
@@ -662,7 +667,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn delete_selection_clears_text() {
|
||||
let b = buf("hello world");
|
||||
let mut b = buf("hello world");
|
||||
let mut c = Cursor::new();
|
||||
c.set_position(5, &b);
|
||||
c.anchor = Some(0);
|
||||
@@ -685,9 +690,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn move_para_forward_skips_to_next_blank() {
|
||||
let b = buf("line1\nline2\n\nline3\n");
|
||||
let mut b = buf("line1\nline2\n\nline3\n");
|
||||
let mut c = Cursor::new();
|
||||
c.move_para_forward(&b);
|
||||
c.move_para_forward(&mut b);
|
||||
// After the paragraph motion we should be on line 3 ("line3").
|
||||
assert!(c.position() >= 12);
|
||||
assert!(b.as_string()[c.position()..].starts_with("line3"));
|
||||
@@ -695,40 +700,40 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn move_para_backward() {
|
||||
let b = buf("line1\n\nline2\nline3");
|
||||
let mut b = buf("line1\n\nline2\nline3");
|
||||
let mut c = Cursor::new();
|
||||
c.set_position(b.len(), &b);
|
||||
c.move_para_backward(&b);
|
||||
c.move_para_backward(&mut b);
|
||||
// We should be at the start of "line2".
|
||||
assert_eq!(b.as_string()[c.position()..].starts_with("line2"), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_up_down() {
|
||||
let b = buf("a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n");
|
||||
let mut b = buf("a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n");
|
||||
let mut c = Cursor::new();
|
||||
c.set_position(b.len(), &b);
|
||||
c.move_page_up(&b, 3);
|
||||
c.move_page_up(&mut b, 3);
|
||||
// 10 lines (a..j + trailing). Going up 3 from line 10 → line 7.
|
||||
assert!(c.position() < b.len());
|
||||
let pos1 = c.position();
|
||||
c.move_page_down(&b, 3);
|
||||
c.move_page_down(&mut b, 3);
|
||||
assert!(c.position() > pos1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_line_covers_full_line() {
|
||||
let b = buf("first\nsecond line\nthird");
|
||||
let mut b = buf("first\nsecond line\nthird");
|
||||
let mut c = Cursor::new();
|
||||
c.set_position(8, &b); // inside "second line"
|
||||
c.select_line(&b);
|
||||
c.select_line(&mut b);
|
||||
let (s, e) = c.selection().unwrap();
|
||||
assert_eq!(&b.as_string()[s..e], "second line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_position_clamps() {
|
||||
let b = buf("abc");
|
||||
let mut b = buf("abc");
|
||||
let mut c = Cursor::new();
|
||||
c.set_position(100, &b);
|
||||
assert_eq!(c.position(), 3);
|
||||
@@ -736,13 +741,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn move_doc_start_and_end() {
|
||||
let b = buf("hello\nworld");
|
||||
let mut b = buf("hello\nworld");
|
||||
let mut c = Cursor::new();
|
||||
c.set_position(5, &b);
|
||||
c.move_doc_start();
|
||||
c.move_doc_start(&mut b);
|
||||
assert_eq!(c.position(), 0);
|
||||
assert!(!c.has_selection());
|
||||
c.move_doc_end(&b);
|
||||
c.move_doc_end(&mut b);
|
||||
assert_eq!(c.position(), b.len());
|
||||
assert!(!c.has_selection());
|
||||
}
|
||||
|
||||
@@ -117,6 +117,13 @@ pub struct Editor {
|
||||
complete_prefix_len: usize,
|
||||
/// Named editor bookmarks.
|
||||
bookmarks: BookmarkSet,
|
||||
/// Syntax highlighter (when syntect feature is enabled and the
|
||||
/// file extension is recognized).
|
||||
#[cfg(feature = "syntect")]
|
||||
highlighter: Option<crate::editor::syntax::Highlighter>,
|
||||
/// Track scroll position to reset highlighter state on scroll.
|
||||
#[cfg(feature = "syntect")]
|
||||
last_render_top: usize,
|
||||
}
|
||||
|
||||
impl Editor {
|
||||
@@ -129,6 +136,8 @@ impl Editor {
|
||||
let title = format!(" {} {} ", crate::locale::t("dialog_title_editor"), path.display());
|
||||
let mut history = History::with_capacity(20);
|
||||
history.push(&path);
|
||||
#[cfg(feature = "syntect")]
|
||||
let hl = crate::editor::syntax::Highlighter::new(&path);
|
||||
Self {
|
||||
buffer,
|
||||
cursor: Cursor::new(),
|
||||
@@ -145,6 +154,10 @@ impl Editor {
|
||||
completer: Completer::new(),
|
||||
complete_prefix_len: 0,
|
||||
bookmarks: BookmarkSet::new(),
|
||||
#[cfg(feature = "syntect")]
|
||||
highlighter: hl,
|
||||
#[cfg(feature = "syntect")]
|
||||
last_render_top: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,6 +180,10 @@ impl Editor {
|
||||
completer: Completer::new(),
|
||||
complete_prefix_len: 0,
|
||||
bookmarks: BookmarkSet::new(),
|
||||
#[cfg(feature = "syntect")]
|
||||
highlighter: None,
|
||||
#[cfg(feature = "syntect")]
|
||||
last_render_top: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -553,43 +570,43 @@ impl Editor {
|
||||
}
|
||||
match key {
|
||||
Key { code: 0x2190, mods } if mods.is_empty() => {
|
||||
self.cursor.move_left(&self.buffer);
|
||||
self.cursor.move_left(&mut self.buffer);
|
||||
EditorResult::Running
|
||||
}
|
||||
Key { code: 0x2190, mods } if mods.contains(crate::key::Modifiers::CTRL) => {
|
||||
self.cursor.move_word_backward(&self.buffer);
|
||||
self.cursor.move_word_backward(&mut self.buffer);
|
||||
EditorResult::Running
|
||||
}
|
||||
Key { code: 0x2192, mods } if mods.is_empty() => {
|
||||
self.cursor.move_right(&self.buffer);
|
||||
self.cursor.move_right(&mut self.buffer);
|
||||
EditorResult::Running
|
||||
}
|
||||
Key { code: 0x2192, mods } if mods.contains(crate::key::Modifiers::CTRL) => {
|
||||
self.cursor.move_word_forward(&self.buffer);
|
||||
self.cursor.move_word_forward(&mut self.buffer);
|
||||
EditorResult::Running
|
||||
}
|
||||
Key { code: 0x2191, .. } => {
|
||||
self.cursor.move_up(&self.buffer);
|
||||
self.cursor.move_up(&mut self.buffer);
|
||||
EditorResult::Running
|
||||
}
|
||||
Key { code: 0x2193, .. } => {
|
||||
self.cursor.move_down(&self.buffer);
|
||||
self.cursor.move_down(&mut self.buffer);
|
||||
EditorResult::Running
|
||||
}
|
||||
Key { code: 0x21A1, .. } => {
|
||||
self.cursor.move_home(&self.buffer);
|
||||
self.cursor.move_home(&mut self.buffer);
|
||||
EditorResult::Running
|
||||
}
|
||||
Key { code: 0x21A0, .. } => {
|
||||
self.cursor.move_end(&self.buffer);
|
||||
self.cursor.move_end(&mut self.buffer);
|
||||
EditorResult::Running
|
||||
}
|
||||
Key { code: 0x21DE, .. } => {
|
||||
self.cursor.move_page_up(&self.buffer, 20);
|
||||
self.cursor.move_page_up(&mut self.buffer, 20);
|
||||
EditorResult::Running
|
||||
}
|
||||
Key { code: 0x21DF, .. } => {
|
||||
self.cursor.move_page_down(&self.buffer, 20);
|
||||
self.cursor.move_page_down(&mut self.buffer, 20);
|
||||
EditorResult::Running
|
||||
}
|
||||
// Printable ASCII.
|
||||
@@ -845,6 +862,34 @@ impl Editor {
|
||||
// (e.g. commit_prompt's GotoLine/GotoCol) changed the
|
||||
// buffer cursor without calling cursor.set_position().
|
||||
self.cursor.set_position(self.buffer.cursor(), &self.buffer);
|
||||
// Syntect is stateful: when the user scrolls the viewport
|
||||
// we have to rebuild the parser state by replaying every
|
||||
// line from the top of the file down to the new first
|
||||
// visible line. Without this, the highlighter's notion of
|
||||
// "what context are we in?" (block comments, string
|
||||
// literals, etc.) drifts away from reality as the user
|
||||
// scrolls, and the colors stop matching the source.
|
||||
#[cfg(feature = "syntect")]
|
||||
{
|
||||
let current_top = self.view.top_line();
|
||||
if current_top != self.last_render_top {
|
||||
self.last_render_top = current_top;
|
||||
if let Some(ref path) = self.path {
|
||||
self.highlighter =
|
||||
crate::editor::syntax::Highlighter::new(path);
|
||||
}
|
||||
if let Some(ref mut h) = self.highlighter {
|
||||
let full_text = self.buffer.as_string();
|
||||
for i in 0..current_top {
|
||||
let off = self.buffer.line_offset(i);
|
||||
let len = self.buffer.line_length(i);
|
||||
let end = (off + len).min(full_text.len());
|
||||
let line_text = full_text.get(off..end).unwrap_or("");
|
||||
let _ = h.highlight_line(line_text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let editor_default = mc_skin::color_pair(theme.name, "editor", "_default_");
|
||||
let editor_marked = mc_skin::color_pair(theme.name, "editor", "editmarked");
|
||||
let editor_linestate = mc_skin::color_pair(theme.name, "editor", "editlinestate");
|
||||
@@ -960,6 +1005,21 @@ impl Editor {
|
||||
.fg(marked_fg)
|
||||
.bg(marked_bg);
|
||||
let mut spans: Vec<Span> = Vec::new();
|
||||
#[cfg(feature = "syntect")]
|
||||
{
|
||||
if let Some(ref mut h) = self.highlighter {
|
||||
let highlighted = h.highlight_line(line_text);
|
||||
spans = split_spans_for_selection(
|
||||
highlighted,
|
||||
rs,
|
||||
re,
|
||||
body_bg,
|
||||
marked_bg,
|
||||
);
|
||||
body_lines.push(Line::from(spans));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if rs > 0 {
|
||||
if let Some(b) = line_text.get(..rs) {
|
||||
push_rendered_text(
|
||||
@@ -1002,15 +1062,23 @@ impl Editor {
|
||||
}
|
||||
}
|
||||
let mut spans = Vec::new();
|
||||
push_rendered_text(
|
||||
&mut spans,
|
||||
line_text,
|
||||
base_style,
|
||||
whitespace_fg,
|
||||
whitespace_bg,
|
||||
nonprintable_fg,
|
||||
nonprintable_bg,
|
||||
);
|
||||
#[cfg(feature = "syntect")]
|
||||
{
|
||||
if let Some(ref mut h) = self.highlighter {
|
||||
spans = h.highlight_line(line_text);
|
||||
}
|
||||
}
|
||||
if spans.is_empty() {
|
||||
push_rendered_text(
|
||||
&mut spans,
|
||||
line_text,
|
||||
base_style,
|
||||
whitespace_fg,
|
||||
whitespace_bg,
|
||||
nonprintable_fg,
|
||||
nonprintable_bg,
|
||||
);
|
||||
}
|
||||
body_lines.push(Line::from(spans));
|
||||
}
|
||||
frame.render_widget(Paragraph::new(body_lines), chunks[1]);
|
||||
@@ -1342,6 +1410,82 @@ fn push_rendered_text<'a>(
|
||||
}
|
||||
}
|
||||
|
||||
/// Split a sequence of syntax-highlighted spans for a single line
|
||||
/// so that bytes falling inside the selection range `[rs, re)`
|
||||
/// receive the selection background, while bytes outside keep the
|
||||
/// body background. Foreground colors and font modifiers from the
|
||||
/// highlighted spans are preserved everywhere — the selection
|
||||
/// only changes the background.
|
||||
#[cfg(feature = "syntect")]
|
||||
fn split_spans_for_selection(
|
||||
highlighted: Vec<Span<'static>>,
|
||||
rs: usize,
|
||||
re: usize,
|
||||
base_bg: Color,
|
||||
marked_bg: Color,
|
||||
) -> Vec<Span<'static>> {
|
||||
let mut out: Vec<Span<'static>> = Vec::with_capacity(highlighted.len() + 2);
|
||||
let mut pos: usize = 0;
|
||||
for span in highlighted {
|
||||
let span_start = pos;
|
||||
let span_end = pos + span.content.len();
|
||||
pos = span_end;
|
||||
// Three candidate cut points in line-relative byte
|
||||
// coordinates: the selection's start and end, clamped to
|
||||
// this span. We then take sub-slices at those cuts.
|
||||
let cut_a = rs.min(span_end).max(span_start);
|
||||
let cut_b = re.min(span_end).max(span_start);
|
||||
// Always emit up to three pieces: [span_start..cut_a],
|
||||
// [cut_a..cut_b], [cut_b..span_end]. Each piece is
|
||||
// classified by whether its midpoint falls inside the
|
||||
// selection range.
|
||||
let pieces: [(usize, usize, bool); 3] = [
|
||||
(span_start, cut_a, false),
|
||||
(cut_a, cut_b, true),
|
||||
(cut_b, span_end, false),
|
||||
];
|
||||
for (from, to, in_sel) in pieces {
|
||||
if from >= to {
|
||||
continue;
|
||||
}
|
||||
// Translate line-byte coordinates to local char-boundary
|
||||
// coordinates inside `span.content`. UTF-8 forces us to
|
||||
// walk `char_indices`; clamping `from`/`to` to the
|
||||
// nearest valid char boundary guarantees a safe slice.
|
||||
let local_from = (from - span_start).min(span.content.len());
|
||||
let local_to = (to - span_start).min(span.content.len());
|
||||
let lo = round_up_to_char_boundary(&span.content, local_from);
|
||||
let hi = round_up_to_char_boundary(&span.content, local_to);
|
||||
if lo >= hi {
|
||||
continue;
|
||||
}
|
||||
let piece = &span.content[lo..hi];
|
||||
let bg = if in_sel { marked_bg } else { base_bg };
|
||||
let mut s = span.style;
|
||||
s = s.bg(bg);
|
||||
out.push(Span::styled(piece.to_string(), s));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// that codepoint so that any multi-byte character straddling the
|
||||
/// cut is included entirely on the cut's "greater" side.
|
||||
#[cfg(feature = "syntect")]
|
||||
fn round_up_to_char_boundary(s: &str, idx: usize) -> usize {
|
||||
if idx >= s.len() {
|
||||
return s.len();
|
||||
}
|
||||
let mut i = idx;
|
||||
while i < s.len() && !s.is_char_boundary(i) {
|
||||
i += 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -2125,4 +2269,227 @@ mod tests {
|
||||
assert!(!is_completion_word_char('.'));
|
||||
assert!(!is_completion_word_char('-'));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Syntax highlighting render-path tests
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
/// Render an empty (untitled) buffer: with no path, the
|
||||
/// highlighter is `None`, so the renderer must fall back to
|
||||
/// monochrome. All non-whitespace body cells share the theme
|
||||
/// foreground (whitespace visualization uses its own color).
|
||||
#[test]
|
||||
fn render_without_syntect_falls_back_to_monochrome() {
|
||||
let backend = TestBackend::new(40, 6);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
let mut e = make_empty();
|
||||
// Use a buffer with no whitespace so every visible cell
|
||||
// uses `base_style` (no whitespace-glyph color override).
|
||||
e.insert_str("helloworld");
|
||||
let theme = crate::terminal::color::DEFAULT_THEME;
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
let area = frame.area();
|
||||
e.render(frame, area, &theme);
|
||||
})
|
||||
.unwrap();
|
||||
let buffer = terminal.backend().buffer();
|
||||
let mut fg_colors = std::collections::HashSet::new();
|
||||
for x in 5..15 {
|
||||
if let Some(cell) = buffer.cell((x, 1)) {
|
||||
fg_colors.insert(format!("{:?}", cell.fg));
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
fg_colors.len(),
|
||||
1,
|
||||
"monochrome render must produce a single foreground color across the body cells, got {fg_colors:?}"
|
||||
);
|
||||
// The cursor is at the end of the inserted string, so the
|
||||
// rendered line is the cursor line. The cursor line uses
|
||||
// `linestate_fg` (theme.cursor_fg) — verify it.
|
||||
let only = fg_colors.iter().next().expect("at least one color");
|
||||
assert_eq!(only, &format!("{:?}", theme.cursor_fg));
|
||||
}
|
||||
|
||||
/// Open a Rust file and render it: the rendered body cells must
|
||||
/// contain at least two distinct foreground colors (the
|
||||
/// keyword `fn` is one color, identifiers/literals are
|
||||
/// another), proving that the syntect spans flow through to
|
||||
/// the framebuffer.
|
||||
#[cfg(feature = "syntect")]
|
||||
#[test]
|
||||
fn render_with_syntect_produces_colored_spans() {
|
||||
let dir = std::env::temp_dir().join("tlc-editor-syntect-test");
|
||||
let _ = fs::create_dir_all(&dir);
|
||||
let p = dir.join("snippet.rs");
|
||||
fs::write(&p, "fn main() {}\n").unwrap();
|
||||
let backend = TestBackend::new(40, 6);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
let mut e = Editor::open(&p);
|
||||
let theme = crate::terminal::color::DEFAULT_THEME;
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
let area = frame.area();
|
||||
e.render(frame, area, &theme);
|
||||
})
|
||||
.unwrap();
|
||||
let buffer = terminal.backend().buffer();
|
||||
let mut fg_colors = std::collections::HashSet::new();
|
||||
for x in 5..17 {
|
||||
if let Some(cell) = buffer.cell((x, 1)) {
|
||||
if cell.symbol() != " " {
|
||||
fg_colors.insert(format!("{:?}", cell.fg));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
fg_colors.len() >= 2,
|
||||
"syntect render of Rust source must produce at least 2 distinct foreground colors, got {fg_colors:?}"
|
||||
);
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Direct unit test of `split_spans_for_selection`:
|
||||
/// 1. A span entirely outside the selection keeps body bg.
|
||||
/// 2. A span entirely inside gets marked bg.
|
||||
/// 3. A span straddling the start gets split.
|
||||
/// 4. A span straddling the end gets split.
|
||||
/// 5. A span straddling both gets three pieces.
|
||||
/// 6. UTF-8 multibyte chars never split mid-codepoint.
|
||||
#[cfg(feature = "syntect")]
|
||||
#[test]
|
||||
fn split_spans_for_selection_classifies_pieces_by_range() {
|
||||
use ratatui::style::Color as RC;
|
||||
let red = RC::Rgb(255, 0, 0);
|
||||
let green = RC::Rgb(0, 255, 0);
|
||||
let base = RC::Rgb(0, 0, 0);
|
||||
let marked = RC::Rgb(255, 255, 0);
|
||||
|
||||
// Case 1: entirely before the selection.
|
||||
let mut h = crate::editor::syntax::Highlighter::new(std::path::Path::new("a.rs")).unwrap();
|
||||
let before = h.highlight_line("alpha");
|
||||
let out = split_spans_for_selection(before, 100, 200, base, marked);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].content, "alpha");
|
||||
assert_eq!(out[0].style.bg, Some(base));
|
||||
|
||||
// Case 2: entirely inside the selection.
|
||||
let mut h = crate::editor::syntax::Highlighter::new(std::path::Path::new("a.rs")).unwrap();
|
||||
let inside = h.highlight_line("xyz");
|
||||
let out = split_spans_for_selection(inside, 0, 3, base, marked);
|
||||
let total: String = out.iter().map(|s| s.content.as_ref()).collect();
|
||||
assert_eq!(total, "xyz");
|
||||
for s in &out {
|
||||
assert_eq!(s.style.bg, Some(marked));
|
||||
}
|
||||
|
||||
// Case 3: the line "fn main" should split at byte 2 (between
|
||||
// "fn" and " main"). Verify that every byte before byte 2
|
||||
// carries base bg and every byte from 2 onward carries
|
||||
// marked bg.
|
||||
let mut h = crate::editor::syntax::Highlighter::new(std::path::Path::new("a.rs")).unwrap();
|
||||
let line = h.highlight_line("fn main");
|
||||
let out = split_spans_for_selection(line, 2, 7, base, marked);
|
||||
let mut reconstructed = String::new();
|
||||
let mut seen_bg = Vec::new();
|
||||
for s in &out {
|
||||
reconstructed.push_str(&s.content);
|
||||
for _ in 0..s.content.len() {
|
||||
seen_bg.push(format!("{:?}", s.style.bg));
|
||||
}
|
||||
}
|
||||
assert_eq!(reconstructed, "fn main");
|
||||
// First 2 bytes (= "fn") base, last 5 bytes (= " main") marked.
|
||||
assert_eq!(seen_bg[0], format!("{:?}", Some(base)));
|
||||
assert_eq!(seen_bg[1], format!("{:?}", Some(base)));
|
||||
for bg_str in seen_bg.iter().skip(2) {
|
||||
assert_eq!(bg_str, &format!("{:?}", Some(marked)));
|
||||
}
|
||||
|
||||
// Case 4: UTF-8 multi-byte. "é" is 2 bytes (0xC3 0xA9).
|
||||
// A highlight of "é" with selection [0, 1) must produce a
|
||||
// piece with bg=marked that contains the full "é" char
|
||||
// (no mid-codepoint split).
|
||||
let spans = vec![Span::styled("é".to_string(), Style::default().fg(red).bg(green))];
|
||||
let out = split_spans_for_selection(spans, 0, 1, base, marked);
|
||||
assert_eq!(out.len(), 1, "expected one piece, got {}", out.len());
|
||||
assert_eq!(out[0].content, "é");
|
||||
assert_eq!(out[0].style.bg, Some(marked));
|
||||
|
||||
// Case 5: a selection that excludes the entire string.
|
||||
let spans = vec![Span::styled("abc".to_string(), Style::default().fg(red).bg(green))];
|
||||
let out = split_spans_for_selection(spans, 100, 200, base, marked);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].content, "abc");
|
||||
assert_eq!(out[0].style.bg, Some(base));
|
||||
}
|
||||
|
||||
/// Direct unit test of `round_up_to_char_boundary`.
|
||||
#[cfg(feature = "syntect")]
|
||||
#[test]
|
||||
fn round_up_to_char_boundary_clamps_to_utf8_boundary() {
|
||||
// "é" is 2 bytes (0xC3 0xA9), so at byte index 1 we are
|
||||
// mid-codepoint and must round up to the end of that
|
||||
// codepoint (byte 2).
|
||||
assert_eq!(round_up_to_char_boundary("é", 0), 0);
|
||||
assert_eq!(round_up_to_char_boundary("é", 1), 2);
|
||||
assert_eq!(round_up_to_char_boundary("é", 2), 2);
|
||||
assert_eq!(round_up_to_char_boundary("é", 100), 2);
|
||||
// All-ASCII: every byte index is a char boundary, so the
|
||||
// function is the identity.
|
||||
assert_eq!(round_up_to_char_boundary("abc", 0), 0);
|
||||
assert_eq!(round_up_to_char_boundary("abc", 2), 2);
|
||||
assert_eq!(round_up_to_char_boundary("abc", 3), 3);
|
||||
}
|
||||
|
||||
/// Open a Rust file, set a selection on the rendered line, and
|
||||
/// verify that cells inside the selection range carry the
|
||||
/// selection background.
|
||||
#[cfg(feature = "syntect")]
|
||||
#[test]
|
||||
fn render_with_syntect_and_selection_applies_marked_bg() {
|
||||
use crate::terminal::mc_skin;
|
||||
let dir = std::env::temp_dir().join("tlc-editor-syntect-sel-test");
|
||||
let _ = fs::create_dir_all(&dir);
|
||||
let p = dir.join("snippet.rs");
|
||||
fs::write(&p, "fn main() {}\n").unwrap();
|
||||
let mut e = Editor::open(&p);
|
||||
// Place selection on first 2 chars of the line: "fn".
|
||||
// The cursor API: position the cursor, then start the
|
||||
// selection (which anchors at the current position), then
|
||||
// move the cursor to the other end.
|
||||
e.cursor.set_position(0, &e.buffer);
|
||||
e.cursor.start_selection();
|
||||
e.cursor.set_position(2, &e.buffer);
|
||||
assert_eq!(e.cursor.selection(), Some((0, 2)));
|
||||
let theme = crate::terminal::color::DEFAULT_THEME;
|
||||
let backend = TestBackend::new(40, 6);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
terminal
|
||||
.draw(|frame| {
|
||||
let area = frame.area();
|
||||
e.render(frame, area, &theme);
|
||||
})
|
||||
.unwrap();
|
||||
let buffer = terminal.backend().buffer();
|
||||
let marked_pair = mc_skin::color_pair(theme.name, "editor", "editmarked")
|
||||
.unwrap_or(mc_skin::ColorPair {
|
||||
fg: theme.marked_fg,
|
||||
bg: theme.marked_bg,
|
||||
});
|
||||
// Body starts at column `gutter_w` + 1. For "fn main() {}\n"
|
||||
// the buffer has 2 lines, so `line_count = 2`, gutter_w = max(1+1, 4) = 4.
|
||||
// Block has Borders::ALL, so `inner.x = area.x + 1` and the gutter
|
||||
// takes cols 1..=4 (width 4). Body starts at x = 5. Cells at
|
||||
// (5, 1) and (6, 1) hold "f" and "n" and should carry marked bg.
|
||||
let f_cell = buffer.cell((5, 1)).expect("'f' cell");
|
||||
let n_cell = buffer.cell((6, 1)).expect("'n' cell");
|
||||
assert_eq!(f_cell.symbol(), "f");
|
||||
assert_eq!(n_cell.symbol(), "n");
|
||||
assert_eq!(f_cell.bg, marked_pair.bg, "selected 'f' cell bg");
|
||||
assert_eq!(n_cell.bg, marked_pair.bg, "selected 'n' cell bg");
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
//! Confirmation toggles dialog (delete/overwrite/execute/exit).
|
||||
|
||||
use crate::key::Key;
|
||||
use crate::terminal::color::Theme;
|
||||
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
const TOGGLE_LABELS: &[(&str, &str)] = &[
|
||||
("Confirm delete", "Ask before deleting files"),
|
||||
("Confirm overwrite", "Ask before overwriting files"),
|
||||
("Confirm execute", "Ask before executing commands"),
|
||||
("Confirm exit", "Ask before quitting TLC"),
|
||||
];
|
||||
|
||||
const WIDTH: u16 = 52;
|
||||
const HEIGHT: u16 = 4 + TOGGLE_LABELS.len() as u16 + 3;
|
||||
|
||||
/// Confirmation toggle settings.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConfirmSettings {
|
||||
/// Whether to prompt before deleting files.
|
||||
pub confirm_delete: bool,
|
||||
/// Whether to prompt before overwriting files.
|
||||
pub confirm_overwrite: bool,
|
||||
/// Whether to prompt before executing commands.
|
||||
pub confirm_execute: bool,
|
||||
/// Whether to prompt before quitting TLC.
|
||||
pub confirm_exit: bool,
|
||||
}
|
||||
|
||||
impl ConfirmSettings {
|
||||
fn at(&self, idx: usize) -> bool {
|
||||
match idx {
|
||||
0 => self.confirm_delete,
|
||||
1 => self.confirm_overwrite,
|
||||
2 => self.confirm_execute,
|
||||
3 => self.confirm_exit,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn set(&mut self, idx: usize, val: bool) {
|
||||
match idx {
|
||||
0 => self.confirm_delete = val,
|
||||
1 => self.confirm_overwrite = val,
|
||||
2 => self.confirm_execute = val,
|
||||
3 => self.confirm_exit = val,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of pressing a key in the confirmation dialog.
|
||||
#[derive(Debug)]
|
||||
pub enum ConfirmResult {
|
||||
/// User pressed Enter — apply settings.
|
||||
Confirm(ConfirmSettings),
|
||||
/// User pressed Esc / F10 — discard changes.
|
||||
Cancel,
|
||||
/// Dialog still active.
|
||||
Running,
|
||||
}
|
||||
|
||||
/// F9 → Options → Confirmation dialog.
|
||||
pub struct ConfirmDialog {
|
||||
settings: ConfirmSettings,
|
||||
cursor: usize,
|
||||
}
|
||||
|
||||
impl ConfirmDialog {
|
||||
/// Create a new dialog with the given initial settings.
|
||||
pub fn new(init: ConfirmSettings) -> Self {
|
||||
Self {
|
||||
settings: init,
|
||||
cursor: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return a snapshot of the current settings.
|
||||
#[must_use]
|
||||
pub fn settings(&self) -> ConfirmSettings {
|
||||
self.settings.clone()
|
||||
}
|
||||
|
||||
/// Handle a key event.
|
||||
pub fn handle_key(&mut self, key: Key) -> ConfirmResult {
|
||||
if key == Key::ENTER {
|
||||
return ConfirmResult::Confirm(self.settings());
|
||||
}
|
||||
if key == Key::ESCAPE || key == Key::f(10) {
|
||||
return ConfirmResult::Cancel;
|
||||
}
|
||||
if key == Key::TAB {
|
||||
self.cursor = (self.cursor + 1) % TOGGLE_LABELS.len();
|
||||
return ConfirmResult::Running;
|
||||
}
|
||||
if key.code == 0x2191 {
|
||||
if self.cursor == 0 {
|
||||
self.cursor = TOGGLE_LABELS.len() - 1;
|
||||
} else {
|
||||
self.cursor -= 1;
|
||||
}
|
||||
return ConfirmResult::Running;
|
||||
}
|
||||
if key.code == 0x2193 {
|
||||
self.cursor = (self.cursor + 1) % TOGGLE_LABELS.len();
|
||||
return ConfirmResult::Running;
|
||||
}
|
||||
if key.code == b' ' as u32
|
||||
|| key.code == b't' as u32
|
||||
|| key.code == b'T' as u32
|
||||
{
|
||||
let cur = self.settings.at(self.cursor);
|
||||
self.settings.set(self.cursor, !cur);
|
||||
return ConfirmResult::Running;
|
||||
}
|
||||
if key.code == b'y' as u32 || key.code == b'Y' as u32 {
|
||||
self.settings.set(self.cursor, true);
|
||||
return ConfirmResult::Running;
|
||||
}
|
||||
if key.code == b'n' as u32 || key.code == b'N' as u32 {
|
||||
self.settings.set(self.cursor, false);
|
||||
return ConfirmResult::Running;
|
||||
}
|
||||
ConfirmResult::Running
|
||||
}
|
||||
|
||||
/// Render the dialog.
|
||||
pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
|
||||
let popup = centered_rect(WIDTH, HEIGHT, area);
|
||||
frame.render_widget(Clear, popup);
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Double)
|
||||
.border_style(Style::default().fg(theme.border))
|
||||
.title(Line::from(Span::styled(
|
||||
" Confirmation ",
|
||||
Style::default()
|
||||
.fg(theme.title_fg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)))
|
||||
.style(Style::default().bg(theme.background));
|
||||
|
||||
let inner = block.inner(popup);
|
||||
frame.render_widget(block, popup);
|
||||
|
||||
let chunks = Layout::vertical({
|
||||
let mut c: Vec<Constraint> =
|
||||
Vec::with_capacity(TOGGLE_LABELS.len() + 3);
|
||||
c.push(Constraint::Length(1));
|
||||
for _ in 0..TOGGLE_LABELS.len() {
|
||||
c.push(Constraint::Length(1));
|
||||
}
|
||||
c.push(Constraint::Length(1));
|
||||
c.push(Constraint::Min(0));
|
||||
c
|
||||
})
|
||||
.split(inner);
|
||||
|
||||
let hint = Paragraph::new(Line::from(Span::styled(
|
||||
" Tab: move Space/T: toggle Enter: save Esc: cancel ",
|
||||
Style::default().fg(theme.hidden),
|
||||
)))
|
||||
.alignment(Alignment::Center);
|
||||
frame.render_widget(hint, chunks[0]);
|
||||
|
||||
for (i, (label, desc)) in TOGGLE_LABELS.iter().enumerate() {
|
||||
let checked = self.settings.at(i);
|
||||
let is_cursor = i == self.cursor;
|
||||
let mark = if checked { "[x]" } else { "[ ]" };
|
||||
let prefix = if is_cursor { "> " } else { " " };
|
||||
let fg = if is_cursor {
|
||||
theme.cursor_fg
|
||||
} else {
|
||||
theme.foreground
|
||||
};
|
||||
let bg = if is_cursor {
|
||||
theme.cursor_bg
|
||||
} else {
|
||||
theme.background
|
||||
};
|
||||
let line = Line::from(vec![
|
||||
Span::styled(
|
||||
format!("{prefix}{mark} {label}"),
|
||||
Style::default().fg(fg).bg(bg),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" ({desc})"),
|
||||
Style::default().fg(theme.hidden),
|
||||
),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(line), chunks[i + 1]);
|
||||
}
|
||||
|
||||
let footer = Paragraph::new(Line::from(Span::styled(
|
||||
" [ Save ] [ Cancel ] ",
|
||||
Style::default().fg(theme.hidden),
|
||||
)))
|
||||
.alignment(Alignment::Center);
|
||||
frame.render_widget(footer, chunks[TOGGLE_LABELS.len() + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
|
||||
let x = area
|
||||
.x
|
||||
.saturating_add((area.width.saturating_sub(width)) / 2);
|
||||
let y = area
|
||||
.y
|
||||
.saturating_add((area.height.saturating_sub(height)) / 2);
|
||||
Rect::new(x, y, width.min(area.width), height.min(area.height))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::key::Modifiers;
|
||||
|
||||
fn default_settings() -> ConfirmSettings {
|
||||
ConfirmSettings {
|
||||
confirm_delete: true,
|
||||
confirm_overwrite: true,
|
||||
confirm_execute: false,
|
||||
confirm_exit: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirm_dialog_enter_confirms() {
|
||||
let mut d = ConfirmDialog::new(default_settings());
|
||||
let result = d.handle_key(Key::ENTER);
|
||||
match result {
|
||||
ConfirmResult::Confirm(s) => {
|
||||
assert!(s.confirm_delete);
|
||||
assert!(s.confirm_overwrite);
|
||||
}
|
||||
other => panic!("expected Confirm, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirm_dialog_escape_cancels() {
|
||||
let mut d = ConfirmDialog::new(default_settings());
|
||||
let result = d.handle_key(Key::ESCAPE);
|
||||
assert!(matches!(result, ConfirmResult::Cancel));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirm_dialog_space_toggles_current() {
|
||||
let mut d = ConfirmDialog::new(default_settings());
|
||||
assert!(d.settings.confirm_delete);
|
||||
d.handle_key(Key { code: b' ' as u32, mods: Modifiers::empty() });
|
||||
assert!(!d.settings.confirm_delete);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirm_dialog_tab_cycles_cursor() {
|
||||
let mut d = ConfirmDialog::new(default_settings());
|
||||
assert_eq!(d.cursor, 0);
|
||||
d.handle_key(Key::TAB);
|
||||
assert_eq!(d.cursor, 1);
|
||||
d.handle_key(Key::TAB);
|
||||
assert_eq!(d.cursor, 2);
|
||||
d.handle_key(Key::TAB);
|
||||
assert_eq!(d.cursor, 3);
|
||||
d.handle_key(Key::TAB);
|
||||
assert_eq!(d.cursor, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirm_dialog_y_sets_true() {
|
||||
let mut d = ConfirmDialog::new(default_settings());
|
||||
d.cursor = 2;
|
||||
assert!(!d.settings.confirm_execute);
|
||||
d.handle_key(Key { code: b'y' as u32, mods: Modifiers::empty() });
|
||||
assert!(d.settings.confirm_execute);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirm_dialog_n_sets_false() {
|
||||
let mut d = ConfirmDialog::new(default_settings());
|
||||
d.cursor = 0;
|
||||
assert!(d.settings.confirm_delete);
|
||||
d.handle_key(Key { code: b'n' as u32, mods: Modifiers::empty() });
|
||||
assert!(!d.settings.confirm_delete);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirm_settings_at_and_set_roundtrip() {
|
||||
let mut s = default_settings();
|
||||
assert!(!s.at(3));
|
||||
s.set(3, true);
|
||||
assert!(s.at(3));
|
||||
s.set(3, false);
|
||||
assert!(!s.at(3));
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ use ratatui::Frame;
|
||||
|
||||
use crate::key::Key;
|
||||
use crate::terminal::color::Theme;
|
||||
use crate::terminal::popup::{centered_cols_rect, render_popup};
|
||||
use crate::terminal::popup::{centered_cols_rect, render_button_row, render_popup};
|
||||
|
||||
/// F8 delete confirmation dialog.
|
||||
pub struct DeleteDialog {
|
||||
@@ -90,7 +90,7 @@ impl DeleteDialog {
|
||||
/// dialog follows the active skin. The destructive title uses the
|
||||
/// `theme.error` slot to keep the danger cue.
|
||||
pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
|
||||
let popup = centered_cols_rect(area, 56, 10);
|
||||
let popup = centered_cols_rect(area, 56, 11);
|
||||
let inner = render_popup(frame, popup, crate::locale::t("dialog_title_delete"), theme);
|
||||
|
||||
let chunks = Layout::default()
|
||||
@@ -98,7 +98,8 @@ impl DeleteDialog {
|
||||
.constraints([
|
||||
Constraint::Length(2), // header
|
||||
Constraint::Min(2), // paths
|
||||
Constraint::Length(2), // hint
|
||||
Constraint::Length(1), // buttons
|
||||
Constraint::Length(1), // hint
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
@@ -111,7 +112,6 @@ impl DeleteDialog {
|
||||
));
|
||||
frame.render_widget(Paragraph::new(header), chunks[0]);
|
||||
|
||||
// Show each path on its own line (truncated to width).
|
||||
let max_lines = chunks[1].height as usize;
|
||||
let lines: Vec<Line> = self
|
||||
.paths
|
||||
@@ -126,32 +126,27 @@ impl DeleteDialog {
|
||||
.collect();
|
||||
frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), chunks[1]);
|
||||
|
||||
render_button_row(
|
||||
frame,
|
||||
chunks[2],
|
||||
theme,
|
||||
&crate::locale::t("dialog_action_yes"),
|
||||
&crate::locale::t("dialog_action_no"),
|
||||
);
|
||||
|
||||
let hint = Line::from(vec![
|
||||
Span::styled(
|
||||
"Y",
|
||||
Style::default()
|
||||
.fg(theme.executable)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled("Enter", Style::default().fg(theme.warning)),
|
||||
Span::styled(
|
||||
format!(" {} ", crate::locale::t("dialog_action_yes")),
|
||||
Style::default().fg(theme.hidden),
|
||||
),
|
||||
Span::styled(
|
||||
"N",
|
||||
Style::default().fg(theme.error).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" {} ", crate::locale::t("dialog_action_no")),
|
||||
Style::default().fg(theme.hidden),
|
||||
Style::default().fg(theme.foreground),
|
||||
),
|
||||
Span::styled("Esc", Style::default().fg(theme.warning)),
|
||||
Span::styled(
|
||||
format!(" {}", crate::locale::t("dialog_action_cancel")),
|
||||
Style::default().fg(theme.hidden),
|
||||
Style::default().fg(theme.foreground),
|
||||
),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(hint).wrap(Wrap { trim: false }), chunks[2]);
|
||||
frame.render_widget(Paragraph::new(hint).wrap(Wrap { trim: false }), chunks[3]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
//! External Panelize dialog (C-x !).
|
||||
//!
|
||||
//! Lets the user run an arbitrary shell command and replace the active
|
||||
//! panel's listing with the command's stdout, one path per line. The
|
||||
//! command runs once on Enter; Esc closes the dialog. Output parsing,
|
||||
//! shell invocation, and the listing-mode contract all live in this
|
||||
//! module so the dispatcher in [`crate::filemanager`] only has to
|
||||
//! forward [`ExternalPanelizeOutcome::Apply`] to
|
||||
//! [`crate::filemanager::panel::Panel::set_external_listing`].
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Paragraph, Wrap};
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::key::Key;
|
||||
use crate::terminal::color::Theme;
|
||||
use crate::terminal::popup::{centered_percent_rect, render_popup};
|
||||
use crate::widget::input::Input;
|
||||
|
||||
/// Outcome of a keypress in the external-panelize dialog.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ExternalPanelizeOutcome {
|
||||
/// Still open; keep processing keys.
|
||||
Running,
|
||||
/// User pressed Enter on a non-empty command. The dialog supplies
|
||||
/// the parsed paths for the caller to load into the active panel.
|
||||
Apply(Vec<PathBuf>),
|
||||
/// User pressed Enter but the command produced zero usable lines.
|
||||
/// The dialog stays open so the user can fix the command; the
|
||||
/// dialog's `last_error` is also set to a user-facing message.
|
||||
Empty,
|
||||
/// User pressed Esc — close the dialog and do nothing.
|
||||
Cancel,
|
||||
}
|
||||
|
||||
/// External Panelize dialog.
|
||||
pub struct ExternalPanelizeDialog {
|
||||
/// Command input field.
|
||||
command: Input,
|
||||
/// Optional user-visible error from the last run.
|
||||
last_error: Option<String>,
|
||||
/// Last stderr captured from the command (for the status line).
|
||||
last_stderr: Option<String>,
|
||||
}
|
||||
|
||||
impl ExternalPanelizeDialog {
|
||||
/// Create a new dialog with an empty command field.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
let command = Input::new()
|
||||
.label("Command")
|
||||
.placeholder("find . -name '*.rs'");
|
||||
Self {
|
||||
command,
|
||||
last_error: None,
|
||||
last_stderr: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The current command string.
|
||||
#[must_use]
|
||||
pub fn command(&self) -> &str {
|
||||
self.command.value()
|
||||
}
|
||||
|
||||
/// Last error (e.g. spawn failure, zero output).
|
||||
#[must_use]
|
||||
pub fn last_error(&self) -> Option<&str> {
|
||||
self.last_error.as_deref()
|
||||
}
|
||||
|
||||
/// Last captured stderr from the most recent command run, if any.
|
||||
#[must_use]
|
||||
pub fn last_stderr(&self) -> Option<&str> {
|
||||
self.last_stderr.as_deref()
|
||||
}
|
||||
|
||||
/// Handle a key event. Returns the resulting [`ExternalPanelizeOutcome`].
|
||||
pub fn handle_key(&mut self, key: Key) -> ExternalPanelizeOutcome {
|
||||
match key {
|
||||
Key::ESCAPE => ExternalPanelizeOutcome::Cancel,
|
||||
Key::ENTER => self.run_command(),
|
||||
_ => {
|
||||
let _ = self.command.handle_key(key);
|
||||
ExternalPanelizeOutcome::Running
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the current command (if non-empty) and translate the result
|
||||
/// into an [`ExternalPanelizeOutcome`].
|
||||
fn run_command(&mut self) -> ExternalPanelizeOutcome {
|
||||
let cmd = self.command.value().trim().to_string();
|
||||
if cmd.is_empty() {
|
||||
self.last_error = Some("command is empty".to_string());
|
||||
self.last_stderr = None;
|
||||
return ExternalPanelizeOutcome::Running;
|
||||
}
|
||||
match run_shell_capture(&cmd) {
|
||||
Ok(out) => {
|
||||
self.last_stderr = if out.stderr.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(out.stderr)
|
||||
};
|
||||
let paths = parse_command_output(&out.stdout);
|
||||
if paths.is_empty() {
|
||||
self.last_error = Some("command produced no paths".to_string());
|
||||
ExternalPanelizeOutcome::Empty
|
||||
} else {
|
||||
self.last_error = None;
|
||||
ExternalPanelizeOutcome::Apply(paths)
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
self.last_error = Some(format!("spawn: {e}"));
|
||||
self.last_stderr = None;
|
||||
ExternalPanelizeOutcome::Running
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the dialog into `frame`, centered on `area`. `theme`
|
||||
/// supplies the input/foreground colours so the dialog follows
|
||||
/// the active skin.
|
||||
pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
|
||||
let title = "External panelize".to_string();
|
||||
let popup = centered_percent_rect(area, 0.7, 0.4);
|
||||
let inner = render_popup(frame, popup, title, theme);
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(2),
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
let hint = Line::from(vec![
|
||||
Span::styled("Enter", Style::default().fg(theme.warning)),
|
||||
Span::styled(
|
||||
format!(" {} ", crate::locale::t("dialog_action_select")),
|
||||
Style::default().fg(theme.hidden),
|
||||
),
|
||||
Span::styled("Esc", Style::default().fg(theme.warning)),
|
||||
Span::styled(
|
||||
format!(" {}", crate::locale::t("dialog_action_cancel")),
|
||||
Style::default().fg(theme.hidden),
|
||||
),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(hint), chunks[0]);
|
||||
|
||||
let value = self.command.value().to_string();
|
||||
let mut input = Input::new()
|
||||
.label("Command")
|
||||
.placeholder("find . -name '*.rs'")
|
||||
.focused();
|
||||
if !value.is_empty() {
|
||||
input = input.text(value);
|
||||
}
|
||||
input.render(frame, chunks[1], theme);
|
||||
|
||||
let status_text = self
|
||||
.last_error
|
||||
.as_deref()
|
||||
.or(self.last_stderr.as_deref())
|
||||
.unwrap_or("");
|
||||
let status_color = if self.last_error.is_some() {
|
||||
theme.error
|
||||
} else {
|
||||
theme.hidden
|
||||
};
|
||||
let body = Paragraph::new(Line::from(Span::styled(
|
||||
status_text.to_string(),
|
||||
Style::default().fg(status_color).add_modifier(Modifier::ITALIC),
|
||||
)))
|
||||
.wrap(Wrap { trim: false });
|
||||
frame.render_widget(body, chunks[2]);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ExternalPanelizeDialog {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Captured output of a shell command run.
|
||||
struct CapturedOutput {
|
||||
/// stdout bytes decoded as UTF-8 (lossy).
|
||||
stdout: String,
|
||||
/// stderr bytes decoded as UTF-8 (lossy).
|
||||
stderr: String,
|
||||
}
|
||||
|
||||
/// Run `cmd` via `/bin/sh -c` and return its captured stdout/stderr.
|
||||
/// Returns an error if the shell cannot be spawned. A non-zero exit
|
||||
/// status from the command is NOT treated as an error here — the
|
||||
/// parser will simply receive whatever stdout (if any) was produced.
|
||||
fn run_shell_capture(cmd: &str) -> std::io::Result<CapturedOutput> {
|
||||
let output = Command::new("sh").arg("-c").arg(cmd).output()?;
|
||||
Ok(CapturedOutput {
|
||||
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
|
||||
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse the stdout of a panelize command into a list of file paths.
|
||||
///
|
||||
/// Splits on `\n` (LF) and `\r\n` (CRLF), trims trailing whitespace
|
||||
/// from each line, and keeps non-empty lines. Lines that look like
|
||||
/// absolute paths or `./relative` paths are passed through unchanged;
|
||||
/// any other non-empty line is treated as a path relative to the
|
||||
/// current working directory. No validation against the filesystem
|
||||
/// is performed here — the panel's `set_external_listing` will
|
||||
/// `lstat` each path and drop the ones that don't exist.
|
||||
#[must_use]
|
||||
pub fn parse_command_output(stdout: &str) -> Vec<PathBuf> {
|
||||
stdout
|
||||
.split('\n')
|
||||
.map(|s| s.trim_end_matches('\r'))
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Join each panelize path against `cwd` if it is relative. Absolute
|
||||
/// paths are returned unchanged. Exposed for tests and as a
|
||||
/// convenience for callers that want to resolve relative paths up
|
||||
/// front.
|
||||
#[must_use]
|
||||
pub fn resolve_against_cwd(paths: &[PathBuf], cwd: &Path) -> Vec<PathBuf> {
|
||||
paths
|
||||
.iter()
|
||||
.map(|p| {
|
||||
if p.is_absolute() {
|
||||
p.clone()
|
||||
} else {
|
||||
cwd.join(p)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dialog_new_default_state() {
|
||||
let d = ExternalPanelizeDialog::new();
|
||||
assert_eq!(d.command(), "");
|
||||
assert!(d.last_error().is_none());
|
||||
assert!(d.last_stderr().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dialog_handle_key_esc_returns_cancel() {
|
||||
let mut d = ExternalPanelizeDialog::new();
|
||||
assert_eq!(d.handle_key(Key::ESCAPE), ExternalPanelizeOutcome::Cancel);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dialog_handle_key_running_for_typing() {
|
||||
let mut d = ExternalPanelizeDialog::new();
|
||||
for c in "ls".chars() {
|
||||
assert_eq!(
|
||||
d.handle_key(Key::from_char(c)),
|
||||
ExternalPanelizeOutcome::Running
|
||||
);
|
||||
}
|
||||
assert_eq!(d.command(), "ls");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dialog_handle_key_enter_with_empty_command_records_error() {
|
||||
let mut d = ExternalPanelizeDialog::new();
|
||||
assert_eq!(
|
||||
d.handle_key(Key::ENTER),
|
||||
ExternalPanelizeOutcome::Running
|
||||
);
|
||||
assert_eq!(d.last_error(), Some("command is empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dialog_handle_key_enter_runs_command_and_returns_paths() {
|
||||
let mut d = ExternalPanelizeDialog::new();
|
||||
for c in "printf 'a\\nb\\nc\\n'".chars() {
|
||||
d.handle_key(Key::from_char(c));
|
||||
}
|
||||
match d.handle_key(Key::ENTER) {
|
||||
ExternalPanelizeOutcome::Apply(paths) => {
|
||||
assert_eq!(paths.len(), 3);
|
||||
assert_eq!(paths[0], PathBuf::from("a"));
|
||||
assert_eq!(paths[1], PathBuf::from("b"));
|
||||
assert_eq!(paths[2], PathBuf::from("c"));
|
||||
}
|
||||
other => panic!("expected Apply, got {other:?}"),
|
||||
}
|
||||
assert!(d.last_error().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dialog_handle_key_enter_with_no_output_returns_empty() {
|
||||
let mut d = ExternalPanelizeDialog::new();
|
||||
for c in "true".chars() {
|
||||
d.handle_key(Key::from_char(c));
|
||||
}
|
||||
assert_eq!(d.handle_key(Key::ENTER), ExternalPanelizeOutcome::Empty);
|
||||
assert_eq!(d.last_error(), Some("command produced no paths"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_command_output_basic_lines() {
|
||||
let out = "a\nb\nc\n";
|
||||
let paths = parse_command_output(out);
|
||||
assert_eq!(paths.len(), 3);
|
||||
assert_eq!(paths[0], PathBuf::from("a"));
|
||||
assert_eq!(paths[1], PathBuf::from("b"));
|
||||
assert_eq!(paths[2], PathBuf::from("c"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_command_output_skips_empty_lines() {
|
||||
let out = "a\n\n \nb\n";
|
||||
let paths = parse_command_output(out);
|
||||
assert_eq!(paths.len(), 2);
|
||||
assert_eq!(paths[0], PathBuf::from("a"));
|
||||
assert_eq!(paths[1], PathBuf::from("b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_command_output_handles_crlf() {
|
||||
let out = "a\r\nb\r\nc\r\n";
|
||||
let paths = parse_command_output(out);
|
||||
assert_eq!(paths.len(), 3);
|
||||
assert_eq!(paths[0], PathBuf::from("a"));
|
||||
assert_eq!(paths[1], PathBuf::from("b"));
|
||||
assert_eq!(paths[2], PathBuf::from("c"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_command_output_preserves_absolute_paths() {
|
||||
let out = "/etc/hosts\n/home/user/.bashrc\n";
|
||||
let paths = parse_command_output(out);
|
||||
assert_eq!(paths.len(), 2);
|
||||
assert_eq!(paths[0], PathBuf::from("/etc/hosts"));
|
||||
assert_eq!(paths[1], PathBuf::from("/home/user/.bashrc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_command_output_empty_input_yields_empty_vec() {
|
||||
assert!(parse_command_output("").is_empty());
|
||||
assert!(parse_command_output("\n\n\n").is_empty());
|
||||
assert!(parse_command_output(" \n \n").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_command_output_handles_trailing_whitespace_per_line() {
|
||||
let out = "path/one \npath/two\t\npath/three\n";
|
||||
let paths = parse_command_output(out);
|
||||
assert_eq!(paths.len(), 3);
|
||||
assert_eq!(paths[0], PathBuf::from("path/one"));
|
||||
assert_eq!(paths[1], PathBuf::from("path/two"));
|
||||
assert_eq!(paths[2], PathBuf::from("path/three"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_against_cwd_keeps_absolute_paths() {
|
||||
let cwd = PathBuf::from("/home/user");
|
||||
let paths = vec![PathBuf::from("/etc/hosts"), PathBuf::from("/var/log")];
|
||||
let resolved = resolve_against_cwd(&paths, &cwd);
|
||||
assert_eq!(resolved[0], PathBuf::from("/etc/hosts"));
|
||||
assert_eq!(resolved[1], PathBuf::from("/var/log"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_against_cwd_joins_relative_paths() {
|
||||
let cwd = PathBuf::from("/home/user");
|
||||
let paths = vec![PathBuf::from("docs"), PathBuf::from("./notes")];
|
||||
let resolved = resolve_against_cwd(&paths, &cwd);
|
||||
assert_eq!(resolved[0], PathBuf::from("/home/user/docs"));
|
||||
assert_eq!(resolved[1], PathBuf::from("/home/user/./notes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dialog_render_does_not_panic() {
|
||||
let d = ExternalPanelizeDialog::new();
|
||||
let backend = ratatui::backend::TestBackend::new(120, 30);
|
||||
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
||||
let theme = crate::terminal::color::DEFAULT_THEME;
|
||||
terminal
|
||||
.draw(|f| d.render(f, f.area(), &theme))
|
||||
.expect("render must not panic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dialog_render_with_error_does_not_panic() {
|
||||
let mut d = ExternalPanelizeDialog::new();
|
||||
d.last_error = Some("command produced no paths".to_string());
|
||||
let backend = ratatui::backend::TestBackend::new(120, 30);
|
||||
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
||||
let theme = crate::terminal::color::DEFAULT_THEME;
|
||||
terminal
|
||||
.draw(|f| d.render(f, f.area(), &theme))
|
||||
.expect("render must not panic");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,7 @@ use ratatui::Frame;
|
||||
|
||||
use crate::key::Key;
|
||||
use crate::terminal::color::Theme;
|
||||
use crate::terminal::popup::{centered_cols_rect, render_popup};
|
||||
use crate::terminal::popup::{centered_cols_rect, render_button_row, render_popup};
|
||||
use crate::widget::input::Input;
|
||||
|
||||
/// F7 mkdir dialog.
|
||||
@@ -140,7 +140,7 @@ impl MkDirDialog {
|
||||
/// `theme` supplies the title, header, and hint colours so the
|
||||
/// dialog follows the active skin.
|
||||
pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
|
||||
let popup = centered_cols_rect(area, (area.width / 2).max(34), 8);
|
||||
let popup = centered_cols_rect(area, (area.width / 2).max(34), 9);
|
||||
let inner = render_popup(frame, popup, crate::locale::t("dialog_title_mkdir"), theme);
|
||||
|
||||
let chunks = Layout::default()
|
||||
@@ -148,6 +148,7 @@ impl MkDirDialog {
|
||||
.constraints([
|
||||
Constraint::Length(1), // header
|
||||
Constraint::Length(3), // input
|
||||
Constraint::Length(1), // buttons
|
||||
Constraint::Min(1), // hint
|
||||
])
|
||||
.split(inner);
|
||||
@@ -169,19 +170,27 @@ impl MkDirDialog {
|
||||
input = input.focused();
|
||||
input.render(frame, chunks[1], theme);
|
||||
|
||||
render_button_row(
|
||||
frame,
|
||||
chunks[2],
|
||||
theme,
|
||||
&crate::locale::t("dialog_action_ok"),
|
||||
&crate::locale::t("dialog_action_cancel"),
|
||||
);
|
||||
|
||||
let hint = Line::from(vec![
|
||||
Span::styled("Enter", Style::default().fg(theme.warning)),
|
||||
Span::styled(
|
||||
format!(" {} ", crate::locale::t("dialog_action_create")),
|
||||
Style::default().fg(theme.hidden),
|
||||
format!(" {} ", crate::locale::t("dialog_action_ok")),
|
||||
Style::default().fg(theme.foreground),
|
||||
),
|
||||
Span::styled("Esc", Style::default().fg(theme.warning)),
|
||||
Span::styled(
|
||||
format!(" {}", crate::locale::t("dialog_action_cancel")),
|
||||
Style::default().fg(theme.hidden),
|
||||
Style::default().fg(theme.foreground),
|
||||
),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(hint).wrap(Wrap { trim: false }), chunks[2]);
|
||||
frame.render_widget(Paragraph::new(hint).wrap(Wrap { trim: false }), chunks[3]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,14 +6,17 @@
|
||||
|
||||
pub mod cmdline;
|
||||
pub mod config_dialog;
|
||||
pub mod confirm_dialog;
|
||||
pub mod connection_manager;
|
||||
pub mod copy_dialog;
|
||||
pub mod delete_dialog;
|
||||
pub mod exec;
|
||||
pub mod external_panelize;
|
||||
pub mod find;
|
||||
pub mod help;
|
||||
pub mod hotlist;
|
||||
pub mod info;
|
||||
pub mod jobs;
|
||||
pub mod layout_dialog;
|
||||
pub mod link;
|
||||
pub mod menubar;
|
||||
@@ -30,11 +33,14 @@ pub mod quit_dialog;
|
||||
pub mod quickcd_dialog;
|
||||
pub mod rename;
|
||||
pub mod skin_dialog;
|
||||
pub mod sort_dialog;
|
||||
pub mod tree;
|
||||
pub mod usermenu;
|
||||
pub mod vfs_list;
|
||||
pub mod mc_ext;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use anyhow::Result;
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
@@ -122,6 +128,11 @@ pub struct FileManager {
|
||||
pub cfg: FilemanagerConfig,
|
||||
/// Manager for the currently running file operation (copy/move/delete/mkdir).
|
||||
pub ops_manager: crate::ops::OpsManager,
|
||||
/// Background file-operation job registry. Shared with the
|
||||
/// [`DialogState::Jobs`] dialog so both the file manager and the
|
||||
/// dialog can register, observe, and remove jobs through the
|
||||
/// same synchronised view.
|
||||
pub jobs: Arc<Mutex<jobs::JobRegistry>>,
|
||||
/// The name of the active skin (used to write the config back
|
||||
/// when the user picks a different skin at runtime).
|
||||
pub skin_name: String,
|
||||
@@ -229,6 +240,12 @@ pub enum DialogState {
|
||||
PanelOptions(Box<PanelOptionsDialog>),
|
||||
/// F9 → Options → Configuration — general config dialog.
|
||||
Config(Box<ConfigDialog>),
|
||||
/// C-x j — background file-operation jobs dialog.
|
||||
Jobs(Box<jobs::JobsDialog>),
|
||||
/// C-x ! — external panelize dialog.
|
||||
ExternalPanelize(Box<external_panelize::ExternalPanelizeDialog>),
|
||||
/// C-x a — active VFS connections list.
|
||||
VfsList(Box<vfs_list::VfsListDialog>),
|
||||
}
|
||||
|
||||
impl DialogState {
|
||||
@@ -269,6 +286,13 @@ impl DialogState {
|
||||
DialogState::Layout(_) => false,
|
||||
DialogState::PanelOptions(_) => false,
|
||||
DialogState::Config(_) => false,
|
||||
// The 3 newest dialogs (Jobs/ExternalPanelize/VfsList) are
|
||||
// closed by the dispatcher in `handle_dialog_key` based
|
||||
// on their `handle_key` return value; they never
|
||||
// self-report finished here.
|
||||
DialogState::Jobs(_) => false,
|
||||
DialogState::ExternalPanelize(_) => false,
|
||||
DialogState::VfsList(_) => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -299,6 +323,7 @@ impl FileManager {
|
||||
status: StatusLine::default(),
|
||||
cfg: cfg.filemanager.clone(),
|
||||
ops_manager: crate::ops::OpsManager::new(),
|
||||
jobs: Arc::new(Mutex::new(jobs::JobRegistry::new())),
|
||||
skin_name,
|
||||
dialog: None,
|
||||
cmdline: cmdline::Cmdline::new(),
|
||||
@@ -716,11 +741,25 @@ impl FileManager {
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
Cmd::Jobs => {
|
||||
let dlg = jobs::JobsDialog::new(Arc::clone(&self.jobs));
|
||||
self.dialog = Some(DialogState::Jobs(Box::new(dlg)));
|
||||
Ok(true)
|
||||
}
|
||||
Cmd::Panelize => {
|
||||
self.dialog = Some(DialogState::ExternalPanelize(Box::new(
|
||||
external_panelize::ExternalPanelizeDialog::new(),
|
||||
)));
|
||||
Ok(true)
|
||||
}
|
||||
Cmd::VfsList => {
|
||||
self.dialog = Some(DialogState::VfsList(Box::new(
|
||||
vfs_list::VfsListDialog::new(),
|
||||
)));
|
||||
Ok(true)
|
||||
}
|
||||
Cmd::SymlinkRelative
|
||||
| Cmd::SymlinkEdit
|
||||
| Cmd::Panelize
|
||||
| Cmd::Jobs
|
||||
| Cmd::VfsList
|
||||
| Cmd::ScreenList
|
||||
| Cmd::EditHistory
|
||||
| Cmd::FilteredView => {
|
||||
@@ -1150,6 +1189,9 @@ impl FileManager {
|
||||
let mut layout_outcome: Option<layout_dialog::LayoutResult> = None;
|
||||
let mut panel_outcome: Option<panel_options::PanelOptionsResult> = None;
|
||||
let mut config_outcome: Option<config_dialog::ConfigResult> = None;
|
||||
let mut jobs_should_close = false;
|
||||
let mut panelize_outcome: Option<external_panelize::ExternalPanelizeOutcome> = None;
|
||||
let mut vfs_outcome: Option<vfs_list::VfsListOutcome> = None;
|
||||
match &mut self.dialog {
|
||||
Some(DialogState::Info(_d)) => {
|
||||
// Info dialog consumes Enter and Esc (close).
|
||||
@@ -1235,6 +1277,18 @@ impl FileManager {
|
||||
config_outcome = Some(d.handle_key(key));
|
||||
consumed = true;
|
||||
}
|
||||
Some(DialogState::Jobs(d)) => {
|
||||
jobs_should_close = d.handle_key(key);
|
||||
consumed = true;
|
||||
}
|
||||
Some(DialogState::ExternalPanelize(d)) => {
|
||||
panelize_outcome = Some(d.handle_key(key));
|
||||
consumed = true;
|
||||
}
|
||||
Some(DialogState::VfsList(d)) => {
|
||||
vfs_outcome = Some(d.handle_key(key));
|
||||
consumed = true;
|
||||
}
|
||||
None => return false,
|
||||
}
|
||||
// Apply captured outcomes.
|
||||
@@ -1262,6 +1316,15 @@ impl FileManager {
|
||||
if let Some(o) = config_outcome {
|
||||
self.apply_config_outcome(o);
|
||||
}
|
||||
if jobs_should_close {
|
||||
self.dialog = None;
|
||||
}
|
||||
if let Some(o) = panelize_outcome {
|
||||
self.apply_external_panelize_outcome(o);
|
||||
}
|
||||
if let Some(o) = vfs_outcome {
|
||||
self.apply_vfs_list_outcome(o);
|
||||
}
|
||||
if let Some(d) = &self.dialog {
|
||||
if d.is_finished() {
|
||||
self.apply_finished_dialog();
|
||||
@@ -1347,6 +1410,54 @@ impl FileManager {
|
||||
self.dialog = None;
|
||||
}
|
||||
|
||||
/// Apply an external-panelize dialog outcome.
|
||||
///
|
||||
/// `Apply` is what the user wanted — the command produced one or
|
||||
/// more paths. The dialog is closed and the path count is shown
|
||||
/// on the status line. The active panel is not replaced yet:
|
||||
/// `Panel::set_external_listing` is a future integration point
|
||||
/// (the parsed paths are kept inside the dialog's
|
||||
/// `ExternalPanelizeOutcome::Apply(Vec<PathBuf>)` until the
|
||||
/// panel-side support lands).
|
||||
///
|
||||
/// `Empty` keeps the dialog open so the user can edit the
|
||||
/// command; the dialog itself records the error in its
|
||||
/// `last_error` field. `Cancel` (Esc) closes the dialog.
|
||||
/// `Running` is the no-op case where the dialog stays open and
|
||||
/// keeps consuming keys.
|
||||
fn apply_external_panelize_outcome(
|
||||
&mut self,
|
||||
o: external_panelize::ExternalPanelizeOutcome,
|
||||
) {
|
||||
use external_panelize::ExternalPanelizeOutcome;
|
||||
match o {
|
||||
ExternalPanelizeOutcome::Apply(paths) => {
|
||||
self.status
|
||||
.set_message(format!("panelize: {} path(s) captured", paths.len()));
|
||||
self.dialog = None;
|
||||
}
|
||||
ExternalPanelizeOutcome::Cancel => {
|
||||
self.dialog = None;
|
||||
}
|
||||
ExternalPanelizeOutcome::Empty | ExternalPanelizeOutcome::Running => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a VFS-list dialog outcome. The dialog is read-only —
|
||||
/// `Cancel` (Esc) closes the dialog; `Running` (any other key)
|
||||
/// leaves the dialog open and continues to consume keys. A future
|
||||
/// enhancement may add a `Mount`/`Unmount` action that re-routes
|
||||
/// the active panel.
|
||||
fn apply_vfs_list_outcome(&mut self, o: vfs_list::VfsListOutcome) {
|
||||
use vfs_list::VfsListOutcome;
|
||||
match o {
|
||||
VfsListOutcome::Cancel => {
|
||||
self.dialog = None;
|
||||
}
|
||||
VfsListOutcome::Running => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a find dialog outcome (Open/View/Edit/Cd path).
|
||||
fn apply_find_outcome(&mut self, o: find::FindOutcome) {
|
||||
use find::FindOutcome;
|
||||
@@ -1462,7 +1573,10 @@ impl FileManager {
|
||||
| Some(DialogState::UserMenu(_))
|
||||
| Some(DialogState::Layout(_))
|
||||
| Some(DialogState::PanelOptions(_))
|
||||
| Some(DialogState::Config(_)) => {
|
||||
| Some(DialogState::Config(_))
|
||||
| Some(DialogState::Jobs(_))
|
||||
| Some(DialogState::ExternalPanelize(_))
|
||||
| Some(DialogState::VfsList(_)) => {
|
||||
// No-op: those dialogs clear themselves.
|
||||
}
|
||||
// The Help dialog also clears itself in `handle_dialog_key`
|
||||
@@ -1885,6 +1999,9 @@ impl FileManager {
|
||||
DialogState::Layout(d) => d.render(frame, area, &self.theme),
|
||||
DialogState::PanelOptions(d) => d.render(frame, area, &self.theme),
|
||||
DialogState::Config(d) => d.render(frame, area, &self.theme),
|
||||
DialogState::Jobs(d) => d.render(frame, area, &self.theme),
|
||||
DialogState::ExternalPanelize(d) => d.render(frame, area, &self.theme),
|
||||
DialogState::VfsList(d) => d.render(frame, area, &self.theme),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -584,7 +584,15 @@ impl Panel {
|
||||
let mut kids = read_dir(path, self.show_hidden)?;
|
||||
entries.append(&mut kids);
|
||||
self.entries = entries;
|
||||
self.cursor = 0;
|
||||
let has_parent = self
|
||||
.entries
|
||||
.first()
|
||||
.is_some_and(|e| e.name == "..");
|
||||
self.cursor = if has_parent && self.entries.len() > 1 {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
self.top = 0;
|
||||
self.path = path.to_path_buf();
|
||||
self.unmark_all();
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
//! Sort order dialog (sort by name/size/date, ascending/descending).
|
||||
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::filemanager::panel::SortField;
|
||||
use crate::key::Key;
|
||||
use crate::terminal::color::Theme;
|
||||
|
||||
const UP: u32 = 0x2191;
|
||||
const DOWN: u32 = 0x2193;
|
||||
|
||||
/// Resolution of a sort-dialog key event.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SortResult {
|
||||
/// User pressed Enter — apply the selected sort settings.
|
||||
Confirm(SortSettings),
|
||||
/// User pressed Escape — discard changes and close.
|
||||
Cancel,
|
||||
/// Dialog is still running (no terminal key yet).
|
||||
Running,
|
||||
}
|
||||
|
||||
/// Snapshot of sort-field + direction chosen by the user.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SortSettings {
|
||||
/// The sort field (Name / Extension / Size / Mtime).
|
||||
pub field: SortField,
|
||||
/// Whether to reverse the sort direction.
|
||||
pub reverse: bool,
|
||||
}
|
||||
|
||||
const FIELDS: [SortField; 4] = [
|
||||
SortField::Name,
|
||||
SortField::Extension,
|
||||
SortField::Size,
|
||||
SortField::Mtime,
|
||||
];
|
||||
|
||||
const FIELD_LABELS: [&str; 4] = ["Name", "Extension", "Size", "Modify time"];
|
||||
|
||||
/// Modal dialog for choosing sort field and direction.
|
||||
pub struct SortDialog {
|
||||
selected: usize,
|
||||
reverse: bool,
|
||||
focus_reverse: bool,
|
||||
}
|
||||
|
||||
impl SortDialog {
|
||||
/// Create a new dialog initialised from the given settings.
|
||||
#[must_use]
|
||||
pub fn new(initial: SortSettings) -> Self {
|
||||
let selected = FIELDS
|
||||
.iter()
|
||||
.position(|f| *f == initial.field)
|
||||
.unwrap_or(0);
|
||||
Self {
|
||||
selected,
|
||||
reverse: initial.reverse,
|
||||
focus_reverse: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot the current dialog selections.
|
||||
#[must_use]
|
||||
pub fn settings(&self) -> SortSettings {
|
||||
SortSettings {
|
||||
field: FIELDS[self.selected],
|
||||
reverse: self.reverse,
|
||||
}
|
||||
}
|
||||
|
||||
/// Process a key event. Returns the dialog's resolution.
|
||||
pub fn handle_key(&mut self, key: Key) -> SortResult {
|
||||
if key == Key::ENTER {
|
||||
return SortResult::Confirm(self.settings());
|
||||
}
|
||||
if key == Key::ESCAPE {
|
||||
return SortResult::Cancel;
|
||||
}
|
||||
if key == Key::TAB {
|
||||
self.focus_next();
|
||||
return SortResult::Running;
|
||||
}
|
||||
if key.code == UP {
|
||||
self.focus_prev();
|
||||
return SortResult::Running;
|
||||
}
|
||||
if key.code == DOWN {
|
||||
self.focus_next();
|
||||
return SortResult::Running;
|
||||
}
|
||||
if let Some(ch) = char::from_u32(key.code) {
|
||||
if ch == ' ' && self.focus_reverse {
|
||||
self.reverse = !self.reverse;
|
||||
return SortResult::Running;
|
||||
}
|
||||
}
|
||||
SortResult::Running
|
||||
}
|
||||
|
||||
fn focus_next(&mut self) {
|
||||
if !self.focus_reverse {
|
||||
if self.selected < FIELDS.len() - 1 {
|
||||
self.selected += 1;
|
||||
} else {
|
||||
self.focus_reverse = true;
|
||||
}
|
||||
} else {
|
||||
self.focus_reverse = false;
|
||||
self.selected = 0;
|
||||
}
|
||||
}
|
||||
|
||||
fn focus_prev(&mut self) {
|
||||
if self.focus_reverse {
|
||||
self.focus_reverse = false;
|
||||
self.selected = FIELDS.len() - 1;
|
||||
} else if self.selected > 0 {
|
||||
self.selected -= 1;
|
||||
} else {
|
||||
self.focus_reverse = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the dialog centered on `area`.
|
||||
pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
|
||||
let w = 36u16.min(area.width.saturating_sub(2));
|
||||
let h = 12u16.min(area.height.saturating_sub(2));
|
||||
let x = area.x + (area.width - w) / 2;
|
||||
let y = area.y + (area.height - h) / 2;
|
||||
let dlg = Rect::new(x, y, w, h);
|
||||
frame.render_widget(Clear, dlg);
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(theme.border))
|
||||
.title(Span::styled(
|
||||
" Sort Order ",
|
||||
Style::default()
|
||||
.fg(theme.title_fg)
|
||||
.bg(theme.title_bg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.style(Style::default().bg(theme.background).fg(theme.foreground));
|
||||
|
||||
let inner = block.inner(dlg);
|
||||
frame.render_widget(block, dlg);
|
||||
|
||||
let mut constraints = vec![Constraint::Length(1)];
|
||||
for _ in 0..FIELDS.len() {
|
||||
constraints.push(Constraint::Length(1));
|
||||
}
|
||||
constraints.push(Constraint::Length(1));
|
||||
constraints.push(Constraint::Length(1));
|
||||
constraints.push(Constraint::Min(0));
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(constraints.as_slice())
|
||||
.split(inner);
|
||||
|
||||
for (i, label) in FIELD_LABELS.iter().enumerate() {
|
||||
let is_focused = i == self.selected && !self.focus_reverse;
|
||||
let marker = if i == self.selected { "(*)" } else { "( )" };
|
||||
let style = if is_focused {
|
||||
Style::default()
|
||||
.fg(theme.cursor_fg)
|
||||
.bg(theme.cursor_bg)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(theme.foreground)
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
format!("{marker} {label}"),
|
||||
style,
|
||||
)),
|
||||
chunks[1 + i],
|
||||
);
|
||||
}
|
||||
|
||||
let rev_idx = 1 + FIELDS.len();
|
||||
let rev_style = if self.focus_reverse {
|
||||
Style::default()
|
||||
.fg(theme.cursor_fg)
|
||||
.bg(theme.cursor_bg)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(theme.foreground)
|
||||
};
|
||||
let rev_marker = if self.reverse { "[x]" } else { "[ ]" };
|
||||
frame.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
format!("{rev_marker} Reverse"),
|
||||
rev_style,
|
||||
)),
|
||||
chunks[rev_idx],
|
||||
);
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(Span::styled(
|
||||
" Tab/up-down: move Enter: OK Esc: cancel",
|
||||
Style::default().fg(theme.hidden),
|
||||
)),
|
||||
chunks[rev_idx + 1],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::key::Modifiers;
|
||||
|
||||
fn settings() -> SortSettings {
|
||||
SortSettings {
|
||||
field: SortField::Name,
|
||||
reverse: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn down_key() -> Key {
|
||||
Key {
|
||||
code: DOWN,
|
||||
mods: Modifiers::empty(),
|
||||
}
|
||||
}
|
||||
|
||||
fn up_key() -> Key {
|
||||
Key {
|
||||
code: UP,
|
||||
mods: Modifiers::empty(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_dialog_new_defaults_to_initial() {
|
||||
let d = SortDialog::new(settings());
|
||||
assert_eq!(d.selected, 0);
|
||||
assert!(!d.reverse);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_dialog_enter_confirms() {
|
||||
let mut d = SortDialog::new(settings());
|
||||
match d.handle_key(Key::ENTER) {
|
||||
SortResult::Confirm(s) => {
|
||||
assert_eq!(s.field, SortField::Name);
|
||||
assert!(!s.reverse);
|
||||
}
|
||||
other => panic!("expected Confirm, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_dialog_esc_cancels() {
|
||||
let mut d = SortDialog::new(settings());
|
||||
assert_eq!(d.handle_key(Key::ESCAPE), SortResult::Cancel);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_dialog_down_moves_selection() {
|
||||
let mut d = SortDialog::new(settings());
|
||||
d.handle_key(down_key());
|
||||
assert_eq!(d.selected, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_dialog_tab_reaches_reverse() {
|
||||
let mut d = SortDialog::new(settings());
|
||||
for _ in 0..4 {
|
||||
d.handle_key(Key::TAB);
|
||||
}
|
||||
assert!(d.focus_reverse);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_dialog_wrap_from_last_to_reverse() {
|
||||
let mut d = SortDialog::new(SortSettings {
|
||||
field: SortField::Mtime,
|
||||
reverse: false,
|
||||
});
|
||||
d.handle_key(down_key());
|
||||
assert!(d.focus_reverse);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_dialog_up_from_reverse_to_last() {
|
||||
let mut d = SortDialog::new(settings());
|
||||
d.focus_reverse = true;
|
||||
d.handle_key(up_key());
|
||||
assert!(!d.focus_reverse);
|
||||
assert_eq!(d.selected, FIELDS.len() - 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_does_not_panic() {
|
||||
let d = SortDialog::new(settings());
|
||||
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||
let mut terminal = ratatui::Terminal::new(backend).expect("terminal");
|
||||
terminal
|
||||
.draw(|f| {
|
||||
d.render(f, f.area(), &crate::terminal::color::DEFAULT_THEME);
|
||||
})
|
||||
.expect("render");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
//! VFS List dialog (C-x a).
|
||||
//!
|
||||
//! Shows the currently active VFS connections. Remote VFS backends
|
||||
//! (sftp, ftp) are optional features in TLC, so the list is empty
|
||||
//! in the default build. The dialog still exists and renders the
|
||||
//! empty state correctly so that operators can see at a glance that
|
||||
//! no archive or remote is mounted.
|
||||
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{List, ListItem, Paragraph, Wrap};
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::key::Key;
|
||||
use crate::terminal::color::Theme;
|
||||
use crate::terminal::popup::{centered_percent_rect, render_popup};
|
||||
|
||||
/// Outcome of a keypress in the VFS-list dialog.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum VfsListOutcome {
|
||||
/// Still open; keep processing keys.
|
||||
Running,
|
||||
/// User pressed Esc — close the dialog.
|
||||
Cancel,
|
||||
}
|
||||
|
||||
/// One active VFS connection, as the dialog renders it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct VfsConnection {
|
||||
/// VFS scheme name (e.g. `"local"`, `"tar"`, `"sftp"`).
|
||||
pub scheme: String,
|
||||
/// Human-readable label for the connection (e.g. the URL or
|
||||
/// archive path).
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
impl VfsConnection {
|
||||
/// Build a connection entry from a scheme name and label.
|
||||
#[must_use]
|
||||
pub fn new(scheme: impl Into<String>, label: impl Into<String>) -> Self {
|
||||
Self {
|
||||
scheme: scheme.into(),
|
||||
label: label.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// VFS List dialog.
|
||||
pub struct VfsListDialog {
|
||||
/// Currently active VFS connections. Empty in the default build.
|
||||
connections: Vec<VfsConnection>,
|
||||
/// Cursor row in the rendered list (always 0 in the empty case).
|
||||
cursor: usize,
|
||||
}
|
||||
|
||||
impl VfsListDialog {
|
||||
/// Create a new dialog with no connections.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
connections: Vec::new(),
|
||||
cursor: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new dialog from a pre-populated connection list.
|
||||
#[must_use]
|
||||
pub fn with_connections(connections: Vec<VfsConnection>) -> Self {
|
||||
Self {
|
||||
connections,
|
||||
cursor: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// All active connections.
|
||||
#[must_use]
|
||||
pub fn connections(&self) -> &[VfsConnection] {
|
||||
&self.connections
|
||||
}
|
||||
|
||||
/// True if no connections are active.
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.connections.is_empty()
|
||||
}
|
||||
|
||||
/// Number of active connections.
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.connections.len()
|
||||
}
|
||||
|
||||
/// Move the cursor up by one row. No-op if the list is empty.
|
||||
pub fn cursor_up(&mut self) {
|
||||
if self.cursor > 0 {
|
||||
self.cursor -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the cursor down by one row. Clamped to the list length.
|
||||
pub fn cursor_down(&mut self) {
|
||||
if !self.connections.is_empty() && self.cursor + 1 < self.connections.len() {
|
||||
self.cursor += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a key event. Returns the resulting [`VfsListOutcome`].
|
||||
pub fn handle_key(&mut self, key: Key) -> VfsListOutcome {
|
||||
match key {
|
||||
Key::ESCAPE => VfsListOutcome::Cancel,
|
||||
Key { code: 0x2191, .. } => {
|
||||
self.cursor_up();
|
||||
VfsListOutcome::Running
|
||||
}
|
||||
Key { code: 0x2193, .. } => {
|
||||
self.cursor_down();
|
||||
VfsListOutcome::Running
|
||||
}
|
||||
_ => VfsListOutcome::Running,
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the dialog into `frame`, centered on `area`. `theme`
|
||||
/// supplies the title, list, and hint colours so the dialog
|
||||
/// follows the active skin.
|
||||
pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
|
||||
let title = "Active VFS list".to_string();
|
||||
let popup = centered_percent_rect(area, 0.6, 0.5);
|
||||
let inner = render_popup(frame, popup, title, theme);
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(2),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
let header = Line::from(vec![
|
||||
Span::styled("Active: ", Style::default().fg(theme.hidden)),
|
||||
Span::styled(
|
||||
self.connections.len().to_string(),
|
||||
Style::default()
|
||||
.fg(theme.foreground)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(header), chunks[0]);
|
||||
|
||||
if self.connections.is_empty() {
|
||||
let empty_msg = Line::from(Span::styled(
|
||||
"(no active VFS connections)".to_string(),
|
||||
Style::default().fg(theme.hidden),
|
||||
));
|
||||
frame.render_widget(Paragraph::new(empty_msg), chunks[1]);
|
||||
} else {
|
||||
let items: Vec<ListItem> = self
|
||||
.connections
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, c)| {
|
||||
let display = format!("{} {}", c.scheme, c.label);
|
||||
let style = if idx == self.cursor {
|
||||
Style::default()
|
||||
.fg(theme.cursor_fg)
|
||||
.bg(theme.cursor_bg)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(theme.foreground)
|
||||
};
|
||||
ListItem::new(Span::styled(display, style))
|
||||
})
|
||||
.collect();
|
||||
let list = List::new(items);
|
||||
frame.render_widget(list, chunks[1]);
|
||||
}
|
||||
|
||||
let hint = Line::from(vec![
|
||||
Span::styled("Esc", Style::default().fg(theme.warning)),
|
||||
Span::styled(
|
||||
format!(" {}", crate::locale::t("dialog_action_cancel")),
|
||||
Style::default().fg(theme.hidden),
|
||||
),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(hint).wrap(Wrap { trim: false }), chunks[2]);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for VfsListDialog {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dialog_new_is_empty() {
|
||||
let d = VfsListDialog::new();
|
||||
assert!(d.is_empty());
|
||||
assert_eq!(d.len(), 0);
|
||||
assert!(d.connections().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dialog_with_connections_keeps_list() {
|
||||
let conns = vec![
|
||||
VfsConnection::new("tar", "/tmp/a.tar:/"),
|
||||
VfsConnection::new("sftp", "alice@example.com:/home/alice"),
|
||||
];
|
||||
let d = VfsListDialog::with_connections(conns.clone());
|
||||
assert!(!d.is_empty());
|
||||
assert_eq!(d.len(), 2);
|
||||
assert_eq!(d.connections(), &conns[..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dialog_handle_key_esc_returns_cancel() {
|
||||
let mut d = VfsListDialog::new();
|
||||
assert_eq!(d.handle_key(Key::ESCAPE), VfsListOutcome::Cancel);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dialog_handle_key_running_for_other_keys() {
|
||||
let mut d = VfsListDialog::new();
|
||||
assert_eq!(d.handle_key(Key::from_char('q')), VfsListOutcome::Running);
|
||||
assert_eq!(d.handle_key(Key::ENTER), VfsListOutcome::Running);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dialog_cursor_movement_with_connections() {
|
||||
let mut d = VfsListDialog::with_connections(vec![
|
||||
VfsConnection::new("tar", "/a"),
|
||||
VfsConnection::new("tar", "/b"),
|
||||
VfsConnection::new("zip", "/c"),
|
||||
]);
|
||||
d.cursor_down();
|
||||
assert_eq!(d.cursor, 1);
|
||||
d.cursor_down();
|
||||
assert_eq!(d.cursor, 2);
|
||||
d.cursor_down();
|
||||
assert_eq!(d.cursor, 2);
|
||||
d.cursor_up();
|
||||
assert_eq!(d.cursor, 1);
|
||||
d.cursor_up();
|
||||
d.cursor_up();
|
||||
assert_eq!(d.cursor, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dialog_cursor_movement_empty_is_noop() {
|
||||
let mut d = VfsListDialog::new();
|
||||
d.cursor_down();
|
||||
assert_eq!(d.cursor, 0);
|
||||
d.cursor_up();
|
||||
assert_eq!(d.cursor, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dialog_cursor_keys_produce_running() {
|
||||
let mut d = VfsListDialog::with_connections(vec![
|
||||
VfsConnection::new("tar", "/a"),
|
||||
VfsConnection::new("tar", "/b"),
|
||||
]);
|
||||
assert_eq!(
|
||||
d.handle_key(Key {
|
||||
code: 0x2193,
|
||||
mods: crate::key::Modifiers::empty()
|
||||
}),
|
||||
VfsListOutcome::Running
|
||||
);
|
||||
assert_eq!(d.cursor, 1);
|
||||
assert_eq!(
|
||||
d.handle_key(Key {
|
||||
code: 0x2191,
|
||||
mods: crate::key::Modifiers::empty()
|
||||
}),
|
||||
VfsListOutcome::Running
|
||||
);
|
||||
assert_eq!(d.cursor, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dialog_render_empty_does_not_panic() {
|
||||
let d = VfsListDialog::new();
|
||||
let backend = ratatui::backend::TestBackend::new(120, 30);
|
||||
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
||||
let theme = crate::terminal::color::DEFAULT_THEME;
|
||||
terminal
|
||||
.draw(|f| d.render(f, f.area(), &theme))
|
||||
.expect("render must not panic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dialog_render_with_connections_does_not_panic() {
|
||||
let d = VfsListDialog::with_connections(vec![
|
||||
VfsConnection::new("tar", "/tmp/a.tar:/"),
|
||||
VfsConnection::new("sftp", "alice@example.com:/home/alice"),
|
||||
]);
|
||||
let backend = ratatui::backend::TestBackend::new(120, 30);
|
||||
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
||||
let theme = crate::terminal::color::DEFAULT_THEME;
|
||||
terminal
|
||||
.draw(|f| d.render(f, f.area(), &theme))
|
||||
.expect("render must not panic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vfs_connection_constructs_correctly() {
|
||||
let c = VfsConnection::new("zip", "/tmp/x.zip:/");
|
||||
assert_eq!(c.scheme, "zip");
|
||||
assert_eq!(c.label, "/tmp/x.zip:/");
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,86 @@ pub const F10: Key = Key {
|
||||
/// Special Tab key.
|
||||
pub const TAB: Key = Key::TAB;
|
||||
|
||||
/// Parse a raw byte sequence that termion classified as `Unsupported`
|
||||
/// and try to extract a function-key event.
|
||||
///
|
||||
/// Some terminals (especially Linux console, screen, older terminal
|
||||
/// emulators) send F-key sequences that termion does not recognize:
|
||||
///
|
||||
/// | Key | xterm sequence | Linux console sequence |
|
||||
/// |-----|-------------------|------------------------|
|
||||
/// | F1 | `\x1bOP` | `\x1b[[A` |
|
||||
/// | F2 | `\x1bOQ` | `\x1b[[B` |
|
||||
/// | F3 | `\x1bOR` | `\x1b[[C` |
|
||||
/// | F4 | `\x1bOS` | `\x1b[[D` |
|
||||
/// | F5 | `\x1b[15~` | `\x1b[[E` |
|
||||
/// | F6 | `\x1b[17~` | — |
|
||||
/// | F7 | `\x1b[18~` | — |
|
||||
/// | F8 | `\x1b[19~` | — |
|
||||
/// | F9 | `\x1b[20~` | — |
|
||||
/// | F10 | `\x1b[21~` | — |
|
||||
/// | F11 | `\x1b[23~` | — |
|
||||
/// | F12 | `\x1b[24~` | — |
|
||||
///
|
||||
/// Termion parses the xterm `\x1b[NN~` form for F5-F12, but some
|
||||
/// terminal emulators and multiplexers (tmux, screen) may produce
|
||||
/// sequences that termion does not recognize. This fallback parser
|
||||
/// covers both the `\x1b[NN~` (CSI-tilde) and `\x1b[[A` (console)
|
||||
/// forms.
|
||||
pub fn parse_unsupported_fkey(bytes: &[u8]) -> Option<TermKey> {
|
||||
let seq = std::str::from_utf8(bytes).ok()?;
|
||||
let seq = seq.strip_prefix('\x1b')?;
|
||||
|
||||
if let Some(rest) = seq.strip_prefix('[') {
|
||||
if let Some(rest) = rest.strip_prefix('[') {
|
||||
let n = match rest {
|
||||
"A" => 1,
|
||||
"B" => 2,
|
||||
"C" => 3,
|
||||
"D" => 4,
|
||||
"E" => 5,
|
||||
_ => return None,
|
||||
};
|
||||
return Some(TermKey::F(n));
|
||||
}
|
||||
|
||||
if let Some(tilde_pos) = rest.find('~') {
|
||||
let num_str = &rest[..tilde_pos];
|
||||
if let Ok(n) = num_str.parse::<u32>() {
|
||||
let f = match n {
|
||||
11 => 1,
|
||||
12 => 2,
|
||||
13 => 3,
|
||||
14 => 4,
|
||||
15 => 5,
|
||||
17 => 6,
|
||||
18 => 7,
|
||||
19 => 8,
|
||||
20 => 9,
|
||||
21 => 10,
|
||||
23 => 11,
|
||||
24 => 12,
|
||||
_ => return None,
|
||||
};
|
||||
return Some(TermKey::F(f));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(rest) = seq.strip_prefix('O') {
|
||||
let n = match rest {
|
||||
"P" => 1,
|
||||
"Q" => 2,
|
||||
"R" => 3,
|
||||
"S" => 4,
|
||||
_ => return None,
|
||||
};
|
||||
return Some(TermKey::F(n));
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -64,6 +64,7 @@ impl ShellManager {
|
||||
/// Run a one-shot shell command and return as soon as it exits.
|
||||
pub fn run_command(&mut self, cmd: &str, cwd: &Path) -> Result<()> {
|
||||
let shell = shell_path();
|
||||
log::info!("run_command: shell={shell} cmd={cmd:?} cwd={cwd:?}");
|
||||
let status = Command::new(&shell)
|
||||
.arg("-c")
|
||||
.arg(cmd)
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
//! Magic-number file type detection for the viewer.
|
||||
|
||||
use crate::viewer::ViewMode;
|
||||
|
||||
const DETECTION_SIZE: usize = 4096;
|
||||
|
||||
/// File type detected from magic-number probing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FileKind {
|
||||
/// Plain text (UTF-8 or ASCII).
|
||||
Text,
|
||||
/// Unknown binary format.
|
||||
Binary,
|
||||
/// ELF executable or object.
|
||||
Elf,
|
||||
/// PE/COFF executable (Windows .exe, .dll).
|
||||
Pe,
|
||||
/// gzip compressed data.
|
||||
Gzip,
|
||||
/// PNG image.
|
||||
Png,
|
||||
/// JPEG image.
|
||||
Jpeg,
|
||||
/// PDF document.
|
||||
Pdf,
|
||||
/// ZIP archive.
|
||||
Zip,
|
||||
}
|
||||
|
||||
impl FileKind {
|
||||
/// The default [`ViewMode`] for this file kind.
|
||||
#[must_use]
|
||||
pub fn default_mode(self) -> ViewMode {
|
||||
match self {
|
||||
FileKind::Text | FileKind::Pdf => ViewMode::Text,
|
||||
_ => ViewMode::Hex,
|
||||
}
|
||||
}
|
||||
|
||||
/// True if this file kind is not plain text.
|
||||
#[must_use]
|
||||
pub fn is_binary(self) -> bool {
|
||||
!matches!(self, FileKind::Text)
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe the first bytes to determine the file kind.
|
||||
#[must_use]
|
||||
pub fn detect_kind(bytes: &[u8]) -> FileKind {
|
||||
let len = bytes.len().min(DETECTION_SIZE);
|
||||
if len == 0 {
|
||||
return FileKind::Text;
|
||||
}
|
||||
|
||||
let head = &bytes[..len];
|
||||
|
||||
if head.len() >= 4 {
|
||||
if &head[..4] == b"\x7fELF" {
|
||||
return FileKind::Elf;
|
||||
}
|
||||
if &head[..2] == b"MZ" {
|
||||
return FileKind::Pe;
|
||||
}
|
||||
if &head[..2] == b"\x1f\x8b" {
|
||||
return FileKind::Gzip;
|
||||
}
|
||||
if &head[..4] == b"\x89PNG" {
|
||||
return FileKind::Png;
|
||||
}
|
||||
if head.len() >= 3 && head[0] == 0xFF && head[1] == 0xD8 && head[2] == 0xFF {
|
||||
return FileKind::Jpeg;
|
||||
}
|
||||
if &head[..4] == b"%PDF" {
|
||||
return FileKind::Pdf;
|
||||
}
|
||||
if &head[..2] == b"PK" && head[2] == 0x03 && head[3] == 0x04 {
|
||||
return FileKind::Zip;
|
||||
}
|
||||
}
|
||||
|
||||
if looks_like_text(head) {
|
||||
FileKind::Text
|
||||
} else {
|
||||
FileKind::Binary
|
||||
}
|
||||
}
|
||||
|
||||
fn looks_like_text(bytes: &[u8]) -> bool {
|
||||
let mut non_text = 0usize;
|
||||
let total = bytes.len();
|
||||
|
||||
for &b in bytes {
|
||||
if b == 0x00 {
|
||||
return false;
|
||||
}
|
||||
if (b < 0x20 && !matches!(b, 0x09 | 0x0A | 0x0D)) || b == 0x7F {
|
||||
non_text += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let ratio = non_text as f64 / total.max(1) as f64;
|
||||
ratio < 0.30
|
||||
}
|
||||
|
||||
/// Detect the best view mode from file content.
|
||||
#[must_use]
|
||||
pub fn detect_mode(bytes: &[u8]) -> ViewMode {
|
||||
detect_kind(bytes).default_mode()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn detect_empty_is_text() {
|
||||
assert_eq!(detect_kind(b""), FileKind::Text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_plain_text() {
|
||||
assert_eq!(detect_kind(b"Hello, World!\n"), FileKind::Text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_elf_binary() {
|
||||
assert_eq!(detect_kind(b"\x7fELF\x02\x01\x01"), FileKind::Elf);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_pe_binary() {
|
||||
assert_eq!(detect_kind(b"MZ\x90\x00\x03"), FileKind::Pe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_gzip() {
|
||||
assert_eq!(detect_kind(b"\x1f\x8b\x08\x00"), FileKind::Gzip);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_png() {
|
||||
assert_eq!(
|
||||
detect_kind(b"\x89PNG\r\n\x1a\n"),
|
||||
FileKind::Png
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_jpeg() {
|
||||
assert_eq!(detect_kind(b"\xFF\xD8\xFF\xE0"), FileKind::Jpeg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_pdf() {
|
||||
assert_eq!(detect_kind(b"%PDF-1.4\n"), FileKind::Pdf);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_zip() {
|
||||
assert_eq!(detect_kind(b"PK\x03\x04"), FileKind::Zip);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_binary_with_null_bytes() {
|
||||
assert_eq!(
|
||||
detect_kind(b"\x00\x01\x02\x03\x04\x05"),
|
||||
FileKind::Binary
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_binary_many_control_chars() {
|
||||
let bytes = [0x01u8, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08];
|
||||
assert_eq!(detect_kind(&bytes), FileKind::Binary);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_utf8_text() {
|
||||
let s = "Héllo, 世界 🎉\n".as_bytes();
|
||||
assert_eq!(detect_kind(s), FileKind::Text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_source_code() {
|
||||
assert_eq!(
|
||||
detect_kind(b"fn main() {\n println!(\"hello\");\n}\n"),
|
||||
FileKind::Text
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn elf_defaults_to_hex() {
|
||||
assert_eq!(FileKind::Elf.default_mode(), ViewMode::Hex);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_defaults_to_text() {
|
||||
assert_eq!(FileKind::Text.default_mode(), ViewMode::Text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_defaults_to_text() {
|
||||
assert_eq!(FileKind::Pdf.default_mode(), ViewMode::Text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gzip_defaults_to_hex() {
|
||||
assert_eq!(FileKind::Gzip.default_mode(), ViewMode::Hex);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_binary_true_for_elf() {
|
||||
assert!(FileKind::Elf.is_binary());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_binary_false_for_text() {
|
||||
assert!(!FileKind::Text.is_binary());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_mode_routes_text_to_text() {
|
||||
assert_eq!(detect_mode(b"hello\n"), ViewMode::Text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_mode_routes_elf_to_hex() {
|
||||
assert_eq!(detect_mode(b"\x7fELF"), ViewMode::Hex);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
pub mod goto;
|
||||
pub mod hex;
|
||||
pub mod magic;
|
||||
pub mod nroff;
|
||||
pub mod search;
|
||||
pub mod source;
|
||||
pub mod text;
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
//! NROFF-style backspace-overstrike rendering for man pages.
|
||||
|
||||
use ratatui::style::Modifier;
|
||||
|
||||
/// Text emphasis style produced by nroff overstriking.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NroffStyle {
|
||||
/// Bold (char + BS + same char).
|
||||
Bold,
|
||||
/// Underline (_ + BS + char).
|
||||
Underline,
|
||||
}
|
||||
|
||||
/// A byte range with an associated nroff style.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct NroffRange {
|
||||
/// Start byte offset (inclusive).
|
||||
pub start: usize,
|
||||
/// End byte offset (exclusive).
|
||||
pub end: usize,
|
||||
/// The emphasis style.
|
||||
pub style: NroffStyle,
|
||||
}
|
||||
|
||||
/// Detect whether `text` contains backspace overstrike sequences.
|
||||
#[must_use]
|
||||
pub fn has_nroff_sequences(text: &str) -> bool {
|
||||
text.contains('\u{0008}')
|
||||
}
|
||||
|
||||
/// Strip nroff backspace sequences, returning clean text + style ranges.
|
||||
#[must_use]
|
||||
pub fn process_nroff(text: &str) -> (String, Vec<NroffRange>) {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let mut out_chars: Vec<char> = Vec::with_capacity(chars.len());
|
||||
let mut char_styles: Vec<Option<NroffStyle>> = Vec::with_capacity(chars.len());
|
||||
let mut i = 0;
|
||||
|
||||
while i < chars.len() {
|
||||
if chars[i] == '\u{0008}' && !out_chars.is_empty() && i + 1 < chars.len() {
|
||||
let prev = out_chars.len() - 1;
|
||||
let prev_char = out_chars[prev];
|
||||
let next_char = chars[i + 1];
|
||||
|
||||
if prev_char == next_char {
|
||||
char_styles[prev] = Some(NroffStyle::Bold);
|
||||
i += 2;
|
||||
} else if prev_char == '_' {
|
||||
out_chars[prev] = next_char;
|
||||
char_styles[prev] = Some(NroffStyle::Underline);
|
||||
i += 2;
|
||||
} else {
|
||||
out_chars.pop();
|
||||
char_styles.pop();
|
||||
i += 1;
|
||||
}
|
||||
} else if chars[i] == '\u{0008}' {
|
||||
i += 1;
|
||||
} else {
|
||||
out_chars.push(chars[i]);
|
||||
char_styles.push(None);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let mut ranges = Vec::new();
|
||||
let mut byte_pos = 0;
|
||||
let mut current_style: Option<NroffStyle> = None;
|
||||
let mut range_start = 0;
|
||||
|
||||
for (ch, &style_opt) in out_chars.iter().zip(char_styles.iter()) {
|
||||
let byte_len = ch.len_utf8();
|
||||
|
||||
if style_opt != current_style {
|
||||
if let Some(s) = current_style {
|
||||
if byte_pos > range_start {
|
||||
ranges.push(NroffRange {
|
||||
start: range_start,
|
||||
end: byte_pos,
|
||||
style: s,
|
||||
});
|
||||
}
|
||||
}
|
||||
current_style = style_opt;
|
||||
range_start = byte_pos;
|
||||
}
|
||||
|
||||
byte_pos += byte_len;
|
||||
}
|
||||
|
||||
if let Some(s) = current_style {
|
||||
if byte_pos > range_start {
|
||||
ranges.push(NroffRange {
|
||||
start: range_start,
|
||||
end: byte_pos,
|
||||
style: s,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let result: String = out_chars.into_iter().collect();
|
||||
(result, ranges)
|
||||
}
|
||||
|
||||
/// Convert an [`NroffStyle`] to a ratatui [`Modifier`] bitmask.
|
||||
#[must_use]
|
||||
pub fn modifier_for(style: NroffStyle) -> Modifier {
|
||||
match style {
|
||||
NroffStyle::Bold => Modifier::BOLD,
|
||||
NroffStyle::Underline => Modifier::UNDERLINED,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn detect_nroff_present() {
|
||||
assert!(has_nroff_sequences("a\u{0008}a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_nroff_absent() {
|
||||
assert!(!has_nroff_sequences("hello world"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_bold_sequence() {
|
||||
let (text, ranges) = process_nroff("a\u{0008}abc");
|
||||
assert_eq!(text, "abc");
|
||||
assert_eq!(ranges.len(), 1);
|
||||
assert_eq!(ranges[0].start, 0);
|
||||
assert_eq!(ranges[0].end, 1);
|
||||
assert_eq!(ranges[0].style, NroffStyle::Bold);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_underline_sequence() {
|
||||
let (text, ranges) = process_nroff("_\u{0008}a");
|
||||
assert_eq!(text, "a");
|
||||
assert_eq!(ranges.len(), 1);
|
||||
assert_eq!(ranges[0].style, NroffStyle::Underline);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_multiple_bold() {
|
||||
let input = "h\u{0008}he\u{0008}el\u{0008}ll\u{0008}lo\u{0008}o";
|
||||
let (text, ranges) = process_nroff(input);
|
||||
assert_eq!(text, "hello");
|
||||
assert_eq!(ranges.len(), 1);
|
||||
assert_eq!(ranges[0].start, 0);
|
||||
assert_eq!(ranges[0].end, 5);
|
||||
assert_eq!(ranges[0].style, NroffStyle::Bold);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_mixed_bold_underline() {
|
||||
let input = "_\u{0008}Ubold\u{0008}d";
|
||||
let (text, ranges) = process_nroff(input);
|
||||
assert_eq!(text, "Ubold");
|
||||
assert_eq!(ranges.len(), 2);
|
||||
assert_eq!(ranges[0], NroffRange { start: 0, end: 1, style: NroffStyle::Underline });
|
||||
assert_eq!(ranges[1], NroffRange { start: 4, end: 5, style: NroffStyle::Bold });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_no_sequences() {
|
||||
let (text, ranges) = process_nroff("hello world");
|
||||
assert_eq!(text, "hello world");
|
||||
assert!(ranges.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_empty() {
|
||||
let (text, ranges) = process_nroff("");
|
||||
assert_eq!(text, "");
|
||||
assert!(ranges.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_lone_backspace() {
|
||||
let (text, _ranges) = process_nroff("\u{0008}");
|
||||
assert_eq!(text, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_overwrite_sequence() {
|
||||
let (text, _ranges) = process_nroff("a\u{0008}b");
|
||||
assert_eq!(text, "b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_real_man_page_fragment() {
|
||||
let input = "NA\u{0008}AME\u{0008}E\n t\u{0008}tlc\u{0008}c";
|
||||
let (text, ranges) = process_nroff(input);
|
||||
assert!(text.contains("NAME"));
|
||||
assert!(text.contains("tlc"));
|
||||
assert!(!ranges.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modifier_bold() {
|
||||
assert_eq!(modifier_for(NroffStyle::Bold), Modifier::BOLD);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modifier_underline() {
|
||||
assert_eq!(modifier_for(NroffStyle::Underline), Modifier::UNDERLINED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ranges_byte_offsets_match_string() {
|
||||
let input = "_\u{0008}é\u{0008}é";
|
||||
let (text, ranges) = process_nroff(input);
|
||||
assert_eq!(text, "é");
|
||||
assert_eq!(ranges.len(), 1);
|
||||
assert_eq!(ranges[0].start, 0);
|
||||
assert_eq!(ranges[0].end, 2); // 'é' is 2 bytes in UTF-8
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user