use crossterm::event::KeyCode;
use ratatui::layout::{Alignment, Rect};
use ratatui::style::{Modifier, Style, Stylize};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Clear, Paragraph};
use ratatui::Frame;
use crate::app::{App, Mode};
use crate::i18n::tr;
use crate::keymap::{KeyPress, LeaderId};
pub struct HelpSection {
pub title: String,
pub rows: Vec<(String, String)>,
}
impl HelpSection {
pub fn new(title: impl Into<String>) -> Self {
Self {
title: title.into(),
rows: Vec::new(),
}
}
pub fn row(mut self, key: impl Into<String>, desc: impl Into<String>) -> Self {
self.rows.push((key.into(), desc.into()));
self
}
}
pub fn help_lines(app: &App) -> Vec<Line<'static>> {
let git_active = app.is_git_view()
|| app.is_git_log()
|| app.is_git_graph()
|| app.is_git_branches()
|| app.is_git_detail();
let mut sections = if git_active {
crate::ui::git::help_sections(app)
} else {
match app.mode {
Mode::Tree => crate::ui::tree::help_sections(app),
Mode::Preview => crate::ui::preview::help_sections(app),
}
};
sections.extend(common_sections(app));
render_sections(§ions)
}
fn render_sections(sections: &[HelpSection]) -> Vec<Line<'static>> {
let mut out: Vec<Line<'static>> = Vec::new();
for sec in sections {
if !out.is_empty() {
out.push(Line::from(""));
}
out.push(Line::from(
Span::from(sec.title.clone())
.add_modifier(Modifier::BOLD)
.underlined(),
));
for (key, desc) in &sec.rows {
out.push(Line::from(vec![
Span::from(format!(" {key:<16}")).bold(),
Span::from(desc.clone()).dim(),
]));
}
}
out
}
pub fn leader_section(app: &App, id: LeaderId, prefix: &str) -> Option<HelpSection> {
let menu = app.keymaps.leaders.get(&id)?;
let title = tr(app.lang, menu.title);
let mut sec = HelpSection::new(title.to_string());
for it in &menu.items {
let label = tr(app.lang, it.label);
sec = sec.row(
format!("{prefix} {}", leader_key_str(it.key)),
label.to_string(),
);
}
Some(sec)
}
fn leader_key_str(kp: KeyPress) -> String {
let base = match kp.code {
KeyCode::Char(' ') => "Space".to_string(),
KeyCode::Char(c) => c.to_string(),
KeyCode::Enter => "Enter".to_string(),
KeyCode::Tab => "Tab".to_string(),
other => format!("{other:?}"),
};
if kp.ctrl {
format!("Ctrl-{base}")
} else {
base
}
}
fn common_sections(app: &App) -> Vec<HelpSection> {
let lang = app.lang;
let l = |m| tr(lang, m);
let mut out = vec![HelpSection::new(l(crate::i18n::Msg::HelpTabs))
.row("t", l(crate::i18n::Msg::HelpNewTab))
.row("w", l(crate::i18n::Msg::CloseTab))
.row("[ / ]", l(crate::i18n::Msg::PrevNextTab))
.row("1 - 9", l(crate::i18n::Msg::HelpJumpTab))];
if let Some(sec) = leader_section(app, LeaderId::Copy, "y") {
out.push(sec);
}
out.push(
HelpSection::new(l(crate::i18n::Msg::HelpGlobal))
.row("p", l(crate::i18n::Msg::CyclePathStyle))
.row("?", l(crate::i18n::Msg::ToggleHelp))
.row("q", l(crate::i18n::Msg::QuitOrCloseTab))
.row("Q", l(crate::i18n::Msg::Quit)),
);
out
}
pub fn render(frame: &mut Frame, app: &App, area: Rect) {
let lines = help_lines(app);
let w = 66.min(area.width.saturating_sub(2)).max(20);
let h = (lines.len() as u16 + 2)
.min(area.height.saturating_sub(2))
.max(3);
let x = area.x + (area.width.saturating_sub(w)) / 2;
let y = area.y + (area.height.saturating_sub(h)) / 2;
let popup = Rect {
x,
y,
width: w,
height: h,
};
let inner_h = h.saturating_sub(2);
let max_scroll = (lines.len() as u16).saturating_sub(inner_h);
let scroll = app.help_scroll.min(max_scroll);
let title = tr(app.lang, crate::i18n::Msg::HelpTitle);
let block = Block::bordered()
.title(title)
.border_style(Style::new().fg(ratatui::style::Color::Cyan));
let para = Paragraph::new(lines)
.block(block)
.scroll((scroll, 0))
.alignment(Alignment::Left);
frame.render_widget(Clear, popup); frame.render_widget(para, popup);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::{App, Mode};
use crate::config::Config;
fn text(app: &App) -> String {
help_lines(app)
.iter()
.map(|line| {
line.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
fn app() -> App {
let dir = std::env::temp_dir().join("konoma_help_lines_test");
std::fs::create_dir_all(&dir).unwrap();
App::new(dir, Config::default()).unwrap()
}
#[test]
fn help_is_mode_specific() {
let mut a = app();
let tree = text(&a);
assert!(tree.contains("Tree"), "Tree 節");
assert!(tree.contains("Git status"), "Git 節");
assert!(!tree.contains("zoom"), "画像節は出さない");
assert!(
!tree.contains("horizontal scroll"),
"テキスト専用節は出さない"
);
assert!(
tree.contains("Tabs") && tree.contains("Copy") && tree.contains("Global"),
"共通節"
);
a.mode = Mode::Preview;
let txt = text(&a);
assert!(txt.contains("Preview: text"), "テキスト節");
assert!(txt.contains("horizontal scroll"), "横スクロール行");
assert!(
!txt.contains("Git status (row markers)") && !txt.contains("zoom"),
"Tree/画像専用節は出さない"
);
assert!(txt.contains("j / k / ↑ ↓"), "矢印付きスクロール行");
assert!(txt.contains("Tabs"), "Preview でも共通タブ節");
}
}