diff --git a/local/recipes/tui/tlc/source/src/terminal/color.rs b/local/recipes/tui/tlc/source/src/terminal/color.rs index 75a1e14b77..a543b357e7 100644 --- a/local/recipes/tui/tlc/source/src/terminal/color.rs +++ b/local/recipes/tui/tlc/source/src/terminal/color.rs @@ -346,6 +346,66 @@ pub struct Theme { } impl Theme { + /// Drop-shadow colour for popups and dialogs. + /// + /// Always black or near-black — the shadow should be darker than + /// any panel background. + #[must_use] + pub fn shadow(&self) -> Color { + match self.background { + Color::Rgb(r, g, b) if r + g + b < 100 => Color::Rgb(0, 0, 0), + _ => Color::Rgb(0, 0, 0), + } + } + + /// Background colour for the editor's current-line highlight. + /// + /// Derived by lightening the panel background by ~8 %. + #[must_use] + pub fn current_line_bg(&self) -> Color { + match self.background { + Color::Rgb(r, g, b) => { + Color::Rgb(r.saturating_add(12), g.saturating_add(12), b.saturating_add(12)) + } + _ => self.cursor_bg, + } + } + + /// Background colour for alternating (even-indexed) rows in the file + /// list. Derived by lightening the panel background by ~4 %. + #[must_use] + pub fn alt_row_bg(&self) -> Color { + match self.background { + Color::Rgb(r, g, b) => { + Color::Rgb(r.saturating_add(6), g.saturating_add(6), b.saturating_add(6)) + } + _ => self.background, + } + } + + /// Top-edge highlight colour for 3-D buttons (the `▔` row). + #[must_use] + pub fn button_highlight(&self) -> Color { + match self.cursor_bg { + Color::Rgb(r, g, b) => { + Color::Rgb(r.saturating_add(40), g.saturating_add(40), b.saturating_add(40)) + } + _ => self.info, + } + } + + /// Bottom-edge shadow colour for 3-D buttons (the `▁` row). + #[must_use] + pub fn button_shadow(&self) -> Color { + self.shadow() + } + + /// Spinner foreground colour. + #[must_use] + pub fn spinner_fg(&self) -> Color { + self.info + } + /// Look up a theme by name. /// /// Recognised built-in names are: diff --git a/local/recipes/tui/tlc/source/src/widget/mod.rs b/local/recipes/tui/tlc/source/src/widget/mod.rs index 2ff3767398..4194d96e77 100644 --- a/local/recipes/tui/tlc/source/src/widget/mod.rs +++ b/local/recipes/tui/tlc/source/src/widget/mod.rs @@ -11,6 +11,8 @@ pub mod dialog; pub mod gauge; pub mod input; pub mod radio; +pub mod spinner; +pub mod toast; use bitflags::bitflags; use ratatui::layout::Rect; @@ -147,3 +149,5 @@ pub use dialog::{Dialog, DialogButton, DialogResult}; pub use gauge::ProgressGauge; pub use input::Input; pub use radio::{RadioButton, RadioGroup}; +pub use spinner::Spinner; +pub use toast::{Toast, ToastLevel, ToastManager}; diff --git a/local/recipes/tui/tlc/source/src/widget/spinner.rs b/local/recipes/tui/tlc/source/src/widget/spinner.rs new file mode 100644 index 0000000000..318ed178d2 --- /dev/null +++ b/local/recipes/tui/tlc/source/src/widget/spinner.rs @@ -0,0 +1,118 @@ +//! Braille spinner widget for loading and background-job indicators. + +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::text::Span; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +use crate::terminal::color::Theme; + +const SPINNER_FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + +/// A Unicode braille spinner that cycles through 10 frames. +#[derive(Debug, Clone, Default)] +pub struct Spinner { + frame: u64, + active: bool, +} + +impl Spinner { + /// Create a new inactive spinner. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Start the spinner (it will begin cycling on the next tick). + pub fn start(&mut self) { + self.active = true; + } + + /// Stop the spinner and reset the frame counter. + pub fn stop(&mut self) { + self.active = false; + self.frame = 0; + } + + /// Returns `true` if the spinner is actively cycling. + #[must_use] + pub fn is_active(&self) -> bool { + self.active + } + + /// Advance the animation by one frame. Should be called on every + /// event-loop tick (~100 ms). + pub fn tick(&mut self) { + if self.active { + self.frame = self.frame.wrapping_add(1); + } + } + + /// Current spinner character. + #[must_use] + pub fn current_char(&self) -> char { + if !self.active { + return ' '; + } + let idx = ((self.frame / 3) % SPINNER_FRAMES.len() as u64) as usize; + SPINNER_FRAMES[idx] + } + + /// Render the spinner as a single-character span. + pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { + let ch = self.current_char(); + let span = Span::styled( + ch.to_string(), + Style::default() + .fg(theme.spinner_fg()) + .add_modifier(Modifier::BOLD), + ); + frame.render_widget(Paragraph::new(span), area); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn inactive_spinner_returns_space() { + let s = Spinner::new(); + assert_eq!(s.current_char(), ' '); + } + + #[test] + fn active_spinner_cycles_frames() { + let mut s = Spinner::new(); + s.start(); + let first = s.current_char(); + s.tick(); + s.tick(); + s.tick(); + let after_tick = s.current_char(); + assert_ne!(first, ' '); + assert!(SPINNER_FRAMES.contains(&after_tick)); + } + + #[test] + fn stop_resets_frame() { + let mut s = Spinner::new(); + s.start(); + for _ in 0..10 { + s.tick(); + } + assert!(s.frame > 0); + s.stop(); + assert!(!s.is_active()); + assert_eq!(s.frame, 0); + } + + #[test] + fn start_sets_active() { + let mut s = Spinner::new(); + assert!(!s.is_active()); + s.start(); + assert!(s.is_active()); + } +} diff --git a/local/recipes/tui/tlc/source/src/widget/toast.rs b/local/recipes/tui/tlc/source/src/widget/toast.rs new file mode 100644 index 0000000000..bfdc5a7d71 --- /dev/null +++ b/local/recipes/tui/tlc/source/src/widget/toast.rs @@ -0,0 +1,253 @@ +//! Toast notification system with slide-in animation. + +use std::time::{Duration, Instant}; + +use ratatui::layout::{Alignment, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph, Widget}; +use ratatui::Frame; + +use crate::terminal::color::Theme; + +const SLIDE_FRAMES: u8 = 4; +const SLIDE_STEP: u8 = 100 / SLIDE_FRAMES; + +/// Severity level of a toast notification. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToastLevel { + /// Informational message (cyan). + Info, + /// Success confirmation (green). + Success, + /// Warning message (yellow). + Warning, + /// Error message (red, bold). + Error, +} + +impl ToastLevel { + fn icon(&self) -> &str { + match self { + Self::Info => "ℹ", + Self::Success => "✓", + Self::Warning => "⚠", + Self::Error => "✗", + } + } + + fn style(&self, theme: &Theme) -> Style { + match self { + Self::Info => Style::default().fg(theme.info), + Self::Success => Style::default().fg(theme.executable), + Self::Warning => Style::default().fg(theme.warning), + Self::Error => Style::default().fg(theme.error).add_modifier(Modifier::BOLD), + } + } +} + +/// A single toast notification with animation state. +#[derive(Debug, Clone)] +pub struct Toast { + message: String, + level: ToastLevel, + spawned_at: Instant, + ttl: Duration, + slide_percent: u8, + closing: bool, +} + +impl Toast { + /// Create a new info-level toast that lives for `ttl`. + #[must_use] + pub fn new(message: impl Into, level: ToastLevel, ttl: Duration) -> Self { + Self { + message: message.into(), + level, + spawned_at: Instant::now(), + ttl, + slide_percent: 0, + closing: false, + } + } + + /// Whether this toast has finished its lifecycle. + #[must_use] + pub fn is_done(&self) -> bool { + self.closing && self.slide_percent == 0 || self.spawned_at.elapsed() > self.ttl + Duration::from_millis(300) + } + + /// Advance the animation by one frame. + pub fn tick(&mut self) { + if self.closing { + self.slide_percent = self.slide_percent.saturating_sub(SLIDE_STEP); + } else if self.slide_percent < 100 { + self.slide_percent = (self.slide_percent + SLIDE_STEP).min(100); + } + } + + /// Begin the closing animation. + pub fn close(&mut self) { + self.closing = true; + } + + /// Whether this toast is mid-animation (needs continued ticking). + #[must_use] + pub fn is_animating(&self) -> bool { + (self.slide_percent > 0 && self.slide_percent < 100) || self.closing + } +} + +/// Manages a queue of toast notifications. +#[derive(Debug, Clone, Default)] +pub struct ToastManager { + toasts: Vec, + pending: Vec<(String, ToastLevel, Duration)>, +} + +impl ToastManager { + /// Create an empty toast manager. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Queue a new toast for display. + pub fn push(&mut self, message: impl Into, level: ToastLevel) { + self.pending.push((message.into(), level, Duration::from_secs(3))); + } + + /// Queue a new toast with a custom TTL. + pub fn push_with_ttl(&mut self, message: impl Into, level: ToastLevel, ttl: Duration) { + self.pending.push((message.into(), level, ttl)); + } + + /// Whether any toasts are visible or animating. + #[must_use] + pub fn is_active(&self) -> bool { + !self.toasts.is_empty() + } + + /// Advance all toast animations. Promotes pending toasts and + /// removes finished ones. Returns `true` if a redraw is needed. + pub fn tick(&mut self) -> bool { + while self.toasts.len() < 3 { + if let Some((msg, level, ttl)) = self.pending.pop() { + self.toasts.push(Toast::new(msg, level, ttl)); + } else { + break; + } + } + let mut need_redraw = false; + for t in &mut self.toasts { + let was_animating = t.is_animating(); + if t.spawned_at.elapsed() > t.ttl && !t.closing { + t.close(); + } + t.tick(); + if was_animating || t.is_animating() { + need_redraw = true; + } + } + self.toasts.retain(|t| !t.is_done()); + need_redraw || !self.pending.is_empty() + } + + /// Render all visible toasts in the bottom-right corner. + pub fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) { + let visible = self.toasts.iter().rev().take(3).collect::>(); + for (i, toast) in visible.iter().enumerate() { + let w = 38u16.min(area.width.saturating_sub(2)); + let h = 3u16; + let base_x = area.right().saturating_sub(w + 1); + let base_y = area.bottom().saturating_sub(5 + (i as u16) * 4); + + let slide_offset = ((100 - toast.slide_percent) as i32 * w as i32) / 100; + let toast_area = Rect { + x: base_x.saturating_add(slide_offset as u16), + y: base_y, + width: w, + height: h, + }; + if toast_area.right() > area.right() || toast_area.x < area.x { + continue; + } + Clear.render(toast_area, frame.buffer_mut()); + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(toast.level.style(theme)) + .style(Style::default().bg(theme.background)); + let inner = block.inner(toast_area); + frame.render_widget(block, toast_area); + let text = format!("{} {}", toast.level.icon(), toast.message); + frame.render_widget( + Paragraph::new(text) + .style(toast.level.style(theme)) + .alignment(Alignment::Left), + inner, + ); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn toast_starts_at_zero_percent() { + let t = Toast::new("hello", ToastLevel::Info, Duration::from_secs(1)); + assert_eq!(t.slide_percent, 0); + assert!(!t.closing); + } + + #[test] + fn toast_slides_in_over_frames() { + let mut t = Toast::new("hi", ToastLevel::Success, Duration::from_secs(5)); + for _ in 0..SLIDE_FRAMES { + t.tick(); + } + assert_eq!(t.slide_percent, 100); + } + + #[test] + fn toast_closes_and_slides_out() { + let mut t = Toast::new("err", ToastLevel::Error, Duration::from_secs(5)); + for _ in 0..SLIDE_FRAMES { + t.tick(); + } + assert_eq!(t.slide_percent, 100); + t.close(); + assert!(t.closing); + for _ in 0..SLIDE_FRAMES { + t.tick(); + } + assert_eq!(t.slide_percent, 0); + } + + #[test] + fn toast_manager_queues_and_promotes() { + let mut mgr = ToastManager::new(); + mgr.push("one", ToastLevel::Info); + mgr.push("two", ToastLevel::Warning); + mgr.tick(); + assert!(!mgr.toasts.is_empty()); + } + + #[test] + fn toast_manager_caps_at_three_visible() { + let mut mgr = ToastManager::new(); + mgr.push("a", ToastLevel::Info); + mgr.push("b", ToastLevel::Info); + mgr.push("c", ToastLevel::Info); + mgr.push("d", ToastLevel::Info); + mgr.tick(); + assert!(mgr.toasts.len() <= 3); + } + + #[test] + fn toast_level_icons_differ() { + assert_ne!(ToastLevel::Info.icon(), ToastLevel::Error.icon()); + assert_ne!(ToastLevel::Success.icon(), ToastLevel::Warning.icon()); + } +}