Skip to main content

fret_ui_kit/declarative/
windowed_rows_surface.rs

1//! Windowed row surface helpers.
2//!
3//! This module provides an ecosystem-level building block for “prepaint-windowed virtual
4//! surfaces” (ADR 0175) in the subset of cases where:
5//!
6//! - the surface can be modeled as a single scrollable region, and
7//! - per-row UI does not need to be represented as a fully composable declarative subtree.
8//!
9//! The core idea is to keep the element tree structurally stable (a `Scroll` + leaf `Canvas`)
10//! while drawing only the visible rows in the canvas paint handler. This avoids cache-root
11//! rerenders for scroll-only deltas and provides a reusable pattern for:
12//!
13//! - huge inspectors/log panes,
14//! - simple search/command result lists,
15//! - table “body” surfaces that handle hit-testing internally.
16//!
17//! If you need fully composable rows with per-item semantics/focus, prefer `VirtualList`-based
18//! helpers (e.g. `list_virtualized`) and keep the “window jump” cost low via overscan.
19
20use std::collections::HashMap;
21use std::hash::{Hash, Hasher};
22use std::panic::Location;
23
24use fret_core::{Point, Px, Rect, Size};
25use fret_runtime::FrameId;
26use fret_ui::action::{ActionCx, OnTimer, PointerDownCx, PointerMoveCx, UiPointerActionHost};
27use fret_ui::canvas::CanvasPainter;
28use fret_ui::element::{
29    AnyElement, CanvasProps, Length, PointerRegionProps, ScrollAxis, ScrollProps,
30};
31use fret_ui::scroll::ScrollHandle;
32use fret_ui::virtual_list::VirtualListMetrics;
33use fret_ui::{ElementContext, UiHost};
34use tracing::info;
35
36#[derive(Debug, Clone, Copy)]
37pub struct WindowedRowsPaintFrame {
38    pub viewport_height: Px,
39    pub offset_y: Px,
40    pub visible_start: usize,
41    pub visible_end: usize,
42}
43
44pub type OnWindowedRowsPaintFrame =
45    std::sync::Arc<dyn for<'p> Fn(&mut CanvasPainter<'p>, WindowedRowsPaintFrame) + 'static>;
46
47#[derive(Debug, Clone, Copy, PartialEq)]
48pub struct WindowedRowsSurfaceWindowTelemetry {
49    pub callsite_id: u64,
50    pub file: &'static str,
51    pub line: u32,
52    pub column: u32,
53
54    pub len: u64,
55    pub row_height: Px,
56    pub overscan: u64,
57    pub gap: Px,
58    pub scroll_margin: Px,
59
60    pub viewport_height: Px,
61    pub offset_y: Px,
62    pub content_height: Px,
63
64    pub visible_start: Option<u64>,
65    pub visible_end: Option<u64>,
66    pub visible_count: u64,
67}
68
69#[derive(Default)]
70pub struct WindowedRowsSurfaceDiagnosticsStore {
71    per_window: HashMap<fret_core::AppWindowId, WindowedRowsSurfaceDiagnosticsFrame>,
72}
73
74#[derive(Default)]
75struct WindowedRowsSurfaceDiagnosticsFrame {
76    frame_id: FrameId,
77    windows: Vec<WindowedRowsSurfaceWindowTelemetry>,
78}
79
80impl WindowedRowsSurfaceDiagnosticsStore {
81    pub fn begin_frame(&mut self, window: fret_core::AppWindowId, frame_id: FrameId) {
82        let w = self.per_window.entry(window).or_default();
83        if w.frame_id != frame_id {
84            w.frame_id = frame_id;
85            w.windows.clear();
86        }
87    }
88
89    pub fn record_window(
90        &mut self,
91        window: fret_core::AppWindowId,
92        frame_id: FrameId,
93        telemetry: WindowedRowsSurfaceWindowTelemetry,
94    ) {
95        self.begin_frame(window, frame_id);
96        let w = self.per_window.entry(window).or_default();
97        w.windows.push(telemetry);
98    }
99
100    #[allow(dead_code)]
101    pub fn windows_for_window(
102        &self,
103        window: fret_core::AppWindowId,
104        frame_id: FrameId,
105    ) -> Option<&[WindowedRowsSurfaceWindowTelemetry]> {
106        let w = self.per_window.get(&window)?;
107        (w.frame_id == frame_id).then_some(w.windows.as_slice())
108    }
109}
110
111/// Props for [`windowed_rows_surface`].
112///
113/// Note: this helper is intentionally fixed-row-height for v1. Variable-height virtualization
114/// needs a measurement pipeline and is tracked separately in the workstream TODOs.
115#[derive(Clone)]
116pub struct WindowedRowsSurfaceProps {
117    pub scroll: ScrollProps,
118    pub canvas: CanvasProps,
119    pub len: usize,
120    pub row_height: Px,
121    pub overscan: usize,
122    pub gap: Px,
123    pub scroll_margin: Px,
124    pub scroll_handle: ScrollHandle,
125    pub on_paint_frame: Option<OnWindowedRowsPaintFrame>,
126}
127
128impl Default for WindowedRowsSurfaceProps {
129    fn default() -> Self {
130        let scroll = ScrollProps {
131            axis: ScrollAxis::Y,
132            layout: fret_ui::element::LayoutStyle {
133                size: fret_ui::element::SizeStyle {
134                    width: Length::Fill,
135                    height: Length::Fill,
136                    ..Default::default()
137                },
138                ..Default::default()
139            },
140            // This surface's paint output depends on the scroll offset (visible window changes), so
141            // scroll-handle updates must be allowed to invalidate view-cache reuse.
142            windowed_paint: true,
143            ..Default::default()
144        };
145
146        let mut canvas = CanvasProps::default();
147        canvas.layout.size.width = Length::Fill;
148
149        Self {
150            scroll,
151            canvas,
152            len: 0,
153            row_height: Px(0.0),
154            overscan: 0,
155            gap: Px(0.0),
156            scroll_margin: Px(0.0),
157            scroll_handle: ScrollHandle::default(),
158            on_paint_frame: None,
159        }
160    }
161}
162
163/// Build a fixed-row-height scroll surface that paints only the visible row window.
164///
165/// `paint_row` is called for each visible row (including overscan).
166///
167/// Coordinate space: the provided `Rect` is expressed in the same "content space" that the
168/// scroll container uses for its child subtree. Concretely, it is anchored at the canvas node's
169/// layout bounds (not `0,0`).
170///
171/// This matches how other `CanvasPainter` consumers treat `Rect` coordinates (absolute in the
172/// current transform space) and avoids callers accidentally painting at the window origin.
173#[track_caller]
174pub fn windowed_rows_surface<H: UiHost>(
175    cx: &mut ElementContext<'_, H>,
176    props: WindowedRowsSurfaceProps,
177    paint_row: impl for<'p> Fn(&mut CanvasPainter<'p>, usize, Rect) + 'static,
178) -> AnyElement {
179    let caller = Location::caller();
180    let WindowedRowsSurfaceProps {
181        mut scroll,
182        mut canvas,
183        len,
184        row_height,
185        overscan,
186        gap,
187        scroll_margin,
188        scroll_handle,
189        on_paint_frame,
190    } = props;
191
192    let mut metrics = VirtualListMetrics::default();
193    metrics.ensure_with_mode(
194        fret_ui::element::VirtualListMeasureMode::Fixed,
195        len,
196        row_height,
197        gap,
198        scroll_margin,
199    );
200    let content_h = metrics.total_height();
201
202    let viewport_h = Px(scroll_handle.viewport_size().height.0.max(0.0));
203    let offset_y = Px(scroll_handle.offset().y.0.max(0.0));
204    let offset_y = metrics.clamp_offset(offset_y, viewport_h);
205    let visible = metrics.visible_range(offset_y, viewport_h, overscan);
206
207    let mut hasher = std::collections::hash_map::DefaultHasher::new();
208    caller.file().hash(&mut hasher);
209    caller.line().hash(&mut hasher);
210    caller.column().hash(&mut hasher);
211    let callsite_id = hasher.finish();
212
213    cx.app.with_global_mut_untracked(
214        WindowedRowsSurfaceDiagnosticsStore::default,
215        |store, _app| {
216            let (visible_start, visible_end, visible_count) = visible
217                .map(|visible| {
218                    let count = visible.count;
219                    if count == 0 {
220                        return (None, None, 0u64);
221                    }
222                    let start = visible.start_index.saturating_sub(visible.overscan);
223                    let end = (visible.end_index + visible.overscan).min(count.saturating_sub(1));
224                    (
225                        Some(start as u64),
226                        Some(end as u64),
227                        (end.saturating_sub(start) as u64).saturating_add(1),
228                    )
229                })
230                .unwrap_or((None, None, 0));
231            store.record_window(
232                cx.window,
233                cx.frame_id,
234                WindowedRowsSurfaceWindowTelemetry {
235                    callsite_id,
236                    file: caller.file(),
237                    line: caller.line(),
238                    column: caller.column(),
239                    len: len as u64,
240                    row_height,
241                    overscan: overscan as u64,
242                    gap,
243                    scroll_margin,
244                    viewport_height: viewport_h,
245                    offset_y,
246                    content_height: content_h,
247                    visible_start,
248                    visible_end,
249                    visible_count,
250                },
251            );
252        },
253    );
254
255    scroll.axis = ScrollAxis::Y;
256    scroll.scroll_handle = Some(scroll_handle.clone());
257    // This surface's paint output depends on the scroll offset (visible window changes), so
258    // scroll-handle updates must be allowed to invalidate view-cache reuse.
259    scroll.windowed_paint = true;
260
261    canvas.layout.size.width = Length::Fill;
262    canvas.layout.size.height = Length::Px(content_h);
263
264    cx.scroll(scroll, move |cx| {
265        let scroll_handle = scroll_handle.clone();
266        let metrics = metrics.clone();
267        let paint_row = std::sync::Arc::new(paint_row);
268        let on_paint_frame = on_paint_frame.clone();
269
270        vec![cx.canvas(canvas, move |painter| {
271            let viewport_h = Px(scroll_handle.viewport_size().height.0.max(0.0));
272            let offset_y = Px(scroll_handle.offset().y.0.max(0.0));
273            let offset_y = metrics.clamp_offset(offset_y, viewport_h);
274            let Some(visible) = metrics.visible_range(offset_y, viewport_h, overscan) else {
275                return;
276            };
277
278            let bounds = painter.bounds();
279            let origin_x = bounds.origin.x;
280            let origin_y = bounds.origin.y;
281            let width = Px(bounds.size.width.0.max(0.0));
282            let count = visible.count;
283            if count == 0 {
284                return;
285            }
286
287            let start = visible.start_index.saturating_sub(visible.overscan);
288            let end = (visible.end_index + visible.overscan).min(count.saturating_sub(1));
289
290            if let Some(on_paint_frame) = &on_paint_frame {
291                on_paint_frame(
292                    painter,
293                    WindowedRowsPaintFrame {
294                        viewport_height: viewport_h,
295                        offset_y,
296                        visible_start: start,
297                        visible_end: end,
298                    },
299                );
300            }
301
302            for index in start..=end {
303                let y = metrics.offset_for_index(index);
304                let h = metrics.height_at(index);
305                let rect = Rect::new(
306                    Point::new(origin_x, Px(origin_y.0 + y.0)),
307                    Size::new(width, h),
308                );
309                paint_row(painter, index, rect);
310            }
311        })]
312    })
313}
314
315pub type OnWindowedRowsPointerDown = std::sync::Arc<
316    dyn Fn(&mut dyn UiPointerActionHost, ActionCx, usize, PointerDownCx) -> bool + 'static,
317>;
318
319pub type OnWindowedRowsPointerMove = std::sync::Arc<
320    dyn Fn(&mut dyn UiPointerActionHost, ActionCx, Option<usize>, PointerMoveCx) -> bool + 'static,
321>;
322
323pub type OnWindowedRowsPointerUp = std::sync::Arc<
324    dyn Fn(
325            &mut dyn UiPointerActionHost,
326            ActionCx,
327            Option<usize>,
328            fret_ui::action::PointerUpCx,
329        ) -> bool
330        + 'static,
331>;
332
333pub type OnWindowedRowsPointerCancel = std::sync::Arc<
334    dyn Fn(&mut dyn UiPointerActionHost, ActionCx, fret_ui::action::PointerCancelCx) -> bool
335        + 'static,
336>;
337
338#[derive(Default, Clone)]
339pub struct WindowedRowsSurfacePointerHandlers {
340    pub on_pointer_down: Option<OnWindowedRowsPointerDown>,
341    pub on_pointer_move: Option<OnWindowedRowsPointerMove>,
342    pub on_pointer_up: Option<OnWindowedRowsPointerUp>,
343    pub on_pointer_cancel: Option<OnWindowedRowsPointerCancel>,
344    pub on_timer: Option<OnTimer>,
345}
346
347fn row_index_for_pointer(
348    metrics: &VirtualListMetrics,
349    scroll_handle: &ScrollHandle,
350    bounds: Rect,
351    position: Point,
352    len: usize,
353) -> Option<usize> {
354    if len == 0 {
355        return None;
356    }
357
358    let viewport_h = Px(scroll_handle.viewport_size().height.0.max(0.0));
359    if viewport_h.0 <= 0.0 {
360        return None;
361    }
362
363    let offset_y = Px(scroll_handle.offset().y.0.max(0.0));
364    let offset_y = metrics.clamp_offset(offset_y, viewport_h);
365
366    let local_y = Px(position.y.0 - bounds.origin.y.0);
367
368    if std::env::var_os("FRET_WINDOWED_ROWS_POINTER_DEBUG")
369        .is_some_and(|v| !v.is_empty() && v != "0")
370    {
371        info!(
372            "windowed_rows_pointer bounds_y={} pos_y={} local_y={} offset_y={} viewport_h={}",
373            bounds.origin.y.0, position.y.0, local_y.0, offset_y.0, viewport_h.0
374        );
375    }
376
377    // Pointer event positions are mapped through the UI tree's transforms. Scroll containers apply
378    // their offset via `children_render_transform`, so descendants typically receive positions in
379    // stable "content space" already.
380    //
381    // For robustness (and to avoid double-counting the scroll offset), compute candidate indices
382    // for both:
383    // - viewport-space events: content_y = offset + local
384    // - content-space events:  content_y = local
385    let idx_viewport = metrics.index_for_offset(Px(offset_y.0 + local_y.0));
386    let idx_content = metrics.index_for_offset(local_y);
387
388    let idx = if let Some(visible) = metrics.visible_range(offset_y, viewport_h, 0) {
389        let in_visible = |idx: usize| idx >= visible.start_index && idx <= visible.end_index;
390        match (in_visible(idx_viewport), in_visible(idx_content)) {
391            (true, false) => idx_viewport,
392            (false, true) => idx_content,
393            // Prefer content-space indices by default (matches runtime event mapping).
394            _ => idx_content,
395        }
396    } else {
397        idx_content
398    };
399
400    Some(idx.min(len.saturating_sub(1)))
401}
402
403/// Like [`windowed_rows_surface`], but wraps the canvas in a `PointerRegion` that performs row
404/// hit-testing and forwards pointer events to the provided handlers.
405#[track_caller]
406pub fn windowed_rows_surface_with_pointer_region<H: UiHost>(
407    cx: &mut ElementContext<'_, H>,
408    props: WindowedRowsSurfaceProps,
409    pointer: PointerRegionProps,
410    handlers: WindowedRowsSurfacePointerHandlers,
411    content_semantics: Option<fret_ui::element::SemanticsProps>,
412    paint_row: impl for<'p> Fn(&mut CanvasPainter<'p>, usize, Rect) + 'static,
413) -> AnyElement {
414    let caller = Location::caller();
415    let WindowedRowsSurfacePointerHandlers {
416        on_pointer_down,
417        on_pointer_move,
418        on_pointer_up,
419        on_pointer_cancel,
420        on_timer,
421    } = handlers;
422
423    let WindowedRowsSurfaceProps {
424        mut scroll,
425        mut canvas,
426        len,
427        row_height,
428        overscan,
429        gap,
430        scroll_margin,
431        scroll_handle,
432        on_paint_frame,
433    } = props;
434
435    let mut metrics = VirtualListMetrics::default();
436    metrics.ensure_with_mode(
437        fret_ui::element::VirtualListMeasureMode::Fixed,
438        len,
439        row_height,
440        gap,
441        scroll_margin,
442    );
443    let content_h = metrics.total_height();
444
445    let viewport_h = Px(scroll_handle.viewport_size().height.0.max(0.0));
446    let offset_y = Px(scroll_handle.offset().y.0.max(0.0));
447    let offset_y = metrics.clamp_offset(offset_y, viewport_h);
448    let visible = metrics.visible_range(offset_y, viewport_h, overscan);
449
450    let mut hasher = std::collections::hash_map::DefaultHasher::new();
451    caller.file().hash(&mut hasher);
452    caller.line().hash(&mut hasher);
453    caller.column().hash(&mut hasher);
454    let callsite_id = hasher.finish();
455
456    cx.app.with_global_mut_untracked(
457        WindowedRowsSurfaceDiagnosticsStore::default,
458        |store, _app| {
459            let (visible_start, visible_end, visible_count) = visible
460                .map(|visible| {
461                    let count = visible.count;
462                    if count == 0 {
463                        return (None, None, 0u64);
464                    }
465                    let start = visible.start_index.saturating_sub(visible.overscan);
466                    let end = (visible.end_index + visible.overscan).min(count.saturating_sub(1));
467                    (
468                        Some(start as u64),
469                        Some(end as u64),
470                        (end.saturating_sub(start) as u64).saturating_add(1),
471                    )
472                })
473                .unwrap_or((None, None, 0));
474            store.record_window(
475                cx.window,
476                cx.frame_id,
477                WindowedRowsSurfaceWindowTelemetry {
478                    callsite_id,
479                    file: caller.file(),
480                    line: caller.line(),
481                    column: caller.column(),
482                    len: len as u64,
483                    row_height,
484                    overscan: overscan as u64,
485                    gap,
486                    scroll_margin,
487                    viewport_height: viewport_h,
488                    offset_y,
489                    content_height: content_h,
490                    visible_start,
491                    visible_end,
492                    visible_count,
493                },
494            );
495        },
496    );
497
498    scroll.axis = ScrollAxis::Y;
499    scroll.scroll_handle = Some(scroll_handle.clone());
500    // This surface's paint output depends on the scroll offset (visible window changes), so
501    // scroll-handle updates must be allowed to invalidate view-cache reuse.
502    scroll.windowed_paint = true;
503
504    canvas.layout.size.width = Length::Fill;
505    canvas.layout.size.height = Length::Px(content_h);
506
507    cx.scroll(scroll, move |cx| {
508        let scroll_handle = scroll_handle.clone();
509        let metrics = metrics.clone();
510        let paint_row = std::sync::Arc::new(paint_row);
511        let on_pointer_down = on_pointer_down.clone();
512        let on_pointer_move = on_pointer_move.clone();
513        let on_pointer_up = on_pointer_up.clone();
514        let on_pointer_cancel = on_pointer_cancel.clone();
515        let content_semantics = content_semantics.clone();
516        let on_paint_frame = on_paint_frame.clone();
517
518        vec![cx.pointer_region(pointer, move |cx| {
519            if let Some(on_timer) = on_timer.clone() {
520                cx.timer_on_timer_for(cx.root_id(), on_timer);
521            }
522
523            if let Some(on_pointer_down) = on_pointer_down.clone() {
524                let scroll_handle = scroll_handle.clone();
525                let metrics = metrics.clone();
526                cx.pointer_region_on_pointer_down(std::sync::Arc::new(
527                    move |host, action_cx, down| {
528                        let bounds = host.bounds();
529                        let idx = row_index_for_pointer(
530                            &metrics,
531                            &scroll_handle,
532                            bounds,
533                            down.position,
534                            len,
535                        );
536                        let Some(idx) = idx else {
537                            return false;
538                        };
539                        on_pointer_down(host, action_cx, idx, down)
540                    },
541                ));
542            }
543
544            if let Some(on_pointer_move) = on_pointer_move.clone() {
545                let scroll_handle = scroll_handle.clone();
546                let metrics = metrics.clone();
547                cx.pointer_region_on_pointer_move(std::sync::Arc::new(
548                    move |host, action_cx, mv| {
549                        let bounds = host.bounds();
550                        let idx = row_index_for_pointer(
551                            &metrics,
552                            &scroll_handle,
553                            bounds,
554                            mv.position,
555                            len,
556                        );
557                        on_pointer_move(host, action_cx, idx, mv)
558                    },
559                ));
560            }
561
562            if let Some(on_pointer_up) = on_pointer_up.clone() {
563                let scroll_handle = scroll_handle.clone();
564                let metrics = metrics.clone();
565                cx.pointer_region_on_pointer_up(std::sync::Arc::new(move |host, action_cx, up| {
566                    let bounds = host.bounds();
567                    let idx =
568                        row_index_for_pointer(&metrics, &scroll_handle, bounds, up.position, len);
569                    on_pointer_up(host, action_cx, idx, up)
570                }));
571            }
572
573            if let Some(on_pointer_cancel) = on_pointer_cancel.clone() {
574                cx.pointer_region_on_pointer_cancel(on_pointer_cancel);
575            }
576
577            let canvas_children = vec![cx.canvas(canvas, move |painter| {
578                let viewport_h = Px(scroll_handle.viewport_size().height.0.max(0.0));
579                let offset_y = Px(scroll_handle.offset().y.0.max(0.0));
580                let offset_y = metrics.clamp_offset(offset_y, viewport_h);
581                let Some(visible) = metrics.visible_range(offset_y, viewport_h, overscan) else {
582                    return;
583                };
584
585                let bounds = painter.bounds();
586                let origin_x = bounds.origin.x;
587                let origin_y = bounds.origin.y;
588                let width = Px(bounds.size.width.0.max(0.0));
589                let count = visible.count;
590                if count == 0 {
591                    return;
592                }
593
594                let start = visible.start_index.saturating_sub(visible.overscan);
595                let end = (visible.end_index + visible.overscan).min(count.saturating_sub(1));
596
597                if let Some(on_paint_frame) = &on_paint_frame {
598                    on_paint_frame(
599                        painter,
600                        WindowedRowsPaintFrame {
601                            viewport_height: viewport_h,
602                            offset_y,
603                            visible_start: start,
604                            visible_end: end,
605                        },
606                    );
607                }
608
609                for index in start..=end {
610                    let y = metrics.offset_for_index(index);
611                    let h = metrics.height_at(index);
612                    let rect = Rect::new(
613                        Point::new(origin_x, Px(origin_y.0 + y.0)),
614                        Size::new(width, h),
615                    );
616                    paint_row(painter, index, rect);
617                }
618            })];
619
620            if let Some(semantics) = content_semantics.clone() {
621                vec![cx.semantics(semantics, |_cx| canvas_children)]
622            } else {
623                canvas_children
624            }
625        })]
626    })
627}
628
629#[cfg(test)]
630mod tests {
631    use super::*;
632
633    #[test]
634    fn default_props_enable_windowed_paint() {
635        let props = WindowedRowsSurfaceProps::default();
636        assert_eq!(props.scroll.axis, ScrollAxis::Y);
637        assert!(props.scroll.windowed_paint);
638    }
639}