add mouse-driven popup interactions

This commit is contained in:
Ogulcan Celik
2026-03-29 23:16:44 +03:00
parent a202425340
commit 00e5da8704
4 changed files with 448 additions and 146 deletions
+344 -93
View File
@@ -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<SettingsAction> {
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<SettingsAction> {
use crate::app::state::SettingsSection;
@@ -176,13 +202,7 @@ fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Option<Settings
preview_selected_theme(state);
}
}
KeyCode::Enter => {
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<crate::app::state::SettingsSection> {
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<usize> {
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<SettingsAction> {
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<SettingsAction> {
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<Rect> {
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<bool> {
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<usize> {
@@ -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 {
+3 -2
View File
@@ -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(_, _) => {}
_ => {}
}
+22 -2
View File
@@ -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,
+79 -49
View File
@@ -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<Rect> {
let total_w = widths
.iter()
.copied()
.sum::<u16>()
.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<ListItem> = CONTEXT_MENU_ITEMS
let items: Vec<ListItem> = menu
.items()
.iter()
.map(|item| ListItem::new(Line::from(*item)))
.collect();