Skip to main content

tui_kit/
form.rs

1use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2use ratatui::{
3    layout::Rect,
4    text::{Line, Span},
5    widgets::Paragraph,
6    Frame,
7};
8
9use crate::Theme;
10
11/// The interactive value held by a [`FormField`].
12#[derive(Debug, Clone)]
13pub enum FieldInput {
14    Text(String),
15    Integer(i64),
16    Float(f64),
17    Boolean(bool),
18    /// Inline selector cycling through `options`; `selected` is the current index.
19    Enum { options: Vec<String>, selected: usize },
20    /// Ordered list of strings. Display-only for now — editing deferred.
21    List(Vec<String>),
22}
23
24/// A single field in a [`FormState`].
25#[derive(Debug, Clone)]
26pub struct FormField {
27    /// Label displayed to the left of the input.
28    pub label: String,
29    /// Current input value.
30    pub input: FieldInput,
31    /// If true, a `*` is appended to the label.
32    pub required: bool,
33    /// Optional hint shown below the field when it is focused.
34    pub description: Option<String>,
35}
36
37/// Event returned by [`FormState::handle_key`].
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum FormEvent {
40    /// No state change visible to the caller.
41    None,
42    /// User confirmed the form (Enter on last field, or explicit submit key).
43    Submit,
44    /// User cancelled the form (Esc).
45    Cancel,
46}
47
48/// State for the [`render_form`] widget.
49pub struct FormState {
50    /// The form fields.
51    pub fields: Vec<FormField>,
52    /// Index of the currently focused field.
53    pub focused: usize,
54    /// Cursor position (byte offset in the string representation) per field.
55    cursors: Vec<usize>,
56}
57
58impl FormState {
59    /// Create a new [`FormState`] from a list of fields.
60    pub fn new(fields: Vec<FormField>) -> Self {
61        let len = fields.len();
62        Self { fields, focused: 0, cursors: vec![0; len] }
63    }
64
65    /// Move focus to the next field, stopping at the last.
66    pub fn focus_next(&mut self) {
67        if self.focused + 1 < self.fields.len() {
68            self.focused += 1;
69        }
70    }
71
72    /// Move focus to the previous field, stopping at the first.
73    pub fn focus_prev(&mut self) {
74        self.focused = self.focused.saturating_sub(1);
75    }
76
77    /// Handle a key event, updating internal state and returning a [`FormEvent`].
78    pub fn handle_key(&mut self, key: KeyEvent) -> FormEvent {
79        if self.fields.is_empty() {
80            return FormEvent::None;
81        }
82
83        match key.code {
84            // Navigation
85            KeyCode::Tab if !key.modifiers.contains(KeyModifiers::SHIFT) => {
86                self.focus_next();
87                FormEvent::None
88            }
89            KeyCode::Tab | KeyCode::BackTab => {
90                self.focus_prev();
91                FormEvent::None
92            }
93            KeyCode::Down => { self.focus_next(); FormEvent::None }
94            KeyCode::Up   => { self.focus_prev(); FormEvent::None }
95
96            // Cancel
97            KeyCode::Esc => FormEvent::Cancel,
98
99            // Confirm
100            KeyCode::Enter => {
101                if self.focused + 1 >= self.fields.len() {
102                    FormEvent::Submit
103                } else {
104                    self.focus_next();
105                    FormEvent::None
106                }
107            }
108
109            // Field-specific input
110            _ => {
111                self.handle_field_key(key);
112                FormEvent::None
113            }
114        }
115    }
116
117    fn handle_field_key(&mut self, key: KeyEvent) {
118        let idx = self.focused;
119        match &mut self.fields[idx].input {
120            FieldInput::Text(s) => match key.code {
121                KeyCode::Char(c) => { s.push(c); self.cursors[idx] = s.len(); }
122                KeyCode::Backspace => { s.pop(); self.cursors[idx] = s.len(); }
123                _ => {}
124            },
125            FieldInput::Integer(n) => match key.code {
126                KeyCode::Char(c) if c.is_ascii_digit() || (c == '-' && *n == 0) => {
127                    let mut s = n.to_string();
128                    s.push(c);
129                    if let Ok(v) = s.parse::<i64>() { *n = v; }
130                    self.cursors[idx] = n.to_string().len();
131                }
132                KeyCode::Backspace => {
133                    let mut s = n.to_string();
134                    s.pop();
135                    *n = s.parse::<i64>().unwrap_or(0);
136                    self.cursors[idx] = n.to_string().len();
137                }
138                _ => {}
139            },
140            FieldInput::Float(f) => match key.code {
141                KeyCode::Char(c) if c.is_ascii_digit() || c == '.' || (c == '-' && *f == 0.0) => {
142                    // Work via a temporary string representation
143                    let mut s = format!("{}", f);
144                    s.push(c);
145                    if let Ok(v) = s.parse::<f64>() { *f = v; }
146                    self.cursors[idx] = format!("{}", f).len();
147                }
148                KeyCode::Backspace => {
149                    let mut s = format!("{}", f);
150                    s.pop();
151                    *f = s.parse::<f64>().unwrap_or(0.0);
152                    self.cursors[idx] = format!("{}", f).len();
153                }
154                _ => {}
155            },
156            FieldInput::Boolean(b) => {
157                if key.code == KeyCode::Char(' ') {
158                    *b = !*b;
159                }
160            }
161            FieldInput::Enum { options, selected } => match key.code {
162                KeyCode::Right | KeyCode::Char('l') => {
163                    if !options.is_empty() {
164                        *selected = (*selected + 1) % options.len();
165                    }
166                }
167                KeyCode::Left | KeyCode::Char('h') => {
168                    if !options.is_empty() && *selected > 0 {
169                        *selected -= 1;
170                    } else if !options.is_empty() {
171                        *selected = options.len() - 1;
172                    }
173                }
174                _ => {}
175            },
176            FieldInput::List(_) => {
177                // Editing deferred — display-only for now.
178            }
179        }
180    }
181
182    /// Borrow the fields slice.
183    pub fn fields(&self) -> &[FormField] {
184        &self.fields
185    }
186
187    /// Consume the state and return the fields with their current input values.
188    pub fn into_fields(self) -> Vec<FormField> {
189        self.fields
190    }
191}
192
193/// Render the form inside `area`.
194///
195/// Each field takes up to 3 rows:
196/// 1. `Label [*]: <input>`
197/// 2. Description hint (only when focused, in [`Theme::hint`])
198/// 3. Blank separator
199///
200/// The caller is responsible for wrapping this in a [`crate::popup::centered_popup`]
201/// + [`crate::block::popup_block`] — no outer border is drawn here.
202pub fn render_form(f: &mut Frame, area: Rect, state: &FormState, theme: &Theme) {
203    if area.height == 0 {
204        return;
205    }
206
207    let mut y = area.y;
208
209    for (idx, field) in state.fields.iter().enumerate() {
210        if y >= area.y + area.height {
211            break;
212        }
213
214        let focused = idx == state.focused;
215        let label_style = if focused { theme.tab_active } else { theme.hint };
216        let bracket_style = if focused { theme.border_focused } else { theme.border_unfocused };
217
218        let label = if field.required {
219            format!("{}*", field.label)
220        } else {
221            field.label.clone()
222        };
223
224        let input_repr = field_repr(&field.input);
225
226        // Line 1: label + input
227        let line = Line::from(vec![
228            Span::styled(format!("{}: ", label), label_style),
229            Span::styled(input_repr, bracket_style),
230        ]);
231        let row = Rect { x: area.x, y, width: area.width, height: 1 };
232        f.render_widget(Paragraph::new(line), row);
233        y += 1;
234
235        // Line 2: description hint (focused only)
236        if focused {
237            if let Some(desc) = &field.description {
238                if y < area.y + area.height {
239                    let hint_row = Rect { x: area.x + 2, y, width: area.width.saturating_sub(2), height: 1 };
240                    f.render_widget(
241                        Paragraph::new(Line::from(Span::styled(desc.clone(), theme.hint))),
242                        hint_row,
243                    );
244                    y += 1;
245                }
246            }
247        }
248
249        // Line 3: blank separator
250        y += 1;
251    }
252}
253
254/// Produce the bracketed string representation of a field input.
255fn field_repr(input: &FieldInput) -> String {
256    match input {
257        FieldInput::Text(s)    => format!("[ {} ]", if s.is_empty() { "_" } else { s }),
258        FieldInput::Integer(n) => format!("[ {} ]", n),
259        FieldInput::Float(f)   => format!("[ {} ]", f),
260        FieldInput::Boolean(b) => if *b { "[x]".into() } else { "[ ]".into() },
261        FieldInput::Enum { options, selected } => {
262            if options.is_empty() {
263                "< >".into()
264            } else {
265                format!("< {} >", options[*selected])
266            }
267        }
268        FieldInput::List(items) => {
269            if items.is_empty() {
270                "[ (empty) ]".into()
271            } else {
272                format!("[ {} ]", items.join(", "))
273            }
274        }
275    }
276}