Skip to main content

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}