1use crate::app::{App, Panel, ThemeMode};
2use crate::shape::{Shape, Status};
3use ratatui::{
4 layout::{Alignment, Constraint, Direction, Layout, Rect},
5 style::{Color, Modifier, Style},
6 text::{Line, Span},
7 widgets::{
8 Block, BorderType, Borders, Cell, Clear, List, ListItem, ListState, Padding, Paragraph,
9 Row, Table, Wrap,
10 },
11 Frame,
12};
13use std::time::Duration;
14
15struct Palette {
16 text: Color,
17 dim: Color,
18 border: Color,
19 warn: Color,
20 ok: Color,
21 err: Color,
22 key: Color,
23 brand: Color,
24 clusters: Color,
25 jobs: Color,
26 pipelines: Color,
27 warehouses: Color,
28 catalog: Color,
29 grad_from: (u8, u8, u8),
31 grad_to: (u8, u8, u8),
32}
33
34const fn rgb(hex: u32) -> Color {
35 Color::Rgb((hex >> 16) as u8, (hex >> 8) as u8, hex as u8)
36}
37
38const fn rgb3(hex: u32) -> (u8, u8, u8) {
39 ((hex >> 16) as u8, (hex >> 8) as u8, hex as u8)
40}
41
42fn palette(mode: ThemeMode) -> Palette {
43 match mode {
44 ThemeMode::Dark => Palette {
46 text: Color::White,
47 dim: Color::DarkGray,
48 border: Color::DarkGray,
49 warn: Color::Yellow,
50 ok: Color::Green,
51 err: Color::Red,
52 key: Color::Cyan,
53 brand: Color::Red,
54 clusters: Color::Cyan,
55 jobs: Color::Magenta,
56 pipelines: Color::Green,
57 warehouses: Color::Blue,
58 catalog: rgb(0xFF8C42),
59 grad_from: rgb3(0xFF3621),
60 grad_to: rgb3(0xFFA046),
61 },
62 ThemeMode::Light => Palette {
64 text: Color::Black,
65 dim: rgb(0x6B7280),
66 border: rgb(0x9CA3AF),
67 warn: rgb(0xB45309),
68 ok: rgb(0x15803D),
69 err: rgb(0xB91C1C),
70 key: rgb(0x0891B2),
71 brand: rgb(0xDC2626),
72 clusters: rgb(0x0891B2),
73 jobs: rgb(0xA21CAF),
74 pipelines: rgb(0x15803D),
75 warehouses: rgb(0x1D4ED8),
76 catalog: rgb(0xC2410C),
77 grad_from: rgb3(0xB91C1C),
78 grad_to: rgb3(0xC2410C),
79 },
80 ThemeMode::CatppuccinMocha => Palette {
81 text: rgb(0xCDD6F4),
82 dim: rgb(0x6C7086),
83 border: rgb(0x585B70),
84 warn: rgb(0xF9E2AF),
85 ok: rgb(0xA6E3A1),
86 err: rgb(0xF38BA8),
87 key: rgb(0x89DCEB),
88 brand: rgb(0xF38BA8),
89 clusters: rgb(0x89DCEB),
90 jobs: rgb(0xCBA6F7),
91 pipelines: rgb(0xA6E3A1),
92 warehouses: rgb(0x89B4FA),
93 catalog: rgb(0xFAB387),
94 grad_from: rgb3(0xF38BA8),
95 grad_to: rgb3(0xFAB387),
96 },
97 ThemeMode::CatppuccinLatte => Palette {
98 text: rgb(0x4C4F69),
99 dim: rgb(0x8C8FA1),
100 border: rgb(0xACB0BE),
101 warn: rgb(0xDF8E1D),
102 ok: rgb(0x40A02B),
103 err: rgb(0xD20F39),
104 key: rgb(0x04A5E5),
105 brand: rgb(0xD20F39),
106 clusters: rgb(0x04A5E5),
107 jobs: rgb(0x8839EF),
108 pipelines: rgb(0x40A02B),
109 warehouses: rgb(0x1E66F5),
110 catalog: rgb(0xFE640B),
111 grad_from: rgb3(0xD20F39),
112 grad_to: rgb3(0xFE640B),
113 },
114 ThemeMode::GruvboxDark => Palette {
115 text: rgb(0xEBDBB2),
116 dim: rgb(0x928374),
117 border: rgb(0x665C54),
118 warn: rgb(0xFABD2F),
119 ok: rgb(0xB8BB26),
120 err: rgb(0xFB4934),
121 key: rgb(0x8EC07C),
122 brand: rgb(0xFB4934),
123 clusters: rgb(0x8EC07C),
124 jobs: rgb(0xD3869B),
125 pipelines: rgb(0xB8BB26),
126 warehouses: rgb(0x83A598),
127 catalog: rgb(0xFE8019),
128 grad_from: rgb3(0xFB4934),
129 grad_to: rgb3(0xFE8019),
130 },
131 ThemeMode::Dracula => Palette {
132 text: rgb(0xF8F8F2),
133 dim: rgb(0x6272A4),
134 border: rgb(0x44475A),
135 warn: rgb(0xF1FA8C),
136 ok: rgb(0x50FA7B),
137 err: rgb(0xFF5555),
138 key: rgb(0x8BE9FD),
139 brand: rgb(0xFF5555),
140 clusters: rgb(0x8BE9FD),
141 jobs: rgb(0xFF79C6),
142 pipelines: rgb(0x50FA7B),
143 warehouses: rgb(0xBD93F9),
144 catalog: rgb(0xFFB86C),
145 grad_from: rgb3(0xFF5555),
146 grad_to: rgb3(0xFFB86C),
147 },
148 ThemeMode::Nord => Palette {
149 text: rgb(0xD8DEE9),
150 dim: rgb(0x4C566A),
151 border: rgb(0x434C5E),
152 warn: rgb(0xEBCB8B),
153 ok: rgb(0xA3BE8C),
154 err: rgb(0xBF616A),
155 key: rgb(0x88C0D0),
156 brand: rgb(0xBF616A),
157 clusters: rgb(0x88C0D0),
158 jobs: rgb(0xB48EAD),
159 pipelines: rgb(0xA3BE8C),
160 warehouses: rgb(0x81A1C1),
161 catalog: rgb(0xD08770),
162 grad_from: rgb3(0xBF616A),
163 grad_to: rgb3(0xD08770),
164 },
165 ThemeMode::TokyoNight => Palette {
166 text: rgb(0xC0CAF5),
167 dim: rgb(0x565F89),
168 border: rgb(0x3B4261),
169 warn: rgb(0xE0AF68),
170 ok: rgb(0x9ECE6A),
171 err: rgb(0xF7768E),
172 key: rgb(0x7DCFFF),
173 brand: rgb(0xF7768E),
174 clusters: rgb(0x7DCFFF),
175 jobs: rgb(0xBB9AF7),
176 pipelines: rgb(0x9ECE6A),
177 warehouses: rgb(0x7AA2F7),
178 catalog: rgb(0xFF9E64),
179 grad_from: rgb3(0xF7768E),
180 grad_to: rgb3(0xFF9E64),
181 },
182 }
183}
184
185fn accent(panel: Panel, p: &Palette) -> Color {
186 match panel {
187 Panel::Clusters => p.clusters,
188 Panel::Jobs => p.jobs,
189 Panel::Pipelines => p.pipelines,
190 Panel::Warehouses => p.warehouses,
191 Panel::Dashboards => p.key,
192 Panel::Catalog => p.catalog,
193 Panel::Secrets => p.warn,
194 }
195}
196
197fn pane_breadcrumb(app: &App, panel: Panel) -> String {
198 match panel {
199 Panel::Catalog => app.uc_path.join("."),
200 Panel::Secrets => app.secret_scope.clone().unwrap_or_default(),
201 _ => String::new(),
202 }
203}
204
205pub fn draw(f: &mut Frame, app: &App) {
206 let p = palette(app.theme);
207
208 if app.splash_active() {
209 draw_splash(f, f.area(), app, &p);
210 return;
211 }
212
213 let root = Layout::default()
214 .direction(Direction::Vertical)
215 .constraints([
216 Constraint::Length(3),
217 Constraint::Min(0),
218 Constraint::Length(1),
219 ])
220 .split(f.area());
221
222 draw_header(f, root[0], app, &p);
223 draw_footer(f, root[2], app, &p);
224
225 if app.sql.is_some() {
226 draw_sql(f, root[1], app, &p);
227 if app.wh_picker.is_some() {
229 draw_wh_picker(f, root[1], app, &p);
230 }
231 return;
232 }
233
234 if app.cost.is_some() {
235 draw_cost(f, root[1], app, &p);
236 return;
237 }
238
239 if app.preview.is_some() {
240 draw_preview(f, root[1], app, &p);
241 return;
242 }
243
244 if app.run_view.is_some() {
245 draw_run(f, root[1], app, &p);
246 return;
247 }
248
249 if app.detail.is_some() {
250 draw_detail(f, root[1], app, &p);
251 return;
252 }
253
254 if app.zoomed {
255 let idx = Panel::ALL
256 .iter()
257 .position(|pn| pn == &app.focus)
258 .unwrap_or(0);
259 draw_panel(
260 f,
261 root[1],
262 app.focus,
263 app.shapes[idx].as_ref(),
264 true,
265 Some(app.selection(idx)),
266 false,
267 &pane_breadcrumb(app, app.focus),
268 app.spinner(),
269 &app.filters[idx],
270 app.filter_entry,
271 &p,
272 );
273 if app.picker.is_some() {
275 draw_picker(f, root[1], app, &p);
276 }
277 if app.wh_picker.is_some() {
278 draw_wh_picker(f, root[1], app, &p);
279 }
280 if app.problems.is_some() {
281 draw_problems(f, root[1], app, &p);
282 }
283 if app.jump.is_some() {
284 draw_jump(f, root[1], app, &p);
285 }
286 if app.pane_cfg.is_some() {
287 draw_pane_cfg(f, root[1], app, &p);
288 }
289 if app.secret_form.is_some() {
290 draw_secret_form(f, root[1], app, &p);
291 }
292 if app.help {
293 draw_help(f, root[1], app, &p);
294 }
295 return;
296 }
297
298 let visible = app.visible_panes();
301 if visible.is_empty() {
302 let par = Paragraph::new("all panes hidden — press H to bring them back")
303 .style(Style::default().fg(p.dim))
304 .alignment(Alignment::Center);
305 f.render_widget(par, root[1]);
306 } else {
307 let rows_of = |n: usize| -> Vec<Constraint> {
308 (0..n).map(|_| Constraint::Ratio(1, n as u32)).collect()
309 };
310 let left_count = visible.len().div_ceil(2);
311 let right_count = visible.len() - left_count;
312 let mut areas: Vec<Rect> = Vec::new();
313 if right_count == 0 {
314 areas.extend(
315 Layout::default()
316 .direction(Direction::Vertical)
317 .constraints(rows_of(left_count))
318 .split(root[1])
319 .iter(),
320 );
321 } else {
322 let body = Layout::default()
323 .direction(Direction::Horizontal)
324 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
325 .split(root[1]);
326 areas.extend(
327 Layout::default()
328 .direction(Direction::Vertical)
329 .constraints(rows_of(left_count))
330 .split(body[0])
331 .iter(),
332 );
333 areas.extend(
334 Layout::default()
335 .direction(Direction::Vertical)
336 .constraints(rows_of(right_count))
337 .split(body[1])
338 .iter(),
339 );
340 }
341
342 for (slot, &i) in visible.iter().enumerate() {
343 let panel = Panel::ALL[i];
344 let focused = app.focus == panel;
345 let shape = app.shapes[i].as_ref();
346 let selected = focused.then(|| app.selection(i));
347 let fresh = app.updated_at[i]
348 .map(|t| t.elapsed() < Duration::from_millis(1200))
349 .unwrap_or(false);
350 draw_panel(
351 f,
352 areas[slot],
353 panel,
354 shape,
355 focused,
356 selected,
357 fresh,
358 &pane_breadcrumb(app, panel),
359 app.spinner(),
360 &app.filters[i],
361 app.filter_entry && focused,
362 &p,
363 );
364 }
365 }
366
367 if app.picker.is_some() {
368 draw_picker(f, root[1], app, &p);
369 }
370 if app.wh_picker.is_some() {
371 draw_wh_picker(f, root[1], app, &p);
372 }
373 if app.problems.is_some() {
374 draw_problems(f, root[1], app, &p);
375 }
376 if app.jump.is_some() {
377 draw_jump(f, root[1], app, &p);
378 }
379 if app.pane_cfg.is_some() {
380 draw_pane_cfg(f, root[1], app, &p);
381 }
382 if app.secret_form.is_some() {
383 draw_secret_form(f, root[1], app, &p);
384 }
385 if app.help {
386 draw_help(f, root[1], app, &p);
387 }
388}
389
390fn draw_secret_form(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
391 let Some(form) = &app.secret_form else {
392 return;
393 };
394 let width = 60.min(area.width.saturating_sub(4));
395 let height = 6.min(area.height);
396 let popup = Rect {
397 x: area.x + (area.width.saturating_sub(width)) / 2,
398 y: area.y + (area.height.saturating_sub(height)) / 2,
399 width,
400 height,
401 };
402 f.render_widget(Clear, popup);
403 let title = match &form.scope {
404 None => "New secret scope ".to_string(),
405 Some(s) => format!("New secret in {s} "),
406 };
407 let block = Block::default()
408 .title(Line::from(vec![
409 Span::styled(" ◈ ", Style::default().fg(p.warn)),
410 Span::styled(
411 title,
412 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
413 ),
414 ]))
415 .borders(Borders::ALL)
416 .border_type(BorderType::Thick)
417 .border_style(Style::default().fg(p.warn).add_modifier(Modifier::BOLD))
418 .padding(Padding::new(1, 1, 1, 0));
419 let label0 = if form.scope.is_none() {
420 "name "
421 } else {
422 "key "
423 };
424 let mut lines = vec![Line::from(vec![
425 Span::styled(label0, Style::default().fg(p.dim)),
426 Span::styled(form.key.as_str(), Style::default().fg(p.text)),
427 if form.stage == 0 {
428 Span::styled("▏", Style::default().fg(p.warn))
429 } else {
430 Span::raw("")
431 },
432 ])];
433 if form.scope.is_some() {
434 lines.push(Line::from(vec![
436 Span::styled("value ", Style::default().fg(p.dim)),
437 Span::styled(
438 "•".repeat(form.value.chars().count()),
439 Style::default().fg(p.text),
440 ),
441 if form.stage == 1 {
442 Span::styled("▏", Style::default().fg(p.warn))
443 } else {
444 Span::raw("")
445 },
446 ]));
447 }
448 f.render_widget(Paragraph::new(lines).block(block), popup);
449}
450
451fn draw_pane_cfg(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
452 let Some(selected) = app.pane_cfg else {
453 return;
454 };
455 let width = 52.min(area.width.saturating_sub(4));
456 let height = 11.min(area.height);
457 let popup = Rect {
458 x: area.x + (area.width.saturating_sub(width)) / 2,
459 y: area.y + (area.height.saturating_sub(height)) / 2,
460 width,
461 height,
462 };
463 f.render_widget(Clear, popup);
464 let block = Block::default()
465 .title(Line::from(vec![
466 Span::styled(" ▦ ", Style::default().fg(p.key)),
467 Span::styled(
468 "Panes ",
469 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
470 ),
471 ]))
472 .borders(Borders::ALL)
473 .border_type(BorderType::Thick)
474 .border_style(Style::default().fg(p.key).add_modifier(Modifier::BOLD))
475 .padding(Padding::new(1, 1, 1, 0));
476 let items: Vec<ListItem> = app
477 .pane_order
478 .iter()
479 .map(|&i| {
480 let panel = Panel::ALL[i];
481 let (mark, style) = if app.hidden[i] {
482 ("○ ", Style::default().fg(p.dim))
483 } else {
484 ("● ", Style::default().fg(p.ok))
485 };
486 ListItem::new(Line::from(vec![
487 Span::styled(mark, style),
488 Span::styled(
489 format!("{} ", panel.icon()),
490 Style::default().fg(accent(panel, p)),
491 ),
492 Span::styled(
493 panel.title(),
494 if app.hidden[i] {
495 Style::default().fg(p.dim)
496 } else {
497 Style::default().fg(p.text)
498 },
499 ),
500 ]))
501 })
502 .collect();
503 let list = List::new(items)
504 .block(block)
505 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
506 let mut state = ListState::default().with_selected(Some(selected));
507 f.render_stateful_widget(list, popup, &mut state);
508}
509
510fn draw_help(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
511 let width = 74.min(area.width.saturating_sub(4));
512 let height = area.height.saturating_sub(2);
513 let popup = Rect {
514 x: area.x + (area.width.saturating_sub(width)) / 2,
515 y: area.y + 1,
516 width,
517 height,
518 };
519 f.render_widget(Clear, popup);
520 let block = Block::default()
521 .title(Line::from(vec![
522 Span::styled(" ? ", Style::default().fg(p.key)),
523 Span::styled(
524 "Keys ",
525 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
526 ),
527 ]))
528 .borders(Borders::ALL)
529 .border_type(BorderType::Thick)
530 .border_style(Style::default().fg(p.key).add_modifier(Modifier::BOLD))
531 .padding(Padding::new(1, 1, 1, 0));
532
533 let sections: &[(&str, &[(&str, &str)])] = &[
534 (
535 "Global",
536 &[
537 ("tab / l, shift+tab / h", "focus next / previous pane"),
538 ("j / k", "select item"),
539 ("enter", "open details / drill down"),
540 ("/", "filter the focused pane (esc clears)"),
541 ("ctrl+p", "command palette: fuzzy-jump anywhere"),
542 ("!", "problems: everything failing, enter jumps"),
543 (":", "SQL console (prefilled on a catalog table)"),
544 ("$", "cost view: DBUs, dollars, top spenders"),
545 ("H", "arrange panes: hide and reorder"),
546 ("z", "zoom the focused pane"),
547 ("w", "switch workspace profile"),
548 ("t", "cycle color theme"),
549 ("r", "refresh now"),
550 ("?", "this help"),
551 ("q / ctrl+c", "quit"),
552 ],
553 ),
554 (
555 "Focused pane",
556 &[
557 ("s", "start / stop / run the selected item"),
558 ("g", "access: grants and permissions"),
559 ("o", "open in the workspace web UI"),
560 ],
561 ),
562 (
563 "Unity Catalog pane",
564 &[
565 ("enter / backspace", "drill down / up (into volumes too)"),
566 ("p / P", "preview table rows (P picks the warehouse)"),
567 ("L", "lineage: upstream and downstream tables"),
568 (":", "query the selected table"),
569 ("enter on a file", "peek at its first 200 lines"),
570 ],
571 ),
572 (
573 "Details & runs",
574 &[
575 ("enter", "job/pipeline detail → latest run/update"),
576 ("h / l", "older / newer run"),
577 ("s", "cancel the shown run"),
578 ("j / k, J", "scroll · toggle raw JSON"),
579 ("e", "in previews: export rows to CSV"),
580 ],
581 ),
582 (
583 "Secret scopes pane",
584 &[
585 ("enter / backspace", "open a scope / back to scopes"),
586 ("a", "create a scope / add a secret (value masked)"),
587 ("x", "delete the selected scope or secret"),
588 ("g", "scope ACLs"),
589 ],
590 ),
591 (
592 "SQL console",
593 &[
594 ("enter", "run the statement"),
595 ("↑ / ↓, ctrl+r", "history · incremental search"),
596 ("ctrl+x", "compose in $EDITOR"),
597 ("ctrl+s", "export results to CSV"),
598 ("pgup / pgdn", "scroll results"),
599 ("esc", "cancel a running query, else close"),
600 ],
601 ),
602 ];
603 let mut lines: Vec<Line> = Vec::new();
604 for (title, keys) in sections {
605 lines.push(Line::from(Span::styled(
606 *title,
607 Style::default().fg(p.key).add_modifier(Modifier::BOLD),
608 )));
609 for (k, desc) in *keys {
610 lines.push(Line::from(vec![
611 Span::styled(format!(" {k:<24}"), Style::default().fg(p.warn)),
612 Span::styled(*desc, Style::default().fg(p.text)),
613 ]));
614 }
615 lines.push(Line::default());
616 }
617 let par = Paragraph::new(lines)
618 .scroll((app.help_scroll, 0))
619 .block(block);
620 f.render_widget(par, popup);
621}
622
623fn draw_jump(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
624 let Some(jump) = &app.jump else {
625 return;
626 };
627 let matches = app.jump_matches();
628 let width = 70.min(area.width.saturating_sub(4));
629 let height = (matches.len() as u16 + 5).min(area.height);
630 let popup = Rect {
631 x: area.x + (area.width.saturating_sub(width)) / 2,
632 y: area.y + 2.min(area.height.saturating_sub(height)),
633 width,
634 height,
635 };
636 f.render_widget(Clear, popup);
637 let block = Block::default()
638 .title(Line::from(vec![
639 Span::styled(" ⌕ ", Style::default().fg(p.key)),
640 Span::styled(
641 "Jump to ",
642 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
643 ),
644 ]))
645 .borders(Borders::ALL)
646 .border_type(BorderType::Thick)
647 .border_style(Style::default().fg(p.key).add_modifier(Modifier::BOLD))
648 .padding(Padding::horizontal(1));
649 let inner = block.inner(popup);
650 f.render_widget(block, popup);
651
652 let parts = Layout::default()
653 .direction(Direction::Vertical)
654 .constraints([Constraint::Length(2), Constraint::Min(0)])
655 .split(inner);
656 f.render_widget(
657 Paragraph::new(Line::from(vec![
658 Span::styled("❯ ", Style::default().fg(p.key)),
659 Span::styled(jump.query.as_str(), Style::default().fg(p.text)),
660 Span::styled("▏", Style::default().fg(p.warn)),
661 ])),
662 parts[0],
663 );
664
665 if matches.is_empty() {
666 f.render_widget(
667 Paragraph::new("∅ nothing matches").style(Style::default().fg(p.dim)),
668 parts[1],
669 );
670 return;
671 }
672 let items: Vec<ListItem> = matches
673 .iter()
674 .map(|(panel_idx, name, label)| {
675 let panel = Panel::ALL[*panel_idx];
676 ListItem::new(Line::from(vec![
677 Span::styled(
678 format!("{} ", panel.icon()),
679 Style::default().fg(accent(panel, p)),
680 ),
681 Span::styled(format!("{name} "), Style::default().fg(p.text)),
682 Span::styled(label.clone(), Style::default().fg(p.dim)),
683 ]))
684 })
685 .collect();
686 let list = List::new(items).highlight_style(Style::default().add_modifier(Modifier::REVERSED));
687 let mut state = ListState::default().with_selected(Some(jump.index));
688 f.render_stateful_widget(list, parts[1], &mut state);
689}
690
691fn draw_problems(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
692 let Some(pr) = &app.problems else {
693 return;
694 };
695 let truncate = |s: &str, max: usize| -> String {
696 if s.chars().count() <= max {
697 s.to_string()
698 } else {
699 let cut: String = s.chars().take(max.saturating_sub(1)).collect();
700 format!("{cut}…")
701 }
702 };
703 let name_w = pr
706 .items
707 .iter()
708 .map(|i| i.name.chars().count())
709 .max()
710 .unwrap_or(10)
711 .clamp(10, 40);
712 let note_w = pr
713 .items
714 .iter()
715 .map(|i| i.note.chars().count())
716 .max()
717 .unwrap_or(0);
718 let width = ((name_w + note_w + 32) as u16).min(area.width.saturating_sub(4));
720 let height = (pr.items.len().max(1) as u16 + 4).min(area.height);
721 let note_space = (width as usize).saturating_sub(name_w + 30);
723 let popup = Rect {
724 x: area.x + (area.width.saturating_sub(width)) / 2,
725 y: area.y + (area.height.saturating_sub(height)) / 2,
726 width,
727 height,
728 };
729 f.render_widget(Clear, popup);
730 let block = Block::default()
731 .title(Line::from(vec![
732 Span::styled(" ✗ ", Style::default().fg(p.err)),
733 Span::styled(
734 format!("Problems · {} ", pr.items.len()),
735 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
736 ),
737 ]))
738 .borders(Borders::ALL)
739 .border_type(BorderType::Rounded)
740 .border_style(Style::default().fg(p.err).add_modifier(Modifier::BOLD))
741 .padding(Padding::new(1, 1, 1, 1));
742
743 if pr.items.is_empty() {
744 let par = Paragraph::new("✓ all clear — nothing failing right now")
745 .style(Style::default().fg(p.ok))
746 .block(block);
747 f.render_widget(par, popup);
748 return;
749 }
750
751 let items: Vec<ListItem> = pr
752 .items
753 .iter()
754 .map(|problem| {
755 let panel = Panel::ALL[problem.panel];
756 ListItem::new(Line::from(vec![
757 Span::styled("✗ ", Style::default().fg(p.err)),
758 Span::styled(
759 format!(
760 "{:<width$}",
761 truncate(&problem.name, name_w),
762 width = name_w + 2
763 ),
764 Style::default().fg(p.text),
765 ),
766 Span::styled(
767 format!("{} {:<18}", panel.icon(), panel.title()),
768 Style::default().fg(accent(panel, p)),
769 ),
770 Span::styled(
771 truncate(&problem.note, note_space),
772 Style::default().fg(p.dim),
773 ),
774 ]))
775 })
776 .collect();
777 let list = List::new(items)
778 .block(block)
779 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
780 let mut state = ListState::default().with_selected(Some(pr.index));
781 f.render_stateful_widget(list, popup, &mut state);
782}
783
784fn draw_wh_picker(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
785 let Some(picker) = &app.wh_picker else {
786 return;
787 };
788 let warehouses = app.warehouses();
789 let name_w = warehouses
790 .iter()
791 .map(|(n, _, _)| n.chars().count())
792 .max()
793 .unwrap_or(12)
794 .max(12);
795 let width = ((name_w + 37) as u16).min(area.width.saturating_sub(4));
797 let height = (warehouses.len() as u16 + 4).min(area.height);
798 let popup = Rect {
799 x: area.x + (area.width.saturating_sub(width)) / 2,
800 y: area.y + (area.height.saturating_sub(height)) / 2,
801 width,
802 height,
803 };
804 f.render_widget(Clear, popup);
805 let block = Block::default()
806 .title(Line::from(vec![
807 Span::styled(" ▣ ", Style::default().fg(p.warehouses)),
808 Span::styled(
809 "Run query on ",
810 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
811 ),
812 ]))
813 .borders(Borders::ALL)
814 .border_type(BorderType::Rounded)
815 .border_style(
816 Style::default()
817 .fg(p.warehouses)
818 .add_modifier(Modifier::BOLD),
819 )
820 .padding(Padding::new(1, 1, 1, 1));
821 let current_id = app.preview_warehouse.as_ref().map(|(id, _)| id.as_str());
822 let items: Vec<ListItem> = warehouses
823 .iter()
824 .map(|(name, id, running)| {
825 let dot = if *running { p.ok } else { p.dim };
826 let marker = if current_id == Some(id.as_str()) {
827 "» "
828 } else {
829 " "
830 };
831 let state = if *running { "running" } else { "idle " };
832 ListItem::new(Line::from(vec![
833 Span::styled(marker, Style::default().fg(p.key)),
834 Span::styled("● ", Style::default().fg(dot)),
835 Span::styled(format!("{name:<name_w$}"), Style::default().fg(p.text)),
836 Span::styled(
837 format!(" {state}"),
838 Style::default().fg(if *running { p.ok } else { p.dim }),
839 ),
840 Span::styled(format!(" {id}"), Style::default().fg(p.dim)),
841 ]))
842 })
843 .collect();
844 let list = List::new(items)
845 .block(block)
846 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
847 let mut state = ListState::default().with_selected(Some(picker.index));
848 f.render_stateful_widget(list, popup, &mut state);
849}
850
851const WORDMARK_TOP: &str = "█▀▄ ▄▀█ ▀█▀ ▄▀█ █▄▄ █▀█ █ █▀▀ █▄▀ █▀";
852const WORDMARK_BOT: &str = "█▄▀ █▀█ █ █▀█ █▄█ █▀▄ █ █▄▄ █ █ ▄█";
853
854fn draw_splash(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
855 let lines_total = 9u16;
856 let top = area.y + (area.height.saturating_sub(lines_total)) / 2;
857 let frame = app.spinner_frame();
858
859 let bricks = ["◢◤", "◢◤ ◢◤", "◢◤ ◢◤ ◢◤"];
861 for (i, row) in bricks.iter().enumerate() {
862 let line = Line::from(Span::styled(
863 *row,
864 Style::default().fg(p.brand).add_modifier(Modifier::BOLD),
865 ));
866 let rect = Rect {
867 x: area.x,
868 y: top + i as u16,
869 width: area.width,
870 height: 1,
871 };
872 f.render_widget(Paragraph::new(line).alignment(Alignment::Center), rect);
873 }
874
875 let sweep = (frame * 2) % (WORDMARK_TOP.chars().count() + 16);
877 for (row, text) in [(4u16, WORDMARK_TOP), (5u16, WORDMARK_BOT)] {
878 let spans: Vec<Span> = text
879 .chars()
880 .enumerate()
881 .map(|(i, c)| {
882 let dist = (i as i32 - sweep as i32 + 8).unsigned_abs();
883 let style = if dist < 3 {
884 Style::default()
885 .fg(Color::White)
886 .add_modifier(Modifier::BOLD)
887 } else {
888 Style::default().fg(p.brand)
889 };
890 Span::styled(c.to_string(), style)
891 })
892 .collect();
893 let rect = Rect {
894 x: area.x,
895 y: top + row,
896 width: area.width,
897 height: 1,
898 };
899 f.render_widget(
900 Paragraph::new(Line::from(spans)).alignment(Alignment::Center),
901 rect,
902 );
903 }
904
905 let tagline = Line::from(vec![
906 Span::styled("the ", Style::default().fg(p.dim)),
907 Span::styled(
908 "Lakehouse",
909 Style::default().fg(p.key).add_modifier(Modifier::BOLD),
910 ),
911 Span::styled(" in your terminal", Style::default().fg(p.dim)),
912 ]);
913 let version = Line::from(Span::styled(
914 format!("v{}", env!("CARGO_PKG_VERSION")),
915 Style::default().fg(p.dim),
916 ));
917 for (offset, line) in [(7u16, tagline), (8u16, version)] {
918 let rect = Rect {
919 x: area.x,
920 y: top + offset,
921 width: area.width,
922 height: 1,
923 };
924 f.render_widget(Paragraph::new(line).alignment(Alignment::Center), rect);
925 }
926}
927
928fn draw_picker(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
929 let selected = app.picker.unwrap_or(0);
930 let width = (app
931 .profiles
932 .iter()
933 .map(|s| s.len())
934 .max()
935 .unwrap_or(10)
936 .max(20) as u16
937 + 6)
938 .min(area.width);
939 let height = (app.profiles.len() as u16 + 2).min(area.height);
940 let popup = Rect {
941 x: area.x + (area.width.saturating_sub(width)) / 2,
942 y: area.y + (area.height.saturating_sub(height)) / 2,
943 width,
944 height,
945 };
946 f.render_widget(Clear, popup);
947 let block = Block::default()
948 .title(Line::from(Span::styled(
949 " ⌂ Workspace ",
950 Style::default().fg(p.key).add_modifier(Modifier::BOLD),
951 )))
952 .borders(Borders::ALL)
953 .border_type(BorderType::Rounded)
954 .border_style(Style::default().fg(p.key))
955 .padding(Padding::horizontal(1));
956 let items: Vec<ListItem> = app
957 .profiles
958 .iter()
959 .map(|name| {
960 let current = app.profile.as_deref() == Some(name.as_str());
961 let marker = if current { "● " } else { " " };
962 ListItem::new(Line::from(vec![
963 Span::styled(marker, Style::default().fg(p.ok)),
964 Span::styled(name.as_str(), Style::default().fg(p.text)),
965 ]))
966 })
967 .collect();
968 let list = List::new(items)
969 .block(block)
970 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
971 let mut state = ListState::default().with_selected(Some(selected));
972 f.render_stateful_widget(list, popup, &mut state);
973}
974
975fn bucket_color(bucket: &str, p: &Palette) -> Color {
976 match bucket {
977 "Jobs" => p.jobs,
978 "SQL" => p.warehouses,
979 "All-Purpose" => p.clusters,
980 "DLT" => p.pipelines,
981 _ => p.dim,
982 }
983}
984
985fn draw_cost(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
986 let cv = app.cost.as_ref().unwrap();
987 let scope = match &cv.data {
988 Some(Ok(d)) if d.scoped => " · this workspace",
989 Some(Ok(_)) => " · all workspaces",
990 _ => "",
991 };
992 let title = Line::from(vec![
993 Span::styled(" ◢◤ ", Style::default().fg(p.brand)),
994 Span::styled(
995 format!("Usage · last 14 days{scope} "),
996 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
997 ),
998 Span::styled(format!("via {} ", cv.warehouse), Style::default().fg(p.dim)),
999 ]);
1000 let block = Block::default()
1001 .title(title)
1002 .borders(Borders::ALL)
1003 .border_type(BorderType::Rounded)
1004 .border_style(Style::default().fg(p.brand).add_modifier(Modifier::BOLD))
1005 .padding(Padding::new(1, 1, 1, 1));
1006
1007 match &cv.data {
1008 None => {
1009 let par = Paragraph::new(format!(
1010 "{} querying system.billing.usage — the warehouse may need to start…",
1011 app.spinner()
1012 ))
1013 .style(Style::default().fg(p.warn))
1014 .block(block);
1015 f.render_widget(par, area);
1016 }
1017 Some(Err(e)) => {
1018 let par = Paragraph::new(format!(
1019 "✗ {e}\n\nsystem tables need to be enabled and readable \
1020 (grants on the `system` catalog)"
1021 ))
1022 .style(Style::default().fg(p.err))
1023 .wrap(Wrap { trim: false })
1024 .block(block);
1025 f.render_widget(par, area);
1026 }
1027 Some(Ok(data)) if data.days.is_empty() => {
1028 let par = Paragraph::new("∅ no usage recorded in the last 14 days")
1029 .style(Style::default().fg(p.dim))
1030 .block(block);
1031 f.render_widget(par, area);
1032 }
1033 Some(Ok(data)) => {
1034 let mut lines: Vec<Line> = Vec::new();
1035
1036 let mut legend = vec![Span::raw("")];
1038 for (bucket, total, usd) in &data.buckets {
1039 legend.push(Span::styled(
1040 "■ ",
1041 Style::default().fg(bucket_color(bucket, p)),
1042 ));
1043 let amount = if data.priced {
1044 format!("{bucket} {total:.1} (${usd:.0}) ")
1045 } else {
1046 format!("{bucket} {total:.1} ")
1047 };
1048 legend.push(Span::styled(amount, Style::default().fg(p.text)));
1049 }
1050 let sigma = if data.priced {
1051 format!("Σ {:.1} DBU ≈ ${:.2}", data.total, data.total_usd)
1052 } else {
1053 format!("Σ {:.1} DBU", data.total)
1054 };
1055 legend.push(Span::styled(
1056 sigma,
1057 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1058 ));
1059 lines.push(Line::from(legend));
1060 if data.priced {
1061 lines.push(Line::from(Span::styled(
1062 " list prices before discounts",
1063 Style::default().fg(p.dim),
1064 )));
1065 }
1066 if !data.scoped {
1067 lines.push(Line::from(Span::styled(
1068 " couldn't resolve this workspace's id — showing the whole account",
1069 Style::default().fg(p.warn),
1070 )));
1071 }
1072 lines.push(Line::default());
1073
1074 let max_day = data
1075 .days
1076 .iter()
1077 .map(|d| d.total)
1078 .fold(0.0_f64, f64::max)
1079 .max(f64::EPSILON);
1080 let inner_w = area.width.saturating_sub(4) as usize;
1081 let bar_w = inner_w.saturating_sub(22).max(10);
1082
1083 for day in &data.days {
1084 let mut spans = vec![Span::styled(
1085 format!("{:<6}", day.date.chars().skip(5).collect::<String>()),
1087 Style::default().fg(p.dim),
1088 )];
1089 for (bucket, value) in &day.by_bucket {
1090 let chars = ((value / max_day) * bar_w as f64)
1091 .round()
1092 .max(if *value > 0.0 { 1.0 } else { 0.0 })
1093 as usize;
1094 spans.push(Span::styled(
1095 "█".repeat(chars),
1096 Style::default().fg(bucket_color(bucket, p)),
1097 ));
1098 }
1099 let day_label = if data.priced {
1100 format!(" {:.1} · ${:.2}", day.total, day.total_usd)
1101 } else {
1102 format!(" {:.1}", day.total)
1103 };
1104 spans.push(Span::styled(day_label, Style::default().fg(p.text)));
1105 lines.push(Line::from(spans));
1106 }
1107
1108 if !data.spenders.is_empty() {
1109 lines.push(Line::default());
1110 lines.push(Line::from(Span::styled(
1111 "TOP SPENDERS · 14 days",
1112 Style::default().fg(p.dim).add_modifier(Modifier::BOLD),
1113 )));
1114 let name_w = data
1115 .spenders
1116 .iter()
1117 .map(|s| {
1118 app.resource_name(&s.kind, &s.id)
1119 .unwrap_or_else(|| s.id.clone())
1120 .chars()
1121 .count()
1122 })
1123 .max()
1124 .unwrap_or(0)
1125 .max(8);
1126 for (i, s) in data.spenders.iter().enumerate() {
1127 let name = app
1128 .resource_name(&s.kind, &s.id)
1129 .unwrap_or_else(|| s.id.clone());
1130 let kind_color = match s.kind.as_str() {
1131 "job" => p.jobs,
1132 "cluster" => p.clusters,
1133 "warehouse" => p.warehouses,
1134 _ => p.dim,
1135 };
1136 let amount = if data.priced {
1137 format!("{:>9} · {:.1} DBU", format!("${:.2}", s.usd), s.dbus)
1138 } else {
1139 format!("{:>8.1} DBU", s.dbus)
1140 };
1141 lines.push(Line::from(vec![
1142 Span::styled(format!("{:>3}. ", i + 1), Style::default().fg(p.dim)),
1143 Span::styled(format!("{:<10}", s.kind), Style::default().fg(kind_color)),
1144 Span::styled(
1145 format!("{:<width$}", name, width = name_w + 2),
1146 Style::default().fg(p.text),
1147 ),
1148 Span::styled(amount, Style::default().fg(p.text)),
1149 ]));
1150 }
1151 }
1152
1153 let par = Paragraph::new(lines).block(block);
1154 f.render_widget(par, area);
1155 }
1156 }
1157}
1158
1159fn draw_sql(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1160 let console = app.sql.as_ref().unwrap();
1161 let parts = Layout::default()
1162 .direction(Direction::Vertical)
1163 .constraints([Constraint::Length(3), Constraint::Min(0)])
1164 .split(area);
1165
1166 let via = if console.warehouse.is_empty() {
1167 String::new()
1168 } else {
1169 format!("via {} ", console.warehouse)
1170 };
1171 let prompt_block = Block::default()
1172 .title(Line::from(vec![
1173 Span::styled(" ⌁ ", Style::default().fg(p.warehouses)),
1174 Span::styled(
1175 "SQL Console ",
1176 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1177 ),
1178 Span::styled(via, Style::default().fg(p.dim)),
1179 ]))
1180 .borders(Borders::ALL)
1181 .border_type(BorderType::Thick)
1182 .border_style(
1183 Style::default()
1184 .fg(p.warehouses)
1185 .add_modifier(Modifier::BOLD),
1186 )
1187 .padding(Padding::horizontal(1));
1188 if let Some((query, _)) = &app.hist_search {
1189 let matched = app
1191 .hist_search_current()
1192 .map(|s| s.replace('\n', " "))
1193 .unwrap_or_default();
1194 let prompt = Paragraph::new(Line::from(vec![
1195 Span::styled("(reverse-i-search)`", Style::default().fg(p.dim)),
1196 Span::styled(
1197 query.as_str(),
1198 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1199 ),
1200 Span::styled("`: ", Style::default().fg(p.dim)),
1201 Span::styled(matched, Style::default().fg(p.text)),
1202 ]))
1203 .block(prompt_block);
1204 f.render_widget(prompt, parts[0]);
1205 } else {
1206 let caret_byte = console
1210 .input
1211 .char_indices()
1212 .nth(console.cursor)
1213 .map(|(i, _)| i)
1214 .unwrap_or(console.input.len());
1215 let (before, after) = console.input.split_at(caret_byte);
1216 let inner_w = parts[0].width.saturating_sub(4) as usize; let hscroll = (console.cursor + 3).saturating_sub(inner_w) as u16;
1218 let prompt = Paragraph::new(Line::from(vec![
1219 Span::styled("❯ ", Style::default().fg(p.key)),
1220 Span::styled(before.replace('\n', " "), Style::default().fg(p.text)),
1221 Span::styled("▏", Style::default().fg(p.warn)),
1222 Span::styled(after.replace('\n', " "), Style::default().fg(p.text)),
1223 ]))
1224 .scroll((0, hscroll))
1225 .block(prompt_block);
1226 f.render_widget(prompt, parts[0]);
1227 }
1228
1229 let row_info = match &console.data {
1230 Some(Ok(t)) => format!("{} rows ", t.rows.len()),
1231 _ => String::new(),
1232 };
1233 let results_block = Block::default()
1234 .title(Line::from(vec![
1235 Span::styled(" Results ", Style::default().fg(p.text)),
1236 Span::styled(row_info, Style::default().fg(p.dim)),
1237 ]))
1238 .borders(Borders::ALL)
1239 .border_type(BorderType::Rounded)
1240 .border_style(Style::default().fg(p.border))
1241 .padding(Padding::horizontal(1));
1242
1243 if console.running {
1244 let par = Paragraph::new(format!(
1245 "{} running on {} — the warehouse may need a moment to start…",
1246 app.spinner(),
1247 console.warehouse
1248 ))
1249 .style(Style::default().fg(p.warn))
1250 .block(results_block);
1251 f.render_widget(par, parts[1]);
1252 return;
1253 }
1254 match &console.data {
1255 None => {
1256 let par = Paragraph::new(
1257 "type a statement and press enter — e.g. SELECT * FROM main.sales.orders LIMIT 50",
1258 )
1259 .style(Style::default().fg(p.dim))
1260 .block(results_block);
1261 f.render_widget(par, parts[1]);
1262 }
1263 Some(Err(e)) => {
1264 let par = Paragraph::new(format!("✗ {e}"))
1265 .style(Style::default().fg(p.err))
1266 .wrap(Wrap { trim: false })
1267 .block(results_block);
1268 f.render_widget(par, parts[1]);
1269 }
1270 Some(Ok(data)) if data.rows.is_empty() && data.headers.is_empty() => {
1271 let par = Paragraph::new("✓ statement succeeded — no result set")
1272 .style(Style::default().fg(p.ok))
1273 .block(results_block);
1274 f.render_widget(par, parts[1]);
1275 }
1276 Some(Ok(data)) => {
1277 let header_cells: Vec<Cell> = data
1278 .headers
1279 .iter()
1280 .map(|h| {
1281 Cell::from(h.as_str()).style(
1282 Style::default()
1283 .fg(p.warehouses)
1284 .add_modifier(Modifier::BOLD),
1285 )
1286 })
1287 .collect();
1288 let header = Row::new(header_cells);
1289 let rows: Vec<Row> = data
1290 .rows
1291 .iter()
1292 .skip(console.scroll)
1293 .map(|r| {
1294 Row::new(
1295 r.iter()
1296 .map(|c| Cell::from(c.as_str()).style(Style::default().fg(p.text)))
1297 .collect::<Vec<_>>(),
1298 )
1299 })
1300 .collect();
1301 let n = data.headers.len().max(1) as u16;
1302 let widths: Vec<Constraint> = data
1303 .headers
1304 .iter()
1305 .map(|_| Constraint::Ratio(1, n as u32))
1306 .collect();
1307 let table = Table::new(rows, widths)
1308 .header(header)
1309 .column_spacing(1)
1310 .block(results_block);
1311 f.render_widget(table, parts[1]);
1312 }
1313 }
1314}
1315
1316fn draw_preview(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1317 let pv = app.preview.as_ref().unwrap();
1318 let acc = p.catalog;
1319 let row_info = match &pv.data {
1320 Some(Ok(t)) => format!(" · {} rows", t.rows.len()),
1321 _ => String::new(),
1322 };
1323 let title = Line::from(vec![
1324 Span::styled(" ◫ ", Style::default().fg(acc)),
1325 Span::styled(
1326 format!("{}{} · preview ", pv.name, row_info),
1327 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1328 ),
1329 Span::styled(format!("via {} ", pv.warehouse), Style::default().fg(p.dim)),
1330 ]);
1331 let block = Block::default()
1332 .title(title)
1333 .borders(Borders::ALL)
1334 .border_type(BorderType::Rounded)
1335 .border_style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
1336 .padding(Padding::horizontal(1));
1337
1338 match &pv.data {
1339 None => {
1340 let par = Paragraph::new(format!(
1341 "{} running SELECT * LIMIT 50 — the warehouse may need a moment to start…",
1342 app.spinner()
1343 ))
1344 .style(Style::default().fg(p.warn))
1345 .block(block);
1346 f.render_widget(par, area);
1347 }
1348 Some(Err(e)) => {
1349 let text = format!(
1350 "✗ {e}\n\nwarehouse: {} ({})\nprofile: {} · host: {}\npress esc, then P to pick a different warehouse",
1351 pv.warehouse,
1352 pv.warehouse_id,
1353 app.profile.as_deref().unwrap_or("default"),
1354 app.host.as_deref().unwrap_or("unknown"),
1355 );
1356 let par = Paragraph::new(text)
1357 .style(Style::default().fg(p.err))
1358 .wrap(Wrap { trim: false })
1359 .block(block);
1360 f.render_widget(par, area);
1361 }
1362 Some(Ok(data)) => {
1363 let header_cells: Vec<Cell> = data
1364 .headers
1365 .iter()
1366 .map(|h| {
1367 Cell::from(h.as_str())
1368 .style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
1369 })
1370 .collect();
1371 let header = Row::new(header_cells);
1372 let rows: Vec<Row> = data
1373 .rows
1374 .iter()
1375 .skip(pv.scroll)
1376 .map(|r| {
1377 Row::new(
1378 r.iter()
1379 .map(|c| Cell::from(c.as_str()).style(Style::default().fg(p.text)))
1380 .collect::<Vec<_>>(),
1381 )
1382 })
1383 .collect();
1384 let n = data.headers.len().max(1) as u16;
1385 let widths: Vec<Constraint> = data
1386 .headers
1387 .iter()
1388 .map(|_| Constraint::Ratio(1, n as u32))
1389 .collect();
1390 let table = Table::new(rows, widths)
1391 .header(header)
1392 .column_spacing(1)
1393 .block(block);
1394 f.render_widget(table, area);
1395 }
1396 }
1397}
1398
1399fn draw_detail(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1400 let d = app.detail.as_ref().unwrap();
1401 let acc = accent(d.panel, p);
1402 let title = Line::from(vec![
1403 Span::styled(format!(" {} ", d.panel.icon()), Style::default().fg(acc)),
1404 Span::styled(
1405 format!("{} · {} ", d.panel.title(), d.name),
1406 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1407 ),
1408 ]);
1409 let block = Block::default()
1410 .title(title)
1411 .borders(Borders::ALL)
1412 .border_type(BorderType::Rounded)
1413 .border_style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
1414 .padding(Padding::horizontal(1));
1415
1416 let Some(data) = &d.data else {
1417 let par = Paragraph::new(format!("{} Loading…", app.spinner()))
1418 .style(Style::default().fg(p.warn))
1419 .block(block);
1420 f.render_widget(par, area);
1421 return;
1422 };
1423
1424 if d.show_raw || data.summary.is_empty() {
1426 let par = Paragraph::new(data.raw.as_str())
1427 .style(Style::default().fg(p.text))
1428 .wrap(Wrap { trim: false })
1429 .scroll((d.scroll, 0))
1430 .block(block);
1431 f.render_widget(par, area);
1432 return;
1433 }
1434
1435 let mut lines: Vec<Line> = data
1436 .summary
1437 .iter()
1438 .map(|(k, v)| {
1439 Line::from(vec![
1440 Span::styled(format!("{:<16}", k), Style::default().fg(p.dim)),
1441 Span::styled(v.as_str(), Style::default().fg(p.text)),
1442 ])
1443 })
1444 .collect();
1445 if !data.activity.is_empty() {
1446 lines.push(Line::default());
1447 lines.push(Line::from(Span::styled(
1448 d.section,
1449 Style::default().fg(acc).add_modifier(Modifier::BOLD),
1450 )));
1451 for (status, text) in &data.activity {
1452 let treeish = text.is_empty() || text.starts_with(['├', '└', '│', '▲', '▼', ' ']);
1454 if treeish {
1455 lines.push(Line::from(Span::styled(
1456 text.as_str(),
1457 Style::default().fg(status_color(status, p)),
1458 )));
1459 } else {
1460 lines.push(Line::from(vec![
1461 Span::styled("● ", Style::default().fg(status_color(status, p))),
1462 Span::styled(text.as_str(), Style::default().fg(p.text)),
1463 ]));
1464 }
1465 }
1466 }
1467 let par = Paragraph::new(lines).scroll((d.scroll, 0)).block(block);
1468 f.render_widget(par, area);
1469}
1470
1471fn draw_run(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1472 let rv = app.run_view.as_ref().unwrap();
1473 let acc = accent(rv.panel, p);
1474 let noun = if rv.panel == Panel::Jobs {
1475 "run"
1476 } else {
1477 "update"
1478 };
1479 let mut title_spans = vec![
1480 Span::styled(format!(" {} ", rv.panel.icon()), Style::default().fg(acc)),
1481 Span::styled(
1482 format!("{} · {noun} ", rv.owner_name),
1483 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1484 ),
1485 ];
1486 if let Some((run_id, _, age)) = rv.runs.get(rv.idx) {
1487 title_spans.push(Span::styled(
1488 format!("{run_id} "),
1489 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
1490 ));
1491 title_spans.push(Span::styled(
1492 format!("· {} of {} · {} ", rv.idx + 1, rv.runs.len(), age),
1493 Style::default().fg(p.dim),
1494 ));
1495 }
1496 if rv.live {
1497 title_spans.push(Span::styled(
1498 format!("{} live ", app.spinner()),
1499 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1500 ));
1501 }
1502 let block = Block::default()
1503 .title(Line::from(title_spans))
1504 .borders(Borders::ALL)
1505 .border_type(BorderType::Rounded)
1506 .border_style(Style::default().fg(acc).add_modifier(Modifier::BOLD))
1507 .padding(Padding::horizontal(1));
1508
1509 let Some(data) = &rv.data else {
1510 let par = Paragraph::new(format!("{} Loading run…", app.spinner()))
1511 .style(Style::default().fg(p.warn))
1512 .block(block);
1513 f.render_widget(par, area);
1514 return;
1515 };
1516
1517 if rv.show_raw || data.summary.is_empty() {
1518 let par = Paragraph::new(data.raw.as_str())
1519 .style(Style::default().fg(p.text))
1520 .wrap(Wrap { trim: false })
1521 .scroll((rv.scroll, 0))
1522 .block(block);
1523 f.render_widget(par, area);
1524 return;
1525 }
1526
1527 let mut lines: Vec<Line> = data
1528 .summary
1529 .iter()
1530 .map(|(k, v)| {
1531 Line::from(vec![
1532 Span::styled(format!("{:<16}", k), Style::default().fg(p.dim)),
1533 Span::styled(v.as_str(), Style::default().fg(p.text)),
1534 ])
1535 })
1536 .collect();
1537 if !data.activity.is_empty() {
1538 lines.push(Line::default());
1539 lines.push(Line::from(Span::styled(
1540 if rv.panel == Panel::Jobs {
1541 "Tasks"
1542 } else {
1543 "Event log"
1544 },
1545 Style::default().fg(acc).add_modifier(Modifier::BOLD),
1546 )));
1547 for (status, text) in &data.activity {
1548 if text.starts_with(" ↳") {
1550 lines.push(Line::from(Span::styled(
1551 text.as_str(),
1552 Style::default().fg(p.err),
1553 )));
1554 } else {
1555 lines.push(Line::from(vec![
1556 Span::styled("● ", Style::default().fg(status_color(status, p))),
1557 Span::styled(text.as_str(), Style::default().fg(p.text)),
1558 ]));
1559 }
1560 }
1561 }
1562 let par = Paragraph::new(lines).scroll((rv.scroll, 0)).block(block);
1563 f.render_widget(par, area);
1564}
1565
1566fn draw_header(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1567 let block = Block::default()
1568 .borders(Borders::ALL)
1569 .border_type(BorderType::Rounded)
1570 .border_style(Style::default().fg(p.border));
1571 let inner = block.inner(area);
1572 f.render_widget(block, area);
1573
1574 let (from, to) = (p.grad_from, p.grad_to);
1578 let grad = |t: f32| {
1579 Color::Rgb(
1580 (from.0 as f32 + (to.0 as f32 - from.0 as f32) * t) as u8,
1581 (from.1 as f32 + (to.1 as f32 - from.1 as f32) * t) as u8,
1582 (from.2 as f32 + (to.2 as f32 - from.2 as f32) * t) as u8,
1583 )
1584 };
1585 let mut left = vec![
1586 Span::styled(
1587 " ◢",
1588 Style::default().fg(grad(0.0)).add_modifier(Modifier::BOLD),
1589 ),
1590 Span::styled(
1591 "◤ ",
1592 Style::default().fg(grad(0.6)).add_modifier(Modifier::BOLD),
1593 ),
1594 ];
1595 let word: Vec<char> = "DATABRICKS".chars().collect();
1596 let sweep = (app.spinner_frame() * 2) % (word.len() + 8);
1597 for (i, ch) in word.iter().enumerate() {
1598 let t = i as f32 / (word.len() - 1) as f32;
1599 let style = if app.loading && (i as i32 - sweep as i32).unsigned_abs() <= 1 {
1600 Style::default().fg(p.text).add_modifier(Modifier::BOLD)
1601 } else {
1602 Style::default().fg(grad(t)).add_modifier(Modifier::BOLD)
1603 };
1604 left.push(Span::styled(ch.to_string(), style));
1605 }
1606 left.push(Span::raw(" "));
1607 for ch in "LAKEHOUSE".chars() {
1608 left.push(Span::styled(format!("{ch} "), Style::default().fg(p.key)));
1609 }
1610 left.push(Span::styled(
1611 format!("· v{}", env!("CARGO_PKG_VERSION")),
1612 Style::default().fg(p.dim),
1613 ));
1614 if let Some(profile) = &app.profile {
1615 left.push(Span::styled(" · ", Style::default().fg(p.dim)));
1616 left.push(Span::styled("⌂ ", Style::default().fg(p.dim)));
1617 left.push(Span::styled(profile.as_str(), Style::default().fg(p.key)));
1618 }
1619 if let Some(Shape::Badge(b)) = &app.user_badge {
1620 left.push(Span::styled(" · ", Style::default().fg(p.dim)));
1621 left.push(Span::styled(
1622 format!("{} {}", b.label, b.value),
1623 Style::default().fg(p.key),
1624 ));
1625 }
1626 if app.zoomed {
1627 left.push(Span::styled(" · ", Style::default().fg(p.dim)));
1628 left.push(Span::styled(
1629 format!("⛶ {}", app.focus.title()),
1630 Style::default()
1631 .fg(accent(app.focus, p))
1632 .add_modifier(Modifier::BOLD),
1633 ));
1634 }
1635
1636 let (ok, pending, failed, idle) = app.status_counts();
1638 for (count, glyph, color) in [
1639 (ok, "●", p.ok),
1640 (pending, "◐", p.warn),
1641 (failed, "✗", p.err),
1642 (idle, "○", p.dim),
1643 ] {
1644 if count > 0 {
1645 left.push(Span::styled(" ", Style::default()));
1646 left.push(Span::styled(
1647 format!("{glyph} {count}"),
1648 Style::default().fg(color),
1649 ));
1650 }
1651 }
1652 f.render_widget(Paragraph::new(Line::from(left)), inner);
1653
1654 let right = if app.loading {
1655 Line::from(Span::styled(
1656 format!("{} refreshing ", app.spinner()),
1657 Style::default().fg(p.warn),
1658 ))
1659 } else {
1660 Line::from(Span::styled(
1661 format!("updated {}s ago ", app.last_refresh_age().as_secs()),
1662 Style::default().fg(p.dim),
1663 ))
1664 };
1665 f.render_widget(Paragraph::new(right).alignment(Alignment::Right), inner);
1666}
1667
1668fn draw_footer(f: &mut Frame, area: Rect, app: &App, p: &Palette) {
1669 let key =
1670 |k: &'static str| Span::styled(k, Style::default().fg(p.key).add_modifier(Modifier::BOLD));
1671 let dim = |t: &'static str| Span::styled(t, Style::default().fg(p.dim));
1672
1673 if let Some(confirm) = &app.confirm {
1674 let line = Line::from(vec![
1675 Span::styled(
1676 format!(" ⚠ {} ", confirm.message),
1677 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1678 ),
1679 key("y"),
1680 dim(" confirm · any other key cancels"),
1681 ]);
1682 f.render_widget(Paragraph::new(line), area);
1683 return;
1684 }
1685
1686 if app.filter_entry {
1687 let line = Line::from(vec![
1688 Span::styled(
1689 format!(" /{}", app.active_filter()),
1690 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
1691 ),
1692 Span::styled("▏", Style::default().fg(p.warn)),
1693 dim(" type to filter "),
1694 key("enter"),
1695 dim(" apply "),
1696 key("esc"),
1697 dim(" clear"),
1698 ]);
1699 f.render_widget(Paragraph::new(line), area);
1700 return;
1701 }
1702
1703 let mut spans = if app.secret_form.is_some() {
1704 vec![
1705 dim(" type "),
1706 key("enter"),
1707 dim(" next / save "),
1708 key("esc"),
1709 dim(" cancel"),
1710 ]
1711 } else if app.help {
1712 vec![
1713 dim(" "),
1714 key("j"),
1715 dim("/"),
1716 key("k"),
1717 dim(" scroll any other key closes"),
1718 ]
1719 } else if app.pane_cfg.is_some() {
1720 vec![
1721 dim(" "),
1722 key("j"),
1723 dim("/"),
1724 key("k"),
1725 dim(" select "),
1726 key("space"),
1727 dim(" show/hide "),
1728 key("J"),
1729 dim("/"),
1730 key("K"),
1731 dim(" move "),
1732 key("esc"),
1733 dim(" done"),
1734 ]
1735 } else if app.jump.is_some() {
1736 vec![
1737 dim(" type to search everything "),
1738 key("\u{2191}"),
1739 dim("/"),
1740 key("\u{2193}"),
1741 dim(" select "),
1742 key("enter"),
1743 dim(" jump "),
1744 key("esc"),
1745 dim(" close"),
1746 ]
1747 } else if app.problems.is_some() {
1748 vec![
1749 dim(" "),
1750 key("j"),
1751 dim("/"),
1752 key("k"),
1753 dim(" select "),
1754 key("enter"),
1755 dim(" jump to item "),
1756 key("esc"),
1757 dim(" close"),
1758 ]
1759 } else if app.cost.is_some() {
1760 vec![
1761 dim(" "),
1762 key("esc"),
1763 dim(" back "),
1764 key("q"),
1765 dim(" quit"),
1766 ]
1767 } else if app.wh_picker.is_some() {
1768 vec![
1769 dim(" "),
1770 key("j"),
1771 dim("/"),
1772 key("k"),
1773 dim(" select "),
1774 key("enter"),
1775 dim(" run preview "),
1776 key("esc"),
1777 dim(" cancel"),
1778 ]
1779 } else if app.sql.is_some() {
1780 if app.hist_search.is_some() {
1781 vec![
1782 dim(" type to search history "),
1783 key("^r"),
1784 dim(" older match "),
1785 key("enter"),
1786 dim(" accept "),
1787 key("esc"),
1788 dim(" cancel"),
1789 ]
1790 } else {
1791 vec![
1792 dim(" "),
1793 key("enter"),
1794 dim(" run "),
1795 key("↑"),
1796 dim("/"),
1797 key("↓"),
1798 dim(" history "),
1799 key("^r"),
1800 dim(" search "),
1801 key("^x"),
1802 dim(" editor "),
1803 key("pgup"),
1804 dim("/"),
1805 key("pgdn"),
1806 dim(" scroll "),
1807 key("^s"),
1808 dim(" export "),
1809 key("esc"),
1810 dim(if app.sql.as_ref().is_some_and(|c| c.running) {
1811 " cancel query"
1812 } else {
1813 " close"
1814 }),
1815 ]
1816 }
1817 } else if app.preview.is_some() {
1818 vec![
1819 dim(" "),
1820 key("esc"),
1821 dim(" back "),
1822 key("j"),
1823 dim("/"),
1824 key("k"),
1825 dim(" scroll rows "),
1826 key("e"),
1827 dim(" export csv "),
1828 key("q"),
1829 dim(" quit"),
1830 ]
1831 } else if app.picker.is_some() {
1832 vec![
1833 dim(" "),
1834 key("j"),
1835 dim("/"),
1836 key("k"),
1837 dim(" select "),
1838 key("enter"),
1839 dim(" switch workspace "),
1840 key("esc"),
1841 dim(" cancel"),
1842 ]
1843 } else if app.run_view.is_some() {
1844 vec![
1845 dim(" "),
1846 key("esc"),
1847 dim(" back "),
1848 key("h"),
1849 dim("/"),
1850 key("l"),
1851 dim(" older/newer "),
1852 key("s"),
1853 dim(" cancel "),
1854 key("j"),
1855 dim("/"),
1856 key("k"),
1857 dim(" scroll "),
1858 key("J"),
1859 dim(" raw "),
1860 key("q"),
1861 dim(" quit"),
1862 ]
1863 } else if app.detail.is_some() {
1864 let mut spans = vec![dim(" "), key("esc"), dim(" back ")];
1865 match app.detail.as_ref() {
1866 Some(d) if d.panel == Panel::Jobs && d.section != "Lineage" => {
1867 spans.push(key("enter"));
1868 spans.push(dim(" latest run "));
1869 }
1870 Some(d) if d.panel == Panel::Pipelines && d.section != "Lineage" => {
1871 spans.push(key("enter"));
1872 spans.push(dim(" latest update "));
1873 }
1874 _ => {}
1875 }
1876 spans.extend([
1877 key("j"),
1878 dim("/"),
1879 key("k"),
1880 dim(" scroll "),
1881 key("J"),
1882 dim(" raw "),
1883 key("o"),
1884 dim(" open "),
1885 key("q"),
1886 dim(" quit"),
1887 ]);
1888 spans
1889 } else {
1890 let mut spans = vec![
1891 dim(" "),
1892 key("tab"),
1893 dim("/"),
1894 key("h"),
1895 dim("/"),
1896 key("l"),
1897 dim(" switch "),
1898 key("j"),
1899 dim("/"),
1900 key("k"),
1901 dim(" select "),
1902 key("/"),
1903 dim(if app.active_filter().is_empty() {
1904 " filter "
1905 } else {
1906 " filter · esc clears "
1907 }),
1908 ];
1909 let enter_label = match app.focus {
1911 Panel::Secrets if app.secret_scope.is_some() => None,
1912 Panel::Secrets => Some(" open scope "),
1913 Panel::Catalog if app.uc_path.len() < 2 => Some(" open "),
1914 _ => Some(" details "),
1915 };
1916 if let Some(label) = enter_label {
1917 spans.push(key("enter"));
1918 spans.push(dim(label));
1919 }
1920 match app.focus {
1922 Panel::Catalog => {
1923 if !app.uc_path.is_empty() {
1924 spans.push(key("bksp"));
1925 spans.push(dim(" up "));
1926 }
1927 if app.uc_path.len() == 2 {
1928 spans.push(key("p"));
1929 spans.push(dim(" preview "));
1930 spans.push(key("L"));
1931 spans.push(dim(" lineage "));
1932 spans.push(key(":"));
1933 spans.push(dim(" query table "));
1934 }
1935 }
1936 Panel::Dashboards => {}
1937 Panel::Secrets => {
1938 if app.secret_scope.is_some() {
1939 spans.push(key("bksp"));
1940 spans.push(dim(" up "));
1941 }
1942 spans.push(key("a"));
1943 spans.push(dim(if app.secret_scope.is_some() {
1944 " add secret "
1945 } else {
1946 " new scope "
1947 }));
1948 spans.push(key("x"));
1949 spans.push(dim(" delete "));
1950 }
1951 _ => {
1952 spans.push(key("s"));
1953 spans.push(dim(" action "));
1954 }
1955 }
1956 spans.extend([
1957 key("g"),
1958 dim(" access "),
1959 key("$"),
1960 dim(" cost "),
1961 key("!"),
1962 dim(" problems "),
1963 ]);
1964 if !(app.focus == Panel::Catalog && app.uc_path.len() == 2) {
1966 spans.push(key(":"));
1967 spans.push(dim(" sql "));
1968 }
1969 spans.extend([
1970 key("o"),
1971 dim(" open "),
1972 key("z"),
1973 dim(if app.zoomed { " unzoom " } else { " zoom " }),
1974 key("w"),
1975 dim(" workspace "),
1976 key("t"),
1977 dim(" theme "),
1978 key("r"),
1979 dim(" refresh "),
1980 key("?"),
1981 dim(" help "),
1982 key("q"),
1983 dim(" quit"),
1984 ]);
1985 spans
1986 };
1987 if let Some((msg, _)) = &app.flash {
1989 let color = if msg.starts_with('✗') { p.err } else { p.ok };
1990 let mut prefixed = vec![
1991 Span::styled(
1992 format!(" {msg}"),
1993 Style::default().fg(color).add_modifier(Modifier::BOLD),
1994 ),
1995 dim(" · "),
1996 ];
1997 prefixed.append(&mut spans);
1998 spans = prefixed;
1999 }
2000 f.render_widget(Paragraph::new(Line::from(spans)), area);
2001}
2002
2003#[allow(clippy::too_many_arguments)]
2004fn draw_panel(
2005 f: &mut Frame,
2006 area: Rect,
2007 panel: Panel,
2008 shape: Option<&Shape>,
2009 focused: bool,
2010 selected: Option<usize>,
2011 fresh: bool,
2012 breadcrumb: &str,
2013 spinner: &str,
2014 filter: &str,
2015 entering: bool,
2016 p: &Palette,
2017) {
2018 let accent = accent(panel, p);
2019 let (border_style, title_style) = if focused {
2020 (
2021 Style::default().fg(accent).add_modifier(Modifier::BOLD),
2022 Style::default().fg(p.text).add_modifier(Modifier::BOLD),
2023 )
2024 } else {
2025 (Style::default().fg(p.border), Style::default().fg(p.dim))
2026 };
2027 let visible: Option<Vec<&crate::shape::ListItem>> = match shape {
2028 Some(Shape::List(items)) => Some(
2029 items
2030 .iter()
2031 .filter(|it| crate::shape::item_matches(it, filter))
2032 .collect(),
2033 ),
2034 _ => None,
2035 };
2036 let count = match (&visible, shape) {
2037 (Some(v), Some(Shape::List(items))) if !filter.is_empty() || entering => {
2039 format!(" · {}/{}", v.len(), items.len())
2040 }
2041 (Some(v), _) => format!(" · {}", v.len()),
2042 (_, Some(Shape::Table(data))) => format!(" · {}", data.rows.len()),
2043 _ => String::new(),
2044 };
2045 let crumb = if !breadcrumb.is_empty() {
2046 format!(" ▸ {breadcrumb}")
2047 } else {
2048 String::new()
2049 };
2050 let title_style = if fresh {
2052 Style::default()
2053 .bg(accent)
2054 .fg(Color::Black)
2055 .add_modifier(Modifier::BOLD)
2056 } else {
2057 title_style
2058 };
2059 let mut title_spans = vec![
2060 Span::styled(format!(" {} ", panel.icon()), Style::default().fg(accent)),
2061 Span::styled(format!("{}{}{} ", panel.title(), crumb, count), title_style),
2062 ];
2063 if entering || !filter.is_empty() {
2064 title_spans.push(Span::styled(
2065 format!("/{}{} ", filter, if entering { "▏" } else { "" }),
2066 Style::default().fg(p.warn).add_modifier(Modifier::BOLD),
2067 ));
2068 }
2069 let title = Line::from(title_spans);
2070 let block = Block::default()
2071 .title(title)
2072 .borders(Borders::ALL)
2073 .border_type(if focused {
2074 BorderType::Thick
2075 } else {
2076 BorderType::Rounded
2077 })
2078 .border_style(border_style)
2079 .padding(Padding::horizontal(1));
2080
2081 match shape {
2082 None => {
2083 let par = Paragraph::new(format!("{} syncing with the lakehouse…", spinner))
2084 .style(Style::default().fg(p.warn))
2085 .block(block);
2086 f.render_widget(par, area);
2087 }
2088 Some(Shape::List(items)) if visible.as_ref().is_none_or(|v| v.is_empty()) => {
2089 let empty = if !items.is_empty() {
2090 format!("∅ nothing matches /{filter}")
2091 } else {
2092 let msg = match panel {
2093 Panel::Clusters => "no compute running",
2094 Panel::Jobs => "no jobs defined",
2095 Panel::Pipelines => "no pipelines",
2096 Panel::Warehouses => "no warehouses",
2097 Panel::Dashboards => "no dashboards yet",
2098 Panel::Catalog => "nothing at this level",
2099 Panel::Secrets => "no secret scopes",
2100 };
2101 format!("∅ {msg}")
2102 };
2103 let par = Paragraph::new(empty)
2104 .style(Style::default().fg(p.dim))
2105 .block(block);
2106 f.render_widget(par, area);
2107 }
2108 Some(Shape::List(_)) => {
2109 let list_items: Vec<ListItem> = visible
2110 .unwrap_or_default()
2111 .iter()
2112 .map(|item| {
2113 let color = status_color(&item.status, p);
2114 let chip = match item.status {
2115 Status::Stopped | Status::Unknown(_) => Span::styled(
2116 format!(" {} ", item.status.label()),
2117 Style::default().fg(color).add_modifier(Modifier::DIM),
2118 ),
2119 _ => Span::styled(
2120 format!(" {} ", item.status.label()),
2121 Style::default()
2122 .bg(color)
2123 .fg(Color::Black)
2124 .add_modifier(Modifier::BOLD),
2125 ),
2126 };
2127 let mut spans = vec![
2128 Span::styled("● ", Style::default().fg(color)),
2129 Span::styled(item.name.as_str(), Style::default().fg(p.text)),
2130 Span::raw(" "),
2131 chip,
2132 ];
2133 if !item.history.is_empty() {
2134 spans.push(Span::raw(" "));
2135 for run in &item.history {
2136 spans.push(Span::styled(
2137 history_glyph(run),
2138 Style::default().fg(status_color(run, p)),
2139 ));
2140 }
2141 }
2142 if let Some(detail) = &item.detail {
2143 spans.push(Span::styled(
2144 format!(" {}", detail),
2145 Style::default().fg(p.dim),
2146 ));
2147 }
2148 ListItem::new(Line::from(spans))
2149 })
2150 .collect();
2151 let list = List::new(list_items)
2152 .block(block)
2153 .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
2154 match selected {
2155 Some(sel) => {
2156 let mut state = ListState::default().with_selected(Some(sel));
2157 f.render_stateful_widget(list, area, &mut state);
2158 }
2159 None => f.render_widget(list, area),
2160 }
2161 }
2162 Some(Shape::Table(data)) => {
2163 let header_cells: Vec<Cell> = data
2164 .headers
2165 .iter()
2166 .map(|h| {
2167 Cell::from(h.as_str()).style(Style::default().add_modifier(Modifier::BOLD))
2168 })
2169 .collect();
2170 let header = Row::new(header_cells).style(Style::default().fg(accent));
2171 let rows: Vec<Row> = data
2172 .rows
2173 .iter()
2174 .map(|r| Row::new(r.iter().map(|c| Cell::from(c.as_str())).collect::<Vec<_>>()))
2175 .collect();
2176 let widths: Vec<Constraint> = data
2177 .headers
2178 .iter()
2179 .map(|_| Constraint::Percentage(100 / data.headers.len() as u16))
2180 .collect();
2181 let table = Table::new(rows, widths).header(header).block(block);
2182 f.render_widget(table, area);
2183 }
2184 Some(Shape::Badge(b)) => {
2185 let text = format!("{}: {}", b.label, b.value);
2186 let par = Paragraph::new(text)
2187 .style(Style::default().fg(p.text))
2188 .block(block);
2189 f.render_widget(par, area);
2190 }
2191 Some(Shape::Text(t)) => {
2192 let color = if t.starts_with('✗') { p.err } else { p.text };
2193 let par = Paragraph::new(t.as_str())
2194 .style(Style::default().fg(color))
2195 .wrap(Wrap { trim: false })
2196 .block(block);
2197 f.render_widget(par, area);
2198 }
2199 }
2200}
2201
2202fn status_color(status: &Status, p: &Palette) -> Color {
2203 match status {
2204 Status::Running | Status::Success => p.ok,
2205 Status::Stopped => p.dim,
2206 Status::Pending => p.warn,
2207 Status::Failed => p.err,
2208 Status::Unknown(_) => p.text,
2209 }
2210}
2211
2212fn history_glyph(status: &Status) -> &'static str {
2213 match status {
2214 Status::Success => "✓",
2215 Status::Failed => "✗",
2216 Status::Running => "●",
2217 Status::Pending => "◐",
2218 Status::Stopped => "○",
2219 Status::Unknown(_) => "·",
2220 }
2221}