From 4718e4953c04c45b936aaeafd3eb813d4e9037a0 Mon Sep 17 00:00:00 2001 From: bluestreak Date: Thu, 16 Apr 2026 05:14:45 +0100 Subject: [PATCH 1/2] Add Far Manager-style F9 menu system, license CI check, and search dialog z-order fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a dropdown menu bar (F9) with five categories—Left, File, Command, Options, Right—organizing all keyboard shortcuts into a discoverable hierarchy. The Left/Right menus show the current sort mode with a bullet indicator and let users set sort per-panel. Sort cycling moves to Ctrl+F9. Also adds cargo-deny license checking to CI (Apache-2.0 compatible deps only) and fixes the Ctrl+S search dialog being painted over by bottom panels. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 9 + deny.toml | 22 ++ src/action.rs | 6 + src/app.rs | 154 +++++++++++- src/panel/mod.rs | 5 + src/theme.rs | 2 +- src/ui/footer.rs | 2 +- src/ui/help_dialog.rs | 3 +- src/ui/menu.rs | 519 +++++++++++++++++++++++++++++++++++++++ src/ui/mod.rs | 28 ++- 10 files changed, 740 insertions(+), 10 deletions(-) create mode 100644 deny.toml create mode 100644 src/ui/menu.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ab76b6..b193723 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,15 @@ jobs: components: rustfmt - run: cargo fmt --check + licenses: + name: License check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: EmbarkStudios/cargo-deny-action@v2 + with: + command: check licenses + clippy: name: Clippy runs-on: ubuntu-latest diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..7163b86 --- /dev/null +++ b/deny.toml @@ -0,0 +1,22 @@ +[licenses] +allow = [ + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "MIT", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "Unicode-3.0", + "Unicode-DFS-2016", + "Zlib", + "BSL-1.0", + "0BSD", + "CC0-1.0", +] +confidence-threshold = 0.8 +unused-allowed-license = "allow" + +[[licenses.clarify]] +name = "ring" +expression = "MIT AND ISC AND OpenSSL" +license-files = [{ path = "LICENSE", hash = 0xbd0eed23 }] diff --git a/src/action.rs b/src/action.rs index 085e8d3..e268b29 100644 --- a/src/action.rs +++ b/src/action.rs @@ -38,6 +38,9 @@ pub enum Action { // Sorting CycleSort, + SortByName(usize), // panel index + SortBySize(usize), + SortByDate(usize), // Quick search QuickSearch(char), @@ -123,6 +126,9 @@ pub enum Action { // Help ShowHelp, + // Menu + OpenMenu, + // Settings ToggleSettings, diff --git a/src/app.rs b/src/app.rs index 5f4e0c8..393f0c7 100644 --- a/src/app.rs +++ b/src/app.rs @@ -95,6 +95,12 @@ pub struct App { pub fuzzy_search: [Option; 2], /// Help dialog state (scroll + optional search filter). pub help_state: Option, + /// Menu bar state (F9 Far Manager style menu). + pub menu_state: Option, + /// Click ranges for menu titles in header bar: (x_start, x_end) per menu. + pub menu_title_ranges: Vec<(u16, u16)>, + /// Y coordinate of the header/menu bar (for click detection). + pub menu_bar_y: u16, /// Settings dialog: selected item index, or None when closed. pub settings_open: Option, /// Shell panels (one per file panel side, like CI panels). @@ -169,6 +175,14 @@ pub struct HelpState { pub filter: String, } +/// Menu bar state (F9 menu, Far Manager style). +pub struct MenuState { + /// Which top-level menu is open (0..MENU_COUNT-1). + pub active_menu: usize, + /// Selected item index within the dropdown. + pub selected_item: usize, +} + /// Overwrite confirmation dialog shown during Ask-mode copy/move. pub struct OverwriteAskState { pub focused: OverwriteAskChoice, @@ -1555,6 +1569,9 @@ impl App { goto_path: [None, None], fuzzy_search: [None, None], help_state: None, + menu_state: None, + menu_title_ranges: Vec::new(), + menu_bar_y: 0, settings_open: None, shell_panels: [None, None], claude_panels: [None, None], @@ -1964,6 +1981,21 @@ impl App { }; } + // Menu bar intercepts keys + if self.menu_state.is_some() { + return match key.code { + KeyCode::Esc | KeyCode::F(9) => Action::DialogCancel, + KeyCode::Left => Action::CursorLeft, + KeyCode::Right => Action::CursorRight, + KeyCode::Up => Action::MoveUp, + KeyCode::Down => Action::MoveDown, + KeyCode::Home => Action::MoveToTop, + KeyCode::End => Action::MoveToBottom, + KeyCode::Enter => Action::DialogConfirm, + _ => Action::None, + }; + } + // Goto-line prompt intercepts keys if self.goto_line_input.is_some() { return match key.code { @@ -2587,7 +2619,13 @@ impl App { } } KeyCode::F(8) => Action::Delete, - KeyCode::F(9) => Action::CycleSort, + KeyCode::F(9) => { + if key.modifiers.contains(KeyModifiers::CONTROL) { + Action::CycleSort + } else { + Action::OpenMenu + } + } KeyCode::F(10) => Action::Quit, KeyCode::F(2) => { if key.modifiers.contains(KeyModifiers::SHIFT) { @@ -3528,6 +3566,51 @@ impl App { return; } + // Menu bar intercepts when active + if let Some(ref mut m) = self.menu_state { + let menu_count = crate::ui::menu::MENUS.len(); + match action { + Action::DialogCancel => self.menu_state = None, + Action::CursorLeft => { + m.active_menu = if m.active_menu == 0 { + menu_count - 1 + } else { + m.active_menu - 1 + }; + m.selected_item = 0; + } + Action::CursorRight => { + m.active_menu = (m.active_menu + 1) % menu_count; + m.selected_item = 0; + } + Action::MoveUp => { + let count = crate::ui::menu::selectable_count(m.active_menu); + m.selected_item = if m.selected_item == 0 { + count.saturating_sub(1) + } else { + m.selected_item - 1 + }; + } + Action::MoveDown => { + let count = crate::ui::menu::selectable_count(m.active_menu); + m.selected_item = (m.selected_item + 1) % count; + } + Action::MoveToTop => m.selected_item = 0, + Action::MoveToBottom => { + m.selected_item = + crate::ui::menu::selectable_count(m.active_menu).saturating_sub(1); + } + Action::DialogConfirm => { + if let Some(action) = crate::ui::menu::selected_action(m) { + self.menu_state = None; + self.handle_action(action); + } + } + _ => {} + } + return; + } + // Settings dialog intercepts when active if self.settings_open.is_some() { match action { @@ -4167,6 +4250,14 @@ impl App { }); } + // Menu + Action::OpenMenu => { + self.menu_state = Some(MenuState { + active_menu: 0, + selected_item: 0, + }); + } + Action::ToggleSettings => {} // handled early, before bottom-panel dispatch // CI failure extraction (only works when CI panel is focused, handled above) @@ -4231,6 +4322,15 @@ impl App { Action::CycleSort => { self.active_panel_mut().cycle_sort(); } + Action::SortByName(side) => { + self.panels[side].set_sort(SortField::Name); + } + Action::SortBySize(side) => { + self.panels[side].set_sort(SortField::Size); + } + Action::SortByDate(side) => { + self.panels[side].set_sort(SortField::Date); + } // CI / GitHub Action::ToggleCi => { @@ -8867,6 +8967,58 @@ impl App { } fn handle_mouse_click(&mut self, col: u16, row: u16) { + // Menu bar click detection (header row) + if row == self.menu_bar_y && matches!(self.mode, AppMode::Normal) { + for (i, &(x_start, x_end)) in self.menu_title_ranges.iter().enumerate() { + if col >= x_start && col < x_end { + let already_open = self + .menu_state + .as_ref() + .is_some_and(|m| m.active_menu == i); + self.menu_state = if already_open { + None + } else { + Some(MenuState { + active_menu: i, + selected_item: 0, + }) + }; + return; + } + } + // Clicked header but not on a title — close menu if open + if self.menu_state.is_some() { + self.menu_state = None; + return; + } + } + + // Click on dropdown item when menu is open + if let Some(ref state) = self.menu_state { + let (sw, sh) = crossterm::terminal::size().unwrap_or((80, 24)); + let screen = Rect::new(0, 0, sw, sh); + if let Some(sel) = crate::ui::menu::dropdown_click( + state, + &self.menu_title_ranges, + self.menu_bar_y, + screen, + col, + row, + ) { + let clicked = MenuState { + active_menu: state.active_menu, + selected_item: sel, + }; + if let Some(action) = crate::ui::menu::selected_action(&clicked) { + self.menu_state = None; + self.handle_action(action); + } + return; + } + // Click outside menu closes it + self.menu_state = None; + } + if let AppMode::Editing(ref mut e) = self.mode { e.click_at(col, row); return; diff --git a/src/panel/mod.rs b/src/panel/mod.rs index b83319c..a497245 100644 --- a/src/panel/mod.rs +++ b/src/panel/mod.rs @@ -303,6 +303,11 @@ impl Panel { self.apply_sort(); } + pub fn set_sort(&mut self, field: SortField) { + self.sort_field = field; + self.apply_sort(); + } + pub fn jump_to_match(&mut self, query: &str) { let query_lower = query.to_lowercase(); if let Some(idx) = self diff --git a/src/theme.rs b/src/theme.rs index 2c19d83..670e92c 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -241,7 +241,7 @@ impl Theme { let text = Color::Rgb(232, 228, 237); // #e8e4ed cool light grey let text_dim = Color::Rgb(154, 143, 176); // #9a8fb0 muted lavender let text_very_dim = Color::Rgb(107, 90, 132); // #6b5a84 dim purple - let selection = Color::Rgb(46, 39, 64); // #2e2740 selection bg + let selection = Color::Rgb(74, 50, 100); // #4a3264 selection bg let green = Color::Rgb(130, 210, 150); // #82d296 soft green let blue = Color::Rgb(130, 160, 230); // #82a0e6 soft blue let red = Color::Rgb(240, 100, 100); // #f06464 soft red diff --git a/src/ui/footer.rs b/src/ui/footer.rs index e954cf6..6411a25 100644 --- a/src/ui/footer.rs +++ b/src/ui/footer.rs @@ -54,7 +54,7 @@ pub fn render(frame: &mut Frame, area: Rect) { ("F6", "RnMov"), ("F7", "MkFld"), ("F8", "Del"), - ("F9", "Sort"), + ("F9", "Menu"), ("F10", "Quit"), ("F12", "Claude"), ]; diff --git a/src/ui/help_dialog.rs b/src/ui/help_dialog.rs index a927018..8dee291 100644 --- a/src/ui/help_dialog.rs +++ b/src/ui/help_dialog.rs @@ -42,7 +42,7 @@ const HELP_SECTIONS: &[(&str, &[(&str, &str)])] = &[ ("Shift+F7", "Create (touch) file"), ("Shift+F5", "Create archive (tar.zst/gz/xz/zip)"), ("F8", "Delete file/selection"), - ("F9", "Cycle sort (name/size/date)"), + ("Ctrl+F9", "Cycle sort (name/size/date)"), ], ), ( @@ -244,6 +244,7 @@ const HELP_SECTIONS: &[(&str, &[(&str, &str)])] = &[ &[ ("F1", "This help screen"), ("Shift+F1", "Settings (theme, etc.)"), + ("F9", "Menu"), ("F10 / Ctrl+Q", "Quit (with confirmation)"), ("F11", "Open PR in browser"), ], diff --git a/src/ui/menu.rs b/src/ui/menu.rs new file mode 100644 index 0000000..739cad1 --- /dev/null +++ b/src/ui/menu.rs @@ -0,0 +1,519 @@ +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph}; +use ratatui::Frame; + +use crate::action::Action; +use crate::app::MenuState; +use crate::panel::sort::SortField; +use crate::theme::theme; + +/// A single item in a dropdown menu. +pub(crate) struct MenuItem { + pub label: &'static str, + pub shortcut: &'static str, + pub action: Option, // None = separator +} + +/// A top-level menu category. +pub(crate) struct MenuCategory { + pub title: &'static str, + pub items: &'static [MenuItem], +} + +const SEP: MenuItem = MenuItem { + label: "", + shortcut: "", + action: None, +}; + +macro_rules! item { + ($label:expr, $shortcut:expr, $action:expr) => { + MenuItem { + label: $label, + shortcut: $shortcut, + action: Some($action), + } + }; +} + +static LEFT_ITEMS: &[MenuItem] = &[ + item!("Sort by name", "", Action::SortByName(0)), + item!("Sort by size", "", Action::SortBySize(0)), + item!("Sort by date", "", Action::SortByDate(0)), +]; + +static FILE_ITEMS: &[MenuItem] = &[ + item!("View", "Shift+F3", Action::ViewFile), + item!("Edit", "F4", Action::EditBuiltin), + item!("Edit with $EDITOR", "Shift+F4", Action::EditFile), + SEP, + item!("Copy", "F5", Action::Copy), + item!("Move", "F6", Action::Move), + item!("Rename", "Shift+F6", Action::Rename), + SEP, + item!("Create directory", "F7", Action::CreateDir), + item!("Create file", "Shift+F7", Action::CreateFile), + item!("Archive", "Shift+F5", Action::Archive), + SEP, + item!("Delete", "F8", Action::Delete), + item!("Calculate size", "F3", Action::CalcSize), +]; + +static COMMAND_ITEMS: &[MenuItem] = &[ + item!("Shell panel", "Ctrl+O", Action::ToggleShell), + item!("Claude Code", "F12", Action::ToggleClaude), + item!("CI panel", "F2", Action::ToggleCi), + item!("PR diff panel", "Ctrl+D", Action::ToggleDiff), + item!("Remote connect", "Ctrl+T", Action::ToggleSsh), + item!("Sessions", "Ctrl+Y", Action::ToggleSessions), + SEP, + item!("File search", "Ctrl+S", Action::FileSearchPrompt), + item!("Fuzzy search", "Ctrl+F", Action::FuzzySearchPrompt), + item!("Go to path", "Ctrl+G", Action::GotoPathPrompt), + SEP, + item!("Open PR in browser", "F11", Action::OpenPr), + item!("Copy filename", "Ctrl+C", Action::CopyName), + item!("Copy full path", "Ctrl+P", Action::CopyPath), +]; + +static OPTIONS_ITEMS: &[MenuItem] = &[ + item!("Settings", "Shift+F1", Action::ToggleSettings), + SEP, + item!("Help", "F1", Action::ShowHelp), +]; + +static RIGHT_ITEMS: &[MenuItem] = &[ + item!("Sort by name", "", Action::SortByName(1)), + item!("Sort by size", "", Action::SortBySize(1)), + item!("Sort by date", "", Action::SortByDate(1)), +]; + +pub(crate) static MENUS: &[MenuCategory] = &[ + MenuCategory { + title: "Left", + items: LEFT_ITEMS, + }, + MenuCategory { + title: "File", + items: FILE_ITEMS, + }, + MenuCategory { + title: "Command", + items: COMMAND_ITEMS, + }, + MenuCategory { + title: "Options", + items: OPTIONS_ITEMS, + }, + MenuCategory { + title: "Right", + items: RIGHT_ITEMS, + }, +]; + +/// Number of selectable (non-separator) items in a menu. +pub(crate) fn selectable_count(menu_idx: usize) -> usize { + MENUS[menu_idx] + .items + .iter() + .filter(|i| i.action.is_some()) + .count() +} + +/// Map a selectable index to the actual item index (skipping separators). +fn selectable_to_item_index(menu_idx: usize, sel: usize) -> usize { + let mut count = 0; + for (i, item) in MENUS[menu_idx].items.iter().enumerate() { + if item.action.is_some() { + if count == sel { + return i; + } + count += 1; + } + } + 0 +} + +/// Get the action for the currently selected menu item. +pub(crate) fn selected_action(state: &MenuState) -> Option { + let idx = selectable_to_item_index(state.active_menu, state.selected_item); + MENUS[state.active_menu].items[idx].action.clone() +} + +/// Compute the unclamped dropdown content width for a menu. +fn menu_content_width(menu: &MenuCategory) -> usize { + let max_label: usize = menu.items.iter().map(|i| i.label.len()).max().unwrap_or(10); + let max_shortcut: usize = menu.items.iter().map(|i| i.shortcut.len()).max().unwrap_or(0); + max_label + 2 + max_shortcut + 2 +} + +/// Compute the clamped dropdown rect given screen bounds. +fn dropdown_rect(menu: &MenuCategory, dropdown_x: u16, dropdown_y: u16, screen: Rect) -> Rect { + let inner_width = menu_content_width(menu); + let raw_w = inner_width as u16 + 2; // +2 for border + let raw_h = menu.items.len() as u16 + 2; + + let avail_w = screen.width.saturating_sub(dropdown_x.saturating_sub(screen.x)); + let avail_h = screen.height.saturating_sub(dropdown_y.saturating_sub(screen.y)); + let dw = raw_w.min(avail_w).max(10); + let dh = raw_h.min(avail_h).max(3); + + Rect::new(dropdown_x, dropdown_y, dw, dh) +} + +/// Given a click position and the menu title ranges, check if the click is inside +/// the dropdown area and return the selectable item index if so. +/// `bar_y` is the y of the menu bar row; the dropdown starts at bar_y + 1. +/// `screen` is the full terminal area (for clamping dimensions to match render). +pub(crate) fn dropdown_click( + state: &MenuState, + menu_title_ranges: &[(u16, u16)], + bar_y: u16, + screen: Rect, + col: u16, + row: u16, +) -> Option { + let menu = &MENUS[state.active_menu]; + let dropdown_x = menu_title_ranges.get(state.active_menu)?.0; + let dropdown_y = bar_y + 1; + + let rect = dropdown_rect(menu, dropdown_x, dropdown_y, screen); + // Inner area (inside border) + let inner_x = rect.x + 1; + let inner_y = rect.y + 1; + let inner_w = rect.width.saturating_sub(2); + let inner_h = rect.height.saturating_sub(2); + + // Check bounds + if col < inner_x || col >= inner_x + inner_w || row < inner_y || row >= inner_y + inner_h { + return None; + } + + let item_row = (row - inner_y) as usize; + if item_row >= menu.items.len() { + return None; + } + + // Separator — not clickable + menu.items[item_row].action.as_ref()?; + + // Convert item index to selectable index + let mut sel_idx = 0; + for (i, item) in menu.items.iter().enumerate() { + if i == item_row { + return Some(sel_idx); + } + if item.action.is_some() { + sel_idx += 1; + } + } + None +} + +/// Check if a sort action matches the current sort field for a panel. +fn is_active_sort(action: &Option, sort_fields: [SortField; 2]) -> bool { + match action { + Some(Action::SortByName(side)) => sort_fields.get(*side) == Some(&SortField::Name), + Some(Action::SortBySize(side)) => sort_fields.get(*side) == Some(&SortField::Size), + Some(Action::SortByDate(side)) => sort_fields.get(*side) == Some(&SortField::Date), + _ => false, + } +} + +/// Render the menu bar and dropdown overlay. +/// Returns the x-ranges for each menu title (for click detection). +/// `sort_fields` contains [left_panel_sort, right_panel_sort] for showing active sort. +pub(crate) fn render( + frame: &mut Frame, + state: &MenuState, + bar_area: Rect, + sort_fields: [SortField; 2], +) -> Vec<(u16, u16)> { + let t = theme(); + + let bar_bg = Style::default().fg(t.dialog_text_fg).bg(t.dialog_bg); + let bar_active = Style::default() + .fg(t.dialog_input_fg_focused) + .bg(t.dialog_input_bg) + .add_modifier(Modifier::BOLD); + + // Render the menu bar (single row) + let mut spans: Vec = Vec::new(); + spans.push(Span::styled(" ", bar_bg)); + + let mut menu_ranges: Vec<(u16, u16)> = Vec::new(); + let mut x_pos: u16 = bar_area.x + 1; + + for (i, menu) in MENUS.iter().enumerate() { + let label = format!(" {} ", menu.title); + let start = x_pos; + let style = if i == state.active_menu { + bar_active + } else { + bar_bg + }; + let w = label.len() as u16; + spans.push(Span::styled(label, style)); + menu_ranges.push((start, start + w)); + x_pos += w; + } + + // Fill remaining width + let used: usize = spans.iter().map(|s| s.width()).sum(); + if (used as u16) < bar_area.width { + let pad = " ".repeat(bar_area.width as usize - used); + spans.push(Span::styled(pad, bar_bg)); + } + + frame.render_widget(Paragraph::new(Line::from(spans)), bar_area); + + // Render the dropdown below the active menu title + let menu = &MENUS[state.active_menu]; + let dropdown_x = menu_ranges[state.active_menu].0; + let dropdown_y = bar_area.y + 1; + + let drect = dropdown_rect(menu, dropdown_x, dropdown_y, frame.area()); + frame.render_widget(Clear, drect); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(t.dialog_border_fg).bg(t.dialog_bg)) + .style(Style::default().bg(t.dialog_bg)); + + let inner = block.inner(drect); + frame.render_widget(block, drect); + + // Render items + let normal = Style::default().fg(t.dialog_text_fg).bg(t.dialog_bg); + let highlight = Style::default() + .fg(t.dialog_input_fg_focused) + .bg(t.dialog_input_bg) + .add_modifier(Modifier::BOLD); + let shortcut_style = Style::default().fg(t.dialog_border_fg).bg(t.dialog_bg); + let shortcut_hl = Style::default() + .fg(t.dialog_border_fg) + .bg(t.dialog_input_bg); + let sep_style = Style::default().fg(t.dialog_border_fg).bg(t.dialog_bg); + + let iw = inner.width as usize; + let mut sel_count = 0; + + for (i, item) in menu.items.iter().enumerate() { + if i as u16 >= inner.height { + break; + } + let row = Rect::new(inner.x, inner.y + i as u16, inner.width, 1); + + if item.action.is_none() { + // Separator + let sep = "─".repeat(iw); + frame.render_widget( + Paragraph::new(Line::from(Span::styled(sep, sep_style))), + row, + ); + } else { + let is_selected = sel_count == state.selected_item; + let st = if is_selected { highlight } else { normal }; + let sc = if is_selected { shortcut_hl } else { shortcut_style }; + + let marker = if is_active_sort(&item.action, sort_fields) { + "\u{2022}" // bullet • + } else { + " " + }; + let label_width = iw.saturating_sub(item.shortcut.len() + 1); + let mut spans = vec![Span::styled( + format!( + "{}{: Rect { + Rect::new(0, 0, 120, 40) + } + + /// Compute the expected inner top-left of the dropdown for a given menu. + fn dropdown_inner_origin(menu_idx: usize, ranges: &[(u16, u16)], bar_y: u16) -> (u16, u16) { + let x = ranges[menu_idx].0 + 1; // border + let y = bar_y + 1 + 1; // below bar + border + (x, y) + } + + fn sample_ranges() -> Vec<(u16, u16)> { + // Mimics the positions from render: " Left File Command Options Right " + vec![(1, 7), (7, 13), (13, 22), (22, 31), (31, 38)] + } + + #[test] + fn dropdown_click_on_first_item() { + let state = MenuState { + active_menu: 0, + selected_item: 0, + }; + let ranges = sample_ranges(); + let (ix, iy) = dropdown_inner_origin(0, &ranges, 0); + let result = dropdown_click(&state, &ranges, 0, make_screen(), ix, iy); + assert_eq!(result, Some(0)); + } + + #[test] + fn dropdown_click_on_second_item() { + let state = MenuState { + active_menu: 0, + selected_item: 0, + }; + let ranges = sample_ranges(); + let (ix, iy) = dropdown_inner_origin(0, &ranges, 0); + let result = dropdown_click(&state, &ranges, 0, make_screen(), ix, iy + 1); + assert_eq!(result, Some(1)); + } + + #[test] + fn dropdown_click_on_separator_returns_none() { + // FILE_ITEMS index 3 is a separator + let state = MenuState { + active_menu: 1, + selected_item: 0, + }; + let ranges = sample_ranges(); + let (ix, iy) = dropdown_inner_origin(1, &ranges, 0); + // Row 3 in FILE_ITEMS is SEP + let result = dropdown_click(&state, &ranges, 0, make_screen(), ix, iy + 3); + assert_eq!(result, None); + } + + #[test] + fn dropdown_click_outside_returns_none() { + let state = MenuState { + active_menu: 0, + selected_item: 0, + }; + let ranges = sample_ranges(); + // Click way off to the right + let result = dropdown_click(&state, &ranges, 0, make_screen(), 100, 5); + assert_eq!(result, None); + } + + #[test] + fn dropdown_click_on_border_returns_none() { + let state = MenuState { + active_menu: 0, + selected_item: 0, + }; + let ranges = sample_ranges(); + let dropdown_x = ranges[0].0; + // Click on the border itself (dropdown_y = bar_y+1, border is at dropdown_y) + let result = dropdown_click(&state, &ranges, 0, make_screen(), dropdown_x, 1); + assert_eq!(result, None); + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index f8b241d..be1fb4d 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -11,6 +11,7 @@ pub mod footer; pub mod header; pub mod help_dialog; pub mod hex_view; +pub mod menu; pub mod mkdir_dialog; pub mod panel_view; pub mod parquet_view; @@ -231,6 +232,20 @@ pub fn render(frame: &mut Frame, app: &mut App) { // Hide the text cursor while a help/F1 overlay is shown CURSOR_POS.set(None); } + + // Render menu bar overlay (on top of everything, only when open) + if let Some(ref state) = app.menu_state { + let bar_area = Rect::new( + frame.area().x, + frame.area().y, + frame.area().width, + 1, + ); + let sort_fields = [app.panels[0].sort_field, app.panels[1].sort_field]; + app.menu_title_ranges = menu::render(frame, state, bar_area, sort_fields); + } else { + app.menu_title_ranges.clear(); + } } fn render_normal(frame: &mut Frame, app: &mut App) { @@ -282,6 +297,7 @@ fn render_normal(frame: &mut Frame, app: &mut App) { app.ssh_panel_areas = [left_ssh_area, right_ssh_area]; header::render(frame, header_area, app); + app.menu_bar_y = header_area.y; // File panels are active only when nothing else is focused let (left_active, right_active) = if app.focus == PanelFocus::FilePanel { @@ -328,12 +344,6 @@ fn render_normal(frame: &mut Frame, app: &mut App) { ); } - // Render file search dialog overlay - if let Some(ref state) = app.file_search_dialog { - let area = file_search_dialog::render(frame, state); - shadow::render_shadow(frame, area); - } - // Render CI panels if let (Some(ci_area), Some(ref mut ci)) = (left_ci_area, &mut app.ci_panels[0]) { ci_view::render(frame, ci_area, ci, app.focus == PanelFocus::Ci(0)); @@ -374,6 +384,12 @@ fn render_normal(frame: &mut Frame, app: &mut App) { terminal_view::render(frame, ssh_area, sp, app.focus == PanelFocus::Ssh(1)); } + // Render file search dialog overlay (after bottom panels so it's on top) + if let Some(ref state) = app.file_search_dialog { + let area = file_search_dialog::render(frame, state); + shadow::render_shadow(frame, area); + } + // Render SSH dialog overlay if let Some(ref state) = app.ssh_dialog { ssh_dialog::render(frame, state); From 483b118e1132118095f6b6a5ee2e9e166ce9ebbd Mon Sep 17 00:00:00 2001 From: bluestreak Date: Thu, 16 Apr 2026 05:22:06 +0100 Subject: [PATCH 2/2] Fix search dialog button/checkbox clicks, formatting, and dropdown dimensions - Clicking Search/Cancel buttons in Ctrl+S dialog now fires the action - Clicking checkboxes in Ctrl+S dialog now toggles them - Fix dropdown_click dimension mismatch with render (shared dropdown_rect) - Use .get() for bounds-safe sort field lookup in is_active_sort - Apply cargo fmt fixes Co-Authored-By: Claude Opus 4.6 (1M context) --- src/action.rs | 2 +- src/app.rs | 33 ++++++++++++++++++++++++++++----- src/ui/menu.rs | 21 +++++++++++++++++---- src/ui/mod.rs | 7 +------ 4 files changed, 47 insertions(+), 16 deletions(-) diff --git a/src/action.rs b/src/action.rs index e268b29..50e8001 100644 --- a/src/action.rs +++ b/src/action.rs @@ -38,7 +38,7 @@ pub enum Action { // Sorting CycleSort, - SortByName(usize), // panel index + SortByName(usize), // panel index SortBySize(usize), SortByDate(usize), diff --git a/src/app.rs b/src/app.rs index 393f0c7..a1e378c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -8907,9 +8907,35 @@ impl App { 30 => FileSearchField::MaxCount, 31 => FileSearchField::MaxFileSize, 32 => FileSearchField::Encoding, - _ => FileSearchField::ButtonSearch, + _ => { + // Button row — pick Search or Cancel by x position, + // then fire the action immediately. + let cw = self + .dialog_content_area + .map(|a| a.width as usize) + .unwrap_or(60); + // Cancel button is on the right half + let btn = if x_off > cw / 2 { + FileSearchField::ButtonCancel + } else { + FileSearchField::ButtonSearch + }; + state.focused = btn; + state.select_focused(); + self.handle_file_search_dialog(Action::DialogConfirm); + return; + } }; state.select_focused(); + // Toggle checkbox on click (inputs just get focused/selected above) + if !state.focused.is_input() + && !matches!( + state.focused, + FileSearchField::ButtonSearch | FileSearchField::ButtonCancel + ) + { + state.toggle_focused(); + } return; } // Search dialog: query at y=2 @@ -8971,10 +8997,7 @@ impl App { if row == self.menu_bar_y && matches!(self.mode, AppMode::Normal) { for (i, &(x_start, x_end)) in self.menu_title_ranges.iter().enumerate() { if col >= x_start && col < x_end { - let already_open = self - .menu_state - .as_ref() - .is_some_and(|m| m.active_menu == i); + let already_open = self.menu_state.as_ref().is_some_and(|m| m.active_menu == i); self.menu_state = if already_open { None } else { diff --git a/src/ui/menu.rs b/src/ui/menu.rs index 739cad1..23ea64e 100644 --- a/src/ui/menu.rs +++ b/src/ui/menu.rs @@ -145,7 +145,12 @@ pub(crate) fn selected_action(state: &MenuState) -> Option { /// Compute the unclamped dropdown content width for a menu. fn menu_content_width(menu: &MenuCategory) -> usize { let max_label: usize = menu.items.iter().map(|i| i.label.len()).max().unwrap_or(10); - let max_shortcut: usize = menu.items.iter().map(|i| i.shortcut.len()).max().unwrap_or(0); + let max_shortcut: usize = menu + .items + .iter() + .map(|i| i.shortcut.len()) + .max() + .unwrap_or(0); max_label + 2 + max_shortcut + 2 } @@ -155,8 +160,12 @@ fn dropdown_rect(menu: &MenuCategory, dropdown_x: u16, dropdown_y: u16, screen: let raw_w = inner_width as u16 + 2; // +2 for border let raw_h = menu.items.len() as u16 + 2; - let avail_w = screen.width.saturating_sub(dropdown_x.saturating_sub(screen.x)); - let avail_h = screen.height.saturating_sub(dropdown_y.saturating_sub(screen.y)); + let avail_w = screen + .width + .saturating_sub(dropdown_x.saturating_sub(screen.x)); + let avail_h = screen + .height + .saturating_sub(dropdown_y.saturating_sub(screen.y)); let dw = raw_w.min(avail_w).max(10); let dh = raw_h.min(avail_h).max(3); @@ -316,7 +325,11 @@ pub(crate) fn render( } else { let is_selected = sel_count == state.selected_item; let st = if is_selected { highlight } else { normal }; - let sc = if is_selected { shortcut_hl } else { shortcut_style }; + let sc = if is_selected { + shortcut_hl + } else { + shortcut_style + }; let marker = if is_active_sort(&item.action, sort_fields) { "\u{2022}" // bullet • diff --git a/src/ui/mod.rs b/src/ui/mod.rs index be1fb4d..448d010 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -235,12 +235,7 @@ pub fn render(frame: &mut Frame, app: &mut App) { // Render menu bar overlay (on top of everything, only when open) if let Some(ref state) = app.menu_state { - let bar_area = Rect::new( - frame.area().x, - frame.area().y, - frame.area().width, - 1, - ); + let bar_area = Rect::new(frame.area().x, frame.area().y, frame.area().width, 1); let sort_fields = [app.panels[0].sort_field, app.panels[1].sort_field]; app.menu_title_ranges = menu::render(frame, state, bar_area, sort_fields); } else {