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:
2026-06-19 23:58:28 +03:00
parent d25a19b0cd
commit 3164d82227
4 changed files with 435 additions and 0 deletions
@@ -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());
}
}