aboutsummaryrefslogtreecommitdiff
path: root/src/ui.rs
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/ui.rs
parent101de0ce453615c809d95175532bcb86f816163a (diff)
fix popup rendering and settings theme flow
Diffstat (limited to 'src/ui.rs')
-rw-r--r--src/ui.rs604
1 files changed, 458 insertions, 146 deletions
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,