use std::path::Path;
use ratatui::layout::{Alignment, Margin, Rect};
use ratatui::style::Stylize;
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap};
use ratatui::Frame;
use ratatui_image::{FilterType, Image, Resize, StatefulImage};
use crate::app::{App, ScrollExtent};
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.tab.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("n / N", l(crate::i18n::Msg::JumpChangeHelp))
.row("f", l(crate::i18n::Msg::HintFollowScope))
.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_table_preview() {
return vec![HelpSection::new(l(crate::i18n::Msg::PreviewTable))
.row("h j k l / arrows", l(crate::i18n::Msg::TableMoveHelp))
.row("g / G", l(crate::i18n::Msg::TopBottom))
.row("0 / $", l(crate::i18n::Msg::TableColsHelp))
.row("/ n / N", l(crate::i18n::Msg::TableSearchHelp))
.row("Enter", l(crate::i18n::Msg::TableCellViewHelp))
.row("y → c / r / C", l(crate::i18n::Msg::CopyHint))
.row("y → f", l(crate::i18n::Msg::WkFull))
.row("Ctrl-n / Ctrl-p", l(crate::i18n::Msg::PreviewFileJumpHelp))
.row("m / '", l(crate::i18n::Msg::PreviewBookmarkHint))
.row("e", l(crate::i18n::Msg::EditExternal))
.row("q / Esc", l(crate::i18n::Msg::BackToTree))];
}
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("Ctrl-n / Ctrl-p", l(crate::i18n::Msg::PreviewFileJumpHelp))
.row("m / '", l(crate::i18n::Msg::PreviewBookmarkHint))
.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("v / V → y", l(crate::i18n::Msg::PreviewSelectHelp))
.row("Y", l(crate::i18n::Msg::AtRefHelp))
.row("R", l(crate::i18n::Msg::MdRawToggleHelp))
.row("o", l(crate::i18n::Msg::HintOutline))
.row("Tab / ⇧Tab", l(crate::i18n::Msg::FocusMdLink))
.row("Enter", l(crate::i18n::Msg::OpenLinkHint))
.row("Ctrl-t", l(crate::i18n::Msg::OpenLinkNewTabHelp))
.row("+ / - / 0", l(crate::i18n::Msg::MermaidZoomHelp))
.row("Space", l(crate::i18n::Msg::MdTaskToggleHelp))
.row("Space / ↵", l(crate::i18n::Msg::HintDetailsToggle))
.row("Ctrl-n / Ctrl-p", l(crate::i18n::Msg::PreviewFileJumpHelp))
.row("m / '", l(crate::i18n::Msg::PreviewBookmarkHint))
.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_table_preview() {
return vec![
hint(lang, "hjkl", crate::i18n::Msg::HintCell),
hint(lang, "↵", crate::i18n::Msg::HintViewCell),
hint(lang, "/", crate::i18n::Msg::HintSearch),
hint(lang, "y", crate::i18n::Msg::CopyHint),
hint(lang, "g/G", crate::i18n::Msg::HintEnds),
hint(lang, "C-n/p", crate::i18n::Msg::HintFileJump),
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),
];
}
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, "C-n/p", crate::i18n::Msg::HintFileJump),
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.tab.preview_kind, Some(PreviewKind::Markdown(_))) && !app.is_raw_source() {
let mut v = vec![
hint(lang, "jk", crate::i18n::Msg::Scroll),
hint(lang, "Tab", crate::i18n::Msg::HintLink),
hint(lang, "↵", crate::i18n::Msg::HintOpen),
hint(lang, "C-t", crate::i18n::Msg::HintNewTab),
];
if app.md_focused_code() {
v.push(hint(lang, "y c", crate::i18n::Msg::HintCopyCode));
}
if app.focused_mermaid_ordinal().is_some() {
v.push(hint(lang, "+/-", crate::i18n::Msg::Zoom));
if app.fence_zoom_level() > 1.001 {
v.push(hint(lang, "hjkl", crate::i18n::Msg::HintPan));
v.push(hint(lang, "0", crate::i18n::Msg::HintFit));
}
}
if app.md_has_tasks() || app.md_focused_details().is_some() {
v.push(hint(lang, "Space", crate::i18n::Msg::HintToggle));
}
v.extend([
hint(lang, "o", crate::i18n::Msg::HintOutline),
hint(lang, "R", crate::i18n::Msg::HintRawSource),
hint(lang, "C-n/p", crate::i18n::Msg::HintFileJump),
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),
]);
return v;
}
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));
if app.is_windowed() {
v.push(hint(lang, "v/V", crate::i18n::Msg::HintSelect));
v.push(hint(lang, "Y", crate::i18n::Msg::WkAtRef));
}
v.push(hint(lang, "F", crate::i18n::Msg::StFollow));
if app.follow_diff_scope_msg().is_some() {
v.push(hint(lang, "f", crate::i18n::Msg::HintFollowScope));
}
if app.is_decorated_kind() {
let msg = if app.is_md_raw() {
crate::i18n::Msg::HintRendered
} else {
crate::i18n::Msg::HintRawSource
};
v.push(hint(lang, "R", msg));
}
v.push(hint(lang, "C-n/p", crate::i18n::Msg::HintFileJump));
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
}
fn scroll_label(lang: crate::i18n::Lang, e: ScrollExtent) -> String {
use crate::i18n::Msg;
if e.max == 0 {
tr(lang, Msg::ScrollAll).to_string()
} else if e.pos == 0 {
tr(lang, Msg::ScrollTop).to_string()
} else if e.pos >= e.max {
tr(lang, Msg::ScrollBot).to_string()
} else {
format!("{}%", e.pos * 100 / e.max)
}
}
fn scroll_title(lang: crate::i18n::Lang, e: ScrollExtent) -> Line<'static> {
Line::from(format!(" [{}] ", scroll_label(lang, e))).right_aligned()
}
fn scrollbar_state(e: ScrollExtent) -> ScrollbarState {
const CAP: u64 = 1 << 20;
let span = e.max.saturating_add(e.viewport).max(1);
let d = span.div_ceil(CAP).max(1);
let (pos, max, viewport) = (e.pos / d, e.max / d, e.viewport / d);
ScrollbarState::new((max + 1) as usize)
.position(pos as usize)
.viewport_content_length(viewport.max(1) as usize)
}
fn render_scrollbar(frame: &mut Frame, area: Rect, e: ScrollExtent) {
if area.height < 3 || area.width == 0 {
return;
}
let mut state = scrollbar_state(e);
let bar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.track_symbol(None)
.begin_symbol(None)
.end_symbol(None);
frame.render_stateful_widget(
bar,
area.inner(Margin {
vertical: 1,
horizontal: 0,
}),
&mut state,
);
}
pub fn render(frame: &mut Frame, app: &mut App, area: Rect) {
if app.is_media_loading()
&& !app.is_image_preview()
&& matches!(
app.tab.preview_kind,
Some(
PreviewKind::Image(_)
| PreviewKind::Svg(_)
| PreviewKind::Video(_)
| PreviewKind::Pdf(_)
| PreviewKind::Mermaid(_)
| PreviewKind::MermaidFence(_)
| PreviewKind::Command { .. }
)
)
{
render_media_loading(frame, app, area);
return;
}
if matches!(app.tab.preview_kind, Some(PreviewKind::Image(_))) {
render_image(frame, app, area);
return;
}
if matches!(
app.tab.preview_kind,
Some(
PreviewKind::Svg(_)
| PreviewKind::Video(_)
| PreviewKind::Pdf(_)
| PreviewKind::Mermaid(_)
| PreviewKind::MermaidFence(_)
| PreviewKind::Command { .. }
)
) && app.is_image_preview()
{
render_image(frame, app, area);
return;
}
if app.is_table_preview() {
crate::ui::table::render(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.tab.preview_kind,
Some(PreviewKind::Markdown(_)) | Some(PreviewKind::Mermaid(_)) | Some(PreviewKind::Code(_))
) {
render_decorated(frame, app, area);
return;
}
let (body, is_text) = match &app.tab.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::MermaidFence(_)) => (
tr(app.lang, crate::i18n::Msg::MermaidUnavailable).to_string(),
false,
),
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,
detached,
..
}) => match app.command_error() {
Some(reason) => {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
(
format!(
"[can not preview: {ext}]\n{}{reason}",
tr(app.lang, crate::i18n::Msg::CommandPreviewFailed)
),
false,
)
}
None if *detached => (
format!(
"{}{}",
tr(app.lang, crate::i18n::Msg::CommandOpenedExternally),
template
),
false,
),
None => (tr(app.lang, crate::i18n::Msg::Loading).to_string(), false),
},
Some(PreviewKind::Table { path, .. }) => (load_body(path, app.lang), true),
Some(PreviewKind::Archive { path, .. }) => (
format!(
"{}\n{}",
tr(app.lang, crate::i18n::Msg::ArchiveListUnavailable),
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
.tab
.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.tab.preview_scroll = app.tab.preview_scroll.min(max_v);
app.tab.preview_viewport = inner.height;
let max_h = if wrap {
0
} else {
max_line_cols.saturating_sub(inner.width as usize) as u16
};
app.tab.preview_hscroll = app.tab.preview_hscroll.min(max_h);
let para = para.scroll((app.tab.preview_scroll, app.tab.preview_hscroll));
frame.render_widget(para, area);
}
fn render_decorated(frame: &mut Frame, app: &mut App, area: Rect) {
let inner = Block::bordered().inner(area);
let (total_rows, max_line_cols) = app.md_layout(inner.width);
let wrap = app.cfg.ui.wrap;
let max_v = total_rows.saturating_sub(inner.height as usize) as u16;
app.tab.preview_scroll = app.tab.preview_scroll.min(max_v);
app.tab.preview_viewport = inner.height;
app.md_view_rows = total_rows;
let max_h = if wrap {
0
} else {
max_line_cols.saturating_sub(inner.width as usize) as u16
};
app.tab.preview_hscroll = app.tab.preview_hscroll.min(max_h);
let extent = ScrollExtent::new(
app.tab.preview_scroll as u64,
max_v as u64,
inner.height as u64,
);
let title = app
.tab
.preview_path
.clone()
.map(|p| format!(" {} ", app.format_path(&p)))
.unwrap_or_else(|| " preview ".to_string());
let block = Block::bordered()
.title(title)
.title(scroll_title(app.lang, extent));
let (lines, local_scroll) = app.md_slice(app.tab.preview_scroll, inner.height);
let mut para = Paragraph::new(Text::from(lines)).block(block);
if wrap {
para = para.wrap(Wrap { trim: false });
}
let para = para.scroll((local_scroll, app.tab.preview_hscroll));
frame.render_widget(para, area);
render_scrollbar(frame, area, extent);
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;
}
use std::hash::{Hash, Hasher};
let mut sig = std::collections::hash_map::DefaultHasher::new();
(inner.x, inner.y, inner.width, inner.height).hash(&mut sig);
let focused_mermaid = app.focused_mermaid_ordinal();
focused_mermaid.hash(&mut sig);
((app.fence_zoom_level() * 1000.0) as u64).hash(&mut sig);
let mut drawn = 0usize;
let scroll = app.tab.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 is_mermaid = crate::preview::markdown::is_mermaid_fence_url(&p.url);
let this_focused = is_mermaid && p.fence_ord.is_some() && focused_mermaid == p.fence_ord;
let vis_line = app.md_visual_span(p.line).0 as i32;
let top = top_bound + vis_line - 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,
};
(
p.url.as_str(),
target.x,
target.y,
target.width,
target.height,
)
.hash(&mut sig);
drawn += 1;
{
let buf = frame.buffer_mut();
for y in target.top()..target.bottom() {
for x in target.left()..target.right() {
if let Some(cell) = buf.cell_mut((x, y)) {
let bg = cell.style().bg;
let mut s = ratatui::style::Style::reset();
if let Some(bg) = bg {
s = s.bg(bg);
}
cell.set_style(s);
}
}
}
}
if this_focused {
let btop = top - 1; let bbot = top + p.rows as i32 + 1; let z = app.fence_zoom_level();
let lang = app.lang;
let title = if z > 1.001 {
format!(
" ◇ mermaid x{z:.1} — {} ",
tr(lang, crate::i18n::Msg::MermaidPanAffordance)
)
} else {
format!(
" ◇ mermaid — {} {} ",
tr(lang, crate::i18n::Msg::MermaidCaption),
tr(lang, crate::i18n::Msg::MermaidZoomAffordance)
)
};
let tw = Span::from(title.as_str()).width() as u16 + 2;
let bw = (cols + 2).max(tw).min(inner.width);
let bx = inner.x + (inner.width.saturating_sub(bw)) / 2;
if btop >= top_bound && bbot <= bottom_bound {
use ratatui::style::{Color as C, Modifier as M, Style as S};
let brect = Rect {
x: bx,
y: btop as u16,
width: bw,
height: (bbot - btop) as u16,
};
let border = Block::bordered()
.border_style(S::new().fg(C::Cyan))
.title(title)
.title_style(S::new().fg(C::Cyan).add_modifier(M::REVERSED));
frame.render_widget(border, brect);
}
}
let zoomed =
this_focused && app.fence_zoom_level() > 1.001 && row_off == 0 && vis_rows >= p.rows;
if is_mermaid {
app.ensure_md_fence_density(&p.url, cols, p.rows);
}
if zoomed {
app.ensure_md_fence_zoom(&p.url, cols, p.rows);
if let Some(img) = app.md_fence_zoom_proto(&p.url, cols, p.rows) {
img.render(target, frame.buffer_mut());
continue;
}
}
app.ensure_md_image(&p.url, cols, p.rows, row_off, vis_rows);
if let Some(img) = app.md_image_proto(&p.url, cols, p.rows, row_off, vis_rows) {
img.render(target, frame.buffer_mut());
}
}
if drawn > 0 {
app.note_md_overlay(sig.finish());
}
}
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 pos = app
.diff_change_position()
.map(|(i, n)| format!(" ({i}/{n})"))
.unwrap_or_default();
let scope = app
.follow_diff_scope_msg()
.map(|m| format!(" · {}", tr(app.lang, m)))
.unwrap_or_default();
let inner = Block::bordered().inner(area);
let titled = |app: &App, e: ScrollExtent| {
let title = app
.tab
.preview_path
.clone()
.map(|p| format!(" diff{mode_tag}: {}{pos}{scope} ", app.format_path(&p)))
.unwrap_or_else(|| " diff ".to_string());
Block::bordered()
.title(title)
.title(scroll_title(app.lang, e))
};
app.tab.preview_viewport = inner.height;
let diff = app.git_diff_lines();
if diff.is_empty() {
let extent = ScrollExtent::new(0, 0, inner.height as u64);
frame.render_widget(titled(app, extent), 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);
render_scrollbar(frame, area, extent);
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 ih = inner.height as usize;
let (lines, para_hscroll, max_v) = if split {
let max_h = crate::preview::gitdiff::side_by_side_max_hscroll(&diff, iw) as u16;
app.tab.preview_hscroll = app.tab.preview_hscroll.min(max_h);
let total_rows = crate::preview::gitdiff::side_by_side_row_count(&diff, &ext);
let max_v = total_rows.saturating_sub(ih) as u16;
app.tab.preview_scroll = app.tab.preview_scroll.min(max_v);
let lines = crate::preview::gitdiff::diff_lines_side_by_side_range(
&diff,
&ext,
&theme,
iw,
app.tab.preview_hscroll as usize,
app.tab.preview_scroll as usize,
ih,
);
(lines, 0, max_v)
} else {
let total_rows = diff.len(); let max_v = total_rows.saturating_sub(ih) as u16;
app.tab.preview_scroll = app.tab.preview_scroll.min(max_v);
let max_h = crate::preview::gitdiff::unified_max_hscroll(&diff, iw) as u16;
app.tab.preview_hscroll = app.tab.preview_hscroll.min(max_h);
let lines = crate::preview::gitdiff::diff_lines_range(
&diff,
&ext,
&theme,
iw,
app.tab.preview_scroll as usize,
ih,
);
(lines, app.tab.preview_hscroll, max_v)
};
let extent = ScrollExtent::new(app.tab.preview_scroll as u64, max_v as u64, ih as u64);
let para = Paragraph::new(Text::from(lines))
.block(titled(app, extent))
.scroll((0, para_hscroll));
frame.render_widget(para, area);
render_scrollbar(frame, area, extent);
}
fn render_windowed(frame: &mut Frame, app: &mut App, area: Rect) {
let extent = app.window_scroll_extent(Block::bordered().inner(area).height);
let mut title = app
.tab
.preview_path
.clone()
.map(|p| format!(" {} ", app.format_path(&p)))
.unwrap_or_else(|| " preview ".to_string());
if app.is_raw_source() {
title.push_str(&format!(
"· {} ",
tr(app.lang, crate::i18n::Msg::HintRawSource)
));
}
if app.is_highlight_pending() && !app.loading_is_indicator() {
title.push_str(tr(app.lang, crate::i18n::Msg::Highlighting));
}
let mut block = Block::bordered().title(title);
if let Some(e) = extent {
block = block.title(scroll_title(app.lang, e));
}
let inner = block.inner(area);
app.tab.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),
);
if let Some(e) = extent {
render_scrollbar(frame, area, e);
}
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;
if !wrap {
if let Some((caret_disp, caret_row)) = caret_display_col(&lines) {
let w = inner.width as usize;
let line_w = lines[caret_row].width();
if line_w <= w {
app.tab.preview_hscroll = 0;
} else {
let h = app.tab.preview_hscroll as usize;
if caret_disp < h {
app.tab.preview_hscroll = caret_disp as u16;
} else if caret_disp >= h + w {
app.tab.preview_hscroll = (caret_disp + 1 - w) as u16;
}
}
}
}
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.tab.preview_hscroll = app.tab.preview_hscroll.min(max_h);
let para = para.scroll((0, app.tab.preview_hscroll));
frame.render_widget(para, area);
if let Some(e) = extent {
render_scrollbar(frame, area, e);
}
}
fn caret_display_col(lines: &[ratatui::text::Line<'static>]) -> Option<(usize, usize)> {
use ratatui::style::Modifier;
for (row, line) in lines.iter().enumerate() {
let mut disp = 0usize;
for span in &line.spans {
if span.style.add_modifier.contains(Modifier::REVERSED) {
return Some((disp, row));
}
disp += span.width();
}
}
None
}
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
.tab
.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
.tab
.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);
if app.uses_kitty_image() {
match (target, app.kitty_image_ref()) {
(Some(target), Some(ki)) => ki.render(target, frame.buffer_mut()),
_ => fallback(frame),
}
return;
}
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(test)]
mod scroll_indicator_tests {
use super::*;
use crate::app::App;
use crate::config::Config;
use crate::i18n::Lang;
use crate::test_support::unique_tmp;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::backend::TestBackend;
use ratatui::Terminal;
use std::path::PathBuf;
fn press(app: &mut App, code: KeyCode) {
crate::handle_key(app, KeyEvent::new(code, KeyModifiers::NONE)).unwrap();
}
fn draw(term: &mut Terminal<TestBackend>, app: &mut App) {
term.draw(|f| crate::ui::render(f, app)).unwrap();
}
fn preview_of(name: &str, body: &str, w: u16, h: u16) -> (App, Terminal<TestBackend>, PathBuf) {
let dir = unique_tmp(name);
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("doc.txt"), body).unwrap();
let root = dir.canonicalize().unwrap();
let mut app = App::new(root, Config::default()).unwrap();
let i = app
.tab
.entries
.iter()
.position(|e| e.path.file_name().is_some_and(|n| n == "doc.txt"))
.expect("doc.txt がツリーに出る");
app.tab.selected = i;
press(&mut app, KeyCode::Enter);
let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
draw(&mut term, &mut app);
(app, term, dir)
}
fn rows(term: &Terminal<TestBackend>) -> Vec<String> {
let buf = term.backend().buffer();
(0..buf.area.height)
.map(|y| {
(0..buf.area.width)
.map(|x| buf[(x, y)].symbol())
.collect::<String>()
})
.collect()
}
#[track_caller]
fn bar(term: &Terminal<TestBackend>) -> (Vec<u16>, Vec<u16>) {
let buf = term.backend().buffer();
let x = buf.area.width - 1;
let at = |y: u16| buf[(x, y)].symbol().to_string();
let top = (0..buf.area.height)
.find(|&y| at(y) == "┐")
.expect("枠の右上コーナー");
let bot = (0..buf.area.height)
.find(|&y| at(y) == "┘")
.expect("枠の右下コーナー");
let (mut track, mut thumb) = (Vec::new(), Vec::new());
for y in (top + 1)..bot {
let s = at(y);
assert!(
s == "│" || s == "█",
"右端は枠か thumb のはず: {s:?} (row {y})"
);
track.push(y);
if s == "█" {
thumb.push(y);
}
}
(track, thumb)
}
#[test]
fn scroll_label_reports_all_top_bot_and_percent() {
let all = ScrollExtent::new(0, 0, 20);
let top = ScrollExtent::new(0, 200, 20);
let mid = ScrollExtent::new(50, 200, 20);
let bot = ScrollExtent::new(200, 200, 20);
assert_eq!(scroll_label(Lang::En, all), "All");
assert_eq!(scroll_label(Lang::En, top), "Top");
assert_eq!(scroll_label(Lang::En, mid), "25%");
assert_eq!(scroll_label(Lang::En, bot), "Bot");
assert_eq!(scroll_label(Lang::Jp, all), "全体");
assert_eq!(scroll_label(Lang::Jp, top), "先頭");
assert_eq!(scroll_label(Lang::Jp, mid), "25%");
assert_eq!(scroll_label(Lang::Jp, bot), "末尾");
assert_eq!(
scroll_label(Lang::En, ScrollExtent::new(999, 200, 20)),
"Bot"
);
let title = scroll_title(Lang::En, bot);
assert_eq!(title.to_string(), " [Bot] ");
assert_eq!(title.alignment, Some(ratatui::layout::Alignment::Right));
}
#[test]
fn a_long_path_does_not_push_the_marker_off_the_border() {
let body: String = (1..=400).map(|i| format!("line {i}\n")).collect();
let (_, term, dir) = preview_of("konoma_ui_scroll_longpath", &body, 30, 14);
let top = &rows(&term)[0..3].join("\n");
assert!(
top.contains("[Top]"),
"パスが枠幅を埋めても位置マーカーは残る:\n{top}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn thumb_reaches_the_track_end_when_scrolled_to_the_bottom() {
let body: String = (1..=400).map(|i| format!("line {i}\n")).collect();
let (mut app, mut term, dir) = preview_of("konoma_ui_scroll_ends", &body, 40, 20);
let (track, thumb) = bar(&term);
assert!(!thumb.is_empty(), "スクロールが要る文書では thumb が出る");
assert_eq!(
thumb.first(),
track.first(),
"先頭では thumb がトラック上端から始まる"
);
assert_ne!(
thumb.last(),
track.last(),
"先頭では thumb が下端に届いていない(残量が見える)"
);
assert!(
rows(&term).iter().any(|r| r.contains("[Top]")),
"先頭のラベルは Top:\n{}",
rows(&term).join("\n")
);
press(&mut app, KeyCode::Char('G'));
draw(&mut term, &mut app);
let (track, thumb) = bar(&term);
assert_eq!(
thumb.last(),
track.last(),
"末尾では thumb がトラック下端に接する"
);
assert!(
rows(&term).iter().any(|r| r.contains("[Bot]")),
"末尾のラベルは Bot:\n{}",
rows(&term).join("\n")
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn thumb_fills_the_track_when_everything_fits() {
let (_, term, dir) = preview_of("konoma_ui_scroll_all", "alpha\nbeta\ngamma\n", 40, 20);
let (track, thumb) = bar(&term);
assert_eq!(thumb, track, "全部見えているときは thumb がトラック全長");
assert!(
rows(&term).iter().any(|r| r.contains("[All]")),
"収まっているときのラベルは All:\n{}",
rows(&term).join("\n")
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn thumb_shrinks_as_the_file_grows() {
let short: String = (1..=40).map(|i| format!("line {i}\n")).collect();
let long: String = (1..=4000).map(|i| format!("line {i}\n")).collect();
let (_, short_term, short_dir) = preview_of("konoma_ui_scroll_short", &short, 40, 20);
let (_, long_term, long_dir) = preview_of("konoma_ui_scroll_long", &long, 40, 20);
let (track, short_thumb) = bar(&short_term);
let (_, long_thumb) = bar(&long_term);
assert!(
long_thumb.len() < short_thumb.len(),
"長いファイルほど thumb が短い: 長={} 短={}",
long_thumb.len(),
short_thumb.len()
);
assert!(
short_thumb.len() < track.len(),
"スクロールが要る文書では thumb は全長にならない"
);
assert!(!long_thumb.is_empty(), "極端に長くても thumb は消えない");
std::fs::remove_dir_all(&short_dir).ok();
std::fs::remove_dir_all(&long_dir).ok();
}
#[test]
fn the_indicator_does_not_steal_a_text_column() {
let w = 40u16;
let text_w = (w - 2) as usize; let body = format!("{}\nSECOND\n", "x".repeat(text_w));
let (_, term, dir) = preview_of("konoma_ui_scroll_width", &body, w, 20);
let screen = rows(&term);
let second = screen
.iter()
.position(|r| r.contains("SECOND"))
.expect("2行目が描かれる");
let filled = &screen[second - 1];
assert_eq!(
filled.matches('x').count(),
text_w,
"枠内いっぱい({text_w}桁)の行が折り返さず1行に収まる:\n{}",
screen.join("\n")
);
let cells: Vec<char> = filled.chars().collect();
assert_eq!(cells[0], '│', "左端は枠");
assert!(
cells[w as usize - 1] == '│' || cells[w as usize - 1] == '█',
"右端は枠かインジケータ"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn decorated_markdown_reports_its_position_too() {
let dir = unique_tmp("konoma_ui_scroll_md");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let body: String = (1..=200).map(|i| format!("- item {i}\n")).collect();
std::fs::write(dir.join("doc.md"), &body).unwrap();
let root = dir.canonicalize().unwrap();
let mut app = App::new(root, Config::default()).unwrap();
let i = app
.tab
.entries
.iter()
.position(|e| e.path.file_name().is_some_and(|n| n == "doc.md"))
.expect("doc.md がツリーに出る");
app.tab.selected = i;
press(&mut app, KeyCode::Enter);
let mut term = Terminal::new(TestBackend::new(40, 20)).unwrap();
draw(&mut term, &mut app);
assert!(!app.is_windowed(), "装飾 Markdown は windowed ではない");
let (track, thumb) = bar(&term);
assert_eq!(thumb.first(), track.first(), "先頭では thumb が上端");
assert!(rows(&term).iter().any(|r| r.contains("[Top]")));
press(&mut app, KeyCode::Char('G'));
draw(&mut term, &mut app);
let (track, thumb) = bar(&term);
assert_eq!(thumb.last(), track.last(), "末尾では thumb が下端に接する");
assert!(
rows(&term).iter().any(|r| r.contains("[Bot]")),
"末尾のラベルは Bot:\n{}",
rows(&term).join("\n")
);
std::fs::remove_dir_all(&dir).ok();
}
}
#[cfg(all(test, feature = "git"))]
mod gitdiff_tests {
use crate::app::App;
use crate::config::Config;
use crate::test_support::unique_tmp;
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_reports_scroll_position() {
let dir = unique_tmp("konoma_ui_gitdiff_scroll");
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"seed\n").unwrap();
run_git(&dir, &["add", "-A"]);
run_git(&dir, &["commit", "-m", "init"]);
let body: String = (1..=200).map(|i| format!("added {i}\n")).collect();
std::fs::write(&f, body).unwrap();
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(50, 16)).unwrap();
let bar = |term: &Terminal<TestBackend>| {
let buf = term.backend().buffer();
let x = buf.area.width - 1;
let (mut track, mut thumb) = (Vec::new(), Vec::new());
for y in 1..(buf.area.height - 1) {
let s = buf[(x, y)].symbol().to_string();
assert!(s == "│" || s == "█", "右端は枠か thumb: {s:?} (row {y})");
track.push(y);
if s == "█" {
thumb.push(y);
}
}
(track, thumb)
};
let screen = |term: &Terminal<TestBackend>| -> String {
term.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect()
};
term.draw(|f| crate::ui::preview::render(f, &mut app, f.area()))
.unwrap();
let (track, thumb) = bar(&term);
assert_eq!(thumb.first(), track.first(), "先頭では thumb が上端");
assert_ne!(thumb.last(), track.last(), "先頭では下端に届かない");
assert!(screen(&term).contains("[Top]"), "先頭のラベルは Top");
app.preview_to_bottom();
term.draw(|f| crate::ui::preview::render(f, &mut app, f.area()))
.unwrap();
let (track, thumb) = bar(&term);
assert_eq!(
thumb.last(),
track.last(),
"末尾では thumb がトラック下端に接する"
);
assert!(screen(&term).contains("[Bot]"), "末尾のラベルは Bot");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn gitdiff_preview_renders_with_colored_rows() {
let dir = unique_tmp("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 = unique_tmp("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();
}
}