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 submenu (l light / d dark / Esc)"),
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 match &state.modal {
31 Some(Modal::Help) => render_help(frame, theme),
32 Some(Modal::Inspector(id)) => render_inspector(frame, state, theme, *id),
33 Some(Modal::OpenUrl(prompt)) => render_open_url(frame, theme, prompt),
34 Some(Modal::CreateWizard(wizard)) => render_create_wizard(frame, theme, wizard),
35 Some(Modal::PrivacyWizard(wizard)) => render_privacy_wizard(frame, theme, wizard),
36 _ => render_main(frame, state, theme),
37 }
38}
39
40fn render_main(frame: &mut Frame, state: &AppState, theme: &Theme) {
43 let mut banner_rows: Vec<Line> = Vec::new();
44 if state.is_recording() {
45 banner_rows.push(Line::styled(" ● Recording — press r or q to stop", theme.error().add_modifier(Modifier::BOLD)));
46 }
47 match &state.modal {
48 Some(Modal::Appearance) => {
49 banner_rows.push(Line::styled(" Appearance — l: light d: dark Esc: cancel", theme.warning()))
50 }
51 Some(Modal::ConfirmErase(_)) => banner_rows.push(Line::styled(" Erase this simulator? [y]es / [n]o", theme.warning())),
52 Some(Modal::ConfirmDelete(_)) => banner_rows.push(Line::styled(" Delete this simulator? [y]es / [n]o", theme.warning())),
53 _ => {}
54 }
55 if state.is_filter_focused || !state.filter_query.is_empty() {
56 banner_rows.push(Line::styled(format!(" Filter: {}_", state.filter_query), theme.base()));
57 }
58
59 let mut constraints = vec![Constraint::Length(1)]; constraints.push(Constraint::Length(banner_rows.len() as u16));
61 constraints.push(Constraint::Min(1)); constraints.push(Constraint::Length(1)); let areas = Layout::vertical(constraints).split(frame.area());
64
65 frame.render_widget(Paragraph::new(Line::styled(" holodeck — iOS Simulator manager", theme.header())), areas[0]);
66 if !banner_rows.is_empty() {
67 frame.render_widget(Paragraph::new(banner_rows), areas[1]);
68 }
69 render_simulator_list(frame, state, theme, areas[2]);
70 render_status_bar(frame, state, theme, areas[3]);
71
72 if let Some(Modal::CommandPalette(palette)) = &state.modal {
73 render_command_palette_overlay(frame, state, theme, palette, frame.area());
74 }
75}
76
77fn render_simulator_list(frame: &mut Frame, state: &AppState, theme: &Theme, area: Rect) {
78 let visible = state.visible_simulators();
79 if visible.is_empty() {
80 let message = if state.simulators.is_empty() { "(no simulators)" } else { "(no matches)" };
81 frame.render_widget(Paragraph::new(message).style(theme.hint()), area);
82 return;
83 }
84
85 let mut items = Vec::with_capacity(visible.len() + 4);
86 let mut selected_row = None;
87 let mut current_runtime = None;
88 for (i, sim) in visible.iter().enumerate() {
89 if current_runtime != Some(&sim.runtime) {
90 items.push(ListItem::new(Line::styled(sim.runtime.display_name(), theme.accent())));
91 current_runtime = Some(&sim.runtime);
92 }
93 if i as i64 == state.selected_index {
94 selected_row = Some(items.len());
95 }
96 let pending = state.pending_operations.get(&sim.id);
97 let status = pending.map(|op| format!(" ({op:?})")).unwrap_or_default();
98 let (dot, dot_style) = match sim.state.raw_value() {
99 "Booted" => ("●", theme.success()),
100 _ => ("○", theme.hint()),
101 };
102 let line = Line::from(vec![
103 Span::styled(format!(" {dot} "), dot_style),
104 Span::styled(format!("{}{status} [{}]", sim.name, sim.state.raw_value()), theme.base()),
105 ]);
106 items.push(ListItem::new(line));
107 }
108
109 let mut list_state = ListState::default();
110 list_state.select(selected_row);
111 let list = List::new(items).highlight_style(theme.bar());
112 frame.render_stateful_widget(list, area, &mut list_state);
113}
114
115fn render_status_bar(frame: &mut Frame, state: &AppState, theme: &Theme, area: Rect) {
116 let bar = theme.bar();
117 let (text, style) = if let Some(err) = &state.last_error {
118 (err.clone(), bar.fg(theme.red))
119 } else if let Some(msg) = &state.status_message {
120 (msg.clone(), bar.fg(theme.yellow))
121 } else if let Some(sim) = state.selected_simulator() {
122 (format!("{} — {}", sim.name, sim.id), bar)
123 } else {
124 (String::new(), bar)
125 };
126 frame.render_widget(Paragraph::new(text).style(style), area);
127}
128
129fn render_help(frame: &mut Frame, theme: &Theme) {
132 let key_width = HELP_ENTRIES.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
133 let mut lines = vec![Line::styled("Keybindings", theme.header()), Line::from("")];
134 for (key, description) in HELP_ENTRIES {
135 lines.push(Line::styled(format!(" {key:key_width$} {description}"), theme.base()));
136 }
137 lines.push(Line::from(""));
138 lines.push(Line::styled("Press any key to close", theme.hint()));
139 frame.render_widget(Paragraph::new(lines), frame.area());
140}
141
142fn render_inspector(frame: &mut Frame, state: &AppState, theme: &Theme, id: uuid::Uuid) {
145 let Some(sim) = state.simulators.iter().find(|s| s.id == id) else {
146 frame.render_widget(
147 Paragraph::new("Simulator no longer available. Press any key to close.").style(theme.hint()),
148 frame.area(),
149 );
150 return;
151 };
152 let rows = [
153 ("Name", sim.name.clone()),
154 ("UDID", sim.id.to_string().to_uppercase()),
155 ("Runtime", sim.runtime.display_name()),
156 ("Device type", sim.device_type.name.clone()),
157 ("State", sim.state.raw_value().to_string()),
158 ("Available", sim.is_available.to_string()),
159 ("Data path", sim.data_path.as_ref().map(|p| p.display().to_string()).unwrap_or_default()),
160 ("Log path", sim.log_path.as_ref().map(|p| p.display().to_string()).unwrap_or_default()),
161 ];
162 let label_width = rows.iter().map(|(l, _)| l.len()).max().unwrap_or(0);
163 let mut lines = vec![Line::styled("Inspector", theme.header()), Line::from("")];
164 for (label, value) in rows {
165 lines.push(Line::styled(format!(" {label:label_width$} {value}"), theme.base()));
166 }
167 lines.push(Line::from(""));
168 lines.push(Line::styled("Press any key to close", theme.hint()));
169 frame.render_widget(Paragraph::new(lines), frame.area());
170}
171
172fn render_open_url(frame: &mut Frame, theme: &Theme, prompt: &OpenUrlPrompt) {
175 let mut lines = vec![Line::styled("Open URL", theme.header()), Line::from("")];
176 lines.push(Line::styled(format!(" {}_", prompt.url), theme.base()));
177 lines.push(Line::from(""));
178 if let Some(error) = &prompt.error {
179 lines.push(Line::styled(format!(" ⚠ {error}"), theme.error()));
180 } else if prompt.is_submitting {
181 lines.push(Line::styled(" Opening…", theme.warning()));
182 }
183 lines.push(Line::from(""));
184 lines.push(Line::styled("↑/↓ history · Enter open · Esc cancel", theme.hint()));
185 frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), frame.area());
186}
187
188fn render_create_wizard(frame: &mut Frame, theme: &Theme, wizard: &CreateWizard) {
191 let areas = Layout::vertical([Constraint::Length(1), Constraint::Length(1), Constraint::Min(1), Constraint::Length(1)])
192 .split(frame.area());
193 frame.render_widget(Paragraph::new(Line::styled(breadcrumb(wizard.step), theme.bar())), areas[0]);
194
195 match wizard.step {
196 CreateWizardStep::Loading | CreateWizardStep::Submitting => {
197 frame.render_widget(Paragraph::new("Loading…").style(theme.hint()), areas[2]);
198 }
199 CreateWizardStep::PickDeviceType => {
200 if wizard.is_device_type_filter_focused || !wizard.device_type_filter.is_empty() {
201 frame.render_widget(
202 Paragraph::new(format!("Filter: {}_", wizard.device_type_filter)).style(theme.base()),
203 areas[1],
204 );
205 }
206 let visible = wizard.visible_device_types();
207 let items: Vec<ListItem> =
208 visible.iter().map(|d| ListItem::new(Span::styled(d.name.clone(), theme.base()))).collect();
209 let mut list_state = ListState::default();
210 list_state.select(usize::try_from(wizard.device_type_index).ok());
211 let list = List::new(items).highlight_style(theme.bar());
212 frame.render_stateful_widget(list, areas[2], &mut list_state);
213 }
214 CreateWizardStep::PickRuntime => {
215 let items: Vec<ListItem> =
216 wizard.runtimes.iter().map(|r| ListItem::new(Span::styled(r.display_name(), theme.base()))).collect();
217 let mut list_state = ListState::default();
218 list_state.select(usize::try_from(wizard.runtime_index).ok());
219 let list = List::new(items).highlight_style(theme.bar());
220 frame.render_stateful_widget(list, areas[2], &mut list_state);
221 }
222 CreateWizardStep::Confirm => {
223 let mut lines = vec![Line::styled(format!("Name: {}", wizard.default_name()), theme.base())];
224 if let Some(d) = wizard.selected_device_type() {
225 lines.push(Line::styled(format!("Device: {}", d.name), theme.base()));
226 }
227 if let Some(r) = wizard.selected_runtime() {
228 lines.push(Line::styled(format!("Runtime: {}", r.display_name()), theme.base()));
229 }
230 if let Some(error) = &wizard.error {
231 lines.push(Line::from(""));
232 lines.push(Line::styled(format!("⚠ {error}"), theme.error()));
233 }
234 lines.push(Line::from(""));
235 lines.push(Line::styled("Enter/y confirm · b back", theme.hint()));
236 frame.render_widget(Paragraph::new(lines), areas[2]);
237 }
238 }
239
240 render_footer(frame, areas[3], wizard_footer_hint(wizard.step), theme);
241}
242
243fn breadcrumb(step: CreateWizardStep) -> &'static str {
244 match step {
245 CreateWizardStep::Loading => " New simulator — loading targets…",
246 CreateWizardStep::PickDeviceType => " New simulator — device type",
247 CreateWizardStep::PickRuntime => " New simulator — runtime",
248 CreateWizardStep::Confirm => " New simulator — confirm",
249 CreateWizardStep::Submitting => " New simulator — creating…",
250 }
251}
252
253fn wizard_footer_hint(step: CreateWizardStep) -> &'static str {
254 match step {
255 CreateWizardStep::PickDeviceType => "↑/↓ select · / filter · Enter next · Esc cancel",
256 CreateWizardStep::PickRuntime => "↑/↓ select · Enter next · b back · Esc cancel",
257 CreateWizardStep::Confirm => "Enter/y confirm · b back · Esc cancel",
258 _ => "Esc cancel",
259 }
260}
261
262fn render_privacy_wizard(frame: &mut Frame, theme: &Theme, wizard: &PrivacyWizard) {
265 let areas = Layout::vertical([Constraint::Length(1), Constraint::Min(1), Constraint::Length(1)]).split(frame.area());
266 frame.render_widget(Paragraph::new(Line::styled(privacy_breadcrumb(wizard.step), theme.bar())), areas[0]);
267
268 match wizard.step {
269 PrivacyWizardStep::LoadingApps | PrivacyWizardStep::Submitting => {
270 frame.render_widget(Paragraph::new("Loading…").style(theme.hint()), areas[1]);
271 }
272 PrivacyWizardStep::PickApp => {
273 let apps = wizard.apps();
274 if apps.is_empty() {
275 frame.render_widget(Paragraph::new("(no apps)").style(theme.hint()), areas[1]);
276 } else {
277 let items: Vec<ListItem> = apps
278 .iter()
279 .map(|a| ListItem::new(Span::styled(format!("{} ({})", a.name, a.bundle_id), theme.base())))
280 .collect();
281 let mut list_state = ListState::default();
282 list_state.select(usize::try_from(wizard.app_index).ok());
283 let list = List::new(items).highlight_style(theme.bar());
284 frame.render_stateful_widget(list, areas[1], &mut list_state);
285 }
286 }
287 PrivacyWizardStep::PickPermission => {
288 let items: Vec<ListItem> = holodeck_core::models::PrivacyPermission::ALL
289 .iter()
290 .map(|p| ListItem::new(Span::styled(p.raw_value(), theme.base())))
291 .collect();
292 let mut list_state = ListState::default();
293 list_state.select(usize::try_from(wizard.permission_index).ok());
294 let list = List::new(items).highlight_style(theme.bar());
295 frame.render_stateful_widget(list, areas[1], &mut list_state);
296 }
297 PrivacyWizardStep::PickAction => {
298 let items: Vec<ListItem> = holodeck_core::models::PrivacyAction::ALL
299 .iter()
300 .map(|a| ListItem::new(Span::styled(a.raw_value(), theme.base())))
301 .collect();
302 let mut list_state = ListState::default();
303 list_state.select(usize::try_from(wizard.action_index).ok());
304 let list = List::new(items).highlight_style(theme.bar());
305 frame.render_stateful_widget(list, areas[1], &mut list_state);
306 }
307 }
308
309 render_footer(frame, areas[2], privacy_footer_hint(wizard.step), theme);
310}
311
312fn privacy_breadcrumb(step: PrivacyWizardStep) -> &'static str {
313 match step {
314 PrivacyWizardStep::LoadingApps => " Privacy — loading apps…",
315 PrivacyWizardStep::PickApp => " Privacy — pick an app",
316 PrivacyWizardStep::PickPermission => " Privacy — pick a permission",
317 PrivacyWizardStep::PickAction => " Privacy — pick an action",
318 PrivacyWizardStep::Submitting => " Privacy — applying…",
319 }
320}
321
322fn privacy_footer_hint(step: PrivacyWizardStep) -> &'static str {
323 match step {
324 PrivacyWizardStep::PickApp => "↑/↓ select · s toggle system apps · Enter next · Esc cancel",
325 PrivacyWizardStep::PickPermission => "↑/↓ select · Enter next · b back · Esc cancel",
326 PrivacyWizardStep::PickAction => "↑/↓ select · Enter apply · b back · Esc cancel",
327 _ => "Esc cancel",
328 }
329}
330
331fn render_footer(frame: &mut Frame, area: Rect, hint: &str, theme: &Theme) {
332 frame.render_widget(Paragraph::new(hint).style(theme.hint()), area);
333}
334
335fn render_command_palette_overlay(frame: &mut Frame, state: &AppState, theme: &Theme, palette: &CommandPalette, area: Rect) {
338 let box_width = area.width.saturating_sub(4).clamp(24, 60);
339 let box_height = 5u16.min(area.height);
340 let x = area.x + (area.width.saturating_sub(box_width)) / 2;
341 let y = area.y + (area.height.saturating_sub(box_height)) / 2;
342 let popup = Rect { x, y, width: box_width, height: box_height };
343
344 frame.render_widget(Clear, popup);
345 let block =
346 Block::default().borders(Borders::ALL).border_style(theme.accent()).title(" Command palette ").style(theme.base());
347 let inner = block.inner(popup);
348 frame.render_widget(block, popup);
349
350 let matched = crate::state::PaletteCommand::all()
351 .into_iter()
352 .find(|c| c.is_applicable(state.selected_simulator(), state.is_recording()) && c.matches(&palette.query));
353 let ghost = matched
354 .map(|c| c.display_name())
355 .filter(|name| name.len() > palette.query.len())
356 .map(|name| name[palette.query.chars().count()..].to_string())
357 .unwrap_or_default();
358
359 let mut lines =
360 vec![Line::from(vec![Span::styled(format!("> {}", palette.query), theme.base()), Span::styled(ghost, theme.hint())])];
361 if let Some(command) = matched {
362 lines.push(Line::from(""));
363 lines.push(Line::styled(command.description(), theme.hint()));
364 }
365 frame.render_widget(Paragraph::new(lines), inner);
366}