Skip to main content

taba_cli/tui/view/modal/
mod.rs

1//! The popups (doc 34, TUI-FRM-07): help, command palette, language, confirmation, result of an action, what a
2//! condition means, and the path of an export. Only one is open at a time.
3
4use ratatui::layout::{Constraint, Layout, Rect};
5use ratatui::style::Style;
6use ratatui::text::{Line, Span};
7use ratatui::widgets::{Paragraph, Wrap};
8use ratatui::Frame;
9
10use crate::tui::api::{FlushReport, Severity, StopReport};
11use crate::tui::i18n::{self, LANGS};
12use crate::tui::keymap;
13use crate::tui::model::{Confirm, Fail, Input, InputPurpose, Modal, Model, Pending, Report};
14use crate::tui::update::{palette_matches, scope_of};
15use crate::tui::view::widgets::{centered, denied_text, popup, scrolling, short};
16
17mod forms;
18
19pub fn draw(f: &mut Frame, area: Rect, m: &Model) {
20    let Some(modal) = &m.modal else { return };
21    match modal {
22        Modal::Menu(menu) => draw_menu(f, area, m, menu),
23        Modal::TopicSummary => draw_topic_summary(f, area, m),
24        Modal::Message => crate::tui::screens::topics::messages::view::draw_dialog(f, area, m),
25        Modal::Help { scroll } => draw_help(f, area, m, *scroll),
26        Modal::Palette { query, selected } => draw_palette(f, area, m, query, *selected),
27        Modal::Language { selected } => draw_language(f, area, m, *selected),
28        Modal::Confirm(confirm) => draw_confirm(f, area, m, confirm),
29        Modal::Report(report) => draw_report(f, area, m, report),
30        Modal::Condition(name) => draw_condition(f, area, m, name),
31        Modal::Input(input) => draw_input(f, area, m, input),
32        Modal::Profiles { selected } => forms::draw_profiles(f, area, m, *selected),
33        Modal::Editor(editor) => forms::draw_editor(f, area, m, editor),
34        Modal::Secret(secret) => forms::draw_secret(f, area, m, secret),
35    }
36}
37
38/// The lines of the help: the keys of the screen being shown, then the global ones (TUI-HLP-01).
39pub fn help_lines(m: &Model) -> Vec<Line<'static>> {
40    let tx = m.tx();
41    let th = m.theme;
42    let scope = scope_of(m);
43    let mut lines = Vec::new();
44    for binding in keymap::help_for(scope) {
45        let Some(help) = binding.help else { continue };
46        lines.push(Line::from(vec![
47            Span::styled(format!("{:<16}", binding.label), th.key()),
48            Span::raw(tx.t(help)),
49        ]));
50    }
51    lines
52}
53
54fn draw_help(f: &mut Frame, area: Rect, m: &Model, scroll: u16) {
55    let tx = m.tx();
56    let rect = centered(area, 72, area.height.saturating_sub(4));
57    let inner = popup(f, rect, &tx.t("help.title"), m.theme.accent());
58    let [body, hint] = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(inner);
59    scrolling(f, body, help_lines(m), scroll);
60    f.render_widget(
61        Paragraph::new(tx.t("help.close")).style(m.theme.dim()),
62        hint,
63    );
64}
65
66fn draw_palette(f: &mut Frame, area: Rect, m: &Model, query: &str, selected: usize) {
67    let tx = m.tx();
68    let th = m.theme;
69    let rect = centered(area, 60, area.height.saturating_sub(6).min(18));
70    let inner = popup(f, rect, &tx.t("palette.title"), th.accent());
71    let [input, list] = Layout::vertical([Constraint::Length(2), Constraint::Min(1)]).areas(inner);
72    f.render_widget(
73        Paragraph::new(Line::from(vec![
74            Span::styled(": ", th.key()),
75            Span::raw(query.to_owned()),
76            Span::styled("▏", th.accent()),
77        ])),
78        input,
79    );
80    let matches = palette_matches(m.lang, query);
81    if matches.is_empty() {
82        f.render_widget(Paragraph::new(tx.t("palette.empty")).style(th.dim()), list);
83        return;
84    }
85    let height = usize::from(list.height);
86    let skip = selected.saturating_sub(height.saturating_sub(1));
87    let lines: Vec<Line> = matches
88        .iter()
89        .enumerate()
90        .skip(skip)
91        .take(height)
92        .map(|(i, (label, action))| {
93            let key = keymap::binding_of(*action).map_or("", |b| b.label);
94            let style = if i == selected {
95                th.selected()
96            } else {
97                Style::default()
98            };
99            Line::from(vec![
100                Span::styled(
101                    format!(
102                        "{}{label}",
103                        if i == selected { th.selector() } else { "  " }
104                    ),
105                    style,
106                ),
107                Span::styled(format!("  {key}"), th.dim()),
108            ])
109        })
110        .collect();
111    f.render_widget(Paragraph::new(lines), list);
112}
113
114/// The summary of the selected topic, as a dialog.
115fn draw_topic_summary(f: &mut Frame, area: Rect, m: &Model) {
116    let tx = m.tx();
117    let th = m.theme;
118    let Some(topic) = crate::tui::screens::topics::selected(m) else {
119        return;
120    };
121    let lines = crate::tui::screens::topics::view::summary_lines(m, &topic);
122    let height = u16::try_from(lines.len())
123        .unwrap_or(u16::MAX)
124        .saturating_add(4);
125    let rect = centered(area, 90, height);
126    let inner = popup(
127        f,
128        rect,
129        &tx.tw("topic.dialog-summary", &[("name", &topic.name)]),
130        th.accent(),
131    );
132    let [body, hint] = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(inner);
133    f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), body);
134    f.render_widget(Paragraph::new(tx.t("summary.hint")).style(th.dim()), hint);
135}
136
137/// The actions of the selected item, each with its key; one that cannot run now is dim and says why.
138fn draw_menu(f: &mut Frame, area: Rect, m: &Model, menu: &crate::tui::model::Menu) {
139    let tx = m.tx();
140    let th = m.theme;
141    let height = u16::try_from(menu.items.len())
142        .unwrap_or(u16::MAX)
143        .saturating_add(4);
144    let rect = centered(area, 76, height.saturating_add(0));
145    let inner = popup(f, rect, &menu.title, th.accent());
146    let [list, hint] = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(inner);
147    let lines: Vec<Line> = menu
148        .items
149        .iter()
150        .enumerate()
151        .map(|(i, action)| {
152            let reason = crate::tui::menu::gate(m, *action);
153            let key = crate::tui::menu::shortcut(m.tab, *action).unwrap_or("");
154            let style = match (i == menu.selected, reason.is_some()) {
155                (true, _) => th.selected(),
156                (false, true) => th.dim(),
157                (false, false) => Style::default(),
158            };
159            let mut spans = vec![Span::styled(
160                format!(
161                    "{}{:<30}",
162                    if i == menu.selected {
163                        th.selector()
164                    } else {
165                        "  "
166                    },
167                    tx.t(crate::tui::menu::label_key(*action))
168                ),
169                style,
170            )];
171            spans.push(Span::styled(format!(" {key:<6}"), th.key()));
172            Line::from(spans)
173        })
174        .collect();
175    f.render_widget(Paragraph::new(lines), list);
176    // The line below says why the entry under the cursor cannot run, or how to use the menu.
177    let why = menu
178        .items
179        .get(menu.selected)
180        .and_then(|action| crate::tui::menu::gate(m, *action));
181    let note = match why {
182        Some(reason) => Paragraph::new(format!("{} {}", th.icon(Severity::Warning), tx.t(reason)))
183            .style(th.warning()),
184        None => Paragraph::new(tx.t("menu.hint")).style(th.dim()),
185    };
186    f.render_widget(note, hint);
187}
188
189fn draw_language(f: &mut Frame, area: Rect, m: &Model, selected: usize) {
190    let tx = m.tx();
191    let th = m.theme;
192    let rect = centered(area, 36, 7);
193    let inner = popup(f, rect, &tx.t("language.title"), th.accent());
194    let mut lines: Vec<Line> = LANGS
195        .iter()
196        .enumerate()
197        .map(|(i, lang)| {
198            let style = if i == selected {
199                th.selected()
200            } else {
201                Style::default()
202            };
203            Line::from(Span::styled(
204                format!(
205                    "{}{} ({})",
206                    if i == selected { th.selector() } else { "  " },
207                    lang.name(),
208                    lang.code()
209                ),
210                style,
211            ))
212        })
213        .collect();
214    lines.push(Line::default());
215    lines.push(Line::from(Span::styled(tx.t("language.hint"), th.dim())));
216    f.render_widget(Paragraph::new(lines), inner);
217}
218
219fn pending_keys(pending: &Pending) -> (&'static str, &'static str) {
220    match pending {
221        Pending::Drain(_) => ("confirm.drain.title", "confirm.drain.body"),
222        Pending::Return(_) => ("confirm.return.title", "confirm.return.body"),
223        Pending::FlushNode(_) => ("confirm.flush-node.title", "confirm.flush-node.body"),
224        Pending::FlushAll => ("confirm.flush-all.title", "confirm.flush-all.body"),
225        Pending::Stop => ("confirm.stop.title", "confirm.stop.body"),
226        Pending::RevokeToken(..) => ("confirm.revoke.title", "confirm.revoke.body"),
227        Pending::RemoveProfile(_) => (
228            "confirm.remove-profile.title",
229            "confirm.remove-profile.body",
230        ),
231        Pending::DeleteTopic(_) => ("confirm.delete-topic.title", "confirm.delete-topic.body"),
232        Pending::Prune(_) => ("confirm.prune.title", "confirm.prune.body"),
233    }
234}
235
236fn draw_confirm(f: &mut Frame, area: Rect, m: &Model, confirm: &Confirm) {
237    let tx = m.tx();
238    let th = m.theme;
239    let (title, body) = pending_keys(&confirm.pending);
240    let node = match &confirm.pending {
241        Pending::Drain(id) | Pending::Return(id) | Pending::FlushNode(id) => short(id),
242        Pending::RevokeToken(_, name)
243        | Pending::RemoveProfile(name)
244        | Pending::DeleteTopic(name)
245        | Pending::Prune(name) => name.clone(),
246        _ => String::new(),
247    };
248    let rect = centered(area, 64, 14);
249    let inner = popup(f, rect, &tx.t(title), th.warning());
250    let [text, input, buttons] = Layout::vertical([
251        Constraint::Min(3),
252        Constraint::Length(3),
253        Constraint::Length(1),
254    ])
255    .areas(inner);
256    f.render_widget(
257        Paragraph::new(tx.tw(body, &[("node", &node), ("name", &node)])).wrap(Wrap { trim: false }),
258        text,
259    );
260    if let Some(typed) = &confirm.typed {
261        let ok = typed.input == typed.expected;
262        let prompt = tx.tw("confirm.type", &[("name", &typed.expected)]);
263        f.render_widget(
264            Paragraph::new(vec![
265                Line::from(Span::styled(prompt, th.dim())),
266                Line::from(vec![
267                    Span::styled("> ", th.key()),
268                    Span::styled(
269                        typed.input.clone(),
270                        if ok { th.ok() } else { Style::default() },
271                    ),
272                    Span::styled("▏", th.accent()),
273                ]),
274            ]),
275            input,
276        );
277    }
278    let button = |label: String, focused: bool, style: Style| {
279        let text = format!(" [ {label} ] ");
280        if focused {
281            Span::styled(text, th.selected().patch(style))
282        } else {
283            Span::raw(text)
284        }
285    };
286    let typed_ready = confirm
287        .typed
288        .as_ref()
289        .is_some_and(|t| t.input == t.expected);
290    let confirm_focus = confirm.ok_focused || typed_ready;
291    f.render_widget(
292        Paragraph::new(Line::from(vec![
293            button(tx.t("confirm.cancel"), !confirm_focus, Style::default()),
294            button(tx.t("confirm.ok"), confirm_focus, th.critical()),
295        ]))
296        .alignment(ratatui::layout::Alignment::Center),
297        buttons,
298    );
299}
300
301fn flush_lines(m: &Model, report: &FlushReport) -> Vec<Line<'static>> {
302    let tx = m.tx();
303    let th = m.theme;
304    let lang = m.lang;
305    let mut lines = vec![if report.complete {
306        Line::from(Span::styled(
307            format!(
308                "{} {}",
309                th.icon(Severity::Ok),
310                tx.t("report.flush.complete")
311            ),
312            th.ok(),
313        ))
314    } else {
315        Line::from(Span::styled(
316            format!(
317                "{} {}",
318                th.icon(Severity::Warning),
319                tx.t("report.flush.incomplete")
320            ),
321            th.warning(),
322        ))
323    }];
324    for node in &report.nodes {
325        let name = node.node_id.as_deref().map_or("—".to_owned(), short);
326        let (severity, text) = if node.complete {
327            (Severity::Ok, tx.t("report.flush.node-complete"))
328        } else if let Some(error) = &node.error {
329            (
330                Severity::Critical,
331                tx.tw("report.flush.node-error", &[("error", error)]),
332            )
333        } else if node.timed_out {
334            (Severity::Warning, tx.t("report.flush.node-timeout"))
335        } else {
336            (
337                Severity::Warning,
338                tx.tw(
339                    "report.flush.node-pending",
340                    &[
341                        ("outbox", &i18n::group(lang, node.outbox_pending)),
342                        ("memory", &i18n::group(lang, node.memory_remaining)),
343                    ],
344                ),
345            )
346        };
347        lines.push(Line::from(Span::styled(
348            format!("{} {name}  {text}", th.icon(severity)),
349            th.severity(severity),
350        )));
351    }
352    lines.push(Line::default());
353    lines.push(Line::from(Span::styled(
354        tx.t("report.flush.repeat"),
355        th.dim(),
356    )));
357    lines
358}
359
360fn stop_lines(m: &Model, report: &StopReport) -> Vec<Line<'static>> {
361    let tx = m.tx();
362    let th = m.theme;
363    let mut lines = vec![Line::from(Span::styled(
364        tx.tn("report.stop.stopped", report.stopped.len() as u64),
365        th.title(),
366    ))];
367    for (node, drained) in &report.nodes {
368        let severity = if *drained {
369            Severity::Ok
370        } else {
371            Severity::Warning
372        };
373        let state = tx.t(if *drained {
374            "report.stop.drained"
375        } else {
376            "report.stop.not-drained"
377        });
378        lines.push(Line::from(Span::styled(
379            format!("{} {}  {state}", th.icon(severity), short(node)),
380            th.severity(severity),
381        )));
382    }
383    lines.push(Line::from(if report.stop_list {
384        Span::styled(tx.t("report.stop.list"), th.ok())
385    } else {
386        Span::styled(tx.t("report.stop.no-list"), th.warning())
387    }));
388    for warning in &report.warnings {
389        lines.push(Line::from(Span::styled(
390            format!("{} {warning}", th.icon(Severity::Warning)),
391            th.warning(),
392        )));
393    }
394    lines.push(Line::default());
395    lines.push(Line::from(Span::styled(
396        tx.t("report.stop.processes"),
397        th.dim(),
398    )));
399    lines
400}
401
402fn failure_lines(m: &Model, fail: &Fail) -> Vec<Line<'static>> {
403    let tx = m.tx();
404    let th = m.theme;
405    let mut lines = vec![Line::from(Span::styled(
406        format!(
407            "{} {}: {}",
408            th.icon(Severity::Critical),
409            fail.code,
410            fail.message
411        ),
412        th.critical(),
413    ))];
414    if fail.is_denied() {
415        lines.push(Line::default());
416        lines.push(Line::from(denied_text(m, fail)));
417    } else if fail.is_offline() {
418        lines.push(Line::default());
419        lines.push(Line::from(tx.t("report.failure.offline")));
420    } else if fail.code == "backup.not-configured" {
421        lines.push(Line::default());
422        lines.push(Line::from(tx.t("report.failure.no-backup")));
423    } else if fail.status == 404 && fail.code.contains("route") {
424        lines.push(Line::default());
425        lines.push(Line::from(tx.t("report.failure.no-ops-port")));
426    } else if fail.status == 502 || fail.status == 504 {
427        lines.push(Line::default());
428        lines.push(Line::from(tx.t("report.failure.node-hint")));
429    }
430    if let Some(id) = &fail.request_id {
431        lines.push(Line::default());
432        lines.push(Line::from(Span::styled(
433            tx.tw("report.failure.request-id", &[("id", id)]),
434            th.dim(),
435        )));
436    }
437    lines
438}
439
440fn draw_report(f: &mut Frame, area: Rect, m: &Model, report: &Report) {
441    let tx = m.tx();
442    let th = m.theme;
443    let (title_key, lines, style) = match report {
444        Report::Flush(r) => ("report.flush.title", flush_lines(m, r), th.accent()),
445        Report::Stop(r) => ("report.stop.title", stop_lines(m, r), th.accent()),
446        Report::Failure { fail, .. } => (
447            "report.failure.title",
448            failure_lines(m, fail),
449            th.critical(),
450        ),
451    };
452    let height = u16::try_from(lines.len())
453        .unwrap_or(u16::MAX)
454        .saturating_add(4);
455    let rect = centered(area, 72, height.min(area.height.saturating_sub(2)));
456    let inner = popup(f, rect, &tx.t(title_key), style);
457    let [body, hint] = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(inner);
458    scrolling(f, body, lines, 0);
459    f.render_widget(Paragraph::new(tx.t("popup.close")).style(th.dim()), hint);
460}
461
462fn draw_condition(f: &mut Frame, area: Rect, m: &Model, name: &str) {
463    let tx = m.tx();
464    let th = m.theme;
465    let rect = centered(area, 70, 14);
466    let inner = popup(f, rect, &tx.t(&format!("condition.{name}")), th.warning());
467    let [body, hint] = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(inner);
468    let lines = vec![
469        Line::from(Span::styled(tx.t("condition.means"), th.title())),
470        Line::from(tx.t(&format!("condition.{name}.meaning"))),
471        Line::default(),
472        Line::from(Span::styled(tx.t("condition.todo"), th.title())),
473        Line::from(tx.t(&format!("condition.{name}.action"))),
474    ];
475    f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), body);
476    f.render_widget(Paragraph::new(tx.t("popup.close")).style(th.dim()), hint);
477}
478
479fn draw_input(f: &mut Frame, area: Rect, m: &Model, input: &Input) {
480    let tx = m.tx();
481    let th = m.theme;
482    let (title, prompt, hint) = match input.purpose {
483        InputPurpose::ExportTopicCsv => ("export.title", "export.prompt", "export.hint"),
484        InputPurpose::ExportCsv { .. } => ("export.title", "export.prompt", "export.hint"),
485        InputPurpose::TokenFilter => ("filter.title", "filter.prompt", "filter.hint"),
486        InputPurpose::TopicFilter => ("topic-filter.title", "topic-filter.prompt", "filter.hint"),
487    };
488    let rect = centered(area, 64, 7);
489    let inner = popup(f, rect, &tx.t(title), th.accent());
490    f.render_widget(
491        Paragraph::new(vec![
492            Line::from(Span::styled(tx.t(prompt), th.dim())),
493            Line::from(vec![
494                Span::styled("> ", th.key()),
495                Span::raw(input.value.clone()),
496                Span::styled("▏", th.accent()),
497            ]),
498            Line::default(),
499            Line::from(Span::styled(tx.t(hint), th.dim())),
500        ]),
501        inner,
502    );
503}
504
505#[cfg(test)]
506mod tests;