1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
//! Frame-layout + picker-state bundle owned by `App` at `app.ui_layout`.
//!
//! Extracted from `app/mod.rs` in task #51 PR B (struct +
//! field migration) and PR C (encapsulation); moved into its own
//! file in v1.6 once the surface was stable enough to warrant the
//! split. Everything here answers "what does the current frame look
//! like and which floating picker is open".
//!
//! Fields with cross-field invariants (`file_list_width_pct`, the
//! two comment-template-picker fields) are private and must be
//! mutated through the methods below, which enforce the clamp and
//! "cursor resets when filter changes" invariants. The rest stay
//! `pub` because they're independent state with no invariants.
use std::collections::HashSet;
/// See module docs for the rationale behind the mixed `pub`/private
/// field visibilities.
#[derive(Debug, Clone)]
pub struct UiLayoutState {
/// Whether the file-list pane is visible on the left side.
pub show_file_list: bool,
/// File-list pane width as a percentage of the main content area.
/// Always in `[FILE_LIST_MIN_PCT, FILE_LIST_MAX_PCT]`; mutate via
/// [`Self::set_file_list_width_pct`] / [`Self::shrink_file_list_width`]
/// / [`Self::grow_file_list_width`].
file_list_width_pct: u16,
/// Cached `Rect` for the file-list pane, captured during the last
/// frame render. Used by mouse hit-testing and pane-width drag
/// handlers. `None` before the first render.
pub file_list_area: Option<ratatui::layout::Rect>,
/// Cached `Rect` for the diff pane, same semantics as
/// `file_list_area`.
pub diff_area: Option<ratatui::layout::Rect>,
/// Directory paths that are currently expanded in the file tree.
/// String keys rather than `PathBuf` so JSON round-trips are
/// trivial and the tree renderer can look up by any prefix.
pub expanded_dirs: HashSet<String>,
/// Whether the `:` command palette popup is enabled. Off-by-config
/// for keyboard-purist users; still toggleable at runtime.
pub command_palette: bool,
/// Highlighted row in the filtered comment-template picker.
/// Invariant: reset to 0 on every filter mutation so a stale
/// selection can't land on the wrong entry. Mutate only through
/// the `*_template_cursor` / `*_template_filter` methods.
comment_template_picker_cursor: usize,
/// Filter buffer for the comment-template picker (typed after
/// `Ctrl+T`). Private — see `comment_template_picker_cursor`.
comment_template_picker_filter: String,
}
impl Default for UiLayoutState {
fn default() -> Self {
Self {
show_file_list: true,
file_list_width_pct: 20,
file_list_area: None,
diff_area: None,
expanded_dirs: HashSet::new(),
command_palette: true,
comment_template_picker_cursor: 0,
comment_template_picker_filter: String::new(),
}
}
}
impl UiLayoutState {
/// Minimum file-list width percentage of the main content area.
/// Anything tighter erases the filename column and makes the tree
/// unreadable; the `<` keybinding clamps to this floor.
pub const FILE_LIST_MIN_PCT: u16 = 10;
/// Maximum file-list width percentage of the main content area.
/// Beyond this the diff pane loses its breathing room; the `>`
/// keybinding clamps to this ceiling.
pub const FILE_LIST_MAX_PCT: u16 = 60;
/// Step size used by `<` / `>` to resize the file list pane.
pub const FILE_LIST_STEP_PCT: u16 = 5;
/// Current file-list width as a percentage of the main content
/// area. Always clamped to `[FILE_LIST_MIN_PCT, FILE_LIST_MAX_PCT]`
/// by the mutating methods on this struct.
pub fn file_list_width_pct(&self) -> u16 {
self.file_list_width_pct
}
/// Set the file-list width, clamped to the legal range. Returns
/// the clamped value so callers can branch on whether the request
/// was honored as-is.
pub fn set_file_list_width_pct(&mut self, pct: u16) -> u16 {
let clamped = pct.clamp(Self::FILE_LIST_MIN_PCT, Self::FILE_LIST_MAX_PCT);
self.file_list_width_pct = clamped;
clamped
}
/// Try to shrink the file-list pane by `FILE_LIST_STEP_PCT`. Returns
/// `Some(new_pct)` if the width changed, `None` if it was already at
/// (or would drop below) `FILE_LIST_MIN_PCT`.
pub fn shrink_file_list_width(&mut self) -> Option<u16> {
let new_pct = self
.file_list_width_pct
.saturating_sub(Self::FILE_LIST_STEP_PCT)
.max(Self::FILE_LIST_MIN_PCT);
if new_pct == self.file_list_width_pct {
None
} else {
self.file_list_width_pct = new_pct;
Some(new_pct)
}
}
/// Try to grow the file-list pane by `FILE_LIST_STEP_PCT`. Returns
/// `Some(new_pct)` if the width changed, `None` if it was already at
/// (or would exceed) `FILE_LIST_MAX_PCT`.
pub fn grow_file_list_width(&mut self) -> Option<u16> {
let new_pct = self
.file_list_width_pct
.saturating_add(Self::FILE_LIST_STEP_PCT)
.min(Self::FILE_LIST_MAX_PCT);
if new_pct == self.file_list_width_pct {
None
} else {
self.file_list_width_pct = new_pct;
Some(new_pct)
}
}
/// Read-only view of the template-picker filter buffer.
pub fn template_filter(&self) -> &str {
&self.comment_template_picker_filter
}
/// Highlighted row in the filtered template picker. Callers that
/// need to map this to an entry must re-run the filter themselves
/// (see `ui::comment_template_picker::filter_templates`).
pub fn template_cursor(&self) -> usize {
self.comment_template_picker_cursor
}
/// Append a character to the filter and reset the cursor to 0.
/// Invariant: every mutation to the filter resets the cursor so a
/// stale selection can't land on the wrong entry after the filter
/// rewords the candidate list.
pub fn push_template_filter_char(&mut self, c: char) {
self.comment_template_picker_filter.push(c);
self.comment_template_picker_cursor = 0;
}
/// Pop the last character from the filter and reset the cursor to 0.
pub fn pop_template_filter_char(&mut self) {
self.comment_template_picker_filter.pop();
self.comment_template_picker_cursor = 0;
}
/// Word-delete in the filter (trailing whitespace then trailing
/// non-whitespace), then reset the cursor. Mirrors the palette
/// `DeleteWord` handling so Ctrl+W feels consistent across pickers.
pub fn word_delete_template_filter(&mut self) {
while self
.comment_template_picker_filter
.chars()
.last()
.is_some_and(char::is_whitespace)
{
self.comment_template_picker_filter.pop();
}
while self
.comment_template_picker_filter
.chars()
.last()
.is_some_and(|c| !c.is_whitespace())
{
self.comment_template_picker_filter.pop();
}
self.comment_template_picker_cursor = 0;
}
/// Clear the filter buffer and reset the cursor.
pub fn clear_template_filter(&mut self) {
self.comment_template_picker_filter.clear();
self.comment_template_picker_cursor = 0;
}
/// Move the template-picker cursor down by one, capped at the last
/// valid row of a list with `filtered_len` entries. No-op when the
/// list is empty.
pub fn advance_template_cursor(&mut self, filtered_len: usize) {
if let Some(max_idx) = filtered_len.checked_sub(1) {
self.comment_template_picker_cursor =
(self.comment_template_picker_cursor + 1).min(max_idx);
}
}
/// Move the template-picker cursor up by one (saturating at 0).
pub fn retreat_template_cursor(&mut self) {
self.comment_template_picker_cursor = self.comment_template_picker_cursor.saturating_sub(1);
}
/// Reset both the filter buffer and cursor. Called on picker
/// open/close so the next session starts from a clean slate.
pub fn reset_template_picker(&mut self) {
self.comment_template_picker_filter.clear();
self.comment_template_picker_cursor = 0;
}
}
#[cfg(test)]
mod tests {
use super::UiLayoutState;
#[test]
fn set_width_clamps_to_min() {
let mut ui = UiLayoutState::default();
let got = ui.set_file_list_width_pct(2);
assert_eq!(got, UiLayoutState::FILE_LIST_MIN_PCT);
assert_eq!(ui.file_list_width_pct(), UiLayoutState::FILE_LIST_MIN_PCT);
}
#[test]
fn set_width_clamps_to_max() {
let mut ui = UiLayoutState::default();
let got = ui.set_file_list_width_pct(99);
assert_eq!(got, UiLayoutState::FILE_LIST_MAX_PCT);
assert_eq!(ui.file_list_width_pct(), UiLayoutState::FILE_LIST_MAX_PCT);
}
#[test]
fn shrink_at_floor_returns_none() {
let mut ui = UiLayoutState::default();
ui.set_file_list_width_pct(UiLayoutState::FILE_LIST_MIN_PCT);
assert_eq!(ui.shrink_file_list_width(), None);
assert_eq!(ui.file_list_width_pct(), UiLayoutState::FILE_LIST_MIN_PCT);
}
#[test]
fn grow_at_ceiling_returns_none() {
let mut ui = UiLayoutState::default();
ui.set_file_list_width_pct(UiLayoutState::FILE_LIST_MAX_PCT);
assert_eq!(ui.grow_file_list_width(), None);
assert_eq!(ui.file_list_width_pct(), UiLayoutState::FILE_LIST_MAX_PCT);
}
#[test]
fn shrink_and_grow_step_by_step_pct() {
let mut ui = UiLayoutState::default();
let start = ui.file_list_width_pct();
let grew = ui.grow_file_list_width().expect("default is below max");
assert_eq!(grew, start + UiLayoutState::FILE_LIST_STEP_PCT);
let shrunk = ui.shrink_file_list_width().expect("just grew above min");
assert_eq!(shrunk, start);
}
#[test]
fn filter_mutation_resets_cursor() {
// Contract: every mutation that changes the filter buffer must
// force the cursor back to 0. The exact non-zero position the
// cursor was sitting at is irrelevant — what matters is that a
// stale cursor can't survive a filter edit.
let mut ui = UiLayoutState::default();
ui.advance_template_cursor(6);
ui.advance_template_cursor(6);
assert_ne!(ui.template_cursor(), 0, "precondition: seeded non-zero");
ui.push_template_filter_char('a');
assert_eq!(ui.template_cursor(), 0, "push should reset cursor");
assert_eq!(ui.template_filter(), "a");
ui.advance_template_cursor(4);
assert_ne!(ui.template_cursor(), 0, "precondition: advanced again");
ui.pop_template_filter_char();
assert_eq!(ui.template_cursor(), 0, "pop should reset cursor");
assert_eq!(ui.template_filter(), "");
}
#[test]
fn word_delete_peels_whitespace_then_word() {
let mut ui = UiLayoutState::default();
"hello world "
.chars()
.for_each(|c| ui.push_template_filter_char(c));
ui.advance_template_cursor(5);
ui.word_delete_template_filter();
assert_eq!(ui.template_filter(), "hello ");
assert_eq!(ui.template_cursor(), 0);
}
#[test]
fn clear_template_filter_resets_both() {
let mut ui = UiLayoutState::default();
ui.push_template_filter_char('x');
ui.advance_template_cursor(10);
ui.clear_template_filter();
assert_eq!(ui.template_filter(), "");
assert_eq!(ui.template_cursor(), 0);
}
#[test]
fn advance_template_cursor_caps_at_max() {
let mut ui = UiLayoutState::default();
for _ in 0..10 {
ui.advance_template_cursor(4);
}
assert_eq!(ui.template_cursor(), 3);
}
#[test]
fn advance_template_cursor_with_empty_list_is_noop() {
let mut ui = UiLayoutState::default();
ui.advance_template_cursor(0);
assert_eq!(ui.template_cursor(), 0);
}
#[test]
fn advance_template_cursor_with_single_item_stays_at_zero() {
// Regression: caller passing `filtered.len() == 1` previously
// had to compute `checked_sub(1) = Some(0)`, which was the
// confusing shape the v1.6 signature change targeted.
let mut ui = UiLayoutState::default();
ui.advance_template_cursor(1);
assert_eq!(ui.template_cursor(), 0);
ui.advance_template_cursor(1);
assert_eq!(ui.template_cursor(), 0);
}
#[test]
fn retreat_template_cursor_saturates_at_zero() {
let mut ui = UiLayoutState::default();
ui.retreat_template_cursor();
assert_eq!(ui.template_cursor(), 0);
}
}