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
10fn 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
18fn 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 Parent,
42 Target,
48 Template,
53 Body,
57 Projects,
66 Epics,
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
88pub enum NewIssueTarget {
89 Triage,
91 #[default]
93 Todo,
94}
95
96impl NewIssueTarget {
97 pub fn label(self) -> &'static str {
99 match self {
100 NewIssueTarget::Triage => "Triage",
101 NewIssueTarget::Todo => "Todo",
102 }
103 }
104
105 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 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 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 FormFocus::Projects,
208 FormFocus::Epics,
210 ];
211
212 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 pub fn is_date(self) -> bool {
222 matches!(self, FormFocus::StartDate | FormFocus::DueDate)
223 }
224
225 pub fn is_parent(self) -> bool {
227 matches!(self, FormFocus::Parent)
228 }
229
230 pub fn is_status(self) -> bool {
236 matches!(self, FormFocus::Status)
237 }
238
239 pub fn is_priority(self) -> bool {
245 matches!(self, FormFocus::Priority)
246 }
247
248 pub fn is_target(self) -> bool {
250 matches!(self, FormFocus::Target)
251 }
252
253 pub fn is_template(self) -> bool {
259 matches!(self, FormFocus::Template)
260 }
261
262 pub fn is_project(self) -> bool {
269 matches!(self, FormFocus::Projects)
270 }
271
272 pub fn is_epic(self) -> bool {
279 matches!(self, FormFocus::Epics)
280 }
281
282 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 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 pub parent: String,
337 pub target: NewIssueTarget,
340 pub template: String,
352 pub template_scope: Option<String>,
362 pub body: String,
365 pub projects: String,
375 pub epics: String,
385 pub focus: FormFocus,
386 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 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 pub fn apply_stable_template(
493 &mut self,
494 id: &str,
495 scope: &str,
496 template: &crate::templates::Template,
497 ) {
498 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 let scalar = |key: &str, fallback: Option<&String>| -> Option<String> {
516 prompt_default(template, key).or_else(|| fallback.cloned())
517 };
518 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 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 body: issue.body.clone(),
619 projects: joined(&issue.projects),
627 epics: joined(&issue.epics),
635 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 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 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 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 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 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 pub fn cursor(&self) -> usize {
737 self.cursor.min(self.focused_value().chars().count())
738 }
739
740 fn editable_text(&self) -> bool {
745 !self.focus.refuses_typing() && !self.focus.is_date()
746 }
747
748 pub fn cursor_to_end(&mut self) {
750 self.cursor = self.focused_value().chars().count();
751 }
752
753 pub fn cursor_to_start(&mut self) {
755 if self.editable_text() {
756 self.cursor = 0;
757 }
758 }
759
760 pub fn move_cursor_left(&mut self) {
762 if self.editable_text() {
763 self.cursor = self.cursor().saturating_sub(1);
764 }
765 }
766
767 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 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 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 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 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 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 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 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 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 pub fn is_date_focus(&self) -> bool {
918 self.focus.is_date()
919 }
920
921 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 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 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 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
1029pub 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 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
1064fn 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 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 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
1120pub 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
1143pub 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 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 #[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 #[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 assert!(FormFocus::RING.contains(&FormState::new("todo").focus));
1217 }
1218
1219 #[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 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 #[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 #[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 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 #[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 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 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 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 Some(Position::new(1 + "> Labels: ".len() as u16, 4))
1424 );
1425 }
1426
1427 #[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 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 #[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 #[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 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 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 #[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 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 *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 #[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 #[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 #[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 form.focus = FormFocus::Body;
1600 form.insert_char('h');
1601 form.insert_char('i');
1602 assert_eq!(form.body, "hi");
1603 }
1604
1605 #[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 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 #[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 #[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 #[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 #[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 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 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 assert_eq!(form.display_value(FormFocus::Template), "bug [repository]");
1751 }
1752
1753 #[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}