1use std::io::{stdout, Write};
2use std::process::Command;
3
4use crossterm::{
5 cursor::{MoveTo, MoveToNextLine},
6 event::{self, Event as CEvent, KeyCode, KeyEventKind},
7 execute, queue,
8 style::Print,
9 terminal::{disable_raw_mode, enable_raw_mode},
10};
11
12use crate::widget::{
13 button::Button, checkbox::Checkbox, input::Input, label::Label, slider::Slider,
14};
15
16#[derive(Debug, Clone, Copy)]
17pub enum Event {
18 Key(char),
19 ArrowLeft,
20 ArrowRight,
21 ArrowUp,
22 ArrowDown,
23}
24
25#[derive(Debug)]
26pub enum WidgetValue {
27 Bool(bool),
28 Int(i32),
29 Text(String),
30}
31
32pub enum WidgetType {
33 Button(Button),
34 Checkbox(Checkbox),
35 Slider(Slider),
36 Label(Label),
37 View(View),
38 Input(Input),
39}
40
41impl From<Button> for WidgetType {
42 fn from(value: Button) -> Self {
43 Self::Button(value)
44 }
45}
46impl From<Checkbox> for WidgetType {
47 fn from(value: Checkbox) -> Self {
48 Self::Checkbox(value)
49 }
50}
51impl From<Slider> for WidgetType {
52 fn from(value: Slider) -> Self {
53 Self::Slider(value)
54 }
55}
56impl From<Label> for WidgetType {
57 fn from(value: Label) -> Self {
58 Self::Label(value)
59 }
60}
61impl From<Input> for WidgetType {
62 fn from(value: Input) -> Self {
63 Self::Input(value)
64 }
65}
66impl From<View> for WidgetType {
67 fn from(value: View) -> Self {
68 Self::View(value)
69 }
70}
71
72impl WidgetType {
73 pub fn render(
74 &self,
75 focused: bool,
76 indent: usize,
77 _focus_index: usize,
78 _focusable_index: &mut usize,
79 current_y: &mut u16,
80 ) -> String {
81 let prefix = if focused { ">" } else { " " };
82 let pad = " ".repeat(indent);
83
84 match self {
85 WidgetType::Button(b) => {
86 *current_y += 1;
87 format!("{pad}{}{label}", prefix, label = b.render())
88 }
89 WidgetType::Checkbox(c) => {
90 *current_y += 1;
91 format!("{pad}{}{label}", prefix, label = c.render())
92 }
93 WidgetType::Slider(s) => {
94 *current_y += 1;
95 format!("{pad}{}{label}", prefix, label = s.render())
96 }
97 WidgetType::Label(l) => {
98 *current_y += 1;
99 format!("{pad} {}", l.render())
100 }
101 WidgetType::View(v) => v.render(indent + 2, _focus_index, _focusable_index, current_y),
102 WidgetType::Input(i) => format!("{pad}{}{label}", prefix, label = i.render(focused)),
103 }
104 }
105
106 pub fn handle_event(&mut self, event: &Event, focus_index: usize) {
107 match self {
108 WidgetType::Checkbox(c) => {
109 if let Event::Key(' ') = event {
110 c.toggle();
111 }
112 }
113 WidgetType::Slider(s) => match event {
114 Event::Key('+') => {
115 if s.value < s.max {
116 s.value += 1;
117 }
118 }
119 Event::Key('-') => {
120 if s.value > s.min {
121 s.value -= 1;
122 }
123 }
124 _ => {}
125 },
126 WidgetType::Input(i) => {
127 match event {
128 Event::Key(c) => match c {
129 '\x08' => i.handle_backspace(), '\x1b' => {} '\n' => {} _ => i.handle_char(*c), },
134 Event::ArrowLeft => i.move_cursor_left(),
135 Event::ArrowRight => i.move_cursor_right(),
136 _ => {}
137 }
138 }
139
140 WidgetType::View(v) => {
141 v.handle_event(event, focus_index);
142 }
143 _ => {}
144 }
145 }
146
147 pub fn is_focusable(&self) -> bool {
148 match self {
149 WidgetType::View(v) => count_focusables(v) > 0,
150 _ => matches!(
151 self,
152 WidgetType::Button(_)
153 | WidgetType::Checkbox(_)
154 | WidgetType::Slider(_)
155 | WidgetType::Input(_)
156 ),
157 }
158 }
159
160 pub fn value(&self) -> Option<(String, WidgetValue)> {
161 match self {
162 WidgetType::Checkbox(c) => Some((c.label.clone(), WidgetValue::Bool(c.checked))),
163 WidgetType::Slider(s) => {
164 Some((format!("Slider({})", s.label), WidgetValue::Int(s.value)))
165 }
166 WidgetType::Input(i) => Some((
167 i.label.clone(),
168 WidgetValue::Text(i.get_value().to_string()),
169 )),
170 WidgetType::View(_) => None,
171 _ => None,
172 }
173 }
174}
175
176pub struct View {
177 pub label: String,
178 pub widgets: Vec<WidgetType>,
179}
180
181impl View {
182 pub fn new(label: &str) -> Self {
183 View {
184 label: label.to_string(),
185 widgets: Vec::new(),
186 }
187 }
188
189 pub fn add(&mut self, widget: impl Into<WidgetType>) {
190 self.widgets.push(widget.into());
191 }
192
193 pub fn flatten_focusable(&mut self) -> Vec<&mut WidgetType> {
194 let mut result = Vec::new();
195 for widget in &mut self.widgets {
196 match widget {
197 WidgetType::View(v) => result.extend(v.flatten_focusable()),
198 _ if widget.is_focusable() => result.push(widget),
199 _ => {}
200 }
201 }
202 result
203 }
204
205 pub fn handle_event(&mut self, event: &Event, focus_index: usize) {
206 let mut focusables = self.flatten_focusable();
207 if focusables.is_empty() {
208 return;
209 }
210
211 if let Some(widget) = focusables.get_mut(focus_index) {
212 widget.handle_event(event, focus_index);
213 }
214 }
215
216 pub fn render(
217 &self,
218 indent: usize,
219 global_focus_index: usize,
220 focusable_index: &mut usize,
221 current_y: &mut u16,
222 ) -> String {
223 let mut output = vec![format!("{}=== {} ===", " ".repeat(indent), self.label)];
224 *current_y += 1; for widget in &self.widgets {
227 match widget {
228 WidgetType::View(v) => {
229 output.push(v.render(
230 indent + 2,
231 global_focus_index,
232 focusable_index,
233 current_y,
234 ));
235 }
236 _ => {
237 let is_focusable = widget.is_focusable();
238 let focused = is_focusable && *focusable_index == global_focus_index;
239
240 output.push(widget.render(
241 focused,
242 indent,
243 global_focus_index,
244 &mut 0, current_y,
246 ));
247
248 *current_y += match widget {
250 WidgetType::Input(_) => 3,
251 _ => 1,
252 };
253
254 if is_focusable {
255 *focusable_index += 1;
256 }
257 }
258 }
259 }
260
261 output.join("\n")
262 }
263
264 pub fn get_values(&self) -> Vec<(String, WidgetValue)> {
265 let mut values = Vec::new();
266 for widget in &self.widgets {
267 match widget {
268 WidgetType::View(v) => values.extend(v.get_values()),
269 _ => {
270 if let Some(val) = widget.value() {
271 values.push(val);
272 }
273 }
274 }
275 }
276 values
277 }
278
279 pub fn run(&mut self) -> std::io::Result<Vec<String>> {
280 enable_raw_mode()?;
281 let mut stdout = stdout();
282 let mut needs_redraw = true;
283 let mut global_focus_index = 0;
284
285 loop {
286 let mut flat_index = 0;
287 let mut current_y = 0;
288
289 if needs_redraw {
290 clear_screen();
291 execute!(stdout, MoveTo(0, 0))?;
292
293 for line in self
294 .render(0, global_focus_index, &mut flat_index, &mut current_y)
295 .split('\n')
296 {
297 queue!(stdout, Print(line), MoveToNextLine(1))?;
298 }
299
300 stdout.flush()?;
301
302 needs_redraw = false;
303 }
304
305 if let CEvent::Key(key_event) = event::read()? {
306 if key_event.kind != KeyEventKind::Press {
307 continue;
308 }
309
310 match key_event.code {
311 KeyCode::Esc => {
312 clear_screen();
313 break;
314 }
315 KeyCode::Tab => {
316 let total = count_focusables(self);
317 global_focus_index = (global_focus_index + 1) % total;
318 needs_redraw = true;
319 }
320 KeyCode::BackTab => {
321 let total = count_focusables(self);
322 global_focus_index = (global_focus_index + total - 1) % total;
323 needs_redraw = true;
324 }
325 KeyCode::Char(c) => {
326 self.handle_event(&crate::Event::Key(c), global_focus_index);
327 needs_redraw = true;
328 }
329 KeyCode::Backspace => {
330 self.handle_event(&crate::Event::Key('\x08'), global_focus_index);
331 needs_redraw = true;
332 }
333 KeyCode::Left => {
334 self.handle_event(&crate::Event::ArrowLeft, global_focus_index);
335 needs_redraw = true;
336 }
337 KeyCode::Right => {
338 self.handle_event(&crate::Event::ArrowRight, global_focus_index);
339 needs_redraw = true;
340 }
341 _ => {}
342 }
343 }
344 }
345
346 disable_raw_mode()?;
347
348 let selected: Vec<String> = self
349 .get_values()
350 .into_iter()
351 .filter_map(|(label, value)| match value {
352 crate::WidgetValue::Bool(true) => Some(label),
353 crate::WidgetValue::Text(text) => Some(format!("{}: {}", label, text)),
354 _ => None,
355 })
356 .collect();
357
358 Ok(selected)
359 }
360}
361
362fn count_focusables(view: &View) -> usize {
363 view.widgets
364 .iter()
365 .map(|w| match w {
366 WidgetType::View(v) => count_focusables(v),
367 _ if w.is_focusable() => 1,
368 _ => 0,
369 })
370 .sum()
371}
372
373fn clear_screen() {
374 if cfg!(target_os = "windows") {
375 let _ = Command::new("cmd").args(&["/C", "cls"]).status();
376 } else {
377 let _ = Command::new("clear").status();
378 }
379}