Skip to main content

stynx_code_tui/widgets/
dialog_select.rs

1use ratatui::{
2    buffer::Buffer,
3    layout::{Constraint, Layout, Rect},
4    style::{Modifier, Style},
5    text::{Line, Span},
6    widgets::{Block, Borders, Clear, Paragraph, Widget},
7};
8
9use crate::state::{DialogOption, filter_options};
10use crate::theme;
11
12pub struct DialogSelect<'a> {
13    pub title: &'a str,
14    pub query: &'a str,
15    pub options: &'a [DialogOption],
16    pub selected: usize,
17    pub current_value: Option<&'a str>,
18    pub footer_hint: Option<&'a str>,
19}
20
21impl<'a> DialogSelect<'a> {
22    pub fn new(
23        title: &'a str,
24        query: &'a str,
25        options: &'a [DialogOption],
26        selected: usize,
27    ) -> Self {
28        Self {
29            title,
30            query,
31            options,
32            selected,
33            current_value: None,
34            footer_hint: None,
35        }
36    }
37
38    pub fn with_current(mut self, v: Option<&'a str>) -> Self {
39        self.current_value = v;
40        self
41    }
42
43    pub fn with_footer(mut self, h: Option<&'a str>) -> Self {
44        self.footer_hint = h;
45        self
46    }
47
48    fn dialog_rect(area: Rect) -> Rect {
49        let target_w = 78.min(area.width.saturating_sub(4));
50        let target_h = 22.min(area.height.saturating_sub(4));
51        let x = area.x + (area.width.saturating_sub(target_w)) / 2;
52        let y = area.y + (area.height.saturating_sub(target_h)) / 2;
53        Rect { x, y, width: target_w, height: target_h }
54    }
55}
56
57impl<'a> Widget for DialogSelect<'a> {
58    fn render(self, area: Rect, buf: &mut Buffer) {
59        let dialog = Self::dialog_rect(area);
60        Clear.render(dialog, buf);
61
62        let block = Block::default()
63            .borders(Borders::ALL)
64            .border_style(Style::default().fg(theme::BORDER_ACTIVE()))
65            .style(Style::default().bg(theme::BACKGROUND_PANEL()));
66        let inner = block.inner(dialog);
67        block.render(dialog, buf);
68
69        let rows = Layout::vertical([
70            Constraint::Length(1),
71            Constraint::Length(1),
72            Constraint::Length(1),
73            Constraint::Min(1),
74            Constraint::Length(1),
75        ])
76        .split(inner);
77
78        Paragraph::new(Line::from(Span::styled(
79            format!(" {} ", self.title),
80            Style::default()
81                .fg(theme::TEXT())
82                .bg(theme::BACKGROUND_PANEL())
83                .add_modifier(Modifier::BOLD),
84        )))
85        .style(Style::default().bg(theme::BACKGROUND_PANEL()))
86        .render(rows[0], buf);
87
88        let query_display = if self.query.is_empty() {
89            Line::from(vec![
90                Span::styled("  › ", Style::default().fg(theme::ACCENT()).bg(theme::BACKGROUND_PANEL())),
91                Span::styled(
92                    "type to filter…",
93                    Style::default()
94                        .fg(theme::TEXT_MUTED())
95                        .bg(theme::BACKGROUND_PANEL())
96                        .add_modifier(Modifier::ITALIC),
97                ),
98            ])
99        } else {
100            Line::from(vec![
101                Span::styled("  › ", Style::default().fg(theme::ACCENT()).bg(theme::BACKGROUND_PANEL())),
102                Span::styled(
103                    self.query.to_string(),
104                    Style::default().fg(theme::TEXT()).bg(theme::BACKGROUND_PANEL()),
105                ),
106                Span::styled("▏", Style::default().fg(theme::ACCENT()).bg(theme::BACKGROUND_PANEL())),
107            ])
108        };
109        Paragraph::new(query_display)
110            .style(Style::default().bg(theme::BACKGROUND_PANEL()))
111            .render(rows[1], buf);
112
113        let divider_line = "─".repeat(rows[2].width as usize);
114        Paragraph::new(Line::from(Span::styled(
115            divider_line,
116            Style::default().fg(theme::BORDER()).bg(theme::BACKGROUND_PANEL()),
117        )))
118        .render(rows[2], buf);
119
120        let list_area = rows[3];
121        let filtered = filter_options(self.options, self.query);
122        let visible = list_area.height as usize;
123        let selected_clamped = self.selected.min(filtered.len().saturating_sub(1));
124        let scroll = if filtered.len() <= visible {
125            0
126        } else if selected_clamped < visible / 2 {
127            0
128        } else if selected_clamped + visible / 2 >= filtered.len() {
129            filtered.len() - visible
130        } else {
131            selected_clamped - visible / 2
132        };
133
134        let mut lines: Vec<Line<'static>> = Vec::new();
135        let mut last_category: Option<String> = None;
136        for (i, &orig_idx) in filtered.iter().enumerate().skip(scroll).take(visible) {
137            let opt = &self.options[orig_idx];
138            if opt.category != last_category {
139                if let Some(cat) = &opt.category {
140                    lines.push(Line::from(Span::styled(
141                        format!("  {cat}"),
142                        Style::default()
143                            .fg(theme::TEXT_MUTED())
144                            .bg(theme::BACKGROUND_PANEL())
145                            .add_modifier(Modifier::DIM | Modifier::ITALIC),
146                    )));
147                }
148                last_category = opt.category.clone();
149            }
150
151            let is_selected = i == selected_clamped;
152            let is_current = self
153                .current_value
154                .map(|c| c == opt.value.as_str())
155                .unwrap_or(false);
156
157            let row_bg = if is_selected {
158                theme::BACKGROUND_MENU()
159            } else {
160                theme::BACKGROUND_PANEL()
161            };
162            let arrow_span = if is_selected {
163                Span::styled(" ▶ ", Style::default().fg(theme::ACCENT()).bg(row_bg))
164            } else if is_current {
165                Span::styled(" • ", Style::default().fg(theme::PRIMARY()).bg(row_bg))
166            } else {
167                Span::styled("   ", Style::default().bg(row_bg))
168            };
169            let title_style = if opt.disabled {
170                Style::default()
171                    .fg(theme::TEXT_MUTED())
172                    .bg(row_bg)
173                    .add_modifier(Modifier::DIM)
174            } else if is_selected {
175                Style::default()
176                    .fg(theme::TEXT())
177                    .bg(row_bg)
178                    .add_modifier(Modifier::BOLD)
179            } else {
180                Style::default().fg(theme::TEXT()).bg(row_bg)
181            };
182
183            let mut spans = vec![arrow_span, Span::styled(opt.title.clone(), title_style)];
184            if let Some(desc) = &opt.description {
185                spans.push(Span::styled(
186                    format!("  {desc}"),
187                    Style::default()
188                        .fg(theme::TEXT_MUTED())
189                        .bg(row_bg)
190                        .add_modifier(Modifier::DIM),
191                ));
192            }
193            if let Some(footer) = &opt.footer {
194                spans.push(Span::styled(
195                    format!("  {footer}"),
196                    Style::default().fg(theme::SUCCESS()).bg(row_bg),
197                ));
198            }
199            let line = Line::from(spans);
200            let line_width = line.width() as u16;
201            let row_y = list_area.y + (i - scroll) as u16;
202            if row_y >= list_area.y + list_area.height {
203                break;
204            }
205            for x in list_area.x..list_area.x + list_area.width {
206                buf[(x, row_y)].set_style(Style::default().bg(row_bg));
207            }
208            let row_rect = Rect {
209                x: list_area.x,
210                y: row_y,
211                width: line_width.min(list_area.width),
212                height: 1,
213            };
214            Paragraph::new(line).render(row_rect, buf);
215        }
216
217        let _ = lines;
218
219        let footer = match self.footer_hint {
220            Some(h) => Line::from(vec![
221                Span::styled(
222                    " ↑↓ ",
223                    Style::default().fg(theme::TEXT()).bg(theme::BACKGROUND_PANEL()),
224                ),
225                Span::styled(
226                    "navigate ",
227                    Style::default().fg(theme::TEXT_MUTED()).bg(theme::BACKGROUND_PANEL()),
228                ),
229                Span::styled(
230                    "↵ ",
231                    Style::default().fg(theme::TEXT()).bg(theme::BACKGROUND_PANEL()),
232                ),
233                Span::styled(
234                    "select ",
235                    Style::default().fg(theme::TEXT_MUTED()).bg(theme::BACKGROUND_PANEL()),
236                ),
237                Span::styled(
238                    "esc ",
239                    Style::default().fg(theme::TEXT()).bg(theme::BACKGROUND_PANEL()),
240                ),
241                Span::styled(
242                    format!("close   {h}"),
243                    Style::default().fg(theme::TEXT_MUTED()).bg(theme::BACKGROUND_PANEL()),
244                ),
245            ]),
246            None => Line::from(vec![
247                Span::styled(
248                    " ↑↓ ",
249                    Style::default().fg(theme::TEXT()).bg(theme::BACKGROUND_PANEL()),
250                ),
251                Span::styled(
252                    "navigate ",
253                    Style::default().fg(theme::TEXT_MUTED()).bg(theme::BACKGROUND_PANEL()),
254                ),
255                Span::styled(
256                    "↵ ",
257                    Style::default().fg(theme::TEXT()).bg(theme::BACKGROUND_PANEL()),
258                ),
259                Span::styled(
260                    "select ",
261                    Style::default().fg(theme::TEXT_MUTED()).bg(theme::BACKGROUND_PANEL()),
262                ),
263                Span::styled(
264                    "esc ",
265                    Style::default().fg(theme::TEXT()).bg(theme::BACKGROUND_PANEL()),
266                ),
267                Span::styled(
268                    "close",
269                    Style::default().fg(theme::TEXT_MUTED()).bg(theme::BACKGROUND_PANEL()),
270                ),
271            ]),
272        };
273        for x in rows[4].x..rows[4].x + rows[4].width {
274            buf[(x, rows[4].y)].set_style(Style::default().bg(theme::BACKGROUND_ELEMENT()));
275        }
276        Paragraph::new(footer)
277            .style(Style::default().bg(theme::BACKGROUND_ELEMENT()))
278            .render(rows[4], buf);
279    }
280}