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 list_state: ListState,
51    pub key_picker: PickerState,
52    pub password_picker: PickerState,
53    pub proxyjump_picker: PickerState,
54    pub vault_role_picker: PickerState,
55    pub tag_picker_state: ListState,
56    pub bulk_tag_editor_state: ListState,
57    pub theme_picker: ThemePickerState,
58    pub provider_list_state: ListState,
59    pub tunnel_list_state: ListState,
60    pub tunnels_overview_state: ListState,
61    pub 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 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 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 container_host_picker_state: ListState,
74    pub container_host_picker_query: String,
75    pub snippet_picker_state: ListState,
76    pub snippet_search: Option<String>,
77    pub region_picker: RegionPickerState,
78    pub help_scroll: u16,
79    pub detail_scroll: u16,
80    /// Set by handler, consumed by AnimationState to trigger detail panel transition.
81    pub detail_toggle_pending: bool,
82    /// Tracks when the welcome screen was opened to auto-dismiss it.
83    pub welcome_opened: Option<std::time::Instant>,
84    /// Set once the first time Esc-on-empty-list hint is shown per process.
85    pub esc_quit_hint_shown: bool,
86    /// Welcome-screen heuristic: number of known hosts at last render.
87    pub known_hosts_count: usize,
88    /// Pending SSH dispatch queued by connect actions; consumed by the event loop.
89    pub 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
123impl ThemePickerState {
124    /// Clear the catalogue lists and the saved-name input. Used by both
125    /// picker-close paths. The `list` cursor and `original` are
126    /// intentionally NOT touched here. The two callers handle `original`
127    /// differently: the Esc/q path consumes it via `.take()` before
128    /// calling reset (to restore the prior live theme); the Enter-save
129    /// path leaves `original` intact through reset and clears it
130    /// explicitly afterwards. Keeping `reset` orthogonal to `original`
131    /// lets both flows share the same body.
132    pub fn reset(&mut self) {
133        self.builtins = Vec::new();
134        self.custom = Vec::new();
135        self.saved_name = String::new();
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn queue_connect_sets_pending_connect_to_some() {
145        let mut s = UiSelection::default();
146        s.queue_connect("web1".into(), Some("vault:foo".into()));
147        assert_eq!(
148            s.pending_connect,
149            Some(("web1".to_string(), Some("vault:foo".to_string())))
150        );
151    }
152
153    #[test]
154    fn queue_connect_with_no_askpass_stores_none() {
155        let mut s = UiSelection::default();
156        s.queue_connect("web1".into(), None);
157        assert_eq!(s.pending_connect, Some(("web1".to_string(), None)));
158    }
159
160    #[test]
161    fn queue_connect_overwrites_existing_pending() {
162        let mut s = UiSelection::default();
163        s.queue_connect("first".into(), None);
164        s.queue_connect("second".into(), Some("p".into()));
165        assert_eq!(
166            s.pending_connect,
167            Some(("second".to_string(), Some("p".to_string())))
168        );
169    }
170
171    #[test]
172    fn open_snippet_search_sets_empty_query() {
173        let mut s = UiSelection::default();
174        s.open_snippet_search();
175        assert_eq!(s.snippet_search.as_deref(), Some(""));
176    }
177
178    #[test]
179    fn open_snippet_search_overwrites_existing_query_with_empty() {
180        // The handler currently calls open only when search was inactive,
181        // but the invariant should still hold: open is unconditional and
182        // resets to an empty query. Pin the reset semantic so a future
183        // caller cannot rely on a preserved query.
184        let mut s = UiSelection {
185            snippet_search: Some("old".to_string()),
186            ..Default::default()
187        };
188        s.open_snippet_search();
189        assert_eq!(s.snippet_search.as_deref(), Some(""));
190    }
191
192    #[test]
193    fn close_snippet_search_clears_query() {
194        let mut s = UiSelection {
195            snippet_search: Some("query".to_string()),
196            ..Default::default()
197        };
198        s.close_snippet_search();
199        assert!(s.snippet_search.is_none());
200    }
201
202    #[test]
203    fn close_snippet_search_is_idempotent() {
204        let mut s = UiSelection::default();
205        s.close_snippet_search();
206        s.close_snippet_search();
207        assert!(s.snippet_search.is_none());
208    }
209
210    #[test]
211    fn theme_picker_reset_clears_lists_and_saved_name() {
212        let mut t = ThemePickerState {
213            builtins: vec![ThemeDef::purple_purple()],
214            custom: vec![ThemeDef::purple_purple(), ThemeDef::purple_purple()],
215            saved_name: "Solarized".to_string(),
216            ..Default::default()
217        };
218        t.reset();
219        assert!(t.builtins.is_empty());
220        assert!(t.custom.is_empty());
221        assert!(t.saved_name.is_empty());
222    }
223
224    #[test]
225    fn theme_picker_reset_preserves_original_and_list_cursor() {
226        let mut t = ThemePickerState {
227            builtins: vec![ThemeDef::purple_purple()],
228            original: Some(ThemeDef::purple_purple()),
229            ..Default::default()
230        };
231        t.list.select(Some(2));
232        t.reset();
233        assert!(t.original.is_some(), "original must survive reset()");
234        assert_eq!(t.list.selected(), Some(2));
235    }
236}