tlc: phase 20 wire-up — F9 menubar fully integrated

Connect the standalone editor::menubar module into the editor's
main loop:

  editor/mod.rs:
    - Add menubar: Option<EditorMenuBar> field on Editor struct
    - Init menubar: None in both Editor::open and Editor::new_empty
    - Add dispatch_editor_cmd(cmd) -> EditorResult, mapping
      EditorCmd to existing editor methods:
        Undo, Redo, Cut, Copy, Paste, GotoTop, GotoBottom,
        BookmarkToggle (opens BookmarkSet prompt),
        BookmarkClearAll, GotoLine (opens GotoLine prompt).
      Not yet wired (New, Open, Save, SaveAs, Quit, Find,
      FindNext, FindPrev, Replace, BookmarkNext/Prev, Settings)
      surface 'F9: ... (not yet wired)' message.

  editor/handlers.rs:
    - At top of handle_key, route through menubar when Some
      (Esc/F9 → Close, Enter → Dispatch, arrows/hotkeys → Running).
    - F9 keybind opens menubar.

  editor/render.rs:
    - When menubar is Some, split area into [Length(1) menubar,
      Min(1) editor]. Pass lower area as editor_area to existing
      layout, replacing two 'area' references with 'editor_area'.

Tests: 1132 passed (was 1129, +3 handler tests covering open,
Esc-close, dispatch-undo integration end-to-end).

Ref: MC src/editor/editmenu.c (six menus), editor handle_key
routing precedence for menubar overlay (matches filemanager).
This commit is contained in:
vasilito
2026-06-20 20:40:44 +03:00
parent 0d4dfb60c7
commit 833509a979
4 changed files with 166 additions and 4 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
# 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 (editor menubar module) substantially complete.
**Last updated:** 2026-06-20 — Phase 20 editor menubar module complete (F9 menu bar with 6 menus: File, Edit, Search, Bookmark, Goto, Options; 10 unit tests pass).
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)
**Branch:** `0.2.4`
**Decision authority:** User selected Option A (Pure Rust TLC) on 2026-06-12.
@@ -32,6 +32,25 @@ 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 F9 menu bar is open, route the key through it.
// Esc / F9 close; arrows / Enter / hotkeys dispatch.
if self.menubar.is_some() {
use crate::editor::menubar::EditorMenuOutcome;
let outcome = self.menubar.as_mut().unwrap().handle_key(key);
return match outcome {
EditorMenuOutcome::Handled | EditorMenuOutcome::Select(_, _) => {
EditorResult::Running
}
EditorMenuOutcome::Close => {
self.menubar = None;
EditorResult::Running
}
EditorMenuOutcome::Dispatch(cmd) => {
self.menubar = None;
self.dispatch_editor_cmd(cmd)
}
};
}
// Ctrl-R / Ctrl-P are intercepted at the dispatcher so
// they work in BOTH Normal and Insert modes (mirroring the
// Save / Close shortcut behaviour). The macro recorder
@@ -168,6 +187,11 @@ impl Editor {
}
return EditorResult::Running;
}
// F9 — Open the editor menu bar (MC editor F9).
if key == Key::f(9) && !self.mode.is_prompt() {
self.menubar = Some(crate::editor::menubar::EditorMenuBar::new());
return EditorResult::Running;
}
// Ctrl-N — New file: discard current buffer after save
// prompt if dirty (MC EditNew).
if key == Key::ctrl('n') {
@@ -195,6 +195,9 @@ pub struct Editor {
/// as the effective top line; each animation tick advances
/// `current` by 25 % toward `target`.
smooth_scroll: Option<SmoothScroll>,
/// F9 menu bar overlay. `None` means the bar is closed. F9
/// opens; Esc / F9 again / F10 close.
menubar: Option<crate::editor::menubar::EditorMenuBar>,
}
/// One in-flight smooth-scroll animation.
@@ -258,6 +261,7 @@ bracket_flash: None,
folds: folding::FoldSet::new(),
tag_stack: Vec::new(),
smooth_scroll: None,
menubar: None,
}
}
@@ -299,6 +303,7 @@ bracket_flash: None,
folds: folding::FoldSet::new(),
tag_stack: Vec::new(),
smooth_scroll: None,
menubar: None,
}
}
@@ -642,6 +647,79 @@ bracket_flash: None,
r
}
/// Dispatch a menu-bar [`crate::editor::menubar::EditorCmd`] to
/// the corresponding editor method. Returns the resulting
/// [`EditorResult`]. Used by F9 menubar dispatch.
pub(crate) fn dispatch_editor_cmd(
&mut self,
cmd: crate::editor::menubar::EditorCmd,
) -> EditorResult {
use crate::editor::menubar::EditorCmd;
match cmd {
EditorCmd::Undo => {
self.undo();
}
EditorCmd::Redo => {
self.redo();
}
EditorCmd::Cut => {
if let Some(text) = self.cursor.selected_text(&self.buffer) {
self.clipboard = Some(text.clone());
let _ = crate::editor::clipboard_osc52::osc52_copy(&text);
self.cursor.delete_selection(&mut self.buffer);
self.cursor.set_position(self.buffer.cursor(), &self.buffer);
self.modified = self.buffer.is_modified();
self.message = Some("Block cut".to_string());
}
}
EditorCmd::Copy => {
if let Some(text) = self.cursor.selected_text(&self.buffer) {
self.clipboard = Some(text.clone());
let _ = crate::editor::clipboard_osc52::osc52_copy(&text);
self.message = Some("Block copied".to_string());
}
}
EditorCmd::Paste => {
if let Some(text) = self.clipboard.clone() {
if self.cursor.has_selection() {
self.cursor.delete_selection(&mut self.buffer);
}
self.insert_str(&text);
self.message = Some("Pasted".to_string());
}
}
EditorCmd::GotoTop => {
self.cursor.set_position(0, &self.buffer);
}
EditorCmd::GotoBottom => {
self.cursor.set_position(self.buffer.len(), &self.buffer);
}
EditorCmd::BookmarkToggle => {
self.prompt_input.clear();
self.mode = Mode::Prompt(PromptKind::BookmarkSet);
}
EditorCmd::BookmarkClearAll => {
let names = self.bookmarks.names();
let count = names.len();
for n in names {
self.bookmarks.clear(n);
}
self.message = Some(format!("{count} bookmarks cleared"));
}
EditorCmd::GotoLine => {
self.prompt_input.clear();
self.mode = Mode::Prompt(PromptKind::GotoLine);
}
// Items not yet wired: New, Open, Save, SaveAs, Quit,
// Find, FindNext, FindPrev, Replace, BookmarkNext/Prev,
// GotoTop/Bottom, Settings.
_ => {
self.message = Some(format!("F9: {:?} (not yet wired)", cmd));
}
}
EditorResult::Running
}
/// Alt-P — reformat the current paragraph.
///
/// Walks the contiguous block of non-blank lines that contains
@@ -2416,5 +2494,50 @@ mod tests {
let cb = e.clipboard.as_ref().expect("clipboard populated");
assert_eq!(cb, "bc\nfg");
}
#[test]
fn f9_opens_menu_bar() {
let mut e = make_empty();
e.insert_str("hello");
assert!(e.menubar.is_none());
let r = e.handle_key(Key::f(9));
assert_eq!(r, EditorResult::Running);
assert!(e.menubar.is_some());
}
#[test]
fn f9_then_esc_closes_menu_bar() {
let mut e = make_empty();
e.handle_key(Key::f(9));
assert!(e.menubar.is_some());
e.handle_key(Key::ESCAPE);
assert!(e.menubar.is_none(), "Esc should close the menubar");
}
#[test]
fn f9_dispatch_undo_runs_undo() {
let mut e = make_empty();
e.insert_str("hello");
// Type some more, then undo via the menubar.
e.insert_str(" world");
assert_eq!(e.buffer().as_string(), "hello world");
e.handle_key(Key::f(9));
// Navigate to Edit menu (right key).
e.handle_key(Key {
code: 0x2192,
mods: Modifiers::empty(),
});
// Down opens the dropdown.
e.handle_key(Key {
code: 0x2193,
mods: Modifiers::empty(),
});
// Enter dispatches "Undo".
let r = e.handle_key(Key::ENTER);
assert_eq!(r, EditorResult::Running);
assert!(e.menubar.is_none(), "menu closes after dispatch");
// Undo should have reverted the last " world" insert.
assert_eq!(e.buffer().as_string(), "hello");
}
}
@@ -24,6 +24,21 @@ impl Editor {
/// `theme` supplies the title, gutter, body, prompt-overlay, and
/// status-line colours so the editor follows the active skin.
pub(crate) fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) {
// When the F9 menu bar is open, reserve the top row for it
// and pass the remaining rows to the normal editor layout.
let editor_area = if let Some(mb) = &self.menubar {
let chunks = ratatui::layout::Layout::default()
.direction(ratatui::layout::Direction::Vertical)
.constraints([
ratatui::layout::Constraint::Length(1),
ratatui::layout::Constraint::Min(1),
])
.split(area);
mb.render(frame, chunks[0], theme);
chunks[1]
} else {
area
};
// Re-sync cursor struct from buffer in case a mutator
// (e.g. commit_prompt's GotoLine/GotoCol) changed the
// buffer cursor without calling cursor.set_position().
@@ -100,8 +115,8 @@ impl Editor {
.bg(body_bg)
.add_modifier(Modifier::BOLD),
));
let inner = block.inner(area);
frame.render_widget(block, area);
let inner = block.inner(editor_area);
frame.render_widget(block, editor_area);
// Split: accent stripe (1) + gutter (line numbers) + body.
let line_count = self.buffer.line_count();