tlc: add spinner + toast widgets and visual Theme methods
Phase 1 animation foundation: braille spinner widget (10 frames), toast notifications with slide-in animation (25% per frame), and 6 new derived Theme methods (shadow, current_line_bg, alt_row_bg, button_highlight, button_shadow, spinner_fg) for all subsequent visual polish phases. 1041 tests pass.
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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<String>, 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<Toast>,
|
||||
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<String>, 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<String>, 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::<Vec<_>>();
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user