1use holodeck_core::models::{
2 DeviceType, InstalledApp, LanguageOption, PrivacyAction, PrivacyPermission, RegionOption, Runtime, Simulator,
3};
4use uuid::Uuid;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum PendingOperation {
14 Boot,
15 Shutdown,
16 Erase,
17 Delete,
18}
19
20#[derive(Debug, Clone, PartialEq)]
21pub enum Modal {
22 Appearance(i64),
24 ConfirmErase(Uuid, i64),
26 ConfirmDelete(Uuid, i64),
28 CreateWizard(CreateWizard),
29 PrivacyWizard(PrivacyWizard),
30 LaunchApp(LaunchAppPrompt),
31 Inspector(Uuid),
32 OpenUrl(OpenUrlPrompt),
33 CommandPalette(CommandPalette),
34 Help,
35}
36
37impl Modal {
38 pub fn referenced_simulator(&self) -> Option<Uuid> {
42 match self {
43 Modal::ConfirmErase(id, _) | Modal::ConfirmDelete(id, _) | Modal::Inspector(id) => Some(*id),
44 Modal::OpenUrl(prompt) => Some(prompt.simulator_id),
45 Modal::LaunchApp(prompt) => Some(prompt.simulator_id),
46 Modal::CommandPalette(palette) => palette.simulator_id,
47 Modal::Appearance(_) | Modal::CreateWizard(_) | Modal::PrivacyWizard(_) | Modal::Help => None,
48 }
49 }
50}
51
52#[derive(Debug, Clone, PartialEq, Default)]
53pub struct CommandPalette {
54 pub simulator_id: Option<Uuid>,
59 pub query: String,
60}
61
62#[derive(Debug, Clone, PartialEq)]
63pub struct OpenUrlPrompt {
64 pub simulator_id: Uuid,
65 pub url: String,
66 pub history_index: i64,
67 pub is_submitting: bool,
68 pub error: Option<String>,
69}
70
71impl OpenUrlPrompt {
72 pub fn new(simulator_id: Uuid) -> Self {
73 Self { simulator_id, url: String::new(), history_index: -1, is_submitting: false, error: None }
74 }
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum PrivacyWizardStep {
79 LoadingApps,
80 PickApp,
81 PickAction,
82 PickPermission,
83 Submitting,
84}
85
86#[derive(Debug, Clone, PartialEq)]
87pub struct PrivacyWizard {
88 pub simulator_id: Uuid,
89 pub step: PrivacyWizardStep,
90 pub all_apps: Vec<InstalledApp>,
91 pub app_index: i64,
92 pub app_scroll_offset: i64,
93 pub action_index: i64,
94 pub permission_index: i64,
95 pub show_system: bool,
96 pub error: Option<String>,
97}
98
99impl PrivacyWizard {
100 pub fn new(simulator_id: Uuid) -> Self {
101 Self {
102 simulator_id,
103 step: PrivacyWizardStep::LoadingApps,
104 all_apps: Vec::new(),
105 app_index: 0,
106 app_scroll_offset: 0,
107 action_index: 0,
108 permission_index: 0,
109 show_system: false,
110 error: None,
111 }
112 }
113
114 pub fn app_viewport(rows: i64) -> i64 {
117 (rows - 5).max(3)
118 }
119
120 pub fn apps(&self) -> Vec<&InstalledApp> {
121 self.all_apps.iter().filter(|app| self.show_system || app.is_user_app).collect()
122 }
123
124 pub fn selected_app(&self) -> Option<&InstalledApp> {
125 let list = self.apps();
126 usize::try_from(self.app_index).ok().and_then(|i| list.get(i).copied())
127 }
128
129 pub fn selected_action(&self) -> Option<PrivacyAction> {
130 usize::try_from(self.action_index).ok().and_then(|i| PrivacyAction::ALL.get(i).copied())
131 }
132
133 pub fn selected_permission(&self) -> Option<PrivacyPermission> {
134 usize::try_from(self.permission_index).ok().and_then(|i| PrivacyPermission::ALL.get(i).copied())
135 }
136}
137
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum LaunchAppStep {
140 LoadingApps,
141 PickApp,
142 PickLanguage,
143 PickRegion,
144 Submitting,
145}
146
147#[derive(Debug, Clone, PartialEq)]
148pub struct LaunchAppPrompt {
149 pub simulator_id: Uuid,
150 pub step: LaunchAppStep,
151 pub all_apps: Vec<InstalledApp>,
152 pub app_index: i64,
153 pub app_scroll_offset: i64,
154 pub show_system: bool,
155 pub chosen_language: Option<&'static LanguageOption>,
160 pub language_index: i64,
161 pub language_scroll_offset: i64,
162 pub language_filter: String,
163 pub is_language_filter_focused: bool,
164 pub region_index: i64,
165 pub region_scroll_offset: i64,
166 pub region_filter: String,
167 pub is_region_filter_focused: bool,
168 pub error: Option<String>,
169}
170
171impl LaunchAppPrompt {
172 pub fn new(simulator_id: Uuid) -> Self {
173 Self {
174 simulator_id,
175 step: LaunchAppStep::LoadingApps,
176 all_apps: Vec::new(),
177 app_index: 0,
178 app_scroll_offset: 0,
179 show_system: false,
180 chosen_language: None,
181 language_index: 0,
182 language_scroll_offset: 0,
183 language_filter: String::new(),
184 is_language_filter_focused: false,
185 region_index: 0,
186 region_scroll_offset: 0,
187 region_filter: String::new(),
188 is_region_filter_focused: false,
189 error: None,
190 }
191 }
192
193 pub fn app_viewport(rows: i64) -> i64 {
194 (rows - 5).max(3)
195 }
196
197 pub fn language_viewport(&self, rows: i64) -> i64 {
202 let banner = i64::from(self.is_language_filter_focused || !self.language_filter.is_empty());
203 (Self::app_viewport(rows) - banner).max(1)
204 }
205
206 pub fn region_viewport(&self, rows: i64) -> i64 {
208 let banner = i64::from(self.is_region_filter_focused || !self.region_filter.is_empty());
209 (Self::app_viewport(rows) - banner).max(1)
210 }
211
212 pub fn apps(&self) -> Vec<&InstalledApp> {
213 self.all_apps.iter().filter(|app| self.show_system || app.is_user_app).collect()
214 }
215
216 pub fn selected_app(&self) -> Option<&InstalledApp> {
217 let list = self.apps();
218 usize::try_from(self.app_index).ok().and_then(|i| list.get(i).copied())
219 }
220
221 pub fn visible_languages(&self) -> Vec<&'static LanguageOption> {
222 if self.language_filter.is_empty() {
223 return LanguageOption::ALL.iter().collect();
224 }
225 let needle = self.language_filter.to_lowercase();
226 LanguageOption::ALL.iter().filter(|l| l.display_name.to_lowercase().contains(&needle)).collect()
227 }
228
229 pub fn selected_language(&self) -> Option<&'static LanguageOption> {
230 let list = self.visible_languages();
231 usize::try_from(self.language_index).ok().and_then(|i| list.get(i).copied())
232 }
233
234 pub fn visible_regions(&self) -> Vec<&'static RegionOption> {
235 if self.region_filter.is_empty() {
236 return RegionOption::ALL.iter().collect();
237 }
238 let needle = self.region_filter.to_lowercase();
239 RegionOption::ALL.iter().filter(|r| r.display_name.to_lowercase().contains(&needle)).collect()
240 }
241
242 pub fn selected_region(&self) -> Option<&'static RegionOption> {
243 let list = self.visible_regions();
244 usize::try_from(self.region_index).ok().and_then(|i| list.get(i).copied())
245 }
246}
247
248#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
249pub enum CreateWizardStep {
250 #[default]
251 Loading,
252 PickDeviceType,
253 PickRuntime,
254 Confirm,
255 Submitting,
256}
257
258#[derive(Debug, Clone, PartialEq, Default)]
259pub struct CreateWizard {
260 pub step: CreateWizardStep,
261 pub device_types: Vec<DeviceType>,
262 pub runtimes: Vec<Runtime>,
263 pub device_type_index: i64,
264 pub device_type_scroll_offset: i64,
265 pub runtime_index: i64,
266 pub runtime_scroll_offset: i64,
267 pub device_type_filter: String,
268 pub is_device_type_filter_focused: bool,
269 pub error: Option<String>,
270}
271
272impl CreateWizard {
273 pub fn new() -> Self {
274 Self::default()
275 }
276
277 pub fn viewport(rows: i64) -> i64 {
278 (rows - 5).max(3)
279 }
280
281 pub fn device_type_viewport(&self, rows: i64) -> i64 {
285 let banner = if self.is_device_type_filter_focused || !self.device_type_filter.is_empty() { 1 } else { 0 };
286 (Self::viewport(rows) - banner).max(1)
287 }
288
289 pub fn visible_device_types(&self) -> Vec<&DeviceType> {
290 if self.device_type_filter.is_empty() {
291 return self.device_types.iter().collect();
292 }
293 let needle = self.device_type_filter.to_lowercase();
294 self.device_types.iter().filter(|d| d.name.to_lowercase().contains(&needle)).collect()
295 }
296
297 pub fn selected_device_type(&self) -> Option<&DeviceType> {
298 let list = self.visible_device_types();
299 usize::try_from(self.device_type_index).ok().and_then(|i| list.get(i).copied())
300 }
301
302 pub fn selected_runtime(&self) -> Option<&Runtime> {
303 usize::try_from(self.runtime_index).ok().and_then(|i| self.runtimes.get(i))
304 }
305
306 pub fn default_name(&self) -> String {
307 match (self.selected_device_type(), self.selected_runtime()) {
308 (Some(device_type), Some(runtime)) => format!("{} ({})", device_type.name, runtime.display_name()),
309 _ => "Simulator".to_string(),
310 }
311 }
312}
313
314#[derive(Debug, Clone, PartialEq)]
315pub struct AppState {
316 pub simulators: Vec<Simulator>,
317 pub selected_index: i64,
318 pub main_scroll_offset: i64,
319 pub filter_query: String,
320 pub is_filter_focused: bool,
321 pub status_message: Option<String>,
322 pub last_error: Option<String>,
323 pub pending_operations: std::collections::HashMap<Uuid, PendingOperation>,
324 pub is_quitting: bool,
325 pub rows: i64,
326 pub cols: i64,
327 pub recording_device_id: Option<Uuid>,
328 pub recording_path: Option<std::path::PathBuf>,
329 pub modal: Option<Modal>,
330 pub url_history: Vec<String>,
331}
332
333impl Default for AppState {
334 fn default() -> Self {
335 Self {
336 simulators: Vec::new(),
337 selected_index: 0,
338 main_scroll_offset: 0,
339 filter_query: String::new(),
340 is_filter_focused: false,
341 status_message: None,
342 last_error: None,
343 pending_operations: std::collections::HashMap::new(),
344 is_quitting: false,
345 rows: 24,
346 cols: 80,
347 recording_device_id: None,
348 recording_path: None,
349 modal: None,
350 url_history: Vec::new(),
351 }
352 }
353}
354
355impl AppState {
356 pub fn is_recording(&self) -> bool {
357 self.recording_device_id.is_some()
358 }
359
360 pub fn visible_simulators(&self) -> Vec<&Simulator> {
361 if self.filter_query.is_empty() {
362 return self.simulators.iter().collect();
363 }
364 let needle = self.filter_query.to_lowercase();
365 self.simulators.iter().filter(|sim| sim.name.to_lowercase().contains(&needle)).collect()
366 }
367
368 pub fn selected_simulator(&self) -> Option<&Simulator> {
369 if self.filter_query.is_empty() {
372 return usize::try_from(self.selected_index).ok().and_then(|i| self.simulators.get(i));
373 }
374 let list = self.visible_simulators();
375 usize::try_from(self.selected_index).ok().and_then(|i| list.get(i).copied())
376 }
377
378 pub fn main_list_viewport(&self) -> i64 {
387 let banner = i64::from(self.is_recording());
388 (self.rows - 4 - banner - 2).max(1)
389 }
390
391 pub fn scroll(offset: i64, index: i64, viewport: i64) -> i64 {
394 if index < offset {
395 return index;
396 }
397 if index >= offset + viewport {
398 return index - viewport + 1;
399 }
400 offset
401 }
402
403 pub fn sort(mut simulators: Vec<Simulator>) -> Vec<Simulator> {
404 simulators.sort_by(|lhs, rhs| rhs.runtime.cmp(&lhs.runtime).then_with(|| lhs.name.cmp(&rhs.name)));
405 simulators
406 }
407}