Skip to main content

max / makenotwork

31.1 KB · 864 lines History Blame Raw
1 //! Drawing the model.
2 //!
3 //! `render(model, theme, now, frame)` is a pure function of the model, the
4 //! theme and the clock. `now` is threaded through rather than read from the
5 //! system clock precisely so that this stays true: with an ambient clock, every
6 //! snapshot test becomes time-dependent and the whole surface stops being
7 //! verifiable without a live daemon. That is unpleasant to retrofit and trivial
8 //! to keep. The theme is threaded the same way and for the same reason.
9 //!
10 //! Every colour here is a makeover intent (`crate::theme`), never a literal.
11 //! The intent names what a thing IS — a danger, a muted label, the surface a
12 //! modal floats on — and which colour that is belongs to the theme file. What
13 //! the terminal costs is geometry rather than colour, so nothing below leans on
14 //! a hue to carry a distinction that the layout should be carrying.
15
16 use chrono::{DateTime, Utc};
17 use makeover_tui::Theme;
18 use makeover_tui::makeover_layout::{Column, Priority, Width};
19 use makeover_tui::table::{self, Cell as TableCell, Sizing, TableStyle};
20 use ops_status::{Method, Node, Status};
21 use ratatui::Frame;
22 use ratatui::layout::{Constraint, Layout, Rect};
23 use ratatui::style::{Modifier, Style};
24 use ratatui::text::{Line, Span};
25 use ratatui::widgets::{Block, Clear, Paragraph, TableState, Tabs};
26
27 use crate::model::{LiveRow, Model, Prompt, SourceState, StoreRow, Tab};
28 use crate::value;
29
30 pub(crate) fn render(model: &Model, theme: &Theme, now: DateTime<Utc>, frame: &mut Frame) {
31 let [header, body, footer] = Layout::vertical([
32 Constraint::Length(1),
33 Constraint::Min(1),
34 Constraint::Length(1),
35 ])
36 .areas(frame.area());
37
38 render_header(model, theme, now, frame, header);
39 match model.tab {
40 Tab::Live => render_live(model, theme, now, frame, body),
41 Tab::Logs => render_logs(model, theme, frame, body),
42 Tab::Store => render_store(model, theme, now, frame, body),
43 }
44 // A prompt floats over whatever tab is showing: the state behind it keeps
45 // updating on every poll, which is the point of not blocking on the modal.
46 if model.prompt.is_some() {
47 render_prompt(model, theme, frame, body);
48 }
49 render_footer(model, theme, frame, footer);
50 }
51
52 /// Secondary text: labels, hints, the things you read past.
53 fn muted(theme: &Theme) -> Style {
54 Style::default().fg(theme.content_muted)
55 }
56
57 /// The selected row, on every list and table here.
58 ///
59 /// A raised surface rather than a reversed one. Reversing swaps a span's own
60 /// foreground into the background, so a selected `FAIL` row used to paint its
61 /// danger colour behind the text and lose the one signal the row carries;
62 /// giving selection a surface of its own leaves every status colour intact on
63 /// top of it.
64 fn selected(theme: &Theme) -> Style {
65 Style::default()
66 .bg(theme.surface_raised)
67 .fg(theme.content_primary)
68 }
69
70 /// A bordered container: the frame in the theme's border colour, the title in
71 /// secondary content so it reads as a label on the box rather than as content.
72 fn container(theme: &Theme, title: &'static str) -> Block<'static> {
73 Block::bordered()
74 .border_style(Style::default().fg(theme.line_border))
75 .title(Span::styled(
76 title,
77 Style::default().fg(theme.content_secondary),
78 ))
79 }
80
81 fn render_header(model: &Model, theme: &Theme, now: DateTime<Utc>, frame: &mut Frame, area: Rect) {
82 let worst = model.worst(now);
83 let [mark, tabs] = Layout::horizontal([Constraint::Length(6), Constraint::Min(1)]).areas(area);
84
85 // The worst-status mark is a filled chip: the worst status is the one thing on
86 // screen that has to be readable from across the room, so it takes the
87 // status colour as a background rather than as text.
88 let status = value::status_style(theme, worst);
89 frame.render_widget(
90 Paragraph::new(Span::styled(
91 format!(" {} ", value::status_mark(worst)),
92 Style::default()
93 .bg(status.fg.unwrap_or(theme.content_primary))
94 .fg(theme.surface_page)
95 .add_modifier(Modifier::BOLD),
96 )),
97 mark,
98 );
99 frame.render_widget(
100 Tabs::new(Tab::titles())
101 .select(model.tab_index())
102 .style(muted(theme))
103 .highlight_style(selected(theme).add_modifier(Modifier::BOLD))
104 .divider(" "),
105 tabs,
106 );
107 }
108
109 fn render_footer(model: &Model, theme: &Theme, frame: &mut Frame, area: Rect) {
110 let text = match &model.message {
111 // A fired action's outcome, which is transient and worth noticing.
112 Some(message) => Span::styled(
113 format!(" {message}"),
114 Style::default().fg(theme.status_warning),
115 ),
116 None => Span::styled(
117 " tab/shift-tab switch up/down move enter run action 1-3 jump q quit",
118 muted(theme),
119 ),
120 };
121 frame.render_widget(
122 Paragraph::new(Line::from(text)).style(Style::default().bg(theme.surface_sunken)),
123 area,
124 );
125 }
126
127 // ---------------------------------------------------------------------------
128 // Live
129 // ---------------------------------------------------------------------------
130
131 /// The live tab's columns, left to right.
132 ///
133 /// The mark's name is empty because the name is what the header row draws, and
134 /// this column's header always was blank: the glyph says what it is.
135 ///
136 /// Nothing here is `Optional`. A live view with the status or the source name
137 /// dropped is not a narrower view, it is a different screen, and the age is
138 /// what turns "FAIL" into "FAIL, and it has been that way for two days". Detail
139 /// absorbs what is left, which is what the old `Min(10)` was saying.
140 const LIVE_COLUMNS: [Column<'static>; 4] = [
141 Column {
142 name: "",
143 width: Width::Fixed,
144 priority: Priority::Essential,
145 sortable: false,
146 sorted: None,
147 },
148 Column {
149 name: "source",
150 width: Width::Content,
151 priority: Priority::Essential,
152 sortable: false,
153 sorted: None,
154 },
155 Column {
156 name: "age",
157 width: Width::Content,
158 priority: Priority::Secondary,
159 sortable: false,
160 sorted: None,
161 },
162 Column {
163 name: "detail",
164 width: Width::Fill,
165 priority: Priority::Essential,
166 sortable: false,
167 sorted: None,
168 },
169 ];
170
171 /// The tracks the hand-written `Constraint`s carried, lifted rather than
172 /// re-chosen. The two `Width::Content` columns measure themselves from the
173 /// cells and use these only as a floor, so a run of short source names stops
174 /// spending fourteen columns to say `pom`.
175 const LIVE_SIZING: Sizing<'static> = Sizing {
176 lengths: &[("", 4), ("source", 14), ("age", 8), ("detail", 10)],
177 fallback: 8,
178 };
179
180 /// Every source at once, worst first, with each source's nodes under it.
181 ///
182 /// The nodes are indented into the same table rather than given a pane of their
183 /// own: there is one cursor, and what it is on is what the detail pane below
184 /// explains. That is the whole of what the per-source tabs used to do, minus
185 /// the visiting them one at a time.
186 fn render_live(model: &Model, theme: &Theme, now: DateTime<Utc>, frame: &mut Frame, area: Rect) {
187 // The table takes the majority and the detail pane what is left. A fixed
188 // height for the detail would eat the whole body on a short terminal, which
189 // is the one case where the list is the thing you need.
190 let [table_area, detail_area] =
191 Layout::vertical([Constraint::Percentage(60), Constraint::Min(3)]).areas(area);
192
193 let live = model.live_rows(now);
194 let rows: Vec<Vec<TableCell>> = live
195 .iter()
196 .map(|row| match row {
197 LiveRow::Source { index } => {
198 let source = &model.sources[*index];
199 let status = source.status(now);
200 let age = match source.age(now) {
201 Some(age) => value::duration(age.num_seconds()),
202 None => "-".into(),
203 };
204 vec![
205 // The mark styles its own span rather than the cell: a
206 // status colour is this app's, not a part the table module
207 // knows, and a span's style sits on top of the cell's.
208 TableCell::new(
209 "",
210 Span::styled(
211 value::status_mark(status),
212 value::status_style(theme, status),
213 ),
214 ),
215 TableCell::new(
216 "source",
217 Span::styled(
218 source.name.clone(),
219 Style::default()
220 .fg(theme.content_primary)
221 .add_modifier(Modifier::BOLD),
222 ),
223 ),
224 TableCell::new("age", Span::styled(age, muted(theme))),
225 TableCell::new("detail", source.summary(now)),
226 ]
227 }
228 LiveRow::Node { node, depth, .. } => vec![
229 TableCell::new(
230 "",
231 Span::styled(
232 value::status_mark(node.status),
233 value::status_style(theme, node.status),
234 ),
235 ),
236 TableCell::new("source", format!("{}{}", " ".repeat(*depth), node.label)),
237 // A node has no age of its own; the source line above it carries
238 // the one age there is, and repeating it would say that each node
239 // was measured separately.
240 TableCell::new("age", Span::styled(String::new(), muted(theme))),
241 TableCell::new("detail", Span::styled(node.kind.clone(), muted(theme))),
242 ],
243 })
244 .collect();
245
246 // The block first, because narrowing is measured against the width the
247 // table actually gets rather than the width of the area around it. Two
248 // columns of border is the difference between "detail fits" and "detail
249 // is cut", which is exactly the decision the cutoff is making.
250 let block = container(theme, " all sources ");
251 let inner = block.inner(table_area);
252 let table = table::table(
253 &LIVE_COLUMNS,
254 &rows,
255 &LIVE_SIZING,
256 &TableStyle::from_theme(theme),
257 inner.width,
258 )
259 .block(block);
260
261 // Selection through the widget's own highlight rather than a per-row style.
262 // `TableStyle::from_theme` carries it on the background alone, which is what
263 // leaves a FAIL row's danger colour on top of it -- the same reason the
264 // local `selected` helper gives a surface instead of reversing.
265 let mut state = TableState::default().with_selected(Some(model.selected));
266 frame.render_stateful_widget(table, table_area, &mut state);
267
268 let row = live.get(model.selected);
269 let source = row.map(|r| &model.sources[r.source_index()]);
270 render_detail(
271 source,
272 row.and_then(LiveRow::node),
273 theme,
274 now,
275 frame,
276 detail_area,
277 );
278 }
279
280 /// The selected node's fields and conditions.
281 ///
282 /// Conditions are the half that usually gets dropped, and the half that pays:
283 /// "blocked" is useless, "blocked because burn_in is 31h of 48h" is what saves
284 /// an SSH.
285 fn render_detail(
286 source: Option<&SourceState>,
287 node: Option<&Node>,
288 theme: &Theme,
289 now: DateTime<Utc>,
290 frame: &mut Frame,
291 area: Rect,
292 ) {
293 let width = area.width.saturating_sub(4) as usize;
294 let mut lines: Vec<Line> = Vec::new();
295
296 match node {
297 Some(node) => {
298 lines.push(Line::from(vec![
299 Span::styled(
300 value::status_mark(node.status),
301 value::status_style(theme, node.status),
302 ),
303 Span::raw(" "),
304 Span::styled(
305 node.label.clone(),
306 Style::default()
307 .fg(theme.content_primary)
308 .add_modifier(Modifier::BOLD),
309 ),
310 ]));
311 lines.extend(field_lines(theme, node, now, width));
312 lines.extend(condition_lines(theme, node, width));
313 if !node.actions.is_empty() {
314 // The hint tells the operator whether Enter does anything here,
315 // so a read-only source does not look broken when a keypress is
316 // ignored.
317 let hint = if source.is_some_and(|s| s.allow_actions) {
318 " (enter to run)"
319 } else {
320 " (read-only)"
321 };
322 lines.push(Line::from(vec![
323 Span::styled(
324 format!("actions: {}", node.actions.join(", ")),
325 muted(theme),
326 ),
327 Span::styled(hint, muted(theme)),
328 ]));
329 }
330 }
331 // A source line, or an empty list. Either way the source's own summary
332 // is the thing worth saying: it is why the source has no nodes to
333 // select, when it has none.
334 None => lines.push(Line::from(Span::styled(
335 source.map_or_else(|| "no sources".to_string(), |source| source.summary(now)),
336 muted(theme),
337 ))),
338 }
339
340 frame.render_widget(
341 Paragraph::new(lines).block(container(theme, " detail ")),
342 area,
343 );
344 }
345
346 // ---------------------------------------------------------------------------
347 // Logs
348 // ---------------------------------------------------------------------------
349
350 /// The logs tab's columns.
351 ///
352 /// The source is a column on every line rather than a heading over a section,
353 /// so a line read on its own still says who said it. The grouping is still
354 /// there: the rows arrive grouped by source and the column makes the boundaries
355 /// visible without costing an index that does not line up with the cursor.
356 const LOG_COLUMNS: [Column<'static>; 5] = [
357 Column {
358 name: "when",
359 width: Width::Content,
360 priority: Priority::Essential,
361 sortable: false,
362 sorted: None,
363 },
364 Column {
365 name: "source",
366 width: Width::Content,
367 priority: Priority::Essential,
368 sortable: false,
369 sorted: None,
370 },
371 Column {
372 name: "",
373 width: Width::Fixed,
374 priority: Priority::Secondary,
375 sortable: false,
376 sorted: None,
377 },
378 Column {
379 name: "event",
380 width: Width::Content,
381 priority: Priority::Essential,
382 sortable: false,
383 sorted: None,
384 },
385 Column {
386 name: "detail",
387 width: Width::Fill,
388 priority: Priority::Optional,
389 sortable: false,
390 sorted: None,
391 },
392 ];
393
394 const LOG_SIZING: Sizing<'static> = Sizing {
395 lengths: &[
396 ("when", 9),
397 ("source", 10),
398 ("", 4),
399 ("event", 20),
400 ("detail", 10),
401 ],
402 fallback: 8,
403 };
404
405 /// What every source has said lately, grouped by which one said it.
406 ///
407 /// The events are contract (`ops_status::Event`) and nothing here knows what any
408 /// of them mean, which is the same bargain the rest of the shell makes: a new
409 /// daemon that emits events gets this screen for free.
410 fn render_logs(model: &Model, theme: &Theme, frame: &mut Frame, area: Rect) {
411 let logs = model.log_rows();
412 if logs.is_empty() {
413 frame.render_widget(
414 Paragraph::new(Line::from(Span::styled(
415 "no source has reported an event",
416 muted(theme),
417 )))
418 .block(container(theme, " logs ")),
419 area,
420 );
421 return;
422 }
423
424 let rows: Vec<Vec<TableCell>> = logs
425 .iter()
426 .map(|row| {
427 let status = row.event.status;
428 vec![
429 TableCell::new(
430 "when",
431 Span::styled(row.event.at.format("%H:%M:%S").to_string(), muted(theme)),
432 ),
433 TableCell::new("source", row.source.to_string()),
434 TableCell::new(
435 "",
436 match status {
437 Some(status) => Span::styled(
438 value::status_mark(status),
439 value::status_style(theme, status),
440 ),
441 // An event with no status is a note, not a verdict.
442 // Blank rather than a guessed mark: inventing "ok" here
443 // is exactly the domain knowledge the shell refuses.
444 None => Span::raw(""),
445 },
446 ),
447 TableCell::new("event", row.event.label.clone()),
448 TableCell::new(
449 "detail",
450 Span::styled(row.event.detail.clone().unwrap_or_default(), muted(theme)),
451 ),
452 ]
453 })
454 .collect();
455
456 let block = container(theme, " logs ");
457 let inner = block.inner(area);
458 let table = table::table(
459 &LOG_COLUMNS,
460 &rows,
461 &LOG_SIZING,
462 &TableStyle::from_theme(theme),
463 inner.width,
464 )
465 .block(block);
466 let mut state = TableState::default().with_selected(Some(model.logs_scroll));
467 frame.render_stateful_widget(table, area, &mut state);
468 }
469
470 // ---------------------------------------------------------------------------
471 // Store
472 // ---------------------------------------------------------------------------
473
474 /// The store tab's columns.
475 ///
476 /// `series` is the operator's own label for the number, not the store's series
477 /// name: the store cannot say what a series means, so the config does, and it is
478 /// the config's word that goes on screen. `labels` is the producer's dimension
479 /// text, carried through verbatim rather than parsed into columns this crate
480 /// would have to invent.
481 const STORE_COLUMNS: [Column<'static>; 5] = [
482 Column {
483 name: "store",
484 width: Width::Content,
485 priority: Priority::Secondary,
486 sortable: false,
487 sorted: None,
488 },
489 Column {
490 name: "series",
491 width: Width::Content,
492 priority: Priority::Essential,
493 sortable: false,
494 sorted: None,
495 },
496 Column {
497 name: "value",
498 width: Width::Content,
499 priority: Priority::Essential,
500 sortable: false,
501 sorted: None,
502 },
503 Column {
504 name: "age",
505 width: Width::Content,
506 priority: Priority::Secondary,
507 sortable: false,
508 sorted: None,
509 },
510 Column {
511 name: "labels",
512 width: Width::Fill,
513 priority: Priority::Optional,
514 sortable: false,
515 sorted: None,
516 },
517 ];
518
519 const STORE_SIZING: Sizing<'static> = Sizing {
520 lengths: &[
521 ("store", 10),
522 ("series", 20),
523 ("value", 12),
524 ("age", 8),
525 ("labels", 10),
526 ],
527 fallback: 8,
528 };
529
530 /// The series a configured store has recorded.
531 ///
532 /// The one screen here that renders something other than the `ops-status`
533 /// contract. What keeps that break narrow is that every row below comes from a
534 /// series the config named: nothing is discovered, nothing is inferred, and a
535 /// series nobody named is not on this screen.
536 fn render_store(model: &Model, theme: &Theme, now: DateTime<Utc>, frame: &mut Frame, area: Rect) {
537 let store_rows = model.store_rows();
538 if store_rows.is_empty() {
539 frame.render_widget(
540 Paragraph::new(Line::from(Span::styled(
541 "no [[store]] configured",
542 muted(theme),
543 )))
544 .block(container(theme, " store ")),
545 area,
546 );
547 return;
548 }
549
550 let rows: Vec<Vec<TableCell>> = store_rows
551 .iter()
552 .map(|row| match row {
553 StoreRow::Unavailable { store, reason } => vec![
554 TableCell::new("store", (*store).to_string()),
555 TableCell::new(
556 "series",
557 Span::styled(
558 "unavailable",
559 Style::default()
560 .fg(theme.status_danger)
561 .add_modifier(Modifier::BOLD),
562 ),
563 ),
564 TableCell::new("value", Span::styled(String::new(), muted(theme))),
565 TableCell::new("age", Span::styled(String::new(), muted(theme))),
566 TableCell::new(
567 "labels",
568 Span::styled(
569 (*reason).to_string(),
570 Style::default().fg(theme.status_danger),
571 ),
572 ),
573 ],
574 StoreRow::Missing { store, spec } => vec![
575 TableCell::new("store", (*store).to_string()),
576 TableCell::new("series", spec.label.clone()),
577 // A named series with nothing behind it is shown, not skipped:
578 // a soak target that has never reported is the thing worth
579 // noticing, and omitting it would be indistinguishable from
580 // never having configured it.
581 TableCell::new(
582 "value",
583 Span::styled(
584 "no observations",
585 value::status_style(theme, Status::Unknown),
586 ),
587 ),
588 TableCell::new("age", Span::styled(String::new(), muted(theme))),
589 TableCell::new("labels", Span::styled(String::new(), muted(theme))),
590 ],
591 StoreRow::Value {
592 store,
593 spec,
594 reading,
595 } => vec![
596 TableCell::new("store", (*store).to_string()),
597 TableCell::new("series", spec.label.clone()),
598 TableCell::new(
599 "value",
600 // `quantity` is what the `ops-status` contract's own
601 // magnitude values render through, so a number on this tab
602 // reads the same as one on the live tab. The unit is the
603 // config's word, since the store has none.
604 Span::styled(
605 value::quantity(reading.value, spec.unit.as_deref()),
606 Style::default().fg(theme.content_primary),
607 ),
608 ),
609 TableCell::new(
610 "age",
611 Span::styled(
612 value::duration((now - reading.at).num_seconds()),
613 muted(theme),
614 ),
615 ),
616 TableCell::new("labels", Span::styled(reading.labels.clone(), muted(theme))),
617 ],
618 })
619 .collect();
620
621 let block = container(theme, " store ");
622 let inner = block.inner(area);
623 let table = table::table(
624 &STORE_COLUMNS,
625 &rows,
626 &STORE_SIZING,
627 &TableStyle::from_theme(theme),
628 inner.width,
629 )
630 .block(block);
631 let mut state = TableState::default().with_selected(Some(model.store_scroll));
632 frame.render_stateful_widget(table, area, &mut state);
633 }
634
635 fn field_lines(theme: &Theme, node: &Node, now: DateTime<Utc>, width: usize) -> Vec<Line<'static>> {
636 let label_width = node
637 .fields
638 .iter()
639 .map(|f| f.label.chars().count())
640 .max()
641 .unwrap_or(0);
642
643 node.fields
644 .iter()
645 .map(|field| {
646 let budget = width.saturating_sub(label_width + 2);
647 let rendered = value::render(&field.value, now, budget);
648 let style = match &field.value {
649 ops_status::Value::Progress { value, max, .. } => {
650 value::progress_style(theme, *value, *max)
651 }
652 other => value::style(theme, other),
653 };
654 Line::from(vec![
655 Span::styled(format!("{:<label_width$} ", field.label), muted(theme)),
656 Span::styled(rendered, style),
657 ])
658 })
659 .collect()
660 }
661
662 fn condition_lines(theme: &Theme, node: &Node, width: usize) -> Vec<Line<'static>> {
663 node.conditions
664 .iter()
665 .map(|condition| {
666 let mut spans = vec![
667 Span::styled(
668 format!("{:<5}", value::status_mark(condition.status)),
669 value::status_style(theme, condition.status),
670 ),
671 Span::styled(
672 condition.condition_type.clone(),
673 Style::default().fg(theme.content_primary),
674 ),
675 ];
676 if let Some(detail) = &condition.detail {
677 let budget = width.saturating_sub(condition.condition_type.chars().count() + 8);
678 spans.push(Span::styled(
679 format!(" {}", truncate(detail, budget.max(8))),
680 muted(theme),
681 ));
682 }
683 Line::from(spans)
684 })
685 .collect()
686 }
687
688 // ---------------------------------------------------------------------------
689 // Action prompts
690 // ---------------------------------------------------------------------------
691
692 /// The modal path to firing an action: pick, then clear its guard.
693 ///
694 /// A `danger` action shows a red header and asks the operator to type its key;
695 /// muscle memory cannot type `rollback-b`, which is the whole safeguard. The
696 /// resolved host is left off — the tab already names the source — so the line
697 /// stays short and shows the method and path the request will use.
698 fn render_prompt(model: &Model, theme: &Theme, frame: &mut Frame, area: Rect) {
699 let Some(prompt) = &model.prompt else { return };
700 let source = model.sources.get(prompt_source(prompt));
701 let plain = Style::default().fg(theme.content_primary);
702 let danger_style = Style::default().fg(theme.status_danger);
703
704 let (title, title_style, lines) = match prompt {
705 Prompt::Pick { keys, selected, .. } => {
706 let mut lines = Vec::new();
707 for (i, key) in keys.iter().enumerate() {
708 let action = source.and_then(|s| s.action(key));
709 let label = action.map_or(key.as_str(), |a| a.label.as_str());
710 let danger = action.is_some_and(|a| a.danger);
711 let marker = if i == *selected { "> " } else { " " };
712 // Danger is the foreground and selection the surface, so a
713 // selected danger action is still red rather than swapped into
714 // its own background.
715 let mut style = if danger { danger_style } else { plain };
716 if i == *selected {
717 style = style.bg(theme.surface_raised);
718 }
719 lines.push(Line::from(Span::styled(
720 format!("{marker}{}. {label} [{key}]", i + 1),
721 style,
722 )));
723 }
724 lines.push(Line::from(""));
725 lines.push(Line::from(Span::styled(
726 "enter run esc cancel",
727 muted(theme),
728 )));
729 (
730 " run action ",
731 Style::default().fg(theme.line_border),
732 lines,
733 )
734 }
735 Prompt::Confirm { key, .. } => {
736 let action = source.and_then(|s| s.action(key));
737 let lines = vec![
738 action_summary_line(theme, action, key),
739 Line::from(""),
740 Line::from(Span::styled(
741 "press y to confirm esc cancel",
742 muted(theme),
743 )),
744 ];
745 (" confirm ", Style::default().fg(theme.line_border), lines)
746 }
747 Prompt::Type { key, typed, .. } => {
748 let lines = vec![
749 Line::from(Span::styled(
750 "DANGER",
751 danger_style.add_modifier(Modifier::BOLD),
752 )),
753 action_summary_line(theme, source.and_then(|s| s.action(key)), key),
754 Line::from(""),
755 Line::from(Span::styled(
756 format!("type '{key}' to confirm:"),
757 muted(theme),
758 )),
759 Line::from(Span::styled(format!("> {typed}\u{258f}"), danger_style)),
760 Line::from(""),
761 Line::from(Span::styled("esc cancel", muted(theme))),
762 ];
763 (" DANGER ", danger_style.add_modifier(Modifier::BOLD), lines)
764 }
765 };
766
767 let width = lines
768 .iter()
769 .map(Line::width)
770 .chain(std::iter::once(title.len()))
771 .max()
772 .unwrap_or(0) as u16
773 + 4;
774 let height = lines.len() as u16 + 2;
775 let popup = centered(area, width, height);
776
777 // Clear what is under the popup so the tab behind does not show through,
778 // then paint the overlay surface: makeover's `surface.overlay` is the
779 // intent for exactly this, a surface that floats above the page.
780 frame.render_widget(Clear, popup);
781 frame.render_widget(
782 Paragraph::new(lines)
783 .style(Style::default().bg(theme.surface_overlay))
784 .block(
785 Block::bordered()
786 .title(Span::styled(title, title_style))
787 .border_style(title_style),
788 ),
789 popup,
790 );
791 }
792
793 /// One line naming what an action will do: `Roll back POST /rollback/b`.
794 fn action_summary_line(
795 theme: &Theme,
796 action: Option<&ops_status::Action>,
797 key: &str,
798 ) -> Line<'static> {
799 match action {
800 Some(action) => Line::from(vec![
801 Span::styled(
802 action.label.clone(),
803 Style::default()
804 .fg(theme.content_primary)
805 .add_modifier(Modifier::BOLD),
806 ),
807 Span::styled(
808 format!(" {} {}", method_str(action.method), action.url),
809 Style::default().fg(theme.content_secondary),
810 ),
811 ]),
812 // The action was retracted by a poll since the prompt opened; say so
813 // rather than render a blank confirmation.
814 None => Line::from(Span::styled(
815 format!("{key}: no longer offered"),
816 Style::default().fg(theme.status_warning),
817 )),
818 }
819 }
820
821 fn method_str(method: Method) -> &'static str {
822 match method {
823 Method::Get => "GET",
824 Method::Post => "POST",
825 Method::Put => "PUT",
826 Method::Delete => "DELETE",
827 }
828 }
829
830 fn prompt_source(prompt: &Prompt) -> usize {
831 match prompt {
832 Prompt::Pick { source, .. }
833 | Prompt::Confirm { source, .. }
834 | Prompt::Type { source, .. } => *source,
835 }
836 }
837
838 /// A rectangle of the given size centered in `area`, clamped so it always fits.
839 fn centered(area: Rect, width: u16, height: u16) -> Rect {
840 let width = width.min(area.width);
841 let height = height.min(area.height);
842 Rect {
843 x: area.x + (area.width - width) / 2,
844 y: area.y + (area.height - height) / 2,
845 width,
846 height,
847 }
848 }
849
850 /// Clip to a character budget, marking that something was cut.
851 fn truncate(text: &str, max: usize) -> String {
852 // A detail string is producer-supplied and may carry newlines; the pane is
853 // line-oriented, so flatten first.
854 let flat: String = text.replace('\n', " ");
855 if flat.chars().count() <= max {
856 return flat;
857 }
858 let kept: String = flat.chars().take(max.saturating_sub(1)).collect();
859 format!("{kept}")
860 }
861
862 #[cfg(test)]
863 mod tests;
864