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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user