Skip to main content

purple_ssh/app/
ui_state.rs

1//! UI selection substate: list cursors, picker overlays, scroll offsets.
2
3use ratatui::widgets::ListState;
4
5use crate::ui::theme::ThemeDef;
6
7/// A picker overlay: open flag plus its list cursor.
8#[derive(Debug, Default)]
9pub struct PickerState {
10    pub open: bool,
11    pub list: ListState,
12}
13
14#[allow(dead_code)]
15impl PickerState {
16    /// Open the picker with the cursor positioned at `index`.
17    pub fn open_at(&mut self, index: usize) {
18        self.open = true;
19        self.list.select(Some(index));
20    }
21
22    /// Close the picker and reset the cursor.
23    pub fn close(&mut self) {
24        self.open = false;
25        self.list.select(None);
26    }
27}
28
29/// Theme picker carries extra catalogue + preview state beyond a simple list.
30#[derive(Debug, Default)]
31pub struct ThemePickerState {
32    pub list: ListState,
33    pub builtins: Vec<ThemeDef>,
34    pub custom: Vec<ThemeDef>,
35    pub saved_name: String,
36    pub original: Option<ThemeDef>,
37}
38
39/// Region picker uses a cursor index rather than a ListState because region
40/// rows are a synthetic flat array (provider × region pairs) rather than a
41/// ratatui-managed list.
42#[derive(Debug, Default)]
43pub struct RegionPickerState {
44    pub open: bool,
45    pub cursor: usize,
46}
47
48#[derive(Debug, Default)]
49pub struct UiSelection {
50    pub(in crate::app) list_state: ListState,
51    pub(in crate::app) key_picker: PickerState,
52    pub(in crate::app) password_picker: PickerState,
53    pub(in crate::app) proxyjump_picker: PickerState,
54    pub(in crate::app) vault_role_picker: PickerState,
55    pub(in crate::app) tag_picker_state: ListState,
56    pub(in crate::app) bulk_tag_editor_state: ListState,
57    pub(in crate::app) theme_picker: ThemePickerState,
58    pub(in crate::app) provider_list_state: ListState,
59    pub(in crate::app) tunnel_list_state: ListState,
60    pub(in crate::app) tunnels_overview_state: ListState,
61    pub(in crate::app) containers_overview_state: ListState,
62    /// Cursor for the host picker reached from the tunnels overview when
63    /// adding a new tunnel. Indexes into the editable-hosts slice built at
64    /// render time (hosts from included files are excluded).
65    pub(in crate::app) tunnel_host_picker_state: ListState,
66    /// Live fuzzy-search query for the tunnel host picker. Always-on input
67    /// mode: every printable keystroke appends to the query and shrinks the
68    /// candidate set. Empty string means "show all".
69    pub(in crate::app) tunnel_host_picker_query: String,
70    /// Cursor + live query for the containers-tab `a` host picker.
71    /// Mirrors the tunnel host picker pair; kept separate so the two
72    /// pickers can be open back-to-back without state bleed.
73    pub(in crate::app) container_host_picker_state: ListState,
74    pub(in crate::app) container_host_picker_query: String,
75    pub(in crate::app) snippet_picker_state: ListState,
76    pub(in crate::app) snippet_search: Option<String>,
77    pub(in crate::app) region_picker: RegionPickerState,
78    pub(in crate::app) help_scroll: u16,
79    pub(in crate::app) detail_scroll: u16,
80    /// Set by handler, consumed by AnimationState to trigger detail panel transition.
81    pub(in crate::app) detail_toggle_pending: bool,
82    /// Tracks when the welcome screen was opened to auto-dismiss it.
83    pub(in crate::app) welcome_opened: Option<std::time::Instant>,
84    /// Set once the first time Esc-on-empty-list hint is shown per process.
85    pub(in crate::app) esc_quit_hint_shown: bool,
86    /// Welcome-screen heuristic: number of known hosts at last render.
87    pub(in crate::app) known_hosts_count: usize,
88    /// Pending SSH dispatch queued by connect actions; consumed by the event loop.
89    pub(in crate::app) pending_connect: Option<(String, Option<String>)>,
90}
91
92impl UiSelection {
93    /// Construct with all picker/list state defaulted and the host list
94    /// selection pre-positioned at `initial` (the first selectable host or
95    /// pattern in the display list).
96    pub fn new_with_initial_selection(initial: Option<usize>) -> Self {
97        let mut s = Self::default();
98        if let Some(pos) = initial {
99            s.list_state.select(Some(pos));
100        }
101        s
102    }
103
104    /// Queue an SSH connect for the event loop to pick up via the next
105    /// `pending_connect.take()`. `askpass` carries the resolved per-host
106    /// password source so the event loop can prepare a SSH_ASKPASS env
107    /// before spawning the child.
108    pub fn queue_connect(&mut self, alias: String, askpass: Option<String>) {
109        self.pending_connect = Some((alias, askpass));
110    }
111
112    /// Enter snippet picker search mode with an empty query.
113    pub fn open_snippet_search(&mut self) {
114        self.snippet_search = Some(String::new());
115    }
116
117    /// Exit snippet picker search mode. Idempotent.
118    pub fn close_snippet_search(&mut self) {
119        self.snippet_search = None;
120    }
121
122    pub fn list_state(&self) -> &ListState {
123        &self.list_state
124    }
125
126    pub fn list_state_mut(&mut self) -> &mut ListState {
127        &mut self.list_state
128    }
129
130    pub fn key_picker(&self) -> &PickerState {
131        &self.key_picker
132    }
133
134    pub fn key_picker_mut(&mut self) -> &mut PickerState {
135        &mut self.key_picker
136    }
137
138    pub fn password_picker(&self) -> &PickerState {
139        &self.password_picker
140    }
141
142    pub fn password_picker_mut(&mut self) -> &mut PickerState {
143        &mut self.password_picker
144    }
145
146    pub fn proxyjump_picker(&self) -> &PickerState {
147        &self.proxyjump_picker
148    }
149
150    pub fn proxyjump_picker_mut(&mut self) -> &mut PickerState {
151        &mut self.proxyjump_picker
152    }
153
154    pub fn vault_role_picker(&self) -> &PickerState {
155        &self.vault_role_picker
156    }
157
158    pub fn vault_role_picker_mut(&mut self) -> &mut PickerState {
159        &mut self.vault_role_picker
160    }
161
162    pub fn tag_picker_state(&self) -> &ListState {
163        &self.tag_picker_state
164    }
165
166    pub fn tag_picker_state_mut(&mut self) -> &mut ListState {
167        &mut self.tag_picker_state
168    }
169
170    pub fn bulk_tag_editor_state(&self) -> &ListState {
171        &self.bulk_tag_editor_state
172    }
173
174    pub fn bulk_tag_editor_state_mut(&mut self) -> &mut ListState {
175        &mut self.bulk_tag_editor_state
176    }
177
178    pub fn theme_picker(&self) -> &ThemePickerState {
179        &self.theme_picker
180    }
181
182    pub fn theme_picker_mut(&mut self) -> &mut ThemePickerState {
183        &mut self.theme_picker
184    }
185
186    pub fn provider_list_state(&self) -> &ListState {
187        &self.provider_list_state
188    }
189
190    pub fn provider_list_state_mut(&mut self) -> &mut ListState {
191        &mut self.provider_list_state
192    }
193
194    pub fn tunnel_list_state(&self) -> &ListState {
195        &self.tunnel_list_state
196    }
197
198    pub fn tunnel_list_state_mut(&mut self) -> &mut ListState {
199        &mut self.tunnel_list_state
200    }
201
202    pub fn tunnels_overview_state(&self) -> &ListState {
203        &self.tunnels_overview_state
204    }
205
206    pub fn tunnels_overview_state_mut(&mut self) -> &mut ListState {
207        &mut self.tunnels_overview_state
208    }
209
210    pub fn containers_overview_state(&self) -> &ListState {
211        &self.containers_overview_state
212    }
213
214    pub fn containers_overview_state_mut(&mut self) -> &mut ListState {
215        &mut self.containers_overview_state
216    }
217
218    pub fn tunnel_host_picker_state(&self) -> &ListState {
219        &self.tunnel_host_picker_state
220    }
221
222    pub fn tunnel_host_picker_state_mut(&mut self) -> &mut ListState {
223        &mut self.tunnel_host_picker_state
224    }
225
226    pub fn tunnel_host_picker_query(&self) -> &String {
227        &self.tunnel_host_picker_query
228    }
229
230    pub fn tunnel_host_picker_query_mut(&mut self) -> &mut String {
231        &mut self.tunnel_host_picker_query
232    }
233
234    pub fn set_tunnel_host_picker_query(&mut self, query: String) {
235        self.tunnel_host_picker_query = query;
236    }
237
238    pub fn container_host_picker_state(&self) -> &ListState {
239        &self.container_host_picker_state
240    }
241
242    pub fn container_host_picker_state_mut(&mut self) -> &mut ListState {
243        &mut self.container_host_picker_state
244    }
245
246    pub fn container_host_picker_query(&self) -> &String {
247        &self.container_host_picker_query
248    }
249
250    pub fn container_host_picker_query_mut(&mut self) -> &mut String {
251        &mut self.container_host_picker_query
252    }
253
254    pub fn set_container_host_picker_query(&mut self, query: String) {
255        self.container_host_picker_query = query;
256    }
257
258    pub fn snippet_picker_state(&self) -> &ListState {
259        &self.snippet_picker_state
260    }
261
262    pub fn snippet_picker_state_mut(&mut self) -> &mut ListState {
263        &mut self.snippet_picker_state
264    }
265
266    pub fn snippet_search(&self) -> Option<&String> {
267        self.snippet_search.as_ref()
268    }
269
270    pub fn snippet_search_mut(&mut self) -> Option<&mut String> {
271        self.snippet_search.as_mut()
272    }
273
274    pub fn region_picker(&self) -> &RegionPickerState {
275        &self.region_picker
276    }
277
278    pub fn region_picker_mut(&mut self) -> &mut RegionPickerState {
279        &mut self.region_picker
280    }
281
282    pub fn help_scroll(&self) -> u16 {
283        self.help_scroll
284    }
285
286    pub fn set_help_scroll(&mut self, scroll: u16) {
287        self.help_scroll = scroll;
288    }
289
290    pub fn detail_scroll(&self) -> u16 {
291        self.detail_scroll
292    }
293
294    pub fn set_detail_scroll(&mut self, scroll: u16) {
295        self.detail_scroll = scroll;
296    }
297
298    pub fn detail_toggle_pending(&self) -> bool {
299        self.detail_toggle_pending
300    }
301
302    pub fn set_detail_toggle_pending(&mut self, pending: bool) {
303        self.detail_toggle_pending = pending;
304    }
305
306    pub fn welcome_opened(&self) -> Option<std::time::Instant> {
307        self.welcome_opened
308    }
309
310    pub fn set_welcome_opened(&mut self, when: Option<std::time::Instant>) {
311        self.welcome_opened = when;
312    }
313
314    pub fn esc_quit_hint_shown(&self) -> bool {
315        self.esc_quit_hint_shown
316    }
317
318    pub fn set_esc_quit_hint_shown(&mut self, shown: bool) {
319        self.esc_quit_hint_shown = shown;
320    }
321
322    pub fn known_hosts_count(&self) -> usize {
323        self.known_hosts_count
324    }
325
326    pub fn set_known_hosts_count(&mut self, count: usize) {
327        self.known_hosts_count = count;
328    }
329
330    pub fn pending_connect(&self) -> Option<&(String, Option<String>)> {
331        self.pending_connect.as_ref()
332    }
333
334    /// Take the queued connect, leaving `None`. Consumed by the event loop.
335    pub fn take_pending_connect(&mut self) -> Option<(String, Option<String>)> {
336        self.pending_connect.take()
337    }
338}
339
340impl ThemePickerState {
341    /// Clear the catalogue lists and the saved-name input. Used by both
342    /// picker-close paths. The `list` cursor and `original` are
343    /// intentionally NOT touched here. The two callers handle `original`
344    /// differently: the Esc/q path consumes it via `.take()` before
345    /// calling reset (to restore the prior live theme); the Enter-save
346    /// path leaves `original` intact through reset and clears it
347    /// explicitly afterwards. Keeping `reset` orthogonal to `original`
348    /// lets both flows share the same body.
349    pub fn reset(&mut self) {
350        self.builtins = Vec::new();
351        self.custom = Vec::new();
352        self.saved_name = String::new();
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    #[test]
361    fn queue_connect_sets_pending_connect_to_some() {
362        let mut s = UiSelection::default();
363        s.queue_connect("web1".into(), Some("vault:foo".into()));
364        assert_eq!(
365            s.pending_connect,
366            Some(("web1".to_string(), Some("vault:foo".to_string())))
367        );
368    }
369
370    #[test]
371    fn queue_connect_with_no_askpass_stores_none() {
372        let mut s = UiSelection::default();
373        s.queue_connect("web1".into(), None);
374        assert_eq!(s.pending_connect, Some(("web1".to_string(), None)));
375    }
376
377    #[test]
378    fn queue_connect_overwrites_existing_pending() {
379        let mut s = UiSelection::default();
380        s.queue_connect("first".into(), None);
381        s.queue_connect("second".into(), Some("p".into()));
382        assert_eq!(
383            s.pending_connect,
384            Some(("second".to_string(), Some("p".to_string())))
385        );
386    }
387
388    #[test]
389    fn open_snippet_search_sets_empty_query() {
390        let mut s = UiSelection::default();
391        s.open_snippet_search();
392        assert_eq!(s.snippet_search.as_deref(), Some(""));
393    }
394
395    #[test]
396    fn open_snippet_search_overwrites_existing_query_with_empty() {
397        // The handler currently calls open only when search was inactive,
398        // but the invariant should still hold: open is unconditional and
399        // resets to an empty query. Pin the reset semantic so a future
400        // caller cannot rely on a preserved query.
401        let mut s = UiSelection {
402            snippet_search: Some("old".to_string()),
403            ..Default::default()
404        };
405        s.open_snippet_search();
406        assert_eq!(s.snippet_search.as_deref(), Some(""));
407    }
408
409    #[test]
410    fn close_snippet_search_clears_query() {
411        let mut s = UiSelection {
412            snippet_search: Some("query".to_string()),
413            ..Default::default()
414        };
415        s.close_snippet_search();
416        assert!(s.snippet_search.is_none());
417    }
418
419    #[test]
420    fn close_snippet_search_is_idempotent() {
421        let mut s = UiSelection::default();
422        s.close_snippet_search();
423        s.close_snippet_search();
424        assert!(s.snippet_search.is_none());
425    }
426
427    #[test]
428    fn theme_picker_reset_clears_lists_and_saved_name() {
429        let mut t = ThemePickerState {
430            builtins: vec![ThemeDef::purple_purple()],
431            custom: vec![ThemeDef::purple_purple(), ThemeDef::purple_purple()],
432            saved_name: "Solarized".to_string(),
433            ..Default::default()
434        };
435        t.reset();
436        assert!(t.builtins.is_empty());
437        assert!(t.custom.is_empty());
438        assert!(t.saved_name.is_empty());
439    }
440
441    #[test]
442    fn theme_picker_reset_preserves_original_and_list_cursor() {
443        let mut t = ThemePickerState {
444            builtins: vec![ThemeDef::purple_purple()],
445            original: Some(ThemeDef::purple_purple()),
446            ..Default::default()
447        };
448        t.list.select(Some(2));
449        t.reset();
450        assert!(t.original.is_some(), "original must survive reset()");
451        assert_eq!(t.list.selected(), Some(2));
452    }
453}