Skip to main content

gm_lib/tui/app/widgets/
form.rs

1use std::{collections::HashMap, marker::PhantomData};
2
3use crossterm::event::{KeyCode, KeyEventKind};
4use ratatui::layout::{Constraint, Layout};
5use ratatui::{
6    buffer::Buffer,
7    layout::Rect,
8    style::Stylize,
9    text::Text,
10    widgets::{Paragraph, Widget, Wrap},
11};
12use strum::IntoEnumIterator;
13
14use super::{button::Button, input_box::InputBox};
15use crate::tui::app::widgets::scroll_bar::CustomScrollBar;
16use crate::tui::traits::{HandleResult, RectUtil};
17use crate::tui::{
18    app::widgets::filter_select_popup::FilterSelectPopup, theme::Theme, traits::WidgetHeight, Event,
19};
20use crate::utils::text::split_string;
21
22pub trait FormItemIndex {
23    fn index(self) -> usize;
24}
25
26#[derive(Clone, Debug)]
27pub enum FormWidget {
28    Heading(&'static str),
29    StaticText(&'static str),
30    InputBox {
31        label: &'static str,
32        text: String,
33        empty_text: Option<&'static str>,
34        currency: Option<String>,
35    },
36    BooleanInput {
37        label: &'static str,
38        value: bool,
39    },
40    DisplayBox {
41        label: &'static str,
42        text: String,
43        empty_text: Option<&'static str>,
44    },
45    SelectInput {
46        label: &'static str,
47        text: String,
48        empty_text: Option<&'static str>,
49        popup: FilterSelectPopup<String>,
50    },
51    Button {
52        label: &'static str,
53    },
54    DisplayText(String),
55    ErrorText(String),
56}
57
58impl FormWidget {
59    pub fn label(&self) -> Option<&'static str> {
60        match self {
61            FormWidget::InputBox { label, .. } => Some(label),
62            FormWidget::DisplayBox { label, .. } => Some(label),
63            FormWidget::BooleanInput { label, .. } => Some(label),
64            FormWidget::Button { label } => Some(label),
65            FormWidget::SelectInput { label, .. } => Some(label),
66            FormWidget::Heading(_)
67            | FormWidget::StaticText(_)
68            | FormWidget::DisplayText(_)
69            | FormWidget::ErrorText(_) => None,
70        }
71    }
72
73    pub fn max_cursor(&self) -> usize {
74        match self {
75            FormWidget::InputBox { text, .. }
76            | FormWidget::DisplayBox { text, .. }
77            | FormWidget::SelectInput { text, .. } => text.len(),
78            FormWidget::BooleanInput { value, .. } => value.to_string().len(),
79            FormWidget::Button { .. }
80            | FormWidget::Heading(_)
81            | FormWidget::StaticText(_)
82            | FormWidget::DisplayText(_)
83            | FormWidget::ErrorText(_) => 0,
84        }
85    }
86
87    pub fn to_value(&self) -> Option<String> {
88        match self {
89            FormWidget::InputBox { text, .. }
90            | FormWidget::DisplayBox { text, .. }
91            | FormWidget::SelectInput { text, .. } => Some(text.clone()),
92            FormWidget::BooleanInput { value, .. } => Some(value.to_string()),
93            FormWidget::Button { .. }
94            | FormWidget::Heading(_)
95            | FormWidget::StaticText(_)
96            | FormWidget::DisplayText(_)
97            | FormWidget::ErrorText(_) => None,
98        }
99    }
100
101    pub fn height(&self, area: Rect) -> u16 {
102        match self {
103            FormWidget::InputBox { text, .. }
104            | FormWidget::DisplayBox { text, .. }
105            | FormWidget::SelectInput { text, .. } => {
106                let lines = split_string(text, (area.width - 2) as usize);
107                (3 + lines.len()) as u16
108            }
109
110            FormWidget::BooleanInput { value, .. } => {
111                let value = value.to_string();
112                let lines = split_string(&value, (area.width - 2) as usize);
113                (2 + lines.len()) as u16
114            }
115            FormWidget::Button { .. } => 4,
116            FormWidget::Heading(text) | FormWidget::StaticText(text) => {
117                (text.len() as u16).div_ceil(area.width) + 1
118            }
119            FormWidget::DisplayText(text) | FormWidget::ErrorText(text) => {
120                (text.len() as u16).div_ceil(area.width) + 3
121            }
122        }
123    }
124}
125
126#[derive(Debug)]
127pub struct Form<E: IntoEnumIterator + FormItemIndex + TryInto<FormWidget, Error = crate::Error>> {
128    pub cursor: usize,
129    pub text_cursor: usize,
130    pub form_focus: bool,
131    pub items: Vec<FormWidget>,
132    pub hide: HashMap<usize, bool>,
133    pub everything_empty: bool,
134    pub _phantom: PhantomData<E>,
135}
136
137impl<E: IntoEnumIterator + FormItemIndex + TryInto<FormWidget, Error = crate::Error>> Form<E> {
138    // TODO remove the cursor parameter, and guess it as the first item that is
139    // not heading or static text or similar
140    pub fn init<F>(set_values_closure: F) -> crate::Result<Self>
141    where
142        F: FnOnce(&mut Self) -> crate::Result<()>,
143    {
144        let mut form = Self {
145            cursor: 0,
146            text_cursor: 0,
147            form_focus: true,
148            items: E::iter()
149                .map(|item| item.try_into())
150                .collect::<Result<Vec<FormWidget>, _>>()?,
151            hide: HashMap::new(),
152            everything_empty: false,
153            _phantom: PhantomData,
154        };
155        for i in 0..form.items.len() {
156            if form.is_valid_cursor(i) {
157                break;
158            } else {
159                form.cursor += 1;
160            }
161        }
162        set_values_closure(&mut form)?;
163        form.text_cursor = form.items[form.cursor].max_cursor();
164
165        Ok(form)
166    }
167
168    pub fn set_form_focus(&mut self, focus: bool) {
169        self.form_focus = focus;
170    }
171
172    pub fn show_everything_empty(&mut self, empty: bool) {
173        self.everything_empty = empty;
174    }
175
176    pub fn hide_item(&mut self, idx: E) {
177        self.hide.insert(idx.index(), true);
178    }
179
180    pub fn show_item(&mut self, idx: E) {
181        self.hide.remove(&idx.index());
182    }
183
184    pub fn hidden_count(&self) -> usize {
185        self.hide.len()
186    }
187
188    pub fn visible_count(&self) -> usize {
189        self.items.len() - self.hidden_count()
190    }
191
192    pub fn advance_cursor(&mut self) {
193        loop {
194            self.cursor = (self.cursor + 1) % self.items.len();
195            self.update_text_cursor();
196
197            if self.is_valid_cursor(self.cursor) {
198                break;
199            }
200        }
201    }
202
203    pub fn retreat_cursor(&mut self) {
204        loop {
205            self.cursor = (self.cursor + self.items.len() - 1) % self.items.len();
206            self.update_text_cursor();
207
208            if self.is_valid_cursor(self.cursor) {
209                break;
210            }
211        }
212    }
213
214    pub fn update_text_cursor(&mut self) {
215        self.text_cursor = self.items[self.cursor].max_cursor();
216    }
217
218    pub fn is_valid_cursor(&self, idx: usize) -> bool {
219        if self.hide.contains_key(&idx) {
220            return false;
221        }
222
223        match &self.items[idx] {
224            FormWidget::Heading(_)
225            | FormWidget::StaticText(_)
226            | FormWidget::DisplayText(_)
227            | FormWidget::ErrorText(_) => false,
228
229            FormWidget::InputBox { .. }
230            | FormWidget::DisplayBox { .. }
231            | FormWidget::BooleanInput { .. }
232            | FormWidget::SelectInput { .. }
233            | FormWidget::Button { .. } => true,
234        }
235    }
236
237    pub fn get_text(&self, idx: E) -> &String {
238        match &self.items[idx.index()] {
239            FormWidget::InputBox { text, .. } => text,
240            FormWidget::DisplayBox { text, .. } => text,
241            FormWidget::DisplayText(text) => text,
242            FormWidget::ErrorText(text) => text,
243            FormWidget::SelectInput { text, .. } => text,
244            _ => unreachable!(),
245        }
246    }
247
248    pub fn get_text_mut(&mut self, idx: E) -> &mut String {
249        match &mut self.items[idx.index()] {
250            FormWidget::InputBox { text, .. } => text,
251            FormWidget::DisplayBox { text, .. } => text,
252            FormWidget::DisplayText(text) => text,
253            FormWidget::ErrorText(text) => text,
254            FormWidget::SelectInput { text, .. } => text,
255            _ => unreachable!(),
256        }
257    }
258
259    pub fn get_boolean(&self, idx: E) -> bool {
260        match &self.items[idx.index()] {
261            FormWidget::BooleanInput { value, .. } => *value,
262            _ => unreachable!(),
263        }
264    }
265
266    pub fn get_boolean_mut(&mut self, idx: E) -> &mut bool {
267        match &mut self.items[idx.index()] {
268            FormWidget::BooleanInput { value, .. } => value,
269            _ => unreachable!(),
270        }
271    }
272
273    pub fn get_currency_mut(&mut self, idx: E) -> Option<&mut Option<String>> {
274        match &mut self.items[idx.index()] {
275            FormWidget::InputBox { currency, .. } => Some(currency),
276            _ => None,
277        }
278    }
279
280    pub fn get_popup_mut(&mut self, idx: E) -> &mut FilterSelectPopup<String> {
281        match &mut self.items[idx.index()] {
282            FormWidget::SelectInput { popup, .. } => popup,
283            _ => unreachable!(),
284        }
285    }
286
287    pub fn is_focused(&self, idx: E) -> bool {
288        self.cursor == idx.index()
289    }
290
291    pub fn is_button_focused(&self) -> bool {
292        matches!(self.items[self.cursor], FormWidget::Button { .. })
293    }
294
295    pub fn is_some_popup_open(&self) -> bool {
296        self.items
297            .iter()
298            .any(|item| matches!(item, FormWidget::SelectInput { popup, .. } if popup.is_open()))
299    }
300
301    pub fn is_select_focused(&self) -> bool {
302        matches!(self.items[self.cursor], FormWidget::SelectInput { .. })
303    }
304
305    pub fn current_label_enum(&self) -> crate::Result<E> {
306        E::iter()
307            .nth(self.cursor)
308            .ok_or(crate::Error::InternalErrorStr("form index out of bounds"))
309    }
310
311    pub fn handle_event<F1, F2>(
312        &mut self,
313        event: &Event,
314        mut on_value_change: F2,
315        mut on_button_press: F1,
316    ) -> crate::Result<HandleResult>
317    where
318        F1: FnMut(E, &mut Self) -> crate::Result<()>,
319        F2: FnMut(E, &mut Self) -> crate::Result<()>,
320    {
321        let mut result = HandleResult::default();
322
323        if let Event::Input(key_event) = event {
324            if key_event.kind == KeyEventKind::Press {
325                if !self.is_some_popup_open() {
326                    match key_event.code {
327                        KeyCode::Up => {
328                            self.retreat_cursor();
329                        }
330                        KeyCode::Down | KeyCode::Tab => {
331                            self.advance_cursor();
332                        }
333                        KeyCode::Enter => {
334                            if !self.is_button_focused() && !self.is_select_focused() {
335                                self.advance_cursor();
336                            }
337                        }
338
339                        _ => {}
340                    }
341                }
342
343                let value_before = self.items[self.cursor].to_value();
344
345                match &mut self.items[self.cursor] {
346                    FormWidget::InputBox { text, .. } => {
347                        InputBox::handle_events(text, &mut self.text_cursor, event)?;
348                    }
349                    FormWidget::DisplayBox { .. } => {
350                        // we don't have to handle this as parent component will do it
351                    }
352                    FormWidget::BooleanInput { value, .. } => {
353                        if matches!(
354                            key_event.code,
355                            KeyCode::Char(_) | KeyCode::Left | KeyCode::Right | KeyCode::Backspace
356                        ) {
357                            *value = !*value;
358                            self.text_cursor = value.to_string().len();
359                        }
360                    }
361                    FormWidget::SelectInput { text, popup, .. } => {
362                        let is_open = popup.is_open();
363
364                        let popup_result = popup.handle_event(event, |selected| {
365                            *text = selected.clone();
366                            self.text_cursor = selected.len();
367                            Ok(())
368                        })?;
369                        result.merge(popup_result);
370
371                        if !is_open {
372                            match key_event.code {
373                                // Press any key to open the popup
374                                KeyCode::Backspace | KeyCode::Char(_) | KeyCode::Enter => {
375                                    popup.open();
376                                }
377                                _ => {}
378                            }
379                        }
380                    }
381                    FormWidget::Button { .. } => {
382                        if matches!(key_event.code, KeyCode::Enter) {
383                            on_button_press(self.current_label_enum()?, self)?
384                        }
385                    }
386                    _ => {}
387                }
388
389                let value_after = self.items[self.cursor].to_value();
390                if value_after != value_before {
391                    on_value_change(self.current_label_enum()?, self)?;
392                }
393            }
394        }
395        Ok(result)
396    }
397
398    pub fn render(&self, mut area: Rect, buf: &mut Buffer, theme: &Theme)
399    where
400        Self: Sized,
401    {
402        let full_area = area;
403        let form_height: u16 = std::cmp::max(
404            self.items.iter().fold(0, |acc, i| acc + i.height(area)),
405            full_area.height,
406        );
407        let mut virtual_buf = Buffer::empty(Rect::new(0, 0, buf.area.width, form_height));
408        let mut scroll_cursor: u16 = 0;
409        let mut scroll_cursor_item_height: u16 = 0;
410        let horizontal_layout = Layout::horizontal([Constraint::Min(3), Constraint::Length(1)]);
411        let [form_area, scroll_area] = horizontal_layout.areas(area);
412        if full_area.height < form_height {
413            area = form_area;
414        }
415
416        for (i, item) in self.items.iter().enumerate() {
417            if self.hide.contains_key(&i) {
418                continue; // skip hidden items
419            }
420            if self.form_focus && self.cursor == i {
421                scroll_cursor = area.y;
422                scroll_cursor_item_height = item.height(area);
423            }
424
425            match item {
426                FormWidget::Heading(heading) => {
427                    heading.bold().render(area, &mut virtual_buf);
428                    area.y += 2;
429                }
430                FormWidget::StaticText(text) => {
431                    text.render(area, &mut virtual_buf);
432                    area.y += 2;
433                }
434                FormWidget::InputBox {
435                    label,
436                    text,
437                    empty_text,
438                    currency,
439                } => {
440                    let widget = InputBox {
441                        focus: self.form_focus && self.cursor == i,
442                        label,
443                        text: if !self.everything_empty {
444                            text
445                        } else {
446                            &"".to_string()
447                        },
448                        empty_text: if !self.everything_empty {
449                            *empty_text
450                        } else {
451                            Some("")
452                        },
453                        currency: currency.as_ref(),
454                    };
455                    let height_used = widget.height_used(area); // to see height based on width
456
457                    widget.render(area, &mut virtual_buf, &self.text_cursor, theme);
458                    area.y += height_used;
459                }
460                FormWidget::DisplayBox {
461                    label,
462                    text,
463                    empty_text,
464                } => {
465                    let widget = InputBox {
466                        focus: self.form_focus && self.cursor == i,
467                        label,
468                        text: if !self.everything_empty {
469                            text
470                        } else {
471                            &"".to_string()
472                        },
473                        empty_text: if !self.everything_empty {
474                            *empty_text
475                        } else {
476                            Some("")
477                        },
478                        currency: None,
479                    };
480                    let height_used = widget.height_used(area); // to see height based on width
481
482                    widget.render(area, &mut virtual_buf, &self.text_cursor, theme);
483                    area.y += height_used;
484                }
485                FormWidget::BooleanInput { label, value } => {
486                    let widget = InputBox {
487                        focus: self.form_focus && self.cursor == i,
488                        label,
489                        text: if !self.everything_empty {
490                            &value.to_string()
491                        } else {
492                            &"".to_string()
493                        },
494                        empty_text: None,
495                        currency: None,
496                    };
497                    let height_used = widget.height_used(area); // to see height based on width
498
499                    widget.render(area, &mut virtual_buf, &self.text_cursor, theme);
500                    area.y += height_used;
501                }
502                FormWidget::SelectInput {
503                    label,
504                    text,
505                    empty_text,
506                    ..
507                } => {
508                    let widget = InputBox {
509                        focus: self.form_focus && self.cursor == i,
510                        label,
511                        text,
512                        empty_text: *empty_text,
513                        currency: None,
514                    };
515                    let height_used = widget.height_used(area); // to see height based on width
516
517                    widget.render(area, &mut virtual_buf, &self.text_cursor, theme);
518                    area.y += height_used;
519                }
520                FormWidget::Button { label } => {
521                    Button {
522                        focus: self.form_focus && self.cursor == i,
523                        label,
524                    }
525                    .render(area, &mut virtual_buf, theme);
526
527                    area.y += 4;
528                }
529                FormWidget::DisplayText(text) | FormWidget::ErrorText(text) => {
530                    if !text.is_empty() {
531                        area.y += 1;
532                        Paragraph::new(Text::raw(text))
533                            .wrap(Wrap { trim: false })
534                            .render(area.margin_h(1), &mut virtual_buf);
535                        area.y += (text.len() as u16).div_ceil(area.width) + 1;
536                    }
537                }
538            }
539        }
540
541        if full_area.height < form_height {
542            // form is overflowing draw a scrollbar
543            CustomScrollBar {
544                cursor: scroll_cursor as usize,
545                total: form_height as usize,
546            }
547            .render(scroll_area, buf);
548        }
549
550        let mut page = area;
551        page.x = full_area.x;
552        page.height = full_area.height;
553        page.y = full_area.y;
554        let item_overflow_top = (page.y).saturating_sub(scroll_cursor - 1);
555        let item_overflow_bottom =
556            (scroll_cursor + scroll_cursor_item_height + 1).saturating_sub(page.y + page.height);
557        page.y = page.y.saturating_sub(item_overflow_top);
558        page.y = page.y.saturating_add(item_overflow_bottom);
559
560        let visible_area = page.intersection(virtual_buf.area);
561
562        // Only show contents that are visible, copy contents from virtual buffer to the actual buffer
563        for (src_row, dst_row) in visible_area.rows().zip(full_area.rows()) {
564            for (src_col, dst_col) in src_row.columns().zip(dst_row.columns()) {
565                if let Some(dst) = buf.cell_mut((dst_col.x, dst_col.y)) {
566                    if let Some(src) = virtual_buf.cell((src_col.x, src_col.y)) {
567                        *dst = src.clone();
568                    }
569                };
570            }
571        }
572        // Render popups at the end so they appear on the top
573        for item in &self.items {
574            #[allow(clippy::single_match)]
575            match item {
576                FormWidget::SelectInput { popup, .. } => {
577                    popup.render(full_area, buf, theme);
578                }
579                _ => {}
580            }
581        }
582    }
583}