1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub struct ListPosition {
17 index: usize,
18 len: usize,
19 depth: usize,
20}
21
22impl ListPosition {
23 pub const fn new(index: usize, len: usize, depth: usize) -> Self {
25 Self { index, len, depth }
26 }
27
28 pub const fn index(self) -> usize {
30 self.index
31 }
32
33 pub const fn len(self) -> usize {
35 self.len
36 }
37
38 pub const fn is_empty(self) -> bool {
40 self.len == 0
41 }
42
43 pub const fn depth(self) -> usize {
45 self.depth
46 }
47}
48
49pub type ListEnumerator = fn(ListPosition) -> String;
51
52pub fn bullet_enumerator(_: ListPosition) -> String {
54 "• ".to_owned()
55}
56
57pub fn dash_enumerator(_: ListPosition) -> String {
59 "- ".to_owned()
60}
61
62pub fn asterisk_enumerator(_: ListPosition) -> String {
64 "* ".to_owned()
65}
66
67pub fn arabic_enumerator(position: ListPosition) -> String {
69 format!("{}. ", position.index().saturating_add(1))
70}
71
72pub 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
89pub 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#[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 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 #[must_use]
155 pub fn item(mut self, item: impl Into<Self>) -> Self {
156 self.items.push(item.into());
157 self
158 }
159
160 #[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 #[must_use]
173 pub const fn hidden(mut self, hidden: bool) -> Self {
174 self.hidden = hidden;
175 self
176 }
177
178 #[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 pub const fn value(&self) -> &T {
187 &self.value
188 }
189
190 pub fn item_nodes(&self) -> &[Self] {
192 &self.items
193 }
194
195 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#[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 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 #[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 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#[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 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 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 #[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 #[must_use]
335 pub fn item_style(self, style: TextStyle) -> Self {
336 self.style(ListRole::Item, style)
337 }
338
339 #[must_use]
341 pub fn enumerator_style(self, style: TextStyle) -> Self {
342 self.style(ListRole::Enumerator, style)
343 }
344
345 #[must_use]
347 pub const fn enumerator(mut self, enumerator: ListEnumerator) -> Self {
348 self.enumerator = enumerator;
349 self
350 }
351
352 #[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 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 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#[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 pub fn new() -> Self {
610 Self::default()
611 }
612
613 #[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 #[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 #[must_use]
633 pub const fn hidden(mut self, hidden: bool) -> Self {
634 self.hidden = hidden;
635 self
636 }
637
638 #[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 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}