use std::path::Path;
use ratatui::layout::{Alignment, Rect};
use ratatui::style::Stylize;
use ratatui::text::{Span, Text};
use ratatui::widgets::{Block, Paragraph, Wrap};
use ratatui::Frame;
use ratatui_image::{FilterType, Image, Resize, StatefulImage};
use crate::app::App;
use crate::i18n::tr;
use crate::preview::{text, PreviewKind};
use crate::ui::status::{hint, page_hint};
pub fn context(app: &App) -> Vec<Span<'static>> {
let mut spans = Vec::new();
if app.is_image_preview() {
spans.push(Span::from(format!(" x{:.2}", app.image_zoom)).bold());
}
if let Some((cur, total)) = app.pdf_page_indicator() {
spans.push(Span::from(format!(" {cur}/{total}")).bold());
}
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);
if app.is_git_diff_preview() {
return vec![HelpSection::new(l(crate::i18n::Msg::PreviewGitDiff))
.row("j / k / ↑ ↓", l(crate::i18n::Msg::Scroll))
.row("g / G", l(crate::i18n::Msg::TopBottom))
.row(crate::ui::status::page_help(app), "")
.row("x", l(crate::i18n::Msg::DiscardWholeFile))
.row("q / Esc", l(crate::i18n::Msg::BackToGitView))];
}
if app.is_image_preview() {
let mut sec = HelpSection::new(l(crate::i18n::Msg::PreviewImage))
.row("+ / -", l(crate::i18n::Msg::Zoom))
.row("0 / =", l(crate::i18n::Msg::ResetFit))
.row("h j k l / arrows", l(crate::i18n::Msg::PanHint));
if app.pdf_can_navigate() {
sec = sec.row("J / K · PageDown / PageUp", l(crate::i18n::Msg::HintPage));
}
return vec![sec
.row("e", l(crate::i18n::Msg::EditExternal))
.row("q / Esc", l(crate::i18n::Msg::BackToTree))];
}
vec![HelpSection::new(l(crate::i18n::Msg::PreviewTextMarkdown))
.row("j / k / ↑ ↓", l(crate::i18n::Msg::Scroll))
.row("g / G", l(crate::i18n::Msg::TopBottom))
.row("h / l / ← →", l(crate::i18n::Msg::HScroll))
.row("0 / $", l(crate::i18n::Msg::LineStartEnd))
.row("/ n / N", l(crate::i18n::Msg::SearchHint))
.row("Tab / ⇧Tab", l(crate::i18n::Msg::FocusMdLink))
.row("Enter", l(crate::i18n::Msg::OpenLinkHint))
.row("e", l(crate::i18n::Msg::EditExternalEnv))
.row(crate::ui::status::page_help(app), "")
.row("q / Esc", l(crate::i18n::Msg::BackToTree))]
}
pub fn footer_hints(app: &App) -> Vec<String> {
let lang = app.lang;
if app.is_image_preview() {
let mut v = vec![
hint(lang, "+/-", crate::i18n::Msg::Zoom),
hint(lang, "0/=", crate::i18n::Msg::HintFit),
hint(lang, "hjkl", crate::i18n::Msg::HintPan),
];
if app.pdf_can_navigate() {
v.push(hint(lang, "J/K", crate::i18n::Msg::HintPage));
}
v.extend([
hint(lang, "q", crate::i18n::Msg::GitBack),
hint(lang, "?", crate::i18n::Msg::HintHelp),
hint(lang, "e", crate::i18n::Msg::HintEdit),
hint(lang, "[/]", crate::i18n::Msg::HintTab),
hint(lang, "p", crate::i18n::Msg::HintPath),
]);
return v;
}
if matches!(app.preview_kind, Some(PreviewKind::Markdown(_))) {
return vec![
hint(lang, "jk", crate::i18n::Msg::Scroll),
hint(lang, "Tab", crate::i18n::Msg::HintLink),
hint(lang, "↵", crate::i18n::Msg::HintOpen),
hint(lang, "q", crate::i18n::Msg::GitBack),
hint(lang, "?", crate::i18n::Msg::HintHelp),
hint(lang, "e", crate::i18n::Msg::HintEdit),
hint(lang, "g/G", crate::i18n::Msg::HintEnds),
hint(lang, "[/]", crate::i18n::Msg::HintTab),
hint(lang, "p", crate::i18n::Msg::HintPath),
page_hint(app),
];
}
let mut v = vec![hint(lang, "jk", crate::i18n::Msg::Scroll)];
if let Some((cur, total)) = app.search_status() {
v.push(format!(
"n/N:{}[{cur}/{total}]",
tr(lang, crate::i18n::Msg::Match)
));
} else if app.preview_search_query().is_some() {
v.push(format!("n/N:{}", tr(lang, crate::i18n::Msg::Match)));
}
v.push(hint(lang, "/", crate::i18n::Msg::HintSearch));
v.push(hint(lang, "q", crate::i18n::Msg::GitBack));
v.push(hint(lang, "?", crate::i18n::Msg::HintHelp));
v.push(hint(lang, "e", crate::i18n::Msg::HintEdit));
v.push(hint(lang, "g/G", crate::i18n::Msg::HintEnds));
v.push(hint(lang, "hl", crate::i18n::Msg::HintHscroll));
v.push(hint(lang, "0/$", crate::i18n::Msg::HintLineEnds));
v.push(hint(lang, "[/]", crate::i18n::Msg::HintTab));
v.push(hint(lang, "p", crate::i18n::Msg::HintPath));
v.push(page_hint(app));
v
}
pub fn render(frame: &mut Frame, app: &mut App, area: Rect) {
if app.is_media_loading()
&& matches!(
app.preview_kind,
Some(
PreviewKind::Image(_)
| PreviewKind::Svg(_)
| PreviewKind::Video(_)
| PreviewKind::Pdf(_)
)
)
{
render_media_loading(frame, app, area);
return;
}
if matches!(app.preview_kind, Some(PreviewKind::Image(_))) {
render_image(frame, app, area);
return;
}
if matches!(
app.preview_kind,
Some(PreviewKind::Svg(_) | PreviewKind::Video(_) | PreviewKind::Pdf(_))
) && app.is_image_preview()
{
render_image(frame, app, area);
return;
}
if app.is_git_diff_preview() {
render_gitdiff(frame, app, area);
return;
}
if app.is_windowed() {
render_windowed(frame, app, area);
return;
}
if matches!(
app.preview_kind,
Some(PreviewKind::Markdown(_)) | Some(PreviewKind::Mermaid(_)) | Some(PreviewKind::Code(_))
) {
render_decorated(frame, app, area);
return;
}
let (body, is_text) = match &app.preview_kind {
Some(PreviewKind::Text(p)) => (load_body(p, app.lang), true),
Some(PreviewKind::Markdown(p))
| Some(PreviewKind::Mermaid(p))
| Some(PreviewKind::Code(p)) => {
(load_body(p, app.lang), true)
}
Some(PreviewKind::Image(p)) => (format!("[image] {}", p.display()), false),
Some(PreviewKind::Svg(p)) => (load_body(p, app.lang), true),
Some(PreviewKind::Video(p)) => (
format!(
"{}\n{}",
tr(app.lang, crate::i18n::Msg::VideoThumbUnavailable),
p.display()
),
false,
),
Some(PreviewKind::Pdf(p)) => (
format!(
"{}\n{}",
tr(app.lang, crate::i18n::Msg::PdfPreviewUnavailable),
p.display()
),
false,
),
Some(PreviewKind::Command {
path,
template,
render_as,
detached,
}) => (
format!(
"[command] {} :: {} (render_as={render_as:?}, detached={detached})",
template,
path.display()
),
false,
),
Some(PreviewKind::CanNotPreview { ext }) => (format!("[can not preview: {ext}]"), false),
Some(PreviewKind::GitDiff(_)) => ("(git diff)".to_string(), false),
None => ("(no preview)".to_string(), false),
};
let title = app
.preview_path
.clone()
.map(|p| format!(" {} ", app.format_path(&p)))
.unwrap_or_else(|| " preview ".to_string());
let wrap = is_text && app.cfg.ui.wrap;
let logical_lines = body.lines().count();
let max_line_cols = body.lines().map(|l| l.chars().count()).max().unwrap_or(0);
let block = Block::bordered().title(title);
let inner = block.inner(area);
let mut para = Paragraph::new(body).block(block);
if wrap {
para = para.wrap(Wrap { trim: false });
}
let total_rows = if wrap {
para.line_count(inner.width)
} else {
logical_lines
};
let max_v = total_rows.saturating_sub(inner.height as usize) as u16;
app.preview_scroll = app.preview_scroll.min(max_v);
app.preview_viewport = inner.height;
let max_h = if wrap {
0
} else {
max_line_cols.saturating_sub(inner.width as usize) as u16
};
app.preview_hscroll = app.preview_hscroll.min(max_h);
let para = para.scroll((app.preview_scroll, app.preview_hscroll));
frame.render_widget(para, area);
}
fn render_decorated(frame: &mut Frame, app: &mut App, area: Rect) {
let title = app
.preview_path
.clone()
.map(|p| format!(" {} ", app.format_path(&p)))
.unwrap_or_else(|| " preview ".to_string());
let block = Block::bordered().title(title);
let inner = block.inner(area);
let lines = app.decorated_lines(inner.width);
let lines = app.decorate_links(lines);
let logical_lines = lines.len();
let max_line_cols = lines.iter().map(|l| l.width()).max().unwrap_or(0);
let wrap = app.cfg.ui.wrap;
let mut para = Paragraph::new(Text::from(lines)).block(block);
if wrap {
para = para.wrap(Wrap { trim: false });
}
let total_rows = if wrap {
para.line_count(inner.width)
} else {
logical_lines
};
let max_v = total_rows.saturating_sub(inner.height as usize) as u16;
app.preview_scroll = app.preview_scroll.min(max_v);
app.preview_viewport = inner.height;
let max_h = if wrap {
0
} else {
max_line_cols.saturating_sub(inner.width as usize) as u16
};
app.preview_hscroll = app.preview_hscroll.min(max_h);
let para = para.scroll((app.preview_scroll, app.preview_hscroll));
frame.render_widget(para, area);
overlay_inline_images(frame, app, inner);
}
fn overlay_inline_images(frame: &mut Frame, app: &mut App, inner: Rect) {
let placements = app.md_images();
if placements.is_empty() {
return;
}
let scroll = app.preview_scroll as i32;
let top_bound = inner.y as i32;
let bottom_bound = (inner.y + inner.height) as i32;
for p in placements {
let top = top_bound + p.line as i32 - scroll; let vis_top = top.max(top_bound);
let vis_bottom = (top + p.rows as i32).min(bottom_bound);
if vis_bottom <= vis_top {
continue; }
let row_off = (vis_top - top) as u16; let vis_rows = (vis_bottom - vis_top) as u16;
let cols = p.cols.min(inner.width);
if cols == 0 || vis_rows == 0 {
continue;
}
let x = inner.x + (inner.width.saturating_sub(cols)) / 2;
let target = Rect {
x,
y: vis_top as u16,
width: cols,
height: vis_rows,
};
app.ensure_md_image(&p.url, cols, p.rows, row_off, vis_rows);
if let Some(proto) = app.md_image_proto(&p.url, cols, p.rows, row_off, vis_rows) {
frame.render_widget(Image::new(proto), target);
}
}
}
fn render_gitdiff(frame: &mut Frame, app: &mut App, area: Rect) {
let split = app.diff_is_split(Block::bordered().inner(area).width);
let mode_tag = if split { " ⇆" } else { "" };
let title = app
.preview_path
.clone()
.map(|p| format!(" diff{mode_tag}: {} ", app.format_path(&p)))
.unwrap_or_else(|| " diff ".to_string());
let block = Block::bordered().title(title);
let inner = block.inner(area);
app.preview_viewport = inner.height;
let diff = app.git_diff_lines();
if diff.is_empty() {
frame.render_widget(block, area);
let msg = tr(app.lang, crate::i18n::Msg::GitNoChanges);
let y = inner.y + inner.height / 2;
let line_area = Rect {
x: inner.x,
y,
width: inner.width,
height: 1,
};
frame.render_widget(Paragraph::new(msg).alignment(Alignment::Center), line_area);
return;
}
let ext = app.current_preview_ext().to_string();
let theme = app.cfg.ui.theme.code_theme.clone();
let iw = inner.width as usize;
let (lines, para_hscroll) = if split {
let max_h = crate::preview::gitdiff::side_by_side_max_hscroll(&diff, iw) as u16;
app.preview_hscroll = app.preview_hscroll.min(max_h);
let lines = crate::preview::gitdiff::diff_lines_side_by_side(
&diff,
&ext,
&theme,
iw,
app.preview_hscroll as usize,
);
(lines, 0)
} else {
let lines = crate::preview::gitdiff::diff_lines(&diff, &ext, &theme, iw);
let max_h = lines
.iter()
.map(|l| l.width())
.max()
.unwrap_or(0)
.saturating_sub(iw) as u16;
app.preview_hscroll = app.preview_hscroll.min(max_h);
(lines, app.preview_hscroll)
};
let total_rows = lines.len();
let max_v = total_rows.saturating_sub(inner.height as usize) as u16;
app.preview_scroll = app.preview_scroll.min(max_v);
let para = Paragraph::new(Text::from(lines))
.block(block)
.scroll((app.preview_scroll, para_hscroll));
frame.render_widget(para, area);
}
fn render_windowed(frame: &mut Frame, app: &mut App, area: Rect) {
let mut title = match (app.preview_path.clone(), app.window_progress()) {
(Some(p), Some(pct)) => format!(" {} [{}%] ", app.format_path(&p), pct),
(Some(p), None) => format!(" {} ", app.format_path(&p)),
_ => " preview ".to_string(),
};
if app.is_highlight_pending() && !app.loading_is_indicator() {
title.push_str(tr(app.lang, crate::i18n::Msg::Highlighting));
}
let block = Block::bordered().title(title);
let inner = block.inner(area);
app.preview_viewport = inner.height;
if app.is_highlight_pending() && app.loading_is_indicator() {
frame.render_widget(block, area);
render_spinner_line(
frame,
inner,
app.spinner_glyph(),
tr(app.lang, crate::i18n::Msg::Loading),
);
return;
}
let lines = app.windowed_lines(inner.height, inner.width);
let max_line_cols = lines.iter().map(|l| l.width()).max().unwrap_or(0);
let wrap = app.cfg.ui.wrap;
let mut para = Paragraph::new(Text::from(lines)).block(block);
if wrap {
para = para.wrap(Wrap { trim: false });
}
let max_h = if wrap {
0
} else {
max_line_cols.saturating_sub(inner.width as usize) as u16
};
app.preview_hscroll = app.preview_hscroll.min(max_h);
let para = para.scroll((0, app.preview_hscroll));
frame.render_widget(para, area);
}
fn render_spinner_line(frame: &mut Frame, inner: Rect, spinner: &str, msg: &str) {
let y = inner.y + inner.height / 2;
let line_area = Rect {
x: inner.x,
y,
width: inner.width,
height: 1,
};
let text = format!("{spinner} {msg}");
frame.render_widget(Paragraph::new(text).alignment(Alignment::Center), line_area);
}
fn render_media_loading(frame: &mut Frame, app: &App, area: Rect) {
let title = app
.preview_path
.clone()
.map(|p| format!(" {} ", app.format_path(&p)))
.unwrap_or_else(|| " image ".to_string());
let block = Block::bordered().title(title);
let inner = block.inner(area);
frame.render_widget(block, area);
render_spinner_line(
frame,
inner,
app.spinner_glyph(),
tr(app.lang, crate::i18n::Msg::Loading),
);
}
fn render_image(frame: &mut Frame, app: &mut App, area: Rect) {
let title = app
.preview_path
.clone()
.map(|p| format!(" {} ", app.format_path(&p)))
.unwrap_or_else(|| " image ".to_string());
let block = Block::bordered().title(title);
let inner = block.inner(area);
frame.render_widget(block, area);
let lang = app.lang;
let fallback = |frame: &mut Frame| {
let msg = tr(lang, crate::i18n::Msg::ImageUnsupported);
frame.render_widget(Paragraph::new(msg), inner);
};
if app.is_gif_active() {
let target = app.prepare_gif(inner);
match (target, app.gif_protocol()) {
(Some(target), Some(proto)) => frame.render_widget(Image::new(proto), target),
_ => fallback(frame),
}
return;
}
let target = app.prepare_image(inner);
match (target, app.image.as_mut()) {
(Some(target), Some(state)) => {
let widget = StatefulImage::new().resize(Resize::Scale(Some(FilterType::Lanczos3)));
frame.render_stateful_widget(widget, target, state);
}
_ => fallback(frame),
}
}
fn load_body(path: &Path, lang: crate::i18n::Lang) -> String {
match text::load(path) {
Ok(content) => {
let mut s = content.lines.join("\n");
if content.truncated {
s.push_str(tr(lang, crate::i18n::Msg::PreviewTruncated));
}
s
}
Err(e) => format!("[can not preview: load failed] {e}"),
}
}
#[cfg(all(test, feature = "git"))]
mod gitdiff_tests {
use crate::app::App;
use crate::config::Config;
use ratatui::backend::TestBackend;
use ratatui::style::Color;
use ratatui::Terminal;
use std::path::Path;
fn init_repo(dir: &Path) {
let repo = git2::Repository::init(dir).unwrap();
let mut cfg = repo.config().unwrap();
cfg.set_str("user.name", "Test").unwrap();
cfg.set_str("user.email", "test@example.com").unwrap();
cfg.set_str("commit.gpgsign", "false").ok();
}
fn run_git(dir: &Path, args: &[&str]) {
let out = std::process::Command::new("git")
.current_dir(dir)
.args(args)
.output()
.unwrap();
assert!(out.status.success(), "git {args:?} 失敗");
}
#[test]
fn gitdiff_preview_renders_with_colored_rows() {
let dir = std::env::temp_dir().join("konoma_ui_gitdiff_render");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
init_repo(&dir);
let f = dir.join("a.rs");
std::fs::write(&f, b"alpha\nbeta\ngamma_keep\n").unwrap();
run_git(&dir, &["add", "-A"]);
run_git(&dir, &["commit", "-m", "init"]);
std::fs::write(&f, b"alpha\ngamma\ngamma_keep\n").unwrap();
let canon = dir.canonicalize().unwrap();
let mut app = App::new(canon.clone(), Config::default()).unwrap();
app.open_git_diff(&canon.join("a.rs"));
assert!(
app.is_git_diff_preview(),
"GitDiff プレビューに入っていない"
);
let mut term = Terminal::new(TestBackend::new(60, 20)).unwrap();
term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
let buf = term.backend().buffer();
let s: String = buf.content().iter().map(|c| c.symbol()).collect();
assert!(s.contains("beta"), "削除行(beta)が出ていない: {s:?}");
assert!(s.contains("gamma"), "追加行(gamma)が出ていない");
let added_bg = Color::Rgb(20, 48, 28);
let removed_bg = Color::Rgb(58, 24, 26);
let has_green = buf.content().iter().any(|c| c.bg == added_bg);
let has_red = buf.content().iter().any(|c| c.bg == removed_bg);
assert!(has_green, "追加行の緑背景セルが無い");
assert!(has_red, "削除行の赤背景セルが無い");
}
#[test]
fn gitdiff_clean_file_shows_no_changes() {
let dir = std::env::temp_dir().join("konoma_ui_gitdiff_clean");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
init_repo(&dir);
let f = dir.join("a.rs");
std::fs::write(&f, b"alpha\n").unwrap();
run_git(&dir, &["add", "-A"]);
run_git(&dir, &["commit", "-m", "init"]);
let canon = dir.canonicalize().unwrap();
let mut app = App::new(canon.clone(), Config::default()).unwrap();
app.open_git_diff(&canon.join("a.rs"));
let mut term = Terminal::new(TestBackend::new(40, 10)).unwrap();
term.draw(|f| crate::ui::render(f, &mut app)).unwrap();
let s: String = term
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect();
assert!(
s.contains("no changes") || s.contains("変更なし"),
"クリーン表示が出ない: {s:?}"
);
std::fs::remove_dir_all(&dir).ok();
}
}