Skip to main content

presentar_widgets/
list.rs

1//! Virtualized list widget for efficient scrolling.
2//!
3//! The List widget only renders items that are visible in the viewport,
4//! enabling smooth scrolling of thousands of items.
5
6use presentar_core::{
7    widget::LayoutResult, Brick, BrickAssertion, BrickBudget, BrickVerification, Canvas,
8    Constraints, Event, Key, Rect, Size, TypeId, Widget,
9};
10use serde::{Deserialize, Serialize};
11use std::any::Any;
12use std::ops::Range;
13use std::time::Duration;
14
15/// Type alias for the render item callback.
16pub type RenderItemFn = Box<dyn Fn(usize, &ListItem) -> Box<dyn Widget> + Send + Sync>;
17
18/// Direction of the list.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
20pub enum ListDirection {
21    /// Vertical scrolling (default)
22    #[default]
23    Vertical,
24    /// Horizontal scrolling
25    Horizontal,
26}
27
28/// Selection mode for list items.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
30pub enum SelectionMode {
31    /// No selection
32    #[default]
33    None,
34    /// Single item selection
35    Single,
36    /// Multiple item selection
37    Multiple,
38}
39
40/// List item data for virtualization.
41#[derive(Debug, Clone)]
42pub struct ListItem {
43    /// Unique key for this item
44    pub key: String,
45    /// Item height (or width in horizontal mode)
46    pub size: f32,
47    /// Whether item is selected
48    pub selected: bool,
49}
50
51impl ListItem {
52    /// Create a new list item.
53    #[must_use]
54    pub fn new(key: impl Into<String>) -> Self {
55        Self {
56            key: key.into(),
57            size: 48.0, // Default item height
58            selected: false,
59        }
60    }
61
62    /// Set the item size.
63    #[must_use]
64    pub const fn size(mut self, size: f32) -> Self {
65        self.size = size;
66        self
67    }
68
69    /// Set selection state.
70    #[must_use]
71    pub const fn selected(mut self, selected: bool) -> Self {
72        self.selected = selected;
73        self
74    }
75}
76
77/// Virtualized list widget.
78#[derive(Serialize, Deserialize)]
79pub struct List {
80    /// Scroll direction
81    pub direction: ListDirection,
82    /// Selection mode
83    pub selection_mode: SelectionMode,
84    /// Fixed item height (if None, items have variable height)
85    pub item_height: Option<f32>,
86    /// Gap between items
87    pub gap: f32,
88    /// Current scroll offset
89    pub scroll_offset: f32,
90    /// List items (keys and sizes)
91    #[serde(skip)]
92    items: Vec<ListItem>,
93    /// Selected indices
94    #[serde(skip)]
95    selected: Vec<usize>,
96    /// Focused item index
97    #[serde(skip)]
98    focused_index: Option<usize>,
99    /// Cached bounds after layout
100    #[serde(skip)]
101    bounds: Rect,
102    /// Cached visible range
103    #[serde(skip)]
104    visible_range: Range<usize>,
105    /// Cached item positions (start position for each item)
106    #[serde(skip)]
107    item_positions: Vec<f32>,
108    /// Total content size
109    #[serde(skip)]
110    content_size: f32,
111    /// Test ID
112    test_id_value: Option<String>,
113    /// Child widgets (rendered items only)
114    #[serde(skip)]
115    children: Vec<Box<dyn Widget>>,
116    /// Render callback (item index -> widget)
117    #[serde(skip)]
118    render_item: Option<RenderItemFn>,
119}
120
121impl Default for List {
122    fn default() -> Self {
123        Self {
124            direction: ListDirection::Vertical,
125            selection_mode: SelectionMode::None,
126            item_height: Some(48.0),
127            gap: 0.0,
128            scroll_offset: 0.0,
129            items: Vec::new(),
130            selected: Vec::new(),
131            focused_index: None,
132            bounds: Rect::default(),
133            visible_range: 0..0,
134            item_positions: Vec::new(),
135            content_size: 0.0,
136            test_id_value: None,
137            children: Vec::new(),
138            render_item: None,
139        }
140    }
141}
142
143impl List {
144    /// Create a new empty list.
145    #[must_use]
146    pub fn new() -> Self {
147        Self::default()
148    }
149
150    /// Set the scroll direction.
151    #[must_use]
152    pub const fn direction(mut self, direction: ListDirection) -> Self {
153        self.direction = direction;
154        self
155    }
156
157    /// Set the selection mode.
158    #[must_use]
159    pub const fn selection_mode(mut self, mode: SelectionMode) -> Self {
160        self.selection_mode = mode;
161        self
162    }
163
164    /// Set fixed item height.
165    #[must_use]
166    pub const fn item_height(mut self, height: f32) -> Self {
167        self.item_height = Some(height);
168        self
169    }
170
171    /// Set gap between items.
172    #[must_use]
173    pub const fn gap(mut self, gap: f32) -> Self {
174        self.gap = gap;
175        self
176    }
177
178    /// Add items to the list.
179    pub fn items(mut self, items: impl IntoIterator<Item = ListItem>) -> Self {
180        self.items = items.into_iter().collect();
181        self.recalculate_positions();
182        self
183    }
184
185    /// Set the render callback for items.
186    pub fn render_with<F>(mut self, f: F) -> Self
187    where
188        F: Fn(usize, &ListItem) -> Box<dyn Widget> + Send + Sync + 'static,
189    {
190        contract_pre_render!();
191        self.render_item = Some(Box::new(f));
192        self
193    }
194
195    /// Set the test ID.
196    #[must_use]
197    pub fn with_test_id(mut self, id: impl Into<String>) -> Self {
198        self.test_id_value = Some(id.into());
199        self
200    }
201
202    /// Get total item count.
203    #[must_use]
204    pub fn item_count(&self) -> usize {
205        self.items.len()
206    }
207
208    /// Get selected indices.
209    #[must_use]
210    pub fn selected_indices(&self) -> &[usize] {
211        &self.selected
212    }
213
214    /// Get visible range.
215    #[must_use]
216    pub fn visible_range(&self) -> Range<usize> {
217        self.visible_range.clone()
218    }
219
220    /// Get total content size.
221    #[must_use]
222    pub const fn content_size(&self) -> f32 {
223        self.content_size
224    }
225
226    /// Scroll to a specific item.
227    pub fn scroll_to(&mut self, index: usize) {
228        if index >= self.items.len() {
229            return;
230        }
231
232        let item_pos = self.item_positions.get(index).copied().unwrap_or(0.0);
233        let viewport_size = match self.direction {
234            ListDirection::Vertical => self.bounds.height,
235            ListDirection::Horizontal => self.bounds.width,
236        };
237
238        // Scroll so item is at top/left of viewport
239        self.scroll_offset = item_pos.min(self.content_size - viewport_size).max(0.0);
240    }
241
242    /// Scroll to ensure an item is visible.
243    pub fn scroll_into_view(&mut self, index: usize) {
244        if index >= self.items.len() {
245            return;
246        }
247
248        let item_pos = self.item_positions.get(index).copied().unwrap_or(0.0);
249        let item_size = self.get_item_size(index);
250        let viewport_size = match self.direction {
251            ListDirection::Vertical => self.bounds.height,
252            ListDirection::Horizontal => self.bounds.width,
253        };
254
255        let item_end = item_pos + item_size;
256        let viewport_end = self.scroll_offset + viewport_size;
257
258        if item_pos < self.scroll_offset {
259            // Item is above viewport - scroll up
260            self.scroll_offset = item_pos;
261        } else if item_end > viewport_end {
262            // Item is below viewport - scroll down
263            self.scroll_offset = (item_end - viewport_size).max(0.0);
264        }
265    }
266
267    /// Select an item.
268    pub fn select(&mut self, index: usize) {
269        match self.selection_mode {
270            SelectionMode::None => {}
271            SelectionMode::Single => {
272                self.selected.clear();
273                if index < self.items.len() {
274                    self.selected.push(index);
275                    self.items[index].selected = true;
276                }
277            }
278            SelectionMode::Multiple => {
279                if index < self.items.len() && !self.selected.contains(&index) {
280                    self.selected.push(index);
281                    self.items[index].selected = true;
282                }
283            }
284        }
285    }
286
287    /// Deselect an item.
288    pub fn deselect(&mut self, index: usize) {
289        if let Some(pos) = self.selected.iter().position(|&i| i == index) {
290            self.selected.remove(pos);
291            if index < self.items.len() {
292                self.items[index].selected = false;
293            }
294        }
295    }
296
297    /// Toggle item selection.
298    pub fn toggle_selection(&mut self, index: usize) {
299        if self.selected.contains(&index) {
300            self.deselect(index);
301        } else {
302            self.select(index);
303        }
304    }
305
306    /// Clear all selections.
307    pub fn clear_selection(&mut self) {
308        for &i in &self.selected {
309            if i < self.items.len() {
310                self.items[i].selected = false;
311            }
312        }
313        self.selected.clear();
314    }
315
316    /// Get item size at index.
317    fn get_item_size(&self, index: usize) -> f32 {
318        if let Some(fixed) = self.item_height {
319            fixed
320        } else {
321            self.items.get(index).map_or(48.0, |i| i.size)
322        }
323    }
324
325    /// Recalculate item positions after items change.
326    fn recalculate_positions(&mut self) {
327        self.item_positions.clear();
328        let mut pos = 0.0;
329
330        for (i, item) in self.items.iter().enumerate() {
331            self.item_positions.push(pos);
332            let size = self.item_height.unwrap_or(item.size);
333            pos += size;
334            if i < self.items.len() - 1 {
335                pos += self.gap;
336            }
337        }
338
339        self.content_size = pos;
340    }
341
342    /// Calculate visible range based on scroll offset and viewport.
343    fn calculate_visible_range(&mut self, viewport_size: f32) {
344        if self.items.is_empty() {
345            self.visible_range = 0..0;
346            return;
347        }
348
349        let start_offset = self.scroll_offset;
350        let end_offset = self.scroll_offset + viewport_size;
351
352        // Binary search for first visible item
353        let first = self
354            .item_positions
355            .partition_point(|&pos| pos + self.get_item_size(0) < start_offset);
356
357        // Linear scan for last visible (typically few items visible)
358        let mut last = first;
359        for i in first..self.items.len() {
360            let pos = self.item_positions.get(i).copied().unwrap_or(0.0);
361            if pos > end_offset {
362                break;
363            }
364            last = i + 1;
365        }
366
367        // Add buffer for smooth scrolling
368        let buffer = 2;
369        let start = first.saturating_sub(buffer);
370        let end = (last + buffer).min(self.items.len());
371
372        self.visible_range = start..end;
373    }
374
375    /// Render visible items.
376    fn render_visible_items(&mut self) {
377        self.children.clear();
378
379        if self.render_item.is_none() {
380            return;
381        }
382
383        for i in self.visible_range.clone() {
384            if let Some(item) = self.items.get(i) {
385                if let Some(ref render) = self.render_item {
386                    let widget = render(i, item);
387                    self.children.push(widget);
388                }
389            }
390        }
391    }
392}
393
394impl Widget for List {
395    fn type_id(&self) -> TypeId {
396        TypeId::of::<Self>()
397    }
398
399    fn measure(&self, constraints: Constraints) -> Size {
400        // List takes available space (scrollable content)
401        constraints.constrain(Size::new(constraints.max_width, constraints.max_height))
402    }
403
404    fn layout(&mut self, bounds: Rect) -> LayoutResult {
405        self.bounds = bounds;
406
407        let viewport_size = match self.direction {
408            ListDirection::Vertical => bounds.height,
409            ListDirection::Horizontal => bounds.width,
410        };
411
412        // Calculate visible range
413        self.calculate_visible_range(viewport_size);
414
415        // Render visible items
416        self.render_visible_items();
417
418        // Layout visible children
419        for (local_idx, i) in self.visible_range.clone().enumerate() {
420            if local_idx >= self.children.len() {
421                break;
422            }
423
424            let item_pos = self.item_positions.get(i).copied().unwrap_or(0.0);
425            let item_size = self.get_item_size(i);
426
427            let item_bounds = match self.direction {
428                ListDirection::Vertical => Rect::new(
429                    bounds.x,
430                    bounds.y + item_pos - self.scroll_offset,
431                    bounds.width,
432                    item_size,
433                ),
434                ListDirection::Horizontal => Rect::new(
435                    bounds.x + item_pos - self.scroll_offset,
436                    bounds.y,
437                    item_size,
438                    bounds.height,
439                ),
440            };
441
442            self.children[local_idx].layout(item_bounds);
443        }
444
445        LayoutResult {
446            size: bounds.size(),
447        }
448    }
449
450    fn paint(&self, canvas: &mut dyn Canvas) {
451        // Clip to bounds
452        canvas.push_clip(self.bounds);
453
454        // Paint visible children
455        for child in &self.children {
456            child.paint(canvas);
457        }
458
459        canvas.pop_clip();
460    }
461
462    fn event(&mut self, event: &Event) -> Option<Box<dyn Any + Send>> {
463        match event {
464            Event::Scroll { delta_y, .. } => {
465                let viewport_size = match self.direction {
466                    ListDirection::Vertical => self.bounds.height,
467                    ListDirection::Horizontal => self.bounds.width,
468                };
469
470                let max_scroll = (self.content_size - viewport_size).max(0.0);
471                self.scroll_offset = (self.scroll_offset - delta_y * 48.0).clamp(0.0, max_scroll);
472
473                // Recalculate visible range
474                self.calculate_visible_range(viewport_size);
475                self.render_visible_items();
476
477                Some(Box::new(ListScrolled {
478                    offset: self.scroll_offset,
479                }))
480            }
481            Event::KeyDown { key, .. } => {
482                if let Some(focused) = self.focused_index {
483                    match key {
484                        Key::Up | Key::Left => {
485                            if focused > 0 {
486                                self.focused_index = Some(focused - 1);
487                                self.scroll_into_view(focused - 1);
488                            }
489                        }
490                        Key::Down | Key::Right => {
491                            if focused < self.items.len() - 1 {
492                                self.focused_index = Some(focused + 1);
493                                self.scroll_into_view(focused + 1);
494                            }
495                        }
496                        Key::Enter | Key::Space => {
497                            self.toggle_selection(focused);
498                            return Some(Box::new(ListItemSelected { index: focused }));
499                        }
500                        Key::Home => {
501                            self.focused_index = Some(0);
502                            self.scroll_to(0);
503                        }
504                        Key::End => {
505                            let last = self.items.len().saturating_sub(1);
506                            self.focused_index = Some(last);
507                            self.scroll_to(last);
508                        }
509                        _ => {}
510                    }
511                }
512                None
513            }
514            Event::MouseDown { position, .. } => {
515                // Find clicked item
516                let pos = match self.direction {
517                    ListDirection::Vertical => position.y - self.bounds.y + self.scroll_offset,
518                    ListDirection::Horizontal => position.x - self.bounds.x + self.scroll_offset,
519                };
520
521                for (i, &item_pos) in self.item_positions.iter().enumerate() {
522                    let item_size = self.get_item_size(i);
523                    if pos >= item_pos && pos < item_pos + item_size {
524                        self.focused_index = Some(i);
525                        self.toggle_selection(i);
526                        return Some(Box::new(ListItemClicked { index: i }));
527                    }
528                }
529                None
530            }
531            _ => {
532                // Propagate to visible children
533                for child in &mut self.children {
534                    if let Some(msg) = child.event(event) {
535                        return Some(msg);
536                    }
537                }
538                None
539            }
540        }
541    }
542
543    fn children(&self) -> &[Box<dyn Widget>] {
544        &self.children
545    }
546
547    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
548        &mut self.children
549    }
550
551    fn is_focusable(&self) -> bool {
552        true
553    }
554
555    fn test_id(&self) -> Option<&str> {
556        self.test_id_value.as_deref()
557    }
558
559    fn bounds(&self) -> Rect {
560        self.bounds
561    }
562}
563
564// PROBAR-SPEC-009: Brick Architecture - Tests define interface
565impl Brick for List {
566    fn brick_name(&self) -> &'static str {
567        "List"
568    }
569
570    fn assertions(&self) -> &[BrickAssertion] {
571        &[BrickAssertion::MaxLatencyMs(16)]
572    }
573
574    fn budget(&self) -> BrickBudget {
575        BrickBudget::uniform(16)
576    }
577
578    fn verify(&self) -> BrickVerification {
579        BrickVerification {
580            passed: self.assertions().to_vec(),
581            failed: vec![],
582            verification_time: Duration::from_micros(10),
583        }
584    }
585
586    fn to_html(&self) -> String {
587        r#"<div class="brick-list"></div>"#.to_string()
588    }
589
590    fn to_css(&self) -> String {
591        ".brick-list { display: block; overflow: auto; }".to_string()
592    }
593
594    fn test_id(&self) -> Option<&str> {
595        self.test_id_value.as_deref()
596    }
597}
598
599/// Message emitted when list is scrolled.
600#[derive(Debug, Clone)]
601pub struct ListScrolled {
602    /// New scroll offset
603    pub offset: f32,
604}
605
606/// Message emitted when a list item is clicked.
607#[derive(Debug, Clone)]
608pub struct ListItemClicked {
609    /// Index of clicked item
610    pub index: usize,
611}
612
613/// Message emitted when a list item is selected.
614#[derive(Debug, Clone)]
615pub struct ListItemSelected {
616    /// Index of selected item
617    pub index: usize,
618}
619
620#[cfg(test)]
621mod tests {
622    use super::*;
623
624    // =========================================================================
625    // ListDirection Tests
626    // =========================================================================
627
628    #[test]
629    fn test_list_direction_default() {
630        assert_eq!(ListDirection::default(), ListDirection::Vertical);
631    }
632
633    // =========================================================================
634    // SelectionMode Tests
635    // =========================================================================
636
637    #[test]
638    fn test_selection_mode_default() {
639        assert_eq!(SelectionMode::default(), SelectionMode::None);
640    }
641
642    // =========================================================================
643    // ListItem Tests
644    // =========================================================================
645
646    #[test]
647    fn test_list_item_new() {
648        let item = ListItem::new("item-1");
649        assert_eq!(item.key, "item-1");
650        assert_eq!(item.size, 48.0);
651        assert!(!item.selected);
652    }
653
654    #[test]
655    fn test_list_item_builder() {
656        let item = ListItem::new("item-1").size(64.0).selected(true);
657        assert_eq!(item.size, 64.0);
658        assert!(item.selected);
659    }
660
661    // =========================================================================
662    // List Tests
663    // =========================================================================
664
665    #[test]
666    fn test_list_new() {
667        let list = List::new();
668        assert_eq!(list.direction, ListDirection::Vertical);
669        assert_eq!(list.selection_mode, SelectionMode::None);
670        assert_eq!(list.item_height, Some(48.0));
671        assert_eq!(list.gap, 0.0);
672        assert_eq!(list.item_count(), 0);
673    }
674
675    #[test]
676    fn test_list_builder() {
677        let list = List::new()
678            .direction(ListDirection::Horizontal)
679            .selection_mode(SelectionMode::Single)
680            .item_height(32.0)
681            .gap(8.0);
682
683        assert_eq!(list.direction, ListDirection::Horizontal);
684        assert_eq!(list.selection_mode, SelectionMode::Single);
685        assert_eq!(list.item_height, Some(32.0));
686        assert_eq!(list.gap, 8.0);
687    }
688
689    #[test]
690    fn test_list_items() {
691        let items = vec![ListItem::new("1"), ListItem::new("2"), ListItem::new("3")];
692        let list = List::new().items(items);
693        assert_eq!(list.item_count(), 3);
694    }
695
696    #[test]
697    fn test_list_content_size() {
698        let items = vec![ListItem::new("1"), ListItem::new("2"), ListItem::new("3")];
699        let list = List::new().item_height(50.0).gap(10.0).items(items);
700        // 3 items * 50px + 2 gaps * 10px = 170px
701        assert_eq!(list.content_size(), 170.0);
702    }
703
704    #[test]
705    fn test_list_content_size_variable_height() {
706        let items = vec![
707            ListItem::new("1").size(30.0),
708            ListItem::new("2").size(40.0),
709            ListItem::new("3").size(50.0),
710        ];
711        let mut list = List::new().gap(5.0);
712        list.item_height = None; // Variable height mode
713        list = list.items(items);
714        // 30 + 5 + 40 + 5 + 50 = 130px
715        assert_eq!(list.content_size(), 130.0);
716    }
717
718    #[test]
719    fn test_list_select_single() {
720        let items = vec![ListItem::new("1"), ListItem::new("2")];
721        let mut list = List::new()
722            .selection_mode(SelectionMode::Single)
723            .items(items);
724
725        list.select(0);
726        assert_eq!(list.selected_indices(), &[0]);
727
728        list.select(1);
729        assert_eq!(list.selected_indices(), &[1]); // Single mode replaces
730    }
731
732    #[test]
733    fn test_list_select_multiple() {
734        let items = vec![ListItem::new("1"), ListItem::new("2")];
735        let mut list = List::new()
736            .selection_mode(SelectionMode::Multiple)
737            .items(items);
738
739        list.select(0);
740        list.select(1);
741        assert_eq!(list.selected_indices(), &[0, 1]);
742    }
743
744    #[test]
745    fn test_list_deselect() {
746        let items = vec![ListItem::new("1"), ListItem::new("2")];
747        let mut list = List::new()
748            .selection_mode(SelectionMode::Multiple)
749            .items(items);
750
751        list.select(0);
752        list.select(1);
753        list.deselect(0);
754        assert_eq!(list.selected_indices(), &[1]);
755    }
756
757    #[test]
758    fn test_list_toggle_selection() {
759        let items = vec![ListItem::new("1")];
760        let mut list = List::new()
761            .selection_mode(SelectionMode::Single)
762            .items(items);
763
764        list.toggle_selection(0);
765        assert_eq!(list.selected_indices(), &[0]);
766
767        list.toggle_selection(0);
768        assert!(list.selected_indices().is_empty());
769    }
770
771    #[test]
772    fn test_list_clear_selection() {
773        let items = vec![ListItem::new("1"), ListItem::new("2")];
774        let mut list = List::new()
775            .selection_mode(SelectionMode::Multiple)
776            .items(items);
777
778        list.select(0);
779        list.select(1);
780        list.clear_selection();
781        assert!(list.selected_indices().is_empty());
782    }
783
784    #[test]
785    fn test_list_scroll_to() {
786        let items: Vec<_> = (0..100).map(|i| ListItem::new(format!("{i}"))).collect();
787        let mut list = List::new().item_height(50.0).items(items);
788        list.bounds = Rect::new(0.0, 0.0, 300.0, 200.0);
789
790        list.scroll_to(10);
791        assert_eq!(list.scroll_offset, 500.0); // 10 * 50px
792    }
793
794    #[test]
795    fn test_list_scroll_into_view() {
796        let items: Vec<_> = (0..10).map(|i| ListItem::new(format!("{i}"))).collect();
797        let mut list = List::new().item_height(50.0).items(items);
798        list.bounds = Rect::new(0.0, 0.0, 300.0, 200.0);
799        list.scroll_offset = 0.0;
800
801        // Item 5 is at 250px, below viewport (0-200)
802        list.scroll_into_view(5);
803        // Should scroll so item end (300px) is at viewport end
804        assert_eq!(list.scroll_offset, 100.0);
805    }
806
807    #[test]
808    fn test_list_visible_range() {
809        let items: Vec<_> = (0..100).map(|i| ListItem::new(format!("{i}"))).collect();
810        let mut list = List::new().item_height(50.0).items(items);
811        list.bounds = Rect::new(0.0, 0.0, 300.0, 200.0);
812        list.scroll_offset = 0.0;
813
814        list.calculate_visible_range(200.0);
815
816        // At scroll 0 with 200px viewport and 50px items, ~4 items visible
817        // Plus buffer of 2 on each side
818        let range = list.visible_range();
819        assert!(range.start <= 4);
820        assert!(range.end >= 4);
821    }
822
823    #[test]
824    fn test_list_measure() {
825        let list = List::new();
826        let size = list.measure(Constraints::loose(Size::new(300.0, 400.0)));
827        assert_eq!(size, Size::new(300.0, 400.0));
828    }
829
830    #[test]
831    fn test_list_layout() {
832        let items: Vec<_> = (0..10).map(|i| ListItem::new(format!("{i}"))).collect();
833        let mut list = List::new().item_height(50.0).items(items);
834
835        let result = list.layout(Rect::new(0.0, 0.0, 300.0, 200.0));
836        assert_eq!(result.size, Size::new(300.0, 200.0));
837        assert_eq!(list.bounds, Rect::new(0.0, 0.0, 300.0, 200.0));
838    }
839
840    #[test]
841    fn test_list_type_id() {
842        let list = List::new();
843        assert_eq!(Widget::type_id(&list), TypeId::of::<List>());
844    }
845
846    #[test]
847    fn test_list_is_focusable() {
848        let list = List::new();
849        assert!(list.is_focusable());
850    }
851
852    #[test]
853    fn test_list_test_id() {
854        let list = List::new().with_test_id("my-list");
855        assert_eq!(Widget::test_id(&list), Some("my-list"));
856    }
857
858    #[test]
859    fn test_list_children_empty() {
860        let list = List::new();
861        assert!(list.children().is_empty());
862    }
863
864    #[test]
865    fn test_list_bounds() {
866        let items: Vec<_> = (0..5).map(|i| ListItem::new(format!("{i}"))).collect();
867        let mut list = List::new().items(items);
868        list.layout(Rect::new(10.0, 20.0, 300.0, 200.0));
869        assert_eq!(list.bounds(), Rect::new(10.0, 20.0, 300.0, 200.0));
870    }
871
872    #[test]
873    fn test_list_scrolled_message() {
874        let msg = ListScrolled { offset: 100.0 };
875        assert_eq!(msg.offset, 100.0);
876    }
877
878    #[test]
879    fn test_list_item_clicked_message() {
880        let msg = ListItemClicked { index: 5 };
881        assert_eq!(msg.index, 5);
882    }
883
884    #[test]
885    fn test_list_item_selected_message() {
886        let msg = ListItemSelected { index: 3 };
887        assert_eq!(msg.index, 3);
888    }
889
890    // =========================================================================
891    // Additional Coverage Tests
892    // =========================================================================
893
894    #[test]
895    fn test_list_direction_horizontal() {
896        let list = List::new().direction(ListDirection::Horizontal);
897        assert_eq!(list.direction, ListDirection::Horizontal);
898    }
899
900    #[test]
901    fn test_list_direction_is_vertical_by_default() {
902        assert_eq!(ListDirection::default(), ListDirection::Vertical);
903    }
904
905    #[test]
906    fn test_selection_mode_is_none_by_default() {
907        assert_eq!(SelectionMode::default(), SelectionMode::None);
908    }
909
910    #[test]
911    fn test_list_with_selection_mode_multiple() {
912        let list = List::new().selection_mode(SelectionMode::Multiple);
913        assert_eq!(list.selection_mode, SelectionMode::Multiple);
914    }
915
916    #[test]
917    fn test_list_with_selection_mode_single() {
918        let list = List::new().selection_mode(SelectionMode::Single);
919        assert_eq!(list.selection_mode, SelectionMode::Single);
920    }
921
922    #[test]
923    fn test_list_gap() {
924        let list = List::new().gap(10.0);
925        assert_eq!(list.gap, 10.0);
926    }
927
928    #[test]
929    fn test_list_item_height_custom() {
930        let list = List::new().item_height(60.0);
931        assert_eq!(list.item_height, Some(60.0));
932    }
933
934    #[test]
935    fn test_list_children_mut() {
936        let mut list = List::new();
937        // Children are empty when no render callback or visible items
938        assert!(list.children_mut().is_empty());
939    }
940
941    #[test]
942    fn test_list_content_size_calculated() {
943        let items: Vec<_> = (0..5).map(|i| ListItem::new(format!("{i}"))).collect();
944        let list = List::new().items(items).item_height(40.0);
945        assert!(list.content_size() > 0.0);
946    }
947
948    #[test]
949    fn test_list_item_size_custom() {
950        let item = ListItem::new("Item").size(60.0);
951        assert_eq!(item.size, 60.0);
952    }
953
954    #[test]
955    fn test_list_item_selected_state() {
956        let item = ListItem::new("Item").selected(true);
957        assert!(item.selected);
958    }
959
960    #[test]
961    fn test_list_event_returns_none_when_empty() {
962        let mut list = List::new();
963        list.layout(Rect::new(0.0, 0.0, 300.0, 200.0));
964        let result = list.event(&Event::key_down(Key::Down));
965        assert!(result.is_none());
966    }
967
968    // =========================================================================
969    // Event Handling Tests
970    // =========================================================================
971
972    #[test]
973    fn test_list_scroll_event() {
974        let items: Vec<_> = (0..20).map(|i| ListItem::new(format!("{i}"))).collect();
975        let mut list = List::new().item_height(50.0).items(items);
976        list.layout(Rect::new(0.0, 0.0, 300.0, 200.0));
977
978        // Scroll down
979        let result = list.event(&Event::Scroll {
980            delta_x: 0.0,
981            delta_y: -2.0,
982        });
983        assert!(result.is_some());
984        assert!(list.scroll_offset > 0.0);
985    }
986
987    #[test]
988    fn test_list_scroll_event_clamp() {
989        let items: Vec<_> = (0..5).map(|i| ListItem::new(format!("{i}"))).collect();
990        let mut list = List::new().item_height(50.0).items(items);
991        list.layout(Rect::new(0.0, 0.0, 300.0, 500.0)); // Viewport larger than content
992
993        // Try to scroll down
994        let _ = list.event(&Event::Scroll {
995            delta_x: 0.0,
996            delta_y: -10.0,
997        });
998        // Should clamp to 0 since content fits in viewport
999        assert_eq!(list.scroll_offset, 0.0);
1000    }
1001
1002    #[test]
1003    fn test_list_key_down_focused() {
1004        let items: Vec<_> = (0..10).map(|i| ListItem::new(format!("{i}"))).collect();
1005        let mut list = List::new()
1006            .selection_mode(SelectionMode::Single)
1007            .item_height(50.0)
1008            .items(items);
1009        list.layout(Rect::new(0.0, 0.0, 300.0, 200.0));
1010        list.focused_index = Some(5);
1011
1012        // Press Down
1013        let _ = list.event(&Event::key_down(Key::Down));
1014        assert_eq!(list.focused_index, Some(6));
1015
1016        // Press Up
1017        let _ = list.event(&Event::key_down(Key::Up));
1018        assert_eq!(list.focused_index, Some(5));
1019    }
1020
1021    #[test]
1022    fn test_list_key_left_right() {
1023        let items: Vec<_> = (0..10).map(|i| ListItem::new(format!("{i}"))).collect();
1024        let mut list = List::new()
1025            .direction(ListDirection::Horizontal)
1026            .selection_mode(SelectionMode::Single)
1027            .item_height(50.0)
1028            .items(items);
1029        list.layout(Rect::new(0.0, 0.0, 300.0, 200.0));
1030        list.focused_index = Some(5);
1031
1032        // Press Right
1033        let _ = list.event(&Event::key_down(Key::Right));
1034        assert_eq!(list.focused_index, Some(6));
1035
1036        // Press Left
1037        let _ = list.event(&Event::key_down(Key::Left));
1038        assert_eq!(list.focused_index, Some(5));
1039    }
1040
1041    #[test]
1042    fn test_list_key_home_end() {
1043        let items: Vec<_> = (0..10).map(|i| ListItem::new(format!("{i}"))).collect();
1044        let mut list = List::new()
1045            .selection_mode(SelectionMode::Single)
1046            .item_height(50.0)
1047            .items(items);
1048        list.layout(Rect::new(0.0, 0.0, 300.0, 200.0));
1049        list.focused_index = Some(5);
1050
1051        // Press Home
1052        let _ = list.event(&Event::key_down(Key::Home));
1053        assert_eq!(list.focused_index, Some(0));
1054
1055        // Press End
1056        let _ = list.event(&Event::key_down(Key::End));
1057        assert_eq!(list.focused_index, Some(9));
1058    }
1059
1060    #[test]
1061    fn test_list_key_enter_selects() {
1062        let items: Vec<_> = (0..5).map(|i| ListItem::new(format!("{i}"))).collect();
1063        let mut list = List::new()
1064            .selection_mode(SelectionMode::Single)
1065            .item_height(50.0)
1066            .items(items);
1067        list.layout(Rect::new(0.0, 0.0, 300.0, 200.0));
1068        list.focused_index = Some(2);
1069
1070        let result = list.event(&Event::key_down(Key::Enter));
1071        assert!(result.is_some());
1072        assert_eq!(list.selected_indices(), &[2]);
1073    }
1074
1075    #[test]
1076    fn test_list_key_space_selects() {
1077        let items: Vec<_> = (0..5).map(|i| ListItem::new(format!("{i}"))).collect();
1078        let mut list = List::new()
1079            .selection_mode(SelectionMode::Single)
1080            .item_height(50.0)
1081            .items(items);
1082        list.layout(Rect::new(0.0, 0.0, 300.0, 200.0));
1083        list.focused_index = Some(3);
1084
1085        let result = list.event(&Event::key_down(Key::Space));
1086        assert!(result.is_some());
1087        assert_eq!(list.selected_indices(), &[3]);
1088    }
1089
1090    #[test]
1091    fn test_list_mouse_down_click() {
1092        let items: Vec<_> = (0..5).map(|i| ListItem::new(format!("{i}"))).collect();
1093        let mut list = List::new()
1094            .selection_mode(SelectionMode::Single)
1095            .item_height(50.0)
1096            .items(items);
1097        list.layout(Rect::new(0.0, 0.0, 300.0, 300.0));
1098
1099        // Click on item at y=75 (should be item 1, which is at 50-100)
1100        let result = list.event(&Event::MouseDown {
1101            position: presentar_core::Point::new(150.0, 75.0),
1102            button: presentar_core::MouseButton::Left,
1103        });
1104        assert!(result.is_some());
1105        assert_eq!(list.focused_index, Some(1));
1106    }
1107
1108    #[test]
1109    fn test_list_mouse_down_horizontal() {
1110        let items: Vec<_> = (0..5).map(|i| ListItem::new(format!("{i}"))).collect();
1111        let mut list = List::new()
1112            .direction(ListDirection::Horizontal)
1113            .selection_mode(SelectionMode::Single)
1114            .item_height(50.0)
1115            .items(items);
1116        list.layout(Rect::new(0.0, 0.0, 300.0, 100.0));
1117
1118        // Click on item at x=75 (should be item 1)
1119        let result = list.event(&Event::MouseDown {
1120            position: presentar_core::Point::new(75.0, 50.0),
1121            button: presentar_core::MouseButton::Left,
1122        });
1123        assert!(result.is_some());
1124        assert_eq!(list.focused_index, Some(1));
1125    }
1126
1127    #[test]
1128    fn test_list_mouse_down_miss() {
1129        let items: Vec<_> = (0..2).map(|i| ListItem::new(format!("{i}"))).collect();
1130        let mut list = List::new()
1131            .selection_mode(SelectionMode::Single)
1132            .item_height(50.0)
1133            .items(items);
1134        list.layout(Rect::new(0.0, 0.0, 300.0, 300.0));
1135
1136        // Click below all items
1137        let result = list.event(&Event::MouseDown {
1138            position: presentar_core::Point::new(150.0, 200.0),
1139            button: presentar_core::MouseButton::Left,
1140        });
1141        assert!(result.is_none());
1142    }
1143
1144    #[test]
1145    fn test_list_other_event() {
1146        let mut list = List::new();
1147        list.layout(Rect::new(0.0, 0.0, 300.0, 200.0));
1148
1149        // Unknown event propagates to children (empty)
1150        let result = list.event(&Event::MouseMove {
1151            position: presentar_core::Point::new(100.0, 100.0),
1152        });
1153        assert!(result.is_none());
1154    }
1155
1156    // =========================================================================
1157    // Paint Tests
1158    // =========================================================================
1159
1160    use presentar_core::RecordingCanvas;
1161
1162    #[test]
1163    fn test_list_paint_empty() {
1164        let list = List::new();
1165        let mut canvas = RecordingCanvas::new();
1166        // Should not panic when painting empty list
1167        list.paint(&mut canvas);
1168    }
1169
1170    #[test]
1171    fn test_list_paint_with_items() {
1172        let items: Vec<_> = (0..5).map(|i| ListItem::new(format!("{i}"))).collect();
1173        let mut list = List::new().item_height(50.0).items(items);
1174        list.bounds = Rect::new(0.0, 0.0, 300.0, 200.0);
1175
1176        let mut canvas = RecordingCanvas::new();
1177        // Should not panic when painting list with items
1178        list.paint(&mut canvas);
1179    }
1180
1181    // =========================================================================
1182    // Edge Case Tests
1183    // =========================================================================
1184
1185    #[test]
1186    fn test_list_scroll_to_out_of_bounds() {
1187        let items: Vec<_> = (0..5).map(|i| ListItem::new(format!("{i}"))).collect();
1188        let mut list = List::new().item_height(50.0).items(items);
1189        list.bounds = Rect::new(0.0, 0.0, 300.0, 200.0);
1190
1191        // Try to scroll to non-existent item
1192        list.scroll_to(100);
1193        // Should not crash, offset unchanged
1194        assert_eq!(list.scroll_offset, 0.0);
1195    }
1196
1197    #[test]
1198    fn test_list_scroll_into_view_out_of_bounds() {
1199        let items: Vec<_> = (0..5).map(|i| ListItem::new(format!("{i}"))).collect();
1200        let mut list = List::new().item_height(50.0).items(items);
1201        list.bounds = Rect::new(0.0, 0.0, 300.0, 200.0);
1202
1203        // Try to scroll to non-existent item
1204        list.scroll_into_view(100);
1205        // Should not crash
1206        assert_eq!(list.scroll_offset, 0.0);
1207    }
1208
1209    #[test]
1210    fn test_list_scroll_into_view_item_above() {
1211        let items: Vec<_> = (0..10).map(|i| ListItem::new(format!("{i}"))).collect();
1212        let mut list = List::new().item_height(50.0).items(items);
1213        list.bounds = Rect::new(0.0, 0.0, 300.0, 200.0);
1214        list.scroll_offset = 200.0; // Start scrolled down
1215
1216        // Item 0 is above viewport
1217        list.scroll_into_view(0);
1218        assert_eq!(list.scroll_offset, 0.0);
1219    }
1220
1221    #[test]
1222    fn test_list_select_none_mode() {
1223        let items: Vec<_> = (0..5).map(|i| ListItem::new(format!("{i}"))).collect();
1224        let mut list = List::new().selection_mode(SelectionMode::None).items(items);
1225
1226        list.select(0);
1227        assert!(list.selected_indices().is_empty());
1228    }
1229
1230    #[test]
1231    fn test_list_select_out_of_bounds() {
1232        let items: Vec<_> = (0..5).map(|i| ListItem::new(format!("{i}"))).collect();
1233        let mut list = List::new()
1234            .selection_mode(SelectionMode::Single)
1235            .items(items);
1236
1237        list.select(100);
1238        assert!(list.selected_indices().is_empty());
1239    }
1240
1241    #[test]
1242    fn test_list_select_multiple_same_item() {
1243        let items: Vec<_> = (0..5).map(|i| ListItem::new(format!("{i}"))).collect();
1244        let mut list = List::new()
1245            .selection_mode(SelectionMode::Multiple)
1246            .items(items);
1247
1248        list.select(0);
1249        list.select(0); // Try to select same item again
1250        assert_eq!(list.selected_indices().len(), 1);
1251    }
1252
1253    #[test]
1254    fn test_list_deselect_nonexistent() {
1255        let items: Vec<_> = (0..5).map(|i| ListItem::new(format!("{i}"))).collect();
1256        let mut list = List::new()
1257            .selection_mode(SelectionMode::Multiple)
1258            .items(items);
1259
1260        list.select(0);
1261        list.deselect(1); // Not selected
1262        assert_eq!(list.selected_indices(), &[0]);
1263    }
1264
1265    #[test]
1266    fn test_list_clear_selection_with_invalid_indices() {
1267        let items: Vec<_> = (0..5).map(|i| ListItem::new(format!("{i}"))).collect();
1268        let mut list = List::new()
1269            .selection_mode(SelectionMode::Multiple)
1270            .items(items);
1271
1272        list.select(0);
1273        list.selected.push(100); // Add invalid index manually
1274        list.clear_selection();
1275        assert!(list.selected_indices().is_empty());
1276    }
1277
1278    #[test]
1279    fn test_list_horizontal_layout() {
1280        let items: Vec<_> = (0..5).map(|i| ListItem::new(format!("{i}"))).collect();
1281        let mut list = List::new()
1282            .direction(ListDirection::Horizontal)
1283            .item_height(50.0)
1284            .items(items);
1285
1286        let result = list.layout(Rect::new(0.0, 0.0, 300.0, 100.0));
1287        assert_eq!(result.size, Size::new(300.0, 100.0));
1288    }
1289
1290    #[test]
1291    fn test_list_horizontal_scroll() {
1292        let items: Vec<_> = (0..20).map(|i| ListItem::new(format!("{i}"))).collect();
1293        let mut list = List::new()
1294            .direction(ListDirection::Horizontal)
1295            .item_height(50.0)
1296            .items(items);
1297        list.layout(Rect::new(0.0, 0.0, 200.0, 100.0));
1298
1299        list.scroll_to(10);
1300        assert_eq!(list.scroll_offset, 500.0);
1301    }
1302
1303    #[test]
1304    fn test_list_horizontal_scroll_into_view() {
1305        let items: Vec<_> = (0..10).map(|i| ListItem::new(format!("{i}"))).collect();
1306        let mut list = List::new()
1307            .direction(ListDirection::Horizontal)
1308            .item_height(50.0)
1309            .items(items);
1310        list.bounds = Rect::new(0.0, 0.0, 200.0, 100.0);
1311        list.scroll_offset = 0.0;
1312
1313        list.scroll_into_view(5);
1314        assert!(list.scroll_offset > 0.0);
1315    }
1316
1317    #[test]
1318    fn test_list_visible_range_empty() {
1319        let mut list = List::new();
1320        list.calculate_visible_range(200.0);
1321        assert_eq!(list.visible_range(), 0..0);
1322    }
1323
1324    #[test]
1325    fn test_list_get_item_size_variable() {
1326        let items = vec![ListItem::new("1").size(30.0), ListItem::new("2").size(50.0)];
1327        let mut list = List::new();
1328        list.item_height = None;
1329        list = list.items(items);
1330
1331        // Private method test via content_size
1332        assert_eq!(list.content_size(), 80.0);
1333    }
1334
1335    #[test]
1336    fn test_list_key_boundary_checks() {
1337        let items: Vec<_> = (0..3).map(|i| ListItem::new(format!("{i}"))).collect();
1338        let mut list = List::new()
1339            .selection_mode(SelectionMode::Single)
1340            .item_height(50.0)
1341            .items(items);
1342        list.layout(Rect::new(0.0, 0.0, 300.0, 200.0));
1343
1344        // At first item, press Up
1345        list.focused_index = Some(0);
1346        let _ = list.event(&Event::key_down(Key::Up));
1347        assert_eq!(list.focused_index, Some(0)); // Should stay at 0
1348
1349        // At last item, press Down
1350        list.focused_index = Some(2);
1351        let _ = list.event(&Event::key_down(Key::Down));
1352        assert_eq!(list.focused_index, Some(2)); // Should stay at 2
1353    }
1354
1355    #[test]
1356    fn test_list_other_key_no_action() {
1357        let items: Vec<_> = (0..3).map(|i| ListItem::new(format!("{i}"))).collect();
1358        let mut list = List::new()
1359            .selection_mode(SelectionMode::Single)
1360            .item_height(50.0)
1361            .items(items);
1362        list.layout(Rect::new(0.0, 0.0, 300.0, 200.0));
1363        list.focused_index = Some(1);
1364
1365        // Press a key that's not handled
1366        let result = list.event(&Event::key_down(Key::Tab));
1367        assert!(result.is_none());
1368        assert_eq!(list.focused_index, Some(1));
1369    }
1370
1371    // =========================================================================
1372    // Brick Trait Tests
1373    // =========================================================================
1374
1375    #[test]
1376    fn test_list_brick_name() {
1377        let list = List::new();
1378        assert_eq!(list.brick_name(), "List");
1379    }
1380
1381    #[test]
1382    fn test_list_brick_assertions() {
1383        let list = List::new();
1384        let assertions = list.assertions();
1385        assert!(!assertions.is_empty());
1386    }
1387
1388    #[test]
1389    fn test_list_brick_budget() {
1390        let list = List::new();
1391        let budget = list.budget();
1392        assert!(budget.layout_ms > 0);
1393    }
1394
1395    #[test]
1396    fn test_list_brick_verify() {
1397        let list = List::new();
1398        let verification = list.verify();
1399        assert!(!verification.passed.is_empty());
1400        assert!(verification.failed.is_empty());
1401    }
1402
1403    #[test]
1404    fn test_list_brick_to_html() {
1405        let list = List::new();
1406        let html = list.to_html();
1407        assert!(html.contains("brick-list"));
1408    }
1409
1410    #[test]
1411    fn test_list_brick_to_css() {
1412        let list = List::new();
1413        let css = list.to_css();
1414        assert!(css.contains("brick-list"));
1415    }
1416
1417    #[test]
1418    fn test_list_brick_test_id() {
1419        let list = List::new().with_test_id("test-list");
1420        assert_eq!(Brick::test_id(&list), Some("test-list"));
1421    }
1422}