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
//! Numbered scrollable list navigation for the coordinator TUI (STUI-1).
//!
//! Why: Child #2 (#1386) landed live polling but rendered the unified list with
//! a plain, non-scrolling `List` whose selection was a bare `usize` on
//! [`CoordinatorState`](crate::tui::coordinator::state::CoordinatorState). STUI-1 (#1278) needs a numbered, scrollable list that
//! shows at least five rows, scrolls with the arrow keys + Page-Up/Page-Down,
//! highlights the selected row, and — crucially — PRESERVES the selection and
//! the scroll offset across the timer re-polls Child #2 added (a refresh must not
//! yank the operator back to the top). ratatui's [`ListState`] already owns both
//! the `selected` index AND the `offset` and auto-scrolls the offset to keep the
//! selection visible, so the navigation model is a thin, pure wrapper over it
//! that the render path can hand straight to `render_stateful_widget`. Keeping it
//! in its own module (rather than swelling `state.rs`) preserves the
//! one-concept-per-file split and the SLOC cap.
//! What: [`ListNav`] wraps a [`ListState`] plus the current row count and offers
//! pure movers ([`ListNav::up`], [`ListNav::down`], [`ListNav::page_up`],
//! [`ListNav::page_down`]) that saturate at the list bounds, a
//! [`ListNav::sync_len`] that re-clamps the selection when the row count changes
//! between polls (preserving the offset where still valid), and the
//! [`ListNav::state_mut`] accessor the renderer passes to
//! `render_stateful_widget`. [`MIN_VISIBLE_ROWS`] documents the spec's
//! five-row-minimum viewport.
//! Test: `super::tests` covers up/down saturation, page scrolling, selection +
//! offset preservation across `sync_len`, and clamp-on-shrink.
use ListState;
/// Minimum number of session rows the list viewport must show (STUI-1 §AC).
///
/// Why: the ticket fixes a five-row floor so the operator always sees a useful
/// slice of the fleet even on a short terminal; the render path sizes the list
/// region with this as the lower bound and ratatui scrolls within it. Naming the
/// floor here single-sources it between the layout constraint and its test.
/// What: the literal `5`.
/// Test: `min_visible_rows_is_five`.
pub const MIN_VISIBLE_ROWS: usize = 5;
/// How many rows a single Page-Up / Page-Down jump moves the selection.
///
/// Why: Page scrolling should advance by roughly a viewport so a long fleet is
/// traversable in a few keystrokes; tying the jump to [`MIN_VISIBLE_ROWS`] keeps
/// it proportional to the guaranteed-visible slice without needing the live
/// (terminal-dependent) viewport height in this pure model.
/// What: equals [`MIN_VISIBLE_ROWS`].
/// Test: `page_down_jumps_by_page_size`, `page_up_jumps_by_page_size`.
pub const PAGE_JUMP: usize = MIN_VISIBLE_ROWS;
/// Selection + scroll-offset model for the unified coordinator list.
///
/// Why: the unified list is `[controller row 0] + [session rows…]`; the operator
/// navigates it with the arrow keys and Page-Up/Page-Down, and the selection +
/// scroll offset must survive the timer re-polls (a refresh re-derives the row
/// count, not the cursor). ratatui's [`ListState`] is the canonical home for both
/// the selected index and the scroll offset and auto-scrolls to keep the
/// selection on screen, so [`ListNav`] owns one and exposes only pure, bounded
/// movers over it — no terminal, no IO — making every navigation rule unit
/// testable.
/// What: holds the [`ListState`] (selected + offset) and the current `len`
/// (total selectable rows, always ≥ 1 because the controller row always exists).
/// Mutators saturate at `0..len`; [`Self::sync_len`] re-clamps after a poll.
/// Test: `super::tests` — `nav_*` cases cover movement saturation, page jumps,
/// and preservation/clamp across `sync_len`.