aboutsummaryrefslogtreecommitdiff
path: root/src/ui/settings.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/ui/settings.rs')
-rw-r--r--src/ui/settings.rs238
1 files changed, 238 insertions, 0 deletions
diff --git a/src/ui/settings.rs b/src/ui/settings.rs
new file mode 100644
index 00000000..1123f408
--- /dev/null
+++ b/src/ui/settings.rs
@@ -0,0 +1,238 @@
+use ratatui::{
+ layout::{Constraint, Layout, Rect},
+ style::{Modifier, Style},
+ text::{Line, Span},
+ widgets::{List, ListItem, ListState, Paragraph, Tabs},
+ Frame,
+};
+
+use super::widgets::{
+ action_button_row_rects, centered_popup_rect, modal_stack_areas, panel_contrast_fg,
+ render_action_button, render_panel_shell, ActionButtonSpec,
+};
+use crate::app::{state::Palette, AppState};
+
+pub(super) fn render_settings_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
+ use crate::app::state::SettingsSection;
+
+ let p = &app.palette;
+ let Some(popup) = centered_popup_rect(area, 56, 20) else {
+ return;
+ };
+
+ super::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 stack = modal_stack_areas(inner, 3, 2, 0, 1);
+ let header_rows = Layout::vertical([
+ Constraint::Length(1),
+ Constraint::Length(1),
+ Constraint::Length(1),
+ ])
+ .areas::<3>(stack.header);
+
+ frame.render_widget(
+ Paragraph::new(Line::from(vec![Span::styled(
+ " settings",
+ Style::default().fg(p.text).add_modifier(Modifier::BOLD),
+ )])),
+ header_rows[0],
+ );
+
+ let tabs = Tabs::new(SettingsSection::ALL.iter().map(|s| s.label()))
+ .select(
+ SettingsSection::ALL
+ .iter()
+ .position(|section| *section == app.settings.section)
+ .unwrap_or(0),
+ )
+ .style(Style::default().fg(p.overlay1))
+ .highlight_style(
+ Style::default()
+ .fg(panel_contrast_fg(p))
+ .bg(p.accent)
+ .add_modifier(Modifier::BOLD),
+ )
+ .divider(" ")
+ .padding(" ", " ");
+ frame.render_widget(tabs, header_rows[1]);
+
+ let sep = "─".repeat(inner.width as usize);
+ frame.render_widget(
+ Paragraph::new(Span::styled(&sep, Style::default().fg(p.surface0))),
+ header_rows[2],
+ );
+
+ let content_area = stack.content;
+
+ 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.list.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.list.selected,
+ );
+ }
+ }
+
+ if let Some(footer_area) = stack.footer {
+ let footer_rows = Layout::vertical([Constraint::Length(1), Constraint::Length(1)])
+ .areas::<2>(footer_area);
+ let (apply_rect, close_rect) = settings_button_rects(inner);
+ render_action_button(
+ frame,
+ apply_rect,
+ Some("↵"),
+ "apply",
+ Style::default()
+ .fg(panel_contrast_fg(p))
+ .bg(p.accent)
+ .add_modifier(Modifier::BOLD),
+ );
+ render_action_button(
+ frame,
+ close_rect,
+ Some("esc"),
+ "close",
+ Style::default()
+ .fg(p.text)
+ .bg(p.surface0)
+ .add_modifier(Modifier::BOLD),
+ );
+
+ 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)),
+ ])),
+ footer_rows[0],
+ );
+ }
+}
+
+pub(crate) fn settings_button_rects(inner: Rect) -> (Rect, Rect) {
+ let rects = action_button_row_rects(
+ inner,
+ &[
+ ActionButtonSpec {
+ hint: Some("↵"),
+ label: "apply",
+ },
+ ActionButtonSpec {
+ hint: Some("esc"),
+ label: "close",
+ },
+ ],
+ 2,
+ inner.height.saturating_sub(1),
+ );
+ (rects[0], rects[1])
+}
+
+fn render_settings_theme(app: &AppState, frame: &mut Frame, area: Rect) {
+ use crate::app::state::THEME_NAMES;
+
+ let p = &app.palette;
+ let items: Vec<ListItem> = THEME_NAMES
+ .iter()
+ .map(|name| {
+ let is_current = name.to_lowercase().replace([' ', '_'], "-")
+ == app.theme_name.to_lowercase().replace([' ', '_'], "-");
+ let marker = if is_current { " ✓" } else { "" };
+ ListItem::new(Line::from(vec![
+ Span::styled(*name, Style::default().fg(p.subtext0)),
+ Span::styled(marker, Style::default().fg(p.green)),
+ ]))
+ })
+ .collect();
+
+ let list = List::new(items)
+ .highlight_style(
+ Style::default()
+ .bg(p.surface0)
+ .fg(p.text)
+ .add_modifier(Modifier::BOLD),
+ )
+ .highlight_symbol(" ▸ ")
+ .style(Style::default().fg(p.subtext0));
+
+ let mut state = ListState::default().with_selected(Some(app.settings.list.selected));
+ frame.render_stateful_widget(list, area, &mut state);
+}
+
+fn render_settings_toggle(
+ frame: &mut Frame,
+ area: Rect,
+ p: &Palette,
+ title: &str,
+ description: &str,
+ current_value: bool,
+ selected_idx: usize,
+) {
+ let [desc_area, _, list_area] = Layout::vertical([
+ Constraint::Length(1),
+ Constraint::Length(1),
+ Constraint::Min(2),
+ ])
+ .areas::<3>(area);
+
+ let max_desc_len = (desc_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))),
+ desc_area,
+ );
+
+ let items: Vec<ListItem> = ["on", "off"]
+ .into_iter()
+ .map(|label| {
+ let is_active = (label == "on") == current_value;
+ let marker = if is_active { " ✓" } else { "" };
+ ListItem::new(Line::from(vec![
+ Span::styled(format!("{title}: {label}"), Style::default().fg(p.subtext0)),
+ Span::styled(marker, Style::default().fg(p.green)),
+ ]))
+ })
+ .collect();
+
+ let list = List::new(items)
+ .highlight_style(
+ Style::default()
+ .bg(p.surface0)
+ .fg(p.text)
+ .add_modifier(Modifier::BOLD),
+ )
+ .highlight_symbol(" ▸ ");
+ let mut state = ListState::default().with_selected(Some(selected_idx.min(1)));
+ frame.render_stateful_widget(list, list_area, &mut state);
+}