1use ratatui::Frame;
2use ratatui::layout::{Position, Rect};
3use ratatui::style::{Color, Modifier, Style};
4use ratatui::text::{Line, Span};
5use ratatui::widgets::{Block, Borders, Paragraph};
6
7use crate::model::Issue;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum FormFocus {
11 Title,
12 Status,
13 Priority,
14 Labels,
15 Assignee,
16 Estimate,
17 Cycles,
18 Milestones,
19 Reviewer,
20 StartDate,
21 DueDate,
22 Parent,
28}
29
30impl FormFocus {
31 pub fn next(self) -> Self {
32 match self {
33 FormFocus::Title => FormFocus::Status,
34 FormFocus::Status => FormFocus::Priority,
35 FormFocus::Priority => FormFocus::Labels,
36 FormFocus::Labels => FormFocus::Assignee,
37 FormFocus::Assignee => FormFocus::Estimate,
38 FormFocus::Estimate => FormFocus::Cycles,
39 FormFocus::Cycles => FormFocus::Milestones,
40 FormFocus::Milestones => FormFocus::Reviewer,
41 FormFocus::Reviewer => FormFocus::StartDate,
42 FormFocus::StartDate => FormFocus::DueDate,
43 FormFocus::DueDate => FormFocus::Parent,
44 FormFocus::Parent => FormFocus::Title,
45 }
46 }
47
48 pub fn prev(self) -> Self {
49 match self {
50 FormFocus::Title => FormFocus::Parent,
56 FormFocus::Status => FormFocus::Title,
57 FormFocus::Priority => FormFocus::Status,
58 FormFocus::Labels => FormFocus::Priority,
59 FormFocus::Assignee => FormFocus::Labels,
60 FormFocus::Estimate => FormFocus::Assignee,
61 FormFocus::Cycles => FormFocus::Estimate,
62 FormFocus::Milestones => FormFocus::Cycles,
63 FormFocus::Reviewer => FormFocus::Milestones,
64 FormFocus::StartDate => FormFocus::Reviewer,
65 FormFocus::DueDate => FormFocus::StartDate,
66 FormFocus::Parent => FormFocus::DueDate,
67 }
68 }
69
70 pub fn label(self) -> &'static str {
71 match self {
72 FormFocus::Title => "Title",
73 FormFocus::Status => "Status",
74 FormFocus::Priority => "Priority",
75 FormFocus::Labels => "Labels",
76 FormFocus::Assignee => "Assignee",
77 FormFocus::Estimate => "Estimate",
78 FormFocus::Cycles => "Cycles",
79 FormFocus::Milestones => "Milestones",
80 FormFocus::Reviewer => "Reviewer",
81 FormFocus::StartDate => "Start date",
82 FormFocus::DueDate => "Due date",
83 FormFocus::Parent => "Parent",
84 }
85 }
86
87 pub const RING: [FormFocus; 12] = [
94 FormFocus::Title,
95 FormFocus::Status,
96 FormFocus::Priority,
97 FormFocus::Labels,
98 FormFocus::Assignee,
99 FormFocus::Estimate,
100 FormFocus::Cycles,
101 FormFocus::Milestones,
102 FormFocus::Reviewer,
103 FormFocus::StartDate,
104 FormFocus::DueDate,
105 FormFocus::Parent,
106 ];
107
108 pub fn index(self) -> usize {
110 Self::RING
111 .iter()
112 .position(|f| *f == self)
113 .expect("every FormFocus variant is in RING")
114 }
115
116 pub fn is_date(self) -> bool {
118 matches!(self, FormFocus::StartDate | FormFocus::DueDate)
119 }
120
121 pub fn is_parent(self) -> bool {
123 matches!(self, FormFocus::Parent)
124 }
125
126 pub fn is_picker_only(self) -> bool {
133 self.is_date() || self.is_parent()
134 }
135}
136
137#[derive(Debug, Clone)]
138pub struct FormState {
139 pub title: String,
140 pub status: String,
141 pub priority: String,
142 pub labels: String,
143 pub assignee: String,
144 pub estimate: String,
145 pub cycles: String,
146 pub milestones: String,
147 pub reviewer: String,
148 pub start_date: String,
149 pub due_date: String,
150 pub parent: String,
157 pub focus: FormFocus,
158}
159
160impl FormState {
161 pub fn new(default_status: &str) -> Self {
162 Self::with_default_assignee(default_status, "")
163 }
164
165 pub fn with_default_assignee(default_status: &str, default_assignee: &str) -> Self {
166 FormState {
167 title: String::new(),
168 status: default_status.to_string(),
169 priority: String::new(),
170 labels: String::new(),
171 assignee: default_assignee.to_string(),
172 estimate: String::new(),
173 cycles: String::new(),
174 milestones: String::new(),
175 reviewer: String::new(),
176 start_date: String::new(),
177 due_date: String::new(),
178 parent: String::new(),
179 focus: FormFocus::Title,
180 }
181 }
182
183 pub fn from_issue(issue: &Issue) -> Self {
198 fn joined(values: &Option<Vec<String>>) -> String {
199 values.as_deref().map(|v| v.join(",")).unwrap_or_default()
200 }
201 FormState {
202 title: issue.title.clone(),
203 status: issue.status.to_string(),
204 priority: issue.priority.to_string(),
205 labels: issue.labels.join(","),
206 assignee: issue.assignee.clone().unwrap_or_default(),
207 estimate: issue.estimate.clone().unwrap_or_default(),
208 cycles: joined(&issue.cycles),
209 milestones: joined(&issue.milestones),
210 reviewer: issue.reviewer.clone().unwrap_or_default(),
211 start_date: issue.start_date.clone().unwrap_or_default(),
212 due_date: issue.due_date.clone().unwrap_or_default(),
213 parent: issue.parent.map(|n| n.to_string()).unwrap_or_default(),
214 focus: FormFocus::Title,
215 }
216 }
217
218 pub fn value_of(&self, focus: FormFocus) -> &str {
224 match focus {
225 FormFocus::Title => &self.title,
226 FormFocus::Status => &self.status,
227 FormFocus::Priority => &self.priority,
228 FormFocus::Labels => &self.labels,
229 FormFocus::Assignee => &self.assignee,
230 FormFocus::Estimate => &self.estimate,
231 FormFocus::Cycles => &self.cycles,
232 FormFocus::Milestones => &self.milestones,
233 FormFocus::Reviewer => &self.reviewer,
234 FormFocus::StartDate => &self.start_date,
235 FormFocus::DueDate => &self.due_date,
236 FormFocus::Parent => &self.parent,
237 }
238 }
239
240 pub fn focused_value(&self) -> &str {
241 self.value_of(self.focus)
242 }
243
244 pub fn focused_value_mut(&mut self) -> &mut String {
245 match self.focus {
246 FormFocus::Title => &mut self.title,
247 FormFocus::Status => &mut self.status,
248 FormFocus::Priority => &mut self.priority,
249 FormFocus::Labels => &mut self.labels,
250 FormFocus::Assignee => &mut self.assignee,
251 FormFocus::Estimate => &mut self.estimate,
252 FormFocus::Cycles => &mut self.cycles,
253 FormFocus::Milestones => &mut self.milestones,
254 FormFocus::Reviewer => &mut self.reviewer,
255 FormFocus::StartDate => &mut self.start_date,
256 FormFocus::DueDate => &mut self.due_date,
257 FormFocus::Parent => &mut self.parent,
258 }
259 }
260
261 pub fn insert_char(&mut self, c: char) {
262 if self.focus.is_parent() {
263 return;
265 }
266 if self.focus.is_date() {
267 if let Some(next) = crate::ui::date_picker::type_date(self.focused_value(), c) {
268 *self.focused_value_mut() = next;
269 }
270 return;
271 }
272 let value = self.focused_value_mut();
273 value.push(c);
274 }
275
276 pub fn delete_char(&mut self) {
277 if self.focus.is_parent() {
278 return;
279 }
280 if self.focus.is_date() {
281 let next = crate::ui::date_picker::backspace_date(self.focused_value());
282 *self.focused_value_mut() = next;
283 return;
284 }
285 let value = self.focused_value_mut();
286 value.pop();
287 }
288
289 pub fn is_date_focus(&self) -> bool {
291 self.focus.is_date()
292 }
293
294 pub fn focused_date_is_complete(&self) -> bool {
296 self.focus.is_date() && crate::ui::date_picker::is_complete_date(self.focused_value())
297 }
298
299 pub fn set_focused_date(&mut self, value: &str) {
301 if self.focus.is_date() {
302 *self.focused_value_mut() = value.to_string();
303 }
304 }
305
306 pub fn move_focus_next(&mut self) {
307 self.focus = self.focus.next();
308 }
309
310 pub fn move_focus_prev(&mut self) {
311 self.focus = self.focus.prev();
312 }
313}
314
315pub fn render(form: &FormState, f: &mut Frame, area: Rect) {
316 let bindings =
317 crate::keybindings::KeyBindings::resolve(&crate::keybindings::KeyBindings::default())
318 .expect("built-in bindings are valid");
319 render_with_bindings(
320 form,
321 f,
322 area,
323 &bindings,
324 crate::keybindings::InputContext::Form,
325 );
326 place_caret(form, f, area);
330}
331
332pub fn render_with_bindings(
333 form: &FormState,
334 f: &mut Frame,
335 area: Rect,
336 bindings: &crate::keybindings::EffectiveBindings,
337 context: crate::keybindings::InputContext,
338) {
339 let focus_idx = form.focus.index();
344 let fields: Vec<(&'static str, &str)> = FormFocus::RING
345 .iter()
346 .map(|focus| (focus.label(), form.value_of(*focus)))
347 .collect();
348
349 let mut lines: Vec<Line<'static>> = Vec::new();
350 for (i, (label, value)) in fields.iter().enumerate() {
351 let cursor = if i == focus_idx { "> " } else { " " };
352 let label_style = if i == focus_idx {
353 Style::default()
354 .add_modifier(Modifier::BOLD)
355 .fg(Color::Cyan)
356 } else {
357 Style::default()
358 };
359 let (display, value_style) = if value.is_empty() && i != focus_idx {
360 ("(empty)".to_string(), Style::default().fg(Color::DarkGray))
361 } else if i == focus_idx {
362 (
363 value.to_string(),
364 Style::default().add_modifier(Modifier::UNDERLINED),
365 )
366 } else {
367 (value.to_string(), Style::default())
368 };
369 lines.push(Line::from(vec![
370 Span::styled(format!("{cursor}{label}: "), label_style),
371 Span::styled(display, value_style),
372 ]));
373 }
374
375 lines.push(Line::from(""));
376 let submit = bindings.label(context, crate::keybindings::TuiAction::Submit);
377 let cancel = bindings.label(context, crate::keybindings::TuiAction::Cancel);
378 let next = bindings.label(context, crate::keybindings::TuiAction::Next);
379 let previous = bindings.label(context, crate::keybindings::TuiAction::Previous);
380 let picker = bindings.label(context, crate::keybindings::TuiAction::OpenPicker);
381 lines.push(Line::from(Span::styled(
382 format!(
383 "{submit}: Submit | {cancel}: Cancel | {next}/{previous}: Fields | {picker}: Date/Parent"
384 ),
385 Style::default().fg(Color::DarkGray),
386 )));
387
388 let para =
389 Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title("Create Issue"));
390
391 f.render_widget(para, area);
392}
393
394pub fn caret_position(form: &FormState, area: Rect) -> Option<Position> {
412 if form.focus.is_parent() {
413 return None;
414 }
415 let inner = Block::default().borders(Borders::ALL).inner(area);
416 if inner.width == 0 || inner.height == 0 {
417 return None;
418 }
419 let row = form.focus.index() as u16;
420 if row >= inner.height {
421 return None;
422 }
423 let prefix = format!("> {}: ", form.focus.label());
428 let column = Span::raw(prefix).width() + Span::raw(form.focused_value().to_string()).width();
429 let column = u16::try_from(column).unwrap_or(u16::MAX);
430 Some(Position::new(
431 inner.x + column.min(inner.width - 1),
432 inner.y + row,
433 ))
434}
435
436pub fn place_caret(form: &FormState, f: &mut Frame, area: Rect) {
449 if let Some(position) = caret_position(form, area) {
450 f.set_cursor_position(position);
451 }
452}
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457
458 #[test]
459 fn focus_cycles_forward() {
460 assert_eq!(FormFocus::Title.next(), FormFocus::Status);
461 assert_eq!(FormFocus::DueDate.next(), FormFocus::Parent);
462 assert_eq!(FormFocus::Parent.next(), FormFocus::Title);
463 }
464
465 #[test]
466 fn focus_cycles_backward() {
467 assert_eq!(FormFocus::Title.prev(), FormFocus::Parent);
468 assert_eq!(FormFocus::Status.prev(), FormFocus::Title);
469 }
470
471 #[test]
479 fn the_ring_and_the_next_prev_chains_agree() {
480 let ring = FormFocus::RING;
481 for (i, focus) in ring.iter().enumerate() {
482 let forward = ring[(i + 1) % ring.len()];
483 let backward = ring[(i + ring.len() - 1) % ring.len()];
484 assert_eq!(focus.next(), forward, "next() after {focus:?} in RING");
485 assert_eq!(focus.prev(), backward, "prev() after {focus:?} in RING");
486 }
487 }
488
489 #[test]
493 fn the_ring_covers_every_field() {
494 for (i, focus) in FormFocus::RING.iter().enumerate() {
495 assert_eq!(focus.index(), i, "{focus:?} must sit at its RING position");
496 }
497 assert!(FormFocus::RING.contains(&FormState::new("todo").focus));
499 }
500
501 #[test]
505 fn the_parent_field_refuses_typed_characters() {
506 let mut form = FormState::new("todo");
507 form.move_focus_next();
508 for _ in 0..FormFocus::RING.len() {
509 if form.focus == FormFocus::Parent {
510 break;
511 }
512 form.move_focus_next();
513 }
514 assert_eq!(form.focus, FormFocus::Parent, "the ring must reach Parent");
515 assert!(form.focus.is_picker_only());
516 assert!(!form.focus.is_date(), "Parent is picker-only, not a date");
517
518 form.insert_char('9');
519 form.insert_char('x');
520 form.delete_char();
521 assert_eq!(form.parent, "", "typing must not reach the parent field");
522 assert_eq!(form.focused_value(), "");
523
524 form.parent = "42".to_string();
526 assert_eq!(form.focused_value(), "42");
527 }
528
529 #[test]
530 fn form_insert_and_delete() {
531 let mut form = FormState::new("todo");
532 form.insert_char('h');
533 form.insert_char('i');
534 assert_eq!(form.title, "hi");
535 form.delete_char();
536 assert_eq!(form.title, "h");
537 }
538
539 #[test]
540 fn form_move_focus() {
541 let mut form = FormState::new("todo");
542 assert_eq!(form.focus, FormFocus::Title);
543 form.move_focus_next();
544 assert_eq!(form.focus, FormFocus::Status);
545 form.move_focus_prev();
546 assert_eq!(form.focus, FormFocus::Title);
547 }
548
549 #[test]
550 fn render_smoke() {
551 use ratatui::Terminal;
552 use ratatui::backend::TestBackend;
553
554 let backend = TestBackend::new(80, 16);
555 let mut terminal = Terminal::new(backend).unwrap();
556 let form = FormState::new("todo");
557 terminal.draw(|f| render(&form, f, f.area())).unwrap();
558
559 let buf = terminal.backend().buffer();
560 let mut text = String::new();
561 for y in 0..buf.area.height {
562 for x in 0..buf.area.width {
563 text.push_str(buf[(x, y)].symbol());
564 }
565 text.push('\n');
566 }
567 assert!(text.contains("F2: Date"), "got {text:?}");
568 }
569
570 #[test]
575 fn rendering_the_create_form_puts_the_caret_on_the_title_row() {
576 use ratatui::Terminal;
577 use ratatui::backend::TestBackend;
578
579 let mut terminal = Terminal::new(TestBackend::new(60, 16)).unwrap();
580 let form = FormState::new("todo");
581 terminal.draw(|f| render(&form, f, f.area())).unwrap();
582
583 assert_eq!(
586 terminal.backend().cursor_position(),
587 Position::new(1 + "> Title: ".len() as u16, 1)
588 );
589 }
590
591 #[test]
592 fn caret_follows_the_typed_value() {
593 let area = Rect::new(10, 5, 40, 20);
594 let mut form = FormState::new("todo");
595 form.title = "abc".to_string();
596 assert_eq!(
597 caret_position(&form, area),
598 Some(Position::new(11 + "> Title: abc".len() as u16, 6))
600 );
601 }
602
603 #[test]
604 fn caret_moves_with_the_focused_row() {
605 let area = Rect::new(0, 0, 40, 20);
606 let mut form = FormState::new("todo");
607 form.move_focus_next();
608 assert_eq!(
609 caret_position(&form, area),
610 Some(Position::new(1 + "> Status: todo".len() as u16, 2))
612 );
613 }
614
615 #[test]
618 fn caret_measures_display_width_not_char_count() {
619 let area = Rect::new(0, 0, 40, 20);
620 let mut form = FormState::new("todo");
621 form.title = "中日".to_string();
622 assert_eq!(
623 caret_position(&form, area),
624 Some(Position::new(1 + "> Title: ".len() as u16 + 4, 1))
625 );
626 }
627
628 #[test]
631 fn caret_is_clamped_into_the_visible_row() {
632 let area = Rect::new(0, 0, 20, 20);
633 let mut form = FormState::new("todo");
634 form.title = "x".repeat(100);
635 assert_eq!(caret_position(&form, area), Some(Position::new(18, 1)));
637 }
638
639 #[test]
640 fn no_caret_on_a_picker_only_field() {
641 let area = Rect::new(0, 0, 40, 20);
642 let mut form = FormState::new("todo");
643 form.focus = FormFocus::Parent;
644 assert_eq!(caret_position(&form, area), None);
645 }
646
647 #[test]
648 fn caret_is_not_placed_outside_the_visible_form() {
649 let area = Rect::new(0, 0, 40, 4);
652 let mut form = FormState::new("todo");
653 form.focus = FormFocus::Reviewer;
654 assert_eq!(caret_position(&form, area), None);
655 }
656
657 #[test]
658 fn with_default_assignee_populates_assignee_field() {
659 let form = FormState::with_default_assignee("in progress", "alice");
660 assert_eq!(form.assignee, "alice");
661 assert_eq!(form.status, "in progress");
662 assert_eq!(form.focus, FormFocus::Title);
663 }
664}