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 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..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}, @@ -31,8 +31,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 +50,6 @@ 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; - } } pub struct AppComponent { @@ -82,6 +74,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 +113,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 @@ -130,11 +124,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() @@ -475,14 +464,13 @@ 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())) + self.toast = Some(Toast::success(SUCCESS_SYNC_COMPLETED, &self.config.theme)); + 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 = Some(Toast::error(&message, &self.config.theme)); + Action::None } SyncStatus::Idle | SyncStatus::InProgress => Action::None, } @@ -492,8 +480,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 = Some(Toast::error(&error, &self.config.theme)); + Action::None } Action::ShowDialog(ref dialog_type) => { info!("Dialog: Showing dialog {:?}", dialog_type); @@ -1168,11 +1156,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 { @@ -1300,9 +1283,16 @@ impl Component for AppComponent { } self.task_list.render(f, main_chunks[1]); - // Render sync status if syncing or loading + // An in-flight sync outranks whatever the last one left behind. if self.state.loading || self.is_syncing() { - AppComponent::render_sync_status_impl(self, f, rect); + 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) @@ -1313,44 +1303,18 @@ 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}, - }; - - // 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); - - Layout::horizontal([Constraint::Percentage(30), Constraint::Min(30), Constraint::Percentage(30)]) - .split(popup_layout[1])[1] - }; - - let title = if self.state.loading { - UI_LOADING_DATA - } else { - UI_SYNCING_WITH_TODOIST - }; - - 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)), - ); + /// Whether a notice is parked in the corner. + pub fn has_toast(&self) -> bool { + self.toast.is_some() + } - f.render_widget(Clear, popup_area); - f.render_widget(content, popup_area); + /// 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(Toast::expired); + if stale { + self.toast = None; + } + stale } } 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/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, 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..8ec6dba 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,7 +73,10 @@ 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(); 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"); +}