tlc: phase 21 — F11 user-menu dispatch for editor

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<EditorUserMenu>
    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).
This commit is contained in:
vasilito
2026-06-20 21:14:01 +03:00
parent 9fa019e78a
commit a5cfae5622
4 changed files with 256 additions and 3 deletions
+3 -3
View File
@@ -1,9 +1,9 @@
# Twilight Commander (TLC) — Pure Rust Reimplementation Plan
**Status:** Architecture chosen. Implementation in progress. Phases 08 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.
@@ -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.
@@ -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<crate::editor::menubar::EditorMenuBar>,
/// F2 user-menu session (MC `CK_UserMenu`). `None` when the
/// menu is closed. F2 opens the dialog; Esc closes.
usermenu_session: Option<crate::editor::usermenu::EditorUserMenu>,
}
/// 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());
}
}
@@ -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<EditorUserMenu>` (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
));
}
}