Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/src/workspace/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27591,6 +27591,7 @@ impl View for Workspace {
.cover()
.with_opacity(opacity_ratio)
.with_corner_radius(window_corner_radius)
.enable_animation_with_start_time(std::time::Instant::now())
.finish(),
)
.finish(),
Expand Down
11 changes: 9 additions & 2 deletions crates/warpui_core/src/elements/gui/image.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,14 @@ impl Image {
self
}

/// Computes elapsed time in milliseconds since animation started.
/// If `started_at` was not set, uses the provided `now` time as the reference point
/// (meaning elapsed time would be 0, showing only the first frame).
fn compute_elapsed_time_ms(&self, now: Instant) -> u128 {
let started_at = self.started_at.unwrap_or_else(|| now);
now.duration_since(started_at).as_millis()
}

pub fn before_load(mut self, element: Box<dyn Element>) -> Self {
self.before_load_element = Some(element);
self
Expand Down Expand Up @@ -329,8 +337,7 @@ impl Image {
) {
// If self.started_at is not provided, we set it to current time
// so only the first frame is shown.
let started_at = self.started_at.unwrap_or_else(Instant::now);
let elapsed_time = started_at.elapsed().as_millis();
let elapsed_time = self.compute_elapsed_time_ms(Instant::now());
// After about ~50 days, casting `elapsed_time` to a u32 will
// silently overflow. The gif may jump and start playing from a
// different frame.
Expand Down
134 changes: 134 additions & 0 deletions crates/warpui_core/src/elements/gui/image_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,137 @@ fn loading_timeout_survives_image_rebuild_for_same_source() {
assert_eq!(timed_out_kind, Some(BackupElementKind::LoadTimeout));
assert_eq!(timed_out_repaint_after, None);
}

#[test]
fn enable_animation_with_start_time_sets_started_at() {
let mut image = test_image();
assert_eq!(image.started_at, None, "started_at should initially be None");

let now = Instant::now();
image = image.enable_animation_with_start_time(now);
assert_eq!(
image.started_at, Some(now),
"started_at should be set after calling enable_animation_with_start_time"
);
}

#[test]
fn enable_animation_with_start_time_can_be_chained() {
let start1 = Instant::now();
let image = test_image().enable_animation_with_start_time(start1);
assert_eq!(image.started_at, Some(start1));

// Can chain multiple calls (though typically would only call once)
let start2 = start1 + Duration::from_millis(10);
let image = image.enable_animation_with_start_time(start2);
assert_eq!(
image.started_at, Some(start2),
"Later call to enable_animation_with_start_time should override"
);
}

#[test]
fn animation_with_no_started_at_uses_instant_now() {
// This test verifies the bug fix: when started_at is None,
// paint_animated_image falls back to Instant::now(), which means
// elapsed_time is always very small and the animation gets stuck.
// The fix ensures that animation callers set started_at via enable_animation_with_start_time.

let image = test_image();

// When started_at is None, the image widget is configured to NOT animate
// (see paint_animated_image: it checks if self.started_at.is_some() before requesting repaint)
assert_eq!(
image.started_at, None,
"started_at should be None for non-animated setup"
);
}

#[test]
fn animation_started_at_must_be_set_before_paint_for_animation() {
// This test verifies that animation only works when started_at is explicitly set.
// The regression test ensures the fix in view.rs
// (adding .enable_animation_with_start_time(Instant::now())) is necessary.

let image_no_start = test_image();
let image_with_start = test_image().enable_animation_with_start_time(Instant::now());

// Verify the difference in started_at state
assert_eq!(image_no_start.started_at, None);
assert_ne!(image_with_start.started_at, None);
}

#[test]
fn compute_elapsed_time_ms_uses_started_at_when_set() {
// This is a regression test for the bug where paint_animated_image would
// always compute a fresh Instant::now() and ignore self.started_at,
// causing elapsed_time to be ~0 and animations to freeze on first frame.
//
// The fix adds compute_elapsed_time_ms which respects self.started_at.
// If someone removes the self.started_at check, this test will fail.

let past_time = Instant::now() - Duration::from_millis(1000);
let image = test_image().enable_animation_with_start_time(past_time);

// Simulate a paint call at a specific time in the future
let now = past_time + Duration::from_millis(500);
let elapsed = image.compute_elapsed_time_ms(now);

// Should compute elapsed time as the difference between now and past_time
assert_eq!(elapsed, 500, "elapsed time should be 500ms from past_time to now");
assert!(
elapsed > 0,
"elapsed time must be > 0 when started_at is set in the past"
);
}

#[test]
fn compute_elapsed_time_ms_uses_provided_time_when_started_at_is_none() {
// When started_at is None (animation not enabled), the provided 'now' time
// is used as the reference, giving elapsed time of 0 (showing only first frame).

let image = test_image(); // No enable_animation_with_start_time call
assert_eq!(
image.started_at, None,
"started_at should be None for non-animated setup"
);

let now = Instant::now();
let elapsed = image.compute_elapsed_time_ms(now);

// When started_at is None, elapsed time should be 0 (first frame only)
assert_eq!(elapsed, 0, "elapsed time should be 0 when started_at is None");
}

#[test]
fn compute_elapsed_time_ms_respects_large_time_differences() {
// Verify that compute_elapsed_time_ms correctly handles larger time spans.
// This tests that the method actually uses started_at for computation,
// not recalculating time fresh each time (which would always be ~0).

let base_time = Instant::now();
let started_at = base_time - Duration::from_secs(5);
let image = test_image().enable_animation_with_start_time(started_at);

// Compute elapsed time at a point 3 seconds after started_at
let now = started_at + Duration::from_secs(3);
let elapsed = image.compute_elapsed_time_ms(now);

assert_eq!(
elapsed, 3000,
"elapsed time should be 3000ms (3 seconds) from started_at"
);

// Compute elapsed time at a point 5+ seconds after started_at (past the original start+5s)
let now_later = started_at + Duration::from_secs(7);
let elapsed_later = image.compute_elapsed_time_ms(now_later);

assert_eq!(
elapsed_later, 7000,
"elapsed time should be 7000ms (7 seconds) from started_at"
);
assert!(
elapsed_later > elapsed,
"elapsed time should increase monotonically"
);
}
Loading