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;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
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,
Info,
Create,
Rename,
BatchRename,
RenamePreview,
DeleteConfirm,
DropConfirm,
QuitConfirm,
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,
}
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)]
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,
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,
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,
}
pub enum MediaPayload {
Static(image::DynamicImage),
Gif(Vec<(image::DynamicImage, std::time::Duration)>),
}
pub struct MediaResult {
gen: u64,
payload: Option<MediaPayload>,
}
enum MediaJob {
Svg(PathBuf, u32),
Gif(PathBuf),
Video(PathBuf),
Pdf(PathBuf, u32),
}
impl MediaJob {
fn run(self) -> Option<MediaPayload> {
match self {
MediaJob::Svg(p, max_px) => {
crate::preview::svg::rasterize(&p, max_px).map(MediaPayload::Static)
}
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)
}
}
}
}
pub struct IgnoredResult {
gen: u64,
workdir: PathBuf,
set: std::collections::HashSet<PathBuf>,
}
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,
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,
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>,
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>,
preview_search: Option<String>,
search_input: Option<String>,
search_matches: Vec<(u64, usize, usize)>,
search_idx: usize,
picker: Option<Picker>,
img_tx: Option<UnboundedSender<ResizeRequest>>,
pub image: Option<ThreadProtocol>,
image_src: Option<image::DynamicImage>,
pub image_zoom: f64,
image_center: (f64, f64),
image_crop: Option<(u32, u32, u32, u32)>,
image_vis_frac: (f64, f64),
pdf_page: u32,
pdf_pages: Option<u32>,
preview_media_mtime: Option<std::time::SystemTime>,
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>,
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,
git_status: std::collections::HashMap<PathBuf, crate::git::FileStatus>,
git_ignored: std::collections::HashSet<PathBuf>,
git_status_for: Option<PathBuf>,
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)]
pub enum TableCopyKind {
Cell,
Row,
Column,
}
struct MdCache {
path: PathBuf,
width: u16,
lines: Vec<Line<'static>>,
images: Vec<crate::preview::markdown::ImagePlacement>,
}
struct DiffCache {
path: PathBuf,
lines: Vec<crate::git::DiffLine>,
}
struct GutterCache {
path: PathBuf,
marks: std::collections::HashMap<u32, GutterMark>,
}
struct MdItem {
line: usize,
kind: MdItemKind,
}
enum MdItemKind {
Link { target: String },
Task { state: char },
}
struct WinCache {
path: PathBuf,
byte_top: u64,
height: u16,
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,
}
#[derive(Clone, Copy, PartialEq, Debug)]
enum MdEncodeKey {
Full { cols: u16, rows: u16 },
Clip {
cols: u16,
full_rows: u16,
row_off: u16,
vis_rows: u16,
},
}
pub struct MdEncodeRequest {
path: PathBuf,
key: MdEncodeKey,
image: Arc<image::DynamicImage>,
crop: Option<(u32, u32, u32, u32)>,
cols: u16,
rows: u16,
}
pub struct MdEncodeResult {
path: PathBuf,
key: MdEncodeKey,
protocol: 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 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);
if let Ok(protocol) =
picker.new_protocol(img, size, Resize::Fit(Some(FilterType::Lanczos3)))
{
if tx
.send(MdEncodeResult {
path: req.path,
key: req.key,
protocol,
})
.is_err()
{
break;
}
}
}
}
pub struct MdImageResult {
path: PathBuf,
image: Result<image::DynamicImage, String>,
}
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),
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,
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,
preview_search: None,
search_input: None,
search_matches: Vec::new(),
search_idx: 0,
picker: None,
img_tx: None,
image: None,
image_src: None,
image_zoom: 1.0,
image_center: (0.5, 0.5),
image_crop: None,
image_vis_frac: (1.0, 1.0),
pdf_page: 1,
pdf_pages: None,
preview_media_mtime: 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(),
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,
git_status: std::collections::HashMap::new(),
git_ignored: std::collections::HashSet::new(),
git_status_for: 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 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: Vec<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;
Ok(())
}
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_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_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_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_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(())
}
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.refresh_git_if_needed(); 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::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),
_ => {}
}
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) {
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.md_raw = false; self.md_cache = None; self.md_items.clear();
self.md_image_cache.clear(); self.focused_item = None;
self.preview_search = None;
self.search_input = None;
self.search_matches.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() {
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),
None => self.flash = Some(tr(self.lang, crate::i18n::Msg::NoFileToEdit).into()),
}
}
pub fn take_pending_edit(&mut self) -> Option<PathBuf> {
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 = matches!(
self.preview_kind,
Some(
PreviewKind::Image(_)
| PreviewKind::Svg(_)
| PreviewKind::Video(_)
| PreviewKind::Pdf(_)
)
);
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 is_windowed(&self) -> bool {
self.preview_win.is_some()
}
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 back_to_tree(&mut self) {
self.mode = Mode::Tree;
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.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 clear_image(&mut self) {
self.image = 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.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;
}
pub fn decorated_lines(&mut self, width: u16) -> Vec<Line<'static>> {
let Some(path) = self.preview_path.clone() else {
return Vec::new();
};
let hit = matches!(&self.md_cache, Some(c) if c.path == path && c.width == width);
if !hit {
let (lines, images, remote) = self.build_decorated(&path, width);
for url in &remote {
self.ensure_remote_md_fetch(url);
}
self.md_cache = Some(MdCache {
path: path.clone(),
width,
lines,
images,
});
}
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()
}
fn build_decorated(
&self,
path: &Path,
width: u16,
) -> (
Vec<Line<'static>>,
Vec<crate::preview::markdown::ImagePlacement>,
Vec<String>,
) {
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(),
)
}
};
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 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 (lines, 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,
);
let remote = if font.is_some() {
crate::preview::markdown::collect_remote_image_urls(&src)
} else {
Vec::new()
};
(lines, images, remote)
}
Some(PreviewKind::Mermaid(_)) => (
crate::preview::markdown::render_mermaid_file(&src, width),
Vec::new(),
Vec::new(),
),
Some(PreviewKind::Code(_)) => (
crate::preview::code::highlight(&src, path, &self.cfg.ui.theme.code_theme),
Vec::new(),
Vec::new(),
),
_ => (Vec::new(), Vec::new(), Vec::new()),
}
}
pub fn attach_image_backend(&mut self, picker: Picker, tx: UnboundedSender<ResizeRequest>) {
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_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 {
let entry = self.md_image_cache.entry(res.path).or_default();
match res.image {
Ok(img) => {
entry.decoded = Some(Arc::new(img));
entry.failed = false;
}
Err(_) => entry.failed = true,
}
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;
match res.key {
MdEncodeKey::Full { cols, rows } => {
entry.protocol = Some(res.protocol);
entry.proto_size = Some((cols, rows));
}
MdEncodeKey::Clip {
cols,
full_rows,
row_off,
vis_rows,
} => {
entry.clip_protocol = Some(res.protocol);
entry.clip_key = Some((cols, full_rows, row_off, vis_rows));
}
}
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 = fetch_remote_image(&u, &dest);
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 base = self
.preview_path
.as_ref()
.and_then(|p| p.parent())
.map(|p| p.to_path_buf());
let Some(path) = resolve_md_image_path(url, base.as_deref()) else {
return;
};
if !self.md_image_cache.contains_key(&path) {
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 =
md_decode_image(&p, svg_max_px).ok_or_else(|| "decode failed".to_string());
let _ = tx.send(MdImageResult { path: p, image });
});
}
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 base = self
.preview_path
.as_ref()
.and_then(|p| p.parent())
.map(|p| p.to_path_buf());
let path = 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();
}
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(img);
self.image_crop = 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))
}
_ => {}
}
}
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 _ = tx.send(MediaResult {
gen,
payload: job.run(),
});
});
}
pub fn apply_media(&mut self, result: MediaResult) -> bool {
if result.gen != self.media_gen {
return false; }
self.media_loading = 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),
}
}
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,
)?;
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(_)
)
)
}
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() {
return;
}
self.image_zoom = (self.image_zoom * factor).clamp(1.0, 16.0);
}
pub fn image_zoom_reset(&mut self) {
if self.image_src.is_none() && !self.is_gif_active() {
return;
}
self.image_zoom = 1.0;
self.image_center = (0.5, 0.5);
}
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,
)?;
let new_tp = if self.image_crop != Some(crop_rect) {
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)
}
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 mut content: Vec<Line<'static>> = if want_syntax {
let hit = matches!(
&self.win_cache,
Some(c) if c.path == path && c.byte_top == top && c.height == height
);
if !hit {
let raw = self
.preview_win
.as_mut()
.and_then(|w| w.read_lines(top, h).ok())
.unwrap_or_default();
let src = raw.join("\n");
let lines =
crate::preview::code::highlight(&src, &path, &self.cfg.ui.theme.code_theme);
self.win_cache = Some(WinCache {
path,
byte_top: top,
height,
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
}
}
pub fn start_search(&mut self) {
if self.preview_win.is_none() {
self.flash = Some(tr(self.lang, crate::i18n::Msg::SearchCodeTextOnly).into());
return;
}
self.search_input = Some(String::new());
}
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();
return;
}
const CAP: usize = 5000;
let matches = self
.preview_win
.as_mut()
.and_then(|w| w.find_all_matches(&q, CAP).ok())
.unwrap_or_default();
self.preview_search = Some(q);
self.search_matches = matches;
if self.search_matches.is_empty() {
self.flash = Some(tr(self.lang, crate::i18n::Msg::NoMatch).into());
return;
}
let top = self.preview_byte_top;
self.search_idx = 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.search_idx = 0;
}
fn jump_to_match(&mut self) {
if let Some(&(off, line, _col)) = self.search_matches.get(self.search_idx) {
self.preview_byte_top = off;
self.preview_top_line = line;
}
}
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;
}
pub fn decorate_md_items(&mut self, lines: Vec<Line<'static>>) -> Vec<Line<'static>> {
let (lines, targets) = collapse_links(lines, self.cfg.ui.icons);
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 },
});
}
}
}
}
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;
};
use ratatui::style::Modifier;
let mut seen = 0usize;
lines
.into_iter()
.map(|line| {
let style = line.style;
let spans = line
.spans
.into_iter()
.map(|mut span| {
if is_link_span(&span) || crate::preview::markdown::is_task_span(&span) {
if seen == target_idx {
span.style = span.style.add_modifier(Modifier::REVERSED);
}
seen += 1;
}
span
})
.collect::<Vec<_>>();
Line::from(spans).style(style)
})
.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;
self.focused_item = Some(next);
let line = self.md_items[next].line;
let vh = self.preview_viewport.max(1) as usize;
let scroll = self.preview_scroll as usize;
if line < scroll {
self.preview_scroll = line as u16;
} else if line >= scroll + vh {
self.preview_scroll = (line + 1).saturating_sub(vh) as u16;
}
}
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(())
}
None => Ok(()),
}
}
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_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 t.starts_with('#') {
self.flash = Some(tr(self.lang, crate::i18n::Msg::AnchorsUnsupported).into());
return Ok(());
}
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)
};
let resolved = std::fs::canonicalize(&resolved).unwrap_or(resolved);
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 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(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,
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,
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) {
let snap = self.snapshot_tab();
self.tabs[self.active_tab] = snap;
}
fn load_active(&mut self) {
let t = self.tabs[self.active_tab].clone();
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.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.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 matches!(
kind,
PreviewKind::Image(_)
| PreviewKind::Svg(_)
| PreviewKind::Video(_)
| PreviewKind::Pdf(_)
) {
self.pdf_page = t.pdf_page.max(1);
self.pdf_pages = t.pdf_pages;
self.start_media_load(&kind, &path);
self.image_zoom = t.image_zoom;
self.image_center = t.image_center;
self.image_crop = None;
}
}
self.md_raw = t.md_raw;
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();
}
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();
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.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.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;
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.tabs.remove(self.active_tab);
if self.active_tab >= self.tabs.len() {
self.active_tab = self.tabs.len() - 1;
}
self.load_active();
}
pub fn tab_cycle(&mut self, dir: i32) {
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();
}
pub fn tab_goto(&mut self, i: usize) {
if i >= self.tabs.len() || i == self.active_tab {
return;
}
self.save_active();
self.active_tab = i;
self.load_active();
}
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 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 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;
}
std::fs::rename(&tmp, dest).is_ok()
}
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)
}
#[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,
) -> Option<(Rect, (u32, u32, u32, u32), (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 natural = Resize::natural_size(src, font_size);
let base = centered_rect((natural.width, natural.height), 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(())
}
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,
is_dir: bool,
size: u64,
mtime: Option<std::time::SystemTime>,
}
fn child_meta(path: PathBuf) -> ChildMeta {
let md = std::fs::metadata(&path).ok();
let is_dir = md.as_ref().map(|m| m.is_dir()).unwrap_or(false);
let size = md.as_ref().map(|m| m.len()).unwrap_or(0);
let mtime = md.as_ref().and_then(|m| m.modified().ok());
ChildMeta {
path,
is_dir,
size,
mtime,
}
}
fn name_cmp(a: &Path, b: &Path) -> std::cmp::Ordering {
let an = a
.file_name()
.map(|s| s.to_string_lossy().to_lowercase())
.unwrap_or_default();
let bn = b
.file_name()
.map(|s| s.to_string_lossy().to_lowercase())
.unwrap_or_default();
an.cmp(&bn)
}
fn ext_lower(p: &Path) -> String {
p.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase()
}
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 => name_cmp(&a.path, &b.path),
SortKey::Size => a.size.cmp(&b.size),
SortKey::Modified => a.mtime.cmp(&b.mtime),
SortKey::Ext => ext_lower(&a.path).cmp(&ext_lower(&b.path)),
};
if sort.reverse {
ord = ord.reverse();
}
ord.then_with(|| name_cmp(&a.path, &b.path))
}
#[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: &[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| e.path())
.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(child_meta)
.collect();
children.sort_by(|a, b| sort_cmp(a, b, sort));
for c in children {
let expanded = c.is_dir && expanded_dirs.iter().any(|d| d == &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;