npm_run_scripts/tui/widgets/
footer.rs

1//! Footer widget for the TUI.
2
3use 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
13/// Footer widget showing keybinding hints.
14pub struct Footer<'a> {
15    mode: &'a AppMode,
16    theme: &'a Theme,
17}
18
19impl<'a> Footer<'a> {
20    /// Create a new footer widget.
21    pub fn new(mode: &'a AppMode, theme: &'a Theme) -> Self {
22        Self { mode, theme }
23    }
24
25    /// Get keybinding hints for the current mode.
26    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    /// Build the footer line with adaptive width.
53    fn build_line(&self, width: u16) -> Line<'a> {
54        let hints = self.get_hints();
55
56        // Calculate total width needed for full hints
57        let full_width: usize = hints
58            .iter()
59            .map(|(key, action)| key.len() + action.len() + 3) // "key action  "
60            .sum();
61
62        // Determine display mode based on available width
63        let mut spans = vec![Span::raw(" ")];
64
65        if (width as usize) >= full_width + 2 {
66            // Full display
67            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            // Compact display (just keys)
76            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            // Ultra compact - just show first few hints
84            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
109/// Simple message footer for status messages.
110pub struct MessageFooter<'a> {
111    message: &'a str,
112    theme: &'a Theme,
113    is_error: bool,
114}
115
116impl<'a> MessageFooter<'a> {
117    /// Create a new message footer.
118    pub fn new(message: &'a str, theme: &'a Theme) -> Self {
119        Self {
120            message,
121            theme,
122            is_error: false,
123        }
124    }
125
126    /// Mark this as an error message.
127    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        // Full width
195        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")); // Full text
198
199        // Narrow width
200        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        // Should still have something
204        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}