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    /// 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    Inspector(Uuid),
29    OpenUrl(OpenUrlPrompt),
30    CommandPalette(CommandPalette),
31    Help,
32}
33
34impl Modal {
35    /// Some modals reference a specific simulator by UDID. If that sim
36    /// disappears between the modal opening and the next refresh, the
37    /// reducer drops the modal.
38    pub fn referenced_simulator(&self) -> Option<Uuid> {
39        match self {
40            Modal::ConfirmErase(id, _) | Modal::ConfirmDelete(id, _) | Modal::Inspector(id) => Some(*id),
41            Modal::OpenUrl(prompt) => Some(prompt.simulator_id),
42            Modal::CommandPalette(palette) => palette.simulator_id,
43            Modal::Appearance(_) | Modal::CreateWizard(_) | Modal::PrivacyWizard(_) | Modal::Help => None,
44        }
45    }
46}
47
48#[derive(Debug, Clone, PartialEq, Default)]
49pub struct CommandPalette {
50    /// Simulator selected when the palette was opened. `None` when no sim was
51    /// selected (only the `new` command is applicable). Used so a refresh
52    /// that drops the underlying sim auto-dismisses the palette before it
53    /// can run a command against the wrong target.
54    pub simulator_id: Option<Uuid>,
55    pub query: String,
56}
57
58#[derive(Debug, Clone, PartialEq)]
59pub struct OpenUrlPrompt {
60    pub simulator_id: Uuid,
61    pub url: String,
62    pub history_index: i64,
63    pub is_submitting: bool,
64    pub error: Option<String>,
65}
66
67impl OpenUrlPrompt {
68    pub fn new(simulator_id: Uuid) -> Self {
69        Self { simulator_id, url: String::new(), history_index: -1, is_submitting: false, error: None }
70    }
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum PrivacyWizardStep {
75    LoadingApps,
76    PickApp,
77    PickAction,
78    PickPermission,
79    Submitting,
80}
81
82#[derive(Debug, Clone, PartialEq)]
83pub struct PrivacyWizard {
84    pub simulator_id: Uuid,
85    pub step: PrivacyWizardStep,
86    pub all_apps: Vec<InstalledApp>,
87    pub app_index: i64,
88    pub app_scroll_offset: i64,
89    pub action_index: i64,
90    pub permission_index: i64,
91    pub show_system: bool,
92    pub error: Option<String>,
93}
94
95impl PrivacyWizard {
96    pub fn new(simulator_id: Uuid) -> Self {
97        Self {
98            simulator_id,
99            step: PrivacyWizardStep::LoadingApps,
100            all_apps: Vec::new(),
101            app_index: 0,
102            app_scroll_offset: 0,
103            action_index: 0,
104            permission_index: 0,
105            show_system: false,
106            error: None,
107        }
108    }
109
110    /// Only the app list scrolls. PrivacyAction/PrivacyPermission lists fit
111    /// any viewport and the view auto-centers their focus at render time.
112    pub fn app_viewport(rows: i64) -> i64 {
113        (rows - 5).max(3)
114    }
115
116    pub fn apps(&self) -> Vec<&InstalledApp> {
117        self.all_apps.iter().filter(|app| self.show_system || app.is_user_app).collect()
118    }
119
120    pub fn selected_app(&self) -> Option<&InstalledApp> {
121        let list = self.apps();
122        usize::try_from(self.app_index).ok().and_then(|i| list.get(i).copied())
123    }
124
125    pub fn selected_action(&self) -> Option<PrivacyAction> {
126        usize::try_from(self.action_index).ok().and_then(|i| PrivacyAction::ALL.get(i).copied())
127    }
128
129    pub fn selected_permission(&self) -> Option<PrivacyPermission> {
130        usize::try_from(self.permission_index).ok().and_then(|i| PrivacyPermission::ALL.get(i).copied())
131    }
132}
133
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
135pub enum CreateWizardStep {
136    #[default]
137    Loading,
138    PickDeviceType,
139    PickRuntime,
140    Confirm,
141    Submitting,
142}
143
144#[derive(Debug, Clone, PartialEq, Default)]
145pub struct CreateWizard {
146    pub step: CreateWizardStep,
147    pub device_types: Vec<DeviceType>,
148    pub runtimes: Vec<Runtime>,
149    pub device_type_index: i64,
150    pub device_type_scroll_offset: i64,
151    pub runtime_index: i64,
152    pub runtime_scroll_offset: i64,
153    pub device_type_filter: String,
154    pub is_device_type_filter_focused: bool,
155    pub error: Option<String>,
156}
157
158impl CreateWizard {
159    pub fn new() -> Self {
160        Self::default()
161    }
162
163    pub fn viewport(rows: i64) -> i64 {
164        (rows - 5).max(3)
165    }
166
167    /// Device-type list viewport accounting for the filter banner (one row).
168    /// The reducer's scroll math and the view's row clamp must agree on this
169    /// number — otherwise the selected row can sit just off the bottom edge.
170    pub fn device_type_viewport(&self, rows: i64) -> i64 {
171        let banner = if self.is_device_type_filter_focused || !self.device_type_filter.is_empty() { 1 } else { 0 };
172        (Self::viewport(rows) - banner).max(1)
173    }
174
175    pub fn visible_device_types(&self) -> Vec<&DeviceType> {
176        if self.device_type_filter.is_empty() {
177            return self.device_types.iter().collect();
178        }
179        let needle = self.device_type_filter.to_lowercase();
180        self.device_types.iter().filter(|d| d.name.to_lowercase().contains(&needle)).collect()
181    }
182
183    pub fn selected_device_type(&self) -> Option<&DeviceType> {
184        let list = self.visible_device_types();
185        usize::try_from(self.device_type_index).ok().and_then(|i| list.get(i).copied())
186    }
187
188    pub fn selected_runtime(&self) -> Option<&Runtime> {
189        usize::try_from(self.runtime_index).ok().and_then(|i| self.runtimes.get(i))
190    }
191
192    pub fn default_name(&self) -> String {
193        match (self.selected_device_type(), self.selected_runtime()) {
194            (Some(device_type), Some(runtime)) => format!("{} ({})", device_type.name, runtime.display_name()),
195            _ => "Simulator".to_string(),
196        }
197    }
198}
199
200#[derive(Debug, Clone, PartialEq)]
201pub struct AppState {
202    pub simulators: Vec<Simulator>,
203    pub selected_index: i64,
204    pub main_scroll_offset: i64,
205    pub filter_query: String,
206    pub is_filter_focused: bool,
207    pub status_message: Option<String>,
208    pub last_error: Option<String>,
209    pub pending_operations: std::collections::HashMap<Uuid, PendingOperation>,
210    pub is_quitting: bool,
211    pub rows: i64,
212    pub cols: i64,
213    pub recording_device_id: Option<Uuid>,
214    pub recording_path: Option<std::path::PathBuf>,
215    pub modal: Option<Modal>,
216    pub url_history: Vec<String>,
217}
218
219impl Default for AppState {
220    fn default() -> Self {
221        Self {
222            simulators: Vec::new(),
223            selected_index: 0,
224            main_scroll_offset: 0,
225            filter_query: String::new(),
226            is_filter_focused: false,
227            status_message: None,
228            last_error: None,
229            pending_operations: std::collections::HashMap::new(),
230            is_quitting: false,
231            rows: 24,
232            cols: 80,
233            recording_device_id: None,
234            recording_path: None,
235            modal: None,
236            url_history: Vec::new(),
237        }
238    }
239}
240
241impl AppState {
242    pub fn is_recording(&self) -> bool {
243        self.recording_device_id.is_some()
244    }
245
246    pub fn visible_simulators(&self) -> Vec<&Simulator> {
247        if self.filter_query.is_empty() {
248            return self.simulators.iter().collect();
249        }
250        let needle = self.filter_query.to_lowercase();
251        self.simulators.iter().filter(|sim| sim.name.to_lowercase().contains(&needle)).collect()
252    }
253
254    pub fn selected_simulator(&self) -> Option<&Simulator> {
255        // Skip the filter rebuild on the unfiltered path — selected_index
256        // already indexes directly into `simulators`.
257        if self.filter_query.is_empty() {
258            return usize::try_from(self.selected_index).ok().and_then(|i| self.simulators.get(i));
259        }
260        let list = self.visible_simulators();
261        usize::try_from(self.selected_index).ok().and_then(|i| list.get(i).copied())
262    }
263
264    /// Conservative count of simulator rows that fit. The view walks the list
265    /// from `main_scroll_offset` and stops when body height is exhausted; the
266    /// 2-line headroom leaves room for runtime-group headers without exact
267    /// counting.
268    ///
269    /// All modals except `Appearance`, `ConfirmErase`, and `ConfirmDelete` are
270    /// rendered as floating popup overlays on top of the simulator list, so
271    /// they do not consume any banner rows in the main layout.
272    pub fn main_list_viewport(&self) -> i64 {
273        let banner = i64::from(self.is_recording());
274        (self.rows - 4 - banner - 2).max(1)
275    }
276
277    /// Scroll-on-edge offset for a windowed list. Returns the new top-visible
278    /// index given the current offset, the focused index, and the viewport.
279    pub fn scroll(offset: i64, index: i64, viewport: i64) -> i64 {
280        if index < offset {
281            return index;
282        }
283        if index >= offset + viewport {
284            return index - viewport + 1;
285        }
286        offset
287    }
288
289    pub fn sort(mut simulators: Vec<Simulator>) -> Vec<Simulator> {
290        simulators.sort_by(|lhs, rhs| rhs.runtime.cmp(&lhs.runtime).then_with(|| lhs.name.cmp(&rhs.name)));
291        simulators
292    }
293}