Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
- Removed traces of the old statusbar implementation
7 changes: 6 additions & 1 deletion src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down
96 changes: 30 additions & 66 deletions src/ui/app_component.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -31,8 +31,6 @@ pub struct AppState {
pub sections: Vec<section::Model>,
pub sidebar_selection: SidebarSelection,
pub loading: bool,
pub error_message: Option<String>,
pub info_message: Option<String>,
pub show_help: bool,
/// didnt we just got rid of custom scrolling ?
pub help_scroll_offset: usize,
Expand All @@ -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 {
Expand All @@ -82,6 +74,7 @@ pub struct AppComponent {
active_sync_task: Option<TaskId>,
is_initial_sync: bool,
last_sync_attempt: Option<Instant>,
toast: Option<Toast>,

// Layout state
sidebar_visible: bool,
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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,
}
Expand All @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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
}
}
2 changes: 2 additions & 0 deletions src/ui/components/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -35,6 +36,7 @@

// Visual element components
pub mod badge;
pub mod toast;

// Utility components
pub mod scrollbar_helper;
Expand Down
98 changes: 98 additions & 0 deletions src/ui/components/toast.rs
Original file line number Diff line number Diff line change
@@ -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<Instant>,
}

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<Rect> {
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,
})
}
5 changes: 0 additions & 5 deletions src/ui/core/task_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion src/ui/renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 5 additions & 6 deletions tests/ui/app_component.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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;
Expand All @@ -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();
Expand Down
3 changes: 3 additions & 0 deletions tests/ui/components.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Loading