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:
@@ -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 0–8 substantially complete.
|
**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 (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();
|
||||||
|
|||||||
Reference in New Issue
Block a user