aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-04-02 22:46:48 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-04-02 23:25:01 +0300
commit0165c333ddf0707ed4455f48048feb50bd6500ba (patch)
tree616e9c47882680573d6b94703fe93faeef420feb /src
parent0ef1e277f34471a7ffb7a0fdc7e419ec4b8f8e1e (diff)
refactor popup menu and selection list interactions
Diffstat (limited to 'src')
-rw-r--r--src/app/input.rs199
-rw-r--r--src/app/mod.rs8
-rw-r--r--src/app/state.rs68
-rw-r--r--src/ui.rs12
4 files changed, 203 insertions, 84 deletions
diff --git a/src/app/input.rs b/src/app/input.rs
index 562db30d..0e6c5b62 100644
--- a/src/app/input.rs
+++ b/src/app/input.rs
@@ -24,7 +24,8 @@ enum WheelRouting {
}
use super::state::{
- key_matches, AppState, ContextMenuKind, ContextMenuState, DragState, DragTarget, Mode,
+ key_matches, AppState, ContextMenuKind, ContextMenuState, DragState, DragTarget, MenuListState,
+ Mode,
};
use super::App;
@@ -121,21 +122,15 @@ impl App {
},
},
_ => match key.code {
- KeyCode::Up | KeyCode::Char('k') => {
- if self.state.onboarding_selected > 0 {
- self.state.onboarding_selected -= 1;
- }
- }
- KeyCode::Down | KeyCode::Char('j') => {
- if self.state.onboarding_selected < 3 {
- self.state.onboarding_selected += 1;
- }
- }
+ KeyCode::Up | KeyCode::Char('k') => self.state.onboarding_list.move_prev(),
+ KeyCode::Down | KeyCode::Char('j') => self.state.onboarding_list.move_next(4),
KeyCode::Left | KeyCode::Char('h') => {
self.state.onboarding_step = 0;
}
KeyCode::Char(c) if ('1'..='4').contains(&c) => {
- self.state.onboarding_selected = (c as usize) - ('1' as usize);
+ self.state
+ .onboarding_list
+ .select((c as usize) - ('1' as usize));
}
KeyCode::Char('q') => self.state.should_quit = true,
_ => match modal_action_from_key(&key, ONBOARDING_NOTIFICATION_ACTIONS) {
@@ -397,7 +392,7 @@ fn current_theme_index(theme_name: &str) -> usize {
fn preview_selected_theme(state: &mut AppState) {
use crate::app::state::{Palette, THEME_NAMES};
- let name = THEME_NAMES[state.settings.selected];
+ let name = THEME_NAMES[state.settings.list.selected];
if let Some(palette) = Palette::from_name(name) {
state.palette = palette;
state.theme_name = name.to_string();
@@ -436,20 +431,25 @@ fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Option<Settings
match state.settings.section {
SettingsSection::Theme => match key.code {
KeyCode::Up | KeyCode::Char('k') => {
- if state.settings.selected > 0 {
- state.settings.selected -= 1;
+ let previous = state.settings.list.selected;
+ state.settings.list.move_prev();
+ if state.settings.list.selected != previous {
preview_selected_theme(state);
}
}
KeyCode::Down | KeyCode::Char('j') => {
- if state.settings.selected + 1 < crate::app::state::THEME_NAMES.len() {
- state.settings.selected += 1;
+ let previous = state.settings.list.selected;
+ state
+ .settings
+ .list
+ .move_next(crate::app::state::THEME_NAMES.len());
+ if state.settings.list.selected != previous {
preview_selected_theme(state);
}
}
KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
state.settings.section = SettingsSection::Sound;
- state.settings.selected = usize::from(!state.sound_enabled());
+ state.settings.list.selected = usize::from(!state.sound_enabled());
}
_ => match modal_action_from_key(&key, SETTINGS_ACTIONS) {
Some(ModalAction::Apply) => return apply_settings(state),
@@ -459,20 +459,20 @@ fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Option<Settings
},
SettingsSection::Sound => match key.code {
KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
- state.settings.selected = 1 - state.settings.selected.min(1);
+ state.settings.list.selected = 1 - state.settings.list.selected.min(1);
}
KeyCode::Enter | KeyCode::Char(' ') => {
- let enabled = state.settings.selected == 0;
+ let enabled = state.settings.list.selected == 0;
state.sound.enabled = enabled;
return Some(SettingsAction::SaveSound(enabled));
}
KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
state.settings.section = SettingsSection::Toast;
- state.settings.selected = usize::from(!state.toast_config.enabled);
+ state.settings.list.selected = usize::from(!state.toast_config.enabled);
}
KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => {
state.settings.section = SettingsSection::Theme;
- state.settings.selected = current_theme_index(&state.theme_name);
+ state.settings.list.selected = current_theme_index(&state.theme_name);
}
_ => match modal_action_from_key(&key, SETTINGS_ACTIONS) {
Some(ModalAction::Close) => cancel_settings(state),
@@ -481,20 +481,20 @@ fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Option<Settings
},
SettingsSection::Toast => match key.code {
KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
- state.settings.selected = 1 - state.settings.selected.min(1);
+ state.settings.list.selected = 1 - state.settings.list.selected.min(1);
}
KeyCode::Enter | KeyCode::Char(' ') => {
- let enabled = state.settings.selected == 0;
+ let enabled = state.settings.list.selected == 0;
state.toast_config.enabled = enabled;
return Some(SettingsAction::SaveToast(enabled));
}
KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => {
state.settings.section = SettingsSection::Sound;
- state.settings.selected = usize::from(!state.sound_enabled());
+ state.settings.list.selected = usize::from(!state.sound_enabled());
}
KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
state.settings.section = SettingsSection::Theme;
- state.settings.selected = current_theme_index(&state.theme_name);
+ state.settings.list.selected = current_theme_index(&state.theme_name);
}
_ => match modal_action_from_key(&key, SETTINGS_ACTIONS) {
Some(ModalAction::Close) => cancel_settings(state),
@@ -507,7 +507,7 @@ fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Option<Settings
}
fn open_global_menu(state: &mut AppState) {
- state.global_menu_selected = 0;
+ state.global_menu = MenuListState::new(0);
state.mode = Mode::GlobalMenu;
}
@@ -525,15 +525,12 @@ fn apply_global_menu_action(state: &mut AppState, action: GlobalMenuAction) {
fn handle_global_menu_key(state: &mut AppState, key: KeyEvent) {
match key.code {
KeyCode::Esc | KeyCode::Char('q') => leave_modal(state),
- KeyCode::Up | KeyCode::Char('k') => {
- state.global_menu_selected = state.global_menu_selected.saturating_sub(1);
- }
+ KeyCode::Up | KeyCode::Char('k') => state.global_menu.move_prev(),
KeyCode::Down | KeyCode::Char('j') => {
- state.global_menu_selected =
- (state.global_menu_selected + 1).min(GlobalMenuAction::ALL.len() - 1);
+ state.global_menu.move_next(GlobalMenuAction::ALL.len())
}
KeyCode::Enter => {
- let action = GlobalMenuAction::ALL[state.global_menu_selected];
+ let action = GlobalMenuAction::ALL[state.global_menu.highlighted];
apply_global_menu_action(state, action);
}
_ => {}
@@ -851,7 +848,7 @@ fn open_settings(state: &mut AppState) {
state.settings.original_palette = Some(state.palette.clone());
state.settings.original_theme = Some(state.theme_name.clone());
state.settings.section = SettingsSection::Theme;
- state.settings.selected = current_theme_index(&state.theme_name);
+ state.settings.list.selected = current_theme_index(&state.theme_name);
state.mode = Mode::Settings;
}
@@ -1036,21 +1033,17 @@ fn handle_context_menu_key(state: &mut AppState, key: KeyEvent) {
}
KeyCode::Up => {
if let Some(menu) = &mut state.context_menu {
- if menu.selected > 0 {
- menu.selected -= 1;
- }
+ menu.list.move_prev();
}
}
KeyCode::Down => {
if let Some(menu) = &mut state.context_menu {
- if menu.selected + 1 < menu.items().len() {
- menu.selected += 1;
- }
+ menu.list.move_next(menu.items().len());
}
}
KeyCode::Enter => {
if let Some(menu) = state.context_menu.take() {
- let idx = menu.selected;
+ let idx = menu.list.highlighted;
apply_context_menu_action(state, menu, idx);
}
}
@@ -1188,10 +1181,7 @@ impl AppState {
}
fn handle_onboarding_mouse(&mut self, mouse: MouseEvent) {
- if !matches!(
- mouse.kind,
- MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Moved
- ) {
+ if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
return;
}
@@ -1222,7 +1212,8 @@ 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;
+ self.onboarding_list
+ .select((mouse.row - options_start_y) as usize);
return;
}
@@ -1362,8 +1353,8 @@ impl AppState {
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
+ let scroll = if self.settings.list.selected >= max_visible {
+ self.settings.list.selected - max_visible + 1
} else {
0
};
@@ -1389,15 +1380,15 @@ impl AppState {
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 {
+ self.settings.list.select(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.column, mouse.row) {
- self.settings.selected = idx;
+ self.settings.list.select(idx);
return match self.settings.section {
SettingsSection::Theme => {
preview_selected_theme(self);
@@ -1465,12 +1456,14 @@ impl AppState {
&& mouse.row < launcher.y + launcher.height;
if matches!(mouse.kind, MouseEventKind::Moved) && self.mode == Mode::GlobalMenu {
- if let Some(action) = self.global_menu_item_at(mouse.column, mouse.row) {
- self.global_menu_selected = GlobalMenuAction::ALL
- .iter()
- .position(|item| *item == action)
- .unwrap_or(0);
- }
+ let hovered = self
+ .global_menu_item_at(mouse.column, mouse.row)
+ .and_then(|action| {
+ GlobalMenuAction::ALL
+ .iter()
+ .position(|item| *item == action)
+ });
+ self.global_menu.hover(hovered);
return None;
}
@@ -1764,10 +1757,9 @@ impl AppState {
}
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;
- }
+ let hovered = self.context_menu_item_at(mouse.column, mouse.row);
+ if let Some(menu) = &mut self.context_menu {
+ menu.list.hover(hovered);
}
}
@@ -1778,7 +1770,7 @@ impl AppState {
kind: ContextMenuKind::Workspace { ws_idx: idx },
x: mouse.column,
y: mouse.row,
- selected: 0,
+ list: MenuListState::new(0),
});
self.mode = Mode::ContextMenu;
}
@@ -1795,7 +1787,7 @@ impl AppState {
kind: ContextMenuKind::Tab { ws_idx, tab_idx },
x: mouse.column,
y: mouse.row,
- selected: 0,
+ list: MenuListState::new(0),
});
self.mode = Mode::ContextMenu;
}
@@ -1807,7 +1799,7 @@ impl AppState {
kind: ContextMenuKind::Pane,
x: mouse.column,
y: mouse.row,
- selected: 0,
+ list: MenuListState::new(0),
});
self.mode = Mode::ContextMenu;
}
@@ -2368,7 +2360,7 @@ mod tests {
let mut state = state_with_workspaces(&["test"]);
open_settings(&mut state);
state.settings.section = crate::app::state::SettingsSection::Sound;
- state.settings.selected = 0;
+ state.settings.list.selected = 0;
let action = update_settings_state(
&mut state,
@@ -2458,6 +2450,22 @@ mod tests {
}
#[test]
+ fn hovering_global_menu_updates_highlight() {
+ let mut app = app_for_mouse_test();
+ let launcher = app.state.global_launcher_rect();
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ launcher.x,
+ launcher.y,
+ ));
+
+ let menu = app.state.global_menu_rect();
+ app.handle_mouse(mouse(MouseEventKind::Moved, menu.x + 2, menu.y + 2));
+
+ assert_eq!(app.state.global_menu.highlighted, 1);
+ }
+
+ #[test]
fn clicking_keybinds_menu_item_opens_help() {
let mut app = app_for_mouse_test();
let launcher = app.state.global_launcher_rect();
@@ -2498,6 +2506,65 @@ mod tests {
}
#[test]
+ fn hovering_context_menu_updates_highlight() {
+ let mut app = app_for_mouse_test();
+ app.state.context_menu = Some(ContextMenuState {
+ kind: ContextMenuKind::Workspace { ws_idx: 0 },
+ x: 2,
+ y: 2,
+ list: MenuListState::new(0),
+ });
+ app.state.mode = Mode::ContextMenu;
+
+ let menu = app.state.context_menu_rect().unwrap();
+ app.handle_mouse(mouse(MouseEventKind::Moved, menu.x + 2, menu.y + 2));
+
+ assert_eq!(app.state.context_menu.unwrap().list.highlighted, 1);
+ }
+
+ #[test]
+ fn onboarding_hover_does_not_change_selection() {
+ let mut app = app_for_mouse_test();
+ app.state.mode = Mode::Onboarding;
+ app.state.onboarding_step = 1;
+ app.state.onboarding_list.select(1);
+
+ let inner = app.state.onboarding_modal_inner(52, 10).unwrap();
+ app.handle_mouse(mouse(MouseEventKind::Moved, inner.x + 2, inner.y + 4));
+
+ assert_eq!(app.state.onboarding_list.selected, 1);
+ }
+
+ #[test]
+ fn onboarding_click_selects_notification_option() {
+ let mut app = app_for_mouse_test();
+ app.state.mode = Mode::Onboarding;
+ app.state.onboarding_step = 1;
+ app.state.onboarding_list.select(0);
+
+ let inner = app.state.onboarding_modal_inner(52, 10).unwrap();
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ inner.x + 2,
+ inner.y + 4,
+ ));
+
+ assert_eq!(app.state.onboarding_list.selected, 2);
+ }
+
+ #[test]
+ fn settings_hover_does_not_change_selection() {
+ let mut app = app_for_mouse_test();
+ open_settings(&mut app.state);
+ app.state.settings.list.select(0);
+
+ let area = app.state.settings_content_rect();
+ app.handle_mouse(mouse(MouseEventKind::Moved, area.x + 2, area.y + 2));
+
+ assert_eq!(app.state.settings.list.selected, 0);
+ }
+
+ #[test]
fn clicking_confirm_close_accepts_workspace_close() {
let mut app = app_for_mouse_test();
app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")];
@@ -2536,7 +2603,7 @@ mod tests {
kind: ContextMenuKind::Workspace { ws_idx: 1 },
x: 2,
y: 2,
- selected: 1,
+ list: MenuListState::new(1),
});
app.state.mode = Mode::ContextMenu;
handle_context_menu_key(
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 996edbad..5cc29e3a 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -190,7 +190,7 @@ impl App {
request_complete_onboarding: false,
name_input: String::new(),
onboarding_step: 0,
- onboarding_selected: 1,
+ onboarding_list: state::SelectionListState::new(1),
release_notes: startup_release_notes.map(|notes| state::ReleaseNotesState {
version: notes.version,
body: notes.body,
@@ -232,11 +232,11 @@ impl App {
.unwrap_or_else(|| "catppuccin".to_string()),
settings: state::SettingsState {
section: state::SettingsSection::Theme,
- selected: 0,
+ list: state::SelectionListState::new(0),
original_palette: None,
original_theme: None,
},
- global_menu_selected: 0,
+ global_menu: state::MenuListState::new(0),
};
for ws in &mut state.workspaces {
@@ -1380,7 +1380,7 @@ impl App {
}
pub(crate) fn complete_onboarding(&mut self) {
- let (sound_enabled, toast_enabled) = match self.state.onboarding_selected {
+ let (sound_enabled, toast_enabled) = match self.state.onboarding_list.selected {
0 => (false, false),
1 => (false, true),
2 => (true, false),
diff --git a/src/app/state.rs b/src/app/state.rs
index d80903cf..91e9061e 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -385,11 +385,63 @@ pub const THEME_NAMES: &[&str] = &[
"rose-pine",
];
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct MenuListState {
+ pub highlighted: usize,
+}
+
+impl MenuListState {
+ pub fn new(highlighted: usize) -> Self {
+ Self { highlighted }
+ }
+
+ pub fn move_prev(&mut self) {
+ self.highlighted = self.highlighted.saturating_sub(1);
+ }
+
+ pub fn move_next(&mut self, item_count: usize) {
+ if item_count > 0 {
+ self.highlighted = (self.highlighted + 1).min(item_count - 1);
+ }
+ }
+
+ pub fn hover(&mut self, idx: Option<usize>) {
+ if let Some(idx) = idx {
+ self.highlighted = idx;
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct SelectionListState {
+ pub selected: usize,
+}
+
+impl SelectionListState {
+ pub fn new(selected: usize) -> Self {
+ Self { selected }
+ }
+
+ pub fn move_prev(&mut self) {
+ self.selected = self.selected.saturating_sub(1);
+ }
+
+ pub fn move_next(&mut self, item_count: usize) {
+ if item_count > 0 {
+ self.selected = (self.selected + 1).min(item_count - 1);
+ }
+ }
+
+ pub fn select(&mut self, idx: usize) {
+ self.selected = idx;
+ }
+}
+
pub struct SettingsState {
/// Which section tab is active.
pub section: SettingsSection,
/// Selected item index within the current section.
- pub selected: usize,
+ pub list: SelectionListState,
/// The palette before opening settings (for cancel/restore).
pub original_palette: Option<Palette>,
/// The theme name before opening settings.
@@ -429,7 +481,7 @@ pub struct ContextMenuState {
pub kind: ContextMenuKind,
pub x: u16,
pub y: u16,
- pub selected: usize,
+ pub list: MenuListState,
}
impl ContextMenuState {
@@ -481,7 +533,7 @@ pub struct AppState {
pub request_complete_onboarding: bool,
pub name_input: String,
pub onboarding_step: usize,
- pub onboarding_selected: usize,
+ pub onboarding_list: SelectionListState,
pub release_notes: Option<ReleaseNotesState>,
// View geometry (computed before render, consumed by render + mouse)
pub view: ViewState,
@@ -513,8 +565,8 @@ pub struct AppState {
pub theme_name: String,
/// Settings panel state.
pub settings: SettingsState,
- /// Currently selected item in the bottom-right global launcher menu.
- pub global_menu_selected: usize,
+ /// Highlight state for the bottom-right global launcher menu.
+ pub global_menu: MenuListState,
}
impl AppState {
@@ -573,7 +625,7 @@ impl AppState {
request_complete_onboarding: false,
name_input: String::new(),
onboarding_step: 0,
- onboarding_selected: 1,
+ onboarding_list: SelectionListState::new(1),
release_notes: None,
view: ViewState {
sidebar_rect: Rect::default(),
@@ -642,11 +694,11 @@ impl AppState {
theme_name: "catppuccin".to_string(),
settings: SettingsState {
section: SettingsSection::Theme,
- selected: 0,
+ list: SelectionListState::new(0),
original_palette: None,
original_theme: None,
},
- global_menu_selected: 0,
+ global_menu: MenuListState::new(0),
}
}
}
diff --git a/src/ui.rs b/src/ui.rs
index 76a65d34..20f37b5a 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -1434,7 +1434,7 @@ fn render_onboarding_notifications(app: &AppState, frame: &mut Frame, area: Rect
];
for (idx, option) in options.iter().enumerate() {
- let selected = idx == app.onboarding_selected;
+ let selected = idx == app.onboarding_list.selected;
let prefix = if selected { "›" } else { " " };
let style = if selected {
Style::default()
@@ -1537,7 +1537,7 @@ fn render_global_launcher_menu(app: &AppState, frame: &mut Frame) {
if y >= inner.y + inner.height {
break;
}
- let selected = idx == app.global_menu_selected;
+ let selected = idx == app.global_menu.highlighted;
let style = if selected {
Style::default()
.fg(app.palette.panel_bg)
@@ -2079,7 +2079,7 @@ fn render_settings_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
"sound alerts",
"play sounds when agents change state in background",
app.sound_enabled(),
- app.settings.selected,
+ app.settings.list.selected,
);
}
SettingsSection::Toast => {
@@ -2090,7 +2090,7 @@ fn render_settings_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
"visual toasts",
"show top-right notifications for background events",
app.toast_config.enabled,
- app.settings.selected,
+ app.settings.list.selected,
);
}
}
@@ -2161,7 +2161,7 @@ fn render_settings_theme(app: &AppState, frame: &mut Frame, area: Rect) {
.highlight_symbol(" ▸ ")
.style(Style::default().fg(p.subtext0));
- let mut state = ListState::default().with_selected(Some(app.settings.selected));
+ let mut state = ListState::default().with_selected(Some(app.settings.list.selected));
frame.render_stateful_widget(list, area, &mut state);
}
@@ -2244,7 +2244,7 @@ fn render_context_menu(app: &AppState, frame: &mut Frame) {
.add_modifier(Modifier::BOLD),
)
.highlight_symbol(" ");
- let mut state = ListState::default().with_selected(Some(menu.selected));
+ let mut state = ListState::default().with_selected(Some(menu.list.highlighted));
frame.render_stateful_widget(list, inner, &mut state);
}