1use std::cell::{Cell, RefCell};
4use std::collections::HashMap;
5use std::fmt;
6use std::ops::Range;
7use std::rc::Rc;
8
9use ravenrs_core::{Border, Color, CursorIcon, Key, Point, Role, Size};
10use ravenrs_layout::{Align, Axis, LayoutStyle};
11use ravenrs_reactive::{Binding, State};
12use ravenrs_style::Semantic;
13
14use crate::context::{BuildCx, NodeId, Paint, QuadPaint, Semantics};
15use crate::view::{AnyView, View};
16
17const OVERSCAN: usize = 3;
19const LINE_STEP: f32 = 40.0;
21const SAME_OFFSET: f32 = 0.25;
23
24#[derive(Clone)]
26pub(crate) enum Sizing {
27 Measured(f32),
29 Fixed(f32),
31 Known(Rc<dyn Fn(usize) -> f32>),
33}
34
35#[derive(Clone, Copy, Debug, PartialEq)]
37pub(crate) enum ScrollCommand {
38 ToIndex(usize),
40 Reveal(usize),
42 By(f32),
44}
45
46#[derive(Clone, Copy, Debug, Default)]
48pub(crate) struct ListProbe {
49 pub offset: f32,
51 pub view: f32,
53 pub total: f32,
55 pub current: usize,
57}
58
59#[derive(Default)]
62struct Track {
63 keys: Vec<u64>,
64 sizes: Vec<f32>,
65 tops: Vec<f32>,
67 measured: HashMap<u64, f32>,
68 built: Vec<(u64, usize)>,
70 padding: f32,
72 anchor: Option<(u64, usize, f32)>,
75 left_at: Option<f32>,
77 at_end: bool,
78 revealing: Option<usize>,
80 visible: Option<Range<usize>>,
81}
82
83#[cfg_attr(feature = "live", ravenrs_live::expose)]
84impl Track {
85 fn total(&self) -> f32 {
86 self.tops.last().map_or(0.0, |end| end + self.padding)
87 }
88
89 fn max_offset(&self, view: f32) -> f32 {
90 (self.total() - view).max(0.0)
91 }
92
93 fn sum(&mut self) {
94 self.tops.clear();
95 self.tops.reserve(self.sizes.len() + 1);
96 let mut at = self.padding;
97 self.tops.push(at);
98 for size in &self.sizes {
99 at += size;
100 self.tops.push(at);
101 }
102 }
103
104 fn sync(&mut self, list: &VirtualList) {
106 let count = list.count;
107 let anchor_size = self.anchor.and_then(|(_, index, _)| self.sizes.get(index).copied());
108 let mut stale = self.keys.len() != count;
109 if !stale && let Some(key) = &list.key {
110 stale = (0..count).any(|i| self.keys[i] != key(i));
111 }
112 let mut changed = stale || self.padding != list.padding;
113 self.padding = list.padding;
114 if stale {
115 self.keys.clear();
116 self.keys.extend((0..count).map(|i| list.key.as_ref().map_or(i as u64, |key| key(i))));
117 let default = list.default_size();
118 self.sizes.clear();
119 for key in &self.keys {
120 self.sizes.push(self.measured.get(key).copied().unwrap_or(default));
121 }
122 }
123 match &list.sizing {
124 Sizing::Measured(_) => {}
125 Sizing::Fixed(size) => {
126 let size = size + list.spacing;
127 if self.sizes.first().is_some_and(|s| (s - size).abs() > f32::EPSILON) {
128 self.sizes.fill(size);
129 changed = true;
130 }
131 }
132 Sizing::Known(size_of) => {
133 for i in 0..count {
134 let size = size_of(i) + list.spacing;
135 if (self.sizes[i] - size).abs() > f32::EPSILON {
136 self.sizes[i] = size;
137 changed = true;
138 }
139 }
140 if let (Some(before), Some((key, index, delta))) = (anchor_size, self.anchor)
142 && let Some(&now) = self.sizes.get(index)
143 && before > 0.0
144 {
145 self.anchor = Some((key, index, delta * now / before));
146 }
147 }
148 }
149 if changed || self.tops.len() != count + 1 {
150 self.sum();
151 }
152 }
153
154 fn take_measurements(&mut self, cx: &BuildCx<'_>, list: &VirtualList) {
155 if !matches!(list.sizing, Sizing::Measured(_)) {
156 return;
157 }
158 let mut changed = false;
159 for &(key, index) in &self.built {
160 let Some(frame) = cx.keyed_child_frame(key) else { continue };
161 let size = list.along(frame);
162 if self.measured.get(&key).is_none_or(|known| (known - size).abs() > 0.1) {
163 self.measured.insert(key, size);
164 if self.keys.get(index) == Some(&key) {
166 self.sizes[index] = size;
167 }
168 changed = true;
169 }
170 }
171 if changed {
172 self.sum();
173 }
174 }
175
176 fn index_at(&self, offset: f32) -> usize {
178 let last = self.keys.len().saturating_sub(1);
179 self.tops.partition_point(|top| *top <= offset).saturating_sub(1).min(last)
180 }
181
182 fn set_anchor(&mut self, offset: f32) {
183 if self.keys.is_empty() {
184 self.anchor = None;
185 return;
186 }
187 let index = self.index_at(offset);
188 self.anchor = Some((self.keys[index], index, offset - self.tops[index]));
189 }
190
191 fn anchored(&mut self) -> Option<f32> {
193 let (key, index, delta) = self.anchor?;
194 let index = if self.keys.get(index) == Some(&key) {
195 index
196 } else {
197 self.keys.iter().position(|k| *k == key)?
198 };
199 self.anchor = Some((key, index, delta));
200 Some(self.tops[index] + delta)
201 }
202}
203
204#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
226pub struct VirtualList {
227 pub(crate) count: usize,
228 pub(crate) item: Rc<dyn Fn(usize) -> AnyView>,
229 pub(crate) key: Option<Rc<dyn Fn(usize) -> u64>>,
230 pub(crate) sizing: Sizing,
231 pub(crate) spacing: f32,
232 follow_end: bool,
233 scroll_to: Option<Binding<Option<usize>>>,
234 selection: Option<Binding<Option<usize>>>,
235 on_activate: Option<Rc<dyn Fn(usize)>>,
236 on_visible: Option<Rc<dyn Fn(Range<usize>)>>,
237 row_name: Option<Rc<dyn Fn(usize) -> String>>,
238 label: Option<String>,
239 length: Option<f32>,
240 horizontal: bool,
241 bare: bool,
243 command: Option<State<Option<ScrollCommand>>>,
244 probe: Option<Rc<Cell<ListProbe>>>,
245 cross_content: Option<f32>,
247 row_align: Align,
248 padding: f32,
249}
250
251impl fmt::Debug for VirtualList {
252 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
253 f.debug_struct("VirtualList").field("count", &self.count).finish_non_exhaustive()
254 }
255}
256
257#[cfg_attr(feature = "live", ravenrs_live::expose)]
258impl VirtualList {
259 pub fn new<V: View + 'static>(count: usize, item: impl Fn(usize) -> V + 'static) -> Self {
261 Self {
262 count,
263 item: Rc::new(move |index| Box::new(item(index))),
264 key: None,
265 sizing: Sizing::Measured(40.0),
266 spacing: 0.0,
267 follow_end: false,
268 scroll_to: None,
269 selection: None,
270 on_activate: None,
271 on_visible: None,
272 row_name: None,
273 label: None,
274 length: None,
275 horizontal: false,
276 bare: false,
277 command: None,
278 probe: None,
279 cross_content: None,
280 row_align: Align::Stretch,
281 padding: 0.0,
282 }
283 }
284
285 pub fn key(mut self, key: impl Fn(usize) -> u64 + 'static) -> Self {
289 self.key = Some(Rc::new(key));
290 self
291 }
292
293 pub fn estimated_height(mut self, height: f32) -> Self {
295 self.sizing = Sizing::Measured(height.max(1.0));
296 self
297 }
298
299 pub fn row_height(mut self, height: f32) -> Self {
301 self.sizing = Sizing::Fixed(height.max(1.0));
302 self
303 }
304
305 pub fn row_heights(mut self, height: impl Fn(usize) -> f32 + 'static) -> Self {
308 self.sizing = Sizing::Known(Rc::new(height));
309 self
310 }
311
312 pub fn spacing(mut self, spacing: f32) -> Self {
314 self.spacing = spacing.max(0.0);
315 self
316 }
317
318 pub fn follow_end(mut self, follow: bool) -> Self {
321 self.follow_end = follow;
322 self
323 }
324
325 pub fn scroll_to(mut self, index: Binding<Option<usize>>) -> Self {
327 self.scroll_to = Some(index);
328 self
329 }
330
331 pub fn selection(mut self, selected: Binding<Option<usize>>) -> Self {
333 self.selection = Some(selected);
334 self
335 }
336
337 pub fn on_activate(mut self, handler: impl Fn(usize) + 'static) -> Self {
339 self.on_activate = Some(Rc::new(handler));
340 self
341 }
342
343 pub fn on_visible(mut self, handler: impl Fn(Range<usize>) + 'static) -> Self {
345 self.on_visible = Some(Rc::new(handler));
346 self
347 }
348
349 pub fn row_name(mut self, name: impl Fn(usize) -> String + 'static) -> Self {
351 self.row_name = Some(Rc::new(name));
352 self
353 }
354
355 pub fn label(mut self, label: impl Into<String>) -> Self {
357 self.label = Some(label.into());
358 self
359 }
360
361 pub fn height(mut self, height: f32) -> Self {
363 self.length = Some(height);
364 self
365 }
366
367 pub fn horizontal(mut self) -> Self {
369 self.horizontal = true;
370 self
371 }
372
373 pub(crate) fn bare(mut self) -> Self {
374 self.bare = true;
375 self
376 }
377
378 pub(crate) fn commands(mut self, command: State<Option<ScrollCommand>>) -> Self {
379 self.command = Some(command);
380 self
381 }
382
383 pub(crate) fn probe(mut self, probe: Rc<Cell<ListProbe>>) -> Self {
384 self.probe = Some(probe);
385 self
386 }
387
388 pub(crate) fn scroll_across(mut self, content: f32) -> Self {
389 self.cross_content = Some(content);
390 self
391 }
392
393 pub(crate) fn row_align(mut self, align: Align) -> Self {
394 self.row_align = align;
395 self
396 }
397
398 pub(crate) fn padding(mut self, padding: f32) -> Self {
400 self.padding = padding.max(0.0);
401 self
402 }
403
404 fn default_size(&self) -> f32 {
405 match &self.sizing {
406 Sizing::Measured(estimate) => estimate + self.spacing,
407 Sizing::Fixed(size) => size + self.spacing,
408 Sizing::Known(_) => self.spacing,
409 }
410 }
411
412 fn along(&self, size: Size) -> f32 {
413 if self.horizontal { size.width } else { size.height }
414 }
415
416 fn axis_point(&self, along: f32, across: f32) -> Point {
417 if self.horizontal { Point::new(along, across) } else { Point::new(across, along) }
418 }
419
420 fn interactive(&self) -> bool {
421 self.selection.is_some() || self.on_activate.is_some()
422 }
423
424 fn spacer(&self, cx: &mut BuildCx<'_>, length: f32) -> NodeId {
425 let style = if self.horizontal {
426 LayoutStyle { width: Some(length), ..LayoutStyle::leaf() }
427 } else {
428 LayoutStyle { height: Some(length), ..LayoutStyle::leaf() }
429 };
430 cx.container(style, &[], Paint::None)
431 }
432
433 fn apply(&self, track: &mut Track, command: ScrollCommand, offset: f32, view: f32) -> f32 {
434 let index = |i: usize| i.min(self.count.saturating_sub(1));
435 let target = match command {
436 ScrollCommand::ToIndex(i) => {
437 let i = index(i);
438 let (key, top) = (track.keys.get(i).copied(), track.tops.get(i).copied().unwrap_or(0.0));
439 track.at_end = false;
440 let target = (top - self.padding).clamp(0.0, track.max_offset(view));
441 track.anchor = key.map(|key| (key, i, target - top));
442 return target;
443 }
444 ScrollCommand::Reveal(i) => {
445 let i = index(i);
446 let (Some(top), Some(bottom)) = (track.tops.get(i), track.tops.get(i + 1)) else {
447 return offset;
448 };
449 let bottom = bottom - self.spacing + self.padding;
450 if *top < offset {
451 top - self.padding
452 } else if bottom > offset + view {
453 (bottom - view).min(*top)
454 } else {
455 offset
456 }
457 }
458 ScrollCommand::By(by) => offset + by,
459 };
460 let target = target.clamp(0.0, track.max_offset(view));
461 track.set_anchor(target);
462 track.at_end = self.follow_end && target >= track.max_offset(view) - 1.0;
463 target
464 }
465}
466
467struct Row<'a> {
469 list: &'a VirtualList,
470 index: usize,
471 size: f32,
472 selected: bool,
473 active: bool,
474 pick: Option<Rc<dyn Fn(usize)>>,
475}
476
477impl View for Row<'_> {
478 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
479 let list = self.list;
480 let theme = *cx.theme();
481 let item = (list.item)(self.index);
482 let child = cx.child(&*item);
483 let fixed = !matches!(list.sizing, Sizing::Measured(_));
484 let gap = ravenrs_core::Insets {
485 bottom: if list.horizontal { 0.0 } else { list.spacing },
486 right: if list.horizontal { list.spacing } else { 0.0 },
487 ..ravenrs_core::Insets::default()
488 };
489 let accent = theme.color(Semantic::Accent);
490 let paint = if self.selected || self.active {
491 let fill = if self.selected { accent.with_alpha(0.14) } else { Color::TRANSPARENT };
492 let border = self.active.then_some(Border { width: 1.5, color: accent });
493 Paint::Quad(QuadPaint { corner_radius: theme.radii.sm, border, ..QuadPaint::new(fill) })
494 } else {
495 Paint::None
496 };
497 let node = cx.container(
498 LayoutStyle {
499 width: (fixed && list.horizontal).then_some(self.size),
500 height: (fixed && !list.horizontal).then_some(self.size),
501 min_width: Some(0.0),
502 padding: gap,
503 overflow_hidden: fixed,
504 align: list.row_align,
505 align_self: Some(Align::Stretch),
506 ..LayoutStyle::stack(Axis::Vertical)
507 },
508 &[child],
509 paint,
510 );
511 if !fixed {
512 cx.report_frame(node);
513 }
514 if list.bare {
515 return node;
516 }
517 let mut semantics = Semantics::new(Role::ListItem).selected(self.selected).active(self.active);
518 if let Some(name) = &list.row_name {
519 semantics = semantics.name(name(self.index));
520 }
521 cx.semantics(node, semantics);
522 if let Some(pick) = &self.pick {
523 let (pick, index) = (pick.clone(), self.index);
524 cx.on_click(node, Rc::new(move || pick(index)));
525 cx.cursor(node, CursorIcon::Pointer);
526 }
527 if let Some(open) = &list.on_activate {
528 let (open, index) = (open.clone(), self.index);
529 cx.on_double_click(node, Rc::new(move || open(index)));
530 }
531 node
532 }
533}
534
535impl View for VirtualList {
536 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
537 let track = cx.use_state(|| Rc::new(RefCell::new(Track::default()))).peek().clone();
538 let local: State<Option<ScrollCommand>> = cx.use_state(|| None);
539 let commands = self.command.clone().unwrap_or_else(|| local.clone());
540 let cursor: State<usize> = cx.use_state(|| 0);
541 let selected = self.selection.as_ref().and_then(Binding::get);
542
543 let raw = cx.scroll_offset();
544 let frame = cx.scroll_viewport();
545 let window = cx.viewport();
546 let view = frame.map_or(self.length.unwrap_or(self.along(window)), |size| self.along(size));
547 let cross = frame.map_or(if self.horizontal { window.height } else { window.width }, |size| {
548 if self.horizontal { size.height } else { size.width }
549 });
550 let mut offset = if self.horizontal { raw.x } else { raw.y };
551 let across = if self.horizontal { raw.y } else { raw.x };
552
553 let mut t = track.borrow_mut();
554 let first_build = t.left_at.is_none();
555 if first_build {
556 t.at_end = self.follow_end;
557 } else if t.left_at.is_some_and(|left| (left - offset).abs() > SAME_OFFSET) {
558 t.set_anchor(offset);
560 t.at_end = self.follow_end && offset >= t.max_offset(view) - 1.0;
561 t.revealing = None;
562 }
563 t.sync(self);
564 t.take_measurements(cx, self);
565
566 let mut desired = t.anchored().unwrap_or(offset);
567 if t.at_end {
568 desired = t.max_offset(view);
569 }
570 desired = desired.clamp(0.0, t.max_offset(view));
571
572 if let Some(order) = commands.get() {
575 commands.set(None);
576 t.revealing = match order {
577 ScrollCommand::Reveal(index) => Some(index),
578 _ => None,
579 };
580 desired = self.apply(&mut t, order, desired, view);
581 }
582 if let Some(asked) = &self.scroll_to
583 && let Some(index) = asked.get()
584 {
585 asked.set(None);
586 t.revealing = None;
587 desired = self.apply(&mut t, ScrollCommand::ToIndex(index), desired, view);
588 }
589 if let Some(index) = t.revealing {
590 desired = self.apply(&mut t, ScrollCommand::Reveal(index), desired, view);
591 }
592 if (desired - offset).abs() > SAME_OFFSET {
593 cx.set_scroll_offset(self.axis_point(desired, across));
594 offset = desired;
595 }
596 t.left_at = Some(offset);
597 if t.anchor.is_none() {
598 t.set_anchor(offset);
599 }
600
601 let count = self.count;
602 let first_seen = t.index_at(offset);
603 let end_seen = t.tops.partition_point(|top| *top < offset + view).max(first_seen + 1).min(count);
604 let current = if offset >= t.max_offset(view) - 1.0 && t.max_offset(view) > 0.0 {
605 count.saturating_sub(1)
606 } else if offset <= 0.0 {
607 0
608 } else {
609 t.index_at(offset + view / 2.0)
610 };
611 if let Some(probe) = &self.probe {
612 probe.set(ListProbe { offset, view, total: t.total(), current });
613 }
614 let seen = first_seen.min(end_seen)..end_seen;
615 if t.visible.as_ref() != Some(&seen) {
616 t.visible = Some(seen.clone());
617 if let Some(handler) = &self.on_visible {
618 handler(seen);
619 }
620 }
621 let first = first_seen.saturating_sub(OVERSCAN);
622 let last = (end_seen + OVERSCAN).min(count);
623 let before = t.tops.get(first).map_or(0.0, |top| top - self.padding);
624 let after = t.tops.last().copied().unwrap_or(0.0) - t.tops.get(last).copied().unwrap_or(0.0);
625 let keys: Vec<u64> = t.keys.get(first..last).unwrap_or(&[]).to_vec();
626 let sizes: Vec<f32> = t.sizes.get(first..last).unwrap_or(&[]).to_vec();
627 t.built.clear();
628 t.built.extend(keys.iter().enumerate().map(|(at, key)| (*key, first + at)));
629 let unmeasured = matches!(self.sizing, Sizing::Measured(_))
631 && keys.iter().any(|key| !t.measured.contains_key(key));
632 if !unmeasured {
633 t.revealing = None;
634 }
635 drop(t);
636 if unmeasured {
637 cx.wake_at(cx.now());
639 }
640
641 let active = cursor.get().min(count.saturating_sub(1));
642 let pick: Option<Rc<dyn Fn(usize)>> = self.selection.clone().map(|selection| {
643 let cursor = cursor.clone();
644 Rc::new(move |index: usize| {
645 cursor.set(index);
646 selection.set(Some(index));
647 }) as Rc<dyn Fn(usize)>
648 });
649 let pick = pick.or_else(|| {
650 self.on_activate.as_ref().map(|_| {
651 let cursor = cursor.clone();
652 Rc::new(move |index: usize| cursor.set(index)) as Rc<dyn Fn(usize)>
653 })
654 });
655
656 let mut nodes = Vec::with_capacity(keys.len() + 2);
657 nodes.push(self.spacer(cx, before));
658 let focused = cx.is_focus_visible();
659 for (at, (key, size)) in keys.iter().zip(&sizes).enumerate() {
660 let index = first + at;
661 let row = Row {
662 list: self,
663 index,
664 size: *size,
665 selected: !self.bare && selected == Some(index),
666 active: !self.bare && self.interactive() && focused && index == active,
667 pick: pick.clone(),
668 };
669 nodes.push(cx.keyed_child(*key, &row));
670 }
671 nodes.push(self.spacer(cx, after));
672 let padding = if self.horizontal {
673 ravenrs_core::Insets::symmetric(0.0, self.padding)
674 } else {
675 ravenrs_core::Insets::symmetric(self.padding, 0.0)
676 };
677 let content = cx.container(
678 LayoutStyle {
679 align: Align::Stretch,
680 padding,
681 grow: if self.horizontal { 1.0 } else { 0.0 },
683 min_width: self.cross_content.map(|content| content.max(cross)),
684 ..LayoutStyle::stack(if self.horizontal { Axis::Horizontal } else { Axis::Vertical })
685 },
686 &nodes,
687 Paint::None,
688 );
689 let body = cx.container(
690 LayoutStyle {
691 height: self.length,
692 grow: if self.length.is_some() { 0.0 } else { 1.0 },
693 min_height: Some(0.0),
694 min_width: Some(0.0),
695 overflow_hidden: true,
696 align: Align::Stretch,
697 align_self: Some(Align::Stretch),
698 ..LayoutStyle::stack(Axis::Vertical)
699 },
700 &[content],
701 Paint::None,
702 );
703 cx.scrollable_virtual(body, self.horizontal || self.cross_content.is_some());
704 if self.horizontal {
705 cx.wheel_scrolls_sideways(body);
706 }
707 if self.bare {
708 return body;
709 }
710
711 let name = self.label.clone().unwrap_or_else(|| cx.labels().list_name.clone());
712 cx.semantics(body, Semantics::new(Role::List).name(name).description(count.to_string()));
713 cx.focusable(body, cx.theme().radii.sm);
714 let (interactive, selection, open) =
715 (self.interactive(), self.selection.clone(), self.on_activate.clone());
716 let horizontal = self.horizontal;
717 let page = (end_seen.saturating_sub(first_seen)).max(2) - 1;
718 cx.on_key(
719 body,
720 Rc::new(move |key, _| {
721 if count == 0 {
722 return false;
723 }
724 let (back, forward) = if horizontal {
725 (Key::ArrowLeft, Key::ArrowRight)
726 } else {
727 (Key::ArrowUp, Key::ArrowDown)
728 };
729 if !interactive {
730 let order = match key {
731 k if *k == back => ScrollCommand::By(-LINE_STEP),
732 k if *k == forward => ScrollCommand::By(LINE_STEP),
733 Key::PageUp => ScrollCommand::By(-view * 0.9),
734 Key::PageDown => ScrollCommand::By(view * 0.9),
735 Key::Home => ScrollCommand::ToIndex(0),
736 Key::End => ScrollCommand::Reveal(count - 1),
737 _ => return false,
738 };
739 commands.set(Some(order));
740 return true;
741 }
742 let to = match key {
743 k if *k == back => active.saturating_sub(1),
744 k if *k == forward => (active + 1).min(count - 1),
745 Key::PageUp => active.saturating_sub(page),
746 Key::PageDown => (active + page).min(count - 1),
747 Key::Home => 0,
748 Key::End => count - 1,
749 Key::Enter | Key::Space => {
750 if let Some(open) = &open {
751 open(active);
752 }
753 if let Some(selection) = &selection {
754 selection.set(Some(active));
755 }
756 return true;
757 }
758 _ => return false,
759 };
760 cursor.set(to);
761 if let Some(selection) = &selection {
762 selection.set(Some(to));
763 }
764 commands.set(Some(ScrollCommand::Reveal(to)));
765 true
766 }),
767 );
768 body
769 }
770}