fix(tui): smooth footer wave animation

## Summary
- replace the footer spacer crest hops with a full-width phase-shifted wave
- update footer animation tests to assert repaint-cadence movement and multiple wave heights

## Test plan
- cargo fmt --all -- --check
- git diff --check
- cargo test -p deepseek-tui working_strip --all-features
- cargo test -p deepseek-tui footer --all-features
- PR CI: lint, Ubuntu/macOS/Windows tests, npm wrapper smoke, version drift, GitGuardian
This commit is contained in:
Hunter Bown
2026-05-06 17:47:06 -05:00
committed by GitHub
parent 8cf3745cd6
commit bea917a034
+57 -83
View File
@@ -82,62 +82,49 @@ pub struct FooterProps {
pub working_strip_frame: Option<u64>,
}
/// One frame of the footer's water-spout animation. `col` is the cell index
/// inside the strip, `width` the strip's total width, `frame` the raw
const WAVE_GLYPHS: [char; 8] = [
'\u{2581}', // ▁
'\u{2582}', // ▂
'\u{2583}', // ▃
'\u{2584}', // ▄
'\u{2585}', // ▅
'\u{2586}', // ▆
'\u{2587}', // ▇
'\u{2588}', // █
];
/// One frame of the footer's live-work wave animation. `col` is the cell
/// index inside the strip, `width` the strip's total width, `frame` the raw
/// millisecond counter. Returns the glyph that should appear in that cell on
/// that frame.
///
/// Visual: two crests sweep across a calm water surface (`─`). The opener
/// `⌒` rises, then a soft `‿` trails behind. Crest A advances one column
/// every ~600 ms (4 × 150 ms), crest B every ~900 ms (6 × 150 ms) —
/// independent speeds give the criss-cross fountain feel. The positions
/// are computed from `frame / 150.0` (fractional) so crests slide smoothly
/// rather than jumping in discrete 150 ms steps.
///
/// All math is pure given (col, width, frame) so unit tests can pin frames.
/// Visual: a full-width phase-shifted wave made from one-cell block-height
/// glyphs. The earlier crest-pair animation only changed when rounded crest
/// positions crossed a terminal cell boundary; at an 80 ms repaint cadence it
/// read as visible hops. Sampling a few moving sine components gives every
/// repaint a new surface while keeping the math deterministic for tests.
#[must_use]
pub fn footer_working_strip_glyph_at(col: usize, width: usize, frame: u64) -> char {
if width == 0 {
return ' ';
}
// Number of 150 ms ticks since epoch — fractional so crests move
// continuously rather than teleporting every 4-6 ticks.
let frame_f = frame as f64 / 150.0;
let t = frame as f64 / 1000.0;
let x = col as f64;
// Crest is two glyphs wide: the leading `⌒` followed by a trailing `‿`.
const CREST_SPAN: i64 = 2;
// Cycle wide enough that each crest enters and exits cleanly.
let cycle = (width as i64).max(CREST_SPAN) + CREST_SPAN * 2;
// Crest A advances one column every ~300 ms (2 × 150 ms ticks).
let pos_a = (frame_f / 2.0).round() as i64 % cycle - CREST_SPAN;
// Phase jitter: every ~2.5 s (17 ticks), nudge B by one column so the
// two crests never lock into a fixed offset.
let jitter = (frame_f / 17.0).round() as i64 % 3;
// Crest B advances one column every ~450 ms (3 × 150 ms ticks).
let pos_b =
((frame_f / 3.0).round() as i64 + jitter + (cycle / 3) + 5).rem_euclid(cycle) - CREST_SPAN;
crest_glyph_for(col as i64, pos_a)
.or_else(|| crest_glyph_for(col as i64, pos_b))
.unwrap_or('\u{2500}') // ─ — calm water surface
let primary = (x * 0.52 - t * 8.0).sin();
let swell = (x * 0.18 + t * 3.1).sin() * 0.35;
let shimmer = (x * 1.35 - t * 11.0).sin() * 0.12;
let value = ((primary + swell + shimmer) / 1.47).clamp(-1.0, 1.0);
let normalized = (value + 1.0) * 0.5;
let idx = (normalized * (WAVE_GLYPHS.len() - 1) as f64).round() as usize;
WAVE_GLYPHS[idx.min(WAVE_GLYPHS.len() - 1)]
}
/// Helper: returns the glyph for column `col` if it falls inside a crest
/// centred at `pos`. A crest is `⌒‿` shaped — soft rise then a gentle dip.
fn crest_glyph_for(col: i64, pos: i64) -> Option<char> {
let dist = col - pos;
match dist {
0 => Some('\u{2312}'), // ⌒ arc rising from the left
1 => Some('\u{203F}'), // ‿ trailing dip
_ => None,
}
}
/// Build the per-frame water-spout string of `width` characters. Empty string
/// Build the per-frame live-work wave string of `width` characters. Empty string
/// when width is 0. The result is the same visual width as requested (one
/// char per column for box-drawing chars) and is safe to drop into a `Span`
/// between the footer's left and right segments.
/// char per column for the selected block-height glyphs) and is safe to drop
/// into a `Span` between the footer's left and right segments.
#[must_use]
pub fn footer_working_strip_string(width: usize, frame: u64) -> String {
let mut out = String::with_capacity(width * 4);
@@ -1018,8 +1005,7 @@ mod tests {
fn working_strip_string_width_matches_request() {
// The strip must produce exactly `width` characters per frame —
// otherwise the spacer math in `FooterWidget::render` would
// mis-align the right-hand chips. (Glyphs are all ASCII / Latin-1
// so char count equals visual width here.)
// mis-align the right-hand chips. Each wave glyph is one cell wide.
for width in [0usize, 1, 8, 60, 200] {
let s = super::footer_working_strip_string(width, 7);
assert_eq!(s.chars().count(), width, "width {width} mismatch");
@@ -1028,21 +1014,19 @@ mod tests {
#[test]
fn working_strip_glyph_is_deterministic_per_frame() {
// Same (col, width, frame) same glyph. Frames are now raw
// milliseconds; 150 ms apart represents one tick.
// Same (col, width, frame) -> same glyph. Frames are raw
// milliseconds so the strip can move at repaint cadence.
let a = super::footer_working_strip_string(40, 150);
let b = super::footer_working_strip_string(40, 150);
assert_eq!(a, b, "deterministic given the same frame");
// 750 ms → 5 ticks, crest A advances every 2 ticks → ≥2 steps.
let c = super::footer_working_strip_string(40, 750);
assert_ne!(a, c, "advancing 4 ticks must change the strip",);
let c = super::footer_working_strip_string(40, 230);
assert_ne!(a, c, "advancing one repaint window must change the strip",);
}
#[test]
fn working_strip_renders_glyphs_only_when_frame_is_some() {
// Idle: spacer is plain whitespace. Active: spacer contains the
// crest animation glyphs (`⌒` opener, `‿` trailer, `─` water
// surface) and visibly differs from the idle render.
// wave animation glyphs and visibly differs from the idle render.
let app = make_app();
let mut props = idle_props_for(&app);
@@ -1061,51 +1045,41 @@ mod tests {
"active footer must visibly differ from idle one"
);
assert!(
active.contains('\u{2312}') // ⌒ crest opener
|| active.contains('\u{203F}') // ‿ crest trailer
|| active.contains('\u{2500}'), // ─ water surface
active
.chars()
.any(|glyph| super::WAVE_GLYPHS.contains(&glyph)),
"active strip must contain at least one animation glyph: {active:?}",
);
}
#[test]
fn working_strip_advances_position_within_full_crest_step() {
// Crest A advances every 2 ticks (300 ms), B every 3 (450 ms).
// 900 ms (6 ticks) guarantees crest A has advanced at least 3 columns.
fn working_strip_changes_at_repaint_cadence() {
let width = 60;
let f0 = super::footer_working_strip_string(width, 0);
let f900 = super::footer_working_strip_string(width, 900);
// Collect the columns that hold a crest opener `⌒` in each frame.
let openers = |s: &str| -> Vec<usize> {
s.chars()
.enumerate()
.filter_map(|(i, c)| (c == '\u{2312}').then_some(i))
.collect()
};
assert_ne!(
openers(&f0),
openers(&f900),
"crest opener columns must shift across a 900ms window",
let f80 = super::footer_working_strip_string(width, 80);
let changed = f0
.chars()
.zip(f80.chars())
.filter(|(before, after)| before != after)
.count();
assert!(
changed > width / 4,
"expected the wave to drift across one 80ms repaint; changed {changed}/{width}"
);
}
#[test]
fn working_strip_renders_paired_crest_glyphs() {
// The `⌒‿` pair is the visual centrepiece — a soft rise followed by
// a gentle dip. Sweep enough time (in ms) that a crest is guaranteed
// to land fully inside a 60-cell strip at some point.
let width = 60;
let mut saw_pair = false;
for frame_ms in (0..24_000).step_by(150) {
let s = super::footer_working_strip_string(width, frame_ms);
if s.contains("\u{2312}\u{203F}") {
saw_pair = true;
break;
fn working_strip_renders_multiple_wave_heights() {
let s = super::footer_working_strip_string(60, 0);
let mut distinct = Vec::new();
for glyph in s.chars() {
if super::WAVE_GLYPHS.contains(&glyph) && !distinct.contains(&glyph) {
distinct.push(glyph);
}
}
assert!(
saw_pair,
"expected `⌒‿` pair somewhere in the first 24s of animation",
distinct.len() >= 5,
"expected several wave heights, saw {distinct:?}",
);
}