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 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
47fn 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
61fn 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
73fn 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)]; constraints.push(Constraint::Length(1)); constraints.push(Constraint::Length(banner_rows.len() as u16));
88 constraints.push(Constraint::Min(1)); constraints.push(Constraint::Length(1)); constraints.push(Constraint::Length(1)); 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
169fn render_help(frame: &mut Frame, theme: &Theme) {
172 let key_width = HELP_ENTRIES.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
173 let content_height = (2 + HELP_ENTRIES.len() + 2) as u16;
175 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
189fn 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
219fn 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
239fn 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 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 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
319fn 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
391fn 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
407enum 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
433fn 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}