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