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