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};
6use unicode_width::UnicodeWidthChar;
7
8use crate::model::Issue;
9
10/// Byte offset of `char_index` in `value`, clamped to the value's length.
11fn byte_offset(value: &str, char_index: usize) -> usize {
12    value
13        .char_indices()
14        .nth(char_index)
15        .map_or(value.len(), |(byte, _)| byte)
16}
17
18/// Whether `c` counts as part of a word for Ctrl-Left/Ctrl-Right and Ctrl-W.
19fn is_word_char(c: char) -> bool {
20    c.is_alphanumeric() || c == '_'
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum FormFocus {
25    Title,
26    Status,
27    Priority,
28    Labels,
29    Assignee,
30    Estimate,
31    Cycles,
32    Milestones,
33    Reviewer,
34    StartDate,
35    DueDate,
36    /// The parent issue (WP06 / T026). Appended **last**, not slotted next to
37    /// `Assignee` where it would read better: every existing row's index is the
38    /// identity of that row in `render_with_bindings`'s `fields` array, and
39    /// inserting in the middle renumbers all five rows after it. Appending keeps
40    /// the eleven existing indices stable, so no render or golden assertion moves.
41    Parent,
42    /// The entry target of the F4 New Issue mode (`tui-new-issue-mode`): Triage or
43    /// Todo. Appended, per the rule above. It is a **toggle**, not a text field:
44    /// Enter/Space flips it and Left/Right pick a side, and typing into it does
45    /// nothing. Choosing a side writes the same status the submit path would use,
46    /// so the Status row below stays the authoritative value.
47    Target,
48    /// The selected new-issue template's name, or empty for none. Enter/F2 opens
49    /// the template picker (a scratch [`crate::ui::pickers::DropdownState`] over
50    /// the configured `[templates.<name>]` names); selecting one pre-fills the
51    /// rows. Not a free-text field: the value names a configured record.
52    Template,
53    /// The issue body (Markdown), which a template pre-fills. Editable like the
54    /// other text rows; newlines are escaped as `\n` on screen so the row stays a
55    /// single line, but the submitted body keeps them verbatim.
56    Body,
57    /// The issue's **project associations** (WP06 T029), the comma-joined spelling
58    /// of the ids the form will associate.
59    ///
60    /// Appended after `Body` for the rule the rows above already state: every
61    /// existing row's index is its identity in the renderer, and inserting in the
62    /// middle renumbers every row after it. The value is a **list of resolved ids**,
63    /// and the row is **picker-only** — a project the form invented would be one the
64    /// registry does not hold, and a reference is chosen from a picker, never typed.
65    Projects,
66    /// The issue's **epic references** (WP06 T031), the comma-joined spelling of the
67    /// ids the form will reference.
68    ///
69    /// Appended after `Projects` for the rule every row above states — no existing
70    /// index moves. The exact analogue of [`FormFocus::Projects`] for the epic
71    /// registry: a list of resolved ids, chosen through the **multi-select** picker
72    /// and never typed, because an epic the form invented would be one the registry
73    /// does not hold. The form constructs and validates no epic itself — the ids come
74    /// from [`crate::epics::list_epics`] and are handed to
75    /// [`crate::commands::epic::resolve_epics`] at submit.
76    Epics,
77}
78
79/// Where a new issue created from the F4 New Issue mode enters the board.
80///
81/// The explicit choice the mode exists for: a form can default to one entry point
82/// but cannot *say* which one it is using, and the two destinations mean different
83/// things (the inbox is a queue with an age; `todo` is scheduled work). The chosen
84/// target's status is written into [`FormState::status`], which remains the value
85/// submitted — so the Status row reflects the choice and can still be overridden
86/// for a column the target does not name.
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
88pub enum NewIssueTarget {
89    /// Enter the triage inbox's entry status.
90    Triage,
91    /// Enter the todo status directly.
92    #[default]
93    Todo,
94}
95
96impl NewIssueTarget {
97    /// The label shown in the Target row.
98    pub fn label(self) -> &'static str {
99        match self {
100            NewIssueTarget::Triage => "Triage",
101            NewIssueTarget::Todo => "Todo",
102        }
103    }
104
105    /// The other side of the toggle.
106    pub fn next(self) -> Self {
107        match self {
108            NewIssueTarget::Triage => NewIssueTarget::Todo,
109            NewIssueTarget::Todo => NewIssueTarget::Triage,
110        }
111    }
112}
113
114impl FormFocus {
115    pub fn next(self) -> Self {
116        match self {
117            FormFocus::Title => FormFocus::Status,
118            FormFocus::Status => FormFocus::Priority,
119            FormFocus::Priority => FormFocus::Labels,
120            FormFocus::Labels => FormFocus::Assignee,
121            FormFocus::Assignee => FormFocus::Estimate,
122            FormFocus::Estimate => FormFocus::Cycles,
123            FormFocus::Cycles => FormFocus::Milestones,
124            FormFocus::Milestones => FormFocus::Reviewer,
125            FormFocus::Reviewer => FormFocus::StartDate,
126            FormFocus::StartDate => FormFocus::DueDate,
127            FormFocus::DueDate => FormFocus::Parent,
128            FormFocus::Parent => FormFocus::Target,
129            FormFocus::Target => FormFocus::Template,
130            FormFocus::Template => FormFocus::Body,
131            FormFocus::Body => FormFocus::Projects,
132            FormFocus::Projects => FormFocus::Epics,
133            FormFocus::Epics => FormFocus::Title,
134        }
135    }
136
137    pub fn prev(self) -> Self {
138        match self {
139            // `Epics`, not `Projects`: `RING` now ends at `Epics`, and the two
140            // must agree or the backward wrap lands on a different field than the
141            // forward one.
142            FormFocus::Title => FormFocus::Epics,
143            FormFocus::Status => FormFocus::Title,
144            FormFocus::Priority => FormFocus::Status,
145            FormFocus::Labels => FormFocus::Priority,
146            FormFocus::Assignee => FormFocus::Labels,
147            FormFocus::Estimate => FormFocus::Assignee,
148            FormFocus::Cycles => FormFocus::Estimate,
149            FormFocus::Milestones => FormFocus::Cycles,
150            FormFocus::Reviewer => FormFocus::Milestones,
151            FormFocus::StartDate => FormFocus::Reviewer,
152            FormFocus::DueDate => FormFocus::StartDate,
153            FormFocus::Parent => FormFocus::DueDate,
154            FormFocus::Target => FormFocus::Parent,
155            FormFocus::Template => FormFocus::Target,
156            FormFocus::Body => FormFocus::Template,
157            FormFocus::Projects => FormFocus::Body,
158            FormFocus::Epics => FormFocus::Projects,
159        }
160    }
161
162    pub fn label(self) -> &'static str {
163        match self {
164            FormFocus::Title => "Title",
165            FormFocus::Status => "Status",
166            FormFocus::Priority => "Priority",
167            FormFocus::Labels => "Labels",
168            FormFocus::Assignee => "Assignee",
169            FormFocus::Estimate => "Estimate",
170            FormFocus::Cycles => "Cycles",
171            FormFocus::Milestones => "Milestones",
172            FormFocus::Reviewer => "Reviewer",
173            FormFocus::StartDate => "Start date",
174            FormFocus::DueDate => "Due date",
175            FormFocus::Parent => "Parent",
176            FormFocus::Target => "Target",
177            FormFocus::Template => "Template",
178            FormFocus::Body => "Body",
179            FormFocus::Projects => "Projects",
180            FormFocus::Epics => "Epics",
181        }
182    }
183
184    /// The focus ring, in `next()` order, starting at `Title`.
185    ///
186    /// One list, so `render_with_bindings` can derive both the focus index and the
187    /// row labels from it. It used to hold a hand-written `match` for the index
188    /// beside a positional array of labels, which agree only by inspection — and
189    /// the two disagree *silently*, as a bold cursor on the wrong row.
190    pub const RING: [FormFocus; 17] = [
191        FormFocus::Title,
192        FormFocus::Status,
193        FormFocus::Priority,
194        FormFocus::Labels,
195        FormFocus::Assignee,
196        FormFocus::Estimate,
197        FormFocus::Cycles,
198        FormFocus::Milestones,
199        FormFocus::Reviewer,
200        FormFocus::StartDate,
201        FormFocus::DueDate,
202        FormFocus::Parent,
203        FormFocus::Target,
204        FormFocus::Template,
205        FormFocus::Body,
206        // WP06 T029, appended last: `RING.len()` moved, no existing index did.
207        FormFocus::Projects,
208        // WP06 T031: `Epics` appended last, for the same reason.
209        FormFocus::Epics,
210    ];
211
212    /// This field's row in [`FormFocus::RING`].
213    pub fn index(self) -> usize {
214        Self::RING
215            .iter()
216            .position(|f| *f == self)
217            .expect("every FormFocus variant is in RING")
218    }
219
220    /// Whether this field holds a date (picker-only, no free typing).
221    pub fn is_date(self) -> bool {
222        matches!(self, FormFocus::StartDate | FormFocus::DueDate)
223    }
224
225    /// Whether this field holds a parent (picker-only, single-select).
226    pub fn is_parent(self) -> bool {
227        matches!(self, FormFocus::Parent)
228    }
229
230    /// Whether this field holds the issue's board column (Status).
231    ///
232    /// Chosen from the active board's columns, never typed: a board column is a
233    /// named position on a board, not a value to invent by hand, and free text
234    /// could name one the board does not configure.
235    pub fn is_status(self) -> bool {
236        matches!(self, FormFocus::Status)
237    }
238
239    /// Whether this field holds the issue's priority.
240    ///
241    /// Chosen from the built-in set plus the priorities the project already uses,
242    /// never typed, for the same reason as Status — with the picker's `+` inline
243    /// create as the escape hatch for a priority the options do not list yet.
244    pub fn is_priority(self) -> bool {
245        matches!(self, FormFocus::Priority)
246    }
247
248    /// Whether this field is the New Issue target toggle.
249    pub fn is_target(self) -> bool {
250        matches!(self, FormFocus::Target)
251    }
252
253    /// Whether this field names a selected template.
254    ///
255    /// Chosen from the template picker, never typed: the value has to name a
256    /// configured `[templates.<name>]` record, and free text could only ever fail
257    /// to resolve to one.
258    pub fn is_template(self) -> bool {
259        matches!(self, FormFocus::Template)
260    }
261
262    /// Whether this field holds the issue's **project associations** (WP06 T029).
263    ///
264    /// Picker-only, for the reason `Status`/`Priority`/`Parent` are: the value has
265    /// to name projects the registry holds, and the form may not construct or
266    /// validate one itself — so a typed id could only ever be a guess, and the
267    /// picker is fed by [`crate::projects::list_projects`].
268    pub fn is_project(self) -> bool {
269        matches!(self, FormFocus::Projects)
270    }
271
272    /// Whether this field holds the issue's **epic references** (WP06 T031).
273    ///
274    /// Picker-only, for the reason `Projects` is: the value has to name epics the
275    /// registry holds, and the form may not construct or validate one itself — so a
276    /// typed id could only ever be a guess, and the picker is fed by
277    /// [`crate::epics::list_epics`].
278    pub fn is_epic(self) -> bool {
279        matches!(self, FormFocus::Epics)
280    }
281
282    /// Whether typing and backspace are refused on this field.
283    ///
284    /// `Parent` (picked by number), `Target` (toggled), `Template` (picked by
285    /// name), `Status` and `Priority` (picked from their dropdowns) all hold
286    /// values that are *chosen*, so a character has no meaning here. They guard
287    /// `insert_char`/`delete_char` from one predicate rather than five inline
288    /// checks, so a further control field cannot be added and silently left
289    /// typeable.
290    pub fn refuses_typing(self) -> bool {
291        self.is_parent()
292            || self.is_target()
293            || self.is_template()
294            || self.is_status()
295            || self.is_priority()
296            || self.is_project()
297            || self.is_epic()
298    }
299
300    /// Whether the field's value is **chosen** from a picker rather than typed.
301    ///
302    /// Typing is refused on the chosen rows, because a half-typed number or a
303    /// hand-typed priority is not a value: the service validates a parent by
304    /// resolved number, and Status/Priority are picked from their dropdowns, so
305    /// free text here could only ever fail or invent a column. This is the
306    /// general form of what `is_date` was already doing for dates alone.
307    pub fn is_picker_only(self) -> bool {
308        self.is_date()
309            || self.is_parent()
310            || self.is_status()
311            || self.is_priority()
312            || self.is_project()
313            || self.is_epic()
314    }
315}
316
317#[derive(Debug, Clone)]
318pub struct FormState {
319    pub title: String,
320    pub status: String,
321    pub priority: String,
322    pub labels: String,
323    pub assignee: String,
324    pub estimate: String,
325    pub cycles: String,
326    pub milestones: String,
327    pub reviewer: String,
328    pub start_date: String,
329    pub due_date: String,
330    /// The chosen parent's **number**, as a string (WP06 / T026).
331    ///
332    /// A `String` for the same reason `assignee` is one: the form's fields are
333    /// uniformly text, and each is parsed and validated at submit rather than at
334    /// Empty means no parent, which carries the same meaning as an
335    /// empty `--parent ""` on the CLI.
336    pub parent: String,
337    /// The entry target of the F4 New Issue mode. Choosing a side writes the
338    /// matching status into [`Self::status`], which is what submit reads.
339    pub target: NewIssueTarget,
340    /// The selected template's identity, or empty for none.
341    ///
342    /// A **stable template ID** (`[issue_templates.<id>]`) when
343    /// [`Self::template_scope`] is set, otherwise the legacy
344    /// `[templates.<name>]` record's name. A label only: the fields it filled are
345    /// held separately, so clearing the selection keeps what was typed.
346    ///
347    /// The stable-ID case is identity, not a display label (C-002): a rename in
348    /// `tix.toml` changes the label the row *shows* through
349    /// [`Self::display_value`], never this value — so a submit resolves against
350    /// the same template after the rename.
351    pub template: String,
352    /// The explicit scope of the selected stable template, when one is selected.
353    ///
354    /// `Some("repository")`/`Some("board:<name>")`/`Some("team:<label>")` marks
355    /// the selection as a stable-ID template and makes the scope **visible**
356    /// (FR-005): [`Self::display_value`] renders it beside the ID. `None` means
357    /// no stable template is selected — either none at all, or a legacy
358    /// `[templates.<name>]` pre-fill. The scope is the template's own declared
359    /// scope, split out here so it travels with the selection and is what the
360    /// shared resolver is asked for.
361    pub template_scope: Option<String>,
362    /// The issue body (Markdown). Submitted verbatim; rendered with newlines
363    /// escaped so the row stays one line.
364    pub body: String,
365    /// The issue's **project associations** (WP06 T029), as the comma-joined
366    /// spelling of the resolved project ids — the same shape `labels`, `cycles`
367    /// and `milestones` use, and the shape the submit path parses.
368    ///
369    /// A set of ids, not a display string: the picker offers
370    /// [`crate::projects::list_projects`]' ids, [`Self::from_issue`] pre-selects the
371    /// issue's stored associations so an edit cannot silently drop the ones the user
372    /// did not touch, and the **service** validates them at submit. The form itself
373    /// never constructs or validates a project.
374    pub projects: String,
375    /// The issue's **epic references** (WP06 T031), as the comma-joined spelling of
376    /// the resolved epic ids — the same shape `projects`, `labels`, `cycles` and
377    /// `milestones` use, and the shape the submit path parses.
378    ///
379    /// A set of ids, not a display string: the picker offers
380    /// [`crate::epics::list_epics`]' ids, [`Self::from_issue`] pre-selects the
381    /// issue's stored references so an edit or clone cannot silently drop the ones
382    /// the user did not touch, and the **service** validates them at submit. The form
383    /// itself never constructs or validates an epic.
384    pub epics: String,
385    pub focus: FormFocus,
386    /// The insertion point in the focused field, in **characters** (not bytes,
387    /// so a multi-byte value is never split). Always clamped to the field's
388    /// value when used; every writer that replaces a value wholesale resets it
389    /// to the end.
390    pub cursor: usize,
391}
392
393impl FormState {
394    pub fn new(default_status: &str) -> Self {
395        Self::with_default_assignee(default_status, "")
396    }
397
398    pub fn with_default_assignee(default_status: &str, default_assignee: &str) -> Self {
399        FormState {
400            title: String::new(),
401            status: default_status.to_string(),
402            priority: String::new(),
403            labels: String::new(),
404            assignee: default_assignee.to_string(),
405            estimate: String::new(),
406            cycles: String::new(),
407            milestones: String::new(),
408            reviewer: String::new(),
409            start_date: String::new(),
410            due_date: String::new(),
411            parent: String::new(),
412            target: NewIssueTarget::default(),
413            template: String::new(),
414            template_scope: None,
415            body: String::new(),
416            projects: String::new(),
417            epics: String::new(),
418            focus: FormFocus::Title,
419            cursor: 0,
420        }
421    }
422
423    /// Pre-fill the rows a template sets, leaving rows it omits untouched.
424    ///
425    /// *Untouched* rather than *cleared* is the whole point: a template is applied
426    /// to a form the user may already have typed into, and a template that named
427    /// only `labels` must not silently erase their title. Vector fields follow the
428    /// same rule as the submit path — they are the comma-joined spelling of the
429    /// list they would create.
430    pub fn apply_template(&mut self, name: &str, template: &crate::config::IssueTemplate) {
431        if let Some(title) = &template.title {
432            self.title = title.clone();
433        }
434        if let Some(body) = &template.body {
435            self.body = body.clone();
436        }
437        if !template.labels.is_empty() {
438            self.labels = template.labels.join(",");
439        }
440        if let Some(status) = &template.status {
441            self.status = status.clone();
442        }
443        if let Some(priority) = &template.priority {
444            self.priority = priority.clone();
445        }
446        if let Some(assignee) = &template.assignee {
447            self.assignee = assignee.clone();
448        }
449        if let Some(estimate) = &template.estimate {
450            self.estimate = estimate.clone();
451        }
452        if let Some(reviewer) = &template.reviewer {
453            self.reviewer = reviewer.clone();
454        }
455        if !template.cycles.is_empty() {
456            self.cycles = template.cycles.join(",");
457        }
458        if !template.milestones.is_empty() {
459            self.milestones = template.milestones.join(",");
460        }
461        if let Some(start) = &template.start_date {
462            self.start_date = start.clone();
463        }
464        if let Some(due) = &template.due_date {
465            self.due_date = due.clone();
466        }
467        self.template = name.to_string();
468        self.template_scope = None;
469        self.cursor_to_end();
470    }
471
472    /// Pre-fill the rows a **stable-ID** template sets, and record its ID + scope.
473    ///
474    /// The sibling of [`Self::apply_template`] for `[issue_templates.<id>]`
475    /// records (WP05/T024), and deliberately the same shape: only rows the
476    /// template actually sets are written, so applying a labels-only template
477    /// cannot erase a title the user already typed.
478    ///
479    /// The values come from the template's `defaults` and its prompt `default`s,
480    /// in the **same precedence the shared resolver merges them** (`contracts/
481    /// resolver.md`): a prompt's default wins over the block default for the same
482    /// key, because user input would. This is seeding only — required prompts with
483    /// no value are left empty and reported by the resolver at submit, not
484    /// pre-filled with a placeholder.
485    ///
486    /// `template.extensions` (unknown metadata) is **not read**: it is preserved
487    /// data, never interpreted as a form field (C-006). List-valued defaults are
488    /// joined with the same comma rule the submit path parses, and rows this form
489    /// does not have (`files`, `related_issues`, `type`) are applied by the
490    /// resolver from the defaults at submit instead — the form neither invents a
491    /// row for them nor silently drops them.
492    pub fn apply_stable_template(
493        &mut self,
494        id: &str,
495        scope: &str,
496        template: &crate::templates::Template,
497    ) {
498        /// The prompt default for `key`, when a prompt declares a non-blank one.
499        ///
500        /// Returned **verbatim**: the resolver clones a default rather than
501        /// trimming it, so trimming here would make the seeded form disagree with
502        /// what a submit actually writes (a Markdown body's trailing newline is
503        /// content). The registry already refuses a blank default.
504        fn prompt_default(template: &crate::templates::Template, key: &str) -> Option<String> {
505            template
506                .prompts
507                .iter()
508                .find(|prompt| prompt.key == key)
509                .and_then(|prompt| prompt.default.as_deref())
510                .filter(|value| !value.trim().is_empty())
511                .map(str::to_string)
512        }
513        // Precedence: prompt default, then block default, then leave untouched.
514        // Both are taken verbatim, matching the resolver's merge.
515        let scalar = |key: &str, fallback: Option<&String>| -> Option<String> {
516            prompt_default(template, key).or_else(|| fallback.cloned())
517        };
518        // A list field: a prompt default is already comma-joined, a block default
519        // is joined with the submit path's rule. Empty means "leave untouched".
520        let list = |key: &str, values: &[String]| -> Option<String> {
521            if let Some(value) = prompt_default(template, key) {
522                return Some(value);
523            }
524            let joined = values
525                .iter()
526                .filter(|value| !value.trim().is_empty())
527                .cloned()
528                .collect::<Vec<_>>()
529                .join(",");
530            (!joined.is_empty()).then_some(joined)
531        };
532
533        let defaults = &template.defaults;
534        if let Some(value) = scalar("title", defaults.title.as_ref()) {
535            self.title = value;
536        }
537        if let Some(value) = scalar("body", defaults.body.as_ref()) {
538            self.body = value;
539        }
540        if let Some(value) = scalar("status", defaults.status.as_ref()) {
541            self.status = value;
542        }
543        if let Some(value) = scalar("priority", defaults.priority.as_ref()) {
544            self.priority = value;
545        }
546        if let Some(value) = scalar("assignee", defaults.assignee.as_ref()) {
547            self.assignee = value;
548        }
549        if let Some(value) = scalar("estimate", defaults.estimate.as_ref()) {
550            self.estimate = value;
551        }
552        if let Some(value) = scalar("reviewer", defaults.reviewer.as_ref()) {
553            self.reviewer = value;
554        }
555        if let Some(value) = scalar("start_date", defaults.start_date.as_ref()) {
556            self.start_date = value;
557        }
558        if let Some(value) = scalar("due_date", defaults.due_date.as_ref()) {
559            self.due_date = value;
560        }
561        if let Some(value) = scalar("parent", defaults.parent.as_ref()) {
562            self.parent = value;
563        }
564        if let Some(value) = list("labels", &defaults.labels) {
565            self.labels = value;
566        }
567        if let Some(value) = list("cycles", &defaults.cycles) {
568            self.cycles = value;
569        }
570        if let Some(value) = list("milestones", &defaults.milestones) {
571            self.milestones = value;
572        }
573        self.template = id.to_string();
574        self.template_scope = Some(scope.to_string());
575        self.cursor_to_end();
576    }
577
578    /// This form with every field prefilled from an existing issue — the TUI's
579    /// "clone issue" action (tix #1484).
580    ///
581    /// Exactly the fields the form *has* are carried over, which is exactly the
582    /// fields creation takes: `number`, `created`, `commit` and `History` are not
583    /// creation inputs, so they are not carried. The title is copied **verbatim**
584    /// rather than suffixed with "copy": the user asked for the source's data, a
585    /// title is not an identity (numbers are), and inventing a naming convention
586    /// here would put a string in the user's issue that no command in this tool
587    /// produces.
588    ///
589    /// Multivalued fields are filled in the same comma-separated form the submit
590    /// path parses (`labels`, `cycles`, `milestones`), so a clone submitted
591    /// unchanged round-trips the source's values exactly.
592    pub fn from_issue(issue: &Issue) -> Self {
593        fn joined(values: &Option<Vec<String>>) -> String {
594            values.as_deref().map(|v| v.join(",")).unwrap_or_default()
595        }
596        FormState {
597            title: issue.title.clone(),
598            status: issue.status.to_string(),
599            priority: issue.priority.to_string(),
600            labels: issue.labels.join(","),
601            assignee: issue.assignee.clone().unwrap_or_default(),
602            estimate: issue.estimate.clone().unwrap_or_default(),
603            cycles: joined(&issue.cycles),
604            milestones: joined(&issue.milestones),
605            reviewer: issue.reviewer.clone().unwrap_or_default(),
606            start_date: issue.start_date.clone().unwrap_or_default(),
607            due_date: issue.due_date.clone().unwrap_or_default(),
608            parent: issue
609                .parent
610                .map(|reference| {
611                    reference
612                        .as_number()
613                        .map_or_else(|| reference.to_string(), |number| number.to_string())
614                })
615                .unwrap_or_default(),
616            // The body is a form row now (`tui-new-issue-mode`), so a clone that
617            // dropped it would be the partial copy #1484's own doc warns about.
618            body: issue.body.clone(),
619            // WP06 T029 step 2: the associations are pre-selected in the same
620            // comma-joined shape the submit path parses, so an edit or clone cannot
621            // silently drop the associations the user did not touch. The stored ids
622            // are carried **verbatim** (including a dangling reference the registry
623            // no longer holds): dropping it here would hide a reference `tix lint`
624            // still reports, and the row's own picker lists the current value so it
625            // stays visible and removable.
626            projects: joined(&issue.projects),
627            // WP06 T031 step 2: the references are pre-selected in the same
628            // comma-joined shape the submit path parses, so an edit or clone cannot
629            // silently drop the references the user did not touch. The stored ids are
630            // carried **verbatim** (including a dangling reference the registry no
631            // longer holds): dropping it here would hide a reference `tix lint` still
632            // reports, and the row's own picker lists the current value so it stays
633            // visible and removable.
634            epics: joined(&issue.epics),
635            // A clone copies the source's status verbatim; the target is set to
636            // match it so the Target row does not claim a destination the form is
637            // not using. `App` refines `Triage` when the source sits in the inbox.
638            target: NewIssueTarget::Todo,
639            template: String::new(),
640            template_scope: None,
641            focus: FormFocus::Title,
642            cursor: issue.title.chars().count(),
643        }
644    }
645
646    /// This form's value for `focus`, regardless of which field is focused.
647    ///
648    /// `focused_value` is `value_of(self.focus)`. The indirection exists so the
649    /// renderer can walk [`FormFocus::RING`] and read each field in turn, which is
650    /// what lets one list drive both the labels and the focus index.
651    pub fn value_of(&self, focus: FormFocus) -> &str {
652        match focus {
653            FormFocus::Title => &self.title,
654            FormFocus::Status => &self.status,
655            FormFocus::Priority => &self.priority,
656            FormFocus::Labels => &self.labels,
657            FormFocus::Assignee => &self.assignee,
658            FormFocus::Estimate => &self.estimate,
659            FormFocus::Cycles => &self.cycles,
660            FormFocus::Milestones => &self.milestones,
661            FormFocus::Reviewer => &self.reviewer,
662            FormFocus::StartDate => &self.start_date,
663            FormFocus::DueDate => &self.due_date,
664            FormFocus::Parent => &self.parent,
665            // Not a stored string: the target is an enum with two labels, and
666            // rendering its label here is what keeps it a row like every other.
667            FormFocus::Target => self.target.label(),
668            FormFocus::Template => &self.template,
669            FormFocus::Body => &self.body,
670            FormFocus::Projects => &self.projects,
671            FormFocus::Epics => &self.epics,
672        }
673    }
674
675    /// The text actually drawn for `focus`.
676    ///
677    /// Identical to [`Self::value_of`] everywhere but two rows. The body's
678    /// newlines are escaped (`\n`) so a multi-line Markdown body occupies one row
679    /// and the row arithmetic the caret depends on stays true; the submitted value
680    /// keeps the real newlines. The Template row appends the selected template's
681    /// **explicit scope** (`id [scope]`) so the scope is visible before submit
682    /// (FR-005); the stored [`Self::template`] stays the bare ID, which is what
683    /// selection and resolution read.
684    pub fn display_value(&self, focus: FormFocus) -> std::borrow::Cow<'_, str> {
685        let value = self.value_of(focus);
686        if focus == FormFocus::Body && value.contains('\n') {
687            return std::borrow::Cow::Owned(value.replace('\n', "\\n"));
688        }
689        if focus == FormFocus::Template
690            && !value.is_empty()
691            && let Some(scope) = &self.template_scope
692        {
693            return std::borrow::Cow::Owned(format!("{value} [{scope}]"));
694        }
695        std::borrow::Cow::Borrowed(value)
696    }
697
698    /// [`Self::display_value`] for the focused row.
699    pub fn focused_display_value(&self) -> std::borrow::Cow<'_, str> {
700        self.display_value(self.focus)
701    }
702
703    pub fn focused_value(&self) -> &str {
704        self.value_of(self.focus)
705    }
706
707    /// The focused field's editable buffer, or `None` when the field has none.
708    ///
709    /// `Option` rather than a `panic` for the control rows: `Target` is an enum
710    /// with no buffer of its own, and the alternative — a `&mut String` arm that
711    /// panics — would make a guard in `insert_char` the only thing standing
712    /// between a mis-set focus and an abort. A `None` makes the callers handle it.
713    pub fn focused_value_mut(&mut self) -> Option<&mut String> {
714        match self.focus {
715            FormFocus::Title => Some(&mut self.title),
716            FormFocus::Status => Some(&mut self.status),
717            FormFocus::Priority => Some(&mut self.priority),
718            FormFocus::Labels => Some(&mut self.labels),
719            FormFocus::Assignee => Some(&mut self.assignee),
720            FormFocus::Estimate => Some(&mut self.estimate),
721            FormFocus::Cycles => Some(&mut self.cycles),
722            FormFocus::Milestones => Some(&mut self.milestones),
723            FormFocus::Reviewer => Some(&mut self.reviewer),
724            FormFocus::StartDate => Some(&mut self.start_date),
725            FormFocus::DueDate => Some(&mut self.due_date),
726            FormFocus::Parent => Some(&mut self.parent),
727            FormFocus::Template => Some(&mut self.template),
728            FormFocus::Body => Some(&mut self.body),
729            FormFocus::Projects => Some(&mut self.projects),
730            FormFocus::Epics => Some(&mut self.epics),
731            FormFocus::Target => None,
732        }
733    }
734
735    /// The insertion point, clamped to the focused value's length.
736    pub fn cursor(&self) -> usize {
737        self.cursor.min(self.focused_value().chars().count())
738    }
739
740    /// Whether the focused row supports insert/cursor editing.
741    ///
742    /// A picker-only field refuses typing, and a date field is edited as a whole
743    /// value (`type_date` appends), so neither has an insertion point to move.
744    fn editable_text(&self) -> bool {
745        !self.focus.refuses_typing() && !self.focus.is_date()
746    }
747
748    /// Put the insertion point after the focused field's last character.
749    pub fn cursor_to_end(&mut self) {
750        self.cursor = self.focused_value().chars().count();
751    }
752
753    /// Put the insertion point before the focused field's first character.
754    pub fn cursor_to_start(&mut self) {
755        if self.editable_text() {
756            self.cursor = 0;
757        }
758    }
759
760    /// Move the insertion point one character left (no-op at the start).
761    pub fn move_cursor_left(&mut self) {
762        if self.editable_text() {
763            self.cursor = self.cursor().saturating_sub(1);
764        }
765    }
766
767    /// Move the insertion point one character right (no-op at the end).
768    pub fn move_cursor_right(&mut self) {
769        if self.editable_text() {
770            self.cursor = (self.cursor() + 1).min(self.focused_value().chars().count());
771        }
772    }
773
774    /// Move the insertion point to the start of the previous word.
775    ///
776    /// Skips the run of non-word characters before the caret first, then the
777    /// word itself, which is the readline/Emacs rule `Ctrl-Left` follows.
778    pub fn move_cursor_word_left(&mut self) {
779        if !self.editable_text() {
780            return;
781        }
782        let chars: Vec<char> = self.focused_value().chars().collect();
783        let mut index = self.cursor();
784        while index > 0 && !is_word_char(chars[index - 1]) {
785            index -= 1;
786        }
787        while index > 0 && is_word_char(chars[index - 1]) {
788            index -= 1;
789        }
790        self.cursor = index;
791    }
792
793    /// Move the insertion point to the end of the next word.
794    pub fn move_cursor_word_right(&mut self) {
795        if !self.editable_text() {
796            return;
797        }
798        let chars: Vec<char> = self.focused_value().chars().collect();
799        let mut index = self.cursor();
800        while index < chars.len() && !is_word_char(chars[index]) {
801            index += 1;
802        }
803        while index < chars.len() && is_word_char(chars[index]) {
804            index += 1;
805        }
806        self.cursor = index;
807    }
808
809    /// Delete the character before the insertion point (`Backspace`).
810    pub fn delete_char(&mut self) {
811        if self.focus.refuses_typing() {
812            return;
813        }
814        if self.focus.is_date() {
815            let next = crate::ui::date_picker::backspace_date(self.focused_value());
816            if let Some(value) = self.focused_value_mut() {
817                *value = next;
818            }
819            return;
820        }
821        let index = self.cursor();
822        if index == 0 {
823            return;
824        }
825        let byte = byte_offset(self.focused_value(), index - 1);
826        if let Some(value) = self.focused_value_mut() {
827            value.remove(byte);
828        }
829        self.cursor = index - 1;
830    }
831
832    /// Delete the character at the insertion point (`Delete`).
833    pub fn delete_forward_char(&mut self) {
834        if !self.editable_text() {
835            return;
836        }
837        let index = self.cursor();
838        let byte = byte_offset(self.focused_value(), index);
839        if byte < self.focused_value().len()
840            && let Some(value) = self.focused_value_mut()
841        {
842            value.remove(byte);
843        }
844    }
845
846    /// Delete from the insertion point to the end of the value (`Ctrl-K`).
847    pub fn delete_to_end(&mut self) {
848        if !self.editable_text() {
849            return;
850        }
851        let byte = byte_offset(self.focused_value(), self.cursor());
852        if let Some(value) = self.focused_value_mut() {
853            value.truncate(byte);
854        }
855    }
856
857    /// Delete from the start of the value to the insertion point (`Ctrl-U`).
858    pub fn delete_to_start(&mut self) {
859        if !self.editable_text() {
860            return;
861        }
862        let byte = byte_offset(self.focused_value(), self.cursor());
863        if let Some(value) = self.focused_value_mut() {
864            value.drain(..byte);
865        }
866        self.cursor = 0;
867    }
868
869    /// Delete the word before the insertion point (`Ctrl-W`, `Alt-Backspace`).
870    pub fn delete_word_backward(&mut self) {
871        if !self.editable_text() {
872            return;
873        }
874        let chars: Vec<char> = self.focused_value().chars().collect();
875        let end = self.cursor();
876        let mut start = end;
877        while start > 0 && !is_word_char(chars[start - 1]) {
878            start -= 1;
879        }
880        while start > 0 && is_word_char(chars[start - 1]) {
881            start -= 1;
882        }
883        if start == end {
884            return;
885        }
886        let from = byte_offset(self.focused_value(), start);
887        let to = byte_offset(self.focused_value(), end);
888        if let Some(value) = self.focused_value_mut() {
889            value.drain(from..to);
890        }
891        self.cursor = start;
892    }
893
894    pub fn insert_char(&mut self, c: char) {
895        if self.focus.refuses_typing() {
896            // Picked or toggled, never typed: a parent by number, a target by
897            // side, a template by name.
898            return;
899        }
900        if self.focus.is_date() {
901            if let Some(next) = crate::ui::date_picker::type_date(self.focused_value(), c)
902                && let Some(value) = self.focused_value_mut()
903            {
904                *value = next;
905            }
906            return;
907        }
908        let index = self.cursor();
909        let byte = byte_offset(self.focused_value(), index);
910        if let Some(value) = self.focused_value_mut() {
911            value.insert(byte, c);
912        }
913        self.cursor = index + 1;
914    }
915
916    /// Whether the focused field is a picker-only date field.
917    pub fn is_date_focus(&self) -> bool {
918        self.focus.is_date()
919    }
920
921    /// Whether the focused date field already holds a complete, valid date.
922    pub fn focused_date_is_complete(&self) -> bool {
923        self.focus.is_date() && crate::ui::date_picker::is_complete_date(self.focused_value())
924    }
925
926    /// Overwrite the focused date field (no-op when it isn't a date field).
927    pub fn set_focused_date(&mut self, value: &str) {
928        if self.focus.is_date()
929            && let Some(field) = self.focused_value_mut()
930        {
931            *field = value.to_string();
932        }
933    }
934
935    pub fn move_focus_next(&mut self) {
936        self.focus = self.focus.next();
937        self.cursor_to_end();
938    }
939
940    pub fn move_focus_prev(&mut self) {
941        self.focus = self.focus.prev();
942        self.cursor_to_end();
943    }
944}
945
946pub fn render(form: &FormState, f: &mut Frame, area: Rect) {
947    let bindings =
948        crate::keybindings::KeyBindings::resolve(&crate::keybindings::KeyBindings::default())
949            .expect("built-in bindings are valid");
950    render_with_bindings(
951        form,
952        f,
953        area,
954        &bindings,
955        crate::keybindings::InputContext::Form,
956    );
957    // This entry point renders the form as the active surface, so it places the
958    // caret too. `render_with_bindings` cannot: it is also what draws the form as a
959    // backdrop under a picker, where the cursor belongs to the overlay.
960    place_caret(form, f, area);
961}
962
963pub fn render_with_bindings(
964    form: &FormState,
965    f: &mut Frame,
966    area: Rect,
967    bindings: &crate::keybindings::EffectiveBindings,
968    context: crate::keybindings::InputContext,
969) {
970    // Both the focus index and the row labels come from `FormFocus::RING`, so the
971    // cursor and the label it labels cannot drift apart. The value for each field
972    // is read through `focused_value`-style indexing into `FormState` below, which
973    // is why the pairing is by position and not by name.
974    let focus_idx = form.focus.index();
975    let fields: Vec<(&'static str, std::borrow::Cow<'_, str>)> = FormFocus::RING
976        .iter()
977        .map(|focus| (focus.label(), form.display_value(*focus)))
978        .collect();
979
980    let inner_width = usize::from(area.width.saturating_sub(2));
981
982    let mut lines: Vec<Line<'static>> = Vec::new();
983    for (i, (label, value)) in fields.iter().enumerate() {
984        let cursor = if i == focus_idx { "> " } else { "  " };
985        let label_style = if i == focus_idx {
986            Style::default()
987                .add_modifier(Modifier::BOLD)
988                .fg(Color::Cyan)
989        } else {
990            Style::default()
991        };
992        let (display, value_style) = if value.is_empty() && i != focus_idx {
993            ("(empty)".to_string(), Style::default().fg(Color::DarkGray))
994        } else if i == focus_idx {
995            let available = inner_width.saturating_sub(Span::raw(format!("> {label}: ")).width());
996            let text = focused_row_window(form, available)
997                .map_or_else(|| value.to_string(), |(text, _)| text);
998            (text, Style::default().add_modifier(Modifier::UNDERLINED))
999        } else {
1000            (value.to_string(), Style::default())
1001        };
1002        lines.push(Line::from(vec![
1003            Span::styled(format!("{cursor}{label}: "), label_style),
1004            Span::styled(display, value_style),
1005        ]));
1006    }
1007
1008    lines.push(Line::from(""));
1009    let submit = bindings.label(context, crate::keybindings::TuiAction::Submit);
1010    let cancel = bindings.label(context, crate::keybindings::TuiAction::Cancel);
1011    let next = bindings.label(context, crate::keybindings::TuiAction::Next);
1012    let previous = bindings.label(context, crate::keybindings::TuiAction::Previous);
1013    let picker = bindings.label(context, crate::keybindings::TuiAction::OpenPicker);
1014    lines.push(Line::from(Span::styled(
1015        format!(
1016            "{submit}: Submit | {cancel}: Cancel | {next}/{previous}: Fields | \
1017             {picker}: Picker/Date/Parent | Arrows: Caret/Target | \
1018             Ctrl-A/E: Start/End | Ctrl-W: Word"
1019        ),
1020        Style::default().fg(Color::DarkGray),
1021    )));
1022
1023    let para =
1024        Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title("New Issue"));
1025
1026    f.render_widget(para, area);
1027}
1028
1029/// Where the terminal caret belongs for `form` rendered into `area`, or `None`
1030/// when this form shows no insertion point.
1031///
1032/// Split from [`place_caret`] so the arithmetic — the part that can be wrong — is
1033/// testable without a terminal.
1034///
1035/// The insertion point is [`FormState::cursor`], clamped to the focused value. A
1036/// picker-only field gets no caret, because typing is refused on it: a caret on
1037/// `Parent` would advertise an insertion point that does not exist. The date
1038/// fields do accept typed digits, so they keep one — at the end, where
1039/// `type_date` appends.
1040///
1041/// A value wider than the row is windowed by [`focused_row_window`], the same
1042/// helper the renderer uses, so the caret is on screen and points at the text
1043/// that is actually drawn.
1044pub fn caret_position(form: &FormState, area: Rect) -> Option<Position> {
1045    if form.focus.refuses_typing() {
1046        return None;
1047    }
1048    let row = field_row_rect(area, form.focus)?;
1049    // The same prefix the row renderer writes: focus marker, label, colon, space.
1050    // Measured on the rendered spans rather than by character count, so a value
1051    // wider than it is long (any non-ASCII in a title) still lands the caret
1052    // correctly.
1053    let prefix = format!("> {}: ", form.focus.label());
1054    let prefix_width = Span::raw(prefix).width();
1055    let available = usize::from(row.width).saturating_sub(prefix_width);
1056    let (_, caret_cells) = focused_row_window(form, available)?;
1057    let column = u16::try_from(prefix_width + caret_cells).unwrap_or(u16::MAX);
1058    Some(Position::new(
1059        row.x + column.min(row.width.saturating_sub(1)),
1060        row.y,
1061    ))
1062}
1063
1064/// The text the focused row should draw and the caret's **cell** offset inside
1065/// it.
1066///
1067/// `None` on a row that shows no insertion point. A value that fits is returned
1068/// whole. A wider one is windowed around the caret: the window starts as far
1069/// back in the value as the row can hold, so the caret stays visible while
1070/// editing the middle of a long title. The escape applied to a body's newlines is
1071/// accounted for, so the caret still lands on the cell its character occupies.
1072fn focused_row_window(form: &FormState, available: usize) -> Option<(String, usize)> {
1073    if form.focus.refuses_typing() {
1074        return None;
1075    }
1076    let display = form.focused_display_value().into_owned();
1077    let chars: Vec<char> = display.chars().collect();
1078    if available == 0 {
1079        return Some((String::new(), 0));
1080    }
1081    // A date is edited as a whole value, so its caret belongs at the end.
1082    let caret_chars = if form.focus.is_date() {
1083        chars.len()
1084    } else {
1085        let cursor = form.cursor();
1086        if form.focus == FormFocus::Body {
1087            let newlines = form
1088                .focused_value()
1089                .chars()
1090                .take(cursor)
1091                .filter(|c| *c == '\n')
1092                .count();
1093            (cursor + newlines).min(chars.len())
1094        } else {
1095            cursor.min(chars.len())
1096        }
1097    };
1098    let caret_cells: usize = chars[..caret_chars]
1099        .iter()
1100        .map(|c| UnicodeWidthChar::width(*c).unwrap_or(0))
1101        .sum();
1102    if Span::raw(display.as_str()).width() <= available {
1103        return Some((display, caret_cells));
1104    }
1105    // The caret must sit inside the row: cells `0..=available - 1`.
1106    let budget = available - 1;
1107    let mut start = caret_chars;
1108    let mut width = 0usize;
1109    while start > 0 {
1110        let cells = UnicodeWidthChar::width(chars[start - 1]).unwrap_or(0);
1111        if width + cells > budget {
1112            break;
1113        }
1114        width += cells;
1115        start -= 1;
1116    }
1117    Some((chars[start..].iter().collect(), width))
1118}
1119
1120/// The one-row inner rectangle `focus` occupies when the form is drawn into
1121/// `area`, or `None` when that row is outside the visible form.
1122///
1123/// The single arithmetic shared by the caret and by the hit region the New Issue
1124/// mode records for the Template row: two copies of "inner rect plus the focus
1125/// index" would be two chances to disagree about where a row is.
1126pub fn field_row_rect(area: Rect, focus: FormFocus) -> Option<Rect> {
1127    let inner = Block::default().borders(Borders::ALL).inner(area);
1128    if inner.width == 0 || inner.height == 0 {
1129        return None;
1130    }
1131    let y = inner.y.checked_add(u16::try_from(focus.index()).ok()?)?;
1132    if y >= inner.y + inner.height {
1133        return None;
1134    }
1135    Some(Rect {
1136        x: inner.x,
1137        y,
1138        width: inner.width,
1139        height: 1,
1140    })
1141}
1142
1143/// Put the terminal's text cursor at the end of the focused field's value.
1144///
1145/// Until this existed, `set_cursor_position` was never called anywhere in the
1146/// crate: the cursor stayed wherever the previously rendered widget left it, and
1147/// the form showed no insertion point at all. "Create issue" opened on an empty
1148/// Title row that was underlined and nothing else, and characters appeared with the
1149/// caret parked elsewhere (#1486).
1150///
1151/// Called by the owner of the frame rather than from `render_with_bindings`,
1152/// because where the form is a **backdrop** — under the date-picker overlay — the
1153/// overlay owns the cursor and a caret on the row behind it would point at the
1154/// wrong surface.
1155pub fn place_caret(form: &FormState, f: &mut Frame, area: Rect) {
1156    if let Some(position) = caret_position(form, area) {
1157        f.set_cursor_position(position);
1158    }
1159}
1160
1161#[cfg(test)]
1162mod tests {
1163    #[allow(clippy::wildcard_imports)]
1164    use super::*;
1165
1166    #[test]
1167    fn focus_cycles_forward() {
1168        assert_eq!(FormFocus::Title.next(), FormFocus::Status);
1169        assert_eq!(FormFocus::DueDate.next(), FormFocus::Parent);
1170        assert_eq!(FormFocus::Parent.next(), FormFocus::Target);
1171        assert_eq!(FormFocus::Target.next(), FormFocus::Template);
1172        assert_eq!(FormFocus::Template.next(), FormFocus::Body);
1173        // WP06 T029/T031: `Projects` and then `Epics` are appended, so `Body` is no
1174        // longer the last row and the wrap home starts from `Epics`.
1175        assert_eq!(FormFocus::Body.next(), FormFocus::Projects);
1176        assert_eq!(FormFocus::Projects.next(), FormFocus::Epics);
1177        assert_eq!(FormFocus::Epics.next(), FormFocus::Title);
1178    }
1179
1180    #[test]
1181    fn focus_cycles_backward() {
1182        assert_eq!(FormFocus::Title.prev(), FormFocus::Epics);
1183        assert_eq!(FormFocus::Status.prev(), FormFocus::Title);
1184        assert_eq!(FormFocus::Body.prev(), FormFocus::Template);
1185        assert_eq!(FormFocus::Projects.prev(), FormFocus::Body);
1186        assert_eq!(FormFocus::Epics.prev(), FormFocus::Projects);
1187    }
1188
1189    /// `next`, `prev`, and `RING` are three statements of the same order, and the
1190    /// compiler cannot check that they agree — an out-of-date arm compiles and
1191    /// silently sends the cursor to the wrong field.
1192    ///
1193    /// Asserted as a walk over the ring rather than as a list of pairs, so adding a
1194    /// field cannot leave the pairs stale: the test has to be updated to pass, and
1195    /// updating it means reading the ring.
1196    #[test]
1197    fn the_ring_and_the_next_prev_chains_agree() {
1198        let ring = FormFocus::RING;
1199        for (i, focus) in ring.iter().enumerate() {
1200            let forward = ring[(i + 1) % ring.len()];
1201            let backward = ring[(i + ring.len() - 1) % ring.len()];
1202            assert_eq!(focus.next(), forward, "next() after {focus:?} in RING");
1203            assert_eq!(focus.prev(), backward, "prev() after {focus:?} in RING");
1204        }
1205    }
1206
1207    /// `RING` must contain every variant, or a field becomes unreachable: there is
1208    /// no way to tab onto it and no way off whatever precedes it. `index()` panics
1209    /// on a variant missing from `RING`, so this is that panic's guard, as a test.
1210    #[test]
1211    fn the_ring_covers_every_field() {
1212        for (i, focus) in FormFocus::RING.iter().enumerate() {
1213            assert_eq!(focus.index(), i, "{focus:?} must sit at its RING position");
1214        }
1215        // And a fresh form starts on a field that is in the ring.
1216        assert!(FormFocus::RING.contains(&FormState::new("todo").focus));
1217    }
1218
1219    /// The parent field is chosen from a picker, so typing into it must do nothing.
1220    /// A half-typed number is not a parent, and the service validates by resolved
1221    /// number — free text here could only ever be a value that fails at submit.
1222    #[test]
1223    fn the_parent_field_refuses_typed_characters() {
1224        let mut form = FormState::new("todo");
1225        form.move_focus_next();
1226        for _ in 0..FormFocus::RING.len() {
1227            if form.focus == FormFocus::Parent {
1228                break;
1229            }
1230            form.move_focus_next();
1231        }
1232        assert_eq!(form.focus, FormFocus::Parent, "the ring must reach Parent");
1233        assert!(form.focus.is_picker_only());
1234        assert!(!form.focus.is_date(), "Parent is picker-only, not a date");
1235
1236        form.insert_char('9');
1237        form.insert_char('x');
1238        form.delete_char();
1239        assert_eq!(form.parent, "", "typing must not reach the parent field");
1240        assert_eq!(form.focused_value(), "");
1241
1242        // But a *picked* value does, and it reads back through the same accessor.
1243        form.parent = "42".to_string();
1244        assert_eq!(form.focused_value(), "42");
1245    }
1246
1247    #[test]
1248    fn form_insert_and_delete() {
1249        let mut form = FormState::new("todo");
1250        form.insert_char('h');
1251        form.insert_char('i');
1252        assert_eq!(form.title, "hi");
1253        form.delete_char();
1254        assert_eq!(form.title, "h");
1255    }
1256
1257    /// Typing, Backspace and Delete act at the insertion point, not at the end
1258    /// (tix #1746).
1259    #[test]
1260    fn form_edits_at_the_insertion_point() {
1261        let mut form = FormState::new("todo");
1262        for c in "abcd".chars() {
1263            form.insert_char(c);
1264        }
1265        form.move_cursor_left();
1266        form.move_cursor_left();
1267        assert_eq!(form.cursor(), 2);
1268
1269        form.insert_char('X');
1270        assert_eq!(form.title, "abXcd");
1271        assert_eq!(form.cursor(), 3);
1272
1273        form.delete_char();
1274        assert_eq!(form.title, "abcd", "Backspace removes before the caret");
1275        assert_eq!(form.cursor(), 2);
1276
1277        form.delete_forward_char();
1278        assert_eq!(form.title, "abd", "Delete removes at the caret");
1279        assert_eq!(form.cursor(), 2);
1280    }
1281
1282    #[test]
1283    fn form_cursor_home_end_and_word_jumps() {
1284        let mut form = FormState::new("todo");
1285        for c in "one two three".chars() {
1286            form.insert_char(c);
1287        }
1288        assert_eq!(form.cursor(), 13);
1289
1290        form.cursor_to_start();
1291        assert_eq!(form.cursor(), 0);
1292        form.move_cursor_word_right();
1293        assert_eq!(form.cursor(), 3, "end of `one`");
1294        form.move_cursor_word_right();
1295        assert_eq!(form.cursor(), 7, "end of `two`");
1296
1297        form.cursor_to_end();
1298        assert_eq!(form.cursor(), 13);
1299        form.move_cursor_word_left();
1300        assert_eq!(form.cursor(), 8, "start of `three`");
1301        form.move_cursor_word_left();
1302        assert_eq!(form.cursor(), 4, "start of `two`");
1303    }
1304
1305    #[test]
1306    fn form_kill_commands_operate_on_the_insertion_point() {
1307        let mut form = FormState::new("todo");
1308        for c in "alpha beta gamma".chars() {
1309            form.insert_char(c);
1310        }
1311        form.delete_word_backward();
1312        assert_eq!(form.title, "alpha beta ");
1313        form.delete_to_start();
1314        assert_eq!(form.title, "", "Ctrl-U removes everything before the caret");
1315
1316        for c in "abcd".chars() {
1317            form.insert_char(c);
1318        }
1319        assert_eq!(form.title, "abcd");
1320        form.cursor = 2;
1321        form.delete_to_end();
1322        assert_eq!(form.title, "ab", "Ctrl-K removes from the caret to the end");
1323    }
1324
1325    /// The cursor moves by characters, so a multi-byte value is never split and
1326    /// inserting or deleting a non-ASCII character keeps the text valid.
1327    #[test]
1328    fn form_cursor_is_character_indexed_not_byte_indexed() {
1329        let mut form = FormState::new("todo");
1330        for c in "héllo".chars() {
1331            form.insert_char(c);
1332        }
1333        form.move_cursor_left();
1334        form.move_cursor_left();
1335        form.insert_char('ü');
1336        assert_eq!(form.title, "hélülo");
1337        form.delete_char();
1338        assert_eq!(form.title, "héllo");
1339    }
1340
1341    #[test]
1342    fn form_move_focus() {
1343        let mut form = FormState::new("todo");
1344        assert_eq!(form.focus, FormFocus::Title);
1345        form.move_focus_next();
1346        assert_eq!(form.focus, FormFocus::Status);
1347        form.move_focus_prev();
1348        assert_eq!(form.focus, FormFocus::Title);
1349    }
1350
1351    #[test]
1352    fn render_smoke() {
1353        use ratatui::Terminal;
1354        use ratatui::backend::TestBackend;
1355
1356        // Tall enough for every row: the ring is 15 fields, plus the blank
1357        // separator, the hint, and the two border rows.
1358        let backend = TestBackend::new(80, 22);
1359        let mut terminal = Terminal::new(backend).unwrap();
1360        let form = FormState::new("todo");
1361        terminal.draw(|f| render(&form, f, f.area())).unwrap();
1362
1363        let buf = terminal.backend().buffer();
1364        let mut text = String::new();
1365        for y in 0..buf.area.height {
1366            for x in 0..buf.area.width {
1367                text.push_str(buf[(x, y)].symbol());
1368            }
1369            text.push('\n');
1370        }
1371        assert!(text.contains("F2: Picker"), "got {text:?}");
1372    }
1373
1374    /// The regression from #1486: with nothing drawn over it, rendering the create
1375    /// form leaves the terminal cursor **on the focused row**. Before this,
1376    /// `set_cursor_position` was called nowhere in the crate, so the cursor stayed
1377    /// wherever the previous frame put it and the form showed no insertion point.
1378    #[test]
1379    fn rendering_the_create_form_puts_the_caret_on_the_title_row() {
1380        use ratatui::Terminal;
1381        use ratatui::backend::TestBackend;
1382
1383        let mut terminal = Terminal::new(TestBackend::new(60, 16)).unwrap();
1384        let form = FormState::new("todo");
1385        terminal.draw(|f| render(&form, f, f.area())).unwrap();
1386
1387        // The block's border puts the inner area at (1, 1); the row prefix for an
1388        // empty title is "> Title: ".
1389        assert_eq!(
1390            terminal.backend().cursor_position(),
1391            Position::new(1 + "> Title: ".len() as u16, 1)
1392        );
1393    }
1394
1395    #[test]
1396    fn caret_follows_the_typed_value() {
1397        let area = Rect::new(10, 5, 40, 20);
1398        let mut form = FormState::new("todo");
1399        for c in "abc".chars() {
1400            form.insert_char(c);
1401        }
1402        assert_eq!(
1403            caret_position(&form, area),
1404            // inner starts one cell inside the border, at (11, 6).
1405            Some(Position::new(11 + "> Title: abc".len() as u16, 6))
1406        );
1407    }
1408
1409    #[test]
1410    fn caret_moves_with_the_focused_row() {
1411        let area = Rect::new(0, 0, 40, 20);
1412        let mut form = FormState::new("todo");
1413        // Status and Priority are picker rows and show no caret (tix #1508), so
1414        // walk on to the first typeable row below them: the caret moved with the
1415        // focused row, and it is on *that* row.
1416        for _ in 0..3 {
1417            form.move_focus_next();
1418        }
1419        assert_eq!(form.focus, FormFocus::Labels);
1420        assert_eq!(
1421            caret_position(&form, area),
1422            // Row 3 of the ring, and the inner area already starts one row down.
1423            Some(Position::new(1 + "> Labels: ".len() as u16, 4))
1424        );
1425    }
1426
1427    /// The caret follows the insertion point, not the end of the value, and a
1428    /// long value is windowed so the caret stays inside the row (tix #1746).
1429    #[test]
1430    fn caret_follows_the_insertion_point_inside_a_long_value() {
1431        let area = Rect::new(0, 0, 20, 20);
1432        let mut form = FormState::new("todo");
1433        form.title = "x".repeat(100);
1434        form.cursor_to_start();
1435        // Inner width is 18; the caret at the start sits right after "> Title: ".
1436        assert_eq!(
1437            caret_position(&form, area),
1438            Some(Position::new(1 + "> Title: ".len() as u16, 1))
1439        );
1440
1441        form.cursor = 50;
1442        assert_eq!(
1443            caret_position(&form, area),
1444            Some(Position::new(18, 1)),
1445            "mid-value, the caret is still the last cell the row can hold"
1446        );
1447    }
1448
1449    /// Width is not length: a two-character title occupies four cells, and the caret
1450    /// follows the cells.
1451    #[test]
1452    fn caret_measures_display_width_not_char_count() {
1453        let area = Rect::new(0, 0, 40, 20);
1454        let mut form = FormState::new("todo");
1455        form.title = "中日".to_string();
1456        form.cursor_to_end();
1457        assert_eq!(
1458            caret_position(&form, area),
1459            Some(Position::new(1 + "> Title: ".len() as u16 + 4, 1))
1460        );
1461    }
1462
1463    /// A value wider than the field is clipped, so its true caret is off-screen; the
1464    /// position must stay inside the row rather than point past the border.
1465    #[test]
1466    fn caret_is_clamped_into_the_visible_row() {
1467        let area = Rect::new(0, 0, 20, 20);
1468        let mut form = FormState::new("todo");
1469        form.title = "x".repeat(100);
1470        form.cursor_to_end();
1471        // Inner width is 18, so the row's last cell is x = 18.
1472        assert_eq!(caret_position(&form, area), Some(Position::new(18, 1)));
1473    }
1474
1475    #[test]
1476    fn no_caret_on_a_picker_only_field() {
1477        let area = Rect::new(0, 0, 40, 20);
1478        let mut form = FormState::new("todo");
1479        form.focus = FormFocus::Parent;
1480        assert_eq!(caret_position(&form, area), None);
1481    }
1482
1483    #[test]
1484    fn caret_is_not_placed_outside_the_visible_form() {
1485        // Four rows: the ring's later fields are below the fold and get no caret,
1486        // because a position outside the rendered area would be drawn nowhere.
1487        let area = Rect::new(0, 0, 40, 4);
1488        let mut form = FormState::new("todo");
1489        form.focus = FormFocus::Reviewer;
1490        assert_eq!(caret_position(&form, area), None);
1491    }
1492
1493    #[test]
1494    fn with_default_assignee_populates_assignee_field() {
1495        let form = FormState::with_default_assignee("in progress", "alice");
1496        assert_eq!(form.assignee, "alice");
1497        assert_eq!(form.status, "in progress");
1498        assert_eq!(form.focus, FormFocus::Title);
1499    }
1500
1501    /// WP06 T029/T031: the Projects and Epics rows are picker-only lists of resolved
1502    /// ids, appended in that order at the end of the ring.
1503    #[test]
1504    fn t029_the_projects_and_epics_rows_are_picker_only_and_appended_in_the_ring() {
1505        assert!(FormFocus::Projects.is_project());
1506        assert!(FormFocus::Projects.refuses_typing());
1507        assert!(FormFocus::Projects.is_picker_only());
1508        assert!(!FormFocus::Projects.is_date());
1509        assert_eq!(FormFocus::Projects.label(), "Projects");
1510        assert_eq!(
1511            FormFocus::RING.len(),
1512            17,
1513            "the rows are appended, not slotted in"
1514        );
1515        assert_eq!(
1516            FormFocus::RING[15],
1517            FormFocus::Projects,
1518            "no existing index moved"
1519        );
1520        // WP06 T031: `Epics` is the newest last row, the exact analogue of `Projects`.
1521        assert!(FormFocus::Epics.is_epic());
1522        assert!(FormFocus::Epics.refuses_typing());
1523        assert!(FormFocus::Epics.is_picker_only());
1524        assert_eq!(FormFocus::Epics.label(), "Epics");
1525        assert_eq!(FormFocus::RING[16], FormFocus::Epics, "last in the ring");
1526
1527        let mut form = FormState::new("todo");
1528        assert_eq!(form.projects, "", "a new issue starts unassociated");
1529        assert_eq!(form.epics, "", "and references no epic");
1530        form.focus = FormFocus::Projects;
1531        form.insert_char('p');
1532        assert_eq!(form.projects, "", "typing is refused on the Projects row");
1533        form.focus = FormFocus::Epics;
1534        form.insert_char('e');
1535        assert_eq!(form.epics, "", "typing is refused on the Epics row");
1536
1537        // A multi-select picker writes the chosen ids back as a comma-joined list,
1538        // which is the shape `apply_picker_selection` and the submit path share.
1539        *form
1540            .focused_value_mut()
1541            .expect("the row has a buffer to receive a selection") = "e-1,e-2".to_string();
1542        assert_eq!(form.value_of(FormFocus::Epics), "e-1,e-2");
1543        assert_eq!(form.focused_value(), "e-1,e-2");
1544    }
1545
1546    /// WP06 T031 step 2: an edit pre-selects the issue's current references, so
1547    /// submitting after touching nothing cannot drop them.
1548    #[test]
1549    fn t031_an_edit_preselects_the_issues_current_epic_references() {
1550        let issue = Issue::parse(
1551            "---\nnumber: 7\ntitle: T\nstatus: todo\nlabels: []\npriority: medium\nepics:\n- e-1\n- e-gone\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nB.",
1552        )
1553        .unwrap();
1554        let form = FormState::from_issue(&issue);
1555        assert_eq!(
1556            form.epics, "e-1,e-gone",
1557            "the stored references travel verbatim, including a dangling one"
1558        );
1559    }
1560
1561    /// WP06 T029 step 2: an edit pre-selects the issue's current associations, so
1562    /// submitting after touching nothing cannot drop them.
1563    #[test]
1564    fn t029_from_issue_carries_the_associations_and_a_dangling_reference_verbatim() {
1565        let issue = Issue::parse(
1566            "---\nnumber: 7\ntitle: T\nstatus: todo\nlabels: []\npriority: medium\nprojects:\n- p-1\n- p-gone\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nB.",
1567        )
1568        .unwrap();
1569        let form = FormState::from_issue(&issue);
1570        assert_eq!(
1571            form.projects, "p-1,p-gone",
1572            "the stored list is carried verbatim, including a reference the registry \
1573             no longer holds, so a clone cannot quietly drop it"
1574        );
1575        assert_eq!(form.value_of(FormFocus::Projects), "p-1,p-gone");
1576    }
1577
1578    /// The control rows are chosen or toggled, never typed: a keypress on them
1579    /// must not reach a buffer.
1580    #[test]
1581    fn control_rows_refuse_typed_characters() {
1582        let mut form = FormState::new("todo");
1583        for (focus, before) in [
1584            (FormFocus::Target, form.target.label().to_string()),
1585            (FormFocus::Template, form.template.clone()),
1586            (FormFocus::Projects, form.projects.clone()),
1587        ] {
1588            form.focus = focus;
1589            assert!(focus.refuses_typing(), "{focus:?} refuses typing");
1590            form.insert_char('9');
1591            form.delete_char();
1592            assert_eq!(
1593                form.value_of(focus),
1594                before,
1595                "typing must not reach {focus:?}"
1596            );
1597        }
1598        // The body is *not* a control row: it accepts typed text like the rest.
1599        form.focus = FormFocus::Body;
1600        form.insert_char('h');
1601        form.insert_char('i');
1602        assert_eq!(form.body, "hi");
1603    }
1604
1605    /// Status and Priority are chosen from dropdowns (tix #1508), so typed
1606    /// characters and backspace must leave their buffers exactly as they were —
1607    /// free text could name a column the board does not configure, or a priority
1608    /// no issue carries.
1609    #[test]
1610    fn the_status_and_priority_rows_refuse_typed_characters() {
1611        let mut form = FormState::new("todo");
1612        form.priority = "high".to_string();
1613        for (focus, before) in [
1614            (FormFocus::Status, form.status.clone()),
1615            (FormFocus::Priority, form.priority.clone()),
1616        ] {
1617            form.focus = focus;
1618            assert!(focus.refuses_typing(), "{focus:?} refuses typing");
1619            assert!(focus.is_picker_only(), "{focus:?} is picker-only");
1620            assert!(!focus.is_date(), "{focus:?} is not a date row");
1621            form.insert_char('9');
1622            form.insert_char('x');
1623            form.delete_char();
1624            assert_eq!(
1625                form.value_of(focus),
1626                before,
1627                "typing must not reach {focus:?}"
1628            );
1629        }
1630        // But a *picked* value does reach them, through the same accessors.
1631        form.focus = FormFocus::Status;
1632        form.status = "in progress".to_string();
1633        assert_eq!(form.focused_value(), "in progress");
1634        form.focus = FormFocus::Priority;
1635        assert_eq!(form.focused_value(), "high");
1636    }
1637
1638    /// A chosen row shows no insertion point: the caret must not advertise an
1639    /// editing affordance the row refuses (tix #1508).
1640    #[test]
1641    fn no_caret_on_the_status_and_priority_rows() {
1642        let area = Rect::new(0, 0, 40, 30);
1643        let mut form = FormState::new("todo");
1644        form.focus = FormFocus::Status;
1645        assert_eq!(caret_position(&form, area), None);
1646        form.focus = FormFocus::Priority;
1647        assert_eq!(caret_position(&form, area), None);
1648    }
1649
1650    /// A multi-line body is folded to one display row, so the row arithmetic the
1651    /// caret depends on holds; the stored value keeps the real newlines.
1652    #[test]
1653    fn body_newlines_are_escaped_for_display_only() {
1654        let mut form = FormState::new("todo");
1655        form.body = "a\nb".to_string();
1656        assert_eq!(form.body, "a\nb", "the stored body is verbatim");
1657        assert_eq!(form.display_value(FormFocus::Body), "a\\nb");
1658
1659        let area = Rect::new(0, 0, 40, 30);
1660        form.focus = FormFocus::Body;
1661        form.cursor_to_end();
1662        let row = field_row_rect(area, FormFocus::Body).expect("body row is visible");
1663        assert_eq!(
1664            caret_position(&form, area),
1665            Some(Position::new(row.x + "> Body: a\\nb".len() as u16, row.y)),
1666            "the caret follows the folded display, not the raw newline"
1667        );
1668    }
1669
1670    /// A template pre-fills the rows it sets and leaves the rest alone, so a
1671    /// labels-only template cannot erase a title the user already typed.
1672    #[test]
1673    fn apply_template_fills_set_fields_and_keeps_the_others() {
1674        let mut form = FormState::new("todo");
1675        form.title = "typed".to_string();
1676        let template = crate::config::IssueTemplate {
1677            body: Some("template body".to_string()),
1678            labels: vec!["bug".to_string(), "urgent".to_string()],
1679            ..crate::config::IssueTemplate::default()
1680        };
1681        form.apply_template("bug-report", &template);
1682        assert_eq!(form.template, "bug-report");
1683        assert_eq!(form.body, "template body");
1684        assert_eq!(form.labels, "bug,urgent");
1685        assert_eq!(form.title, "typed", "an omitted field is left untouched");
1686    }
1687
1688    #[test]
1689    fn no_caret_on_a_target_or_template_row() {
1690        let area = Rect::new(0, 0, 40, 30);
1691        let mut form = FormState::new("todo");
1692        form.focus = FormFocus::Target;
1693        assert_eq!(caret_position(&form, area), None);
1694        form.focus = FormFocus::Template;
1695        assert_eq!(caret_position(&form, area), None);
1696    }
1697
1698    /// A stable-ID template seeds only the rows it sets, records its **ID** (not
1699    /// its display name) and its explicit scope, and never interprets unknown
1700    /// metadata (WP05/T024).
1701    #[test]
1702    fn apply_stable_template_seeds_rows_and_records_the_stable_id_and_scope() {
1703        use crate::templates::registry::{PromptKind, TemplateDefaults, TemplatePrompt};
1704        let mut form = FormState::new("todo");
1705        form.title = "typed".to_string();
1706        let template = crate::templates::Template {
1707            name: Some("Bug report".to_string()),
1708            defaults: TemplateDefaults {
1709                body: Some("## Steps\n".to_string()),
1710                labels: vec!["bug".to_string()],
1711                ..TemplateDefaults::default()
1712            },
1713            // A prompt default outranks the block default for the same key, the
1714            // same precedence the resolver merges them with.
1715            prompts: vec![
1716                TemplatePrompt {
1717                    key: "priority".to_string(),
1718                    label: None,
1719                    kind: PromptKind::Select,
1720                    required: false,
1721                    default: Some("high".to_string()),
1722                    options_ref: None,
1723                },
1724                // An unknown-metadata-ish key cannot exist (the registry rejects
1725                // it), so a prompt with no default leaves its row untouched.
1726                TemplatePrompt {
1727                    key: "estimate".to_string(),
1728                    label: None,
1729                    kind: PromptKind::Text,
1730                    required: true,
1731                    default: None,
1732                    options_ref: None,
1733                },
1734            ],
1735            ..crate::templates::Template::default()
1736        };
1737        form.apply_stable_template("bug", "repository", &template);
1738        assert_eq!(form.template, "bug", "the stored value is the stable ID");
1739        assert_eq!(form.template_scope.as_deref(), Some("repository"));
1740        assert_eq!(form.body, "## Steps\n");
1741        assert_eq!(form.labels, "bug");
1742        assert_eq!(form.priority, "high", "a prompt default wins");
1743        assert_eq!(form.title, "typed", "an omitted row is left untouched");
1744        assert_eq!(
1745            form.estimate, "",
1746            "a required prompt with no value stays empty for the resolver"
1747        );
1748
1749        // The scope is visible on the row without changing the stored ID.
1750        assert_eq!(form.display_value(FormFocus::Template), "bug [repository]");
1751    }
1752
1753    /// Selecting a legacy `[templates.<name>]` record clears any stable scope, so
1754    /// the submit path cannot route a legacy pre-fill through the stable resolver.
1755    #[test]
1756    fn legacy_apply_template_clears_the_stable_scope() {
1757        let mut form = FormState::new("todo");
1758        form.apply_stable_template("bug", "repository", &crate::templates::Template::default());
1759        assert!(form.template_scope.is_some());
1760        form.apply_template(
1761            "old",
1762            &crate::config::IssueTemplate {
1763                labels: vec!["legacy".to_string()],
1764                ..crate::config::IssueTemplate::default()
1765            },
1766        );
1767        assert_eq!(form.template, "old");
1768        assert_eq!(form.template_scope, None);
1769    }
1770}