Skip to main content

fret_ui/
canvas.rs

1use std::any::TypeId;
2use std::collections::HashMap;
3use std::hash::{Hash, Hasher};
4use std::sync::Arc;
5
6use fret_core::scene::{Paint, PaintBindingV1};
7use fret_core::{
8    AttributedText, Color, Corners, DrawOrder, EffectChain, EffectMode, EffectQuality, FontId,
9    FontWeight, Point, Px, Rect, Scene, SceneOp, SvgFit, TextConstraints, TextMetrics,
10    TextOverflow, TextSlant, TextStyle, TextWrap, Transform2D,
11};
12use fret_core::{PathCommand, PathConstraints, PathMetrics, PathStyle};
13use fret_runtime::ModelId;
14
15use crate::Theme;
16use crate::element::CanvasCachePolicy;
17use crate::widget::Invalidation;
18use crate::{SvgSource, UiHost, widget::PaintCx};
19
20pub type OnCanvasPaint = Arc<dyn for<'a> Fn(&mut CanvasPainter<'a>) + 'static>;
21
22/// A stable, user-provided cache key for hosted canvas resources.
23///
24/// Callers should treat this as an identity key for a logical draw item that is stable across
25/// frames (e.g. "grid label #42"). The runtime mixes in scale-factor bits where needed, so the
26/// same key can be reused across DPI/zoom changes.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28pub struct CanvasKey(pub u64);
29
30impl CanvasKey {
31    pub const fn new(value: u64) -> Self {
32        Self(value)
33    }
34
35    /// Combine a child identifier into this key.
36    pub fn combine(self, value: u64) -> Self {
37        Self(mix_u64(self.0, value))
38    }
39
40    /// Combine a deterministic hash of `value` into this key.
41    pub fn combine_hash<T: Hash>(self, value: &T) -> Self {
42        self.combine(Self::from_hash(value).0)
43    }
44
45    /// Compute a deterministic hash for `value`.
46    ///
47    /// This uses a fixed-seed FNV-1a hasher (unlike `DefaultHasher`, which is randomized).
48    pub fn from_hash<T: Hash>(value: &T) -> Self {
49        let mut hasher = Fnv1a64::default();
50        value.hash(&mut hasher);
51        Self(hasher.finish())
52    }
53}
54
55impl From<CanvasKey> for u64 {
56    fn from(value: CanvasKey) -> Self {
57        value.0
58    }
59}
60
61impl From<u64> for CanvasKey {
62    fn from(value: u64) -> Self {
63        Self(value)
64    }
65}
66
67#[derive(Default)]
68pub(crate) struct CanvasPaintHooks {
69    pub on_paint: Option<OnCanvasPaint>,
70}
71
72/// Object-safe paint surface for declarative canvas paint handlers.
73///
74/// This mirrors the "action hook host" pattern (ADR 0074): we cannot store closures that depend on
75/// `H: UiHost` because `UiHost` is not object-safe.
76pub(crate) trait UiCanvasHost {
77    fn bounds(&self) -> Rect;
78    fn scale_factor(&self) -> f32;
79    fn text_font_stack_key(&mut self) -> u64;
80    fn inherited_foreground(&self) -> Option<Color>;
81    fn inherited_text_style(&mut self) -> Option<fret_core::TextStyleRefinement>;
82
83    fn theme(&mut self) -> &Theme;
84    fn request_redraw(&mut self);
85    fn request_animation_frame(&mut self);
86
87    fn observe_model_id(&mut self, model: ModelId, invalidation: Invalidation);
88    fn observe_global(&mut self, global: TypeId, invalidation: Invalidation);
89
90    fn scene(&mut self) -> &mut Scene;
91    fn services_and_scene(&mut self) -> (&mut dyn fret_core::UiServices, &mut Scene);
92}
93
94pub(crate) struct UiCanvasHostAdapter<'a, 'b, H: UiHost> {
95    cx: &'a mut PaintCx<'b, H>,
96}
97
98impl<'a, 'b, H: UiHost> UiCanvasHostAdapter<'a, 'b, H> {
99    pub(crate) fn new(cx: &'a mut PaintCx<'b, H>) -> Self {
100        Self { cx }
101    }
102}
103
104impl<'a, 'b, H: UiHost> UiCanvasHost for UiCanvasHostAdapter<'a, 'b, H> {
105    fn bounds(&self) -> Rect {
106        self.cx.bounds
107    }
108
109    fn scale_factor(&self) -> f32 {
110        self.cx.scale_factor
111    }
112
113    fn text_font_stack_key(&mut self) -> u64 {
114        self.cx
115            .observe_global::<fret_runtime::TextFontStackKey>(Invalidation::Layout);
116        self.cx
117            .app
118            .global::<fret_runtime::TextFontStackKey>()
119            .map(|k| k.0)
120            .unwrap_or(0)
121    }
122
123    fn inherited_foreground(&self) -> Option<Color> {
124        self.cx.inherited_foreground()
125    }
126
127    fn inherited_text_style(&mut self) -> Option<fret_core::TextStyleRefinement> {
128        let Some(window) = self.cx.window else {
129            return None;
130        };
131        crate::declarative::frame::inherited_text_style_for_node(self.cx.app, window, self.cx.node)
132    }
133
134    fn theme(&mut self) -> &Theme {
135        self.cx.theme()
136    }
137
138    fn request_redraw(&mut self) {
139        self.cx.request_redraw();
140    }
141
142    fn request_animation_frame(&mut self) {
143        self.cx.request_animation_frame();
144    }
145
146    fn observe_model_id(&mut self, model: ModelId, invalidation: Invalidation) {
147        (self.cx.observe_model)(model, invalidation);
148    }
149
150    fn observe_global(&mut self, global: TypeId, invalidation: Invalidation) {
151        (self.cx.observe_global)(global, invalidation);
152    }
153
154    fn scene(&mut self) -> &mut Scene {
155        self.cx.scene
156    }
157
158    fn services_and_scene(&mut self) -> (&mut dyn fret_core::UiServices, &mut Scene) {
159        (self.cx.services, self.cx.scene)
160    }
161}
162
163pub struct CanvasPainter<'a> {
164    host: &'a mut dyn UiCanvasHost,
165    cache: &'a mut CanvasCache,
166}
167
168impl<'a> CanvasPainter<'a> {
169    pub(crate) fn new(host: &'a mut dyn UiCanvasHost, cache: &'a mut CanvasCache) -> Self {
170        Self { host, cache }
171    }
172
173    /// Current runner-owned frame id for this paint pass.
174    pub fn frame_id(&self) -> u64 {
175        self.cache.frame
176    }
177
178    pub fn bounds(&self) -> Rect {
179        self.host.bounds()
180    }
181
182    pub fn scale_factor(&self) -> f32 {
183        self.host.scale_factor()
184    }
185
186    pub fn theme(&mut self) -> &Theme {
187        self.host.theme()
188    }
189
190    pub fn inherited_foreground(&self) -> Option<Color> {
191        self.host.inherited_foreground()
192    }
193
194    pub fn inherited_text_style(&mut self) -> Option<fret_core::TextStyleRefinement> {
195        self.host.inherited_text_style()
196    }
197
198    pub fn resolved_passive_text_style(&mut self, explicit: Option<TextStyle>) -> TextStyle {
199        let theme = self.host.theme().snapshot();
200        let inherited = self.host.inherited_text_style();
201        crate::text_props::resolve_text_style(theme, explicit, inherited.as_ref())
202    }
203
204    pub fn request_redraw(&mut self) {
205        self.host.request_redraw();
206    }
207
208    pub fn request_animation_frame(&mut self) {
209        self.host.request_animation_frame();
210    }
211
212    pub fn observe_model_id(&mut self, model: ModelId, invalidation: Invalidation) {
213        self.host.observe_model_id(model, invalidation);
214    }
215
216    pub fn observe_global<T: std::any::Any>(&mut self, invalidation: Invalidation) {
217        self.host.observe_global(TypeId::of::<T>(), invalidation);
218    }
219
220    /// Compute a deterministic `u64` key for `value`.
221    pub fn key<T: Hash>(&self, value: &T) -> u64 {
222        CanvasKey::from_hash(value).0
223    }
224
225    /// Create a deterministic base key for a logical key "namespace".
226    ///
227    /// Use this to avoid accidental key collisions across unrelated subsystems.
228    pub fn key_scope<T: Hash>(&self, scope: &T) -> CanvasKey {
229        CanvasKey::from_hash(scope)
230    }
231
232    /// Combine a child identifier into a scoped key.
233    pub fn child_key<T: Hash>(&self, parent: CanvasKey, child: &T) -> CanvasKey {
234        parent.combine_hash(child)
235    }
236
237    pub fn scene(&mut self) -> &mut Scene {
238        self.host.scene()
239    }
240
241    /// Access the underlying UI services and scene for advanced canvas paint handlers.
242    ///
243    /// This is primarily intended for diagnostics/profiling overlays and experimental paint
244    /// surfaces that need text geometry queries (selection rects, hit-testing, etc.).
245    pub fn services_and_scene(&mut self) -> (&mut dyn fret_core::UiServices, &mut Scene) {
246        self.host.services_and_scene()
247    }
248
249    pub fn with_clip_rect<R>(&mut self, rect: Rect, f: impl FnOnce(&mut Self) -> R) -> R {
250        {
251            let scene = self.host.scene();
252            scene.push(SceneOp::PushClipRect { rect });
253        }
254        let out = f(self);
255        {
256            let scene = self.host.scene();
257            scene.push(SceneOp::PopClip);
258        }
259        out
260    }
261
262    pub fn with_clip_rrect<R>(
263        &mut self,
264        rect: Rect,
265        corner_radii: Corners,
266        f: impl FnOnce(&mut Self) -> R,
267    ) -> R {
268        {
269            let scene = self.host.scene();
270            scene.push(SceneOp::PushClipRRect { rect, corner_radii });
271        }
272        let out = f(self);
273        {
274            let scene = self.host.scene();
275            scene.push(SceneOp::PopClip);
276        }
277        out
278    }
279
280    pub fn with_transform<R>(
281        &mut self,
282        transform: Transform2D,
283        f: impl FnOnce(&mut Self) -> R,
284    ) -> R {
285        let is_finite = transform.a.is_finite()
286            && transform.b.is_finite()
287            && transform.c.is_finite()
288            && transform.d.is_finite()
289            && transform.tx.is_finite()
290            && transform.ty.is_finite();
291
292        if !is_finite || transform == Transform2D::IDENTITY {
293            return f(self);
294        }
295
296        {
297            let scene = self.host.scene();
298            scene.push(SceneOp::PushTransform { transform });
299        }
300        let out = f(self);
301        {
302            let scene = self.host.scene();
303            scene.push(SceneOp::PopTransform);
304        }
305        out
306    }
307
308    pub fn with_opacity<R>(&mut self, opacity: f32, f: impl FnOnce(&mut Self) -> R) -> R {
309        let opacity = if opacity.is_finite() {
310            opacity.clamp(0.0, 1.0)
311        } else {
312            1.0
313        };
314
315        if opacity >= 1.0 {
316            return f(self);
317        }
318
319        {
320            let scene = self.host.scene();
321            scene.push(SceneOp::PushOpacity { opacity });
322        }
323        let out = f(self);
324        {
325            let scene = self.host.scene();
326            scene.push(SceneOp::PopOpacity);
327        }
328        out
329    }
330
331    pub fn with_effect<R>(
332        &mut self,
333        bounds: Rect,
334        mode: EffectMode,
335        chain: EffectChain,
336        quality: EffectQuality,
337        f: impl FnOnce(&mut Self) -> R,
338    ) -> R {
339        if chain.is_empty() {
340            return f(self);
341        }
342
343        {
344            let scene = self.host.scene();
345            scene.push(SceneOp::PushEffect {
346                bounds,
347                mode,
348                chain,
349                quality,
350            });
351        }
352        let out = f(self);
353        {
354            let scene = self.host.scene();
355            scene.push(SceneOp::PopEffect);
356        }
357        out
358    }
359
360    /// Draw a cached text blob prepared at `raster_scale_factor`.
361    ///
362    /// - `key` must be stable across frames for the *same* logical text instance.
363    /// - `raster_scale_factor` should usually be `device_scale_factor * zoom`, where zoom is an
364    ///   explicit policy decision of the caller (ADR 0141).
365    #[allow(clippy::too_many_arguments)]
366    pub fn text(
367        &mut self,
368        key: u64,
369        order: DrawOrder,
370        origin: Point,
371        text: impl Into<Arc<str>>,
372        style: TextStyle,
373        color: Color,
374        constraints: CanvasTextConstraints,
375        raster_scale_factor: f32,
376    ) -> TextMetrics {
377        let text = text.into();
378        let font_stack_key = self.host.text_font_stack_key();
379        let (services, scene) = self.host.services_and_scene();
380        self.cache.text(
381            services,
382            key,
383            order,
384            origin,
385            HostedTextContent::Plain(text),
386            style,
387            color,
388            constraints,
389            raster_scale_factor,
390            font_stack_key,
391            scene,
392        )
393    }
394
395    /// Draw a cached text blob keyed by (content, style, constraints).
396    ///
397    /// This is intended for repeated labels where callers do not have a stable per-instance key.
398    /// High-entropy or scroll-driven surfaces (code editors, large virtual lists, etc.) should
399    /// prefer `text(...)` with stable keys to avoid churn in the shared cache.
400    #[allow(clippy::too_many_arguments)]
401    pub fn shared_text(
402        &mut self,
403        order: DrawOrder,
404        origin: Point,
405        text: impl Into<Arc<str>>,
406        style: TextStyle,
407        color: Color,
408        constraints: CanvasTextConstraints,
409        raster_scale_factor: f32,
410    ) -> TextMetrics {
411        let text = text.into();
412        let font_stack_key = self.host.text_font_stack_key();
413        let (services, scene) = self.host.services_and_scene();
414        self.cache
415            .shared_text_draw(
416                services,
417                order,
418                origin,
419                text,
420                style,
421                color,
422                constraints,
423                raster_scale_factor,
424                font_stack_key,
425                scene,
426            )
427            .metrics
428    }
429
430    /// Draw a cached text blob prepared at `raster_scale_factor` and return its `TextBlobId`.
431    ///
432    /// This is intended for advanced paint handlers that need to query text geometry (caret stops,
433    /// selection rects, hit-testing, etc.) using the returned blob.
434    #[allow(clippy::too_many_arguments)]
435    pub fn text_with_blob(
436        &mut self,
437        key: u64,
438        order: DrawOrder,
439        origin: Point,
440        text: impl Into<Arc<str>>,
441        style: TextStyle,
442        color: Color,
443        constraints: CanvasTextConstraints,
444        raster_scale_factor: f32,
445    ) -> (fret_core::TextBlobId, TextMetrics) {
446        let text = text.into();
447        let font_stack_key = self.host.text_font_stack_key();
448        let (services, scene) = self.host.services_and_scene();
449        let draw = self.cache.text_draw(
450            services,
451            key,
452            order,
453            origin,
454            HostedTextContent::Plain(text),
455            style,
456            color,
457            constraints,
458            raster_scale_factor,
459            font_stack_key,
460            scene,
461        );
462        (draw.blob, draw.metrics)
463    }
464
465    /// Variant of `shared_text` that returns its prepared `TextBlobId`.
466    #[allow(clippy::too_many_arguments)]
467    pub fn shared_text_with_blob(
468        &mut self,
469        order: DrawOrder,
470        origin: Point,
471        text: impl Into<Arc<str>>,
472        style: TextStyle,
473        color: Color,
474        constraints: CanvasTextConstraints,
475        raster_scale_factor: f32,
476    ) -> (fret_core::TextBlobId, TextMetrics) {
477        let text = text.into();
478        let font_stack_key = self.host.text_font_stack_key();
479        let (services, scene) = self.host.services_and_scene();
480        let draw = self.cache.shared_text_draw(
481            services,
482            order,
483            origin,
484            text,
485            style,
486            color,
487            constraints,
488            raster_scale_factor,
489            font_stack_key,
490            scene,
491        );
492        (draw.blob, draw.metrics)
493    }
494
495    #[allow(clippy::too_many_arguments)]
496    pub fn rich_text(
497        &mut self,
498        key: u64,
499        order: DrawOrder,
500        origin: Point,
501        rich: AttributedText,
502        base_style: TextStyle,
503        color: Color,
504        constraints: CanvasTextConstraints,
505        raster_scale_factor: f32,
506    ) -> TextMetrics {
507        let font_stack_key = self.host.text_font_stack_key();
508        let (services, scene) = self.host.services_and_scene();
509        self.cache.text(
510            services,
511            key,
512            order,
513            origin,
514            HostedTextContent::Rich(rich),
515            base_style,
516            color,
517            constraints,
518            raster_scale_factor,
519            font_stack_key,
520            scene,
521        )
522    }
523
524    /// Draw a cached rich text blob prepared at `raster_scale_factor` and return its `TextBlobId`.
525    ///
526    /// This is intended for advanced paint handlers that need to query text geometry (caret stops,
527    /// selection rects, hit-testing, etc.) using the returned blob.
528    #[allow(clippy::too_many_arguments)]
529    pub fn rich_text_with_blob(
530        &mut self,
531        key: u64,
532        order: DrawOrder,
533        origin: Point,
534        rich: AttributedText,
535        base_style: TextStyle,
536        color: Color,
537        constraints: CanvasTextConstraints,
538        raster_scale_factor: f32,
539    ) -> (fret_core::TextBlobId, TextMetrics) {
540        let font_stack_key = self.host.text_font_stack_key();
541        let (services, scene) = self.host.services_and_scene();
542        let draw = self.cache.text_draw(
543            services,
544            key,
545            order,
546            origin,
547            HostedTextContent::Rich(rich),
548            base_style,
549            color,
550            constraints,
551            raster_scale_factor,
552            font_stack_key,
553            scene,
554        );
555        (draw.blob, draw.metrics)
556    }
557
558    /// Draw a cached tessellated path prepared at `raster_scale_factor`.
559    ///
560    /// - `key` must be stable across frames for the *same* logical path instance.
561    /// - `raster_scale_factor` should usually be `device_scale_factor * zoom`, where zoom is an
562    ///   explicit policy decision of the caller (ADR 0141).
563    #[allow(clippy::too_many_arguments)]
564    pub fn path(
565        &mut self,
566        key: u64,
567        order: DrawOrder,
568        origin: Point,
569        commands: &[PathCommand],
570        style: PathStyle,
571        color: Color,
572        raster_scale_factor: f32,
573    ) -> PathMetrics {
574        let (services, scene) = self.host.services_and_scene();
575        self.cache.path(
576            services,
577            key,
578            order,
579            origin,
580            commands,
581            style,
582            color.into(),
583            raster_scale_factor,
584            scene,
585        )
586    }
587
588    /// Draw a cached tessellated path with an explicit paint binding.
589    ///
590    /// This is the paint-general form of `path(...)`: geometry caching is keyed by path commands,
591    /// style, and scale. Paint binding changes should not force re-tessellation.
592    #[allow(clippy::too_many_arguments)]
593    pub fn path_paint(
594        &mut self,
595        key: u64,
596        order: DrawOrder,
597        origin: Point,
598        commands: &[PathCommand],
599        style: PathStyle,
600        paint: PaintBindingV1,
601        raster_scale_factor: f32,
602    ) -> PathMetrics {
603        let (services, scene) = self.host.services_and_scene();
604        self.cache.path(
605            services,
606            key,
607            order,
608            origin,
609            commands,
610            style,
611            paint,
612            raster_scale_factor,
613            scene,
614        )
615    }
616
617    #[allow(clippy::too_many_arguments)]
618    pub fn svg_mask_icon(
619        &mut self,
620        key: u64,
621        order: DrawOrder,
622        rect: Rect,
623        svg: &SvgSource,
624        fit: SvgFit,
625        color: Color,
626        opacity: f32,
627    ) {
628        let opacity = opacity.clamp(0.0, 1.0);
629        if opacity <= 0.0 || color.a <= 0.0 {
630            return;
631        }
632
633        let (services, scene) = self.host.services_and_scene();
634        let svg_id = self.cache.svg(services, key, svg);
635        scene.push(SceneOp::SvgMaskIcon {
636            order,
637            rect,
638            svg: svg_id,
639            fit,
640            color,
641            opacity,
642        });
643    }
644
645    pub fn svg_image(
646        &mut self,
647        key: u64,
648        order: DrawOrder,
649        rect: Rect,
650        svg: &SvgSource,
651        fit: SvgFit,
652        opacity: f32,
653    ) {
654        let opacity = opacity.clamp(0.0, 1.0);
655        if opacity <= 0.0 {
656            return;
657        }
658
659        let (services, scene) = self.host.services_and_scene();
660        let svg_id = self.cache.svg(services, key, svg);
661        scene.push(SceneOp::SvgImage {
662            order,
663            rect,
664            svg: svg_id,
665            fit,
666            opacity,
667        });
668    }
669}
670
671#[derive(Debug, Clone, Copy, PartialEq)]
672pub struct CanvasTextConstraints {
673    pub max_width: Option<Px>,
674    pub wrap: TextWrap,
675    pub overflow: TextOverflow,
676}
677
678impl Default for CanvasTextConstraints {
679    fn default() -> Self {
680        Self {
681            max_width: None,
682            wrap: TextWrap::Word,
683            overflow: TextOverflow::Clip,
684        }
685    }
686}
687
688#[derive(Default)]
689pub(crate) struct CanvasCache {
690    frame: u64,
691    policy: CanvasCachePolicy,
692    text_by_key: HashMap<CanvasTextCacheKey, HostedTextEntry>,
693    shared_text_by_fingerprint: HashMap<SharedTextFingerprintKey, SharedTextEntry>,
694    path_by_key: HashMap<CanvasPathCacheKey, HostedPathEntry>,
695    svg_by_key: HashMap<CanvasSvgCacheKey, HostedSvgEntry>,
696}
697
698impl CanvasCache {
699    pub(crate) fn begin_paint(&mut self, frame: u64, policy: CanvasCachePolicy) {
700        self.frame = frame;
701        self.policy = policy;
702    }
703
704    pub(crate) fn end_paint(&mut self, services: &mut dyn fret_core::UiServices) {
705        self.evict_hosted_text(services);
706        self.evict_hosted_paths(services);
707        self.evict_hosted_svgs(services);
708
709        self.evict_shared_text(services);
710    }
711
712    pub(crate) fn cleanup_resources(&mut self, services: &mut dyn fret_core::UiServices) {
713        for (_, mut entry) in self.text_by_key.drain() {
714            if let Some(blob) = entry.blob.take() {
715                services.text().release(blob);
716            }
717        }
718        for (_, entry) in self.shared_text_by_fingerprint.drain() {
719            services.text().release(entry.blob);
720        }
721        for (_, mut entry) in self.path_by_key.drain() {
722            if let Some(path) = entry.path.take() {
723                services.path().release(path);
724            }
725        }
726        for (_, mut entry) in self.svg_by_key.drain() {
727            if let Some(svg) = entry.svg.take() {
728                let _ = services.svg().unregister_svg(svg);
729            }
730        }
731        self.frame = 0;
732    }
733
734    fn evict_shared_text(&mut self, services: &mut dyn fret_core::UiServices) {
735        let now = self.frame;
736        let keep_frames = self.policy.shared_text.keep_frames;
737        let max_entries = self.policy.shared_text.max_entries;
738
739        if self.shared_text_by_fingerprint.is_empty() {
740            return;
741        }
742
743        if max_entries == 0 {
744            for (_, entry) in self.shared_text_by_fingerprint.drain() {
745                services.text().release(entry.blob);
746            }
747            return;
748        }
749
750        let mut to_remove: Vec<SharedTextFingerprintKey> = Vec::new();
751        for (key, entry) in &self.shared_text_by_fingerprint {
752            if entry.last_used_frame == now {
753                continue;
754            }
755            if now.saturating_sub(entry.last_used_frame) > keep_frames {
756                to_remove.push(key.clone());
757            }
758        }
759
760        for key in to_remove {
761            if let Some(entry) = self.shared_text_by_fingerprint.remove(&key) {
762                services.text().release(entry.blob);
763            }
764        }
765
766        if self.shared_text_by_fingerprint.len() <= max_entries {
767            return;
768        }
769
770        let mut candidates: Vec<(u64, SharedTextFingerprintKey)> = self
771            .shared_text_by_fingerprint
772            .iter()
773            .filter_map(|(key, entry)| {
774                if entry.last_used_frame == now {
775                    None
776                } else {
777                    Some((entry.last_used_frame, key.clone()))
778                }
779            })
780            .collect();
781        candidates.sort_by_key(|(last_used, _)| *last_used);
782
783        let mut idx = 0usize;
784        while self.shared_text_by_fingerprint.len() > max_entries && idx < candidates.len() {
785            let key = candidates[idx].1.clone();
786            if let Some(entry) = self.shared_text_by_fingerprint.remove(&key) {
787                services.text().release(entry.blob);
788            }
789            idx += 1;
790        }
791    }
792
793    fn evict_hosted_text(&mut self, services: &mut dyn fret_core::UiServices) {
794        let now = self.frame;
795        let keep_frames = self.policy.text.keep_frames;
796        let max_entries = self.policy.text.max_entries;
797
798        self.text_by_key.retain(|_, entry| {
799            let keep = now.saturating_sub(entry.last_used_frame) <= keep_frames;
800            if !keep && let Some(blob) = entry.blob.take() {
801                services.text().release(blob);
802            }
803            keep
804        });
805
806        if max_entries == 0 {
807            for (_, mut entry) in self.text_by_key.drain() {
808                if let Some(blob) = entry.blob.take() {
809                    services.text().release(blob);
810                }
811            }
812            return;
813        }
814
815        let over = self.text_by_key.len().saturating_sub(max_entries);
816        if over == 0 {
817            return;
818        }
819
820        let mut candidates: Vec<(u64, CanvasTextCacheKey)> = self
821            .text_by_key
822            .iter()
823            .map(|(k, v)| (v.last_used_frame, *k))
824            .collect();
825        candidates.sort_by_key(|(last, _)| *last);
826
827        for (_, key) in candidates.into_iter().take(over) {
828            if let Some(mut entry) = self.text_by_key.remove(&key)
829                && let Some(blob) = entry.blob.take()
830            {
831                services.text().release(blob);
832            }
833        }
834    }
835
836    fn evict_hosted_paths(&mut self, services: &mut dyn fret_core::UiServices) {
837        let now = self.frame;
838        let keep_frames = self.policy.path.keep_frames;
839        let max_entries = self.policy.path.max_entries;
840
841        self.path_by_key.retain(|_, entry| {
842            let keep = now.saturating_sub(entry.last_used_frame) <= keep_frames;
843            if !keep && let Some(path) = entry.path.take() {
844                services.path().release(path);
845            }
846            keep
847        });
848
849        if max_entries == 0 {
850            for (_, mut entry) in self.path_by_key.drain() {
851                if let Some(path) = entry.path.take() {
852                    services.path().release(path);
853                }
854            }
855            return;
856        }
857
858        let over = self.path_by_key.len().saturating_sub(max_entries);
859        if over == 0 {
860            return;
861        }
862
863        let mut candidates: Vec<(u64, CanvasPathCacheKey)> = self
864            .path_by_key
865            .iter()
866            .map(|(k, v)| (v.last_used_frame, *k))
867            .collect();
868        candidates.sort_by_key(|(last, _)| *last);
869
870        for (_, key) in candidates.into_iter().take(over) {
871            if let Some(mut entry) = self.path_by_key.remove(&key)
872                && let Some(path) = entry.path.take()
873            {
874                services.path().release(path);
875            }
876        }
877    }
878
879    fn evict_hosted_svgs(&mut self, services: &mut dyn fret_core::UiServices) {
880        let now = self.frame;
881        let keep_frames = self.policy.svg.keep_frames;
882        let max_entries = self.policy.svg.max_entries;
883
884        self.svg_by_key.retain(|_, entry| {
885            let keep = now.saturating_sub(entry.last_used_frame) <= keep_frames;
886            if !keep && let Some(svg) = entry.svg.take() {
887                let _ = services.svg().unregister_svg(svg);
888            }
889            keep
890        });
891
892        if max_entries == 0 {
893            for (_, mut entry) in self.svg_by_key.drain() {
894                if let Some(svg) = entry.svg.take() {
895                    let _ = services.svg().unregister_svg(svg);
896                }
897            }
898            return;
899        }
900
901        let over = self.svg_by_key.len().saturating_sub(max_entries);
902        if over == 0 {
903            return;
904        }
905
906        let mut candidates: Vec<(u64, CanvasSvgCacheKey)> = self
907            .svg_by_key
908            .iter()
909            .map(|(k, v)| (v.last_used_frame, *k))
910            .collect();
911        candidates.sort_by_key(|(last, _)| *last);
912
913        for (_, key) in candidates.into_iter().take(over) {
914            if let Some(mut entry) = self.svg_by_key.remove(&key)
915                && let Some(svg) = entry.svg.take()
916            {
917                let _ = services.svg().unregister_svg(svg);
918            }
919        }
920    }
921
922    #[allow(clippy::too_many_arguments)]
923    fn text(
924        &mut self,
925        services: &mut dyn fret_core::UiServices,
926        key: u64,
927        order: DrawOrder,
928        origin: Point,
929        content: HostedTextContent,
930        style: TextStyle,
931        color: Color,
932        constraints: CanvasTextConstraints,
933        raster_scale_factor: f32,
934        font_stack_key: u64,
935        scene: &mut Scene,
936    ) -> TextMetrics {
937        self.text_draw(
938            services,
939            key,
940            order,
941            origin,
942            content,
943            style,
944            color,
945            constraints,
946            raster_scale_factor,
947            font_stack_key,
948            scene,
949        )
950        .metrics
951    }
952
953    #[allow(clippy::too_many_arguments)]
954    fn shared_text_draw(
955        &mut self,
956        services: &mut dyn fret_core::UiServices,
957        order: DrawOrder,
958        origin: Point,
959        text: Arc<str>,
960        style: TextStyle,
961        color: Color,
962        constraints: CanvasTextConstraints,
963        raster_scale_factor: f32,
964        font_stack_key: u64,
965        scene: &mut Scene,
966    ) -> TextDraw {
967        let raster_scale_factor = normalize_scale_factor(raster_scale_factor);
968        let scale_bits = raster_scale_factor.to_bits();
969
970        if self.policy.shared_text.max_entries == 0 {
971            let text_constraints = TextConstraints {
972                max_width: constraints.max_width,
973                wrap: constraints.wrap,
974                overflow: constraints.overflow,
975                align: fret_core::TextAlign::Start,
976                scale_factor: raster_scale_factor,
977            };
978
979            let (blob, metrics) =
980                services
981                    .text()
982                    .prepare_str(text.as_ref(), &style, text_constraints);
983            scene.push(SceneOp::Text {
984                order,
985                origin,
986                text: blob,
987                paint: Paint::Solid(color).into(),
988                outline: None,
989                shadow: None,
990            });
991            return TextDraw { blob, metrics };
992        }
993
994        let shared_key = SharedTextFingerprintKey {
995            content: SharedTextContentKey::Plain(Arc::clone(&text)),
996            style: TextStyleCacheKey::from_style(&style),
997            constraints: CanvasTextConstraintsKey::from_constraints(constraints),
998            font_stack_key,
999            scale_bits,
1000        };
1001
1002        if let Some(entry) = self.shared_text_by_fingerprint.get_mut(&shared_key) {
1003            entry.last_used_frame = self.frame;
1004            scene.push(SceneOp::Text {
1005                order,
1006                origin,
1007                text: entry.blob,
1008                paint: Paint::Solid(color).into(),
1009                outline: None,
1010                shadow: None,
1011            });
1012            return TextDraw {
1013                blob: entry.blob,
1014                metrics: entry.metrics,
1015            };
1016        }
1017
1018        let text_constraints = TextConstraints {
1019            max_width: constraints.max_width,
1020            wrap: constraints.wrap,
1021            overflow: constraints.overflow,
1022            align: fret_core::TextAlign::Start,
1023            scale_factor: raster_scale_factor,
1024        };
1025
1026        let (blob, metrics) = services
1027            .text()
1028            .prepare_str(text.as_ref(), &style, text_constraints);
1029        self.shared_text_by_fingerprint.insert(
1030            shared_key,
1031            SharedTextEntry {
1032                blob,
1033                metrics,
1034                last_used_frame: self.frame,
1035            },
1036        );
1037
1038        scene.push(SceneOp::Text {
1039            order,
1040            origin,
1041            text: blob,
1042            paint: Paint::Solid(color).into(),
1043            outline: None,
1044            shadow: None,
1045        });
1046        TextDraw { blob, metrics }
1047    }
1048
1049    #[allow(clippy::too_many_arguments)]
1050    fn text_draw(
1051        &mut self,
1052        services: &mut dyn fret_core::UiServices,
1053        key: u64,
1054        order: DrawOrder,
1055        origin: Point,
1056        content: HostedTextContent,
1057        style: TextStyle,
1058        color: Color,
1059        constraints: CanvasTextConstraints,
1060        raster_scale_factor: f32,
1061        font_stack_key: u64,
1062        scene: &mut Scene,
1063    ) -> TextDraw {
1064        let raster_scale_factor = normalize_scale_factor(raster_scale_factor);
1065        let scale_bits = raster_scale_factor.to_bits();
1066
1067        let fingerprint_constraints = match constraints.wrap {
1068            TextWrap::None if constraints.overflow != TextOverflow::Ellipsis => {
1069                CanvasTextConstraints {
1070                    max_width: None,
1071                    ..constraints
1072                }
1073            }
1074            _ => constraints,
1075        };
1076
1077        let cache_key = CanvasTextCacheKey { key, scale_bits };
1078        let entry = self.text_by_key.entry(cache_key).or_default();
1079        entry.last_used_frame = self.frame;
1080
1081        let fingerprint = HostedTextFingerprint {
1082            content: content.clone(),
1083            style: style.clone(),
1084            constraints: fingerprint_constraints,
1085            font_stack_key,
1086            scale_bits,
1087        };
1088
1089        let needs_prepare =
1090            entry.blob.is_none() || entry.fingerprint.as_ref() != Some(&fingerprint);
1091        if needs_prepare {
1092            if let Some(blob) = entry.blob.take() {
1093                services.text().release(blob);
1094            }
1095
1096            let text_constraints = TextConstraints {
1097                max_width: fingerprint_constraints.max_width,
1098                wrap: fingerprint_constraints.wrap,
1099                overflow: fingerprint_constraints.overflow,
1100                align: fret_core::TextAlign::Start,
1101                scale_factor: raster_scale_factor,
1102            };
1103
1104            let (blob, metrics) = match &content {
1105                HostedTextContent::Plain(text) => {
1106                    services
1107                        .text()
1108                        .prepare_str(text.as_ref(), &style, text_constraints)
1109                }
1110                HostedTextContent::Rich(rich) => {
1111                    services.text().prepare_rich(rich, &style, text_constraints)
1112                }
1113            };
1114
1115            entry.blob = Some(blob);
1116            entry.metrics = Some(metrics);
1117            entry.fingerprint = Some(fingerprint);
1118        }
1119
1120        let Some(blob) = entry.blob else {
1121            return TextDraw {
1122                blob: fret_core::TextBlobId::default(),
1123                metrics: TextMetrics {
1124                    size: fret_core::Size::new(Px(0.0), Px(0.0)),
1125                    baseline: Px(0.0),
1126                },
1127            };
1128        };
1129        let metrics = entry.metrics.unwrap_or(TextMetrics {
1130            size: fret_core::Size::new(Px(0.0), Px(0.0)),
1131            baseline: Px(0.0),
1132        });
1133
1134        scene.push(SceneOp::Text {
1135            order,
1136            origin,
1137            text: blob,
1138            paint: Paint::Solid(color).into(),
1139            outline: None,
1140            shadow: None,
1141        });
1142        TextDraw { blob, metrics }
1143    }
1144
1145    #[allow(clippy::too_many_arguments)]
1146    fn path(
1147        &mut self,
1148        services: &mut dyn fret_core::UiServices,
1149        key: u64,
1150        order: DrawOrder,
1151        origin: Point,
1152        commands: &[PathCommand],
1153        style: PathStyle,
1154        paint: PaintBindingV1,
1155        raster_scale_factor: f32,
1156        scene: &mut Scene,
1157    ) -> PathMetrics {
1158        let raster_scale_factor = normalize_scale_factor(raster_scale_factor);
1159        let scale_bits = raster_scale_factor.to_bits();
1160
1161        let cache_key = CanvasPathCacheKey { key, scale_bits };
1162        let entry = self.path_by_key.entry(cache_key).or_default();
1163        entry.last_used_frame = self.frame;
1164
1165        let fingerprint = HostedPathFingerprint {
1166            commands_hash: hash_path_commands(commands),
1167            commands_len: commands.len(),
1168            style,
1169            scale_bits,
1170        };
1171
1172        let needs_prepare =
1173            entry.path.is_none() || entry.fingerprint.as_ref() != Some(&fingerprint);
1174        if needs_prepare {
1175            if let Some(path) = entry.path.take() {
1176                services.path().release(path);
1177            }
1178            let constraints = PathConstraints {
1179                scale_factor: raster_scale_factor,
1180            };
1181            let (path, metrics) = services.path().prepare(commands, style, constraints);
1182            entry.path = Some(path);
1183            entry.metrics = Some(metrics);
1184            entry.fingerprint = Some(fingerprint);
1185        }
1186
1187        let Some(path) = entry.path else {
1188            return PathMetrics::default();
1189        };
1190        let metrics = entry.metrics.unwrap_or_default();
1191
1192        scene.push(SceneOp::Path {
1193            order,
1194            origin,
1195            path,
1196            paint,
1197        });
1198        metrics
1199    }
1200
1201    fn svg(
1202        &mut self,
1203        services: &mut dyn fret_core::UiServices,
1204        key: u64,
1205        svg: &SvgSource,
1206    ) -> fret_core::SvgId {
1207        match svg {
1208            SvgSource::Id(id) => *id,
1209            SvgSource::Static(bytes) => self.svg_bytes(services, key, SvgBytesKey::Static(bytes)),
1210            SvgSource::Bytes(bytes) => {
1211                self.svg_bytes(services, key, SvgBytesKey::Bytes(bytes.clone()))
1212            }
1213        }
1214    }
1215
1216    fn svg_bytes(
1217        &mut self,
1218        services: &mut dyn fret_core::UiServices,
1219        key: u64,
1220        bytes: SvgBytesKey,
1221    ) -> fret_core::SvgId {
1222        let cache_key = CanvasSvgCacheKey { key };
1223        let entry = self.svg_by_key.entry(cache_key).or_default();
1224        entry.last_used_frame = self.frame;
1225        let fingerprint = SvgFingerprint {
1226            bytes: bytes.fingerprint(),
1227        };
1228
1229        let needs_prepare = entry.svg.is_none() || entry.fingerprint.as_ref() != Some(&fingerprint);
1230        if needs_prepare {
1231            let svg_id = match &bytes {
1232                SvgBytesKey::Static(bytes) => services.svg().register_svg(bytes),
1233                SvgBytesKey::Bytes(bytes) => services.svg().register_svg(bytes),
1234            };
1235            if let Some(old) = entry.svg.replace(svg_id) {
1236                let _ = services.svg().unregister_svg(old);
1237            }
1238            entry.fingerprint = Some(fingerprint);
1239        }
1240
1241        entry.svg.unwrap_or_default()
1242    }
1243}
1244
1245#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1246struct CanvasTextCacheKey {
1247    key: u64,
1248    scale_bits: u32,
1249}
1250
1251#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1252struct TextStyleCacheKey {
1253    font: FontId,
1254    size_bits: u32,
1255    weight: FontWeight,
1256    slant: TextSlant,
1257    line_height_bits: Option<u32>,
1258    letter_spacing_em_bits: Option<u32>,
1259}
1260
1261impl TextStyleCacheKey {
1262    fn from_style(style: &TextStyle) -> Self {
1263        Self {
1264            font: style.font.clone(),
1265            size_bits: style.size.0.to_bits(),
1266            weight: style.weight,
1267            slant: style.slant,
1268            line_height_bits: style.line_height.map(|h| h.0.to_bits()),
1269            letter_spacing_em_bits: style.letter_spacing_em.map(f32::to_bits),
1270        }
1271    }
1272}
1273
1274#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1275struct CanvasTextConstraintsKey {
1276    max_width_bits: Option<u32>,
1277    wrap: TextWrap,
1278    overflow: TextOverflow,
1279}
1280
1281impl CanvasTextConstraintsKey {
1282    fn from_constraints(constraints: CanvasTextConstraints) -> Self {
1283        let max_width_bits = match constraints.wrap {
1284            // `TextWrap::None` does not change shaping results based on width unless we need to
1285            // materialize an overflow policy (ellipsis). Callers clip at higher levels.
1286            TextWrap::None if constraints.overflow != TextOverflow::Ellipsis => None,
1287            _ => constraints.max_width.map(|w| w.0.to_bits()),
1288        };
1289        Self {
1290            max_width_bits,
1291            wrap: constraints.wrap,
1292            overflow: constraints.overflow,
1293        }
1294    }
1295}
1296
1297#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1298enum SharedTextContentKey {
1299    Plain(Arc<str>),
1300}
1301
1302#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1303struct SharedTextFingerprintKey {
1304    content: SharedTextContentKey,
1305    style: TextStyleCacheKey,
1306    constraints: CanvasTextConstraintsKey,
1307    font_stack_key: u64,
1308    scale_bits: u32,
1309}
1310
1311#[derive(Debug, Clone, Copy)]
1312struct SharedTextEntry {
1313    blob: fret_core::TextBlobId,
1314    metrics: TextMetrics,
1315    last_used_frame: u64,
1316}
1317
1318#[derive(Debug, Clone, Copy)]
1319struct TextDraw {
1320    blob: fret_core::TextBlobId,
1321    metrics: TextMetrics,
1322}
1323
1324#[derive(Debug, Clone)]
1325enum HostedTextContent {
1326    Plain(Arc<str>),
1327    Rich(AttributedText),
1328}
1329
1330impl PartialEq for HostedTextContent {
1331    fn eq(&self, other: &Self) -> bool {
1332        match (self, other) {
1333            (Self::Plain(a), Self::Plain(b)) => Arc::ptr_eq(a, b) || a.as_ref() == b.as_ref(),
1334            (Self::Rich(a), Self::Rich(b)) => {
1335                (Arc::ptr_eq(&a.text, &b.text) && Arc::ptr_eq(&a.spans, &b.spans)) || a == b
1336            }
1337            _ => false,
1338        }
1339    }
1340}
1341
1342#[derive(Debug, Clone, PartialEq)]
1343struct HostedTextFingerprint {
1344    content: HostedTextContent,
1345    style: TextStyle,
1346    constraints: CanvasTextConstraints,
1347    font_stack_key: u64,
1348    scale_bits: u32,
1349}
1350
1351#[derive(Default)]
1352struct HostedTextEntry {
1353    blob: Option<fret_core::TextBlobId>,
1354    metrics: Option<TextMetrics>,
1355    fingerprint: Option<HostedTextFingerprint>,
1356    last_used_frame: u64,
1357}
1358
1359#[derive(Debug, Clone, Copy, PartialEq)]
1360struct HostedPathFingerprint {
1361    commands_hash: u64,
1362    commands_len: usize,
1363    style: PathStyle,
1364    scale_bits: u32,
1365}
1366
1367#[derive(Default)]
1368struct HostedPathEntry {
1369    path: Option<fret_core::PathId>,
1370    metrics: Option<PathMetrics>,
1371    fingerprint: Option<HostedPathFingerprint>,
1372    last_used_frame: u64,
1373}
1374
1375#[derive(Default)]
1376struct HostedSvgEntry {
1377    svg: Option<fret_core::SvgId>,
1378    fingerprint: Option<SvgFingerprint>,
1379    last_used_frame: u64,
1380}
1381
1382#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1383struct SvgFingerprint {
1384    bytes: SvgBytesFingerprint,
1385}
1386
1387#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1388struct CanvasSvgCacheKey {
1389    key: u64,
1390}
1391
1392#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1393struct CanvasPathCacheKey {
1394    key: u64,
1395    scale_bits: u32,
1396}
1397
1398fn normalize_scale_factor(scale_factor: f32) -> f32 {
1399    if !scale_factor.is_finite() || scale_factor <= 0.0 {
1400        1.0
1401    } else {
1402        scale_factor
1403    }
1404}
1405
1406fn hash_path_commands(commands: &[PathCommand]) -> u64 {
1407    let mut state = 0u64;
1408    for cmd in commands {
1409        match *cmd {
1410            PathCommand::MoveTo(p) => {
1411                state = mix_u64(state, 1);
1412                state = mix_point(state, p);
1413            }
1414            PathCommand::LineTo(p) => {
1415                state = mix_u64(state, 2);
1416                state = mix_point(state, p);
1417            }
1418            PathCommand::QuadTo { ctrl, to } => {
1419                state = mix_u64(state, 3);
1420                state = mix_point(state, ctrl);
1421                state = mix_point(state, to);
1422            }
1423            PathCommand::CubicTo { ctrl1, ctrl2, to } => {
1424                state = mix_u64(state, 4);
1425                state = mix_point(state, ctrl1);
1426                state = mix_point(state, ctrl2);
1427                state = mix_point(state, to);
1428            }
1429            PathCommand::Close => {
1430                state = mix_u64(state, 5);
1431            }
1432        }
1433    }
1434    state
1435}
1436
1437fn mix_u64(mut state: u64, value: u64) -> u64 {
1438    // Keep mixing deterministic and reasonably avalanche-y (not cryptographic).
1439    state ^= value.wrapping_add(0x9E37_79B9_7F4A_7C15);
1440    state = state.rotate_left(7);
1441    state = state.wrapping_mul(0xD6E8_FEB8_6659_FD93);
1442    state
1443}
1444
1445fn mix_f32(state: u64, value: f32) -> u64 {
1446    mix_u64(state, u64::from(value.to_bits()))
1447}
1448
1449fn mix_px(state: u64, value: fret_core::Px) -> u64 {
1450    mix_f32(state, value.0)
1451}
1452
1453fn mix_point(mut state: u64, p: fret_core::Point) -> u64 {
1454    state = mix_px(state, p.x);
1455    state = mix_px(state, p.y);
1456    state
1457}
1458
1459#[derive(Clone)]
1460enum SvgBytesKey {
1461    Static(&'static [u8]),
1462    Bytes(Arc<[u8]>),
1463}
1464
1465#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1466enum SvgBytesFingerprint {
1467    Static { ptr: usize, len: usize },
1468    Bytes { ptr: usize, len: usize },
1469}
1470
1471impl SvgBytesKey {
1472    fn fingerprint(&self) -> SvgBytesFingerprint {
1473        match self {
1474            SvgBytesKey::Static(bytes) => SvgBytesFingerprint::Static {
1475                ptr: bytes.as_ptr() as usize,
1476                len: bytes.len(),
1477            },
1478            SvgBytesKey::Bytes(bytes) => SvgBytesFingerprint::Bytes {
1479                ptr: bytes.as_ptr() as usize,
1480                len: bytes.len(),
1481            },
1482        }
1483    }
1484}
1485
1486#[derive(Default)]
1487struct Fnv1a64(u64);
1488
1489impl Hasher for Fnv1a64 {
1490    fn finish(&self) -> u64 {
1491        self.0
1492    }
1493
1494    fn write(&mut self, bytes: &[u8]) {
1495        let mut hash = if self.0 == 0 {
1496            0xcbf29ce484222325
1497        } else {
1498            self.0
1499        };
1500        for b in bytes {
1501            hash ^= *b as u64;
1502            hash = hash.wrapping_mul(0x100000001b3);
1503        }
1504        self.0 = hash;
1505    }
1506}