Skip to main content

holodeck_simctl_tui/state/
app_state.rs

1use holodeck_core::models::{DeviceType, InstalledApp, 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    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    /// Some modals reference a specific simulator by UDID. If that sim
33    /// disappears between the modal opening and the next refresh, the
34    /// reducer drops the modal.
35    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    /// Simulator selected when the palette was opened. `None` when no sim was
48    /// selected (only the `new` command is applicable). Used so a refresh
49    /// that drops the underlying sim auto-dismisses the palette before it
50    /// can run a command against the wrong target.
51    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    /// Only the app list scrolls. PrivacyAction/PrivacyPermission lists fit
108    /// any viewport and the view auto-centers their focus at render time.
109    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    /// Device-type list viewport accounting for the filter banner (one row).
165    /// The reducer's scroll math and the view's row clamp must agree on this
166    /// number — otherwise the selected row can sit just off the bottom edge.
167    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        // Skip the filter rebuild on the unfiltered path — selected_index
253        // already indexes directly into `simulators`.
254        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    /// Conservative count of simulator rows that fit. The view walks the list
262    /// from `main_scroll_offset` and stops when body height is exhausted; the
263    /// 2-line headroom leaves room for runtime-group headers without exact
264    /// counting.
265    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    /// Scroll-on-edge offset for a windowed list. Returns the new top-visible
276    /// index given the current offset, the focused index, and the viewport.
277    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}