use crate::app::{App, Service, ViewMode};
use crate::keymap::Mode;
use crate::ui::cfn::DetailTab;
use crate::ui::red_text;
use ratatui::{prelude::*, widgets::*};
use std::time::{SystemTime, UNIX_EPOCH};
pub const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧"];
pub fn first_hint(key: &'static str, action: &'static str) -> Vec<Span<'static>> {
vec![
Span::styled(key, red_text()),
Span::raw(" "),
Span::raw(action),
Span::raw(" ⋮"),
]
}
pub fn hint(key: &'static str, action: &'static str) -> Vec<Span<'static>> {
vec![
Span::raw(" "),
Span::styled(key, red_text()),
Span::raw(" "),
Span::raw(action),
Span::raw(" ⋮"),
]
}
pub fn last_hint(key: &'static str, action: &'static str) -> Vec<Span<'static>> {
vec![
Span::raw(" "),
Span::styled(key, red_text()),
Span::raw(" "),
Span::raw(action),
]
}
fn common_detail_hotkeys() -> Vec<Span<'static>> {
let mut spans = vec![];
spans.extend(first_hint("↑↓", "scroll"));
spans.extend(hint("⎋", "back"));
spans.extend(hint("[]", "switch"));
spans.extend(hint("⇤⇥", "switch"));
spans.extend(hint("^u", "page up"));
spans.extend(hint("^d", "page down"));
spans.extend(hint("y", "yank"));
spans.extend(hint("^o", "console"));
spans.extend(hint("p", "preferences"));
spans.extend(hint("^p", "print"));
spans.extend(hint("^r", "refresh"));
spans.extend(hint("^w", "close"));
spans.extend(last_hint("q", "quit"));
spans
}
fn common_list_hotkeys() -> Vec<Span<'static>> {
let mut spans = vec![];
spans.extend(first_hint("↑↓", "scroll"));
spans.extend(hint("←→", "toggle"));
spans.extend(hint("⏎", "open"));
spans.extend(hint("[]", "switch"));
spans.extend(hint("^u", "page up"));
spans.extend(hint("^d", "page down"));
spans.extend(hint("y", "yank"));
spans.extend(hint("^o", "console"));
spans.extend(hint("p", "preferences"));
spans.extend(hint("^p", "print"));
spans.extend(hint("^r", "refresh"));
spans.extend(hint("^w", "close"));
spans.extend(last_hint("q", "quit"));
spans
}
pub fn render_bottom_bar(frame: &mut Frame, app: &App, area: Rect) {
let is_insert = match app.mode {
Mode::FilterInput | Mode::EventFilterInput | Mode::InsightsInput => true,
Mode::ServicePicker | Mode::SpaceMenu => app.service_picker.filter_active,
Mode::RegionPicker => app.region_filter_active,
Mode::SessionPicker => app.session_filter_active,
Mode::ProfilePicker => app.profile_filter_active,
_ => false,
};
let mode_indicator = if is_insert { " INSERT " } else { " NORMAL " };
let mode_style = if is_insert {
Style::default().bg(Color::Yellow).fg(Color::Black)
} else {
Style::default().bg(Color::Blue).fg(Color::White)
};
let help = if app.mode == Mode::ColumnSelector {
let mut hints = vec![];
hints.extend(hint("↑↓", "scroll"));
hints.extend(hint("␣", "toggle"));
if app.current_service == Service::CloudWatchAlarms {
hints.extend(hint("⇤⇥", "switch"));
}
hints.extend(last_hint("⎋", "close"));
hints
} else if app.mode == Mode::InsightsInput {
let mut hints = vec![];
hints.extend(hint(" tab", "switch"));
hints.extend(hint("↑↓", "scroll"));
hints.extend(hint("␣", "toggle"));
hints.extend(hint("⏎", "execute"));
hints.extend(hint("⎋", "cancel"));
hints.extend(hint("^r", "refresh"));
hints.extend(last_hint("^w", "close"));
hints
} else if app.current_service == Service::CloudWatchInsights {
let mut hints = vec![];
hints.extend(hint(" i", "insert"));
hints.extend(hint("⏎", "execute"));
hints.extend(hint("?", "help"));
hints.extend(hint("^r", "refresh"));
hints.extend(hint("^o", "console"));
hints.extend(hint("⎋", "back"));
hints.extend(hint("^w", "close"));
hints.extend(last_hint("q", "quit"));
hints
} else if app.mode == Mode::EventFilterInput {
let mut hints = vec![];
hints.extend(first_hint("⇤⇥", "switch"));
hints.extend(hint("␣", "change unit"));
hints.extend(hint("⏎", "apply"));
hints.extend(hint("⎋", "cancel"));
hints.extend(last_hint("^w", "close"));
hints
} else if app.mode == Mode::RegionPicker {
let mut hints = vec![];
hints.extend(first_hint("↑↓", "scroll"));
hints.extend(hint("i", "filter"));
hints.extend(hint("⏎", "select"));
hints.extend(hint("^l", "measure latency"));
hints.extend(hint("⎋", "close"));
hints.extend(last_hint("q", "quit"));
hints
} else if app.mode == Mode::FilterInput {
let mut hints = vec![];
hints.extend(first_hint("⏎", "apply"));
hints.extend(hint("⎋", "cancel"));
hints.extend(hint("␣", "toggle"));
hints.extend(hint("⇤⇥", "switch"));
hints.extend(last_hint("^w", "close"));
hints
} else if app.view_mode == ViewMode::Events {
let mut hints = vec![];
hints.extend(first_hint("↑↓", "scroll"));
hints.extend(hint("←→", "toggle"));
hints.extend(hint("y", "yank"));
hints.extend(hint("^o", "console"));
hints.extend(hint("^r", "refresh"));
hints.extend(hint("p", "preferences"));
hints.extend(hint("^p", "print"));
hints.extend(hint("^w", "close"));
hints.extend(last_hint("q", "quit"));
hints
} else if app.view_mode == ViewMode::Detail {
let mut hints = vec![];
hints.extend(first_hint("↑↓", "scroll"));
hints.extend(hint("←→", "toggle"));
hints.extend(hint("⏎", "open"));
hints.extend(hint("⎋", "back"));
hints.extend(hint("i", "insert"));
hints.extend(hint("s", "sort"));
hints.extend(hint("o", "order"));
hints.extend(hint("<num>p", "page"));
hints.extend(hint("^o", "console"));
hints.extend(hint("⇤⇥", "switch"));
hints.extend(hint("^r", "refresh"));
hints.extend(hint("p", "preferences"));
hints.extend(hint("^p", "print"));
hints.extend(hint("^w", "close"));
hints.extend(last_hint("q", "quit"));
hints
} else if app.current_service == Service::EcrRepositories {
if app.ecr_state.current_repository.is_some() {
let mut hints = vec![];
hints.extend(first_hint("↑↓", "scroll"));
hints.extend(hint("←→", "toggle"));
hints.extend(hint("⎋", "back"));
hints.extend(hint("y", "yank"));
hints.extend(hint("^o", "console"));
hints.extend(hint("^r", "refresh"));
hints.extend(hint("^w", "close"));
hints.extend(last_hint("q", "quit"));
hints
} else {
let mut hints = vec![];
hints.extend(first_hint("↑↓", "scroll"));
hints.extend(hint("←→", "toggle"));
hints.extend(hint("⏎", "open"));
hints.extend(hint("⇤⇥", "switch"));
hints.extend(hint("y", "yank"));
hints.extend(hint("^o", "console"));
hints.extend(hint("^r", "refresh"));
hints.extend(hint("^w", "close"));
hints.extend(last_hint("q", "quit"));
hints
}
} else if app.current_service == Service::S3Buckets {
if app.s3_state.current_bucket.is_some() {
let mut hints = vec![];
hints.extend(first_hint("↑↓", "scroll"));
hints.extend(hint("←→", "toggle"));
hints.extend(hint("⏎", "open"));
hints.extend(hint("⎋", "back"));
hints.extend(hint("⇤⇥", "switch"));
hints.extend(hint("^o", "console"));
hints.extend(hint("^r", "refresh"));
hints.extend(hint("^w", "close"));
hints.extend(last_hint("q", "quit"));
hints
} else {
let mut hints = vec![];
hints.extend(first_hint("↑↓", "scroll"));
hints.extend(hint("←→", "toggle"));
hints.extend(hint("⏎", "open"));
hints.extend(hint("⇤⇥", "switch"));
hints.extend(hint("^o", "console"));
hints.extend(hint("^r", "refresh"));
hints.extend(hint("^w", "close"));
hints.extend(last_hint("q", "quit"));
hints
}
} else if app.current_service == Service::CloudFormationStacks {
if app.cfn_state.current_stack.is_some() {
if app.cfn_state.detail_tab == DetailTab::Template
|| app.cfn_state.detail_tab == DetailTab::GitSync
{
let mut hints = vec![];
hints.extend(first_hint("↑↓", "scroll"));
hints.extend(hint("⎋", "back"));
hints.extend(hint("[]", "switch"));
hints.extend(hint("⇤⇥", "switch"));
hints.extend(hint("^u", "page up"));
hints.extend(hint("^d", "page down"));
hints.extend(hint("y", "yank"));
hints.extend(hint("^o", "console"));
hints.extend(hint("^r", "refresh"));
hints.extend(hint("^w", "close"));
hints.extend(last_hint("q", "quit"));
hints
} else {
common_detail_hotkeys()
}
} else {
let mut hints = vec![];
hints.extend(first_hint("↑↓", "scroll"));
hints.extend(hint("←→", "toggle"));
hints.extend(hint("⏎", "open"));
hints.extend(hint("[]", "switch"));
hints.extend(hint("^u", "page up"));
hints.extend(hint("^d", "page down"));
hints.extend(hint("y", "yank"));
hints.extend(hint("^p", "snapshot"));
hints.extend(hint("^o", "console"));
hints.extend(hint("^r", "refresh"));
hints.extend(hint("^w", "close"));
hints.extend(last_hint("q", "quit"));
hints
}
} else if app.current_service == Service::IamUsers {
if app.iam_state.current_user.is_some() {
common_detail_hotkeys()
} else {
common_list_hotkeys()
}
} else if app.current_service == Service::IamRoles {
if app.iam_state.current_role.is_some() {
common_detail_hotkeys()
} else {
common_list_hotkeys()
}
} else if app.current_service == Service::LambdaFunctions {
if app.lambda_state.current_function.is_some() {
common_detail_hotkeys()
} else {
common_list_hotkeys()
}
} else if app.view_mode == ViewMode::List {
let mut hints = vec![];
hints.extend(first_hint("↑↓", "scroll"));
hints.extend(hint("←→", "toggle"));
hints.extend(hint("⏎", "open"));
hints.extend(hint("^o", "console"));
hints.extend(hint("p", "preferences"));
hints.extend(hint("^p", "print"));
hints.extend(hint("^r", "refresh"));
hints.extend(hint("^w", "close"));
hints.extend(last_hint("q", "quit"));
hints
} else {
let mut hints = vec![];
hints.extend(first_hint("↑↓", "scroll"));
hints.extend(hint("←→", "toggle"));
hints.extend(hint("⏎", "open"));
hints.extend(hint("⎋", "back"));
hints.extend(hint("<num>p", "page"));
hints.extend(hint("^o", "console"));
hints.extend(hint("^r", "refresh"));
hints.extend(hint("p", "preferences"));
hints.extend(hint("^p", "print"));
hints.extend(hint("^w", "close"));
hints.extend(last_hint("q", "quit"));
hints
};
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis();
let spinner_frame = if app.log_groups_state.loading {
SPINNER_FRAMES[(millis / 100 % SPINNER_FRAMES.len() as u128) as usize]
} else {
" "
};
let status_line_temp = if app.log_groups_state.loading {
let max_width = area.width.saturating_sub(10) as usize;
let msg = if app.log_groups_state.loading_message.len() > max_width {
format!(
"{}...",
&app.log_groups_state.loading_message[..max_width.saturating_sub(3)]
)
} else {
app.log_groups_state.loading_message.clone()
};
Some(Line::from(vec![Span::raw(msg)]))
} else if !app.page_input.is_empty() {
Some(Line::from(vec![Span::raw(format!(
"Go to page {} (press p to confirm)",
app.page_input
))]))
} else {
None
};
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(8),
Constraint::Length(2),
Constraint::Min(0),
])
.split(area);
let mode_widget = Paragraph::new(mode_indicator).style(mode_style);
let spinner_widget = Paragraph::new(spinner_frame)
.block(Block::default())
.style(Style::default().bg(Color::DarkGray).fg(Color::Yellow));
if let Some(line) = status_line_temp {
let status_widget = Paragraph::new(line)
.alignment(Alignment::Left)
.style(Style::default().bg(Color::DarkGray).fg(Color::White));
frame.render_widget(mode_widget, chunks[0]);
frame.render_widget(spinner_widget, chunks[1]);
frame.render_widget(status_widget, chunks[2]);
} else {
let version = env!("CARGO_PKG_VERSION");
let commit = option_env!("GIT_HASH").unwrap_or("unknown");
let version_text = format!("⋮ RUSTICITY v{} (#{})", version, commit);
let colors = [
Color::Red,
Color::Green,
Color::Yellow,
Color::Blue,
Color::Magenta,
Color::Cyan,
];
let seed = millis as usize;
let version_spans: Vec<Span> = version_text
.chars()
.enumerate()
.map(|(i, c)| {
let color = colors[(seed + i) % colors.len()];
Span::styled(c.to_string(), Style::default().fg(color))
})
.collect();
let version_line = Line::from(version_spans);
let version_width = version_text.len() as u16;
let status_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(0), Constraint::Length(version_width)])
.split(chunks[2]);
let help_widget = Paragraph::new(Line::from(help))
.block(Block::default())
.alignment(Alignment::Left)
.style(Style::default().bg(Color::DarkGray).fg(Color::White));
let version_widget = Paragraph::new(version_line)
.alignment(Alignment::Right)
.style(Style::default().bg(Color::DarkGray).fg(Color::White));
frame.render_widget(mode_widget, chunks[0]);
frame.render_widget(spinner_widget, chunks[1]);
frame.render_widget(help_widget, status_chunks[0]);
frame.render_widget(version_widget, status_chunks[1]);
}
}
#[cfg(test)]
mod tests {
#[test]
fn test_version_string_format() {
let version = env!("CARGO_PKG_VERSION");
let commit = option_env!("GIT_HASH").unwrap_or("unknown");
let version_text = format!("RUSTICITY v{} (#{})", version, commit);
assert!(version_text.starts_with("RUSTICITY v"));
assert!(version_text.contains("#"));
}
#[test]
fn test_version_padding_calculation() {
let help_text = " ↑↓ scroll ⋮ ←→ toggle ⋮ ⏎ open ⋮ ^o console ⋮ p preferences ⋮ ^p print ⋮ ^r refresh ⋮ ^w close ⋮ q quit ";
let help_width: usize = help_text.len();
let version = env!("CARGO_PKG_VERSION");
let commit = option_env!("GIT_HASH").unwrap_or("unknown");
let version_text = format!("RUSTICITY v{} (#{})", version, commit);
let version_width: usize = version_text.len();
let status_width: usize = 200;
let padding: usize = status_width
.saturating_sub(help_width)
.saturating_sub(version_width);
assert_eq!(help_width + padding + version_width, status_width);
}
#[test]
fn test_preferences_hint_uses_p_not_ctrl_p() {
use super::common_list_hotkeys;
let spans = common_list_hotkeys();
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
text.contains("p preferences"),
"Should show 'p preferences'"
);
assert!(
!text.contains("^p preferences"),
"Should not show '^p preferences'"
);
}
#[test]
fn test_print_hint_uses_ctrl_p() {
use super::common_list_hotkeys;
let spans = common_list_hotkeys();
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
text.contains("^p print"),
"Should show '^p print' for copy to clipboard"
);
}
#[test]
fn test_yank_hint_uses_y() {
use super::common_list_hotkeys;
let spans = common_list_hotkeys();
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
text.contains("y yank"),
"Should show 'y yank' for copying selected item"
);
}
#[test]
fn test_common_detail_hotkeys_has_correct_hints() {
use super::common_detail_hotkeys;
let spans = common_detail_hotkeys();
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
text.contains("p preferences"),
"Detail view should show 'p preferences'"
);
assert!(
text.contains("^p print"),
"Detail view should show '^p print'"
);
}
#[test]
fn test_no_columns_hint_anywhere() {
let list_spans = super::common_list_hotkeys();
let list_text: String = list_spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
!list_text.contains("p columns"),
"Should not show 'p columns', use 'p preferences' instead"
);
let detail_spans = super::common_detail_hotkeys();
let detail_text: String = detail_spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
!detail_text.contains("p columns"),
"Should not show 'p columns', use 'p preferences' instead"
);
}
#[test]
fn test_all_views_have_print_hotkey() {
let list_spans = super::common_list_hotkeys();
let list_text: String = list_spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
list_text.contains("^p print"),
"List view must have '^p print' hotkey"
);
let detail_spans = super::common_detail_hotkeys();
let detail_text: String = detail_spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
detail_text.contains("^p print"),
"Detail view must have '^p print' hotkey"
);
}
#[test]
fn test_preferences_always_uses_p_not_ctrl_p() {
let list_spans = super::common_list_hotkeys();
let list_text: String = list_spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
list_text.contains("p preferences"),
"Should use 'p preferences'"
);
assert!(
!list_text.contains("^p preferences"),
"Should not use '^p preferences'"
);
let detail_spans = super::common_detail_hotkeys();
let detail_text: String = detail_spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
detail_text.contains("p preferences"),
"Should use 'p preferences'"
);
assert!(
!detail_text.contains("^p preferences"),
"Should not use '^p preferences'"
);
}
}