gwm/tui/state/create_form.rs
1//! Create-worktree input form state (extracted from `tui::app::App` per
2//! #123 / #102).
3//!
4//! Pure state: holds the four user-editable values (field focus + type
5//! index + issue number buffer + slug buffer) and exposes the rotation /
6//! push-pop / reset primitives. The `App` orchestrator owns the side
7//! effects in `submit_create` (it composes `BranchSpec` from the form's
8//! values, then dispatches `worktree::add` + `bootstrap::run` on the async
9//! task spine).
10
11/// Max digits accepted in the issue-number field. Seven digits covers any
12/// realistic GitHub issue/PR number (up to 9,999,999) while keeping the
13/// resolved branch name well within git's 255-byte ref limit (#217).
14pub const MAX_ISSUE_LEN: usize = 7;
15
16/// Max characters accepted in the description (slug) field. Bounded so the
17/// `<type>/#<issue>-<desc>` branch name cannot exceed git's 255-byte ref
18/// limit even with the longest configured branch type (#217).
19pub const MAX_DESC_LEN: usize = 200;
20
21/// Which input is currently focused inside the create overlay. Selected
22/// via Tab / Shift-Tab; the Type field is special — it's cycled via
23/// `next_type` / `prev_type` rather than typed into.
24#[derive(Debug, Default, PartialEq, Eq, Clone, Copy)]
25pub enum Field {
26 #[default]
27 Type,
28 Issue,
29 Desc,
30}
31
32/// Input state for the create-worktree overlay. `Default` opens the form
33/// in the initial state (Type field focused, first type selected, both
34/// string fields empty).
35#[derive(Debug, Default)]
36pub struct CreateForm {
37 pub field: Field,
38 pub type_index: usize,
39 pub issue: String,
40 pub desc: String,
41}
42
43impl CreateForm {
44 pub fn new() -> Self {
45 Self::default()
46 }
47
48 /// Return to the freshly-opened state. Called by the orchestrator when
49 /// the form opens or cancels.
50 pub fn reset(&mut self) {
51 self.field = Field::Type;
52 self.type_index = 0;
53 self.issue.clear();
54 self.desc.clear();
55 }
56
57 /// Rotate field focus forward (Type → Issue → Desc → Type).
58 pub fn next_field(&mut self) {
59 self.field = match self.field {
60 Field::Type => Field::Issue,
61 Field::Issue => Field::Desc,
62 Field::Desc => Field::Type,
63 };
64 }
65
66 /// Rotate field focus backward (Type → Desc → Issue → Type).
67 pub fn prev_field(&mut self) {
68 self.field = match self.field {
69 Field::Type => Field::Desc,
70 Field::Issue => Field::Type,
71 Field::Desc => Field::Issue,
72 };
73 }
74
75 /// Advance to the next branch type. `types_len` = the number of
76 /// declared types (from `Config::resolved_branch_types().types.len()`);
77 /// passing 0 is a no-op so the form survives an empty allow-list
78 /// rather than panicking on `% 0`.
79 pub fn next_type(&mut self, types_len: usize) {
80 if types_len == 0 {
81 return;
82 }
83 self.type_index = (self.type_index + 1) % types_len;
84 }
85
86 /// Step back to the previous branch type, wrapping at zero.
87 pub fn prev_type(&mut self, types_len: usize) {
88 if types_len == 0 {
89 return;
90 }
91 if self.type_index == 0 {
92 self.type_index = types_len - 1;
93 } else {
94 self.type_index -= 1;
95 }
96 }
97
98 /// Append a character to the currently focused string field. Issue
99 /// drops non-digits to match the `<type>/#<digits>-<slug>` branch
100 /// convention; Desc accepts any character (slug normalisation happens
101 /// downstream in `BranchSpec`). Type is no-op (cycled, not typed).
102 pub fn push_char(&mut self, c: char) {
103 match self.field {
104 Field::Issue if c.is_ascii_digit() && self.issue.chars().count() < MAX_ISSUE_LEN => self.issue.push(c),
105 Field::Desc if self.desc.chars().count() < MAX_DESC_LEN => self.desc.push(c),
106 _ => {}
107 }
108 }
109
110 /// Pop the last character from the currently focused string field.
111 /// Type is no-op.
112 pub fn pop_char(&mut self) {
113 match self.field {
114 Field::Issue => {
115 self.issue.pop();
116 }
117 Field::Desc => {
118 self.desc.pop();
119 }
120 _ => {}
121 }
122 }
123}