tui_kit/footer.rs
1//! Footer / keybind bar widget.
2//!
3//! Renders a single-line row of keyboard shortcut hints at the bottom of the screen.
4//! Each hint is a `(key, action)` pair styled as:
5//!
6//! ```text
7//! Navigate: j/k | Open: Enter | Leader: space | Quit: q
8//! ^^^^^^^^^^ ^^^ ^^^^ ^^^^^ ^^^^ ^
9//! action key action key action separator
10//! (dark gray)(yellow) (dark gray)
11//! ```
12//!
13//! ## Usage
14//!
15//! ```rust
16//! // In your render function, at the bottom of the layout:
17//! render_footer(f, chunks[FOOTER], &[
18//! ("j/k", "navigate"),
19//! ("enter", "open"),
20//! ("space", "leader"),
21//! ("q", "quit"),
22//! ], &theme);
23//! ```
24//!
25//! The pairs are context-aware — build them based on the current app state
26//! (e.g. show different hints when a popup is open):
27//!
28//! ```rust
29//! let mut pairs: Vec<(&str, &str)> = Vec::new();
30//! if popup_open {
31//! pairs.push(("Esc", "close"));
32//! pairs.push(("Enter", "confirm"));
33//! } else {
34//! pairs.push(("j/k", "navigate"));
35//! pairs.push(("space", "leader"));
36//! pairs.push(("q", "quit"));
37//! }
38//! render_footer(f, footer_area, &pairs, &theme);
39//! ```
40//!
41//! ## Optional right-hand label
42//!
43//! Use [`render_footer_with_app`] to also show an app name + version pinned to
44//! the right edge (as rad does):
45//!
46//! ```rust
47//! render_footer_with_app(f, chunks[FOOTER], &pairs, "myapp", "1.0.0", &theme);
48//! ```
49
50use ratatui::{
51 layout::{Alignment, Constraint, Direction, Layout, Rect},
52 text::{Line, Span},
53 widgets::Paragraph,
54 Frame,
55};
56
57use crate::theme::Theme;
58
59/// Render a keybind bar from a list of `(key, action)` pairs.
60///
61/// Pairs are separated by `" | "` and styled with the theme's
62/// `shortcut_key` (yellow) for keys and `hint` (dark gray) for actions and
63/// separators. A 1-column margin is applied on both sides so the bar aligns
64/// with bordered widgets that have their own borders.
65///
66/// # Arguments
67/// * `pairs` — slice of `(key_label, action_label)`. Action labels should be
68/// lowercase, no leading colon needed (the colon is added automatically).
69pub fn render_footer(f: &mut Frame, area: Rect, pairs: &[(&str, &str)], theme: &Theme) {
70 let inner = horizontal_margin(area);
71 f.render_widget(Paragraph::new(Line::from(keybind_spans(pairs, theme))), inner);
72}
73
74/// Like [`render_footer`] but also renders `"name version"` right-aligned.
75///
76/// Useful for showing the app name and version in the bottom-right corner,
77/// exactly as the rad project does.
78pub fn render_footer_with_app(
79 f: &mut Frame,
80 area: Rect,
81 pairs: &[(&str, &str)],
82 app_name: &str,
83 app_version: &str,
84 theme: &Theme,
85) {
86 let inner = horizontal_margin(area);
87 let right_width = (app_name.len() + 1 + app_version.len()) as u16;
88 let chunks = Layout::default()
89 .direction(Direction::Horizontal)
90 .constraints([Constraint::Min(0), Constraint::Length(right_width)])
91 .split(inner);
92
93 f.render_widget(
94 Paragraph::new(Line::from(keybind_spans(pairs, theme))),
95 chunks[0],
96 );
97
98 let right_line = Line::from(vec![
99 Span::styled(app_name.to_string(), theme.shortcut_key),
100 Span::styled(format!(" {}", app_version), theme.hint),
101 ]);
102 f.render_widget(
103 Paragraph::new(right_line).alignment(Alignment::Right),
104 chunks[1],
105 );
106}
107
108/// Build the `Vec<Span>` for a keybind bar without rendering it.
109///
110/// Useful when you need to embed the hints inside a larger `Line` or a block
111/// title rather than a full-width footer row.
112pub fn keybind_spans<'a>(pairs: &[(&'a str, &'a str)], theme: &Theme) -> Vec<Span<'a>> {
113 let mut spans = Vec::new();
114 for (i, (key, action)) in pairs.iter().enumerate() {
115 if i > 0 {
116 spans.push(Span::styled(" | ", theme.hint));
117 }
118 spans.push(Span::styled(*action, theme.shortcut_key));
119 spans.push(Span::styled(format!(": {}", key), theme.hint));
120 }
121 spans
122}
123
124/// Apply a 1-column left+right margin so the footer aligns with bordered panels.
125fn horizontal_margin(area: Rect) -> Rect {
126 let chunks = Layout::default()
127 .direction(Direction::Horizontal)
128 .constraints([Constraint::Length(1), Constraint::Min(0), Constraint::Length(1)])
129 .split(area);
130 chunks[1]
131}