diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-04-22 17:01:25 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-04-22 17:01:25 +0300 |
| commit | 4b482f37a1e233b9aca6bf99b12e8b4da3e5c05e (patch) | |
| tree | 69d1a56ad6335547eec9727691310cf96ebe9623 /src/ui | |
| parent | 5bd939fbfc92a8073d0eac11dfb02a31ea638b15 (diff) | |
feat: add terminal-delivered client notifications
Diffstat (limited to 'src/ui')
| -rw-r--r-- | src/ui/onboarding.rs | 113 | ||||
| -rw-r--r-- | src/ui/settings.rs | 74 | ||||
| -rw-r--r-- | src/ui/widgets.rs | 87 |
3 files changed, 108 insertions, 166 deletions
diff --git a/src/ui/onboarding.rs b/src/ui/onboarding.rs index 6f35c1a6..4a1d4f57 100644 --- a/src/ui/onboarding.rs +++ b/src/ui/onboarding.rs @@ -7,8 +7,8 @@ use ratatui::{ }; use super::widgets::{ - action_button_row_rects, action_button_width, modal_stack_areas, panel_contrast_fg, - render_action_button, render_modal_header, render_modal_shell, ActionButtonSpec, + action_button_width, modal_stack_areas, panel_contrast_fg, render_action_button, + render_modal_shell, }; use crate::app::AppState; @@ -16,11 +16,7 @@ const ONBOARDING_PREFIX_LABEL: &str = "ctrl+b"; pub(super) fn render_onboarding_overlay(app: &AppState, frame: &mut Frame, area: Rect) { super::dim_background(frame, area); - - match app.onboarding_step { - 0 => render_onboarding_welcome(app, frame, area), - _ => render_onboarding_notifications(app, frame, area), - } + render_onboarding_welcome(app, frame, area); } pub(crate) fn onboarding_welcome_continue_rect(area: Rect) -> Rect { @@ -32,25 +28,6 @@ pub(crate) fn onboarding_welcome_continue_rect(area: Rect) -> Rect { ) } -pub(crate) fn onboarding_notification_button_rects(area: Rect) -> (Rect, Rect) { - let rects = action_button_row_rects( - area, - &[ - ActionButtonSpec { - hint: Some("esc"), - label: "back", - }, - ActionButtonSpec { - hint: Some("↵"), - label: "start", - }, - ], - 2, - 0, - ); - (rects[0], rects[1]) -} - fn render_onboarding_welcome(app: &AppState, frame: &mut Frame, area: Rect) { let Some(inner) = render_modal_shell(frame, area, 64, 16, &app.palette) else { return; @@ -129,87 +106,3 @@ fn render_onboarding_welcome(app: &AppState, frame: &mut Frame, area: Rect) { .add_modifier(Modifier::BOLD), ); } - -fn render_onboarding_notifications(app: &AppState, frame: &mut Frame, area: Rect) { - let Some(inner) = render_modal_shell(frame, area, 56, 14, &app.palette) else { - return; - }; - - if inner.height < 11 { - return; - } - - let stack = modal_stack_areas(inner, 3, 0, 1, 1); - let header_rows = Layout::vertical([ - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - ]) - .areas::<3>(stack.header); - let option_rows = Layout::vertical([ - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Min(0), - ]) - .areas::<5>(stack.content); - - render_modal_header(frame, header_rows[0], "notification style", &app.palette); - frame.render_widget( - Paragraph::new(" herdr watches background panes and can alert you") - .style(Style::default().fg(app.palette.overlay1)), - header_rows[1], - ); - frame.render_widget( - Paragraph::new(" when agents finish or need attention.") - .style(Style::default().fg(app.palette.overlay1)), - header_rows[2], - ); - - let options = [ - "quiet no interruptions", - "visual only top-right toasts", - "sound only sound alerts", - "both sound and toasts", - ]; - - for (idx, option) in options.iter().enumerate() { - let selected = idx == app.onboarding_list.selected; - let prefix = if selected { "›" } else { " " }; - let style = if selected { - Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - } else { - Style::default().fg(app.palette.text) - }; - frame.render_widget( - Paragraph::new(format!(" {prefix} {}. {option}", idx + 1)).style(style), - option_rows[idx], - ); - } - - let (back_rect, save_rect) = - onboarding_notification_button_rects(stack.actions.unwrap_or_default()); - render_action_button( - frame, - back_rect, - Some("esc"), - "back", - Style::default() - .fg(app.palette.text) - .bg(app.palette.surface0) - .add_modifier(Modifier::BOLD), - ); - render_action_button( - frame, - save_rect, - Some("↵"), - "start", - Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD), - ); -} diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 1123f408..5269f380 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -8,15 +8,18 @@ use ratatui::{ use super::widgets::{ action_button_row_rects, centered_popup_rect, modal_stack_areas, panel_contrast_fg, - render_action_button, render_panel_shell, ActionButtonSpec, + render_action_button, render_modal_choice_list, render_panel_shell, ActionButtonSpec, +}; +use crate::{ + app::{state::Palette, AppState}, + config::ToastDelivery, }; -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 { + let Some(popup) = centered_popup_rect(area, 76, 22) else { return; }; @@ -87,14 +90,20 @@ pub(super) fn render_settings_overlay(app: &AppState, frame: &mut Frame, area: R ); } SettingsSection::Toast => { - render_settings_toggle( + render_modal_choice_list( frame, content_area, - p, - "visual toasts", - "show top-right notifications for background events", - app.toast_config.enabled, + "notification popups", + "choose where background popup notifications should appear", + &[ + ("off", ToastDelivery::Off), + ("inside herdr", ToastDelivery::Herdr), + ("via terminal", ToastDelivery::Terminal), + ], + app.toast_delivery(), app.settings.list.selected, + p, + 2, ); } } @@ -195,44 +204,15 @@ fn render_settings_toggle( 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, + render_modal_choice_list( + frame, + area, + title, + description, + &[("on", true), ("off", false)], + current_value, + selected_idx, + p, + 1, ); - - 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); } diff --git a/src/ui/widgets.rs b/src/ui/widgets.rs index 354b7884..ef416134 100644 --- a/src/ui/widgets.rs +++ b/src/ui/widgets.rs @@ -2,7 +2,7 @@ use ratatui::{ layout::{Alignment, Constraint, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Clear, Paragraph}, + widgets::{Block, Borders, Clear, Paragraph, Wrap}, Frame, }; @@ -60,14 +60,10 @@ pub(super) fn render_modal_shell( } pub(super) fn render_modal_header(frame: &mut Frame, area: Rect, title: &str, p: &Palette) { - let line = Line::from(vec![ - Span::styled( - title, - Style::default().fg(p.text).add_modifier(Modifier::BOLD), - ), - Span::raw(" "), - Span::styled("⬆", Style::default().fg(p.accent)), - ]); + let line = Line::from(vec![Span::styled( + title, + Style::default().fg(p.text).add_modifier(Modifier::BOLD), + )]); frame.render_widget(Paragraph::new(line), area); } @@ -180,6 +176,79 @@ pub(super) fn render_action_button( ); } +pub(crate) fn render_modal_description(frame: &mut Frame, area: Rect, text: &str, style: Style) { + frame.render_widget( + Paragraph::new(format!(" {text}")) + .style(style) + .wrap(Wrap { trim: false }), + area, + ); +} + +pub(crate) fn modal_choice_rows(area: Rect, count: usize, row_height: u16) -> Vec<Rect> { + let mut rows = Vec::with_capacity(count); + let mut y = area.y; + for _ in 0..count { + if y >= area.y + area.height { + break; + } + let remaining = area.y + area.height - y; + let height = row_height.min(remaining); + rows.push(Rect::new(area.x, y, area.width, height)); + y = y.saturating_add(row_height); + } + rows +} + +pub(crate) fn render_modal_choice_list<T>( + frame: &mut Frame, + area: Rect, + title: &str, + description: &str, + options: &[(&str, T)], + current_value: T, + selected_idx: usize, + p: &Palette, + row_height: u16, +) where + T: Copy + PartialEq, +{ + let [desc_area, _, list_area] = Layout::vertical([ + Constraint::Length(2), + Constraint::Length(1), + Constraint::Min(2), + ]) + .areas::<3>(area); + + render_modal_description( + frame, + desc_area, + description, + Style::default().fg(p.overlay1), + ); + + let rows = modal_choice_rows(list_area, options.len(), row_height); + for (idx, ((label, value), row)) in options.iter().zip(rows.iter()).enumerate() { + let is_active = *value == current_value; + let is_selected = idx == selected_idx; + let marker = if is_active { " ✓" } else { "" }; + let style = if is_selected { + Style::default() + .bg(p.surface0) + .fg(p.text) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(p.subtext0) + }; + frame.render_widget( + Paragraph::new(format!(" {title}: {label}{marker}")) + .style(style) + .wrap(Wrap { trim: false }), + *row, + ); + } +} + pub(super) fn centered_button_row( inner: Rect, widths: &[u16], |
