use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::Result;
use ratatui::layout::Rect;
use ratatui::text::{Line, Span};
use ratatui_image::errors::Errors;
use ratatui_image::picker::Picker;
use ratatui_image::protocol::Protocol;
use ratatui_image::thread::{ResizeRequest, ResizeResponse, ThreadProtocol};
use ratatui_image::{FilterType, Resize};
use tokio::sync::mpsc::UnboundedSender;
use crate::config::Config;
use crate::i18n::tr;
use crate::preview::PreviewKind;
mod bookmark_actions;
mod file_actions;
mod git_view;
mod md_tasks;
mod paste_jump;
mod session_actions;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Mode {
#[default]
Tree,
Preview,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DisplayMode {
Tree,
Preview,
Image,
Table,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InternalMode {
Visual,
PreviewVisual,
Filter,
ChangedFilter,
Search,
Sort,
Mark,
Bookmarks,
Tabs,
Outline,
Info,
Create,
Rename,
BatchRename,
RenamePreview,
DeleteConfirm,
DropConfirm,
QuitConfirm,
BookmarkConfirm,
GitChanges,
GitDiff,
Commit,
GitLog,
GitDetail,
GitBranch,
GitGraph,
GitGraphPicker,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeyScheme {
Vim,
Less,
}
impl KeyScheme {
pub fn parse(s: &str) -> Self {
match s {
"less" => Self::Less,
_ => Self::Vim,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffLayout {
Unified,
Split,
Auto,
}
impl DiffLayout {
pub fn parse(s: &str) -> Self {
let n: String = s
.chars()
.filter(|c| !matches!(c, ' ' | '-' | '_'))
.flat_map(|c| c.to_lowercase())
.collect();
match n.as_str() {
"split" | "horizontal" | "sidebyside" | "side" => Self::Split,
"auto" => Self::Auto,
_ => Self::Unified, }
}
pub fn is_split(self, width: u16) -> bool {
match self {
DiffLayout::Unified => false,
DiffLayout::Split => true,
DiffLayout::Auto => width >= DIFF_AUTO_MIN_WIDTH,
}
}
#[cfg_attr(not(feature = "git"), allow(dead_code))]
pub fn next(self) -> Self {
match self {
DiffLayout::Unified => DiffLayout::Split,
DiffLayout::Split => DiffLayout::Auto,
DiffLayout::Auto => DiffLayout::Unified,
}
}
}
const DIFF_AUTO_MIN_WIDTH: u16 = 90;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StatusbarLayout {
Split,
Bottom,
}
impl StatusbarLayout {
pub fn parse(s: &str) -> Self {
match s {
"bottom" => Self::Bottom,
_ => Self::Split,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PathStyle {
Relative,
Home,
Full,
}
impl PathStyle {
pub fn parse(s: &str) -> Self {
match s {
"home" => Self::Home,
"full" => Self::Full,
_ => Self::Relative,
}
}
pub fn next(self) -> Self {
match self {
Self::Relative => Self::Home,
Self::Home => Self::Full,
Self::Full => Self::Relative,
}
}
}
#[derive(Debug, Clone)]
pub struct Entry {
pub path: PathBuf,
pub is_dir: bool,
pub depth: usize,
pub expanded: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortKey {
Name,
Size,
Modified,
Ext,
}
#[derive(Clone, Copy)]
pub struct Sort {
pub key: SortKey,
pub reverse: bool,
pub dirs_first: bool,
}
impl Default for Sort {
fn default() -> Self {
Self {
key: SortKey::Name,
reverse: false,
dirs_first: true,
}
}
}
impl SortKey {
pub fn parse(s: &str) -> Self {
match s.trim().to_lowercase().as_str() {
"size" => SortKey::Size,
"modified" | "mtime" | "mod" => SortKey::Modified,
"ext" | "extension" | "type" => SortKey::Ext,
_ => SortKey::Name,
}
}
}
impl Sort {
pub fn from_config(c: &crate::config::SortConfig) -> Self {
Self {
key: SortKey::parse(&c.key),
reverse: c.reverse,
dirs_first: c.dirs_first,
}
}
}
#[cfg_attr(not(feature = "git"), allow(dead_code))]
#[derive(Clone)]
enum PendingOp {
Create { dir: PathBuf },
Rename { target: PathBuf },
Delete { targets: Vec<PathBuf> },
BatchRenameInput { targets: Vec<PathBuf> },
BatchRenameApply { plan: Vec<(PathBuf, PathBuf)> },
GitDiscard { path: PathBuf },
GitCommit,
GitCreateBranch,
GitDeleteBranch { name: String },
DropTransfer { sources: Vec<PathBuf>, dir: PathBuf },
Quit,
BookmarkOverwrite { key: char, target: PathBuf },
}
struct Dialog {
op: PendingOp,
kind: DialogKind,
}
enum DialogKind {
Confirm {
message: String,
allow_permanent: bool,
},
Input {
title: String,
buffer: String,
cursor: usize,
},
Preview {
title: String,
lines: Vec<String>,
scroll: usize,
},
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum ClipOp {
Copy,
Cut,
}
#[derive(Clone)]
struct Clipboard {
op: ClipOp,
paths: Vec<PathBuf>,
}
#[derive(Clone, Default)]
struct TabState {
root: PathBuf,
open_dir: PathBuf,
entries: Vec<Entry>,
selected: usize,
show_hidden: bool,
tree_viewport: u16,
mode: Mode,
preview_path: Option<PathBuf>,
preview_kind: Option<PreviewKind>,
preview_scroll: u16,
preview_hscroll: u16,
preview_viewport: u16,
preview_byte_top: u64,
preview_top_line: usize,
image_zoom: f64,
image_center: (f64, f64),
pdf_page: u32,
pdf_pages: Option<u32>,
table_cur_row: usize,
table_cur_col: usize,
table_top_row: usize,
table_left_col: usize,
preview_cursor_line: usize,
preview_cursor_col: usize,
md_raw: bool,
focused_item: Option<usize>,
fence_zoom: f64,
fence_center: (f64, f64),
fence_return: Option<(u16, Option<usize>)>,
git_view: bool,
git_view_sel: usize,
git_view_entries: Vec<crate::git::ChangeEntry>,
came_from_git_view: bool,
git_log: Option<Vec<crate::git::CommitInfo>>,
git_log_sel: usize,
git_detail: Option<Vec<crate::git::DiffLine>>,
git_detail_meta: Option<crate::git::CommitMeta>,
git_detail_title: Option<String>,
git_detail_scroll: u16,
git_detail_hscroll: u16,
git_detail_viewport: u16,
git_detail_total: usize,
git_branches: Option<Vec<crate::git::BranchInfo>>,
git_branch_sel: usize,
git_branch_filter: String,
git_branch_filtering: bool,
git_graph: Option<Vec<crate::git::GraphRow>>,
git_graph_sel: usize,
git_graph_base: Option<String>,
git_graph_base_label: Option<String>,
git_graph_visible: std::collections::HashSet<String>,
git_graph_legend: Vec<crate::git::LegendEntry>,
git_graph_hidden: usize,
git_graph_order: Vec<String>,
selection: BTreeSet<PathBuf>,
visual_anchor: Option<usize>,
tree_filter: Option<String>,
filter_input: Option<String>,
filter_pool: Vec<Entry>,
changed_filter: bool,
preview_search: Option<String>,
search_input: Option<String>,
search_matches: Vec<(u64, usize, usize)>,
search_idx: usize,
}
#[cfg(test)]
pub fn sizeof_app() -> usize {
std::mem::size_of::<App>()
}
#[cfg(test)]
pub fn sizeof_tabstate() -> usize {
std::mem::size_of::<TabState>()
}
pub enum MediaPayload {
Static(image::DynamicImage),
Gif(Vec<(image::DynamicImage, std::time::Duration)>),
Vector {
img: image::DynamicImage,
svg: std::sync::Arc<Vec<u8>>,
},
}
pub struct MediaResult {
gen: u64,
payload: Option<MediaPayload>,
}
type KittyGeom = ((u32, u32, u32, u32), u16, u16);
pub struct KittyResult {
gen: u64,
image: Option<crate::preview::kitty::KittyImage>,
}
enum MediaJob {
Svg(PathBuf, u32),
Gif(PathBuf),
Video(PathBuf),
Pdf(PathBuf, u32),
Mermaid(PathBuf, u32, String),
MermaidSrc(String, u32, String),
SvgReraster(std::sync::Arc<Vec<u8>>, PathBuf, u32),
}
impl MediaJob {
fn run(self) -> Option<MediaPayload> {
match self {
MediaJob::Svg(p, max_px) => {
let data = std::fs::read(&p).ok()?;
let img = crate::preview::svg::rasterize_bytes(&data, &p, max_px)?;
Some(MediaPayload::Vector {
img,
svg: std::sync::Arc::new(data),
})
}
MediaJob::Gif(p) => match crate::preview::image::decode_gif(&p) {
Some(frames) => Some(MediaPayload::Gif(frames)),
None => crate::preview::image::decode_static(&p).map(MediaPayload::Static),
},
MediaJob::Video(p) => crate::preview::video::thumbnail(&p).map(MediaPayload::Static),
MediaJob::Pdf(p, page) => {
crate::preview::pdf::render_page(&p, page).map(MediaPayload::Static)
}
MediaJob::Mermaid(p, max_px, theme) => {
let code = std::fs::read_to_string(&p).ok()?;
MediaJob::MermaidSrc(code, max_px, theme).run()
}
MediaJob::MermaidSrc(code, max_px, theme) => {
let svg = crate::preview::markdown::mermaid_to_svg(&code, &theme)?;
let data = svg.into_bytes();
let img =
crate::preview::svg::rasterize_bytes(&data, Path::new("mermaid.svg"), max_px)?;
Some(MediaPayload::Vector {
img,
svg: std::sync::Arc::new(data),
})
}
MediaJob::SvgReraster(svg, p, max_px) => {
let img = crate::preview::svg::rasterize_bytes(&svg, &p, max_px)?;
Some(MediaPayload::Vector { img, svg })
}
}
}
}
pub struct IgnoredResult {
gen: u64,
workdir: PathBuf,
set: std::collections::HashSet<PathBuf>,
}
struct MediaCache {
path: PathBuf,
mtime: Option<std::time::SystemTime>,
len: Option<u64>,
page: u32,
fence_ord: Option<usize>,
src: std::sync::Arc<image::DynamicImage>,
logical: Option<(u32, u32)>,
vector_svg: Option<std::sync::Arc<Vec<u8>>>,
}
pub struct StatusResult {
gen: u64,
workdir: Option<PathBuf>,
statuses: std::collections::HashMap<PathBuf, crate::git::FileStatus>,
branch: Option<String>,
}
pub struct App {
pub mode: Mode,
pub root: PathBuf,
pub open_dir: PathBuf,
launch_dir: PathBuf,
pub path_style: PathStyle,
pub key_scheme: KeyScheme,
pub lang: crate::i18n::Lang,
pub cfg: Config,
pub entries: Vec<Entry>,
pub selected: usize,
pub show_hidden: bool,
pub sort: Sort,
sort_menu: bool,
pub bookmarks: crate::bookmarks::Bookmarks,
session_store: Option<crate::session::SessionStore>,
session_restoring: bool,
mark_set_pending: bool,
bookmark_list: bool,
bookmark_list_sel: usize,
dialog: Option<Dialog>,
selection: BTreeSet<PathBuf>,
visual_anchor: Option<usize>,
clipboard: Option<Clipboard>,
pub tree_viewport: u16,
tree_filter: Option<String>,
filter_input: Option<String>,
filter_pool: Vec<Entry>,
changed_filter: bool,
follow_mode: bool,
follow_session: Vec<PathBuf>,
diff_follow_scope: bool,
pub preview_path: Option<PathBuf>,
pub preview_kind: Option<PreviewKind>,
pub preview_scroll: u16,
pub preview_hscroll: u16,
pub preview_viewport: u16,
pub md_view_rows: usize,
preview_win: Option<crate::preview::window::FileWindow>,
preview_byte_top: u64,
preview_top_line: usize,
preview_total_lines: Option<usize>,
win_cache: Option<WinCache>,
hl_pending: bool,
hl_warming: bool,
spinner_frame: usize,
pending_edit: Option<(PathBuf, Option<usize>)>,
pending_git_tool: bool,
md_cache: Option<MdCache>,
md_raw: bool,
diff_cache: Option<DiffCache>,
gutter_cache: Option<GutterCache>,
md_items: Vec<MdItem>,
focused_item: Option<usize>,
details_open: std::collections::HashMap<usize, bool>,
preview_search: Option<String>,
search_input: Option<String>,
search_matches: Vec<(u64, usize, usize)>,
table_search_hits: std::collections::HashSet<(usize, usize)>,
search_idx: usize,
picker: Option<Picker>,
img_tx: Option<UnboundedSender<ResizeRequest>>,
pub image: Option<ThreadProtocol>,
use_kitty: bool,
kitty_is_tmux: bool,
kitty_image: Option<crate::preview::kitty::KittyImage>,
kitty_tx: Option<std::sync::mpsc::Sender<KittyResult>>,
kitty_gen: u64,
kitty_want: Option<KittyGeom>,
kitty_shown: Option<KittyGeom>,
image_src: Option<std::sync::Arc<image::DynamicImage>>,
pub image_zoom: f64,
image_center: (f64, f64),
image_crop: Option<(u32, u32, u32, u32)>,
image_vis_frac: (f64, f64),
vector_svg: Option<std::sync::Arc<Vec<u8>>>,
image_logical: Option<(u32, u32)>,
vector_reraster_inflight: bool,
fence_return: Option<(u16, Option<usize>)>,
md_overlay_last: Option<u64>,
md_overlay_seen: Option<u64>,
md_overlay_moved: bool,
fence_zoom: f64,
fence_center: (f64, f64),
pdf_page: u32,
pdf_pages: Option<u32>,
preview_media_mtime: Option<std::time::SystemTime>,
media_cache: Option<MediaCache>,
table_data: Option<crate::preview::table::TableData>,
table_cur_row: usize,
table_cur_col: usize,
table_top_row: usize,
table_left_col: usize,
table_viewport_rows: u16,
preview_cursor_line: usize,
preview_cursor_col: usize,
preview_visual_anchor: Option<(usize, usize)>,
preview_visual_linewise: bool,
gif_frames: Vec<(image::DynamicImage, std::time::Duration)>,
gif_idx: usize,
gif_shown_at: Option<std::time::Instant>,
gif_protocol: Option<Protocol>,
gif_proto_key: Option<(usize, (u32, u32, u32, u32))>,
media_tx: Option<std::sync::mpsc::Sender<MediaResult>>,
md_image_cache: std::collections::HashMap<PathBuf, MdImgEntry>,
detail_cells_cache: std::collections::HashMap<PathBuf, Vec<String>>,
md_img_tx: Option<std::sync::mpsc::Sender<MdImageResult>>,
md_remote_inflight: std::collections::HashSet<String>,
md_remote_failed: std::collections::HashSet<String>,
md_remote_tx: Option<std::sync::mpsc::Sender<RemoteFetch>>,
md_enc_tx: Option<std::sync::mpsc::Sender<MdEncodeRequest>>,
media_gen: u64,
media_loading: bool,
pub keymaps: crate::keymap::KeyMap,
pub pending_leader: Option<crate::keymap::LeaderId>,
pub flash: Option<String>,
pub show_help: bool,
pub help_scroll: u16,
pub show_info: bool,
tabs: Vec<TabState>,
active_tab: usize,
tab_list: bool,
tab_list_sel: usize,
outline_open: bool,
outline_sel: usize,
git_status: std::collections::HashMap<PathBuf, crate::git::FileStatus>,
git_ignored: std::collections::HashSet<PathBuf>,
git_status_for: Option<PathBuf>,
git_status_workdir: Option<PathBuf>,
git_status_dirty: bool,
git_status_pending: Option<PathBuf>,
git_status_gen: u64,
status_tx: Option<std::sync::mpsc::Sender<StatusResult>>,
git_ignored_for: Option<PathBuf>,
git_ignored_pending: Option<PathBuf>,
git_ignored_gen: u64,
git_ignored_dirty: bool,
ignored_tx: Option<std::sync::mpsc::Sender<IgnoredResult>>,
diff_layout: DiffLayout,
git_detail_title: Option<String>,
git_branch: Option<String>,
git_view: bool,
git_view_sel: usize,
git_view_entries: Vec<crate::git::ChangeEntry>,
came_from_git_view: bool,
git_log: Option<Vec<crate::git::CommitInfo>>,
git_log_sel: usize,
git_detail: Option<Vec<crate::git::DiffLine>>,
git_detail_meta: Option<crate::git::CommitMeta>,
git_detail_scroll: u16,
git_detail_hscroll: u16,
git_detail_viewport: u16,
git_detail_total: usize,
git_branches: Option<Vec<crate::git::BranchInfo>>,
git_branch_sel: usize,
git_branch_filter: String,
git_branch_filtering: bool,
git_graph: Option<Vec<crate::git::GraphRow>>,
git_graph_sel: usize,
git_graph_base: Option<String>,
git_graph_base_label: Option<String>,
git_graph_visible: std::collections::HashSet<String>,
git_graph_legend: Vec<crate::git::LegendEntry>,
git_graph_hidden: usize,
git_graph_picker: bool,
git_graph_picker_sel: usize,
git_graph_picker_set: std::collections::HashSet<String>,
git_graph_order: Vec<String>,
git_graph_reordered: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CopyKind {
Name,
Full,
Relative,
Parent,
AtRef,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(not(feature = "git"), allow(dead_code))]
pub enum GitCopyKind {
ShortHash,
FullHash,
Subject,
Message,
Author,
Date,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SearchTarget {
Windowed,
Table,
Markdown,
Unsupported,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TableCopyKind {
Cell,
Row,
Column,
}
struct MdCache {
path: PathBuf,
width: u16,
lines: Vec<Line<'static>>,
items: Vec<MdItem>,
images: Vec<crate::preview::markdown::ImagePlacement>,
src_lines: usize,
max_line_cols: usize,
row_prefix: Vec<usize>,
fence_rows: u16,
anchors: Vec<(String, usize)>,
details_states: Vec<bool>,
}
struct DiffCache {
path: PathBuf,
lines: Vec<crate::git::DiffLine>,
}
struct GutterCache {
path: PathBuf,
marks: std::collections::HashMap<u32, GutterMark>,
}
#[derive(Clone)]
struct MdItem {
line: usize,
kind: MdItemKind,
}
#[derive(Clone)]
enum MdItemKind {
Link { target: String },
Task { state: char },
CodeBlock,
MermaidFence { ordinal: usize },
Details { ordinal: usize },
}
struct WinCache {
path: PathBuf,
byte_top: u64,
height: u16,
styled: bool,
lines: Vec<Line<'static>>,
}
#[derive(Default)]
struct MdImgEntry {
decoded: Option<Arc<image::DynamicImage>>,
protocol: Option<Protocol>,
proto_size: Option<(u16, u16)>,
clip_protocol: Option<Protocol>,
clip_key: Option<(u16, u16, u16, u16)>,
enc_inflight: bool,
failed: bool,
zoom_protocol: Option<Protocol>,
zoom_key: Option<(u16, u16, PxRect)>,
svg: Option<std::sync::Arc<Vec<u8>>>,
layout_px: Option<(u32, u32)>,
reraster_inflight: bool,
}
#[derive(PartialEq)]
enum FenceSharpen {
NotNeeded,
Spawned,
AppliedSync,
}
#[derive(Clone, Copy, PartialEq, Debug)]
enum MdEncodeKey {
Full { cols: u16, rows: u16 },
Clip {
cols: u16,
full_rows: u16,
row_off: u16,
vis_rows: u16,
},
Zoom { cols: u16, rows: u16, crop: PxRect },
}
pub struct MdEncodeRequest {
path: PathBuf,
key: MdEncodeKey,
image: Arc<image::DynamicImage>,
crop: Option<PxRect>,
cols: u16,
rows: u16,
}
pub struct MdEncodeResult {
path: PathBuf,
key: MdEncodeKey,
protocol: Option<Protocol>,
}
pub fn md_encode_worker(
picker: Picker,
rx: std::sync::mpsc::Receiver<MdEncodeRequest>,
tx: std::sync::mpsc::Sender<MdEncodeResult>,
) {
while let Ok(req) = rx.recv() {
let (path, key) = (req.path.clone(), req.key);
let picker = &picker; let protocol = crate::preview::markdown::catch_silent(move || {
let img = match req.crop {
Some((x, y, w, h)) => req.image.crop_imm(x, y, w, h),
None => (*req.image).clone(),
};
let size = ratatui::layout::Size::new(req.cols, req.rows);
let resize =
if crate::preview::markdown::is_mermaid_fence_url(&req.path.to_string_lossy()) {
Resize::Scale(Some(FilterType::Lanczos3))
} else {
Resize::Fit(Some(FilterType::Lanczos3))
};
picker.new_protocol(img, size, resize).ok()
})
.flatten();
if tx
.send(MdEncodeResult {
path,
key,
protocol,
})
.is_err()
{
break;
}
}
}
pub struct MdImageResult {
path: PathBuf,
image: Result<image::DynamicImage, String>,
svg: Option<std::sync::Arc<Vec<u8>>>,
reraster: bool,
}
pub struct RemoteFetch {
url: String,
ok: bool,
}
impl App {
pub fn new(root: PathBuf, cfg: Config) -> Result<Self> {
let path_style = PathStyle::parse(&cfg.ui.path_style);
let key_scheme = KeyScheme::parse(&cfg.ui.keys);
let lang = crate::i18n::Lang::resolve(&cfg.ui.lang);
let sort = Sort::from_config(&cfg.ui.sort);
let diff_layout = DiffLayout::parse(&cfg.git.diff);
let keymaps = crate::keymap::KeyMap::from_config(
crate::keymap::scheme_from_str(&cfg.ui.keys),
&cfg.keys.to_keymap_config(),
);
let mut app = Self {
mode: Mode::Tree,
root: root.clone(),
open_dir: root.clone(),
launch_dir: root.clone(),
path_style,
key_scheme,
lang,
cfg,
entries: Vec::new(),
selected: 0,
show_hidden: false,
sort,
sort_menu: false,
bookmarks: crate::bookmarks::Bookmarks::load(&root),
session_store: None,
session_restoring: false,
mark_set_pending: false,
bookmark_list: false,
bookmark_list_sel: 0,
dialog: None,
selection: BTreeSet::new(),
visual_anchor: None,
clipboard: None,
tree_viewport: 0,
tree_filter: None,
filter_input: None,
filter_pool: Vec::new(),
changed_filter: false,
follow_mode: false,
follow_session: Vec::new(),
diff_follow_scope: false,
preview_path: None,
preview_kind: None,
preview_scroll: 0,
md_view_rows: 0,
preview_hscroll: 0,
preview_viewport: 0,
preview_win: None,
preview_byte_top: 0,
preview_top_line: 0,
preview_total_lines: None,
win_cache: None,
hl_pending: false,
hl_warming: false,
spinner_frame: 0,
pending_edit: None,
pending_git_tool: false,
md_cache: None,
md_raw: false,
diff_cache: None,
gutter_cache: None,
md_items: Vec::new(),
focused_item: None,
details_open: std::collections::HashMap::new(),
preview_search: None,
search_input: None,
search_matches: Vec::new(),
table_search_hits: std::collections::HashSet::new(),
search_idx: 0,
picker: None,
img_tx: None,
image: None,
use_kitty: false,
kitty_is_tmux: false,
kitty_image: None,
kitty_tx: None,
kitty_gen: 0,
kitty_want: None,
kitty_shown: None,
image_src: None,
image_zoom: 1.0,
image_center: (0.5, 0.5),
image_crop: None,
image_vis_frac: (1.0, 1.0),
vector_svg: None,
image_logical: None,
vector_reraster_inflight: false,
fence_return: None,
fence_zoom: 1.0,
fence_center: (0.5, 0.5),
md_overlay_last: None,
md_overlay_seen: None,
md_overlay_moved: false,
pdf_page: 1,
pdf_pages: None,
preview_media_mtime: None,
media_cache: None,
table_data: None,
table_cur_row: 0,
table_cur_col: 0,
table_top_row: 0,
table_left_col: 0,
table_viewport_rows: 0,
preview_cursor_line: 0,
preview_cursor_col: 0,
preview_visual_anchor: None,
preview_visual_linewise: false,
gif_frames: Vec::new(),
gif_idx: 0,
gif_shown_at: None,
gif_protocol: None,
gif_proto_key: None,
media_tx: None,
md_image_cache: std::collections::HashMap::new(),
detail_cells_cache: std::collections::HashMap::new(),
md_img_tx: None,
md_remote_inflight: std::collections::HashSet::new(),
md_remote_failed: std::collections::HashSet::new(),
md_remote_tx: None,
md_enc_tx: None,
media_gen: 0,
media_loading: false,
keymaps,
pending_leader: None,
flash: None,
show_help: false,
show_info: false,
help_scroll: 0,
tabs: Vec::new(),
active_tab: 0,
tab_list: false,
tab_list_sel: 0,
outline_open: false,
outline_sel: 0,
git_status: std::collections::HashMap::new(),
git_ignored: std::collections::HashSet::new(),
git_status_for: None,
git_status_workdir: None,
git_status_dirty: false,
git_status_pending: None,
git_status_gen: 0,
status_tx: None,
git_ignored_for: None,
git_ignored_pending: None,
git_ignored_gen: 0,
git_ignored_dirty: false,
ignored_tx: None,
diff_layout,
git_detail_title: None,
git_branch: None,
git_view: false,
git_view_sel: 0,
git_view_entries: Vec::new(),
came_from_git_view: false,
git_log: None,
git_log_sel: 0,
git_detail: None,
git_detail_meta: None,
git_detail_scroll: 0,
git_detail_hscroll: 0,
git_detail_viewport: 0,
git_detail_total: 0,
git_branches: None,
git_branch_sel: 0,
git_branch_filter: String::new(),
git_branch_filtering: false,
git_graph: None,
git_graph_sel: 0,
git_graph_base: None,
git_graph_base_label: None,
git_graph_visible: std::collections::HashSet::new(),
git_graph_legend: Vec::new(),
git_graph_hidden: 0,
git_graph_picker: false,
git_graph_picker_sel: 0,
git_graph_picker_set: std::collections::HashSet::new(),
git_graph_order: Vec::new(),
git_graph_reordered: false,
};
app.rebuild_tree()?;
app.tabs.push(app.snapshot_tab());
Ok(app)
}
pub fn attach_session_store(&mut self, store: crate::session::SessionStore) {
self.session_store = Some(store);
}
pub fn keymap_report(&self) -> Option<String> {
let nc = self.keymaps.conflicts.len();
let nw = self.keymaps.warnings.len();
if nc == 0 && nw == 0 {
return None;
}
let mut parts: Vec<String> = Vec::new();
if nc > 0 {
parts.push(match self.lang {
crate::i18n::Lang::Jp => format!("キー衝突{nc}件(既定で継続)"),
crate::i18n::Lang::En => format!("{nc} key conflict(s) (using defaults)"),
});
}
if nw > 0 {
parts.push(match self.lang {
crate::i18n::Lang::Jp => format!("無効な設定{nw}件を無視"),
crate::i18n::Lang::En => format!("{nw} invalid key setting(s) ignored"),
});
}
let head = tr(self.lang, crate::i18n::Msg::Keymap);
Some(format!("{head}: {}", parts.join(" / ")))
}
pub fn rebuild_tree(&mut self) -> Result<()> {
let mut out = Vec::new();
let expanded_dirs: std::collections::HashSet<PathBuf> = self
.entries
.iter()
.filter(|e| e.is_dir && e.expanded)
.map(|e| e.path.clone())
.collect();
build_dir(
&self.root,
0,
&expanded_dirs,
self.show_hidden,
self.sort,
&mut out,
)?;
self.entries = out;
if self.selected >= self.entries.len() {
self.selected = self.entries.len().saturating_sub(1);
}
self.visual_anchor = None;
self.detail_cells_cache.clear();
Ok(())
}
pub fn ensure_detail_cells(&mut self, rows: &[(PathBuf, bool)], cols: &[String]) {
for (path, is_dir) in rows {
if self.detail_cells_cache.contains_key(path) {
continue;
}
let meta = crate::fileops::quick_meta(path).unwrap_or(crate::fileops::RowMeta {
is_dir: *is_dir,
is_symlink: false,
size: 0,
mtime: None,
mode: 0,
});
let cells: Vec<String> = cols
.iter()
.map(|id| crate::fileops::detail_cell(id, path, &meta).unwrap_or_default())
.collect();
self.detail_cells_cache.insert(path.clone(), cells);
}
}
pub fn detail_cells_get(&self, path: &Path) -> Option<&Vec<String>> {
self.detail_cells_cache.get(path)
}
fn rebuild_tree_notify(&mut self) -> bool {
if let Err(e) = self.rebuild_tree() {
self.flash = Some(format!(
"{}{e}",
crate::i18n::tr(self.lang, crate::i18n::Msg::OperationFailed)
));
false
} else {
true
}
}
pub fn is_sort_menu(&self) -> bool {
self.sort_menu
}
pub fn open_sort_menu(&mut self) {
self.sort_menu = true;
}
pub fn close_sort_menu(&mut self) {
self.sort_menu = false;
}
pub fn sort_menu_key(&mut self, c: char) -> Result<()> {
let keep_open = match c {
'n' => {
self.sort.key = SortKey::Name;
false
}
's' => {
self.sort.key = SortKey::Size;
false
}
'm' => {
self.sort.key = SortKey::Modified;
false
}
'e' => {
self.sort.key = SortKey::Ext;
false
}
'r' => {
self.sort.reverse = !self.sort.reverse;
true
}
'.' => {
self.sort.dirs_first = !self.sort.dirs_first;
true
}
_ => {
self.sort_menu = false;
return Ok(());
}
};
if self.tree_filter.is_none() {
self.rebuild_tree()?;
}
self.sort_menu = keep_open;
Ok(())
}
pub fn sort_label(&self) -> String {
let k = match self.sort.key {
SortKey::Name => "name",
SortKey::Size => "size",
SortKey::Modified => "mod",
SortKey::Ext => "ext",
};
let arrow = if self.sort.reverse { "↓" } else { "↑" };
format!("sort: {k} {arrow}")
}
pub fn display_mode(&self) -> DisplayMode {
match self.mode {
Mode::Tree => DisplayMode::Tree,
Mode::Preview => {
if self.is_image_preview() {
DisplayMode::Image
} else if self.is_table_preview() {
DisplayMode::Table
} else {
DisplayMode::Preview
}
}
}
}
pub fn internal_mode(&self) -> Option<InternalMode> {
if let Some(d) = &self.dialog {
return Some(match &d.kind {
DialogKind::Confirm { .. } if self.confirm_is_quit() => InternalMode::QuitConfirm,
DialogKind::Confirm { .. } if self.confirm_is_bookmark() => {
InternalMode::BookmarkConfirm
}
DialogKind::Confirm { .. } if self.confirm_is_drop() => InternalMode::DropConfirm,
DialogKind::Confirm { .. } => InternalMode::DeleteConfirm,
DialogKind::Preview { .. } => InternalMode::RenamePreview,
DialogKind::Input { .. } => match &d.op {
PendingOp::Create { .. } => InternalMode::Create,
PendingOp::BatchRenameInput { .. } => InternalMode::BatchRename,
PendingOp::GitCommit => InternalMode::Commit,
PendingOp::GitCreateBranch => InternalMode::GitBranch,
_ => InternalMode::Rename,
},
});
}
if self.is_git_detail() {
return Some(InternalMode::GitDetail);
}
if self.is_git_log() {
return Some(InternalMode::GitLog);
}
if self.is_git_graph_picker() {
return Some(InternalMode::GitGraphPicker);
}
if self.is_git_graph() {
return Some(InternalMode::GitGraph);
}
if self.is_git_branches() {
return Some(InternalMode::GitBranch);
}
if self.is_git_view() {
return Some(InternalMode::GitChanges);
}
if self.is_git_diff_preview() {
return Some(InternalMode::GitDiff);
}
if self.is_filtering() {
return Some(InternalMode::Filter);
}
if self.is_searching() {
return Some(InternalMode::Search);
}
if self.is_sort_menu() {
return Some(InternalMode::Sort);
}
if self.is_marking() {
return Some(InternalMode::Mark);
}
if self.is_bookmark_list() {
return Some(InternalMode::Bookmarks);
}
if self.is_tab_list() {
return Some(InternalMode::Tabs);
}
if self.is_outline() {
return Some(InternalMode::Outline);
}
if self.is_info() {
return Some(InternalMode::Info);
}
if self.is_visual() {
return Some(InternalMode::Visual);
}
if self.is_preview_visual() {
return Some(InternalMode::PreviewVisual);
}
if self.changed_filter && matches!(self.mode, Mode::Tree) {
return Some(InternalMode::ChangedFilter);
}
None
}
pub fn surface(&self) -> crate::keymap::Surface {
use crate::keymap::Surface as S;
if self.is_dialog() {
if self.dialog_is_preview() {
return S::DialogRenamePreview;
}
if self.dialog_is_confirm() {
if self.confirm_is_quit() {
return S::DialogConfirmQuit;
}
if self.confirm_is_bookmark() {
return S::DialogConfirmBookmark;
}
if self.confirm_is_drop() {
return S::DialogConfirmDrop;
}
return S::DialogConfirmDelete;
}
return S::DialogInput;
}
if self.show_help {
return S::Help;
}
#[cfg(feature = "git")]
{
if self.is_git_detail() {
return S::GitDetail;
}
if self.is_git_log() {
return S::GitLog;
}
if self.is_git_graph_picker() {
return S::GitGraphPicker;
}
if self.is_git_graph() {
return S::GitGraph;
}
if self.is_git_branches() {
return if self.git_branch_filtering() {
S::BranchFilter
} else {
S::GitBranches
};
}
if self.is_git_view() {
return S::GitChanges;
}
if self.is_git_diff_preview() {
return S::PreviewGitDiff;
}
}
if self.is_filtering() {
return S::Filter;
}
if self.is_searching() {
return S::Search;
}
if self.is_sort_menu() {
return S::Sort;
}
if self.is_marking() {
return S::Mark;
}
if self.is_bookmark_list() {
return S::Bookmarks;
}
if self.is_tab_list() {
return S::Tabs;
}
if self.is_outline() {
return S::Outline;
}
if self.is_info() {
return S::Info;
}
if self.is_visual() {
return S::Visual;
}
match self.mode {
Mode::Preview => {
if self.is_image_preview() {
S::PreviewImage
} else if self.is_table_preview() {
S::PreviewTable
} else if self.is_preview_visual() {
S::PreviewTextVisual
} else {
S::PreviewText
}
}
Mode::Tree => S::Tree,
}
}
pub fn clipboard_label(&self) -> Option<String> {
self.clipboard.as_ref().map(|c| {
let verb = match c.op {
ClipOp::Copy => crate::i18n::tr(self.lang, crate::i18n::Msg::CopyHint),
ClipOp::Cut => crate::i18n::tr(self.lang, crate::i18n::Msg::CutHint),
};
format!("{verb} {}", c.paths.len())
})
}
pub fn copy_selection(&mut self) {
let targets = self.op_targets();
if targets.is_empty() {
self.flash = Some(crate::i18n::tr(self.lang, crate::i18n::Msg::NoTarget).into());
return;
}
let n = targets.len();
self.clipboard = Some(Clipboard {
op: ClipOp::Copy,
paths: targets,
});
self.clear_selection();
self.flash = Some(format!(
"{} ({n})",
crate::i18n::tr(self.lang, crate::i18n::Msg::Copied)
));
}
pub fn cut_selection(&mut self) {
let targets = self.op_targets();
if targets.is_empty() {
self.flash = Some(crate::i18n::tr(self.lang, crate::i18n::Msg::NoTarget).into());
return;
}
let n = targets.len();
self.clipboard = Some(Clipboard {
op: ClipOp::Cut,
paths: targets,
});
self.clear_selection();
self.flash = Some(format!(
"{} ({n})",
crate::i18n::tr(self.lang, crate::i18n::Msg::CutDone)
));
}
pub fn paste(&mut self) -> Result<()> {
let Some(clip) = self.clipboard.clone() else {
self.flash = Some(crate::i18n::tr(self.lang, crate::i18n::Msg::ClipboardEmpty).into());
return Ok(());
};
let dir = self.op_base_dir();
let (mut ok, mut last, mut err) = (0usize, None, None);
for src in &clip.paths {
if dir.starts_with(src) {
err = Some(
crate::i18n::tr(self.lang, crate::i18n::Msg::CannotPasteIntoSelf).to_string(),
);
continue;
}
let res = match clip.op {
ClipOp::Copy => crate::fileops::copy_into(&dir, src),
ClipOp::Cut => crate::fileops::move_into(&dir, src),
};
match res {
Ok(p) => {
ok += 1;
last = Some(p);
}
Err(e) => err = Some(e.to_string()),
}
}
if matches!(clip.op, ClipOp::Cut) {
self.clipboard = None; }
self.refresh()?;
if let Some(p) = &last {
self.reveal_and_select(p)?;
}
self.flash = Some(match err {
Some(e) => format!(
"{} {ok} / {}: {e}",
crate::i18n::tr(self.lang, crate::i18n::Msg::Pasted),
crate::i18n::tr(self.lang, crate::i18n::Msg::Failed),
),
None => format!(
"{} ({ok})",
crate::i18n::tr(self.lang, crate::i18n::Msg::Pasted)
),
});
Ok(())
}
pub fn duplicate_selection(&mut self) -> Result<()> {
let targets = self.op_targets();
if targets.is_empty() {
self.flash = Some(crate::i18n::tr(self.lang, crate::i18n::Msg::NoTarget).into());
return Ok(());
}
let (mut ok, mut last, mut err) = (0usize, None, None);
for src in &targets {
let Some(dir) = src.parent() else {
err =
Some(crate::i18n::tr(self.lang, crate::i18n::Msg::OperationFailed).to_string());
continue;
};
match crate::fileops::copy_into(dir, src) {
Ok(p) => {
ok += 1;
last = Some(p);
}
Err(e) => err = Some(e.to_string()),
}
}
self.clear_selection();
self.refresh()?;
if let Some(p) = &last {
self.reveal_and_select(p)?;
}
self.flash = Some(match err {
Some(e) => format!(
"{} {ok} / {}: {e}",
crate::i18n::tr(self.lang, crate::i18n::Msg::Duplicated),
crate::i18n::tr(self.lang, crate::i18n::Msg::Failed)
),
None => format!(
"{} ({ok})",
crate::i18n::tr(self.lang, crate::i18n::Msg::Duplicated)
),
});
Ok(())
}
fn selection_in_display_order(&self) -> Vec<PathBuf> {
let mut v: Vec<PathBuf> = self
.entries
.iter()
.filter(|e| self.selection.contains(&e.path))
.map(|e| e.path.clone())
.collect();
for p in &self.selection {
if !v.contains(p) {
v.push(p.clone());
}
}
v
}
fn batch_rename_title(&self, count: usize) -> String {
format!(
"{} {} {} {{n}} {{n:0W}} {{name}} {{ext}}",
crate::i18n::tr(self.lang, crate::i18n::Msg::BatchRename),
count,
crate::i18n::tr(self.lang, crate::i18n::Msg::Items),
)
}
pub fn start_batch_rename(&mut self) {
let targets = self.selection_in_display_order();
if targets.is_empty() {
self.flash = Some(crate::i18n::tr(self.lang, crate::i18n::Msg::NoTarget).into());
return;
}
let title = self.batch_rename_title(targets.len());
self.dialog = Some(Dialog {
op: PendingOp::BatchRenameInput { targets },
kind: DialogKind::Input {
title,
buffer: String::new(),
cursor: 0,
},
});
}
pub fn start_create(&mut self) {
let dir = self.op_base_dir();
let where_ = self.format_path(&dir);
self.dialog = Some(Dialog {
op: PendingOp::Create { dir },
kind: DialogKind::Input {
title: format!(
"{} in {where_} ({})",
crate::i18n::tr(self.lang, crate::i18n::Msg::Create),
crate::i18n::tr(self.lang, crate::i18n::Msg::TrailingSlashFolder),
),
buffer: String::new(),
cursor: 0,
},
});
}
pub fn start_rename(&mut self) {
let Some(target) = self.entries.get(self.selected).map(|e| e.path.clone()) else {
self.flash = Some(crate::i18n::tr(self.lang, crate::i18n::Msg::NoTarget).into());
return;
};
let name = target
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
let cursor = name.chars().count(); self.dialog = Some(Dialog {
op: PendingOp::Rename { target },
kind: DialogKind::Input {
title: crate::i18n::tr(self.lang, crate::i18n::Msg::Rename).into(),
buffer: name,
cursor,
},
});
}
pub fn start_delete(&mut self) {
let targets = self.op_targets();
if targets.is_empty() {
self.flash = Some(crate::i18n::tr(self.lang, crate::i18n::Msg::NoTarget).into());
return;
}
let label = if targets.len() == 1 {
targets[0]
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("?")
.to_string()
} else {
let first = targets[0]
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("?");
format!(
"{} {} ({first} {})",
targets.len(),
crate::i18n::tr(self.lang, crate::i18n::Msg::Items),
crate::i18n::tr(self.lang, crate::i18n::Msg::Etc),
)
};
self.dialog = Some(Dialog {
op: PendingOp::Delete { targets },
kind: DialogKind::Confirm {
message: format!(
"{}: {label}",
crate::i18n::tr(self.lang, crate::i18n::Msg::DeleteTarget),
),
allow_permanent: true,
},
});
}
fn dialog_input_mut(&mut self) -> Option<(&mut String, &mut usize)> {
match self.dialog.as_mut() {
Some(Dialog {
kind: DialogKind::Input { buffer, cursor, .. },
..
}) => Some((buffer, cursor)),
_ => None,
}
}
pub fn dialog_input_push(&mut self, c: char) {
if let Some((buffer, cursor)) = self.dialog_input_mut() {
let at = char_byte(buffer, *cursor);
buffer.insert(at, c);
*cursor += 1;
}
}
pub fn dialog_input_backspace(&mut self) {
if let Some((buffer, cursor)) = self.dialog_input_mut() {
if *cursor > 0 {
let start = char_byte(buffer, *cursor - 1);
let end = char_byte(buffer, *cursor);
buffer.replace_range(start..end, "");
*cursor -= 1;
}
}
}
pub fn dialog_input_delete(&mut self) {
if let Some((buffer, cursor)) = self.dialog_input_mut() {
let count = buffer.chars().count();
if *cursor < count {
let start = char_byte(buffer, *cursor);
let end = char_byte(buffer, *cursor + 1);
buffer.replace_range(start..end, "");
}
}
}
pub fn dialog_cursor_left(&mut self) {
if let Some((_, cursor)) = self.dialog_input_mut() {
*cursor = cursor.saturating_sub(1);
}
}
pub fn dialog_cursor_right(&mut self) {
if let Some((buffer, cursor)) = self.dialog_input_mut() {
*cursor = (*cursor + 1).min(buffer.chars().count());
}
}
pub fn dialog_cursor_home(&mut self) {
if let Some((_, cursor)) = self.dialog_input_mut() {
*cursor = 0;
}
}
pub fn dialog_cursor_end(&mut self) {
if let Some((buffer, cursor)) = self.dialog_input_mut() {
*cursor = buffer.chars().count();
}
}
pub fn dialog_cancel(&mut self) {
self.dialog = None;
}
pub fn request_quit(&mut self) -> bool {
if !self.cfg.ui.confirm_quit {
return false;
}
self.dialog = Some(Dialog {
op: PendingOp::Quit,
kind: DialogKind::Confirm {
message: crate::i18n::tr(self.lang, crate::i18n::Msg::QuitConfirm).into(),
allow_permanent: false,
},
});
true
}
pub fn dialog_submit(&mut self) -> Result<()> {
let Some(dialog) = self.dialog.take() else {
return Ok(());
};
let DialogKind::Input { buffer, .. } = dialog.kind else {
return Ok(()); };
if matches!(dialog.op, PendingOp::GitCommit) {
let message = buffer.trim();
if message.is_empty() {
self.flash =
Some(crate::i18n::tr(self.lang, crate::i18n::Msg::MessageEmpty).into());
return Ok(());
}
match crate::git::commit(&self.root, message) {
Ok(()) => {
self.refresh()?;
if self.is_git_view() {
self.git_view_reload();
}
self.flash =
Some(crate::i18n::tr(self.lang, crate::i18n::Msg::Committed).into());
}
Err(e) => {
self.flash = Some(format!("{e}"));
let cursor = message.chars().count();
self.dialog = Some(Dialog {
op: PendingOp::GitCommit,
kind: DialogKind::Input {
title: crate::i18n::tr(self.lang, crate::i18n::Msg::CommitMessage)
.into(),
buffer: message.to_string(),
cursor,
},
});
}
}
return Ok(());
}
if matches!(dialog.op, PendingOp::GitCreateBranch) {
let bname = buffer.trim();
if bname.is_empty() {
self.flash = Some(crate::i18n::tr(self.lang, crate::i18n::Msg::NameEmpty).into());
return Ok(());
}
match crate::git::create_branch(&self.root, bname) {
Ok(()) => {
self.refresh()?;
self.ensure_git_status_now(); self.close_git_branches(); self.flash = Some(format!(
"{}: {bname}",
crate::i18n::tr(self.lang, crate::i18n::Msg::CreatedBranch)
));
}
Err(e) => {
self.flash = Some(format!("{e}"));
let cursor = bname.chars().count();
self.dialog = Some(Dialog {
op: PendingOp::GitCreateBranch,
kind: DialogKind::Input {
title: crate::i18n::tr(self.lang, crate::i18n::Msg::NewBranch).into(),
buffer: bname.to_string(),
cursor,
},
});
}
}
return Ok(());
}
let name = buffer.trim().trim_end_matches('/').trim();
let want_folder = buffer.trim_end().ends_with('/');
if name.is_empty() {
self.flash = Some(crate::i18n::tr(self.lang, crate::i18n::Msg::NameEmpty).into());
return Ok(());
}
match dialog.op {
PendingOp::Create { dir } => {
let created = if want_folder {
crate::fileops::create_dir(&dir, name)
} else {
crate::fileops::create_file(&dir, name)
};
match created {
Ok(path) => {
self.refresh()?;
self.reveal_and_select(&path)?;
self.flash = Some(format!(
"{}: {}",
crate::i18n::tr(self.lang, crate::i18n::Msg::Created),
self.format_path(&path)
));
}
Err(e) => {
self.flash = Some(format!(
"{}: {e}",
crate::i18n::tr(self.lang, crate::i18n::Msg::Failed)
))
}
}
}
PendingOp::Rename { target } => match crate::fileops::rename(&target, name) {
Ok(path) => {
self.refresh()?;
self.reveal_and_select(&path)?;
self.flash = Some(format!(
"{}: {}",
crate::i18n::tr(self.lang, crate::i18n::Msg::Renamed),
self.format_path(&path)
));
}
Err(e) => {
self.flash = Some(format!(
"{}: {e}",
crate::i18n::tr(self.lang, crate::i18n::Msg::Failed)
))
}
},
PendingOp::BatchRenameInput { targets } => {
match build_rename_plan(&targets, name) {
Ok(plan) => {
let lines: Vec<String> = plan
.iter()
.map(|(s, d)| {
format!(
"{} → {}",
s.file_name().and_then(|n| n.to_str()).unwrap_or("?"),
d.file_name().and_then(|n| n.to_str()).unwrap_or("?"),
)
})
.collect();
let title = format!(
"{} {} {}",
crate::i18n::tr(self.lang, crate::i18n::Msg::Rename),
plan.len(),
crate::i18n::tr(self.lang, crate::i18n::Msg::Items),
);
self.dialog = Some(Dialog {
op: PendingOp::BatchRenameApply { plan },
kind: DialogKind::Preview {
title,
lines,
scroll: 0,
},
});
}
Err(e) => {
self.flash = Some(format!(
"{}: {e}",
crate::i18n::tr(self.lang, crate::i18n::Msg::Failed)
));
let cursor = name.chars().count();
let title = self.batch_rename_title(targets.len());
self.dialog = Some(Dialog {
op: PendingOp::BatchRenameInput { targets },
kind: DialogKind::Input {
title,
buffer: name.to_string(),
cursor,
},
});
}
}
}
PendingOp::Delete { .. }
| PendingOp::BatchRenameApply { .. }
| PendingOp::GitDiscard { .. }
| PendingOp::GitCommit
| PendingOp::GitCreateBranch
| PendingOp::GitDeleteBranch { .. }
| PendingOp::DropTransfer { .. }
| PendingOp::BookmarkOverwrite { .. }
| PendingOp::Quit => {}
}
Ok(())
}
pub fn dialog_preview_apply(&mut self) -> Result<()> {
let Some(dialog) = self.dialog.take() else {
return Ok(());
};
if let PendingOp::BatchRenameApply { plan } = dialog.op {
match crate::fileops::batch_rename(&plan) {
Ok(()) => {
self.refresh()?;
self.clear_selection();
self.flash = Some(format!(
"{} ({})",
crate::i18n::tr(self.lang, crate::i18n::Msg::Renamed),
plan.len()
));
}
Err(e) => {
self.flash = Some(format!(
"{}: {e}",
crate::i18n::tr(self.lang, crate::i18n::Msg::Failed)
))
}
}
}
Ok(())
}
pub fn dialog_confirm(&mut self, yes: bool) -> Result<()> {
let Some(dialog) = self.dialog.take() else {
return Ok(());
};
if !yes {
self.flash = Some(crate::i18n::tr(self.lang, crate::i18n::Msg::Canceled).into());
return Ok(());
}
match dialog.op {
PendingOp::Delete { targets } => match crate::fileops::move_to_trash(&targets) {
Ok(()) => {
self.refresh()?;
self.clear_selection();
self.flash = Some(format!(
"{} ({})",
crate::i18n::tr(self.lang, crate::i18n::Msg::MovedToTrash),
targets.len()
));
}
Err(e) => {
self.flash = Some(format!(
"{}: {e}",
crate::i18n::tr(self.lang, crate::i18n::Msg::Failed)
))
}
},
PendingOp::GitDiscard { path } => match crate::git::discard(&self.root, &path) {
Ok(()) => {
let from_diff = self.is_git_diff_preview();
if from_diff {
self.came_from_git_view = false;
self.back_to_tree();
self.open_git_view();
}
self.git_view_reload();
if self.rebuild_tree_notify() {
self.flash = Some(format!(
"{}: {}",
crate::i18n::tr(self.lang, crate::i18n::Msg::Discarded),
self.format_path(&path)
));
}
}
Err(e) => {
self.flash = Some(format!(
"{}: {e}",
crate::i18n::tr(self.lang, crate::i18n::Msg::Failed)
))
}
},
PendingOp::GitDeleteBranch { name } => self.git_delete_branch(&name, false),
PendingOp::BookmarkOverwrite { key, target } => self.perform_mark_set(key, target),
_ => {}
}
Ok(())
}
pub fn dialog_delete_permanent(&mut self) -> Result<()> {
let Some(dialog) = self.dialog.take() else {
return Ok(());
};
if let PendingOp::GitDeleteBranch { name } = &dialog.op {
self.git_delete_branch(&name.clone(), true);
return Ok(());
}
if let PendingOp::Delete { targets } = dialog.op {
match crate::fileops::delete_permanently(&targets) {
Ok(()) => {
self.refresh()?;
self.clear_selection();
self.flash = Some(format!(
"{} ({})",
crate::i18n::tr(self.lang, crate::i18n::Msg::DeletedPermanently),
targets.len()
));
}
Err(e) => {
self.flash = Some(format!(
"{}: {e}",
crate::i18n::tr(self.lang, crate::i18n::Msg::Failed)
))
}
}
}
Ok(())
}
pub fn tree_next(&mut self) {
if self.selected + 1 < self.entries.len() {
self.selected += 1;
}
}
pub fn tree_prev(&mut self) {
self.selected = self.selected.saturating_sub(1);
}
pub fn tree_first(&mut self) {
self.selected = 0;
}
pub fn tree_last(&mut self) {
self.selected = self.entries.len().saturating_sub(1);
}
pub fn tree_page(&mut self, dir: i32) {
let page = self.tree_viewport.saturating_sub(1).max(1) as i32;
self.tree_move(dir * page);
}
pub fn tree_half_page(&mut self, dir: i32) {
let half = (self.tree_viewport / 2).max(1) as i32;
self.tree_move(dir * half);
}
fn tree_move(&mut self, delta: i32) {
self.selected = clamp_cursor(self.selected, delta, self.entries.len());
}
pub fn tree_activate(&mut self) -> Result<()> {
if self.tree_filter.is_some() || self.changed_filter {
return self.tree_descend();
}
let Some(entry) = self.entries.get(self.selected).cloned() else {
return Ok(());
};
if entry.is_dir {
if let Some(e) = self.entries.get_mut(self.selected) {
e.expanded = !e.expanded;
}
self.rebuild_tree()?;
} else {
self.enter_preview(&entry.path);
}
Ok(())
}
fn clear_for_root_change(&mut self) {
self.selection.clear();
self.visual_anchor = None;
self.clear_filter_state();
self.search_clear();
}
pub fn tree_leave(&mut self) -> Result<()> {
if self.tree_filter.is_some() {
self.filter_clear();
return Ok(());
}
if self.changed_filter {
self.toggle_changed_filter(); return Ok(());
}
if let Some(parent) = self.root.parent().map(Path::to_path_buf) {
self.clear_for_root_change();
self.root = parent;
self.entries.clear();
self.selected = 0;
self.rebuild_tree()?;
}
Ok(())
}
pub fn tree_descend(&mut self) -> Result<()> {
let Some(entry) = self.entries.get(self.selected).cloned() else {
return Ok(());
};
let was_filtering = self.tree_filter.is_some();
if entry.is_dir {
self.clear_for_root_change();
self.root = entry.path;
self.entries.clear();
self.selected = 0;
self.rebuild_tree()?;
} else {
let _ = was_filtering;
self.enter_preview(&entry.path);
}
Ok(())
}
pub fn toggle_hidden(&mut self) -> Result<()> {
self.show_hidden = !self.show_hidden;
self.rebuild_tree()
}
pub fn toggle_follow(&mut self) {
self.follow_mode = !self.follow_mode;
if self.follow_mode {
self.follow_session.clear();
}
let msg = if self.follow_mode {
crate::i18n::Msg::FollowOn
} else {
crate::i18n::Msg::FollowOff
};
self.flash = Some(tr(self.lang, msg).into());
}
pub fn follow_enabled(&self) -> bool {
self.follow_mode
}
pub fn follow_break(&mut self) {
if self.follow_mode {
self.follow_mode = false;
self.flash = Some(tr(self.lang, crate::i18n::Msg::FollowOff).into());
}
}
pub fn follow_jump(&mut self, path: &Path) {
use crate::keymap::Surface;
if !self.follow_mode {
return;
}
let surface_ok = matches!(
self.surface(),
Surface::Tree | Surface::PreviewText | Surface::PreviewImage | Surface::PreviewTable
);
#[cfg(feature = "git")]
let surface_ok = surface_ok || matches!(self.surface(), Surface::PreviewGitDiff);
if !surface_ok {
return;
}
if self.preview_path.as_deref() == Some(path) {
return;
}
if !self.follow_target_ok(path) {
return;
}
self.flash = None;
if self.changed_filter {
self.reapply_changed_filter();
if let Some(i) = self.entries.iter().position(|e| e.path == path) {
self.selected = i;
}
} else if !matches!(self.reveal_path_deep(path), Ok(true)) {
return;
}
if self.cfg.ui.follow_view != "file" && !self.follow_is_media(path) {
let diff = crate::git::file_diff(&self.root, path);
if !diff.is_empty() {
self.open_git_diff(path);
self.diff_cache = Some(DiffCache {
path: path.to_path_buf(),
lines: diff,
});
self.diff_follow_scope = true;
return;
}
}
self.enter_preview(path);
self.follow_scroll_to_first_change();
}
fn follow_target_ok(&self, path: &Path) -> bool {
if !path.starts_with(&self.root) || !path.is_file() || self.is_ignored(path) {
return false;
}
if !self.show_hidden {
let hidden = path
.strip_prefix(&self.root)
.map(|r| {
r.components().any(|c| {
c.as_os_str()
.to_str()
.map(|s| s.starts_with('.'))
.unwrap_or(false)
})
})
.unwrap_or(true);
if hidden {
return false;
}
}
true
}
pub fn follow_note_change(&mut self, path: &Path) -> bool {
if !self.follow_mode || !self.follow_target_ok(path) {
return false;
}
if !self.follow_session.iter().any(|p| p == path) {
self.follow_session.push(path.to_path_buf());
}
true
}
fn follow_is_media(&self, path: &Path) -> bool {
matches!(
self.cfg.resolve_preview(path),
PreviewKind::Image(_)
| PreviewKind::Svg(_)
| PreviewKind::Video(_)
| PreviewKind::Pdf(_)
)
}
fn follow_scroll_to_first_change(&mut self) {
if !self.is_windowed() {
return;
}
let Some(path) = self.preview_path.clone() else {
return;
};
let marks = if self.cfg.ui.git_gutter {
self.git_gutter_marks()
} else {
gutter_marks(&crate::git::file_diff(&self.root, &path))
};
let Some(first) = marks.keys().min().copied() else {
return;
};
let line0 = (first as usize).saturating_sub(1); let top = line0.saturating_sub(3); let Some(win) = self.preview_win.as_mut() else {
return;
};
if let Ok((off, _)) = win.advance(0, top) {
self.preview_byte_top = off;
self.preview_top_line = top;
self.preview_cursor_line = line0;
}
}
fn enter_preview(&mut self, path: &Path) {
let same_file = self.preview_path.as_deref() == Some(path);
self.preview_path = Some(path.to_path_buf());
let kind = self.cfg.resolve_preview(path);
self.preview_scroll = 0;
self.preview_hscroll = 0;
self.preview_byte_top = 0;
self.preview_top_line = 0;
self.clear_image();
if matches!(kind, PreviewKind::Pdf(_)) {
self.pdf_pages = crate::preview::pdf::page_count(path);
}
self.start_media_load(&kind, path);
self.preview_kind = Some(kind);
self.fence_return = None; self.fence_zoom = 1.0;
self.fence_center = (0.5, 0.5);
self.md_raw = false; self.md_cache = None; self.md_items.clear();
if !same_file {
self.md_image_cache.clear(); }
self.focused_item = None;
self.outline_open = false; self.details_open.clear(); self.preview_search = None;
self.search_input = None;
self.search_matches.clear();
self.table_search_hits.clear();
self.search_idx = 0;
self.setup_windowed(); self.preview_cursor_line = 0;
self.preview_cursor_col = 0;
self.preview_visual_anchor = None;
self.preview_visual_linewise = false;
self.table_cur_row = 0;
self.table_cur_col = 0;
self.table_top_row = 0;
self.table_left_col = 0;
self.load_table();
self.mode = Mode::Preview;
}
fn setup_windowed(&mut self) {
self.preview_win = None;
self.win_cache = None;
self.preview_total_lines = None;
self.hl_pending = self.cfg.ui.syntax_highlight
&& matches!(self.preview_kind, Some(PreviewKind::Code(_)))
&& !crate::preview::code::is_ext_warm(self.current_preview_ext());
self.hl_warming = false;
let windowed_kind = matches!(
self.preview_kind,
Some(PreviewKind::Code(_)) | Some(PreviewKind::Text(_))
) || self.is_raw_source();
if !windowed_kind {
return;
}
let Some(path) = self.preview_path.clone() else {
return;
};
if let Ok(w) = crate::preview::window::FileWindow::open(&path) {
self.preview_win = Some(w);
}
}
pub fn current_preview_ext(&self) -> &str {
self.preview_path
.as_deref()
.and_then(|p| p.extension())
.and_then(|e| e.to_str())
.unwrap_or("")
}
pub fn is_highlight_pending(&self) -> bool {
self.hl_pending
}
pub fn loading_is_indicator(&self) -> bool {
self.cfg.ui.preview_loading != "progressive"
}
pub fn clear_highlight_pending(&mut self) {
self.hl_pending = false;
self.hl_warming = false;
}
pub fn take_warm_job(&mut self) -> Option<(String, PathBuf)> {
if self.hl_pending && !self.hl_warming {
if let Some(path) = self.preview_path.clone() {
self.hl_warming = true;
return Some((self.current_preview_ext().to_string(), path));
}
}
None
}
pub fn busy_jobs(&self) -> Vec<crate::i18n::Msg> {
let mut v = Vec::new();
if self.git_ignored_pending.is_some() || self.git_status_pending.is_some() {
v.push(crate::i18n::Msg::BusyGitScan);
}
if self.media_loading {
v.push(crate::i18n::Msg::BusyMedia);
}
if self.hl_pending || self.hl_warming {
v.push(crate::i18n::Msg::BusyHighlight);
}
if self.md_images_loading() {
v.push(crate::i18n::Msg::BusyImages);
}
v
}
pub fn busy_indicator_active(&self) -> bool {
self.cfg.ui.busy_indicator && !self.busy_jobs().is_empty()
}
pub fn tick_spinner(&mut self) {
self.spinner_frame = self.spinner_frame.wrapping_add(1);
}
pub fn spinner_glyph(&self) -> &'static str {
const FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
FRAMES[self.spinner_frame % FRAMES.len()]
}
pub fn request_edit(&mut self) {
let target = match self.mode {
Mode::Tree => match self.entries.get(self.selected) {
Some(e) if e.is_dir => {
self.flash = Some(tr(self.lang, crate::i18n::Msg::CannotEditDirectory).into());
return;
}
Some(e) => Some(e.path.clone()),
None => None,
},
Mode::Preview => self.preview_path.clone(),
};
match target {
Some(p) => self.pending_edit = Some((p, self.preview_edit_line())),
None => self.flash = Some(tr(self.lang, crate::i18n::Msg::NoFileToEdit).into()),
}
}
fn preview_edit_line(&self) -> Option<usize> {
if self.mode != Mode::Preview {
return None;
}
if self.is_windowed() {
return Some(self.preview_cursor_line + 1);
}
if matches!(self.preview_kind, Some(PreviewKind::Markdown(_))) {
if let Some(c) = &self.md_cache {
let same = self.preview_path.as_ref().is_some_and(|p| *p == c.path);
if same && c.src_lines > 0 && self.md_view_rows > 0 {
let (logical, row) = match self.md_focused_visible_line() {
Some(hit) => hit,
None => (
self.md_top_logical_line().unwrap_or(0),
self.preview_scroll as usize,
),
};
let est = (row * c.src_lines / self.md_view_rows).min(c.src_lines - 1);
return Some(self.md_content_anchor_line(logical, est).unwrap_or(est) + 1);
}
}
}
None
}
fn md_focused_visible_line(&self) -> Option<(usize, usize)> {
let item = self.focused_item.and_then(|i| self.md_items.get(i))?;
let (row, h) = self.md_visual_span(item.line);
let scroll = self.preview_scroll as usize;
let vh = self.preview_viewport.max(1) as usize;
(row < scroll + vh && row + h > scroll).then_some((item.line, row))
}
fn md_top_logical_line(&self) -> Option<usize> {
let c = self.md_cache.as_ref()?;
if c.lines.is_empty() {
return None;
}
let scroll = self.preview_scroll as usize;
if !self.cfg.ui.wrap || c.width == 0 || c.row_prefix.len() != c.lines.len() + 1 {
return Some(scroll.min(c.lines.len() - 1));
}
let i = c
.row_prefix
.partition_point(|&p| p <= scroll)
.saturating_sub(1);
Some(i.min(c.lines.len() - 1))
}
fn md_content_anchor_line(&self, logical: usize, est: usize) -> Option<usize> {
use ratatui::style::Modifier;
let c = self.md_cache.as_ref()?;
let line = c.lines.get(logical)?;
let key = line
.spans
.iter()
.filter(|s| !s.style.add_modifier.contains(Modifier::HIDDEN))
.map(|s| s.content.trim())
.filter(|t| !t.is_empty())
.max_by_key(|t| t.chars().count())?;
if key.chars().count() < 4 {
return None;
}
let content = crate::preview::text::load(c.path.as_path()).ok()?;
let mut best: Option<usize> = None;
let mut best_d = usize::MAX;
for (i, sl) in content.lines.iter().enumerate() {
if sl.contains(key) {
let d = i.abs_diff(est);
if d < best_d {
best_d = d;
best = Some(i);
}
}
}
best
}
pub fn take_pending_edit(&mut self) -> Option<(PathBuf, Option<usize>)> {
self.pending_edit.take()
}
#[cfg_attr(not(feature = "git"), allow(dead_code))]
pub fn launch_git_tool(&mut self) {
self.pending_git_tool = true;
}
pub fn take_launch_git_tool(&mut self) -> bool {
std::mem::take(&mut self.pending_git_tool)
}
pub fn reload_preview(&mut self) {
self.md_cache = None;
self.win_cache = None;
if matches!(self.mode, Mode::Preview) {
self.setup_windowed();
self.reload_media_if_changed();
if matches!(self.preview_kind, Some(PreviewKind::Table { .. })) {
self.load_table();
self.clamp_table_cursor();
}
}
}
fn reload_media_if_changed(&mut self) {
let is_media = match &self.preview_kind {
Some(kind) => self.kind_loads_media(kind),
None => false,
};
if !is_media {
return;
}
let Some(path) = self.preview_path.clone() else {
return;
};
let Some(kind) = self.preview_kind.clone() else {
return;
};
if file_mtime(&path) == self.preview_media_mtime {
return; }
let (zoom, center, page) = (self.image_zoom, self.image_center, self.pdf_page);
self.clear_image(); if matches!(kind, PreviewKind::Pdf(_)) {
self.pdf_pages = crate::preview::pdf::page_count(&path);
self.pdf_page = page.clamp(1, self.pdf_pages.unwrap_or(1).max(1));
}
self.start_media_load(&kind, &path); self.image_zoom = zoom;
self.image_center = center;
}
pub fn out_of_root_watch_dir(&self) -> Option<PathBuf> {
if !matches!(self.mode, Mode::Preview) {
return None;
}
let p = self.preview_path.as_ref()?;
if p.starts_with(&self.root) {
return None; }
p.parent().map(|d| d.to_path_buf())
}
pub fn git_dir_watch(&self) -> Option<PathBuf> {
let wd = crate::git::workdir(&self.root)?;
if self.root == wd {
return None; }
let git = wd.join(".git");
git.is_dir().then_some(git)
}
pub fn is_windowed(&self) -> bool {
self.preview_win.is_some()
}
#[cfg(test)]
pub fn focused_item(&self) -> Option<usize> {
self.focused_item
}
#[cfg(test)]
pub fn md_link_targets(&self) -> Vec<String> {
self.md_items
.iter()
.filter_map(|it| match &it.kind {
MdItemKind::Link { target } => Some(target.clone()),
_ => None,
})
.collect()
}
pub fn is_raw_source(&self) -> bool {
self.md_raw
&& matches!(
self.preview_kind,
Some(PreviewKind::Markdown(_)) | Some(PreviewKind::Mermaid(_))
)
}
pub fn is_decorated_kind(&self) -> bool {
matches!(
self.preview_kind,
Some(PreviewKind::Markdown(_)) | Some(PreviewKind::Mermaid(_))
)
}
pub fn is_md_raw(&self) -> bool {
self.md_raw
}
pub fn toggle_md_raw(&mut self) {
if !self.is_decorated_kind() {
return;
}
self.md_raw = !self.md_raw;
self.preview_byte_top = 0;
self.preview_top_line = 0;
self.preview_scroll = 0;
self.preview_hscroll = 0;
self.preview_cursor_line = 0;
self.preview_cursor_col = 0;
self.preview_visual_anchor = None;
self.preview_visual_linewise = false;
self.md_cache = None;
self.setup_windowed();
}
pub fn is_preview_visual(&self) -> bool {
self.is_windowed() && self.preview_visual_anchor.is_some()
}
pub fn preview_visual_linewise(&self) -> bool {
self.preview_visual_linewise
}
pub fn preview_selection(&self) -> PreviewSelection {
let Some((al, ac)) = self.preview_visual_anchor else {
return PreviewSelection::None;
};
let (cl, cc) = (self.preview_cursor_line, self.preview_cursor_col);
if self.preview_visual_linewise {
PreviewSelection::Line {
lo: al.min(cl),
hi: al.max(cl),
}
} else {
let (start, end) = if (al, ac) <= (cl, cc) {
((al, ac), (cl, cc))
} else {
((cl, cc), (al, ac))
};
PreviewSelection::Char { start, end }
}
}
pub fn preview_enter_visual(&mut self, linewise: bool) {
if self.is_windowed() {
self.preview_visual_anchor = Some((self.preview_cursor_line, self.preview_cursor_col));
self.preview_visual_linewise = linewise;
}
}
pub fn preview_exit_visual(&mut self) {
self.preview_visual_anchor = None;
}
fn preview_selection_text(&self) -> String {
let Some(path) = self.preview_path.as_ref() else {
return String::new();
};
let bytes = match std::fs::read(path) {
Ok(b) => b,
Err(_) => return String::new(),
};
let s = String::from_utf8_lossy(&bytes);
let lines: Vec<&str> = s.lines().collect();
if lines.is_empty() {
return String::new();
}
let last = lines.len() - 1;
match self.preview_selection() {
PreviewSelection::Line { lo, hi } => {
let end = hi.min(last);
if lo > end {
String::new()
} else {
lines[lo..=end].join("\n")
}
}
PreviewSelection::None => {
let l = self.preview_cursor_line.min(last);
lines[l].to_string()
}
PreviewSelection::Char { start, end } => selection_char_text(&lines, start, end),
}
}
pub fn preview_copy_selection(&mut self) {
let text = self.preview_selection_text();
self.preview_visual_anchor = None;
if text.is_empty() {
self.flash = Some(tr(self.lang, crate::i18n::Msg::NoCopyTarget).into());
return;
}
self.set_clipboard_flash(&text);
}
fn preview_selection_ref_text(&self) -> Option<String> {
let path = self.preview_path.as_ref()?;
let base = at_ref_text(&self.open_dir, path);
if !self.is_windowed() {
return Some(base);
}
let (lo, hi) = match self.preview_selection() {
PreviewSelection::Line { lo, hi } => (lo, hi),
PreviewSelection::Char { start, end } => (start.0, end.0),
PreviewSelection::None => (self.preview_cursor_line, self.preview_cursor_line),
};
Some(if lo == hi {
format!("{base}#L{}", lo + 1)
} else {
format!("{base}#L{}-{}", lo + 1, hi + 1)
})
}
pub fn preview_copy_selection_ref(&mut self) {
let Some(text) = self.preview_selection_ref_text() else {
self.flash = Some(tr(self.lang, crate::i18n::Msg::NoCopyTarget).into());
return;
};
self.preview_visual_anchor = None;
self.set_clipboard_flash(&text);
}
pub fn preview_col_move(&mut self, dir: i32) {
if self.is_windowed() {
let next = (self.preview_cursor_col as i64 + dir as i64).max(0) as usize;
self.preview_cursor_col = next;
} else {
self.preview_hscroll(dir * 2);
}
}
pub fn preview_col_home(&mut self) {
if self.is_windowed() {
self.preview_cursor_col = 0;
} else {
self.preview_hscroll_home();
}
}
pub fn preview_col_end(&mut self) {
if self.is_windowed() {
self.preview_cursor_col = usize::MAX;
} else {
self.preview_hscroll_end();
}
}
pub fn preview_jump_file(&mut self, dir: i32) {
if self.mode != Mode::Preview || self.entries.is_empty() {
return;
}
let anchor = self
.preview_path
.as_ref()
.and_then(|p| self.entries.iter().position(|e| e.path == *p))
.unwrap_or_else(|| self.selected.min(self.entries.len() - 1));
let n = self.entries.len() as i64;
let mut i = anchor as i64;
for _ in 0..n {
i = (i + dir as i64).rem_euclid(n);
if i as usize == anchor {
break; }
if !self.entries[i as usize].is_dir {
self.selected = i as usize;
let path = self.entries[i as usize].path.clone();
self.enter_preview(&path);
return;
}
}
}
pub fn back_to_tree(&mut self) {
if matches!(self.preview_kind, Some(PreviewKind::MermaidFence(_))) {
if let Some(md) = self.preview_path.clone() {
let ret = self.fence_return.take();
self.enter_preview(&md);
if let Some((scroll, focus)) = ret {
self.preview_scroll = scroll;
self.focused_item = focus;
}
return;
}
}
self.mode = Mode::Tree;
self.git_status_for = None;
self.git_status_dirty = true;
self.detail_cells_cache.clear();
self.preview_path = None;
self.preview_kind = None;
self.clear_image(); self.md_cache = None;
self.md_raw = false;
self.preview_win = None;
self.win_cache = None;
self.preview_total_lines = None;
self.hl_pending = false;
self.hl_warming = false;
self.preview_byte_top = 0;
self.preview_top_line = 0;
self.md_items.clear();
self.focused_item = None;
self.preview_search = None;
self.search_input = None;
self.search_matches.clear();
self.table_search_hits.clear();
self.search_idx = 0;
self.came_from_git_view = false;
self.table_data = None;
self.table_cur_row = 0;
self.table_cur_col = 0;
self.table_top_row = 0;
self.table_left_col = 0;
self.preview_cursor_line = 0;
self.preview_cursor_col = 0;
self.preview_visual_anchor = None;
self.preview_visual_linewise = false;
}
fn stash_media_cache(&mut self) {
if !self.gif_frames.is_empty() {
return;
}
let (Some(path), Some(src)) = (self.preview_path.clone(), self.image_src.clone()) else {
return;
};
const MAX_CACHED_BYTES: usize = 128 * 1024 * 1024;
if src.as_bytes().len() > MAX_CACHED_BYTES {
self.media_cache = None; return;
}
let meta = std::fs::metadata(&path).ok();
self.media_cache = Some(MediaCache {
path,
mtime: self.preview_media_mtime,
len: meta.map(|m| m.len()),
page: self.pdf_page.max(1),
fence_ord: self.preview_fence_ord(),
src,
logical: self.image_logical,
vector_svg: self.vector_svg.clone(),
});
}
fn drop_media_cache_for_active(&mut self) {
let cur = self.preview_path.as_deref();
if matches!((&self.media_cache, cur), (Some(c), Some(p)) if c.path == p) {
self.media_cache = None;
}
if self.image_src.is_some() && cur.is_some() {
self.media_cache = match self.media_cache.take() {
Some(c) if Some(c.path.as_path()) != cur => Some(c),
_ => None,
};
}
}
fn preview_fence_ord(&self) -> Option<usize> {
match &self.preview_kind {
Some(PreviewKind::MermaidFence(ord)) => Some(*ord),
_ => None,
}
}
fn restore_media_cache(&mut self, path: &Path, page: u32) -> bool {
let len = std::fs::metadata(path).ok().map(|m| m.len());
let fence = self.preview_fence_ord();
let hit = matches!(&self.media_cache, Some(c)
if c.path == path
&& c.page == page
&& c.fence_ord == fence
&& c.mtime == file_mtime(path)
&& c.mtime.is_some()
&& c.len == len);
if !hit {
return false;
}
let c = self.media_cache.as_ref().expect("checked above");
self.image_logical = c.logical;
self.vector_svg = c.vector_svg.clone();
self.preview_media_mtime = c.mtime;
self.image_src = Some(c.src.clone());
self.image_crop = None;
true
}
fn kind_loads_media(&self, kind: &PreviewKind) -> bool {
matches!(
kind,
PreviewKind::Image(_)
| PreviewKind::Svg(_)
| PreviewKind::Video(_)
| PreviewKind::Pdf(_)
) || (matches!(kind, PreviewKind::Mermaid(_) | PreviewKind::MermaidFence(_))
&& self.mermaid_image_mode())
}
fn clear_image(&mut self) {
self.image = None;
self.kitty_image = None;
self.kitty_gen = self.kitty_gen.wrapping_add(1);
self.kitty_want = None;
self.kitty_shown = None;
self.image_src = None;
self.image_zoom = 1.0;
self.image_center = (0.5, 0.5);
self.image_crop = None;
self.image_vis_frac = (1.0, 1.0);
self.vector_svg = None;
self.image_logical = None;
self.gif_frames = Vec::new();
self.gif_idx = 0;
self.gif_shown_at = None;
self.gif_protocol = None;
self.gif_proto_key = None;
self.pdf_page = 1;
self.pdf_pages = None;
self.media_gen = self.media_gen.wrapping_add(1);
self.media_loading = false;
self.vector_reraster_inflight = false;
self.preview_media_mtime = None;
}
fn ensure_md_cache(&mut self, width: u16) {
let Some(path) = self.preview_path.clone() else {
return;
};
let fence_rows = self.mermaid_fit_rows();
if matches!(&self.md_cache, Some(c) if c.path == path && c.width == width
&& (c.fence_rows == fence_rows
|| !c.images.iter().any(|p| crate::preview::markdown::is_mermaid_fence_url(&p.url))))
{
return;
}
let (raw, images, remote, fences, math, src_lines) = self.build_decorated(&path, width);
for url in &remote {
self.ensure_remote_md_fetch(url);
}
let mut resync = false;
for code in &fences {
if code.trim().is_empty() {
continue; }
resync |= self.ensure_mermaid_fence_render(code.clone());
}
for (latex, display) in &math {
if latex.trim().is_empty() {
continue;
}
resync |= self.ensure_math_render(latex.clone(), *display);
}
{
let mut live: std::collections::HashSet<PathBuf> = fences
.iter()
.map(|c| PathBuf::from(crate::preview::markdown::mermaid_fence_url(c)))
.collect();
live.extend(
math.iter()
.map(|(l, d)| PathBuf::from(crate::preview::markdown::math_url(l, *d))),
);
self.md_image_cache.retain(|k, _| {
let s = k.to_string_lossy();
!(crate::preview::markdown::is_mermaid_fence_url(&s)
|| crate::preview::markdown::is_math_url(&s))
|| live.contains(k)
});
}
let (raw, images, remote, src_lines) = if resync {
let (raw, images, remote, _fences, _math, src_lines) =
self.build_decorated(&path, width);
(raw, images, remote, src_lines)
} else {
(raw, images, remote, src_lines)
};
let _ = &remote;
let (lines, targets) = self.postprocess_md(raw);
let fence_ords: Vec<usize> = images.iter().filter_map(|p| p.fence_ord).collect();
let items = build_md_items(&lines, &targets, &fence_ords);
let anchors = compute_md_anchors(&lines);
let max_line_cols = lines.iter().map(|l| l.width()).max().unwrap_or(0);
let row_prefix = if self.cfg.ui.wrap && width > 0 {
use ratatui::text::Text;
use ratatui::widgets::{Paragraph, Wrap};
let mut pre = Vec::with_capacity(lines.len() + 1);
let mut acc = 0usize;
pre.push(0);
for line in &lines {
acc += Paragraph::new(Text::from(vec![line.clone()]))
.wrap(Wrap { trim: false })
.line_count(width)
.max(1);
pre.push(acc);
}
pre
} else {
Vec::new()
};
self.md_items = items.clone();
match self.focused_item {
Some(_) if self.md_items.is_empty() => self.focused_item = None,
Some(f) if f >= self.md_items.len() => {
self.focused_item = Some(self.md_items.len() - 1)
}
_ => {}
}
self.md_cache = Some(MdCache {
path,
width,
lines,
items,
images,
src_lines,
max_line_cols,
row_prefix,
fence_rows,
anchors,
details_states: crate::preview::markdown::current_details_states(),
});
}
fn details_default_open(&self, open_attr: bool) -> bool {
match self.cfg.ui.md_details.as_str() {
"open" => true,
"closed" => false,
_ => open_attr,
}
}
pub fn toggle_details(&mut self, ordinal: usize) {
let cur = self
.md_cache
.as_ref()
.and_then(|c| c.details_states.get(ordinal).copied())
.unwrap_or(true);
self.details_open.insert(ordinal, !cur);
self.md_cache = None; }
fn md_scroll_to_anchor(&mut self, slug: &str) -> bool {
let target = slug.trim().to_lowercase();
let Some(logical) = self.md_cache.as_ref().and_then(|c| {
c.anchors
.iter()
.find(|(s, _)| *s == target)
.map(|(_, i)| *i)
}) else {
return false;
};
let (row, _) = self.md_visual_span(logical);
self.preview_scroll = row.min(u16::MAX as usize) as u16;
true
}
pub fn md_layout(&mut self, width: u16) -> (usize, usize) {
self.ensure_md_cache(width);
let Some(c) = &self.md_cache else {
return (0, 0);
};
let total = if self.cfg.ui.wrap {
c.row_prefix.last().copied().unwrap_or(c.lines.len())
} else {
c.lines.len()
};
(total, c.max_line_cols)
}
pub fn md_slice(&self, scroll: u16, height: u16) -> (Vec<Line<'static>>, u16) {
let Some(c) = &self.md_cache else {
return (Vec::new(), 0);
};
let s = scroll as usize;
let h = height.max(1) as usize;
let (lo, hi, local) = if self.cfg.ui.wrap && c.row_prefix.len() == c.lines.len() + 1 {
let lo = c.row_prefix.partition_point(|&p| p <= s).saturating_sub(1);
let hi = c
.row_prefix
.partition_point(|&p| p < s + h)
.min(c.lines.len());
(lo, hi.max(lo), (s - c.row_prefix[lo]) as u16)
} else {
let lo = s.min(c.lines.len());
let hi = (s + h).min(c.lines.len());
(lo, hi, 0)
};
let mut out: Vec<Line<'static>> = c.lines[lo..hi].to_vec();
if let Some(q) = self.preview_search.as_deref() {
let cur = self.search_matches.get(self.search_idx).copied();
for (i, line) in out.iter_mut().enumerate() {
let li = lo + i;
if !self.search_matches.iter().any(|(_, l, _)| *l == li) {
continue;
}
let rank = cur.and_then(|(_, cl, _)| {
(cl == li).then(|| {
self.search_matches[..self.search_idx]
.iter()
.filter(|(_, l, _)| *l == li)
.count()
})
});
*line = highlight_query_in_line(std::mem::take(line), q, rank);
}
}
if let Some(f) = self.focused_item {
if let Some(it) = c.items.get(f) {
if it.line >= lo && it.line < hi {
let whole = matches!(it.kind, MdItemKind::CodeBlock);
let first_on_line = c.items.partition_point(|x| x.line < it.line);
let ordinal = f - first_on_line;
out[it.line - lo] = invert_focused_line(&c.lines[it.line], ordinal, whole);
}
}
}
(out, local)
}
#[cfg(test)]
pub fn decorated_lines(&mut self, width: u16) -> Vec<Line<'static>> {
self.ensure_md_cache(width);
self.md_cache
.as_ref()
.map(|c| c.lines.clone())
.unwrap_or_default()
}
pub fn md_images(&self) -> Vec<crate::preview::markdown::ImagePlacement> {
self.md_cache
.as_ref()
.map(|c| c.images.clone())
.unwrap_or_default()
}
#[allow(clippy::type_complexity)] fn build_decorated(
&self,
path: &Path,
width: u16,
) -> (
Vec<Line<'static>>,
Vec<crate::preview::markdown::ImagePlacement>,
Vec<String>,
Vec<String>,
Vec<(String, bool)>,
usize,
) {
let src = match crate::preview::text::load(path) {
Ok(content) => {
let mut s = content.lines.join("\n");
if content.truncated {
s.push_str("\n\n— (省略: 表示上限に達しました) —");
}
s
}
Err(e) => {
return (
vec![Line::from(format!("[can not preview: 読み込み失敗] {e}"))],
Vec::new(),
Vec::new(),
Vec::new(),
Vec::new(),
0,
)
}
};
let src_lines = src.lines().count();
match &self.preview_kind {
Some(PreviewKind::Markdown(_)) => {
let theme = &self.cfg.ui.theme;
let code = crate::preview::markdown::CodeStyle {
bg: theme.code_bg(),
label_bg: theme.code_label_bg(),
label_right: theme.code_label_right(),
tab_width: self.cfg.ui.tab_width,
wrap: self.cfg.ui.wrap,
};
let (fm_lines, src) = if self.cfg.ui.md_frontmatter {
match crate::preview::markdown::strip_front_matter(&src) {
(Some(fm), body) => (
crate::preview::markdown::render_front_matter(&fm, width),
body,
),
(None, body) => (Vec::new(), body),
}
} else {
(Vec::new(), src)
};
let src = if self.cfg.ui.md_footnotes {
crate::preview::markdown::process_footnotes(&src)
} else {
src
};
let src = if self.cfg.ui.md_inline_html {
crate::preview::markdown::process_inline_html(&src)
} else {
src
};
let details_states: Vec<bool> =
crate::preview::markdown::collect_details_open(&src)
.iter()
.enumerate()
.map(|(ord, &attr)| {
self.details_open
.get(&ord)
.copied()
.unwrap_or_else(|| self.details_default_open(attr))
})
.collect();
crate::preview::markdown::set_details_open(details_states);
let font = self.picker.as_ref().map(|p| p.font_size());
let base_dir = path.parent().map(|p| p.to_path_buf());
let avail = width.saturating_sub(2);
let slot_of = |url: &str| -> crate::preview::markdown::ImageSlot {
use crate::preview::markdown::ImageSlot;
let Some(font) = font else {
return ImageSlot::Unavailable;
};
if let Some(p) = resolve_md_image_path(url, base_dir.as_deref()) {
match md_image_dims(&p) {
Some((pw, ph)) => {
let (cols, rows) = md_image_cells(
pw,
ph,
font.width,
font.height,
avail,
MD_IMAGE_MAX_ROWS,
);
ImageSlot::Inline { cols, rows }
}
None => ImageSlot::Unavailable,
}
} else if crate::preview::markdown::is_remote_image_url(url)
&& !self.md_remote_failed.contains(url)
{
ImageSlot::Loading
} else {
ImageSlot::Unavailable
}
};
let mermaid_on = self.mermaid_image_mode();
let fence_target_rows = self.mermaid_fit_rows();
let mermaid_slot = |code: &str| -> crate::preview::markdown::MermaidSlot {
use crate::preview::markdown::MermaidSlot;
if !mermaid_on {
return MermaidSlot::Text;
}
if code.is_empty() {
return MermaidSlot::Loading; }
let Some(font) = font else {
return MermaidSlot::Text;
};
let key = PathBuf::from(crate::preview::markdown::mermaid_fence_url(code));
match self.md_image_cache.get(&key) {
Some(e) if e.failed => MermaidSlot::Text,
Some(e) => match e.decoded.as_ref() {
Some(img) => {
use image::GenericImageView;
let (pw, ph) = e.layout_px.unwrap_or_else(|| img.dimensions());
let (cols, rows) = mermaid_cells(
pw,
ph,
font.width,
font.height,
avail,
fence_target_rows,
);
MermaidSlot::Image { cols, rows }
}
None => MermaidSlot::Loading,
},
None => MermaidSlot::Loading,
}
};
let math_on = font.is_some() && self.math_image_mode();
let math_slot =
|latex: &str, display: bool| -> crate::preview::markdown::MathSlot {
use crate::preview::markdown::MathSlot;
let Some(font) = font else {
return MathSlot::Raw;
};
let key = PathBuf::from(crate::preview::markdown::math_url(latex, display));
match self.md_image_cache.get(&key) {
Some(e) if e.failed => MathSlot::Raw,
Some(e) => match (e.decoded.as_ref(), e.layout_px) {
(Some(_), Some((uw, uh))) => {
let (cols, rows) =
math_cells(uw, uh, font.width, font.height, avail, display);
MathSlot::Image { cols, rows }
}
_ => MathSlot::Loading,
},
None => MathSlot::Loading,
}
};
let (mut lines, mut images) = crate::preview::markdown::render_markdown_with_images(
&src,
width,
code,
&theme.code_theme,
self.cfg.ui.icons,
&self.cfg.ui.md_task_state_chars(),
&slot_of,
&mermaid_slot,
tr(self.lang, crate::i18n::Msg::MermaidCaption),
self.cfg.ui.md_alerts,
&math_slot,
math_on,
);
let remote = if font.is_some() {
crate::preview::markdown::collect_remote_image_urls(&src)
} else {
Vec::new()
};
let fences = if mermaid_on {
crate::preview::markdown::collect_mermaid_fences(&src)
} else {
Vec::new()
};
let math = if math_on {
crate::preview::markdown::collect_math_exprs(&src)
} else {
Vec::new()
};
if !fm_lines.is_empty() {
for p in &mut images {
p.line += fm_lines.len();
}
let mut all = fm_lines;
all.extend(lines);
lines = all;
}
(lines, images, remote, fences, math, src_lines)
}
Some(PreviewKind::Mermaid(_)) => (
crate::preview::markdown::render_mermaid_file(&src, width),
Vec::new(),
Vec::new(),
Vec::new(),
Vec::new(),
src_lines,
),
Some(PreviewKind::Code(_)) => (
crate::preview::code::highlight(&src, path, &self.cfg.ui.theme.code_theme),
Vec::new(),
Vec::new(),
Vec::new(),
Vec::new(),
src_lines,
),
_ => (
Vec::new(),
Vec::new(),
Vec::new(),
Vec::new(),
Vec::new(),
src_lines,
),
}
}
pub fn attach_image_backend(&mut self, picker: Picker, tx: UnboundedSender<ResizeRequest>) {
self.use_kitty = matches!(
picker.protocol_type(),
ratatui_image::picker::ProtocolType::Kitty
);
self.kitty_is_tmux = std::env::var_os("TMUX").is_some();
self.picker = Some(picker);
self.img_tx = Some(tx);
}
pub fn attach_media_loader(&mut self, tx: std::sync::mpsc::Sender<MediaResult>) {
self.media_tx = Some(tx);
}
pub fn attach_kitty_loader(&mut self, tx: std::sync::mpsc::Sender<KittyResult>) {
self.kitty_tx = Some(tx);
}
pub fn attach_md_image_loader(&mut self, tx: std::sync::mpsc::Sender<MdImageResult>) {
self.md_img_tx = Some(tx);
}
pub fn attach_md_encoder(&mut self, tx: std::sync::mpsc::Sender<MdEncodeRequest>) {
self.md_enc_tx = Some(tx);
}
pub fn apply_md_image(&mut self, res: MdImageResult) -> bool {
if !self.md_image_cache.contains_key(&res.path) {
return false;
}
let url_str = res.path.to_string_lossy().to_string();
let is_math = crate::preview::markdown::is_math_url(&url_str);
if !res.reraster && (crate::preview::markdown::is_mermaid_fence_url(&url_str) || is_math) {
self.md_cache = None;
}
let entry = self.md_image_cache.entry(res.path).or_default();
if res.reraster {
entry.reraster_inflight = false;
}
match res.image {
Ok(img) => {
use image::GenericImageView;
if entry.layout_px.is_none() {
entry.layout_px = if is_math {
res.svg
.as_deref()
.and_then(|d| crate::preview::svg::intrinsic_size_bytes(d))
.or_else(|| Some(img.dimensions()))
} else {
Some(img.dimensions())
};
}
entry.decoded = Some(Arc::new(img));
entry.failed = false;
if let Some(svg) = res.svg {
entry.svg = Some(svg);
}
if res.reraster {
entry.proto_size = None;
entry.clip_key = None;
entry.zoom_key = None;
}
}
Err(_) if res.reraster => {}
Err(_) => entry.failed = true,
}
true
}
pub fn ensure_md_fence_zoom(&mut self, url: &str, cols: u16, rows: u16) {
use image::GenericImageView;
let key_path = PathBuf::from(url);
let zoom = self.fence_zoom;
let font = self.picker.as_ref().map(|p| p.font_size());
let enc_tx = self.md_enc_tx.clone();
let Some(entry) = self.md_image_cache.get_mut(&key_path) else {
return;
};
let Some(img) = entry.decoded.clone() else {
return;
};
let (sw, sh) = img.dimensions();
if let Some(f) = font {
let disp = ((cols as f64 * f.width as f64).max(rows as f64 * f.height as f64) * zoom)
.ceil() as u32;
if self.fence_sharpen_if_needed(&key_path, disp) == FenceSharpen::AppliedSync {
drop(img);
return self.ensure_md_fence_zoom(url, cols, rows);
}
}
let f = (1.0 / zoom.max(1.0)).clamp(0.0, 1.0);
let (crop, center) = fence_crop((sw, sh), f, self.fence_center);
self.fence_center = center;
let Some(entry) = self.md_image_cache.get_mut(&key_path) else {
return;
};
let zkey = (cols, rows, crop);
if entry.zoom_key == Some(zkey) || entry.enc_inflight {
return;
}
let Some(tx) = enc_tx else { return };
entry.enc_inflight = true;
let _ = tx.send(MdEncodeRequest {
path: key_path,
key: MdEncodeKey::Zoom { cols, rows, crop },
image: img,
crop: Some(crop),
cols,
rows,
});
}
pub fn md_fence_zoom_proto(&self, url: &str, cols: u16, rows: u16) -> Option<&Protocol> {
let entry = self.md_image_cache.get(&PathBuf::from(url))?;
match entry.zoom_key {
Some((c, r, _)) if (c, r) == (cols, rows) => {
entry.zoom_protocol.as_ref().or(entry.protocol.as_ref())
}
_ => entry.protocol.as_ref(),
}
}
fn fence_sharpen_if_needed(&mut self, key_path: &PathBuf, needed_px: u32) -> FenceSharpen {
use image::GenericImageView;
let img_tx = self.md_img_tx.clone();
let Some(entry) = self.md_image_cache.get_mut(key_path) else {
return FenceSharpen::NotNeeded;
};
let (Some(img), Some(svg)) = (entry.decoded.as_ref(), entry.svg.clone()) else {
return FenceSharpen::NotNeeded;
};
let (sw, sh) = img.dimensions();
let cur = sw.max(sh);
if needed_px <= cur + cur / 8 || cur >= 4096 || entry.reraster_inflight {
return FenceSharpen::NotNeeded;
}
entry.reraster_inflight = true;
let target = needed_px.min(4096);
let kp = key_path.clone();
let job = move || {
let image =
crate::preview::svg::rasterize_bytes(&svg, Path::new("mermaid.svg"), target)
.ok_or_else(|| "rasterize failed".to_string());
MdImageResult {
path: kp,
image,
svg: None,
reraster: true,
}
};
if let Some(tx) = img_tx {
std::thread::spawn(move || {
let _ = tx.send(job());
});
FenceSharpen::Spawned
} else {
let res = job();
let ok = res.image.is_ok();
self.apply_md_image(res);
if ok {
FenceSharpen::AppliedSync
} else {
FenceSharpen::NotNeeded
}
}
}
pub fn ensure_md_fence_density(&mut self, url: &str, cols: u16, rows: u16) {
let Some(f) = self.picker.as_ref().map(|p| p.font_size()) else {
return;
};
let needed =
((cols as f64 * f.width as f64).max(rows as f64 * f.height as f64)).ceil() as u32;
let _ = self.fence_sharpen_if_needed(&PathBuf::from(url), needed);
}
pub fn begin_md_overlay_frame(&mut self) {
self.md_overlay_seen = None;
}
pub fn note_md_overlay(&mut self, sig: u64) {
self.md_overlay_seen = Some(sig);
}
pub fn finish_md_overlay_frame(&mut self) {
if self.md_overlay_last != self.md_overlay_seen {
if self.md_overlay_last.is_some() {
self.md_overlay_moved = true;
}
self.md_overlay_last = self.md_overlay_seen;
}
}
pub fn take_md_overlay_moved(&mut self) -> bool {
std::mem::take(&mut self.md_overlay_moved)
}
fn ensure_mermaid_fence_render(&mut self, code: String) -> bool {
let key = PathBuf::from(crate::preview::markdown::mermaid_fence_url(&code));
if self.md_image_cache.contains_key(&key) {
return false;
}
self.md_image_cache
.insert(key.clone(), MdImgEntry::default());
let max_px = self.mermaid_px();
let theme = self.cfg.ui.mermaid_theme.clone();
let render = move || -> (Result<image::DynamicImage, String>, Option<std::sync::Arc<Vec<u8>>>) {
let Some(svg) = crate::preview::markdown::mermaid_to_svg(&code, &theme) else {
return (Err("mermaid render failed".to_string()), None);
};
let data = std::sync::Arc::new(svg.into_bytes());
let img =
crate::preview::svg::rasterize_bytes(&data, Path::new("mermaid.svg"), max_px)
.ok_or_else(|| "rasterize failed".to_string());
(img, Some(data))
};
if let Some(tx) = self.md_img_tx.clone() {
std::thread::spawn(move || {
let (image, svg) = crate::preview::markdown::catch_silent(render)
.unwrap_or_else(|| (Err("mermaid render panicked".to_string()), None));
let _ = tx.send(MdImageResult {
path: key,
image,
svg,
reraster: false,
});
});
false
} else {
let (image, svg) = render();
self.apply_md_image(MdImageResult {
path: key,
image,
svg,
reraster: false,
});
true
}
}
fn ensure_math_render(&mut self, latex: String, display: bool) -> bool {
let key = PathBuf::from(crate::preview::markdown::math_url(&latex, display));
if self.md_image_cache.contains_key(&key) {
return false;
}
self.md_image_cache
.insert(key.clone(), MdImgEntry::default());
let max_px = self.math_px();
let color = self.cfg.ui.math_color().to_string();
let render =
move || -> (Result<image::DynamicImage, String>, Option<std::sync::Arc<Vec<u8>>>) {
let Some(svg) = crate::preview::math::latex_to_svg(&latex, display, &color) else {
return (Err("math render failed".to_string()), None);
};
let data = std::sync::Arc::new(svg.into_bytes());
let img = crate::preview::svg::rasterize_bytes(&data, Path::new("math.svg"), max_px)
.ok_or_else(|| "rasterize failed".to_string());
(img, Some(data))
};
if let Some(tx) = self.md_img_tx.clone() {
std::thread::spawn(move || {
let (image, svg) = crate::preview::markdown::catch_silent(render)
.unwrap_or_else(|| (Err("math render panicked".to_string()), None));
let _ = tx.send(MdImageResult {
path: key,
image,
svg,
reraster: false,
});
});
false
} else {
let (image, svg) = render();
self.apply_md_image(MdImageResult {
path: key,
image,
svg,
reraster: false,
});
true
}
}
pub fn apply_md_encode(&mut self, res: MdEncodeResult) -> bool {
let Some(entry) = self.md_image_cache.get_mut(&res.path) else {
return false;
};
entry.enc_inflight = false;
let mut degrade = false;
match (res.key, res.protocol) {
(MdEncodeKey::Full { cols, rows }, Some(p)) => {
entry.protocol = Some(p);
entry.proto_size = Some((cols, rows));
}
(MdEncodeKey::Full { cols, rows }, None) => {
entry.proto_size = Some((cols, rows));
if entry.protocol.is_none() {
entry.failed = true;
degrade = true;
}
}
(
MdEncodeKey::Clip {
cols,
full_rows,
row_off,
vis_rows,
},
p,
) => {
if let Some(p) = p {
entry.clip_protocol = Some(p);
}
entry.clip_key = Some((cols, full_rows, row_off, vis_rows));
}
(MdEncodeKey::Zoom { cols, rows, crop }, p) => {
if let Some(p) = p {
entry.zoom_protocol = Some(p);
}
entry.zoom_key = Some((cols, rows, crop));
}
}
if degrade {
self.md_cache = None;
}
true
}
pub fn attach_remote_md_loader(&mut self, tx: std::sync::mpsc::Sender<RemoteFetch>) {
self.md_remote_tx = Some(tx);
}
pub fn apply_remote_fetch(&mut self, res: RemoteFetch) -> bool {
self.md_remote_inflight.remove(&res.url);
if !res.ok {
self.md_remote_failed.insert(res.url);
}
self.md_cache = None;
true
}
fn ensure_remote_md_fetch(&mut self, url: &str) {
if !crate::preview::markdown::is_remote_image_url(url) {
return;
}
if resolve_md_image_path(url, None).is_some()
|| self.md_remote_failed.contains(url)
|| self.md_remote_inflight.contains(url)
{
return;
}
let (Some(tx), Some(dest)) = (self.md_remote_tx.clone(), md_remote_cache_path(url)) else {
return;
};
self.md_remote_inflight.insert(url.to_string());
let u = url.to_string();
std::thread::spawn(move || {
let ok = crate::preview::markdown::catch_silent(|| fetch_remote_image(&u, &dest))
.unwrap_or(false);
let _ = tx.send(RemoteFetch { url: u, ok });
});
}
pub fn ensure_md_image(
&mut self,
url: &str,
cols: u16,
full_rows: u16,
row_off: u16,
vis_rows: u16,
) {
let path = if crate::preview::markdown::is_synthetic_md_url(url) {
PathBuf::from(url)
} else {
let base = self
.preview_path
.as_ref()
.and_then(|p| p.parent())
.map(|p| p.to_path_buf());
let Some(p) = resolve_md_image_path(url, base.as_deref()) else {
return;
};
p
};
if !self.md_image_cache.contains_key(&path) {
if crate::preview::markdown::is_synthetic_md_url(url) {
return;
}
self.md_image_cache
.insert(path.clone(), MdImgEntry::default());
if let Some(tx) = self.md_img_tx.clone() {
let p = path.clone();
let svg_max_px = self.cfg.ui.svg_max_px;
std::thread::spawn(move || {
let image =
crate::preview::markdown::catch_silent(|| md_decode_image(&p, svg_max_px))
.flatten()
.ok_or_else(|| "decode failed".to_string());
let _ = tx.send(MdImageResult {
path: p,
image,
svg: None,
reraster: false,
});
});
}
return;
}
let Some(enc_tx) = self.md_enc_tx.clone() else {
return;
};
let Some(entry) = self.md_image_cache.get_mut(&path) else {
return;
};
if entry.failed || entry.enc_inflight {
return;
}
let Some(img) = entry.decoded.clone() else {
return;
};
if row_off == 0 && vis_rows >= full_rows {
if entry.proto_size == Some((cols, full_rows)) {
return;
}
entry.enc_inflight = true;
let _ = enc_tx.send(MdEncodeRequest {
path,
key: MdEncodeKey::Full {
cols,
rows: full_rows,
},
image: img,
crop: None,
cols,
rows: full_rows,
});
return;
}
if entry.clip_key == Some((cols, full_rows, row_off, vis_rows)) {
return;
}
let (dw, dh) = (img.width(), img.height());
let (y0, h) = md_band_pixels(full_rows, row_off, vis_rows, dh);
entry.enc_inflight = true;
let _ = enc_tx.send(MdEncodeRequest {
path,
key: MdEncodeKey::Clip {
cols,
full_rows,
row_off,
vis_rows,
},
image: img,
crop: Some((0, y0, dw, h)),
cols,
rows: vis_rows,
});
}
pub fn md_image_proto(
&self,
url: &str,
cols: u16,
full_rows: u16,
row_off: u16,
vis_rows: u16,
) -> Option<&Protocol> {
let path = if crate::preview::markdown::is_synthetic_md_url(url) {
PathBuf::from(url)
} else {
let base = self
.preview_path
.as_ref()
.and_then(|p| p.parent())
.map(|p| p.to_path_buf());
resolve_md_image_path(url, base.as_deref())?
};
let entry = self.md_image_cache.get(&path)?;
if row_off == 0 && vis_rows >= full_rows {
if entry.proto_size == Some((cols, full_rows)) {
return entry.protocol.as_ref();
}
} else if entry.clip_key == Some((cols, full_rows, row_off, vis_rows)) {
return entry.clip_protocol.as_ref().or(entry.protocol.as_ref());
}
entry.clip_protocol.as_ref().or(entry.protocol.as_ref())
}
pub fn md_images_loading(&self) -> bool {
!self.md_remote_inflight.is_empty()
|| self
.md_image_cache
.values()
.any(|e| (e.decoded.is_none() && !e.failed) || e.enc_inflight)
}
pub fn detach_image_backend(&mut self) {
self.clear_image();
self.img_tx = None;
}
fn load_image(&mut self, path: &Path) {
if self.picker.is_none() || self.img_tx.is_none() {
return; }
let Some(dyn_img) = crate::preview::image::decode_static(path) else {
return;
};
self.set_static_image(dyn_img);
}
fn set_static_image(&mut self, img: image::DynamicImage) {
self.image_src = Some(std::sync::Arc::new(img));
self.image_crop = None; self.kitty_gen = self.kitty_gen.wrapping_add(1);
self.kitty_want = None;
self.kitty_shown = None;
}
fn set_gif_frames(&mut self, frames: Vec<(image::DynamicImage, std::time::Duration)>) {
self.gif_frames = frames;
self.gif_idx = 0;
self.gif_shown_at = None; self.gif_protocol = None;
self.gif_proto_key = None;
self.image_crop = None;
}
fn start_media_load(&mut self, kind: &PreviewKind, path: &Path) {
self.preview_media_mtime = file_mtime(path);
match kind {
PreviewKind::Image(_) if Self::looks_like_gif(path) => {
self.spawn_or_sync_media(MediaJob::Gif(path.to_path_buf()))
}
PreviewKind::Image(_) => self.load_image(path),
PreviewKind::Svg(_) => {
self.spawn_or_sync_media(MediaJob::Svg(path.to_path_buf(), self.cfg.ui.svg_max_px))
}
PreviewKind::Video(_) => self.spawn_or_sync_media(MediaJob::Video(path.to_path_buf())),
PreviewKind::Pdf(_) => {
self.spawn_or_sync_media(MediaJob::Pdf(path.to_path_buf(), self.pdf_page))
}
PreviewKind::Mermaid(_) if self.mermaid_image_mode() => {
self.spawn_or_sync_media(MediaJob::Mermaid(
path.to_path_buf(),
self.mermaid_px(),
self.cfg.ui.mermaid_theme.clone(),
))
}
PreviewKind::MermaidFence(ord) => {
if let Some(code) = self.mermaid_fence_code(path, *ord) {
self.spawn_or_sync_media(MediaJob::MermaidSrc(
code,
self.mermaid_px(),
self.cfg.ui.mermaid_theme.clone(),
));
}
}
_ => {}
}
}
pub fn mermaid_image_mode(&self) -> bool {
self.cfg.ui.mermaid != "text" && self.picker.is_some()
}
fn mermaid_px(&self) -> u32 {
self.cfg.ui.svg_max_px
}
pub fn math_image_mode(&self) -> bool {
self.cfg.ui.math != "text" && self.picker.is_some()
}
fn math_px(&self) -> u32 {
1024
}
fn mermaid_rows_cap(&self) -> u16 {
match self.cfg.ui.mermaid_rows {
0 => 24,
v => v,
}
}
fn mermaid_fit_rows(&self) -> u16 {
let cap = self.mermaid_rows_cap();
if self.preview_viewport == 0 {
return cap;
}
cap.min(self.preview_viewport.saturating_sub(2)).max(4)
}
fn mermaid_fence_code(&self, md: &Path, ord: usize) -> Option<String> {
let src = std::fs::read_to_string(md).ok()?;
crate::preview::markdown::collect_mermaid_fences(&src)
.into_iter()
.nth(ord)
}
fn spawn_or_sync_media(&mut self, job: MediaJob) {
if self.picker.is_none() {
return; }
let Some(tx) = self.media_tx.clone() else {
if let Some(payload) = job.run() {
self.apply_payload(payload);
}
return;
};
self.media_gen = self.media_gen.wrapping_add(1);
self.media_loading = true;
let gen = self.media_gen;
std::thread::spawn(move || {
let payload = crate::preview::markdown::catch_silent(move || job.run()).flatten();
let _ = tx.send(MediaResult { gen, payload });
});
}
pub fn apply_media(&mut self, result: MediaResult) -> bool {
if result.gen != self.media_gen {
return false; }
self.media_loading = false;
self.vector_reraster_inflight = false;
match result.payload {
Some(payload) => {
self.apply_payload(payload);
true
}
None => true, }
}
fn apply_payload(&mut self, payload: MediaPayload) {
match payload {
MediaPayload::Static(img) => self.set_static_image(img),
MediaPayload::Gif(frames) => self.set_gif_frames(frames),
MediaPayload::Vector { img, svg } => {
use image::GenericImageView;
if self.image_logical.is_none() {
self.image_logical = Some(img.dimensions());
}
self.vector_svg = Some(svg);
self.set_static_image(img);
self.maybe_sharpen_vector();
}
}
}
pub fn is_media_loading(&self) -> bool {
self.media_loading
}
fn looks_like_gif(path: &Path) -> bool {
use std::io::Read;
let Ok(mut f) = std::fs::File::open(path) else {
return false;
};
let mut head = [0u8; 6];
if f.read_exact(&mut head).is_err() {
return false;
}
&head[..4] == b"GIF8" }
pub fn advance_gif_if_due(&mut self) -> bool {
if self.gif_frames.len() < 2 {
return false;
}
let now = std::time::Instant::now();
let Some(shown_at) = self.gif_shown_at else {
self.gif_shown_at = Some(now);
return false;
};
let delay = self.gif_frames[self.gif_idx].1;
if now.duration_since(shown_at) < delay {
return false;
}
self.gif_idx = (self.gif_idx + 1) % self.gif_frames.len();
self.gif_shown_at = Some(now);
true
}
pub fn is_gif_active(&self) -> bool {
self.gif_frames.len() >= 2
}
pub fn gif_protocol(&self) -> Option<&Protocol> {
self.gif_protocol.as_ref()
}
pub fn prepare_gif(&mut self, inner: Rect) -> Option<Rect> {
let picker = self.picker.as_ref()?;
let scale = self.cfg.ui.image_render_scale;
let src = &self.gif_frames.get(self.gif_idx)?.0;
let (target, crop_rect, center, frac) = image_layout(
src,
picker.font_size(),
self.image_zoom,
self.image_center,
inner,
scale,
None, )?;
let key = (self.gif_idx, crop_rect);
let built = if self.gif_proto_key != Some(key) {
let (x0, y0, cw, ch) = crop_rect;
let crop = src.crop_imm(x0, y0, cw, ch);
let size = ratatui::layout::Size::new(target.width.max(1), target.height.max(1));
Some(picker.new_protocol(crop, size, Resize::Scale(Some(FilterType::Triangle))))
} else {
None
};
if let Some(res) = built {
match res {
Ok(p) => {
self.gif_protocol = Some(p);
self.gif_proto_key = Some(key);
}
Err(_) => {
self.gif_protocol = None;
self.gif_proto_key = None;
}
}
}
self.image_center = center;
self.image_vis_frac = frac;
self.image_crop = Some(crop_rect);
Some(target)
}
pub fn gif_poll_timeout(&self) -> Option<std::time::Duration> {
use std::time::Duration;
if self.gif_frames.len() < 2 {
return None;
}
let remaining = match self.gif_shown_at {
None => Duration::ZERO, Some(t) => self.gif_frames[self.gif_idx]
.1
.checked_sub(t.elapsed())
.unwrap_or(Duration::ZERO),
};
Some(remaining.clamp(Duration::from_millis(10), Duration::from_millis(100)))
}
fn load_table(&mut self) {
self.table_data = None;
if let Some(PreviewKind::Table { path, delimiter }) = self.preview_kind.clone() {
if let Ok(t) = crate::preview::table::parse(&path, delimiter) {
self.table_data = Some(t);
}
}
}
fn clamp_table_cursor(&mut self) {
match &self.table_data {
Some(t) if t.nrows() > 0 && t.ncols > 0 => {
self.table_cur_row = self.table_cur_row.min(t.nrows() - 1);
self.table_cur_col = self.table_cur_col.min(t.ncols - 1);
}
_ => {
self.table_cur_row = 0;
self.table_cur_col = 0;
}
}
}
pub fn is_table_preview(&self) -> bool {
matches!(self.preview_kind, Some(PreviewKind::Table { .. })) && self.table_data.is_some()
}
fn table_delimiter(&self) -> u8 {
match self.preview_kind {
Some(PreviewKind::Table { delimiter, .. }) => delimiter,
_ => b',',
}
}
pub fn table_data(&self) -> Option<&crate::preview::table::TableData> {
self.table_data.as_ref()
}
pub fn table_cursor(&self) -> (usize, usize) {
(self.table_cur_row, self.table_cur_col)
}
pub fn table_scroll(&self) -> (usize, usize) {
(self.table_top_row, self.table_left_col)
}
pub fn set_table_view(&mut self, top_row: usize, left_col: usize, viewport_rows: u16) {
self.table_top_row = top_row;
self.table_left_col = left_col;
self.table_viewport_rows = viewport_rows;
}
pub fn table_cursor_move(&mut self, drow: i32, dcol: i32) {
let Some(t) = &self.table_data else {
return;
};
let (nr, nc) = (t.nrows(), t.ncols);
if nr == 0 || nc == 0 {
return;
}
let r = (self.table_cur_row as i64 + drow as i64).clamp(0, nr as i64 - 1);
let c = (self.table_cur_col as i64 + dcol as i64).clamp(0, nc as i64 - 1);
self.table_cur_row = r as usize;
self.table_cur_col = c as usize;
}
pub fn table_row_to(&mut self, bottom: bool) {
let Some(t) = &self.table_data else {
return;
};
self.table_cur_row = if bottom {
t.nrows().saturating_sub(1)
} else {
0
};
}
pub fn table_col_to(&mut self, end: bool) {
let Some(t) = &self.table_data else {
return;
};
self.table_cur_col = if end { t.ncols.saturating_sub(1) } else { 0 };
}
pub fn table_page(&mut self, dir: i32) {
let page = self.table_viewport_rows.max(1) as i32;
self.table_cursor_move(dir * page, 0);
}
pub fn table_half_page(&mut self, dir: i32) {
let half = (self.table_viewport_rows / 2).max(1) as i32;
self.table_cursor_move(dir * half, 0);
}
fn table_copy_text(&self, kind: TableCopyKind) -> Option<String> {
let t = self.table_data.as_ref()?;
let (r, c) = (self.table_cur_row, self.table_cur_col);
let sep = (self.table_delimiter() as char).to_string();
Some(match kind {
TableCopyKind::Cell => {
if t.nrows() == 0 {
t.header(c).to_string()
} else {
t.cell(r, c).to_string()
}
}
TableCopyKind::Row => {
if t.nrows() == 0 {
t.headers.join(&sep)
} else {
t.rows.get(r).map(|row| row.join(&sep)).unwrap_or_default()
}
}
TableCopyKind::Column => {
let mut vals = vec![t.header(c).to_string()];
vals.extend(
t.rows
.iter()
.map(|row| row.get(c).cloned().unwrap_or_default()),
);
vals.join("\n")
}
})
}
pub fn table_copy(&mut self, kind: TableCopyKind) {
let Some(text) = self.table_copy_text(kind) else {
self.flash = Some(tr(self.lang, crate::i18n::Msg::NoCopyTarget).into());
return;
};
self.set_clipboard_flash(&text);
}
pub fn is_image_preview(&self) -> bool {
(self.image_src.is_some() || self.is_gif_active())
&& matches!(
self.preview_kind,
Some(
PreviewKind::Image(_)
| PreviewKind::Svg(_)
| PreviewKind::Video(_)
| PreviewKind::Pdf(_)
| PreviewKind::Mermaid(_)
| PreviewKind::MermaidFence(_)
)
)
}
pub fn is_pdf_preview(&self) -> bool {
matches!(self.preview_kind, Some(PreviewKind::Pdf(_)))
}
pub fn pdf_page_indicator(&self) -> Option<(u32, u32)> {
if !self.is_pdf_preview() {
return None;
}
let total = self.pdf_pages?;
Some((self.pdf_page.min(total), total))
}
pub fn pdf_can_navigate(&self) -> bool {
matches!(self.pdf_pages, Some(n) if n > 1)
}
pub fn pdf_next_page(&mut self) {
self.pdf_goto(self.pdf_page.saturating_add(1));
}
pub fn pdf_prev_page(&mut self) {
self.pdf_goto(self.pdf_page.saturating_sub(1));
}
fn pdf_goto(&mut self, page: u32) {
if !self.pdf_can_navigate() {
return;
}
let Some(total) = self.pdf_pages else { return };
let page = page.clamp(1, total);
if page == self.pdf_page {
return;
}
self.pdf_page = page;
self.image_zoom = 1.0;
self.image_center = (0.5, 0.5);
self.image_crop = None;
if let Some(PreviewKind::Pdf(p)) = self.preview_kind.clone() {
self.spawn_or_sync_media(MediaJob::Pdf(p, self.pdf_page));
}
}
pub fn image_zoom_by(&mut self, factor: f64) {
if self.image_src.is_none() && !self.is_gif_active() {
if self.focused_mermaid_ordinal().is_some() {
self.fence_zoom = (self.fence_zoom * factor).clamp(1.0, 16.0);
}
return;
}
self.image_zoom = (self.image_zoom * factor).clamp(1.0, 16.0);
self.maybe_sharpen_vector();
}
fn maybe_sharpen_vector(&mut self) {
use image::GenericImageView;
if self.vector_reraster_inflight {
return;
}
let (Some(svg), Some(src), Some((lw, lh))) =
(&self.vector_svg, &self.image_src, self.image_logical)
else {
return;
};
let cur_side = src.dimensions().0.max(src.dimensions().1);
let want = ((lw.max(lh) as f64) * self.image_zoom).ceil() as u32;
if want <= cur_side + cur_side / 8 || cur_side >= 4096 {
return;
}
let base = self.preview_path.clone().unwrap_or_default();
if self.media_tx.is_some() {
self.vector_reraster_inflight = true;
}
self.spawn_or_sync_media(MediaJob::SvgReraster(svg.clone(), base, want));
}
pub fn image_zoom_reset(&mut self) {
if self.image_src.is_none() && !self.is_gif_active() {
if self.focused_mermaid_ordinal().is_some() {
self.fence_zoom = 1.0;
self.fence_center = (0.5, 0.5);
}
return;
}
self.image_zoom = 1.0;
self.image_center = (0.5, 0.5);
}
pub fn fence_zoom_level(&self) -> f64 {
self.fence_zoom
}
fn focused_fence_fully_visible(&self) -> bool {
let Some(ord) = self.focused_mermaid_ordinal() else {
return false;
};
let Some((line, rows)) = self.mermaid_placement(ord) else {
return false;
};
let vh = self.preview_viewport as usize;
if vh == 0 {
return true;
}
let (top, _) = self.md_visual_span(line);
let scroll = self.preview_scroll as usize;
top >= scroll && top + rows as usize <= scroll + vh
}
pub fn fence_pan_motion(&mut self, m: crate::keymap::Motion) -> bool {
use crate::keymap::Motion as M;
if self.fence_zoom <= 1.001 || !self.focused_fence_fully_visible() {
return false;
}
if matches!(m, M::LineHome) {
self.fence_zoom = 1.0;
self.fence_center = (0.5, 0.5);
return true;
}
let (dx, dy) = match m {
M::Left => (-1.0, 0.0),
M::Right => (1.0, 0.0),
M::Up => (0.0, -1.0),
M::Down => (0.0, 1.0),
_ => return false,
};
let f = 1.0 / self.fence_zoom;
self.fence_center.0 = (self.fence_center.0 + dx * f * 0.25).clamp(0.0, 1.0);
self.fence_center.1 = (self.fence_center.1 + dy * f * 0.25).clamp(0.0, 1.0);
true
}
pub fn image_pan(&mut self, dx: f64, dy: f64) {
if self.image_src.is_none() && !self.is_gif_active() {
return;
}
let (fw, fh) = self.image_vis_frac;
self.image_center.0 += dx * 0.25 * fw;
self.image_center.1 += dy * 0.25 * fh;
}
pub fn prepare_image(&mut self, inner: Rect) -> Option<Rect> {
let src = self.image_src.as_ref()?;
let picker = self.picker.as_ref()?;
let scale = self.cfg.ui.image_render_scale;
let (target, crop_rect, center, frac) = image_layout(
src,
picker.font_size(),
self.image_zoom,
self.image_center,
inner,
scale,
self.image_logical,
)?;
let crop_changed = self.image_crop != Some(crop_rect);
if self.use_kitty {
let want = (crop_rect, target.width, target.height);
if self.kitty_want != Some(want) {
self.kitty_want = Some(want);
self.request_kitty_build(crop_rect, target, picker.font_size());
}
} else {
let new_tp = if crop_changed {
let (x0, y0, cw, ch) = crop_rect;
let crop = src.crop_imm(x0, y0, cw, ch);
let proto = picker.new_resize_protocol(crop);
Some(ThreadProtocol::new(
self.img_tx.as_ref()?.clone(),
Some(proto),
))
} else {
None
};
if let Some(tp) = new_tp {
self.image = Some(tp);
}
}
self.image_center = center;
self.image_vis_frac = frac;
self.image_crop = Some(crop_rect);
Some(target)
}
fn request_kitty_build(
&mut self,
crop_rect: (u32, u32, u32, u32),
target: Rect,
font: ratatui_image::FontSize,
) {
let Some(src) = self.image_src.clone() else {
return; };
let (cols, rows) = (target.width, target.height);
let is_tmux = self.kitty_is_tmux;
self.kitty_gen = self.kitty_gen.wrapping_add(1);
if self.kitty_image.is_none() || self.kitty_tx.is_none() {
self.kitty_image = crate::preview::kitty::build_from_source(
&src, crop_rect, cols, rows, font, is_tmux,
);
self.kitty_shown = self.kitty_want; return;
}
let tx = self.kitty_tx.clone().unwrap();
let gen = self.kitty_gen;
std::thread::spawn(move || {
let image = crate::preview::markdown::catch_silent(move || {
crate::preview::kitty::build_from_source(&src, crop_rect, cols, rows, font, is_tmux)
})
.flatten();
let _ = tx.send(KittyResult { gen, image });
});
}
pub fn apply_kitty(&mut self, result: KittyResult) -> bool {
if result.gen != self.kitty_gen {
return false; }
self.kitty_shown = self.kitty_want;
match result.image {
Some(ki) => {
self.kitty_image = Some(ki);
true
}
None => false,
}
}
pub fn kitty_build_pending(&self) -> bool {
self.use_kitty && self.kitty_want != self.kitty_shown
}
pub fn uses_kitty_image(&self) -> bool {
self.use_kitty
}
pub fn kitty_image_ref(&self) -> Option<&crate::preview::kitty::KittyImage> {
self.kitty_image.as_ref()
}
pub fn preview_page(&mut self, dir: i32) {
let page = self.preview_viewport.saturating_sub(1).max(1) as i32;
self.preview_scroll(dir * page);
}
pub fn preview_half_page(&mut self, dir: i32) {
let half = (self.preview_viewport / 2).max(1) as i32;
self.preview_scroll(dir * half);
}
pub fn preview_to_top(&mut self) {
if self.is_windowed() {
self.preview_cursor_line = 0;
self.preview_byte_top = 0;
self.preview_top_line = 0;
} else {
self.preview_scroll = 0;
}
}
pub fn preview_to_bottom(&mut self) {
if !self.is_windowed() {
self.preview_scroll = u16::MAX;
return;
}
let vh = self.preview_viewport.max(1) as usize;
let total = self.win_total();
let cur = self.preview_top_line;
if let Some(b) = self
.preview_win
.as_mut()
.map(|w| w.last_page_top(vh).unwrap_or(0))
{
self.preview_byte_top = b;
self.preview_top_line = total.map(|t| t.saturating_sub(vh)).unwrap_or(cur);
}
if let Some(t) = total {
self.preview_cursor_line = t.saturating_sub(1);
}
}
fn win_total(&mut self) -> Option<usize> {
if let Some(t) = self.preview_total_lines {
return Some(t);
}
let t = self.preview_win.as_mut()?.count_lines().ok()?;
self.preview_total_lines = Some(t);
Some(t)
}
pub fn apply_image_resize(&mut self, resp: Result<ResizeResponse, Errors>) -> bool {
let Ok(resp) = resp else {
return false; };
match self.image.as_mut() {
Some(state) => state.update_resized_protocol(resp),
None => false,
}
}
pub fn preview_scroll(&mut self, delta: i32) {
if self.is_windowed() {
self.preview_cursor_move(delta);
return;
}
let next = self.preview_scroll as i32 + delta;
self.preview_scroll = next.max(0) as u16;
}
fn preview_cursor_move(&mut self, delta: i32) {
let total = self.win_total().unwrap_or(usize::MAX);
let maxl = total.saturating_sub(1) as i64;
let next = (self.preview_cursor_line as i64 + delta as i64).clamp(0, maxl.max(0)) as usize;
self.preview_cursor_line = next;
self.follow_cursor();
}
fn follow_cursor(&mut self) {
let vh = self.preview_viewport.max(1) as usize;
let top = self.preview_top_line;
let cur = self.preview_cursor_line;
if cur < top {
self.win_scroll_lines(-((top - cur) as i32));
} else if cur >= top + vh {
self.win_scroll_lines((cur + 1 - (top + vh)) as i32);
}
}
fn win_scroll_lines(&mut self, delta: i32) {
let vh = self.preview_viewport.max(1) as usize;
let top = self.preview_byte_top;
let line = self.preview_top_line;
let total = if self.cfg.ui.line_numbers || self.preview_total_lines.is_some() {
self.win_total()
} else {
None
};
let result = self.preview_win.as_mut().map(|w| {
if delta > 0 {
let (adv, moved) = w.advance(top, delta as usize).unwrap_or((top, 0));
let maxt = w.last_page_top(vh).unwrap_or(top);
if adv >= maxt {
let bl = total.map(|t| t.saturating_sub(vh)).unwrap_or(line + moved);
(maxt, bl)
} else {
(adv, line + moved)
}
} else if delta < 0 {
let (ret, moved) = w.retreat(top, (-delta) as usize).unwrap_or((0, 0));
(ret, line.saturating_sub(moved))
} else {
(top, line)
}
});
if let Some((nt, nl)) = result {
self.preview_byte_top = nt;
self.preview_top_line = nl;
}
}
pub fn windowed_lines(&mut self, height: u16, width: u16) -> Vec<Line<'static>> {
let _ = width; let h = height.max(1) as usize;
let top0 = self.preview_byte_top;
let maxt = self
.preview_win
.as_mut()
.and_then(|w| w.last_page_top(h).ok());
if let Some(maxt) = maxt {
if top0 > maxt {
self.preview_byte_top = maxt;
if let Some(t) = self.win_total() {
self.preview_top_line = t.saturating_sub(h);
}
}
}
let top = self.preview_byte_top;
let path = self.preview_path.clone().unwrap_or_default();
let syntax_kind = match &self.preview_kind {
Some(PreviewKind::Code(_)) => true,
Some(PreviewKind::Text(p)) => crate::preview::code::has_named_syntax(p),
Some(PreviewKind::Markdown(p)) | Some(PreviewKind::Mermaid(p)) if self.md_raw => {
crate::preview::code::has_named_syntax(p)
}
_ => false,
};
let want_syntax = self.cfg.ui.syntax_highlight
&& syntax_kind
&& (!self.hl_pending || self.loading_is_indicator());
let cacheable = want_syntax || !(self.cfg.ui.syntax_highlight && syntax_kind);
let mut content: Vec<Line<'static>> = if cacheable {
let hit = matches!(
&self.win_cache,
Some(c) if c.path == path && c.byte_top == top && c.height == height
&& c.styled == want_syntax
);
if !hit {
let raw = self
.preview_win
.as_mut()
.and_then(|w| w.read_lines(top, h).ok())
.unwrap_or_default();
let lines = if want_syntax {
let src = raw.join("\n");
crate::preview::code::highlight(&src, &path, &self.cfg.ui.theme.code_theme)
} else {
raw.into_iter().map(Line::from).collect()
};
self.win_cache = Some(WinCache {
path,
byte_top: top,
height,
styled: want_syntax,
lines,
});
}
self.win_cache
.as_ref()
.map(|c| c.lines.clone())
.unwrap_or_default()
} else {
let raw = self
.preview_win
.as_mut()
.and_then(|w| w.read_lines(top, h).ok())
.unwrap_or_default();
raw.into_iter().map(Line::from).collect()
};
content = crate::preview::code::expand_tabs(content, self.cfg.ui.tab_width);
if let Some(q) = self.preview_search.clone() {
let (cur_line, cur_rank) = match self.search_matches.get(self.search_idx).copied() {
Some((_, line, _)) => {
let rank = self.search_matches[..self.search_idx]
.iter()
.filter(|(_, l, _)| *l == line)
.count();
(Some(line), Some(rank))
}
None => (None, None),
};
let top_line = self.preview_top_line;
content = content
.into_iter()
.enumerate()
.map(|(i, l)| {
let abs = top_line + i;
let rank = if cur_line == Some(abs) {
cur_rank
} else {
None
};
highlight_query_in_line(l, &q, rank)
})
.collect();
}
let top_line = self.preview_top_line;
if self.preview_cursor_line >= top_line
&& self.preview_cursor_line < top_line + content.len()
{
let ln = &content[self.preview_cursor_line - top_line];
let len: usize = ln.spans.iter().map(|s| s.content.chars().count()).sum();
self.preview_cursor_col = self.preview_cursor_col.min(len.saturating_sub(1));
}
let content = apply_preview_caret(
content,
top_line,
self.preview_cursor_line,
self.preview_cursor_col,
self.preview_selection(),
);
let content = if self.cfg.ui.line_numbers {
with_line_numbers(content, top_line)
} else {
content
};
let marks = self.git_gutter_marks();
with_git_gutter(content, top_line, &marks)
}
fn git_gutter_marks(&mut self) -> std::collections::HashMap<u32, GutterMark> {
if !self.cfg.ui.git_gutter {
return std::collections::HashMap::new();
}
let Some(path) = self.preview_path.clone() else {
return std::collections::HashMap::new();
};
let hit = matches!(&self.gutter_cache, Some(c) if c.path == path);
if !hit {
let diff = crate::git::file_diff(&self.root, &path);
let marks = gutter_marks(&diff);
self.gutter_cache = Some(GutterCache {
path: path.clone(),
marks,
});
}
self.gutter_cache
.as_ref()
.map(|c| c.marks.clone())
.unwrap_or_default()
}
pub fn window_progress(&self) -> Option<u16> {
let w = self.preview_win.as_ref()?;
let len = w.len();
if len == 0 {
return Some(0);
}
Some((self.preview_byte_top.min(len) * 100 / len) as u16)
}
pub fn is_searching(&self) -> bool {
self.search_input.is_some()
}
pub fn preview_search_query(&self) -> Option<&str> {
self.preview_search.as_deref()
}
pub fn search_input(&self) -> Option<&str> {
self.search_input.as_deref()
}
pub fn search_status(&self) -> Option<(usize, usize)> {
if self.preview_search.is_some() && !self.search_matches.is_empty() {
Some((self.search_idx + 1, self.search_matches.len()))
} else {
None
}
}
fn search_target(&self) -> SearchTarget {
if self.preview_win.is_some() {
SearchTarget::Windowed
} else if self.table_data.is_some() {
SearchTarget::Table
} else if self.md_cache.is_some() {
SearchTarget::Markdown
} else {
SearchTarget::Unsupported
}
}
fn md_search_scan(&mut self, q: &str) {
self.search_matches.clear();
let needle = q.to_lowercase();
let Some(c) = self.md_cache.as_ref() else {
return;
};
for (li, line) in c.lines.iter().enumerate() {
let full: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
let lower = full.to_lowercase();
if lower.len() != full.len() {
continue;
}
let mut i = 0usize;
while let Some(rel) = lower[i..].find(&needle) {
let s = i + rel;
self.search_matches.push((0, li, s));
i = s + needle.len().max(1);
if i >= lower.len() {
break;
}
}
}
}
fn md_scroll_line_into_view(&mut self, line: usize) {
const CONTEXT_ROWS: usize = 3;
let (top, height) = self.md_visual_span(line);
let vh = self.preview_viewport.max(1) as usize;
let scroll = self.preview_scroll as usize;
let fully_visible = top >= scroll && top + height <= scroll + vh;
if fully_visible {
return;
}
self.preview_scroll = top.saturating_sub(CONTEXT_ROWS) as u16;
}
pub fn start_search(&mut self) {
if matches!(self.search_target(), SearchTarget::Unsupported) {
self.flash = Some(tr(self.lang, crate::i18n::Msg::SearchCodeTextOnly).into());
return;
}
self.search_input = Some(String::new());
}
fn table_search_scan(&mut self, q: &str) {
self.table_search_hits.clear();
self.search_matches.clear();
let needle = q.to_lowercase();
let Some(t) = self.table_data.as_ref() else {
return;
};
for r in 0..t.nrows() {
for c in 0..t.ncols {
if t.cell(r, c).to_lowercase().contains(&needle) {
self.search_matches.push((0, r, c));
self.table_search_hits.insert((r, c));
}
}
}
}
pub fn table_cell_is_hit(&self, row: usize, col: usize) -> bool {
self.table_search_hits.contains(&(row, col))
}
pub fn search_input_push(&mut self, c: char) {
if let Some(s) = self.search_input.as_mut() {
s.push(c);
}
}
pub fn search_input_backspace(&mut self) {
if let Some(s) = self.search_input.as_mut() {
s.pop();
}
}
pub fn search_commit(&mut self) {
let q = self.search_input.take().unwrap_or_default();
if q.is_empty() {
self.preview_search = None;
self.search_matches.clear();
self.table_search_hits.clear();
return;
}
const CAP: usize = 5000;
let target = self.search_target();
match target {
SearchTarget::Table => self.table_search_scan(&q),
SearchTarget::Markdown => {
self.table_search_hits.clear();
self.md_search_scan(&q);
}
_ => {
self.table_search_hits.clear();
self.search_matches = self
.preview_win
.as_mut()
.and_then(|w| w.find_all_matches(&q, CAP).ok())
.unwrap_or_default();
}
}
self.preview_search = Some(q);
if self.search_matches.is_empty() {
self.flash = Some(tr(self.lang, crate::i18n::Msg::NoMatch).into());
return;
}
self.search_idx = match target {
SearchTarget::Table => {
let (cr, cc) = (self.table_cur_row, self.table_cur_col);
self.search_matches
.iter()
.position(|(_, r, c)| (*r, *c) >= (cr, cc))
.unwrap_or(0)
}
SearchTarget::Markdown => {
let from = self.md_top_logical_line().unwrap_or(0);
self.search_matches
.iter()
.position(|(_, line, _)| *line >= from)
.unwrap_or(0)
}
_ => {
let top = self.preview_byte_top;
self.search_matches
.iter()
.position(|(off, _, _)| *off >= top)
.unwrap_or(0)
}
};
self.jump_to_match();
}
pub fn search_next(&mut self, dir: i32) {
if self.search_matches.is_empty() {
return;
}
let n = self.search_matches.len() as i32;
self.search_idx = (self.search_idx as i32 + dir).rem_euclid(n) as usize;
self.jump_to_match();
}
pub fn search_clear(&mut self) {
self.preview_search = None;
self.search_input = None;
self.search_matches.clear();
self.table_search_hits.clear();
self.search_idx = 0;
}
fn jump_to_match(&mut self) {
let Some(&(off, a, b)) = self.search_matches.get(self.search_idx) else {
return;
};
match self.search_target() {
SearchTarget::Table => {
self.table_cur_row = a;
self.table_cur_col = b;
}
SearchTarget::Markdown => self.md_scroll_line_into_view(a),
_ => {
self.preview_byte_top = off;
self.preview_top_line = a;
}
}
}
pub fn preview_hscroll(&mut self, delta: i32) {
let next = self.preview_hscroll as i32 + delta;
self.preview_hscroll = next.max(0) as u16;
}
pub fn preview_hscroll_home(&mut self) {
self.preview_hscroll = 0;
}
pub fn preview_hscroll_end(&mut self) {
self.preview_hscroll = u16::MAX;
}
fn postprocess_md(&self, lines: Vec<Line<'static>>) -> (Vec<Line<'static>>, Vec<String>) {
let (lines, targets) = collapse_links(lines, self.cfg.ui.icons);
let (lines, targets) = if self.cfg.ui.md_autolink {
autolink_bare_urls(lines, targets)
} else {
(lines, targets)
};
let lines = if self.cfg.ui.md_emoji {
substitute_emoji(lines)
} else {
lines
};
(lines, targets)
}
#[cfg(test)]
pub fn decorate_md_items(&mut self, lines: Vec<Line<'static>>) -> Vec<Line<'static>> {
let (lines, targets) = self.postprocess_md(lines);
let items = build_md_items(&lines, &targets, &[]);
match self.focused_item {
Some(_) if items.is_empty() => self.focused_item = None,
Some(f) if f >= items.len() => self.focused_item = Some(items.len() - 1),
_ => {}
}
self.md_items = items;
let Some(target_idx) = self.focused_item else {
return lines;
};
let (target_line, whole_line) = {
let it = &self.md_items[target_idx];
(it.line, matches!(it.kind, MdItemKind::CodeBlock))
};
let first_on_line = self.md_items.partition_point(|x| x.line < target_line);
let ordinal = target_idx - first_on_line;
lines
.into_iter()
.enumerate()
.map(|(li, line)| {
if li == target_line {
invert_focused_line(&line, ordinal, whole_line)
} else {
line
}
})
.collect()
}
pub fn md_focus_move(&mut self, dir: i32) {
if self.md_items.is_empty() {
return;
}
let n = self.md_items.len() as i32;
let next = match self.focused_item {
Some(f) => (f as i32 + dir).rem_euclid(n),
None if dir >= 0 => 0,
None => n - 1,
} as usize;
if self.focused_item != Some(next) {
self.fence_zoom = 1.0;
self.fence_center = (0.5, 0.5);
}
self.focused_item = Some(next);
let line = self.md_items[next].line;
let (top, mut height) = self.md_visual_span(line);
let block_end = if let MdItemKind::MermaidFence { ordinal } = self.md_items[next].kind {
self.mermaid_placement(ordinal)
.map(|(pl, pr)| pl + pr as usize) .unwrap_or(line)
} else {
self.md_item_block_end(next)
};
if block_end > line {
let (bt, bh) = self.md_visual_span(block_end);
height = (bt + bh).saturating_sub(top).max(height);
}
let vh = self.preview_viewport.max(1) as usize;
let scroll = self.preview_scroll as usize;
if height >= vh || top < scroll {
self.preview_scroll = top as u16;
} else if top + height > scroll + vh {
self.preview_scroll = (top + height).saturating_sub(vh) as u16;
}
}
fn md_item_block_end(&self, idx: usize) -> usize {
let Some(item) = self.md_items.get(idx) else {
return 0;
};
let start = item.line;
let Some(cache) = self.md_cache.as_ref() else {
return start;
};
let belongs: fn(&ratatui::text::Line<'_>) -> bool = match item.kind {
MdItemKind::CodeBlock => crate::preview::markdown::is_code_line,
MdItemKind::Details { .. } => crate::preview::markdown::is_details_body_line,
_ => return start,
};
let mut end = start;
for (i, line) in cache.lines.iter().enumerate().skip(start + 1) {
if !belongs(line) {
break;
}
end = i;
}
end
}
#[cfg(test)]
pub fn md_item_block_end_for_test(&self, idx: usize) -> usize {
self.md_item_block_end(idx)
}
#[cfg(test)]
pub fn md_item_line_for_test(&self, idx: usize) -> usize {
self.md_items[idx].line
}
#[cfg(test)]
pub fn md_visual_span_for_test(&self, line: usize) -> (usize, usize) {
self.md_visual_span(line)
}
#[cfg(test)]
pub fn preview_viewport_for_test(&self) -> u16 {
self.preview_viewport
}
fn mermaid_placement(&self, ordinal: usize) -> Option<(usize, u16)> {
let cache = self.md_cache.as_ref()?;
cache
.images
.iter()
.find(|p| p.fence_ord == Some(ordinal))
.map(|p| (p.line, p.rows))
}
pub fn focused_mermaid_ordinal(&self) -> Option<usize> {
let f = self.focused_item?;
match self.md_items.get(f)?.kind {
MdItemKind::MermaidFence { ordinal } => Some(ordinal),
_ => None,
}
}
pub(crate) fn md_visual_span(&self, line: usize) -> (usize, usize) {
if !self.cfg.ui.wrap {
return (line, 1);
}
let Some(cache) = &self.md_cache else {
return (line, 1);
};
if cache.width == 0
|| line >= cache.lines.len()
|| cache.row_prefix.len() != cache.lines.len() + 1
{
return (line, 1);
}
let top = cache.row_prefix[line];
let height = (cache.row_prefix[line + 1] - top).max(1);
(top, height)
}
pub fn md_activate_focused(&mut self) -> Result<()> {
let Some(f) = self.focused_item else {
return Ok(());
};
match self.md_items.get(f) {
Some(MdItem {
kind: MdItemKind::Link { target },
..
}) => {
let target = target.clone();
self.open_link_target(&target)
}
Some(MdItem {
kind: MdItemKind::Task { .. },
..
}) => {
self.md_toggle_focused_task();
Ok(())
}
Some(MdItem {
kind: MdItemKind::CodeBlock,
..
}) => Ok(()),
Some(MdItem {
kind: MdItemKind::MermaidFence { ordinal },
..
}) => {
let ord = *ordinal;
self.open_mermaid_fence(ord);
Ok(())
}
Some(MdItem {
kind: MdItemKind::Details { ordinal },
..
}) => {
let ord = *ordinal;
self.toggle_details(ord);
Ok(())
}
None => Ok(()),
}
}
fn open_mermaid_fence(&mut self, ordinal: usize) {
let Some(md) = self.preview_path.clone() else {
return;
};
if self.mermaid_fence_code(&md, ordinal).is_none() {
self.flash = Some(tr(self.lang, crate::i18n::Msg::DiagramOpenFailed).into());
return;
}
self.fence_return = Some((self.preview_scroll, self.focused_item));
self.clear_image();
let kind = PreviewKind::MermaidFence(ordinal);
self.start_media_load(&kind, &md);
self.preview_kind = Some(kind);
}
fn focused_code_source(&self) -> Option<String> {
let f = self.focused_item?;
if !matches!(
self.md_items.get(f),
Some(MdItem {
kind: MdItemKind::CodeBlock,
..
})
) {
return None;
}
let ordinal = self.md_items[..=f]
.iter()
.filter(|it| matches!(it.kind, MdItemKind::CodeBlock))
.count()
.saturating_sub(1);
let total = self
.md_items
.iter()
.filter(|it| matches!(it.kind, MdItemKind::CodeBlock))
.count();
let src = std::fs::read_to_string(self.preview_path.as_ref()?).ok()?;
let details_states: Vec<bool> = self
.md_cache
.as_ref()
.map(|c| c.details_states.clone())
.unwrap_or_default();
let blocks = crate::preview::markdown::code_block_source_locs(&src, &details_states);
(blocks.len() == total)
.then(|| blocks.get(ordinal).cloned())
.flatten()
}
pub fn md_copy_focused_code(&mut self) {
let Some(text) = self.focused_code_source() else {
self.flash = Some(tr(self.lang, crate::i18n::Msg::CodeBlockCopyUnavailable).into());
return;
};
match set_clipboard(&text) {
Ok(()) => self.flash = Some(tr(self.lang, crate::i18n::Msg::CopiedCodeBlock).into()),
Err(e) => {
self.flash = Some(format!(
"{}{e}",
tr(self.lang, crate::i18n::Msg::CopyFailed)
))
}
}
}
#[cfg(test)]
pub fn focused_code_text(&self) -> Option<String> {
self.focused_code_source()
}
pub fn md_focused_code(&self) -> bool {
!self.is_raw_source()
&& self
.focused_item
.and_then(|f| self.md_items.get(f))
.is_some_and(|it| matches!(it.kind, MdItemKind::CodeBlock))
}
pub fn md_focused_task(&self) -> bool {
!self.is_raw_source()
&& self
.focused_item
.and_then(|f| self.md_items.get(f))
.is_some_and(|it| matches!(it.kind, MdItemKind::Task { .. }))
}
pub fn md_focused_details(&self) -> Option<usize> {
if self.is_raw_source() {
return None;
}
match self.focused_item.and_then(|f| self.md_items.get(f)) {
Some(MdItem {
kind: MdItemKind::Details { ordinal },
..
}) => Some(*ordinal),
_ => None,
}
}
pub fn md_has_tasks(&self) -> bool {
self.md_items
.iter()
.any(|it| matches!(it.kind, MdItemKind::Task { .. }))
}
fn open_link_target(&mut self, target: &str) -> Result<()> {
let t = target.trim();
if t.contains("://") || t.starts_with("mailto:") || t.starts_with("tel:") {
return self.open_external(t);
}
if let Some(anchor) = t.strip_prefix('#') {
if !self.md_scroll_to_anchor(anchor) {
self.flash = Some(format!(
"{}{}",
tr(self.lang, crate::i18n::Msg::AnchorNotFound),
t
));
}
return Ok(());
}
let path_part = t.split('#').next().unwrap_or(t);
let resolved = self.resolve_link_local(t);
if resolved.is_dir() {
self.back_to_tree();
self.clear_for_root_change();
self.root = resolved;
self.open_dir = self.root.clone();
self.entries.clear();
self.selected = 0;
self.rebuild_tree()?;
} else if resolved.is_file() {
self.enter_preview(&resolved);
} else {
self.flash = Some(format!(
"{}{}",
tr(self.lang, crate::i18n::Msg::NotFound),
path_part
));
}
Ok(())
}
fn resolve_link_local(&self, target: &str) -> PathBuf {
let t = target.trim();
let path_part = t.split('#').next().unwrap_or(t);
let base = self
.preview_path
.as_ref()
.and_then(|p| p.parent())
.map(Path::to_path_buf)
.unwrap_or_else(|| self.root.clone());
let p = Path::new(path_part);
let resolved = if p.is_absolute() {
p.to_path_buf()
} else {
base.join(p)
};
std::fs::canonicalize(&resolved).unwrap_or(resolved)
}
pub fn md_open_focused_link_new_tab(&mut self) -> Result<()> {
let Some(f) = self.focused_item else {
return Ok(());
};
let target = match self.md_items.get(f) {
Some(MdItem {
kind: MdItemKind::Link { target },
..
}) => target.clone(),
_ => return Ok(()),
};
let t = target.trim();
if t.contains("://") || t.starts_with("mailto:") || t.starts_with("tel:") {
return self.open_external(t);
}
if t.starts_with('#') {
return self.open_link_target(t);
}
let resolved = self.resolve_link_local(t);
if !resolved.exists() {
self.flash = Some(format!(
"{}{}",
tr(self.lang, crate::i18n::Msg::NotFound),
t.split('#').next().unwrap_or(t)
));
return Ok(());
}
self.tab_new()?;
self.paste_jump_to(&resolved, None);
Ok(())
}
fn open_external(&mut self, url: &str) -> Result<()> {
match std::process::Command::new("open").arg(url).spawn() {
Ok(_) => self.flash = Some(format!("{}{url}", tr(self.lang, crate::i18n::Msg::Opened))),
Err(e) => {
self.flash = Some(format!(
"{}{e}",
tr(self.lang, crate::i18n::Msg::OpenFailed)
))
}
}
Ok(())
}
pub fn cycle_path_style(&mut self) {
self.path_style = self.path_style.next();
}
pub fn toggle_help(&mut self) {
self.show_help = !self.show_help;
self.help_scroll = 0;
}
pub fn toggle_info(&mut self) {
if self.show_info {
self.show_info = false;
} else if self.info_target().is_some() {
self.show_info = true;
} else {
self.flash = Some(crate::i18n::tr(self.lang, crate::i18n::Msg::NoTarget).into());
}
}
pub fn is_info(&self) -> bool {
self.show_info
}
pub fn info_target(&self) -> Option<PathBuf> {
match self.mode {
Mode::Tree => self.entries.get(self.selected).map(|e| e.path.clone()),
Mode::Preview => self.preview_path.clone(),
}
}
pub fn help_scroll_by(&mut self, delta: i32) {
self.help_scroll = (self.help_scroll as i32 + delta).max(0) as u16;
}
fn copy_target(&self) -> Option<PathBuf> {
#[cfg(feature = "git")]
if self.surface() == crate::keymap::Surface::GitChanges {
return self.git_view_selected();
}
match self.mode {
Mode::Preview => self.preview_path.clone(),
Mode::Tree => self.entries.get(self.selected).map(|e| e.path.clone()),
}
}
pub fn copy_path(&mut self, kind: CopyKind) {
let Some(path) = self.copy_target() else {
self.flash = Some(tr(self.lang, crate::i18n::Msg::NoCopyTarget).into());
return;
};
let text = copy_text(&path, &self.open_dir, kind);
match set_clipboard(&text) {
Ok(()) => {
self.flash = Some(format!(
"{}{text}",
tr(self.lang, crate::i18n::Msg::CopiedPrefix)
))
}
Err(e) => {
self.flash = Some(format!(
"{}{e}",
tr(self.lang, crate::i18n::Msg::CopyFailed)
))
}
}
}
#[cfg(test)]
pub fn copy_string_for(&self, kind: CopyKind) -> Option<String> {
let path = self.copy_target()?;
Some(copy_text(&path, &self.open_dir, kind))
}
#[cfg(test)]
pub fn selection_ref_string(&self) -> Option<String> {
self.preview_selection_ref_text()
}
#[cfg(feature = "git")]
fn current_commit_meta(&self) -> Option<crate::git::CommitMeta> {
use crate::keymap::Surface;
match self.surface() {
Surface::GitDetail => self.git_detail_meta.clone(),
Surface::GitLog => {
let id = self.git_log_selected_id()?;
crate::git::commit_meta(&self.root, &id)
}
Surface::GitGraph => {
let id = self
.git_graph_selected_row()
.and_then(|r| r.commit.clone())?;
crate::git::commit_meta(&self.root, &id)
}
_ => None,
}
}
#[cfg(feature = "git")]
pub fn git_copy(&mut self, kind: GitCopyKind) {
let Some(meta) = self.current_commit_meta() else {
self.flash = Some(tr(self.lang, crate::i18n::Msg::NoCommitToCopy).into());
return;
};
let text = match kind {
GitCopyKind::ShortHash => meta.short,
GitCopyKind::FullHash => meta.id,
GitCopyKind::Subject => meta.message.lines().next().unwrap_or("").to_string(),
GitCopyKind::Message => meta.message,
GitCopyKind::Author => meta.author,
GitCopyKind::Date => meta.date,
};
self.set_clipboard_flash(&text);
}
#[cfg(feature = "git")]
pub fn git_copy_branch_name(&mut self) {
let Some(b) = self.git_branch_selected() else {
self.flash = Some(tr(self.lang, crate::i18n::Msg::NoCopyTarget).into());
return;
};
let name = b.name;
self.set_clipboard_flash(&name);
}
fn set_clipboard_flash(&mut self, text: &str) {
match set_clipboard(text) {
Ok(()) => {
let first = text.lines().next().unwrap_or("");
let mut preview: String = first.chars().take(60).collect();
if first.chars().count() > 60 || text.lines().nth(1).is_some() {
preview.push('…');
}
self.flash = Some(format!(
"{}{preview}",
tr(self.lang, crate::i18n::Msg::CopiedPrefix)
));
}
Err(e) => {
self.flash = Some(format!(
"{}{e}",
tr(self.lang, crate::i18n::Msg::CopyFailed)
))
}
}
}
fn snapshot_tab(&self) -> TabState {
TabState {
root: self.root.clone(),
open_dir: self.open_dir.clone(),
entries: self.entries.clone(),
selected: self.selected,
show_hidden: self.show_hidden,
tree_viewport: self.tree_viewport,
mode: self.mode,
preview_path: self.preview_path.clone(),
preview_kind: self.preview_kind.clone(),
preview_scroll: self.preview_scroll,
preview_hscroll: self.preview_hscroll,
preview_viewport: self.preview_viewport,
preview_byte_top: self.preview_byte_top,
preview_top_line: self.preview_top_line,
image_zoom: self.image_zoom,
image_center: self.image_center,
pdf_page: self.pdf_page,
pdf_pages: self.pdf_pages,
table_cur_row: self.table_cur_row,
table_cur_col: self.table_cur_col,
table_top_row: self.table_top_row,
table_left_col: self.table_left_col,
preview_cursor_line: self.preview_cursor_line,
preview_cursor_col: self.preview_cursor_col,
md_raw: self.md_raw,
focused_item: self.focused_item,
fence_zoom: self.fence_zoom,
fence_center: self.fence_center,
fence_return: self.fence_return,
git_view: self.git_view,
git_view_sel: self.git_view_sel,
git_view_entries: self.git_view_entries.clone(),
came_from_git_view: self.came_from_git_view,
git_log: self.git_log.clone(),
git_log_sel: self.git_log_sel,
git_detail: self.git_detail.clone(),
git_detail_meta: self.git_detail_meta.clone(),
git_detail_title: self.git_detail_title.clone(),
git_detail_scroll: self.git_detail_scroll,
git_detail_hscroll: self.git_detail_hscroll,
git_detail_viewport: self.git_detail_viewport,
git_detail_total: self.git_detail_total,
git_branches: self.git_branches.clone(),
git_branch_sel: self.git_branch_sel,
git_branch_filter: self.git_branch_filter.clone(),
git_branch_filtering: self.git_branch_filtering,
git_graph: self.git_graph.clone(),
git_graph_sel: self.git_graph_sel,
git_graph_base: self.git_graph_base.clone(),
git_graph_base_label: self.git_graph_base_label.clone(),
git_graph_visible: self.git_graph_visible.clone(),
git_graph_legend: self.git_graph_legend.clone(),
git_graph_hidden: self.git_graph_hidden,
git_graph_order: self.git_graph_order.clone(),
selection: self.selection.clone(),
visual_anchor: self.visual_anchor,
tree_filter: self.tree_filter.clone(),
filter_input: self.filter_input.clone(),
filter_pool: self.filter_pool.clone(),
changed_filter: self.changed_filter,
preview_search: self.preview_search.clone(),
search_input: self.search_input.clone(),
search_matches: self.search_matches.clone(),
search_idx: self.search_idx,
}
}
fn save_active(&mut self) {
self.stash_media_cache();
let snap = self.snapshot_tab();
self.tabs[self.active_tab] = snap;
}
fn load_active(&mut self) {
let t = std::mem::take(&mut self.tabs[self.active_tab]);
self.root = t.root;
self.open_dir = t.open_dir;
self.entries = t.entries;
self.selected = t.selected;
self.show_hidden = t.show_hidden;
self.tree_viewport = t.tree_viewport;
self.mode = t.mode;
self.preview_path = t.preview_path;
self.preview_kind = t.preview_kind;
self.preview_scroll = t.preview_scroll;
self.preview_hscroll = t.preview_hscroll;
self.preview_viewport = t.preview_viewport;
self.preview_byte_top = t.preview_byte_top;
self.preview_top_line = t.preview_top_line;
self.git_view = t.git_view;
self.git_view_sel = t.git_view_sel;
self.git_view_entries = t.git_view_entries;
self.came_from_git_view = t.came_from_git_view;
self.git_log = t.git_log;
self.git_log_sel = t.git_log_sel;
self.git_detail = t.git_detail;
self.git_detail_meta = t.git_detail_meta;
self.git_detail_title = t.git_detail_title;
self.git_detail_scroll = t.git_detail_scroll;
self.git_detail_hscroll = t.git_detail_hscroll;
self.git_detail_viewport = t.git_detail_viewport;
self.git_detail_total = t.git_detail_total;
self.git_branches = t.git_branches;
self.git_branch_sel = t.git_branch_sel;
self.git_branch_filter = t.git_branch_filter;
self.git_branch_filtering = t.git_branch_filtering;
self.git_graph = t.git_graph;
self.git_graph_sel = t.git_graph_sel;
self.git_graph_base = t.git_graph_base;
self.git_graph_base_label = t.git_graph_base_label;
self.git_graph_visible = t.git_graph_visible;
self.git_graph_legend = t.git_graph_legend;
self.git_graph_hidden = t.git_graph_hidden;
self.git_graph_order = t.git_graph_order;
self.git_graph_picker = false;
self.git_graph_picker_sel = 0;
self.git_graph_picker_set.clear();
self.git_graph_reordered = false;
self.outline_open = false;
self.details_open.clear();
self.selection = t.selection;
self.visual_anchor = t.visual_anchor;
self.tree_filter = t.tree_filter;
self.filter_input = t.filter_input;
self.filter_pool = t.filter_pool;
self.changed_filter = t.changed_filter;
self.preview_search = t.preview_search;
self.search_input = t.search_input;
self.search_matches = t.search_matches;
self.search_idx = t.search_idx;
self.table_search_hits = if matches!(self.preview_kind, Some(PreviewKind::Table { .. })) {
self.search_matches
.iter()
.map(|&(_, r, c)| (r, c))
.collect()
} else {
std::collections::HashSet::new()
};
self.md_cache = None;
self.diff_follow_scope = false;
self.clear_image();
if let (Some(kind), Some(path)) = (self.preview_kind.clone(), self.preview_path.clone()) {
if self.kind_loads_media(&kind) {
self.pdf_page = t.pdf_page.max(1);
self.pdf_pages = t.pdf_pages;
let reused = self.restore_media_cache(&path, self.pdf_page);
if !reused {
self.start_media_load(&kind, &path);
}
self.image_zoom = t.image_zoom;
self.image_center = t.image_center;
self.image_crop = None;
if reused {
self.maybe_sharpen_vector();
}
}
}
self.md_raw = t.md_raw;
self.md_items.clear();
self.focused_item = t.focused_item;
self.fence_zoom = t.fence_zoom;
self.fence_center = t.fence_center;
self.fence_return = t.fence_return;
self.setup_windowed();
self.preview_cursor_line = t.preview_cursor_line;
self.preview_cursor_col = t.preview_cursor_col;
self.preview_visual_anchor = None;
self.preview_visual_linewise = false;
self.load_table();
self.table_cur_row = t.table_cur_row;
self.table_cur_col = t.table_cur_col;
self.table_top_row = t.table_top_row;
self.table_left_col = t.table_left_col;
self.clamp_table_cursor();
self.git_status_dirty = true;
let _ = self.refresh_fs_after_tab_switch();
}
pub fn tab_count(&self) -> usize {
self.tabs.len()
}
pub fn active_tab_index(&self) -> usize {
self.active_tab
}
pub fn tab_label(&self, i: usize) -> String {
let (mode, root, preview) = if i == self.active_tab {
(self.mode, self.root.as_path(), self.preview_path.as_deref())
} else if let Some(t) = self.tabs.get(i) {
(t.mode, t.root.as_path(), t.preview_path.as_deref())
} else {
return String::new();
};
let path = match mode {
Mode::Preview => preview.unwrap_or(root), Mode::Tree => root,
};
path.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string())
}
pub fn tab_new(&mut self) -> Result<()> {
self.save_active();
self.outline_open = false;
let root = self.root.clone();
self.open_dir = root.clone();
self.root = root;
self.selected = 0;
self.entries.clear();
self.rebuild_tree()?;
self.mode = Mode::Tree;
self.clear_image();
self.preview_path = None;
self.preview_kind = None;
self.preview_scroll = 0;
self.preview_hscroll = 0;
self.preview_byte_top = 0;
self.preview_top_line = 0;
self.preview_win = None;
self.win_cache = None;
self.preview_total_lines = None;
self.md_cache = None;
self.md_items.clear();
self.focused_item = None;
self.fence_zoom = 1.0;
self.fence_center = (0.5, 0.5);
self.fence_return = None;
self.git_view = false;
self.git_view_sel = 0;
self.git_view_entries.clear();
self.came_from_git_view = false;
self.git_log = None;
self.git_log_sel = 0;
self.git_detail = None;
self.git_detail_meta = None;
self.git_detail_title = None;
self.git_detail_scroll = 0;
self.git_detail_hscroll = 0;
self.git_detail_viewport = 0;
self.git_detail_total = 0;
self.git_branches = None;
self.git_branch_sel = 0;
self.git_branch_filter.clear();
self.git_branch_filtering = false;
self.git_graph = None;
self.git_graph_sel = 0;
self.git_graph_base = None;
self.git_graph_base_label = None;
self.git_graph_visible.clear();
self.git_graph_legend.clear();
self.git_graph_hidden = 0;
self.git_graph_order.clear();
self.git_graph_picker = false;
self.git_graph_picker_sel = 0;
self.git_graph_picker_set.clear();
self.git_graph_reordered = false;
self.selection.clear();
self.visual_anchor = None;
self.clear_filter_state();
self.search_clear();
self.tabs.push(self.snapshot_tab());
self.active_tab = self.tabs.len() - 1;
self.save_session();
Ok(())
}
pub fn tab_new_from_selection(&mut self) -> Result<()> {
let Some(entry) = self.entries.get(self.selected).cloned() else {
return Ok(());
};
self.tab_new()?; if entry.is_dir {
self.clear_for_root_change();
self.root = entry.path;
self.entries.clear();
self.selected = 0;
self.rebuild_tree()?;
} else {
let _ = self.reveal_path_deep(&entry.path);
self.enter_preview(&entry.path);
}
self.save_session();
Ok(())
}
pub fn tab_close(&mut self) {
if self.tabs.len() <= 1 {
self.flash = Some(tr(self.lang, crate::i18n::Msg::CantCloseLastTab).into());
return;
}
self.drop_media_cache_for_active();
self.tabs.remove(self.active_tab);
if self.active_tab >= self.tabs.len() {
self.active_tab = self.tabs.len() - 1;
}
self.load_active();
self.save_session();
}
pub fn tab_cycle(&mut self, dir: i32) {
self.tab_list = false; if self.tabs.len() <= 1 {
return;
}
self.save_active();
let n = self.tabs.len() as i32;
self.active_tab = (self.active_tab as i32 + dir).rem_euclid(n) as usize;
self.load_active();
self.save_session();
}
pub fn tab_goto(&mut self, i: usize) {
self.tab_list = false; if i >= self.tabs.len() || i == self.active_tab {
return;
}
self.save_active();
self.active_tab = i;
self.load_active();
self.save_session();
}
pub fn is_outline(&self) -> bool {
self.outline_open
}
pub(crate) fn md_outline(&self) -> Vec<(u8, String, usize)> {
let Some(c) = &self.md_cache else {
return Vec::new();
};
c.anchors
.iter()
.filter_map(|(_slug, line)| {
let l = c.lines.get(*line)?;
let text = crate::preview::markdown::heading_text(l)?;
let level = crate::preview::markdown::heading_level_hint(l, c.lines.get(*line + 1));
Some((level, text, *line))
})
.collect()
}
pub fn toggle_outline(&mut self) {
if self.outline_open {
self.outline_open = false;
return;
}
let items = self.md_outline();
if items.is_empty() {
self.flash = Some(tr(self.lang, crate::i18n::Msg::OutlineEmpty).into());
return;
}
let top = self.md_top_logical_line().unwrap_or(0);
self.outline_sel = items
.iter()
.rposition(|(_, _, line)| *line <= top)
.unwrap_or(0);
self.outline_open = true;
}
pub fn outline_sel(&self) -> usize {
self.outline_sel
}
pub fn outline_move(&mut self, delta: i32) {
let n = self.md_outline().len();
if n == 0 {
return;
}
self.outline_sel = (self.outline_sel as i32 + delta).rem_euclid(n as i32) as usize;
}
pub fn outline_jump(&mut self) {
let line = self.md_outline().get(self.outline_sel).map(|(_, _, l)| *l);
self.outline_open = false;
if let Some(line) = line {
let (row, _) = self.md_visual_span(line);
self.preview_scroll = row.min(u16::MAX as usize) as u16;
}
}
pub fn is_tab_list(&self) -> bool {
self.tab_list
}
pub fn toggle_tab_list(&mut self) {
self.tab_list = !self.tab_list;
if self.tab_list {
self.tab_list_sel = self.active_tab;
}
}
pub fn tab_list_sel(&self) -> usize {
self.tab_list_sel
}
pub fn tab_list_move(&mut self, delta: i32) {
let n = self.tabs.len();
if n == 0 {
return;
}
self.tab_list_sel = (self.tab_list_sel as i32 + delta).rem_euclid(n as i32) as usize;
}
pub fn tab_list_activate(&mut self) {
let i = self.tab_list_sel;
self.tab_list = false;
self.tab_goto(i);
}
pub fn tab_list_close_selected(&mut self) {
if self.tabs.len() <= 1 {
self.flash = Some(tr(self.lang, crate::i18n::Msg::CantCloseLastTab).into());
return;
}
let i = self.tab_list_sel;
if i == self.active_tab {
self.tab_close();
} else {
if let Some(t) = self.tabs.get(i) {
let closing = t.preview_path.clone();
if matches!((&self.media_cache, &closing), (Some(c), Some(p)) if &c.path == p) {
self.media_cache = None;
}
}
self.tabs.remove(i);
if self.active_tab > i {
self.active_tab -= 1;
}
}
self.tab_list_sel = self.tab_list_sel.min(self.tabs.len() - 1);
self.save_session();
}
pub fn tab_root(&self, i: usize) -> std::path::PathBuf {
if i == self.active_tab {
self.root.clone()
} else {
self.tabs
.get(i)
.map(|t| t.root.clone())
.unwrap_or_else(|| self.root.clone())
}
}
pub fn format_path(&self, path: &Path) -> String {
match self.path_style {
PathStyle::Full => path.display().to_string(),
PathStyle::Home => home_relative(path),
PathStyle::Relative => rel_to_open(&self.open_dir, path),
}
}
}
fn clamp_cursor(cur: usize, delta: i32, len: usize) -> usize {
if len == 0 {
return 0;
}
let last = len.saturating_sub(1) as i64;
(cur as i64).saturating_add(delta as i64).clamp(0, last) as usize
}
fn is_link_span(span: &Span<'static>) -> bool {
use ratatui::style::{Color, Modifier};
span.style.add_modifier.contains(Modifier::UNDERLINED) && span.style.fg == Some(Color::Blue)
}
fn github_slug(text: &str) -> String {
let mut s = String::new();
for c in text.trim().chars() {
if c.is_alphanumeric() {
s.extend(c.to_lowercase());
} else if c == ' ' {
s.push('-');
} else if c == '-' || c == '_' {
s.push(c);
}
}
s
}
fn compute_md_anchors(lines: &[Line<'static>]) -> Vec<(String, usize)> {
use std::collections::HashMap;
let mut anchors = Vec::new();
let mut counts: HashMap<String, usize> = HashMap::new();
for (i, line) in lines.iter().enumerate() {
let Some(text) = crate::preview::markdown::heading_text(line) else {
continue;
};
let base = github_slug(&text);
if base.is_empty() {
continue;
}
let n = counts.entry(base.clone()).or_insert(0);
let slug = if *n == 0 {
base.clone()
} else {
format!("{base}-{n}")
};
*n += 1;
anchors.push((slug, i));
}
anchors
}
fn build_md_items(
lines: &[Line<'static>],
targets: &[String],
fence_ords: &[usize],
) -> Vec<MdItem> {
let mut items = Vec::new();
let mut k = 0usize;
for (li, line) in lines.iter().enumerate() {
for span in &line.spans {
if is_link_span(span) {
let target = targets.get(k).cloned().unwrap_or_default();
items.push(MdItem {
line: li,
kind: MdItemKind::Link { target },
});
k += 1;
} else if crate::preview::markdown::is_task_span(span) {
if let Some(state) =
crate::preview::markdown::task_span_state(span.content.as_ref())
{
items.push(MdItem {
line: li,
kind: MdItemKind::Task { state },
});
}
} else if crate::preview::markdown::is_code_header_span(span) {
items.push(MdItem {
line: li,
kind: MdItemKind::CodeBlock,
});
} else if crate::preview::markdown::is_mermaid_header_span(span) {
let seen = items
.iter()
.filter(|it| matches!(it.kind, MdItemKind::MermaidFence { .. }))
.count();
let ordinal = fence_ords.get(seen).copied().unwrap_or(seen);
items.push(MdItem {
line: li,
kind: MdItemKind::MermaidFence { ordinal },
});
} else if crate::preview::markdown::is_details_header_span(span) {
let ordinal = items
.iter()
.filter(|it| matches!(it.kind, MdItemKind::Details { .. }))
.count();
items.push(MdItem {
line: li,
kind: MdItemKind::Details { ordinal },
});
}
}
}
items
}
fn invert_focused_line(line: &Line<'static>, ordinal: usize, whole_line: bool) -> Line<'static> {
use ratatui::style::Modifier;
let style = line.style;
let mut seen = 0usize;
let spans = line
.spans
.iter()
.cloned()
.map(|mut span| {
if whole_line {
span.style = span.style.add_modifier(Modifier::REVERSED);
} else if is_link_span(&span)
|| crate::preview::markdown::is_task_span(&span)
|| crate::preview::markdown::is_code_header_span(&span)
|| crate::preview::markdown::is_mermaid_header_span(&span)
|| crate::preview::markdown::is_details_header_span(&span)
{
if seen == ordinal {
span.style = span.style.add_modifier(Modifier::REVERSED);
}
seen += 1;
}
span
})
.collect::<Vec<_>>();
Line::from(spans).style(style)
}
fn collapse_links(lines: Vec<Line<'static>>, icons: bool) -> (Vec<Line<'static>>, Vec<String>) {
use ratatui::style::{Color, Modifier, Style};
let link_style = Style::new()
.fg(Color::Blue)
.add_modifier(Modifier::UNDERLINED);
let mut out = Vec::with_capacity(lines.len());
let mut targets = Vec::new();
for line in lines {
let style = line.style;
let spans = line.spans;
let n = spans.len();
let mut new: Vec<Span<'static>> = Vec::with_capacity(n);
let mut i = 0;
while i < n {
if i + 1 < n
&& is_link_span(&spans[i])
&& crate::preview::markdown::is_hidden_link_target(&spans[i + 1])
{
targets.push(spans[i + 1].content.to_string());
new.push(spans[i].clone());
i += 2;
continue;
}
let is_link_pattern = i + 2 < n
&& spans[i + 1].content.as_ref() == " ("
&& is_link_span(&spans[i + 2])
&& spans.get(i + 3).is_some_and(|s| s.content.starts_with(')'));
if is_link_pattern {
let label = spans[i].content.as_ref();
targets.push(spans[i + 2].content.to_string());
let text = if icons {
format!("{} {label}", crate::ui::icons::link_icon())
} else {
label.to_string()
};
new.push(Span::styled(text, link_style));
let closer = spans[i + 3].content.as_ref();
if closer.len() > 1 {
new.push(Span::styled(closer[1..].to_string(), spans[i + 3].style));
}
i += 4;
} else {
new.push(spans[i].clone());
i += 1;
}
}
out.push(Line::from(new).style(style));
}
(out, targets)
}
fn trim_url_end(s: &str) -> &str {
let mut end = s.len();
loop {
let sub = &s[..end];
let Some(last) = sub.chars().last() else {
break;
};
if "?!.,:*_~'\"".contains(last) {
end -= last.len_utf8();
continue;
}
if last == ')' && sub.matches(')').count() > sub.matches('(').count() {
end -= 1;
continue;
}
break;
}
&s[..end]
}
fn is_url_stop(c: char) -> bool {
c.is_whitespace()
|| c == '<'
|| matches!(
c,
'、' | '。'
| ','
| '.'
| '!'
| '?'
| ';'
| ':'
| '('
| ')'
| '「'
| '」'
| '『'
| '』'
| '【'
| '】'
| '〈'
| '〉'
| '《'
| '》'
| '…'
| '・'
| '〜'
| '”'
| '“'
)
}
fn match_bare_url(rest: &str) -> Option<(usize, String)> {
let is_www = rest.starts_with("www.");
if !(is_www || rest.starts_with("http://") || rest.starts_with("https://")) {
return None;
}
let run_end = rest.find(is_url_stop).unwrap_or(rest.len());
let run = trim_url_end(&rest[..run_end]);
let valid = if is_www {
run.len() > 4 && run[4..].contains(|c: char| c.is_ascii_alphanumeric())
} else {
run.find("://").is_some_and(|p| run.len() > p + 3)
};
if !valid {
return None;
}
let target = if is_www {
format!("http://{run}")
} else {
run.to_string()
};
Some((run.len(), target))
}
fn is_email_local(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '%' | '+' | '-')
}
fn is_email_domain(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c, '.' | '-')
}
fn match_bare_email(rest: &str) -> Option<(usize, String)> {
let local_len: usize = rest
.chars()
.take_while(|&c| is_email_local(c))
.map(char::len_utf8)
.sum();
if local_len == 0 || !rest[local_len..].starts_with('@') {
return None;
}
let after_at = &rest[local_len + 1..];
let mut dom_len: usize = after_at
.chars()
.take_while(|&c| is_email_domain(c))
.map(char::len_utf8)
.sum();
while dom_len > 0 && matches!(after_at.as_bytes()[dom_len - 1], b'.' | b'-') {
dom_len -= 1;
}
let domain = &after_at[..dom_len];
let tld_ok = domain
.rsplit_once('.')
.is_some_and(|(_, tld)| tld.len() >= 2 && tld.chars().all(|c| c.is_ascii_alphabetic()));
if !tld_ok {
return None;
}
let end = local_len + 1 + dom_len;
Some((end, format!("mailto:{}", &rest[..end])))
}
fn find_bare_links(text: &str) -> Vec<(usize, usize, String)> {
let bytes = text.as_bytes();
let n = bytes.len();
let mut out: Vec<(usize, usize, String)> = Vec::new();
let mut i = 0usize;
while i < n {
let boundary_ok = i == 0 || !bytes[i - 1].is_ascii_alphanumeric();
if boundary_ok {
let rest = &text[i..];
if let Some((len, target)) = match_bare_url(rest).or_else(|| match_bare_email(rest)) {
out.push((i, i + len, target));
i += len.max(1);
continue;
}
}
i += text[i..].chars().next().map_or(1, char::len_utf8);
}
out
}
fn autolink_bare_urls(
lines: Vec<Line<'static>>,
in_targets: Vec<String>,
) -> (Vec<Line<'static>>, Vec<String>) {
use ratatui::style::{Color, Modifier, Style};
let link_style = Style::new()
.fg(Color::Blue)
.add_modifier(Modifier::UNDERLINED);
let mut out_lines = Vec::with_capacity(lines.len());
let mut out_targets = Vec::new();
let mut ti = 0usize;
for line in lines {
if crate::preview::markdown::is_code_line(&line) {
for span in &line.spans {
if is_link_span(span) {
out_targets.push(in_targets.get(ti).cloned().unwrap_or_default());
ti += 1;
}
}
out_lines.push(line);
continue;
}
let style = line.style;
let mut new: Vec<Span<'static>> = Vec::with_capacity(line.spans.len());
for span in line.spans {
if is_link_span(&span) {
out_targets.push(in_targets.get(ti).cloned().unwrap_or_default());
ti += 1;
new.push(span);
continue;
}
if span.style.bg.is_some()
|| crate::preview::markdown::is_inline_code_span(&span)
|| crate::preview::markdown::is_task_span(&span)
|| crate::preview::markdown::is_code_header_span(&span)
|| crate::preview::markdown::is_mermaid_header_span(&span)
|| crate::preview::markdown::is_hidden_link_target(&span)
{
new.push(span);
continue;
}
let text = span.content.into_owned();
let matches = find_bare_links(&text);
if matches.is_empty() {
new.push(Span::styled(text, span.style));
continue;
}
let mut pos = 0usize;
for (s, e, url) in matches {
if s > pos {
new.push(Span::styled(text[pos..s].to_string(), span.style));
}
new.push(Span::styled(text[s..e].to_string(), link_style));
out_targets.push(url);
pos = e;
}
if pos < text.len() {
new.push(Span::styled(text[pos..].to_string(), span.style));
}
}
out_lines.push(Line::from(new).style(style));
}
(out_lines, out_targets)
}
fn replace_emoji_shortcodes(text: &str) -> Option<String> {
if !text.contains(':') {
return None;
}
let mut out = String::new();
let mut changed = false;
let mut rest = text;
while let Some(start) = rest.find(':') {
out.push_str(&rest[..start]);
let after = &rest[start + 1..];
if let Some(end) = after.find(':') {
let code = &after[..end];
let looks_like_code = !code.is_empty()
&& code
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '+' | '-'));
if looks_like_code {
if let Some(e) = emojis::get_by_shortcode(code) {
out.push_str(e.as_str());
changed = true;
rest = &after[end + 1..];
continue;
}
}
out.push(':');
rest = after;
} else {
out.push(':');
out.push_str(after);
rest = "";
}
}
out.push_str(rest);
changed.then_some(out)
}
fn substitute_emoji(lines: Vec<Line<'static>>) -> Vec<Line<'static>> {
lines
.into_iter()
.map(|line| {
if crate::preview::markdown::is_code_line(&line) {
return line;
}
let style = line.style;
let spans = line
.spans
.into_iter()
.map(|span| {
if span.style.bg.is_some()
|| is_link_span(&span)
|| crate::preview::markdown::is_inline_code_span(&span)
|| crate::preview::markdown::is_task_span(&span)
|| crate::preview::markdown::is_code_header_span(&span)
|| crate::preview::markdown::is_mermaid_header_span(&span)
|| crate::preview::markdown::is_hidden_link_target(&span)
{
return span;
}
match replace_emoji_shortcodes(span.content.as_ref()) {
Some(new) => Span::styled(new, span.style),
None => span,
}
})
.collect::<Vec<_>>();
Line::from(spans).style(style)
})
.collect()
}
fn highlight_query_in_line(
line: Line<'static>,
query: &str,
current_occurrence: Option<usize>,
) -> Line<'static> {
use ratatui::style::{Color, Modifier};
if query.is_empty() {
return line;
}
let full: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
let lower = full.to_lowercase();
let q = query.to_lowercase();
if lower.len() != full.len() {
return line; }
let mut ranges: Vec<(usize, usize)> = Vec::new();
let mut i = 0;
while let Some(rel) = lower[i..].find(&q) {
let s = i + rel;
let e = s + q.len();
ranges.push((s, e));
i = e;
}
if ranges.is_empty() {
return line;
}
let style = line.style;
let mut out: Vec<Span<'static>> = Vec::new();
let mut pos = 0usize; for span in line.spans {
let text = span.content.into_owned();
let span_start = pos;
let span_end = pos + text.len();
let mut points = vec![span_start, span_end];
for (s, e) in &ranges {
if *s > span_start && *s < span_end {
points.push(*s);
}
if *e > span_start && *e < span_end {
points.push(*e);
}
}
points.sort_unstable();
points.dedup();
for w in points.windows(2) {
let (a, b) = (w[0], w[1]);
if let Some(seg) = text.get(a - span_start..b - span_start) {
if seg.is_empty() {
continue;
}
let hit = ranges.iter().position(|(s, e)| a >= *s && b <= *e);
let st = if let Some(idx) = hit {
let bg = if current_occurrence == Some(idx) {
Color::Rgb(0xff, 0x8c, 0x00) } else {
Color::Yellow };
span.style
.bg(bg)
.fg(Color::Black)
.add_modifier(Modifier::BOLD)
} else {
span.style
};
out.push(Span::styled(seg.to_string(), st));
}
}
pos = span_end;
}
Line::from(out).style(style)
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum GutterMark {
Added,
Modified,
Deleted,
}
const GUTTER_ADDED: ratatui::style::Color = ratatui::style::Color::Rgb(87, 171, 90);
const GUTTER_MODIFIED: ratatui::style::Color = ratatui::style::Color::Rgb(216, 166, 74);
const GUTTER_DELETED: ratatui::style::Color = ratatui::style::Color::Rgb(199, 84, 80);
fn gutter_marks(diff: &[crate::git::DiffLine]) -> std::collections::HashMap<u32, GutterMark> {
use crate::git::DiffLineKind;
let mut marks = std::collections::HashMap::new();
let mut i = 0;
while i < diff.len() {
if diff[i].kind == DiffLineKind::Context {
i += 1;
continue;
}
let start = i;
let mut removed = 0usize;
let mut added: Vec<u32> = Vec::new();
while i < diff.len() && diff[i].kind != DiffLineKind::Context {
match diff[i].kind {
DiffLineKind::Removed => removed += 1,
DiffLineKind::Added => {
if let Some(n) = diff[i].new_no {
added.push(n);
}
}
DiffLineKind::Context => {}
}
i += 1;
}
if !added.is_empty() {
let mark = if removed > 0 {
GutterMark::Modified
} else {
GutterMark::Added
};
for n in added {
marks.insert(n, mark);
}
} else if removed > 0 {
let anchor = diff
.get(i)
.and_then(|d| d.new_no)
.or_else(|| diff[..start].iter().rev().find_map(|d| d.new_no));
if let Some(a) = anchor {
marks.entry(a).or_insert(GutterMark::Deleted);
}
}
}
marks
}
fn with_git_gutter(
lines: Vec<Line<'static>>,
top_line: usize,
marks: &std::collections::HashMap<u32, GutterMark>,
) -> Vec<Line<'static>> {
use ratatui::style::{Color, Style};
if marks.is_empty() {
return lines;
}
lines
.into_iter()
.enumerate()
.map(|(i, line)| {
let n = (top_line + i + 1) as u32;
let (glyph, color) = match marks.get(&n) {
Some(GutterMark::Added) => ("▌", GUTTER_ADDED),
Some(GutterMark::Modified) => ("▌", GUTTER_MODIFIED),
Some(GutterMark::Deleted) => ("▔", GUTTER_DELETED),
None => (" ", Color::Reset),
};
let style = line.style;
let mut spans = Vec::with_capacity(line.spans.len() + 1);
spans.push(Span::styled(glyph, Style::new().fg(color)));
spans.extend(line.spans);
Line::from(spans).style(style)
})
.collect()
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PreviewSelection {
None,
Char {
start: (usize, usize),
end: (usize, usize),
},
Line { lo: usize, hi: usize },
}
fn selection_char_text(lines: &[&str], start: (usize, usize), end: (usize, usize)) -> String {
let (sl, sc) = start;
let (el, ec) = end;
if sl >= lines.len() {
return String::new();
}
let el = el.min(lines.len() - 1);
if sl == el {
lines[sl]
.chars()
.skip(sc)
.take((ec + 1).saturating_sub(sc))
.collect()
} else {
let mut out = String::new();
out.push_str(&lines[sl].chars().skip(sc).collect::<String>());
for l in &lines[sl + 1..el] {
out.push('\n');
out.push_str(l);
}
out.push('\n');
out.push_str(&lines[el].chars().take(ec + 1).collect::<String>());
out
}
}
fn char_range_for_line(
abs: usize,
start: (usize, usize),
end: (usize, usize),
) -> Option<(usize, usize)> {
if abs < start.0 || abs > end.0 {
return None;
}
let lo = if abs == start.0 { start.1 } else { 0 };
let hi = if abs == end.0 {
end.1.saturating_add(1)
} else {
usize::MAX
};
Some((lo, hi))
}
fn apply_preview_caret(
lines: Vec<Line<'static>>,
top_line: usize,
cursor_line: usize,
cursor_col: usize,
sel: PreviewSelection,
) -> Vec<Line<'static>> {
use ratatui::style::Color;
const CURSOR_BG: Color = Color::Rgb(55, 60, 74);
const SEL_BG: Color = Color::Rgb(40, 66, 104);
lines
.into_iter()
.enumerate()
.map(|(i, line)| {
let abs = top_line + i;
let caret = if abs == cursor_line {
Some(cursor_col)
} else {
None
};
let (range, whole): (Option<(usize, usize, Color)>, bool) = match sel {
PreviewSelection::None => {
if abs == cursor_line {
(Some((0, usize::MAX, CURSOR_BG)), true)
} else {
(None, false)
}
}
PreviewSelection::Line { lo, hi } => {
if abs >= lo && abs <= hi {
(Some((0, usize::MAX, SEL_BG)), true)
} else {
(None, false)
}
}
PreviewSelection::Char { start, end } => match char_range_for_line(abs, start, end)
{
Some((lo, hi)) => (Some((lo, hi, SEL_BG)), lo == 0 && hi == usize::MAX),
None => (None, false),
},
};
if range.is_none() && caret.is_none() {
return line;
}
restyle_line(line, range, whole, caret)
})
.collect()
}
fn restyle_line(
line: Line<'static>,
range: Option<(usize, usize, ratatui::style::Color)>,
whole: bool,
caret: Option<usize>,
) -> Line<'static> {
use ratatui::style::Modifier;
let base = line.style;
let mut out: Vec<Span<'static>> = Vec::new();
let mut idx = 0usize; let mut cur = String::new();
let mut cur_style: Option<ratatui::style::Style> = None;
for span in line.spans.into_iter() {
for ch in span.content.chars() {
let mut style = span.style;
if let Some((lo, hi, color)) = range {
if idx >= lo && idx < hi {
style = style.bg(color);
}
}
if caret == Some(idx) {
style = style.add_modifier(Modifier::REVERSED);
}
if cur_style != Some(style) {
if !cur.is_empty() {
out.push(Span::styled(std::mem::take(&mut cur), cur_style.unwrap()));
}
cur_style = Some(style);
}
cur.push(ch);
idx += 1;
}
}
if !cur.is_empty() {
out.push(Span::styled(cur, cur_style.unwrap_or(base)));
}
let mut line = Line::from(out);
line.style = if whole {
if let Some((_, _, color)) = range {
base.bg(color)
} else {
base
}
} else {
base
};
line
}
fn with_line_numbers(lines: Vec<Line<'static>>, top_line: usize) -> Vec<Line<'static>> {
use ratatui::style::{Color, Style};
let last = top_line + lines.len().max(1);
let width = last.to_string().len().max(3); lines
.into_iter()
.enumerate()
.map(|(i, line)| {
let n = top_line + i + 1;
let gutter = Span::styled(format!("{n:>width$} "), Style::new().fg(Color::DarkGray));
let style = line.style;
let mut spans = Vec::with_capacity(line.spans.len() + 1);
spans.push(gutter);
spans.extend(line.spans);
Line::from(spans).style(style)
})
.collect()
}
fn centered_rect(cells: (u16, u16), inner: Rect, allow_upscale: bool) -> Rect {
let (cw, ch) = cells;
if cw == 0 || ch == 0 || inner.width == 0 || inner.height == 0 {
return inner;
}
let mut scale = (inner.width as f64 / cw as f64).min(inner.height as f64 / ch as f64);
if !allow_upscale {
scale = scale.min(1.0);
}
let w = ((cw as f64 * scale).round() as u16).clamp(1, inner.width);
let h = ((ch as f64 * scale).round() as u16).clamp(1, inner.height);
let x = inner.x + (inner.width - w) / 2;
let y = inner.y + (inner.height - h) / 2;
Rect {
x,
y,
width: w,
height: h,
}
}
const MD_IMAGE_MAX_ROWS: u16 = 24;
fn resolve_md_image_path(url: &str, base: Option<&Path>) -> Option<PathBuf> {
let u = url.trim();
if u.is_empty() {
return None;
}
if crate::preview::markdown::is_remote_image_url(u) {
let p = md_remote_cache_path(u)?;
return p.is_file().then_some(p);
}
let lower = u.to_ascii_lowercase();
if lower.starts_with("data:") {
return None;
}
let u = u.strip_prefix("file://").unwrap_or(u);
let p = PathBuf::from(u);
let p = if p.is_absolute() {
p
} else if let Some(b) = base {
b.join(p)
} else {
p
};
p.is_file().then_some(p)
}
fn md_image_dims(path: &Path) -> Option<(u32, u32)> {
crate::preview::image::dimensions(path).or_else(|| crate::preview::svg::intrinsic_size(path))
}
fn md_decode_image(path: &Path, svg_max_px: u32) -> Option<image::DynamicImage> {
crate::preview::image::decode_static(path)
.or_else(|| crate::preview::svg::rasterize(path, svg_max_px))
}
fn md_band_pixels(full_rows: u16, row_off: u16, vis_rows: u16, dh: u32) -> (u32, u32) {
let fr = full_rows.max(1) as u32;
let y0 = (row_off as u32 * dh) / fr;
let y1 = ((row_off as u32 + vis_rows as u32).min(fr) * dh) / fr;
(y0, y1.saturating_sub(y0).max(1))
}
fn cache_root() -> Option<PathBuf> {
if let Some(x) = std::env::var_os("XDG_CACHE_HOME") {
if !x.is_empty() {
return Some(PathBuf::from(x));
}
}
let home = std::env::var_os("HOME")?;
if home.is_empty() {
return None;
}
Some(PathBuf::from(home).join(".cache"))
}
fn md_remote_cache_path(url: &str) -> Option<PathBuf> {
use std::hash::{Hash, Hasher};
let root = cache_root()?;
let mut hasher = std::collections::hash_map::DefaultHasher::new();
url.trim().hash(&mut hasher);
Some(
root.join("konoma")
.join("remote-images")
.join(format!("{:016x}", hasher.finish())),
)
}
const MD_REMOTE_MAX_BYTES: u64 = 25 * 1024 * 1024;
fn fetch_remote_image(url: &str, dest: &Path) -> bool {
use std::process::{Command, Stdio};
let Some(parent) = dest.parent() else {
return false;
};
if std::fs::create_dir_all(parent).is_err() {
return false;
}
let tmp = dest.with_extension("part");
let status = Command::new("curl")
.args([
"-sSL", "--fail",
"--max-time",
"20",
"--max-filesize",
&MD_REMOTE_MAX_BYTES.to_string(),
"-A",
"konoma image preview",
"-o",
])
.arg(&tmp)
.arg(url)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
let downloaded = matches!(status, Ok(s) if s.success());
if !downloaded {
let _ = std::fs::remove_file(&tmp);
return false;
}
if md_image_dims(&tmp).is_none() {
let _ = std::fs::remove_file(&tmp);
return false;
}
let ok = std::fs::rename(&tmp, dest).is_ok();
if ok {
prune_remote_cache(parent, MD_REMOTE_CACHE_MAX);
}
ok
}
fn prune_remote_cache(dir: &Path, keep: usize) {
let Ok(rd) = std::fs::read_dir(dir) else {
return;
};
let mut files: Vec<(std::time::SystemTime, PathBuf)> = rd
.flatten()
.filter(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false))
.filter_map(|e| {
let p = e.path();
if p.extension().is_some_and(|x| x == "part") {
return None;
}
let mtime = e.metadata().and_then(|m| m.modified()).ok()?;
Some((mtime, p))
})
.collect();
if files.len() <= keep {
return;
}
files.sort_by_key(|(t, _)| *t); for (_, p) in files.iter().take(files.len() - keep) {
let _ = std::fs::remove_file(p);
}
}
const MD_REMOTE_CACHE_MAX: usize = 256;
fn md_image_cells(
pw: u32,
ph: u32,
fw: u16,
fh: u16,
avail_cols: u16,
max_rows: u16,
) -> (u16, u16) {
let fw = (fw.max(1)) as f64;
let fh = (fh.max(1)) as f64;
let nat_cols = (pw as f64 / fw).ceil().max(1.0);
let nat_rows = (ph as f64 / fh).ceil().max(1.0);
let avail = avail_cols.max(1) as f64;
let (mut cols, mut rows) = if nat_cols <= avail {
(nat_cols, nat_rows)
} else {
let s = avail / nat_cols;
(avail, (nat_rows * s).round().max(1.0))
};
let maxr = max_rows.max(1) as f64;
if rows > maxr {
let s = maxr / rows;
rows = maxr;
cols = (cols * s).round().max(1.0);
}
(cols as u16, rows as u16)
}
fn mermaid_cells(pw: u32, ph: u32, fw: u16, fh: u16, avail: u16, target_rows: u16) -> (u16, u16) {
let (fw, fh) = (fw.max(1) as f64, fh.max(1) as f64);
let ar = (pw.max(1) as f64) / (ph.max(1) as f64); let mut rows = target_rows.max(1) as f64;
let mut cols = (rows * fh * ar / fw).round().max(1.0);
let avail = avail.max(1) as f64;
if cols > avail {
cols = avail;
rows = (cols * fw / ar / fh).round().max(1.0);
}
(cols as u16, rows as u16)
}
fn math_cells(uw: u32, uh: u32, fw: u16, fh: u16, avail: u16, display: bool) -> (u16, u16) {
let (fw, fh) = (fw.max(1) as f64, fh.max(1) as f64);
let em_h = (uh.max(1) as f64) / 40.0;
let rows_per_em = if display { 1.5 } else { 1.3 };
let mut rows = (em_h * rows_per_em).round().max(1.0);
let ar = (uw.max(1) as f64) / (uh.max(1) as f64);
let mut cols = (rows * fh * ar / fw).round().max(1.0);
let avail = avail.max(1) as f64;
if cols > avail {
cols = avail;
rows = (cols * fw / ar / fh).round().max(1.0);
}
let maxr = 24.0;
if rows > maxr {
let s = maxr / rows;
rows = maxr;
cols = (cols * s).round().max(1.0);
}
(cols as u16, rows as u16)
}
type PxRect = (u32, u32, u32, u32);
fn fence_crop(dims: (u32, u32), f: f64, center: (f64, f64)) -> (PxRect, (f64, f64)) {
let (sw, sh) = dims;
let f = f.clamp(0.0, 1.0);
let cx = center.0.clamp(f / 2.0, 1.0 - f / 2.0);
let cy = center.1.clamp(f / 2.0, 1.0 - f / 2.0);
let cw = ((sw as f64 * f).round() as u32).clamp(1, sw);
let ch = ((sh as f64 * f).round() as u32).clamp(1, sh);
let x0 = ((cx * sw as f64) as i64 - cw as i64 / 2).clamp(0, (sw - cw) as i64) as u32;
let y0 = ((cy * sh as f64) as i64 - ch as i64 / 2).clamp(0, (sh - ch) as i64) as u32;
((x0, y0, cw, ch), (cx, cy))
}
#[allow(clippy::type_complexity)]
fn image_layout(
src: &image::DynamicImage,
font_size: ratatui_image::FontSize,
zoom: f64,
center: (f64, f64),
inner: Rect,
render_scale: f64,
logical: Option<(u32, u32)>,
) -> Option<(Rect, PxRect, (f64, f64), (f64, f64))> {
use image::GenericImageView;
let (sw, sh) = src.dimensions();
if sw == 0 || sh == 0 || inner.width == 0 || inner.height == 0 {
return None;
}
let (nw, nh) = match logical {
Some((lw, lh)) if lw > 0 && lh > 0 => (
(lw as u16).div_ceil(font_size.width.max(1)),
(lh as u16).div_ceil(font_size.height.max(1)),
),
_ => {
let n = Resize::natural_size(src, font_size);
(n.width, n.height)
}
};
let base = centered_rect((nw, nh), inner, false);
let disp_w = (base.width as f64 * zoom).max(1.0);
let disp_h = (base.height as f64 * zoom).max(1.0);
let tw = (disp_w.min(inner.width as f64).round() as u16).max(1);
let th = (disp_h.min(inner.height as f64).round() as u16).max(1);
let fw = (tw as f64 / disp_w).min(1.0);
let fh = (th as f64 / disp_h).min(1.0);
let cx = center.0.clamp(fw / 2.0, 1.0 - fw / 2.0);
let cy = center.1.clamp(fh / 2.0, 1.0 - fh / 2.0);
let cw = ((sw as f64 * fw).round() as u32).clamp(1, sw);
let ch = ((sh as f64 * fh).round() as u32).clamp(1, sh);
let x0 = ((cx * sw as f64) as i64 - cw as i64 / 2).clamp(0, (sw - cw) as i64) as u32;
let y0 = ((cy * sh as f64) as i64 - ch as i64 / 2).clamp(0, (sh - ch) as i64) as u32;
let crop_rect = (x0, y0, cw, ch);
let scale = render_scale.clamp(0.1, 1.0);
let (tw, th) = if scale >= 0.999 {
(tw, th)
} else {
(
((tw as f64 * scale).round() as u16).max(1),
((th as f64 * scale).round() as u16).max(1),
)
};
let target = Rect {
x: inner.x + (inner.width - tw.min(inner.width)) / 2,
y: inner.y + (inner.height - th.min(inner.height)) / 2,
width: tw.min(inner.width),
height: th.min(inner.height),
};
Some((target, crop_rect, (cx, cy), (fw, fh)))
}
fn file_mtime(path: &Path) -> Option<std::time::SystemTime> {
std::fs::metadata(path).and_then(|m| m.modified()).ok()
}
fn copy_text(path: &Path, open_dir: &Path, kind: CopyKind) -> String {
match kind {
CopyKind::Name => path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default(),
CopyKind::Full => path.display().to_string(),
CopyKind::Parent => path
.parent()
.map(|p| p.display().to_string())
.unwrap_or_default(),
CopyKind::Relative => rel_to_open(open_dir, path),
CopyKind::AtRef => at_ref_text(open_dir, path),
}
}
fn at_ref_text(open_dir: &Path, path: &Path) -> String {
let rel = match path.strip_prefix(open_dir) {
Ok(r) if !r.as_os_str().is_empty() => r.display().to_string(),
_ => match rel_from(open_dir, path) {
Some(r) if !r.as_os_str().is_empty() => r.display().to_string(),
_ => path.display().to_string(),
},
};
format!("@{rel}")
}
fn rel_to_open(open_dir: &Path, path: &Path) -> String {
if path.starts_with(open_dir) {
let base = open_dir.parent().unwrap_or(open_dir);
match path.strip_prefix(base) {
Ok(rel) if !rel.as_os_str().is_empty() => rel.display().to_string(),
_ => home_relative(path),
}
} else {
match rel_from(open_dir, path) {
Some(rel) if !rel.as_os_str().is_empty() => rel.display().to_string(),
_ => home_relative(path),
}
}
}
fn set_clipboard(text: &str) -> Result<()> {
let mut cb = arboard::Clipboard::new()?;
cb.set_text(text.to_string())?;
Ok(())
}
pub(crate) fn home_relative(path: &Path) -> String {
if let Some(home) = std::env::var_os("HOME") {
let home = PathBuf::from(home);
if let Ok(rel) = path.strip_prefix(&home) {
if rel.as_os_str().is_empty() {
return "~".to_string();
}
return format!("~/{}", rel.display());
}
}
path.display().to_string()
}
fn parse_dropped_paths(text: &str) -> Vec<PathBuf> {
let mut tokens: Vec<String> = Vec::new();
let mut cur = String::new();
let mut escaped = false;
for c in text.chars() {
if escaped {
cur.push(c); escaped = false;
} else if c == '\\' {
escaped = true;
} else if c.is_whitespace() {
if !cur.is_empty() {
tokens.push(std::mem::take(&mut cur));
}
} else {
cur.push(c);
}
}
if !cur.is_empty() {
tokens.push(cur);
}
tokens
.into_iter()
.map(PathBuf::from)
.filter(|p| p.exists()) .collect()
}
fn build_rename_plan(targets: &[PathBuf], template: &str) -> Result<Vec<(PathBuf, PathBuf)>> {
let mut plan: Vec<(PathBuf, PathBuf)> = Vec::with_capacity(targets.len());
for (idx, src) in targets.iter().enumerate() {
let n = idx + 1;
let stem = src.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let ext = src.extension().and_then(|s| s.to_str()).unwrap_or("");
let rendered = crate::fileops::render_rename_template(template, n, stem, ext);
let rendered = rendered.trim().to_string();
if rendered.is_empty() {
anyhow::bail!("空の名前になります (n={n})");
}
if rendered.contains('/') {
anyhow::bail!("名前に / は使えません: {rendered}");
}
let has_ext = Path::new(&rendered)
.extension()
.filter(|e| !e.is_empty())
.is_some();
let new_name = if !has_ext && !ext.is_empty() {
format!("{rendered}.{ext}")
} else {
rendered
};
let dst = src
.parent()
.unwrap_or_else(|| Path::new("."))
.join(&new_name);
plan.push((src.clone(), dst));
}
let mut seen = BTreeSet::new();
for (_, dst) in &plan {
if !seen.insert(dst.clone()) {
anyhow::bail!("リネーム先が重複: {}", dst.display());
}
}
let vacated: BTreeSet<PathBuf> = plan
.iter()
.filter(|(s, d)| s != d)
.map(|(s, _)| s.clone())
.collect();
for (src, dst) in &plan {
if src == dst {
continue;
}
if dst.exists() && !vacated.contains(dst) {
anyhow::bail!("既に存在します: {}", dst.display());
}
}
Ok(plan)
}
fn char_byte(s: &str, char_idx: usize) -> usize {
s.char_indices()
.nth(char_idx)
.map(|(b, _)| b)
.unwrap_or(s.len())
}
fn rel_from(base: &Path, target: &Path) -> Option<PathBuf> {
use std::path::Component;
let bc: Vec<Component> = base.components().collect();
let tc: Vec<Component> = target.components().collect();
let mut i = 0;
while i < bc.len() && i < tc.len() && bc[i] == tc[i] {
i += 1;
}
if i == 0 {
let is_root = |c: Option<&Component>| {
matches!(c, Some(Component::Prefix(_)) | Some(Component::RootDir))
};
if is_root(bc.first()) || is_root(tc.first()) {
return None;
}
}
let mut rel = PathBuf::new();
for _ in i..bc.len() {
rel.push("..");
}
for c in &tc[i..] {
rel.push(c.as_os_str());
}
Some(rel)
}
struct ChildMeta {
path: PathBuf,
name_lower: String,
ext_lower: String,
is_dir: bool,
size: u64,
mtime: Option<std::time::SystemTime>,
}
fn child_meta(path: PathBuf, ft: Option<std::fs::FileType>, sort: Sort) -> ChildMeta {
let need_stat = matches!(sort.key, SortKey::Size | SortKey::Modified);
let is_symlink = ft.map(|t| t.is_symlink()).unwrap_or(true);
let (is_dir, size, mtime) = if need_stat || is_symlink {
let md = std::fs::metadata(&path).ok();
(
md.as_ref().map(|m| m.is_dir()).unwrap_or(false),
md.as_ref().map(|m| m.len()).unwrap_or(0),
md.as_ref().and_then(|m| m.modified().ok()),
)
} else {
(ft.map(|t| t.is_dir()).unwrap_or(false), 0, None)
};
let name_lower = path
.file_name()
.map(|s| s.to_string_lossy().to_lowercase())
.unwrap_or_default();
let ext_lower = if matches!(sort.key, SortKey::Ext) {
path.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase()
} else {
String::new()
};
ChildMeta {
path,
name_lower,
ext_lower,
is_dir,
size,
mtime,
}
}
fn sort_cmp(a: &ChildMeta, b: &ChildMeta, sort: Sort) -> std::cmp::Ordering {
use std::cmp::Ordering;
if sort.dirs_first {
let d = b.is_dir.cmp(&a.is_dir); if d != Ordering::Equal {
return d;
}
}
let mut ord = match sort.key {
SortKey::Name => a.name_lower.cmp(&b.name_lower),
SortKey::Size => a.size.cmp(&b.size),
SortKey::Modified => a.mtime.cmp(&b.mtime),
SortKey::Ext => a.ext_lower.cmp(&b.ext_lower),
};
if sort.reverse {
ord = ord.reverse();
}
ord.then_with(|| a.name_lower.cmp(&b.name_lower))
}
#[cfg_attr(not(feature = "git"), allow(dead_code))]
fn base_label_from(refs: &str, short: &str) -> String {
for part in refs.split(',').map(str::trim) {
let p = part.strip_prefix("HEAD -> ").unwrap_or(part);
if p.is_empty() || p.starts_with("tag:") {
continue;
}
return p.to_string();
}
short.to_string()
}
fn build_dir(
dir: &Path,
depth: usize,
expanded_dirs: &std::collections::HashSet<PathBuf>,
show_hidden: bool,
sort: Sort,
out: &mut Vec<Entry>,
) -> Result<()> {
let mut children: Vec<ChildMeta> = std::fs::read_dir(dir)?
.filter_map(|r| r.ok())
.map(|e| {
let ft = e.file_type().ok();
(e.path(), ft)
})
.filter(|(p, _)| {
if show_hidden {
return true;
}
!p.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with('.'))
.unwrap_or(false)
})
.map(|(p, ft)| child_meta(p, ft, sort))
.collect();
children.sort_by(|a, b| sort_cmp(a, b, sort));
for c in children {
let expanded = c.is_dir && expanded_dirs.contains(&c.path);
out.push(Entry {
path: c.path.clone(),
is_dir: c.is_dir,
depth,
expanded,
});
if expanded {
build_dir(&c.path, depth + 1, expanded_dirs, show_hidden, sort, out)?;
}
}
Ok(())
}
fn collect_all(root: &Path, show_hidden: bool) -> Vec<Entry> {
const CAP: usize = 50_000;
let mut out = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
if out.len() >= CAP {
break;
}
let Ok(rd) = std::fs::read_dir(&dir) else {
continue;
};
for path in rd.filter_map(|r| r.ok()).map(|e| e.path()) {
if out.len() >= CAP {
break;
}
let hidden = path
.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with('.'))
.unwrap_or(false);
if hidden && !show_hidden {
continue;
}
let is_symlink = std::fs::symlink_metadata(&path)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false);
let is_dir = path.is_dir();
out.push(Entry {
path: path.clone(),
is_dir,
depth: 0,
expanded: false,
});
if is_dir && !is_symlink {
stack.push(path);
}
}
}
out.sort_by(|a, b| a.path.cmp(&b.path));
out
}
#[cfg(test)]
mod tests;