use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style, Stylize};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use ratatui::Frame;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use crate::app::App;
use crate::fileops;
use crate::i18n::tr;
use crate::ui::icons;
use crate::ui::status::{hint, page_hint};
fn truncate_width(s: &str, max: usize) -> (String, usize) {
let w = s.width();
if w <= max {
return (s.to_string(), w);
}
if max == 0 {
return (String::new(), 0);
}
let mut out = String::new();
let mut used = 0usize;
for ch in s.chars() {
let cw = ch.width().unwrap_or(0);
if used + cw > max - 1 {
break; }
out.push(ch);
used += cw;
}
out.push('…');
(out, used + 1)
}
fn highlight_match(name: &str, query: &str, base: Style) -> Vec<Span<'static>> {
let q: Vec<char> = query.to_lowercase().chars().collect();
if q.is_empty() {
return vec![Span::styled(name.to_string(), base)];
}
let mut units: Vec<(char, usize, usize)> = Vec::new();
for (b, ch) in name.char_indices() {
let end = b + ch.len_utf8();
for lc in ch.to_lowercase() {
units.push((lc, b, end));
}
}
let mut ranges: Vec<(usize, usize)> = Vec::new();
let mut k = 0;
while k + q.len() <= units.len() {
if units[k..k + q.len()]
.iter()
.map(|u| u.0)
.eq(q.iter().copied())
{
let start = units[k].1;
let end = units[k + q.len() - 1].2;
match ranges.last_mut() {
Some(last) if start <= last.1 => last.1 = last.1.max(end),
_ => ranges.push((start, end)),
}
k += q.len();
} else {
k += 1;
}
}
if ranges.is_empty() {
return vec![Span::styled(name.to_string(), base)];
}
let hl = base.fg(Color::Yellow).add_modifier(Modifier::BOLD);
let mut spans = Vec::new();
let mut i = 0;
for (start, end) in ranges {
if start > i {
spans.push(Span::styled(name[i..start].to_string(), base));
}
spans.push(Span::styled(name[start..end].to_string(), hl));
i = end;
}
if i < name.len() {
spans.push(Span::styled(name[i..].to_string(), base));
}
spans
}
pub fn context(app: &App) -> Vec<Span<'static>> {
let mut spans = vec![Span::from(format!(" {}", app.sort_label())).dim()];
if app.show_selection_gutter() {
spans.push(
Span::from(format!(
" {}: {}",
tr(app.lang, crate::i18n::Msg::SelLabel),
app.marked_count()
))
.bold(),
);
}
if let Some(label) = app.clipboard_label() {
spans.push(Span::from(format!(" [{label}]")).dim());
}
spans
}
pub fn help_sections(app: &App) -> Vec<crate::ui::help::HelpSection> {
use crate::ui::help::HelpSection;
let lang = app.lang;
let l = |m| tr(lang, m);
vec![
HelpSection::new(l(crate::i18n::Msg::TreeSection))
.row("j / k / ↑ ↓", l(crate::i18n::Msg::TreeMoveUpDown))
.row("g / G", l(crate::i18n::Msg::TopBottom))
.row("l", l(crate::i18n::Msg::EnterDirectory))
.row("Enter", l(crate::i18n::Msg::ExpandInPlace))
.row("h", l(crate::i18n::Msg::ToParent))
.row("a", l(crate::i18n::Msg::TreeAnchorRoot))
.row("A", l(crate::i18n::Msg::ResetRoot))
.row("d", l(crate::i18n::Msg::TreeDiffFile))
.row("/", l(crate::i18n::Msg::TreeFilter))
.row(".", l(crate::i18n::Msg::ToggleHidden))
.row("i", l(crate::i18n::Msg::TreeFileInfo))
.row("e", l(crate::i18n::Msg::EditExternalEnv))
.row("o", l(crate::i18n::Msg::TreeGitChangesHub))
.row("r", l(crate::i18n::Msg::Refresh))
.row("s", l(crate::i18n::Msg::SortHint))
.row("m / '", l(crate::i18n::Msg::TreeBookmarkHint))
.row(crate::ui::status::page_help(app), ""),
crate::ui::help::leader_section(app, crate::keymap::LeaderId::File, "Space")
.unwrap_or_else(|| HelpSection::new(l(crate::i18n::Msg::TreeFile))),
HelpSection::new(l(crate::i18n::Msg::TreeSelection))
.row("v", l(crate::i18n::Msg::VisualRangeHint))
.row("V", l(crate::i18n::Msg::ToggleOne))
.row(
l(crate::i18n::Msg::TreeDragDrop),
l(crate::i18n::Msg::TreeDropFiles),
),
HelpSection::new(l(crate::i18n::Msg::TreeGitStatus))
.row("M", l(crate::i18n::Msg::TreeModified))
.row("A", l(crate::i18n::Msg::TreeAddedStaged))
.row("U", l(crate::i18n::Msg::Untracked))
.row("D", l(crate::i18n::Msg::TreeDeleted))
.row("R / T / !", l(crate::i18n::Msg::TreeStatusRenamed)),
]
}
pub fn footer_hints(app: &App) -> Vec<String> {
let lang = app.lang;
vec![
hint(lang, "jk", crate::i18n::Msg::GitMove),
hint(lang, "l", crate::i18n::Msg::HintEnter),
hint(lang, "h", crate::i18n::Msg::HintUp),
hint(lang, "q", crate::i18n::Msg::HintQuit),
hint(lang, "?", crate::i18n::Msg::HintHelp),
hint(lang, "/", crate::i18n::Msg::HintFilter),
hint(lang, "Space", crate::i18n::Msg::HintFileOps),
hint(lang, "e", crate::i18n::Msg::HintEdit),
hint(lang, "o", crate::i18n::Msg::HintGit),
hint(lang, "d", crate::i18n::Msg::HintDiff),
hint(lang, "y", crate::i18n::Msg::CopyHint),
hint(lang, "t", crate::i18n::Msg::HintTab),
hint(lang, "[/]", crate::i18n::Msg::HintTab),
hint(lang, "p", crate::i18n::Msg::HintPath),
hint(lang, ".", crate::i18n::Msg::HintHidden),
hint(lang, "i", crate::i18n::Msg::HintInfo),
hint(lang, "s", crate::i18n::Msg::HintSort),
hint(lang, "m", crate::i18n::Msg::HintMark),
hint(lang, "'", crate::i18n::Msg::HintGoto),
hint(lang, "v", crate::i18n::Msg::HintVisual),
hint(lang, "V", crate::i18n::Msg::HintPick),
hint(lang, "a", crate::i18n::Msg::HintAnchor),
page_hint(app),
]
}
pub fn render(frame: &mut Frame, app: &mut App, area: Rect) {
app.refresh_git_if_needed();
let icons_on = app.cfg.ui.icons;
let show_gutter = app.git_has_changes();
let show_sel = app.show_selection_gutter();
let filtering = app.filter_query().is_some();
let query = app.filter_query().unwrap_or("").to_string();
let root_for_rel = app.root.clone();
let detail_cols: Vec<String> = app
.cfg
.ui
.details
.iter()
.filter(|id| fileops::detail_column_width(id).is_some())
.cloned()
.collect();
let show_details = !detail_cols.is_empty();
let details_w: usize = detail_cols
.iter()
.map(|id| 2 + fileops::detail_column_width(id).unwrap()) .sum();
let inner_w = area.width.saturating_sub(2) as usize; let name_region_w = inner_w.saturating_sub(details_w);
let visible = area.height.saturating_sub(2) as usize; app.tree_viewport = visible as u16; let offset = if visible > 0 && app.selected >= visible {
app.selected - visible + 1
} else {
0
};
let end = offset.saturating_add(visible).min(app.entries.len());
let lines: Vec<Line> = app.entries[offset..end]
.iter()
.enumerate()
.map(|(vi, e)| {
let i = offset + vi; let indent = " ".repeat(e.depth);
let fname = || {
e.path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("?")
.to_string()
};
let name: String = if filtering {
e.path
.strip_prefix(&root_for_rel)
.ok()
.and_then(|p| p.to_str())
.map(str::to_string)
.unwrap_or_else(fname)
} else {
fname()
};
let chevron = if e.is_dir {
if e.expanded {
"▾ "
} else {
"▸ "
}
} else {
" "
};
let status = app.git_status_of(&e.path);
let prefix = if icons_on {
let glyph = if e.is_dir {
icons::dir_icon(e.expanded)
} else {
icons::file_icon(&e.path)
};
format!("{indent}{chevron}{glyph} ")
} else {
format!("{indent}{chevron}")
};
let mut spans: Vec<Span> = Vec::new();
if show_sel {
if app.is_selected(&e.path) || app.is_in_visual_range(i) {
spans.push(Span::from("● ").bold());
} else {
spans.push(Span::from(" "));
}
}
if show_gutter {
match status {
Some(st) => spans.push(Span::styled(
format!("{} ", st.marker()),
Style::new().fg(st.color()),
)),
None => spans.push(Span::from(" ")),
}
}
let prefix_w = prefix.width();
let ignored = status.is_none() && app.is_ignored(&e.path);
let dim = Style::new().add_modifier(Modifier::DIM);
spans.push(Span::styled(
prefix,
if ignored { dim } else { Style::default() },
));
let name_style = if let Some(st) = status {
Style::new().fg(st.color())
} else if ignored {
dim
} else {
Style::default()
};
if show_details {
let left_w = (show_sel as usize) * 2 + (show_gutter as usize) * 2 + prefix_w;
let budget = name_region_w.saturating_sub(left_w);
let (tname, nw) = truncate_width(&name, budget);
spans.push(Span::styled(tname, name_style));
let pad = name_region_w.saturating_sub(left_w + nw);
if pad > 0 {
spans.push(Span::from(" ".repeat(pad)));
}
let meta = fileops::quick_meta(&e.path).unwrap_or(fileops::RowMeta {
is_dir: e.is_dir,
is_symlink: false,
size: 0,
mtime: None,
mode: 0,
});
for id in &detail_cols {
let w = fileops::detail_column_width(id).unwrap();
let cell = fileops::detail_cell(id, &e.path, &meta).unwrap_or_default();
let (cell_t, _) = truncate_width(&cell, w);
spans.push(Span::from(format!(" {cell_t:>w$}")).dim());
}
} else if filtering && !query.is_empty() {
spans.extend(highlight_match(&name, &query, name_style));
} else {
spans.push(Span::styled(name, name_style));
}
let line = Line::from(spans);
if i == app.selected {
line.reversed() } else {
line
}
})
.collect();
let root = app.root.clone();
let title = match (app.filter_query(), app.git_branch()) {
(Some(q), _) => format!(
" {} /{} ({}) ",
app.format_path(&root),
q,
app.entries.len()
),
(None, Some(branch)) => format!(" {} ⎇ {} ", app.format_path(&root), branch),
(None, None) => format!(" {} ", app.format_path(&root)),
};
let widget = Paragraph::new(lines).block(Block::bordered().title(title));
frame.render_widget(widget, area);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn highlight_match_marks_query_in_yellow() {
let spans = highlight_match("src/main.rs", "rs", Style::default());
let joined: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(joined, "src/main.rs");
assert!(
spans.iter().any(|s| s.content.as_ref() == "rs"
&& s.style.fg == Some(Color::Yellow)
&& s.style.add_modifier.contains(Modifier::BOLD)),
"マッチ部が黄色強調でない: {spans:?}"
);
}
#[test]
fn highlight_match_empty_query_is_single_span() {
let spans = highlight_match("file.txt", "", Style::default());
assert_eq!(spans.len(), 1);
assert_eq!(spans[0].content.as_ref(), "file.txt");
}
#[test]
fn highlight_match_non_ascii_marks_query() {
let spans = highlight_match("café.txt", "É", Style::default());
let joined: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(joined, "café.txt");
assert!(
spans.iter().any(|s| s.content.as_ref() == "é"
&& s.style.fg == Some(Color::Yellow)
&& s.style.add_modifier.contains(Modifier::BOLD)),
"非 ASCII マッチ部が黄色強調でない: {spans:?}"
);
}
#[test]
fn highlight_match_lowercase_expands_no_panic() {
let spans = highlight_match("İẞ", "ß", Style::default());
let joined: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(joined, "İẞ");
assert!(
spans
.iter()
.any(|s| s.content.as_ref() == "ẞ" && s.style.fg == Some(Color::Yellow)),
"強調が出ていない: {spans:?}"
);
}
#[test]
fn help_sections_lists_tree_keys_and_leaders() {
let dir = std::env::temp_dir().join("konoma_help_sections_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let app = App::new(dir.clone(), crate::config::Config::default()).unwrap();
let secs = help_sections(&app);
assert!(
secs.len() >= 3,
"ツリー/ファイル管理/選択など複数セクション"
);
let tree = &secs[0];
assert_eq!(tree.title, tr(app.lang, crate::i18n::Msg::TreeSection));
assert!(
tree.rows.iter().any(|(k, _)| k.contains("j / k")),
"j/k 行が無い: {:?}",
tree.rows
);
assert!(
tree.rows.iter().any(|(k, _)| k == "o"),
"git 変更ハブ(o)の行が無い"
);
assert!(
secs.iter().all(|s| !s.title.is_empty()),
"空タイトルのセクションがある"
);
std::fs::remove_dir_all(&dir).ok();
}
}