diff --git a/local/recipes/tui/tlc/PLAN.md b/local/recipes/tui/tlc/PLAN.md index 65e79e453d..f0213fe7d4 100644 --- a/local/recipes/tui/tlc/PLAN.md +++ b/local/recipes/tui/tlc/PLAN.md @@ -1,8 +1,8 @@ # 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 (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. diff --git a/local/recipes/tui/tlc/source/src/editor/handlers.rs b/local/recipes/tui/tlc/source/src/editor/handlers.rs index 342e2892cb..064230213f 100644 --- a/local/recipes/tui/tlc/source/src/editor/handlers.rs +++ b/local/recipes/tui/tlc/source/src/editor/handlers.rs @@ -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') { diff --git a/local/recipes/tui/tlc/source/src/editor/mod.rs b/local/recipes/tui/tlc/source/src/editor/mod.rs index 0fc51092a7..4393e8d0bf 100644 --- a/local/recipes/tui/tlc/source/src/editor/mod.rs +++ b/local/recipes/tui/tlc/source/src/editor/mod.rs @@ -195,6 +195,9 @@ pub struct Editor { /// as the effective top line; each animation tick advances /// `current` by 25 % toward `target`. smooth_scroll: Option, + /// F9 menu bar overlay. `None` means the bar is closed. F9 + /// opens; Esc / F9 again / F10 close. + menubar: Option, } /// 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"); + } } diff --git a/local/recipes/tui/tlc/source/src/editor/render.rs b/local/recipes/tui/tlc/source/src/editor/render.rs index 19adf15e94..240753170d 100644 --- a/local/recipes/tui/tlc/source/src/editor/render.rs +++ b/local/recipes/tui/tlc/source/src/editor/render.rs @@ -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();