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