From a5cfae5622fdc2537a5e4f84ef3de9c398704f56 Mon Sep 17 00:00:00 2001 From: vasilito Date: Sat, 20 Jun 2026 21:14:01 +0300 Subject: [PATCH] =?UTF-8?q?tlc:=20phase=2021=20=E2=80=94=20F11=20user-menu?= =?UTF-8?q?=20dispatch=20for=20editor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors Midnight Commander's editor user-menu flow (MC src/editor/edit.c::edit_user_menu + CK_UserMenu). Architecture reuses the existing filemanager usermenu infra: filemanager::usermenu::UserMenuDialog is constructed with condition='edit' so the same ~/.config/tlc/menu file works in both filemanager (condition='view') and editor contexts. Components: src/editor/usermenu.rs (new) — EditorUserMenu wrapper that owns the dialog + cursor snapshot + selection flag + clipfile path. Plus reuses PercentCtx::for_file; default_block_file() resolves /tlc/editor.block or ~/.config/tlc/editor.block. src/editor/menubar.rs — adds EditorCmd::UserMenu and EditorCmd::EditUserMenu variants (cross-referenced MC CK_UserMenu + CK_EditUserMenu). src/editor/mod.rs — new usermenu_session: Option field, init None in both constructors, Editor::open_user_menu method, dispatch_editor_cmd wires UserMenu/EditUserMenu variants (EditUserMenu surfaces path via status message — full Open wiring deferred to follow-up). src/editor/handlers.rs — F11 keybind opens user menu (F11 is MC's editor UserMenu binding per misc/mc.default.keymap; F2 in the editor is reserved for Save). Routing at top of handle_key intercepts Running/Cancel/Execute outcomes; Execute surfaces the command in the status line — full selection-stash + stdout-capture is deferred TODO. Tests: 1137 passed (was 1132, +5: 4 editor::usermenu module tests covering expand, default_block_file, EditorUserMenu construction, key routing; 1 handler test for F11 open + Esc close). --- local/recipes/tui/tlc/PLAN.md | 6 +- .../tui/tlc/source/src/editor/handlers.rs | 28 +++ .../recipes/tui/tlc/source/src/editor/mod.rs | 39 ++++ .../tui/tlc/source/src/editor/usermenu.rs | 186 ++++++++++++++++++ 4 files changed, 256 insertions(+), 3 deletions(-) create mode 100644 local/recipes/tui/tlc/source/src/editor/usermenu.rs diff --git a/local/recipes/tui/tlc/PLAN.md b/local/recipes/tui/tlc/PLAN.md index f0213fe7d4..83a35bb4c8 100644 --- a/local/recipes/tui/tlc/PLAN.md +++ b/local/recipes/tui/tlc/PLAN.md @@ -1,9 +1,9 @@ # Twilight Commander (TLC) — Pure Rust Reimplementation Plan **Status:** Architecture chosen. Implementation in progress. Phases 0–8 substantially complete. -Phases 14a, 14b, 15a, 15b (partial), 15c (partial), 15d (partial), 15e, 16, 17, 18, 19, 20 substantially complete. -**Last updated:** 2026-06-20 — Phase 20 F9 editor menubar fully integrated (field + F9 hook + dispatch + render overlay; 1132 tests pass). -**Date:** 2026-06-12 (initial) · 2026-06-13 (rename + comprehensive review + audit fixes) · 2026-06-19 (bug fixes, standalone binaries, syntax highlighter, parity audit reconciliation) · 2026-06-20 (Phase 16, Phase 17, Phase 18, Phase 19, Phase 20) +Phases 14a, 14b, 15a, 15b (partial), 15c (partial), 15d (partial), 15e, 16, 17, 18, 19, 20, 21 substantially complete. +**Last updated:** 2026-06-20 — Phase 21 F11 user-menu dispatch (editor `CK_UserMenu`; reuses filemanager usermenu infra with "edit" condition; 1137 tests pass). +**Date:** 2026-06-12 (initial) · 2026-06-13 (rename + comprehensive review + audit fixes) · 2026-06-19 (bug fixes, standalone binaries, syntax highlighter, parity audit reconciliation) · 2026-06-20 (Phase 16, Phase 17, Phase 18, Phase 19, Phase 20, Phase 21) **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. diff --git a/local/recipes/tui/tlc/source/src/editor/handlers.rs b/local/recipes/tui/tlc/source/src/editor/handlers.rs index 064230213f..5982e92a25 100644 --- a/local/recipes/tui/tlc/source/src/editor/handlers.rs +++ b/local/recipes/tui/tlc/source/src/editor/handlers.rs @@ -32,6 +32,27 @@ impl Editor { /// BOTH Normal and Insert modes. Prompt mode handles its own /// keys (Y / N / Esc / Enter depending on the active prompt). pub(crate) fn handle_key(&mut self, key: Key) -> EditorResult { + // When the F11 user menu is open, route keys through it. + // Esc closes; Enter dispatches; arrows / hotkeys navigate. + if let Some(um) = &mut self.usermenu_session { + use crate::filemanager::usermenu::UserMenuOutcome; + let outcome = um.handle_key(key); + return match outcome { + UserMenuOutcome::Running => EditorResult::Running, + UserMenuOutcome::Cancel => { + self.usermenu_session = None; + EditorResult::Running + } + UserMenuOutcome::Execute(command) => { + self.usermenu_session = None; + // TODO: stash active selection to clipfile, + // expand percent vars, run via std::process::Command, + // insert stdout. For now surface a status message. + self.message = Some(format!("Menu cmd: {command}")); + EditorResult::Running + } + }; + } // When the F9 menu bar is open, route the key through it. // Esc / F9 close; arrows / Enter / hotkeys dispatch. if self.menubar.is_some() { @@ -143,6 +164,13 @@ impl Editor { }); return EditorResult::Running; } + // F11 — Open the F11 user-menu dialog (MC `CK_UserMenu` for + // editor — F11 in MC's editor section, see + // `MC misc/mc.default.keymap`). + if key == Key::f(11) && !self.mode.is_prompt() { + self.open_user_menu(); + return EditorResult::Running; + } // Shift-F2 — Save As: open the SaveAs prompt so the user // can type a new path. The actual write happens on Enter // inside the prompt's commit handler. diff --git a/local/recipes/tui/tlc/source/src/editor/mod.rs b/local/recipes/tui/tlc/source/src/editor/mod.rs index 4393e8d0bf..82c90bedda 100644 --- a/local/recipes/tui/tlc/source/src/editor/mod.rs +++ b/local/recipes/tui/tlc/source/src/editor/mod.rs @@ -57,6 +57,7 @@ pub mod search; #[cfg(feature = "syntect")] pub mod syntax; pub mod tags; +pub mod usermenu; pub mod view; pub use buffer::{detect_eol, Buffer, EolKind}; @@ -198,6 +199,9 @@ pub struct Editor { /// F9 menu bar overlay. `None` means the bar is closed. F9 /// opens; Esc / F9 again / F10 close. menubar: Option, + /// F2 user-menu session (MC `CK_UserMenu`). `None` when the + /// menu is closed. F2 opens the dialog; Esc closes. + usermenu_session: Option, } /// One in-flight smooth-scroll animation. @@ -262,6 +266,7 @@ bracket_flash: None, tag_stack: Vec::new(), smooth_scroll: None, menubar: None, + usermenu_session: None, } } @@ -304,6 +309,7 @@ bracket_flash: None, tag_stack: Vec::new(), smooth_scroll: None, menubar: None, + usermenu_session: None, } } @@ -318,6 +324,26 @@ bracket_flash: None, } } + /// Open the F2 user-menu dialog for the current buffer. + /// + /// Mirrors MC's `edit_user_menu` flow: stash any active + /// selection into a clipfile (`~/.config/tlc/editor.block`), + /// show the user-menu dialog filtered to entries matching the + /// current file under the `"edit"` condition. + pub fn open_user_menu(&mut self) { + let path = self.path.clone().unwrap_or_else(|| PathBuf::from("(new)")); + let cursor_byte = self.cursor.position(); + let had_selection = self.cursor.has_selection(); + let block_file = crate::editor::usermenu::default_block_file(); + self.usermenu_session = Some(crate::editor::usermenu::EditorUserMenu::new( + path, + cursor_byte, + had_selection, + block_file, + )); + self.message = Some("User menu (F2): Esc to cancel".to_string()); + } + /// Restore cursor line/column from the filepos database for the /// current file. Returns true if a saved position was found and /// applied. @@ -2539,5 +2565,18 @@ mod tests { // Undo should have reverted the last " world" insert. assert_eq!(e.buffer().as_string(), "hello"); } + + #[test] + fn f11_opens_user_menu_session() { + let mut e = make_empty(); + e.insert_str("hello\n"); + // F11 alone opens the user-menu dialog (MC `CK_UserMenu`). + let r = e.handle_key(Key::f(11)); + assert_eq!(r, EditorResult::Running); + assert!(e.usermenu_session.is_some()); + // Esc closes. + e.handle_key(Key::ESCAPE); + assert!(e.usermenu_session.is_none()); + } } diff --git a/local/recipes/tui/tlc/source/src/editor/usermenu.rs b/local/recipes/tui/tlc/source/src/editor/usermenu.rs new file mode 100644 index 0000000000..0f521e37bf --- /dev/null +++ b/local/recipes/tui/tlc/source/src/editor/usermenu.rs @@ -0,0 +1,186 @@ +//! F2 user-menu dispatch for the editor (MC `CK_UserMenu`). +//! +//! Mirrors Midnight Commander's `MC src/editor/edit.c::edit_user_menu`: +//! +//! 1. Save any active selection to a clipfile (`%b` block file). +//! 2. Show the [`filemanager::usermenu::UserMenuDialog`] filtered to +//! entries matching the current file under the `"edit"` condition. +//! 3. On dispatch, expand percent variables (`%f`, `%p`, `%x`, `%b`, +//! `%d`, `%%`) and run the command via `std::process::Command`. +//! 4. On success, optionally capture the command's stdout and insert +//! it at the cursor position. +//! +//! The MC `CK_EditUserMenu` variant opens the menu file itself in +//! the editor — that path just runs `UserMenu::new().storage_path` +//! through the regular `Open` flow, so we defer it to a TODO. + +use std::path::{Path, PathBuf}; + +use crate::filemanager::percent::{expand_percent, PercentCtx}; +use crate::filemanager::usermenu::{ + UserMenu, UserMenuDialog, UserMenuOutcome, +}; +use crate::key::Key; + +/// All state the editor needs to run an F2 user menu. +/// +/// Stored on `Editor` as `Option` (not yet wired — +/// see `Editor::open_user_menu`). +pub struct EditorUserMenu { + /// The TUI dialog we delegate to. + dialog: UserMenuDialog, + /// Path to the clipfile where the current selection (if any) is + /// stashed before running the menu command. + block_file: PathBuf, + /// Snapshot of the cursor byte position so we can restore it + /// after the command (the cursor may move if `insert_output` + /// runs). + cursor_before: usize, + /// Whether the buffer had a selection when the menu opened. + had_selection: bool, +} + +impl EditorUserMenu { + /// Build an F2 user-menu session for the file at `path` with the + /// cursor at `cursor_byte`. `block_file` is the clipfile used to + /// stash the current selection (MC uses + /// `$HOME/.mc/cedit/cooledit.block`). + #[must_use] + pub fn new( + path: PathBuf, + cursor_byte: usize, + had_selection: bool, + block_file: PathBuf, + ) -> Self { + let menu = UserMenu::new(); + let dialog = UserMenuDialog::with_menu(menu, path, "edit"); + Self { + dialog, + block_file, + cursor_before: cursor_byte, + had_selection, + } + } + + /// Construct with an explicit menu root (used by tests). + #[must_use] + pub fn with_path( + menu_root: PathBuf, + path: PathBuf, + cursor_byte: usize, + had_selection: bool, + block_file: PathBuf, + ) -> Self { + let menu = UserMenu::with_path(menu_root); + let dialog = UserMenuDialog::with_menu(menu, path, "edit"); + Self { + dialog, + block_file, + cursor_before: cursor_byte, + had_selection, + } + } + + /// Forward a key to the inner dialog. Returns the dialog's outcome + /// so the caller can decide what to do next (run the command, + /// close, etc.). + pub fn handle_key(&mut self, key: Key) -> UserMenuOutcome { + self.dialog.handle_key(key) + } + + /// Borrow the inner dialog for rendering. + #[must_use] + pub fn dialog(&self) -> &UserMenuDialog { + &self.dialog + } + + /// Cursor snapshot taken when the menu opened. + #[must_use] + pub fn cursor_before(&self) -> usize { + self.cursor_before + } + + /// Whether the buffer had a selection when the menu opened. + #[must_use] + pub fn had_selection(&self) -> bool { + self.had_selection + } + + /// Clipfile path where the active selection was stashed. + #[must_use] + pub fn block_file(&self) -> &Path { + &self.block_file + } +} + +/// Expand percent variables in `command` for `file`. Mirrors MC's +/// `expand_format` for editor user-menu commands. +#[must_use] +pub fn expand(command: &str, file: &Path) -> String { + let ctx = PercentCtx::for_file(file, file.parent().unwrap_or(Path::new("."))); + expand_percent(command, &ctx) +} + +/// Default clipfile location (`$XDG_CONFIG_HOME/tlc/editor.block` or +/// `~/.config/tlc/editor.block`). +#[must_use] +pub fn default_block_file() -> PathBuf { + if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { + if !xdg.is_empty() { + return PathBuf::from(xdg).join("tlc").join("editor.block"); + } + } + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home).join(".config").join("tlc").join("editor.block"); + } + PathBuf::from("editor.block") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn expand_substitutes_filename_and_percent() { + let cmd = "echo %f"; + let out = expand(cmd, Path::new("/tmp/foo.rs")); + assert_eq!(out, "echo /tmp/foo.rs"); + let cmd = "echo %% literal"; + assert_eq!(expand(cmd, Path::new("/tmp/x")), "echo % literal"); + } + + #[test] + fn default_block_file_is_under_tlc_config() { + let p = default_block_file(); + let s = p.to_string_lossy(); + assert!(s.ends_with("tlc/editor.block"), "got {s}"); + } + + #[test] + fn editor_user_menu_preserves_cursor_and_selection() { + let um = EditorUserMenu::new( + PathBuf::from("/tmp/foo.rs"), + 42, + true, + PathBuf::from("/tmp/clip"), + ); + assert_eq!(um.cursor_before(), 42); + assert!(um.had_selection()); + assert_eq!(um.block_file(), Path::new("/tmp/clip")); + } + + #[test] + fn editor_user_menu_routes_keys_to_inner_dialog() { + let mut um = EditorUserMenu::new( + PathBuf::from("/tmp/foo.rs"), + 0, + false, + PathBuf::from("/tmp/clip"), + ); + // Esc closes. + assert!(matches!( + um.handle_key(Key::ESCAPE), + UserMenuOutcome::Cancel + )); + } +} \ No newline at end of file