Skip to main content

rusticity_term/ui/
status.rs

1use crate::app::{App, Service, ViewMode};
2use crate::keymap::Mode;
3use crate::ui::cfn::DetailTab;
4use crate::ui::red_text;
5use ratatui::{prelude::*, widgets::*};
6use std::time::{SystemTime, UNIX_EPOCH};
7
8pub const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧"];
9
10pub fn first_hint(key: &'static str, action: &'static str) -> Vec<Span<'static>> {
11    vec![
12        Span::styled(key, red_text()),
13        Span::raw(" "),
14        Span::raw(action),
15        Span::raw(" ⋮"),
16    ]
17}
18
19pub fn hint(key: &'static str, action: &'static str) -> Vec<Span<'static>> {
20    vec![
21        Span::raw(" "),
22        Span::styled(key, red_text()),
23        Span::raw(" "),
24        Span::raw(action),
25        Span::raw(" ⋮"),
26    ]
27}
28
29pub fn last_hint(key: &'static str, action: &'static str) -> Vec<Span<'static>> {
30    vec![
31        Span::raw(" "),
32        Span::styled(key, red_text()),
33        Span::raw(" "),
34        Span::raw(action),
35    ]
36}
37
38fn common_detail_hotkeys() -> Vec<Span<'static>> {
39    let mut spans = vec![];
40    spans.extend(first_hint("↑↓", "scroll"));
41    spans.extend(hint("⎋", "back"));
42    spans.extend(hint("[]", "switch"));
43    spans.extend(hint("⇤⇥", "switch"));
44    spans.extend(hint("^u", "page up"));
45    spans.extend(hint("^d", "page down"));
46    spans.extend(hint("y", "yank"));
47    spans.extend(hint("^o", "console"));
48    spans.extend(hint("p", "preferences"));
49    spans.extend(hint("^p", "print"));
50    spans.extend(hint("^r", "refresh"));
51    spans.extend(hint("^w", "close"));
52    spans.extend(last_hint("q", "quit"));
53    spans
54}
55
56fn common_list_hotkeys() -> Vec<Span<'static>> {
57    let mut spans = vec![];
58    spans.extend(first_hint("↑↓", "scroll"));
59    spans.extend(hint("←→", "toggle"));
60    spans.extend(hint("⏎", "open"));
61    spans.extend(hint("[]", "switch"));
62    spans.extend(hint("^u", "page up"));
63    spans.extend(hint("^d", "page down"));
64    spans.extend(hint("y", "yank"));
65    spans.extend(hint("^o", "console"));
66    spans.extend(hint("p", "preferences"));
67    spans.extend(hint("^p", "print"));
68    spans.extend(hint("^r", "refresh"));
69    spans.extend(hint("^w", "close"));
70    spans.extend(last_hint("q", "quit"));
71    spans
72}
73
74pub fn render_bottom_bar(frame: &mut Frame, app: &App, area: Rect) {
75    let is_insert = match app.mode {
76        Mode::FilterInput | Mode::EventFilterInput | Mode::InsightsInput => true,
77        Mode::ServicePicker | Mode::SpaceMenu => app.service_picker.filter_active,
78        Mode::RegionPicker => app.region_filter_active,
79        Mode::SessionPicker => app.session_filter_active,
80        Mode::ProfilePicker => app.profile_filter_active,
81        _ => false,
82    };
83
84    let mode_indicator = if is_insert { " INSERT " } else { " NORMAL " };
85    let mode_style = if is_insert {
86        Style::default().bg(Color::Yellow).fg(Color::Black)
87    } else {
88        Style::default().bg(Color::Blue).fg(Color::White)
89    };
90
91    let help = if app.mode == Mode::ColumnSelector {
92        let mut hints = vec![];
93        hints.extend(hint("↑↓", "scroll"));
94        hints.extend(hint("␣", "toggle"));
95
96        if app.current_service == Service::CloudWatchAlarms {
97            hints.extend(hint("⇤⇥", "switch"));
98        }
99
100        hints.extend(last_hint("⎋", "close"));
101        hints
102    } else if app.mode == Mode::InsightsInput {
103        let mut hints = vec![];
104        hints.extend(hint(" tab", "switch"));
105        hints.extend(hint("↑↓", "scroll"));
106        hints.extend(hint("␣", "toggle"));
107        hints.extend(hint("⏎", "execute"));
108        hints.extend(hint("⎋", "cancel"));
109        hints.extend(hint("^r", "refresh"));
110        hints.extend(last_hint("^w", "close"));
111        hints
112    } else if app.current_service == Service::CloudWatchInsights {
113        let mut hints = vec![];
114        hints.extend(hint(" i", "insert"));
115        hints.extend(hint("⏎", "execute"));
116        hints.extend(hint("?", "help"));
117        hints.extend(hint("^r", "refresh"));
118        hints.extend(hint("^o", "console"));
119        hints.extend(hint("⎋", "back"));
120        hints.extend(hint("^w", "close"));
121        hints.extend(last_hint("q", "quit"));
122        hints
123    } else if app.mode == Mode::EventFilterInput {
124        let mut hints = vec![];
125        hints.extend(first_hint("⇤⇥", "switch"));
126        hints.extend(hint("␣", "change unit"));
127        hints.extend(hint("⏎", "apply"));
128        hints.extend(hint("⎋", "cancel"));
129        hints.extend(last_hint("^w", "close"));
130        hints
131    } else if app.mode == Mode::FilterInput {
132        let mut hints = vec![];
133        hints.extend(first_hint("⏎", "apply"));
134        hints.extend(hint("⎋", "cancel"));
135        hints.extend(hint("␣", "toggle"));
136        hints.extend(hint("⇤⇥", "switch"));
137        hints.extend(last_hint("^w", "close"));
138        hints
139    } else if app.view_mode == ViewMode::Events {
140        let mut hints = vec![];
141        hints.extend(first_hint("↑↓", "scroll"));
142        hints.extend(hint("←→", "toggle"));
143        hints.extend(hint("y", "yank"));
144        hints.extend(hint("^o", "console"));
145        hints.extend(hint("^r", "refresh"));
146        hints.extend(hint("p", "preferences"));
147        hints.extend(hint("^p", "print"));
148        hints.extend(hint("^w", "close"));
149        hints.extend(last_hint("q", "quit"));
150        hints
151    } else if app.view_mode == ViewMode::Detail {
152        let mut hints = vec![];
153        hints.extend(first_hint("↑↓", "scroll"));
154        hints.extend(hint("←→", "toggle"));
155        hints.extend(hint("⏎", "open"));
156        hints.extend(hint("⎋", "back"));
157        hints.extend(hint("i", "insert"));
158        hints.extend(hint("s", "sort"));
159        hints.extend(hint("o", "order"));
160        hints.extend(hint("<num>p", "page"));
161        hints.extend(hint("^o", "console"));
162        hints.extend(hint("⇤⇥", "switch"));
163        hints.extend(hint("^r", "refresh"));
164        hints.extend(hint("p", "preferences"));
165        hints.extend(hint("^p", "print"));
166        hints.extend(hint("^w", "close"));
167        hints.extend(last_hint("q", "quit"));
168        hints
169    } else if app.current_service == Service::EcrRepositories {
170        if app.ecr_state.current_repository.is_some() {
171            let mut hints = vec![];
172            hints.extend(first_hint("↑↓", "scroll"));
173            hints.extend(hint("←→", "toggle"));
174            hints.extend(hint("⎋", "back"));
175            hints.extend(hint("y", "yank"));
176            hints.extend(hint("^o", "console"));
177            hints.extend(hint("^r", "refresh"));
178            hints.extend(hint("^w", "close"));
179            hints.extend(last_hint("q", "quit"));
180            hints
181        } else {
182            let mut hints = vec![];
183            hints.extend(first_hint("↑↓", "scroll"));
184            hints.extend(hint("←→", "toggle"));
185            hints.extend(hint("⏎", "open"));
186            hints.extend(hint("⇤⇥", "switch"));
187            hints.extend(hint("y", "yank"));
188            hints.extend(hint("^o", "console"));
189            hints.extend(hint("^r", "refresh"));
190            hints.extend(hint("^w", "close"));
191            hints.extend(last_hint("q", "quit"));
192            hints
193        }
194    } else if app.current_service == Service::S3Buckets {
195        if app.s3_state.current_bucket.is_some() {
196            let mut hints = vec![];
197            hints.extend(first_hint("↑↓", "scroll"));
198            hints.extend(hint("←→", "toggle"));
199            hints.extend(hint("⏎", "open"));
200            hints.extend(hint("⎋", "back"));
201            hints.extend(hint("⇤⇥", "switch"));
202            hints.extend(hint("^o", "console"));
203            hints.extend(hint("^r", "refresh"));
204            hints.extend(hint("^w", "close"));
205            hints.extend(last_hint("q", "quit"));
206            hints
207        } else {
208            let mut hints = vec![];
209            hints.extend(first_hint("↑↓", "scroll"));
210            hints.extend(hint("←→", "toggle"));
211            hints.extend(hint("⏎", "open"));
212            hints.extend(hint("⇤⇥", "switch"));
213            hints.extend(hint("^o", "console"));
214            hints.extend(hint("^r", "refresh"));
215            hints.extend(hint("^w", "close"));
216            hints.extend(last_hint("q", "quit"));
217            hints
218        }
219    } else if app.current_service == Service::CloudFormationStacks {
220        if app.cfn_state.current_stack.is_some() {
221            // In stack detail view - customize hints based on tab
222            if app.cfn_state.detail_tab == DetailTab::Template
223                || app.cfn_state.detail_tab == DetailTab::GitSync
224            {
225                // Template and GitSync tabs: no preferences
226                let mut hints = vec![];
227                hints.extend(first_hint("↑↓", "scroll"));
228                hints.extend(hint("⎋", "back"));
229                hints.extend(hint("[]", "switch"));
230                hints.extend(hint("⇤⇥", "switch"));
231                hints.extend(hint("^u", "page up"));
232                hints.extend(hint("^d", "page down"));
233                hints.extend(hint("y", "yank"));
234                hints.extend(hint("^o", "console"));
235                hints.extend(hint("^r", "refresh"));
236                hints.extend(hint("^w", "close"));
237                hints.extend(last_hint("q", "quit"));
238                hints
239            } else {
240                common_detail_hotkeys()
241            }
242        } else {
243            // In stack list view - build custom hints with snapshot
244            let mut hints = vec![];
245            hints.extend(first_hint("↑↓", "scroll"));
246            hints.extend(hint("←→", "toggle"));
247            hints.extend(hint("⏎", "open"));
248            hints.extend(hint("[]", "switch"));
249            hints.extend(hint("^u", "page up"));
250            hints.extend(hint("^d", "page down"));
251            hints.extend(hint("y", "yank"));
252            hints.extend(hint("^p", "snapshot"));
253            hints.extend(hint("^o", "console"));
254            hints.extend(hint("^r", "refresh"));
255            hints.extend(hint("^w", "close"));
256            hints.extend(last_hint("q", "quit"));
257            hints
258        }
259    } else if app.current_service == Service::IamUsers {
260        if app.iam_state.current_user.is_some() {
261            common_detail_hotkeys()
262        } else {
263            common_list_hotkeys()
264        }
265    } else if app.current_service == Service::IamRoles {
266        if app.iam_state.current_role.is_some() {
267            common_detail_hotkeys()
268        } else {
269            common_list_hotkeys()
270        }
271    } else if app.current_service == Service::LambdaFunctions {
272        if app.lambda_state.current_function.is_some() {
273            common_detail_hotkeys()
274        } else {
275            common_list_hotkeys()
276        }
277    } else if app.view_mode == ViewMode::List {
278        let mut hints = vec![];
279        hints.extend(first_hint("↑↓", "scroll"));
280        hints.extend(hint("←→", "toggle"));
281        hints.extend(hint("⏎", "open"));
282        hints.extend(hint("^o", "console"));
283        hints.extend(hint("p", "preferences"));
284        hints.extend(hint("^p", "print"));
285        hints.extend(hint("^r", "refresh"));
286        hints.extend(hint("^w", "close"));
287        hints.extend(last_hint("q", "quit"));
288        hints
289    } else {
290        let mut hints = vec![];
291        hints.extend(first_hint("↑↓", "scroll"));
292        hints.extend(hint("←→", "toggle"));
293        hints.extend(hint("⏎", "open"));
294        hints.extend(hint("⎋", "back"));
295        hints.extend(hint("<num>p", "page"));
296        hints.extend(hint("^o", "console"));
297        hints.extend(hint("^r", "refresh"));
298        hints.extend(hint("p", "preferences"));
299        hints.extend(hint("^p", "print"));
300        hints.extend(hint("^w", "close"));
301        hints.extend(last_hint("q", "quit"));
302        hints
303    };
304
305    let millis = SystemTime::now()
306        .duration_since(UNIX_EPOCH)
307        .unwrap()
308        .as_millis();
309
310    let spinner_frame = if app.log_groups_state.loading {
311        SPINNER_FRAMES[(millis / 100 % SPINNER_FRAMES.len() as u128) as usize]
312    } else {
313        " "
314    };
315
316    let status_line_temp = if app.log_groups_state.loading {
317        let max_width = area.width.saturating_sub(10) as usize;
318        let msg = if app.log_groups_state.loading_message.len() > max_width {
319            format!(
320                "{}...",
321                &app.log_groups_state.loading_message[..max_width.saturating_sub(3)]
322            )
323        } else {
324            app.log_groups_state.loading_message.clone()
325        };
326        Some(Line::from(vec![Span::raw(msg)]))
327    } else if !app.page_input.is_empty() {
328        Some(Line::from(vec![Span::raw(format!(
329            "Go to page {} (press p to confirm)",
330            app.page_input
331        ))]))
332    } else {
333        None
334    };
335
336    let chunks = Layout::default()
337        .direction(Direction::Horizontal)
338        .constraints([
339            Constraint::Length(8),
340            Constraint::Length(2),
341            Constraint::Min(0),
342        ])
343        .split(area);
344
345    let mode_widget = Paragraph::new(mode_indicator).style(mode_style);
346
347    let spinner_widget = Paragraph::new(spinner_frame)
348        .block(Block::default())
349        .style(Style::default().bg(Color::DarkGray).fg(Color::Yellow));
350
351    if let Some(line) = status_line_temp {
352        let status_widget = Paragraph::new(line)
353            .alignment(Alignment::Left)
354            .style(Style::default().bg(Color::DarkGray).fg(Color::White));
355        frame.render_widget(mode_widget, chunks[0]);
356        frame.render_widget(spinner_widget, chunks[1]);
357        frame.render_widget(status_widget, chunks[2]);
358    } else {
359        // Build version string
360        let version = env!("CARGO_PKG_VERSION");
361        let commit = option_env!("GIT_HASH").unwrap_or("unknown");
362        let version_text = format!("⋮ RUSTICITY v{} (#{})", version, commit);
363
364        let colors = [
365            Color::Red,
366            Color::Green,
367            Color::Yellow,
368            Color::Blue,
369            Color::Magenta,
370            Color::Cyan,
371        ];
372        let seed = millis as usize;
373        let version_spans: Vec<Span> = version_text
374            .chars()
375            .enumerate()
376            .map(|(i, c)| {
377                let color = colors[(seed + i) % colors.len()];
378                Span::styled(c.to_string(), Style::default().fg(color))
379            })
380            .collect();
381        let version_line = Line::from(version_spans);
382        let version_width = version_text.len() as u16;
383
384        // Split status area into help (left) and version (right)
385        let status_chunks = Layout::default()
386            .direction(Direction::Horizontal)
387            .constraints([Constraint::Min(0), Constraint::Length(version_width)])
388            .split(chunks[2]);
389
390        let help_widget = Paragraph::new(Line::from(help))
391            .block(Block::default())
392            .alignment(Alignment::Left)
393            .style(Style::default().bg(Color::DarkGray).fg(Color::White));
394
395        let version_widget = Paragraph::new(version_line)
396            .alignment(Alignment::Right)
397            .style(Style::default().bg(Color::DarkGray).fg(Color::White));
398
399        frame.render_widget(mode_widget, chunks[0]);
400        frame.render_widget(spinner_widget, chunks[1]);
401        frame.render_widget(help_widget, status_chunks[0]);
402        frame.render_widget(version_widget, status_chunks[1]);
403    }
404}
405
406#[cfg(test)]
407mod tests {
408    #[test]
409    fn test_version_string_format() {
410        let version = env!("CARGO_PKG_VERSION");
411        let commit = option_env!("GIT_HASH").unwrap_or("unknown");
412        let version_text = format!("RUSTICITY v{} (#{})", version, commit);
413
414        assert!(version_text.starts_with("RUSTICITY v"));
415        assert!(version_text.contains("#"));
416    }
417
418    #[test]
419    fn test_version_padding_calculation() {
420        // Simulate help text width
421        let help_text = " ↑↓ scroll ⋮ ←→ toggle ⋮ ⏎ open ⋮ ^o console ⋮ p preferences ⋮ ^p print ⋮ ^r refresh ⋮ ^w close ⋮ q quit ";
422        let help_width: usize = help_text.len();
423
424        let version = env!("CARGO_PKG_VERSION");
425        let commit = option_env!("GIT_HASH").unwrap_or("unknown");
426        let version_text = format!("RUSTICITY v{} (#{})", version, commit);
427        let version_width: usize = version_text.len();
428
429        // Simulate terminal width of 200
430        let status_width: usize = 200;
431        let padding: usize = status_width
432            .saturating_sub(help_width)
433            .saturating_sub(version_width);
434
435        // Total should equal status_width
436        assert_eq!(help_width + padding + version_width, status_width);
437    }
438
439    #[test]
440    fn test_preferences_hint_uses_p_not_ctrl_p() {
441        use super::common_list_hotkeys;
442        let spans = common_list_hotkeys();
443        let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
444
445        // Should have "p preferences" not "^p preferences"
446        assert!(
447            text.contains("p preferences"),
448            "Should show 'p preferences'"
449        );
450        assert!(
451            !text.contains("^p preferences"),
452            "Should not show '^p preferences'"
453        );
454    }
455
456    #[test]
457    fn test_print_hint_uses_ctrl_p() {
458        use super::common_list_hotkeys;
459        let spans = common_list_hotkeys();
460        let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
461
462        // Should have "^p print" for copy to clipboard
463        assert!(
464            text.contains("^p print"),
465            "Should show '^p print' for copy to clipboard"
466        );
467    }
468
469    #[test]
470    fn test_yank_hint_uses_y() {
471        use super::common_list_hotkeys;
472        let spans = common_list_hotkeys();
473        let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
474
475        // Should have "y yank" for copying selected item
476        assert!(
477            text.contains("y yank"),
478            "Should show 'y yank' for copying selected item"
479        );
480    }
481
482    #[test]
483    fn test_common_detail_hotkeys_has_correct_hints() {
484        use super::common_detail_hotkeys;
485        let spans = common_detail_hotkeys();
486        let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
487
488        // Detail views should have p for preferences and ^p for print
489        assert!(
490            text.contains("p preferences"),
491            "Detail view should show 'p preferences'"
492        );
493        assert!(
494            text.contains("^p print"),
495            "Detail view should show '^p print'"
496        );
497    }
498
499    #[test]
500    fn test_no_columns_hint_anywhere() {
501        let list_spans = super::common_list_hotkeys();
502        let list_text: String = list_spans.iter().map(|s| s.content.as_ref()).collect();
503
504        // Should never show "columns", always "preferences"
505        assert!(
506            !list_text.contains("p columns"),
507            "Should not show 'p columns', use 'p preferences' instead"
508        );
509
510        let detail_spans = super::common_detail_hotkeys();
511        let detail_text: String = detail_spans.iter().map(|s| s.content.as_ref()).collect();
512        assert!(
513            !detail_text.contains("p columns"),
514            "Should not show 'p columns', use 'p preferences' instead"
515        );
516    }
517
518    #[test]
519    fn test_all_views_have_print_hotkey() {
520        // Test list view
521        let list_spans = super::common_list_hotkeys();
522        let list_text: String = list_spans.iter().map(|s| s.content.as_ref()).collect();
523        assert!(
524            list_text.contains("^p print"),
525            "List view must have '^p print' hotkey"
526        );
527
528        // Test detail view
529        let detail_spans = super::common_detail_hotkeys();
530        let detail_text: String = detail_spans.iter().map(|s| s.content.as_ref()).collect();
531        assert!(
532            detail_text.contains("^p print"),
533            "Detail view must have '^p print' hotkey"
534        );
535    }
536
537    #[test]
538    fn test_preferences_always_uses_p_not_ctrl_p() {
539        let list_spans = super::common_list_hotkeys();
540        let list_text: String = list_spans.iter().map(|s| s.content.as_ref()).collect();
541        assert!(
542            list_text.contains("p preferences"),
543            "Should use 'p preferences'"
544        );
545        assert!(
546            !list_text.contains("^p preferences"),
547            "Should not use '^p preferences'"
548        );
549
550        let detail_spans = super::common_detail_hotkeys();
551        let detail_text: String = detail_spans.iter().map(|s| s.content.as_ref()).collect();
552        assert!(
553            detail_text.contains("p preferences"),
554            "Should use 'p preferences'"
555        );
556        assert!(
557            !detail_text.contains("^p preferences"),
558            "Should not use '^p preferences'"
559        );
560    }
561}