Skip to main content

tui_kit/
picker.rs

1//! Telescope-style two-column picker widget.
2//!
3//! Layout:
4//! ```text
5//! ┌─ Title ──────────────────────────────────────────────────┐
6//! │ ┌─ search ──────────┐  ┌─ Detail ─────────────────────┐ │
7//! │ │ query_            │  │ selected item detail…        │ │
8//! │ └───────────────────┘  │                              │ │
9//! │ ┌─ 3/12 ────────────┐  │                              │ │
10//! │ │   item one        │  └──────────────────────────────┘ │
11//! │ │ ▶ item two        │                                   │
12//! │ │   item three      │                                   │
13//! │ └───────────────────┘                                   │
14//! └─────────────────────────────────────────────────────────┘
15//! ```
16//!
17//! ## Usage
18//!
19//! ```ignore
20//! // 1. Create/manage PickerState in your app state
21//! // 2. Filter your items externally based on state.search
22//! // 3. Compute detail lines for state.selected item
23//! // 4. Call render_picker each frame
24//!
25//! let filtered: Vec<PickerItem> = all_items
26//!     .iter()
27//!     .filter(|i| i.label.to_lowercase().contains(&state.search.to_lowercase()))
28//!     .cloned()
29//!     .collect();
30//!
31//! let detail = if let Some(item) = filtered.get(state.selected) {
32//!     vec![Line::from(item.label.clone())]
33//! } else {
34//!     vec![]
35//! };
36//!
37//! let area = tui_kit::popup::centered_popup(f, 0.88, 120, 30);
38//! render_picker(f, area, "Find instance", &state, &filtered, detail, &theme);
39//! ```
40
41use ratatui::{
42    layout::{Constraint, Direction, Layout, Rect},
43    style::{Modifier, Style},
44    text::{Line, Span, Text},
45    widgets::{Block, Borders, List, ListItem, Paragraph, Wrap},
46    Frame,
47};
48
49use crate::Theme;
50
51/// Navigation and search state for a [`render_picker`] widget.
52#[derive(Debug, Clone)]
53pub struct PickerState {
54    /// Current search string (shown in the search box).
55    pub search: String,
56    /// Index of the selected item in the **filtered** list.
57    pub selected: usize,
58    /// First visible item index (scroll offset).
59    pub scroll_offset: usize,
60}
61
62impl PickerState {
63    pub fn new() -> Self {
64        Self { search: String::new(), selected: 0, scroll_offset: 0 }
65    }
66
67    /// Move selection down by one, clamped to `item_count`.
68    pub fn select_next(&mut self, item_count: usize) {
69        if item_count == 0 { return; }
70        if self.selected + 1 < item_count {
71            self.selected += 1;
72        }
73    }
74
75    /// Move selection up by one.
76    pub fn select_prev(&mut self) {
77        self.selected = self.selected.saturating_sub(1);
78    }
79
80    /// Reset selection and scroll when the search changes.
81    pub fn reset_selection(&mut self) {
82        self.selected = 0;
83        self.scroll_offset = 0;
84    }
85
86    /// Clamp scroll so the selected row is always visible.
87    pub fn clamp_scroll(&mut self, visible_height: usize) {
88        if visible_height == 0 { return; }
89        if self.selected < self.scroll_offset {
90            self.scroll_offset = self.selected;
91        } else if self.selected >= self.scroll_offset + visible_height {
92            self.scroll_offset = self.selected - visible_height + 1;
93        }
94    }
95}
96
97impl Default for PickerState {
98    fn default() -> Self { Self::new() }
99}
100
101/// A single row in a [`render_picker`] list.
102#[derive(Debug, Clone)]
103pub struct PickerItem {
104    /// Primary label shown in the list.
105    pub label: String,
106    /// Optional short tag shown before the label, e.g. `"[C]"`, `"[R]"`.
107    pub tag: Option<String>,
108    /// Style applied to the tag when the row is not selected. Ignored when tag is None.
109    pub tag_style: Option<Style>,
110}
111
112impl PickerItem {
113    pub fn new(label: impl Into<String>) -> Self {
114        Self { label: label.into(), tag: None, tag_style: None }
115    }
116
117    pub fn with_tag(label: impl Into<String>, tag: impl Into<String>) -> Self {
118        Self { label: label.into(), tag: Some(tag.into()), tag_style: None }
119    }
120
121    pub fn with_tag_styled(label: impl Into<String>, tag: impl Into<String>, style: Style) -> Self {
122        Self { label: label.into(), tag: Some(tag.into()), tag_style: Some(style) }
123    }
124}
125
126/// Render a two-column picker inside `area`.
127///
128/// - `title`       — label shown on the search box border.
129/// - `items`       — pre-filtered list of items to display.
130/// - `state`       — current search/selection state.
131/// - `detail`      — lines to render in the right-hand detail pane.
132///                   Computed by the caller from `items[state.selected]`.
133/// - `total_count` — total (unfiltered) item count, shown as `n/total` in list title.
134pub fn render_picker<'a>(
135    f: &mut Frame,
136    area: Rect,
137    title: &str,
138    items: &[PickerItem],
139    state: &PickerState,
140    detail: Vec<Line<'a>>,
141    total_count: usize,
142    theme: &Theme,
143) {
144    // Two columns: 45% list | 55% detail
145    let cols = Layout::default()
146        .direction(Direction::Horizontal)
147        .constraints([Constraint::Percentage(45), Constraint::Percentage(55)])
148        .split(area);
149
150    // ── Left: search box + list ──────────────────────────────────────────────
151
152    let left_rows = Layout::default()
153        .direction(Direction::Vertical)
154        .constraints([Constraint::Length(3), Constraint::Min(1)])
155        .split(cols[0]);
156
157    // Search box
158    let search_block = Block::default()
159        .borders(Borders::ALL)
160        .border_style(theme.border_focused)
161        .title(format!(" {} ", title))
162        .title_style(theme.tab_active);
163    f.render_widget(
164        Paragraph::new(state.search.as_str()).block(search_block),
165        left_rows[0],
166    );
167
168    // List
169    let visible_height = left_rows[1].height.saturating_sub(2) as usize;
170    let scroll = state.scroll_offset.min(items.len().saturating_sub(1));
171
172    let list_items: Vec<ListItem> = items
173        .iter()
174        .enumerate()
175        .skip(scroll)
176        .take(visible_height)
177        .map(|(idx, item)| {
178            let selected = idx == state.selected;
179            let row_style = if selected { theme.selection } else { theme.body };
180            let prefix = if selected { "▶ " } else { "  " };
181            let line = match &item.tag {
182                Some(tag) => {
183                    let tag_style = if selected {
184                        row_style
185                    } else {
186                        item.tag_style.unwrap_or(row_style)
187                    };
188                    Line::from(vec![
189                        Span::styled(prefix.to_string(), row_style),
190                        Span::styled(format!("{} ", tag), tag_style),
191                        Span::styled(item.label.clone(), row_style),
192                    ])
193                }
194                None => Line::from(vec![
195                    Span::styled(prefix.to_string(), row_style),
196                    Span::styled(item.label.clone(), row_style),
197                ]),
198            };
199            ListItem::new(line)
200        })
201        .collect();
202
203    let count_title = format!(" {}/{} ", items.len(), total_count);
204    let list_block = Block::default()
205        .borders(Borders::ALL)
206        .border_style(theme.border_unfocused)
207        .title(count_title)
208        .title_style(theme.hint);
209    f.render_widget(List::new(list_items).block(list_block), left_rows[1]);
210
211    // ── Right: detail ────────────────────────────────────────────────────────
212
213    let detail_content = if detail.is_empty() {
214        vec![Line::from(Span::styled(
215            "(no selection)",
216            theme.hint.add_modifier(Modifier::ITALIC),
217        ))]
218    } else {
219        detail
220    };
221
222    let detail_block = Block::default()
223        .borders(Borders::ALL)
224        .border_style(theme.border_unfocused)
225        .title(" Detail ")
226        .title_style(theme.hint);
227
228    f.render_widget(
229        Paragraph::new(Text::from(detail_content))
230            .block(detail_block)
231            .wrap(Wrap { trim: false }),
232        cols[1],
233    );
234}