Skip to main content

urushi/component/
list.rs

1//! Renderer-neutral lists with an independent public model.
2
3use std::{fmt, sync::Arc};
4
5use crate::text::{PrintableLines, PrintableText, wrap_text};
6use crate::view::{CanvasMeasure, CanvasRequirements};
7use crate::{
8    Canvas, CanvasContext, CanvasItem, CanvasSizing, Composition, Grapheme, ListRole, Position,
9    TextStyle, View,
10};
11
12use super::traversable::normalize_marker;
13
14/// A list item's position among its visible siblings.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub struct ListPosition {
17    index: usize,
18    len: usize,
19    depth: usize,
20}
21
22impl ListPosition {
23    /// Creates a list sibling position.
24    pub const fn new(index: usize, len: usize, depth: usize) -> Self {
25        Self { index, len, depth }
26    }
27
28    /// Returns the zero-based visible sibling index.
29    pub const fn index(self) -> usize {
30        self.index
31    }
32
33    /// Returns the number of visible siblings.
34    pub const fn len(self) -> usize {
35        self.len
36    }
37
38    /// Returns whether there are no visible siblings.
39    pub const fn is_empty(self) -> bool {
40        self.len == 0
41    }
42
43    /// Returns the zero-based nesting depth.
44    pub const fn depth(self) -> usize {
45        self.depth
46    }
47}
48
49/// Produces the single-line marker drawn before one visible list item.
50pub type ListEnumerator = fn(ListPosition) -> String;
51
52/// Draws the default bullet marker.
53pub fn bullet_enumerator(_: ListPosition) -> String {
54    "• ".to_owned()
55}
56
57/// Draws a dash marker.
58pub fn dash_enumerator(_: ListPosition) -> String {
59    "- ".to_owned()
60}
61
62/// Draws an asterisk marker.
63pub fn asterisk_enumerator(_: ListPosition) -> String {
64    "* ".to_owned()
65}
66
67/// Draws a one-based Arabic numeral marker.
68pub fn arabic_enumerator(position: ListPosition) -> String {
69    format!("{}. ", position.index().saturating_add(1))
70}
71
72/// Draws an uppercase alphabetic marker (`A` through `Z`, then `AA`, ...).
73pub fn alphabet_enumerator(position: ListPosition) -> String {
74    let mut index = position.index();
75    let mut letters = Vec::new();
76
77    loop {
78        letters.push((b'A' + (index % 26) as u8) as char);
79        if index < 26 {
80            break;
81        }
82        index = index / 26 - 1;
83    }
84
85    letters.reverse();
86    format!("{}. ", letters.into_iter().collect::<String>())
87}
88
89/// Draws an uppercase Roman numeral marker through 3999.
90///
91/// Larger positions fall back to Arabic numerals because conventional Roman
92/// notation has no single portable representation above `MMMCMXCIX`.
93pub fn roman_enumerator(position: ListPosition) -> String {
94    const NUMERALS: &[(usize, &str)] = &[
95        (1000, "M"),
96        (900, "CM"),
97        (500, "D"),
98        (400, "CD"),
99        (100, "C"),
100        (90, "XC"),
101        (50, "L"),
102        (40, "XL"),
103        (10, "X"),
104        (9, "IX"),
105        (5, "V"),
106        (4, "IV"),
107        (1, "I"),
108    ];
109
110    let mut value = position.index().saturating_add(1);
111    if value > 3999 {
112        return format!("{value}. ");
113    }
114    let mut numeral = String::new();
115    for &(arabic, roman) in NUMERALS {
116        while value >= arabic {
117            value -= arabic;
118            numeral.push_str(roman);
119        }
120    }
121    format!("{numeral}. ")
122}
123
124#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
125struct ItemOffset {
126    start: usize,
127    end: usize,
128}
129
130/// One owned typed value and its recursive nested list items.
131///
132/// `ListItem` is deliberately independent of the Tree component's public node
133/// model, so either public API can evolve without changing the other.
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct ListItem<T> {
136    value: T,
137    items: Vec<Self>,
138    hidden: bool,
139    offset: ItemOffset,
140}
141
142impl<T> ListItem<T> {
143    /// Creates a visible leaf item from a typed value.
144    pub fn new(value: T) -> Self {
145        Self {
146            value,
147            items: Vec::new(),
148            hidden: false,
149            offset: ItemOffset::default(),
150        }
151    }
152
153    /// Appends one nested item.
154    #[must_use]
155    pub fn item(mut self, item: impl Into<Self>) -> Self {
156        self.items.push(item.into());
157        self
158    }
159
160    /// Appends nested items in iteration order.
161    #[must_use]
162    pub fn items<I, N>(mut self, items: I) -> Self
163    where
164        I: IntoIterator<Item = N>,
165        N: Into<Self>,
166    {
167        self.items.extend(items.into_iter().map(Into::into));
168        self
169    }
170
171    /// Includes or excludes this item and all of its descendants.
172    #[must_use]
173    pub const fn hidden(mut self, hidden: bool) -> Self {
174        self.hidden = hidden;
175        self
176    }
177
178    /// Omits `start` nested items from the front and `end` from the back.
179    #[must_use]
180    pub const fn offset(mut self, start: usize, end: usize) -> Self {
181        self.offset = ItemOffset { start, end };
182        self
183    }
184
185    /// Returns this item's value.
186    pub const fn value(&self) -> &T {
187        &self.value
188    }
189
190    /// Returns all nested items before visibility and offset are applied.
191    pub fn item_nodes(&self) -> &[Self] {
192        &self.items
193    }
194
195    /// Returns whether this item and its descendants are excluded.
196    pub const fn is_hidden(&self) -> bool {
197        self.hidden
198    }
199
200    fn visible_items(&self) -> Vec<&Self> {
201        visible_items(&self.items, self.offset)
202    }
203}
204
205impl<T> From<T> for ListItem<T> {
206    fn from(value: T) -> Self {
207        Self::new(value)
208    }
209}
210
211type ItemFormatter<'a, T> = dyn Fn(&T, ListPosition) -> String + 'a;
212type ItemStyler<'a, T> = dyn Fn(&T, ListPosition, ListRole) -> Option<TextStyle> + 'a;
213
214/// Typed formatting and optional role-style overrides for List items.
215///
216/// This policy is separate from [`ListPresentation`], which remains the
217/// type-independent List-wide presentation stored by a Theme. During
218/// composition, the formatter is evaluated once per visible item and the style
219/// callback once for each List role. The composed frame retains only their text
220/// and style results.
221#[derive(Clone)]
222pub struct ListItemPresentation<'a, T> {
223    format: Arc<ItemFormatter<'a, T>>,
224    item_style: Arc<ItemStyler<'a, T>>,
225}
226
227impl<T> fmt::Debug for ListItemPresentation<'_, T> {
228    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
229        formatter.write_str("ListItemPresentation { .. }")
230    }
231}
232
233impl<'a, T: 'a> ListItemPresentation<'a, T> {
234    /// Creates an item presentation with a custom formatter and no style overrides.
235    pub fn new<F>(format: F) -> Self
236    where
237        F: Fn(&T, ListPosition) -> String + 'a,
238    {
239        Self {
240            format: Arc::new(format),
241            item_style: Arc::new(no_item_style::<T>),
242        }
243    }
244
245    /// Replaces the per-item role-style policy.
246    ///
247    /// `None` keeps the [`ListPresentation`] role default. `Some(style)`
248    /// replaces that complete style rather than layering over it.
249    #[must_use]
250    pub fn item_style<S>(mut self, item_style: S) -> Self
251    where
252        S: Fn(&T, ListPosition, ListRole) -> Option<TextStyle> + 'a,
253    {
254        self.item_style = Arc::new(item_style);
255        self
256    }
257}
258
259impl<'a, T> ListItemPresentation<'a, T>
260where
261    T: fmt::Display + 'a,
262{
263    /// Uses the value's canonical [`fmt::Display`] representation.
264    pub fn display() -> Self {
265        Self::new(display_item::<T>)
266    }
267}
268
269fn display_item<T>(value: &T, _: ListPosition) -> String
270where
271    T: fmt::Display,
272{
273    value.to_string()
274}
275
276fn no_item_style<T>(_: &T, _: ListPosition, _: ListRole) -> Option<TextStyle> {
277    None
278}
279
280/// Presentation policy used to compose a [`List`] into a [`View`].
281///
282/// [`ListPresentation::compose`] formats values with [`fmt::Display`]. Use
283/// [`ListPresentation::compose_with`] with a [`ListItemPresentation<T>`] when
284/// values need List-specific formatting or per-item role-style overrides.
285#[derive(Debug, Clone)]
286pub struct ListPresentation {
287    item_style: TextStyle,
288    enumerator_style: TextStyle,
289    enumerator: ListEnumerator,
290    nesting_indent: usize,
291}
292
293impl PartialEq for ListPresentation {
294    fn eq(&self, other: &Self) -> bool {
295        self.item_style == other.item_style
296            && self.enumerator_style == other.enumerator_style
297            && std::ptr::fn_addr_eq(self.enumerator, other.enumerator)
298            && self.nesting_indent == other.nesting_indent
299    }
300}
301
302impl ListPresentation {
303    /// Creates the canonical list presentation with bullet markers.
304    pub fn new(item_style: TextStyle, enumerator_style: TextStyle) -> Self {
305        Self {
306            item_style,
307            enumerator_style,
308            enumerator: bullet_enumerator,
309            nesting_indent: 2,
310        }
311    }
312}
313
314impl ListPresentation {
315    /// Returns the style assigned to one logical list role.
316    pub fn get_style(&self, role: ListRole) -> &TextStyle {
317        match role {
318            ListRole::Item => &self.item_style,
319            ListRole::Enumerator => &self.enumerator_style,
320        }
321    }
322
323    /// Replaces the style assigned to one logical list role.
324    #[must_use]
325    pub fn style(mut self, role: ListRole, style: TextStyle) -> Self {
326        match role {
327            ListRole::Item => self.item_style = style,
328            ListRole::Enumerator => self.enumerator_style = style,
329        }
330        self
331    }
332
333    /// Replaces the item style.
334    #[must_use]
335    pub fn item_style(self, style: TextStyle) -> Self {
336        self.style(ListRole::Item, style)
337    }
338
339    /// Replaces the marker style.
340    #[must_use]
341    pub fn enumerator_style(self, style: TextStyle) -> Self {
342        self.style(ListRole::Enumerator, style)
343    }
344
345    /// Replaces the item-marker policy.
346    #[must_use]
347    pub const fn enumerator(mut self, enumerator: ListEnumerator) -> Self {
348        self.enumerator = enumerator;
349        self
350    }
351
352    /// Sets the fixed horizontal step between nesting levels, in terminal cells.
353    #[must_use]
354    pub const fn nesting_indent(mut self, nesting_indent: usize) -> Self {
355        self.nesting_indent = nesting_indent;
356        self
357    }
358
359    /// Composes displayable list data into an intrinsically sized Canvas.
360    pub fn compose<T>(&self, list: &List<T>) -> View
361    where
362        T: fmt::Display,
363    {
364        self.compose_using(list, &display_item::<T>, &no_item_style::<T>)
365    }
366
367    /// Composes list data with typed item formatting and style overrides.
368    pub fn compose_with<T>(&self, list: &List<T>, items: &ListItemPresentation<'_, T>) -> View {
369        self.compose_using(list, &*items.format, &*items.item_style)
370    }
371
372    fn compose_using<T, F, S>(&self, list: &List<T>, format: &F, style: &S) -> View
373    where
374        F: Fn(&T, ListPosition) -> String + ?Sized,
375        S: Fn(&T, ListPosition, ListRole) -> Option<TextStyle> + ?Sized,
376    {
377        if list.hidden {
378            return View::empty();
379        }
380
381        let items = visible_items(&list.items, list.offset);
382        if items.is_empty() {
383            return View::empty();
384        }
385
386        let mut bound = Vec::new();
387        bind_items(&mut bound, &items, 0, self, format, style);
388        let item = ListCanvasItem(Arc::new(ListFrame { items: bound }));
389        View::canvas(Canvas::new().sizing(item.sizing()).item(item))
390    }
391}
392
393#[derive(Debug, Clone, PartialEq, Eq)]
394struct BoundListItem {
395    value: String,
396    marker: String,
397    item_style: TextStyle,
398    enumerator_style: TextStyle,
399    track_x: usize,
400    marker_x: usize,
401    content_x: usize,
402    track_width: usize,
403}
404
405fn bind_items<T, F, S>(
406    bound: &mut Vec<BoundListItem>,
407    items: &[&ListItem<T>],
408    depth: usize,
409    presentation: &ListPresentation,
410    format: &F,
411    style: &S,
412) where
413    F: Fn(&T, ListPosition) -> String + ?Sized,
414    S: Fn(&T, ListPosition, ListRole) -> Option<TextStyle> + ?Sized,
415{
416    let markers = (0..items.len())
417        .map(|index| {
418            normalize_marker((presentation.enumerator)(ListPosition::new(
419                index,
420                items.len(),
421                depth,
422            )))
423        })
424        .collect::<Vec<_>>();
425    let track_width = markers
426        .iter()
427        .map(|marker| PrintableText::new(marker).width())
428        .max()
429        .unwrap_or(0);
430    let track_x = depth.saturating_mul(presentation.nesting_indent);
431    let content_x = track_x.saturating_add(track_width);
432
433    for (index, (item, marker)) in items.iter().zip(markers).enumerate() {
434        let position = ListPosition::new(index, items.len(), depth);
435        let marker_width = PrintableText::new(&marker).width();
436        bound.push(BoundListItem {
437            value: format(&item.value, position),
438            marker,
439            item_style: style(&item.value, position, ListRole::Item)
440                .unwrap_or_else(|| presentation.item_style.clone()),
441            enumerator_style: style(&item.value, position, ListRole::Enumerator)
442                .unwrap_or_else(|| presentation.enumerator_style.clone()),
443            track_x,
444            marker_x: track_x.saturating_add(track_width - marker_width),
445            content_x,
446            track_width,
447        });
448        let children = item.visible_items();
449        if !children.is_empty() {
450            bind_items(
451                bound,
452                &children,
453                depth.saturating_add(1),
454                presentation,
455                format,
456                style,
457            );
458        }
459    }
460}
461
462#[derive(Debug, Clone, PartialEq)]
463struct ListFrame {
464    items: Vec<BoundListItem>,
465}
466
467impl ListFrame {
468    fn width_requirements(&self) -> CanvasRequirements {
469        let (demand, floor) = self.items.iter().fold((0, 0), |requirements, item| {
470            let lines = item.value.split('\n').map(PrintableText::new);
471            let (content_demand, content_floor) = lines.fold((0, 0), |widths, line| {
472                (
473                    widths.0.max(line.width()),
474                    widths
475                        .1
476                        .max(line.graphemes().map(Grapheme::width).max().unwrap_or(0)),
477                )
478            });
479            (
480                requirements
481                    .0
482                    .max(item.content_x.saturating_add(content_demand)),
483                requirements
484                    .1
485                    .max(item.content_x.saturating_add(content_floor)),
486            )
487        });
488        CanvasRequirements::new(demand, floor)
489    }
490
491    fn rows(&self, width: usize) -> Vec<ListRow> {
492        let mut rows = Vec::new();
493        for (item_index, item) in self.items.iter().enumerate() {
494            let content_width = width.saturating_sub(item.content_x);
495            let mut first = true;
496            for line in item.value.split('\n') {
497                for text in wrap_text(PrintableLines::new(line), content_width) {
498                    rows.push(ListRow {
499                        item_index,
500                        text,
501                        first,
502                    });
503                    first = false;
504                }
505            }
506        }
507        rows
508    }
509
510    fn draw(&self, context: &mut CanvasContext) {
511        let width = context.size().width();
512        for (y, row) in self.rows(width).into_iter().enumerate() {
513            let item = &self.items[row.item_index];
514            if row.first {
515                let visible_track = width.saturating_sub(item.track_x).min(item.track_width);
516                if visible_track > 0 {
517                    context.text_with(
518                        Position::new(position(item.track_x), position(y)),
519                        " ".repeat(visible_track),
520                        item.enumerator_style.clone(),
521                        Composition::Replace,
522                    );
523                }
524                context.text_with(
525                    Position::new(position(item.marker_x), position(y)),
526                    item.marker.clone(),
527                    item.enumerator_style.clone(),
528                    Composition::Replace,
529                );
530            }
531            context.text_with(
532                Position::new(position(item.content_x), position(y)),
533                row.text,
534                item.item_style.clone(),
535                Composition::Replace,
536            );
537        }
538    }
539}
540
541#[derive(Debug, Clone, PartialEq, Eq)]
542struct ListRow {
543    item_index: usize,
544    text: String,
545    first: bool,
546}
547
548#[derive(Debug, Clone, PartialEq)]
549struct ListCanvasItem(Arc<ListFrame>);
550
551impl ListCanvasItem {
552    fn sizing(&self) -> CanvasSizing {
553        CanvasSizing::intrinsic(self.clone())
554    }
555}
556
557impl CanvasMeasure for ListCanvasItem {
558    fn width_requirements(&self) -> CanvasRequirements {
559        self.0.width_requirements()
560    }
561
562    fn height_requirements(&self, width: usize) -> CanvasRequirements {
563        CanvasRequirements::new(self.0.rows(width).len(), 0)
564    }
565}
566
567impl CanvasItem for ListCanvasItem {
568    fn draw(&self, context: &mut CanvasContext) {
569        self.0.draw(context);
570    }
571}
572
573fn position(value: usize) -> i64 {
574    i64::try_from(value).unwrap_or(i64::MAX)
575}
576
577/// Owned typed list data independent of presentation policy.
578///
579/// List owns its public model independently from Tree. Its presentation binds
580/// this recursive data without coupling either public data API to the other
581/// component.
582///
583/// Offsets are supported with [`List::offset`] and [`ListItem::offset`]. A
584/// filter callback is intentionally not part of this API: omit items before
585/// construction or mark individual [`ListItem`] values hidden instead.
586///
587/// [`List::new`] creates the same empty container for every value type. Calls
588/// that append an item infer `T` from that value; an empty List states its type
589/// explicitly or through its surrounding context.
590#[derive(Debug, Clone, PartialEq, Eq)]
591pub struct List<T> {
592    items: Vec<ListItem<T>>,
593    hidden: bool,
594    offset: ItemOffset,
595}
596
597impl<T> Default for List<T> {
598    fn default() -> Self {
599        Self {
600            items: Vec::new(),
601            hidden: false,
602            offset: ItemOffset::default(),
603        }
604    }
605}
606
607impl<T> List<T> {
608    /// Creates an empty typed list.
609    pub fn new() -> Self {
610        Self::default()
611    }
612
613    /// Appends one top-level item.
614    #[must_use]
615    pub fn item(mut self, item: impl Into<ListItem<T>>) -> Self {
616        self.items.push(item.into());
617        self
618    }
619
620    /// Appends top-level items in iteration order.
621    #[must_use]
622    pub fn items<I, N>(mut self, items: I) -> Self
623    where
624        I: IntoIterator<Item = N>,
625        N: Into<ListItem<T>>,
626    {
627        self.items.extend(items.into_iter().map(Into::into));
628        self
629    }
630
631    /// Includes or excludes the complete list.
632    #[must_use]
633    pub const fn hidden(mut self, hidden: bool) -> Self {
634        self.hidden = hidden;
635        self
636    }
637
638    /// Omits `start` top-level items from the front and `end` from the back.
639    #[must_use]
640    pub const fn offset(mut self, start: usize, end: usize) -> Self {
641        self.offset = ItemOffset { start, end };
642        self
643    }
644
645    /// Returns all owned top-level items before visibility and offset are applied.
646    pub fn item_nodes(&self) -> &[ListItem<T>] {
647        &self.items
648    }
649}
650
651fn visible_items<T>(items: &[ListItem<T>], offset: ItemOffset) -> Vec<&ListItem<T>> {
652    let end = items.len().saturating_sub(offset.end);
653    if offset.start >= end {
654        return Vec::new();
655    }
656    items[offset.start..end]
657        .iter()
658        .filter(|item| !item.hidden)
659        .collect()
660}
661
662#[cfg(test)]
663mod tests {
664    use std::sync::atomic::{AtomicUsize, Ordering};
665
666    use super::*;
667    use crate::test_support::{plain, style_at};
668    use crate::{
669        Available, Color, ComponentTheme, SemanticTokens, StyledGrapheme, measure, resolve,
670    };
671
672    fn styles() -> ComponentTheme {
673        ComponentTheme::from_tokens(&SemanticTokens {
674            text: Color::WHITE,
675            text_muted: Color::BRIGHT_BLACK,
676            background: Color::BLACK,
677            surface: Color::BLACK,
678            accent: Color::CYAN,
679            accent_text: Color::BLACK,
680            success: Color::GREEN,
681            warning: Color::YELLOW,
682            error: Color::RED,
683            border: Color::BRIGHT_BLACK,
684        })
685    }
686
687    fn plain_at(view: &View, width: usize) -> String {
688        resolve(view, Available::columns(width))
689            .unwrap()
690            .rows()
691            .iter()
692            .map(|row| {
693                row.iter()
694                    .map(StyledGrapheme::symbol)
695                    .collect::<String>()
696                    .trim_end()
697                    .to_owned()
698            })
699            .collect::<Vec<_>>()
700            .join("\n")
701    }
702
703    #[test]
704    fn renders_empty_flat_and_nested_lists() {
705        assert!(measure(&styles().list().compose(&List::<String>::new())).is_empty());
706
707        let list = List::<&str>::new()
708            .item("alpha")
709            .item(ListItem::new("beta").items(["nested", "last"]))
710            .item("omega");
711
712        assert_eq!(
713            plain(&styles().list().compose(&list)),
714            "• alpha\n• beta\n  • nested\n  • last\n• omega"
715        );
716    }
717
718    #[test]
719    fn list_items_are_independent_from_tree_nodes() {
720        fn accepts_list_item(_: ListItem<&str>) {}
721        accepts_list_item(ListItem::new("list").item("nested"));
722
723        assert_eq!(*ListItem::new("item").value(), "item");
724        assert_eq!(
725            ListItem::new("item")
726                .items(["one", "two"])
727                .item_nodes()
728                .len(),
729            2
730        );
731    }
732
733    #[derive(Debug, Clone, PartialEq, Eq)]
734    struct Task {
735        id: usize,
736        label: &'static str,
737        selected: bool,
738    }
739
740    impl fmt::Display for Task {
741        fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
742            formatter.write_str(self.label)
743        }
744    }
745
746    #[test]
747    fn typed_values_reach_item_presentation_after_visibility_and_offsets() {
748        let selected_item = TextStyle::new().foreground(Color::GREEN);
749        let selected_enumerator = TextStyle::new().foreground(Color::BLUE);
750        let list = List::<Task>::new()
751            .item(Task {
752                id: 0,
753                label: "skip",
754                selected: false,
755            })
756            .item(
757                ListItem::new(Task {
758                    id: 7,
759                    label: "parent",
760                    selected: true,
761                })
762                .items([
763                    ListItem::new(Task {
764                        id: 8,
765                        label: "hidden",
766                        selected: false,
767                    })
768                    .hidden(true),
769                    ListItem::new(Task {
770                        id: 9,
771                        label: "child",
772                        selected: false,
773                    }),
774                ]),
775            )
776            .item(Task {
777                id: 10,
778                label: "drop",
779                selected: false,
780            })
781            .offset(1, 1);
782        let item_style = selected_item.clone();
783        let enumerator_style = selected_enumerator.clone();
784        let items = ListItemPresentation::new(|task: &Task, position| {
785            format!(
786                "{}:{}:{}/{}@{}",
787                task.id,
788                task.label,
789                position.index(),
790                position.len(),
791                position.depth()
792            )
793        })
794        .item_style(move |task, _, role| {
795            task.selected.then(|| match role {
796                ListRole::Item => item_style.clone(),
797                ListRole::Enumerator => enumerator_style.clone(),
798            })
799        });
800        let component_styles = styles();
801        let presentation = component_styles.list();
802        let view = presentation.compose_with(&list, &items);
803
804        assert_eq!(list.item_nodes()[1].value().id, 7);
805        assert_eq!(plain(&view), "• 7:parent:0/1@0\n  • 9:child:0/1@1");
806        assert_eq!(style_at(&view, 0, 0), selected_enumerator);
807        assert_eq!(style_at(&view, 0, 2), selected_item);
808        assert_eq!(view, presentation.compose_with(&list, &items.clone()));
809    }
810
811    #[test]
812    fn default_presentation_formats_typed_display_values() {
813        let list = List::new().items([
814            Task {
815                id: 1,
816                label: "first",
817                selected: false,
818            },
819            Task {
820                id: 2,
821                label: "second",
822                selected: true,
823            },
824        ]);
825
826        assert_eq!(plain(&styles().list().compose(&list)), "• first\n• second");
827    }
828
829    #[test]
830    fn supports_all_builtin_enumerators() {
831        let items = ["one", "two", "three"];
832        let cases = [
833            (bullet_enumerator as ListEnumerator, "• one\n• two\n• three"),
834            (dash_enumerator, "- one\n- two\n- three"),
835            (asterisk_enumerator, "* one\n* two\n* three"),
836            (arabic_enumerator, "1. one\n2. two\n3. three"),
837            (alphabet_enumerator, "A. one\nB. two\nC. three"),
838            (roman_enumerator, "  I. one\n II. two\nIII. three"),
839        ];
840
841        for (enumerator, expected) in cases {
842            let list = List::new().items(items);
843            let presentation = styles().list().clone().enumerator(enumerator);
844            let view = presentation.compose(&list);
845            assert_eq!(plain(&view), expected);
846        }
847    }
848
849    #[test]
850    fn alphabet_enumerator_extends_beyond_one_letter() {
851        let positions = [
852            (25, "Z. "),
853            (26, "AA. "),
854            (51, "AZ. "),
855            (701, "ZZ. "),
856            (702, "AAA. "),
857        ];
858        for (index, expected) in positions {
859            assert_eq!(
860                alphabet_enumerator(ListPosition::new(index, 703, 0)),
861                expected
862            );
863        }
864    }
865
866    #[test]
867    fn numeric_enumerators_are_bounded_at_extreme_public_positions() {
868        assert_eq!(
869            roman_enumerator(ListPosition::new(3998, 3999, usize::MAX)),
870            "MMMCMXCIX. "
871        );
872        assert_eq!(
873            roman_enumerator(ListPosition::new(3999, 4000, usize::MAX)),
874            "4000. "
875        );
876        assert_eq!(
877            roman_enumerator(ListPosition::new(usize::MAX, usize::MAX, usize::MAX)),
878            format!("{}. ", usize::MAX)
879        );
880        assert_eq!(
881            arabic_enumerator(ListPosition::new(usize::MAX, usize::MAX, usize::MAX)),
882            format!("{}. ", usize::MAX)
883        );
884    }
885
886    fn custom_enumerator(position: ListPosition) -> String {
887        if position.depth() == 0 {
888            format!("[{}] ", position.index())
889        } else {
890            "• ".to_owned()
891        }
892    }
893
894    #[test]
895    fn supports_custom_markers_styles_visibility_and_offsets() {
896        let item = TextStyle::new().foreground(Color::GREEN);
897        let enumerator = TextStyle::new().foreground(Color::BLUE);
898        let list = List::<&str>::new()
899            .items([
900                ListItem::new("skip"),
901                ListItem::new("parent")
902                    .items([ListItem::new("hidden").hidden(true), ListItem::new("child")]),
903                ListItem::new("drop"),
904            ])
905            .offset(1, 1);
906        let presentation = styles()
907            .list()
908            .clone()
909            .enumerator(custom_enumerator)
910            .item_style(item.clone())
911            .enumerator_style(enumerator.clone());
912        let view = presentation.compose(&list);
913
914        assert_eq!(plain(&view), "[0] parent\n  • child");
915        assert_eq!(style_at(&view, 0, 0), enumerator);
916        assert_eq!(style_at(&view, 0, 4), item);
917        assert_eq!(style_at(&view, 1, 2), enumerator);
918        assert_eq!(style_at(&view, 1, 4), item);
919    }
920
921    #[test]
922    fn nested_offsets_apply_before_hidden_items() {
923        let list = List::<&str>::new().item(
924            ListItem::new("parent")
925                .items(["skip", "hidden", "kept", "drop"])
926                .offset(1, 1),
927        );
928        let hidden_list = List::<&str>::new().item(ListItem::new("parent").items([
929            ListItem::new("visible"),
930            ListItem::new("hidden").hidden(true),
931        ]));
932        let component_styles = styles();
933        let view = component_styles.list().compose(&list);
934        let hidden = component_styles.list().compose(&hidden_list);
935
936        assert_eq!(plain(&view), "• parent\n  • hidden\n  • kept");
937        assert_eq!(plain(&hidden), "• parent\n  • visible");
938    }
939
940    #[test]
941    fn aligns_multiline_cjk_items_by_terminal_cell_width() {
942        let list = List::new().items(["日本語\nsecond", "終端\n続き"]);
943        let presentation = styles().list().clone().enumerator(arabic_enumerator);
944        let view = presentation.compose(&list);
945
946        assert_eq!(plain(&view), "1. 日本語\n   second\n2. 終端\n   続き");
947    }
948
949    #[test]
950    fn selected_width_reflows_without_recomposing() {
951        let view = styles().list().compose(&List::new().item("alpha beta"));
952
953        assert_eq!(plain_at(&view, 12), "• alpha beta");
954        assert_eq!(plain_at(&view, 8), "• alpha\n  beta");
955    }
956
957    fn hanging_enumerator(position: ListPosition) -> String {
958        if position.depth() > 0 {
959            "• ".to_owned()
960        } else if position.index() == 0 {
961            "1. ".to_owned()
962        } else {
963            "1000. ".to_owned()
964        }
965    }
966
967    #[test]
968    fn aligns_markers_in_local_sibling_tracks() {
969        let list = List::new()
970            .item(ListItem::new("parent").item("child"))
971            .item("last");
972        let view = styles()
973            .list()
974            .clone()
975            .enumerator(hanging_enumerator)
976            .compose(&list);
977
978        assert_eq!(plain(&view), "   1. parent\n  • child\n1000. last");
979    }
980
981    #[test]
982    fn marker_style_covers_geometric_alignment_cells() {
983        let marker = TextStyle::new().background(Color::BLUE);
984        let view = styles()
985            .list()
986            .clone()
987            .enumerator(hanging_enumerator)
988            .enumerator_style(marker.clone())
989            .compose(&List::new().items(["first", "second"]));
990
991        assert_eq!(style_at(&view, 0, 0), marker);
992    }
993
994    #[test]
995    fn preserves_explicit_and_wrapped_continuations_including_trailing_empty_line() {
996        let view = styles().list().compose(&List::new().item("ab cd\n日\n"));
997
998        assert_eq!(plain_at(&view, 5), "• ab\n  cd\n  日\n");
999    }
1000
1001    fn normalized_wide_enumerator(position: ListPosition) -> String {
1002        if position.index() == 0 {
1003            "👩‍💻\r\n".to_owned()
1004        } else {
1005            "• ".to_owned()
1006        }
1007    }
1008
1009    #[test]
1010    fn normalizes_and_aligns_cjk_and_emoji_markers() {
1011        let view = styles()
1012            .list()
1013            .clone()
1014            .enumerator(normalized_wide_enumerator)
1015            .compose(&List::new().items(["one", "two"]));
1016
1017        assert_eq!(plain(&view), "👩‍💻 one\n • two");
1018    }
1019
1020    #[test]
1021    fn width_requirements_include_prefix_and_widest_grapheme() {
1022        let list = List::new().item("a日本語");
1023        let items = visible_items(&list.items, list.offset);
1024        let presentation = styles().list().clone();
1025        let mut bound = Vec::new();
1026        bind_items(
1027            &mut bound,
1028            &items,
1029            0,
1030            &presentation,
1031            &display_item::<&str>,
1032            &no_item_style::<&str>,
1033        );
1034        let requirements = ListFrame { items: bound }.width_requirements();
1035
1036        assert_eq!(requirements.demand(), 9);
1037        assert_eq!(requirements.floor(), 4);
1038    }
1039
1040    static ENUMERATOR_CALLS: AtomicUsize = AtomicUsize::new(0);
1041    static TEXT_CALLS: AtomicUsize = AtomicUsize::new(0);
1042    static ITEM_STYLE_CALLS: AtomicUsize = AtomicUsize::new(0);
1043    static ENUMERATOR_STYLE_CALLS: AtomicUsize = AtomicUsize::new(0);
1044
1045    fn counted_enumerator(_: ListPosition) -> String {
1046        ENUMERATOR_CALLS.fetch_add(1, Ordering::Relaxed);
1047        "• ".to_owned()
1048    }
1049
1050    fn counted_text(value: &&str, _: ListPosition) -> String {
1051        TEXT_CALLS.fetch_add(1, Ordering::Relaxed);
1052        (*value).to_owned()
1053    }
1054
1055    fn counted_style(_: &&str, _: ListPosition, role: ListRole) -> Option<TextStyle> {
1056        match role {
1057            ListRole::Item => &ITEM_STYLE_CALLS,
1058            ListRole::Enumerator => &ENUMERATOR_STYLE_CALLS,
1059        }
1060        .fetch_add(1, Ordering::Relaxed);
1061        None
1062    }
1063
1064    #[test]
1065    fn evaluates_enumerators_only_while_composing() {
1066        ENUMERATOR_CALLS.store(0, Ordering::Relaxed);
1067        TEXT_CALLS.store(0, Ordering::Relaxed);
1068        ITEM_STYLE_CALLS.store(0, Ordering::Relaxed);
1069        ENUMERATOR_STYLE_CALLS.store(0, Ordering::Relaxed);
1070        let presentation = styles().list().clone().enumerator(counted_enumerator);
1071        let items = ListItemPresentation::new(counted_text).item_style(counted_style);
1072        let view = presentation.compose_with(&List::new().items(["one", "two"]), &items);
1073        assert_eq!(ENUMERATOR_CALLS.load(Ordering::Relaxed), 2);
1074        assert_eq!(TEXT_CALLS.load(Ordering::Relaxed), 2);
1075        assert_eq!(ITEM_STYLE_CALLS.load(Ordering::Relaxed), 2);
1076        assert_eq!(ENUMERATOR_STYLE_CALLS.load(Ordering::Relaxed), 2);
1077
1078        let _ = resolve(&view, Available::columns(8));
1079        let _ = resolve(&view, Available::columns(4));
1080        assert_eq!(ENUMERATOR_CALLS.load(Ordering::Relaxed), 2);
1081        assert_eq!(TEXT_CALLS.load(Ordering::Relaxed), 2);
1082        assert_eq!(ITEM_STYLE_CALLS.load(Ordering::Relaxed), 2);
1083        assert_eq!(ENUMERATOR_STYLE_CALLS.load(Ordering::Relaxed), 2);
1084    }
1085
1086    #[test]
1087    fn extreme_nesting_arithmetic_saturates() {
1088        let list = List::<&str>::new().item(ListItem::new("parent").item("child"));
1089        let items = visible_items(&list.items, list.offset);
1090        let presentation = styles().list().clone().nesting_indent(usize::MAX);
1091        let mut bound = Vec::new();
1092
1093        bind_items(
1094            &mut bound,
1095            &items,
1096            0,
1097            &presentation,
1098            &display_item::<&str>,
1099            &no_item_style::<&str>,
1100        );
1101
1102        assert_eq!(bound[1].track_x, usize::MAX);
1103        assert_eq!(bound[1].content_x, usize::MAX);
1104    }
1105
1106    #[test]
1107    fn hidden_list_returns_an_empty_view() {
1108        let list = List::new().item("item").hidden(true);
1109        assert!(measure(&styles().list().compose(&list)).is_empty());
1110    }
1111}