Skip to main content

vtcode_ui/design/
list.rs

1//! Canonical list-row builders for TUI modals and command menus.
2//!
3//! Call sites should not assemble `InlineListItem { .. }` literals directly.
4//! These helpers encode the shared visual contract: group headers, title ·
5//! value · description rows, toned actions, and current/choice rows.
6//!
7//! Wire types stay in `vtcode_commons::ui_protocol` (protocol layer); this
8//! module is the design-system factory that returns them.
9
10pub use vtcode_commons::ui_protocol::{InlineItemKind, InlineListItem, InlineListSelection, InlineStatus, InlineTone};
11
12/// Section header: bold title with blank spacing above and below.
13#[must_use]
14pub fn group_header(title: impl Into<String>) -> InlineListItem {
15    InlineListItem::group_header(title)
16}
17
18/// Full-width rule between option groups.
19#[must_use]
20pub fn group_divider() -> InlineListItem {
21    InlineListItem::group_divider()
22}
23
24/// Non-selectable dimmed note (never a group header).
25#[must_use]
26pub fn hint(text: impl Into<String>) -> InlineListItem {
27    InlineListItem {
28        title: text.into(),
29        kind: InlineItemKind::Hint,
30        ..InlineListItem::default()
31    }
32}
33
34/// Title + optional accent value + dimmed description.
35///
36/// Used for settings keys and model capability rows (`value` is the live
37/// value or capability summary; `description` is help text).
38#[must_use]
39pub fn setting(
40    title: impl Into<String>,
41    value: Option<String>,
42    description: Option<String>,
43    selection: Option<InlineListSelection>,
44) -> InlineListItem {
45    InlineListItem {
46        title: title.into(),
47        value,
48        subtitle: description,
49        selection,
50        kind: InlineItemKind::Setting,
51        badge_tone: InlineTone::Accent,
52        ..InlineListItem::default()
53    }
54}
55
56/// Imperative row (Back, Reset, Refresh, Pick model, …) with an explicit tone.
57#[must_use]
58pub fn action(
59    title: impl Into<String>,
60    description: impl Into<String>,
61    badge: Option<String>,
62    tone: InlineTone,
63    selection: Option<InlineListSelection>,
64) -> InlineListItem {
65    let title = title.into();
66    let description = description.into();
67    let search_value = format!("{title} {description}").to_ascii_lowercase();
68    InlineListItem {
69        subtitle: (!description.trim().is_empty()).then_some(description),
70        title,
71        badge,
72        selection,
73        kind: InlineItemKind::Action,
74        badge_tone: tone,
75        search_value: Some(search_value),
76        ..InlineListItem::default()
77    }
78}
79
80/// Selectable choice in a list (reasoning level, service tier, list entry).
81#[must_use]
82pub fn choice(
83    title: impl Into<String>,
84    description: Option<String>,
85    selection: Option<InlineListSelection>,
86) -> InlineListItem {
87    let title = title.into();
88    let search_value = format!("{title} {}", description.clone().unwrap_or_default()).to_ascii_lowercase();
89    InlineListItem {
90        subtitle: description,
91        title,
92        selection,
93        kind: InlineItemKind::Item,
94        search_value: Some(search_value),
95        ..InlineListItem::default()
96    }
97}
98
99/// Choice row that is live/current (kept setting, active model).
100#[must_use]
101pub fn current_choice(
102    title: impl Into<String>,
103    description: Option<String>,
104    selection: Option<InlineListSelection>,
105) -> InlineListItem {
106    choice(title, description, selection).with_badge("Current", InlineTone::Current)
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    #[test]
114    fn group_header_is_non_selectable_header() {
115        let row = group_header("Anthropic");
116        assert!(row.selection.is_none());
117        assert_eq!(row.kind, InlineItemKind::Header);
118        assert!(row.is_header());
119    }
120
121    #[test]
122    fn setting_carries_value_and_accent_tone() {
123        let row = setting("Theme", Some("mono".into()), Some("ANSI theme".into()), None);
124        assert_eq!(row.value.as_deref(), Some("mono"));
125        assert_eq!(row.subtitle.as_deref(), Some("ANSI theme"));
126        assert_eq!(row.kind, InlineItemKind::Setting);
127        assert_eq!(row.badge_tone, InlineTone::Accent);
128    }
129
130    #[test]
131    fn action_uses_explicit_tone_and_skips_empty_description() {
132        let row = action("Reset", "", Some("Destructive".to_string()), InlineTone::Danger, None);
133        assert_eq!(row.kind, InlineItemKind::Action);
134        assert_eq!(row.badge_tone, InlineTone::Danger);
135        assert!(row.subtitle.is_none());
136    }
137
138    #[test]
139    fn current_choice_marks_current_badge() {
140        let row = current_choice("Keep current (high)", Some("desc".into()), None);
141        assert_eq!(row.badge.as_deref(), Some("Current"));
142        assert_eq!(row.badge_tone, InlineTone::Current);
143    }
144
145    #[test]
146    fn choice_seeds_search_from_title_and_description() {
147        let row = choice("Flex", Some("lower cost".to_string()), None);
148        assert_eq!(row.search_value.as_deref(), Some("flex lower cost"));
149    }
150
151    #[test]
152    fn hint_is_not_a_header() {
153        let row = hint("Press Esc to reset");
154        assert_eq!(row.kind, InlineItemKind::Hint);
155        assert!(!row.is_header());
156    }
157}