Skip to main content

holodeck_simctl_tui/
view.rs

1use ratatui::prelude::*;
2use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap};
3
4use crate::state::{
5    AppState, CommandPalette, CreateWizard, CreateWizardStep, Modal, OpenUrlPrompt, PrivacyWizard, PrivacyWizardStep,
6};
7use crate::theme::Theme;
8
9const HELP_ENTRIES: &[(&str, &str)] = &[
10    ("↑ ↓ / j k", "Navigate the simulator list"),
11    ("Enter / Space", "Boot or shut down the selection"),
12    ("R", "Force refresh"),
13    ("r", "Start / stop recording"),
14    ("p", "Screenshot"),
15    ("a", "Appearance submenu (l light / d dark / Esc)"),
16    ("n", "New simulator wizard"),
17    ("f", "Focus Simulator.app on the selection"),
18    ("e", "Erase (shut-down sims only; y/n confirm)"),
19    ("d", "Delete (y/n confirm)"),
20    ("P", "Privacy wizard"),
21    ("/", "Filter simulators by name"),
22    ("i", "Inspect the selected simulator"),
23    ("o", "Open a URL / deep link on the selected booted sim"),
24    (":", "Command palette"),
25    ("?", "Help overlay"),
26    ("q / Esc", "Quit (or cancel the active modal)"),
27];
28
29pub fn render(frame: &mut Frame, state: &AppState, theme: &Theme) {
30    match &state.modal {
31        Some(Modal::Help) => render_help(frame, theme),
32        Some(Modal::Inspector(id)) => render_inspector(frame, state, theme, *id),
33        Some(Modal::OpenUrl(prompt)) => render_open_url(frame, theme, prompt),
34        Some(Modal::CreateWizard(wizard)) => render_create_wizard(frame, theme, wizard),
35        Some(Modal::PrivacyWizard(wizard)) => render_privacy_wizard(frame, theme, wizard),
36        _ => render_main(frame, state, theme),
37    }
38}
39
40// MARK: - Main simulator list
41
42fn render_main(frame: &mut Frame, state: &AppState, theme: &Theme) {
43    let mut banner_rows: Vec<Line> = Vec::new();
44    if state.is_recording() {
45        banner_rows.push(Line::styled(" ● Recording — press r or q to stop", theme.error().add_modifier(Modifier::BOLD)));
46    }
47    match &state.modal {
48        Some(Modal::Appearance) => {
49            banner_rows.push(Line::styled(" Appearance — l: light  d: dark  Esc: cancel", theme.warning()))
50        }
51        Some(Modal::ConfirmErase(_)) => banner_rows.push(Line::styled(" Erase this simulator? [y]es / [n]o", theme.warning())),
52        Some(Modal::ConfirmDelete(_)) => banner_rows.push(Line::styled(" Delete this simulator? [y]es / [n]o", theme.warning())),
53        _ => {}
54    }
55    if state.is_filter_focused || !state.filter_query.is_empty() {
56        banner_rows.push(Line::styled(format!(" Filter: {}_", state.filter_query), theme.base()));
57    }
58
59    let mut constraints = vec![Constraint::Length(1)]; // header
60    constraints.push(Constraint::Length(banner_rows.len() as u16));
61    constraints.push(Constraint::Min(1)); // list
62    constraints.push(Constraint::Length(1)); // status bar
63    let areas = Layout::vertical(constraints).split(frame.area());
64
65    frame.render_widget(Paragraph::new(Line::styled(" holodeck — iOS Simulator manager", theme.header())), areas[0]);
66    if !banner_rows.is_empty() {
67        frame.render_widget(Paragraph::new(banner_rows), areas[1]);
68    }
69    render_simulator_list(frame, state, theme, areas[2]);
70    render_status_bar(frame, state, theme, areas[3]);
71
72    if let Some(Modal::CommandPalette(palette)) = &state.modal {
73        render_command_palette_overlay(frame, state, theme, palette, frame.area());
74    }
75}
76
77fn render_simulator_list(frame: &mut Frame, state: &AppState, theme: &Theme, area: Rect) {
78    let visible = state.visible_simulators();
79    if visible.is_empty() {
80        let message = if state.simulators.is_empty() { "(no simulators)" } else { "(no matches)" };
81        frame.render_widget(Paragraph::new(message).style(theme.hint()), area);
82        return;
83    }
84
85    let mut items = Vec::with_capacity(visible.len() + 4);
86    let mut selected_row = None;
87    let mut current_runtime = None;
88    for (i, sim) in visible.iter().enumerate() {
89        if current_runtime != Some(&sim.runtime) {
90            items.push(ListItem::new(Line::styled(sim.runtime.display_name(), theme.accent())));
91            current_runtime = Some(&sim.runtime);
92        }
93        if i as i64 == state.selected_index {
94            selected_row = Some(items.len());
95        }
96        let pending = state.pending_operations.get(&sim.id);
97        let status = pending.map(|op| format!(" ({op:?})")).unwrap_or_default();
98        let (dot, dot_style) = match sim.state.raw_value() {
99            "Booted" => ("●", theme.success()),
100            _ => ("○", theme.hint()),
101        };
102        let line = Line::from(vec![
103            Span::styled(format!("  {dot} "), dot_style),
104            Span::styled(format!("{}{status}  [{}]", sim.name, sim.state.raw_value()), theme.base()),
105        ]);
106        items.push(ListItem::new(line));
107    }
108
109    let mut list_state = ListState::default();
110    list_state.select(selected_row);
111    let list = List::new(items).highlight_style(theme.bar());
112    frame.render_stateful_widget(list, area, &mut list_state);
113}
114
115fn render_status_bar(frame: &mut Frame, state: &AppState, theme: &Theme, area: Rect) {
116    let bar = theme.bar();
117    let (text, style) = if let Some(err) = &state.last_error {
118        (err.clone(), bar.fg(theme.red))
119    } else if let Some(msg) = &state.status_message {
120        (msg.clone(), bar.fg(theme.yellow))
121    } else if let Some(sim) = state.selected_simulator() {
122        (format!("{} — {}", sim.name, sim.id), bar)
123    } else {
124        (String::new(), bar)
125    };
126    frame.render_widget(Paragraph::new(text).style(style), area);
127}
128
129// MARK: - Help
130
131fn render_help(frame: &mut Frame, theme: &Theme) {
132    let key_width = HELP_ENTRIES.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
133    let mut lines = vec![Line::styled("Keybindings", theme.header()), Line::from("")];
134    for (key, description) in HELP_ENTRIES {
135        lines.push(Line::styled(format!("  {key:key_width$}  {description}"), theme.base()));
136    }
137    lines.push(Line::from(""));
138    lines.push(Line::styled("Press any key to close", theme.hint()));
139    frame.render_widget(Paragraph::new(lines), frame.area());
140}
141
142// MARK: - Inspector
143
144fn render_inspector(frame: &mut Frame, state: &AppState, theme: &Theme, id: uuid::Uuid) {
145    let Some(sim) = state.simulators.iter().find(|s| s.id == id) else {
146        frame.render_widget(
147            Paragraph::new("Simulator no longer available. Press any key to close.").style(theme.hint()),
148            frame.area(),
149        );
150        return;
151    };
152    let rows = [
153        ("Name", sim.name.clone()),
154        ("UDID", sim.id.to_string().to_uppercase()),
155        ("Runtime", sim.runtime.display_name()),
156        ("Device type", sim.device_type.name.clone()),
157        ("State", sim.state.raw_value().to_string()),
158        ("Available", sim.is_available.to_string()),
159        ("Data path", sim.data_path.as_ref().map(|p| p.display().to_string()).unwrap_or_default()),
160        ("Log path", sim.log_path.as_ref().map(|p| p.display().to_string()).unwrap_or_default()),
161    ];
162    let label_width = rows.iter().map(|(l, _)| l.len()).max().unwrap_or(0);
163    let mut lines = vec![Line::styled("Inspector", theme.header()), Line::from("")];
164    for (label, value) in rows {
165        lines.push(Line::styled(format!("  {label:label_width$}  {value}"), theme.base()));
166    }
167    lines.push(Line::from(""));
168    lines.push(Line::styled("Press any key to close", theme.hint()));
169    frame.render_widget(Paragraph::new(lines), frame.area());
170}
171
172// MARK: - Open URL
173
174fn render_open_url(frame: &mut Frame, theme: &Theme, prompt: &OpenUrlPrompt) {
175    let mut lines = vec![Line::styled("Open URL", theme.header()), Line::from("")];
176    lines.push(Line::styled(format!("  {}_", prompt.url), theme.base()));
177    lines.push(Line::from(""));
178    if let Some(error) = &prompt.error {
179        lines.push(Line::styled(format!("  ⚠ {error}"), theme.error()));
180    } else if prompt.is_submitting {
181        lines.push(Line::styled("  Opening…", theme.warning()));
182    }
183    lines.push(Line::from(""));
184    lines.push(Line::styled("↑/↓ history · Enter open · Esc cancel", theme.hint()));
185    frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), frame.area());
186}
187
188// MARK: - Create wizard
189
190fn render_create_wizard(frame: &mut Frame, theme: &Theme, wizard: &CreateWizard) {
191    let areas = Layout::vertical([Constraint::Length(1), Constraint::Length(1), Constraint::Min(1), Constraint::Length(1)])
192        .split(frame.area());
193    frame.render_widget(Paragraph::new(Line::styled(breadcrumb(wizard.step), theme.bar())), areas[0]);
194
195    match wizard.step {
196        CreateWizardStep::Loading | CreateWizardStep::Submitting => {
197            frame.render_widget(Paragraph::new("Loading…").style(theme.hint()), areas[2]);
198        }
199        CreateWizardStep::PickDeviceType => {
200            if wizard.is_device_type_filter_focused || !wizard.device_type_filter.is_empty() {
201                frame.render_widget(
202                    Paragraph::new(format!("Filter: {}_", wizard.device_type_filter)).style(theme.base()),
203                    areas[1],
204                );
205            }
206            let visible = wizard.visible_device_types();
207            let items: Vec<ListItem> =
208                visible.iter().map(|d| ListItem::new(Span::styled(d.name.clone(), theme.base()))).collect();
209            let mut list_state = ListState::default();
210            list_state.select(usize::try_from(wizard.device_type_index).ok());
211            let list = List::new(items).highlight_style(theme.bar());
212            frame.render_stateful_widget(list, areas[2], &mut list_state);
213        }
214        CreateWizardStep::PickRuntime => {
215            let items: Vec<ListItem> =
216                wizard.runtimes.iter().map(|r| ListItem::new(Span::styled(r.display_name(), theme.base()))).collect();
217            let mut list_state = ListState::default();
218            list_state.select(usize::try_from(wizard.runtime_index).ok());
219            let list = List::new(items).highlight_style(theme.bar());
220            frame.render_stateful_widget(list, areas[2], &mut list_state);
221        }
222        CreateWizardStep::Confirm => {
223            let mut lines = vec![Line::styled(format!("Name: {}", wizard.default_name()), theme.base())];
224            if let Some(d) = wizard.selected_device_type() {
225                lines.push(Line::styled(format!("Device: {}", d.name), theme.base()));
226            }
227            if let Some(r) = wizard.selected_runtime() {
228                lines.push(Line::styled(format!("Runtime: {}", r.display_name()), theme.base()));
229            }
230            if let Some(error) = &wizard.error {
231                lines.push(Line::from(""));
232                lines.push(Line::styled(format!("⚠ {error}"), theme.error()));
233            }
234            lines.push(Line::from(""));
235            lines.push(Line::styled("Enter/y confirm · b back", theme.hint()));
236            frame.render_widget(Paragraph::new(lines), areas[2]);
237        }
238    }
239
240    render_footer(frame, areas[3], wizard_footer_hint(wizard.step), theme);
241}
242
243fn breadcrumb(step: CreateWizardStep) -> &'static str {
244    match step {
245        CreateWizardStep::Loading => " New simulator — loading targets…",
246        CreateWizardStep::PickDeviceType => " New simulator — device type",
247        CreateWizardStep::PickRuntime => " New simulator — runtime",
248        CreateWizardStep::Confirm => " New simulator — confirm",
249        CreateWizardStep::Submitting => " New simulator — creating…",
250    }
251}
252
253fn wizard_footer_hint(step: CreateWizardStep) -> &'static str {
254    match step {
255        CreateWizardStep::PickDeviceType => "↑/↓ select · / filter · Enter next · Esc cancel",
256        CreateWizardStep::PickRuntime => "↑/↓ select · Enter next · b back · Esc cancel",
257        CreateWizardStep::Confirm => "Enter/y confirm · b back · Esc cancel",
258        _ => "Esc cancel",
259    }
260}
261
262// MARK: - Privacy wizard
263
264fn render_privacy_wizard(frame: &mut Frame, theme: &Theme, wizard: &PrivacyWizard) {
265    let areas = Layout::vertical([Constraint::Length(1), Constraint::Min(1), Constraint::Length(1)]).split(frame.area());
266    frame.render_widget(Paragraph::new(Line::styled(privacy_breadcrumb(wizard.step), theme.bar())), areas[0]);
267
268    match wizard.step {
269        PrivacyWizardStep::LoadingApps | PrivacyWizardStep::Submitting => {
270            frame.render_widget(Paragraph::new("Loading…").style(theme.hint()), areas[1]);
271        }
272        PrivacyWizardStep::PickApp => {
273            let apps = wizard.apps();
274            if apps.is_empty() {
275                frame.render_widget(Paragraph::new("(no apps)").style(theme.hint()), areas[1]);
276            } else {
277                let items: Vec<ListItem> = apps
278                    .iter()
279                    .map(|a| ListItem::new(Span::styled(format!("{} ({})", a.name, a.bundle_id), theme.base())))
280                    .collect();
281                let mut list_state = ListState::default();
282                list_state.select(usize::try_from(wizard.app_index).ok());
283                let list = List::new(items).highlight_style(theme.bar());
284                frame.render_stateful_widget(list, areas[1], &mut list_state);
285            }
286        }
287        PrivacyWizardStep::PickPermission => {
288            let items: Vec<ListItem> = holodeck_core::models::PrivacyPermission::ALL
289                .iter()
290                .map(|p| ListItem::new(Span::styled(p.raw_value(), theme.base())))
291                .collect();
292            let mut list_state = ListState::default();
293            list_state.select(usize::try_from(wizard.permission_index).ok());
294            let list = List::new(items).highlight_style(theme.bar());
295            frame.render_stateful_widget(list, areas[1], &mut list_state);
296        }
297        PrivacyWizardStep::PickAction => {
298            let items: Vec<ListItem> = holodeck_core::models::PrivacyAction::ALL
299                .iter()
300                .map(|a| ListItem::new(Span::styled(a.raw_value(), theme.base())))
301                .collect();
302            let mut list_state = ListState::default();
303            list_state.select(usize::try_from(wizard.action_index).ok());
304            let list = List::new(items).highlight_style(theme.bar());
305            frame.render_stateful_widget(list, areas[1], &mut list_state);
306        }
307    }
308
309    render_footer(frame, areas[2], privacy_footer_hint(wizard.step), theme);
310}
311
312fn privacy_breadcrumb(step: PrivacyWizardStep) -> &'static str {
313    match step {
314        PrivacyWizardStep::LoadingApps => " Privacy — loading apps…",
315        PrivacyWizardStep::PickApp => " Privacy — pick an app",
316        PrivacyWizardStep::PickPermission => " Privacy — pick a permission",
317        PrivacyWizardStep::PickAction => " Privacy — pick an action",
318        PrivacyWizardStep::Submitting => " Privacy — applying…",
319    }
320}
321
322fn privacy_footer_hint(step: PrivacyWizardStep) -> &'static str {
323    match step {
324        PrivacyWizardStep::PickApp => "↑/↓ select · s toggle system apps · Enter next · Esc cancel",
325        PrivacyWizardStep::PickPermission => "↑/↓ select · Enter next · b back · Esc cancel",
326        PrivacyWizardStep::PickAction => "↑/↓ select · Enter apply · b back · Esc cancel",
327        _ => "Esc cancel",
328    }
329}
330
331fn render_footer(frame: &mut Frame, area: Rect, hint: &str, theme: &Theme) {
332    frame.render_widget(Paragraph::new(hint).style(theme.hint()), area);
333}
334
335// MARK: - Command palette overlay
336
337fn render_command_palette_overlay(frame: &mut Frame, state: &AppState, theme: &Theme, palette: &CommandPalette, area: Rect) {
338    let box_width = area.width.saturating_sub(4).clamp(24, 60);
339    let box_height = 5u16.min(area.height);
340    let x = area.x + (area.width.saturating_sub(box_width)) / 2;
341    let y = area.y + (area.height.saturating_sub(box_height)) / 2;
342    let popup = Rect { x, y, width: box_width, height: box_height };
343
344    frame.render_widget(Clear, popup);
345    let block =
346        Block::default().borders(Borders::ALL).border_style(theme.accent()).title(" Command palette ").style(theme.base());
347    let inner = block.inner(popup);
348    frame.render_widget(block, popup);
349
350    let matched = crate::state::PaletteCommand::all()
351        .into_iter()
352        .find(|c| c.is_applicable(state.selected_simulator(), state.is_recording()) && c.matches(&palette.query));
353    let ghost = matched
354        .map(|c| c.display_name())
355        .filter(|name| name.len() > palette.query.len())
356        .map(|name| name[palette.query.chars().count()..].to_string())
357        .unwrap_or_default();
358
359    let mut lines =
360        vec![Line::from(vec![Span::styled(format!("> {}", palette.query), theme.base()), Span::styled(ghost, theme.hint())])];
361    if let Some(command) = matched {
362        lines.push(Line::from(""));
363        lines.push(Line::styled(command.description(), theme.hint()));
364    }
365    frame.render_widget(Paragraph::new(lines), inner);
366}