Skip to main content

holodeck_simctl_tui/state/
app_state.rs

1use holodeck_core::models::{DeviceType, InstalledApp, LanguageOption, PrivacyAction, PrivacyPermission, Runtime, Simulator};
2use uuid::Uuid;
3
4/// Intent behind an in-flight simctl operation. Lets the reducer reconcile
5/// `pending_operations` against an arriving `Refreshed` listing — if the sim
6/// already reached the target state we can drop the pending entry even when
7/// the spawned task has not yet returned (a known macOS quirk where `xcrun
8/// simctl shutdown` can block for many seconds after the simulator is
9/// already shut down).
10#[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    /// Selected index: 0 = Light, 1 = Dark.
21    Appearance(i64),
22    /// Selected index: 0 = Yes, 1 = No.
23    ConfirmErase(Uuid, i64),
24    /// Selected index: 0 = Yes, 1 = No.
25    ConfirmDelete(Uuid, i64),
26    CreateWizard(CreateWizard),
27    PrivacyWizard(PrivacyWizard),
28    LaunchApp(LaunchAppPrompt),
29    Inspector(Uuid),
30    OpenUrl(OpenUrlPrompt),
31    CommandPalette(CommandPalette),
32    Help,
33}
34
35impl Modal {
36    /// Some modals reference a specific simulator by UDID. If that sim
37    /// disappears between the modal opening and the next refresh, the
38    /// reducer drops the modal.
39    pub fn referenced_simulator(&self) -> Option<Uuid> {
40        match self {
41            Modal::ConfirmErase(id, _) | Modal::ConfirmDelete(id, _) | Modal::Inspector(id) => Some(*id),
42            Modal::OpenUrl(prompt) => Some(prompt.simulator_id),
43            Modal::LaunchApp(prompt) => Some(prompt.simulator_id),
44            Modal::CommandPalette(palette) => palette.simulator_id,
45            Modal::Appearance(_) | Modal::CreateWizard(_) | Modal::PrivacyWizard(_) | Modal::Help => None,
46        }
47    }
48}
49
50#[derive(Debug, Clone, PartialEq, Default)]
51pub struct CommandPalette {
52    /// Simulator selected when the palette was opened. `None` when no sim was
53    /// selected (only the `new` command is applicable). Used so a refresh
54    /// that drops the underlying sim auto-dismisses the palette before it
55    /// can run a command against the wrong target.
56    pub simulator_id: Option<Uuid>,
57    pub query: String,
58}
59
60#[derive(Debug, Clone, PartialEq)]
61pub struct OpenUrlPrompt {
62    pub simulator_id: Uuid,
63    pub url: String,
64    pub history_index: i64,
65    pub is_submitting: bool,
66    pub error: Option<String>,
67}
68
69impl OpenUrlPrompt {
70    pub fn new(simulator_id: Uuid) -> Self {
71        Self { simulator_id, url: String::new(), history_index: -1, is_submitting: false, error: None }
72    }
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum PrivacyWizardStep {
77    LoadingApps,
78    PickApp,
79    PickAction,
80    PickPermission,
81    Submitting,
82}
83
84#[derive(Debug, Clone, PartialEq)]
85pub struct PrivacyWizard {
86    pub simulator_id: Uuid,
87    pub step: PrivacyWizardStep,
88    pub all_apps: Vec<InstalledApp>,
89    pub app_index: i64,
90    pub app_scroll_offset: i64,
91    pub action_index: i64,
92    pub permission_index: i64,
93    pub show_system: bool,
94    pub error: Option<String>,
95}
96
97impl PrivacyWizard {
98    pub fn new(simulator_id: Uuid) -> Self {
99        Self {
100            simulator_id,
101            step: PrivacyWizardStep::LoadingApps,
102            all_apps: Vec::new(),
103            app_index: 0,
104            app_scroll_offset: 0,
105            action_index: 0,
106            permission_index: 0,
107            show_system: false,
108            error: None,
109        }
110    }
111
112    /// Only the app list scrolls. PrivacyAction/PrivacyPermission lists fit
113    /// any viewport and the view auto-centers their focus at render time.
114    pub fn app_viewport(rows: i64) -> i64 {
115        (rows - 5).max(3)
116    }
117
118    pub fn apps(&self) -> Vec<&InstalledApp> {
119        self.all_apps.iter().filter(|app| self.show_system || app.is_user_app).collect()
120    }
121
122    pub fn selected_app(&self) -> Option<&InstalledApp> {
123        let list = self.apps();
124        usize::try_from(self.app_index).ok().and_then(|i| list.get(i).copied())
125    }
126
127    pub fn selected_action(&self) -> Option<PrivacyAction> {
128        usize::try_from(self.action_index).ok().and_then(|i| PrivacyAction::ALL.get(i).copied())
129    }
130
131    pub fn selected_permission(&self) -> Option<PrivacyPermission> {
132        usize::try_from(self.permission_index).ok().and_then(|i| PrivacyPermission::ALL.get(i).copied())
133    }
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub enum LaunchAppStep {
138    LoadingApps,
139    PickApp,
140    PickLanguage,
141    Submitting,
142}
143
144#[derive(Debug, Clone, PartialEq)]
145pub struct LaunchAppPrompt {
146    pub simulator_id: Uuid,
147    pub step: LaunchAppStep,
148    pub all_apps: Vec<InstalledApp>,
149    pub app_index: i64,
150    pub app_scroll_offset: i64,
151    pub show_system: bool,
152    pub language_index: i64,
153    pub language_scroll_offset: i64,
154    pub language_filter: String,
155    pub is_language_filter_focused: bool,
156    pub error: Option<String>,
157}
158
159impl LaunchAppPrompt {
160    pub fn new(simulator_id: Uuid) -> Self {
161        Self {
162            simulator_id,
163            step: LaunchAppStep::LoadingApps,
164            all_apps: Vec::new(),
165            app_index: 0,
166            app_scroll_offset: 0,
167            show_system: false,
168            language_index: 0,
169            language_scroll_offset: 0,
170            language_filter: String::new(),
171            is_language_filter_focused: false,
172            error: None,
173        }
174    }
175
176    pub fn app_viewport(rows: i64) -> i64 {
177        (rows - 5).max(3)
178    }
179
180    /// The language filter is a conditional banner (visible once focused or
181    /// once something has been typed) — mirrors
182    /// `CreateWizard::device_type_viewport`'s filter-banner accounting, and
183    /// the reducer's scroll math and the view's layout must agree on it.
184    pub fn language_viewport(&self, rows: i64) -> i64 {
185        let banner = i64::from(self.is_language_filter_focused || !self.language_filter.is_empty());
186        (Self::app_viewport(rows) - banner).max(1)
187    }
188
189    pub fn apps(&self) -> Vec<&InstalledApp> {
190        self.all_apps.iter().filter(|app| self.show_system || app.is_user_app).collect()
191    }
192
193    pub fn selected_app(&self) -> Option<&InstalledApp> {
194        let list = self.apps();
195        usize::try_from(self.app_index).ok().and_then(|i| list.get(i).copied())
196    }
197
198    pub fn visible_languages(&self) -> Vec<&'static LanguageOption> {
199        if self.language_filter.is_empty() {
200            return LanguageOption::ALL.iter().collect();
201        }
202        let needle = self.language_filter.to_lowercase();
203        LanguageOption::ALL.iter().filter(|l| l.display_name.to_lowercase().contains(&needle)).collect()
204    }
205
206    pub fn selected_language(&self) -> Option<&'static LanguageOption> {
207        let list = self.visible_languages();
208        usize::try_from(self.language_index).ok().and_then(|i| list.get(i).copied())
209    }
210}
211
212#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
213pub enum CreateWizardStep {
214    #[default]
215    Loading,
216    PickDeviceType,
217    PickRuntime,
218    Confirm,
219    Submitting,
220}
221
222#[derive(Debug, Clone, PartialEq, Default)]
223pub struct CreateWizard {
224    pub step: CreateWizardStep,
225    pub device_types: Vec<DeviceType>,
226    pub runtimes: Vec<Runtime>,
227    pub device_type_index: i64,
228    pub device_type_scroll_offset: i64,
229    pub runtime_index: i64,
230    pub runtime_scroll_offset: i64,
231    pub device_type_filter: String,
232    pub is_device_type_filter_focused: bool,
233    pub error: Option<String>,
234}
235
236impl CreateWizard {
237    pub fn new() -> Self {
238        Self::default()
239    }
240
241    pub fn viewport(rows: i64) -> i64 {
242        (rows - 5).max(3)
243    }
244
245    /// Device-type list viewport accounting for the filter banner (one row).
246    /// The reducer's scroll math and the view's row clamp must agree on this
247    /// number — otherwise the selected row can sit just off the bottom edge.
248    pub fn device_type_viewport(&self, rows: i64) -> i64 {
249        let banner = if self.is_device_type_filter_focused || !self.device_type_filter.is_empty() { 1 } else { 0 };
250        (Self::viewport(rows) - banner).max(1)
251    }
252
253    pub fn visible_device_types(&self) -> Vec<&DeviceType> {
254        if self.device_type_filter.is_empty() {
255            return self.device_types.iter().collect();
256        }
257        let needle = self.device_type_filter.to_lowercase();
258        self.device_types.iter().filter(|d| d.name.to_lowercase().contains(&needle)).collect()
259    }
260
261    pub fn selected_device_type(&self) -> Option<&DeviceType> {
262        let list = self.visible_device_types();
263        usize::try_from(self.device_type_index).ok().and_then(|i| list.get(i).copied())
264    }
265
266    pub fn selected_runtime(&self) -> Option<&Runtime> {
267        usize::try_from(self.runtime_index).ok().and_then(|i| self.runtimes.get(i))
268    }
269
270    pub fn default_name(&self) -> String {
271        match (self.selected_device_type(), self.selected_runtime()) {
272            (Some(device_type), Some(runtime)) => format!("{} ({})", device_type.name, runtime.display_name()),
273            _ => "Simulator".to_string(),
274        }
275    }
276}
277
278#[derive(Debug, Clone, PartialEq)]
279pub struct AppState {
280    pub simulators: Vec<Simulator>,
281    pub selected_index: i64,
282    pub main_scroll_offset: i64,
283    pub filter_query: String,
284    pub is_filter_focused: bool,
285    pub status_message: Option<String>,
286    pub last_error: Option<String>,
287    pub pending_operations: std::collections::HashMap<Uuid, PendingOperation>,
288    pub is_quitting: bool,
289    pub rows: i64,
290    pub cols: i64,
291    pub recording_device_id: Option<Uuid>,
292    pub recording_path: Option<std::path::PathBuf>,
293    pub modal: Option<Modal>,
294    pub url_history: Vec<String>,
295}
296
297impl Default for AppState {
298    fn default() -> Self {
299        Self {
300            simulators: Vec::new(),
301            selected_index: 0,
302            main_scroll_offset: 0,
303            filter_query: String::new(),
304            is_filter_focused: false,
305            status_message: None,
306            last_error: None,
307            pending_operations: std::collections::HashMap::new(),
308            is_quitting: false,
309            rows: 24,
310            cols: 80,
311            recording_device_id: None,
312            recording_path: None,
313            modal: None,
314            url_history: Vec::new(),
315        }
316    }
317}
318
319impl AppState {
320    pub fn is_recording(&self) -> bool {
321        self.recording_device_id.is_some()
322    }
323
324    pub fn visible_simulators(&self) -> Vec<&Simulator> {
325        if self.filter_query.is_empty() {
326            return self.simulators.iter().collect();
327        }
328        let needle = self.filter_query.to_lowercase();
329        self.simulators.iter().filter(|sim| sim.name.to_lowercase().contains(&needle)).collect()
330    }
331
332    pub fn selected_simulator(&self) -> Option<&Simulator> {
333        // Skip the filter rebuild on the unfiltered path — selected_index
334        // already indexes directly into `simulators`.
335        if self.filter_query.is_empty() {
336            return usize::try_from(self.selected_index).ok().and_then(|i| self.simulators.get(i));
337        }
338        let list = self.visible_simulators();
339        usize::try_from(self.selected_index).ok().and_then(|i| list.get(i).copied())
340    }
341
342    /// Conservative count of simulator rows that fit. The view walks the list
343    /// from `main_scroll_offset` and stops when body height is exhausted; the
344    /// 2-line headroom leaves room for runtime-group headers without exact
345    /// counting.
346    ///
347    /// All modals except `Appearance`, `ConfirmErase`, and `ConfirmDelete` are
348    /// rendered as floating popup overlays on top of the simulator list, so
349    /// they do not consume any banner rows in the main layout.
350    pub fn main_list_viewport(&self) -> i64 {
351        let banner = i64::from(self.is_recording());
352        (self.rows - 4 - banner - 2).max(1)
353    }
354
355    /// Scroll-on-edge offset for a windowed list. Returns the new top-visible
356    /// index given the current offset, the focused index, and the viewport.
357    pub fn scroll(offset: i64, index: i64, viewport: i64) -> i64 {
358        if index < offset {
359            return index;
360        }
361        if index >= offset + viewport {
362            return index - viewport + 1;
363        }
364        offset
365    }
366
367    pub fn sort(mut simulators: Vec<Simulator>) -> Vec<Simulator> {
368        simulators.sort_by(|lhs, rhs| rhs.runtime.cmp(&lhs.runtime).then_with(|| lhs.name.cmp(&rhs.name)));
369        simulators
370    }
371}