use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Paragraph, Widget};
use ratatui::Frame;
use crate::ui::Theme;
use super::{center_rect, Modal, ModalResult};
const MODAL_WIDTH: u16 = 78;
const MODAL_MIN_HEIGHT: u16 = 14;
const KEY_COL_WIDTH: usize = 22;
enum Row {
Section(&'static str),
Binding(&'static str, &'static str),
Blank,
}
pub struct HelpModal {
rows: Vec<Row>,
scroll: usize,
viewport: std::cell::Cell<usize>,
}
impl Default for HelpModal {
fn default() -> Self {
Self::new()
}
}
impl HelpModal {
pub fn new() -> Self {
Self {
rows: build_rows(),
scroll: 0,
viewport: std::cell::Cell::new(0),
}
}
fn max_scroll(&self) -> usize {
let vp = self.viewport.get().max(1);
self.rows.len().saturating_sub(vp)
}
fn scroll_by(&mut self, delta: isize) {
let max = self.max_scroll() as isize;
let next = (self.scroll as isize + delta).clamp(0, max);
self.scroll = next as usize;
}
}
impl Modal for HelpModal {
fn id(&self) -> &'static str {
"help"
}
fn handle(&mut self, key: KeyEvent) -> ModalResult {
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
return ModalResult::Close(None);
}
match key.code {
KeyCode::Esc
| KeyCode::Enter
| KeyCode::Char('q')
| KeyCode::Char('?')
| KeyCode::Char('h')
| KeyCode::Char('H') => ModalResult::Close(None),
KeyCode::Up | KeyCode::Char('k') => {
self.scroll_by(-1);
ModalResult::Consumed
}
KeyCode::Down | KeyCode::Char('j') => {
self.scroll_by(1);
ModalResult::Consumed
}
KeyCode::PageUp => {
let step = self.viewport.get().max(1) as isize;
self.scroll_by(-step);
ModalResult::Consumed
}
KeyCode::PageDown => {
let step = self.viewport.get().max(1) as isize;
self.scroll_by(step);
ModalResult::Consumed
}
KeyCode::Home => {
self.scroll = 0;
ModalResult::Consumed
}
KeyCode::End => {
self.scroll = self.max_scroll();
ModalResult::Consumed
}
_ => ModalResult::Consumed,
}
}
fn render(&self, frame: &mut Frame<'_>, area: Rect, theme: &Theme) {
let needed = self.rows.len() as u16 + 6; let max_h = area.height.saturating_sub(2).max(MODAL_MIN_HEIGHT);
let height = needed.clamp(MODAL_MIN_HEIGHT, max_h);
let rect = center_rect(area, MODAL_WIDTH, height);
let body_bg = theme.panel_alt;
let buf = frame.buffer_mut();
if rect.x + rect.width < area.x + area.width && rect.y + rect.height < area.y + area.height
{
let shadow = Rect::new(rect.x + 1, rect.y + 1, rect.width, rect.height);
let style = Style::default().bg(theme.shadow);
for y in shadow.top()..shadow.bottom() {
for x in shadow.left()..shadow.right() {
buf[(x, y)].set_style(style);
}
}
}
let body_style = Style::default().bg(body_bg);
for y in rect.top()..rect.bottom() {
for x in rect.left()..rect.right() {
let cell = &mut buf[(x, y)];
cell.set_char(' ');
cell.set_style(body_style);
}
}
let accent_style = Style::default().bg(theme.accent);
for y in rect.top()..rect.bottom() {
let cell = &mut buf[(rect.left(), y)];
cell.set_char(' ');
cell.set_style(accent_style);
}
let inner = Rect::new(
rect.x + 3,
rect.y + 1,
rect.width.saturating_sub(4),
rect.height.saturating_sub(2),
);
let viewport = (inner.height as usize).saturating_sub(3);
self.viewport.set(viewport);
let scroll = self.scroll.min(self.rows.len().saturating_sub(viewport));
let has_more_above = scroll > 0;
let has_more_below = scroll + viewport < self.rows.len();
let title_hint = match (has_more_above, has_more_below) {
(false, false) => " esc close · ↑↓ scroll".to_string(),
(true, false) => " esc close · ↑↓ scroll ▲ more above".to_string(),
(false, true) => " esc close · ↑↓ scroll ▼ more below".to_string(),
(true, true) => " esc close · ↑↓ scroll ▲▼ more".to_string(),
};
let mut lines: Vec<Line<'static>> = Vec::with_capacity(viewport + 2);
lines.push(Line::from(vec![
Span::styled(
"Bosun · Key Bindings",
Style::default()
.fg(theme.text)
.bg(body_bg)
.add_modifier(Modifier::BOLD),
),
Span::styled(
title_hint,
Style::default().fg(theme.text_muted).bg(body_bg),
),
]));
lines.push(Line::from(""));
for row in self.rows.iter().skip(scroll).take(viewport) {
lines.push(render_row(row, theme, body_bg));
}
Paragraph::new(lines)
.style(Style::default().bg(body_bg))
.render(inner, frame.buffer_mut());
}
}
fn render_row(row: &Row, theme: &Theme, bg: ratatui::style::Color) -> Line<'static> {
match row {
Row::Section(name) => Line::from(vec![Span::styled(
(*name).to_string(),
Style::default()
.fg(theme.accent)
.bg(bg)
.add_modifier(Modifier::BOLD),
)]),
Row::Binding(keys, action) => {
let mut key_padded = format!(" {}", keys);
while key_padded.chars().count() < KEY_COL_WIDTH {
key_padded.push(' ');
}
Line::from(vec![
Span::styled(
key_padded,
Style::default()
.fg(theme.text)
.bg(bg)
.add_modifier(Modifier::BOLD),
),
Span::styled(
(*action).to_string(),
Style::default().fg(theme.text_muted).bg(bg),
),
])
}
Row::Blank => Line::from(""),
}
}
fn build_rows() -> Vec<Row> {
use Row::*;
vec![
Section("Navigation"),
Binding("↑ ↓ / k j", "Move selection"),
Binding("Enter", "Attach to selected session"),
Binding("→ ← / ] [", "Cycle next / prev tab within container"),
Binding("Tab", "Collapse / expand section (on header)"),
Binding("/", "Quick-switch — type-ahead session picker"),
Binding("Mouse wheel", "Scroll session list"),
Binding("Drag divider", "Resize list / preview split"),
Blank,
Section("Sessions"),
Binding("n", "New session"),
Binding("r", "Rename session (on header: rename section)"),
Binding("R", "Restart — kill + recreate with same spec"),
Binding(
"m",
"Modify session (path, agent, flags) — applies on next R",
),
Binding(
"d",
"Kill active tab (on header: delete section; last tab kills container)",
),
Binding("Shift+D", "Kill whole container — every tab at once"),
Binding("e", "Open session's path in configured editor"),
Binding("Ctrl+R", "Force immediate refresh"),
Binding(
"Ctrl+L",
"Redraw screen — recover from a terminal reset (e.g. Cmd+R)",
),
Binding(
"Ctrl+B",
"Hide / show the sidebar (while focused on a session; remembered)",
),
Blank,
Section("Tabs"),
Binding("Ctrl+T", "Add a tab to the selected container"),
Binding(
"→ ← / ] [ / Shift+→ ←",
"Cycle next / prev tab within container",
),
Binding(
"Click tab / +",
"Mouse: switch active tab or open the add-tab modal",
),
Blank,
Section("Organize"),
Binding(
"Ctrl+Shift+↑ ↓ / K J",
"Reorder within section / move section block",
),
Binding("Ctrl+Shift+→", "Move session to next section"),
Binding("Ctrl+Shift+←", "Move session to previous section"),
Binding("Shift+↑ ↓", "Cycle next / prev session in sidebar order"),
Binding("1 – 9", "Move session to section N"),
Binding("0", "Move session to ungrouped"),
Binding("g", "New section"),
Binding("f", "Cycle banner font (header: section override)"),
Blank,
Section("Settings"),
Binding("t", "Theme picker (↑↓ live preview, Enter applies)"),
Binding("? / h", "Show this help"),
Binding("q / Ctrl+C", "Quit"),
Blank,
Section("Inside attached session"),
Binding("Ctrl+Q", "Detach back to bosun"),
Binding(
"Shift+→ / Shift+←",
"Cycle next / prev tab within the current container",
),
Binding(
"Shift+↓ / Shift+↑",
"Cycle next / prev session in sidebar order",
),
Binding("Option+← / Option+→", "Move cursor by word"),
Binding("Option+Delete", "Delete previous word"),
Binding(
"Ctrl+B",
"Hide / show the sidebar (remembered across sessions)",
),
Binding("Ctrl+L", "Redraw — recover from a terminal reset (Cmd+R)"),
Blank,
Section("New-session modal"),
Binding("Tab / Shift+Tab", "Next / previous field"),
Binding("Ctrl+R", "Open recents picker — pre-fill from history"),
Binding("Tab (path field)", "Filesystem completion"),
Binding("↑ ↓ (path field)", "Navigate filesystem dropdown"),
Binding("Esc (path field)", "Dismiss dropdown so Tab advances"),
Binding("Space (checkbox)", "Toggle option"),
Binding("Enter", "Create session"),
Binding("Esc", "Cancel"),
Blank,
Section("Recents picker"),
Binding("↑ ↓", "Navigate"),
Binding("Type", "Filter by name / agent / path"),
Binding("Enter", "Pre-fill new-session form"),
Binding("Ctrl+D", "Delete recent entry"),
Binding("Esc", "Close"),
Blank,
Section("Quick-switch (/)"),
Binding("↑ ↓", "Navigate matches"),
Binding("Type", "Filter"),
Binding("Enter", "Attach to match"),
Binding("Esc", "Cancel"),
Blank,
Section("Theme picker"),
Binding("↑ ↓ / k j", "Live-preview next / previous theme"),
Binding("Home / End", "Jump to first / last theme"),
Binding("Enter", "Apply + persist to config.toml"),
Binding("Esc", "Revert"),
Blank,
Section("Help (this dialog)"),
Binding("↑ ↓ / k j", "Scroll one line"),
Binding("PgUp / PgDn", "Scroll one page"),
Binding("Home / End", "Top / bottom"),
Binding("Esc / Enter / ? / h / q", "Close"),
]
}
#[cfg(test)]
mod tests {
use super::*;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
#[test]
fn esc_closes() {
let mut m = HelpModal::new();
assert!(matches!(
m.handle(key(KeyCode::Esc)),
ModalResult::Close(None)
));
}
#[test]
fn question_mark_closes() {
let mut m = HelpModal::new();
assert!(matches!(
m.handle(key(KeyCode::Char('?'))),
ModalResult::Close(None)
));
}
#[test]
fn h_closes() {
let mut m = HelpModal::new();
assert!(matches!(
m.handle(key(KeyCode::Char('h'))),
ModalResult::Close(None)
));
}
#[test]
fn down_scrolls_when_viewport_known() {
let mut m = HelpModal::new();
m.viewport.set(5);
let before = m.scroll;
m.handle(key(KeyCode::Down));
assert_eq!(m.scroll, before + 1);
}
#[test]
fn scroll_clamps_at_bottom() {
let mut m = HelpModal::new();
m.viewport.set(5);
for _ in 0..1000 {
m.handle(key(KeyCode::Down));
}
assert_eq!(m.scroll, m.max_scroll());
}
#[test]
fn up_does_not_underflow_at_top() {
let mut m = HelpModal::new();
m.viewport.set(5);
m.handle(key(KeyCode::Up));
assert_eq!(m.scroll, 0);
}
#[test]
fn pgdn_jumps_a_viewport() {
let mut m = HelpModal::new();
m.viewport.set(8);
m.handle(key(KeyCode::PageDown));
assert_eq!(m.scroll, 8.min(m.max_scroll()));
}
#[test]
fn home_resets_to_top() {
let mut m = HelpModal::new();
m.viewport.set(5);
m.scroll = 10;
m.handle(key(KeyCode::Home));
assert_eq!(m.scroll, 0);
}
#[test]
fn end_goes_to_max() {
let mut m = HelpModal::new();
m.viewport.set(5);
m.handle(key(KeyCode::End));
assert_eq!(m.scroll, m.max_scroll());
}
}