aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-03-29 23:16:44 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-03-29 23:16:44 +0300
commit00e5da8704ecc1a59fbb270e01defdf7dcd9593e (patch)
tree86aacc5ee279901e9ec4ab493c3f9cc2bcbd0420
parenta2024253401f1bf2952290bf549dc71ca73502db (diff)
add mouse-driven popup interactions
-rw-r--r--src/app/input.rs437
-rw-r--r--src/app/mod.rs5
-rw-r--r--src/app/state.rs24
-rw-r--r--src/ui.rs122
4 files changed, 445 insertions, 143 deletions
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<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 {
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),
- ),
+ .add_modifier(Modifier::BOLD)
+ } else {
+ Style::default()
+ .fg(app.palette.text)
+ .bg(app.palette.surface0)
+ .add_modifier(Modifier::BOLD)
+ }),
confirm_rect,
);
frame.render_widget(
- Paragraph::new(" cancel ").style(
+ Paragraph::new(" cancel ").style(if confirm_selected {
Style::default()
.fg(app.palette.text)
.bg(app.palette.surface0)
- .add_modifier(Modifier::BOLD),
- ),
+ .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();