aboutsummaryrefslogtreecommitdiff
path: root/src
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
parent101de0ce453615c809d95175532bcb86f816163a (diff)
fix popup rendering and settings theme flow
Diffstat (limited to 'src')
-rw-r--r--src/app/input.rs384
-rw-r--r--src/app/mod.rs51
-rw-r--r--src/app/state.rs80
-rw-r--r--src/config.rs14
-rw-r--r--src/ui.rs604
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());
diff --git a/src/ui.rs b/src/ui.rs
index 0a4fcd62..89838e62 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -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,