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