Skip to main content

tix/ui/
form.rs

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    /// The parent issue (WP06 / T026). Appended **last**, not slotted next to
23    /// `Assignee` where it would read better: every existing row's index is the
24    /// identity of that row in `render_with_bindings`'s `fields` array, and
25    /// inserting in the middle renumbers all five rows after it. Appending keeps
26    /// the eleven existing indices stable, so no render or golden assertion moves.
27    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            // `Parent`, not `DueDate`: `RING` now ends at `Parent`, and the two
51            // must agree or the backward wrap lands on a different field than the
52            // forward one. This drift was real — `form_arrows_navigate_fields` is
53            // what caught it, and it is why that test compares against `RING` now
54            // instead of a literal.
55            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    /// The focus ring, in `next()` order, starting at `Title`.
88    ///
89    /// One list, so `render_with_bindings` can derive both the focus index and the
90    /// row labels from it. It used to hold a hand-written `match` for the index
91    /// beside a positional array of labels, which agree only by inspection — and
92    /// the two disagree *silently*, as a bold cursor on the wrong row.
93    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    /// This field's row in [`FormFocus::RING`].
109    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    /// Whether this field holds a date (picker-only, no free typing).
117    pub fn is_date(self) -> bool {
118        matches!(self, FormFocus::StartDate | FormFocus::DueDate)
119    }
120
121    /// Whether this field holds a parent (picker-only, single-select).
122    pub fn is_parent(self) -> bool {
123        matches!(self, FormFocus::Parent)
124    }
125
126    /// Whether the field's value is **chosen** from a picker rather than typed.
127    ///
128    /// Typing is refused on these, because a half-typed number or a hand-typed
129    /// title is not a value: the service validates a parent by resolved number, so
130    /// free text here could only ever fail. This is the general form of what
131    /// `is_date` was already doing for dates alone.
132    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    /// The chosen parent's **number**, as a string (WP06 / T026).
151    ///
152    /// A `String` for the same reason `assignee` is one: the form's fields are
153    /// uniformly text, and each is parsed and validated at submit rather than at
154    /// the keystroke. Empty means no parent, which carries the same meaning as an
155    /// empty `--parent ""` on the CLI.
156    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    /// This form with every field prefilled from an existing issue — the TUI's
184    /// "clone issue" action (tix #1484).
185    ///
186    /// Exactly the fields the form *has* are carried over, which is exactly the
187    /// fields creation takes: `number`, `created`, `commit` and `History` are not
188    /// creation inputs, so they are not carried. The title is copied **verbatim**
189    /// rather than suffixed with "copy": the user asked for the source's data, a
190    /// title is not an identity (numbers are), and inventing a naming convention
191    /// here would put a string in the user's issue that no command in this tool
192    /// produces.
193    ///
194    /// Multivalued fields are filled in the same comma-separated form the submit
195    /// path parses (`labels`, `cycles`, `milestones`), so a clone submitted
196    /// unchanged round-trips the source's values exactly.
197    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    /// This form's value for `focus`, regardless of which field is focused.
219    ///
220    /// `focused_value` is `value_of(self.focus)`. The indirection exists so the
221    /// renderer can walk [`FormFocus::RING`] and read each field in turn, which is
222    /// what lets one list drive both the labels and the focus index.
223    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            // A parent is picked, never typed.
264            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    /// Whether the focused field is a picker-only date field.
290    pub fn is_date_focus(&self) -> bool {
291        self.focus.is_date()
292    }
293
294    /// Whether the focused date field already holds a complete, valid date.
295    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    /// Overwrite the focused date field (no-op when it isn't a date field).
300    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    // This entry point renders the form as the active surface, so it places the
327    // caret too. `render_with_bindings` cannot: it is also what draws the form as a
328    // backdrop under a picker, where the cursor belongs to the overlay.
329    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    // Both the focus index and the row labels come from `FormFocus::RING`, so the
340    // cursor and the label it labels cannot drift apart. The value for each field
341    // is read through `focused_value`-style indexing into `FormState` below, which
342    // is why the pairing is by position and not by name.
343    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
394/// Where the terminal caret belongs for `form` rendered into `area`, or `None`
395/// when this form shows no insertion point.
396///
397/// Split from [`place_caret`] so the arithmetic — the part that can be wrong — is
398/// testable without a terminal.
399///
400/// The form is **append-only** editing: `insert_char` pushes and `delete_char`
401/// pops, so the insertion point is always the end of the focused value's text and
402/// that is where the caret belongs. A picker-only field gets none, because
403/// `insert_char` returns early on it: a caret on `Parent` would advertise an
404/// insertion point that does not exist. The date fields do accept typed digits, so
405/// they keep one.
406///
407/// A value wider than the field is clipped by `Paragraph` (which does not wrap
408/// here) and its true caret is off-screen, so the column is clamped into the
409/// visible row rather than handed back out of bounds. Making the value scroll is a
410/// larger change than this fix.
411pub 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    // The same prefix the row renderer writes: focus marker, label, colon, space.
424    // Measured on the rendered spans rather than by character count, so a value
425    // wider than it is long (any non-ASCII in a title) still lands the caret
426    // correctly.
427    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
436/// Put the terminal's text cursor at the end of the focused field's value.
437///
438/// Until this existed, `set_cursor_position` was never called anywhere in the
439/// crate: the cursor stayed wherever the previously rendered widget left it, and
440/// the form showed no insertion point at all. "Create issue" opened on an empty
441/// Title row that was underlined and nothing else, and characters appeared with the
442/// caret parked elsewhere (#1486).
443///
444/// Called by the owner of the frame rather than from `render_with_bindings`,
445/// because where the form is a **backdrop** — under the date-picker overlay — the
446/// overlay owns the cursor and a caret on the row behind it would point at the
447/// wrong surface.
448pub 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    /// `next`, `prev`, and `RING` are three statements of the same order, and the
472    /// compiler cannot check that they agree — an out-of-date arm compiles and
473    /// silently sends the cursor to the wrong field.
474    ///
475    /// Asserted as a walk over the ring rather than as a list of pairs, so adding a
476    /// field cannot leave the pairs stale: the test has to be updated to pass, and
477    /// updating it means reading the ring.
478    #[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    /// `RING` must contain every variant, or a field becomes unreachable: there is
490    /// no way to tab onto it and no way off whatever precedes it. `index()` panics
491    /// on a variant missing from `RING`, so this is that panic's guard, as a test.
492    #[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        // And a fresh form starts on a field that is in the ring.
498        assert!(FormFocus::RING.contains(&FormState::new("todo").focus));
499    }
500
501    /// The parent field is chosen from a picker, so typing into it must do nothing.
502    /// A half-typed number is not a parent, and the service validates by resolved
503    /// number — free text here could only ever be a value that fails at submit.
504    #[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        // But a *picked* value does, and it reads back through the same accessor.
525        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    /// The regression from #1486: with nothing drawn over it, rendering the create
571    /// form leaves the terminal cursor **on the focused row**. Before this,
572    /// `set_cursor_position` was called nowhere in the crate, so the cursor stayed
573    /// wherever the previous frame put it and the form showed no insertion point.
574    #[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        // The block's border puts the inner area at (1, 1); the row prefix for an
584        // empty title is "> Title: ".
585        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            // inner starts one cell inside the border, at (11, 6).
599            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            // Row 1 of the ring, and the inner area already starts one row down.
611            Some(Position::new(1 + "> Status: todo".len() as u16, 2))
612        );
613    }
614
615    /// Width is not length: a two-character title occupies four cells, and the caret
616    /// follows the cells.
617    #[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    /// A value wider than the field is clipped, so its true caret is off-screen; the
629    /// position must stay inside the row rather than point past the border.
630    #[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        // Inner width is 18, so the row's last cell is x = 18.
636        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        // Four rows: the ring's later fields are below the fold and get no caret,
650        // because a position outside the rendered area would be drawn nowhere.
651        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}