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 if app.cfn_state.detail_tab == DetailTab::Template
232 || app.cfn_state.detail_tab == DetailTab::GitSync
233 {
234 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 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 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 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 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 let status_width: usize = 200;
440 let padding: usize = status_width
441 .saturating_sub(help_width)
442 .saturating_sub(version_width);
443
444 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 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 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 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 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 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 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 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}