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_modal_choice_list, render_panel_shell, ActionButtonSpec, }; use crate::{ app::{state::Palette, AppState}, config::ToastDelivery, }; 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, 76, 22) 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_modal_choice_list( frame, content_area, "notification popups", "choose where background popup notifications should appear", &[ ("off", ToastDelivery::Off), ("inside herdr", ToastDelivery::Herdr), ("via terminal", ToastDelivery::Terminal), ("via system", ToastDelivery::System), ], app.toast_delivery(), app.settings.list.selected, p, 2, ); } SettingsSection::PaneLabels => { render_settings_toggle( frame, content_area, p, "agent border labels", "show detected agent names in split pane borders", app.agent_border_labels_enabled(), app.settings.list.selected, ); } SettingsSection::Integrations => { render_settings_integrations(app, frame, content_area); } } if let Some(footer_area) = stack.footer { let footer_rows = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]) .areas::<2>(footer_area); let primary_label = settings_primary_button_label(app.settings.section); let show_primary = settings_show_primary_action(app); let (apply_rect, close_rect) = settings_button_rects(inner, app.settings.section, show_primary); if let Some(apply_rect) = apply_rect { render_action_button( frame, apply_rect, Some("↵"), primary_label, 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_primary_button_label( section: crate::app::state::SettingsSection, ) -> &'static str { match section { crate::app::state::SettingsSection::Integrations => "install", _ => "apply", } } pub(crate) fn settings_show_primary_action(app: &AppState) -> bool { app.settings.section != crate::app::state::SettingsSection::Integrations || app .integration_recommendations .iter() .any(crate::integration::IntegrationRecommendation::needs_install) } pub(crate) fn settings_button_rects( inner: Rect, section: crate::app::state::SettingsSection, show_primary: bool, ) -> (Option, Rect) { if !show_primary { let rects = action_button_row_rects( inner, &[ActionButtonSpec { hint: Some("esc"), label: "close", }], 2, inner.height.saturating_sub(1), ); return (None, rects[0]); } let rects = action_button_row_rects( inner, &[ ActionButtonSpec { hint: Some("↵"), label: settings_primary_button_label(section), }, ActionButtonSpec { hint: Some("esc"), label: "close", }, ], 2, inner.height.saturating_sub(1), ); (Some(rects[0]), rects[1]) } fn render_settings_integrations(app: &AppState, frame: &mut Frame, area: Rect) { let p = &app.palette; let rows = Layout::vertical([ Constraint::Length(1), Constraint::Length(2), Constraint::Length(1), Constraint::Min(0), ]) .areas::<4>(area); frame.render_widget( Paragraph::new("agent integrations") .style(Style::default().fg(p.text).add_modifier(Modifier::BOLD)), rows[0], ); frame.render_widget( Paragraph::new( "let agents report state directly instead of relying only on process detection", ) .style(Style::default().fg(p.overlay1)) .wrap(ratatui::widgets::Wrap { trim: false }), rows[1], ); let mut lines = Vec::new(); for item in &app.integration_recommendations { let marker = match item.state { crate::integration::IntegrationStatusKind::Current => "✓", crate::integration::IntegrationStatusKind::Outdated => "↻", crate::integration::IntegrationStatusKind::NotInstalled if item.available => "+", crate::integration::IntegrationStatusKind::NotInstalled => "–", }; let marker_style = match item.state { crate::integration::IntegrationStatusKind::Current => Style::default().fg(p.green), crate::integration::IntegrationStatusKind::Outdated => Style::default().fg(p.yellow), crate::integration::IntegrationStatusKind::NotInstalled if item.available => { Style::default().fg(p.accent) } crate::integration::IntegrationStatusKind::NotInstalled => { Style::default().fg(p.overlay0) } }; lines.push(Line::from(vec![ Span::styled(format!(" {marker} "), marker_style), Span::styled( format!("{:<9}", item.label), Style::default().fg(p.subtext0), ), Span::styled(item.status_label(), Style::default().fg(p.overlay1)), ])); } if lines.is_empty() { lines.push(Line::from(Span::styled( " no integration targets available", Style::default().fg(p.overlay1), ))); } if !app.integration_install_messages.is_empty() { lines.push(Line::from("")); for message in &app.integration_install_messages { lines.push(Line::from(Span::styled( format!(" {message}"), Style::default().fg(p.overlay1), ))); } } else { lines.push(Line::from("")); let found_any = app.integration_recommendations.iter().any(|item| { item.available || item.state != crate::integration::IntegrationStatusKind::NotInstalled }); let hint = if app .integration_recommendations .iter() .any(crate::integration::IntegrationRecommendation::needs_install) { " press install to add available or outdated integrations" } else if found_any { " all detected integrations are installed" } else { " no supported agent CLIs found on PATH" }; lines.push(Line::from(Span::styled( hint, Style::default().fg(p.overlay1), ))); } frame.render_widget(Paragraph::new(lines), rows[3]); } fn render_settings_theme(app: &AppState, frame: &mut Frame, area: Rect) { use crate::app::state::THEME_NAMES; let p = &app.palette; let items: Vec = 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, ) { render_modal_choice_list( frame, area, title, description, &[("on", true), ("off", false)], current_value, selected_idx, p, 1, ); }