holodeck_simctl_tui/state/
app_state.rs1use holodeck_core::models::{DeviceType, InstalledApp, PrivacyAction, PrivacyPermission, Runtime, Simulator};
2use uuid::Uuid;
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum PendingOperation {
12 Boot,
13 Shutdown,
14 Erase,
15 Delete,
16}
17
18#[derive(Debug, Clone, PartialEq)]
19pub enum Modal {
20 Appearance,
21 ConfirmErase(Uuid),
22 ConfirmDelete(Uuid),
23 CreateWizard(CreateWizard),
24 PrivacyWizard(PrivacyWizard),
25 Inspector(Uuid),
26 OpenUrl(OpenUrlPrompt),
27 CommandPalette(CommandPalette),
28 Help,
29}
30
31impl Modal {
32 pub fn referenced_simulator(&self) -> Option<Uuid> {
36 match self {
37 Modal::ConfirmErase(id) | Modal::ConfirmDelete(id) | Modal::Inspector(id) => Some(*id),
38 Modal::OpenUrl(prompt) => Some(prompt.simulator_id),
39 Modal::CommandPalette(palette) => palette.simulator_id,
40 Modal::Appearance | Modal::CreateWizard(_) | Modal::PrivacyWizard(_) | Modal::Help => None,
41 }
42 }
43}
44
45#[derive(Debug, Clone, PartialEq, Default)]
46pub struct CommandPalette {
47 pub simulator_id: Option<Uuid>,
52 pub query: String,
53}
54
55#[derive(Debug, Clone, PartialEq)]
56pub struct OpenUrlPrompt {
57 pub simulator_id: Uuid,
58 pub url: String,
59 pub history_index: i64,
60 pub is_submitting: bool,
61 pub error: Option<String>,
62}
63
64impl OpenUrlPrompt {
65 pub fn new(simulator_id: Uuid) -> Self {
66 Self { simulator_id, url: String::new(), history_index: -1, is_submitting: false, error: None }
67 }
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum PrivacyWizardStep {
72 LoadingApps,
73 PickApp,
74 PickAction,
75 PickPermission,
76 Submitting,
77}
78
79#[derive(Debug, Clone, PartialEq)]
80pub struct PrivacyWizard {
81 pub simulator_id: Uuid,
82 pub step: PrivacyWizardStep,
83 pub all_apps: Vec<InstalledApp>,
84 pub app_index: i64,
85 pub app_scroll_offset: i64,
86 pub action_index: i64,
87 pub permission_index: i64,
88 pub show_system: bool,
89 pub error: Option<String>,
90}
91
92impl PrivacyWizard {
93 pub fn new(simulator_id: Uuid) -> Self {
94 Self {
95 simulator_id,
96 step: PrivacyWizardStep::LoadingApps,
97 all_apps: Vec::new(),
98 app_index: 0,
99 app_scroll_offset: 0,
100 action_index: 0,
101 permission_index: 0,
102 show_system: false,
103 error: None,
104 }
105 }
106
107 pub fn app_viewport(rows: i64) -> i64 {
110 (rows - 5).max(3)
111 }
112
113 pub fn apps(&self) -> Vec<&InstalledApp> {
114 self.all_apps.iter().filter(|app| self.show_system || app.is_user_app).collect()
115 }
116
117 pub fn selected_app(&self) -> Option<&InstalledApp> {
118 let list = self.apps();
119 usize::try_from(self.app_index).ok().and_then(|i| list.get(i).copied())
120 }
121
122 pub fn selected_action(&self) -> Option<PrivacyAction> {
123 usize::try_from(self.action_index).ok().and_then(|i| PrivacyAction::ALL.get(i).copied())
124 }
125
126 pub fn selected_permission(&self) -> Option<PrivacyPermission> {
127 usize::try_from(self.permission_index).ok().and_then(|i| PrivacyPermission::ALL.get(i).copied())
128 }
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
132pub enum CreateWizardStep {
133 #[default]
134 Loading,
135 PickDeviceType,
136 PickRuntime,
137 Confirm,
138 Submitting,
139}
140
141#[derive(Debug, Clone, PartialEq, Default)]
142pub struct CreateWizard {
143 pub step: CreateWizardStep,
144 pub device_types: Vec<DeviceType>,
145 pub runtimes: Vec<Runtime>,
146 pub device_type_index: i64,
147 pub device_type_scroll_offset: i64,
148 pub runtime_index: i64,
149 pub runtime_scroll_offset: i64,
150 pub device_type_filter: String,
151 pub is_device_type_filter_focused: bool,
152 pub error: Option<String>,
153}
154
155impl CreateWizard {
156 pub fn new() -> Self {
157 Self::default()
158 }
159
160 pub fn viewport(rows: i64) -> i64 {
161 (rows - 5).max(3)
162 }
163
164 pub fn device_type_viewport(&self, rows: i64) -> i64 {
168 let banner = if self.is_device_type_filter_focused || !self.device_type_filter.is_empty() { 1 } else { 0 };
169 (Self::viewport(rows) - banner).max(1)
170 }
171
172 pub fn visible_device_types(&self) -> Vec<&DeviceType> {
173 if self.device_type_filter.is_empty() {
174 return self.device_types.iter().collect();
175 }
176 let needle = self.device_type_filter.to_lowercase();
177 self.device_types.iter().filter(|d| d.name.to_lowercase().contains(&needle)).collect()
178 }
179
180 pub fn selected_device_type(&self) -> Option<&DeviceType> {
181 let list = self.visible_device_types();
182 usize::try_from(self.device_type_index).ok().and_then(|i| list.get(i).copied())
183 }
184
185 pub fn selected_runtime(&self) -> Option<&Runtime> {
186 usize::try_from(self.runtime_index).ok().and_then(|i| self.runtimes.get(i))
187 }
188
189 pub fn default_name(&self) -> String {
190 match (self.selected_device_type(), self.selected_runtime()) {
191 (Some(device_type), Some(runtime)) => format!("{} ({})", device_type.name, runtime.display_name()),
192 _ => "Simulator".to_string(),
193 }
194 }
195}
196
197#[derive(Debug, Clone, PartialEq)]
198pub struct AppState {
199 pub simulators: Vec<Simulator>,
200 pub selected_index: i64,
201 pub main_scroll_offset: i64,
202 pub filter_query: String,
203 pub is_filter_focused: bool,
204 pub status_message: Option<String>,
205 pub last_error: Option<String>,
206 pub pending_operations: std::collections::HashMap<Uuid, PendingOperation>,
207 pub is_quitting: bool,
208 pub rows: i64,
209 pub cols: i64,
210 pub recording_device_id: Option<Uuid>,
211 pub recording_path: Option<std::path::PathBuf>,
212 pub modal: Option<Modal>,
213 pub url_history: Vec<String>,
214}
215
216impl Default for AppState {
217 fn default() -> Self {
218 Self {
219 simulators: Vec::new(),
220 selected_index: 0,
221 main_scroll_offset: 0,
222 filter_query: String::new(),
223 is_filter_focused: false,
224 status_message: None,
225 last_error: None,
226 pending_operations: std::collections::HashMap::new(),
227 is_quitting: false,
228 rows: 24,
229 cols: 80,
230 recording_device_id: None,
231 recording_path: None,
232 modal: None,
233 url_history: Vec::new(),
234 }
235 }
236}
237
238impl AppState {
239 pub fn is_recording(&self) -> bool {
240 self.recording_device_id.is_some()
241 }
242
243 pub fn visible_simulators(&self) -> Vec<&Simulator> {
244 if self.filter_query.is_empty() {
245 return self.simulators.iter().collect();
246 }
247 let needle = self.filter_query.to_lowercase();
248 self.simulators.iter().filter(|sim| sim.name.to_lowercase().contains(&needle)).collect()
249 }
250
251 pub fn selected_simulator(&self) -> Option<&Simulator> {
252 if self.filter_query.is_empty() {
255 return usize::try_from(self.selected_index).ok().and_then(|i| self.simulators.get(i));
256 }
257 let list = self.visible_simulators();
258 usize::try_from(self.selected_index).ok().and_then(|i| list.get(i).copied())
259 }
260
261 pub fn main_list_viewport(&self) -> i64 {
266 let modal_banner = match &self.modal {
267 Some(Modal::CommandPalette(_)) => 0,
268 Some(_) => 1,
269 None => 0,
270 };
271 let banner = i64::from(self.is_recording()) + modal_banner;
272 (self.rows - 4 - banner - 2).max(1)
273 }
274
275 pub fn scroll(offset: i64, index: i64, viewport: i64) -> i64 {
278 if index < offset {
279 return index;
280 }
281 if index >= offset + viewport {
282 return index - viewport + 1;
283 }
284 offset
285 }
286
287 pub fn sort(mut simulators: Vec<Simulator>) -> Vec<Simulator> {
288 simulators.sort_by(|lhs, rhs| rhs.runtime.cmp(&lhs.runtime).then_with(|| lhs.name.cmp(&rhs.name)));
289 simulators
290 }
291}