Skip to main content

tix/ui/
form.rs

1use ratatui::Frame;
2use ratatui::layout::Rect;
3use ratatui::style::{Color, Modifier, Style};
4use ratatui::text::{Line, Span};
5use ratatui::widgets::{Block, Borders, Paragraph};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum FormFocus {
9    Title,
10    Status,
11    Priority,
12    Labels,
13    Assignee,
14    Estimate,
15}
16
17impl FormFocus {
18    pub fn next(self) -> Self {
19        match self {
20            FormFocus::Title => FormFocus::Status,
21            FormFocus::Status => FormFocus::Priority,
22            FormFocus::Priority => FormFocus::Labels,
23            FormFocus::Labels => FormFocus::Assignee,
24            FormFocus::Assignee => FormFocus::Estimate,
25            FormFocus::Estimate => FormFocus::Title,
26        }
27    }
28
29    pub fn prev(self) -> Self {
30        match self {
31            FormFocus::Title => FormFocus::Estimate,
32            FormFocus::Status => FormFocus::Title,
33            FormFocus::Priority => FormFocus::Status,
34            FormFocus::Labels => FormFocus::Priority,
35            FormFocus::Assignee => FormFocus::Labels,
36            FormFocus::Estimate => FormFocus::Assignee,
37        }
38    }
39
40    pub fn label(self) -> &'static str {
41        match self {
42            FormFocus::Title => "Title",
43            FormFocus::Status => "Status",
44            FormFocus::Priority => "Priority",
45            FormFocus::Labels => "Labels",
46            FormFocus::Assignee => "Assignee",
47            FormFocus::Estimate => "Estimate",
48        }
49    }
50}
51
52#[derive(Debug, Clone)]
53pub struct FormState {
54    pub title: String,
55    pub status: String,
56    pub priority: String,
57    pub labels: String,
58    pub assignee: String,
59    pub estimate: String,
60    pub focus: FormFocus,
61}
62
63impl FormState {
64    pub fn new(default_status: &str) -> Self {
65        Self::with_default_assignee(default_status, "")
66    }
67
68    pub fn with_default_assignee(default_status: &str, default_assignee: &str) -> Self {
69        FormState {
70            title: String::new(),
71            status: default_status.to_string(),
72            priority: String::new(),
73            labels: String::new(),
74            assignee: default_assignee.to_string(),
75            estimate: String::new(),
76            focus: FormFocus::Title,
77        }
78    }
79
80    pub fn focused_value(&self) -> &str {
81        match self.focus {
82            FormFocus::Title => &self.title,
83            FormFocus::Status => &self.status,
84            FormFocus::Priority => &self.priority,
85            FormFocus::Labels => &self.labels,
86            FormFocus::Assignee => &self.assignee,
87            FormFocus::Estimate => &self.estimate,
88        }
89    }
90
91    pub fn focused_value_mut(&mut self) -> &mut String {
92        match self.focus {
93            FormFocus::Title => &mut self.title,
94            FormFocus::Status => &mut self.status,
95            FormFocus::Priority => &mut self.priority,
96            FormFocus::Labels => &mut self.labels,
97            FormFocus::Assignee => &mut self.assignee,
98            FormFocus::Estimate => &mut self.estimate,
99        }
100    }
101
102    pub fn insert_char(&mut self, c: char) {
103        let value = self.focused_value_mut();
104        value.push(c);
105    }
106
107    pub fn delete_char(&mut self) {
108        let value = self.focused_value_mut();
109        value.pop();
110    }
111
112    pub fn move_focus_next(&mut self) {
113        self.focus = self.focus.next();
114    }
115
116    pub fn move_focus_prev(&mut self) {
117        self.focus = self.focus.prev();
118    }
119}
120
121pub fn render(form: &FormState, f: &mut Frame, area: Rect) {
122    let focus_idx = match form.focus {
123        FormFocus::Title => 0,
124        FormFocus::Status => 1,
125        FormFocus::Priority => 2,
126        FormFocus::Labels => 3,
127        FormFocus::Assignee => 4,
128        FormFocus::Estimate => 5,
129    };
130
131    let fields = [
132        ("Title", form.title.as_str()),
133        ("Status", form.status.as_str()),
134        ("Priority", form.priority.as_str()),
135        ("Labels", form.labels.as_str()),
136        ("Assignee", form.assignee.as_str()),
137        ("Estimate", form.estimate.as_str()),
138    ];
139
140    let mut lines: Vec<Line<'static>> = Vec::new();
141    for (i, (label, value)) in fields.iter().enumerate() {
142        let cursor = if i == focus_idx { "> " } else { "  " };
143        let label_style = if i == focus_idx {
144            Style::default()
145                .add_modifier(Modifier::BOLD)
146                .fg(Color::Cyan)
147        } else {
148            Style::default()
149        };
150        let (display, value_style) = if value.is_empty() && i != focus_idx {
151            ("(empty)".to_string(), Style::default().fg(Color::DarkGray))
152        } else if i == focus_idx {
153            (
154                value.to_string(),
155                Style::default().add_modifier(Modifier::UNDERLINED),
156            )
157        } else {
158            (value.to_string(), Style::default())
159        };
160        lines.push(Line::from(vec![
161            Span::styled(format!("{cursor}{label}: "), label_style),
162            Span::styled(display, value_style),
163        ]));
164    }
165
166    lines.push(Line::from(""));
167    lines.push(Line::from(Span::styled(
168        "Enter: Submit | Esc: Cancel | Tab: Next field",
169        Style::default().fg(Color::DarkGray),
170    )));
171
172    let para =
173        Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title("Create Issue"));
174
175    f.render_widget(para, area);
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn focus_cycles_forward() {
184        assert_eq!(FormFocus::Title.next(), FormFocus::Status);
185        assert_eq!(FormFocus::Estimate.next(), FormFocus::Title);
186    }
187
188    #[test]
189    fn focus_cycles_backward() {
190        assert_eq!(FormFocus::Title.prev(), FormFocus::Estimate);
191        assert_eq!(FormFocus::Status.prev(), FormFocus::Title);
192    }
193
194    #[test]
195    fn form_insert_and_delete() {
196        let mut form = FormState::new("todo");
197        form.insert_char('h');
198        form.insert_char('i');
199        assert_eq!(form.title, "hi");
200        form.delete_char();
201        assert_eq!(form.title, "h");
202    }
203
204    #[test]
205    fn form_move_focus() {
206        let mut form = FormState::new("todo");
207        assert_eq!(form.focus, FormFocus::Title);
208        form.move_focus_next();
209        assert_eq!(form.focus, FormFocus::Status);
210        form.move_focus_prev();
211        assert_eq!(form.focus, FormFocus::Title);
212    }
213
214    #[test]
215    fn render_smoke() {
216        use ratatui::Terminal;
217        use ratatui::backend::TestBackend;
218
219        let backend = TestBackend::new(40, 12);
220        let mut terminal = Terminal::new(backend).unwrap();
221        let form = FormState::new("todo");
222        terminal.draw(|f| render(&form, f, f.area())).unwrap();
223    }
224
225    #[test]
226    fn with_default_assignee_populates_assignee_field() {
227        let form = FormState::with_default_assignee("in progress", "alice");
228        assert_eq!(form.assignee, "alice");
229        assert_eq!(form.status, "in progress");
230        assert_eq!(form.focus, FormFocus::Title);
231    }
232}