Skip to main content

taba_cli/tui/view/
mod.rs

1//! `view(model) -> frame` (doc 34, section 4): draws the whole interface from the model and changes nothing. The
2//! frame is a status line, the tabs, the screen of the tab, a footer, and at most one popup over them (TUI-FRM-*).
3
4use ratatui::layout::{Alignment, Constraint, Layout, Rect};
5use ratatui::style::Style;
6use ratatui::text::{Line, Span};
7use ratatui::widgets::{Block, Paragraph, Tabs, Wrap};
8use ratatui::Frame;
9
10use crate::tui::api::Severity;
11use crate::tui::i18n;
12use crate::tui::keymap::{self, Action};
13use crate::tui::model::{ClusterView, Model, Tab, TopicTab, TABS};
14use crate::tui::screens;
15use crate::tui::screens::cluster as cluster_screen;
16
17pub mod modal;
18pub mod widgets;
19
20/// The smallest terminal the interface is laid out for (TUI-FRM-06).
21pub const MIN_SIZE: (u16, u16) = (80, 24);
22
23pub fn view(f: &mut Frame, m: &Model) {
24    let area = f.area();
25    if area.width < MIN_SIZE.0 || area.height < MIN_SIZE.1 {
26        draw_too_small(f, area, m);
27        return;
28    }
29    let [status, tabs, body, footer] = Layout::vertical([
30        Constraint::Length(1),
31        Constraint::Length(1),
32        Constraint::Min(5),
33        Constraint::Length(1),
34    ])
35    .areas(area);
36    draw_status(f, status, m);
37    draw_tabs(f, tabs, m);
38    match m.tab {
39        Tab::Cluster => cluster_screen::view::draw(f, body, m),
40        Tab::Tokens => screens::tokens::view::draw(f, body, m),
41        Tab::Topics if m.topics.monitor.is_some() => {
42            screens::topics::monitor::view::draw(f, body, m)
43        }
44        Tab::Topics => screens::topics::view::draw(f, body, m),
45        Tab::Profiles => screens::profiles::view::draw(f, body, m),
46    }
47    draw_footer(f, footer, m);
48    modal::draw(f, area, m);
49}
50
51fn draw_too_small(f: &mut Frame, area: Rect, m: &Model) {
52    let tx = m.tx();
53    let text = tx.tw(
54        "frame.too-small",
55        &[
56            ("width", &area.width),
57            ("height", &area.height),
58            ("min_width", &MIN_SIZE.0),
59            ("min_height", &MIN_SIZE.1),
60        ],
61    );
62    f.render_widget(
63        Paragraph::new(text)
64            .alignment(Alignment::Center)
65            .wrap(Wrap { trim: true })
66            .style(m.theme.warning()),
67        area,
68    );
69}
70
71/// Profile, address, the worst condition (a word and a symbol) and the clock (TUI-FRM-01).
72fn draw_status(f: &mut Frame, area: Rect, m: &Model) {
73    let tx = m.tx();
74    let th = m.theme;
75    let (severity, word) = m.health();
76    let label = match word.as_str() {
77        "ok" | "offline" | "degraded" | "unknown" => tx.t(&format!("health.{word}")),
78        other => tx.t(&format!("condition.{other}")),
79    };
80    let icon = if word == "offline" {
81        th.offline()
82    } else {
83        th.icon(severity)
84    };
85    let mut spans = vec![
86        Span::styled(format!(" {} ", m.profile), th.title()),
87        Span::styled(m.url.clone(), th.dim()),
88        Span::raw("  "),
89        Span::styled(format!("{icon} {label}"), th.severity(severity)),
90    ];
91    let partial = m.status.data.as_ref().is_some_and(|s| s.partial)
92        || m.cluster.nodes.data.as_ref().is_some_and(|n| n.partial);
93    if partial {
94        spans.push(Span::styled(
95            format!("  {} {}", th.icon(Severity::Warning), tx.t("seal.short")),
96            th.warning(),
97        ));
98    }
99    let clock = format!("{} UTC ", i18n::clock(m.wall_ms));
100    let used: usize = spans.iter().map(Span::width).sum();
101    let pad = usize::from(area.width)
102        .saturating_sub(used + clock.chars().count())
103        .max(1);
104    spans.push(Span::raw(" ".repeat(pad)));
105    spans.push(Span::styled(clock, th.dim()));
106    f.render_widget(Paragraph::new(Line::from(spans)), area);
107}
108
109fn draw_tabs(f: &mut Frame, area: Rect, m: &Model) {
110    let tx = m.tx();
111    let titles: Vec<String> = TABS
112        .iter()
113        .enumerate()
114        .map(|(i, tab)| format!("{} {}", i + 1, tx.t(tab.name_key())))
115        .collect();
116    f.render_widget(
117        Tabs::new(titles)
118            .select(m.tab.index())
119            .highlight_style(m.theme.selected())
120            .divider("│")
121            .block(Block::default()),
122        area,
123    );
124}
125
126/// The short label of an action in the footer.
127fn hint_key(action: Action) -> &'static str {
128    match action {
129        Action::Detail => "hint.detail",
130        Action::Enter => "hint.actions",
131        Action::TopicCharts => "hint.charts",
132        Action::Drain => "hint.drain",
133        Action::Return => "hint.return",
134        Action::FlushNode => "hint.flush",
135        Action::FlushAll => "hint.flush-all",
136        Action::StopCluster => "hint.stop",
137        Action::Monitor => "hint.monitor",
138        Action::Help => "hint.help",
139        Action::Palette => "hint.palette",
140        Action::Quit => "hint.quit",
141        Action::Esc => "hint.back",
142        Action::Left | Action::Right | Action::Up | Action::Down => "hint.chart",
143        Action::ChartFull => "hint.full",
144        Action::WindowWider | Action::WindowNarrower => "hint.window",
145        Action::PauseChart => "hint.freeze",
146        Action::Scale => "hint.scale",
147        Action::Export => "hint.export",
148        Action::Language => "hint.language",
149        Action::Refresh => "hint.refresh",
150        Action::Pause => "hint.pause",
151        Action::Filter => "hint.filter",
152        Action::Sort => "hint.sort",
153        Action::SwitchProfile => "hint.profile",
154        Action::TokenNew | Action::TopicNew | Action::ProfileNew => "hint.new",
155        Action::TokenEdit | Action::TopicEdit | Action::ProfileEdit => "hint.edit",
156        Action::TokenClone => "hint.clone",
157        Action::TokenRevoke => "hint.revoke",
158        Action::TopicDelete => "hint.delete",
159        Action::TopicDead => "hint.dead",
160        Action::TopicMessages => "hint.messages",
161        Action::MsgPublish => "hint.publish",
162        Action::MsgReceive => "hint.receive",
163        Action::MsgAck => "hint.ack",
164        Action::MsgAckAll => "hint.ack-all",
165        Action::MsgRelease => "hint.release",
166        Action::MsgPrune => "hint.prune",
167        Action::ProfileUse => "hint.use",
168        Action::ProfileDefault => "hint.default",
169        Action::ProfileRemove => "hint.remove",
170        Action::ProfileTest => "hint.test",
171        _ => "hint.more",
172    }
173}
174
175/// The first key of an action, the one the footer shows.
176fn hint_key_label(action: Action) -> String {
177    keymap::binding_of(action)
178        .map(|b| b.label.split(' ').next().unwrap_or(b.label).to_owned())
179        .unwrap_or_default()
180}
181
182fn hint_actions(m: &Model) -> &'static [Action] {
183    match (m.tab, m.cluster.view) {
184        (Tab::Cluster, ClusterView::List) => &[
185            Action::Enter,
186            Action::Drain,
187            Action::Return,
188            Action::FlushNode,
189            Action::FlushAll,
190            Action::StopCluster,
191            Action::Monitor,
192            Action::Help,
193            Action::Quit,
194        ],
195        (Tab::Cluster, ClusterView::Detail) => &[
196            Action::Esc,
197            Action::Drain,
198            Action::Return,
199            Action::FlushNode,
200            Action::Monitor,
201            Action::Help,
202            Action::Quit,
203        ],
204        (Tab::Cluster, ClusterView::Monitor) => &[
205            Action::Esc,
206            Action::Up,
207            Action::ChartFull,
208            Action::WindowWider,
209            Action::PauseChart,
210            Action::Scale,
211            Action::Export,
212            Action::Help,
213        ],
214        (Tab::Tokens, _) => &[
215            Action::Enter,
216            Action::TokenNew,
217            Action::TokenEdit,
218            Action::TokenClone,
219            Action::TokenRevoke,
220            Action::Filter,
221            Action::Sort,
222            Action::Help,
223            Action::Quit,
224        ],
225        (Tab::Topics, _)
226            if m.topics
227                .monitor
228                .as_ref()
229                .is_some_and(|mon| mon.tab == TopicTab::Messages) =>
230        {
231            &[
232                Action::Esc,
233                Action::Enter,
234                Action::MsgPublish,
235                Action::MsgReceive,
236                Action::MsgAck,
237                Action::MsgAckAll,
238                Action::MsgRelease,
239                Action::MsgPrune,
240                Action::Help,
241            ]
242        }
243        (Tab::Topics, _) if m.topics.monitor.is_some() => &[
244            Action::Esc,
245            Action::Up,
246            Action::ChartFull,
247            Action::WindowWider,
248            Action::PauseChart,
249            Action::Scale,
250            Action::Export,
251            Action::Help,
252            Action::Quit,
253        ],
254        (Tab::Topics, _) => &[
255            Action::Enter,
256            Action::TopicCharts,
257            Action::TopicMessages,
258            Action::TopicNew,
259            Action::TopicEdit,
260            Action::TopicDelete,
261            Action::TopicDead,
262            Action::Filter,
263            Action::Sort,
264            Action::Help,
265            Action::Quit,
266        ],
267        (Tab::Profiles, _) => &[
268            Action::Enter,
269            Action::ProfileUse,
270            Action::ProfileNew,
271            Action::ProfileEdit,
272            Action::ProfileDefault,
273            Action::ProfileRemove,
274            Action::ProfileTest,
275            Action::Help,
276            Action::Quit,
277        ],
278    }
279}
280
281/// `true` when the footer should show this action as disabled right now.
282fn disabled(m: &Model, action: Action) -> bool {
283    if m.tab == Tab::Tokens {
284        return matches!(
285            action,
286            Action::TokenNew | Action::TokenEdit | Action::TokenClone | Action::TokenRevoke
287        ) && screens::tokens::gate(m, action).is_some();
288    }
289    if m.tab == Tab::Topics {
290        if m.topics.monitor.is_some() {
291            return matches!(
292                action,
293                Action::MsgPublish
294                    | Action::MsgReceive
295                    | Action::MsgAck
296                    | Action::MsgAckAll
297                    | Action::MsgRelease
298                    | Action::MsgPrune
299            ) && screens::topics::messages::gate(m, action).is_some();
300        }
301        return matches!(
302            action,
303            Action::TopicNew | Action::TopicEdit | Action::TopicDelete
304        ) && screens::topics::gate(m, action).is_some();
305    }
306    m.tab == Tab::Cluster
307        && matches!(
308            action,
309            Action::Drain
310                | Action::Return
311                | Action::FlushNode
312                | Action::FlushAll
313                | Action::StopCluster
314                | Action::Monitor
315                | Action::Detail
316        )
317        && cluster_screen::gate(m, action).is_some()
318}
319
320/// The state of the refresh, for the right of the footer (TUI-FRM-03).
321fn refresh_state(m: &Model) -> (String, Style) {
322    let tx = m.tx();
323    let th = m.theme;
324    let feeds_error = if m.uses_status() {
325        m.status
326            .error
327            .as_ref()
328            .map(|_| (m.status.fetched_at, m.status.retry_in(m.now)))
329    } else {
330        m.cluster
331            .nodes
332            .error
333            .as_ref()
334            .map(|_| (m.cluster.nodes.fetched_at, m.cluster.nodes.retry_in(m.now)))
335    };
336    if m.paused {
337        return (format!("⏸ {}", tx.t("footer.paused")), th.warning());
338    }
339    if let Some((fetched, retry)) = feeds_error {
340        let since = fetched.map_or_else(
341            || "—".to_owned(),
342            |at| i18n::clock(m.wall_ms - i64::try_from(m.now.saturating_sub(at)).unwrap_or(0)),
343        );
344        let retry = retry.unwrap_or(0);
345        return (
346            tx.tw("footer.stale", &[("since", &since), ("retry", &retry)]),
347            th.warning(),
348        );
349    }
350    if m.busy.is_some() {
351        return (
352            format!("{} {}", th.refresh(), tx.t("footer.working")),
353            th.accent(),
354        );
355    }
356    let every = if m.tab == Tab::Cluster {
357        2
358    } else {
359        m.history.interval_ms() / 1000
360    };
361    (format!("{} {every}s", th.refresh()), th.dim())
362}
363
364fn draw_footer(f: &mut Frame, area: Rect, m: &Model) {
365    let tx = m.tx();
366    let th = m.theme;
367    let (right_text, right_style) = refresh_state(m);
368    let right = format!("{right_text} · L {} ", m.lang.code());
369    let right_width = right.chars().count();
370    let left_width = usize::from(area.width).saturating_sub(right_width + 1);
371    let mut spans: Vec<Span> = Vec::new();
372    if let Some(notice) = &m.notice {
373        spans.push(Span::styled(
374            format!(" {} {}", th.icon(notice.severity), notice.text),
375            th.severity(notice.severity),
376        ));
377    } else {
378        // Help and quit always have room: the other hints fill what is left.
379        let mut all = hint_actions(m).to_vec();
380        // The language key is on every screen, right before the help.
381        if !all.contains(&Action::Language) {
382            let at = all
383                .iter()
384                .position(|a| *a == Action::Help)
385                .unwrap_or(all.len());
386            all.insert(at, Action::Language);
387        }
388        let is_tail = |a: &&Action| matches!(a, Action::Language | Action::Help | Action::Quit);
389        let tail: Vec<Action> = all.iter().filter(is_tail).copied().collect();
390        let width_of = |action: Action| {
391            let key = hint_key_label(action);
392            key.chars().count() + tx.t(hint_key(action)).chars().count() + 3
393        };
394        let reserved: usize = tail.iter().map(|a| width_of(*a)).sum();
395        let mut used = 0;
396        let mut shown = Vec::new();
397        for action in all.iter().filter(|a| !is_tail(a)) {
398            let width = width_of(*action);
399            if used + width + reserved > left_width {
400                break;
401            }
402            used += width;
403            shown.push(*action);
404        }
405        shown.extend(tail);
406        for action in shown {
407            let off = disabled(m, action);
408            spans.push(Span::styled(
409                format!(" {}", hint_key_label(action)),
410                if off { th.dim() } else { th.key() },
411            ));
412            spans.push(Span::styled(
413                format!(" {} ", tx.t(hint_key(action))),
414                if off { th.dim() } else { Style::default() },
415            ));
416        }
417    }
418    let used: usize = spans.iter().map(Span::width).sum();
419    let pad = usize::from(area.width).saturating_sub(used + right_width);
420    spans.push(Span::raw(" ".repeat(pad)));
421    spans.push(Span::styled(right, right_style));
422    f.render_widget(Paragraph::new(Line::from(spans)), area);
423}
424
425#[cfg(test)]
426mod tests;