From b42458864b3fb32786965cf690aeb69a6ff311d2 Mon Sep 17 00:00:00 2001 From: Romain Bertrand Date: Fri, 4 Sep 2026 17:39:42 +0200 Subject: [PATCH 1/4] feat(ui): replace the sync modal with a corner toast Every successful sync fired a centered Info dialog, and every failure a centered Error one. That was tolerable while sync blocked the UI, but sync now runs in the background and, since the auto-sync interval was wired up, fires on a timer. The modal had started interrupting the user every five minutes on its own. Sync outcomes now land in a small box in the bottom-right of the task list: success in theme green, gone after three seconds; failure in theme red, given fifteen because it carries something the user has to actually read. The in-flight indicator moves to the same corner instead of covering the middle of the screen. Task operation errors are untouched and still get a modal, since those are user-initiated. Nothing dismisses a toast by hand. Clearing it on any keypress meant a failure notice died to the next `j`, and Esc is already the quit key, so the TTL is the only thing that takes a toast down. The event loop only repaints on input or background work, so an expired toast would otherwise sit on screen until the next keystroke. `toast_expired()` asks for a repaint on tick once the TTL has passed, and that single repaint is what sweeps the toast away. The box is sized from the line's display width rather than its character count, because the status emoji occupy two columns and this is the same measure ratatui centers by. Getting that wrong put the leftover column entirely on one side. `info_message`, `error_message` and `clear_messages` are gone from `AppState`: they existed only to feed these dialogs, and `clear_messages` had no callers at all. --- src/constants.rs | 7 +- src/ui/app_component.rs | 139 ++++++++++++++++++++++++-------------- src/ui/renderer.rs | 5 +- tests/ui/app_component.rs | 44 ++++++++++-- 4 files changed, 137 insertions(+), 58 deletions(-) diff --git a/src/constants.rs b/src/constants.rs index a6f8723..4c359f0 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -27,7 +27,7 @@ pub const SUCCESS_LABEL_DELETED: &str = "✅ Label deleted"; pub const SUCCESS_LABEL_UPDATED: &str = "✅ Label updated"; pub const SUCCESS_TASK_PRIORITY_UPDATED: &str = "✅ Task priority updated to P"; pub const SUCCESS_TASK_RESTORED: &str = "✅ Task restored"; -pub const SUCCESS_SYNC_COMPLETED: &str = "Sync completed successfully"; +pub const SUCCESS_SYNC_COMPLETED: &str = "✅ Synced"; // Error Messages pub const ERROR_TASK_COMPLETION_FAILED: &str = "❌ Failed to complete task"; @@ -84,6 +84,11 @@ pub const UI_LOADING_DATA_FROM_STORAGE: &str = "Loading data from storage"; // Date header format for upcoming view pub const UPCOMING_DATE_FORMAT: &str = "📊 {} - {}"; +/// How long a success toast lingers before it fades on its own. +pub const TOAST_TTL_SECS: u64 = 3; +/// Failures get longer: they carry information the user has to actually read. +pub const TOAST_ERROR_TTL_SECS: u64 = 15; + // UI Layout Constants (width in columns) pub const SIDEBAR_MIN_WIDTH: u16 = 15; pub const SIDEBAR_MAX_WIDTH: u16 = 50; diff --git a/src/ui/app_component.rs b/src/ui/app_component.rs index 15b8e8f..ff1699e 100644 --- a/src/ui/app_component.rs +++ b/src/ui/app_component.rs @@ -15,7 +15,10 @@ use crate::utils::datetime; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use log::info; use ratatui::{ - layout::{Constraint, Layout, Rect}, + layout::{Alignment, Constraint, Layout, Rect}, + style::{Color, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph, Wrap}, Frame, }; use std::time::{Duration, Instant}; @@ -31,8 +34,6 @@ pub struct AppState { pub sections: Vec, pub sidebar_selection: SidebarSelection, pub loading: bool, - pub error_message: Option, - pub info_message: Option, pub show_help: bool, /// didnt we just got rid of custom scrolling ? pub help_scroll_offset: usize, @@ -52,12 +53,13 @@ impl AppState { self.sections = sections; self.tasks = tasks; } +} - /// Clear any transient messages - pub fn clear_messages(&mut self) { - self.error_message = None; - self.info_message = None; - } +/// A notice parked in the bottom-right corner, until `expires` passes. +struct Toast { + text: String, + color: Color, + expires: Instant, } pub struct AppComponent { @@ -82,6 +84,7 @@ pub struct AppComponent { active_sync_task: Option, is_initial_sync: bool, last_sync_attempt: Option, + toast: Option, // Layout state sidebar_visible: bool, @@ -120,6 +123,7 @@ impl AppComponent { active_sync_task: None, is_initial_sync: false, last_sync_attempt: None, + toast: None, sidebar_width: 30, // Default width screen_width: 100, // Default width screen_height: 50, // Default height @@ -475,14 +479,14 @@ impl AppComponent { SyncStatus::Success => { self.update_data_from_sync(SyncStatus::Success); self.sync_component_data(); - self.state.info_message = Some(SUCCESS_SYNC_COMPLETED.to_string()); - info!("Sync: Showing completion info dialog"); - Action::ShowDialog(DialogType::Info(self.state.info_message.clone().unwrap())) + let ok = self.config.theme.success; + self.toast(SUCCESS_SYNC_COMPLETED.into(), ok, TOAST_TTL_SECS); + Action::None } SyncStatus::Error { message } => { self.is_initial_sync = false; - self.state.error_message = Some(message); - Action::ShowDialog(DialogType::Error(self.state.error_message.clone().unwrap_or_default())) + self.toast_err(message); + Action::None } SyncStatus::Idle | SyncStatus::InProgress => Action::None, } @@ -492,8 +496,8 @@ impl AppComponent { self.active_sync_task = None; self.state.loading = false; self.is_initial_sync = false; // Reset flag on failure - self.state.error_message = Some(error); - Action::ShowDialog(DialogType::Error(self.state.error_message.clone().unwrap_or_default())) + self.toast_err(error); + Action::None } Action::ShowDialog(ref dialog_type) => { info!("Dialog: Showing dialog {:?}", dialog_type); @@ -1300,9 +1304,9 @@ impl Component for AppComponent { } self.task_list.render(f, main_chunks[1]); - // Render sync status if syncing or loading - if self.state.loading || self.is_syncing() { - AppComponent::render_sync_status_impl(self, f, rect); + // An in-flight sync outranks whatever the last one left behind. + if let Some((text, color)) = self.current_toast() { + self.render_toast(f, main_chunks[1], &text, color); } // Render dialog on top if visible (includes help dialog) @@ -1313,44 +1317,79 @@ impl Component for AppComponent { } impl AppComponent { - /// Render sync status indicator - fn render_sync_status_impl(&self, f: &mut Frame, rect: Rect) { - use ratatui::{ - layout::{Alignment, Constraint, Layout}, - style::Style, - text::{Line, Span}, - widgets::{Block, Borders, Clear, Paragraph}, - }; + fn toast(&mut self, text: String, color: Color, ttl_secs: u64) { + self.toast = Some(Toast { + text, + color, + expires: Instant::now() + Duration::from_secs(ttl_secs), + }); + } - // Calculate centered area for the sync indicator - let popup_area = { - let popup_layout = - Layout::vertical([Constraint::Percentage(40), Constraint::Min(3), Constraint::Percentage(40)]) - .split(rect); + fn toast_err(&mut self, message: String) { + let danger = self.config.theme.danger; + self.toast(format!("❌ {}", message), danger, TOAST_ERROR_TTL_SECS); + } - Layout::horizontal([Constraint::Percentage(30), Constraint::Min(30), Constraint::Percentage(30)]) - .split(popup_layout[1])[1] - }; + /// Whether a notice is parked in the corner. + pub fn has_toast(&self) -> bool { + self.toast.is_some() + } - let title = if self.state.loading { - UI_LOADING_DATA - } else { - UI_SYNCING_WITH_TODOIST + /// Drops an expired toast, reporting whether it did. The event loop only repaints on + /// input or background work, so it sweeps on tick to make stale toasts disappear. + pub fn sweep_toast(&mut self) -> bool { + let stale = self.toast.as_ref().is_some_and(|t| t.expires <= Instant::now()); + if stale { + self.toast = None; + } + stale + } + + fn current_toast(&self) -> Option<(String, Color)> { + if self.state.loading || self.is_syncing() { + let title = if self.state.loading { + UI_LOADING_DATA + } else { + UI_SYNCING_WITH_TODOIST + }; + return Some((format!("⟳ {}…", title), self.config.theme.warning)); + } + self.toast.as_ref().map(|t| (t.text.clone(), t.color)) + } + + /// Draw a notice in the bottom-right corner of `area`, one column clear of its border. + fn render_toast(&self, f: &mut Frame, area: Rect, text: &str, color: Color) { + // Size from display width, not char count: the status emoji are two columns each. + let line = Line::from(Span::styled(text, Style::default().fg(color))); + let Some(rect) = toast_rect(area, line.width()) else { + return; }; - let spinner = "⟳"; - let content = Paragraph::new(Line::from(Span::styled( - format!("{} {}…", spinner, title), - Style::default().fg(self.config.theme.warning), - ))) - .alignment(Alignment::Center) - .block( - Block::default() - .borders(Borders::ALL) - .style(Style::default().fg(self.config.theme.warning)), + f.render_widget(Clear, rect); + f.render_widget( + Paragraph::new(line) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }) + .block(Block::default().borders(Borders::ALL).style(Style::default().fg(color))), + rect, ); + } +} - f.render_widget(Clear, popup_area); - f.render_widget(content, popup_area); +/// Bottom-right box for `text_width` display columns, inset one column off the border. +/// `None` when `area` is too cramped to be worth it. +pub fn toast_rect(area: Rect, text_width: usize) -> Option { + if area.width < 12 || area.height < 5 { + return None; } + let len = u16::try_from(text_width).unwrap_or(u16::MAX); + let width = len.saturating_add(4).min(area.width - 2); + let lines = len.div_ceil(width - 2).max(1); + let height = (lines + 2).min(area.height - 2); + Some(Rect { + x: area.right() - width - 1, + y: area.bottom() - height - 1, + width, + height, + }) } diff --git a/src/ui/renderer.rs b/src/ui/renderer.rs index 09e4f7b..5570dc6 100644 --- a/src/ui/renderer.rs +++ b/src/ui/renderer.rs @@ -115,7 +115,10 @@ where } } } - // Don't render on every tick - only when there are actual background actions + // Otherwise don't render on every tick, only to sweep away a stale toast. + if app.sweep_toast() { + needs_render = true; + } } EventType::Render => { needs_render = true; diff --git a/tests/ui/app_component.rs b/tests/ui/app_component.rs index 97ad2d9..288b88c 100644 --- a/tests/ui/app_component.rs +++ b/tests/ui/app_component.rs @@ -5,10 +5,6 @@ fn test_app_state_default() { // Test that AppState can be created with default values let state = AppState::default(); assert!(!state.loading, "Default AppState should not be loading"); - assert!( - state.error_message.is_none(), - "Default AppState should have no error message" - ); } #[tokio::test] @@ -63,7 +59,7 @@ async fn startup_loads_cached_data_when_the_backend_is_unavailable() { for action in actions { app.handle_app_action(action).await; } - if app.total_projects() == 1 && app.state.error_message.is_some() { + if app.total_projects() == 1 && app.has_toast() { break; } tokio::time::sleep(std::time::Duration::from_millis(10)).await; @@ -77,9 +73,45 @@ async fn startup_loads_cached_data_when_the_backend_is_unavailable() { ); assert_eq!(app.total_projects(), 1); assert_eq!(app.state.projects[0].name, "Cached project"); - assert!(app.state.error_message.is_some()); + assert!( + app.has_toast(), + "the failed sync should have left a notice in the corner" + ); drop(app); // TaskManager cancels its tasks on drop storage.lock().await.conn.clone().close().await.unwrap(); std::fs::remove_file(db_path).unwrap(); } + +mod sync_toast { + use ratatui::layout::Rect; + use terminalist::ui::app_component::toast_rect; + + /// The toast must always land inside its area, borders included, whatever the + /// terminal size or message length. + #[test] + fn toast_rect_stays_inside_its_area() { + for w in 0..60u16 { + for h in 0..30u16 { + let area = Rect::new(3, 2, w, h); + for len in [0usize, 1, 8, 40, 400, usize::MAX] { + let Some(r) = toast_rect(area, len) else { continue }; + assert!(r.width >= 3 && r.height >= 3, "degenerate box {r:?}"); + assert!(r.x > area.x && r.y > area.y, "{r:?} not inset in {area:?}"); + assert!(r.right() < area.right(), "{r:?} overflows {area:?}"); + assert!(r.bottom() < area.bottom(), "{r:?} overflows {area:?}"); + } + } + } + } + + /// A long message grows the box downward rather than being silently clipped. + #[test] + fn toast_rect_grows_for_wrapped_text() { + let area = Rect::new(0, 0, 40, 20); + let short = toast_rect(area, 5).unwrap(); + let long = toast_rect(area, 200).unwrap(); + assert_eq!(short.height, 3); + assert!(long.height > short.height, "{long:?} should wrap onto more lines"); + } +} From 9209db8d7b52859dcf529add39ded2a722884208 Mon Sep 17 00:00:00 2001 From: Romain Bertrand Date: Fri, 4 Sep 2026 17:49:03 +0200 Subject: [PATCH 2/4] docs: mention the new toast --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20553ab..3d50f57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Periodic auto-sync** - `auto_sync_interval_minutes` is now actually honoured; until now the key was read from the config file and then ignored. Defaults to every 5 minutes, `0` disables it and leaves syncing manual. +### Changed +- **Sync status toast** - Replace blocking sync dialog with a toast. + ### Security - **Cache file permissions** - The local SQLite cache stores the Todoist API token, and is now created with `0600` so only its owner can read it. Existing cache files are tightened on the next launch. @@ -157,4 +160,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed - Removed mentions of the old status bar - Cleaned up unused/dead code -- Removed traces of the old statusbar implementation \ No newline at end of file +- Removed traces of the old statusbar implementation From 77d36dc626f2da525e1e1a63de949693fdf4be07 Mon Sep 17 00:00:00 2001 From: Romain Bertrand Date: Fri, 4 Sep 2026 18:13:52 +0200 Subject: [PATCH 3/4] refactor: drop unused task-count helpers is_busy, active_task_count and task_count had zero callers; the lib target keeps the dead_code lint quiet about pub items. --- src/ui/app_component.rs | 10 ---------- src/ui/core/task_manager.rs | 5 ----- 2 files changed, 15 deletions(-) diff --git a/src/ui/app_component.rs b/src/ui/app_component.rs index ff1699e..c308021 100644 --- a/src/ui/app_component.rs +++ b/src/ui/app_component.rs @@ -134,11 +134,6 @@ impl AppComponent { self.should_quit } - /// Get the number of active background tasks - pub fn active_task_count(&self) -> usize { - self.task_manager.task_count() - } - /// Check if currently syncing pub fn is_syncing(&self) -> bool { self.active_sync_task.is_some() @@ -1172,11 +1167,6 @@ impl AppComponent { actions } - /// Check if any background operations are running - pub fn is_busy(&self) -> bool { - self.task_manager.task_count() > 0 - } - /// Process an event through the component hierarchy pub async fn handle_event(&mut self, event_type: EventType) -> anyhow::Result<()> { let action = match event_type { diff --git a/src/ui/core/task_manager.rs b/src/ui/core/task_manager.rs index 259e621..4a93ace 100644 --- a/src/ui/core/task_manager.rs +++ b/src/ui/core/task_manager.rs @@ -175,11 +175,6 @@ impl TaskManager { } } - /// Get the number of active tasks - pub fn task_count(&self) -> usize { - self.tasks.len() - } - /// Spawn a background data loading operation pub fn spawn_data_load( &mut self, From 12bf79fbd4d19323973a384685dd61c753655f23 Mon Sep 17 00:00:00 2001 From: Romain Bertrand Date: Fri, 4 Sep 2026 18:15:28 +0200 Subject: [PATCH 4/4] refactor(ui): extract the toast into its own component The corner toast lived in app_component.rs as a private struct plus four methods, with its layout helper made pub only so a test could reach it. It now sits in src/ui/components/toast.rs next to badge.rs, and its test in the mirroring tests/ui/components/toast.rs. Toast::success, Toast::error and Toast::spinner replace the ad-hoc constructors, and `expires` became Option: a spinner is a toast with no deadline, so expired() no longer has to rely on nobody asking. app_component.rs keeps only what the app owns, the Option field and sweep_toast(), which is an event-loop concern rather than a widget one. current_toast() and render_toast() are gone, the render site is a plain if/else. --- src/ui/app_component.rs | 95 ++++++---------------------------- src/ui/components/mod.rs | 2 + src/ui/components/toast.rs | 98 ++++++++++++++++++++++++++++++++++++ tests/ui/app_component.rs | 33 ------------ tests/ui/components.rs | 3 ++ tests/ui/components/toast.rs | 33 ++++++++++++ 6 files changed, 151 insertions(+), 113 deletions(-) create mode 100644 src/ui/components/toast.rs create mode 100644 tests/ui/components/toast.rs diff --git a/src/ui/app_component.rs b/src/ui/app_component.rs index c308021..80bdf92 100644 --- a/src/ui/app_component.rs +++ b/src/ui/app_component.rs @@ -3,7 +3,7 @@ use crate::constants::*; use crate::entities::{label, project, section, task}; use crate::sync::{SyncService, SyncStatus}; use crate::theme::{self, ThemeWarning}; -use crate::ui::components::{DialogComponent, SidebarComponent, TaskListComponent}; +use crate::ui::components::{toast::Toast, DialogComponent, SidebarComponent, TaskListComponent}; use crate::ui::core::SidebarSelection; use crate::ui::core::{ actions::{Action, DialogType}, @@ -15,10 +15,7 @@ use crate::utils::datetime; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use log::info; use ratatui::{ - layout::{Alignment, Constraint, Layout, Rect}, - style::{Color, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Clear, Paragraph, Wrap}, + layout::{Constraint, Layout, Rect}, Frame, }; use std::time::{Duration, Instant}; @@ -55,13 +52,6 @@ impl AppState { } } -/// A notice parked in the bottom-right corner, until `expires` passes. -struct Toast { - text: String, - color: Color, - expires: Instant, -} - pub struct AppComponent { // Component composition sidebar: SidebarComponent, @@ -474,13 +464,12 @@ impl AppComponent { SyncStatus::Success => { self.update_data_from_sync(SyncStatus::Success); self.sync_component_data(); - let ok = self.config.theme.success; - self.toast(SUCCESS_SYNC_COMPLETED.into(), ok, TOAST_TTL_SECS); + self.toast = Some(Toast::success(SUCCESS_SYNC_COMPLETED, &self.config.theme)); Action::None } SyncStatus::Error { message } => { self.is_initial_sync = false; - self.toast_err(message); + self.toast = Some(Toast::error(&message, &self.config.theme)); Action::None } SyncStatus::Idle | SyncStatus::InProgress => Action::None, @@ -491,7 +480,7 @@ impl AppComponent { self.active_sync_task = None; self.state.loading = false; self.is_initial_sync = false; // Reset flag on failure - self.toast_err(error); + self.toast = Some(Toast::error(&error, &self.config.theme)); Action::None } Action::ShowDialog(ref dialog_type) => { @@ -1295,8 +1284,15 @@ impl Component for AppComponent { self.task_list.render(f, main_chunks[1]); // An in-flight sync outranks whatever the last one left behind. - if let Some((text, color)) = self.current_toast() { - self.render_toast(f, main_chunks[1], &text, color); + if self.state.loading || self.is_syncing() { + let title = if self.state.loading { + UI_LOADING_DATA + } else { + UI_SYNCING_WITH_TODOIST + }; + Toast::spinner(title, &self.config.theme).render(f, main_chunks[1]); + } else if let Some(toast) = &self.toast { + toast.render(f, main_chunks[1]); } // Render dialog on top if visible (includes help dialog) @@ -1307,19 +1303,6 @@ impl Component for AppComponent { } impl AppComponent { - fn toast(&mut self, text: String, color: Color, ttl_secs: u64) { - self.toast = Some(Toast { - text, - color, - expires: Instant::now() + Duration::from_secs(ttl_secs), - }); - } - - fn toast_err(&mut self, message: String) { - let danger = self.config.theme.danger; - self.toast(format!("❌ {}", message), danger, TOAST_ERROR_TTL_SECS); - } - /// Whether a notice is parked in the corner. pub fn has_toast(&self) -> bool { self.toast.is_some() @@ -1328,58 +1311,10 @@ impl AppComponent { /// Drops an expired toast, reporting whether it did. The event loop only repaints on /// input or background work, so it sweeps on tick to make stale toasts disappear. pub fn sweep_toast(&mut self) -> bool { - let stale = self.toast.as_ref().is_some_and(|t| t.expires <= Instant::now()); + let stale = self.toast.as_ref().is_some_and(Toast::expired); if stale { self.toast = None; } stale } - - fn current_toast(&self) -> Option<(String, Color)> { - if self.state.loading || self.is_syncing() { - let title = if self.state.loading { - UI_LOADING_DATA - } else { - UI_SYNCING_WITH_TODOIST - }; - return Some((format!("⟳ {}…", title), self.config.theme.warning)); - } - self.toast.as_ref().map(|t| (t.text.clone(), t.color)) - } - - /// Draw a notice in the bottom-right corner of `area`, one column clear of its border. - fn render_toast(&self, f: &mut Frame, area: Rect, text: &str, color: Color) { - // Size from display width, not char count: the status emoji are two columns each. - let line = Line::from(Span::styled(text, Style::default().fg(color))); - let Some(rect) = toast_rect(area, line.width()) else { - return; - }; - - f.render_widget(Clear, rect); - f.render_widget( - Paragraph::new(line) - .alignment(Alignment::Center) - .wrap(Wrap { trim: true }) - .block(Block::default().borders(Borders::ALL).style(Style::default().fg(color))), - rect, - ); - } -} - -/// Bottom-right box for `text_width` display columns, inset one column off the border. -/// `None` when `area` is too cramped to be worth it. -pub fn toast_rect(area: Rect, text_width: usize) -> Option { - if area.width < 12 || area.height < 5 { - return None; - } - let len = u16::try_from(text_width).unwrap_or(u16::MAX); - let width = len.saturating_add(4).min(area.width - 2); - let lines = len.div_ceil(width - 2).max(1); - let height = (lines + 2).min(area.height - 2); - Some(Rect { - x: area.right() - width - 1, - y: area.bottom() - height - 1, - width, - height, - }) } diff --git a/src/ui/components/mod.rs b/src/ui/components/mod.rs index fab559c..74156e8 100644 --- a/src/ui/components/mod.rs +++ b/src/ui/components/mod.rs @@ -9,6 +9,7 @@ //! //! ## Visual Elements //! - [`badge`] - Small status indicators and labels +//! - [`toast`] - Transient corner notices for background work //! //! ## Interactive Components //! - [`dialog_component`] - Modal dialog framework @@ -35,6 +36,7 @@ // Visual element components pub mod badge; +pub mod toast; // Utility components pub mod scrollbar_helper; diff --git a/src/ui/components/toast.rs b/src/ui/components/toast.rs new file mode 100644 index 0000000..76ad538 --- /dev/null +++ b/src/ui/components/toast.rs @@ -0,0 +1,98 @@ +//! Transient notices parked in the bottom-right corner. +//! +//! A toast reports the outcome of background work without stealing focus the way a +//! modal dialog does. It carries its own expiry, so the owner only has to drop it +//! once [`Toast::expired`] says so. + +use crate::constants::{TOAST_ERROR_TTL_SECS, TOAST_TTL_SECS}; +use crate::theme::Theme; +use ratatui::{ + layout::{Alignment, Rect}, + style::{Color, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph, Wrap}, + Frame, +}; +use std::time::{Duration, Instant}; + +/// A notice in the corner. `expires` is `None` for one that stays until whatever it +/// reports on finishes, like an in-flight sync. +pub struct Toast { + text: String, + color: Color, + expires: Option, +} + +impl Toast { + #[must_use] + pub fn success(text: &str, theme: &Theme) -> Self { + Self::timed(text.to_string(), theme.success, TOAST_TTL_SECS) + } + + /// Failures linger: they carry information the user has to actually read. + #[must_use] + pub fn error(message: &str, theme: &Theme) -> Self { + Self::timed(format!("❌ {message}"), theme.danger, TOAST_ERROR_TTL_SECS) + } + + /// Work in progress. Sticks around until the caller stops rendering it. + #[must_use] + pub fn spinner(title: &str, theme: &Theme) -> Self { + Self { + text: format!("⟳ {title}…"), + color: theme.warning, + expires: None, + } + } + + fn timed(text: String, color: Color, ttl_secs: u64) -> Self { + Self { + text, + color, + expires: Some(Instant::now() + Duration::from_secs(ttl_secs)), + } + } + + /// Whether this toast has outlived its TTL. Always false for a spinner. + #[must_use] + pub fn expired(&self) -> bool { + self.expires.is_some_and(|e| e <= Instant::now()) + } + + /// Draw into the bottom-right of `area`, one column clear of its border. + pub fn render(&self, f: &mut Frame, area: Rect) { + // Size from display width, not char count: the status emoji are two columns each. + let line = Line::from(Span::styled(self.text.as_str(), Style::default().fg(self.color))); + let Some(bounds) = rect(area, line.width()) else { + return; + }; + + f.render_widget(Clear, bounds); + f.render_widget( + Paragraph::new(line) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }) + .block(Block::default().borders(Borders::ALL).style(Style::default().fg(self.color))), + bounds, + ); + } +} + +/// Bottom-right box for `text_width` display columns, inset one column off the border. +/// `None` when `area` is too cramped to be worth it. +#[must_use] +pub fn rect(area: Rect, text_width: usize) -> Option { + if area.width < 12 || area.height < 5 { + return None; + } + let len = u16::try_from(text_width).unwrap_or(u16::MAX); + let width = len.saturating_add(4).min(area.width - 2); + let lines = len.div_ceil(width - 2).max(1); + let height = (lines + 2).min(area.height - 2); + Some(Rect { + x: area.right() - width - 1, + y: area.bottom() - height - 1, + width, + height, + }) +} diff --git a/tests/ui/app_component.rs b/tests/ui/app_component.rs index 288b88c..8ec6dba 100644 --- a/tests/ui/app_component.rs +++ b/tests/ui/app_component.rs @@ -82,36 +82,3 @@ async fn startup_loads_cached_data_when_the_backend_is_unavailable() { storage.lock().await.conn.clone().close().await.unwrap(); std::fs::remove_file(db_path).unwrap(); } - -mod sync_toast { - use ratatui::layout::Rect; - use terminalist::ui::app_component::toast_rect; - - /// The toast must always land inside its area, borders included, whatever the - /// terminal size or message length. - #[test] - fn toast_rect_stays_inside_its_area() { - for w in 0..60u16 { - for h in 0..30u16 { - let area = Rect::new(3, 2, w, h); - for len in [0usize, 1, 8, 40, 400, usize::MAX] { - let Some(r) = toast_rect(area, len) else { continue }; - assert!(r.width >= 3 && r.height >= 3, "degenerate box {r:?}"); - assert!(r.x > area.x && r.y > area.y, "{r:?} not inset in {area:?}"); - assert!(r.right() < area.right(), "{r:?} overflows {area:?}"); - assert!(r.bottom() < area.bottom(), "{r:?} overflows {area:?}"); - } - } - } - } - - /// A long message grows the box downward rather than being silently clipped. - #[test] - fn toast_rect_grows_for_wrapped_text() { - let area = Rect::new(0, 0, 40, 20); - let short = toast_rect(area, 5).unwrap(); - let long = toast_rect(area, 200).unwrap(); - assert_eq!(short.height, 3); - assert!(long.height > short.height, "{long:?} should wrap onto more lines"); - } -} diff --git a/tests/ui/components.rs b/tests/ui/components.rs index aade189..c272572 100644 --- a/tests/ui/components.rs +++ b/tests/ui/components.rs @@ -10,6 +10,9 @@ mod dialogs; #[path = "components/sidebar_component.rs"] mod sidebar_component; +#[path = "components/toast.rs"] +mod toast; + #[path = "components/task_list_component.rs"] mod task_list_component; diff --git a/tests/ui/components/toast.rs b/tests/ui/components/toast.rs new file mode 100644 index 0000000..865f7c4 --- /dev/null +++ b/tests/ui/components/toast.rs @@ -0,0 +1,33 @@ +//! Layout maths for the corner toast. The rendering itself is eyeballed, but the box +//! has to stay inside its area at every terminal size, so that part gets a check. + +use ratatui::layout::Rect; +use terminalist::ui::components::toast; + +/// The toast must always land inside its area, borders included, whatever the +/// terminal size or message length. +#[test] +fn rect_stays_inside_its_area() { + for w in 0..60u16 { + for h in 0..30u16 { + let area = Rect::new(3, 2, w, h); + for len in [0usize, 1, 8, 40, 400, usize::MAX] { + let Some(r) = toast::rect(area, len) else { continue }; + assert!(r.width >= 3 && r.height >= 3, "degenerate box {r:?}"); + assert!(r.x > area.x && r.y > area.y, "{r:?} not inset in {area:?}"); + assert!(r.right() < area.right(), "{r:?} overflows {area:?}"); + assert!(r.bottom() < area.bottom(), "{r:?} overflows {area:?}"); + } + } + } +} + +/// A long message grows the box downward rather than being silently clipped. +#[test] +fn rect_grows_for_wrapped_text() { + let area = Rect::new(0, 0, 40, 20); + let short = toast::rect(area, 5).unwrap(); + let long = toast::rect(area, 200).unwrap(); + assert_eq!(short.height, 3); + assert!(long.height > short.height, "{long:?} should wrap onto more lines"); +}