diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-03-29 22:48:02 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-03-29 22:48:02 +0300 |
| commit | 9badd642b7c0c71301cb5034245706f700401ba0 (patch) | |
| tree | c598ece50568519387e7c7eb92d3a79edaf37b47 /src | |
| parent | 101de0ce453615c809d95175532bcb86f816163a (diff) | |
fix popup rendering and settings theme flow
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/input.rs | 384 | ||||
| -rw-r--r-- | src/app/mod.rs | 51 | ||||
| -rw-r--r-- | src/app/state.rs | 80 | ||||
| -rw-r--r-- | src/config.rs | 14 | ||||
| -rw-r--r-- | src/ui.rs | 604 |
5 files changed, 939 insertions, 194 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, + }, } } } diff --git a/src/config.rs b/src/config.rs index fc26b3a5..cfd76594 100644 --- a/src/config.rs +++ b/src/config.rs @@ -46,6 +46,7 @@ pub struct ThemeConfig { #[serde(default)] pub struct CustomThemeColors { pub accent: Option<String>, + pub panel_bg: Option<String>, pub surface0: Option<String>, pub surface1: Option<String>, pub surface_dim: Option<String>, @@ -589,7 +590,16 @@ fn upsert_top_level_bool(content: &str, key: &str, value: bool) -> String { } } -fn upsert_section_bool(content: &str, section: &str, key: &str, value: bool) -> String { +/// Write a key = value pair in a TOML section (creates section if missing). +pub fn upsert_section_value(content: &str, section: &str, key: &str, value: &str) -> String { + upsert_section_raw(content, section, key, value) +} + +pub fn upsert_section_bool(content: &str, section: &str, key: &str, value: bool) -> String { + upsert_section_raw(content, section, key, &value.to_string()) +} + +fn upsert_section_raw(content: &str, section: &str, key: &str, value: &str) -> String { let header = format!("[{section}]"); let assignment = format!("{key} = {value}"); let lines: Vec<&str> = content.lines().collect(); @@ -998,12 +1008,14 @@ name = "dracula" name = "nord" [theme.custom] +panel_bg = "#1e1e2e" accent = "#ff79c6" red = "rgb(255, 85, 85)" "##; let config: Config = toml::from_str(toml).unwrap(); assert_eq!(config.theme.name.as_deref(), Some("nord")); let custom = config.theme.custom.as_ref().unwrap(); + assert_eq!(custom.panel_bg.as_deref(), Some("#1e1e2e")); assert_eq!(custom.accent.as_deref(), Some("#ff79c6")); assert_eq!(custom.red.as_deref(), Some("rgb(255, 85, 85)")); assert!(custom.green.is_none()); @@ -80,6 +80,7 @@ pub fn render(app: &AppState, frame: &mut Frame) { render_navigate_overlay(app, frame, terminal_area); render_context_menu(app, frame); } + Mode::Settings => render_settings_overlay(app, frame, frame.area()), Mode::RenameSession => {} Mode::Terminal => {} } @@ -87,15 +88,21 @@ pub fn render(app: &AppState, frame: &mut Frame) { // Notifications (rendered on top of everything) if let Some(version) = &app.update_available { if !app.update_dismissed { - render_update_notification(frame, terminal_area, version, app.accent); + render_update_notification(frame, terminal_area, version, &app.palette); } } let has_config_diagnostic = app.config_diagnostic.is_some(); if let Some(message) = &app.config_diagnostic { - render_config_diagnostic(frame, terminal_area, message); + render_config_diagnostic(frame, terminal_area, message, &app.palette); } if let Some(toast) = &app.toast { - render_toast_notification(frame, terminal_area, toast, has_config_diagnostic); + render_toast_notification( + frame, + terminal_area, + toast, + has_config_diagnostic, + &app.palette, + ); } } @@ -186,6 +193,7 @@ fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area: Rect) { | Mode::Resize | Mode::ConfirmClose | Mode::ContextMenu + | Mode::Settings ); let p = &app.palette; @@ -240,7 +248,7 @@ fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area: Rect) { ); } - render_sidebar_toggle(frame, area, true); + render_sidebar_toggle(frame, area, true, p); } fn render_sidebar(app: &AppState, frame: &mut Frame, area: Rect) { @@ -252,6 +260,7 @@ fn render_sidebar(app: &AppState, frame: &mut Frame, area: Rect) { | Mode::Resize | Mode::ConfirmClose | Mode::ContextMenu + | Mode::Settings ); let sep_style = if is_navigating { Style::default().fg(p.accent) @@ -299,7 +308,7 @@ fn render_sidebar(app: &AppState, frame: &mut Frame, area: Rect) { } } - render_sidebar_toggle(frame, area, false); + render_sidebar_toggle(frame, area, false, p); } /// Render the workspace list in the top section of the sidebar. @@ -513,7 +522,7 @@ fn render_agent_detail( } } -fn render_sidebar_toggle(frame: &mut Frame, area: Rect, collapsed: bool) { +fn render_sidebar_toggle(frame: &mut Frame, area: Rect, collapsed: bool, p: &Palette) { // Toggle button not needed when sidebar has content — skip for now // to avoid conflicting with the agent detail panel at the bottom. if !collapsed { @@ -528,18 +537,18 @@ fn render_sidebar_toggle(frame: &mut Frame, area: Rect, collapsed: bool) { let x = area.x + content_w / 2; let toggle_area = Rect::new(x, bottom_y, 1, 1); frame.render_widget( - Paragraph::new(Span::styled(icon, Style::default().fg(Color::DarkGray))), + Paragraph::new(Span::styled(icon, Style::default().fg(p.overlay0))), toggle_area, ); } fn render_panes(app: &AppState, frame: &mut Frame, area: Rect) { let Some(ws_idx) = app.active else { - render_empty(frame, area, app.accent); + render_empty(frame, area, &app.palette); return; }; let Some(ws) = app.workspaces.get(ws_idx) else { - render_empty(frame, area, app.accent); + render_empty(frame, area, &app.palette); return; }; @@ -552,17 +561,17 @@ fn render_panes(app: &AppState, frame: &mut Frame, area: Rect) { if multi_pane { let (border_style, border_set) = if info.is_focused && terminal_active { ( - Style::default().fg(app.accent), + Style::default().fg(app.palette.accent), ratatui::symbols::border::THICK, ) } else if info.is_focused { ( - Style::default().fg(app.accent), + Style::default().fg(app.palette.accent), ratatui::symbols::border::PLAIN, ) } else { ( - Style::default().fg(Color::DarkGray), + Style::default().fg(app.palette.overlay0), ratatui::symbols::border::PLAIN, ) }; @@ -597,7 +606,13 @@ fn render_panes(app: &AppState, frame: &mut Frame, area: Rect) { } // Selection highlight - render_selection_highlight(&app.selection, frame, info.id, info.inner_rect); + render_selection_highlight( + &app.selection, + frame, + info.id, + info.inner_rect, + &app.palette, + ); } } } @@ -632,6 +647,7 @@ fn render_selection_highlight( frame: &mut Frame, pane_id: crate::layout::PaneId, inner: Rect, + p: &Palette, ) { if let Some(sel) = selection { if sel.is_visible() && sel.pane_id == pane_id { @@ -640,13 +656,7 @@ fn render_selection_highlight( for x in 0..inner.width { if sel.contains(y, x) { let cell = &mut buf[(inner.x + x, inner.y + y)]; - // Fixed highlight: white text on blue background. - // Consistent regardless of the cell's original colors. - cell.set_style( - Style::default() - .fg(Color::White) - .bg(Color::Rgb(40, 80, 140)), - ); + cell.set_style(Style::default().fg(p.panel_bg).bg(p.blue)); } } } @@ -654,29 +664,29 @@ fn render_selection_highlight( } } -fn render_empty(frame: &mut Frame, area: Rect, accent: Color) { +fn render_empty(frame: &mut Frame, area: Rect, p: &Palette) { let lines = vec![ Line::from(""), Line::from(""), Line::from(Span::styled( " No active workspace", - Style::default().fg(Color::DarkGray), + Style::default().fg(p.overlay0), )), Line::from(""), Line::from(vec![ - Span::styled(" Press ", Style::default().fg(Color::DarkGray)), + Span::styled(" Press ", Style::default().fg(p.overlay0)), Span::styled( "new", - Style::default().fg(accent).add_modifier(Modifier::BOLD), + Style::default().fg(p.accent).add_modifier(Modifier::BOLD), ), - Span::styled(" to create one", Style::default().fg(Color::DarkGray)), + Span::styled(" to create one", Style::default().fg(p.overlay0)), ]), ]; frame.render_widget( Paragraph::new(lines).block( Block::default() .borders(Borders::ALL) - .border_style(Style::default().fg(Color::DarkGray)), + .border_style(Style::default().fg(p.surface_dim)), ), area, ); @@ -694,12 +704,33 @@ fn dim_background(frame: &mut Frame, area: Rect) { } } +fn render_panel_shell( + frame: &mut Frame, + area: Rect, + border_color: Color, + bg: Color, +) -> Option<Rect> { + if area.width < 2 || area.height < 2 { + return None; + } + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(border_color)) + .border_set(ratatui::symbols::border::PLAIN) + .style(Style::default().bg(bg)); + let inner = block.inner(area); + frame.render_widget(Clear, area); + frame.render_widget(block, area); + Some(inner) +} + fn render_modal_shell( frame: &mut Frame, area: Rect, popup_w: u16, popup_h: u16, - accent: Color, + p: &Palette, ) -> Option<Rect> { let popup_w = popup_w.min(area.width.saturating_sub(4)); let popup_h = popup_h.min(area.height.saturating_sub(2)); @@ -710,25 +741,14 @@ fn render_modal_shell( 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 block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(accent)) - .style(Style::default().bg(Color::Black)); - let inner = block.inner(popup); - frame.render_widget(Clear, popup); - frame.render_widget(block, popup); - Some(inner) + render_panel_shell(frame, popup, p.accent, p.panel_bg) } -fn render_modal_header(frame: &mut Frame, area: Rect, title: &str, accent: Color) { +fn render_modal_header(frame: &mut Frame, area: Rect, title: &str, p: &Palette) { frame.render_widget( Paragraph::new(Span::styled( format!(" {title} "), - Style::default() - .fg(Color::Black) - .bg(accent) - .add_modifier(Modifier::BOLD), + Style::default().fg(p.text).add_modifier(Modifier::BOLD), )), area, ); @@ -744,7 +764,7 @@ fn render_onboarding_overlay(app: &AppState, frame: &mut Frame, area: Rect) { } fn render_onboarding_welcome(app: &AppState, frame: &mut Frame, area: Rect) { - let Some(inner) = render_modal_shell(frame, area, 64, 15, app.accent) else { + let Some(inner) = render_modal_shell(frame, area, 64, 15, &app.palette) else { return; }; if inner.height < 10 { @@ -769,55 +789,64 @@ fn render_onboarding_welcome(app: &AppState, frame: &mut Frame, area: Rect) { frame.render_widget( Paragraph::new(" herdr").style( Style::default() - .fg(Color::White) + .fg(app.palette.text) .add_modifier(Modifier::BOLD), ), rows[1], ); frame.render_widget( Paragraph::new(" workspace manager for coding agents") - .style(Style::default().fg(Color::DarkGray)), + .style(Style::default().fg(app.palette.overlay0)), rows[2], ); let line1 = Line::from(vec![ Span::styled( format!(" {}", ONBOARDING_PREFIX_LABEL), - Style::default().fg(app.accent).add_modifier(Modifier::BOLD), + Style::default() + .fg(app.palette.accent) + .add_modifier(Modifier::BOLD), ), - Span::styled(" navigate ", Style::default().fg(Color::Gray)), - Span::styled("·", Style::default().fg(Color::DarkGray)), - Span::styled(" click sidebar ", Style::default().fg(Color::Gray)), - Span::styled("·", Style::default().fg(Color::DarkGray)), - Span::styled(" scroll panes", Style::default().fg(Color::Gray)), + Span::styled(" navigate ", Style::default().fg(app.palette.overlay1)), + Span::styled("·", Style::default().fg(app.palette.overlay0)), + Span::styled(" click sidebar ", Style::default().fg(app.palette.overlay1)), + Span::styled("·", Style::default().fg(app.palette.overlay0)), + Span::styled(" scroll panes", Style::default().fg(app.palette.overlay1)), ]); frame.render_widget(Paragraph::new(line1), rows[4]); let line2 = Line::from(vec![ Span::styled( " ↑↓", - Style::default().fg(app.accent).add_modifier(Modifier::BOLD), + Style::default() + .fg(app.palette.accent) + .add_modifier(Modifier::BOLD), ), - Span::styled(" switch workspace ", Style::default().fg(Color::Gray)), - Span::styled("·", Style::default().fg(Color::DarkGray)), - Span::styled(" drag borders ", Style::default().fg(Color::Gray)), - Span::styled("·", Style::default().fg(Color::DarkGray)), + Span::styled( + " switch workspace ", + Style::default().fg(app.palette.overlay1), + ), + Span::styled("·", Style::default().fg(app.palette.overlay0)), + Span::styled(" drag borders ", Style::default().fg(app.palette.overlay1)), + Span::styled("·", Style::default().fg(app.palette.overlay0)), Span::styled( " ⇥", - Style::default().fg(app.accent).add_modifier(Modifier::BOLD), + Style::default() + .fg(app.palette.accent) + .add_modifier(Modifier::BOLD), ), - Span::styled(" pane", Style::default().fg(Color::Gray)), + Span::styled(" pane", Style::default().fg(app.palette.overlay1)), ]); frame.render_widget(Paragraph::new(line2), rows[5]); frame.render_widget( Paragraph::new(Line::from(vec![ - Span::styled(" ● ", Style::default().fg(Color::Red)), - Span::styled("needs you ", Style::default().fg(Color::Gray)), - Span::styled("○ ", Style::default().fg(Color::Yellow)), - Span::styled("working ", Style::default().fg(Color::Gray)), - Span::styled("◌ ", Style::default().fg(Color::DarkGray)), - Span::styled("no agent", Style::default().fg(Color::Gray)), + Span::styled(" ● ", Style::default().fg(app.palette.red)), + Span::styled("needs you ", Style::default().fg(app.palette.overlay1)), + Span::styled("○ ", Style::default().fg(app.palette.yellow)), + Span::styled("working ", Style::default().fg(app.palette.overlay1)), + Span::styled("◌ ", Style::default().fg(app.palette.overlay0)), + Span::styled("no agent", Style::default().fg(app.palette.overlay1)), ])), rows[7], ); @@ -826,13 +855,13 @@ fn render_onboarding_welcome(app: &AppState, frame: &mut Frame, area: Rect) { Span::styled( " continue ", Style::default() - .fg(Color::Black) - .bg(app.accent) + .fg(app.palette.panel_bg) + .bg(app.palette.accent) .add_modifier(Modifier::BOLD), ), Span::styled( " readme has config and more", - Style::default().fg(Color::DarkGray), + Style::default().fg(app.palette.overlay0), ), ])), rows[9], @@ -840,7 +869,7 @@ fn render_onboarding_welcome(app: &AppState, frame: &mut Frame, area: Rect) { } fn render_onboarding_notifications(app: &AppState, frame: &mut Frame, area: Rect) { - let Some(inner) = render_modal_shell(frame, area, 52, 10, app.accent) else { + let Some(inner) = render_modal_shell(frame, area, 52, 10, &app.palette) else { return; }; @@ -859,10 +888,10 @@ fn render_onboarding_notifications(app: &AppState, frame: &mut Frame, area: Rect ]) .areas::<7>(inner); - render_modal_header(frame, rows[0], "choose notification style", app.accent); + render_modal_header(frame, rows[0], "choose notification style", &app.palette); frame.render_widget( Paragraph::new(" herdr can alert you when background work needs attention or finishes.") - .style(Style::default().fg(Color::Gray)), + .style(Style::default().fg(app.palette.overlay1)), rows[1], ); @@ -877,9 +906,11 @@ fn render_onboarding_notifications(app: &AppState, frame: &mut Frame, area: Rect let selected = idx == app.onboarding_selected; let prefix = if selected { "›" } else { " " }; let style = if selected { - Style::default().fg(Color::Black).bg(app.accent) + Style::default() + .fg(app.palette.panel_bg) + .bg(app.palette.accent) } else { - Style::default().fg(Color::White) + Style::default().fg(app.palette.text) }; frame.render_widget( Paragraph::new(format!(" {prefix} {}. {option}", idx + 1)).style(style), @@ -889,13 +920,13 @@ fn render_onboarding_notifications(app: &AppState, frame: &mut Frame, area: Rect frame.render_widget( Paragraph::new(Line::from(vec![ - Span::styled(" [ back ] ", Style::default().fg(Color::DarkGray)), + Span::styled(" [ back ] ", Style::default().fg(app.palette.overlay0)), Span::raw(" "), Span::styled( " [ save ] ", Style::default() - .fg(Color::Black) - .bg(app.accent) + .fg(app.palette.panel_bg) + .bg(app.palette.accent) .add_modifier(Modifier::BOLD), ), ])), @@ -905,9 +936,11 @@ fn render_onboarding_notifications(app: &AppState, frame: &mut Frame, area: Rect /// Floating overlay for navigate mode — appears at bottom of terminal area. fn render_navigate_overlay(app: &AppState, frame: &mut Frame, area: Rect) { - let key = Style::default().fg(app.accent).add_modifier(Modifier::BOLD); - let dim = Style::default().fg(Color::DarkGray); - let label = Style::default().fg(Color::White); + let key = Style::default() + .fg(app.palette.accent) + .add_modifier(Modifier::BOLD); + let dim = Style::default().fg(app.palette.overlay0); + let label = Style::default().fg(app.palette.text); let kb = &app.keybinds; let line1 = Line::from(vec![ @@ -948,8 +981,8 @@ fn render_navigate_overlay(app: &AppState, frame: &mut Frame, area: Rect) { .unwrap_or_default(); let mode_style = Style::default() - .fg(Color::Black) - .bg(app.accent) + .fg(app.palette.panel_bg) + .bg(app.palette.accent) .add_modifier(Modifier::BOLD); let line2 = Line::from(vec![ @@ -968,6 +1001,8 @@ fn render_navigate_overlay(app: &AppState, frame: &mut Frame, area: Rect) { Span::styled(" resize ", dim), Span::styled(kb.toggle_sidebar_label.as_str(), key), Span::styled(" sidebar ", dim), + Span::styled("s", key), + Span::styled(" settings ", dim), Span::styled("⏎", key), Span::styled(" open ", dim), Span::styled("q", key), @@ -981,7 +1016,7 @@ fn render_navigate_overlay(app: &AppState, frame: &mut Frame, area: Rect) { // Clear the area behind the overlay frame.render_widget(Clear, overlay_area); - let bg = Style::default().bg(Color::Black); + let bg = Style::default().bg(app.palette.panel_bg); let buf = frame.buffer_mut(); for y in overlay_area.y..overlay_area.y + overlay_area.height { for x in overlay_area.x..overlay_area.x + overlay_area.width { @@ -997,12 +1032,14 @@ fn render_navigate_overlay(app: &AppState, frame: &mut Frame, area: Rect) { /// Floating overlay for resize mode. fn render_resize_overlay(app: &AppState, frame: &mut Frame, area: Rect) { - let key = Style::default().fg(app.accent).add_modifier(Modifier::BOLD); - let dim = Style::default().fg(Color::DarkGray); + let key = Style::default() + .fg(app.palette.accent) + .add_modifier(Modifier::BOLD); + let dim = Style::default().fg(app.palette.overlay0); let mode_style = Style::default() - .fg(Color::Black) - .bg(Color::Magenta) + .fg(app.palette.panel_bg) + .bg(app.palette.mauve) .add_modifier(Modifier::BOLD); let line = Line::from(vec![ @@ -1020,7 +1057,7 @@ fn render_resize_overlay(app: &AppState, frame: &mut Frame, area: Rect) { let overlay_area = Rect::new(area.x, overlay_y, area.width, 1); frame.render_widget(Clear, overlay_area); - let bg = Style::default().bg(Color::Black); + let bg = Style::default().bg(app.palette.panel_bg); let buf = frame.buffer_mut(); for x in overlay_area.x..overlay_area.x + overlay_area.width { buf[(x, overlay_y)].set_style(bg); @@ -1058,14 +1095,18 @@ 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 = 5u16; + let popup_h = 6u16; 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.accent).add_modifier(Modifier::BOLD); - let warn = Style::default().fg(Color::Red).add_modifier(Modifier::BOLD); - let dim = Style::default().fg(Color::DarkGray); + let key = Style::default() + .fg(app.palette.accent) + .add_modifier(Modifier::BOLD); + let warn = Style::default() + .fg(app.palette.red) + .add_modifier(Modifier::BOLD); + let dim = Style::default().fg(app.palette.overlay0); let title_line = Line::from(vec![Span::styled(" Close workspace?", warn)]); @@ -1073,75 +1114,347 @@ fn render_confirm_close_overlay(app: &AppState, frame: &mut Frame, area: Rect) { Span::styled( format!(" {ws_name}"), Style::default() - .fg(Color::White) + .fg(app.palette.text) .add_modifier(Modifier::BOLD), ), Span::styled(format!(" — {pane_text}"), dim), ]); - let action_line = Line::from(vec![ - Span::raw(" "), - Span::styled("y", key), - Span::styled("/", dim), - Span::styled("enter", key), - Span::styled(" confirm ", dim), - Span::styled("any key", key), - Span::styled(" cancel", dim), - ]); - - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Red)) - .style(Style::default().bg(Color::Black)); - - let inner = block.inner(popup); - frame.render_widget(Clear, popup); - frame.render_widget(block, popup); + let Some(inner) = render_panel_shell(frame, popup, app.palette.red, app.palette.panel_bg) + else { + return; + }; - if inner.height >= 3 { + if inner.height >= 4 { let rows = Layout::vertical([ Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), + Constraint::Length(1), ]) - .areas::<3>(inner); + .areas::<4>(inner); + frame.render_widget(Paragraph::new(title_line), rows[0]); frame.render_widget(Paragraph::new(detail_line), rows[1]); - frame.render_widget(Paragraph::new(action_line), rows[2]); + + let (confirm_rect, cancel_rect) = confirm_close_button_rects(inner); + frame.render_widget( + Paragraph::new(" confirm ").style( + Style::default() + .fg(app.palette.panel_bg) + .bg(app.palette.red) + .add_modifier(Modifier::BOLD), + ), + confirm_rect, + ); + frame.render_widget( + Paragraph::new(" cancel ").style( + Style::default() + .fg(app.palette.text) + .bg(app.palette.surface0) + .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 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, + ), + ) +} + /// Right-click context menu popup anchored near the click position. -fn render_context_menu(app: &AppState, frame: &mut Frame) { - use crate::app::CONTEXT_MENU_ITEMS; +// --------------------------------------------------------------------------- +// Settings overlay +// --------------------------------------------------------------------------- - let Some(menu) = &app.context_menu else { +fn render_settings_overlay(app: &AppState, frame: &mut Frame, area: Rect) { + use crate::app::state::SettingsSection; + + let p = &app.palette; + let popup_w: u16 = 56; + let popup_h: u16 = 20; + + let popup_w = popup_w.min(area.width.saturating_sub(4)); + let popup_h = popup_h.min(area.height.saturating_sub(2)); + if popup_w < 20 || popup_h < 10 { + return; + } + + 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); + + // Dim everything behind the modal + dim_background(frame, area); + + let Some(inner) = render_panel_shell(frame, popup, p.accent, p.panel_bg) else { return; }; + if inner.height < 4 || inner.width < 10 { + return; + } - let menu_w = 14u16; - let menu_h = CONTEXT_MENU_ITEMS.len() as u16 + 2; // +2 for border - let area = frame.area(); + let mut y = inner.y; - // Position: try to place below-right of click, clamp to screen - let x = menu.x.min(area.width.saturating_sub(menu_w)); - let y = menu.y.min(area.height.saturating_sub(menu_h)); - let menu_rect = Rect::new(x, y, menu_w, menu_h); + // Title + frame.render_widget( + Paragraph::new(Line::from(vec![Span::styled( + " settings", + Style::default().fg(p.text).add_modifier(Modifier::BOLD), + )])), + Rect::new(inner.x, y, inner.width, 1), + ); + y += 1; - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(app.accent)) - .style(Style::default().bg(Color::Black)); - let inner = block.inner(menu_rect); + // Tab bar + let tabs: Vec<Span> = SettingsSection::ALL + .iter() + .map(|s| { + let active = *s == app.settings.section; + let label = format!(" {} ", s.label()); + if active { + Span::styled( + label, + Style::default() + .fg(p.panel_bg) + .bg(p.accent) + .add_modifier(Modifier::BOLD), + ) + } else { + Span::styled(label, Style::default().fg(p.overlay1)) + } + }) + .collect(); + let mut tab_line: Vec<Span> = Vec::new(); + tab_line.push(Span::styled(" ", Style::default())); + for (i, tab) in tabs.into_iter().enumerate() { + if i > 0 { + tab_line.push(Span::styled(" ", Style::default())); + } + tab_line.push(tab); + } + frame.render_widget( + Paragraph::new(Line::from(tab_line)), + Rect::new(inner.x, y, inner.width, 1), + ); + y += 1; + + // Separator + let sep = "─".repeat(inner.width as usize); + frame.render_widget( + Paragraph::new(Span::styled(&sep, Style::default().fg(p.surface0))), + Rect::new(inner.x, y, inner.width, 1), + ); + y += 1; + + // Section content + let content_area = Rect::new(inner.x, y, inner.width, inner.y + inner.height - y); + + match app.settings.section { + SettingsSection::Theme => { + render_settings_theme(app, frame, content_area); + } + SettingsSection::Sound => { + render_settings_toggle( + frame, + content_area, + p, + "sound alerts", + "play sounds when agents change state in background", + app.sound_enabled(), + app.settings.selected, + ); + } + SettingsSection::Toast => { + render_settings_toggle( + frame, + content_area, + p, + "visual toasts", + "show top-right notifications for background events", + app.toast_config.enabled, + app.settings.selected, + ); + } + } + + // Footer hints + let footer_y = inner.y + inner.height - 1; + if footer_y > y { + 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)), + ])), + Rect::new(inner.x, footer_y, inner.width, 1), + ); + } +} + +/// Render the theme picker list inside the settings panel. +fn render_settings_theme(app: &AppState, frame: &mut Frame, area: Rect) { + use crate::app::state::THEME_NAMES; + + let p = &app.palette; + let max_visible = (area.height as usize).saturating_sub(1); + let selected = app.settings.selected; + + // Scroll offset + let scroll = if selected >= max_visible { + selected - max_visible + 1 + } else { + 0 + }; + + for (vi, theme_idx) in (scroll..THEME_NAMES.len()).enumerate() { + if vi >= max_visible { + break; + } + let name = THEME_NAMES[theme_idx]; + let is_selected = theme_idx == selected; + let is_current = name.to_lowercase().replace([' ', '_'], "-") + == app.theme_name.to_lowercase().replace([' ', '_'], "-"); + + let prefix = if is_selected { "▸" } else { " " }; + let marker = if is_current { " ✓" } else { "" }; + + let row_y = area.y + vi as u16; + + // Background for selected + if is_selected { + let buf = frame.buffer_mut(); + for x in area.x..area.x + area.width { + buf[(x, row_y)].set_style(Style::default().bg(p.surface0)); + } + } + + let name_style = if is_selected { + Style::default().fg(p.text).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(p.subtext0) + }; + + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled(format!(" {prefix} "), Style::default().fg(p.overlay0)), + Span::styled(name, name_style), + Span::styled(marker, Style::default().fg(p.green)), + ])), + Rect::new(area.x, row_y, area.width, 1), + ); + } +} + +/// Reusable toggle widget for boolean settings (sound, toast). +fn render_settings_toggle( + frame: &mut Frame, + area: Rect, + p: &crate::app::state::Palette, + title: &str, + description: &str, + current_value: bool, + selected_idx: usize, +) { + let mut y = area.y; + + // Description — truncate to fit within area + let max_desc_len = (area.width as usize).saturating_sub(2); + let desc_text = if description.len() > max_desc_len { + format!(" {}…", &description[..max_desc_len.saturating_sub(2)]) + } else { + format!(" {description}") + }; + frame.render_widget( + Paragraph::new(Span::styled(desc_text, Style::default().fg(p.overlay1))), + Rect::new(area.x, y, area.width, 1), + ); + y += 2; - frame.render_widget(Clear, menu_rect); - frame.render_widget(block, menu_rect); + let options = ["on", "off"]; + for (i, label) in options.iter().enumerate() { + if y >= area.y + area.height { + break; + } + let is_selected = i == selected_idx; + let is_active = (i == 0) == current_value; + + let prefix = if is_selected { "▸" } else { " " }; + let marker = if is_active { " ✓" } else { "" }; + + if is_selected { + let buf = frame.buffer_mut(); + for x in area.x..area.x + area.width { + buf[(x, y)].set_style(Style::default().bg(p.surface0)); + } + } + + let label_style = if is_selected { + Style::default().fg(p.text).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(p.subtext0) + }; + + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled(format!(" {prefix} "), Style::default().fg(p.overlay0)), + Span::styled(format!("{title}: {label}"), label_style), + Span::styled(marker, Style::default().fg(p.green)), + ])), + Rect::new(area.x, y, area.width, 1), + ); + y += 1; + } +} + +fn render_context_menu(app: &AppState, frame: &mut Frame) { + use crate::app::CONTEXT_MENU_ITEMS; + + let Some(menu) = &app.context_menu else { + return; + }; + + let p = &app.palette; + let Some(menu_rect) = app.context_menu_rect() else { + return; + }; + let Some(inner) = render_panel_shell(frame, menu_rect, p.accent, p.panel_bg) else { + return; + }; let highlight = Style::default() - .fg(Color::Black) - .bg(app.accent) + .fg(p.panel_bg) + .bg(p.accent) .add_modifier(Modifier::BOLD); - let normal = Style::default().fg(Color::White); + let normal = Style::default().fg(p.text).bg(p.panel_bg); for (i, item) in CONTEXT_MENU_ITEMS.iter().enumerate() { if i as u16 >= inner.height { @@ -1157,7 +1470,7 @@ fn render_context_menu(app: &AppState, frame: &mut Frame) { } } -fn render_update_notification(frame: &mut Frame, area: Rect, version: &str, accent: Color) { +fn render_update_notification(frame: &mut Frame, area: Rect, version: &str, p: &Palette) { let text = format!(" ✦ herdr v{version} installed — restart to update "); let width = text.len() as u16 + 2; let x = area.x + area.width.saturating_sub(width) / 2; @@ -1169,8 +1482,8 @@ fn render_update_notification(frame: &mut Frame, area: Rect, version: &str, acce Paragraph::new(Span::styled( text, Style::default() - .fg(Color::Black) - .bg(accent) + .fg(p.panel_bg) + .bg(p.accent) .add_modifier(Modifier::BOLD), )), notif_area, @@ -1182,10 +1495,11 @@ fn render_toast_notification( area: Rect, toast: &ToastNotification, offset_for_warning: bool, + p: &Palette, ) { let dot_color = match toast.kind { - ToastKind::NeedsAttention => Color::Red, - ToastKind::Finished => Color::Blue, + ToastKind::NeedsAttention => p.red, + ToastKind::Finished => p.blue, }; let content_width = (toast.title.len().max(toast.context.len()) as u16) + 4; let width = content_width.saturating_add(2).min(area.width); @@ -1197,8 +1511,8 @@ fn render_toast_notification( frame.render_widget(Clear, toast_area); let block = Block::default() .borders(Borders::ALL) - .border_style(Style::default().fg(Color::DarkGray)) - .style(Style::default().bg(Color::Black)); + .border_style(Style::default().fg(p.overlay0)) + .style(Style::default().bg(p.panel_bg)); let inner = block.inner(toast_area); frame.render_widget(block, toast_area); @@ -1214,14 +1528,12 @@ fn render_toast_notification( Span::raw(" "), Span::styled( &toast.title, - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD), + Style::default().fg(p.text).add_modifier(Modifier::BOLD), ), ]); let context = Line::from(vec![ - Span::styled(" ", Style::default().fg(Color::DarkGray)), - Span::styled(&toast.context, Style::default().fg(Color::DarkGray)), + Span::styled(" ", Style::default().fg(p.overlay0)), + Span::styled(&toast.context, Style::default().fg(p.overlay0)), ]); frame.render_widget(Paragraph::new(title), title_row); @@ -1239,7 +1551,7 @@ fn render_toast_notification( /// /// Filled dot = needs attention (working, or finished unseen). /// Hollow dot = nothing to do here. -fn render_config_diagnostic(frame: &mut Frame, area: Rect, message: &str) { +fn render_config_diagnostic(frame: &mut Frame, area: Rect, message: &str, p: &Palette) { let text = format!(" config warning: {message} "); let width = text.len() as u16 + 2; let notif_area = Rect::new( @@ -1254,8 +1566,8 @@ fn render_config_diagnostic(frame: &mut Frame, area: Rect, message: &str) { Paragraph::new(Span::styled( text, Style::default() - .fg(Color::Black) - .bg(Color::Yellow) + .fg(p.panel_bg) + .bg(p.yellow) .add_modifier(Modifier::BOLD), )), notif_area, |
