Skip to main content

tui_lipan/widgets/list/
mod.rs

1//! List widget.
2
3use std::sync::Arc;
4
5use crate::callback::{Callback, KeyHandler};
6use crate::core::element::{Element, ElementKind};
7use crate::core::event::{KeyEvent, MouseEvent};
8use crate::style::{BorderStyle, Length, Padding, ScrollbarConfig, Span, Style, StyleSlot};
9use unicode_width::UnicodeWidthStr;
10
11use super::{Spinner, SpinnerSpeed, SpinnerStyle};
12use crate::widgets::scroll::{ScrollAction, ScrollKeymap, scroll_action_from_key};
13
14/// Shared configuration for the inner [`List`] used by composite widgets
15/// like [`Select`](super::Select), [`ComboBox`](super::ComboBox),
16/// [`MultiSelect`](super::MultiSelect), and [`SearchPalette`](super::SearchPalette).
17///
18/// Each of these widgets composes a `List` and proxies many list-related style
19/// fields. `ListConfig` deduplicates the common subset so that a single struct
20/// can carry them all.
21///
22/// Individual convenience setters (e.g. `.list_style(...)`) are still available
23/// on each widget; they delegate to the embedded `ListConfig`.
24#[derive(Clone, Debug, PartialEq)]
25pub struct ListConfig {
26    /// Whether to draw a border around the list.
27    pub border: bool,
28    /// Border style.
29    /// Default: `BorderStyle::Plain`.
30    pub border_style: BorderStyle,
31    /// Inner padding.
32    /// Default: `Padding::default()`.
33    pub padding: Padding,
34    /// Base style.
35    pub style: Style,
36    /// Style applied to the active_index (selected) item.
37    pub selection_style: StyleSlot,
38    /// Style applied to the selected item while the list is not focused.
39    ///
40    /// When unset, `selection_style` is used regardless of focus state.
41    pub unfocused_selection_style: StyleSlot,
42    /// Per-row hover style. `None` lets each host widget apply its own default
43    /// (several fall back to `selection_style`).
44    pub item_hover_style: Option<StyleSlot>,
45    /// Whether the highlight spans the full row width.
46    pub selection_full_width: bool,
47    /// Symbol shown on the active_index item.
48    pub selection_symbol: Option<Arc<str>>,
49    /// Trailing symbol after the selected item's label (pairs with
50    /// `selection_symbol` for "pill" caps). Shares `selection_symbol_style`.
51    pub selection_symbol_right: Option<Arc<str>>,
52    /// Style for the highlight symbol.
53    pub selection_symbol_style: Option<Style>,
54    /// Style for the highlight symbol while the list is not focused.
55    ///
56    /// When unset, `selection_symbol_style` is used regardless of focus state.
57    pub unfocused_selection_symbol_style: Option<Style>,
58    /// Whether to enable the left symbol/status column.
59    pub symbol_column: bool,
60    /// Explicit gap after the widest item gutter.
61    pub gutter_gap: u16,
62    /// Whether non-selectable rows participate in gutter alignment.
63    pub gutter_for_non_selectable: bool,
64    /// Left/right padding for normal rows (interior to the selection highlight).
65    pub item_horizontal_padding: Padding,
66    /// Left/right padding for header rows.
67    pub header_horizontal_padding: Padding,
68    /// Style for the empty-state placeholder text.
69    pub empty_text_style: Style,
70    /// Whether to show a vertical scrollbar when content overflows.
71    pub scrollbar: bool,
72    /// Scrollbar configuration.
73    pub scrollbar_config: ScrollbarConfig,
74}
75
76impl Default for ListConfig {
77    fn default() -> Self {
78        Self {
79            border: true,
80            border_style: BorderStyle::Plain,
81            padding: Padding::default(),
82            style: Style::default(),
83            selection_style: StyleSlot::Inherit,
84            unfocused_selection_style: StyleSlot::Inherit,
85            item_hover_style: None,
86            selection_full_width: false,
87            selection_symbol: None,
88            selection_symbol_right: None,
89            selection_symbol_style: None,
90            unfocused_selection_symbol_style: None,
91            symbol_column: true,
92            gutter_gap: 0,
93            gutter_for_non_selectable: false,
94            item_horizontal_padding: Padding::default(),
95            header_horizontal_padding: Padding::default(),
96            empty_text_style: Style::default(),
97            scrollbar: false,
98            scrollbar_config: ScrollbarConfig::default(),
99        }
100    }
101}
102
103impl ListConfig {
104    /// Create a new `ListConfig` with defaults.
105    pub fn new() -> Self {
106        Self::default()
107    }
108
109    /// Set border visibility.
110    pub fn border(mut self, border: bool) -> Self {
111        self.border = border;
112        self
113    }
114
115    /// Set border style.
116    pub fn border_style(mut self, style: BorderStyle) -> Self {
117        self.border_style = style;
118        self
119    }
120
121    /// Set padding.
122    pub fn padding(mut self, padding: impl Into<Padding>) -> Self {
123        self.padding = padding.into();
124        self
125    }
126
127    /// Set base style.
128    pub fn style(mut self, style: Style) -> Self {
129        self.style = style;
130        self
131    }
132
133    /// Set highlight style.
134    pub fn selection_style(mut self, style: Style) -> Self {
135        self.selection_style = StyleSlot::Replace(style);
136        self
137    }
138
139    /// Extend the theme selection style with this partial overlay.
140    pub fn extend_selection_style(mut self, style: Style) -> Self {
141        self.selection_style = StyleSlot::Extend(style);
142        self
143    }
144
145    /// Inherit the active theme selection style.
146    pub fn inherit_selection_style(mut self) -> Self {
147        self.selection_style = StyleSlot::Inherit;
148        self
149    }
150
151    /// Set highlight style while the list is not focused.
152    pub fn unfocused_selection_style(mut self, style: Style) -> Self {
153        self.unfocused_selection_style = StyleSlot::Replace(style);
154        self
155    }
156
157    /// Extend the theme unfocused-selection style with this partial overlay.
158    pub fn extend_unfocused_selection_style(mut self, style: Style) -> Self {
159        self.unfocused_selection_style = StyleSlot::Extend(style);
160        self
161    }
162
163    /// Inherit the active theme unfocused-selection style.
164    pub fn inherit_unfocused_selection_style(mut self) -> Self {
165        self.unfocused_selection_style = StyleSlot::Inherit;
166        self
167    }
168
169    /// Set whether highlight spans the full row width.
170    pub fn selection_full_width(mut self, full_width: bool) -> Self {
171        self.selection_full_width = full_width;
172        self
173    }
174
175    /// Set highlight symbol.
176    pub fn selection_symbol(mut self, symbol: Option<impl Into<Arc<str>>>) -> Self {
177        self.selection_symbol = symbol.map(Into::into);
178        self
179    }
180
181    /// Set the trailing selection symbol (right "pill" cap). Pairs with
182    /// [`Self::selection_symbol`] and shares [`Self::selection_symbol_style`].
183    pub fn selection_symbol_right(mut self, symbol: Option<impl Into<Arc<str>>>) -> Self {
184        self.selection_symbol_right = symbol.map(Into::into);
185        self
186    }
187
188    /// Set highlight symbol style.
189    pub fn selection_symbol_style(mut self, style: Style) -> Self {
190        self.selection_symbol_style = Some(style);
191        self
192    }
193
194    /// Set highlight symbol style while the list is not focused.
195    pub fn unfocused_selection_symbol_style(mut self, style: Style) -> Self {
196        self.unfocused_selection_symbol_style = Some(style);
197        self
198    }
199
200    /// Set whether the left symbol/status column is enabled.
201    pub fn symbol_column(mut self, enabled: bool) -> Self {
202        self.symbol_column = enabled;
203        self
204    }
205
206    /// Set the explicit gap after item gutters.
207    pub fn gutter_gap(mut self, gap: u16) -> Self {
208        self.gutter_gap = gap;
209        self
210    }
211
212    /// Set whether headers/spacers participate in gutter column alignment.
213    pub fn gutter_for_non_selectable(mut self, enabled: bool) -> Self {
214        self.gutter_for_non_selectable = enabled;
215        self
216    }
217
218    /// Set the per-row hover style.
219    pub fn item_hover_style(mut self, style: Style) -> Self {
220        self.item_hover_style = Some(StyleSlot::Replace(style));
221        self
222    }
223
224    /// Set the per-row hover style slot directly.
225    pub fn item_hover_style_slot(mut self, slot: StyleSlot) -> Self {
226        self.item_hover_style = Some(slot);
227        self
228    }
229
230    /// Set left/right padding for normal rows (interior to the highlight).
231    pub fn item_horizontal_padding(mut self, padding: impl Into<Padding>) -> Self {
232        self.item_horizontal_padding = padding.into();
233        self
234    }
235
236    /// Set left/right padding for header rows.
237    pub fn header_horizontal_padding(mut self, padding: impl Into<Padding>) -> Self {
238        self.header_horizontal_padding = padding.into();
239        self
240    }
241
242    /// Set the empty-state placeholder text style.
243    pub fn empty_text_style(mut self, style: Style) -> Self {
244        self.empty_text_style = style;
245        self
246    }
247
248    /// Set scrollbar visibility.
249    pub fn scrollbar(mut self, scrollbar: bool) -> Self {
250        self.scrollbar = scrollbar;
251        self
252    }
253
254    /// Set scrollbar configuration.
255    pub fn scrollbar_config(mut self, config: ScrollbarConfig) -> Self {
256        self.scrollbar_config = config;
257        self
258    }
259}
260
261/// A list selection event.
262#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
263pub struct ListEvent {
264    /// Selected item index.
265    pub index: usize,
266}
267
268/// Semantic role of a list row.
269#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
270pub enum ListItemRole {
271    /// Regular selectable row.
272    #[default]
273    Normal,
274    /// Non-selectable section/header row.
275    Header,
276    /// Non-selectable blank spacer row.
277    Spacer,
278}
279
280#[derive(Clone, Debug, PartialEq, Eq, Hash)]
281pub(crate) enum ListItemPrefixKind {
282    Plain,
283    Numbered(usize),
284}
285
286/// Placement of list symbols relative to the main label content.
287#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
288pub enum ListSymbolPosition {
289    /// Render the symbol in the left symbol column.
290    #[default]
291    Left,
292    /// Render the symbol immediately after the label content.
293    Right,
294}
295
296/// A fixed-width left gutter rendered before a list row label.
297#[derive(Clone, Debug, PartialEq, Eq)]
298pub struct ListItemGutter {
299    pub(crate) kind: ListItemGutterKind,
300}
301
302#[derive(Clone, Debug, PartialEq, Eq)]
303pub(crate) enum ListItemGutterKind {
304    Text(Vec<Span>),
305    Spinner(ListItemSpinnerGutter),
306}
307
308/// Per-row status content rendered in the list symbol column.
309#[derive(Clone, Debug, PartialEq, Eq)]
310pub struct ListItemStatus {
311    pub(crate) kind: ListItemStatusKind,
312}
313
314#[derive(Clone, Debug, PartialEq, Eq)]
315pub(crate) enum ListItemStatusKind {
316    Text(Vec<Span>),
317    Spinner(ListItemSpinnerGutter),
318}
319
320#[derive(Clone, Debug, PartialEq, Eq)]
321pub(crate) struct ListItemSpinnerGutter {
322    pub spinner_style: SpinnerStyle,
323    pub speed: SpinnerSpeed,
324    pub frame: usize,
325    pub auto_frame: bool,
326    pub label: Option<Arc<str>>,
327    pub gap: u16,
328    /// Blank cells before the glyph, so a 1-cell frame can match text markers like `" ●"`.
329    pub leading: u16,
330    pub style: Style,
331    pub label_style: Style,
332}
333
334impl ListItemGutter {
335    /// Create a plain text gutter.
336    pub fn text(text: impl Into<Arc<str>>) -> Self {
337        Self::from_spans([Span::new(text)])
338    }
339
340    /// Create a rich-text gutter.
341    pub fn from_spans(spans: impl IntoIterator<Item = Span>) -> Self {
342        Self {
343            kind: ListItemGutterKind::Text(spans.into_iter().collect()),
344        }
345    }
346
347    /// Create a spinner gutter.
348    pub fn spinner(spinner: Spinner) -> Self {
349        spinner.into()
350    }
351
352    /// Blank cells before a spinner glyph (no-op for text gutters).
353    ///
354    /// Use `1` when the spinner should line up with text markers like `" ●"`.
355    pub fn leading(mut self, leading: u16) -> Self {
356        if let ListItemGutterKind::Spinner(spinner) = &mut self.kind {
357            spinner.leading = leading;
358        }
359        self
360    }
361
362    pub(crate) fn width(&self) -> u16 {
363        match &self.kind {
364            ListItemGutterKind::Text(spans) => spans
365                .iter()
366                .map(|span| UnicodeWidthStr::width(span.content.as_ref()))
367                .sum::<usize>()
368                .min(u16::MAX as usize) as u16,
369            ListItemGutterKind::Spinner(spinner) => {
370                let label_width = spinner
371                    .label
372                    .as_ref()
373                    .map(|label| UnicodeWidthStr::width(label.as_ref()) as u16)
374                    .unwrap_or(0);
375                let gap = if label_width > 0 { spinner.gap } else { 0 };
376                spinner
377                    .leading
378                    .saturating_add(spinner.spinner_style.width())
379                    .saturating_add(gap)
380                    .saturating_add(label_width)
381            }
382        }
383    }
384
385    pub(crate) fn has_spinner(&self) -> bool {
386        matches!(self.kind, ListItemGutterKind::Spinner(_))
387    }
388
389    pub(crate) fn spinner_mut(&mut self) -> Option<&mut ListItemSpinnerGutter> {
390        match &mut self.kind {
391            ListItemGutterKind::Spinner(spinner) => Some(spinner),
392            ListItemGutterKind::Text(_) => None,
393        }
394    }
395}
396
397impl From<Spinner> for ListItemGutter {
398    fn from(spinner: Spinner) -> Self {
399        Self {
400            kind: ListItemGutterKind::Spinner(ListItemSpinnerGutter {
401                spinner_style: spinner.spinner_style,
402                speed: spinner.speed,
403                frame: spinner.frame.unwrap_or(0),
404                auto_frame: spinner.frame.is_none(),
405                label: spinner.label,
406                gap: spinner.gap,
407                leading: 0,
408                style: spinner.style,
409                label_style: spinner.label_style,
410            }),
411        }
412    }
413}
414
415impl ListItemStatus {
416    /// Create a plain-text status symbol.
417    pub fn text(text: impl Into<Arc<str>>) -> Self {
418        Self::from_spans([Span::new(text)])
419    }
420
421    /// Create a rich-text status symbol.
422    pub fn from_spans(spans: impl IntoIterator<Item = Span>) -> Self {
423        Self {
424            kind: ListItemStatusKind::Text(spans.into_iter().collect()),
425        }
426    }
427
428    /// Create an animated spinner status symbol.
429    pub fn spinner(spinner: Spinner) -> Self {
430        spinner.into()
431    }
432
433    pub(crate) fn width(&self) -> u16 {
434        match &self.kind {
435            ListItemStatusKind::Text(spans) => spans
436                .iter()
437                .map(|span| UnicodeWidthStr::width(span.content.as_ref()))
438                .sum::<usize>()
439                .min(u16::MAX as usize) as u16,
440            ListItemStatusKind::Spinner(spinner) => spinner.spinner_style.width(),
441        }
442    }
443
444    pub(crate) fn has_spinner(&self) -> bool {
445        matches!(self.kind, ListItemStatusKind::Spinner(_))
446    }
447
448    pub(crate) fn spinner_mut(&mut self) -> Option<&mut ListItemSpinnerGutter> {
449        match &mut self.kind {
450            ListItemStatusKind::Spinner(spinner) => Some(spinner),
451            ListItemStatusKind::Text(_) => None,
452        }
453    }
454}
455
456impl From<Spinner> for ListItemStatus {
457    fn from(spinner: Spinner) -> Self {
458        Self {
459            kind: ListItemStatusKind::Spinner(ListItemSpinnerGutter {
460                spinner_style: spinner.spinner_style,
461                speed: spinner.speed,
462                frame: spinner.frame.unwrap_or(0),
463                auto_frame: spinner.frame.is_none(),
464                label: None,
465                gap: 0,
466                leading: 0,
467                style: spinner.style,
468                label_style: spinner.label_style,
469            }),
470        }
471    }
472}
473
474/// A list item.
475#[derive(Clone, Debug, PartialEq, Eq)]
476pub struct ListItem {
477    pub(crate) spans: Vec<Span>,
478    pub(crate) description_spans: Vec<Span>,
479    pub(crate) extra_lines: Vec<ListItemLine>,
480    pub(crate) status: Option<ListItemStatus>,
481    pub(crate) gutter: Option<ListItemGutter>,
482    pub(crate) gutter_line: usize,
483    pub(crate) prefix: Option<Arc<str>>,
484    pub(crate) prefix_kind: ListItemPrefixKind,
485    pub(crate) prefix_style: Option<Style>,
486    pub(crate) extra_line_indent: u16,
487    pub(crate) style: Style,
488    pub(crate) role: ListItemRole,
489    pub(crate) active: bool,
490    pub(crate) primary_selection_label: bool,
491    pub(crate) primary_selection_description: bool,
492    pub(crate) primary_hover_label: bool,
493    pub(crate) primary_hover_description: bool,
494    pub(crate) primary_truncate_description_first: bool,
495    pub(crate) primary_wrap_label: bool,
496    pub(crate) primary_wrap_description: bool,
497    pub(crate) primary_max_label_width: Option<u16>,
498    pub(crate) primary_max_description_width: Option<u16>,
499    pub(crate) symbol_line: usize,
500}
501
502/// An additional rendered line for a [`ListItem`].
503#[derive(Clone, Debug, PartialEq, Eq)]
504pub struct ListItemLine {
505    pub(crate) spans: Vec<Span>,
506    pub(crate) description_spans: Vec<Span>,
507    pub(crate) style: Style,
508    pub(crate) selection_label: bool,
509    pub(crate) selection_description: bool,
510    pub(crate) hover_label: bool,
511    pub(crate) hover_description: bool,
512    pub(crate) truncate_description_first: bool,
513    pub(crate) wrap_label: bool,
514    pub(crate) wrap_description: bool,
515    pub(crate) max_label_width: Option<u16>,
516    pub(crate) max_description_width: Option<u16>,
517}
518
519impl ListItemLine {
520    /// Create a new line.
521    pub fn new(content: impl Into<Arc<str>>) -> Self {
522        Self {
523            spans: vec![Span::new(content)],
524            description_spans: Vec::new(),
525            style: Style::default(),
526            selection_label: true,
527            selection_description: true,
528            hover_label: true,
529            hover_description: true,
530            truncate_description_first: false,
531            wrap_label: false,
532            wrap_description: false,
533            max_label_width: None,
534            max_description_width: None,
535        }
536    }
537
538    /// Create from multiple spans.
539    pub fn from_spans(spans: impl IntoIterator<Item = Span>) -> Self {
540        Self {
541            spans: spans.into_iter().collect(),
542            description_spans: Vec::new(),
543            style: Style::default(),
544            selection_label: true,
545            selection_description: true,
546            hover_label: true,
547            hover_description: true,
548            truncate_description_first: false,
549            wrap_label: false,
550            wrap_description: false,
551            max_label_width: None,
552            max_description_width: None,
553        }
554    }
555
556    /// Replace the description (right-aligned) content.
557    pub fn description_spans(mut self, spans: impl IntoIterator<Item = Span>) -> Self {
558        self.description_spans = spans.into_iter().collect();
559        self
560    }
561
562    /// Set description as plain text (creates a single unstyled span).
563    pub fn description(mut self, text: impl Into<Arc<str>>) -> Self {
564        self.description_spans = vec![Span::new(text)];
565        self
566    }
567
568    /// Apply a style to all current description spans.
569    pub fn description_style(mut self, style: Style) -> Self {
570        for span in &mut self.description_spans {
571            span.style = style;
572        }
573        self
574    }
575
576    /// Set line style.
577    pub fn style(mut self, style: Style) -> Self {
578        self.style = style;
579        self
580    }
581
582    /// Control whether selection highlight applies to the label side.
583    pub fn selection_label(mut self, highlight: bool) -> Self {
584        self.selection_label = highlight;
585        self
586    }
587
588    /// Control whether selection highlight applies to the description side.
589    pub fn selection_description(mut self, highlight: bool) -> Self {
590        self.selection_description = highlight;
591        self
592    }
593
594    /// Control whether hover style applies to the label side.
595    pub fn hover_label(mut self, hover: bool) -> Self {
596        self.hover_label = hover;
597        self
598    }
599
600    /// Control whether hover style applies to the description side.
601    pub fn hover_description(mut self, hover: bool) -> Self {
602        self.hover_description = hover;
603        self
604    }
605
606    /// Prefer truncating description content before label content.
607    pub fn truncate_description_first(mut self, truncate: bool) -> Self {
608        self.truncate_description_first = truncate;
609        self
610    }
611
612    /// Wrap label content onto additional lines when needed.
613    pub fn wrap_label(mut self, wrap: bool) -> Self {
614        self.wrap_label = wrap;
615        self
616    }
617
618    /// Wrap description content onto extra lines when needed.
619    pub fn wrap_description(mut self, wrap: bool) -> Self {
620        self.wrap_description = wrap;
621        self
622    }
623
624    /// Limit the label to at most this many columns. Surplus space goes to the description.
625    pub fn max_label_width(mut self, width: u16) -> Self {
626        self.max_label_width = Some(width);
627        self
628    }
629
630    /// Limit the description to at most this many columns. Surplus space goes to the label.
631    pub fn max_description_width(mut self, width: u16) -> Self {
632        self.max_description_width = Some(width);
633        self
634    }
635}
636
637impl ListItem {
638    /// Create a new list item.
639    pub fn new(content: impl Into<Arc<str>>) -> Self {
640        Self {
641            spans: vec![Span::new(content)],
642            description_spans: Vec::new(),
643            extra_lines: Vec::new(),
644            status: None,
645            gutter: None,
646            gutter_line: 0,
647            prefix: None,
648            prefix_kind: ListItemPrefixKind::Plain,
649            prefix_style: None,
650            extra_line_indent: 0,
651            style: Style::default(),
652            role: ListItemRole::Normal,
653            active: false,
654            primary_selection_label: true,
655            primary_selection_description: true,
656            primary_hover_label: true,
657            primary_hover_description: true,
658            primary_truncate_description_first: false,
659            primary_wrap_label: false,
660            primary_wrap_description: false,
661            primary_max_label_width: None,
662            primary_max_description_width: None,
663            symbol_line: 0,
664        }
665    }
666
667    /// Create a non-selectable section/header row.
668    pub fn header(content: impl Into<Arc<str>>) -> Self {
669        Self::new(content)
670            .role(ListItemRole::Header)
671            .style(Style::default())
672    }
673
674    /// Create a non-selectable blank spacer row.
675    pub fn spacer() -> Self {
676        Self {
677            spans: vec![Span::new("")],
678            description_spans: Vec::new(),
679            extra_lines: Vec::new(),
680            status: None,
681            gutter: None,
682            gutter_line: 0,
683            prefix: None,
684            prefix_kind: ListItemPrefixKind::Plain,
685            prefix_style: None,
686            extra_line_indent: 0,
687            style: Style::default(),
688            role: ListItemRole::Spacer,
689            active: false,
690            primary_selection_label: true,
691            primary_selection_description: true,
692            primary_hover_label: true,
693            primary_hover_description: true,
694            primary_truncate_description_first: false,
695            primary_wrap_label: false,
696            primary_wrap_description: false,
697            primary_max_label_width: None,
698            primary_max_description_width: None,
699            symbol_line: 0,
700        }
701    }
702
703    /// Create from multiple spans.
704    pub fn from_spans(spans: impl IntoIterator<Item = Span>) -> Self {
705        Self {
706            spans: spans.into_iter().collect(),
707            description_spans: Vec::new(),
708            extra_lines: Vec::new(),
709            status: None,
710            gutter: None,
711            gutter_line: 0,
712            prefix: None,
713            prefix_kind: ListItemPrefixKind::Plain,
714            prefix_style: None,
715            extra_line_indent: 0,
716            style: Style::default(),
717            role: ListItemRole::Normal,
718            active: false,
719            primary_selection_label: true,
720            primary_selection_description: true,
721            primary_hover_label: true,
722            primary_hover_description: true,
723            primary_truncate_description_first: false,
724            primary_wrap_label: false,
725            primary_wrap_description: false,
726            primary_max_label_width: None,
727            primary_max_description_width: None,
728            symbol_line: 0,
729        }
730    }
731
732    /// Replace the description (right-aligned) content.
733    pub fn description_spans(mut self, spans: impl IntoIterator<Item = Span>) -> Self {
734        self.description_spans = spans.into_iter().collect();
735        self
736    }
737
738    /// Set description as plain text (creates a single unstyled span).
739    pub fn description(mut self, text: impl Into<Arc<str>>) -> Self {
740        self.description_spans = vec![Span::new(text)];
741        self
742    }
743
744    /// Apply a style to all current description spans.
745    pub fn description_style(mut self, style: Style) -> Self {
746        for span in &mut self.description_spans {
747            span.style = style;
748        }
749        self
750    }
751
752    /// Add an extra rendered line below the primary line.
753    pub fn line(mut self, line: impl Into<ListItemLine>) -> Self {
754        self.extra_lines.push(line.into());
755        self
756    }
757
758    /// Set extra lines below the primary line.
759    pub fn lines(mut self, lines: impl IntoIterator<Item = ListItemLine>) -> Self {
760        self.extra_lines = lines.into_iter().collect();
761        self
762    }
763
764    /// Set a fixed-width left gutter rendered before the primary label column.
765    pub fn gutter(mut self, gutter: impl Into<ListItemGutter>) -> Self {
766        self.gutter = Some(gutter.into());
767        self
768    }
769
770    /// Set per-row status content in the existing list symbol column.
771    ///
772    /// The status is used only when the row is not showing an active or selected
773    /// symbol. This is useful for status markers such as a busy spinner without
774    /// creating an extra label gutter column.
775    pub fn status(mut self, status: impl Into<ListItemStatus>) -> Self {
776        self.status = Some(status.into());
777        self
778    }
779
780    /// Set a plain-text per-row status symbol.
781    pub fn status_symbol(self, symbol: impl Into<Arc<str>>) -> Self {
782        self.status(ListItemStatus::text(symbol))
783    }
784
785    /// Set an animated per-row status spinner.
786    pub fn status_spinner(self, spinner: Spinner) -> Self {
787        self.status(ListItemStatus::spinner(spinner))
788    }
789
790    /// Set which visual line carries the gutter.
791    ///
792    /// `0` means primary line, `1` means first extra line, etc.
793    pub fn gutter_line(mut self, line: usize) -> Self {
794        self.gutter_line = line;
795        self
796    }
797
798    /// Set a text prefix for the primary line.
799    pub fn prefix(mut self, prefix: impl Into<Arc<str>>) -> Self {
800        let prefix = prefix.into();
801        self.extra_line_indent =
802            UnicodeWidthStr::width(prefix.as_ref()).min(u16::MAX as usize) as u16;
803        self.prefix = Some(prefix);
804        self.prefix_kind = ListItemPrefixKind::Plain;
805        self
806    }
807
808    /// Set style for the primary-line prefix.
809    pub fn prefix_style(mut self, style: Style) -> Self {
810        self.prefix_style = Some(style);
811        self
812    }
813
814    /// Prefix the row with `"N. "`.
815    pub fn numbered(mut self, n: usize) -> Self {
816        let prefix = format!("{n}. ");
817        self.extra_line_indent =
818            UnicodeWidthStr::width(prefix.as_str()).min(u16::MAX as usize) as u16;
819        self.prefix = Some(prefix.into());
820        self.prefix_kind = ListItemPrefixKind::Numbered(n);
821        self
822    }
823
824    /// Prefix the row with `"<ch> "`.
825    pub fn bulleted(self, ch: char) -> Self {
826        self.prefix(format!("{ch} "))
827    }
828
829    /// Set extra-line indentation width in terminal columns.
830    pub fn extra_line_indent(mut self, indent: u16) -> Self {
831        self.extra_line_indent = indent;
832        self
833    }
834
835    /// Control whether selection highlight applies to primary label content.
836    pub fn primary_selection_label(mut self, highlight: bool) -> Self {
837        self.primary_selection_label = highlight;
838        self
839    }
840
841    /// Control whether selection highlight applies to primary description content.
842    pub fn primary_selection_description(mut self, highlight: bool) -> Self {
843        self.primary_selection_description = highlight;
844        self
845    }
846
847    /// Control whether hover style applies to primary label content.
848    pub fn primary_hover_label(mut self, hover: bool) -> Self {
849        self.primary_hover_label = hover;
850        self
851    }
852
853    /// Control whether hover style applies to primary description content.
854    pub fn primary_hover_description(mut self, hover: bool) -> Self {
855        self.primary_hover_description = hover;
856        self
857    }
858
859    /// Prefer truncating primary description content before primary label content.
860    pub fn primary_truncate_description_first(mut self, truncate: bool) -> Self {
861        self.primary_truncate_description_first = truncate;
862        self
863    }
864
865    /// Wrap primary label content onto extra lines when needed.
866    pub fn primary_wrap_label(mut self, wrap: bool) -> Self {
867        self.primary_wrap_label = wrap;
868        self
869    }
870
871    /// Wrap primary description content onto extra lines when needed.
872    pub fn primary_wrap_description(mut self, wrap: bool) -> Self {
873        self.primary_wrap_description = wrap;
874        self
875    }
876
877    /// Limit the primary label to at most this many columns.
878    pub fn primary_max_label_width(mut self, width: u16) -> Self {
879        self.primary_max_label_width = Some(width);
880        self
881    }
882
883    /// Limit the primary description to at most this many columns.
884    pub fn primary_max_description_width(mut self, width: u16) -> Self {
885        self.primary_max_description_width = Some(width);
886        self
887    }
888
889    /// Set which visual line carries the symbol column.
890    ///
891    /// `0` means primary line, `1` means first extra line, etc.
892    pub fn symbol_line(mut self, line: usize) -> Self {
893        self.symbol_line = line;
894        self
895    }
896
897    /// Set base style.
898    pub fn style(mut self, style: Style) -> Self {
899        self.style = style;
900        self
901    }
902
903    /// Set semantic row role.
904    pub fn role(mut self, role: ListItemRole) -> Self {
905        self.role = role;
906        self
907    }
908
909    /// Mark this row as active.
910    pub fn active(mut self, active: bool) -> Self {
911        self.active = active;
912        self
913    }
914
915    /// Whether this row is keyboard/mouse selectable.
916    pub fn is_selectable(&self) -> bool {
917        matches!(self.role, ListItemRole::Normal)
918    }
919
920    /// Whether this row is active.
921    pub fn is_active(&self) -> bool {
922        self.active
923    }
924
925    /// Returns the concatenated plain text content.
926    pub fn plain_content(&self) -> String {
927        let mut s = String::new();
928        for span in &self.spans {
929            s.push_str(&span.content);
930        }
931        for line in &self.extra_lines {
932            s.push('\n');
933            for span in &line.spans {
934                s.push_str(&span.content);
935            }
936        }
937        s
938    }
939
940    pub(crate) fn line_count(&self) -> usize {
941        1 + self.extra_lines.len()
942    }
943}
944
945impl From<&'static str> for ListItemLine {
946    fn from(value: &'static str) -> Self {
947        Self::new(value)
948    }
949}
950
951impl From<String> for ListItemLine {
952    fn from(value: String) -> Self {
953        Self::new(value)
954    }
955}
956
957impl From<Arc<str>> for ListItemLine {
958    fn from(value: Arc<str>) -> Self {
959        Self::new(value)
960    }
961}
962
963impl From<&'static str> for ListItem {
964    fn from(value: &'static str) -> Self {
965        Self::new(value)
966    }
967}
968
969impl From<String> for ListItem {
970    fn from(value: String) -> Self {
971        Self::new(value)
972    }
973}
974
975impl From<Arc<str>> for ListItem {
976    fn from(value: Arc<str>) -> Self {
977        Self::new(value)
978    }
979}
980
981pub(crate) fn reserved_symbol_width_for_items(
982    items: &[ListItem],
983    symbol_column: bool,
984    active_symbol_position: ListSymbolPosition,
985    active_symbol: Option<&str>,
986    selection_symbol: Option<&str>,
987    unselected_symbol: Option<&str>,
988) -> u16 {
989    if !symbol_column {
990        return 0;
991    }
992
993    let status_width = items
994        .iter()
995        .filter(|item| item.is_selectable())
996        .filter_map(|item| item.status.as_ref())
997        .map(ListItemStatus::width)
998        .max()
999        .unwrap_or(0) as usize;
1000
1001    selection_symbol
1002        .map(UnicodeWidthStr::width)
1003        .unwrap_or(0)
1004        .max(
1005            if matches!(active_symbol_position, ListSymbolPosition::Left) {
1006                active_symbol.map(UnicodeWidthStr::width).unwrap_or(0)
1007            } else {
1008                0
1009            },
1010        )
1011        .max(unselected_symbol.map(UnicodeWidthStr::width).unwrap_or(0))
1012        .max(status_width)
1013        .min(u16::MAX as usize) as u16
1014}
1015
1016pub(crate) fn reserved_symbol_width(list: &List) -> u16 {
1017    reserved_symbol_width_for_items(
1018        &list.items,
1019        list.symbol_column,
1020        list.active_symbol_position,
1021        list.active_symbol.as_deref(),
1022        list.selection_symbol.as_deref(),
1023        list.unselected_symbol.as_deref(),
1024    )
1025}
1026
1027pub(crate) struct ListSymbolWidthCtx<'a> {
1028    pub active_symbol_position: ListSymbolPosition,
1029    pub active_symbol: Option<&'a str>,
1030    pub selection_symbol: Option<&'a str>,
1031    pub unselected_symbol: Option<&'a str>,
1032}
1033
1034pub(crate) fn item_symbol_width_for_reserved(
1035    reserved: u16,
1036    item: &ListItem,
1037    is_selected: bool,
1038    ctx: ListSymbolWidthCtx<'_>,
1039) -> u16 {
1040    let ListSymbolWidthCtx {
1041        active_symbol_position,
1042        active_symbol,
1043        selection_symbol,
1044        unselected_symbol,
1045    } = ctx;
1046    if reserved == 0 || !item.is_selectable() {
1047        return 0;
1048    }
1049
1050    if matches!(active_symbol_position, ListSymbolPosition::Left)
1051        && item.is_active()
1052        && let Some(symbol) = active_symbol
1053    {
1054        return UnicodeWidthStr::width(symbol).min(u16::MAX as usize) as u16;
1055    }
1056
1057    if item.status.is_some() {
1058        return reserved;
1059    }
1060
1061    if is_selected {
1062        return selection_symbol
1063            .map(|symbol| UnicodeWidthStr::width(symbol).min(u16::MAX as usize) as u16)
1064            .unwrap_or(reserved);
1065    }
1066
1067    unselected_symbol
1068        .map(|symbol| UnicodeWidthStr::width(symbol).min(u16::MAX as usize) as u16)
1069        .unwrap_or(reserved)
1070}
1071
1072pub(crate) fn item_symbol_width(list: &List, item: &ListItem, is_selected: bool) -> u16 {
1073    item_symbol_width_for_reserved(
1074        reserved_symbol_width(list),
1075        item,
1076        is_selected,
1077        ListSymbolWidthCtx {
1078            active_symbol_position: list.active_symbol_position,
1079            active_symbol: list.active_symbol.as_deref(),
1080            selection_symbol: list.selection_symbol.as_deref(),
1081            unselected_symbol: list.unselected_symbol.as_deref(),
1082        },
1083    )
1084}
1085
1086pub(crate) fn item_active_right_symbol_width(list: &List, item: &ListItem) -> u16 {
1087    if matches!(list.active_symbol_position, ListSymbolPosition::Right)
1088        && item.is_active()
1089        && let Some(symbol) = list.active_symbol.as_deref()
1090    {
1091        UnicodeWidthStr::width(symbol).min(u16::MAX as usize) as u16
1092    } else {
1093        0
1094    }
1095}
1096
1097/// Width of the trailing selection symbol for a given row.
1098///
1099/// Returns 0 unless the row is the selected, selectable row and a
1100/// `selection_symbol_right` is configured. A right-positioned `active_symbol`
1101/// occupies the same trailing slot and takes priority, so this returns 0 when
1102/// that symbol applies to the row to avoid double-reserving width.
1103pub(crate) fn item_selection_right_symbol_width(
1104    list: &List,
1105    item: &ListItem,
1106    is_selected: bool,
1107) -> u16 {
1108    if is_selected
1109        && item.is_selectable()
1110        && item_active_right_symbol_width(list, item) == 0
1111        && let Some(symbol) = list.selection_symbol_right.as_deref()
1112    {
1113        UnicodeWidthStr::width(symbol).min(u16::MAX as usize) as u16
1114    } else {
1115        0
1116    }
1117}
1118
1119pub(crate) fn item_uses_gutter(item: &ListItem, gutter_for_non_selectable: bool) -> bool {
1120    item.is_selectable() || gutter_for_non_selectable
1121}
1122
1123pub(crate) fn reserved_gutter_width_for_items(
1124    items: &[ListItem],
1125    gutter_gap: u16,
1126    gutter_for_non_selectable: bool,
1127) -> u16 {
1128    let width = items
1129        .iter()
1130        .filter(|item| item_uses_gutter(item, gutter_for_non_selectable))
1131        .filter_map(|item| item.gutter.as_ref())
1132        .map(ListItemGutter::width)
1133        .max()
1134        .unwrap_or(0);
1135    if width > 0 {
1136        width.saturating_add(gutter_gap)
1137    } else {
1138        0
1139    }
1140}
1141
1142pub(crate) fn reserved_gutter_width(list: &List) -> u16 {
1143    reserved_gutter_width_for_items(&list.items, list.gutter_gap, list.gutter_for_non_selectable)
1144}
1145
1146#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1147pub(crate) struct ListLeadingMetrics {
1148    pub symbol_width: u16,
1149    pub gutter_width: u16,
1150    pub active_right_symbol_width: u16,
1151    pub selection_right_symbol_width: u16,
1152}
1153
1154pub(crate) fn leading_metrics(
1155    list: &List,
1156    item: &ListItem,
1157    is_selected: bool,
1158) -> ListLeadingMetrics {
1159    ListLeadingMetrics {
1160        symbol_width: item_symbol_width(list, item, is_selected),
1161        gutter_width: if item_uses_gutter(item, list.gutter_for_non_selectable) {
1162            reserved_gutter_width(list)
1163        } else {
1164            0
1165        },
1166        active_right_symbol_width: item_active_right_symbol_width(list, item),
1167        selection_right_symbol_width: item_selection_right_symbol_width(list, item, is_selected),
1168    }
1169}
1170
1171pub(crate) fn max_numbered_prefix_width(list: &List) -> u16 {
1172    list.items
1173        .iter()
1174        .filter_map(|item| match item.prefix_kind {
1175            ListItemPrefixKind::Numbered(n) => Some(n),
1176            ListItemPrefixKind::Plain => None,
1177        })
1178        .map(|n| UnicodeWidthStr::width(format!("{n}. ").as_str()))
1179        .max()
1180        .unwrap_or(0)
1181        .min(u16::MAX as usize) as u16
1182}
1183
1184pub(crate) fn max_numbered_prefix_width_for_items(items: &[ListItem]) -> u16 {
1185    items
1186        .iter()
1187        .filter_map(|item| match item.prefix_kind {
1188            ListItemPrefixKind::Numbered(n) => Some(n),
1189            ListItemPrefixKind::Plain => None,
1190        })
1191        .map(|n| UnicodeWidthStr::width(format!("{n}. ").as_str()))
1192        .max()
1193        .unwrap_or(0)
1194        .min(u16::MAX as usize) as u16
1195}
1196
1197pub(crate) fn effective_prefix_for_width<'a>(
1198    item: &'a ListItem,
1199    numbered_prefix_width: u16,
1200) -> Option<std::borrow::Cow<'a, str>> {
1201    match (&item.prefix, &item.prefix_kind) {
1202        (Some(prefix), ListItemPrefixKind::Numbered(n)) => {
1203            let target_width = numbered_prefix_width as usize;
1204            let text = format!("{n}. ");
1205            let width = UnicodeWidthStr::width(text.as_str());
1206            if target_width > width {
1207                Some(std::borrow::Cow::Owned(format!(
1208                    "{}{}",
1209                    " ".repeat(target_width - width),
1210                    text
1211                )))
1212            } else {
1213                Some(std::borrow::Cow::Borrowed(prefix.as_ref()))
1214            }
1215        }
1216        (Some(prefix), ListItemPrefixKind::Plain) => {
1217            Some(std::borrow::Cow::Borrowed(prefix.as_ref()))
1218        }
1219        (None, _) => None,
1220    }
1221}
1222
1223pub(crate) fn effective_extra_line_indent_for_width(
1224    item: &ListItem,
1225    numbered_prefix_width: u16,
1226) -> u16 {
1227    match item.prefix_kind {
1228        ListItemPrefixKind::Numbered(_) => numbered_prefix_width,
1229        ListItemPrefixKind::Plain => item.extra_line_indent,
1230    }
1231}
1232
1233pub(crate) fn effective_prefix<'a>(
1234    list: &List,
1235    item: &'a ListItem,
1236) -> Option<std::borrow::Cow<'a, str>> {
1237    effective_prefix_for_width(item, max_numbered_prefix_width(list))
1238}
1239
1240pub(crate) fn effective_extra_line_indent(list: &List, item: &ListItem) -> u16 {
1241    effective_extra_line_indent_for_width(item, max_numbered_prefix_width(list))
1242}
1243
1244/// A vertically scrolling list.
1245#[derive(Clone)]
1246pub struct List {
1247    pub(crate) items: Arc<[ListItem]>,
1248    pub(crate) selected: Option<usize>,
1249    pub(crate) scroll_keys: ScrollKeymap,
1250    pub(crate) scroll_wheel: bool,
1251    pub(crate) style: Style,
1252    pub(crate) hover_style: StyleSlot,
1253    pub(crate) item_hover_style: StyleSlot,
1254    pub(crate) active_style: StyleSlot,
1255    pub(crate) selection_style: StyleSlot,
1256    pub(crate) unfocused_selection_style: StyleSlot,
1257    pub(crate) active_symbol: Option<Arc<str>>,
1258    pub(crate) active_symbol_position: ListSymbolPosition,
1259    pub(crate) active_symbol_style: Option<Style>,
1260    pub(crate) selection_symbol: Option<Arc<str>>,
1261    pub(crate) selection_symbol_right: Option<Arc<str>>,
1262    pub(crate) selection_symbol_style: Option<Style>,
1263    pub(crate) unfocused_selection_symbol_style: Option<Style>,
1264    pub(crate) unselected_symbol: Option<Arc<str>>,
1265    pub(crate) symbol_column: bool,
1266    pub(crate) gutter_gap: u16,
1267    pub(crate) gutter_for_non_selectable: bool,
1268    pub(crate) selection_full_width: bool,
1269    pub(crate) item_horizontal_padding: Padding,
1270    pub(crate) header_horizontal_padding: Padding,
1271    pub(crate) border: bool,
1272    pub(crate) border_style: BorderStyle,
1273    pub(crate) title: Option<Arc<str>>,
1274    pub(crate) title_style: Style,
1275    pub(crate) padding: Padding,
1276    pub(crate) scrollbar: bool,
1277    pub(crate) scrollbar_config: ScrollbarConfig,
1278    pub(crate) width: Length,
1279    pub(crate) height: Length,
1280    pub(crate) on_select: Option<Callback<ListEvent>>,
1281    pub(crate) on_item_click: Option<Callback<ListEvent>>,
1282    pub(crate) on_activate: Option<Callback<ListEvent>>,
1283    pub(crate) on_click: Option<Callback<MouseEvent>>,
1284    pub(crate) activate_on_click: bool,
1285    pub(crate) on_scroll_to: Option<Callback<usize>>,
1286    pub(crate) on_key: Option<KeyHandler>,
1287    pub(crate) disabled: bool,
1288    pub(crate) disabled_style: Style,
1289    pub(crate) focusable: bool,
1290    pub(crate) tab_stop: bool,
1291    pub(crate) on_focus: Option<Callback<()>>,
1292    pub(crate) on_blur: Option<Callback<()>>,
1293    pub(crate) show_scroll_indicators: bool,
1294    pub(crate) scroll_indicator_style: Style,
1295    pub(crate) empty_text: Option<Arc<str>>,
1296    pub(crate) empty_text_style: Style,
1297    pub(crate) force_scroll_to_selected: bool,
1298}
1299
1300impl Default for List {
1301    fn default() -> Self {
1302        Self {
1303            items: Arc::new([]),
1304            selected: Some(0),
1305            scroll_keys: ScrollKeymap::default(),
1306            scroll_wheel: true,
1307            style: Style::default(),
1308            hover_style: StyleSlot::Inherit,
1309            item_hover_style: StyleSlot::Inherit,
1310            active_style: StyleSlot::Inherit,
1311            selection_style: StyleSlot::Inherit,
1312            unfocused_selection_style: StyleSlot::Inherit,
1313            active_symbol: None,
1314            active_symbol_position: ListSymbolPosition::Left,
1315            active_symbol_style: None,
1316            selection_symbol: None,
1317            selection_symbol_right: None,
1318            selection_symbol_style: None,
1319            unfocused_selection_symbol_style: None,
1320            unselected_symbol: None,
1321            symbol_column: true,
1322            gutter_gap: 0,
1323            gutter_for_non_selectable: false,
1324            selection_full_width: false,
1325            item_horizontal_padding: Padding::default(),
1326            header_horizontal_padding: Padding::default(),
1327            border: false,
1328            border_style: BorderStyle::Plain,
1329            title: None,
1330            title_style: Style::default(),
1331            padding: Padding::default(),
1332            scrollbar: false,
1333            scrollbar_config: ScrollbarConfig::default(),
1334            width: Length::Flex(1),
1335            height: Length::Flex(1),
1336            on_select: None,
1337            on_item_click: None,
1338            on_activate: None,
1339            on_click: None,
1340            activate_on_click: true,
1341            on_scroll_to: None,
1342            on_key: None,
1343            disabled: false,
1344            disabled_style: Style::default(),
1345            focusable: true,
1346            tab_stop: true,
1347            on_focus: None,
1348            on_blur: None,
1349            show_scroll_indicators: false,
1350            scroll_indicator_style: Style::default(),
1351            empty_text: None,
1352            empty_text_style: Style::default(),
1353            force_scroll_to_selected: false,
1354        }
1355    }
1356}
1357
1358impl List {
1359    /// Create an empty list.
1360    pub fn new() -> Self {
1361        Self::default()
1362    }
1363
1364    /// Replace list items.
1365    pub fn items<I>(mut self, items: I) -> Self
1366    where
1367        I: IntoIterator<Item = ListItem>,
1368    {
1369        self.items = items.into_iter().collect();
1370        self
1371    }
1372
1373    /// Add an item.
1374    ///
1375    /// Note: This clones the internal Arc if it's shared, which might be expensive.
1376    /// For bulk updates, use `items()`.
1377    pub fn item(mut self, item: impl Into<ListItem>) -> Self {
1378        let mut items = self.items.to_vec();
1379        items.push(item.into());
1380        self.items = items.into();
1381        self
1382    }
1383
1384    /// Set items from a shared slice.
1385    ///
1386    /// This is efficient for avoiding allocations when the list items don't change often.
1387    pub fn items_arc(mut self, items: Arc<[ListItem]>) -> Self {
1388        self.items = items;
1389        self
1390    }
1391
1392    /// Set selected item index.
1393    ///
1394    /// Pass `None` for no current row (no selection highlight). Bare integers
1395    /// still work via `From<T> for Option<T>` (`list.selected(0)`).
1396    pub fn selected(mut self, selected: impl Into<Option<usize>>) -> Self {
1397        self.selected = selected.into();
1398        self
1399    }
1400
1401    /// Force scroll to make the selected item visible on next render.
1402    ///
1403    /// Use this when you want the list to jump directly to the selected item,
1404    /// bypassing the smart scroll logic that normally preserves scroll position.
1405    /// This is useful for auto-follow scenarios where you always want to show
1406    /// the latest item.
1407    pub fn force_scroll_to_selected(mut self, force: bool) -> Self {
1408        self.force_scroll_to_selected = force;
1409        self
1410    }
1411
1412    /// Configure which keys move the selection.
1413    pub fn scroll_keys(mut self, keys: ScrollKeymap) -> Self {
1414        self.scroll_keys = keys;
1415        self
1416    }
1417
1418    /// Enable mouse wheel selection changes.
1419    pub fn scroll_wheel(mut self, enabled: bool) -> Self {
1420        self.scroll_wheel = enabled;
1421        self
1422    }
1423
1424    /// Set base style.
1425    pub fn style(mut self, style: Style) -> Self {
1426        self.style = style;
1427        self
1428    }
1429
1430    /// Set style when list is hovered.
1431    pub fn hover_style(mut self, style: Style) -> Self {
1432        self.hover_style = StyleSlot::Replace(style);
1433        self
1434    }
1435
1436    /// Extend the theme hover style with this partial overlay.
1437    pub fn extend_hover_style(mut self, style: Style) -> Self {
1438        self.hover_style = StyleSlot::Extend(style);
1439        self
1440    }
1441
1442    /// Inherit the active theme hover style.
1443    pub fn inherit_hover_style(mut self) -> Self {
1444        self.hover_style = StyleSlot::Inherit;
1445        self
1446    }
1447
1448    /// Set hover style slot directly for composite forwarding.
1449    pub fn hover_style_slot(mut self, slot: StyleSlot) -> Self {
1450        self.hover_style = slot;
1451        self
1452    }
1453
1454    /// Set style for hovered items.
1455    pub fn item_hover_style(mut self, style: Style) -> Self {
1456        self.item_hover_style = StyleSlot::Replace(style);
1457        self
1458    }
1459
1460    /// Extend the theme item-hover style with this partial overlay.
1461    pub fn extend_item_hover_style(mut self, style: Style) -> Self {
1462        self.item_hover_style = StyleSlot::Extend(style);
1463        self
1464    }
1465
1466    /// Inherit the active theme item-hover style.
1467    pub fn inherit_item_hover_style(mut self) -> Self {
1468        self.item_hover_style = StyleSlot::Inherit;
1469        self
1470    }
1471
1472    /// Set item-hover style slot directly for composite forwarding.
1473    pub fn item_hover_style_slot(mut self, slot: StyleSlot) -> Self {
1474        self.item_hover_style = slot;
1475        self
1476    }
1477
1478    /// Set style for active rows.
1479    pub fn active_style(mut self, style: Style) -> Self {
1480        self.active_style = StyleSlot::Replace(style);
1481        self
1482    }
1483
1484    /// Extend the theme active style with this partial overlay.
1485    pub fn extend_active_style(mut self, style: Style) -> Self {
1486        self.active_style = StyleSlot::Extend(style);
1487        self
1488    }
1489
1490    /// Inherit the active theme active-row style.
1491    pub fn inherit_active_style(mut self) -> Self {
1492        self.active_style = StyleSlot::Inherit;
1493        self
1494    }
1495
1496    /// Set active style slot directly for composite forwarding.
1497    pub fn active_style_slot(mut self, slot: StyleSlot) -> Self {
1498        self.active_style = slot;
1499        self
1500    }
1501
1502    /// Set selected item style.
1503    pub fn selection_style(mut self, style: Style) -> Self {
1504        self.selection_style = StyleSlot::Replace(style);
1505        self
1506    }
1507
1508    /// Extend the theme selection style with this partial overlay.
1509    pub fn extend_selection_style(mut self, style: Style) -> Self {
1510        self.selection_style = StyleSlot::Extend(style);
1511        self
1512    }
1513
1514    /// Inherit the active theme selection style.
1515    pub fn inherit_selection_style(mut self) -> Self {
1516        self.selection_style = StyleSlot::Inherit;
1517        self
1518    }
1519
1520    /// Set selection style slot directly for composite forwarding.
1521    pub fn selection_style_slot(mut self, slot: StyleSlot) -> Self {
1522        self.selection_style = slot;
1523        self
1524    }
1525
1526    /// Set selected item style while the list is not focused.
1527    ///
1528    /// When unset, [`Self::selection_style`] is used regardless of focus state.
1529    pub fn unfocused_selection_style(mut self, style: Style) -> Self {
1530        self.unfocused_selection_style = StyleSlot::Replace(style);
1531        self
1532    }
1533
1534    /// Extend the theme unfocused-selection style with this partial overlay.
1535    pub fn extend_unfocused_selection_style(mut self, style: Style) -> Self {
1536        self.unfocused_selection_style = StyleSlot::Extend(style);
1537        self
1538    }
1539
1540    /// Inherit the active theme unfocused-selection style.
1541    pub fn inherit_unfocused_selection_style(mut self) -> Self {
1542        self.unfocused_selection_style = StyleSlot::Inherit;
1543        self
1544    }
1545
1546    /// Set unfocused-selection style slot directly for composite forwarding.
1547    pub fn unfocused_selection_style_slot(mut self, slot: StyleSlot) -> Self {
1548        self.unfocused_selection_style = slot;
1549        self
1550    }
1551
1552    /// Set the symbol shown on the selected (active_index) item.
1553    ///
1554    /// Symbols are shown with a consistent left-aligned prefix column whose width
1555    /// is the maximum of `selection_symbol`, `unselected_symbol`, and `active_symbol`
1556    /// when `active_symbol_position` is [`ListSymbolPosition::Left`].
1557    ///
1558    /// Symbol rendering priority per row:
1559    /// 1. `active_symbol` - shown whenever the item is active (overrides highlight).
1560    /// 2. `selection_symbol` - shown for the selected item when not active.
1561    /// 3. `unselected_symbol` - shown for all other rows.
1562    /// 4. Spaces - auto-padding to the reserved column width when no symbol matches.
1563    pub fn selection_symbol(mut self, symbol: Option<impl Into<Arc<str>>>) -> Self {
1564        self.selection_symbol = symbol.map(Into::into);
1565        self
1566    }
1567
1568    /// Set a trailing symbol rendered immediately after the selected row's
1569    /// label content.
1570    ///
1571    /// This mirrors [`Self::selection_symbol`] (the leading symbol) on the right
1572    /// side, enabling "pill"/capsule selection styles: pair a left cap (e.g.
1573    /// `""`) via `selection_symbol` with a right cap (e.g. `""`) here, and
1574    /// color both with [`Self::selection_symbol_style`] so their foreground
1575    /// equals the selection background. Combine with
1576    /// `selection_full_width(false)` so the highlight hugs the label and the
1577    /// caps sit on the surrounding background.
1578    ///
1579    /// The trailing symbol shares [`Self::selection_symbol_style`] /
1580    /// [`Self::unfocused_selection_symbol_style`] with the leading symbol — both
1581    /// caps of a pill are the same color by definition. It renders only on the
1582    /// selected row's symbol line, in the same position as a right-positioned
1583    /// [`Self::active_symbol`]; when a row is both selected and shows a
1584    /// right-positioned active symbol, the active symbol takes priority.
1585    pub fn selection_symbol_right(mut self, symbol: Option<impl Into<Arc<str>>>) -> Self {
1586        self.selection_symbol_right = symbol.map(Into::into);
1587        self
1588    }
1589
1590    /// Set the symbol shown on active rows.
1591    ///
1592    /// When `active_symbol_position` is [`ListSymbolPosition::Left`], `active_symbol`
1593    /// takes priority over `selection_symbol`, even when the item is both active and
1594    /// selected. When set to [`ListSymbolPosition::Right`], the active symbol is rendered
1595    /// immediately after the label content instead.
1596    pub fn active_symbol(mut self, symbol: Option<impl Into<Arc<str>>>) -> Self {
1597        self.active_symbol = symbol.map(Into::into);
1598        self
1599    }
1600
1601    /// Set where the active symbol is rendered.
1602    pub fn active_symbol_position(mut self, position: ListSymbolPosition) -> Self {
1603        self.active_symbol_position = position;
1604        self
1605    }
1606
1607    /// Set style for the active symbol.
1608    ///
1609    /// If not set, it defaults to the computed row style.
1610    pub fn active_symbol_style(mut self, style: Style) -> Self {
1611        self.active_symbol_style = Some(style);
1612        self
1613    }
1614
1615    /// Set style for the highlight symbol.
1616    ///
1617    /// If not set, it defaults to the computed row style (with `selection_style` applied).
1618    pub fn selection_symbol_style(mut self, style: Style) -> Self {
1619        self.selection_symbol_style = Some(style);
1620        self
1621    }
1622
1623    /// Set selected item symbol style while the list is not focused.
1624    ///
1625    /// When unset, [`Self::selection_symbol_style`] is used regardless of focus state.
1626    pub fn unfocused_selection_symbol_style(mut self, style: Style) -> Self {
1627        self.unfocused_selection_symbol_style = Some(style);
1628        self
1629    }
1630
1631    /// Set the symbol shown for rows that are neither selected nor active.
1632    ///
1633    /// When not set, non-selected rows are padded with spaces equal to the
1634    /// reserved prefix column width (the max of all three symbol widths), keeping
1635    /// all content aligned. To remove all left padding set this to `Some("")`.
1636    pub fn unselected_symbol(mut self, symbol: Option<impl Into<Arc<str>>>) -> Self {
1637        self.unselected_symbol = symbol.map(Into::into);
1638        self
1639    }
1640
1641    /// Set whether the left symbol/status column is enabled.
1642    ///
1643    /// This does not affect right-positioned active symbols.
1644    pub fn symbol_column(mut self, enabled: bool) -> Self {
1645        self.symbol_column = enabled;
1646        self
1647    }
1648
1649    /// Set the explicit gap after the widest item gutter.
1650    pub fn gutter_gap(mut self, gap: u16) -> Self {
1651        self.gutter_gap = gap;
1652        self
1653    }
1654
1655    /// Set whether non-selectable rows reserve the gutter column.
1656    pub fn gutter_for_non_selectable(mut self, enabled: bool) -> Self {
1657        self.gutter_for_non_selectable = enabled;
1658        self
1659    }
1660
1661    /// Set whether the highlight should span the full width of the list.
1662    pub fn selection_full_width(mut self, full_width: bool) -> Self {
1663        self.selection_full_width = full_width;
1664        self
1665    }
1666
1667    /// Set row padding for normal rows.
1668    ///
1669    /// Only left/right are used by the renderer; top/bottom are ignored.
1670    pub fn item_horizontal_padding(mut self, padding: impl Into<Padding>) -> Self {
1671        self.item_horizontal_padding = padding.into();
1672        self
1673    }
1674
1675    /// Set row padding for header rows.
1676    ///
1677    /// Only left/right are used by the renderer; top/bottom are ignored.
1678    pub fn header_horizontal_padding(mut self, padding: impl Into<Padding>) -> Self {
1679        self.header_horizontal_padding = padding.into();
1680        self
1681    }
1682
1683    /// Draw a border.
1684    pub fn border(mut self, border: bool) -> Self {
1685        self.border = border;
1686        self
1687    }
1688
1689    /// Set border style.
1690    pub fn border_style(mut self, border_style: BorderStyle) -> Self {
1691        self.border_style = border_style;
1692        self
1693    }
1694
1695    /// Set the title (only visible when `border` is true).
1696    pub fn title(mut self, title: impl Into<Arc<str>>) -> Self {
1697        self.title = Some(title.into());
1698        self
1699    }
1700
1701    /// Set title style.
1702    pub fn title_style(mut self, style: Style) -> Self {
1703        self.title_style = style;
1704        self
1705    }
1706
1707    /// Set padding.
1708    pub fn padding(mut self, padding: impl Into<Padding>) -> Self {
1709        self.padding = padding.into();
1710        self
1711    }
1712
1713    /// Draw a vertical scrollbar.
1714    pub fn scrollbar(mut self, scrollbar: bool) -> Self {
1715        self.scrollbar = scrollbar;
1716        self
1717    }
1718
1719    /// Set scrollbar configuration.
1720    pub fn scrollbar_config(mut self, config: ScrollbarConfig) -> Self {
1721        self.scrollbar_config = config;
1722        self
1723    }
1724
1725    /// Override requested width.
1726    pub fn width(mut self, width: Length) -> Self {
1727        self.width = width;
1728        self
1729    }
1730
1731    /// Override requested height.
1732    pub fn height(mut self, height: Length) -> Self {
1733        self.height = height;
1734        self
1735    }
1736
1737    /// Callback fired when selection changes (keyboard or mouse).
1738    pub fn on_select(mut self, cb: Callback<ListEvent>) -> Self {
1739        self.on_select = Some(cb);
1740        self
1741    }
1742
1743    /// Callback fired when a row is clicked with the mouse.
1744    pub fn on_item_click(mut self, cb: Callback<ListEvent>) -> Self {
1745        self.on_item_click = Some(cb);
1746        self
1747    }
1748
1749    /// Callback fired on activation (Enter).
1750    pub fn on_activate(mut self, cb: Callback<ListEvent>) -> Self {
1751        self.on_activate = Some(cb);
1752        self
1753    }
1754
1755    /// Control whether clicking an item emits `on_activate`.
1756    pub fn activate_on_click(mut self, activate: bool) -> Self {
1757        self.activate_on_click = activate;
1758        self
1759    }
1760
1761    /// Set on-click handler.
1762    pub fn on_click(mut self, cb: Callback<MouseEvent>) -> Self {
1763        self.on_click = Some(cb);
1764        self
1765    }
1766
1767    /// Set on-scroll-to handler.
1768    pub fn on_scroll_to(mut self, cb: Callback<usize>) -> Self {
1769        self.on_scroll_to = Some(cb);
1770        self
1771    }
1772
1773    /// Set on-key handler.
1774    pub fn on_key(mut self, handler: KeyHandler) -> Self {
1775        self.on_key = Some(handler);
1776        self
1777    }
1778
1779    /// Set disabled state.
1780    pub fn disabled(mut self, disabled: bool) -> Self {
1781        self.disabled = disabled;
1782        self
1783    }
1784
1785    /// Set disabled style.
1786    pub fn disabled_style(mut self, style: Style) -> Self {
1787        self.disabled_style = style;
1788        self
1789    }
1790
1791    /// Control whether the node is focusable.
1792    pub fn focusable(mut self, focusable: bool) -> Self {
1793        self.focusable = focusable;
1794        self
1795    }
1796
1797    /// Control whether the list participates in Tab / Shift+Tab traversal.
1798    pub fn tab_stop(mut self, tab_stop: bool) -> Self {
1799        self.tab_stop = tab_stop;
1800        self
1801    }
1802
1803    /// Set the callback fired when the list gains focus.
1804    pub fn on_focus(mut self, cb: Callback<()>) -> Self {
1805        self.on_focus = Some(cb);
1806        self
1807    }
1808
1809    /// Set the callback fired when the list loses focus.
1810    pub fn on_blur(mut self, cb: Callback<()>) -> Self {
1811        self.on_blur = Some(cb);
1812        self
1813    }
1814
1815    /// Enable "N more" scroll indicators when items are hidden.
1816    pub fn show_scroll_indicators(mut self, show: bool) -> Self {
1817        self.show_scroll_indicators = show;
1818        self
1819    }
1820
1821    /// Set style for scroll indicators.
1822    pub fn scroll_indicator_style(mut self, style: Style) -> Self {
1823        self.scroll_indicator_style = style;
1824        self
1825    }
1826
1827    /// Set text to display when the list is empty.
1828    pub fn empty_text(mut self, text: impl Into<Arc<str>>) -> Self {
1829        self.empty_text = Some(text.into());
1830        self
1831    }
1832
1833    /// Set style for empty text.
1834    pub fn empty_text_style(mut self, style: Style) -> Self {
1835        self.empty_text_style = style;
1836        self
1837    }
1838
1839    pub(crate) fn first_selectable_index(items: &[ListItem]) -> Option<usize> {
1840        items.iter().position(ListItem::is_selectable)
1841    }
1842
1843    pub(crate) fn last_selectable_index(items: &[ListItem]) -> Option<usize> {
1844        items.iter().rposition(ListItem::is_selectable)
1845    }
1846
1847    pub(crate) fn is_selectable_index(items: &[ListItem], index: usize) -> bool {
1848        items.get(index).is_some_and(ListItem::is_selectable)
1849    }
1850
1851    pub(crate) fn selectable_at_or_after(items: &[ListItem], start: usize) -> Option<usize> {
1852        if items.is_empty() {
1853            return None;
1854        }
1855
1856        let from = start.min(items.len().saturating_sub(1));
1857        items
1858            .iter()
1859            .enumerate()
1860            .skip(from)
1861            .find_map(|(idx, item)| item.is_selectable().then_some(idx))
1862    }
1863
1864    pub(crate) fn selectable_at_or_before(items: &[ListItem], start: usize) -> Option<usize> {
1865        if items.is_empty() {
1866            return None;
1867        }
1868
1869        let to = start.min(items.len().saturating_sub(1));
1870        items
1871            .iter()
1872            .enumerate()
1873            .take(to.saturating_add(1))
1874            .rfind(|(_, item)| item.is_selectable())
1875            .map(|(idx, _)| idx)
1876    }
1877
1878    pub(crate) fn nearest_selectable_index(items: &[ListItem], preferred: usize) -> Option<usize> {
1879        if items.is_empty() {
1880            return None;
1881        }
1882
1883        let preferred = preferred.min(items.len().saturating_sub(1));
1884        if Self::is_selectable_index(items, preferred) {
1885            return Some(preferred);
1886        }
1887
1888        let before = Self::selectable_at_or_before(items, preferred);
1889        let after = Self::selectable_at_or_after(items, preferred);
1890
1891        match (before, after) {
1892            (Some(left), Some(right)) => {
1893                if preferred.abs_diff(right) <= preferred.abs_diff(left) {
1894                    Some(right)
1895                } else {
1896                    Some(left)
1897                }
1898            }
1899            (Some(left), None) => Some(left),
1900            (None, Some(right)) => Some(right),
1901            (None, None) => None,
1902        }
1903    }
1904
1905    pub(crate) fn selection_for_action(
1906        selected: usize,
1907        items: &[ListItem],
1908        action: ScrollAction,
1909    ) -> Option<usize> {
1910        let len = items.len();
1911        if len == 0 {
1912            return None;
1913        }
1914
1915        let mut selected = Self::nearest_selectable_index(items, selected)?;
1916
1917        let next = match action {
1918            ScrollAction::LineUp(lines) => {
1919                for _ in 0..lines {
1920                    if selected == 0 {
1921                        break;
1922                    }
1923
1924                    let Some(prev) = Self::selectable_at_or_before(items, selected - 1) else {
1925                        break;
1926                    };
1927                    selected = prev;
1928                }
1929                selected
1930            }
1931            ScrollAction::LineDown(lines) => {
1932                for _ in 0..lines {
1933                    if selected.saturating_add(1) >= len {
1934                        break;
1935                    }
1936
1937                    let Some(next) = Self::selectable_at_or_after(items, selected + 1) else {
1938                        break;
1939                    };
1940                    selected = next;
1941                }
1942                selected
1943            }
1944            ScrollAction::LineLeft(_) | ScrollAction::LineRight(_) => return None,
1945            ScrollAction::Home => Self::first_selectable_index(items)?,
1946            ScrollAction::End => Self::last_selectable_index(items)?,
1947        };
1948
1949        Some(next)
1950    }
1951
1952    pub(crate) fn selection_for_action_in_len(
1953        selected: usize,
1954        len: usize,
1955        action: ScrollAction,
1956    ) -> Option<usize> {
1957        if len == 0 {
1958            return None;
1959        }
1960
1961        let selected = selected.min(len.saturating_sub(1));
1962        let next = match action {
1963            ScrollAction::LineUp(lines) => selected.saturating_sub(lines),
1964            ScrollAction::LineDown(lines) => (selected + lines).min(len.saturating_sub(1)),
1965            ScrollAction::LineLeft(_) | ScrollAction::LineRight(_) => return None,
1966            ScrollAction::Home => 0,
1967            ScrollAction::End => len.saturating_sub(1),
1968        };
1969
1970        Some(next)
1971    }
1972
1973    pub(crate) fn next_selection(
1974        selected: usize,
1975        items: &[ListItem],
1976        key: &KeyEvent,
1977        scroll_keys: ScrollKeymap,
1978    ) -> Option<usize> {
1979        let action = scroll_action_from_key(key, scroll_keys)?;
1980        Self::selection_for_action(selected, items, action)
1981    }
1982}
1983
1984impl From<List> for Element {
1985    fn from(value: List) -> Self {
1986        Element::new(ElementKind::List(Box::new(value)))
1987    }
1988}
1989
1990fn hash_list_item_layout(item: &ListItem, hasher: &mut impl std::hash::Hasher) {
1991    use std::hash::Hash;
1992
1993    item.role.hash(hasher);
1994    item.active.hash(hasher);
1995    item.status.as_ref().map(ListItemStatus::width).hash(hasher);
1996    item.gutter.as_ref().map(ListItemGutter::width).hash(hasher);
1997    item.gutter_line.hash(hasher);
1998    item.prefix.hash(hasher);
1999    item.prefix_kind.hash(hasher);
2000    item.extra_line_indent.hash(hasher);
2001    item.primary_truncate_description_first.hash(hasher);
2002    item.primary_wrap_label.hash(hasher);
2003    item.primary_wrap_description.hash(hasher);
2004    item.primary_max_label_width.hash(hasher);
2005    item.primary_max_description_width.hash(hasher);
2006    item.symbol_line.hash(hasher);
2007}
2008
2009fn hash_list_item_line_layout(line: &ListItemLine, hasher: &mut impl std::hash::Hasher) {
2010    use std::hash::Hash;
2011
2012    line.truncate_description_first.hash(hasher);
2013    line.wrap_label.hash(hasher);
2014    line.wrap_description.hash(hasher);
2015    line.max_label_width.hash(hasher);
2016    line.max_description_width.hash(hasher);
2017}
2018
2019impl crate::layout::hash::LayoutHash for List {
2020    fn layout_hash(
2021        &self,
2022        hasher: &mut impl std::hash::Hasher,
2023        _recurse: &dyn Fn(&Element) -> Option<u64>,
2024    ) -> Option<()> {
2025        use crate::layout::hash::hash_spans_content;
2026        use std::hash::Hash;
2027
2028        self.width.hash(hasher);
2029        self.height.hash(hasher);
2030        self.border.hash(hasher);
2031        self.scrollbar.hash(hasher);
2032        self.scrollbar_config.variant.hash(hasher);
2033        self.scrollbar_config.gap.hash(hasher);
2034        self.padding.hash(hasher);
2035        self.item_horizontal_padding.hash(hasher);
2036        self.header_horizontal_padding.hash(hasher);
2037        self.symbol_column.hash(hasher);
2038        self.gutter_gap.hash(hasher);
2039        self.gutter_for_non_selectable.hash(hasher);
2040
2041        let needs_content = matches!(self.width, Length::Auto);
2042        let needs_len = matches!(self.height, Length::Auto) || self.scrollbar;
2043
2044        if needs_len {
2045            self.items.len().hash(hasher);
2046        }
2047
2048        if needs_content || needs_len {
2049            self.selected.hash(hasher);
2050            for item in self.items.iter() {
2051                hash_list_item_layout(item, hasher);
2052                hash_spans_content(&item.spans, hasher);
2053                hash_spans_content(&item.description_spans, hasher);
2054                for line in &item.extra_lines {
2055                    hash_list_item_line_layout(line, hasher);
2056                    hash_spans_content(&line.spans, hasher);
2057                    hash_spans_content(&line.description_spans, hasher);
2058                }
2059            }
2060        }
2061
2062        self.title.hash(hasher);
2063        self.empty_text.hash(hasher);
2064        self.selection_symbol.hash(hasher);
2065        self.selection_symbol_right.hash(hasher);
2066        self.active_symbol.hash(hasher);
2067        self.active_symbol_position.hash(hasher);
2068        self.unselected_symbol.hash(hasher);
2069        Some(())
2070    }
2071}
2072
2073#[cfg(test)]
2074mod tests {
2075    use super::*;
2076    use crate::core::element::Element;
2077    use crate::core::event::{KeyCode, KeyEvent, KeyMods};
2078    use crate::layout::hash::element_layout_hash;
2079    fn key(code: KeyCode) -> KeyEvent {
2080        KeyEvent {
2081            code,
2082            mods: KeyMods::default(),
2083        }
2084    }
2085
2086    fn fixture_items() -> Vec<ListItem> {
2087        vec![
2088            ListItem::header("Group A"),
2089            ListItem::new("Alpha"),
2090            ListItem::new("Beta"),
2091            ListItem::spacer(),
2092            ListItem::header("Group B"),
2093            ListItem::new("Gamma"),
2094        ]
2095    }
2096
2097    #[test]
2098    fn keyboard_navigation_skips_headers_and_spacers() {
2099        let items = fixture_items();
2100        let next = List::next_selection(1, &items, &key(KeyCode::Down), ScrollKeymap::default());
2101        assert_eq!(next, Some(2));
2102
2103        let next = List::next_selection(2, &items, &key(KeyCode::Down), ScrollKeymap::default());
2104        assert_eq!(next, Some(5));
2105    }
2106
2107    #[test]
2108    fn home_end_resolve_to_selectable_rows() {
2109        let items = fixture_items();
2110
2111        let home = List::next_selection(5, &items, &key(KeyCode::Home), ScrollKeymap::default());
2112        assert_eq!(home, Some(1));
2113
2114        let end = List::next_selection(1, &items, &key(KeyCode::End), ScrollKeymap::default());
2115        assert_eq!(end, Some(5));
2116    }
2117
2118    #[test]
2119    fn all_non_selectable_rows_have_no_selection_target() {
2120        let items = vec![ListItem::header("Section"), ListItem::spacer()];
2121
2122        assert_eq!(List::first_selectable_index(&items), None);
2123        assert_eq!(
2124            List::next_selection(0, &items, &key(KeyCode::Down), ScrollKeymap::default()),
2125            None
2126        );
2127    }
2128
2129    #[test]
2130    fn auto_height_layout_hash_tracks_extra_line_content() {
2131        let base: Element = List::new()
2132            .items(vec![ListItem::new("Type your own answer")])
2133            .height(Length::Auto)
2134            .into();
2135        let with_answer: Element = List::new()
2136            .items(vec![
2137                ListItem::new("Type your own answer").line("saved answer"),
2138            ])
2139            .height(Length::Auto)
2140            .into();
2141
2142        assert_ne!(
2143            element_layout_hash(&base),
2144            element_layout_hash(&with_answer)
2145        );
2146    }
2147
2148    #[test]
2149    fn layout_hash_tracks_leading_column_config() {
2150        let base: Element = List::new()
2151            .items(vec![ListItem::new("Alpha").gutter(Spinner::new())])
2152            .width(Length::Auto)
2153            .into();
2154        let no_symbol: Element = List::new()
2155            .items(vec![ListItem::new("Alpha").gutter(Spinner::new())])
2156            .width(Length::Auto)
2157            .symbol_column(false)
2158            .into();
2159        let gap: Element = List::new()
2160            .items(vec![ListItem::new("Alpha").gutter(Spinner::new())])
2161            .width(Length::Auto)
2162            .gutter_gap(1)
2163            .into();
2164
2165        assert_ne!(element_layout_hash(&base), element_layout_hash(&no_symbol));
2166        assert_ne!(element_layout_hash(&base), element_layout_hash(&gap));
2167    }
2168
2169    #[test]
2170    fn layout_hash_tracks_row_leading_width_fields() {
2171        let base: Element = List::new()
2172            .items(vec![ListItem::new("A")])
2173            .width(Length::Auto)
2174            .into();
2175        let with_status: Element = List::new()
2176            .items(vec![ListItem::new("A").status_symbol("!!")])
2177            .width(Length::Auto)
2178            .into();
2179        let with_gutter: Element = List::new()
2180            .items(vec![ListItem::new("A").gutter(ListItemGutter::text(">>"))])
2181            .width(Length::Auto)
2182            .into();
2183        let header_with_gutter: Element = List::new()
2184            .items(vec![
2185                ListItem::header("A").gutter(ListItemGutter::text(">>")),
2186            ])
2187            .width(Length::Auto)
2188            .into();
2189
2190        assert_ne!(
2191            element_layout_hash(&base),
2192            element_layout_hash(&with_status)
2193        );
2194        assert_ne!(
2195            element_layout_hash(&base),
2196            element_layout_hash(&with_gutter)
2197        );
2198        assert_ne!(
2199            element_layout_hash(&with_gutter),
2200            element_layout_hash(&header_with_gutter)
2201        );
2202    }
2203
2204    #[test]
2205    fn layout_hash_tracks_wrap_flags_for_auto_height() {
2206        let base: Element = List::new()
2207            .items(vec![ListItem::new("alpha beta gamma")])
2208            .height(Length::Auto)
2209            .into();
2210        let wrapped: Element = List::new()
2211            .items(vec![
2212                ListItem::new("alpha beta gamma").primary_wrap_label(true),
2213            ])
2214            .height(Length::Auto)
2215            .into();
2216
2217        assert_ne!(element_layout_hash(&base), element_layout_hash(&wrapped));
2218    }
2219}
2220
2221pub(crate) mod layout;
2222pub(crate) mod node;
2223pub(crate) mod reconcile;
2224pub(crate) mod utils;
2225
2226pub use node::ListNode;
2227pub(crate) use reconcile::reconcile_list;