use crate::app::{App, Mode};
use crate::keybindings::Action;
use crate::repo::RemoteInfo;
use crate::ui::layout::{centered_rect, centered_rect_fixed};
use crate::ui::style::{
ACCENT, CARD_BORDER, DANGER, SUCCESS, WARNING, accent_style, muted_style, parse_color,
primary_style,
};
use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Margin, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{
Block, BorderType, Borders, Clear, Gauge, List, ListItem, ListState, Padding, Paragraph, Wrap,
};
fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
let mut lines = Vec::new();
let mut current_line = String::new();
for word in text.split_whitespace() {
if current_line.is_empty() {
current_line.push_str(word);
} else if current_line.chars().count() + 1 + word.chars().count() <= max_width {
current_line.push(' ');
current_line.push_str(word);
} else {
lines.push(current_line);
current_line = word.to_string();
}
}
if !current_line.is_empty() {
lines.push(current_line);
}
if lines.is_empty() {
lines.push(String::new());
}
lines
}
pub(crate) fn nav_key_caption(
kb: &crate::keybindings::KeybindingsConfig,
action: Action,
compat: bool,
) -> String {
let keys = kb.get_action_keys(action);
if keys.is_empty() {
return "-".to_string();
}
keys.iter()
.map(|k| {
let name = match (k.as_str(), compat) {
("up", false) => "↑ [Up]",
("up", true) => "Up",
("down", false) => "↓ [Down]",
("down", true) => "Down",
("left", false) => "← [Left]",
("left", true) => "Left",
("right", false) => "→ [Right]",
("right", true) => "Right",
("pageup" | "pgup", false) => "⇞ [PgUp]",
("pageup" | "pgup", true) => "PgUp",
("pagedown" | "pgdn", false) => "⇟ [PgDn]",
("pagedown" | "pgdn", true) => "PgDn",
("home", _) => "Home",
("end", _) => "End",
("space", _) => "Space",
("delete" | "del", _) => "Del",
("backspace", false) => "⌫",
("backspace", true) => "Backspace",
("enter" | "return", false) => "↵",
("enter" | "return", true) => "Enter",
("esc" | "escape", false) => "⎋",
("esc" | "escape", true) => "Esc",
("tab", false) => "⇥",
("tab", true) => "Tab",
("backtab" | "shift-tab", false) => "⇧⇥",
("backtab" | "shift-tab", true) => "Shift+Tab",
_ => k.as_str(),
};
name.to_string()
})
.collect::<Vec<_>>()
.join(" / ")
}
pub fn get_help_lines(app: &App, usable_width: usize) -> Vec<Line<'_>> {
let mut lines = Vec::new();
let is_compat = app.config.compatibility_mode;
let format_key = |k: &str| {
let mut key = k.to_string();
if is_compat {
key = key
.replace("↑", "^")
.replace("↓", "v")
.replace("⇟", "PgDn")
.replace("⇞", "PgUp")
.replace("↵", "Enter")
.replace("→", ">")
.replace("⎋", "Esc")
.replace("⌫", "Backspace")
.replace("⇥", "Tab")
.replace("⇧⇥", "Shift+Tab")
.replace("⇧", "Shift+");
}
key
};
let kb = &app.keybindings;
let help_key = kb.format_action_keys(Action::Help, is_compat);
let about_key = kb.format_action_keys(Action::HomeAbout, is_compat);
let legend_key = kb.format_action_keys(Action::HomeSymbolsHelp, is_compat);
let close_key = kb.format_action_keys(Action::CloseDetail, is_compat);
let quit_key = kb.format_action_keys(Action::Close, is_compat);
let add_key = kb.format_action_keys(Action::HomeAddRepo, is_compat);
let bulk_add_key = kb.format_action_keys(Action::HomeBulkAdd, is_compat);
let import_key = kb.format_action_keys(Action::HomeImportRepo, is_compat);
let edit_key = kb.format_action_keys(Action::HomeEditRepo, is_compat);
let delete_key = kb.format_action_keys(Action::HomeDeleteRepo, is_compat);
let search_key = kb.format_action_keys(Action::HomeSearchRepo, is_compat);
let refresh_key = kb.format_action_keys(Action::HomeRefresh, is_compat);
let sort_key = format!(
"{}/{}",
kb.format_action_keys(Action::HomeCycleSort, is_compat),
kb.format_action_keys(Action::HomeToggleSortReverse, is_compat)
);
let compact_key = kb.format_action_keys(Action::HomeCycleViewMode, is_compat);
let pin_key = kb.format_action_keys(Action::HomeTogglePin, is_compat);
let settings_key = kb.format_action_keys(Action::HomeOpenSettings, is_compat);
let labels_key = kb.format_action_keys(Action::HomeEditLabels, is_compat);
let debug_key = kb.format_action_keys(Action::HomeOpenDebugLogs, is_compat);
let update_key = kb.format_action_keys(Action::HomeCheckUpdate, is_compat);
let open_detail_key = kb.format_action_keys(Action::HomeOpenDetail, is_compat);
let terminal_key = kb.format_action_keys(Action::HomeOpenTerminal, is_compat);
let external_shell_key = kb.format_action_keys(Action::HomeOpenExternalShell, is_compat);
let terminal_toggle_key = kb.format_action_keys(Action::ToggleTerminalPanel, is_compat);
let palette_key = kb.format_action_keys(Action::CommandPalette, is_compat);
let star_key = kb.format_action_keys(Action::HomeToggleStar, is_compat);
let yank_key = kb.format_action_keys(Action::HomeYankPath, is_compat);
let jump_picker_key = kb.format_action_keys(Action::HomeJumpPicker, is_compat);
let fetch_all_key = kb.format_action_keys(Action::HomeFetchAll, is_compat);
let fetch_details_key = kb.format_action_keys(Action::HomeFetchDetails, is_compat);
let select_key = kb.format_action_keys(Action::HomeSelect, is_compat);
let global_search_key = kb.format_action_keys(Action::HomeGlobalSearch, is_compat);
let stats_key = kb.format_action_keys(Action::HomeOpenStatsDashboard, is_compat);
let cycle_filter_key = format!(
"{}/{}",
kb.format_action_keys(Action::HomeCycleFilter, is_compat),
kb.format_action_keys(Action::HomeCycleFilterBack, is_compat)
);
let label_picker_key = kb.format_action_keys(Action::HomeLabelPicker, is_compat);
let label_slot_keys = kb.format_label_slot_keys(is_compat);
let nav = |action: Action| nav_key_caption(kb, action, is_compat);
let move_up_key = nav(Action::HomeMoveUp);
let move_down_key = nav(Action::HomeMoveDown);
let page_up_key = nav(Action::HomePageUp);
let page_down_key = nav(Action::HomePageDown);
let home_key = nav(Action::HomeHome);
let end_key = nav(Action::HomeEnd);
let debug_page_key = format!("{} / {}", nav(Action::NavPageUp), nav(Action::NavPageDown));
let debug_jump_key = format!("{} / {}", nav(Action::NavHome), nav(Action::NavEnd));
let make_cat = |title: &'static str,
items: Vec<(&str, &'static str)>|
-> (&str, Vec<(String, &'static str)>) {
let mapped = items.into_iter().map(|(k, d)| (k.to_string(), d)).collect();
(title, mapped)
};
let mut categories: Vec<(&str, Vec<(String, &str)>)> = vec![
(
"Global & Navigation",
vec![
(move_up_key, "Move selection up / scroll up"),
(move_down_key, "Move selection down / scroll down"),
(page_up_key, "Jump one page up"),
(page_down_key, "Jump one page down"),
(home_key, "Go to top / scroll to top"),
(end_key, "Go to bottom / scroll to bottom"),
("⎋ [Esc]".to_string(), "Cancel input, clear search/filter, or cancel selections"),
(close_key, "Close dialog / leave detail view"),
(
kb.format_action_keys(Action::ToggleStatusBar, is_compat),
"Toggle status bar visibility",
),
(terminal_toggle_key, "Toggle the embedded terminal panel (also unfocuses it)"),
(palette_key, "Open the command palette: run any action by name"),
("Shift+PgUp/PgDn".to_string(), "Scroll terminal panel history (while focused)"),
(help_key, "Toggle this help overlay"),
(about_key, "Show about popup / creator profile"),
(legend_key, "Show signs & symbols legend popup"),
(quit_key, "Quit application"),
],
),
(
"Main List Operations",
vec![
(open_detail_key, "Open Detail view for selected repository"),
(add_key, "Add a new repository"),
(bulk_add_key, "Bulk add folders in a directory"),
(import_key, "Import remote repository"),
(edit_key, "Edit selected item"),
(delete_key, "Delete selected item"),
(search_key, "Enter repository search mode"),
(cycle_filter_key, "Cycle summary filter (repos/dirty/ahead/stale)"),
(label_picker_key, "Pick a label to filter the list (sticky until deselected)"),
(
label_slot_keys,
"Filter by quick-label slot (numbered chips under the summary bar, filled in the order labels are first viewed; press again to clear)",
),
(
"→ [in label picker]".to_string(),
"Open Label Settings for the highlighted label (shared by its repos)",
),
(global_search_key, "Open global code search popup overlay"),
(refresh_key, "Refresh status of selected item"),
(fetch_all_key, "Bulk fetch all tracked repositories concurrently"),
(fetch_details_key, "Show why the selected repository failed to fetch"),
(
sort_key,
"Cycle sorting mode / Toggle reverse sorting (the active label's own sort when its Label Settings set one)",
),
(
compact_key,
"Cycle between standard cards, compact 1-row view, and tile grid view (the active label's own layout when its Label Settings set one)",
),
(
pin_key,
"Toggle pin status of selected item (pinned repos stay at the top, above a thin rule)",
),
(star_key, "Toggle Favorite / Star status of selected item"),
(select_key, "Toggle selection of item for batch operations"),
(yank_key, "Yank absolute path of selected item to clipboard"),
(jump_picker_key, "Open fuzzy Jump-to-Repo picker overlay"),
(
"← / → / Space / Enter".to_string(),
"Collapse/expand label group (on group header)",
),
("← / →".to_string(), "Move selection horizontally (Tile view)"),
(
kb.format_action_keys(Action::HomeOpenGitApp, is_compat),
"Launch preferred Git client for selected repository",
),
(terminal_key, "Open embedded terminal panel in the selected repository"),
(external_shell_key, "Suspend the TUI and open a full-screen shell"),
(settings_key, "Open options/settings page"),
(labels_key, "Edit labels of selected item"),
(debug_key, "Open debug logs panel"),
(stats_key, "Open app usage stats dashboard"),
(update_key, "Check for application updates manually"),
("⌫ [Backspace]".to_string(), "Erase character before the caret while typing"),
("← → [Arrows]".to_string(), "Move the caret while typing"),
("Home / End".to_string(), "Jump the caret to the start / end of the field"),
("Del [Delete]".to_string(), "Erase character under the caret while typing"),
],
),
];
categories.push((
"Debug Logs Panel",
vec![
("Esc / q / D / l / L".to_string(), "Exit debug logs panel"),
("c / C / x".to_string(), "Clear all debug logs"),
("/".to_string(), "Fuzzy search/filter debug logs"),
("Enter".to_string(), "Leave the search field to scroll results (while searching)"),
("↑ / ↓ / j / k".to_string(), "Scroll log entries"),
(debug_page_key, "Scroll log entries one page up / down"),
(debug_jump_key, "Jump to the first / last log entry"),
],
));
categories.push(make_cat(
"Mouse Interactions",
vec![
("Left-Click", "Focus clicked panel / change tab (mouse support)"),
("Left-Click+Drag", "Drag boundaries to resize split panels"),
],
));
let mut max_key_width = 0;
for (_, keys) in &categories {
for (key, _) in keys {
let width = format_key(key).chars().count();
if width > max_key_width {
max_key_width = width;
}
}
}
let desc_width = usable_width.saturating_sub(4 + max_key_width + 3);
for (cat_title, keys) in categories {
lines.push(Line::from(""));
let title_style = if is_compat {
Style::default().add_modifier(Modifier::BOLD)
} else {
primary_style().add_modifier(Modifier::BOLD)
};
let prefix = if is_compat { "=== " } else { "■ " };
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(prefix, if is_compat { Style::default() } else { accent_style() }),
Span::styled(cat_title.to_uppercase(), title_style),
]));
for (key, desc) in keys {
let k_str = format_key(&key);
let padded_key = format!("{:>width$}", k_str, width = max_key_width);
let desc_lines = wrap_text(desc, desc_width);
for (idx, desc_line) in desc_lines.into_iter().enumerate() {
if idx == 0 {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(padded_key.clone(), accent_style()),
Span::raw(" "),
Span::raw(desc_line),
]));
} else {
let indent = " ".repeat(4 + max_key_width + 3);
lines.push(Line::from(vec![Span::raw(indent), Span::raw(desc_line)]));
}
}
}
}
lines
}
pub fn get_help_lines_len(app: &App, width: u16) -> usize {
let popup_width = (width * 80) / 100;
let usable_width = popup_width.saturating_sub(4) as usize;
get_help_lines(app, usable_width).len()
}
pub fn draw_help_overlay(f: &mut Frame, app: &App, area: Rect, scroll: usize) {
let popup_area = centered_rect(80, 70, area);
let usable_width = popup_area.width.saturating_sub(4) as usize;
let lines = get_help_lines(app, usable_width);
let help_block = Block::default()
.borders(Borders::ALL)
.border_type(CARD_BORDER())
.border_style(Style::default().fg(ACCENT()))
.title(
Line::from(vec![
Span::raw(" "),
Span::styled("Shortcuts & Legend", accent_style()),
Span::raw(" "),
])
.alignment(Alignment::Left),
)
.padding(Padding::horizontal(1));
let inner_height = popup_area.height.saturating_sub(2) as usize;
let max_scroll = lines.len().saturating_sub(inner_height);
let scroll = scroll.min(max_scroll);
let lines_len = lines.len();
let help = Paragraph::new(lines).block(help_block).scroll((scroll as u16, 0));
f.render_widget(Clear, popup_area);
f.render_widget(help, popup_area);
crate::ui::scrollbar::draw_vertical_scrollbar(f, popup_area, scroll, lines_len, inner_height);
}
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
pub struct HelpPopup;
impl HelpPopup {
pub fn handle_event(app: &mut crate::app::App, key: KeyEvent) -> bool {
let code = key.code;
if app.is_bound(Action::Help, key) || app.is_bound(Action::CloseDetail, key) {
app.close_dialog();
return true;
}
match code {
_ if app.keybindings.matches(crate::keybindings::Action::NavUp, key) => {
app.help_scroll_up();
}
_ if app.keybindings.matches(crate::keybindings::Action::NavDown, key) => {
app.help_scroll_down();
}
_ if app.keybindings.matches(crate::keybindings::Action::NavPageUp, key) => {
app.help_scroll_page_up(app.config.page_size);
}
_ if app.keybindings.matches(crate::keybindings::Action::NavPageDown, key) => {
app.help_scroll_page_down(app.config.page_size);
}
_ if app.keybindings.matches(crate::keybindings::Action::NavHome, key) => {
app.help_scroll_to_top();
}
_ if app.keybindings.matches(crate::keybindings::Action::NavEnd, key) => {
app.help_scroll_to_bottom();
}
_ => {}
}
false
}
}
pub struct DetailHelpPopup;
impl DetailHelpPopup {
pub fn handle_event(app: &mut crate::app::App, key: KeyEvent) -> bool {
let code = key.code;
if app.is_bound(Action::DetailHelp, key) || app.is_bound(Action::CloseDetail, key) {
app.close_detail_help();
return true;
}
match code {
_ if app.keybindings.matches(crate::keybindings::Action::NavUp, key) => {
app.help_scroll_up();
}
_ if app.keybindings.matches(crate::keybindings::Action::NavDown, key) => {
app.help_scroll_down();
}
_ if app.keybindings.matches(crate::keybindings::Action::NavPageUp, key) => {
app.help_scroll_page_up(app.config.page_size);
}
_ if app.keybindings.matches(crate::keybindings::Action::NavPageDown, key) => {
app.help_scroll_page_down(app.config.page_size);
}
_ if app.keybindings.matches(crate::keybindings::Action::NavHome, key) => {
app.help_scroll_to_top();
}
_ if app.keybindings.matches(crate::keybindings::Action::NavEnd, key) => {
app.help_scroll_to_bottom();
}
_ => {}
}
false
}
}