diff --git a/src/app/input.rs b/src/app/input.rs index eca37247..a45b8e42 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -8,7 +8,7 @@ use tracing::warn; use crate::layout::{NavDirection, PaneInfo, SplitBorder}; use crate::selection::Selection; -use super::state::{key_matches, AppState, ContextMenuState, DragState, Mode, CONTEXT_MENU_ITEMS}; +use super::state::{key_matches, AppState, ContextMenuKind, ContextMenuState, DragState, Mode}; use super::App; // --------------------------------------------------------------------------- @@ -94,6 +94,16 @@ impl App { } } + pub(super) fn handle_mouse(&mut self, mouse: MouseEvent) { + if let Some(action) = self.state.handle_mouse(mouse) { + match action { + SettingsAction::SaveTheme(name) => self.save_theme(&name), + SettingsAction::SaveSound(enabled) => self.save_sound(enabled), + SettingsAction::SaveToast(enabled) => self.save_toast(enabled), + } + } + } + async fn handle_terminal_key(&mut self, key: KeyEvent) { self.state.clear_selection(); self.state.update_dismissed = true; @@ -159,6 +169,22 @@ fn cancel_settings(state: &mut AppState) { leave_modal(state); } +fn apply_settings(state: &mut AppState) -> Option { + match state.settings.section { + crate::app::state::SettingsSection::Theme => { + let theme_name = state.theme_name.clone(); + state.settings.original_palette = None; + state.settings.original_theme = None; + leave_modal(state); + Some(SettingsAction::SaveTheme(theme_name)) + } + _ => { + leave_modal(state); + None + } + } +} + fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Option { use crate::app::state::SettingsSection; @@ -176,13 +202,7 @@ fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Option { - let theme_name = state.theme_name.clone(); - state.settings.original_palette = None; - state.settings.original_theme = None; - leave_modal(state); - return Some(SettingsAction::SaveTheme(theme_name)); - } + KeyCode::Enter => return apply_settings(state), KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { state.settings.section = SettingsSection::Sound; state.settings.selected = usize::from(!state.sound_enabled()); @@ -346,7 +366,7 @@ fn execute_navigate_action(state: &mut AppState, action: NavigateAction) { NavigateAction::CloseWorkspace => { if !state.workspaces.is_empty() { if state.confirm_close { - state.mode = Mode::ConfirmClose; + open_confirm_close(state); } else { state.close_selected_workspace(); leave_navigate_mode(state); @@ -458,6 +478,11 @@ fn handle_resize_key(state: &mut AppState, key: KeyEvent) { } } +fn open_confirm_close(state: &mut AppState) { + state.confirm_close_selected_confirm = true; + state.mode = Mode::ConfirmClose; +} + fn confirm_close_accept(state: &mut AppState) { state.close_selected_workspace(); if state.workspaces.is_empty() { @@ -473,8 +498,58 @@ fn confirm_close_cancel(state: &mut AppState) { fn handle_confirm_close_key(state: &mut AppState, key: KeyEvent) { match key.code { - KeyCode::Char('y') | KeyCode::Enter => confirm_close_accept(state), - _ => confirm_close_cancel(state), + KeyCode::Left | KeyCode::Char('h') => state.confirm_close_selected_confirm = true, + KeyCode::Right | KeyCode::Char('l') => state.confirm_close_selected_confirm = false, + KeyCode::Enter => { + if state.confirm_close_selected_confirm { + confirm_close_accept(state); + } else { + confirm_close_cancel(state); + } + } + KeyCode::Esc => confirm_close_cancel(state), + _ => {} + } +} + +fn apply_context_menu_action(state: &mut AppState, menu: ContextMenuState, idx: usize) { + let item = menu.items().get(idx).copied(); + match (menu.kind, item) { + (ContextMenuKind::Workspace { ws_idx }, Some("Rename")) => { + state.selected = ws_idx; + state.name_input = state.workspaces[ws_idx].display_name(); + state.mode = Mode::RenameSession; + } + (ContextMenuKind::Workspace { ws_idx }, Some("Close")) => { + state.selected = ws_idx; + if state.confirm_close { + open_confirm_close(state); + } else { + state.close_selected_workspace(); + state.mode = Mode::Navigate; + } + } + (ContextMenuKind::Pane, Some("Split vertical")) => { + state.split_pane(Direction::Horizontal); + state.mode = Mode::Terminal; + } + (ContextMenuKind::Pane, Some("Split horizontal")) => { + state.split_pane(Direction::Vertical); + state.mode = Mode::Terminal; + } + (ContextMenuKind::Pane, Some("Fullscreen")) => { + state.toggle_fullscreen(); + state.mode = Mode::Terminal; + } + (ContextMenuKind::Pane, Some("Close pane")) => { + state.close_pane(); + state.mode = if state.active.is_some() { + Mode::Terminal + } else { + Mode::Navigate + }; + } + _ => leave_modal(state), } } @@ -482,7 +557,7 @@ fn handle_context_menu_key(state: &mut AppState, key: KeyEvent) { match key.code { KeyCode::Esc => { state.context_menu = None; - state.mode = Mode::Navigate; + leave_modal(state); } KeyCode::Up => { if let Some(menu) = &mut state.context_menu { @@ -493,30 +568,15 @@ fn handle_context_menu_key(state: &mut AppState, key: KeyEvent) { } KeyCode::Down => { if let Some(menu) = &mut state.context_menu { - if menu.selected < CONTEXT_MENU_ITEMS.len() - 1 { + if menu.selected + 1 < menu.items().len() { menu.selected += 1; } } } KeyCode::Enter => { if let Some(menu) = state.context_menu.take() { - match CONTEXT_MENU_ITEMS[menu.selected] { - "Rename" => { - state.selected = menu.ws_idx; - state.name_input = state.workspaces[menu.ws_idx].display_name(); - state.mode = Mode::RenameSession; - } - "Close" => { - state.selected = menu.ws_idx; - if state.confirm_close { - state.mode = Mode::ConfirmClose; - } else { - state.close_selected_workspace(); - state.mode = Mode::Navigate; - } - } - _ => state.mode = Mode::Navigate, - } + let idx = menu.selected; + apply_context_menu_action(state, menu, idx); } } _ => {} @@ -547,7 +607,10 @@ impl AppState { } fn handle_onboarding_mouse(&mut self, mouse: MouseEvent) { - if mouse.kind != MouseEventKind::Down(MouseButton::Left) { + if !matches!( + mouse.kind, + MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Moved + ) { return; } @@ -573,6 +636,9 @@ impl AppState { let options_start_y = inner.y + 2; if mouse.row >= options_start_y && mouse.row < options_start_y + 4 { self.onboarding_selected = (mouse.row - options_start_y) as usize; + if matches!(mouse.kind, MouseEventKind::Moved) { + return; + } return; } @@ -592,10 +658,172 @@ impl AppState { } } - pub(crate) fn handle_mouse(&mut self, mouse: MouseEvent) { + fn settings_popup_rect(&self) -> Rect { + let area = self.screen_rect(); + let popup_w = 56u16.min(area.width.saturating_sub(4)); + let popup_h = 20u16.min(area.height.saturating_sub(2)); + let popup_x = area.x + (area.width.saturating_sub(popup_w)) / 2; + let popup_y = area.y + (area.height.saturating_sub(popup_h)) / 2; + Rect::new(popup_x, popup_y, popup_w, popup_h) + } + + fn settings_inner_rect(&self) -> Rect { + let popup = self.settings_popup_rect(); + Rect::new( + popup.x + 1, + popup.y + 1, + popup.width.saturating_sub(2), + popup.height.saturating_sub(2), + ) + } + + fn settings_tab_at(&self, col: u16, row: u16) -> Option { + use crate::app::state::SettingsSection; + + let inner = self.settings_inner_rect(); + let tab_y = inner.y + 1; + if row != tab_y { + return None; + } + let mut x = inner.x; + for section in SettingsSection::ALL { + let width = section.label().len() as u16 + 2; + if col >= x && col < x + width { + return Some(*section); + } + x += width + 1; + } + None + } + + fn settings_content_rect(&self) -> Rect { + let inner = self.settings_inner_rect(); + let y = inner.y + 3; + Rect::new(inner.x, y, inner.width, inner.y + inner.height - y) + } + + fn settings_list_index_at(&self, row: u16) -> Option { + let area = self.settings_content_rect(); + if row < area.y || row >= area.y + area.height { + return None; + } + + match self.settings.section { + crate::app::state::SettingsSection::Theme => { + let max_visible = area.height as usize; + let scroll = if self.settings.selected >= max_visible { + self.settings.selected - max_visible + 1 + } else { + 0 + }; + let idx = scroll + (row - area.y) as usize; + (idx < crate::app::state::THEME_NAMES.len()).then_some(idx) + } + crate::app::state::SettingsSection::Sound + | crate::app::state::SettingsSection::Toast => { + let list_y = area.y + 2; + if row >= list_y && row < list_y + 2 { + Some((row - list_y) as usize) + } else { + None + } + } + } + } + + fn settings_button_at(&self, col: u16, row: u16) -> Option<&'static str> { + let inner = self.settings_inner_rect(); + let footer_y = inner.y + inner.height.saturating_sub(1); + if row != footer_y { + return None; + } + let total_w = 7u16 + 2 + 7u16; + let apply_x = inner.x + inner.width.saturating_sub(total_w) / 2; + let close_x = apply_x + 9; + if col >= apply_x && col < apply_x + 7 { + Some("apply") + } else if col >= close_x && col < close_x + 7 { + Some("close") + } else { + None + } + } + + fn handle_settings_mouse(&mut self, mouse: MouseEvent) -> Option { + use crate::app::state::SettingsSection; + + match mouse.kind { + MouseEventKind::Moved => { + if let Some(section) = self.settings_tab_at(mouse.column, mouse.row) { + self.settings.section = section; + self.settings.selected = match section { + SettingsSection::Theme => current_theme_index(&self.theme_name), + SettingsSection::Sound => usize::from(!self.sound_enabled()), + SettingsSection::Toast => usize::from(!self.toast_config.enabled), + }; + return None; + } + if let Some(idx) = self.settings_list_index_at(mouse.row) { + self.settings.selected = idx; + if self.settings.section == SettingsSection::Theme { + preview_selected_theme(self); + } + } + None + } + MouseEventKind::Down(MouseButton::Left) => { + if let Some(section) = self.settings_tab_at(mouse.column, mouse.row) { + self.settings.section = section; + self.settings.selected = match section { + SettingsSection::Theme => current_theme_index(&self.theme_name), + SettingsSection::Sound => usize::from(!self.sound_enabled()), + SettingsSection::Toast => usize::from(!self.toast_config.enabled), + }; + return None; + } + if let Some(idx) = self.settings_list_index_at(mouse.row) { + self.settings.selected = idx; + return match self.settings.section { + SettingsSection::Theme => { + preview_selected_theme(self); + None + } + SettingsSection::Sound => { + let enabled = idx == 0; + self.sound.enabled = enabled; + Some(SettingsAction::SaveSound(enabled)) + } + SettingsSection::Toast => { + let enabled = idx == 0; + self.toast_config.enabled = enabled; + Some(SettingsAction::SaveToast(enabled)) + } + }; + } + match self.settings_button_at(mouse.column, mouse.row) { + Some("apply") => apply_settings(self), + Some("close") => { + cancel_settings(self); + None + } + _ => { + cancel_settings(self); + None + } + } + } + _ => None, + } + } + + fn handle_mouse(&mut self, mouse: MouseEvent) -> Option { if self.mode == Mode::Onboarding { self.handle_onboarding_mouse(mouse); - return; + return None; + } + + if self.mode == Mode::Settings { + return self.handle_settings_mouse(mouse); } let sidebar = self.view.sidebar_rect; @@ -609,43 +837,24 @@ impl AppState { self.selection = None; if self.mode == Mode::ConfirmClose { - if self.confirm_close_confirm_button_at(mouse.column, mouse.row) { - confirm_close_accept(self); - } else { - confirm_close_cancel(self); + match self.confirm_close_button_at(mouse.column, mouse.row) { + Some(true) => confirm_close_accept(self), + Some(false) => confirm_close_cancel(self), + None => confirm_close_cancel(self), } - return; + return None; } if self.mode == Mode::ContextMenu { - if let Some(menu) = &self.context_menu { - let item_idx = self.context_menu_item_at(mouse.column, mouse.row); + let item_idx = self.context_menu_item_at(mouse.column, mouse.row); + if let Some(menu) = self.context_menu.take() { if let Some(idx) = item_idx { - let ws_idx = menu.ws_idx; - self.context_menu = None; - match CONTEXT_MENU_ITEMS[idx] { - "Rename" => { - self.selected = ws_idx; - self.name_input = self.workspaces[ws_idx].display_name(); - self.mode = Mode::RenameSession; - } - "Close" => { - self.selected = ws_idx; - if self.confirm_close { - self.mode = Mode::ConfirmClose; - } else { - self.close_selected_workspace(); - self.mode = Mode::Navigate; - } - } - _ => self.mode = Mode::Navigate, - } + apply_context_menu_action(self, menu, idx); } else { - self.context_menu = None; - self.mode = Mode::Navigate; + leave_modal(self); } } - return; + return None; } if !in_sidebar { @@ -655,38 +864,33 @@ impl AppState { direction: border.direction, area: border.area, }); - return; + return None; } } if in_sidebar { if self.sidebar_collapsed { - // Collapsed: each workspace is 1 row let idx = (mouse.row - sidebar.y) as usize; if idx < self.workspaces.len() { self.switch_workspace(idx); self.mode = Mode::Terminal; } - return; + return None; } - // Two-section layout: top half is workspaces let total_h = sidebar.height as usize; let ws_h = (total_h + 1) / 2; let ws_bottom = sidebar.y + ws_h as u16; - - // "new" button is at the last row of workspace section let new_row = ws_bottom.saturating_sub(1); if mouse.row == new_row { self.request_new_workspace = true; - return; + return None; } - // Workspace clicks in top section if let Some(idx) = self.workspace_at_row(mouse.row) { self.switch_workspace(idx); self.mode = Mode::Terminal; - return; + return None; } } else if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() { let (row, col) = ( @@ -703,23 +907,21 @@ impl AppState { if self.mode != Mode::Terminal { self.mode = Mode::Terminal; } - } else { - if let Some(info) = self.view.pane_infos.iter().find(|p| { - mouse.column >= p.rect.x - && mouse.column < p.rect.x + p.rect.width - && mouse.row >= p.rect.y - && mouse.row < p.rect.y + p.rect.height - }) { - let id = info.id; - if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { - if ws.layout.focused() != id { - ws.layout.focus_pane(id); - } - } - if self.mode != Mode::Terminal { - self.mode = Mode::Terminal; + } else if let Some(info) = self.view.pane_infos.iter().find(|p| { + mouse.column >= p.rect.x + && mouse.column < p.rect.x + p.rect.width + && mouse.row >= p.rect.y + && mouse.row < p.rect.y + p.rect.height + }) { + let id = info.id; + if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { + if ws.layout.focused() != id { + ws.layout.focus_pane(id); } } + if self.mode != Mode::Terminal { + self.mode = Mode::Terminal; + } } } @@ -747,7 +949,6 @@ impl AppState { MouseEventKind::Up(MouseButton::Left) => { if self.drag.take().is_some() { - // Drag ended } else { let was_click = self.selection.as_ref().is_some_and(|s| s.was_just_click()); if was_click { @@ -786,11 +987,39 @@ impl AppState { } } + MouseEventKind::Moved if self.mode == Mode::ConfirmClose => { + if let Some(selected_confirm) = + self.confirm_close_button_at(mouse.column, mouse.row) + { + self.confirm_close_selected_confirm = selected_confirm; + } + } + + MouseEventKind::Moved if self.mode == Mode::ContextMenu => { + if let Some(idx) = self.context_menu_item_at(mouse.column, mouse.row) { + if let Some(menu) = &mut self.context_menu { + menu.selected = idx; + } + } + } + MouseEventKind::Down(MouseButton::Right) if in_sidebar && !self.sidebar_collapsed => { if let Some(idx) = self.workspace_at_row(mouse.row) { self.selected = idx; self.context_menu = Some(ContextMenuState { - ws_idx: idx, + kind: ContextMenuKind::Workspace { ws_idx: idx }, + x: mouse.column, + y: mouse.row, + selected: 0, + }); + self.mode = Mode::ContextMenu; + } + } + + MouseEventKind::Down(MouseButton::Right) if !in_sidebar => { + if self.pane_at(mouse.column, mouse.row).is_some() { + self.context_menu = Some(ContextMenuState { + kind: ContextMenuKind::Pane, x: mouse.column, y: mouse.row, selected: 0, @@ -801,6 +1030,8 @@ impl AppState { _ => {} } + + None } /// Find which workspace index a sidebar row belongs to (two-section layout). @@ -843,8 +1074,14 @@ impl AppState { pub(crate) fn context_menu_rect(&self) -> Option { let menu = self.context_menu.as_ref()?; let screen = self.screen_rect(); - let menu_w = 14u16.min(screen.width.max(1)); - let menu_h = (CONTEXT_MENU_ITEMS.len() as u16 + 2).min(screen.height.max(1)); + let max_item_w = menu + .items() + .iter() + .map(|item| item.len() as u16) + .max() + .unwrap_or(0); + let menu_w = (max_item_w + 4).max(14).min(screen.width.max(1)); + let menu_h = (menu.items().len() as u16 + 2).min(screen.height.max(1)); let x = menu.x.min(screen.x + screen.width.saturating_sub(menu_w)); let y = menu.y.min(screen.y + screen.height.saturating_sub(menu_h)); Some(Rect::new(x, y, menu_w, menu_h)) @@ -859,7 +1096,7 @@ impl AppState { Rect::new(popup_x, popup_y, popup_w, popup_h) } - fn confirm_close_confirm_button_at(&self, col: u16, row: u16) -> bool { + fn confirm_close_button_at(&self, col: u16, row: u16) -> Option { let popup = self.confirm_close_rect(); let inner = Rect::new( popup.x + 1, @@ -873,7 +1110,16 @@ impl AppState { let total_w = confirm_w + gap + cancel_w; let x = inner.x + inner.width.saturating_sub(total_w) / 2; let y = inner.y + 2.min(inner.height.saturating_sub(1)); - col >= x && col < x + confirm_w && row == y + if row != y { + return None; + } + if col >= x && col < x + confirm_w { + Some(true) + } else if col >= x + confirm_w + gap && col < x + total_w { + Some(false) + } else { + None + } } fn context_menu_item_at(&self, col: u16, row: u16) -> Option { @@ -882,10 +1128,15 @@ impl AppState { let inner_y = menu_rect.y + 1; let inner_w = menu_rect.width.saturating_sub(2); let inner_h = menu_rect.height.saturating_sub(2); + let item_count = self + .context_menu + .as_ref() + .map(|menu| menu.items().len() as u16) + .unwrap_or(0); if col >= inner_x && col < inner_x + inner_w && row >= inner_y - && row < inner_y + inner_h.min(CONTEXT_MENU_ITEMS.len() as u16) + && row < inner_y + inner_h.min(item_count) { Some((row - inner_y) as usize) } else { diff --git a/src/app/mod.rs b/src/app/mod.rs index e1f371ab..c7408e47 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -21,7 +21,7 @@ use crate::config::Config; use crate::events::AppEvent; use crate::workspace::Workspace; -pub use state::{AppState, Mode, ToastKind, ViewState, CONTEXT_MENU_ITEMS}; +pub use state::{AppState, Mode, ToastKind, ViewState}; /// Full application: AppState + runtime concerns (event channels, async I/O). pub struct App { @@ -134,6 +134,7 @@ impl App { sidebar_width: config.ui.sidebar_width, sidebar_collapsed: false, confirm_close: config.ui.confirm_close, + confirm_close_selected_confirm: true, accent: crate::config::parse_color(&config.ui.accent), sound: config.ui.sound.clone(), toast_config: config.ui.toast.clone(), @@ -223,7 +224,7 @@ impl App { self.handle_key(key).await; } Event::Paste(text) => self.handle_paste(text).await, - Event::Mouse(mouse) => self.state.handle_mouse(mouse), + Event::Mouse(mouse) => self.handle_mouse(mouse), Event::Resize(_, _) => {} _ => {} } diff --git a/src/app/state.rs b/src/app/state.rs index 186ede78..fcde6bff 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -396,15 +396,33 @@ pub(crate) struct DragState { pub area: Rect, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ContextMenuKind { + Workspace { ws_idx: usize }, + Pane, +} + /// Right-click context menu state. pub struct ContextMenuState { - pub ws_idx: usize, + pub kind: ContextMenuKind, pub x: u16, pub y: u16, pub selected: usize, } -pub const CONTEXT_MENU_ITEMS: &[&str] = &["Rename", "Close"]; +impl ContextMenuState { + pub fn items(&self) -> &'static [&'static str] { + match self.kind { + ContextMenuKind::Workspace { .. } => &["Rename", "Close"], + ContextMenuKind::Pane => &[ + "Split vertical", + "Split horizontal", + "Fullscreen", + "Close pane", + ], + } + } +} #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ToastKind { @@ -448,6 +466,7 @@ pub struct AppState { pub sidebar_width: u16, pub sidebar_collapsed: bool, pub confirm_close: bool, + pub confirm_close_selected_confirm: bool, #[allow(dead_code)] // kept for backward compat; palette.accent is the source of truth pub accent: Color, pub sound: SoundConfig, @@ -537,6 +556,7 @@ impl AppState { sidebar_width: 26, sidebar_collapsed: false, confirm_close: true, + confirm_close_selected_confirm: true, accent: Color::Cyan, sound: SoundConfig { enabled: false, diff --git a/src/ui.rs b/src/ui.rs index 66fcf0ea..291a2e98 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1095,14 +1095,11 @@ fn render_confirm_close_overlay(app: &AppState, frame: &mut Frame, area: Rect) { // Centered popup let popup_w = 44u16.min(area.width.saturating_sub(4)); - let popup_h = 6u16; + let popup_h = 5u16; let popup_x = area.x + (area.width.saturating_sub(popup_w)) / 2; let popup_y = area.y + (area.height.saturating_sub(popup_h)) / 2; let popup = Rect::new(popup_x, popup_y, popup_w, popup_h); - let key = Style::default() - .fg(app.palette.accent) - .add_modifier(Modifier::BOLD); let warn = Style::default() .fg(app.palette.red) .add_modifier(Modifier::BOLD); @@ -1125,64 +1122,81 @@ fn render_confirm_close_overlay(app: &AppState, frame: &mut Frame, area: Rect) { return; }; - if inner.height >= 4 { + if inner.height >= 3 { let rows = Layout::vertical([ Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), - Constraint::Length(1), ]) - .areas::<4>(inner); + .areas::<3>(inner); frame.render_widget(Paragraph::new(title_line), rows[0]); frame.render_widget(Paragraph::new(detail_line), rows[1]); let (confirm_rect, cancel_rect) = confirm_close_button_rects(inner); + let confirm_selected = app.confirm_close_selected_confirm; frame.render_widget( - Paragraph::new(" confirm ").style( + Paragraph::new(" confirm ").style(if confirm_selected { Style::default() .fg(app.palette.panel_bg) .bg(app.palette.red) - .add_modifier(Modifier::BOLD), - ), - confirm_rect, - ); - frame.render_widget( - Paragraph::new(" cancel ").style( + .add_modifier(Modifier::BOLD) + } else { Style::default() .fg(app.palette.text) .bg(app.palette.surface0) - .add_modifier(Modifier::BOLD), - ), + .add_modifier(Modifier::BOLD) + }), + confirm_rect, + ); + frame.render_widget( + Paragraph::new(" cancel ").style(if confirm_selected { + Style::default() + .fg(app.palette.text) + .bg(app.palette.surface0) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + .fg(app.palette.panel_bg) + .bg(app.palette.accent) + .add_modifier(Modifier::BOLD) + }), cancel_rect, ); - - let hints = Line::from(vec![ - Span::styled(" enter/y", key), - Span::styled(" confirm ", dim), - Span::styled("esc/click", key), - Span::styled(" cancel", dim), - ]); - frame.render_widget(Paragraph::new(hints), rows[3]); } } +fn centered_button_row(inner: Rect, widths: &[u16], gap: u16, row_offset: u16) -> Vec { + let total_w = widths + .iter() + .copied() + .sum::() + .saturating_add(gap.saturating_mul(widths.len().saturating_sub(1) as u16)); + let mut x = inner.x + inner.width.saturating_sub(total_w) / 2; + let y = inner.y + row_offset.min(inner.height.saturating_sub(1)); + widths + .iter() + .map(|w| { + let rect = Rect::new( + x, + y, + (*w).min(inner.width.saturating_sub(x.saturating_sub(inner.x))), + 1, + ); + x = x.saturating_add(*w).saturating_add(gap); + rect + }) + .collect() +} + fn confirm_close_button_rects(inner: Rect) -> (Rect, Rect) { - let confirm_w = 9u16; - let cancel_w = 8u16; - let gap = 2u16; - let total_w = confirm_w + gap + cancel_w; - let x = inner.x + inner.width.saturating_sub(total_w) / 2; - let y = inner.y + 2.min(inner.height.saturating_sub(1)); - ( - Rect::new(x, y, confirm_w.min(inner.width), 1), - Rect::new( - x + confirm_w + gap, - y, - cancel_w.min(inner.width.saturating_sub(confirm_w + gap)), - 1, - ), - ) + let rects = centered_button_row(inner, &[9, 8], 2, 2); + (rects[0], rects[1]) +} + +fn settings_button_rects(inner: Rect) -> (Rect, Rect) { + let rects = centered_button_row(inner, &[7, 7], 2, inner.height.saturating_sub(1)); + (rects[0], rects[1]) } /// Right-click context menu popup anchored near the click position. @@ -1288,21 +1302,38 @@ fn render_settings_overlay(app: &AppState, frame: &mut Frame, area: Rect) { } } - // Footer hints + // Footer buttons + hints let footer_y = inner.y + inner.height - 1; if footer_y > y { + let (apply_rect, close_rect) = settings_button_rects(inner); + frame.render_widget( + Paragraph::new(" apply ").style( + Style::default() + .fg(p.panel_bg) + .bg(p.accent) + .add_modifier(Modifier::BOLD), + ), + apply_rect, + ); + frame.render_widget( + Paragraph::new(" close ").style( + Style::default() + .fg(p.text) + .bg(p.surface0) + .add_modifier(Modifier::BOLD), + ), + close_rect, + ); + + let hint_area = Rect::new(inner.x, footer_y.saturating_sub(1), inner.width, 1); frame.render_widget( Paragraph::new(Line::from(vec![ Span::styled(" ↑↓", Style::default().fg(p.overlay0)), Span::styled(" select ", Style::default().fg(p.overlay1)), Span::styled("tab", Style::default().fg(p.overlay0)), - Span::styled(" section ", Style::default().fg(p.overlay1)), - Span::styled("⏎", Style::default().fg(p.overlay0)), - Span::styled(" apply ", Style::default().fg(p.overlay1)), - Span::styled("esc", Style::default().fg(p.overlay0)), - Span::styled(" close", Style::default().fg(p.overlay1)), + Span::styled(" section", Style::default().fg(p.overlay1)), ])), - Rect::new(inner.x, footer_y, inner.width, 1), + hint_area, ); } } @@ -1392,8 +1423,6 @@ fn render_settings_toggle( } fn render_context_menu(app: &AppState, frame: &mut Frame) { - use crate::app::CONTEXT_MENU_ITEMS; - let Some(menu) = &app.context_menu else { return; }; @@ -1406,7 +1435,8 @@ fn render_context_menu(app: &AppState, frame: &mut Frame) { return; }; - let items: Vec = CONTEXT_MENU_ITEMS + let items: Vec = menu + .items() .iter() .map(|item| ListItem::new(Line::from(*item))) .collect();