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 = 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
76fn 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)]; constraints.push(Constraint::Length(banner_rows.len() as u16));
90 constraints.push(Constraint::Min(1)); constraints.push(Constraint::Length(1)); 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
164fn render_help(frame: &mut Frame, theme: &Theme) {
167 let key_width = HELP_ENTRIES.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
168 let content_height = (2 + HELP_ENTRIES.len() + 2) as u16;
170 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
184fn 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
216fn 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
236fn 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 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 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
324fn 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
396fn 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
415enum 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
444fn 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}