Port upstream UI polish and refresh dependencies

Bring over selected Warp UI improvements: selectable Thought text, message
timestamps, animated theme backgrounds, editable New Window shortcuts,
safer tab renaming and restoration, and text/font/discard crash fixes.

Match upstream orchestration pill geometry and tooltips, and replace expanded
subagent previews with compact links that retain live status and conversation
navigation for launches and follow-up messages.

Include the pending Cargo.lock dependency refresh.

Adapted upstream PRs: #15948, #15884, #15691, #14618, #15772, #15705,
#15605, #14895, #15996, #15771, #14893, and #14603.
This commit is contained in:
2026-09-17 23:44:47 -05:00
parent f084548b34
commit a29ee94d84
46 changed files with 2529 additions and 1479 deletions
@@ -1,14 +1,18 @@
use std::cell::RefCell;
use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::Vector2F;
use super::{
AfterLayoutContext, AppContext, DispatchEventResult, Element, Event, EventContext,
LayoutContext, PaintContext, Point, SizeConstraint, ZIndex,
LayoutContext, PaintContext, Point, SelectableElement, Selection, SelectionFragment,
SizeConstraint, ZIndex,
};
use crate::event::{DispatchedEvent, EventDiscriminants, KeyState, ModifiersState};
use crate::keymap::Keystroke;
use crate::platform::keyboard::KeyCode;
use crate::text::word_boundaries::WordBoundariesPolicy;
use crate::text::{IsRect, SelectionDirection, SelectionType};
type Handler = Box<dyn FnMut(&mut EventContext, &AppContext, Vector2F) -> DispatchEventResult>;
type KeyHandler = Box<dyn FnMut(&mut EventContext, &AppContext, &Keystroke) -> DispatchEventResult>;
@@ -361,11 +365,75 @@ impl Element for EventHandler {
handled
}
fn as_selectable_element(&self) -> Option<&dyn SelectableElement> {
Some(self)
}
fn origin(&self) -> Option<Point> {
self.child.origin()
}
}
impl SelectableElement for EventHandler {
fn get_selection(
&self,
selection_start: Vector2F,
selection_end: Vector2F,
is_rect: IsRect,
) -> Option<Vec<SelectionFragment>> {
self.child
.as_selectable_element()
.and_then(|selectable_child| {
selectable_child.get_selection(selection_start, selection_end, is_rect)
})
}
fn expand_selection(
&self,
point: Vector2F,
direction: SelectionDirection,
unit: SelectionType,
word_boundaries_policy: &WordBoundariesPolicy,
) -> Option<Vector2F> {
self.child
.as_selectable_element()
.and_then(|selectable_child| {
selectable_child.expand_selection(point, direction, unit, word_boundaries_policy)
})
}
fn is_point_semantically_before(
&self,
absolute_point: Vector2F,
absolute_point_other: Vector2F,
) -> Option<bool> {
self.child
.as_selectable_element()
.and_then(|selectable_child| {
selectable_child.is_point_semantically_before(absolute_point, absolute_point_other)
})
}
fn smart_select(
&self,
absolute_point: Vector2F,
smart_select_fn: crate::elements::SmartSelectFn,
) -> Option<(Vector2F, Vector2F)> {
self.child
.as_selectable_element()
.and_then(|selectable_child| {
selectable_child.smart_select(absolute_point, smart_select_fn)
})
}
fn calculate_clickable_bounds(&self, current_selection: Option<Selection>) -> Vec<RectF> {
self.child
.as_selectable_element()
.map(|selectable_child| selectable_child.calculate_clickable_bounds(current_selection))
.unwrap_or_default()
}
}
#[cfg(test)]
#[path = "event_handler_test.rs"]
mod tests;
@@ -2556,6 +2556,10 @@ impl SelectableElement for FormattedTextElement {
let line = text_frame.lines().get(text_selection_bound.row_index)?;
let first_glyph = line.first_glyph()?;
let last_glyph = line.last_glyph()?;
// Visual glyph order can oppose logical indices, and usize::clamp panics if min > max.
if first_glyph.index > last_glyph.index {
return None;
}
// If we clicked to the right of a line, the text_selection_bound's glyph index would be one larger than the last glyph's index.
// Snap it within the line's index range so we do smart selection as if we clicked on the last glyph in the line.
let char_offset = text_selection_bound
@@ -9,6 +9,7 @@ use pathfinder_color::ColorU;
use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::vec2f;
use string_offset::ByteOffset;
use vec1::vec1;
use super::{
apply_secret_replacements, FormattedTextElement, FrameMouseHandlers,
@@ -16,9 +17,9 @@ use super::{
SecretRange,
};
use crate::elements::{Point, SelectableElement, ZIndex};
use crate::fonts::FamilyId;
use crate::fonts::{FamilyId, FontId};
use crate::text::BlockHeaderSize;
use crate::text_layout::TextFrame;
use crate::text_layout::{Glyph, Line, Run, TextAlignment, TextFrame, TextStyle};
#[test]
fn test_default_heading_font_size_multipliers() {
@@ -220,3 +221,78 @@ fn smart_select_returns_none_when_point_is_outside_horizontal_bounds() {
.smart_select(point, select_first_character)
.is_none());
}
const TEST_GLYPH_ADVANCE: f32 = 10.0;
/// Builds a single-line element whose visual first glyph has a higher logical index than
/// its visual last glyph, which RTL layout can produce.
fn text_element_with_reversed_endpoint_glyph_indices(text: &str) -> FormattedTextElement {
let char_count = text.chars().count();
let width = char_count as f32 * TEST_GLYPH_ADVANCE;
let glyphs = text
.chars()
.enumerate()
.map(|(visual_index, _)| Glyph {
id: Default::default(),
position_along_baseline: vec2f(visual_index as f32 * TEST_GLYPH_ADVANCE, 0.0),
index: char_count - 1 - visual_index,
width: TEST_GLYPH_ADVANCE,
})
.collect();
let mut line = Line::mock(vec![Run {
font_id: FontId(0),
glyphs,
styles: TextStyle::new(),
width,
}]);
line.width = width;
let text_frame = Arc::new(TextFrame::new(vec1![line], width, TextAlignment::Left));
let frame_bounds = RectF::new(vec2f(0.0, 0.0), vec2f(width, text_frame.height()));
let mouse_handlers = Rc::new(RefCell::new(FrameMouseHandlers::default()));
let formatted_text = FormattedText::new([FormattedTextLine::Line(vec![
FormattedTextFragment::plain_text(text),
])]);
let mut element = FormattedTextElement::new(
formatted_text,
13.0,
FamilyId(0),
FamilyId(0),
ColorU::black(),
HighlightedHyperlink::default(),
);
element.origin = Some(Point::new(0.0, 0.0, ZIndex::new(0)));
element.size = Some(frame_bounds.size());
element.laid_out_text = vec![LaidOutTextFrame::Text {
text_frame,
frame_bounds,
bottom_padding: 0.0,
raw_text: text.to_string(),
mouse_handlers: mouse_handlers.clone(),
}];
element.text_frame_mouse_handlers = vec![mouse_handlers];
element.hyperlink_support = HyperlinkSupport {
highlighted_hyperlink: Arc::new(Mutex::new(None)),
hyperlink_font_color: ColorU::black(),
};
element
}
#[test]
fn smart_select_returns_none_when_line_glyph_indices_are_reversed() {
let element = text_element_with_reversed_endpoint_glyph_indices("abc");
let line = match element.laid_out_text.first() {
Some(LaidOutTextFrame::Text { text_frame, .. }) => {
text_frame.lines().first().expect("line")
}
_ => panic!("expected a text frame"),
};
assert!(
line.first_glyph().expect("first glyph").index
> line.last_glyph().expect("last glyph").index
);
assert!(element
.smart_select(vec2f(5.0, 5.0), select_first_character)
.is_none());
}
@@ -14,7 +14,7 @@ use string_offset::ByteOffset;
use super::{
AfterLayoutContext, AppContext, ColorU, Element, Event, EventContext, LayoutContext,
PaintContext, Point, SelectionFragment, SizeConstraint,
PaintContext, Point, SelectionFragment, SizeConstraint, ZIndex,
};
use crate::event::{DispatchedEvent, ModifiersState};
use crate::text::word_boundaries::WordBoundariesPolicy;
@@ -42,6 +42,11 @@ pub struct SelectableArea {
smart_select_fn: Option<SmartSelectFn>,
should_support_rect_select: bool,
// Upper bound of z-indexes painted by the child subtree. Mouse-down hit testing uses this
// instead of the origin's z-index so child content that starts its own layers (e.g. a clipped
// scrollable) isn't treated as covering this element; later-painted overlays still are.
child_max_z_index: Option<ZIndex>,
}
/// Stores the selection start and end points. We include the option to store
@@ -233,6 +238,7 @@ impl SelectableArea {
word_boundaries_policy: WordBoundariesPolicy::Default,
smart_select_fn: None,
should_support_rect_select: false,
child_max_z_index: None,
}
}
@@ -650,6 +656,7 @@ impl Element for SelectableArea {
ctx.current_selection = self.get_current_selection_absolute();
self.child.paint(origin, ctx, app);
ctx.current_selection = None;
self.child_max_z_index = Some(ctx.scene.max_active_z_index());
}
fn dispatch_event(
@@ -675,7 +682,8 @@ impl Element for SelectableArea {
event.raw_event(),
Event::LeftMouseDown { .. } | Event::RightMouseDown { .. }
) && self
.z_index()
.child_max_z_index
.or_else(|| self.z_index())
.is_some_and(|z_index| event.at_z_index(z_index, ctx).is_none())
{
return false;
@@ -755,3 +763,7 @@ impl Element for SelectableArea {
self.origin
}
}
#[cfg(test)]
#[path = "selectable_area_tests.rs"]
mod tests;
@@ -0,0 +1,221 @@
use std::cell::RefCell;
use std::rc::Rc;
use pathfinder_geometry::rect::RectF;
use super::*;
use crate::elements::new_scrollable::SingleAxisConfig;
use crate::elements::{
ClippedScrollStateHandle, EventHandler, Fill, NewScrollable, SelectableElement,
};
use crate::platform::WindowStyle;
use crate::{App, Entity, EntityIdSet, Presenter, TypedActionView, WindowInvalidation};
/// Selectable element that paints a hit-recorded rect (as `Container` does), so content inside a
/// layer-starting parent registers as covered at lower z-indexes.
#[derive(Default)]
struct LayeredSelectableProbe {
origin: Option<Point>,
}
const PROBE_SIZE: f32 = 100.;
impl Element for LayeredSelectableProbe {
fn layout(
&mut self,
_constraint: SizeConstraint,
_ctx: &mut LayoutContext,
_app: &AppContext,
) -> Vector2F {
vec2f(PROBE_SIZE, PROBE_SIZE)
}
fn after_layout(&mut self, _ctx: &mut AfterLayoutContext, _app: &AppContext) {}
fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, _app: &AppContext) {
self.origin = Some(Point::from_vec2f(origin, ctx.scene.z_index()));
ctx.scene
.draw_rect_with_hit_recording(RectF::new(origin, vec2f(PROBE_SIZE, PROBE_SIZE)))
.with_background(Fill::None);
}
fn size(&self) -> Option<Vector2F> {
Some(vec2f(PROBE_SIZE, PROBE_SIZE))
}
fn origin(&self) -> Option<Point> {
self.origin
}
fn dispatch_event(
&mut self,
_event: &DispatchedEvent,
_ctx: &mut EventContext,
_app: &AppContext,
) -> bool {
false
}
fn as_selectable_element(&self) -> Option<&dyn SelectableElement> {
Some(self)
}
}
impl SelectableElement for LayeredSelectableProbe {
fn get_selection(
&self,
_selection_start: Vector2F,
_selection_end: Vector2F,
_is_rect: IsRect,
) -> Option<Vec<SelectionFragment>> {
Some(vec![SelectionFragment {
text: "probe".to_string(),
origin: self.origin?,
}])
}
fn expand_selection(
&self,
_absolute_point: Vector2F,
_direction: SelectionDirection,
_unit: SelectionType,
_word_boundaries_policy: &WordBoundariesPolicy,
) -> Option<Vector2F> {
None
}
fn is_point_semantically_before(
&self,
absolute_point: Vector2F,
absolute_point_other: Vector2F,
) -> Option<bool> {
Some(
(absolute_point.y(), absolute_point.x())
< (absolute_point_other.y(), absolute_point_other.x()),
)
}
fn smart_select(
&self,
_absolute_point: Vector2F,
_smart_select_fn: crate::elements::SmartSelectFn,
) -> Option<(Vector2F, Vector2F)> {
None
}
fn calculate_clickable_bounds(&self, _current_selection: Option<Selection>) -> Vec<RectF> {
Vec::new()
}
}
#[derive(Default)]
struct ScrollableSelectionView {
selection_handle: SelectionHandle,
captured_selection: Rc<RefCell<Option<Option<String>>>>,
}
impl Entity for ScrollableSelectionView {
type Event = ();
}
impl crate::core::View for ScrollableSelectionView {
fn ui_name() -> &'static str {
"selectable_area_test_view"
}
fn render(&self, _: &AppContext) -> Box<dyn Element> {
let captured_selection = self.captured_selection.clone();
// Mirrors the element sandwich around collapsible reasoning block bodies: an
// EventHandler wrapping a clipped scrollable.
SelectableArea::new(
self.selection_handle.clone(),
move |args, _, _| {
*captured_selection.borrow_mut() = Some(args.selection);
},
EventHandler::new(
NewScrollable::vertical(
SingleAxisConfig::Clipped {
handle: ClippedScrollStateHandle::default(),
child: Box::new(LayeredSelectableProbe::default()),
},
Fill::None,
Fill::None,
Fill::None,
)
.finish(),
)
.finish(),
)
.finish()
}
}
impl TypedActionView for ScrollableSelectionView {
type Action = ();
}
/// A drag starting on content painted in a child's own layers must start a selection and yield
/// the child's fragments on mouse up. Covers the mouse-down hit test at the child subtree's max
/// z-index and `EventHandler`'s forwarding of selection queries.
#[test]
fn selection_starts_on_content_painted_in_child_layers() {
App::test((), |mut app| async move {
let (window_id, view) = app.add_window(WindowStyle::NotStealFocus, |_| {
ScrollableSelectionView::default()
});
let (selection_handle, captured_selection) = view.read(&app, |view, _| {
(
view.selection_handle.clone(),
view.captured_selection.clone(),
)
});
let root_view_id = view.id();
app.update(move |ctx| {
let mut presenter = Presenter::new(window_id);
let mut updated = EntityIdSet::default();
updated.insert(root_view_id);
let invalidation = WindowInvalidation {
updated,
..Default::default()
};
presenter.invalidate(invalidation, ctx);
presenter.build_scene(vec2f(200., 200.), 1., None, ctx);
let presenter = Rc::new(RefCell::new(presenter));
ctx.simulate_window_event(
Event::LeftMouseDown {
position: vec2f(10., 10.),
modifiers: Default::default(),
click_count: 1,
is_first_mouse: false,
},
window_id,
presenter.clone(),
);
ctx.simulate_window_event(
Event::LeftMouseDragged {
position: vec2f(60., 10.),
modifiers: Default::default(),
},
window_id,
presenter.clone(),
);
ctx.simulate_window_event(
Event::LeftMouseUp {
position: vec2f(60., 10.),
modifiers: Default::default(),
},
window_id,
presenter,
);
});
assert_eq!(
*captured_selection.borrow(),
Some(Some("probe".to_string())),
"the drag should have produced a selection from the layered child",
);
assert!(!selection_handle.is_selecting());
});
}
+15 -6
View File
@@ -509,12 +509,21 @@ impl Cache {
let (glyph_id, _) = self
.glyph_for_char(font_id, 'm', false)
.expect("we verify in Config::new that the font has an 'm' glyph");
let bounds = self
.glyph_typographic_bounds(font_id, font_size, glyph_id)
.expect(
"we verify in Config::new that we can measure the typographic bounds of the 'm' glyph",
);
bounds.width()
// Some Windows fonts map 'm' with a horizontal advance but no outline bounding box.
match self.glyph_typographic_bounds(font_id, font_size, glyph_id) {
Ok(bounds) => bounds.width(),
Err(_) => match self.glyph_advance(font_id, font_size, glyph_id) {
Ok(advance) if advance.x().is_finite() && advance.x() > 0.0 => advance.x(),
advance => {
log::warn!(
"[em_width] 'm' glyph has no usable horizontal advance; falling back to \
font_size font_id={font_id:?} font_size={font_size} advance={advance:?}"
);
// Scroll converts pixels with `/ em_width`; keep a positive width.
font_size.max(1.0)
}
},
}
}
pub(crate) fn remove_glyphs_by_char_entry(&mut self, key: (FontId, char)) {
+139
View File
@@ -1,4 +1,9 @@
use anyhow::{anyhow, Result};
use pathfinder_geometry::rect::RectI;
use pathfinder_geometry::vector::{vec2i, Vector2F, Vector2I};
use super::*;
use crate::platform::{self, TextLayoutSystem};
#[test]
fn test_subpixel_alignment_computation() {
@@ -40,3 +45,137 @@ fn test_subpixel_alignment_computation() {
assert_eq!(alignment.0, 0);
}
}
struct EmWidthFontDB {
bounds: Option<RectI>,
advance: Option<Vector2I>,
}
impl EmWidthFontDB {
fn cache(bounds: Option<RectI>, advance: Option<Vector2I>) -> Cache {
Cache::new(Box::new(Self { bounds, advance }))
}
}
impl platform::FontDB for EmWidthFontDB {
fn load_from_bytes(&mut self, _name: &str, _bytes: Vec<Vec<u8>>) -> Result<FamilyId> {
unimplemented!()
}
#[cfg(not(target_family = "wasm"))]
fn load_from_system(&mut self, _font_family: &str) -> Result<FamilyId> {
unimplemented!()
}
#[cfg(not(target_family = "wasm"))]
fn load_all_system_fonts(
&self,
) -> futures::future::BoxFuture<'static, Box<dyn platform::LoadedSystemFonts>> {
unimplemented!()
}
#[cfg(not(target_family = "wasm"))]
fn process_loaded_system_fonts(
&mut self,
_loaded_system_fonts: Box<dyn platform::LoadedSystemFonts>,
) -> Vec<(Option<FamilyId>, FontInfo)> {
unimplemented!()
}
fn fallback_fonts(&self, _ch: char, _font_id: FontId) -> Vec<FontId> {
unimplemented!()
}
fn select_font(&self, _family_id: FamilyId, _properties: Properties) -> FontId {
FontId(0)
}
fn font_metrics(&self, _font_id: FontId) -> Metrics {
Metrics {
units_per_em: 16,
ascent: 12,
descent: -4,
line_gap: 0,
}
}
fn glyph_advance(&self, _font_id: FontId, _glyph_id: GlyphId) -> Result<Vector2I> {
self.advance.ok_or_else(|| anyhow!("No advance for glyph"))
}
fn load_family_name_from_id(&self, _id: FamilyId) -> Option<String> {
unimplemented!()
}
fn glyph_raster_bounds(
&self,
_font_id: FontId,
_size: f32,
_glyph_id: GlyphId,
_scale: Vector2F,
_glyph_config: &crate::rendering::GlyphConfig,
) -> Result<RectI> {
unimplemented!()
}
fn glyph_typographic_bounds(&self, _font_id: FontId, glyph_id: GlyphId) -> Result<RectI> {
self.bounds
.ok_or_else(|| anyhow!("No bounding box for glyph id {glyph_id}"))
}
fn rasterize_glyph(
&self,
_font_id: FontId,
_size: f32,
_glyph_id: GlyphId,
_scale: Vector2F,
_subpixel_alignment: SubpixelAlignment,
_glyph_config: &crate::rendering::GlyphConfig,
_format: canvas::RasterFormat,
) -> Result<RasterizedGlyph> {
unimplemented!()
}
fn glyph_for_char(&self, _font_id: FontId, _char: char) -> Option<GlyphId> {
Some(0)
}
fn family_id_for_name(&self, _name: &str) -> Option<FamilyId> {
unimplemented!()
}
fn text_layout_system(&self) -> &dyn TextLayoutSystem {
unimplemented!()
}
}
#[test]
fn em_width_uses_typographic_bounds_width_when_available() {
let cache = EmWidthFontDB::cache(
Some(RectI::new(vec2i(0, 0), vec2i(10, 12))),
Some(vec2i(20, 0)),
);
assert_eq!(cache.em_width(FamilyId(0), 16.0), 10.0);
}
#[test]
fn em_width_uses_horizontal_advance_when_typographic_bounds_are_missing() {
let cache = EmWidthFontDB::cache(None, Some(vec2i(20, 0)));
assert_eq!(cache.em_width(FamilyId(0), 16.0), 20.0);
}
#[test]
fn em_width_does_not_panic_when_bounds_and_advance_are_missing() {
let cache = EmWidthFontDB::cache(None, None);
assert_eq!(cache.em_width(FamilyId(0), 16.0), 16.0);
}
#[test]
fn em_width_falls_back_when_horizontal_advance_is_zero() {
let cache = EmWidthFontDB::cache(None, Some(vec2i(0, 0)));
assert_eq!(cache.em_width(FamilyId(0), 16.0), 16.0);
}
@@ -708,3 +708,245 @@ fn test_respects_max_dimensions_for_cacheoption_bysize() {
// Assert that, when we specify a max dimension of 512, the image is resized accordingly.
assert_eq!(image.img.dimensions(), (512, 512));
}
#[test]
fn animated_image_get_current_frame_advances_with_elapsed_time() {
let asset_cache = new_asset_cache();
let image_cache = ImageCache::new();
// Load an animated GIF with FullAnimation behavior to get an AnimatedImage
let image = load_bundled_image(
&image_cache,
&asset_cache,
"numbers-1000ms.gif",
Vector2I::new(16, 16),
FitType::Contain,
AnimatedImageBehavior::FullAnimation,
);
let Image::Animated(animated) = image.as_ref() else {
panic!("Expected animated image but got static image!");
};
// The numbers-1000ms.gif has multiple frames with various delays
assert!(
animated.frames.len() >= 2,
"Expected at least 2 frames for testing"
);
assert!(animated.duration > 0, "Expected positive total duration");
let (_frame_0, remaining_0) = animated
.get_current_frame(0)
.expect("Should get frame at elapsed 0ms");
let (_frame_100, remaining_100) = animated
.get_current_frame(100)
.expect("Should get frame at elapsed 100ms");
let (_frame_500, remaining_500) = animated
.get_current_frame(500)
.expect("Should get frame at elapsed 500ms");
assert_ne!(
remaining_0, remaining_100,
"Remaining delay should differ at different elapsed times"
);
assert_ne!(
remaining_100, remaining_500,
"Remaining delay should differ at different elapsed times"
);
}
#[test]
fn animated_image_get_current_frame_wraps_at_duration() {
let asset_cache = new_asset_cache();
let image_cache = ImageCache::new();
let image = load_bundled_image(
&image_cache,
&asset_cache,
"numbers-1000ms.gif",
Vector2I::new(16, 16),
FitType::Contain,
AnimatedImageBehavior::FullAnimation,
);
let Image::Animated(animated) = image.as_ref() else {
panic!("Expected animated image but got static image!");
};
let duration = animated.duration;
let (frame_start, _) = animated
.get_current_frame(0)
.expect("Should get frame at start");
let (frame_end, _) = animated
.get_current_frame(duration - 1)
.expect("Should get frame near end");
// After wrapping (elapsed >= duration), should return to start of animation
let (frame_wrapped_start, _) = animated
.get_current_frame(duration)
.expect("Should get frame after one complete cycle");
let (frame_wrapped_end, _) = animated
.get_current_frame(duration * 2 - 1)
.expect("Should get frame in second cycle");
assert!(
Arc::ptr_eq(&frame_start, &frame_wrapped_start),
"Frame at start should equal frame at wrapped start"
);
assert!(
Arc::ptr_eq(&frame_end, &frame_wrapped_end),
"Frame at cycle end should equal frame at next cycle end"
);
}
#[test]
fn static_image_not_affected_by_animation_behavior_change() {
let asset_cache = new_asset_cache();
let image_cache = ImageCache::new();
// Load a static PNG with FullAnimation behavior (should remain static)
let static_full_animation = load_bundled_image(
&image_cache,
&asset_cache,
"local.png",
Vector2I::new(512, 512),
FitType::Cover,
AnimatedImageBehavior::FullAnimation,
);
// Load the same static PNG with FirstFramePreview behavior (should remain static)
let static_preview = load_bundled_image(
&image_cache,
&asset_cache,
"local.png",
Vector2I::new(512, 512),
FitType::Cover,
AnimatedImageBehavior::FirstFramePreview,
);
// Both should be static images
assert!(matches!(static_full_animation.as_ref(), Image::Static(_)));
assert!(matches!(static_preview.as_ref(), Image::Static(_)));
// Extract and verify they're the same image data
let Image::Static(full_static) = static_full_animation.as_ref() else {
unreachable!();
};
let Image::Static(preview_static) = static_preview.as_ref() else {
unreachable!();
};
// Both should have the same dimensions
assert_eq!(
full_static.img.dimensions(),
preview_static.img.dimensions()
);
}
#[test]
fn animated_gif_shows_first_frame_preview_when_requested() {
let asset_cache = new_asset_cache();
let image_cache = ImageCache::new();
// Request FirstFramePreview behavior for an animated GIF
let preview = load_bundled_image(
&image_cache,
&asset_cache,
"numbers-1000ms.gif",
Vector2I::new(16, 16),
FitType::Contain,
AnimatedImageBehavior::FirstFramePreview,
);
// Should get a static image (the first frame)
let Image::Static(_) = preview.as_ref() else {
panic!("Expected static image for FirstFramePreview");
};
// But the underlying asset in asset_cache should still be the full AnimatedBitmap
let asset: AssetState<ImageType> = asset_cache.load_asset(AssetSource::Bundled {
path: "numbers-1000ms.gif",
});
let AssetState::Loaded { data } = asset else {
panic!("Bundled asset should be available immediately!");
};
assert!(matches!(data.as_ref(), ImageType::AnimatedBitmap { .. }));
}
#[test]
fn animated_image_remaining_delay_decreases_within_frame() {
let asset_cache = new_asset_cache();
let image_cache = ImageCache::new();
let image = load_bundled_image(
&image_cache,
&asset_cache,
"numbers-1000ms.gif",
Vector2I::new(16, 16),
FitType::Contain,
AnimatedImageBehavior::FullAnimation,
);
let Image::Animated(animated) = image.as_ref() else {
panic!("Expected animated image");
};
// Assumes the gif's first frame lasts at least 50ms.
let (_, remaining_at_start) = animated
.get_current_frame(0)
.expect("Should get frame at 0ms");
let (_, remaining_at_50) = animated
.get_current_frame(50)
.expect("Should get frame at 50ms");
assert!(
remaining_at_50 < remaining_at_start,
"Remaining delay should decrease as we progress through a frame"
);
}
#[test]
fn animated_webp_also_advances_frames() {
let asset_cache = new_asset_cache();
let image_cache = ImageCache::new();
let image = load_bundled_image(
&image_cache,
&asset_cache,
"animated.webp",
Vector2I::new(16, 16),
FitType::Contain,
AnimatedImageBehavior::FullAnimation,
);
let Image::Animated(animated) = image.as_ref() else {
panic!("Expected animated image");
};
assert!(
animated.frames.len() > 1,
"WebP should have multiple frames"
);
let (frame_0, _) = animated
.get_current_frame(0)
.expect("Should get frame at 0ms");
let (frame_near_end, _) = animated
.get_current_frame(animated.duration - 1)
.expect("Should get frame near end");
assert!(
!Arc::ptr_eq(&frame_0, &frame_near_end) || animated.frames.len() == 1,
"WebP animation should traverse different frames or only have one frame"
);
let (frame_wrapped, _) = animated
.get_current_frame(animated.duration)
.expect("Should get frame after one cycle");
assert!(
Arc::ptr_eq(&frame_0, &frame_wrapped),
"Frame should wrap back to start after duration"
);
}