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 # Twilight Commander (TLC) — Pure Rust Reimplementation Plan
**Status:** Architecture chosen. Implementation in progress. Phases 08 substantially complete. **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. 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 editor menubar module complete (F9 menu bar with 6 menus: File, Edit, Search, Bookmark, Goto, Options; 10 unit tests pass). **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) **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` **Branch:** `0.2.4`
**Decision authority:** User selected Option A (Pure Rust TLC) on 2026-06-12. **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 /// BOTH Normal and Insert modes. Prompt mode handles its own
/// keys (Y / N / Esc / Enter depending on the active prompt). /// keys (Y / N / Esc / Enter depending on the active prompt).
pub(crate) fn handle_key(&mut self, key: Key) -> EditorResult { 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 // Ctrl-R / Ctrl-P are intercepted at the dispatcher so
// they work in BOTH Normal and Insert modes (mirroring the // they work in BOTH Normal and Insert modes (mirroring the
// Save / Close shortcut behaviour). The macro recorder // Save / Close shortcut behaviour). The macro recorder
@@ -168,6 +187,11 @@ impl Editor {
} }
return EditorResult::Running; 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 // Ctrl-N — New file: discard current buffer after save
// prompt if dirty (MC EditNew). // prompt if dirty (MC EditNew).
if key == Key::ctrl('n') { if key == Key::ctrl('n') {
@@ -195,6 +195,9 @@ pub struct Editor {
/// as the effective top line; each animation tick advances /// as the effective top line; each animation tick advances
/// `current` by 25 % toward `target`. /// `current` by 25 % toward `target`.
smooth_scroll: Option<SmoothScroll>, 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. /// One in-flight smooth-scroll animation.
@@ -258,6 +261,7 @@ bracket_flash: None,
folds: folding::FoldSet::new(), folds: folding::FoldSet::new(),
tag_stack: Vec::new(), tag_stack: Vec::new(),
smooth_scroll: None, smooth_scroll: None,
menubar: None,
} }
} }
@@ -299,6 +303,7 @@ bracket_flash: None,
folds: folding::FoldSet::new(), folds: folding::FoldSet::new(),
tag_stack: Vec::new(), tag_stack: Vec::new(),
smooth_scroll: None, smooth_scroll: None,
menubar: None,
} }
} }
@@ -642,6 +647,79 @@ bracket_flash: None,
r 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. /// Alt-P — reformat the current paragraph.
/// ///
/// Walks the contiguous block of non-blank lines that contains /// 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"); let cb = e.clipboard.as_ref().expect("clipboard populated");
assert_eq!(cb, "bc\nfg"); 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 /// `theme` supplies the title, gutter, body, prompt-overlay, and
/// status-line colours so the editor follows the active skin. /// status-line colours so the editor follows the active skin.
pub(crate) fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) { 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 // Re-sync cursor struct from buffer in case a mutator
// (e.g. commit_prompt's GotoLine/GotoCol) changed the // (e.g. commit_prompt's GotoLine/GotoCol) changed the
// buffer cursor without calling cursor.set_position(). // buffer cursor without calling cursor.set_position().
@@ -100,8 +115,8 @@ impl Editor {
.bg(body_bg) .bg(body_bg)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
)); ));
let inner = block.inner(area); let inner = block.inner(editor_area);
frame.render_widget(block, area); frame.render_widget(block, editor_area);
// Split: accent stripe (1) + gutter (line numbers) + body. // Split: accent stripe (1) + gutter (line numbers) + body.
let line_count = self.buffer.line_count(); let line_count = self.buffer.line_count();