Fade blossom replays back to the idle state (#48547)

## Why

Clicking the welcome blossom replays its animation, but completion abruptly switches from full color to the dim idle frame.

## What changed

Fade the blossom back to idle opacity over 400 ms after the replay finishes spinning. Keep scheduling redraws until the fade completes, then clear the replay state.

## Testing

Extend the replay test with color snapshots at the start, midpoint, and end of the fade, and assertions that redraws stop and the original idle frame is restored.

GitOrigin-RevId: ec3fafb6766533d9c221913e8a3be03b8a8036f7
This commit is contained in:
Felipe Coury
2026-09-26 21:57:55 +00:00
committed by copyberry
parent 7ed14a27db
commit d6f25a54ba
3 changed files with 65 additions and 3 deletions
+11 -3
View File
@@ -175,7 +175,12 @@ impl EmptyStateAnimation {
let phase =
self.spin_elapsed.min(sequence::SPIN_DURATION).as_secs_f64() / sequence::LOOP_SECONDS;
let settling = !finished && static_mark && self.fade_elapsed < sequence::STATIC_FADE;
self.opacity = if finished {
self.opacity = if finished && self.replaying {
sequence::static_opacity(
self.spin_elapsed - sequence::SPIN_DURATION,
/*from*/ 1.0,
)
} else if finished {
1.0
} else if static_mark {
sequence::static_opacity(self.fade_elapsed, self.fade_from)
@@ -188,7 +193,10 @@ impl EmptyStateAnimation {
* sequence::progress(self.fade_elapsed, sequence::STATIC_FADE) as f32;
}
self.paint_frame(area, buffer, phase, self.opacity);
(!finished && (!static_mark || settling)).then_some(FRAME_INTERVAL)
((!finished && (!static_mark || settling))
|| (self.replaying
&& self.spin_elapsed < sequence::SPIN_DURATION + sequence::STATIC_FADE))
.then_some(FRAME_INTERVAL)
}
/// Draw only in space the caller has cleared and owns. The centered blossom returns
@@ -228,7 +236,7 @@ impl EmptyStateAnimation {
if let Some(delay) = self.render_in(stage, buffer, Presentation::Animated) {
return Some(delay);
}
// Paint the idle pose over the final full-color frame in the same update.
// The replay has faded back to the idle pose and no longer needs redraws.
self.replaying = false;
}
self.paint_frame(stage, buffer, SETTLED_BLOSSOM, sequence::STATIC_OPACITY);
@@ -134,6 +134,52 @@ fn first_screen_single_click_replays_the_visible_blossom() {
Some(FRAME_INTERVAL)
);
assert_eq!(buffer, static_frame);
let finished_at =
animation.last_frame.unwrap() + sequence::SPIN_DURATION - animation.spin_elapsed;
let mut fade_frames = Vec::new();
for elapsed in [
Duration::ZERO,
sequence::STATIC_FADE / 2,
sequence::STATIC_FADE,
] {
buffer.reset();
crate::terminal_palette::with_test_default_colors(
crate::terminal_probe::DefaultColors {
fg: (210, 221, 235),
bg: (15, 20, 37),
},
|| {
assert_eq!(
animation.render_in_at(
stage,
&mut buffer,
Presentation::Animated,
finished_at + elapsed,
),
(elapsed < sequence::STATIC_FADE).then_some(FRAME_INTERVAL)
);
},
);
let painted = buffer
.content
.iter()
.find(|cell| cell.symbol() != " ")
.unwrap();
fade_frames.push(format!(
"{}ms: {} {:?}",
elapsed.as_millis(),
painted.symbol(),
painted.fg,
));
}
insta::assert_snapshot!("first_screen_replay_fade", fade_frames.join("\n"));
assert_eq!(
draw(&mut animation, &mut buffer, Some(ComposerState::Empty)),
None
);
assert_eq!(buffer, static_frame);
assert!(!animation.replaying);
assert!(animation.handle_mouse(click));
assert_eq!(
draw(&mut animation, &mut buffer, Some(ComposerState::Draft)),
None
@@ -0,0 +1,8 @@
---
source: tui/src/empty_state_animation_tests.rs
assertion_line: 171
expression: "fade_frames.join(\"\\n\")"
---
0ms: ⣀ Rgb(195, 201, 206)
200ms: ⣀ Rgb(121, 126, 136)
400ms: ⣀ Rgb(47, 52, 67)