From bea917a03479c9d45695cc7d111a03f556bcc39f Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Wed, 6 May 2026 17:47:06 -0500 Subject: [PATCH] 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 --- crates/tui/src/tui/widgets/footer.rs | 140 +++++++++++---------------- 1 file changed, 57 insertions(+), 83 deletions(-) diff --git a/crates/tui/src/tui/widgets/footer.rs b/crates/tui/src/tui/widgets/footer.rs index 403dea84..3d91eb2a 100644 --- a/crates/tui/src/tui/widgets/footer.rs +++ b/crates/tui/src/tui/widgets/footer.rs @@ -82,62 +82,49 @@ pub struct FooterProps { pub working_strip_frame: Option, } -/// 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 { - 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 { - 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:?}", ); }