Skip to main content

holodeck_simctl_tui/state/
app_state.rs

1use holodeck_core::models::{
2    DeviceType, InstalledApp, LanguageOption, PrivacyAction, PrivacyPermission, RegionOption, Runtime, Simulator,
3};
4use uuid::Uuid;
5
6/// Intent behind an in-flight simctl operation. Lets the reducer reconcile
7/// `pending_operations` against an arriving `Refreshed` listing — if the sim
8/// already reached the target state we can drop the pending entry even when
9/// the spawned task has not yet returned (a known macOS quirk where `xcrun
10/// simctl shutdown` can block for many seconds after the simulator is
11/// already shut down).
12#[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    /// Selected index: 0 = Light, 1 = Dark.
23    Appearance(i64),
24    /// Selected index: 0 = Yes, 1 = No.
25    ConfirmErase(Uuid, i64),
26    /// Selected index: 0 = Yes, 1 = No.
27    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    /// Some modals reference a specific simulator by UDID. If that sim
39    /// disappears between the modal opening and the next refresh, the
40    /// reducer drops the modal.
41    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    /// Simulator selected when the palette was opened. `None` when no sim was
55    /// selected (only the `new` command is applicable). Used so a refresh
56    /// that drops the underlying sim auto-dismisses the palette before it
57    /// can run a command against the wrong target.
58    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    /// Only the app list scrolls. PrivacyAction/PrivacyPermission lists fit
115    /// any viewport and the view auto-centers their focus at render time.
116    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    /// Language attached while chaining through `PickLanguage` into
156    /// `PickRegion` (see `launch_app_reducer`). `None` when the region
157    /// picker was reached directly (a region-only launch), which also
158    /// distinguishes what `Esc` means once inside `PickRegion`.
159    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    /// The language filter is a conditional banner (visible once focused or
198    /// once something has been typed) — mirrors
199    /// `CreateWizard::device_type_viewport`'s filter-banner accounting, and
200    /// the reducer's scroll math and the view's layout must agree on it.
201    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    /// Same accounting as `language_viewport`, for the region filter banner.
207    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    /// Device-type list viewport accounting for the filter banner (one row).
282    /// The reducer's scroll math and the view's row clamp must agree on this
283    /// number — otherwise the selected row can sit just off the bottom edge.
284    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        // Skip the filter rebuild on the unfiltered path — selected_index
370        // already indexes directly into `simulators`.
371        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    /// Conservative count of simulator rows that fit. The view walks the list
379    /// from `main_scroll_offset` and stops when body height is exhausted; the
380    /// 2-line headroom leaves room for runtime-group headers without exact
381    /// counting.
382    ///
383    /// All modals except `Appearance`, `ConfirmErase`, and `ConfirmDelete` are
384    /// rendered as floating popup overlays on top of the simulator list, so
385    /// they do not consume any banner rows in the main layout.
386    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    /// Scroll-on-edge offset for a windowed list. Returns the new top-visible
392    /// index given the current offset, the focused index, and the viewport.
393    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}