Skip to main content

rosace_widgets/tree/
list_view.rs

1use std::sync::Arc;
2
3use rosace_core::types::{Point, Rect, Size};
4use rosace_layout::Constraints;
5use rosace_render::{Color, DrawCommand};
6use super::{Widget, LayoutCtx, PaintCtx, BoxedWidget, avail_w, avail_h, intersect_rect, ScrollAxes};
7
8/// A virtualized vertical list (RecyclerView / FlatList model).
9///
10/// Rows are built ON DEMAND by the `builder` closure — for a 1,000-item list
11/// only the rows intersecting the viewport are built, laid out, and painted
12/// each frame (typically 10–20). Memory and paint cost are O(visible), not
13/// O(count).
14///
15/// v1 uses a fixed `item_extent` (like Flutter's `itemExtent` /
16/// RecyclerView's fixed row height): the scroll geometry is pure arithmetic,
17/// no measurement of off-screen rows ever happens. Variable-extent rows are
18/// a future extension.
19///
20/// Prefer this over [`super::ScrollView`] for long lists — `ScrollView`'s
21/// GPU-composited layer path (D090) composites its ENTIRE content as one
22/// texture, capped at [`super::MAX_TL_DIM`] (4096 logical px); content past
23/// that silently falls back to a plain (still correct, non-GPU) paint.
24/// `ListView` never materializes off-screen content at all, so it has no
25/// such limit regardless of `count`.
26///
27/// ```rust,ignore
28/// let scroll = ctx.state(0.0f32);
29/// ListView::builder(1_000, 48.0, scroll, |i| {
30///     Box::new(ListTile::new(format!("Row {i}")))
31/// })
32/// ```
33pub struct ListView {
34    count: usize,
35    item_extent: f32,
36    builder: Arc<dyn Fn(usize) -> BoxedWidget + Send + Sync>,
37    pub show_scrollbar: bool,
38    pub scrollbar_color: Color,
39}
40
41impl ListView {
42    /// A virtualized list of `count` rows, each `item_extent` logical pixels
43    /// tall. Scroll position is implicit per-node state (D101).
44    pub fn builder(
45        count: usize,
46        item_extent: f32,
47        builder: impl Fn(usize) -> BoxedWidget + Send + Sync + 'static,
48    ) -> Self {
49        Self {
50            count,
51            item_extent: item_extent.max(1.0),
52            builder: Arc::new(builder),
53            show_scrollbar: true,
54            scrollbar_color: Color::rgb(50, 55, 85),
55        }
56    }
57
58    pub fn no_scrollbar(mut self) -> Self { self.show_scrollbar = false; self }
59}
60
61impl Widget for ListView {
62    fn layout(&self, ctx: &LayoutCtx) -> Size {
63        // The list is a viewport: it fills the available space; content
64        // height is virtual (count × extent) and never materialized.
65        let c = ctx.constraints;
66        let h = avail_h(c);
67        Size {
68            width: avail_w(c),
69            height: if h.is_finite() { h } else { self.count as f32 * self.item_extent },
70        }
71    }
72
73    fn paint(&self, ctx: &mut PaintCtx) {
74        let vp = ctx.rect;
75        let ctrl = ctx.scroll_controller();
76        let content_h = self.count as f32 * self.item_extent;
77        let max_scroll = (content_h - vp.size.height).max(0.0);
78        let scroll = ctrl.offset.get()[1].clamp(0.0, max_scroll);
79        // Publish extents (guarded) so programmatic scroll_to can clamp.
80        let vp_s = [vp.size.width, vp.size.height];
81        if ctrl.viewport_size.get() != vp_s { ctrl.viewport_size.set(vp_s); }
82        let cs = [vp.size.width, content_h];
83        if ctrl.content_size.get() != cs { ctrl.content_size.set(cs); }
84
85        ctx.record(DrawCommand::PushClip { rect: vp });
86        let effective_clip = ctx.clip_rect
87            .and_then(|parent| intersect_rect(parent, vp))
88            .unwrap_or(vp);
89
90        // The visible window — the only rows that ever exist this frame.
91        let first = (scroll / self.item_extent).floor().max(0.0) as usize;
92        let last = (((scroll + vp.size.height) / self.item_extent).ceil() as usize)
93            .min(self.count);
94
95        for i in first..last {
96            let row = (self.builder)(i);
97            let row_rect = Rect {
98                origin: Point {
99                    x: vp.origin.x,
100                    y: vp.origin.y + i as f32 * self.item_extent - scroll,
101                },
102                size: Size { width: vp.size.width, height: self.item_extent },
103            };
104            let lctx = ctx.layout_ctx(Constraints::tight(vp.size.width, self.item_extent));
105            let _ = row.layout(&lctx);
106            let mut row_ctx = ctx.child(row_rect);
107            row_ctx.clip_rect = Some(effective_clip);
108            row.paint(&mut row_ctx);
109        }
110
111        ctx.record(DrawCommand::PopClip);
112
113        // Wheel/trackpad drives the node controller (vertical only).
114        let wheel = ctrl.clone();
115        ctx.register_scroll_target(vp, ScrollAxes::Y, Arc::new(move |_dx, dy| {
116            wheel.scroll_by(0.0, -dy);
117        }));
118
119        if self.show_scrollbar && content_h > vp.size.height {
120            let ratio = vp.size.height / content_h;
121            let bar_h = (vp.size.height * ratio).max(16.0);
122            let bar_y = vp.origin.y + (scroll / content_h) * vp.size.height;
123            ctx.fill_rect(Rect {
124                origin: Point { x: vp.origin.x + vp.size.width - 4.0, y: bar_y },
125                size: Size { width: 3.0, height: bar_h },
126            }, self.scrollbar_color);
127        }
128    }
129}