feat(tui): animated water-spout working strip in the footer (closes #61)
Replace the single-spout bounce animation with two independent `╭───╮` arcs sweeping at different speeds across a calm `─` water surface. Add `footer_working_label` to pulse `working` → `working...` at 400 ms cadence while a turn is live. The dot-pulse fires even in low-motion mode; the arc strip is gated behind `!app.low_motion`. Frame math is purely deterministic so the test suite can pin specific frames. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3135,16 +3135,30 @@ fn render_footer(f: &mut Frame, area: Rect, app: &mut App) {
|
||||
// Animate the spacer between the left status line and the right-hand
|
||||
// chips whenever a turn is live: model loading/streaming, compacting, or
|
||||
// sub-agents in flight. Honors the `low_motion` setting — calm terminals
|
||||
// get the plain whitespace gap. Frame counter ticks every 80 ms; the
|
||||
// renderer is deterministic given the frame, so tests can pin specific
|
||||
// frames. Computed independently of `state_label` so removing the
|
||||
// "thinking" text label doesn't kill the visual signal.
|
||||
if !app.low_motion && footer_working_strip_active(app) {
|
||||
let frame = std::time::SystemTime::now()
|
||||
// get the plain whitespace gap. Strip frame counter ticks every 80 ms;
|
||||
// dot-pulse counter ticks every 400 ms so `working` → `working...` reads
|
||||
// at a calm pace. The renderer is deterministic given the frame.
|
||||
if footer_working_strip_active(app) {
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64 / 80)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
props.working_strip_frame = Some(frame);
|
||||
let dot_frame = now_ms / 400;
|
||||
// Surface a `working`-with-dot-pulse label whenever a turn is live.
|
||||
// This replaces the plain "working" / no-label state for the
|
||||
// duration of the turn so the user always has a textual signal,
|
||||
// even on terminals where the spout strip is disabled.
|
||||
let working_label = crate::tui::widgets::footer_working_label(dot_frame);
|
||||
props.state_label = working_label;
|
||||
props.state_color = palette::DEEPSEEK_SKY;
|
||||
|
||||
// Spout drift: only animate when low_motion is off. The textual
|
||||
// `working...` pulse stays even in low-motion mode so the user still
|
||||
// sees that something is happening.
|
||||
if !app.low_motion {
|
||||
let strip_frame = now_ms / 80;
|
||||
props.working_strip_frame = Some(strip_frame);
|
||||
}
|
||||
}
|
||||
|
||||
let widget = FooterWidget::new(props);
|
||||
|
||||
@@ -61,28 +61,46 @@ pub struct FooterProps {
|
||||
/// frame counter. Returns the glyph that should appear in that cell on that
|
||||
/// frame.
|
||||
///
|
||||
/// Visual: a single calm water line of `─` with one upward spout glyph that
|
||||
/// drifts back and forth via a triangle-wave bounce. Minimal, artistic, and
|
||||
/// purely deterministic so the test suite can pin a specific frame.
|
||||
/// Visual: two box-drawing "arcs" (`╭───╮`) sweeping horizontally at
|
||||
/// independent speeds across a calm water surface (`─`). Arcs that meet
|
||||
/// blend into a wider arch, giving the criss-cross fountain feel. Purely
|
||||
/// deterministic given (col, width, frame) so unit tests can pin frames.
|
||||
#[must_use]
|
||||
pub fn footer_working_strip_glyph_at(col: usize, width: usize, frame: u64) -> char {
|
||||
if width == 0 {
|
||||
return ' ';
|
||||
}
|
||||
let w = width as i64;
|
||||
|
||||
// Half-width of an arc (so a full arc spans `2*ARC_HALF + 1` columns:
|
||||
// `╭`, `─`...`─`, `╮`). Three keeps the arcs `╭───╮` — five glyphs wide,
|
||||
// matching the issue's sketch.
|
||||
const ARC_HALF: i64 = 2;
|
||||
let arc_span = ARC_HALF * 2 + 1; // 5
|
||||
|
||||
// Two arcs at independent speeds drifting through a wrap-around span
|
||||
// wider than the strip itself, so each arc enters from the left, sweeps
|
||||
// across, and exits on the right before re-entering. Phase offsets keep
|
||||
// them from synchronising at frame 0.
|
||||
let cycle = (width as i64).max(arc_span) + arc_span * 2;
|
||||
let frame = frame as i64;
|
||||
let pos1 = (frame).rem_euclid(cycle) - arc_span;
|
||||
let pos2 = (frame * 2 + (cycle / 3) + 7).rem_euclid(cycle) - arc_span;
|
||||
|
||||
// Bounce a value that counts up forever between [0, w-1] using a
|
||||
// triangle wave so the spout rides back and forth instead of wrapping.
|
||||
let span = (w * 2).max(2);
|
||||
let t = frame.rem_euclid(span);
|
||||
let pos = if t < w { t } else { (span - 1) - t };
|
||||
arc_glyph_for(col as i64, pos1)
|
||||
.or_else(|| arc_glyph_for(col as i64, pos2))
|
||||
.unwrap_or('\u{2500}') // ─ — calm water surface
|
||||
}
|
||||
|
||||
let dist = (col as i64 - pos).abs();
|
||||
/// Helper: returns the glyph for column `col` if it falls inside an arc
|
||||
/// centred at `pos`, else `None`. An arc is `╭───╮` shaped — left cup, three
|
||||
/// dashes, right cup — five columns wide.
|
||||
fn arc_glyph_for(col: i64, pos: i64) -> Option<char> {
|
||||
let dist = col - pos;
|
||||
match dist {
|
||||
0 => '\u{257F}', // ╿ — vertical bar with a stronger top half: a spout standing up out of the surface
|
||||
1 => '\u{2576}', // ╶ — short stub on the spout's shoulder, like a splash
|
||||
_ => '\u{2500}', // ─ — calm water surface
|
||||
-2 => Some('\u{256D}'), // ╭ arc rising from the left
|
||||
-1..=1 => Some('\u{2500}'), // ─ arc top
|
||||
2 => Some('\u{256E}'), // ╮ arc falling on the right
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +117,22 @@ pub fn footer_working_strip_string(width: usize, frame: u64) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// Pulse `working` through `working`, `working.`, `working..`, `working...`
|
||||
/// keyed off `frame`. The cycle period is 4 frames (matching the four
|
||||
/// states), so adjacent ticks visibly differ. Returns a static-friendly
|
||||
/// `String` so callers can drop it into a `Span::styled` without lifetime
|
||||
/// gymnastics.
|
||||
#[must_use]
|
||||
pub fn footer_working_label(frame: u64) -> String {
|
||||
let dots = (frame % 4) as usize;
|
||||
let mut out = String::with_capacity(7 + dots);
|
||||
out.push_str("working");
|
||||
for _ in 0..dots {
|
||||
out.push('.');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Build a "N agents" chip span list when there are sub-agents in flight.
|
||||
/// Empty list when N == 0 hides the chip entirely. Singular for N == 1
|
||||
/// reads naturally; plural otherwise.
|
||||
@@ -554,8 +588,8 @@ mod tests {
|
||||
#[test]
|
||||
fn working_strip_renders_glyphs_only_when_frame_is_some() {
|
||||
// Idle: spacer is plain whitespace. Active: spacer contains the
|
||||
// box-drawing animation glyphs (`╿` spout, `╶` splash, `─` water
|
||||
// surface) and visibly differs from the idle render.
|
||||
// box-drawing animation glyphs (`╭` rising-arc, `╮` falling-arc,
|
||||
// `─` water surface) and visibly differs from the idle render.
|
||||
let app = make_app();
|
||||
let mut props = idle_props_for(&app);
|
||||
|
||||
@@ -574,24 +608,61 @@ mod tests {
|
||||
"active footer must visibly differ from idle one"
|
||||
);
|
||||
assert!(
|
||||
active.contains('\u{257F}')
|
||||
|| active.contains('\u{2576}')
|
||||
|| active.contains('\u{2500}'),
|
||||
active.contains('\u{256D}') // ╭
|
||||
|| active.contains('\u{256E}') // ╮
|
||||
|| active.contains('\u{2500}'), // ─
|
||||
"active strip must contain at least one animation glyph: {active:?}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn working_strip_spout_position_advances_with_frame() {
|
||||
// The single spout column must move between consecutive frames so
|
||||
// the animation reads as drift rather than a static pattern.
|
||||
let width = 16;
|
||||
fn working_strip_arc_position_advances_with_frame() {
|
||||
// At least one arc cup (╭) must shift columns between consecutive
|
||||
// frames so the animation reads as drift, not a static pattern.
|
||||
let width = 32;
|
||||
let f0 = super::footer_working_strip_string(width, 1);
|
||||
let f1 = super::footer_working_strip_string(width, 2);
|
||||
let pos = |s: &str| s.chars().position(|c| c == '\u{257F}');
|
||||
let p0 = pos(&f0).expect("frame 1 has a spout");
|
||||
let p1 = pos(&f1).expect("frame 2 has a spout");
|
||||
assert_ne!(p0, p1, "spout column must advance between frames");
|
||||
// Collect the columns that hold a left-arc `╭` glyph in each frame.
|
||||
let cups = |s: &str| -> Vec<usize> {
|
||||
s.chars()
|
||||
.enumerate()
|
||||
.filter_map(|(i, c)| (c == '\u{256D}').then_some(i))
|
||||
.collect()
|
||||
};
|
||||
let p0 = cups(&f0);
|
||||
let p1 = cups(&f1);
|
||||
assert_ne!(p0, p1, "arc cup positions must advance between frames");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn working_strip_renders_full_arc_when_room() {
|
||||
// A frame at which arc 1 is fully inside the strip should render
|
||||
// the canonical `╭───╮` shape — the artistic centrepiece of the
|
||||
// animation. Arc 1's leading edge starts at `frame % cycle - 5`,
|
||||
// so frame == 5 puts the arc's left cup exactly at column 0.
|
||||
// (See `footer_working_strip_glyph_at` for the math.)
|
||||
let s = super::footer_working_strip_string(40, 5);
|
||||
assert!(
|
||||
s.contains("\u{256D}\u{2500}\u{2500}\u{2500}\u{256E}"),
|
||||
"expected ╭───╮ arc somewhere in the strip: {s:?}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn working_label_pulses_dots_through_full_cycle() {
|
||||
// The label sequence `working` → `working.` → `working..` →
|
||||
// `working...` then wraps back. Each frame is a discrete tick;
|
||||
// the cycle is exactly 4 frames so adjacent ticks visibly differ.
|
||||
assert_eq!(super::footer_working_label(0), "working");
|
||||
assert_eq!(super::footer_working_label(1), "working.");
|
||||
assert_eq!(super::footer_working_label(2), "working..");
|
||||
assert_eq!(super::footer_working_label(3), "working...");
|
||||
assert_eq!(
|
||||
super::footer_working_label(4),
|
||||
"working",
|
||||
"wraps back at frame 4",
|
||||
);
|
||||
assert_eq!(super::footer_working_label(7), "working...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2,7 +2,9 @@ mod footer;
|
||||
mod header;
|
||||
mod renderable;
|
||||
|
||||
pub use footer::{FooterProps, FooterToast, FooterWidget, footer_agents_chip};
|
||||
pub use footer::{
|
||||
FooterProps, FooterToast, FooterWidget, footer_agents_chip, footer_working_label,
|
||||
};
|
||||
pub use header::{HeaderData, HeaderWidget};
|
||||
pub use renderable::Renderable;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user