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), Constraint::Length(1), Constraint::Length(1), Constraint::Min(1), Constraint::Length(1), ])
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}