use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use crate::app::{Action, App};
use crate::keybindings::{InputContext, TuiAction};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub enum TopMode {
NewIssue,
Triage,
#[default]
Board,
Cycle,
Milestones,
Repos,
Stats,
Schedule,
Portfolio,
Library,
Config,
}
impl TopMode {
pub const ALL: [Self; 11] = [
Self::NewIssue,
Self::Triage,
Self::Board,
Self::Cycle,
Self::Milestones,
Self::Repos,
Self::Stats,
Self::Schedule,
Self::Portfolio,
Self::Library,
Self::Config,
];
pub const fn name(self) -> &'static str {
match self {
Self::NewIssue => "New Issue",
Self::Triage => "Triage",
Self::Board => "Board",
Self::Cycle => "Cycle",
Self::Milestones => "Milestone",
Self::Repos => "Repos",
Self::Stats => "Stats",
Self::Schedule => "Schedule",
Self::Library => "Library",
Self::Portfolio => "Portfolio",
Self::Config => "Config",
}
}
pub const fn label(self, plural: bool) -> &'static str {
match (self, plural) {
(Self::Board, true) => "Boards",
(Self::Cycle, true) => "Cycles",
(Self::Milestones, true) => "Milestones",
_ => self.name(),
}
}
pub const fn action(self) -> TuiAction {
match self {
Self::NewIssue => TuiAction::SwitchToNewIssue,
Self::Triage => TuiAction::SwitchToTriage,
Self::Board => TuiAction::SwitchToBoard,
Self::Cycle => TuiAction::SwitchToCycle,
Self::Milestones => TuiAction::SwitchToMilestones,
Self::Repos => TuiAction::SwitchToRepos,
Self::Stats => TuiAction::SwitchToStats,
Self::Schedule => TuiAction::SwitchToSchedule,
Self::Library => TuiAction::SwitchToLibrary,
Self::Portfolio => TuiAction::SwitchToPortfolio,
Self::Config => TuiAction::SwitchToConfig,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum NavDisplay {
Off,
Small,
#[default]
Default,
}
impl NavDisplay {
pub const fn next(self) -> Self {
match self {
Self::Off => Self::Small,
Self::Small => Self::Default,
Self::Default => Self::Off,
}
}
pub const fn width(self) -> u16 {
match self {
Self::Off => 0,
Self::Small => 5,
Self::Default => 14,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BranchKind {
Board,
Cycle,
Milestone,
Template,
Repo,
Project,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NavBranch {
pub label: String,
pub action: Action,
pub kind: BranchKind,
pub active: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NavNode {
pub mode: TopMode,
pub branches: Vec<NavBranch>,
}
fn highlight_style() -> Style {
Style::default()
.fg(Color::White)
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD)
}
fn trunk_style(app: &App, mode: TopMode, index: usize) -> Style {
if app.is_hovered(&Action::SwitchMode(mode)) || app.nav_row_focused(index) {
highlight_style()
} else if mode == app.top_mode {
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Gray)
}
}
fn branch_style(app: &App, branch: &NavBranch, index: usize) -> Style {
if app.is_hovered(&branch.action) || app.nav_row_focused(index) {
highlight_style()
} else if branch.active {
Style::default()
.fg(Color::Gray)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
}
}
fn branch_abbrev(label: &str) -> String {
label.chars().take(2).collect()
}
pub fn render(app: &App, f: &mut Frame, area: Rect) {
if app.nav_display == NavDisplay::Off || area.width == 0 {
return;
}
let nodes = app.nav_tree();
let mut lines: Vec<Line<'static>> = Vec::new();
let mut targets: Vec<Action> = Vec::new();
for node in &nodes {
if lines.len() as u16 >= area.height {
break;
}
let chord = app.bindings.label(InputContext::Shell, node.mode.action());
let trunk = match app.nav_display {
NavDisplay::Small => chord,
_ => format!("{chord} {}", app.mode_label(node.mode)),
};
lines.push(Line::from(Span::styled(
trunk,
trunk_style(app, node.mode, lines.len()),
)));
targets.push(Action::SwitchMode(node.mode));
for branch in &node.branches {
if lines.len() as u16 >= area.height {
break;
}
let text = match app.nav_display {
NavDisplay::Small => format!(" {}", branch_abbrev(&branch.label)),
_ => format!(" {}", branch.label),
};
lines.push(Line::from(Span::styled(
text,
branch_style(app, branch, lines.len()),
)));
targets.push(branch.action.clone());
}
}
let block = if app.nav_focus_index().is_some() {
Block::default()
.borders(Borders::RIGHT)
.border_style(Style::default().fg(Color::Yellow))
} else {
Block::default()
};
f.render_widget(Paragraph::new(lines).block(block), area);
for (index, action) in targets.into_iter().enumerate() {
let rect = Rect::new(area.x, area.y + index as u16, area.width, 1);
app.record_region(rect, action);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn display_states_cycle_and_shrink() {
assert_eq!(NavDisplay::Off.next(), NavDisplay::Small);
assert_eq!(NavDisplay::Small.next(), NavDisplay::Default);
assert_eq!(NavDisplay::Default.next(), NavDisplay::Off);
assert_eq!(NavDisplay::Off.width(), 0);
assert!(NavDisplay::Small.width() < NavDisplay::Default.width());
assert_eq!(NavDisplay::Default, NavDisplay::default());
}
#[test]
fn every_mode_maps_to_a_distinct_shell_action() {
let actions: Vec<TuiAction> = TopMode::ALL.iter().map(|mode| mode.action()).collect();
for (index, action) in actions.iter().enumerate() {
assert!(
!actions[index + 1..].contains(action),
"two modes share the switch action {action:?}"
);
}
}
#[test]
fn modes_are_ordered_new_issue_to_config() {
assert_eq!(
TopMode::ALL,
[
TopMode::NewIssue,
TopMode::Triage,
TopMode::Board,
TopMode::Cycle,
TopMode::Milestones,
TopMode::Repos,
TopMode::Stats,
TopMode::Schedule,
TopMode::Portfolio,
TopMode::Library,
TopMode::Config,
]
);
}
#[test]
fn modes_are_ordered_by_their_default_chord() {
let bindings = crate::config::Config::default()
.effective_keybindings()
.expect("the shipped key bindings must resolve");
let keys: Vec<(u16, TopMode)> = TopMode::ALL
.iter()
.map(|mode| {
let chord = bindings.label(InputContext::Shell, mode.action());
let number = chord
.strip_prefix('F')
.and_then(|rest| rest.parse::<u16>().ok())
.unwrap_or_else(|| {
panic!("{mode:?} advertises the shell chord `{chord}`, not an `F<n>` key")
});
(number, *mode)
})
.collect();
assert!(
keys.windows(2).all(|pair| pair[0].0 < pair[1].0),
"the navigation rail lists modes in ascending key order, got {keys:?}"
);
}
#[test]
fn small_branch_abbreviation_is_two_characters() {
assert_eq!(branch_abbrev("Default"), "De");
assert_eq!(branch_abbrev("October 2026"), "Oc");
assert_eq!(branch_abbrev("Backlog"), "Ba");
assert_eq!(branch_abbrev("X"), "X");
assert_eq!(branch_abbrev(""), "");
assert_eq!(branch_abbrev("Ünïcode"), "Ün");
}
#[test]
fn small_width_fits_the_branch_form() {
assert!(NavDisplay::Small.width() >= " Xx".chars().count() as u16);
}
#[test]
fn label_pluralizes_only_the_countable_modes() {
assert_eq!(TopMode::Board.label(false), "Board");
assert_eq!(TopMode::Board.label(true), "Boards");
assert_eq!(TopMode::Cycle.label(false), "Cycle");
assert_eq!(TopMode::Cycle.label(true), "Cycles");
assert_eq!(TopMode::Milestones.label(false), "Milestone");
assert_eq!(TopMode::Milestones.label(true), "Milestones");
assert_eq!(TopMode::NewIssue.label(true), "New Issue");
assert_eq!(TopMode::Triage.label(true), "Triage");
assert_eq!(TopMode::Config.label(true), "Config");
assert_eq!(TopMode::Cycle.name(), "Cycle");
}
}