1use 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
20pub 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
71fn 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
126fn 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
175fn 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
281fn 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
320fn 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 let mut all = hint_actions(m).to_vec();
380 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;