1use 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
38pub 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
114fn 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
137fn 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 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;