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 (↑/↓ select light/dark · Enter apply)"),
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    // Always render the main simulator list first, then overlay the active modal on top.
31    render_main(frame, state, theme);
32
33    match &state.modal {
34        Some(Modal::Help) => render_help(frame, theme),
35        Some(Modal::Inspector(id)) => render_inspector(frame, state, theme, *id),
36        Some(Modal::OpenUrl(prompt)) => render_open_url(frame, theme, prompt),
37        Some(Modal::CreateWizard(wizard)) => render_create_wizard(frame, state, theme, wizard),
38        Some(Modal::PrivacyWizard(wizard)) => render_privacy_wizard(frame, state, theme, wizard),
39        Some(Modal::CommandPalette(palette)) => render_command_palette_overlay(frame, state, theme, palette, frame.area()),
40        Some(Modal::Appearance(index)) => render_appearance(frame, theme, *index),
41        Some(Modal::ConfirmErase(id, index)) => render_confirm(frame, state, theme, *id, *index, ConfirmKind::Erase),
42        Some(Modal::ConfirmDelete(id, index)) => render_confirm(frame, state, theme, *id, *index, ConfirmKind::Delete),
43        _ => {}
44    }
45}
46
47// MARK: - Popup overlay geometry
48
49/// Computes a centered floating popup `Rect` over `area`.
50///
51/// `width_pct` and `height_pct` are percentages (0–100) of `area` dimensions.
52/// The result is clamped so the popup never exceeds `area`.
53fn popup_rect(area: Rect, width_pct: u16, height_pct: u16) -> Rect {
54    let width = (area.width * width_pct / 100).min(area.width);
55    let height = (area.height * height_pct / 100).min(area.height);
56    let x = area.x + (area.width.saturating_sub(width)) / 2;
57    let y = area.y + (area.height.saturating_sub(height)) / 2;
58    Rect { x, y, width, height }
59}
60
61/// Renders a centered floating popup with a title and returns the inner `Rect`
62/// available for content (i.e. inside the border).
63fn render_popup(frame: &mut Frame, area: Rect, width_pct: u16, height_pct: u16, title: &str, theme: &Theme) -> Rect {
64    let popup = popup_rect(area, width_pct, height_pct);
65    frame.render_widget(Clear, popup);
66    let block =
67        Block::default().borders(Borders::ALL).border_style(theme.accent_style()).title(format!(" {title} ")).style(theme.base());
68    let inner = block.inner(popup);
69    frame.render_widget(block, popup);
70    inner
71}
72
73// MARK: - Main simulator list
74
75fn render_main(frame: &mut Frame, state: &AppState, theme: &Theme) {
76    let mut banner_rows: Vec<Line> = Vec::new();
77    if state.is_recording() {
78        banner_rows.push(Line::styled(" ● Recording — press r or q to stop", theme.error().add_modifier(Modifier::BOLD)));
79    }
80
81    if state.is_filter_focused || !state.filter_query.is_empty() {
82        banner_rows.push(Line::styled(format!(" Filter: {}_", state.filter_query), theme.base()));
83    }
84
85    let mut constraints = vec![Constraint::Length(1)]; // header
86    constraints.push(Constraint::Length(1)); // rule below header
87    constraints.push(Constraint::Length(banner_rows.len() as u16));
88    constraints.push(Constraint::Min(1)); // list
89    constraints.push(Constraint::Length(1)); // rule above status bar
90    constraints.push(Constraint::Length(1)); // status bar
91    let areas = Layout::vertical(constraints).split(frame.area());
92
93    let header_left = " holodeck ";
94    let header_right = " ⏎ toggle  : cmd  ? help  q quit ";
95    let left_width = header_left.chars().count();
96    let right_width = header_right.chars().count();
97    let gap = (state.cols as usize).saturating_sub(left_width + right_width);
98    let header_line = Line::from(vec![
99        Span::styled(header_left, Style::new().fg(theme.chrome).add_modifier(Modifier::BOLD)),
100        Span::styled(" ".repeat(gap), Style::new()),
101        Span::styled(header_right, theme.hint()),
102    ]);
103    frame.render_widget(Paragraph::new(header_line), areas[0]);
104
105    let rule_line = Line::styled("─".repeat(state.cols as usize), theme.rule());
106    frame.render_widget(Paragraph::new(rule_line), areas[1]);
107
108    if !banner_rows.is_empty() {
109        frame.render_widget(Paragraph::new(banner_rows), areas[2]);
110    }
111    render_simulator_list(frame, state, theme, areas[3]);
112
113    let bottom_rule = Line::styled("─".repeat(state.cols as usize), theme.rule());
114    frame.render_widget(Paragraph::new(bottom_rule), areas[4]);
115    render_status_bar(frame, state, theme, areas[5]);
116}
117
118fn render_simulator_list(frame: &mut Frame, state: &AppState, theme: &Theme, area: Rect) {
119    let visible = state.visible_simulators();
120    if visible.is_empty() {
121        let message = if state.simulators.is_empty() { "(no simulators)" } else { "(no matches)" };
122        frame.render_widget(Paragraph::new(message).style(theme.hint()), area);
123        return;
124    }
125
126    let mut items = Vec::with_capacity(visible.len() + 4);
127    let mut selected_row = None;
128    let mut current_runtime = None;
129    for (i, sim) in visible.iter().enumerate() {
130        if current_runtime != Some(&sim.runtime) {
131            items.push(ListItem::new(Line::styled(format!(" {}", sim.runtime.display_name()), theme.accent_style())));
132            current_runtime = Some(&sim.runtime);
133        }
134        if i as i64 == state.selected_index {
135            selected_row = Some(items.len());
136        }
137        let pending = state.pending_operations.get(&sim.id);
138        let status = pending.map(|op| format!(" ({op:?})")).unwrap_or_default();
139        let (dot, dot_style) = match sim.state.raw_value() {
140            "Booted" => ("●", theme.success()),
141            _ => ("○", theme.hint()),
142        };
143        let line = Line::from(vec![
144            Span::styled(format!("  {dot} "), dot_style),
145            Span::styled(format!("{}{status}  [{}]", sim.name, sim.state.raw_value()), theme.base()),
146        ]);
147        items.push(ListItem::new(line));
148    }
149
150    let mut list_state = ListState::default();
151    list_state.select(selected_row);
152    let list = List::new(items).highlight_style(theme.bar());
153    frame.render_stateful_widget(list, area, &mut list_state);
154}
155
156fn render_status_bar(frame: &mut Frame, state: &AppState, theme: &Theme, area: Rect) {
157    let (text, style) = if let Some(err) = &state.last_error {
158        (format!(" {err}"), theme.error())
159    } else if let Some(msg) = &state.status_message {
160        (format!(" {msg}"), theme.warning())
161    } else if let Some(sim) = state.selected_simulator() {
162        (format!(" {} — {}", sim.name, sim.id), theme.hint())
163    } else {
164        (String::new(), theme.hint())
165    };
166    frame.render_widget(Paragraph::new(text).style(style), area);
167}
168
169// MARK: - Help
170
171fn render_help(frame: &mut Frame, theme: &Theme) {
172    let key_width = HELP_ENTRIES.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
173    // Content rows: title + blank + one per entry + blank + footer
174    let content_height = (2 + HELP_ENTRIES.len() + 2) as u16;
175    // Popup height: content + 2 border rows, clamped to terminal height
176    let popup_height_pct = ((content_height + 2) * 100 / frame.area().height.max(1)).min(90);
177
178    let inner = render_popup(frame, frame.area(), 60, popup_height_pct.max(40), "Keybindings", theme);
179
180    let mut lines = vec![Line::from("")];
181    for (key, description) in HELP_ENTRIES {
182        lines.push(Line::styled(format!("  {key:key_width$}  {description}"), theme.base()));
183    }
184    lines.push(Line::from(""));
185    lines.push(Line::styled("  Press any key to close", theme.hint()));
186    frame.render_widget(Paragraph::new(lines), inner);
187}
188
189// MARK: - Inspector
190
191fn render_inspector(frame: &mut Frame, state: &AppState, theme: &Theme, id: uuid::Uuid) {
192    let inner = render_popup(frame, frame.area(), 70, 60, "Inspector", theme);
193
194    let Some(sim) = state.simulators.iter().find(|s| s.id == id) else {
195        frame
196            .render_widget(Paragraph::new("  Simulator no longer available. Press any key to close.").style(theme.hint()), inner);
197        return;
198    };
199    let rows = [
200        ("Name", sim.name.clone()),
201        ("UDID", sim.id.to_string().to_uppercase()),
202        ("Runtime", sim.runtime.display_name()),
203        ("Device type", sim.device_type.name.clone()),
204        ("State", sim.state.raw_value().to_string()),
205        ("Available", sim.is_available.to_string()),
206        ("Data path", sim.data_path.as_ref().map(|p| p.display().to_string()).unwrap_or_default()),
207        ("Log path", sim.log_path.as_ref().map(|p| p.display().to_string()).unwrap_or_default()),
208    ];
209    let label_width = rows.iter().map(|(l, _)| l.len()).max().unwrap_or(0);
210    let mut lines = vec![Line::from("")];
211    for (label, value) in rows {
212        lines.push(Line::styled(format!("  {label:label_width$}  {value}"), theme.base()));
213    }
214    lines.push(Line::from(""));
215    lines.push(Line::styled("  Press any key to close", theme.hint()));
216    frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
217}
218
219// MARK: - Open URL
220
221fn render_open_url(frame: &mut Frame, theme: &Theme, prompt: &OpenUrlPrompt) {
222    let inner = render_popup(frame, frame.area(), 70, 40, "Open URL", theme);
223
224    let mut lines = vec![Line::from("")];
225    lines.push(Line::styled(format!("  {}_", prompt.url), theme.base()));
226    lines.push(Line::from(""));
227    if let Some(error) = &prompt.error {
228        lines.push(Line::styled(format!("  ⚠ {error}"), theme.error()));
229    } else if prompt.is_submitting {
230        lines.push(Line::styled("  Opening…", theme.warning()));
231    } else {
232        lines.push(Line::from(""));
233    }
234    lines.push(Line::from(""));
235    lines.push(Line::styled("  ↑/↓ history · Enter open · Esc cancel", theme.hint()));
236    frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
237}
238
239// MARK: - Create wizard
240
241fn render_create_wizard(frame: &mut Frame, state: &AppState, theme: &Theme, wizard: &CreateWizard) {
242    let inner = render_popup(frame, frame.area(), 80, 80, breadcrumb(wizard.step), theme);
243
244    // Split inner area: optional filter banner, list/content, footer
245    let filter_visible = wizard.is_device_type_filter_focused || !wizard.device_type_filter.is_empty();
246    let filter_height = if filter_visible && wizard.step == CreateWizardStep::PickDeviceType { 1 } else { 0 };
247    let areas = Layout::vertical([Constraint::Length(filter_height), Constraint::Min(1), Constraint::Length(1)]).split(inner);
248
249    if filter_height > 0 {
250        frame.render_widget(Paragraph::new(format!("  Filter: {}_", wizard.device_type_filter)).style(theme.base()), areas[0]);
251    }
252
253    match wizard.step {
254        CreateWizardStep::Loading | CreateWizardStep::Submitting => {
255            frame.render_widget(Paragraph::new("  Loading…").style(theme.hint()), areas[1]);
256        }
257        CreateWizardStep::PickDeviceType => {
258            let visible = wizard.visible_device_types();
259            let items: Vec<ListItem> =
260                visible.iter().map(|d| ListItem::new(Span::styled(format!("  {}", d.name), theme.base()))).collect();
261            let mut list_state = ListState::default();
262            list_state.select(usize::try_from(wizard.device_type_index).ok());
263            let list = List::new(items).highlight_style(theme.bar());
264            frame.render_stateful_widget(list, areas[1], &mut list_state);
265        }
266        CreateWizardStep::PickRuntime => {
267            let items: Vec<ListItem> = wizard
268                .runtimes
269                .iter()
270                .map(|r| ListItem::new(Span::styled(format!("  {}", r.display_name()), theme.base())))
271                .collect();
272            let mut list_state = ListState::default();
273            list_state.select(usize::try_from(wizard.runtime_index).ok());
274            let list = List::new(items).highlight_style(theme.bar());
275            frame.render_stateful_widget(list, areas[1], &mut list_state);
276        }
277        CreateWizardStep::Confirm => {
278            let mut lines = vec![Line::from("")];
279            lines.push(Line::styled(format!("  Name:    {}", wizard.default_name()), theme.base()));
280            if let Some(d) = wizard.selected_device_type() {
281                lines.push(Line::styled(format!("  Device:  {}", d.name), theme.base()));
282            }
283            if let Some(r) = wizard.selected_runtime() {
284                lines.push(Line::styled(format!("  Runtime: {}", r.display_name()), theme.base()));
285            }
286            if let Some(error) = &wizard.error {
287                lines.push(Line::from(""));
288                lines.push(Line::styled(format!("  ⚠ {error}"), theme.error()));
289            }
290            frame.render_widget(Paragraph::new(lines), areas[1]);
291        }
292    }
293
294    frame.render_widget(Paragraph::new(wizard_footer_hint(wizard.step)).style(theme.hint()), areas[2]);
295
296    // Keep scroll math consistent with the popup inner height.
297    let _ = state;
298}
299
300fn breadcrumb(step: CreateWizardStep) -> &'static str {
301    match step {
302        CreateWizardStep::Loading => "New simulator — loading…",
303        CreateWizardStep::PickDeviceType => "New simulator — device type",
304        CreateWizardStep::PickRuntime => "New simulator — runtime",
305        CreateWizardStep::Confirm => "New simulator — confirm",
306        CreateWizardStep::Submitting => "New simulator — creating…",
307    }
308}
309
310fn wizard_footer_hint(step: CreateWizardStep) -> &'static str {
311    match step {
312        CreateWizardStep::PickDeviceType => "↑/↓ select · / filter · Enter next · Esc cancel",
313        CreateWizardStep::PickRuntime => "↑/↓ select · Enter next · b back · Esc cancel",
314        CreateWizardStep::Confirm => "Enter/y confirm · b back · Esc cancel",
315        _ => "Esc cancel",
316    }
317}
318
319// MARK: - Privacy wizard
320
321fn render_privacy_wizard(frame: &mut Frame, state: &AppState, theme: &Theme, wizard: &PrivacyWizard) {
322    let inner = render_popup(frame, frame.area(), 80, 80, privacy_breadcrumb(wizard.step), theme);
323
324    let areas = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(inner);
325
326    match wizard.step {
327        PrivacyWizardStep::LoadingApps | PrivacyWizardStep::Submitting => {
328            frame.render_widget(Paragraph::new("  Loading…").style(theme.hint()), areas[0]);
329        }
330        PrivacyWizardStep::PickApp => {
331            let apps = wizard.apps();
332            if apps.is_empty() {
333                frame.render_widget(Paragraph::new("  (no apps)").style(theme.hint()), areas[0]);
334            } else {
335                let items: Vec<ListItem> = apps
336                    .iter()
337                    .map(|a| ListItem::new(Span::styled(format!("  {} ({})", a.name, a.bundle_id), theme.base())))
338                    .collect();
339                let mut list_state = ListState::default();
340                list_state.select(usize::try_from(wizard.app_index).ok());
341                let list = List::new(items).highlight_style(theme.bar());
342                frame.render_stateful_widget(list, areas[0], &mut list_state);
343            }
344        }
345        PrivacyWizardStep::PickPermission => {
346            let items: Vec<ListItem> = holodeck_core::models::PrivacyPermission::ALL
347                .iter()
348                .map(|p| ListItem::new(Span::styled(format!("  {}", p.raw_value()), theme.base())))
349                .collect();
350            let mut list_state = ListState::default();
351            list_state.select(usize::try_from(wizard.permission_index).ok());
352            let list = List::new(items).highlight_style(theme.bar());
353            frame.render_stateful_widget(list, areas[0], &mut list_state);
354        }
355        PrivacyWizardStep::PickAction => {
356            let items: Vec<ListItem> = holodeck_core::models::PrivacyAction::ALL
357                .iter()
358                .map(|a| ListItem::new(Span::styled(format!("  {}", a.raw_value()), theme.base())))
359                .collect();
360            let mut list_state = ListState::default();
361            list_state.select(usize::try_from(wizard.action_index).ok());
362            let list = List::new(items).highlight_style(theme.bar());
363            frame.render_stateful_widget(list, areas[0], &mut list_state);
364        }
365    }
366
367    frame.render_widget(Paragraph::new(privacy_footer_hint(wizard.step)).style(theme.hint()), areas[1]);
368
369    let _ = state;
370}
371
372fn privacy_breadcrumb(step: PrivacyWizardStep) -> &'static str {
373    match step {
374        PrivacyWizardStep::LoadingApps => "Privacy — loading apps…",
375        PrivacyWizardStep::PickApp => "Privacy — pick an app",
376        PrivacyWizardStep::PickPermission => "Privacy — pick a permission",
377        PrivacyWizardStep::PickAction => "Privacy — pick an action",
378        PrivacyWizardStep::Submitting => "Privacy — applying…",
379    }
380}
381
382fn privacy_footer_hint(step: PrivacyWizardStep) -> &'static str {
383    match step {
384        PrivacyWizardStep::PickApp => "↑/↓ select · s toggle system apps · Enter next · Esc cancel",
385        PrivacyWizardStep::PickPermission => "↑/↓ select · Enter next · b back · Esc cancel",
386        PrivacyWizardStep::PickAction => "↑/↓ select · Enter apply · b back · Esc cancel",
387        _ => "Esc cancel",
388    }
389}
390
391// MARK: - Appearance popup
392
393fn render_appearance(frame: &mut Frame, theme: &Theme, index: i64) {
394    let inner = render_popup(frame, frame.area(), 40, 35, "Appearance", theme);
395    let options = ["Light", "Dark"];
396    let mut lines = vec![Line::from("")];
397    for (i, label) in options.iter().enumerate() {
398        let (bullet, style) =
399            if i as i64 == index { ("●", theme.base().add_modifier(Modifier::BOLD)) } else { ("○", theme.hint()) };
400        lines.push(Line::styled(format!("  {bullet} {label}"), style));
401    }
402    lines.push(Line::from(""));
403    lines.push(Line::styled("  ↑/↓ select · Enter apply · Esc cancel", theme.hint()));
404    frame.render_widget(Paragraph::new(lines), inner);
405}
406
407// MARK: - Confirm erase / delete popup
408
409enum ConfirmKind {
410    Erase,
411    Delete,
412}
413
414fn render_confirm(frame: &mut Frame, state: &AppState, theme: &Theme, id: uuid::Uuid, index: i64, kind: ConfirmKind) {
415    let sim_name = state.simulators.iter().find(|s| s.id == id).map(|s| s.name.as_str()).unwrap_or("?");
416    let title = match kind {
417        ConfirmKind::Erase => format!("Erase \u{201c}{sim_name}\u{201d}?"),
418        ConfirmKind::Delete => format!("Delete \u{201c}{sim_name}\u{201d}?"),
419    };
420    let inner = render_popup(frame, frame.area(), 44, 35, &title, theme);
421    let options = ["Yes", "No"];
422    let mut lines = vec![Line::from("")];
423    for (i, label) in options.iter().enumerate() {
424        let (bullet, style) =
425            if i as i64 == index { ("●", theme.base().add_modifier(Modifier::BOLD)) } else { ("○", theme.hint()) };
426        lines.push(Line::styled(format!("  {bullet} {label}"), style));
427    }
428    lines.push(Line::from(""));
429    lines.push(Line::styled("  ↑/↓ select · Enter confirm · Esc cancel", theme.hint()));
430    frame.render_widget(Paragraph::new(lines), inner);
431}
432
433// MARK: - Command palette overlay
434
435fn render_command_palette_overlay(frame: &mut Frame, state: &AppState, theme: &Theme, palette: &CommandPalette, area: Rect) {
436    let box_width = area.width.saturating_sub(4).clamp(24, 60);
437    let box_height = 5u16.min(area.height);
438    let x = area.x + (area.width.saturating_sub(box_width)) / 2;
439    let y = area.y + (area.height.saturating_sub(box_height)) / 2;
440    let popup = Rect { x, y, width: box_width, height: box_height };
441
442    frame.render_widget(Clear, popup);
443    let block =
444        Block::default().borders(Borders::ALL).border_style(theme.accent_style()).title(" Command palette ").style(theme.base());
445    let inner = block.inner(popup);
446    frame.render_widget(block, popup);
447
448    let matched = crate::state::PaletteCommand::all()
449        .into_iter()
450        .find(|c| c.is_applicable(state.selected_simulator(), state.is_recording()) && c.matches(&palette.query));
451    let ghost = matched
452        .map(|c| c.display_name())
453        .filter(|name| name.len() > palette.query.len())
454        .map(|name| name[palette.query.chars().count()..].to_string())
455        .unwrap_or_default();
456
457    let mut lines =
458        vec![Line::from(vec![Span::styled(format!("> {}", palette.query), theme.base()), Span::styled(ghost, theme.hint())])];
459    if let Some(command) = matched {
460        lines.push(Line::from(""));
461        lines.push(Line::styled(command.description(), theme.hint()));
462    }
463    frame.render_widget(Paragraph::new(lines), inner);
464}