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:
@@ -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:?}",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user