use crate::background::{Latest, Watch, WatchEvent};
use crate::fs_ops::{Clash, Description, Mount, Progress, Reply, Step, Transfer, copy_path, count_entries, delete_path, disk_usage, get_current_dir, load_directory_rows, measure, move_path, nearest_existing_dir, path_exists, rename_in_place};
use crate::options::{OPTION_ROWS, OptionRow, Options};
use crate::strip::{Strip, StripHit};
use crate::viewer::Preview;
use crate::viewer::{ViewMode, ViewerState};
use image::DynamicImage;
use ratatui::layout::{Position, Rect};
use ratatui::style::Style;
use ratatui::text::Span;
use ratatui::widgets::TableState;
use std::collections::{HashMap, HashSet};
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{self, Receiver, Sender, TryRecvError};
use std::time::{Duration, Instant, SystemTime};
pub struct TextInput {
pub text: String,
pub cursor: usize,
}
impl TextInput {
pub fn new() -> Self {
Self { text: String::new(), cursor: 0 }
}
pub fn set(&mut self, value: String) {
self.cursor = value.chars().count();
self.text = value;
}
fn byte_index(&self) -> usize {
self.text.char_indices().nth(self.cursor).map(|(i, _)| i).unwrap_or(self.text.len())
}
pub fn move_left(&mut self) {
self.cursor = self.cursor.saturating_sub(1);
}
pub fn move_right(&mut self) {
let len = self.text.chars().count();
if self.cursor < len {
self.cursor += 1;
}
}
pub fn move_home(&mut self) {
self.cursor = 0;
}
pub fn move_end(&mut self) {
self.cursor = self.text.chars().count();
}
pub fn insert(&mut self, c: char) {
let idx = self.byte_index();
self.text.insert(idx, c);
self.cursor += 1;
}
pub fn backspace(&mut self) {
if self.cursor > 0 {
let byte_start = self.text.char_indices().nth(self.cursor - 1).map(|(i, _)| i).unwrap_or(0);
let byte_end = self.byte_index();
self.text.replace_range(byte_start..byte_end, "");
self.cursor -= 1;
}
}
pub fn delete_forward(&mut self) {
let len = self.text.chars().count();
if self.cursor < len {
let byte_start = self.byte_index();
let byte_end = self.text.char_indices().nth(self.cursor + 1).map(|(i, _)| i).unwrap_or(self.text.len());
self.text.replace_range(byte_start..byte_end, "");
}
}
pub fn cursor_spans_within(&self, width: usize, text_style: Style, cursor_style: Style) -> Vec<Span<'static>> {
if width == 0 {
return Vec::new();
}
let cell = |character: char| crate::utils::display_width(&character.to_string()).max(1);
let characters: Vec<char> = self.text.chars().collect();
let cursor = self.cursor.min(characters.len());
let at_cursor = characters.get(cursor).copied().unwrap_or(' ');
let whole: usize = characters.iter().map(|&c| cell(c)).sum::<usize>() + 1;
let mut first = 0;
if whole > width {
let mut used = cell(at_cursor);
first = cursor;
while first > 0 && used + cell(characters[first - 1]) <= width {
first -= 1;
used += cell(characters[first]);
}
}
let before: String = characters[first..cursor].iter().collect();
let mut used = crate::utils::display_width(&before) + cell(at_cursor);
let mut after = String::new();
for &character in characters.iter().skip(cursor + 1) {
let this = cell(character);
if used + this > width {
break;
}
used += this;
after.push(character);
}
vec![
Span::styled(crate::utils::printable_name(&before), text_style),
Span::styled(crate::utils::printable_name(&at_cursor.to_string()), cursor_style),
Span::styled(crate::utils::printable_name(&after), text_style),
]
}
pub fn cursor_spans(&self, text_style: Style, cursor_style: Style) -> Vec<Span<'static>> {
let byte_idx = self.byte_index();
let before = self.text[..byte_idx].to_string();
let rest = &self.text[byte_idx..];
let mut chars = rest.chars();
let cursor_char = chars.next().unwrap_or(' ');
let after: String = chars.collect();
vec![
Span::styled(crate::utils::printable_name(&before), text_style),
Span::styled(crate::utils::printable_name(&cursor_char.to_string()), cursor_style),
Span::styled(crate::utils::printable_name(&after), text_style),
]
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum PromptKind {
Find,
GoToLine,
Select,
Deselect,
Replace,
ReplaceWith,
GoTo,
}
pub enum Screen {
Panels,
Viewer(ViewerState),
Editor(EditorState),
}
pub enum Dialog {
Help,
Options { editing: Option<TextInput> },
Rename(TextInput),
Create { is_dir: bool, input: TextInput },
Delete { items: Vec<(PathBuf, bool)>, to_trash: bool },
Transfer {
sources: Vec<(PathBuf, bool)>,
is_copy: bool,
input: TextInput,
opened_with: String,
default: PathBuf,
},
LargeFile(LargeFile),
SaveChanges { then_quit: bool },
DiskChanged { then: AfterSave, opened: Option<DiskStamp>, now: Option<DiskStamp> },
DrivePicker { is_left: bool, index: usize },
Prompt(PromptKind, TextInput),
FindFiles(Box<FindFiles>),
Replacing { stop_at: (usize, usize), wrapped: bool, replaced: usize },
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum AfterSave {
Stay,
Close,
Quit,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct DiskStamp {
pub modified: Option<SystemTime>,
pub size: u64,
}
impl DiskStamp {
pub fn of(path: &Path) -> Option<Self> {
let metadata = std::fs::metadata(path).ok()?;
Some(DiskStamp {
modified: metadata.modified().ok(),
size: metadata.len(),
})
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Saved {
Done,
ChangedOnDisk {
opened: Option<DiskStamp>,
now: Option<DiskStamp>,
},
}
pub enum FindFiles {
Asking { name: TextInput, containing: TextInput, on_containing: bool },
Found(Search),
}
pub struct Search {
pub start: PathBuf,
pub label: String,
pub results: Vec<(PathBuf, bool)>,
pub cursor: usize,
pub scroll: usize,
pub looking_in: Option<PathBuf>,
pub done: bool,
pub unreadable: usize,
cancel: Arc<AtomicBool>,
updates: Receiver<crate::search::Update>,
}
impl Drop for Search {
fn drop(&mut self) {
self.cancel.store(true, Ordering::Relaxed);
}
}
impl Dialog {
fn is_popup(&self) -> bool {
!matches!(self, Dialog::Rename(_) | Dialog::Prompt(..) | Dialog::DrivePicker { .. })
}
}
pub struct AppState {
pub screen: Screen,
pub dialog: Option<Dialog>,
pub error: Option<String>,
pub options: Options,
pub options_cursor: usize,
pub external_edit: Option<PathBuf>,
pub show_preview: bool,
pub preview: Option<PreviewState>,
pub clipboard: String,
pub mounts: Vec<Mount>,
pub is_left_active: bool,
pub dir_left: PathBuf,
pub dir_right: PathBuf,
pub page_size: u16,
pub state_left: TableState,
pub state_right: TableState,
pub children_left: Vec<Item>,
pub children_right: Vec<Item>,
pub search_input: String,
pub cached_clock: String,
pub cached_separator_height: u16,
pub cached_separator: String,
pub viewer_viewport_height: usize,
pub viewer_viewport_width: usize,
pub editor_viewport_height: usize,
pub select_pattern: String,
pub find_files_last: (String, String),
pub find_files_page: usize,
pub replace_with: String,
pub viewer_wrap: bool,
pub find_term: String,
pub find_shown: bool,
pub find_note: Option<String>,
pub selected_left: HashSet<OsString>,
pub selected_right: HashSet<OsString>,
pub dir_sizes: HashMap<PathBuf, u64>,
pub last_click_time: Option<Instant>,
pub last_click_pos: (u16, u16),
pub table_area_left: Rect,
pub table_area_right: Rect,
pub viewport_start_left: usize,
pub viewport_start_right: usize,
pub editor_content_area: Rect,
pub viewer_content_area: Rect,
pub drive_strip_left: Strip,
pub drive_strip_right: Strip,
pub tabs_left: Tabs,
pub tabs_right: Tabs,
pub listing_left: Option<PendingListing>,
pub listing_right: Option<PendingListing>,
pub listing_wait: Duration,
pub detail_wait: Duration,
watch_left: Option<Watch>,
watch_right: Option<Watch>,
_watch_mounts: Watch,
watch_sender: Sender<WatchEvent>,
watch_events: Receiver<WatchEvent>,
pub watch_interval: Duration,
stale_left: bool,
stale_right: bool,
detail_worker: Latest<(PathBuf, bool), Option<Description>>,
preview_worker: Latest<(PathBuf, bool), Preview>,
pub disk_left: Option<(u64, u64)>,
pub disk_right: Option<(u64, u64)>,
pub job: Option<TransferJob>,
pub dir_sizing: Vec<DirSizing>,
pub image_cache: ImageCache,
pub cursor_detail: Option<CursorDetail>,
pub quit_armed: bool,
}
pub struct TransferJob {
pub kind: TransferKind,
pub total: Option<u64>,
pub done: u64,
pub current: PathBuf,
pub started: Instant,
cancel: Arc<AtomicBool>,
updates: Receiver<JobUpdate>,
pub problem: Option<JobProblem>,
pub clash: Option<JobClash>,
answers: Sender<Answer>,
pub skipped: u64,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum TransferKind {
Copy,
Move,
Delete,
Trash,
}
impl TransferKind {
pub fn title(self) -> &'static str {
match self {
TransferKind::Copy => "Copy",
TransferKind::Move => "Move",
TransferKind::Delete => "Delete",
TransferKind::Trash => "Move to trash",
}
}
}
impl TransferJob {
pub fn fraction(&self) -> Option<f64> {
match self.total {
Some(total) if total > 0 => Some((self.done as f64 / total as f64).min(1.0)),
_ => None,
}
}
pub fn is_cancelling(&self) -> bool {
self.cancel.load(Ordering::Relaxed)
}
pub fn answer(&mut self, answer: Answer) {
if self.problem.take().is_some() || self.clash.take().is_some() {
let _ = self.answers.send(answer);
}
}
}
pub struct JobClash {
pub source: PathBuf,
pub dest: PathBuf,
pub source_size: Option<u64>,
pub source_modified: Option<SystemTime>,
pub dest_size: Option<u64>,
pub dest_modified: Option<SystemTime>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Clashes {
Ask,
ReplaceAll,
KeepAll,
ReplaceOlder,
}
pub struct JobProblem {
pub path: PathBuf,
pub message: String,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Answer {
Retry,
Skip,
SkipAll,
Abort,
Delete,
Overwrite,
OverwriteAll,
OverwriteOlder,
}
enum JobUpdate {
Total(u64),
Starting(PathBuf),
Advanced(u64),
Problem(JobProblem),
Clash(JobClash),
Skipped,
Finished(Result<Transfer, String>),
}
struct JobProgress<'a> {
updates: &'a Sender<JobUpdate>,
cancel: &'a AtomicBool,
answers: &'a Receiver<Answer>,
skip_all: bool,
clashes: Clashes,
aborted: bool,
}
impl<'a> JobProgress<'a> {
fn new(updates: &'a Sender<JobUpdate>, cancel: &'a AtomicBool, answers: &'a Receiver<Answer>, clashes: Clashes) -> Self {
JobProgress {
updates,
cancel,
answers,
skip_all: false,
clashes,
aborted: false,
}
}
fn finish(&self, outcome: Result<Transfer, std::io::Error>) {
let outcome = match outcome {
Err(_) if self.aborted => Ok(Transfer::Cancelled),
outcome => outcome.map_err(|e| e.to_string()),
};
let _ = self.updates.send(JobUpdate::Finished(outcome));
}
}
impl Progress for JobProgress<'_> {
fn step(&mut self, step: Step<'_>) -> bool {
let update = match step {
Step::Starting(path) => JobUpdate::Starting(path.to_path_buf()),
Step::Advanced(amount) => JobUpdate::Advanced(amount),
};
self.updates.send(update).is_ok() && !self.cancel.load(Ordering::Relaxed)
}
fn failed(&mut self, path: &Path, error: &std::io::Error) -> Reply {
if self.cancel.load(Ordering::Relaxed) {
self.aborted = true;
return Reply::Abort;
}
let answer = if self.skip_all {
Answer::Skip
} else {
let problem = JobProblem {
path: path.to_path_buf(),
message: error.to_string(),
};
if self.updates.send(JobUpdate::Problem(problem)).is_err() { Answer::Abort } else { self.answers.recv().unwrap_or(Answer::Abort) }
};
match answer {
Answer::Retry => Reply::Retry,
Answer::Skip | Answer::SkipAll => {
self.skip_all |= answer == Answer::SkipAll;
let _ = self.updates.send(JobUpdate::Skipped);
Reply::Skip
}
Answer::Abort => {
self.aborted = true;
Reply::Abort
}
Answer::Delete => Reply::Delete,
Answer::Overwrite | Answer::OverwriteAll | Answer::OverwriteOlder => Reply::Skip,
}
}
fn exists(&mut self, source: &Path, dest: &Path) -> Clash {
if self.cancel.load(Ordering::Relaxed) {
return Clash::Abort;
}
let modified = |path: &Path| std::fs::metadata(path).or_else(|_| path.symlink_metadata()).ok().and_then(|metadata| metadata.modified().ok());
let clash = match self.clashes {
Clashes::Ask => {
let size = |path: &Path| std::fs::metadata(path).or_else(|_| path.symlink_metadata()).ok().map(|metadata| metadata.len());
let question = JobClash {
source: source.to_path_buf(),
dest: dest.to_path_buf(),
source_size: size(source),
source_modified: modified(source),
dest_size: size(dest),
dest_modified: modified(dest),
};
let answer = if self.updates.send(JobUpdate::Clash(question)).is_err() { Answer::Abort } else { self.answers.recv().unwrap_or(Answer::Abort) };
match answer {
Answer::Overwrite => Clash::Replace,
Answer::Skip => Clash::Keep,
Answer::OverwriteAll => {
self.clashes = Clashes::ReplaceAll;
Clash::Replace
}
Answer::SkipAll => {
self.clashes = Clashes::KeepAll;
Clash::Keep
}
Answer::OverwriteOlder => {
self.clashes = Clashes::ReplaceOlder;
replace_if_older(modified(source), modified(dest))
}
Answer::Abort | Answer::Retry | Answer::Delete => Clash::Abort,
}
}
Clashes::ReplaceAll => Clash::Replace,
Clashes::KeepAll => Clash::Keep,
Clashes::ReplaceOlder => replace_if_older(modified(source), modified(dest)),
};
if clash == Clash::Keep {
let _ = self.updates.send(JobUpdate::Skipped);
}
clash
}
}
fn replace_if_older(source: Option<SystemTime>, dest: Option<SystemTime>) -> Clash {
match (source, dest) {
(Some(source), Some(dest)) if dest < source => Clash::Replace,
_ => Clash::Keep,
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Tab {
pub dir: PathBuf,
pub cursor: Option<OsString>,
pub selected: HashSet<OsString>,
pub back: Vec<Visit>,
pub forward: Vec<Visit>,
}
impl Tab {
pub fn new(dir: PathBuf) -> Self {
Tab {
dir,
cursor: None,
selected: HashSet::new(),
back: Vec::new(),
forward: Vec::new(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Visit {
pub dir: PathBuf,
pub cursor: Option<OsString>,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Travel {
Stay,
Go,
GoTo,
Back,
Forward,
}
pub struct Tabs {
pub list: Vec<Tab>,
pub active: usize,
pub strip: Strip,
}
impl Tabs {
pub fn new(dirs: Vec<PathBuf>, active: usize) -> Self {
let mut list: Vec<Tab> = dirs.into_iter().map(Tab::new).collect();
if list.is_empty() {
list.push(Tab::new(PathBuf::from(".")));
}
let active = active.min(list.len() - 1);
Tabs { list, active, strip: Strip::default() }
}
}
pub struct PendingListing {
pub dir: PathBuf,
navigate: bool,
select: Option<OsString>,
tab: Option<TabSwitch>,
travel: Travel,
answer: Receiver<Listed>,
}
#[cfg(test)]
impl PendingListing {
pub fn going_to(dir: PathBuf) -> Self {
let (_, answer) = mpsc::channel();
PendingListing {
dir,
navigate: true,
select: None,
tab: None,
travel: Travel::Go,
answer,
}
}
}
struct TabSwitch {
selected: HashSet<OsString>,
undo: TabUndo,
}
enum TabUndo {
Active(usize),
Reopen(usize, Tab),
}
struct Listed {
dir: Option<PathBuf>,
items: std::io::Result<Vec<Item>>,
stamp: Option<SystemTime>,
disk: Option<(u64, u64)>,
}
fn read_listing(dir: &Path, options: &Options) -> Listed {
let Some(found) = nearest_existing_dir(dir) else {
return Listed {
dir: None,
items: Err(std::io::ErrorKind::NotFound.into()),
stamp: None,
disk: None,
};
};
let stamp = std::fs::metadata(&found).and_then(|metadata| metadata.modified()).ok();
let items = load_directory_rows(&found, options);
let disk = disk_usage(&found);
Listed { dir: Some(found), items, stamp, disk }
}
fn read_preview(path: &Path, is_dir: bool) -> Preview {
if !is_dir {
return crate::viewer::load_preview(path, crate::constants::PREVIEW_MAX_BYTES, crate::constants::PREVIEW_MAX_LINES);
}
Preview::Lines(vec![match std::fs::read_dir(path) {
Ok(entries) => format!("{} items", entries.count()),
Err(e) => e.to_string(),
}])
}
pub struct DirSizing {
pub path: PathBuf,
pub found: u64,
cancel: Arc<AtomicBool>,
updates: Receiver<SizeUpdate>,
}
enum SizeUpdate {
Found(u64),
Finished(Result<Option<u64>, String>),
}
type Decoded = Option<(DynamicImage, String)>;
#[derive(Default)]
pub struct ImageCache {
ready: Vec<(PathBuf, DynamicImage, String)>,
pending: Option<(PathBuf, Receiver<Decoded>)>,
forward: bool,
}
impl ImageCache {
const KEEP: usize = 2;
fn take(&mut self, path: &Path) -> Option<(DynamicImage, String)> {
let at = self.ready.iter().position(|(held, _, _)| held == path)?;
let (_, image, label) = self.ready.remove(at);
Some((image, label))
}
fn keep(&mut self, path: PathBuf, image: DynamicImage, label: String) {
self.ready.retain(|(held, _, _)| *held != path);
self.ready.push((path, image, label));
while self.ready.len() > Self::KEEP {
self.ready.remove(0);
}
}
fn holds(&self, path: &Path) -> bool {
self.ready.iter().any(|(held, _, _)| held == path) || self.pending.as_ref().is_some_and(|(wanted, _)| wanted == path)
}
fn request(&mut self, path: PathBuf) {
if self.holds(&path) {
return;
}
let (sender, receiver) = mpsc::channel();
let wanted = path.clone();
std::thread::spawn(move || {
let _ = sender.send(crate::viewer::decode_image(&wanted));
});
self.pending = Some((path, receiver));
}
pub fn collect(&mut self) {
let Some((path, receiver)) = &self.pending else {
return;
};
match receiver.try_recv() {
Ok(Some((image, label))) => {
let path = path.clone();
self.pending = None;
self.keep(path, image, label);
}
Ok(None) | Err(TryRecvError::Disconnected) => self.pending = None,
Err(TryRecvError::Empty) => {}
}
}
fn clear(&mut self) {
self.ready.clear();
self.pending = None;
}
}
pub struct CursorDetail {
pub path: Option<PathBuf>,
pub name: String,
pub size: String,
pub modified: String,
pub owner: String,
pub attributes: String,
pub link: Option<String>,
awaiting: bool,
}
pub struct LargeFile {
pub path: PathBuf,
pub is_edit: bool,
pub size: u64,
pub dimensions: Option<(u32, u32)>,
}
#[derive(Clone)]
pub struct EditorState {
pub file_path: PathBuf,
pub lines: Vec<String>,
pub highlighted_lines: Vec<Vec<Span<'static>>>,
pub cursor_line: usize,
pub cursor_col: usize,
pub scroll_offset: usize,
pub horizontal_offset: usize,
pub modified: bool,
pub auto_scroll: bool,
pub selection_anchor: Option<(usize, usize)>,
pub undo_stack: Vec<EditStep>,
pub redo_stack: Vec<EditStep>,
pub line_states: Vec<crate::viewer::LineState>,
pub line_ending: &'static str,
pub disk_stamp: Option<DiskStamp>,
}
#[derive(Clone)]
pub struct EditStep {
first_line: usize,
before: Vec<String>,
total_lines_before: usize,
cursor: (usize, usize),
}
pub struct PreviewState {
pub path: Option<PathBuf>,
pub label: String,
pub lines: Vec<String>,
pub bytes: Vec<u8>,
awaiting: bool,
}
#[derive(Debug, Clone)]
pub struct Item {
pub name_os: OsString,
pub name_full: String,
pub name: String,
pub extension: String,
pub is_dir: bool,
pub size: String,
pub size_bytes: u64,
pub modified_at: Option<SystemTime>,
pub attributes: String,
}
impl Item {
pub fn path_in(&self, dir: &Path) -> PathBuf {
dir.join(&self.name_os)
}
}
impl AppState {
pub fn new() -> Self {
let mut state_left = TableState::default();
state_left.select(Some(1));
let mut state_right = TableState::default();
state_right.select(Some(1));
let (watch_sender, watch_events) = mpsc::channel();
let (error, dir_root) = match get_current_dir() {
Ok(root) => (None, root),
Err(e) => (Some(e.to_string()), PathBuf::new()),
};
Self {
screen: Screen::Panels,
dialog: None,
error,
options: if cfg!(test) { Options::default() } else { Options::load() },
options_cursor: 0,
external_edit: None,
show_preview: false,
preview: None,
clipboard: String::new(),
mounts: Vec::new(),
is_left_active: true,
dir_left: dir_root.clone(),
dir_right: dir_root.clone(),
page_size: 0,
state_left,
state_right,
children_left: Vec::new(),
children_right: Vec::new(),
search_input: String::new(),
cached_clock: String::new(),
cached_separator_height: 0,
cached_separator: String::new(),
viewer_viewport_height: 0,
viewer_viewport_width: 0,
editor_viewport_height: 0,
select_pattern: "*".to_string(),
find_files_last: ("*".to_string(), String::new()),
find_files_page: 10,
find_term: String::new(),
replace_with: String::new(),
viewer_wrap: false,
find_shown: false,
find_note: None,
selected_left: HashSet::new(),
selected_right: HashSet::new(),
dir_sizes: HashMap::new(),
last_click_time: None,
last_click_pos: (0, 0),
table_area_left: Rect::default(),
table_area_right: Rect::default(),
viewport_start_left: 0,
viewport_start_right: 0,
editor_content_area: Rect::default(),
viewer_content_area: Rect::default(),
listing_left: None,
listing_right: None,
listing_wait: if cfg!(test) { Duration::MAX } else { crate::constants::LISTING_WAIT },
detail_wait: if cfg!(test) { Duration::MAX } else { crate::constants::DETAIL_WAIT },
watch_left: None,
watch_right: None,
_watch_mounts: crate::background::watch_mounts(crate::constants::REFRESH_INTERVAL, watch_sender.clone()),
watch_sender,
watch_events,
watch_interval: crate::constants::REFRESH_INTERVAL,
stale_left: false,
stale_right: false,
detail_worker: Latest::new(|(path, is_dir): &(PathBuf, bool)| crate::fs_ops::describe(path, *is_dir)),
preview_worker: Latest::new(|(path, is_dir): &(PathBuf, bool)| read_preview(path, *is_dir)),
disk_left: None,
disk_right: None,
job: None,
dir_sizing: Vec::new(),
image_cache: ImageCache::default(),
cursor_detail: None,
quit_armed: false,
drive_strip_left: Strip::default(),
drive_strip_right: Strip::default(),
tabs_left: Tabs::new(vec![dir_root.clone()], 0),
tabs_right: Tabs::new(vec![dir_root], 0),
}
}
pub fn display_error(&mut self, message: String) {
self.error = Some(message);
}
pub fn reset_error(&mut self) {
self.error = None;
}
pub fn viewer(&self) -> Option<&ViewerState> {
match &self.screen {
Screen::Viewer(state) => Some(state),
_ => None,
}
}
pub fn viewer_mut(&mut self) -> Option<&mut ViewerState> {
match &mut self.screen {
Screen::Viewer(state) => Some(state),
_ => None,
}
}
pub fn editor(&self) -> Option<&EditorState> {
match &self.screen {
Screen::Editor(state) => Some(state),
_ => None,
}
}
pub fn editor_mut(&mut self) -> Option<&mut EditorState> {
match &mut self.screen {
Screen::Editor(state) => Some(state),
_ => None,
}
}
pub fn is_viewing(&self) -> bool {
matches!(self.screen, Screen::Viewer(_))
}
pub fn is_editing(&self) -> bool {
matches!(self.screen, Screen::Editor(_))
}
pub fn dialog_input(&mut self) -> Option<&mut TextInput> {
match self.dialog.as_mut()? {
Dialog::Rename(input) | Dialog::Create { input, .. } | Dialog::Transfer { input, .. } | Dialog::Prompt(_, input) => Some(input),
Dialog::Options { editing } => editing.as_mut(),
Dialog::FindFiles(find) => match find.as_mut() {
FindFiles::Asking { name, containing, on_containing } => Some(if *on_containing { containing } else { name }),
FindFiles::Found(_) => None,
},
_ => None,
}
}
fn options_row(&self) -> OptionRow {
OPTION_ROWS[self.options_cursor.min(OPTION_ROWS.len() - 1)]
}
pub fn options_move(&mut self, down: bool) {
self.options_cursor = if down { (self.options_cursor + 1).min(OPTION_ROWS.len() - 1) } else { self.options_cursor.saturating_sub(1) };
}
pub fn options_change(&mut self, forward: bool) {
let row = self.options_row();
if row.is_text() {
let mut input = TextInput::new();
input.set(self.options.text(row).to_string());
self.dialog = Some(Dialog::Options { editing: Some(input) });
return;
}
self.options.cycle(row, forward);
self.options_changed(row);
}
pub fn options_commit_edit(&mut self) {
let Some(Dialog::Options { editing }) = &mut self.dialog else {
return;
};
let Some(input) = editing.take() else {
return;
};
let row = self.options_row();
self.options.set_text(row, input.text.trim().to_string());
self.options_changed(row);
}
pub fn options_cancel_edit(&mut self) {
if let Some(Dialog::Options { editing }) = &mut self.dialog {
*editing = None;
}
}
fn options_changed(&mut self, row: OptionRow) {
crate::display::apply(&self.options);
self.cursor_detail = None;
if row.affects_listing() {
self.reload_panel(true, None);
self.reload_panel(false, None);
}
if let Err(e) = self.options.save() {
self.display_error(format!("Cannot save options: {}", e));
}
}
pub fn search_add_char(&mut self, c: char) {
self.search_input.push(c);
self.jump_to_first_match();
}
pub fn search_backspace(&mut self) {
self.search_input.pop();
if !self.search_input.is_empty() {
self.jump_to_first_match();
}
}
pub fn search_clear(&mut self) {
self.search_input.clear();
}
pub fn jump_to_first_match(&mut self) {
if self.search_input.is_empty() {
return;
}
let search_lower = self.search_input.to_lowercase();
let (children, state) = self.active_panel_mut();
if let Some(index) = children.iter().position(|item| item.name_full.to_lowercase().starts_with(&search_lower)) {
state.select(Some(index));
}
}
pub fn jump_to_next_match(&mut self) {
if self.search_input.is_empty() {
return;
}
let search_lower = self.search_input.to_lowercase();
let (children, state) = self.active_panel_mut();
let current = state.selected().unwrap_or(0);
let len = children.len();
for offset in 1..=len {
let i = (current + offset) % len;
if children[i].name_full.to_lowercase().starts_with(&search_lower) {
state.select(Some(i));
return;
}
}
}
pub fn jump_to_prev_match(&mut self) {
if self.search_input.is_empty() {
return;
}
let search_lower = self.search_input.to_lowercase();
let (children, state) = self.active_panel_mut();
let current = state.selected().unwrap_or(0);
let len = children.len();
for offset in 1..=len {
let i = (current + len - offset) % len;
if children[i].name_full.to_lowercase().starts_with(&search_lower) {
state.select(Some(i));
return;
}
}
}
fn active_panel_mut(&mut self) -> (&[Item], &mut TableState) {
if self.is_left_active { (&self.children_left, &mut self.state_left) } else { (&self.children_right, &mut self.state_right) }
}
pub fn request_open(&mut self, file_path: PathBuf, is_edit: bool) {
let size = match std::fs::metadata(&file_path) {
Ok(metadata) if !metadata.is_file() => {
self.display_error(format!("Not a regular file: {}", file_path.display()));
return;
}
Ok(metadata) => metadata.len(),
Err(e) => {
self.display_error(e.to_string());
return;
}
};
if size > self.options.large_file_bytes() {
self.dialog = Some(Dialog::LargeFile(LargeFile {
path: file_path,
is_edit,
size,
dimensions: None,
}));
return;
}
if !is_edit
&& let Some((dimensions, decoded)) = crate::viewer::image_cost(&file_path)
&& decoded > crate::constants::IMAGE_MAX_DECODED
{
self.dialog = Some(Dialog::LargeFile(LargeFile {
path: file_path,
is_edit,
size: decoded,
dimensions: Some(dimensions),
}));
return;
}
self.open_file(file_path, is_edit);
}
pub fn confirm_large_file(&mut self) {
if let Some(Dialog::LargeFile(large)) = self.dialog.take() {
self.open_file(large.path, large.is_edit);
}
}
fn open_file(&mut self, file_path: PathBuf, is_edit: bool) {
let result = if is_edit { self.open_editor(file_path) } else { self.open_viewer(file_path) };
if let Err(e) = result {
self.display_error(e);
}
}
pub fn open_viewer(&mut self, file_path: PathBuf) -> Result<(), String> {
let decoded = if self.options.image_prefetch { self.image_cache.take(&file_path) } else { None };
let mut state = crate::viewer::load_file_content(&file_path, decoded).map_err(|e| e.to_string())?;
state.image_fill = self.options.image_fill;
state.wrap = self.viewer_wrap;
if self.options.image_prefetch
&& let Screen::Viewer(previous) = std::mem::replace(&mut self.screen, Screen::Panels)
&& let Some(image) = previous.image
{
self.image_cache.keep(previous.file_path, image, previous.syntax_name);
}
self.screen = Screen::Viewer(state);
self.read_ahead();
Ok(())
}
pub fn fit_viewer_image(&mut self) -> bool {
let (width, height) = (self.viewer_viewport_width, self.viewer_viewport_height);
let backgrounds = self.options.image_backgrounds;
let Screen::Viewer(state) = &mut self.screen else {
return false;
};
let Some(image) = &state.image else {
return false;
};
if state.mode != ViewMode::Image || width == 0 || height == 0 {
return false;
}
let (columns, rows) = crate::viewer::image_size_for(image, width, height, state.image_fill, state.image_zoom);
let drawn_with_backgrounds = !state.image_backgrounds.is_empty();
if columns == state.image_columns && rows == state.image_lines.len() && backgrounds == drawn_with_backgrounds {
return false;
}
let (was_columns, was_rows) = (state.image_columns, state.image_lines.len());
(state.image_lines, state.image_colors, state.image_backgrounds) = crate::viewer::image_to_ascii(image, columns, rows, backgrounds);
state.total_lines = state.line_count();
state.image_columns = columns;
state.horizontal_offset = recentre(state.horizontal_offset, width, was_columns, columns);
state.scroll_offset = recentre(state.scroll_offset, height, was_rows, rows);
state.selection = None;
state.pan_from = None;
true
}
pub fn fit_viewer_wrap(&mut self) -> bool {
let width = self.viewer_viewport_width;
let Screen::Viewer(state) = &mut self.screen else {
return false;
};
if state.wrapping() {
if width > 0 && state.rows_width != width {
state.rewrap(width);
return true;
}
} else if !state.rows.is_empty() {
state.unwrap_rows();
return true;
}
false
}
pub fn viewer_toggle_wrap(&mut self) {
let width = self.viewer_viewport_width;
let Screen::Viewer(state) = &mut self.screen else {
return;
};
if state.mode != ViewMode::Text || state.from_edit {
return;
}
state.wrap = !state.wrap;
self.viewer_wrap = state.wrap;
if state.wrap && width > 0 {
state.rewrap(width);
} else {
state.unwrap_rows();
}
}
pub fn viewer_toggle_fill(&mut self) {
if let Screen::Viewer(state) = &mut self.screen
&& state.mode == ViewMode::Image
{
state.image_fill = !state.image_fill;
state.image_zoom = crate::constants::IMAGE_ZOOM_NORMAL;
state.scroll_offset = 0;
}
}
pub fn viewer_zoom(&mut self, closer: bool) {
if let Screen::Viewer(state) = &mut self.screen
&& state.mode == ViewMode::Image
{
let steps = crate::constants::IMAGE_ZOOM_STEPS;
let at = steps.iter().position(|&percent| percent >= state.image_zoom).unwrap_or(steps.len() - 1);
let next = if closer { (at + 1).min(steps.len() - 1) } else { at.saturating_sub(1) };
state.image_zoom = steps[next];
}
}
pub fn close_viewer(&mut self) {
self.screen = Screen::Panels;
self.close_find();
self.image_cache.clear();
}
pub fn viewer_scroll_down(&mut self) {
if let Screen::Viewer(state) = &mut self.screen {
let max = state.total_lines.saturating_sub(self.viewer_viewport_height);
state.scroll_offset = (state.scroll_offset + 1).min(max);
}
}
pub fn viewer_scroll_up(&mut self) {
if let Screen::Viewer(state) = &mut self.screen {
state.scroll_offset = state.scroll_offset.saturating_sub(1);
}
}
pub fn viewer_page_down(&mut self) {
if let Screen::Viewer(state) = &mut self.screen {
let max = state.total_lines.saturating_sub(self.viewer_viewport_height);
state.scroll_offset = (state.scroll_offset + self.viewer_viewport_height).min(max);
}
}
pub fn viewer_page_up(&mut self) {
if let Screen::Viewer(state) = &mut self.screen {
state.scroll_offset = state.scroll_offset.saturating_sub(self.viewer_viewport_height);
}
}
pub fn viewer_home(&mut self) {
if let Screen::Viewer(state) = &mut self.screen {
state.scroll_offset = 0;
state.horizontal_offset = 0;
}
}
pub fn viewer_scroll_left(&mut self) {
if let Screen::Viewer(state) = &mut self.screen {
state.horizontal_offset = state.horizontal_offset.saturating_sub(1);
}
}
pub fn viewer_copy(&mut self) {
if let Some(text) = self.viewer().and_then(|state| state.selected_text()) {
crate::utils::set_system_clipboard(&text);
self.clipboard = text;
}
}
pub fn viewer_next_mode(&mut self) {
if self.viewer().is_some_and(|state| state.from_edit) {
return;
}
let mut error = None;
if let Screen::Viewer(state) = &mut self.screen {
let next = state.next_mode();
if next == ViewMode::Hex && state.bytes.is_empty() && state.file_size > 0 {
match std::fs::read(&state.file_path) {
Ok(bytes) => state.bytes = bytes,
Err(e) => error = Some(e.to_string()),
}
}
if error.is_none() {
if next == ViewMode::Text && state.content_lines.is_empty() {
state.content_lines = String::from_utf8_lossy(&state.bytes).lines().map(str::to_string).collect();
if state.content_lines.is_empty() {
state.content_lines.push(String::new());
}
state.max_line_width = state.content_lines.iter().map(|line| crate::utils::line_display_width(line)).max().unwrap_or(0);
}
state.unwrap_rows();
state.mode = next;
state.total_lines = state.line_count();
state.selection = None;
state.scroll_offset = state.scroll_offset.min(state.total_lines.saturating_sub(1));
state.horizontal_offset = 0;
}
}
if let Some(e) = error {
self.display_error(e);
}
}
pub fn viewer_step_image(&mut self, forward: bool) {
let Some(showing) = self.viewer().map(|state| state.file_path.clone()) else {
return;
};
let Some(going_to) = self.image_neighbour(&showing, forward) else {
return;
};
let carried = self.viewer().map(|state| (state.image_fill, state.image_zoom));
self.image_cache.forward = forward;
self.request_open(going_to, false);
if let Screen::Viewer(state) = &mut self.screen
&& let Some((fill, zoom)) = carried
{
state.image_fill = fill;
state.image_zoom = zoom;
}
}
fn image_neighbour(&self, showing: &Path, forward: bool) -> Option<PathBuf> {
let (children, dir) = if self.is_left_active { (&self.children_left, &self.dir_left) } else { (&self.children_right, &self.dir_right) };
let files: Vec<PathBuf> = children.iter().filter(|item| !item.is_dir).map(|item| item.path_in(dir)).collect();
let at = files.iter().position(|path| path == showing)?;
let total = files.len();
(1..=total)
.find_map(|step| {
let index = if forward { (at + step) % total } else { (at + total - step) % total };
crate::viewer::image_cost(&files[index]).map(|_| index)
})
.map(|index| files[index].clone())
.filter(|path| path != showing)
}
fn read_ahead(&mut self) {
if !self.options.image_prefetch || !self.viewer_shows_image() {
return;
}
let Some(showing) = self.viewer().map(|state| state.file_path.clone()) else {
return;
};
if let Some(next) = self.image_neighbour(&showing, self.image_cache.forward) {
self.image_cache.request(next);
}
}
pub fn viewer_shows_image(&self) -> bool {
self.viewer().is_some_and(|state| state.mode == ViewMode::Image && !state.from_edit)
}
pub fn viewer_pan_start(&mut self, column: u16, row: u16) {
let content = self.viewer_content_area;
if let Screen::Viewer(state) = &mut self.screen
&& content.contains(Position::new(column, row))
{
state.pan_from = Some(((column, row), (state.horizontal_offset, state.scroll_offset)));
}
}
pub fn viewer_pan_to(&mut self, column: u16, row: u16) {
let (width, height) = (self.viewer_viewport_width, self.viewer_viewport_height);
if let Screen::Viewer(state) = &mut self.screen
&& let Some(((from_column, from_row), (from_horizontal, from_vertical))) = state.pan_from
{
let moved_x = i64::from(column) - i64::from(from_column);
let moved_y = i64::from(row) - i64::from(from_row);
let furthest_x = state.image_columns.saturating_sub(width) as i64;
let furthest_y = state.total_lines.saturating_sub(height) as i64;
state.horizontal_offset = (from_horizontal as i64 - moved_x).clamp(0, furthest_x) as usize;
state.scroll_offset = (from_vertical as i64 - moved_y).clamp(0, furthest_y) as usize;
}
}
pub fn viewer_scroll_right(&mut self) {
if let Screen::Viewer(state) = &mut self.screen
&& !state.wrapping()
{
let longest = match state.mode {
ViewMode::Hex => crate::constants::HEX_LINE_WIDTH,
ViewMode::Image => state.image_columns,
ViewMode::Text => state.max_line_width,
};
let max = longest.saturating_sub(self.viewer_viewport_width);
state.horizontal_offset = (state.horizontal_offset + 1).min(max);
}
}
pub fn viewer_end(&mut self) {
if let Screen::Viewer(state) = &mut self.screen {
state.scroll_offset = state.total_lines.saturating_sub(self.viewer_viewport_height);
}
}
pub fn open_editor(&mut self, file_path: PathBuf) -> Result<(), String> {
use crate::viewer::{highlight_all, is_binary_file};
let file_size = std::fs::metadata(&file_path).map_err(|e| e.to_string())?.len();
let disk_stamp = DiskStamp::of(&file_path);
if is_binary_file(&file_path).unwrap_or(false) {
self.open_viewer(file_path)?;
if let Screen::Viewer(state) = &mut self.screen {
state.from_edit = true;
state.mode = ViewMode::Text;
state.bytes = Vec::new();
state.image = None;
state.image_lines = Vec::new();
state.image_colors = Vec::new();
state.content_lines = Vec::new();
state.total_lines = 1;
}
return Ok(());
}
let content = String::from_utf8(std::fs::read(&file_path).map_err(|e| e.to_string())?).map_err(|_| format!("Cannot edit {}: it is not UTF-8 text. F3 shows it as it is.", file_path.display()))?;
let line_ending = detect_line_ending(&content);
let mut lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
if content.ends_with('\n') {
lines.push(String::new());
}
if lines.is_empty() {
lines.push(String::new());
}
let extension = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
let limit = self.options.highlight_limit_bytes();
let (highlighted_lines, line_states) = if limit > 0 && file_size <= limit {
highlight_all(&lines, extension)
} else {
(Vec::new(), Vec::new())
};
self.screen = Screen::Editor(EditorState {
file_path,
lines,
highlighted_lines,
cursor_line: 0,
cursor_col: 0,
scroll_offset: 0,
horizontal_offset: 0,
modified: false,
auto_scroll: true,
selection_anchor: None,
line_ending,
line_states,
undo_stack: Vec::new(),
redo_stack: Vec::new(),
disk_stamp,
});
Ok(())
}
pub fn editor_reload(&mut self) -> Result<(), String> {
let Some((path, line)) = self.editor().map(|state| (state.file_path.clone(), state.cursor_line)) else {
return Ok(());
};
self.open_editor(path)?;
if let Some(state) = self.editor_mut() {
state.cursor_line = line.min(state.lines.len().saturating_sub(1));
}
Ok(())
}
pub fn close_editor(&mut self) {
self.screen = Screen::Panels;
self.close_find();
}
fn close_find(&mut self) {
if matches!(self.dialog, Some(Dialog::Prompt(..))) {
self.dialog = None;
}
self.find_shown = false;
self.find_note = None;
}
pub fn open_prompt(&mut self, kind: PromptKind) {
if !self.can_find() {
return;
}
let mut input = TextInput::new();
if kind == PromptKind::Find {
input.set(self.find_term.clone());
}
self.dialog = Some(Dialog::Prompt(kind, input));
}
fn can_find(&self) -> bool {
match &self.screen {
Screen::Editor(_) => true,
Screen::Viewer(state) => !state.from_edit && state.mode != ViewMode::Image,
Screen::Panels => false,
}
}
pub fn confirm_prompt(&mut self) {
let Some(Dialog::Prompt(kind, input)) = self.dialog.take() else {
return;
};
match kind {
PromptKind::Find => {
if input.text.is_empty() {
self.find_shown = false;
return;
}
self.find_term = input.text;
self.find(true, true);
}
PromptKind::GoToLine => match input.text.trim().parse::<usize>() {
Ok(number) => self.go_to_line(number),
Err(_) if input.text.trim().is_empty() => {}
Err(_) => self.find_note = Some(format!("Not a line number: {}", input.text.trim())),
},
PromptKind::Select | PromptKind::Deselect => {
let Some(glob) = crate::glob::Glob::new(&input.text) else {
return;
};
self.select_pattern = input.text;
self.select_by(&glob, kind == PromptKind::Select);
}
PromptKind::Replace => {
if input.text.is_empty() {
return;
}
self.find_term = input.text;
let mut with = TextInput::new();
with.set(self.replace_with.clone());
self.dialog = Some(Dialog::Prompt(PromptKind::ReplaceWith, with));
}
PromptKind::ReplaceWith => {
self.replace_with = input.text;
self.start_replace();
}
PromptKind::GoTo => self.go_to_path(&input.text),
}
}
pub fn open_replace(&mut self) {
let Some(state) = self.editor() else {
return;
};
let offered = state.selected_text().filter(|text| !text.contains('\n')).unwrap_or_else(|| self.find_term.clone());
let mut input = TextInput::new();
input.set(offered);
self.dialog = Some(Dialog::Prompt(PromptKind::Replace, input));
}
fn start_replace(&mut self) {
let Some(needle) = crate::find::Needle::new(&self.find_term) else {
return;
};
let Some(state) = self.editor() else {
return;
};
let stop_at = state.selection().map_or((state.cursor_line, state.cursor_col), |(start, _)| start);
self.find_shown = true;
match self.editor_find(&needle, true, true) {
Some(wrapped) => self.dialog = Some(Dialog::Replacing { stop_at, wrapped, replaced: 0 }),
None => self.find_note = Some(format!("Not found: {}", self.find_term)),
}
}
pub fn replace_step(&mut self, replace: bool) -> bool {
let Some(Dialog::Replacing { mut stop_at, mut wrapped, mut replaced }) = self.dialog.take() else {
return false;
};
let Some(needle) = crate::find::Needle::new(&self.find_term) else {
return false;
};
if replace && let Some(((line, start), (_, end))) = self.editor().and_then(EditorState::selection) {
let with = self.replace_with.clone();
self.replace_selection(&with);
replaced += 1;
if wrapped && line == stop_at.0 && start < stop_at.1 {
stop_at.1 = (stop_at.1 + with.chars().count()).saturating_sub(end - start);
}
}
let next = self.editor_find(&needle, true, false);
let at = self.editor().and_then(EditorState::selection).map(|(start, _)| start);
let finished = |went_round: bool, at: (usize, usize)| (wrapped && went_round) || ((wrapped || went_round) && at >= stop_at);
match (next, at) {
(Some(went_round), Some(at)) if !finished(went_round, at) => {
wrapped |= went_round;
self.dialog = Some(Dialog::Replacing { stop_at, wrapped, replaced });
true
}
_ => {
self.finish_replace(replaced);
false
}
}
}
pub fn replace_all(&mut self) {
let Some(state) = self.editor_mut() else {
return;
};
let before = state.lines.clone();
let cursor = (state.cursor_line, state.cursor_col);
let history = std::mem::take(&mut state.undo_stack);
let line_states = std::mem::take(&mut state.line_states);
while self.replace_step(true) {}
if let Some(state) = self.editor_mut() {
state.line_states = line_states;
let changed = state.lines != before;
if changed && !state.line_states.is_empty() {
let differs = |index: &usize| state.lines.get(*index) != before.get(*index);
let (first, last) = if state.lines.len() == before.len() {
let last_index = state.lines.len().saturating_sub(1);
((0..state.lines.len()).find(differs).unwrap_or(0), (0..state.lines.len()).rev().find(differs).unwrap_or(last_index))
} else {
(0, state.lines.len().saturating_sub(1))
};
let extension = state.file_path.extension().and_then(|e| e.to_str()).unwrap_or("").to_string();
crate::viewer::highlight_through(&state.lines, &extension, first, last, &mut state.highlighted_lines, &mut state.line_states);
}
state.undo_stack = history;
if changed {
state.undo_stack.push(EditStep {
first_line: 0,
total_lines_before: before.len(),
before,
cursor,
});
if state.undo_stack.len() > crate::constants::UNDO_LIMIT {
state.undo_stack.remove(0);
}
}
}
}
pub fn finish_replace(&mut self, replaced: usize) {
if let Some(Dialog::Replacing { .. }) = self.dialog {
self.dialog = None;
}
self.find_note = Some(match replaced {
0 => "Nothing replaced".to_string(),
1 => "Replaced 1".to_string(),
many => format!("Replaced {many}"),
});
}
fn replace_selection(&mut self, text: &str) {
if !text.is_empty() {
self.editor_insert_text(text);
return;
}
let line = self.editor().map_or(0, |state| state.cursor_line);
self.push_undo(self.edit_range(line..=line));
if let Some(from) = self.delete_selection() {
self.editor_rehighlight_from(from);
}
self.finish_edit();
}
pub fn open_find_files(&mut self) {
let (mut name, mut containing) = (TextInput::new(), TextInput::new());
name.set(self.find_files_last.0.clone());
containing.set(self.find_files_last.1.clone());
self.dialog = Some(Dialog::FindFiles(Box::new(FindFiles::Asking { name, containing, on_containing: false })));
}
pub fn find_files_switch_field(&mut self) {
if let Some(Dialog::FindFiles(find)) = &mut self.dialog
&& let FindFiles::Asking { on_containing, .. } = find.as_mut()
{
*on_containing = !*on_containing;
}
}
pub fn start_find_files(&mut self) {
let Some(Dialog::FindFiles(find)) = &self.dialog else {
return;
};
let FindFiles::Asking { name, containing, .. } = find.as_ref() else {
return;
};
let (name, containing) = (name.text.clone(), containing.text.clone());
self.find_files_last = (name.clone(), containing.clone());
let pattern = if name.trim().is_empty() { "*" } else { name.as_str() };
let label = match containing.is_empty() {
true => pattern.to_string(),
false => format!("{pattern} containing \"{containing}\""),
};
let start = self.panel_dir(self.is_left_active);
let query = crate::search::Query {
start: start.clone(),
name: crate::glob::Glob::new(pattern).unwrap_or_else(|| crate::glob::Glob::new("*").expect("* is a pattern")),
containing: crate::find::Needle::new(&containing),
show_hidden: self.options.show_hidden,
case_sensitive: self.options.case_sensitive,
};
let cancel = Arc::new(AtomicBool::new(false));
let (sender, updates) = mpsc::channel();
let worker_cancel = Arc::clone(&cancel);
std::thread::spawn(move || {
crate::search::search(&query, &worker_cancel, &mut |update| sender.send(update).is_ok());
});
self.dialog = Some(Dialog::FindFiles(Box::new(FindFiles::Found(Search {
looking_in: Some(start.clone()),
start,
label,
results: Vec::new(),
cursor: 0,
scroll: 0,
done: false,
unreadable: 0,
cancel,
updates,
}))));
}
pub fn poll_find_files(&mut self) {
let Some(Dialog::FindFiles(find)) = &mut self.dialog else {
return;
};
let FindFiles::Found(search) = find.as_mut() else {
return;
};
loop {
match search.updates.try_recv() {
Ok(crate::search::Update::Looking(dir)) => search.looking_in = Some(dir),
Ok(crate::search::Update::Found(path, is_dir)) => search.results.push((path, is_dir)),
Ok(crate::search::Update::Finished { unreadable }) => {
search.unreadable = unreadable;
search.looking_in = None;
search.done = true;
}
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => {
search.looking_in = None;
search.done = true;
break;
}
}
}
}
pub fn find_files_move(&mut self, by: isize) {
let page = self.find_files_page.max(1);
if let Some(Dialog::FindFiles(find)) = &mut self.dialog
&& let FindFiles::Found(search) = find.as_mut()
{
let last = search.results.len().saturating_sub(1);
search.cursor = search.cursor.saturating_add_signed(by).min(last);
if search.cursor < search.scroll {
search.scroll = search.cursor;
} else if search.cursor >= search.scroll + page {
search.scroll = search.cursor + 1 - page;
}
}
}
pub fn find_files_go(&mut self) {
let Some(Dialog::FindFiles(find)) = &self.dialog else {
return;
};
let FindFiles::Found(search) = find.as_ref() else {
return;
};
let Some((path, _)) = search.results.get(search.cursor) else {
return;
};
let (Some(dir), Some(name)) = (path.parent().map(Path::to_path_buf), path.file_name().map(OsString::from)) else {
return;
};
self.dialog = None;
self.open_dir(self.is_left_active, dir, Some(&name));
}
pub fn open_select_prompt(&mut self, select: bool) {
let mut input = TextInput::new();
input.set(self.select_pattern.clone());
self.dialog = Some(Dialog::Prompt(if select { PromptKind::Select } else { PromptKind::Deselect }, input));
}
pub fn select_by(&mut self, glob: &crate::glob::Glob, select: bool) {
let (children, selected) = if self.is_left_active {
(&self.children_left, &mut self.selected_left)
} else {
(&self.children_right, &mut self.selected_right)
};
for item in children.iter().filter(|item| item.name != ".." && glob.matches(&item.name_full, item.is_dir)) {
if select {
selected.insert(item.name_os.clone());
} else {
selected.remove(&item.name_os);
}
}
}
pub fn invert_selection(&mut self, with_dirs: bool) {
let (children, selected) = if self.is_left_active {
(&self.children_left, &mut self.selected_left)
} else {
(&self.children_right, &mut self.selected_right)
};
for item in children.iter().filter(|item| (with_dirs || !item.is_dir) && item.name != "..") {
if !selected.remove(&item.name_os) {
selected.insert(item.name_os.clone());
}
}
}
pub fn find(&mut self, forward: bool, fresh: bool) {
let Some(needle) = crate::find::Needle::new(&self.find_term) else {
self.open_prompt(PromptKind::Find);
return;
};
if !self.can_find() {
return;
}
self.find_shown = true;
let found = if self.is_editing() { self.editor_find(&needle, forward, fresh) } else { self.viewer_find(&needle, forward, fresh) };
self.find_note = match found {
None => Some(format!("Not found: {}", self.find_term)),
Some(true) => Some(if forward { "Wrapped to the top" } else { "Wrapped to the bottom" }.to_string()),
Some(false) => None,
};
}
fn editor_find(&mut self, needle: &crate::find::Needle, forward: bool, fresh: bool) -> Option<bool> {
let height = self.editor_viewport_height.max(1);
let state = self.editor_mut()?;
let cursor = (state.cursor_line, state.cursor_col);
let start = state.selection().map_or(cursor, |(start, _)| start);
let from = if forward && !fresh { cursor } else { start };
let lines = &state.lines;
let found = crate::find::search(needle, lines.len(), |index| lines[index].clone(), from, forward)?;
state.selection_anchor = Some((found.line, found.start));
state.cursor_line = found.line;
state.cursor_col = found.end;
state.auto_scroll = true;
if found.line < state.scroll_offset || found.line >= state.scroll_offset + height {
state.scroll_offset = found.line.saturating_sub(height / 3);
}
Some(found.wrapped)
}
fn viewer_find(&mut self, needle: &crate::find::Needle, forward: bool, fresh: bool) -> Option<bool> {
let (height, width) = (self.viewer_viewport_height.max(1), self.viewer_viewport_width.max(1));
let state = self.viewer_mut()?;
let from = match state.selected_range() {
Some((_, end)) if forward && !fresh => end,
Some((start, _)) => start,
None => {
let (line, from, _) = state.row(state.scroll_offset);
(line, from)
}
};
let found = crate::find::search(needle, state.line_count(), |index| state.line_text(index), from, forward)?;
state.selection = Some(((found.line, found.start), (found.line, found.end)));
let row = state.row_of(found.line, found.start);
if row < state.scroll_offset || row >= state.scroll_offset + height {
let max = state.total_lines.saturating_sub(height);
state.scroll_offset = row.saturating_sub(height / 3).min(max);
}
if !state.wrapping() && (found.start < state.horizontal_offset || found.end > state.horizontal_offset + width) {
state.horizontal_offset = found.start.saturating_sub(width / 4);
}
Some(found.wrapped)
}
pub fn go_to_line(&mut self, number: usize) {
if self.is_editing() {
let height = self.editor_viewport_height.max(1);
if let Screen::Editor(state) = &mut self.screen {
let line = number.clamp(1, state.lines.len().max(1)) - 1;
state.selection_anchor = None;
state.cursor_line = line;
state.cursor_col = 0;
state.auto_scroll = true;
if line < state.scroll_offset || line >= state.scroll_offset + height {
state.scroll_offset = line.saturating_sub(height / 3);
}
}
} else if let Screen::Viewer(state) = &mut self.screen {
let line = number.clamp(1, state.line_count().max(1)) - 1;
state.scroll_offset = state.row_of(line, 0).min(state.total_lines.saturating_sub(self.viewer_viewport_height));
}
}
pub fn editor_rehighlight_from(&mut self, from: usize) {
if let Screen::Editor(state) = &mut self.screen {
if state.line_states.is_empty() {
return; }
let extension = state.file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
crate::viewer::highlight_from(&state.lines, extension, from, &mut state.highlighted_lines, &mut state.line_states);
}
}
pub fn editor_scroll_up(&mut self) {
if let Screen::Editor(state) = &mut self.screen
&& state.scroll_offset > 0
{
state.scroll_offset -= 1;
state.auto_scroll = false;
}
}
pub fn editor_scroll_down(&mut self) {
if let Screen::Editor(state) = &mut self.screen {
let max = state.lines.len().saturating_sub(self.editor_viewport_height);
if state.scroll_offset < max {
state.scroll_offset += 1;
state.auto_scroll = false;
}
}
}
pub fn editor_scroll_left(&mut self) {
if let Screen::Editor(state) = &mut self.screen {
state.horizontal_offset = state.horizontal_offset.saturating_sub(1);
state.auto_scroll = false;
}
}
pub fn editor_scroll_right(&mut self) {
if let Screen::Editor(state) = &mut self.screen {
state.horizontal_offset += 1;
state.auto_scroll = false;
}
}
fn push_undo(&mut self, range: std::ops::RangeInclusive<usize>) {
if let Screen::Editor(state) = &mut self.screen {
let last = (*range.end()).min(state.lines.len().saturating_sub(1));
let first = (*range.start()).min(last);
state.undo_stack.push(EditStep {
first_line: first,
before: state.lines[first..=last].to_vec(),
total_lines_before: state.lines.len(),
cursor: (state.cursor_line, state.cursor_col),
});
}
}
fn finish_edit(&mut self) {
if let Screen::Editor(state) = &mut self.screen {
let Some(step) = state.undo_stack.last() else {
return;
};
let unchanged = state.lines.len() == step.total_lines_before && state.lines[step.first_line..step.first_line + step.before.len()] == step.before[..];
if unchanged {
state.undo_stack.pop();
return;
}
state.redo_stack.clear();
if state.undo_stack.len() > crate::constants::UNDO_LIMIT {
state.undo_stack.remove(0);
}
}
}
fn edit_range(&self, fallback: std::ops::RangeInclusive<usize>) -> std::ops::RangeInclusive<usize> {
match self.editor().and_then(|state| state.selection()) {
Some(((first, _), (last, _))) => first..=last,
None => fallback,
}
}
pub fn editor_undo(&mut self) {
self.step_history(true);
}
pub fn editor_redo(&mut self) {
self.step_history(false);
}
fn step_history(&mut self, undoing: bool) {
let mut from = None;
if let Screen::Editor(state) = &mut self.screen {
let stack = if undoing { &mut state.undo_stack } else { &mut state.redo_stack };
if let Some(step) = stack.pop() {
let removed = step.total_lines_before - step.before.len();
let replaced = state.lines.len().saturating_sub(removed);
let end = (step.first_line + replaced).min(state.lines.len());
let inverse = EditStep {
first_line: step.first_line,
before: state.lines[step.first_line..end].to_vec(),
total_lines_before: state.lines.len(),
cursor: (state.cursor_line, state.cursor_col),
};
state.lines.splice(step.first_line..end, step.before);
state.cursor_line = step.cursor.0.min(state.lines.len().saturating_sub(1));
state.cursor_col = step.cursor.1.min(state.lines[state.cursor_line].chars().count());
state.selection_anchor = None;
state.modified = true;
if state.cursor_line < state.scroll_offset {
state.scroll_offset = state.cursor_line;
}
let other = if undoing { &mut state.redo_stack } else { &mut state.undo_stack };
other.push(inverse);
if other.len() > crate::constants::UNDO_LIMIT {
other.remove(0);
}
from = Some(step.first_line);
}
}
if let Some(from) = from {
self.editor_rehighlight_from(from);
}
}
fn delete_selection(&mut self) -> Option<usize> {
let state = self.editor_mut()?;
let ((first_line, first_col), (last_line, last_col)) = state.selection()?;
let head = char_slice(&state.lines[first_line], 0, first_col);
let last = &state.lines[last_line];
let tail = char_slice(last, last_col, last.chars().count());
state.lines.splice(first_line..=last_line, [format!("{head}{tail}")]);
state.cursor_line = first_line;
state.cursor_col = first_col;
state.selection_anchor = None;
state.modified = true;
Some(first_line)
}
pub fn editor_select_all(&mut self) {
let height = self.editor_viewport_height;
if let Screen::Editor(state) = &mut self.screen {
let last_line = state.lines.len().saturating_sub(1);
state.selection_anchor = Some((0, 0));
state.cursor_line = last_line;
state.cursor_col = state.lines[last_line].chars().count();
state.scroll_offset = last_line.saturating_sub(height.saturating_sub(1));
}
}
pub fn editor_copy(&mut self) {
if let Some(text) = self.editor().and_then(|state| state.selected_text()) {
crate::utils::set_system_clipboard(&text);
self.clipboard = text;
}
}
pub fn editor_cut(&mut self) {
let line = self.editor().map_or(0, |state| state.cursor_line);
self.push_undo(self.edit_range(line..=line));
self.editor_copy();
if let Some(from) = self.delete_selection() {
self.editor_rehighlight_from(from);
}
self.finish_edit();
}
pub fn editor_paste(&mut self) {
let text = self.clipboard.clone();
self.editor_insert_text(&text);
}
pub fn editor_insert_text(&mut self, text: &str) {
if text.is_empty() {
return;
}
let line = self.editor().map_or(0, |state| state.cursor_line);
self.push_undo(self.edit_range(line..=line));
let text = text.replace("\r\n", "\n");
let mut rehighlight = self.delete_selection();
if let Screen::Editor(state) = &mut self.screen {
let line = state.lines[state.cursor_line].clone();
let head = char_slice(&line, 0, state.cursor_col);
let tail = char_slice(&line, state.cursor_col, line.chars().count());
let pieces: Vec<&str> = text.split('\n').collect();
let start_line = state.cursor_line;
if let [only] = pieces[..] {
state.lines[start_line] = format!("{head}{only}{tail}");
state.cursor_col += only.chars().count();
} else {
let last = pieces.len() - 1;
let mut replacement = Vec::with_capacity(pieces.len());
replacement.push(format!("{head}{}", pieces[0]));
replacement.extend(pieces[1..last].iter().map(|piece| (*piece).to_string()));
replacement.push(format!("{}{tail}", pieces[last]));
state.lines.splice(start_line..=start_line, replacement);
state.cursor_line = start_line + last;
state.cursor_col = pieces[last].chars().count();
}
state.modified = true;
state.selection_anchor = None;
rehighlight = Some(rehighlight.unwrap_or(start_line).min(start_line));
}
if let Some(from) = rehighlight {
self.editor_rehighlight_from(from);
}
self.finish_edit();
}
pub fn editor_prepare_move(&mut self, extend: bool) {
if let Screen::Editor(state) = &mut self.screen {
if extend {
if state.selection_anchor.is_none() {
state.selection_anchor = Some((state.cursor_line, state.cursor_col));
}
} else {
state.selection_anchor = None;
}
}
}
pub fn editor_cursor_up(&mut self) {
if let Screen::Editor(state) = &mut self.screen
&& state.cursor_line > 0
{
state.cursor_line -= 1;
state.clamp_col();
if state.cursor_line < state.scroll_offset {
state.scroll_offset = state.cursor_line;
}
}
}
pub fn editor_cursor_down(&mut self) {
if let Screen::Editor(state) = &mut self.screen
&& state.cursor_line < state.lines.len().saturating_sub(1)
{
state.cursor_line += 1;
state.clamp_col();
if state.cursor_line >= state.scroll_offset + self.editor_viewport_height {
state.scroll_offset = state.cursor_line - self.editor_viewport_height + 1;
}
}
}
pub fn editor_cursor_left(&mut self) {
if let Screen::Editor(state) = &mut self.screen {
if state.cursor_col > 0 {
state.cursor_col -= 1;
} else if state.cursor_line > 0 {
state.cursor_line -= 1;
state.cursor_col = state.lines[state.cursor_line].chars().count();
if state.cursor_line < state.scroll_offset {
state.scroll_offset = state.cursor_line;
}
}
}
}
pub fn editor_cursor_right(&mut self) {
if let Screen::Editor(state) = &mut self.screen {
let line_len = state.lines[state.cursor_line].chars().count();
if state.cursor_col < line_len {
state.cursor_col += 1;
} else if state.cursor_line < state.lines.len().saturating_sub(1) {
state.cursor_line += 1;
state.cursor_col = 0;
if state.cursor_line >= state.scroll_offset + self.editor_viewport_height {
state.scroll_offset = state.cursor_line - self.editor_viewport_height + 1;
}
}
}
}
pub fn editor_home(&mut self) {
if let Screen::Editor(state) = &mut self.screen {
state.cursor_col = 0;
}
}
pub fn editor_end(&mut self) {
if let Screen::Editor(state) = &mut self.screen {
state.cursor_col = state.lines[state.cursor_line].chars().count();
}
}
pub fn editor_page_up(&mut self) {
if let Screen::Editor(state) = &mut self.screen {
let page = self.editor_viewport_height.saturating_sub(1);
state.cursor_line = state.cursor_line.saturating_sub(page);
state.scroll_offset = state.scroll_offset.saturating_sub(page);
state.clamp_col();
}
}
pub fn editor_page_down(&mut self) {
if let Screen::Editor(state) = &mut self.screen {
let page = self.editor_viewport_height.saturating_sub(1);
let max_line = state.lines.len().saturating_sub(1);
state.cursor_line = (state.cursor_line + page).min(max_line);
let max_scroll = state.lines.len().saturating_sub(self.editor_viewport_height);
state.scroll_offset = (state.scroll_offset + page).min(max_scroll);
state.clamp_col();
}
}
pub fn editor_insert_char(&mut self, c: char) {
let line = self.editor().map_or(0, |state| state.cursor_line);
self.push_undo(self.edit_range(line..=line));
self.delete_selection();
let mut from = None;
if let Screen::Editor(state) = &mut self.screen {
let line = &mut state.lines[state.cursor_line];
let byte_idx = char_to_byte(line, state.cursor_col);
line.insert(byte_idx, c);
state.cursor_col += 1;
state.modified = true;
from = Some(state.cursor_line);
}
if let Some(from) = from {
self.editor_rehighlight_from(from);
}
self.finish_edit();
}
pub fn editor_backspace(&mut self) {
let fallback = match self.editor() {
Some(state) if state.cursor_col == 0 => state.cursor_line.saturating_sub(1)..=state.cursor_line,
Some(state) => state.cursor_line..=state.cursor_line,
None => return,
};
self.push_undo(self.edit_range(fallback));
if let Some(from) = self.delete_selection() {
self.editor_rehighlight_from(from);
self.finish_edit();
return;
}
let mut from = None;
if let Screen::Editor(state) = &mut self.screen {
if state.cursor_col > 0 {
let line = &mut state.lines[state.cursor_line];
let byte_start = char_to_byte(line, state.cursor_col - 1);
let byte_end = char_to_byte(line, state.cursor_col);
line.replace_range(byte_start..byte_end, "");
state.cursor_col -= 1;
state.modified = true;
from = Some(state.cursor_line);
} else if state.cursor_line > 0 {
let current_line = state.lines.remove(state.cursor_line);
state.cursor_line -= 1;
state.cursor_col = state.lines[state.cursor_line].chars().count();
state.lines[state.cursor_line].push_str(¤t_line);
state.modified = true;
from = Some(state.cursor_line);
if state.cursor_line < state.scroll_offset {
state.scroll_offset = state.cursor_line;
}
}
}
if let Some(from) = from {
self.editor_rehighlight_from(from);
}
self.finish_edit();
}
pub fn editor_delete(&mut self) {
let fallback = match self.editor() {
Some(state) if state.cursor_col >= state.lines[state.cursor_line].chars().count() => state.cursor_line..=state.cursor_line + 1,
Some(state) => state.cursor_line..=state.cursor_line,
None => return,
};
self.push_undo(self.edit_range(fallback));
if let Some(from) = self.delete_selection() {
self.editor_rehighlight_from(from);
self.finish_edit();
return;
}
let mut from = None;
if let Screen::Editor(state) = &mut self.screen {
let line_len = state.lines[state.cursor_line].chars().count();
if state.cursor_col < line_len {
let line = &mut state.lines[state.cursor_line];
let byte_start = char_to_byte(line, state.cursor_col);
let byte_end = char_to_byte(line, state.cursor_col + 1);
line.replace_range(byte_start..byte_end, "");
state.modified = true;
from = Some(state.cursor_line);
} else if state.cursor_line < state.lines.len().saturating_sub(1) {
let next_line = state.lines.remove(state.cursor_line + 1);
state.lines[state.cursor_line].push_str(&next_line);
state.modified = true;
from = Some(state.cursor_line);
}
}
if let Some(from) = from {
self.editor_rehighlight_from(from);
}
self.finish_edit();
}
pub fn editor_enter(&mut self) {
let line = self.editor().map_or(0, |state| state.cursor_line);
self.push_undo(self.edit_range(line..=line));
self.delete_selection();
let mut from = None;
if let Screen::Editor(state) = &mut self.screen {
from = Some(state.cursor_line);
let line = &mut state.lines[state.cursor_line];
let byte_idx = char_to_byte(line, state.cursor_col);
let new_line = line[byte_idx..].to_string();
line.truncate(byte_idx);
state.cursor_line += 1;
state.lines.insert(state.cursor_line, new_line);
state.cursor_col = 0;
state.modified = true;
if state.cursor_line >= state.scroll_offset + self.editor_viewport_height {
state.scroll_offset = state.cursor_line - self.editor_viewport_height + 1;
}
}
if let Some(from) = from {
self.editor_rehighlight_from(from);
}
self.finish_edit();
}
pub fn editor_save(&mut self) -> Result<Saved, String> {
if let Some(state) = self.editor() {
let now = DiskStamp::of(&state.file_path);
if now != state.disk_stamp {
return Ok(Saved::ChangedOnDisk { opened: state.disk_stamp, now });
}
}
self.editor_save_over()?;
Ok(Saved::Done)
}
pub fn editor_save_over(&mut self) -> Result<(), String> {
if let Screen::Editor(state) = &mut self.screen {
let content = state.lines.join(state.line_ending);
crate::fs_ops::save_file(&state.file_path, content.as_bytes()).map_err(|e| e.to_string())?;
state.modified = false;
state.disk_stamp = DiskStamp::of(&state.file_path);
self.reload_panel(true, None);
self.reload_panel(false, None);
}
Ok(())
}
pub fn editor_is_modified(&self) -> bool {
self.editor().is_some_and(|s| s.modified)
}
pub fn toggle_selection(&mut self) {
self.toggle_selection_inner(true);
}
pub fn toggle_selection_no_size(&mut self) {
self.toggle_selection_inner(false);
}
fn toggle_selection_inner(&mut self, calculate_size: bool) {
let mut to_size: Option<PathBuf> = None;
{
let (state, children, selected_set, current_dir) = if self.is_left_active {
(&mut self.state_left, &self.children_left, &mut self.selected_left, &self.dir_left)
} else {
(&mut self.state_right, &self.children_right, &mut self.selected_right, &self.dir_right)
};
if let Some(index) = state.selected() {
if index < children.len() && children[index].name != ".." {
let item = &children[index];
if !selected_set.remove(&item.name_os) {
selected_set.insert(item.name_os.clone());
if calculate_size && item.is_dir {
to_size = Some(item.path_in(current_dir));
}
}
}
let len = children.len();
if len > 0 {
state.select(Some((index + 1).min(len - 1)));
}
}
}
if let Some(path) = to_size {
self.start_dir_size(path);
}
}
pub fn start_dir_size(&mut self, path: PathBuf) {
if self.dir_sizing.iter().any(|sizing| sizing.path == path) {
return;
}
let cancel = Arc::new(AtomicBool::new(false));
let (sender, updates) = mpsc::channel();
let (worker_path, worker_cancel) = (path.clone(), Arc::clone(&cancel));
std::thread::spawn(move || {
let (mut found, mut reported) = (0, Instant::now());
let result = crate::fs_ops::walk_dir_size(&worker_path, &worker_cancel, &mut |bytes| {
found += bytes;
if reported.elapsed() >= crate::constants::SIZE_PROGRESS_INTERVAL {
reported = Instant::now();
let _ = sender.send(SizeUpdate::Found(found));
}
});
let _ = sender.send(SizeUpdate::Finished(result.map_err(|e| e.to_string())));
});
self.dir_sizing.push(DirSizing { path, found: 0, cancel, updates });
}
pub fn poll_dir_sizes(&mut self) {
let mut finished = Vec::new();
self.dir_sizing.retain_mut(|sizing| {
loop {
match sizing.updates.try_recv() {
Ok(SizeUpdate::Found(found)) => sizing.found = found,
Ok(SizeUpdate::Finished(result)) => {
finished.push((sizing.path.clone(), result));
break false;
}
Err(TryRecvError::Empty) => break true,
Err(TryRecvError::Disconnected) => break false,
}
}
});
for (path, result) in finished {
match result {
Ok(Some(size)) => {
self.dir_sizes.insert(path, size);
}
Ok(None) => {}
Err(e) => self.display_error(format!("Cannot calculate size: {}", e)),
}
}
}
pub fn cancel_dir_sizes(&mut self) {
for sizing in self.dir_sizing.drain(..) {
sizing.cancel.store(true, Ordering::Relaxed);
}
}
pub fn dir_size_so_far(&self, path: &Path) -> Option<u64> {
self.dir_sizing.iter().find(|sizing| sizing.path == path).map(|sizing| sizing.found)
}
pub fn popup_is_open(&self) -> bool {
self.error.is_some() || self.job.is_some() || self.dialog.as_ref().is_some_and(Dialog::is_popup)
}
pub fn is_modal_open(&self) -> bool {
self.popup_is_open() || !matches!(self.screen, Screen::Panels) || matches!(self.dialog, Some(Dialog::Rename(_)))
}
fn start_job<F>(&mut self, kind: TransferKind, clashes: Clashes, work: F)
where
F: FnOnce(JobProgress<'_>) + Send + 'static,
{
let cancel = Arc::new(AtomicBool::new(false));
let (sender, updates) = mpsc::channel();
let (answers, worker_answers) = mpsc::channel();
let worker_cancel = Arc::clone(&cancel);
std::thread::spawn(move || work(JobProgress::new(&sender, &worker_cancel, &worker_answers, clashes)));
self.job = Some(TransferJob {
kind,
total: None,
done: 0,
current: PathBuf::new(),
started: Instant::now(),
cancel,
updates,
problem: None,
clash: None,
answers,
skipped: 0,
});
}
pub fn start_transfer(&mut self, items: Vec<(PathBuf, PathBuf, bool)>, is_copy: bool, clashes: Clashes) {
let kind = if is_copy { TransferKind::Copy } else { TransferKind::Move };
self.start_job(kind, clashes, move |progress| run_transfer(items, is_copy, progress));
}
pub fn start_delete(&mut self, items: Vec<(PathBuf, bool)>) {
self.start_job(TransferKind::Delete, Clashes::Ask, move |progress| run_delete(items, progress));
}
pub fn start_trash(&mut self, items: Vec<(PathBuf, bool)>) {
self.start_job(TransferKind::Trash, Clashes::Ask, move |progress| run_trash(items, progress));
}
pub fn cancel_transfer(&mut self) {
if let Some(job) = &mut self.job {
job.cancel.store(true, Ordering::Relaxed);
job.answer(Answer::Abort);
}
}
pub fn poll_transfer(&mut self) {
let Some(job) = &mut self.job else {
return;
};
let mut finished = None;
loop {
match job.updates.try_recv() {
Ok(JobUpdate::Total(amount)) => job.total = Some(amount),
Ok(JobUpdate::Starting(path)) => job.current = path,
Ok(JobUpdate::Advanced(amount)) => job.done += amount,
Ok(JobUpdate::Problem(problem)) => job.problem = Some(problem),
Ok(JobUpdate::Clash(clash)) => job.clash = Some(clash),
Ok(JobUpdate::Skipped) => job.skipped += 1,
Ok(JobUpdate::Finished(result)) => {
finished = Some(result);
break;
}
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => {
finished = Some(Err("Transfer stopped unexpectedly".to_string()));
break;
}
}
}
let Some(result) = finished else {
return;
};
let job = self.job.take().expect("checked at the top");
self.reload_panel(true, None);
self.reload_panel(false, None);
self.clear_active_selections();
self.dir_sizes.clear();
self.cancel_dir_sizes();
self.quit_armed = false;
match result {
Ok(Transfer::Done) if job.skipped > 0 => {
let entries = if job.skipped == 1 { "entry" } else { "entries" };
self.display_error(format!("{} finished, {} {entries} skipped", job.kind.title(), job.skipped))
}
Ok(Transfer::Done) => {}
Ok(Transfer::Cancelled) => {
let far = match job.kind {
TransferKind::Delete => format!("{} entries", job.done),
TransferKind::Trash => format!("{} items", job.done),
_ => crate::utils::format_size(job.done),
};
self.display_error(format!("{} cancelled after {far}", job.kind.title()))
}
Err(e) => self.display_error(e),
}
}
fn listing(&self, is_left: bool) -> &Option<PendingListing> {
if is_left { &self.listing_left } else { &self.listing_right }
}
fn listing_mut(&mut self, is_left: bool) -> &mut Option<PendingListing> {
if is_left { &mut self.listing_left } else { &mut self.listing_right }
}
pub fn panel_dir(&self, is_left: bool) -> PathBuf {
match self.listing(is_left) {
Some(pending) if pending.navigate => pending.dir.clone(),
_ => {
if is_left {
self.dir_left.clone()
} else {
self.dir_right.clone()
}
}
}
}
pub fn panel_busy(&self, is_left: bool) -> bool {
self.listing(is_left).is_some()
}
pub fn open_dir(&mut self, is_left: bool, dir: PathBuf, select: Option<&std::ffi::OsStr>) -> bool {
self.request_listing(is_left, dir, true, select.map(std::ffi::OsStr::to_os_string), None, Travel::Go).unwrap_or(true)
}
pub fn open_in_panel(&mut self, is_left: bool) {
let (children, state, dir) = if self.is_left_active {
(&self.children_left, &self.state_left, &self.dir_left)
} else {
(&self.children_right, &self.state_right, &self.dir_right)
};
let item = state.selected().and_then(|index| children.get(index));
let (target, select) = match item {
Some(item) if item.name == ".." => match dir.parent() {
Some(parent) => (parent.to_path_buf(), dir.file_name().map(std::ffi::OsStr::to_os_string)),
None => return,
},
Some(item) if item.is_dir => (item.path_in(dir), None),
Some(item) => (dir.clone(), Some(item.name_os.clone())),
None => (dir.clone(), None),
};
self.open_dir(is_left, target, select.as_deref());
}
pub fn reload_panel(&mut self, is_left: bool, prefer: Option<&std::ffi::OsStr>) {
if self.listing(is_left).as_ref().is_some_and(|pending| pending.navigate) {
return;
}
let dir = if is_left { self.dir_left.clone() } else { self.dir_right.clone() };
self.request_listing(is_left, dir, false, prefer.map(std::ffi::OsStr::to_os_string), None, Travel::Stay);
}
fn request_listing(&mut self, is_left: bool, dir: PathBuf, navigate: bool, select: Option<OsString>, tab: Option<TabSwitch>, travel: Travel) -> Option<bool> {
let (sender, answer) = mpsc::channel();
let (worker_dir, options) = (dir.clone(), self.options.clone());
std::thread::spawn(move || {
let _ = sender.send(read_listing(&worker_dir, &options));
});
let pending = PendingListing { dir, navigate, select, tab, travel, answer };
match pending.answer.recv_timeout(self.listing_wait) {
Ok(listed) => Some(self.apply_listing(is_left, pending, listed)),
Err(mpsc::RecvTimeoutError::Timeout) => {
*self.listing_mut(is_left) = Some(pending);
None
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
self.display_error(format!("Cannot read {}", pending.dir.display()));
self.undo_tab(is_left, pending.tab);
Some(false)
}
}
}
pub fn poll_listings(&mut self) {
for is_left in [true, false] {
let Some(pending) = self.listing_mut(is_left).take() else {
continue;
};
match pending.answer.try_recv() {
Ok(listed) => {
self.apply_listing(is_left, pending, listed);
}
Err(TryRecvError::Empty) => *self.listing_mut(is_left) = Some(pending),
Err(TryRecvError::Disconnected) => {
self.display_error(format!("Cannot read {}", pending.dir.display()));
self.undo_tab(is_left, pending.tab);
}
}
}
}
pub fn cancel_listing(&mut self, is_left: bool) {
if let Some(pending) = self.listing_mut(is_left).take() {
self.undo_tab(is_left, pending.tab);
}
}
fn apply_listing(&mut self, is_left: bool, pending: PendingListing, listed: Listed) -> bool {
let Some(found) = listed.dir else {
self.display_error(format!("No such directory: {}", pending.dir.display()));
self.undo_tab(is_left, pending.tab);
self.forget_travel(is_left, pending.travel);
return false;
};
if pending.navigate
&& found == pending.dir
&& let Err(e) = &listed.items
{
self.display_error(format!("Cannot open {}: {}", pending.dir.display(), e));
self.undo_tab(is_left, pending.tab);
self.forget_travel(is_left, pending.travel);
return false;
}
let to_file = pending.travel == Travel::GoTo && found != pending.dir && pending.dir.parent() == Some(found.as_path()) && listed.items.as_ref().is_ok_and(|items| items.iter().any(|item| Some(item.name_os.as_os_str()) == pending.dir.file_name()));
if pending.travel == Travel::GoTo && found != pending.dir && !to_file {
self.display_error(format!("No such file or directory: {}", pending.dir.display()));
return false;
}
let leaving = self.panel_as_tab(is_left);
let relocated = found != pending.dir && !to_file;
if pending.navigate || relocated {
if is_left {
self.dir_left = found.clone();
self.selected_left.clear();
self.state_left.select(Some(0));
} else {
self.dir_right = found.clone();
self.selected_right.clear();
self.state_right.select(Some(0));
}
self.search_clear();
}
let (children, state) = if is_left { (&self.children_left, &self.state_left) } else { (&self.children_right, &self.state_right) };
let previous_index = state.selected().unwrap_or(0);
let wanted = if to_file {
pending.dir.file_name().map(std::ffi::OsStr::to_os_string)
} else if relocated {
None
} else if pending.navigate {
pending.select
} else {
pending.select.or_else(|| children.get(previous_index).map(|item| item.name_os.clone()))
};
match listed.items {
Ok(items) => {
let selected = if is_left { &mut self.selected_left } else { &mut self.selected_right };
prune_selection(selected, &items);
let index = wanted.and_then(|name| items.iter().position(|item| item.name_os == name)).unwrap_or(previous_index).min(items.len().saturating_sub(1));
if is_left {
self.children_left = items;
self.state_left.select(Some(index));
} else {
self.children_right = items;
self.state_right.select(Some(index));
}
}
Err(e) => self.display_error(e.to_string()),
}
if is_left {
self.disk_left = listed.disk;
} else {
self.disk_right = listed.disk;
}
let watch = if is_left { &self.watch_left } else { &self.watch_right };
if watch.as_ref().is_none_or(|watch| watch.dir != found) {
let watch = crate::background::watch_dir(is_left, found.clone(), listed.stamp, listed.disk, self.watch_interval, self.watch_sender.clone());
if is_left {
self.watch_left = Some(watch);
} else {
self.watch_right = Some(watch);
}
}
if let Some(tab) = pending.tab {
let (children, selected) = if is_left { (&self.children_left, &mut self.selected_left) } else { (&self.children_right, &mut self.selected_right) };
if relocated {
selected.clear();
} else {
*selected = tab.selected;
prune_selection(selected, children);
}
}
self.preview = None;
self.cursor_detail = None;
if pending.navigate {
let stepped = matches!(pending.travel, Travel::Back | Travel::Forward);
let moved = self.record_travel(is_left, pending.travel, leaving, &found);
if stepped && !moved {
self.travel(pending.travel == Travel::Back);
}
}
true
}
fn record_travel(&mut self, is_left: bool, travel: Travel, leaving: Tab, found: &Path) -> bool {
let moved = leaving.dir != found;
let visit = Visit { dir: leaving.dir, cursor: leaving.cursor };
let tabs = self.tabs_mut(is_left);
let tab = &mut tabs.list[tabs.active];
match travel {
Travel::Stay => return moved,
Travel::Go | Travel::GoTo if moved => {
tab.back.push(visit);
tab.forward.clear();
}
Travel::Go | Travel::GoTo => {}
Travel::Back => {
tab.back.pop();
if moved {
tab.forward.push(visit);
}
}
Travel::Forward => {
tab.forward.pop();
if moved {
tab.back.push(visit);
}
}
}
if tab.back.len() > crate::constants::HISTORY_MAX {
tab.back.remove(0);
}
moved
}
fn forget_travel(&mut self, is_left: bool, travel: Travel) {
let tabs = self.tabs_mut(is_left);
let tab = &mut tabs.list[tabs.active];
match travel {
Travel::Back => {
tab.back.pop();
}
Travel::Forward => {
tab.forward.pop();
}
_ => {}
}
}
pub fn travel(&mut self, back: bool) {
let is_left = self.is_left_active;
if self.panel_busy(is_left) {
return;
}
let tabs = self.tabs_mut(is_left);
let tab = &tabs.list[tabs.active];
let Some(visit) = (if back { tab.back.last() } else { tab.forward.last() }).cloned() else {
return;
};
let travel = if back { Travel::Back } else { Travel::Forward };
self.request_listing(is_left, visit.dir, true, visit.cursor, None, travel);
}
pub fn open_go_to(&mut self) {
let mut text = self.panel_dir(self.is_left_active).display().to_string();
if !text.ends_with(std::path::is_separator) {
text.push(std::path::MAIN_SEPARATOR);
}
let mut input = TextInput::new();
input.set(text);
self.dialog = Some(Dialog::Prompt(PromptKind::GoTo, input));
}
fn go_to_path(&mut self, text: &str) {
if text.trim().is_empty() {
return;
}
let is_left = self.is_left_active;
let path = crate::fs_ops::tidy_path(&crate::fs_ops::typed_path(text, &self.panel_dir(is_left)));
self.request_listing(is_left, path, true, None, None, Travel::GoTo);
}
pub fn complete_go_to(&mut self) {
let Some(Dialog::Prompt(PromptKind::GoTo, input)) = &self.dialog else {
return;
};
let (text, base, hidden) = (input.text.clone(), self.panel_dir(self.is_left_active), self.options.show_hidden);
let (sender, answer) = mpsc::channel();
std::thread::spawn(move || {
let _ = sender.send(crate::fs_ops::complete_path(&text, &base, hidden));
});
if let Ok(Some(completed)) = answer.recv_timeout(self.listing_wait)
&& let Some(Dialog::Prompt(PromptKind::GoTo, input)) = &mut self.dialog
{
input.set(completed);
}
}
fn undo_tab(&mut self, is_left: bool, tab: Option<TabSwitch>) {
let Some(tab) = tab else {
return;
};
let tabs = self.tabs_mut(is_left);
match tab.undo {
TabUndo::Active(previous) => tabs.active = previous,
TabUndo::Reopen(index, closed) => {
tabs.list.insert(index, closed);
tabs.active = index;
}
}
}
pub fn poll_watchers(&mut self) {
while let Ok(event) = self.watch_events.try_recv() {
match event {
WatchEvent::Changed { is_left, dir } => {
if dir == *(if is_left { &self.dir_left } else { &self.dir_right }) {
if is_left {
self.stale_left = true;
} else {
self.stale_right = true;
}
}
}
WatchEvent::Disk { is_left, dir, usage } => {
if dir == *(if is_left { &self.dir_left } else { &self.dir_right }) {
if is_left {
self.disk_left = usage;
} else {
self.disk_right = usage;
}
}
}
WatchEvent::Mounts(mounts) => self.mounts = mounts,
}
}
}
pub fn drive_strip_at(&self, column: u16, row: u16) -> Option<bool> {
let position = Position::new(column, row);
if self.drive_strip_left.area.contains(position) {
Some(true)
} else if self.drive_strip_right.area.contains(position) {
Some(false)
} else {
None
}
}
pub fn drive_at(&self, column: u16, row: u16) -> Option<(bool, StripHit)> {
let is_left = self.drive_strip_at(column, row)?;
let strip = if is_left { &self.drive_strip_left } else { &self.drive_strip_right };
let hit = strip_hit(strip, column)?;
match hit {
StripHit::Item(mount) if mount >= self.mounts.len() => None,
_ => Some((is_left, hit)),
}
}
pub fn scroll_drive_strip(&mut self, is_left: bool, forward: bool) {
let strip = if is_left { &mut self.drive_strip_left } else { &mut self.drive_strip_right };
strip.scroll(forward);
}
pub fn tab_strip_at(&self, column: u16, row: u16) -> Option<bool> {
let position = Position::new(column, row);
if self.tabs_left.strip.area.contains(position) {
Some(true)
} else if self.tabs_right.strip.area.contains(position) {
Some(false)
} else {
None
}
}
pub fn tab_at(&self, column: u16, row: u16) -> Option<(bool, StripHit)> {
let is_left = self.tab_strip_at(column, row)?;
let tabs = if is_left { &self.tabs_left } else { &self.tabs_right };
let hit = strip_hit(&tabs.strip, column)?;
match hit {
StripHit::Item(index) if index >= tabs.list.len() => None,
_ => Some((is_left, hit)),
}
}
pub fn scroll_tab_strip(&mut self, is_left: bool, forward: bool) {
let tabs = if is_left { &mut self.tabs_left } else { &mut self.tabs_right };
tabs.strip.scroll(forward);
}
fn tabs_mut(&mut self, is_left: bool) -> &mut Tabs {
if is_left { &mut self.tabs_left } else { &mut self.tabs_right }
}
fn panel_as_tab(&self, is_left: bool) -> Tab {
let (dir, children, state, selected) = if is_left {
(&self.dir_left, &self.children_left, &self.state_left, &self.selected_left)
} else {
(&self.dir_right, &self.children_right, &self.state_right, &self.selected_right)
};
let tabs = if is_left { &self.tabs_left } else { &self.tabs_right };
let shown = &tabs.list[tabs.active];
Tab {
dir: dir.clone(),
cursor: state.selected().and_then(|index| children.get(index)).map(|item| item.name_os.clone()),
selected: selected.clone(),
back: shown.back.clone(),
forward: shown.forward.clone(),
}
}
pub fn tab_dirs(&self, is_left: bool) -> (Vec<PathBuf>, usize) {
let (tabs, dir) = if is_left { (&self.tabs_left, &self.dir_left) } else { (&self.tabs_right, &self.dir_right) };
let mut dirs: Vec<PathBuf> = tabs.list.iter().map(|tab| tab.dir.clone()).collect();
dirs[tabs.active] = dir.clone();
(dirs, tabs.active)
}
pub fn new_tab(&mut self) {
let is_left = self.is_left_active;
if self.panel_busy(is_left) {
return;
}
let here = self.panel_as_tab(is_left);
let tabs = self.tabs_mut(is_left);
tabs.list[tabs.active] = here.clone();
tabs.list.insert(
tabs.active + 1,
Tab {
selected: HashSet::new(),
back: Vec::new(),
forward: Vec::new(),
..here
},
);
tabs.active += 1;
if is_left { self.selected_left.clear() } else { self.selected_right.clear() }
}
pub fn close_tab(&mut self, is_left: bool, index: usize) {
if self.panel_busy(is_left) {
return;
}
let tabs = self.tabs_mut(is_left);
if tabs.list.len() <= 1 || index >= tabs.list.len() {
return;
}
let closed = tabs.list.remove(index);
if index == tabs.active {
tabs.active = index.min(tabs.list.len() - 1);
self.show_tab(is_left, TabUndo::Reopen(index, closed));
} else if index < tabs.active {
tabs.active -= 1;
}
}
pub fn select_tab(&mut self, is_left: bool, index: usize) {
if self.panel_busy(is_left) {
return;
}
let here = self.panel_as_tab(is_left);
let tabs = self.tabs_mut(is_left);
if index >= tabs.list.len() || index == tabs.active {
return;
}
let previous = tabs.active;
tabs.list[previous] = here;
tabs.active = index;
self.show_tab(is_left, TabUndo::Active(previous));
}
pub fn cycle_tab(&mut self, forward: bool) {
let is_left = self.is_left_active;
let tabs = self.tabs_mut(is_left);
let count = tabs.list.len();
let next = if forward { (tabs.active + 1) % count } else { (tabs.active + count - 1) % count };
self.select_tab(is_left, next);
}
fn show_tab(&mut self, is_left: bool, undo: TabUndo) {
let tabs = self.tabs_mut(is_left);
let tab = tabs.list[tabs.active].clone();
self.request_listing(is_left, tab.dir, true, tab.cursor, Some(TabSwitch { selected: tab.selected, undo }), Travel::Stay);
}
pub fn current_mount(&self, is_left: bool) -> Option<usize> {
let dir = if is_left { &self.dir_left } else { &self.dir_right };
self.mounts
.iter()
.enumerate()
.filter(|(_, mount)| dir.starts_with(&mount.path))
.max_by_key(|(_, mount)| mount.path.as_os_str().len())
.map(|(index, _)| index)
}
pub fn open_drive_picker(&mut self, is_left: bool) {
if self.mounts.is_empty() {
return;
}
let current = self.current_mount(is_left).unwrap_or(0);
self.dialog = Some(Dialog::DrivePicker { is_left, index: current });
let strip = if is_left { &mut self.drive_strip_left } else { &mut self.drive_strip_right };
strip.followed = None;
}
pub fn move_drive_picker(&mut self, forward: bool) {
let count = self.mounts.len();
if let Some(Dialog::DrivePicker { index, .. }) = &mut self.dialog
&& count > 0
{
*index = if forward { (*index + 1) % count } else { (*index + count - 1) % count };
}
}
pub fn confirm_drive_picker(&mut self) {
if let Some(Dialog::DrivePicker { is_left, index }) = self.dialog.take()
&& let Some(mount) = self.mounts.get(index)
{
let path = mount.path.clone();
self.open_dir(is_left, path, None);
}
}
pub fn refresh_stale_panels(&mut self) {
if self.is_modal_open() {
return;
}
for is_left in [true, false] {
let stale = if is_left { self.stale_left } else { self.stale_right };
if stale && !self.panel_busy(is_left) {
if is_left {
self.stale_left = false;
} else {
self.stale_right = false;
}
self.reload_panel(is_left, None);
}
}
}
fn cursor_target(&self) -> Option<(PathBuf, String, bool)> {
let (children, state, dir) = if self.is_left_active {
(&self.children_left, &self.state_left, &self.dir_left)
} else {
(&self.children_right, &self.state_right, &self.dir_right)
};
let item = children.get(state.selected()?)?;
if item.name == ".." {
return None;
}
Some((item.path_in(dir), item.name_full.clone(), item.is_dir))
}
pub fn refresh_cursor_detail(&mut self) {
let target = self.cursor_target();
let path = target.as_ref().map(|(path, _, _)| path.clone());
let mut wait = Duration::ZERO;
if self.cursor_detail.as_ref().is_none_or(|detail| detail.path != path) {
let (name, awaiting) = match target {
Some((path, name, is_dir)) => {
self.detail_worker.ask((path, is_dir));
wait = self.detail_wait;
(name, true)
}
None => (String::new(), false),
};
self.cursor_detail = Some(CursorDetail {
path,
name,
size: String::new(),
modified: String::new(),
owner: String::new(),
attributes: String::new(),
link: None,
awaiting,
});
}
if !self.cursor_detail.as_ref().is_some_and(|detail| detail.awaiting) {
return;
}
let Some(((path, _), described)) = self.detail_worker.answer(wait) else {
return;
};
let date_format = self.options.date_format;
let Some(detail) = self.cursor_detail.as_mut().filter(|detail| detail.path.as_ref() == Some(&path)) else {
return;
};
detail.awaiting = false;
detail.size = described.as_ref().and_then(|described| described.size_bytes).map(|bytes| format!("{} bytes", crate::utils::grouped(bytes))).unwrap_or_default();
detail.modified = described.as_ref().and_then(|described| described.modified).map(|at| crate::utils::format_exact(at, date_format)).unwrap_or_default();
detail.owner = described.as_ref().map(|described| described.owner.clone()).unwrap_or_default();
detail.attributes = described.as_ref().map(|described| described.attributes.clone()).unwrap_or_default();
detail.link = described.and_then(|described| described.link);
}
pub fn refresh_preview(&mut self) {
if !self.show_preview {
self.preview = None;
return;
}
let target = self.cursor_target();
let path = target.as_ref().map(|(path, _, _)| path.clone());
let mut wait = Duration::ZERO;
if self.preview.as_ref().is_none_or(|preview| preview.path != path) {
let (label, awaiting) = match target {
Some((path, name, is_dir)) => {
self.preview_worker.ask((path, is_dir));
wait = self.detail_wait;
(name, true)
}
None => (String::new(), false),
};
self.preview = Some(PreviewState {
path,
label,
lines: Vec::new(),
bytes: Vec::new(),
awaiting,
});
}
if !self.preview.as_ref().is_some_and(|preview| preview.awaiting) {
return;
}
let Some(((path, _), content)) = self.preview_worker.answer(wait) else {
return;
};
let Some(preview) = self.preview.as_mut().filter(|preview| preview.path.as_ref() == Some(&path)) else {
return;
};
preview.awaiting = false;
match content {
Preview::Lines(lines) => preview.lines = lines,
Preview::Bytes(bytes) => preview.bytes = bytes,
}
}
pub fn clear_active_selections(&mut self) {
if self.is_left_active {
self.selected_left.clear();
} else {
self.selected_right.clear();
}
}
}
impl EditorState {
pub fn selection(&self) -> Option<((usize, usize), (usize, usize))> {
let anchor = self.selection_anchor?;
let cursor = (self.cursor_line, self.cursor_col);
if anchor == cursor {
return None;
}
Some(if anchor < cursor { (anchor, cursor) } else { (cursor, anchor) })
}
pub fn selected_text(&self) -> Option<String> {
let ((first_line, first_col), (last_line, last_col)) = self.selection()?;
if first_line == last_line {
return Some(char_slice(&self.lines[first_line], first_col, last_col));
}
let first = &self.lines[first_line];
let mut text = char_slice(first, first_col, first.chars().count());
for line in &self.lines[first_line + 1..last_line] {
text.push('\n');
text.push_str(line);
}
text.push('\n');
text.push_str(&char_slice(&self.lines[last_line], 0, last_col));
Some(text)
}
pub fn keep_in_view(&mut self, height: usize) {
let height = height.max(1);
if self.auto_scroll {
if self.cursor_line < self.scroll_offset {
self.scroll_offset = self.cursor_line;
} else if self.cursor_line >= self.scroll_offset + height {
self.scroll_offset = self.cursor_line + 1 - height;
}
}
self.scroll_offset = self.scroll_offset.min(self.lines.len().saturating_sub(height));
}
fn clamp_col(&mut self) {
let line_len = self.lines[self.cursor_line].chars().count();
self.cursor_col = self.cursor_col.min(line_len);
}
}
fn detect_line_ending(content: &str) -> &'static str {
let crlf = content.matches("\r\n").count();
let lf = content.matches('\n').count() - crlf;
if crlf > lf { "\r\n" } else { "\n" }
}
fn run_delete(items: Vec<(PathBuf, bool)>, mut progress: JobProgress<'_>) {
let _ = progress.updates.send(JobUpdate::Total(count_entries(&items)));
for (path, is_dir) in items {
match delete_path(path, is_dir, &mut progress) {
Ok(Transfer::Done) => {}
outcome => return progress.finish(outcome),
}
}
progress.finish(Ok(Transfer::Done));
}
fn run_trash(items: Vec<(PathBuf, bool)>, mut progress: JobProgress<'_>) {
let _ = progress.updates.send(JobUpdate::Total(items.len() as u64));
for (path, is_dir) in items {
match crate::fs_ops::trash_path(path, is_dir, &mut progress) {
Ok(Transfer::Done) => {}
outcome => return progress.finish(outcome),
}
}
progress.finish(Ok(Transfer::Done));
}
fn prune_selection(selected: &mut HashSet<OsString>, items: &[Item]) {
let names: HashSet<&OsString> = items.iter().map(|item| &item.name_os).collect();
selected.retain(|name| names.contains(name));
}
fn strip_hit(strip: &Strip, column: u16) -> Option<StripHit> {
let index = slot_at(&strip.slots, column.checked_sub(strip.area.x)?)?;
strip.hits.get(index).copied()
}
fn slot_at(slots: &[(u16, u16)], offset: u16) -> Option<usize> {
slots.iter().position(|&(start, width)| offset >= start && offset < start + width)
}
fn run_transfer(items: Vec<(PathBuf, PathBuf, bool)>, is_copy: bool, mut progress: JobProgress<'_>) {
let mut remaining = Vec::new();
for (source, dest, is_dir) in items {
if !is_copy && !path_exists(&dest) && rename_in_place(&source, &dest) {
continue;
}
remaining.push((source, dest, is_dir));
}
let _ = progress.updates.send(JobUpdate::Total(measure(&remaining)));
for (source, dest, is_dir) in remaining {
let result = if is_copy { copy_path(source, dest, is_dir, &mut progress) } else { move_path(source, dest, &mut progress) };
match result {
Ok(Transfer::Done) => {}
outcome => return progress.finish(outcome),
}
}
progress.finish(Ok(Transfer::Done));
}
fn recentre(offset: usize, extent: usize, before: usize, after: usize) -> usize {
if before == 0 || after <= extent {
return 0;
}
let middle = if before <= extent { 0.5 } else { (offset as f64 + extent as f64 / 2.0) / before as f64 };
let centre = middle * after as f64 - extent as f64 / 2.0;
(centre.round().max(0.0) as usize).min(after - extent)
}
fn char_slice(line: &str, from: usize, to: usize) -> String {
line[char_to_byte(line, from)..char_to_byte(line, to)].to_string()
}
fn char_to_byte(s: &str, char_idx: usize) -> usize {
s.char_indices().nth(char_idx).map(|(i, _)| i).unwrap_or(s.len())
}
#[cfg(test)]
impl AppState {
fn editor_state_cursor(&mut self, line: usize, col: usize) {
if let Some(state) = self.editor_mut() {
state.cursor_line = line;
state.cursor_col = col;
state.selection_anchor = None;
}
}
}
#[cfg(test)]
mod editor_tests {
use super::AppState;
fn editor_with(name: &str, text: &str) -> (AppState, std::path::PathBuf) {
let path = std::env::temp_dir().join(format!("fm84-{name}-{}.txt", std::process::id()));
std::fs::write(&path, text).unwrap();
let mut app_state = AppState::new();
app_state.options = crate::options::Options::default();
app_state.open_editor(path.clone()).unwrap();
(app_state, path)
}
fn answer_prompt(app_state: &mut AppState, kind: super::PromptKind, text: &str) {
app_state.open_prompt(kind);
let input = app_state.dialog_input().unwrap();
input.set(String::new());
text.chars().for_each(|c| input.insert(c));
app_state.confirm_prompt();
}
fn selected(app_state: &AppState) -> Option<((usize, usize), (usize, usize))> {
app_state.editor().unwrap().selection()
}
#[test]
fn find_selects_each_match_in_turn_and_wraps() {
let (mut app_state, path) = editor_with("find", "alpha beta\nBETA\ngamma beta\n");
app_state.editor_viewport_height = 10;
app_state.find(true, false);
assert!(matches!(app_state.dialog, Some(super::Dialog::Prompt(super::PromptKind::Find, _))));
app_state.dialog = None;
answer_prompt(&mut app_state, super::PromptKind::Find, "beta");
assert_eq!(selected(&app_state), Some(((0, 6), (0, 10))));
assert!(app_state.find_shown);
assert_eq!(app_state.find_note, None);
app_state.find(true, false);
assert_eq!(selected(&app_state), Some(((1, 0), (1, 4))), "lower case finds any case");
app_state.find(true, false);
assert_eq!(selected(&app_state), Some(((2, 6), (2, 10))));
app_state.find(true, false);
assert_eq!(selected(&app_state), Some(((0, 6), (0, 10))));
assert_eq!(app_state.find_note.as_deref(), Some("Wrapped to the top"));
app_state.find(false, false);
assert_eq!(selected(&app_state), Some(((2, 6), (2, 10))));
assert_eq!(app_state.find_note.as_deref(), Some("Wrapped to the bottom"));
app_state.open_prompt(super::PromptKind::Find);
assert_eq!(app_state.dialog_input().unwrap().text, "beta");
app_state.dialog = None;
answer_prompt(&mut app_state, super::PromptKind::Find, "BETAMAX");
assert_eq!(selected(&app_state), Some(((2, 6), (2, 10))));
assert_eq!(app_state.find_note.as_deref(), Some("Not found: BETAMAX"));
app_state.close_editor();
assert!(!app_state.find_shown);
assert_eq!(app_state.find_term, "BETAMAX");
std::fs::remove_file(path).unwrap();
}
#[test]
fn go_to_line_puts_the_cursor_on_it_and_keeps_inside_the_file() {
let text: String = (1..=100).map(|n| format!("line {n}\n")).collect();
let (mut app_state, path) = editor_with("goto", &text);
app_state.editor_viewport_height = 10;
answer_prompt(&mut app_state, super::PromptKind::GoToLine, "42");
let state = app_state.editor().unwrap();
assert_eq!((state.cursor_line, state.cursor_col), (41, 0));
assert!(state.scroll_offset <= 41 && 41 < state.scroll_offset + 10, "the line is on screen");
answer_prompt(&mut app_state, super::PromptKind::GoToLine, "100000");
assert_eq!(app_state.editor().unwrap().cursor_line, 100, "the last line");
answer_prompt(&mut app_state, super::PromptKind::GoToLine, "0");
assert_eq!(app_state.editor().unwrap().cursor_line, 0);
std::fs::remove_file(path).unwrap();
}
#[test]
fn the_viewer_finds_and_goes_to_lines_too() {
let path = std::env::temp_dir().join(format!("fm84-view-find-{}.txt", std::process::id()));
let text: String = (1..=50).map(|n| if n == 30 { "\tthe needle\n".to_string() } else { format!("hay {n}\n") }).collect();
std::fs::write(&path, text).unwrap();
let mut app_state = AppState::new();
app_state.options = crate::options::Options::default();
app_state.open_viewer(path.clone()).unwrap();
app_state.viewer_viewport_height = 10;
app_state.viewer_viewport_width = 40;
answer_prompt(&mut app_state, super::PromptKind::Find, "needle");
let state = app_state.viewer().unwrap();
let start = crate::display::tab_width() + 4;
assert_eq!(state.selected_range(), Some(((29, start), (29, start + 6))));
assert_eq!(state.selected_text().as_deref(), Some("needle"));
assert!(state.scroll_offset <= 29 && 29 < state.scroll_offset + 10);
answer_prompt(&mut app_state, super::PromptKind::GoToLine, "5");
assert_eq!(app_state.viewer().unwrap().scroll_offset, 4);
answer_prompt(&mut app_state, super::PromptKind::GoToLine, "50");
app_state.viewer_end();
let end = app_state.viewer().unwrap().scroll_offset;
app_state.viewer_home();
answer_prompt(&mut app_state, super::PromptKind::GoToLine, "50");
assert_eq!(app_state.viewer().unwrap().scroll_offset, end, "no further than the end allows");
std::fs::remove_file(path).unwrap();
}
#[test]
fn a_stray_byte_past_the_head_still_views_and_is_refused_for_editing() {
let path = std::env::temp_dir().join(format!("fm84-latin1-late-{}.txt", std::process::id()));
let mut bytes = vec![b'a'; 600];
bytes.extend_from_slice(b"\ncaf\xe9\n");
std::fs::write(&path, bytes).unwrap();
let mut app_state = AppState::new();
app_state.options = crate::options::Options::default();
app_state.open_viewer(path.clone()).unwrap();
let state = app_state.viewer().unwrap();
assert_eq!(state.content_lines[1], "caf\u{fffd}");
let refused = app_state.open_editor(path.clone()).unwrap_err();
assert!(refused.contains("not UTF-8"), "{refused}");
assert!(app_state.editor().is_none());
std::fs::remove_file(path).unwrap();
}
fn replace(app_state: &mut AppState, what: &str, with: &str) {
app_state.open_replace();
app_state.dialog_input().unwrap().set(what.to_string());
app_state.confirm_prompt();
app_state.dialog_input().unwrap().set(with.to_string());
app_state.confirm_prompt();
}
fn text(app_state: &AppState) -> String {
app_state.editor().unwrap().lines.join("\n")
}
fn asking(app_state: &AppState) -> bool {
matches!(app_state.dialog, Some(super::Dialog::Replacing { .. }))
}
#[test]
fn replace_asks_about_each_match_and_stops_where_it_began() {
let (mut app_state, path) = editor_with("replace-each", "a b a\nb a");
replace(&mut app_state, "a", "xy");
assert_eq!(selected(&app_state), Some(((0, 0), (0, 1))));
assert!(app_state.replace_step(true));
assert!(app_state.replace_step(false));
assert!(!app_state.replace_step(true));
assert!(!asking(&app_state));
assert_eq!(text(&app_state), "xy b a\nb xy");
assert_eq!(app_state.find_note.as_deref(), Some("Replaced 2"));
std::fs::remove_file(path).unwrap();
}
#[test]
fn replace_all_is_one_step_to_undo_and_never_chases_its_own_text() {
let (mut app_state, path) = editor_with("replace-all", "a a\na");
app_state.editor_state_cursor(1, 0);
replace(&mut app_state, "a", "aa");
app_state.replace_all();
assert_eq!(text(&app_state), "aa aa\naa");
assert_eq!(app_state.find_note.as_deref(), Some("Replaced 3"));
app_state.editor_undo();
assert_eq!(text(&app_state), "a a\na");
app_state.editor_redo();
assert_eq!(text(&app_state), "aa aa\naa");
std::fs::remove_file(path).unwrap();
}
#[test]
fn replace_stops_after_once_round_even_into_its_own_text() {
let (mut app_state, path) = editor_with("replace-once-round", "let x;\nlet y; z");
app_state.editor_state_cursor(1, 8);
replace(&mut app_state, "let", "/* let");
let mut steps = 1;
while app_state.replace_step(true) {
steps += 1;
assert!(steps < 10, "still replacing: {}", text(&app_state));
}
assert_eq!(text(&app_state), "/* let x;\n/* let y; z");
assert_eq!(app_state.find_note.as_deref(), Some("Replaced 2"));
std::fs::remove_file(path).unwrap();
}
#[test]
fn replace_all_highlights_as_a_fresh_read_would() {
let text: String = (0..40).map(|n| format!("let foo_{n} = foo(\"foo\"); // foo\n")).collect::<String>() + "fn tail() {}\n";
let path = std::env::temp_dir().join(format!("fm84-replace-highlight-{}.rs", std::process::id()));
std::fs::write(&path, &text).unwrap();
let mut app_state = AppState::new();
app_state.open_editor(path.clone()).unwrap();
let shape = |spans: &[Vec<ratatui::text::Span<'static>>]| -> Vec<Vec<(String, Option<ratatui::style::Color>)>> { spans.iter().map(|line| line.iter().map(|span| (span.content.to_string(), span.style.fg)).collect()).collect() };
for (what, with) in [("foo", "bar"), ("let", "/* let")] {
replace(&mut app_state, what, with);
app_state.replace_all();
let state = app_state.editor().unwrap();
assert!(!state.line_states.is_empty(), "highlighting back on");
let (fresh, _) = crate::viewer::highlight_all(&state.lines, "rs");
assert_eq!(shape(&state.highlighted_lines), shape(&fresh), "after {what} -> {with}");
}
std::fs::remove_file(path).unwrap();
}
#[test]
fn replacing_with_nothing_deletes_and_lower_case_matches_any_case() {
let (mut app_state, path) = editor_with("replace-nothing", "Foo bar foo");
replace(&mut app_state, "foo", "");
app_state.replace_all();
assert_eq!(text(&app_state), " bar ");
std::fs::remove_file(path).unwrap();
}
#[test]
fn going_round_moves_the_stopping_point_with_the_text_before_it() {
let (mut app_state, path) = editor_with("replace-shift", "ab ab ab");
app_state.editor_state_cursor(0, 4);
replace(&mut app_state, "ab", "abab");
app_state.replace_all();
assert_eq!(text(&app_state), "abab abab abab");
assert_eq!(app_state.find_note.as_deref(), Some("Replaced 3"));
replace(&mut app_state, "zzz", "y");
assert!(!asking(&app_state));
assert_eq!(app_state.find_note.as_deref(), Some("Not found: zzz"));
std::fs::remove_file(path).unwrap();
}
#[test]
fn the_viewer_wraps_long_lines_onto_rows() {
let path = std::env::temp_dir().join(format!("fm84-wrap-{}.txt", std::process::id()));
std::fs::write(&path, "short\none two three four five six seven needle eight\nlast").unwrap();
let mut app_state = AppState::new();
app_state.open_viewer(path.clone()).unwrap();
(app_state.viewer_viewport_width, app_state.viewer_viewport_height) = (10, 3);
app_state.viewer_toggle_wrap();
let state = app_state.viewer().unwrap();
assert!(state.wrapping());
assert_eq!(state.line_count(), 3);
assert_eq!(state.total_lines, 8);
assert_eq!(state.row(1), (1, 0, 8));
app_state.find_term = "needle".to_string();
app_state.find(true, true);
let state = app_state.viewer().unwrap();
assert_eq!(state.selected_text().as_deref(), Some("needle"));
let row = state.row_of(1, 38);
assert!(state.scroll_offset <= row && row < state.scroll_offset + 3, "row {row} off screen from {}", state.scroll_offset);
let state = app_state.viewer_mut().unwrap();
state.selection = Some(((1, 4), (1, 13)));
assert_eq!(state.selected_text().as_deref(), Some("two three"));
state.scroll_offset = 0;
assert_eq!(state.position_at(2, 3), (1, 11));
app_state.go_to_line(3);
assert_eq!(app_state.viewer().unwrap().scroll_offset, 5);
app_state.viewer_next_mode();
assert!(app_state.viewer().unwrap().rows.is_empty());
app_state.viewer_next_mode();
assert!(app_state.fit_viewer_wrap());
assert_eq!(app_state.viewer().unwrap().total_lines, 8);
app_state.viewer_mut().unwrap().scroll_offset = 3;
app_state.viewer_toggle_wrap();
let state = app_state.viewer().unwrap();
assert!(!state.wrapping() && state.rows.is_empty());
assert_eq!((state.scroll_offset, state.total_lines), (1, 3));
app_state.viewer_toggle_wrap();
app_state.close_viewer();
app_state.open_viewer(path.clone()).unwrap();
assert!(app_state.viewer().unwrap().wrap);
std::fs::remove_file(path).unwrap();
}
fn stacks(app_state: &AppState) -> (usize, usize) {
let state = app_state.editor().unwrap();
(state.undo_stack.len(), state.redo_stack.len())
}
#[test]
fn saving_updates_the_size_the_panels_show() {
let dir = std::env::temp_dir().join(format!("fm84-save-size-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("notes.txt");
std::fs::write(&path, "ab").unwrap();
let mut app_state = AppState::new();
app_state.options = crate::options::Options::default();
app_state.dir_left = dir.clone();
app_state.dir_right = dir.clone();
app_state.reload_panel(true, None);
app_state.reload_panel(false, None);
let size = |children: &[super::Item]| children.iter().find(|item| item.name_full == "notes.txt").unwrap().size_bytes;
assert_eq!(size(&app_state.children_left), 2);
app_state.open_editor(path.clone()).unwrap();
app_state.editor_insert_text("more text");
app_state.editor_save().unwrap();
assert_eq!(size(&app_state.children_left), 11);
assert_eq!(size(&app_state.children_right), 11);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn an_edit_that_changes_nothing_keeps_the_redo_history() {
let (mut app_state, path) = editor_with("noop", "ab\ncd");
app_state.editor_end();
app_state.editor_insert_char('x');
app_state.editor_undo();
assert_eq!(stacks(&app_state), (0, 1));
app_state.editor_mut().unwrap().cursor_col = 0;
app_state.editor_backspace();
app_state.editor_cut();
{
let state = app_state.editor_mut().unwrap();
state.cursor_line = 1;
state.cursor_col = 2;
}
app_state.editor_delete();
assert_eq!(stacks(&app_state), (0, 1));
app_state.editor_redo();
assert_eq!(app_state.editor().unwrap().lines, ["abx", "cd"]);
app_state.editor_undo();
app_state.editor_insert_char('y');
assert_eq!(stacks(&app_state), (1, 0));
std::fs::remove_file(path).unwrap();
}
}
#[cfg(test)]
mod job_tests {
use super::AppState;
#[test]
fn a_finished_job_forgets_directory_sizes() {
let dir = std::env::temp_dir().join(format!("fm84-sizes-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("doomed"), vec![0u8; 100]).unwrap();
let mut app_state = AppState::new();
app_state.dir_sizes.insert(dir.clone(), 100);
app_state.start_delete(vec![(dir.join("doomed"), false)]);
let started = std::time::Instant::now();
while app_state.job.is_some() {
assert!(started.elapsed() < std::time::Duration::from_secs(10), "delete never finished");
std::thread::sleep(std::time::Duration::from_millis(5));
app_state.poll_transfer();
}
assert!(app_state.dir_sizes.is_empty());
std::fs::remove_dir_all(&dir).unwrap();
}
}
#[cfg(test)]
mod tests {
use super::{AppState, Item, prune_selection, recentre, slot_at};
use crate::options::Options;
use std::collections::HashSet;
#[test]
fn the_options_highlight_stops_at_either_end() {
let mut app_state = AppState::new();
app_state.options_move(false);
assert_eq!(app_state.options_cursor, 0, "Up on the first row stays there");
let last = crate::options::OPTION_ROWS.len() - 1;
for _ in 0..=last {
app_state.options_move(true);
}
assert_eq!(app_state.options_cursor, last, "Down on the last row stays there");
}
#[cfg(unix)]
#[test]
fn a_name_that_is_not_utf8_still_names_its_file() {
use std::os::unix::ffi::OsStrExt;
let dir = std::env::temp_dir().join(format!("fm84-latin1-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let raw = std::ffi::OsStr::from_bytes(b"caf\xe9.txt");
std::fs::write(dir.join(raw), "contents").unwrap();
let mut app_state = AppState::new();
app_state.options = Options::default();
app_state.open_dir(true, dir.clone(), None);
let item = app_state.children_left.iter().find(|item| item.name_full != "..").unwrap().clone();
assert!(!item.name.is_empty());
let index = app_state.children_left.iter().position(|row| row.name_full == item.name_full).unwrap();
app_state.state_left.select(Some(index));
let (path, _, _) = app_state.cursor_target().unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "contents");
assert!(!dir.join(&item.name_full).exists());
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_reread_gathers_the_preview_and_the_detail_again() {
let dir = std::env::temp_dir().join(format!("fm84-reread-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("notes.txt"), "hello").unwrap();
let mut app_state = AppState::new();
app_state.options = Options::default();
app_state.show_preview = true;
app_state.open_dir(true, dir.clone(), Some("notes.txt".as_ref()));
app_state.refresh_cursor_detail();
app_state.refresh_preview();
assert_eq!(app_state.cursor_detail.as_ref().unwrap().size, "5 bytes");
assert_eq!(app_state.preview.as_ref().unwrap().lines, ["hello"]);
std::fs::write(dir.join("notes.txt"), "hello, a longer file now").unwrap();
app_state.reload_panel(true, None);
app_state.refresh_cursor_detail();
app_state.refresh_preview();
assert_eq!(app_state.cursor_detail.as_ref().unwrap().size, "24 bytes");
assert_eq!(app_state.preview.as_ref().unwrap().lines, ["hello, a longer file now"]);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn space_sizes_a_directory_off_the_ui_thread() {
let dir = std::env::temp_dir().join(format!("fm84-sizing-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("sub")).unwrap();
std::fs::write(dir.join("sub").join("data.bin"), vec![0u8; 4096]).unwrap();
let mut app_state = AppState::new();
app_state.options = Options::default();
app_state.open_dir(true, dir.clone(), Some("sub".as_ref()));
app_state.toggle_selection();
assert_eq!(app_state.dir_sizing.len(), 1);
let started = std::time::Instant::now();
while !app_state.dir_sizing.is_empty() {
assert!(started.elapsed() < std::time::Duration::from_secs(10), "sizing never finished");
std::thread::sleep(std::time::Duration::from_millis(5));
app_state.poll_dir_sizes();
}
assert_eq!(app_state.dir_sizes.get(&dir.join("sub")), Some(&4096));
app_state.dir_sizes.clear();
app_state.start_dir_size(dir.join("sub"));
app_state.cancel_dir_sizes();
assert!(app_state.dir_sizing.is_empty());
app_state.poll_dir_sizes();
assert!(app_state.dir_sizes.is_empty());
std::fs::remove_dir_all(&dir).unwrap();
}
#[cfg(unix)]
#[test]
fn a_job_asks_about_a_failure_and_goes_on_when_told_to_skip() {
let dir = std::env::temp_dir().join(format!("fm84-ask-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let tree = dir.join("tree");
std::fs::create_dir_all(&tree).unwrap();
std::fs::write(tree.join("a.txt"), "a").unwrap();
let pipe = std::ffi::CString::new(tree.join("pipe").to_str().unwrap()).unwrap();
assert_eq!(unsafe { libc::mkfifo(pipe.as_ptr(), 0o600) }, 0);
std::fs::write(tree.join("z.txt"), "z").unwrap();
let mut app_state = AppState::new();
app_state.options = Options::default();
app_state.start_transfer(vec![(tree.clone(), dir.join("copy"), true)], true, super::Clashes::Ask);
let started = std::time::Instant::now();
let wait = |app_state: &mut AppState, done: &dyn Fn(&AppState) -> bool| {
while !done(app_state) {
assert!(started.elapsed() < std::time::Duration::from_secs(10), "the job never got there");
std::thread::sleep(std::time::Duration::from_millis(5));
app_state.poll_transfer();
}
};
wait(&mut app_state, &|app_state| app_state.job.as_ref().is_some_and(|job| job.problem.is_some()));
assert_eq!(app_state.job.as_ref().unwrap().problem.as_ref().unwrap().path, tree.join("pipe"));
app_state.job.as_mut().unwrap().answer(super::Answer::Skip);
wait(&mut app_state, &|app_state| app_state.job.is_none());
assert_eq!(app_state.error.as_deref(), Some("Copy finished, 1 entry skipped"));
assert!(dir.join("copy").join("a.txt").exists());
assert!(dir.join("copy").join("z.txt").exists());
app_state.start_transfer(vec![(tree.clone(), dir.join("again"), true)], true, super::Clashes::Ask);
wait(&mut app_state, &|app_state| app_state.job.as_ref().is_some_and(|job| job.problem.is_some()));
app_state.cancel_transfer();
wait(&mut app_state, &|app_state| app_state.job.is_none());
assert!(app_state.error.as_deref().unwrap_or_default().starts_with("Copy cancelled"), "{:?}", app_state.error);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn files_in_the_way_are_asked_about_one_at_a_time() {
use super::{Answer, Clashes};
let dir = std::env::temp_dir().join(format!("fm84-clashes-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let then = |seconds: u64| std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(seconds);
let write = |path: &std::path::Path, text: &str, at: u64| {
std::fs::write(path, text).unwrap();
std::fs::File::options().write(true).open(path).unwrap().set_modified(then(at)).unwrap();
};
let fresh = |name: &str| {
let (from, onto) = (dir.join(name).join("from"), dir.join(name).join("onto"));
std::fs::create_dir_all(&from).unwrap();
std::fs::create_dir_all(&onto).unwrap();
for file in ["a", "b", "c"] {
write(&from.join(file), "new", 2_000_000_000);
write(&onto.join(file), "old", if file == "c" { 2_100_000_000 } else { 1_000_000_000 });
}
write(&from.join("only-new"), "new", 2_000_000_000);
(from, onto)
};
let run = |clashes: Clashes, answer: &dyn Fn(&str) -> Answer, from: &std::path::Path, onto: &std::path::Path| {
let mut app_state = AppState::new();
app_state.start_transfer(vec![(from.to_path_buf(), onto.to_path_buf(), true)], true, clashes);
let (mut asked, started) = (0, std::time::Instant::now());
while let Some(job) = app_state.job.as_mut() {
assert!(started.elapsed() < std::time::Duration::from_secs(10), "the job never finished");
if let Some(clash) = &job.clash {
let name = clash.dest.file_name().unwrap().to_string_lossy().into_owned();
asked += 1;
job.answer(answer(&name));
}
std::thread::sleep(std::time::Duration::from_millis(2));
app_state.poll_transfer();
}
let held: Vec<String> = ["a", "b", "c", "only-new"].iter().map(|file| std::fs::read_to_string(onto.join(file)).unwrap()).collect();
(asked, held, app_state.error)
};
let (from, onto) = fresh("each");
let (asked, held, said) = run(Clashes::Ask, &|name| if name == "b" { Answer::Skip } else { Answer::Overwrite }, &from, &onto);
assert_eq!(asked, 3);
assert_eq!(held, ["new", "old", "new", "new"]);
assert_eq!(said.as_deref(), Some("Copy finished, 1 entry skipped"));
let (from, onto) = fresh("all");
let (asked, held, _) = run(Clashes::Ask, &|_| Answer::OverwriteAll, &from, &onto);
assert_eq!((asked, held), (1, vec!["new".to_string(); 4]));
let (from, onto) = fresh("none");
let (asked, held, said) = run(Clashes::Ask, &|_| Answer::SkipAll, &from, &onto);
assert_eq!((asked, held), (1, vec!["old".into(), "old".into(), "old".into(), "new".into()]));
assert_eq!(said.as_deref(), Some("Copy finished, 3 entries skipped"));
let (from, onto) = fresh("older");
let (asked, held, _) = run(Clashes::Ask, &|_| Answer::OverwriteOlder, &from, &onto);
assert_eq!((asked, held), (1, vec!["new".into(), "new".into(), "old".into(), "new".into()]));
let (from, onto) = fresh("overwrite");
let (asked, held, _) = run(Clashes::ReplaceAll, &|_| Answer::Abort, &from, &onto);
assert_eq!((asked, held), (0, vec!["new".to_string(); 4]));
std::fs::remove_dir_all(&dir).unwrap();
}
#[cfg(unix)]
#[test]
fn an_unreadable_directory_is_refused_once() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("fm84-unreadable-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let locked = dir.join("locked");
std::fs::create_dir_all(&locked).unwrap();
std::fs::write(dir.join("here.txt"), "").unwrap();
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
if std::fs::read_dir(&locked).is_ok() {
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap();
return std::fs::remove_dir_all(&dir).unwrap();
}
let mut app_state = AppState::new();
app_state.options = Options::default();
app_state.watch_interval = std::time::Duration::from_millis(5);
app_state.open_dir(true, dir.clone(), None);
let listed = app_state.children_left.len();
app_state.open_dir(true, locked.clone(), None);
assert!(app_state.error.is_some());
assert_eq!(app_state.dir_left, dir);
assert_eq!(app_state.children_left.len(), listed);
app_state.reset_error();
std::thread::sleep(std::time::Duration::from_millis(50));
app_state.poll_watchers();
app_state.refresh_stale_panels();
assert!(app_state.error.is_none());
app_state.open_dir(true, locked.parent().unwrap().to_path_buf(), None);
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap();
app_state.open_dir(true, locked.clone(), None);
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
app_state.reload_panel(true, None);
assert!(app_state.error.is_some());
app_state.reset_error();
std::thread::sleep(std::time::Duration::from_millis(50));
app_state.poll_watchers();
app_state.refresh_stale_panels();
assert!(app_state.error.is_none());
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap();
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn each_tab_keeps_its_own_way_back() {
let dir = std::env::temp_dir().join(format!("fm84-tab-history-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("a")).unwrap();
std::fs::create_dir_all(dir.join("b")).unwrap();
let mut app_state = AppState::new();
app_state.is_left_active = true;
app_state.open_dir(true, dir.join("a"), None);
app_state.open_dir(true, dir.join("b"), None);
app_state.new_tab();
app_state.travel(true);
assert_eq!(app_state.dir_left, dir.join("b"), "a new tab has nowhere to go back to");
app_state.open_dir(true, dir.clone(), None);
app_state.select_tab(true, 0);
app_state.travel(true);
assert_eq!(app_state.dir_left, dir.join("a"));
app_state.select_tab(true, 1);
app_state.travel(true);
assert_eq!(app_state.dir_left, dir.join("b"));
app_state.travel(true);
assert_eq!(app_state.dir_left, dir.join("b"));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn ctrl_arrows_send_the_entry_under_the_cursor_to_a_panel() {
let dir = std::env::temp_dir().join(format!("fm84-open-in-panel-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("inner")).unwrap();
std::fs::write(dir.join("note.txt"), "").unwrap();
let mut app_state = AppState::new();
app_state.options = Options::default();
app_state.open_dir(true, dir.clone(), None);
app_state.open_dir(false, std::env::temp_dir(), None);
app_state.is_left_active = true;
let at = |app_state: &AppState, name: &str| app_state.children_left.iter().position(|item| item.name_full == name);
let cursor_right = |app_state: &AppState| app_state.state_right.selected().and_then(|index| app_state.children_right.get(index)).map(|item| item.name_full.clone());
app_state.state_left.select(at(&app_state, "inner"));
app_state.open_in_panel(false);
assert_eq!(app_state.dir_right, dir.join("inner"));
assert!(app_state.is_left_active);
assert_eq!(app_state.dir_left, dir);
app_state.state_left.select(at(&app_state, "note.txt"));
app_state.open_in_panel(false);
assert_eq!(app_state.dir_right, dir);
assert_eq!(cursor_right(&app_state).as_deref(), Some("note.txt"));
app_state.state_left.select(at(&app_state, ".."));
app_state.open_in_panel(false);
assert_eq!(app_state.dir_right, std::env::temp_dir());
assert_eq!(cursor_right(&app_state), dir.file_name().map(|name| name.to_string_lossy().into_owned()));
app_state.state_left.select(at(&app_state, "inner"));
app_state.open_in_panel(true);
assert_eq!(app_state.dir_left, dir.join("inner"));
std::fs::remove_dir_all(&dir).unwrap();
}
fn row(name: &str) -> Item {
Item {
name_os: name.into(),
name_full: name.to_string(),
name: name.to_string(),
extension: String::new(),
is_dir: false,
size: String::new(),
size_bytes: 0,
modified_at: None,
attributes: String::new(),
}
}
#[test]
fn plus_minus_and_star_select_by_pattern() {
let mut app_state = AppState::new();
app_state.is_left_active = true;
let directory = Item { is_dir: true, ..row("photos") };
app_state.children_left = vec![row(".."), directory, row("a.jpg"), row("B.JPG"), row("c.png"), row("notes.txt")];
let selected = |app_state: &AppState| {
let mut names: Vec<String> = app_state.selected_left.iter().map(|name| name.to_string_lossy().into_owned()).collect();
names.sort();
names
};
app_state.open_select_prompt(true);
app_state.dialog_input().unwrap().set("*.jpg;*.png".to_string());
app_state.confirm_prompt();
assert_eq!(selected(&app_state), ["B.JPG", "a.jpg", "c.png"]);
assert_eq!(app_state.select_pattern, "*.jpg;*.png");
app_state.open_select_prompt(false);
app_state.dialog_input().unwrap().set("?.png".to_string());
app_state.confirm_prompt();
assert_eq!(selected(&app_state), ["B.JPG", "a.jpg"]);
app_state.invert_selection(false);
assert_eq!(selected(&app_state), ["c.png", "notes.txt"]);
app_state.invert_selection(true);
assert_eq!(selected(&app_state), ["B.JPG", "a.jpg", "photos"]);
app_state.invert_selection(true);
assert_eq!(selected(&app_state), ["c.png", "notes.txt"]);
app_state.select_by(&crate::glob::Glob::new("*").unwrap(), true);
assert_eq!(selected(&app_state), ["B.JPG", "a.jpg", "c.png", "notes.txt"]);
app_state.select_by(&crate::glob::Glob::new("*/").unwrap(), true);
assert_eq!(selected(&app_state), ["B.JPG", "a.jpg", "c.png", "notes.txt", "photos"]);
app_state.open_select_prompt(false);
app_state.dialog_input().unwrap().set(String::new());
app_state.confirm_prompt();
assert_eq!(selected(&app_state).len(), 5);
assert_eq!(app_state.select_pattern, "?.png");
}
#[test]
fn a_selection_outlives_a_re_sort_but_not_its_file() {
let mut selected: HashSet<std::ffi::OsString> = ["one.txt", "two.txt"].iter().map(|name| name.into()).collect();
prune_selection(&mut selected, &[row("zzz.txt"), row("two.txt"), row("one.txt")]);
assert_eq!(selected.len(), 2);
prune_selection(&mut selected, &[row("renamed.txt"), row("two.txt")]);
assert_eq!(selected.iter().collect::<Vec<_>>(), ["two.txt"]);
prune_selection(&mut selected, &[row("one.txt"), row("two.txt")]);
assert_eq!(selected.iter().collect::<Vec<_>>(), ["two.txt"]);
prune_selection(&mut selected, &[]);
assert!(selected.is_empty());
}
#[cfg(unix)]
#[test]
fn names_that_read_alike_are_selected_apart() {
use std::os::unix::ffi::OsStrExt;
let dir = std::env::temp_dir().join(format!("fm84-alike-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
for raw in [&b"a\xfe"[..], &b"a\xff"[..]] {
std::fs::write(dir.join(std::ffi::OsStr::from_bytes(raw)), "").unwrap();
}
let mut app_state = AppState::new();
app_state.options = Options::default();
app_state.open_dir(true, dir.clone(), None);
let rows: Vec<Item> = app_state.children_left.iter().filter(|item| item.name != "..").cloned().collect();
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].name_full, rows[1].name_full);
let index = app_state.children_left.iter().position(|row| row.name_os == rows[0].name_os).unwrap();
app_state.state_left.select(Some(index));
app_state.toggle_selection_no_size();
assert_eq!(app_state.selected_left.iter().collect::<Vec<_>>(), [&rows[0].name_os]);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn tabs_keep_where_each_was_left() {
let dir = std::env::temp_dir().join(format!("fm84-tabs-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("inner")).unwrap();
for name in ["one.txt", "two.txt"] {
std::fs::write(dir.join(name), "").unwrap();
std::fs::write(dir.join("inner").join(name), "").unwrap();
}
let mut app_state = AppState::new();
app_state.options = Options::default();
app_state.is_left_active = true;
app_state.open_dir(true, dir.clone(), None);
let at = |app_state: &AppState, name: &str| app_state.children_left.iter().position(|item| item.name_full == name);
let cursor = |app_state: &AppState| app_state.state_left.selected().and_then(|index| app_state.children_left.get(index)).map(|item| item.name_full.clone());
app_state.selected_left.insert("one.txt".into());
app_state.state_left.select(at(&app_state, "two.txt"));
app_state.new_tab();
assert_eq!((app_state.tabs_left.list.len(), app_state.tabs_left.active), (2, 1));
assert_eq!(app_state.dir_left, dir);
assert!(app_state.selected_left.is_empty());
assert_eq!(cursor(&app_state).as_deref(), Some("two.txt"));
app_state.open_dir(true, dir.join("inner"), None);
app_state.selected_left.insert("two.txt".into());
assert_eq!(app_state.tab_dirs(true), (vec![dir.clone(), dir.join("inner")], 1));
app_state.select_tab(true, 0);
assert_eq!(app_state.dir_left, dir);
assert_eq!(cursor(&app_state).as_deref(), Some("two.txt"));
assert_eq!(app_state.selected_left.iter().collect::<Vec<_>>(), [&std::ffi::OsString::from("one.txt")]);
app_state.cycle_tab(true);
assert_eq!(app_state.dir_left, dir.join("inner"));
assert_eq!(app_state.selected_left.iter().collect::<Vec<_>>(), [&std::ffi::OsString::from("two.txt")]);
app_state.cycle_tab(true);
assert_eq!(app_state.tabs_left.active, 0);
app_state.select_tab(true, 1);
app_state.close_tab(true, 0);
assert_eq!((app_state.tabs_left.list.len(), app_state.tabs_left.active), (1, 0));
assert_eq!(app_state.dir_left, dir.join("inner"));
app_state.close_tab(true, 0);
assert_eq!(app_state.tabs_left.list.len(), 1);
app_state.new_tab();
app_state.open_dir(true, dir.clone(), None);
app_state.close_tab(true, 1);
assert_eq!(app_state.dir_left, dir.join("inner"));
assert_eq!(app_state.tabs_right.list.len(), 1);
std::fs::remove_dir_all(&dir).unwrap();
}
#[cfg(unix)]
#[test]
fn a_tab_that_cannot_be_read_is_refused_and_kept() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("fm84-tab-locked-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let locked = dir.join("locked");
std::fs::create_dir_all(&locked).unwrap();
std::fs::write(dir.join("here.txt"), "").unwrap();
let mut app_state = AppState::new();
app_state.options = Options::default();
app_state.is_left_active = true;
app_state.open_dir(true, dir.clone(), None);
app_state.new_tab();
app_state.open_dir(true, locked.clone(), None);
app_state.select_tab(true, 0);
app_state.selected_left.insert("here.txt".into());
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
if std::fs::read_dir(&locked).is_ok() {
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap();
return std::fs::remove_dir_all(&dir).unwrap();
}
app_state.select_tab(true, 1);
assert!(app_state.error.is_some());
assert_eq!(app_state.tabs_left.active, 0);
assert_eq!(app_state.dir_left, dir);
assert_eq!(app_state.selected_left.len(), 1);
assert_eq!(app_state.tab_dirs(true), (vec![dir.clone(), locked.clone()], 0));
app_state.reset_error();
app_state.close_tab(true, 0);
assert!(app_state.error.is_some());
assert_eq!(app_state.tab_dirs(true), (vec![dir.clone(), locked.clone()], 0));
assert_eq!(app_state.selected_left.len(), 1);
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap();
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_slow_directory_leaves_the_panel_as_it_was_until_it_lands() {
let dir = std::env::temp_dir().join(format!("fm84-slow-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("far")).unwrap();
std::fs::write(dir.join("far").join("there.txt"), "").unwrap();
let mut app_state = AppState::new();
app_state.is_left_active = true;
app_state.open_dir(true, dir.clone(), None);
let listed = app_state.children_left.len();
let (sender, answer) = std::sync::mpsc::channel();
app_state.listing_left = Some(super::PendingListing {
dir: dir.join("far"),
navigate: true,
select: None,
tab: None,
travel: super::Travel::Go,
answer,
});
assert!(app_state.panel_busy(true));
app_state.new_tab();
assert_eq!(app_state.tabs_left.list.len(), 1);
assert_eq!((app_state.dir_left.clone(), app_state.children_left.len()), (dir.clone(), listed));
app_state.reload_panel(true, None);
assert!(app_state.listing_left.as_ref().is_some_and(|pending| pending.dir == dir.join("far")));
app_state.poll_listings();
assert!(app_state.panel_busy(true), "nothing has landed yet");
sender.send(super::read_listing(&dir.join("far"), &app_state.options)).unwrap();
app_state.poll_listings();
assert!(!app_state.panel_busy(true));
assert_eq!(app_state.dir_left, dir.join("far"));
assert!(app_state.children_left.iter().any(|item| item.name_full == "there.txt"));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_late_refusal_or_esc_puts_the_tab_back() {
let dir = std::env::temp_dir().join(format!("fm84-late-tab-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut app_state = AppState::new();
app_state.open_dir(true, dir.clone(), None);
app_state.tabs_left = super::Tabs::new(vec![dir.clone(), dir.join("locked")], 0);
for refused in [true, false] {
let (sender, answer) = std::sync::mpsc::channel();
app_state.tabs_left.active = 1;
let tab = super::TabSwitch {
selected: HashSet::new(),
undo: super::TabUndo::Active(0),
};
app_state.listing_left = Some(super::PendingListing {
dir: dir.join("locked"),
navigate: true,
select: None,
tab: Some(tab),
travel: super::Travel::Stay,
answer,
});
if refused {
let denied = std::io::Error::from(std::io::ErrorKind::PermissionDenied);
sender
.send(super::Listed {
dir: Some(dir.join("locked")),
items: Err(denied),
stamp: None,
disk: None,
})
.unwrap();
app_state.poll_listings();
assert!(app_state.error.is_some());
app_state.reset_error();
} else {
app_state.cancel_listing(true);
}
assert!(!app_state.panel_busy(true));
assert_eq!(app_state.tabs_left.active, 0);
assert_eq!(app_state.dir_left, dir);
}
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn changes_and_details_arrive_from_their_threads() {
let dir = std::env::temp_dir().join(format!("fm84-watched-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("notes.txt"), "hello").unwrap();
let mut app_state = AppState::new();
app_state.watch_interval = std::time::Duration::from_millis(5);
app_state.open_dir(true, dir.clone(), Some("notes.txt".as_ref()));
std::fs::write(dir.join("new.txt"), "").unwrap();
let started = std::time::Instant::now();
while !app_state.children_left.iter().any(|item| item.name_full == "new.txt") {
assert!(started.elapsed() < std::time::Duration::from_secs(10), "the change was never seen");
std::thread::sleep(std::time::Duration::from_millis(5));
app_state.poll_watchers();
app_state.refresh_stale_panels();
}
app_state.detail_wait = std::time::Duration::ZERO;
app_state.show_preview = true;
let at = app_state.children_left.iter().position(|item| item.name_full == "notes.txt");
app_state.state_left.select(at);
app_state.cursor_detail = None;
app_state.refresh_cursor_detail();
assert_eq!(app_state.cursor_detail.as_ref().unwrap().name, "notes.txt");
let started = std::time::Instant::now();
loop {
app_state.refresh_cursor_detail();
app_state.refresh_preview();
let detail = app_state.cursor_detail.as_ref().unwrap();
let preview = app_state.preview.as_ref().unwrap();
if !detail.awaiting && !preview.awaiting {
assert_eq!(detail.size, "5 bytes");
assert_eq!(preview.lines, ["hello"]);
break;
}
assert!(started.elapsed() < std::time::Duration::from_secs(10), "the details never came");
std::thread::sleep(std::time::Duration::from_millis(5));
}
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_click_lands_on_the_icon_it_looks_like() {
let slots: Vec<(u16, u16)> = (0..7).map(|i| (1 + i * 2, 2)).collect();
assert_eq!(slot_at(&slots, 0), None); assert_eq!(slot_at(&slots, 1), Some(0)); assert_eq!(slot_at(&slots, 2), Some(0)); assert_eq!(slot_at(&slots, 3), Some(1));
assert_eq!(slot_at(&slots, 13), Some(6)); assert_eq!(slot_at(&slots, 14), Some(6));
assert_eq!(slot_at(&slots, 15), None); assert_eq!(slot_at(&slots, 200), None);
for (index, &(start, width)) in slots.iter().enumerate() {
for offset in start..start + width {
assert_eq!(slot_at(&slots, offset), Some(index), "offset {offset}");
}
}
assert_eq!(slot_at(&[], 1), None);
}
#[test]
fn a_drawing_smaller_than_the_pane_has_no_offset() {
assert_eq!(recentre(0, 33, 7, 15), 0);
assert_eq!(recentre(0, 33, 15, 22), 0);
assert_eq!(recentre(0, 33, 30, 33), 0);
assert_eq!(recentre(0, 33, 0, 45), 0);
}
#[test]
fn growing_past_the_pane_centres_what_was_centred() {
assert_eq!(recentre(0, 33, 30, 45), 6);
assert_eq!(recentre(0, 33, 30, 60), 14);
}
#[test]
fn an_overflowing_drawing_keeps_the_middle_of_the_pane() {
assert_eq!(recentre(6, 33, 45, 60), 14);
assert_eq!(recentre(0, 33, 45, 60), 6);
assert_eq!(recentre(12, 33, 45, 60), 22);
}
#[test]
fn the_offset_never_runs_past_the_end() {
for before in [1usize, 7, 30, 45, 200] {
for after in [1usize, 7, 30, 45, 200] {
for offset in [0, 5, 100] {
let landed = recentre(offset, 33, before, after);
assert!(landed <= after.saturating_sub(33), "{before}->{after} from {offset}: {landed}");
}
}
}
}
}