use crossterm::event::KeyCode;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style, Stylize};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
use unicode_width::UnicodeWidthStr;
use crate::app::{App, DisplayMode, InternalMode, KeyScheme, Mode, PathStyle};
use crate::i18n::{tr, Lang, Msg};
fn chip(lang: Lang, msg: Msg, bg: Color, dark_bg: bool) -> Span<'static> {
chip_str(tr(lang, msg).to_string(), bg, dark_bg)
}
fn chip_str(text: String, bg: Color, dark_bg: bool) -> Span<'static> {
let fg = if dark_bg { Color::White } else { Color::Black };
Span::styled(
format!(" {text} "),
Style::new().fg(fg).bg(bg).add_modifier(Modifier::BOLD),
)
}
fn display_chip(app: &App) -> Span<'static> {
if app.in_git_view() {
return chip(app.lang, Msg::StGit, Color::Yellow, false);
}
let (msg, bg, dark) = match app.display_mode() {
DisplayMode::Tree => (Msg::StTree, Color::White, false),
DisplayMode::Preview => (Msg::StPreview, Color::Blue, true),
DisplayMode::Image => (Msg::StImage, Color::LightBlue, false),
DisplayMode::Table => (Msg::StTable, Color::Cyan, true),
};
chip(app.lang, msg, bg, dark)
}
fn internal_chip(app: &App) -> Option<Span<'static>> {
if matches!(app.internal_mode()?, InternalMode::GitGraphPicker) {
let text = format!(
"{} - {}",
tr(app.lang, Msg::StGraph),
tr(app.lang, Msg::StBranch)
);
return Some(chip_str(text, Color::Yellow, false));
}
let (msg, bg, dark) = match app.internal_mode()? {
InternalMode::Visual => (Msg::StVisual, Color::Magenta, true),
InternalMode::PreviewVisual => {
if app.preview_visual_linewise() {
(Msg::StVisualLine, Color::Magenta, true)
} else {
(Msg::StVisual, Color::Magenta, true)
}
}
InternalMode::Filter => (Msg::StFilter, Color::Yellow, false),
InternalMode::ChangedFilter => (Msg::StChangedOnly, Color::Yellow, false),
InternalMode::Search => (Msg::StSearch, Color::Yellow, false),
InternalMode::Sort => (Msg::StSort, Color::Yellow, false),
InternalMode::Mark => (Msg::StMark, Color::Yellow, false),
InternalMode::Bookmarks => (Msg::StBookmarks, Color::Yellow, false),
InternalMode::Info => (Msg::StInfo, Color::DarkGray, true),
InternalMode::Create => (Msg::StCreate, Color::Green, false),
InternalMode::Rename => (Msg::StRename, Color::Cyan, false),
InternalMode::BatchRename => (Msg::StBatchRename, Color::Cyan, false),
InternalMode::RenamePreview => (Msg::StRenameConfirm, Color::Cyan, false),
InternalMode::DeleteConfirm => (Msg::StDelete, Color::Red, true),
InternalMode::DropConfirm => (Msg::StDrop, Color::Cyan, false),
InternalMode::QuitConfirm => (Msg::StQuit, Color::Yellow, false),
InternalMode::GitChanges => (Msg::StChanges, Color::Yellow, false),
InternalMode::GitDiff => (Msg::StDiff, Color::Blue, true),
InternalMode::Commit => (Msg::StCommit, Color::Green, false),
InternalMode::GitLog => (Msg::StLog, Color::Yellow, false),
InternalMode::GitDetail => (Msg::StCommitDiff, Color::Blue, true),
InternalMode::GitBranch => (Msg::StBranch, Color::Yellow, false),
InternalMode::GitGraph => (Msg::StGraph, Color::Yellow, false),
InternalMode::GitGraphPicker => (Msg::StGraph, Color::Yellow, false),
};
Some(chip(app.lang, msg, bg, dark))
}
pub fn hint(lang: Lang, keys: &str, msg: Msg) -> String {
format!("{keys}:{}", tr(lang, msg))
}
pub fn page_hint(app: &App) -> String {
match app.key_scheme {
KeyScheme::Vim => hint(app.lang, "^f/^b", crate::i18n::Msg::HintPage),
KeyScheme::Less => hint(app.lang, "Spc/b", crate::i18n::Msg::HintPage),
}
}
pub fn page_help(app: &App) -> String {
match app.key_scheme {
KeyScheme::Vim => tr(app.lang, crate::i18n::Msg::StScrollHintCtrl),
KeyScheme::Less => tr(app.lang, crate::i18n::Msg::StPagerSpaceHint),
}
.to_string()
}
pub fn context_spans(app: &App) -> Vec<Span<'static>> {
let mut spans = vec![display_chip(app)];
if let Some(ic) = internal_chip(app) {
spans.push(Span::from(" "));
spans.push(ic);
}
if app.follow_enabled() {
spans.push(Span::from(" "));
spans.push(chip(app.lang, Msg::StFollow, Color::Green, false));
}
spans.extend(match app.mode {
Mode::Tree => crate::ui::tree::context(app),
Mode::Preview => crate::ui::preview::context(app),
});
let lang = app.lang;
let style = match app.path_style {
PathStyle::Relative => tr(lang, crate::i18n::Msg::RelLabel),
PathStyle::Home => "~",
PathStyle::Full => tr(lang, crate::i18n::Msg::StPathAbs),
};
let prefix = tr(lang, crate::i18n::Msg::PathLabel);
spans.push(Span::from(format!(" {prefix}{style}")).dim());
spans
}
fn mode_footer(app: &App) -> Option<Vec<Span<'static>>> {
let lang = app.lang;
let s = match app.internal_mode()? {
InternalMode::DeleteConfirm if app.confirm_is_branch_delete() => {
tr(lang, crate::i18n::Msg::StDeleteForce)
}
InternalMode::DeleteConfirm => tr(lang, crate::i18n::Msg::StTrashDelete),
InternalMode::DropConfirm => tr(lang, crate::i18n::Msg::StCopyMoveHint),
InternalMode::QuitConfirm => tr(lang, crate::i18n::Msg::StQuitHint),
InternalMode::RenamePreview => tr(lang, crate::i18n::Msg::StApply),
InternalMode::Create => tr(lang, crate::i18n::Msg::StCreateHint),
InternalMode::BatchRename => tr(lang, crate::i18n::Msg::StRenamePreviewHint),
InternalMode::Rename => tr(lang, crate::i18n::Msg::StRenameHint),
InternalMode::Visual => tr(lang, crate::i18n::Msg::VisualOpsHint),
InternalMode::PreviewVisual => {
if app.preview_visual_linewise() {
tr(lang, crate::i18n::Msg::PreviewVisualLineHint)
} else {
tr(lang, crate::i18n::Msg::PreviewVisualHint)
}
}
InternalMode::Info => tr(lang, crate::i18n::Msg::StCloseHint),
InternalMode::ChangedFilter => tr(lang, crate::i18n::Msg::ChangedFilterHint),
InternalMode::GitChanges => tr(lang, crate::i18n::Msg::StGitHubKeys),
InternalMode::GitGraph => tr(lang, crate::i18n::Msg::GitNavDetailCommitHint),
InternalMode::GitGraphPicker => tr(lang, crate::i18n::Msg::GraphPickerFooter),
InternalMode::GitBranch => tr(lang, crate::i18n::Msg::BranchesNavHint),
InternalMode::GitDiff => tr(lang, crate::i18n::Msg::DiffScrollDiscardHint),
InternalMode::Commit => tr(lang, crate::i18n::Msg::StCommitHint),
InternalMode::GitLog => tr(lang, crate::i18n::Msg::GitNavDetailHint),
InternalMode::GitDetail => tr(lang, crate::i18n::Msg::DiffScrollHint),
_ => return None,
};
Some(vec![Span::from(s).bold()])
}
pub fn footer_spans(app: &App, width: u16) -> Vec<Span<'static>> {
let lang = app.lang;
if let Some(msg) = &app.flash {
return vec![Span::from(msg.clone()).bold()];
}
if let Some(spans) = whichkey_spans(app) {
return spans;
}
if app.git_branch_filtering() {
let q = app.git_branch_query();
return vec![
Span::from(format!("/{q}")).bold(),
Span::from("▏").dim(),
Span::from(format!(
" {}",
tr(lang, crate::i18n::Msg::StApplyClearHint)
))
.dim(),
];
}
if let Some(spans) = mode_footer(app) {
return spans;
}
if app.is_filtering() {
let q = app.filter_query().unwrap_or("");
return vec![
Span::from(format!("/{q}")).bold(),
Span::from("▏").dim(),
Span::from(format!(
" {}",
tr(lang, crate::i18n::Msg::StApplyClearHint)
))
.dim(),
];
}
if app.is_searching() {
let q = app.search_input().unwrap_or("");
return vec![
Span::from(format!("/{q}")).bold(),
Span::from("▏").dim(),
Span::from(format!(" {}", tr(lang, crate::i18n::Msg::StSearchHint))).dim(),
];
}
if app.is_sort_menu() {
return vec![
Span::from(format!("{} ▸ ", app.sort_label())).bold(),
Span::from(tr(lang, crate::i18n::Msg::StSortHint)).dim(),
];
}
if app.is_marking() {
return vec![Span::from(tr(lang, crate::i18n::Msg::MarkHint)).bold()];
}
vec![Span::from(fit_tokens(&hint_tokens(app), width)).dim()]
}
fn whichkey_spans(app: &App) -> Option<Vec<Span<'static>>> {
let lead = app.pending_leader?;
let menu = app.keymaps.leaders.get(&lead)?;
let title = tr(app.lang, menu.title);
let items: Vec<String> = menu
.items
.iter()
.map(|it| {
let k = match it.key.code {
KeyCode::Char(' ') => "Space".to_string(),
KeyCode::Char(c) => c.to_string(),
_ => "?".to_string(),
};
let label = tr(app.lang, it.label);
format!("{k}:{label}")
})
.collect();
Some(vec![
Span::from(format!("{title} ▸ ")).bold(),
Span::from(items.join(" ")).dim(),
])
}
fn hint_tokens(app: &App) -> Vec<String> {
match app.mode {
Mode::Tree => crate::ui::tree::footer_hints(app),
Mode::Preview => crate::ui::preview::footer_hints(app),
}
}
fn fit_tokens(tokens: &[String], max: u16) -> String {
const SEP: &str = " ";
let max = max as usize;
let ellipsis = " …";
let mut out = String::new();
let mut used = 0usize;
let mut shown = 0usize;
for tok in tokens {
let add = if out.is_empty() {
tok.width()
} else {
SEP.width() + tok.width()
};
let reserve = if shown + 1 < tokens.len() {
ellipsis.width()
} else {
0
};
if used + add + reserve > max {
break;
}
if !out.is_empty() {
out.push_str(SEP);
}
out.push_str(tok);
used += add;
shown += 1;
}
if shown < tokens.len() && shown > 0 {
out.push_str(ellipsis);
}
out
}
pub fn render_combined(frame: &mut Frame, app: &App, area: Rect) {
let mut spans = context_spans(app);
let used: usize = spans.iter().map(|s| s.width()).sum();
spans.push(Span::from(" "));
let rest = area.width.saturating_sub(used as u16 + 2);
spans.extend(footer_spans(app, rest));
frame.render_widget(Paragraph::new(Line::from(spans)), area);
}
pub fn render_context(frame: &mut Frame, app: &App, area: Rect) {
let p =
Paragraph::new(Line::from(context_spans(app))).alignment(ratatui::layout::Alignment::Right);
frame.render_widget(p, area);
}
pub fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
frame.render_widget(
Paragraph::new(Line::from(footer_spans(app, area.width))),
area,
);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
#[test]
fn markdown_preview_footer_shows_link_keys() {
let dir = std::env::temp_dir().join("konoma_md_hints_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.md"), b"[x](https://e.com)\n").unwrap();
std::fs::write(dir.join("b.txt"), b"plain text\n").unwrap();
let mut app = App::new(dir.canonicalize().unwrap(), Config::default()).unwrap();
app.selected = app
.entries
.iter()
.position(|e| e.path.ends_with("a.md"))
.unwrap();
app.tree_activate().unwrap();
let md = hint_tokens(&app).join(" ");
assert!(md.contains("Tab:link"), "md にリンク操作が無い: {md}");
assert!(md.contains("↵:open"), "md に開く操作が無い: {md}");
app.selected = app
.entries
.iter()
.position(|e| e.path.ends_with("b.txt"))
.unwrap();
app.tree_activate().unwrap();
let txt = hint_tokens(&app).join(" ");
assert!(
!txt.contains("Tab:link") && !txt.contains("↵:open"),
"テキストにリンク操作が出ている: {txt}"
);
assert!(txt.contains("hl:"), "テキストは横移動ヒントを出す: {txt}");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn fit_tokens_priority_and_truncation() {
let toks: Vec<String> = ["aaa", "bbb", "ccc", "ddd"]
.iter()
.map(|s| s.to_string())
.collect();
let all = fit_tokens(&toks, 80);
assert_eq!(all, "aaa bbb ccc ddd");
let some = fit_tokens(&toks, 12);
assert!(some.starts_with("aaa"), "先頭優先: {some}");
assert!(some.ends_with('…'), "続きありの省略記号: {some}");
assert!(!some.contains("ddd"), "末尾は落ちる: {some}");
assert_eq!(fit_tokens(&toks, 0), "");
}
#[test]
fn footer_shows_whichkey_menu_while_leader_pending() {
let dir = std::env::temp_dir().join("konoma_whichkey_footer_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut app = App::new(dir.clone(), Config::default()).unwrap();
assert!(whichkey_spans(&app).is_none(), "未押下では None");
app.pending_leader = Some(crate::keymap::LeaderId::Copy);
let spans = footer_spans(&app, 120);
let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains('▸'), "which-key 見出しが無い: {text}");
assert!(text.contains(':'), "key:label 形式の候補が無い: {text}");
let direct = whichkey_spans(&app).expect("リーダー中は Some");
assert_eq!(direct.len(), 2, "見出し span + 候補 span");
app.flash = Some("hello flash".into());
let f: String = footer_spans(&app, 120)
.iter()
.map(|s| s.content.as_ref())
.collect();
assert!(
f.contains("hello flash") && !f.contains('▸'),
"flash 優先: {f}"
);
std::fs::remove_dir_all(&dir).ok();
}
}