aboutsummaryrefslogtreecommitdiff
path: root/src/app
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-03-29 22:48:02 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-03-29 22:48:02 +0300
commit9badd642b7c0c71301cb5034245706f700401ba0 (patch)
treec598ece50568519387e7c7eb92d3a79edaf37b47 /src/app
parent101de0ce453615c809d95175532bcb86f816163a (diff)
fix popup rendering and settings theme flow
Diffstat (limited to 'src/app')
-rw-r--r--src/app/input.rs384
-rw-r--r--src/app/mod.rs51
-rw-r--r--src/app/state.rs80
3 files changed, 468 insertions, 47 deletions
diff --git a/src/app/input.rs b/src/app/input.rs
index 7d2e709e..eca37247 100644
--- a/src/app/input.rs
+++ b/src/app/input.rs
@@ -2,7 +2,7 @@
use bytes::Bytes;
use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
-use ratatui::layout::Direction;
+use ratatui::layout::{Direction, Rect};
use tracing::warn;
use crate::layout::{NavDirection, PaneInfo, SplitBorder};
@@ -25,6 +25,7 @@ impl App {
Mode::Resize => handle_resize_key(&mut self.state, key),
Mode::ConfirmClose => handle_confirm_close_key(&mut self.state, key),
Mode::ContextMenu => handle_context_menu_key(&mut self.state, key),
+ Mode::Settings => self.handle_settings_key(key),
}
}
@@ -83,6 +84,16 @@ impl App {
}
}
+ fn handle_settings_key(&mut self, key: KeyEvent) {
+ if let Some(action) = update_settings_state(&mut self.state, key) {
+ 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;
@@ -107,6 +118,123 @@ impl App {
}
}
+#[derive(Debug, Clone, PartialEq, Eq)]
+enum SettingsAction {
+ SaveTheme(String),
+ SaveSound(bool),
+ SaveToast(bool),
+}
+
+fn normalize_theme_name(name: &str) -> String {
+ name.to_lowercase().replace([' ', '_'], "-")
+}
+
+fn current_theme_index(theme_name: &str) -> usize {
+ use crate::app::state::THEME_NAMES;
+
+ let normalized = normalize_theme_name(theme_name);
+ THEME_NAMES
+ .iter()
+ .position(|name| normalize_theme_name(name) == normalized)
+ .unwrap_or(0)
+}
+
+fn preview_selected_theme(state: &mut AppState) {
+ use crate::app::state::{Palette, THEME_NAMES};
+
+ let name = THEME_NAMES[state.settings.selected];
+ if let Some(palette) = Palette::from_name(name) {
+ state.palette = palette;
+ state.theme_name = name.to_string();
+ }
+}
+
+fn cancel_settings(state: &mut AppState) {
+ if let Some(palette) = state.settings.original_palette.take() {
+ state.palette = palette;
+ }
+ if let Some(theme_name) = state.settings.original_theme.take() {
+ state.theme_name = theme_name;
+ }
+ leave_modal(state);
+}
+
+fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Option<SettingsAction> {
+ use crate::app::state::SettingsSection;
+
+ match state.settings.section {
+ SettingsSection::Theme => match key.code {
+ KeyCode::Up | KeyCode::Char('k') => {
+ if state.settings.selected > 0 {
+ state.settings.selected -= 1;
+ preview_selected_theme(state);
+ }
+ }
+ KeyCode::Down | KeyCode::Char('j') => {
+ if state.settings.selected + 1 < crate::app::state::THEME_NAMES.len() {
+ state.settings.selected += 1;
+ 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::Tab | KeyCode::Right | KeyCode::Char('l') => {
+ state.settings.section = SettingsSection::Sound;
+ state.settings.selected = usize::from(!state.sound_enabled());
+ }
+ KeyCode::Esc | KeyCode::Char('q') => cancel_settings(state),
+ _ => {}
+ },
+ SettingsSection::Sound => match key.code {
+ KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
+ state.settings.selected = 1 - state.settings.selected.min(1);
+ }
+ KeyCode::Enter | KeyCode::Char(' ') => {
+ let enabled = state.settings.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);
+ }
+ KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => {
+ state.settings.section = SettingsSection::Theme;
+ state.settings.selected = current_theme_index(&state.theme_name);
+ }
+ KeyCode::Esc | KeyCode::Char('q') => cancel_settings(state),
+ _ => {}
+ },
+ SettingsSection::Toast => match key.code {
+ KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
+ state.settings.selected = 1 - state.settings.selected.min(1);
+ }
+ KeyCode::Enter | KeyCode::Char(' ') => {
+ let enabled = state.settings.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());
+ }
+ KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
+ state.settings.section = SettingsSection::Theme;
+ state.settings.selected = current_theme_index(&state.theme_name);
+ }
+ KeyCode::Esc | KeyCode::Char('q') => cancel_settings(state),
+ _ => {}
+ },
+ }
+
+ None
+}
+
fn handle_navigate_key(state: &mut AppState, key: KeyEvent) {
state.update_dismissed = true;
@@ -135,6 +263,9 @@ fn handle_navigate_key(state: &mut AppState, key: KeyEvent) {
leave_navigate_mode(state);
}
}
+ KeyCode::Char('s') => {
+ open_settings(state);
+ }
KeyCode::Up => {
if state.selected > 0 {
state.selected -= 1;
@@ -252,6 +383,27 @@ fn leave_navigate_mode(state: &mut AppState) {
}
}
+/// Return to the appropriate mode after completing a modal action.
+/// Goes to Terminal if a workspace is active, otherwise Navigate.
+fn leave_modal(state: &mut AppState) {
+ if state.active.is_some() {
+ state.mode = Mode::Terminal;
+ } else {
+ state.mode = Mode::Navigate;
+ }
+}
+
+fn open_settings(state: &mut AppState) {
+ use crate::app::state::SettingsSection;
+
+ // Save current state for cancel
+ 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.mode = Mode::Settings;
+}
+
fn handle_rename_key(state: &mut AppState, key: KeyEvent) {
match key.code {
KeyCode::Enter => {
@@ -306,19 +458,23 @@ fn handle_resize_key(state: &mut AppState, key: KeyEvent) {
}
}
+fn confirm_close_accept(state: &mut AppState) {
+ state.close_selected_workspace();
+ if state.workspaces.is_empty() {
+ state.mode = Mode::Navigate;
+ } else {
+ state.mode = Mode::Terminal;
+ }
+}
+
+fn confirm_close_cancel(state: &mut AppState) {
+ state.mode = Mode::Navigate;
+}
+
fn handle_confirm_close_key(state: &mut AppState, key: KeyEvent) {
match key.code {
- KeyCode::Char('y') | KeyCode::Enter => {
- state.close_selected_workspace();
- if state.workspaces.is_empty() {
- state.mode = Mode::Navigate;
- } else {
- state.mode = Mode::Terminal;
- }
- }
- _ => {
- state.mode = Mode::Navigate;
- }
+ KeyCode::Char('y') | KeyCode::Enter => confirm_close_accept(state),
+ _ => confirm_close_cancel(state),
}
}
@@ -452,6 +608,15 @@ impl AppState {
MouseEventKind::Down(MouseButton::Left) => {
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);
+ }
+ return;
+ }
+
if self.mode == Mode::ContextMenu {
if let Some(menu) = &self.context_menu {
let item_idx = self.context_menu_item_at(mouse.column, mouse.row);
@@ -495,26 +660,33 @@ impl AppState {
}
if in_sidebar {
- let bottom_y = sidebar.y + sidebar.height.saturating_sub(1);
- let new_row_y = bottom_y.saturating_sub(1);
-
- if mouse.row == bottom_y {
- self.sidebar_collapsed = !self.sidebar_collapsed;
+ 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;
}
- if !self.sidebar_collapsed && mouse.row == new_row_y {
+ // 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;
}
- let inner_y = sidebar.y;
- if mouse.row >= inner_y && mouse.row < new_row_y {
- let idx = ((mouse.row - inner_y) / 2) as usize;
- if idx < self.workspaces.len() {
- self.switch_workspace(idx);
- self.mode = Mode::Terminal;
- }
+ // Workspace clicks in top section
+ if let Some(idx) = self.workspace_at_row(mouse.row) {
+ self.switch_workspace(idx);
+ self.mode = Mode::Terminal;
+ return;
}
} else if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() {
let (row, col) = (
@@ -614,22 +786,16 @@ impl AppState {
}
}
- MouseEventKind::Down(MouseButton::Right) if in_sidebar => {
- let inner_y = sidebar.y;
- let bottom_y = sidebar.y + sidebar.height.saturating_sub(1);
- let new_row_y = bottom_y.saturating_sub(1);
- if mouse.row >= inner_y && mouse.row < new_row_y {
- let idx = ((mouse.row - inner_y) / 2) as usize;
- if idx < self.workspaces.len() {
- self.selected = idx;
- self.context_menu = Some(ContextMenuState {
- ws_idx: idx,
- x: mouse.column,
- y: mouse.row,
- selected: 0,
- });
- self.mode = Mode::ContextMenu;
- }
+ 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,
+ x: mouse.column,
+ y: mouse.row,
+ selected: 0,
+ });
+ self.mode = Mode::ContextMenu;
}
}
@@ -637,15 +803,89 @@ impl AppState {
}
}
- fn context_menu_item_at(&self, col: u16, row: u16) -> Option<usize> {
+ /// Find which workspace index a sidebar row belongs to (two-section layout).
+ fn workspace_at_row(&self, row: u16) -> Option<usize> {
+ let sidebar = self.view.sidebar_rect;
+ let total_h = sidebar.height as usize;
+ let ws_h = (total_h + 1) / 2;
+ let ws_bottom = sidebar.y + ws_h as u16;
+ let new_row = ws_bottom.saturating_sub(1);
+
+ if row < sidebar.y || row >= new_row {
+ return None;
+ }
+
+ let mut row_y = sidebar.y;
+ for (i, ws) in self.workspaces.iter().enumerate() {
+ let has_branch = ws.branch().is_some();
+ let card_h: u16 = if has_branch { 2 } else { 1 };
+ if row >= row_y && row < row_y + card_h {
+ return Some(i);
+ }
+ row_y += card_h + 1; // +1 for gap
+ if row_y >= new_row {
+ break;
+ }
+ }
+ None
+ }
+
+ fn screen_rect(&self) -> Rect {
+ let sidebar = self.view.sidebar_rect;
+ let terminal = self.view.terminal_area;
+ let x = sidebar.x.min(terminal.x);
+ let y = sidebar.y.min(terminal.y);
+ let right = (sidebar.x + sidebar.width).max(terminal.x + terminal.width);
+ let bottom = (sidebar.y + sidebar.height).max(terminal.y + terminal.height);
+ Rect::new(x, y, right.saturating_sub(x), bottom.saturating_sub(y))
+ }
+
+ pub(crate) fn context_menu_rect(&self) -> Option<Rect> {
let menu = self.context_menu.as_ref()?;
- let menu_w = 14u16;
- let inner_x = menu.x;
- let inner_y = menu.y + 1;
+ 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 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))
+ }
+
+ pub(crate) fn confirm_close_rect(&self) -> Rect {
+ let area = self.view.terminal_area;
+ let popup_w = 44u16.min(area.width.saturating_sub(4));
+ let popup_h = 6u16.min(area.height.max(1));
+ 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 confirm_close_confirm_button_at(&self, col: u16, row: u16) -> bool {
+ let popup = self.confirm_close_rect();
+ let inner = Rect::new(
+ popup.x + 1,
+ popup.y + 1,
+ popup.width.saturating_sub(2),
+ popup.height.saturating_sub(2),
+ );
+ 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));
+ col >= x && col < x + confirm_w && row == y
+ }
+
+ fn context_menu_item_at(&self, col: u16, row: u16) -> Option<usize> {
+ let menu_rect = self.context_menu_rect()?;
+ let inner_x = menu_rect.x + 1;
+ 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);
if col >= inner_x
- && col < inner_x + menu_w
+ && col < inner_x + inner_w
&& row >= inner_y
- && row < inner_y + CONTEXT_MENU_ITEMS.len() as u16
+ && row < inner_y + inner_h.min(CONTEXT_MENU_ITEMS.len() as u16)
{
Some((row - inner_y) as usize)
} else {
@@ -824,4 +1064,54 @@ mod tests {
assert_eq!(state.mode, Mode::Terminal);
}
+
+ #[test]
+ fn settings_cancel_restores_previewed_theme_from_other_sections() {
+ let mut state = state_with_workspaces(&["test"]);
+ let original_palette = state.palette.clone();
+ let original_theme = state.theme_name.clone();
+
+ open_settings(&mut state);
+ update_settings_state(
+ &mut state,
+ KeyEvent::new(KeyCode::Down, KeyModifiers::empty()),
+ );
+ assert_ne!(state.theme_name, original_theme);
+
+ update_settings_state(
+ &mut state,
+ KeyEvent::new(KeyCode::Tab, KeyModifiers::empty()),
+ );
+ assert_eq!(
+ state.settings.section,
+ crate::app::state::SettingsSection::Sound
+ );
+
+ update_settings_state(
+ &mut state,
+ KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()),
+ );
+
+ assert_eq!(state.mode, Mode::Terminal);
+ assert_eq!(state.theme_name, original_theme);
+ assert_eq!(state.palette.accent, original_palette.accent);
+ assert_eq!(state.palette.panel_bg, original_palette.panel_bg);
+ }
+
+ #[test]
+ fn settings_sound_toggle_returns_save_action() {
+ let mut state = state_with_workspaces(&["test"]);
+ open_settings(&mut state);
+ state.settings.section = crate::app::state::SettingsSection::Sound;
+ state.settings.selected = 0;
+
+ let action = update_settings_state(
+ &mut state,
+ KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
+ );
+
+ assert_eq!(action, Some(SettingsAction::SaveSound(true)));
+ assert!(state.sound.enabled);
+ assert_eq!(state.mode, Mode::Settings);
+ }
}
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 6562c5d6..e1f371ab 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -140,6 +140,17 @@ impl App {
keybinds: config.keybinds(),
spinner_tick: 0,
palette: resolve_palette(&config),
+ theme_name: config
+ .theme
+ .name
+ .clone()
+ .unwrap_or_else(|| "catppuccin".to_string()),
+ settings: state::SettingsState {
+ section: state::SettingsSection::Theme,
+ selected: 0,
+ original_palette: None,
+ original_theme: None,
+ },
};
// Background auto-update (skipped in --no-session / test mode)
@@ -902,6 +913,46 @@ impl App {
}
}
+ fn update_config_file<F>(&mut self, error_context: &str, update: F)
+ where
+ F: FnOnce(&str) -> String,
+ {
+ let path = crate::config::config_path();
+ if let Some(parent) = path.parent() {
+ if let Err(err) = std::fs::create_dir_all(parent) {
+ self.state.config_diagnostic =
+ Some(format!("failed to save {error_context}: {err}"));
+ self.config_diagnostic_deadline = Some(Instant::now() + Duration::from_secs(5));
+ return;
+ }
+ }
+
+ let content = std::fs::read_to_string(&path).unwrap_or_default();
+ let new_content = update(&content);
+ if let Err(err) = std::fs::write(&path, new_content) {
+ self.state.config_diagnostic = Some(format!("failed to save {error_context}: {err}"));
+ self.config_diagnostic_deadline = Some(Instant::now() + Duration::from_secs(5));
+ }
+ }
+
+ fn save_theme(&mut self, name: &str) {
+ self.update_config_file("theme", |content| {
+ crate::config::upsert_section_value(content, "theme", "name", &format!("\"{name}\""))
+ });
+ }
+
+ fn save_sound(&mut self, enabled: bool) {
+ self.update_config_file("sound setting", |content| {
+ crate::config::upsert_section_bool(content, "ui.sound", "enabled", enabled)
+ });
+ }
+
+ fn save_toast(&mut self, enabled: bool) {
+ self.update_config_file("toast setting", |content| {
+ crate::config::upsert_section_bool(content, "ui.toast", "enabled", enabled)
+ });
+ }
+
/// Create a workspace with a real PTY (needs event_tx).
fn create_workspace(&mut self) {
let initial_cwd = self
diff --git a/src/app/state.rs b/src/app/state.rs
index 2a91dc38..186ede78 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -13,10 +13,13 @@ use crate::workspace::Workspace;
/// All colors used by the UI. Derived from a base accent color for now,
/// but structured so a full theme system can replace it later.
+#[derive(Clone)]
#[allow(dead_code)] // all fields defined for theming — some used later
pub struct Palette {
/// Primary accent (highlight, active borders).
pub accent: Color,
+ /// Background for floating panels, overlays, and modals.
+ pub panel_bg: Color,
/// Subtle surface background for selected/focused items.
pub surface0: Color,
/// Slightly lighter surface for hover/active states.
@@ -52,6 +55,7 @@ impl Palette {
pub fn catppuccin() -> Self {
Self {
accent: Color::Rgb(137, 180, 250), // blue
+ panel_bg: Color::Rgb(24, 24, 37),
surface0: Color::Rgb(49, 50, 68),
surface1: Color::Rgb(69, 71, 90),
surface_dim: Color::Rgb(30, 30, 46),
@@ -73,6 +77,7 @@ impl Palette {
pub fn tokyo_night() -> Self {
Self {
accent: Color::Rgb(122, 162, 247), // blue
+ panel_bg: Color::Rgb(26, 27, 38),
surface0: Color::Rgb(36, 40, 59),
surface1: Color::Rgb(65, 72, 104),
surface_dim: Color::Rgb(26, 27, 38),
@@ -94,6 +99,7 @@ impl Palette {
pub fn dracula() -> Self {
Self {
accent: Color::Rgb(189, 147, 249), // purple
+ panel_bg: Color::Rgb(40, 42, 54),
surface0: Color::Rgb(68, 71, 90),
surface1: Color::Rgb(98, 114, 164),
surface_dim: Color::Rgb(40, 42, 54),
@@ -115,6 +121,7 @@ impl Palette {
pub fn nord() -> Self {
Self {
accent: Color::Rgb(136, 192, 208), // frost
+ panel_bg: Color::Rgb(46, 52, 64),
surface0: Color::Rgb(59, 66, 82),
surface1: Color::Rgb(67, 76, 94),
surface_dim: Color::Rgb(46, 52, 64),
@@ -136,6 +143,7 @@ impl Palette {
pub fn gruvbox() -> Self {
Self {
accent: Color::Rgb(215, 153, 33), // yellow
+ panel_bg: Color::Rgb(40, 40, 40),
surface0: Color::Rgb(60, 56, 54),
surface1: Color::Rgb(80, 73, 69),
surface_dim: Color::Rgb(40, 40, 40),
@@ -157,6 +165,7 @@ impl Palette {
pub fn one_dark() -> Self {
Self {
accent: Color::Rgb(97, 175, 239), // blue
+ panel_bg: Color::Rgb(40, 44, 52),
surface0: Color::Rgb(44, 49, 58),
surface1: Color::Rgb(62, 68, 81),
surface_dim: Color::Rgb(40, 44, 52),
@@ -178,6 +187,7 @@ impl Palette {
pub fn solarized() -> Self {
Self {
accent: Color::Rgb(38, 139, 210), // blue
+ panel_bg: Color::Rgb(0, 43, 54),
surface0: Color::Rgb(7, 54, 66),
surface1: Color::Rgb(88, 110, 117),
surface_dim: Color::Rgb(0, 43, 54),
@@ -199,6 +209,7 @@ impl Palette {
pub fn kanagawa() -> Self {
Self {
accent: Color::Rgb(126, 156, 216), // blue
+ panel_bg: Color::Rgb(31, 31, 40),
surface0: Color::Rgb(42, 42, 55),
surface1: Color::Rgb(54, 54, 70),
surface_dim: Color::Rgb(31, 31, 40),
@@ -220,6 +231,7 @@ impl Palette {
pub fn rose_pine() -> Self {
Self {
accent: Color::Rgb(196, 167, 231), // iris
+ panel_bg: Color::Rgb(25, 23, 36),
surface0: Color::Rgb(31, 29, 46),
surface1: Color::Rgb(38, 35, 58),
surface_dim: Color::Rgb(25, 23, 36),
@@ -259,6 +271,9 @@ impl Palette {
if let Some(c) = &custom.accent {
self.accent = parse_color(c);
}
+ if let Some(c) = &custom.panel_bg {
+ self.panel_bg = parse_color(c);
+ }
if let Some(c) = &custom.surface0 {
self.surface0 = parse_color(c);
}
@@ -323,6 +338,55 @@ pub enum Mode {
Resize,
ConfirmClose,
ContextMenu,
+ Settings,
+}
+
+// ---------------------------------------------------------------------------
+// Settings UI state
+// ---------------------------------------------------------------------------
+
+/// Which section of the settings panel is focused.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum SettingsSection {
+ Theme,
+ Sound,
+ Toast,
+}
+
+impl SettingsSection {
+ pub const ALL: &[Self] = &[Self::Theme, Self::Sound, Self::Toast];
+
+ pub fn label(self) -> &'static str {
+ match self {
+ Self::Theme => "theme",
+ Self::Sound => "sound",
+ Self::Toast => "toasts",
+ }
+ }
+}
+
+/// All built-in theme names in display order.
+pub const THEME_NAMES: &[&str] = &[
+ "catppuccin",
+ "tokyo-night",
+ "dracula",
+ "nord",
+ "gruvbox",
+ "one-dark",
+ "solarized",
+ "kanagawa",
+ "rose-pine",
+];
+
+pub struct SettingsState {
+ /// Which section tab is active.
+ pub section: SettingsSection,
+ /// Selected item index within the current section.
+ pub selected: usize,
+ /// The palette before opening settings (for cancel/restore).
+ pub original_palette: Option<Palette>,
+ /// The theme name before opening settings.
+ pub original_theme: Option<String>,
}
/// Active mouse drag on a split border.
@@ -384,6 +448,7 @@ pub struct AppState {
pub sidebar_width: u16,
pub sidebar_collapsed: bool,
pub confirm_close: bool,
+ #[allow(dead_code)] // kept for backward compat; palette.accent is the source of truth
pub accent: Color,
pub sound: SoundConfig,
pub toast_config: ToastConfig,
@@ -392,9 +457,17 @@ pub struct AppState {
pub spinner_tick: u32,
/// UI color palette — all sidebar/UI colors centralized for theming.
pub palette: Palette,
+ /// Currently applied theme name (for settings UI).
+ pub theme_name: String,
+ /// Settings panel state.
+ pub settings: SettingsState,
}
impl AppState {
+ pub fn sound_enabled(&self) -> bool {
+ self.sound.enabled
+ }
+
pub fn is_prefix(&self, key: &crossterm::event::KeyEvent) -> bool {
key_matches(key, self.prefix_code, self.prefix_mods)
}
@@ -492,6 +565,13 @@ impl AppState {
},
spinner_tick: 0,
palette: Palette::catppuccin(),
+ theme_name: "catppuccin".to_string(),
+ settings: SettingsState {
+ section: SettingsSection::Theme,
+ selected: 0,
+ original_palette: None,
+ original_theme: None,
+ },
}
}
}