aboutsummaryrefslogtreecommitdiff
path: root/src/ui
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-04-22 17:01:25 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-04-22 17:01:25 +0300
commit4b482f37a1e233b9aca6bf99b12e8b4da3e5c05e (patch)
tree69d1a56ad6335547eec9727691310cf96ebe9623 /src/ui
parent5bd939fbfc92a8073d0eac11dfb02a31ea638b15 (diff)
feat: add terminal-delivered client notifications
Diffstat (limited to 'src/ui')
-rw-r--r--src/ui/onboarding.rs113
-rw-r--r--src/ui/settings.rs74
-rw-r--r--src/ui/widgets.rs87
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],