npm_run_scripts/tui/widgets/
footer.rs1use ratatui::{
4 buffer::Buffer,
5 layout::Rect,
6 text::{Line, Span},
7 widgets::{Paragraph, Widget},
8};
9
10use crate::tui::app::AppMode;
11use crate::tui::theme::Theme;
12
13pub struct Footer<'a> {
15 mode: &'a AppMode,
16 theme: &'a Theme,
17}
18
19impl<'a> Footer<'a> {
20 pub fn new(mode: &'a AppMode, theme: &'a Theme) -> Self {
22 Self { mode, theme }
23 }
24
25 fn get_hints(&self) -> Vec<(&'static str, &'static str)> {
27 match self.mode {
28 AppMode::Normal => vec![
29 ("j/k", "move"),
30 ("Enter", "run"),
31 ("1-9", "quick"),
32 ("/", "filter"),
33 ("?", "help"),
34 ("q", "quit"),
35 ],
36 AppMode::Filter { .. } => vec![("j/k", "move"), ("Enter", "run"), ("Esc", "cancel")],
37 AppMode::Help => vec![("any key", "close")],
38 AppMode::Error { .. } => vec![("any key", "dismiss")],
39 AppMode::MultiSelect { .. } => {
40 vec![("Space", "toggle"), ("Enter", "run"), ("Esc", "cancel")]
41 }
42 AppMode::Args { .. } => vec![("Enter", "run"), ("Esc", "cancel")],
43 AppMode::WorkspaceSelect => vec![
44 ("j/k", "move"),
45 ("Enter", "select"),
46 ("1-9", "quick"),
47 ("q", "quit"),
48 ],
49 }
50 }
51
52 fn build_line(&self, width: u16) -> Line<'a> {
54 let hints = self.get_hints();
55
56 let full_width: usize = hints
58 .iter()
59 .map(|(key, action)| key.len() + action.len() + 3) .sum();
61
62 let mut spans = vec![Span::raw(" ")];
64
65 if (width as usize) >= full_width + 2 {
66 for (i, (key, action)) in hints.iter().enumerate() {
68 spans.push(Span::styled(*key, self.theme.key()));
69 spans.push(Span::styled(format!(" {} ", action), self.theme.footer()));
70 if i < hints.len() - 1 {
71 spans.push(Span::styled(" ", self.theme.footer()));
72 }
73 }
74 } else if (width as usize) >= hints.len() * 4 {
75 for (i, (key, _)) in hints.iter().enumerate() {
77 spans.push(Span::styled(*key, self.theme.key()));
78 if i < hints.len() - 1 {
79 spans.push(Span::styled(" ", self.theme.footer()));
80 }
81 }
82 } else {
83 let max_hints = ((width as usize) / 6).max(1).min(hints.len());
85 for (i, (key, _)) in hints.iter().take(max_hints).enumerate() {
86 spans.push(Span::styled(*key, self.theme.key()));
87 if i < max_hints - 1 {
88 spans.push(Span::styled(" ", self.theme.footer()));
89 }
90 }
91 }
92
93 Line::from(spans)
94 }
95}
96
97impl Widget for Footer<'_> {
98 fn render(self, area: Rect, buf: &mut Buffer) {
99 if area.height == 0 {
100 return;
101 }
102
103 let line = self.build_line(area.width);
104 let paragraph = Paragraph::new(line);
105 paragraph.render(area, buf);
106 }
107}
108
109pub struct MessageFooter<'a> {
111 message: &'a str,
112 theme: &'a Theme,
113 is_error: bool,
114}
115
116impl<'a> MessageFooter<'a> {
117 pub fn new(message: &'a str, theme: &'a Theme) -> Self {
119 Self {
120 message,
121 theme,
122 is_error: false,
123 }
124 }
125
126 pub fn error(mut self) -> Self {
128 self.is_error = true;
129 self
130 }
131}
132
133impl Widget for MessageFooter<'_> {
134 fn render(self, area: Rect, buf: &mut Buffer) {
135 if area.height == 0 {
136 return;
137 }
138
139 let style = if self.is_error {
140 self.theme.error()
141 } else {
142 self.theme.footer()
143 };
144
145 let line = Line::from(vec![Span::raw(" "), Span::styled(self.message, style)]);
146 let paragraph = Paragraph::new(line);
147 paragraph.render(area, buf);
148 }
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154
155 #[test]
156 fn test_footer_normal_mode() {
157 let mode = AppMode::Normal;
158 let theme = Theme::default();
159 let footer = Footer::new(&mode, &theme);
160
161 let hints = footer.get_hints();
162 assert!(!hints.is_empty());
163 assert!(hints.iter().any(|(k, _)| *k == "q"));
164 }
165
166 #[test]
167 fn test_footer_filter_mode() {
168 let mode = AppMode::Filter {
169 query: String::new(),
170 };
171 let theme = Theme::default();
172 let footer = Footer::new(&mode, &theme);
173
174 let hints = footer.get_hints();
175 assert!(hints.iter().any(|(k, _)| *k == "Esc"));
176 }
177
178 #[test]
179 fn test_footer_help_mode() {
180 let mode = AppMode::Help;
181 let theme = Theme::default();
182 let footer = Footer::new(&mode, &theme);
183
184 let hints = footer.get_hints();
185 assert!(hints.iter().any(|(_, a)| *a == "close"));
186 }
187
188 #[test]
189 fn test_footer_adaptive_width() {
190 let mode = AppMode::Normal;
191 let theme = Theme::default();
192 let footer = Footer::new(&mode, &theme);
193
194 let line = footer.build_line(100);
196 let content: String = line.spans.iter().map(|s| s.content.to_string()).collect();
197 assert!(content.contains("move")); let footer = Footer::new(&mode, &theme);
201 let line = footer.build_line(20);
202 let content: String = line.spans.iter().map(|s| s.content.to_string()).collect();
203 assert!(!content.trim().is_empty());
205 }
206
207 #[test]
208 fn test_message_footer() {
209 let theme = Theme::default();
210 let _footer = MessageFooter::new("Script completed", &theme);
211 }
212
213 #[test]
214 fn test_message_footer_error() {
215 let theme = Theme::default();
216 let footer = MessageFooter::new("Error occurred", &theme).error();
217 assert!(footer.is_error);
218 }
219}