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 if app.cfn_state.detail_tab == DetailTab::Template
223 || app.cfn_state.detail_tab == DetailTab::GitSync
224 {
225 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 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 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 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 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 let status_width: usize = 200;
431 let padding: usize = status_width
432 .saturating_sub(help_width)
433 .saturating_sub(version_width);
434
435 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 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 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 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 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 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 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 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}