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::RegionPicker {
132        let mut hints = vec![];
133        hints.extend(first_hint("↑↓", "scroll"));
134        hints.extend(hint("i", "filter"));
135        hints.extend(hint("⏎", "select"));
136        hints.extend(hint("^l", "measure latency"));
137        hints.extend(hint("⎋", "close"));
138        hints.extend(last_hint("q", "quit"));
139        hints
140    } else if app.mode == Mode::FilterInput {
141        let mut hints = vec![];
142        hints.extend(first_hint("⏎", "apply"));
143        hints.extend(hint("⎋", "cancel"));
144        hints.extend(hint("␣", "toggle"));
145        hints.extend(hint("⇤⇥", "switch"));
146        hints.extend(last_hint("^w", "close"));
147        hints
148    } else if app.view_mode == ViewMode::Events {
149        let mut hints = vec![];
150        hints.extend(first_hint("↑↓", "scroll"));
151        hints.extend(hint("←→", "toggle"));
152        hints.extend(hint("y", "yank"));
153        hints.extend(hint("^o", "console"));
154        hints.extend(hint("^r", "refresh"));
155        hints.extend(hint("p", "preferences"));
156        hints.extend(hint("^p", "print"));
157        hints.extend(hint("^w", "close"));
158        hints.extend(last_hint("q", "quit"));
159        hints
160    } else if app.view_mode == ViewMode::Detail {
161        let mut hints = vec![];
162        hints.extend(first_hint("↑↓", "scroll"));
163        hints.extend(hint("←→", "toggle"));
164        hints.extend(hint("⏎", "open"));
165        hints.extend(hint("⎋", "back"));
166        hints.extend(hint("i", "insert"));
167        hints.extend(hint("s", "sort"));
168        hints.extend(hint("o", "order"));
169        hints.extend(hint("<num>p", "page"));
170        hints.extend(hint("^o", "console"));
171        hints.extend(hint("⇤⇥", "switch"));
172        hints.extend(hint("^r", "refresh"));
173        hints.extend(hint("p", "preferences"));
174        hints.extend(hint("^p", "print"));
175        hints.extend(hint("^w", "close"));
176        hints.extend(last_hint("q", "quit"));
177        hints
178    } else if app.current_service == Service::EcrRepositories {
179        if app.ecr_state.current_repository.is_some() {
180            let mut hints = vec![];
181            hints.extend(first_hint("↑↓", "scroll"));
182            hints.extend(hint("←→", "toggle"));
183            hints.extend(hint("⎋", "back"));
184            hints.extend(hint("y", "yank"));
185            hints.extend(hint("^o", "console"));
186            hints.extend(hint("^r", "refresh"));
187            hints.extend(hint("^w", "close"));
188            hints.extend(last_hint("q", "quit"));
189            hints
190        } else {
191            let mut hints = vec![];
192            hints.extend(first_hint("↑↓", "scroll"));
193            hints.extend(hint("←→", "toggle"));
194            hints.extend(hint("⏎", "open"));
195            hints.extend(hint("⇤⇥", "switch"));
196            hints.extend(hint("y", "yank"));
197            hints.extend(hint("^o", "console"));
198            hints.extend(hint("^r", "refresh"));
199            hints.extend(hint("^w", "close"));
200            hints.extend(last_hint("q", "quit"));
201            hints
202        }
203    } else if app.current_service == Service::S3Buckets {
204        if app.s3_state.current_bucket.is_some() {
205            let mut hints = vec![];
206            hints.extend(first_hint("↑↓", "scroll"));
207            hints.extend(hint("←→", "toggle"));
208            hints.extend(hint("⏎", "open"));
209            hints.extend(hint("⎋", "back"));
210            hints.extend(hint("⇤⇥", "switch"));
211            hints.extend(hint("^o", "console"));
212            hints.extend(hint("^r", "refresh"));
213            hints.extend(hint("^w", "close"));
214            hints.extend(last_hint("q", "quit"));
215            hints
216        } else {
217            let mut hints = vec![];
218            hints.extend(first_hint("↑↓", "scroll"));
219            hints.extend(hint("←→", "toggle"));
220            hints.extend(hint("⏎", "open"));
221            hints.extend(hint("⇤⇥", "switch"));
222            hints.extend(hint("^o", "console"));
223            hints.extend(hint("^r", "refresh"));
224            hints.extend(hint("^w", "close"));
225            hints.extend(last_hint("q", "quit"));
226            hints
227        }
228    } else if app.current_service == Service::CloudFormationStacks {
229        if app.cfn_state.current_stack.is_some() {
230            // In stack detail view - customize hints based on tab
231            if app.cfn_state.detail_tab == DetailTab::Template
232                || app.cfn_state.detail_tab == DetailTab::GitSync
233            {
234                // Template and GitSync tabs: no preferences
235                let mut hints = vec![];
236                hints.extend(first_hint("↑↓", "scroll"));
237                hints.extend(hint("⎋", "back"));
238                hints.extend(hint("[]", "switch"));
239                hints.extend(hint("⇤⇥", "switch"));
240                hints.extend(hint("^u", "page up"));
241                hints.extend(hint("^d", "page down"));
242                hints.extend(hint("y", "yank"));
243                hints.extend(hint("^o", "console"));
244                hints.extend(hint("^r", "refresh"));
245                hints.extend(hint("^w", "close"));
246                hints.extend(last_hint("q", "quit"));
247                hints
248            } else {
249                common_detail_hotkeys()
250            }
251        } else {
252            // In stack list view - build custom hints with snapshot
253            let mut hints = vec![];
254            hints.extend(first_hint("↑↓", "scroll"));
255            hints.extend(hint("←→", "toggle"));
256            hints.extend(hint("⏎", "open"));
257            hints.extend(hint("[]", "switch"));
258            hints.extend(hint("^u", "page up"));
259            hints.extend(hint("^d", "page down"));
260            hints.extend(hint("y", "yank"));
261            hints.extend(hint("^p", "snapshot"));
262            hints.extend(hint("^o", "console"));
263            hints.extend(hint("^r", "refresh"));
264            hints.extend(hint("^w", "close"));
265            hints.extend(last_hint("q", "quit"));
266            hints
267        }
268    } else if app.current_service == Service::IamUsers {
269        if app.iam_state.current_user.is_some() {
270            common_detail_hotkeys()
271        } else {
272            common_list_hotkeys()
273        }
274    } else if app.current_service == Service::IamRoles {
275        if app.iam_state.current_role.is_some() {
276            common_detail_hotkeys()
277        } else {
278            common_list_hotkeys()
279        }
280    } else if app.current_service == Service::LambdaFunctions {
281        if app.lambda_state.current_function.is_some() {
282            common_detail_hotkeys()
283        } else {
284            common_list_hotkeys()
285        }
286    } else if app.view_mode == ViewMode::List {
287        let mut hints = vec![];
288        hints.extend(first_hint("↑↓", "scroll"));
289        hints.extend(hint("←→", "toggle"));
290        hints.extend(hint("⏎", "open"));
291        hints.extend(hint("^o", "console"));
292        hints.extend(hint("p", "preferences"));
293        hints.extend(hint("^p", "print"));
294        hints.extend(hint("^r", "refresh"));
295        hints.extend(hint("^w", "close"));
296        hints.extend(last_hint("q", "quit"));
297        hints
298    } else {
299        let mut hints = vec![];
300        hints.extend(first_hint("↑↓", "scroll"));
301        hints.extend(hint("←→", "toggle"));
302        hints.extend(hint("⏎", "open"));
303        hints.extend(hint("⎋", "back"));
304        hints.extend(hint("<num>p", "page"));
305        hints.extend(hint("^o", "console"));
306        hints.extend(hint("^r", "refresh"));
307        hints.extend(hint("p", "preferences"));
308        hints.extend(hint("^p", "print"));
309        hints.extend(hint("^w", "close"));
310        hints.extend(last_hint("q", "quit"));
311        hints
312    };
313
314    let millis = SystemTime::now()
315        .duration_since(UNIX_EPOCH)
316        .unwrap()
317        .as_millis();
318
319    let spinner_frame = if app.log_groups_state.loading {
320        SPINNER_FRAMES[(millis / 100 % SPINNER_FRAMES.len() as u128) as usize]
321    } else {
322        " "
323    };
324
325    let status_line_temp = if app.log_groups_state.loading {
326        let max_width = area.width.saturating_sub(10) as usize;
327        let msg = if app.log_groups_state.loading_message.len() > max_width {
328            format!(
329                "{}...",
330                &app.log_groups_state.loading_message[..max_width.saturating_sub(3)]
331            )
332        } else {
333            app.log_groups_state.loading_message.clone()
334        };
335        Some(Line::from(vec![Span::raw(msg)]))
336    } else if !app.page_input.is_empty() {
337        Some(Line::from(vec![Span::raw(format!(
338            "Go to page {} (press p to confirm)",
339            app.page_input
340        ))]))
341    } else {
342        None
343    };
344
345    let chunks = Layout::default()
346        .direction(Direction::Horizontal)
347        .constraints([
348            Constraint::Length(8),
349            Constraint::Length(2),
350            Constraint::Min(0),
351        ])
352        .split(area);
353
354    let mode_widget = Paragraph::new(mode_indicator).style(mode_style);
355
356    let spinner_widget = Paragraph::new(spinner_frame)
357        .block(Block::default())
358        .style(Style::default().bg(Color::DarkGray).fg(Color::Yellow));
359
360    if let Some(line) = status_line_temp {
361        let status_widget = Paragraph::new(line)
362            .alignment(Alignment::Left)
363            .style(Style::default().bg(Color::DarkGray).fg(Color::White));
364        frame.render_widget(mode_widget, chunks[0]);
365        frame.render_widget(spinner_widget, chunks[1]);
366        frame.render_widget(status_widget, chunks[2]);
367    } else {
368        // Build version string
369        let version = env!("CARGO_PKG_VERSION");
370        let commit = option_env!("GIT_HASH").unwrap_or("unknown");
371        let version_text = format!("⋮ RUSTICITY v{} (#{})", version, commit);
372
373        let colors = [
374            Color::Red,
375            Color::Green,
376            Color::Yellow,
377            Color::Blue,
378            Color::Magenta,
379            Color::Cyan,
380        ];
381        let seed = millis as usize;
382        let version_spans: Vec<Span> = version_text
383            .chars()
384            .enumerate()
385            .map(|(i, c)| {
386                let color = colors[(seed + i) % colors.len()];
387                Span::styled(c.to_string(), Style::default().fg(color))
388            })
389            .collect();
390        let version_line = Line::from(version_spans);
391        let version_width = version_text.len() as u16;
392
393        // Split status area into help (left) and version (right)
394        let status_chunks = Layout::default()
395            .direction(Direction::Horizontal)
396            .constraints([Constraint::Min(0), Constraint::Length(version_width)])
397            .split(chunks[2]);
398
399        let help_widget = Paragraph::new(Line::from(help))
400            .block(Block::default())
401            .alignment(Alignment::Left)
402            .style(Style::default().bg(Color::DarkGray).fg(Color::White));
403
404        let version_widget = Paragraph::new(version_line)
405            .alignment(Alignment::Right)
406            .style(Style::default().bg(Color::DarkGray).fg(Color::White));
407
408        frame.render_widget(mode_widget, chunks[0]);
409        frame.render_widget(spinner_widget, chunks[1]);
410        frame.render_widget(help_widget, status_chunks[0]);
411        frame.render_widget(version_widget, status_chunks[1]);
412    }
413}
414
415#[cfg(test)]
416mod tests {
417    #[test]
418    fn test_version_string_format() {
419        let version = env!("CARGO_PKG_VERSION");
420        let commit = option_env!("GIT_HASH").unwrap_or("unknown");
421        let version_text = format!("RUSTICITY v{} (#{})", version, commit);
422
423        assert!(version_text.starts_with("RUSTICITY v"));
424        assert!(version_text.contains("#"));
425    }
426
427    #[test]
428    fn test_version_padding_calculation() {
429        // Simulate help text width
430        let help_text = " ↑↓ scroll ⋮ ←→ toggle ⋮ ⏎ open ⋮ ^o console ⋮ p preferences ⋮ ^p print ⋮ ^r refresh ⋮ ^w close ⋮ q quit ";
431        let help_width: usize = help_text.len();
432
433        let version = env!("CARGO_PKG_VERSION");
434        let commit = option_env!("GIT_HASH").unwrap_or("unknown");
435        let version_text = format!("RUSTICITY v{} (#{})", version, commit);
436        let version_width: usize = version_text.len();
437
438        // Simulate terminal width of 200
439        let status_width: usize = 200;
440        let padding: usize = status_width
441            .saturating_sub(help_width)
442            .saturating_sub(version_width);
443
444        // Total should equal status_width
445        assert_eq!(help_width + padding + version_width, status_width);
446    }
447
448    #[test]
449    fn test_preferences_hint_uses_p_not_ctrl_p() {
450        use super::common_list_hotkeys;
451        let spans = common_list_hotkeys();
452        let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
453
454        // Should have "p preferences" not "^p preferences"
455        assert!(
456            text.contains("p preferences"),
457            "Should show 'p preferences'"
458        );
459        assert!(
460            !text.contains("^p preferences"),
461            "Should not show '^p preferences'"
462        );
463    }
464
465    #[test]
466    fn test_print_hint_uses_ctrl_p() {
467        use super::common_list_hotkeys;
468        let spans = common_list_hotkeys();
469        let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
470
471        // Should have "^p print" for copy to clipboard
472        assert!(
473            text.contains("^p print"),
474            "Should show '^p print' for copy to clipboard"
475        );
476    }
477
478    #[test]
479    fn test_yank_hint_uses_y() {
480        use super::common_list_hotkeys;
481        let spans = common_list_hotkeys();
482        let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
483
484        // Should have "y yank" for copying selected item
485        assert!(
486            text.contains("y yank"),
487            "Should show 'y yank' for copying selected item"
488        );
489    }
490
491    #[test]
492    fn test_common_detail_hotkeys_has_correct_hints() {
493        use super::common_detail_hotkeys;
494        let spans = common_detail_hotkeys();
495        let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
496
497        // Detail views should have p for preferences and ^p for print
498        assert!(
499            text.contains("p preferences"),
500            "Detail view should show 'p preferences'"
501        );
502        assert!(
503            text.contains("^p print"),
504            "Detail view should show '^p print'"
505        );
506    }
507
508    #[test]
509    fn test_no_columns_hint_anywhere() {
510        let list_spans = super::common_list_hotkeys();
511        let list_text: String = list_spans.iter().map(|s| s.content.as_ref()).collect();
512
513        // Should never show "columns", always "preferences"
514        assert!(
515            !list_text.contains("p columns"),
516            "Should not show 'p columns', use 'p preferences' instead"
517        );
518
519        let detail_spans = super::common_detail_hotkeys();
520        let detail_text: String = detail_spans.iter().map(|s| s.content.as_ref()).collect();
521        assert!(
522            !detail_text.contains("p columns"),
523            "Should not show 'p columns', use 'p preferences' instead"
524        );
525    }
526
527    #[test]
528    fn test_all_views_have_print_hotkey() {
529        // Test list view
530        let list_spans = super::common_list_hotkeys();
531        let list_text: String = list_spans.iter().map(|s| s.content.as_ref()).collect();
532        assert!(
533            list_text.contains("^p print"),
534            "List view must have '^p print' hotkey"
535        );
536
537        // Test detail view
538        let detail_spans = super::common_detail_hotkeys();
539        let detail_text: String = detail_spans.iter().map(|s| s.content.as_ref()).collect();
540        assert!(
541            detail_text.contains("^p print"),
542            "Detail view must have '^p print' hotkey"
543        );
544    }
545
546    #[test]
547    fn test_preferences_always_uses_p_not_ctrl_p() {
548        let list_spans = super::common_list_hotkeys();
549        let list_text: String = list_spans.iter().map(|s| s.content.as_ref()).collect();
550        assert!(
551            list_text.contains("p preferences"),
552            "Should use 'p preferences'"
553        );
554        assert!(
555            !list_text.contains("^p preferences"),
556            "Should not use '^p preferences'"
557        );
558
559        let detail_spans = super::common_detail_hotkeys();
560        let detail_text: String = detail_spans.iter().map(|s| s.content.as_ref()).collect();
561        assert!(
562            detail_text.contains("p preferences"),
563            "Should use 'p preferences'"
564        );
565        assert!(
566            !detail_text.contains("^p preferences"),
567            "Should not use '^p preferences'"
568        );
569    }
570}