purple_ssh/app/
ui_state.rs1use ratatui::widgets::ListState;
4
5use crate::ui::theme::ThemeDef;
6
7#[derive(Debug, Default)]
9pub struct PickerState {
10 pub open: bool,
11 pub list: ListState,
12}
13
14#[allow(dead_code)]
15impl PickerState {
16 pub fn open_at(&mut self, index: usize) {
18 self.open = true;
19 self.list.select(Some(index));
20 }
21
22 pub fn close(&mut self) {
24 self.open = false;
25 self.list.select(None);
26 }
27}
28
29#[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#[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 pub tunnel_host_picker_state: ListState,
66 pub tunnel_host_picker_query: String,
70 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 pub detail_toggle_pending: bool,
82 pub welcome_opened: Option<std::time::Instant>,
84 pub esc_quit_hint_shown: bool,
86 pub known_hosts_count: usize,
88 pub pending_connect: Option<(String, Option<String>)>,
90}
91
92impl UiSelection {
93 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 pub fn queue_connect(&mut self, alias: String, askpass: Option<String>) {
109 self.pending_connect = Some((alias, askpass));
110 }
111
112 pub fn open_snippet_search(&mut self) {
114 self.snippet_search = Some(String::new());
115 }
116
117 pub fn close_snippet_search(&mut self) {
119 self.snippet_search = None;
120 }
121}
122
123impl ThemePickerState {
124 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 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}