a789495a98
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
263 lines
8.7 KiB
Rust
263 lines
8.7 KiB
Rust
use crate::color;
|
|
use crate::color::rgb;
|
|
use ratatui::prelude::*;
|
|
|
|
pub fn user_color() -> Color {
|
|
rgb(138, 180, 248)
|
|
}
|
|
pub fn ai_color() -> Color {
|
|
rgb(129, 199, 132)
|
|
}
|
|
pub fn tool_color() -> Color {
|
|
rgb(120, 120, 120)
|
|
}
|
|
pub fn file_link_color() -> Color {
|
|
rgb(180, 200, 255)
|
|
}
|
|
pub fn dim_color() -> Color {
|
|
rgb(80, 80, 80)
|
|
}
|
|
pub fn accent_color() -> Color {
|
|
rgb(186, 139, 255)
|
|
}
|
|
pub fn system_message_color() -> Color {
|
|
rgb(255, 170, 220)
|
|
}
|
|
pub fn queued_color() -> Color {
|
|
rgb(255, 193, 7)
|
|
}
|
|
pub fn asap_color() -> Color {
|
|
rgb(110, 210, 255)
|
|
}
|
|
pub fn pending_color() -> Color {
|
|
rgb(140, 140, 140)
|
|
}
|
|
pub fn user_text() -> Color {
|
|
rgb(245, 245, 255)
|
|
}
|
|
pub fn user_bg() -> Color {
|
|
rgb(35, 40, 50)
|
|
}
|
|
pub fn ai_text() -> Color {
|
|
rgb(220, 220, 215)
|
|
}
|
|
pub fn header_icon_color() -> Color {
|
|
rgb(120, 210, 230)
|
|
}
|
|
pub fn header_name_color() -> Color {
|
|
rgb(190, 210, 235)
|
|
}
|
|
pub fn header_session_color() -> Color {
|
|
rgb(255, 255, 255)
|
|
}
|
|
|
|
// Spinner frames for animated status. Keep these single-cell because the fast
|
|
// spinner-only renderer patches one status cell between full TUI redraws. This
|
|
// sequence should read as a circular spin, not a grow/recede pulse.
|
|
const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
|
|
/// Frame rate for slow, full-line "liveness" indicators that can only be
|
|
/// repainted by a full TUI redraw (e.g. the running-tool progress bar) when
|
|
/// decorative animations are disabled (Minimal tier, SSH, WSL, etc.). These
|
|
/// ride the ~1 Hz passive-liveness redraw, so advancing them faster would just
|
|
/// skip frames. Keep this slow so they read as alive without forcing more
|
|
/// expensive full-frame redraws.
|
|
pub const LIVENESS_INDICATOR_FPS: f32 = 1.5;
|
|
|
|
/// Frame rate for the low-cost single-cell circular spinner when decorative
|
|
/// animations are disabled. Unlike the full-line indicators above, this spinner
|
|
/// is patched by the cheap one-cell fast path between full redraws, so it can
|
|
/// animate at a smooth, responsive cadence (well above ~1 Hz) while still
|
|
/// staying very light on resources. Keep this in sync with the spinner-only
|
|
/// tick interval in the TUI run loop (`STATUS_SPINNER_ONLY_INTERVAL`, 80ms) so
|
|
/// each tick lands on exactly one new frame.
|
|
pub const LIVENESS_SPINNER_FPS: f32 = 12.5;
|
|
|
|
pub fn spinner_frame_index(elapsed: f32, fps: f32) -> usize {
|
|
((elapsed * fps) as usize) % SPINNER_FRAMES.len()
|
|
}
|
|
|
|
pub fn spinner_frame(elapsed: f32, fps: f32) -> &'static str {
|
|
SPINNER_FRAMES[spinner_frame_index(elapsed, fps)]
|
|
}
|
|
|
|
pub fn activity_indicator_frame_index(
|
|
elapsed: f32,
|
|
fps: f32,
|
|
enable_decorative_animations: bool,
|
|
) -> usize {
|
|
if enable_decorative_animations {
|
|
spinner_frame_index(elapsed, fps)
|
|
} else {
|
|
// Keep ticking at the smooth liveness rate instead of freezing on a
|
|
// single frame. The single-cell fast path repaints this cheaply, so it
|
|
// can animate well above ~1 Hz without a full-frame redraw.
|
|
spinner_frame_index(elapsed, LIVENESS_SPINNER_FPS)
|
|
}
|
|
}
|
|
|
|
pub fn activity_indicator(
|
|
elapsed: f32,
|
|
fps: f32,
|
|
enable_decorative_animations: bool,
|
|
) -> &'static str {
|
|
SPINNER_FRAMES[activity_indicator_frame_index(elapsed, fps, enable_decorative_animations)]
|
|
}
|
|
|
|
/// Convert HSL to RGB (h in 0-360, s and l in 0-1)
|
|
/// Chroma color based on position and time - creates flowing rainbow wave
|
|
/// Calculate chroma color with fade-in from dim during startup
|
|
/// Calculate smooth animated color for the header (single color, no position)
|
|
pub fn color_to_floats(c: Color, fallback: (f32, f32, f32)) -> (f32, f32, f32) {
|
|
match c {
|
|
Color::Rgb(r, g, b) => (r as f32, g as f32, b as f32),
|
|
Color::Indexed(n) => {
|
|
let (r, g, b) = color::indexed_to_rgb(n);
|
|
(r as f32, g as f32, b as f32)
|
|
}
|
|
_ => fallback,
|
|
}
|
|
}
|
|
|
|
pub fn blend_color(from: Color, to: Color, t: f32) -> Color {
|
|
let (fr, fg, fb) = color_to_floats(from, (80.0, 80.0, 80.0));
|
|
let (tr, tg, tb) = color_to_floats(to, (200.0, 200.0, 200.0));
|
|
let r = fr + (tr - fr) * t;
|
|
let g = fg + (tg - fg) * t;
|
|
let b = fb + (tb - fb) * t;
|
|
rgb(
|
|
r.clamp(0.0, 255.0) as u8,
|
|
g.clamp(0.0, 255.0) as u8,
|
|
b.clamp(0.0, 255.0) as u8,
|
|
)
|
|
}
|
|
|
|
pub fn rainbow_prompt_color(distance: usize) -> Color {
|
|
// Rainbow colors (hue progression): red -> orange -> yellow -> green -> cyan -> blue -> violet
|
|
const RAINBOW: [(u8, u8, u8); 7] = [
|
|
(255, 80, 80), // Red (softened)
|
|
(255, 160, 80), // Orange
|
|
(255, 230, 80), // Yellow
|
|
(80, 220, 100), // Green
|
|
(80, 200, 220), // Cyan
|
|
(100, 140, 255), // Blue
|
|
(180, 100, 255), // Violet
|
|
];
|
|
|
|
// Gray target (dim_color())
|
|
const GRAY: (u8, u8, u8) = (80, 80, 80);
|
|
|
|
// Exponential decay factor - how quickly we fade to gray
|
|
// decay = e^(-distance * rate), rate of ~0.4 gives nice falloff
|
|
let decay = (-0.4 * distance as f32).exp();
|
|
|
|
// Select rainbow color based on distance (cycle through)
|
|
let rainbow_idx = distance.min(RAINBOW.len() - 1);
|
|
let (r, g, b) = RAINBOW[rainbow_idx];
|
|
|
|
// Blend rainbow color with gray based on decay
|
|
// At distance 0: 100% rainbow, as distance increases: approaches gray
|
|
let blend = |rainbow: u8, gray: u8| -> u8 {
|
|
(rainbow as f32 * decay + gray as f32 * (1.0 - decay)) as u8
|
|
};
|
|
|
|
rgb(blend(r, GRAY.0), blend(g, GRAY.1), blend(b, GRAY.2))
|
|
}
|
|
|
|
pub fn prompt_entry_color(base: Color, t: f32) -> Color {
|
|
let peak = rgb(255, 230, 120);
|
|
// Quick pulse in/out over the animation window.
|
|
let phase = if t < 0.5 { t * 2.0 } else { (1.0 - t) * 2.0 };
|
|
blend_color(base, peak, phase.clamp(0.0, 1.0) * 0.7)
|
|
}
|
|
|
|
pub fn prompt_entry_bg_color(base: Color, t: f32) -> Color {
|
|
let spotlight = rgb(58, 66, 82);
|
|
let ease_in = 1.0 - (1.0 - t).powi(3);
|
|
let ease_out = (1.0 - t).powi(2);
|
|
let phase = (ease_in * ease_out * 1.65).clamp(0.0, 1.0);
|
|
blend_color(base, spotlight, phase * 0.85)
|
|
}
|
|
|
|
pub fn prompt_entry_shimmer_color(base: Color, pos: f32, t: f32) -> Color {
|
|
let travel = (t * 1.15).clamp(0.0, 1.0);
|
|
let width = 0.18;
|
|
let dist = (pos - travel).abs();
|
|
let shimmer = (1.0 - (dist / width).clamp(0.0, 1.0)).powf(2.2);
|
|
let pulse = (1.0 - t).powf(0.55);
|
|
let highlight = rgb(255, 248, 210);
|
|
blend_color(base, highlight, shimmer * pulse * 0.7)
|
|
}
|
|
|
|
/// Generate an animated color that pulses between two colors
|
|
pub fn animated_tool_color(elapsed: f32, enable_decorative_animations: bool) -> Color {
|
|
if !enable_decorative_animations {
|
|
return tool_color();
|
|
}
|
|
|
|
// Cycle period of ~1.5 seconds
|
|
let t = (elapsed * 2.0).sin() * 0.5 + 0.5; // 0.0 to 1.0
|
|
|
|
// Interpolate between cyan and purple
|
|
let r = (80.0 + t * 106.0) as u8; // 80 -> 186
|
|
let g = (200.0 - t * 61.0) as u8; // 200 -> 139
|
|
let b = (220.0 + t * 35.0) as u8; // 220 -> 255
|
|
|
|
rgb(r, g, b)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn spinner_frames_are_circular_braille_sequence() {
|
|
assert_eq!(
|
|
SPINNER_FRAMES,
|
|
&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn spinner_frame_wraps_at_sequence_length() {
|
|
let fps = 10.0;
|
|
assert_eq!(spinner_frame(0.0, fps), "⠋");
|
|
assert_eq!(spinner_frame(0.9, fps), "⠏");
|
|
assert_eq!(spinner_frame(1.0, fps), "⠋");
|
|
}
|
|
|
|
#[test]
|
|
fn activity_indicator_still_advances_without_decorative_animations() {
|
|
// With decorative animations disabled the single-cell spinner must keep
|
|
// ticking instead of freezing on one frame.
|
|
let first = activity_indicator(0.0, 12.5, false);
|
|
let later = activity_indicator(1.0, 12.5, false);
|
|
assert!(SPINNER_FRAMES.contains(&first));
|
|
assert_ne!(
|
|
first, later,
|
|
"liveness spinner should advance within one second"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn liveness_spinner_advances_smoothly_within_a_few_frames() {
|
|
// The single-cell fast path patches one status cell per 80ms tick, so the
|
|
// non-decorative liveness spinner should advance well faster than ~1 Hz
|
|
// (it should not still read as frozen between consecutive fast-path ticks).
|
|
let frame_at = |elapsed: f32| activity_indicator(elapsed, 12.5, false);
|
|
// One 80ms fast-path tick should already move to the next frame.
|
|
assert_ne!(
|
|
frame_at(0.0),
|
|
frame_at(0.08),
|
|
"liveness spinner should advance every fast-path tick (80ms)"
|
|
);
|
|
// It must be meaningfully faster than the old ~1.5 Hz cadence.
|
|
const {
|
|
assert!(
|
|
LIVENESS_SPINNER_FPS >= 8.0,
|
|
"liveness spinner should animate at a smooth, responsive rate"
|
|
);
|
|
}
|
|
}
|
|
}
|