Skip to main content

oino_ui/widgets/
list.rs

1//! ratatui `List` (§4.5.1): [`List`], [`ListItem`], [`ListState`] and helpers.
2
3use serde::{Deserialize, Serialize};
4
5use crate::extras::icon::Icon;
6use crate::node::{ActionId, Common, IntoNode, Key, Node};
7use crate::style::Style;
8use crate::text::{Line, Text};
9
10/// ratatui `ListDirection`.
11#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
12pub enum ListDirection {
13    /// First item at the top.
14    #[default]
15    TopToBottom,
16    /// First item at the bottom.
17    BottomToTop,
18}
19
20/// ratatui `HighlightSpacing`.
21#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
22pub enum HighlightSpacing {
23    /// Always reserve space for the highlight symbol.
24    #[default]
25    Always,
26    /// Reserve space only when an item is selected.
27    WhenSelected,
28    /// Never reserve space.
29    Never,
30}
31
32/// Selection state: one key, or several when `multi` (§4.5.1).
33#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
34#[serde(rename_all = "camelCase")]
35pub struct ListState {
36    /// Selected item key(s) (by `key`, §4.1).
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub selected: Option<Selection>,
39    /// Scroll offset in items.
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub offset: Option<u32>,
42}
43
44impl ListState {
45    /// State with one selected key.
46    #[must_use]
47    pub fn with_selected(key: impl Into<Key>) -> Self {
48        Self {
49            selected: Some(Selection::One(key.into())),
50            offset: None,
51        }
52    }
53
54    /// State with several selected keys (`multi` lists).
55    #[must_use]
56    pub fn with_selected_many(keys: Vec<Key>) -> Self {
57        Self {
58            selected: Some(Selection::Many(keys)),
59            offset: None,
60        }
61    }
62}
63
64/// Selected item keys: one, or several for `multi` lists.
65#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
66#[serde(untagged)]
67pub enum Selection {
68    /// Single selection.
69    One(Key),
70    /// Multi selection (`List.multi`).
71    Many(Vec<Key>),
72}
73
74/// ratatui `ListItem`: a `Text` shorthand or the full object with key / detail /
75/// badge / icon.
76#[allow(
77    clippy::large_enum_variant,
78    reason = "list items are built once per list; boxing the content adds noise"
79)]
80#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
81#[serde(untagged)]
82pub enum ListItem {
83    /// Shorthand: item content as text.
84    Text(Text),
85    /// Full form.
86    Full {
87        /// Identity among siblings (defaults to the positional key, §4.1).
88        #[serde(default, skip_serializing_if = "Option::is_none")]
89        key: Option<Key>,
90        /// Item content.
91        content: Text,
92        /// Item style.
93        #[serde(default, skip_serializing_if = "Option::is_none")]
94        style: Option<Style>,
95        /// GUI extra: muted second line.
96        #[serde(default, skip_serializing_if = "Option::is_none")]
97        detail: Option<Line>,
98        /// GUI extra: right-aligned badge.
99        #[serde(default, skip_serializing_if = "Option::is_none")]
100        badge: Option<Line>,
101        /// GUI extra: leading icon.
102        #[serde(default, skip_serializing_if = "Option::is_none")]
103        icon: Option<Icon>,
104        /// GUI extra: greyed out, not activatable.
105        #[serde(default, skip_serializing_if = "Option::is_none")]
106        disabled: Option<bool>,
107    },
108}
109
110impl ListItem {
111    /// Item from any text-like value.
112    #[must_use]
113    pub fn new(content: impl Into<Text>) -> Self {
114        Self::Full {
115            key: None,
116            content: content.into(),
117            style: None,
118            detail: None,
119            badge: None,
120            icon: None,
121            disabled: None,
122        }
123    }
124
125    /// Sets the item key.
126    #[must_use]
127    pub fn key(mut self, key: impl AsRef<str>) -> Self {
128        if let Self::Full { key: slot, .. } = &mut self {
129            *slot = Some(key.as_ref().to_owned());
130        }
131        self
132    }
133
134    /// Sets the muted second line.
135    #[must_use]
136    pub fn detail(mut self, detail: impl Into<Line>) -> Self {
137        if let Self::Full { detail: slot, .. } = &mut self {
138            *slot = Some(detail.into());
139        }
140        self
141    }
142
143    /// Sets the right-aligned badge.
144    #[must_use]
145    pub fn badge(mut self, badge: impl Into<Line>) -> Self {
146        if let Self::Full { badge: slot, .. } = &mut self {
147            *slot = Some(badge.into());
148        }
149        self
150    }
151
152    /// Sets the leading icon.
153    #[must_use]
154    pub fn icon(mut self, icon: Icon) -> Self {
155        if let Self::Full { icon: slot, .. } = &mut self {
156            *slot = Some(icon);
157        }
158        self
159    }
160
161    /// Greys the item out.
162    #[must_use]
163    pub fn disabled(mut self, disabled: bool) -> Self {
164        if let Self::Full { disabled: slot, .. } = &mut self {
165            *slot = Some(disabled);
166        }
167        self
168    }
169
170    /// Sets the item style.
171    #[must_use]
172    pub fn style(mut self, style: Style) -> Self {
173        if let Self::Full { style: slot, .. } = &mut self {
174            *slot = Some(style);
175        }
176        self
177    }
178
179    /// The item's content text.
180    #[must_use]
181    pub fn content(&self) -> &Text {
182        match self {
183            Self::Text(text) => text,
184            Self::Full { content, .. } => content,
185        }
186    }
187}
188
189impl From<Text> for ListItem {
190    fn from(text: Text) -> Self {
191        Self::Text(text)
192    }
193}
194
195impl From<&str> for ListItem {
196    fn from(s: &str) -> Self {
197        Self::Text(Text::from(s))
198    }
199}
200
201impl From<String> for ListItem {
202    fn from(s: String) -> Self {
203        Self::Text(Text::from(s))
204    }
205}
206
207/// ratatui `List` (§4.5.1). Selection is by item key; actions fire on select /
208/// activate.
209#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
210#[serde(rename_all = "camelCase")]
211pub struct List {
212    /// Common node props (`key`, `style`, `block`, `fallback`).
213    #[serde(flatten)]
214    pub common: Common,
215    /// Items (≤ 5 000, §4.8).
216    pub items: Vec<ListItem>,
217    /// Selection / offset state (wins when it changes, §4.6).
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub state: Option<ListState>,
220    /// Style of the selected item.
221    #[serde(skip_serializing_if = "Option::is_none")]
222    pub highlight_style: Option<Style>,
223    /// Prefix drawn before the selected item.
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub highlight_symbol: Option<String>,
226    /// Draw the highlight symbol on unselected items too (ratatui `repeat`).
227    #[serde(skip_serializing_if = "Option::is_none")]
228    pub repeat_highlight_symbol: Option<bool>,
229    /// Space reserved for the highlight symbol.
230    #[serde(skip_serializing_if = "Option::is_none")]
231    pub highlight_spacing: Option<HighlightSpacing>,
232    /// Item order.
233    #[serde(skip_serializing_if = "Option::is_none")]
234    pub direction: Option<ListDirection>,
235    /// Items kept above / below the selection when scrolling.
236    #[serde(skip_serializing_if = "Option::is_none")]
237    pub scroll_padding: Option<u16>,
238    /// GUI extra: multi-select (Space toggles; `onSelect` → `key[]`).
239    #[serde(skip_serializing_if = "Option::is_none")]
240    pub multi: Option<bool>,
241    /// GUI extra: native scrollbar.
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub scrollbar: Option<bool>,
244    /// Action on selection change (item key, or `key[]` when `multi`).
245    #[serde(skip_serializing_if = "Option::is_none")]
246    pub on_select: Option<ActionId>,
247    /// Action on Enter (item key).
248    #[serde(skip_serializing_if = "Option::is_none")]
249    pub on_activate: Option<ActionId>,
250}
251
252impl List {
253    /// A list from items.
254    #[must_use]
255    pub fn new(items: impl IntoIterator<Item = impl Into<ListItem>>) -> Self {
256        Self {
257            items: items.into_iter().map(Into::into).collect(),
258            ..Self::default()
259        }
260    }
261
262    /// Replaces the items.
263    #[must_use]
264    pub fn items(mut self, items: impl IntoIterator<Item = impl Into<ListItem>>) -> Self {
265        self.items = items.into_iter().map(Into::into).collect();
266        self
267    }
268
269    /// Sets the selection / offset state.
270    #[must_use]
271    pub fn state(mut self, state: ListState) -> Self {
272        self.state = Some(state);
273        self
274    }
275
276    /// Sets the selected-item style.
277    #[must_use]
278    pub fn highlight_style(mut self, style: Style) -> Self {
279        self.highlight_style = Some(style);
280        self
281    }
282
283    /// Sets the prefix drawn before the selected item.
284    #[must_use]
285    pub fn highlight_symbol(mut self, symbol: impl Into<String>) -> Self {
286        self.highlight_symbol = Some(symbol.into());
287        self
288    }
289
290    /// Draws the highlight symbol on unselected items too.
291    #[must_use]
292    pub fn repeat_highlight_symbol(mut self, repeat: bool) -> Self {
293        self.repeat_highlight_symbol = Some(repeat);
294        self
295    }
296
297    /// Sets the highlight-symbol spacing policy.
298    #[must_use]
299    pub fn highlight_spacing(mut self, spacing: HighlightSpacing) -> Self {
300        self.highlight_spacing = Some(spacing);
301        self
302    }
303
304    /// Sets the item order.
305    #[must_use]
306    pub fn direction(mut self, direction: ListDirection) -> Self {
307        self.direction = Some(direction);
308        self
309    }
310
311    /// Sets the scroll padding.
312    #[must_use]
313    pub fn scroll_padding(mut self, padding: u16) -> Self {
314        self.scroll_padding = Some(padding);
315        self
316    }
317
318    /// Enables multi-select.
319    #[must_use]
320    pub fn multi(mut self, multi: bool) -> Self {
321        self.multi = Some(multi);
322        self
323    }
324
325    /// GUI extra: native scrollbar.
326    #[must_use]
327    pub fn scrollbar(mut self, scrollbar: bool) -> Self {
328        self.scrollbar = Some(scrollbar);
329        self
330    }
331
332    /// Sets the selection action.
333    #[must_use]
334    pub fn on_select(mut self, action: impl Into<ActionId>) -> Self {
335        self.on_select = Some(action.into());
336        self
337    }
338
339    /// Sets the activate action.
340    #[must_use]
341    pub fn on_activate(mut self, action: impl Into<ActionId>) -> Self {
342        self.on_activate = Some(action.into());
343        self
344    }
345}
346
347impl IntoNode for List {}
348impl From<List> for Node {
349    fn from(l: List) -> Self {
350        Node::List(l)
351    }
352}
353
354/// Compatibility alias: the table keeps the same state shape as a list
355/// (Appendix A `Table.state` → `ListState`).
356pub type TableState = ListState;