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