use crate::config::{IconMode, Layout};
use crate::format::Format;
use crate::git::{self, GitStatus};
use crate::media::{self, ImagePane};
use crate::{highlight, icons, query, theme, typeahead};
use crossterm::event::{
self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind,
};
use image::DynamicImage;
use ratatui::buffer::Buffer;
use ratatui::layout::{Constraint, Direction, Layout as RtLayout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{
Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph, StatefulWidget,
};
use ratatui::{DefaultTerminal, Frame};
use std::cmp::Ordering;
use std::fs;
use std::io::{self, Read};
use std::ops::Range;
use std::path::{Path, PathBuf};
use std::sync::mpsc::{Receiver, Sender};
use std::thread;
use std::time::{Duration, Instant, SystemTime};
struct Entry {
name: String,
path: PathBuf,
kind: Format,
size: u64,
modified: Option<SystemTime>,
}
enum Mode {
Browse,
Filter,
Search,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum MetaCol {
Size,
Modified,
None,
}
impl MetaCol {
fn toggle(self) -> Self {
match self {
MetaCol::Modified => MetaCol::Size,
MetaCol::Size | MetaCol::None => MetaCol::Modified,
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum SortKey {
Name,
Size,
Modified,
Ext,
}
impl SortKey {
fn cycle(self) -> Self {
match self {
SortKey::Name => SortKey::Size,
SortKey::Size => SortKey::Modified,
SortKey::Modified => SortKey::Ext,
SortKey::Ext => SortKey::Name,
}
}
fn label(self) -> &'static str {
match self {
SortKey::Name => "name",
SortKey::Size => "size",
SortKey::Modified => "modified",
SortKey::Ext => "ext",
}
}
}
#[derive(Clone, Copy)]
struct Sort {
key: SortKey,
reverse: bool,
}
impl Sort {
fn default() -> Self {
Sort {
key: SortKey::Name,
reverse: false,
}
}
fn label(self) -> String {
let arrow = if self.reverse { "↓" } else { "↑" };
format!("sort: {} {arrow}", self.key.label())
}
}
fn name_ext(name: &str) -> &str {
match name.rsplit_once('.') {
Some((stem, ext)) if !stem.is_empty() => ext,
_ => "",
}
}
fn cmp_name_ci(a: &str, b: &str) -> Ordering {
let mut ai = a.chars().flat_map(char::to_lowercase);
let mut bi = b.chars().flat_map(char::to_lowercase);
loop {
match (ai.next(), bi.next()) {
(Some(x), Some(y)) => match x.cmp(&y) {
Ordering::Equal => continue,
ord => return ord,
},
(Some(_), None) => return Ordering::Greater,
(None, Some(_)) => return Ordering::Less,
(None, None) => return Ordering::Equal,
}
}
}
trait Sortable {
fn sort_name(&self) -> &str;
fn sort_is_dir(&self) -> bool;
fn sort_size(&self) -> u64;
fn sort_modified(&self) -> Option<SystemTime>;
}
impl Sortable for Entry {
fn sort_name(&self) -> &str {
&self.name
}
fn sort_is_dir(&self) -> bool {
self.kind == Format::Directory
}
fn sort_size(&self) -> u64 {
self.size
}
fn sort_modified(&self) -> Option<SystemTime> {
self.modified
}
}
impl Sortable for crate::search::Hit {
fn sort_name(&self) -> &str {
&self.rel
}
fn sort_is_dir(&self) -> bool {
self.kind == Format::Directory
}
fn sort_size(&self) -> u64 {
self.size
}
fn sort_modified(&self) -> Option<SystemTime> {
self.modified
}
}
fn sort_cmp<T: Sortable>(a: &T, b: &T, sort: Sort) -> Ordering {
let dirs_first = b.sort_is_dir().cmp(&a.sort_is_dir());
if dirs_first != Ordering::Equal {
return dirs_first; }
let by_name = || cmp_name_ci(a.sort_name(), b.sort_name());
let ord = match sort.key {
SortKey::Name => by_name(),
SortKey::Size => a.sort_size().cmp(&b.sort_size()).then_with(by_name),
SortKey::Modified => a.sort_modified().cmp(&b.sort_modified()).then_with(by_name),
SortKey::Ext => {
cmp_name_ci(name_ext(a.sort_name()), name_ext(b.sort_name())).then_with(by_name)
}
};
if sort.reverse {
ord.reverse()
} else {
ord
}
}
enum Pv {
Text, Loading, Image, }
enum Rastered {
Still(DynamicImage),
Animated(Vec<media::Frame>),
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum SlideDir {
FromRight,
FromLeft,
}
struct Slide {
anim: crate::anim::Anim,
dir: SlideDir,
old: Buffer,
frames: u32,
}
struct SearchState {
query: String,
engine: Option<crate::search::Search>,
results: Vec<crate::search::Hit>,
state: ListState,
done: bool,
capped: bool,
}
impl SearchState {
fn new() -> Self {
SearchState {
query: String::new(),
engine: None,
results: Vec::new(),
state: ListState::default(),
done: false,
capped: false,
}
}
fn selected(&self) -> Option<&crate::search::Hit> {
self.state.selected().and_then(|i| self.results.get(i))
}
fn move_sel(&mut self, delta: isize) {
let next = search_sel(self.state.selected(), delta, self.results.len());
self.state.select(next);
}
}
struct App {
cwd: PathBuf,
icons: IconMode,
layout: Layout,
git_enabled: bool,
git: Option<std::collections::HashMap<String, GitStatus>>,
head: Option<git::RepoHead>,
all: Vec<Entry>,
view: Vec<usize>, parent: Vec<Entry>,
state: ListState,
filter: String,
mode: Mode,
show_hidden: bool,
viewport_h: u16,
status: Option<String>,
typeahead: String,
typeahead_at: Option<Instant>,
preview: Vec<Line<'static>>,
preview_for: Option<PathBuf>,
pv: Pv,
caption: String,
pane: Option<ImagePane>,
img_cache: Vec<(PathBuf, DynamicImage)>,
raster_tx: Sender<(PathBuf, Option<Rastered>)>,
raster_rx: Receiver<(PathBuf, Option<Rastered>)>,
raster_pending: Option<PathBuf>, raster_want: Option<(PathBuf, Format)>, preview_animated: bool,
meta: MetaCol,
crumb_hits: Vec<(Range<u16>, PathBuf)>,
list_area: Rect,
parent_area: Option<Rect>,
spin: usize,
animate: bool,
fade: Option<crate::anim::Anim>,
fade_frames: u32,
slide: Option<Slide>,
search: Option<SearchState>,
search_area: Rect,
sort: Sort,
help: bool,
}
enum Action {
Quit,
Open(PathBuf),
}
enum CharAction {
Down,
Up,
HalfDown,
HalfUp,
Top,
Bottom,
Open,
OpenExternal,
Parent,
Filter,
Search,
ToggleHidden,
ToggleLayout,
ToggleMeta,
CycleSort,
ReverseSort,
Help,
Quit,
}
fn browse_char(c: char) -> Option<CharAction> {
Some(match c {
'j' => CharAction::Down,
'k' => CharAction::Up,
'd' => CharAction::HalfDown,
'u' => CharAction::HalfUp,
'g' => CharAction::Top,
'G' => CharAction::Bottom,
'l' => CharAction::Open,
'x' => CharAction::OpenExternal,
'h' => CharAction::Parent,
'/' => CharAction::Filter,
'S' => CharAction::Search,
'.' => CharAction::ToggleHidden,
'M' => CharAction::ToggleLayout,
't' => CharAction::ToggleMeta,
'o' => CharAction::CycleSort,
'O' => CharAction::ReverseSort,
'?' => CharAction::Help,
'q' => CharAction::Quit,
_ => return None,
})
}
struct MouseGuard(bool);
impl MouseGuard {
fn enable(on: bool) -> Self {
if on {
let _ = crossterm::execute!(io::stdout(), crossterm::event::EnableMouseCapture);
}
MouseGuard(on)
}
}
impl Drop for MouseGuard {
fn drop(&mut self) {
if self.0 {
let _ = crossterm::execute!(io::stdout(), crossterm::event::DisableMouseCapture);
}
}
}
pub fn run(
start: String,
icons: IconMode,
layout: Layout,
git_enabled: bool,
mouse: bool,
) -> io::Result<()> {
let cwd = fs::canonicalize(&start).unwrap_or_else(|_| PathBuf::from(&start));
let pane = ImagePane::new().ok();
let (raster_tx, raster_rx) = std::sync::mpsc::channel();
let mut app = App {
cwd,
icons,
layout,
git_enabled,
git: None,
head: None,
all: Vec::new(),
view: Vec::new(),
parent: Vec::new(),
state: ListState::default(),
filter: String::new(),
mode: Mode::Browse,
show_hidden: false,
viewport_h: 0,
status: None,
typeahead: String::new(),
typeahead_at: None,
preview: Vec::new(),
preview_for: None,
pv: Pv::Text,
caption: String::new(),
pane,
img_cache: Vec::new(),
raster_tx,
raster_rx,
raster_pending: None,
raster_want: None,
preview_animated: false,
meta: MetaCol::Size,
crumb_hits: Vec::new(),
list_area: Rect::default(),
parent_area: None,
spin: 0,
animate: crate::anim::enabled(),
fade: None,
fade_frames: 0,
slide: None,
search: None,
search_area: Rect::default(),
sort: Sort::default(),
help: false,
};
app.load();
loop {
let mut term = ratatui::init();
let guard = MouseGuard::enable(mouse);
let action = app.main_loop(&mut term);
drop(guard);
ratatui::restore();
match action {
Ok(Action::Quit) => return Ok(()),
Ok(Action::Open(path)) => {
crate::open_interactive(&path.to_string_lossy());
app.preview_for = None; }
Err(e) => return Err(e),
}
}
}
struct Sel {
name: String,
path: PathBuf,
kind: Format,
size: u64,
modified: Option<SystemTime>,
}
impl App {
fn cur_sel(&self) -> Option<Sel> {
if let Some(search) = self.search.as_ref() {
let hit = search.selected()?;
let name = hit
.path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| hit.rel.clone());
Some(Sel {
name,
path: hit.path.clone(),
kind: hit.kind,
size: hit.size,
modified: hit.modified,
})
} else {
let e = self.selected()?;
Some(Sel {
name: e.name.clone(),
path: e.path.clone(),
kind: e.kind,
size: e.size,
modified: e.modified,
})
}
}
fn load(&mut self) {
self.all = read_entries(&self.cwd, self.sort);
self.parent = match self.cwd.parent() {
Some(p) => read_entries(p, self.sort),
None => Vec::new(),
};
self.git = if self.git_enabled {
git::status_map(&self.cwd)
} else {
None
};
self.head = if self.git.is_some() {
git::head_info(&self.cwd)
} else {
None
};
self.refilter();
}
fn refilter(&mut self) {
let q = query::parse(&self.filter);
self.view = self
.all
.iter()
.enumerate()
.filter(|(_, e)| self.show_hidden || !e.name.starts_with('.'))
.filter(|(_, e)| q.matches(&e.name, e.kind, e.size, e.modified))
.map(|(i, _)| i)
.collect();
let sel = if self.view.is_empty() {
None
} else {
Some(self.state.selected().unwrap_or(0).min(self.view.len() - 1))
};
self.state.select(sel);
self.preview_for = None;
}
fn resort(&mut self) {
let sort = self.sort;
self.all.sort_by(|a, b| sort_cmp(a, b, sort));
self.parent.sort_by(|a, b| sort_cmp(a, b, sort));
self.refilter();
self.status = Some(self.sort.label());
}
fn selected(&self) -> Option<&Entry> {
let i = self.state.selected()?;
self.all.get(*self.view.get(i)?)
}
fn move_sel(&mut self, delta: isize) {
if self.view.is_empty() {
return;
}
let n = self.view.len() as isize;
let cur = self.state.selected().unwrap_or(0) as isize;
let next = (cur + delta).clamp(0, n - 1);
self.state.select(Some(next as usize));
}
fn enter_dir(&mut self, path: PathBuf, dir: SlideDir) {
let old = if self.animate {
self.snapshot_current_inner()
} else {
None
};
self.cwd = path;
self.filter.clear();
self.mode = Mode::Browse;
self.state.select(Some(0));
self.status = None;
self.typeahead.clear();
self.typeahead_at = None;
self.load();
if self.animate {
let now = Instant::now();
self.fade = Some(crate::anim::Anim::new(now, NAV_ANIM));
self.fade_frames = 0;
self.slide = old.map(|old| Slide {
anim: crate::anim::Anim::new(now, NAV_ANIM),
dir,
old,
frames: 0,
});
}
}
fn go_parent(&mut self) {
if let Some(parent) = self.cwd.parent().map(Path::to_path_buf) {
let from = self
.cwd
.file_name()
.map(|n| n.to_string_lossy().into_owned());
self.enter_dir(parent, SlideDir::FromLeft);
if let Some(name) = from {
if let Some(pos) = self.view.iter().position(|&i| self.all[i].name == name) {
self.state.select(Some(pos));
}
}
}
}
fn snapshot_current_inner(&self) -> Option<Buffer> {
let area = self.list_area;
let inner = entry_inner(area);
if inner.width == 0 || inner.height == 0 {
return None;
}
let view = EntryListView {
entries: &self.all,
order: &self.view,
selected: self.state.selected(),
title: String::new(),
git: self.git.as_ref(),
meta: self.meta,
fade_t: None,
};
let (_, window) = visible_window(
self.state.offset(),
view.selected,
view.order.len(),
inner.height as usize,
);
let items = entry_items(area, &view, self.icons, window.clone());
let list = entry_list(items, None);
let mut buf = Buffer::empty(inner);
let mut state = ListState::default();
state.select(window_selection(view.selected, &window));
render_items_into(&mut buf, inner, list, &mut state);
Some(buf)
}
fn activate(&mut self) -> Option<Action> {
let e = self.selected()?;
if e.kind == Format::Directory {
let p = e.path.clone();
self.enter_dir(p, SlideDir::FromRight);
None
} else if e.kind.opens() {
Some(Action::Open(e.path.clone()))
} else {
self.status = Some(format!("no viewer for {}", e.kind.label()));
None
}
}
fn exit_search(&mut self) {
self.search = None;
self.mode = Mode::Browse;
self.status = None;
self.preview_for = None;
}
fn restart_search(&mut self) {
let cwd = self.cwd.clone();
let show_hidden = self.show_hidden;
let Some(search) = self.search.as_mut() else {
return;
};
let q = query::parse(&search.query);
search.results.clear();
search.state.select(None);
search.done = false;
search.capped = false;
search.engine = if q.is_empty() {
None
} else {
Some(crate::search::start(cwd, q, show_hidden))
};
}
fn pump_search(&mut self) -> bool {
let sort = self.sort;
let Some(search) = self.search.as_mut() else {
return false;
};
let Some(engine) = search.engine.as_ref() else {
return false;
};
let msgs = engine.drain();
if msgs.is_empty() {
return false;
}
let anchor = search.selected().map(|h| h.path.clone());
for msg in msgs {
match msg {
crate::search::Msg::Hit(h) => search.results.push(h),
crate::search::Msg::Done { capped } => {
search.done = true;
search.capped = capped;
search.engine = None; }
}
}
search.results.sort_by(|a, b| sort_cmp(a, b, sort));
let restored = anchor.and_then(|p| search.results.iter().position(|h| h.path == p));
search
.state
.select(restored.or((!search.results.is_empty()).then_some(0)));
true
}
fn search_move(&mut self, delta: isize) {
if let Some(search) = self.search.as_mut() {
search.move_sel(delta);
}
}
fn activate_search(&mut self) -> Option<Action> {
let sel = self.cur_sel()?;
if sel.kind == Format::Directory {
self.exit_search();
self.enter_dir(sel.path, SlideDir::FromRight);
self.slide = None;
None
} else if sel.kind.opens() {
Some(Action::Open(sel.path))
} else {
self.status = Some(format!("no viewer for {}", sel.kind.label()));
None
}
}
fn main_loop(&mut self, term: &mut DefaultTerminal) -> io::Result<Action> {
let mut dirty = true;
loop {
if self.pump_search() {
dirty = true;
}
let cur = self.cur_sel().map(|s| s.path);
if cur != self.preview_for {
self.build_preview();
self.preview_for = cur;
dirty = true;
}
if self.pump_raster() {
dirty = true;
}
if let Some(fade) = self.fade {
let now = Instant::now();
dirty = true;
if fade.done(now) {
crate::anim::record("folder-fade", self.fade_frames, fade.elapsed(now));
self.fade = None;
} else {
self.fade_frames = self.fade_frames.saturating_add(1);
}
}
let slide_done = self
.slide
.as_ref()
.map(|s| s.anim.done(Instant::now()))
.unwrap_or(false);
if let Some(slide) = self.slide.as_mut() {
dirty = true;
if slide_done {
crate::anim::record(
"folder-slide",
slide.frames,
slide.anim.elapsed(Instant::now()),
);
} else {
slide.frames = slide.frames.saturating_add(1);
}
}
if slide_done {
self.slide = None;
}
if dirty {
term.draw(|f| self.render(f))?;
dirty = false;
}
let raster_active = self.raster_pending.is_some() || self.raster_want.is_some();
let animating = self.preview_animated && matches!(self.pv, Pv::Image);
let searching = self.search.as_ref().is_some_and(|s| s.engine.is_some());
let fading = self.fade.is_some() || self.slide.is_some();
let timeout = if fading {
Duration::from_millis(4)
} else if raster_active || animating || searching {
Duration::from_millis(60)
} else {
Duration::from_millis(1000)
};
if event::poll(timeout)? {
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => {
dirty = true;
self.fade = None;
self.slide = None;
if let Some(action) = self.handle_key(key) {
return Ok(action);
}
}
Event::Mouse(me) if matches!(self.mode, Mode::Search) => match me.kind {
MouseEventKind::Down(MouseButton::Left) => {
let (offset, len, cur) = match self.search.as_ref() {
Some(s) => (s.state.offset(), s.results.len(), s.state.selected()),
None => (0, 0, None),
};
if let Some(idx) =
row_to_index(self.search_area, offset, me.row, me.column, len)
{
if cur == Some(idx) {
if let Some(action) = self.activate_search() {
return Ok(action);
}
} else if let Some(s) = self.search.as_mut() {
s.state.select(Some(idx));
}
dirty = true;
}
}
MouseEventKind::ScrollDown => {
self.search_move(1);
dirty = true;
}
MouseEventKind::ScrollUp => {
self.search_move(-1);
dirty = true;
}
_ => {}
},
Event::Mouse(_) if self.help => {
self.help = false;
dirty = true;
}
Event::Mouse(me) => match me.kind {
MouseEventKind::Down(MouseButton::Left) => {
if me.row == 0 {
if let Some(target) = crumb_hit(&self.crumb_hits, me.column) {
if target != self.cwd {
self.enter_dir(target, SlideDir::FromLeft);
dirty = true;
}
}
} else if let Some(idx) = row_to_index(
self.list_area,
self.state.offset(),
me.row,
me.column,
self.view.len(),
) {
if self.state.selected() == Some(idx) {
if let Some(action) = self.activate() {
return Ok(action);
}
} else {
self.state.select(Some(idx));
}
dirty = true;
} else if self
.parent_area
.is_some_and(|a| rect_contains(a, me.column, me.row))
{
self.go_parent();
dirty = true;
}
}
MouseEventKind::ScrollDown => {
self.move_sel(1);
dirty = true;
}
MouseEventKind::ScrollUp => {
self.move_sel(-1);
dirty = true;
}
_ => {}
},
Event::Resize(..) => dirty = true,
_ => {}
}
} else {
if raster_active {
self.spin = self.spin.wrapping_add(1);
dirty = true;
}
if animating {
if let Some(pane) = self.pane.as_mut() {
if pane.tick(Instant::now()) {
dirty = true;
}
}
}
}
}
}
fn handle_key(&mut self, key: KeyEvent) -> Option<Action> {
let code = key.code;
if let Mode::Filter = self.mode {
match code {
KeyCode::Esc => {
self.filter.clear();
self.mode = Mode::Browse;
self.refilter();
}
KeyCode::Enter => self.mode = Mode::Browse,
KeyCode::Backspace => {
self.filter.pop();
self.refilter();
}
KeyCode::Down => self.move_sel(1),
KeyCode::Up => self.move_sel(-1),
KeyCode::Char(c) => {
self.filter.push(c);
self.refilter();
}
_ => {}
}
return None;
}
if let Mode::Search = self.mode {
let half = (self.viewport_h / 2).max(1) as isize;
match code {
KeyCode::Esc => self.exit_search(),
KeyCode::Enter | KeyCode::Right => return self.activate_search(),
KeyCode::Backspace => {
if let Some(s) = self.search.as_mut() {
s.query.pop();
}
self.restart_search();
}
KeyCode::Down => self.search_move(1),
KeyCode::Up => self.search_move(-1),
KeyCode::PageDown => self.search_move(half),
KeyCode::PageUp => self.search_move(-half),
KeyCode::Char(c) => {
if let Some(s) = self.search.as_mut() {
s.query.push(c);
}
self.restart_search();
}
_ => {}
}
return None;
}
if self.help {
self.help = false;
return None;
}
let now = Instant::now();
let is_active = typeahead::active(now, self.typeahead_at, typeahead::TIMEOUT);
if !is_active && !self.typeahead.is_empty() {
self.typeahead.clear();
if self
.status
.as_deref()
.is_some_and(|s| s.starts_with("type: "))
{
self.status = None;
}
}
if is_active {
match code {
KeyCode::Esc => {
self.cancel_typeahead();
return None;
}
KeyCode::Backspace => {
self.typeahead.pop();
self.typeahead_at = Some(now);
self.apply_typeahead();
return None;
}
_ => {}
}
}
if let KeyCode::Char(c) = code {
let ctrl_alt = key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT);
if !ctrl_alt {
match typeahead::action(is_active, browse_char(c).is_some()) {
typeahead::Action::Append => {
self.typeahead.push(c);
self.typeahead_at = Some(now);
self.apply_typeahead();
return None;
}
typeahead::Action::StartNew => {
self.typeahead = c.to_string();
self.typeahead_at = Some(now);
self.apply_typeahead();
return None;
}
typeahead::Action::PassThrough => {}
}
}
}
let half = (self.viewport_h / 2).max(1) as isize;
match code {
KeyCode::Char(c) => {
if let Some(action) = browse_char(c) {
return self.run_char_action(action);
}
}
KeyCode::Esc => return Some(Action::Quit),
KeyCode::Down => self.move_sel(1),
KeyCode::Up => self.move_sel(-1),
KeyCode::PageDown => self.move_sel(half),
KeyCode::PageUp => self.move_sel(-half),
KeyCode::Home => self.state.select(Some(0)),
KeyCode::End => {
if !self.view.is_empty() {
self.state.select(Some(self.view.len() - 1));
}
}
KeyCode::Enter | KeyCode::Right => return self.activate(),
KeyCode::Left | KeyCode::Backspace => self.go_parent(),
_ => {}
}
None
}
fn run_char_action(&mut self, action: CharAction) -> Option<Action> {
let half = (self.viewport_h / 2).max(1) as isize;
match action {
CharAction::Down => self.move_sel(1),
CharAction::Up => self.move_sel(-1),
CharAction::HalfDown => self.move_sel(half),
CharAction::HalfUp => self.move_sel(-half),
CharAction::Top => self.state.select(Some(0)),
CharAction::Bottom => {
if !self.view.is_empty() {
self.state.select(Some(self.view.len() - 1));
}
}
CharAction::Open => return self.activate(),
CharAction::OpenExternal => {
if let Some(e) = self.selected() {
crate::util::open_in_native_app(&e.path.to_string_lossy());
}
}
CharAction::Parent => self.go_parent(),
CharAction::Filter => {
self.mode = Mode::Filter;
self.filter.clear();
self.status = None; self.refilter();
}
CharAction::Search => {
self.mode = Mode::Search;
self.search = Some(SearchState::new());
self.status = None; }
CharAction::ToggleHidden => {
self.show_hidden = !self.show_hidden;
self.refilter();
}
CharAction::ToggleLayout => self.layout = self.layout.cycle(),
CharAction::ToggleMeta => self.meta = self.meta.toggle(),
CharAction::CycleSort => {
self.sort.key = self.sort.key.cycle();
self.resort();
}
CharAction::ReverseSort => {
self.sort.reverse = !self.sort.reverse;
self.resort();
}
CharAction::Help => self.help = !self.help,
CharAction::Quit => return Some(Action::Quit),
}
None
}
fn apply_typeahead(&mut self) {
let idx = {
let names: Vec<&str> = self
.view
.iter()
.map(|&i| self.all[i].name.as_str())
.collect();
typeahead::match_prefix(&names, &self.typeahead)
};
if let Some(i) = idx {
self.state.select(Some(i));
}
self.status = Some(format!("type: {}", self.typeahead));
}
fn cancel_typeahead(&mut self) {
self.typeahead.clear();
self.typeahead_at = None;
self.status = None;
}
fn cache_put(&mut self, path: PathBuf, img: DynamicImage) {
self.img_cache.push((path, img));
if self.img_cache.len() > 8 {
self.img_cache.remove(0);
}
}
fn show_image(&mut self, img: DynamicImage) {
if let Some(pane) = self.pane.as_mut() {
pane.set(img);
}
self.pv = Pv::Image;
self.preview_animated = false;
}
fn show_animation(&mut self, frames: Vec<media::Frame>) {
if let Some(pane) = self.pane.as_mut() {
pane.set_animation(frames);
}
self.pv = Pv::Image;
self.preview_animated = true;
}
fn pump_raster(&mut self) -> bool {
let mut dirty = false;
let cur = self.cur_sel().map(|s| s.path);
while let Ok((path, result)) = self.raster_rx.try_recv() {
if let Some(Rastered::Still(img)) = &result {
self.cache_put(path.clone(), img.clone());
}
if self.raster_pending.as_deref() == Some(path.as_path()) {
self.raster_pending = None;
}
if Some(&path) != cur.as_ref() {
continue; }
match result {
Some(Rastered::Still(img)) => self.show_image(img),
Some(Rastered::Animated(frames)) => self.show_animation(frames),
None => {
self.preview.push(no_preview());
self.pv = Pv::Text;
}
}
dirty = true;
}
if self.raster_pending.is_none() {
if let Some((path, kind)) = self.raster_want.take() {
if let Some((_, img)) = self.img_cache.iter().find(|(p, _)| *p == path) {
if Some(&path) == cur.as_ref() {
let img = img.clone();
self.show_image(img);
dirty = true;
}
} else {
let tx = self.raster_tx.clone();
let p = path.clone();
thread::spawn(move || {
let result: Option<Rastered> = match kind {
Format::Image => media::decode_frames(&p)
.map(Rastered::Animated)
.or_else(|| {
crate::util::open_image_reader(&p)
.ok()
.and_then(|r| r.decode().ok())
.map(Rastered::Still)
}),
Format::Pdf => crate::pdf::poster(&p.to_string_lossy())
.ok()
.map(Rastered::Still),
Format::Video => crate::video::poster(&p.to_string_lossy())
.ok()
.map(Rastered::Still),
Format::Svg => crate::svg::render_svg(&p.to_string_lossy())
.ok()
.map(Rastered::Still),
Format::Keynote => crate::keynote::preview_image(&p.to_string_lossy())
.ok()
.map(Rastered::Still),
_ => None,
};
let _ = tx.send((p, result));
});
self.raster_pending = Some(path);
}
}
}
dirty
}
fn build_preview(&mut self) {
self.preview.clear();
self.pv = Pv::Text;
self.preview_animated = false;
self.raster_want = None;
let Some(sel) = self.cur_sel() else { return };
let name = sel.name;
let kind = sel.kind;
let size = sel.size;
let modified = sel.modified;
let path = sel.path;
let mut meta = kind.label().to_string();
if kind != Format::Directory {
meta.push_str(&format!(" · {}", crate::util::human_size(size)));
}
if let Some(m) = modified {
meta.push_str(&format!(" · {}", crate::util::rel_time(m)));
}
self.preview.push(Line::from(Span::styled(
name.clone(),
Style::default()
.fg(kind.color())
.add_modifier(Modifier::BOLD),
)));
self.preview.push(Line::from(Span::styled(
meta.clone(),
Style::default().fg(theme::palette().dim),
)));
self.preview.push(Line::from(""));
if matches!(
kind,
Format::Image | Format::Svg | Format::Pdf | Format::Video | Format::Keynote
) && self.pane.is_some()
{
let extra = match kind {
Format::Image => crate::util::image_dimensions(&path)
.map(|(w, h)| format!(" · {w}×{h}"))
.unwrap_or_default(),
Format::Video => " · Enter to play".into(),
Format::Pdf => " · page 1".into(),
Format::Svg => " · Enter for source".into(),
Format::Keynote => " · preview".into(),
_ => String::new(),
};
self.caption = format!("{name} {meta}{extra}");
if let Some((_, img)) = self.img_cache.iter().find(|(p, _)| *p == path) {
let img = img.clone();
self.show_image(img); } else {
self.pv = Pv::Loading;
self.raster_want = Some((path, kind));
}
return;
}
match kind {
Format::Directory => self.preview_dir(&path),
Format::Markdown => self.preview_markdown(read_capped(&path)),
Format::Html => {
match crate::html::to_markdown(&path.to_string_lossy()) {
Ok(src) => self.preview_markdown(src),
Err(_) => self.preview.push(no_preview()),
}
}
Format::Docx => {
match crate::docx::to_markdown(&path.to_string_lossy()) {
Ok(src) => self.preview_markdown(src),
Err(_) => self.preview.push(no_preview()),
}
}
Format::Pptx => {
match crate::pptx::to_markdown(&path.to_string_lossy()) {
Ok(src) => self.preview_markdown(src),
Err(_) => self.preview.push(no_preview()),
}
}
Format::Epub => {
match crate::epub::to_markdown(&path.to_string_lossy()) {
Ok(src) => self.preview_markdown(src),
Err(_) => self.preview.push(no_preview()),
}
}
Format::Ipynb => {
match crate::ipynb::to_markdown(&path.to_string_lossy()) {
Ok(src) => self.preview_markdown(src),
Err(_) => self.preview.push(no_preview()),
}
}
Format::Sheet | Format::Data => self.preview_sheet(&path),
Format::Archive => self.preview_archive(&path),
Format::Binary => self.preview_hex(&path),
_ => self.preview_text_head(&path),
}
}
fn preview_dir(&mut self, path: &Path) {
let mut kids: Vec<(String, bool)> = match fs::read_dir(path) {
Ok(rd) => rd
.flatten()
.map(|c| {
let n = c.file_name().to_string_lossy().into_owned();
(n, c.path().is_dir())
})
.filter(|(n, _)| self.show_hidden || !n.starts_with('.'))
.collect(),
Err(_) => {
self.preview.push(no_preview());
return;
}
};
kids.sort_by(|a, b| {
b.1.cmp(&a.1)
.then_with(|| a.0.to_lowercase().cmp(&b.0.to_lowercase()))
});
let total = kids.len();
if total == 0 {
self.preview.push(Line::from(Span::styled(
"empty",
Style::default().fg(theme::palette().dim),
)));
return;
}
self.preview.insert(
2,
Line::from(Span::styled(
format!("{total} items"),
Style::default().fg(theme::palette().dim),
)),
);
for (n, d) in kids.into_iter().take(300) {
let (c, suffix) = if d {
(theme::palette().dir, "/")
} else {
(theme::palette().other, "")
};
self.preview.push(Line::from(Span::styled(
format!("{n}{suffix}"),
Style::default().fg(c),
)));
}
}
fn preview_markdown(&mut self, src: String) {
let width = preview_text_width();
let (lines, _, _) = crate::markdown::Rendered::build(&src).layout(width);
self.preview.extend(lines.into_iter().take(600));
}
fn preview_sheet(&mut self, path: &Path) {
const MAX_COLS: usize = 20;
const COL_CAP: usize = 18; let Some(rows) = crate::sheet::preview_grid(&path.to_string_lossy(), 200, MAX_COLS) else {
self.preview.push(no_preview());
return;
};
let ncols = rows.iter().map(|r| r.len()).max().unwrap_or(0);
let mut widths = vec![1usize; ncols];
for r in &rows {
for (i, c) in r.iter().enumerate() {
widths[i] = widths[i].max(c.chars().count()).min(COL_CAP);
}
}
for (ri, r) in rows.iter().take(400).enumerate() {
let spans: Vec<Span> = (0..ncols)
.map(|i| {
let cell = r.get(i).map(String::as_str).unwrap_or("");
let color = if ri == 0 {
theme::palette().accent
} else {
theme::palette().other
};
Span::styled(
format!("{} ", pad_cell(cell, widths[i])),
Style::default().fg(color),
)
})
.collect();
self.preview.push(Line::from(spans));
}
}
fn preview_archive(&mut self, path: &Path) {
match crate::archive::entries(&path.to_string_lossy()) {
Ok(list) => {
self.preview.insert(
2,
Line::from(Span::styled(
format!("{} entries", list.len()),
Style::default().fg(theme::palette().dim),
)),
);
for e in list.into_iter().take(500) {
let size = if e.is_dir {
" dir".to_string()
} else {
format!("{:>8}", crate::util::human_size(e.size))
};
let color = if e.is_dir {
theme::palette().dir
} else {
theme::palette().other
};
self.preview.push(Line::from(vec![
Span::styled(
format!("{size} "),
Style::default().fg(theme::palette().dim),
),
Span::styled(e.name, Style::default().fg(color)),
]));
}
}
Err(_) => self.preview.push(no_preview()),
}
}
fn preview_hex(&mut self, path: &Path) {
for line in crate::hex::preview(&path.to_string_lossy(), 500) {
self.preview.push(Line::from(Span::styled(
line,
Style::default().fg(theme::palette().other),
)));
}
}
fn preview_text_head(&mut self, path: &Path) {
let Some(text) = head_text(path, 64 * 1024, 500) else {
self.preview.push(no_preview());
return;
};
let ext = path
.extension()
.map(|e| e.to_string_lossy().to_lowercase())
.unwrap_or_default();
if !highlight::is_text_ext(&ext) {
for l in text.lines() {
self.preview.push(Line::from(Span::styled(
l.to_string(),
Style::default().fg(theme::palette().other),
)));
}
return;
}
let syntax = highlight::syntax_for(&ext).unwrap_or(highlight::PLAIN);
for line in highlight::highlight(&text, syntax) {
let spans: Vec<Span> = line
.into_iter()
.map(|tok| {
Span::styled(tok.text, Style::default().fg(theme::token_color(tok.kind)))
})
.collect();
self.preview.push(Line::from(spans));
}
}
fn render(&mut self, f: &mut Frame) {
if matches!(self.mode, Mode::Search) {
self.render_search(f);
return;
}
let area = f.area();
let rows = RtLayout::default()
.constraints([
Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), ])
.split(area);
self.render_crumb(f, rows[0]);
let has_parent = self.cwd.parent().is_some();
let filter = matches!(self.mode, Mode::Filter);
let now = Instant::now();
let fade_t = self
.fade
.map(|a| crate::anim::ease_out_cubic(a.progress(now)));
if effective_columns(self.layout, area.width, has_parent) == 3 {
let cols = RtLayout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(20),
Constraint::Percentage(34),
Constraint::Percentage(46),
])
.split(rows[1]);
self.render_parent(f, cols[0]);
self.list_area = cols[1];
self.parent_area = Some(cols[0]);
self.viewport_h = cols[1].height.saturating_sub(2);
let view = EntryListView {
entries: &self.all,
order: &self.view,
selected: self.state.selected(),
title: format!(" {} ", self.view.len()),
git: self.git.as_ref(), meta: self.meta,
fade_t, };
match self.slide.as_ref().filter(|s| !s.anim.done(now)) {
Some(slide) => render_entry_slide(
f,
cols[1],
&view,
&self.state,
self.icons,
true,
filter,
slide,
now,
),
None => {
render_entry_list(f, cols[1], &view, &mut self.state, true, filter, self.icons)
}
}
self.render_preview(f, cols[2]);
} else {
let cols = RtLayout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(42), Constraint::Percentage(58)])
.split(rows[1]);
self.list_area = cols[0];
self.parent_area = None;
self.viewport_h = cols[0].height.saturating_sub(2);
let view = EntryListView {
entries: &self.all,
order: &self.view,
selected: self.state.selected(),
title: format!(" {} ", self.view.len()),
git: self.git.as_ref(), meta: self.meta,
fade_t, };
match self.slide.as_ref().filter(|s| !s.anim.done(now)) {
Some(slide) => render_entry_slide(
f,
cols[0],
&view,
&self.state,
self.icons,
true,
filter,
slide,
now,
),
None => {
render_entry_list(f, cols[0], &view, &mut self.state, true, filter, self.icons)
}
}
self.render_preview(f, cols[1]);
}
self.render_status(f, rows[2]);
if self.help {
render_browse_help(f, area, self.sort);
}
}
fn render_parent(&self, f: &mut Frame, area: Rect) {
let Some(parent) = self.cwd.parent().map(Path::to_path_buf) else {
return;
};
let entries = &self.parent;
let order: Vec<usize> = entries
.iter()
.enumerate()
.filter(|(_, e)| self.show_hidden || !e.name.starts_with('.'))
.map(|(i, _)| i)
.collect();
let here = self
.cwd
.file_name()
.map(|n| n.to_string_lossy().into_owned());
let selected = here
.as_deref()
.and_then(|name| order.iter().position(|&i| entries[i].name == name));
let mut state = ListState::default();
state.select(selected);
let view = EntryListView {
entries,
order: &order,
selected,
title: format!(" {} ", pretty_dir_name(&parent)),
git: None,
meta: MetaCol::None, fade_t: None, };
render_entry_list(f, area, &view, &mut state, false, false, self.icons);
}
fn render_crumb(&mut self, f: &mut Frame, area: Rect) {
let accent = theme::palette().accent;
let dim = theme::palette().dim;
let home = std::env::var_os("HOME").map(PathBuf::from);
let segments = crumb_segments(&self.cwd, home.as_deref());
let last = segments.len().saturating_sub(1);
self.crumb_hits.clear();
let mut spans = vec![Span::raw(" ")];
let mut x = area.x.saturating_add(1);
let mut prev_ends_slash = false;
for (idx, (label, target)) in segments.iter().enumerate() {
if idx > 0 && !prev_ends_slash {
spans.push(Span::styled("/".to_string(), Style::default().fg(dim)));
x = x.saturating_add(1);
}
let w = label.chars().count() as u16;
let start = x;
let style = if idx == last {
Style::default().fg(accent).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(dim)
};
spans.push(Span::styled(label.clone(), style));
x = x.saturating_add(w);
self.crumb_hits.push((start..x, target.clone()));
prev_ends_slash = label.ends_with('/');
}
f.render_widget(Paragraph::new(Line::from(spans)), area);
if let Some(head) = &self.head {
let dirty = self.git.as_ref().is_some_and(|m| !m.is_empty());
let git_spans = head_spans(head, dirty, self.icons);
let w: u16 = git_spans
.iter()
.map(|s| s.content.chars().count() as u16)
.sum();
let right = area.x + area.width;
if w > 0 && x + 2 + w <= right {
let rect = Rect::new(right - w, area.y, w, 1);
f.render_widget(Paragraph::new(Line::from(git_spans)), rect);
}
}
}
fn render_preview(&mut self, f: &mut Frame, area: Rect) {
match self.pv {
Pv::Image => {
let block = preview_block(caption_title(&self.caption, area));
let inner = block.inner(area);
f.render_widget(block, area);
if let Some(pane) = self.pane.as_mut() {
pane.render(f, inner);
}
}
Pv::Loading => {
let block = preview_block(caption_title(&self.caption, area));
let inner = block.inner(area);
f.render_widget(block, area);
let frame = SPINNER[self.spin % SPINNER.len()];
f.render_widget(
Paragraph::new(Line::from(Span::styled(
format!("{frame} rendering…"),
Style::default().fg(theme::palette().dim),
))),
inner,
);
}
Pv::Text => {
let block = preview_block(preview_caption(" Preview "));
let inner_h = area.height.saturating_sub(2) as usize;
let text: Vec<Line> = self.preview.iter().take(inner_h).cloned().collect();
f.render_widget(Paragraph::new(Text::from(text)).block(block), area);
}
}
}
fn render_status(&self, f: &mut Frame, area: Rect) {
let txt = if let Mode::Filter = self.mode {
let hint = if query::parse(&self.filter).has_predicates() {
"[Enter] keep [Esc] clear"
} else {
"text + kind: ext: size: modified: [Enter] keep [Esc] clear"
};
format!(" /{} {hint}", self.filter)
} else if let Some(s) = &self.status {
format!(" {s}")
} else {
let hidden = if self.show_hidden { "shown" } else { "hidden" };
format!(
" [j/k] move [Enter] open [/] filter [S] search [.] dot ({hidden}) [?] help"
)
};
let color = if let Mode::Filter = self.mode {
theme::palette().doc
} else {
theme::palette().dim
};
f.render_widget(
Paragraph::new(Line::from(txt)).style(Style::default().fg(color)),
area,
);
}
fn render_search(&mut self, f: &mut Frame) {
let area = f.area();
let rows = RtLayout::default()
.constraints([
Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), ])
.split(area);
self.render_search_input(f, rows[0]);
let cols = RtLayout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(42), Constraint::Percentage(58)])
.split(rows[1]);
self.render_results(f, cols[0]);
self.render_preview(f, cols[1]);
self.render_search_status(f, rows[2]);
}
fn render_search_input(&self, f: &mut Frame, area: Rect) {
let accent = theme::palette().accent;
let query = self.search.as_ref().map(|s| s.query.as_str()).unwrap_or("");
let line = Line::from(vec![
Span::styled(
format!(" ⌕ {query}"),
Style::default().fg(accent).add_modifier(Modifier::BOLD),
),
Span::styled("█", Style::default().fg(accent)),
]);
f.render_widget(Paragraph::new(line), area);
}
fn render_results(&mut self, f: &mut Frame, area: Rect) {
self.search_area = area;
self.viewport_h = area.height.saturating_sub(2);
let accent = theme::palette().accent;
let n = self.search.as_ref().map(|s| s.results.len()).unwrap_or(0);
let title = Line::from(Span::styled(
format!(" {n} "),
Style::default().fg(accent).add_modifier(Modifier::BOLD),
));
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(accent))
.title(title);
f.render_widget(block, area);
let inner = entry_inner(area);
let (offset, len, selected) = match self.search.as_ref() {
Some(s) => (s.state.offset(), s.results.len(), s.state.selected()),
None => (0, 0, None),
};
let (new_offset, window) = visible_window(offset, selected, len, inner.height as usize);
let items = self.search_items(area.width, window.clone());
let list = List::new(items)
.highlight_style(
Style::default()
.bg(theme::palette().selection)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("▎ ");
if let Some(search) = self.search.as_mut() {
let mut local = ListState::default();
local.select(window_selection(selected, &window));
render_items_into(f.buffer_mut(), inner, list, &mut local);
*search.state.offset_mut() = new_offset;
}
}
fn search_items(&self, width: u16, window: std::ops::Range<usize>) -> Vec<ListItem<'static>> {
let Some(search) = self.search.as_ref() else {
return Vec::new();
};
let dim = theme::palette().dim;
let chrome_w = match self.icons {
IconMode::None => 4,
_ => 6,
};
let inner_w = width.saturating_sub(chrome_w) as usize;
search.results[window]
.iter()
.map(|hit| {
let mut spans: Vec<Span> = Vec::with_capacity(3);
let rel_color = match self.icons {
IconMode::Unicode => {
let c = hit.kind.color();
spans.push(Span::styled(
format!("{} ", hit.kind.glyph()),
Style::default().fg(c),
));
c
}
IconMode::Nerd => {
let ext = hit
.path
.extension()
.map(|x| x.to_string_lossy().to_lowercase())
.unwrap_or_default();
let c = icons::nerd_color(&ext, hit.kind);
spans.push(Span::styled(
format!("{} ", icons::nerd_glyph(&ext, hit.kind)),
Style::default().fg(c),
));
c
}
IconMode::None => hit.kind.color(),
};
let rel_shown = truncate(&hit.rel, inner_w);
let budget = inner_w.saturating_sub(rel_shown.chars().count());
spans.push(Span::styled(rel_shown, Style::default().fg(rel_color)));
let suffix = snippet_suffix(hit.snippet.as_ref());
if !suffix.is_empty() && budget > 0 {
let suffix_shown = truncate(&suffix, budget);
spans.push(Span::styled(suffix_shown, Style::default().fg(dim)));
}
ListItem::new(Line::from(spans))
})
.collect()
}
fn render_search_status(&self, f: &mut Frame, area: Rect) {
let dim = theme::palette().dim;
let txt = match self.search.as_ref() {
None => String::new(),
Some(s) if query::parse(&s.query).is_empty() => {
" type to search · kind: ext: size: content: … [Esc] back".to_string()
}
Some(s) => {
let n = s.results.len();
let state = if !s.done {
format!("searching… {n} found")
} else if n == 0 {
"no matches".to_string()
} else if s.capped {
format!("{n} results (capped at 5000)")
} else {
format!("{n} results")
};
format!(" {state} [Enter] open [Esc] back")
}
};
f.render_widget(
Paragraph::new(Line::from(txt)).style(Style::default().fg(dim)),
area,
);
}
}
const MILLER_MIN: u16 = 100;
const NAV_ANIM: Duration = Duration::from_millis(150);
const FADE_BG: Color = Color::Rgb(16, 16, 20);
const SPINNER: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
struct EntryListView<'a> {
entries: &'a [Entry],
order: &'a [usize],
selected: Option<usize>,
title: String,
git: Option<&'a std::collections::HashMap<String, GitStatus>>,
meta: MetaCol,
fade_t: Option<f32>,
}
fn effective_columns(layout: Layout, width: u16, has_parent: bool) -> u8 {
let wants_miller = match layout {
Layout::Miller | Layout::Auto => true,
Layout::Double => false,
};
if wants_miller && width >= MILLER_MIN && has_parent {
3
} else {
2
}
}
fn render_entry_list(
f: &mut Frame,
area: Rect,
view: &EntryListView,
state: &mut ListState,
active: bool,
filter: bool,
icons: IconMode,
) {
let block = entry_block(view, active, filter);
f.render_widget(block, area);
let inner = entry_inner(area);
let (offset, window) = visible_window(
state.offset(),
view.selected,
view.order.len(),
inner.height as usize,
);
let items = entry_items(area, view, icons, window.clone());
let list = entry_list(items, view.fade_t);
let mut local = ListState::default();
local.select(window_selection(view.selected, &window));
render_items_into(f.buffer_mut(), inner, list, &mut local);
state.select(view.selected);
*state.offset_mut() = offset;
}
fn window_selection(selected: Option<usize>, window: &std::ops::Range<usize>) -> Option<usize> {
selected
.filter(|s| window.contains(s))
.map(|s| s - window.start)
}
#[allow(clippy::too_many_arguments)]
fn render_entry_slide(
f: &mut Frame,
area: Rect,
view: &EntryListView,
state: &ListState,
icons: IconMode,
active: bool,
filter: bool,
slide: &Slide,
now: Instant,
) {
let block = entry_block(view, active, filter);
f.render_widget(block, area);
let inner = entry_inner(area);
if inner.width == 0 || inner.height == 0 {
return;
}
let (_, window) = visible_window(
state.offset(),
view.selected,
view.order.len(),
inner.height as usize,
);
let items = entry_items(area, view, icons, window.clone());
let list = entry_list(items, view.fade_t);
let mut new_buf = Buffer::empty(inner);
let mut st = ListState::default(); st.select(window_selection(view.selected, &window));
render_items_into(&mut new_buf, inner, list, &mut st);
let t = crate::anim::ease_out_cubic(slide.anim.progress(now));
let (old_dx, new_dx) = slide_offsets(slide.dir, t, inner.width);
blit_shifted(f.buffer_mut(), &slide.old, inner, old_dx);
blit_shifted(f.buffer_mut(), &new_buf, inner, new_dx);
}
fn entry_inner(area: Rect) -> Rect {
Block::default().borders(Borders::ALL).inner(area)
}
fn fade_color(fade_t: Option<f32>, c: Color) -> Color {
match fade_t {
Some(t) => crate::anim::lerp_color(FADE_BG, c, t),
None => c,
}
}
fn entry_block(view: &EntryListView, active: bool, filter: bool) -> Block<'static> {
let accent = theme::palette().accent;
let border = if active {
if filter {
theme::palette().doc
} else {
accent
}
} else {
theme::palette().dim
};
let title_style = if active {
Style::default().fg(accent).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme::palette().dim)
};
let title = Line::from(Span::styled(view.title.clone(), title_style));
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(border))
.title(title)
}
fn visible_window(
offset: usize,
selected: Option<usize>,
len: usize,
height: usize,
) -> (usize, std::ops::Range<usize>) {
if height == 0 || len == 0 {
return (0, 0..0);
}
let max_offset = len.saturating_sub(height);
let mut offset = offset.min(max_offset);
if let Some(sel) = selected {
let sel = sel.min(len - 1);
if sel < offset {
offset = sel; } else if sel >= offset + height {
offset = sel - height + 1; }
offset = offset.min(max_offset);
}
let end = (offset + height).min(len);
(offset, offset..end)
}
fn entry_items(
area: Rect,
view: &EntryListView,
icons: IconMode,
window: std::ops::Range<usize>,
) -> Vec<ListItem<'static>> {
let fade = |c: Color| fade_color(view.fade_t, c);
let chrome_w = match icons {
IconMode::None => 4, _ => 6, };
let git_w: u16 = if view.git.is_some() { 2 } else { 0 };
let inner_w = area.width.saturating_sub(chrome_w + git_w) as usize;
let size_w = 8usize;
let size_reserve = if view.meta == MetaCol::None {
0
} else {
size_w + 1
};
let name_w = inner_w.saturating_sub(size_reserve).max(4);
let now = SystemTime::now();
view.order[window]
.iter()
.map(|&i| {
let e = &view.entries[i];
let name = truncate(&e.name, name_w);
let meta_str = match view.meta {
MetaCol::Size => {
if e.kind == Format::Directory {
String::new()
} else {
crate::util::human_size(e.size)
}
}
MetaCol::Modified => e
.modified
.map(|m| crate::util::human_age(m, now))
.unwrap_or_default(),
MetaCol::None => String::new(),
};
let mut spans: Vec<Span> = Vec::with_capacity(4);
let name_color = match icons {
IconMode::Unicode => {
let c = e.kind.color();
spans.push(Span::styled(
format!("{} ", e.kind.glyph()),
Style::default().fg(fade(c)),
));
c
}
IconMode::Nerd => {
let ext = e
.path
.extension()
.map(|x| x.to_string_lossy().to_lowercase())
.unwrap_or_default();
let c = icons::nerd_color(&ext, e.kind);
spans.push(Span::styled(
format!("{} ", icons::nerd_glyph(&ext, e.kind)),
Style::default().fg(fade(c)),
));
c
}
IconMode::None => e.kind.color(),
};
if let Some(git) = view.git {
match git.get(&e.name) {
Some(st) => spans.push(Span::styled(
format!("{} ", st.glyph()),
Style::default().fg(fade(st.color())),
)),
None => spans.push(Span::raw(" ")),
}
}
spans.push(Span::styled(
format!("{name:<name_w$}"),
Style::default().fg(fade(name_color)),
));
if view.meta != MetaCol::None {
spans.push(Span::styled(
format!(" {meta_str:>size_w$}"),
Style::default().fg(fade(theme::palette().dim)),
));
}
ListItem::new(Line::from(spans))
})
.collect()
}
fn entry_list<'a>(items: Vec<ListItem<'a>>, fade_t: Option<f32>) -> List<'a> {
List::new(items)
.highlight_style(
Style::default()
.bg(fade_color(fade_t, theme::palette().selection))
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("▎ ")
}
fn render_items_into(buf: &mut Buffer, inner: Rect, list: List, state: &mut ListState) {
StatefulWidget::render(list, inner, buf, state);
}
fn slide_offsets(dir: SlideDir, t: f32, w: u16) -> (i32, i32) {
let w = w as f32;
let shift = (t * w).round() as i32; let anti = ((1.0 - t) * w).round() as i32; match dir {
SlideDir::FromRight => (-shift, anti),
SlideDir::FromLeft => (shift, -anti),
}
}
fn blit_shifted(dst: &mut Buffer, src: &Buffer, inner: Rect, dx: i32) {
let (left, right) = (inner.left() as i32, inner.right() as i32);
for y in inner.top()..inner.bottom() {
for x in inner.left()..inner.right() {
let x2 = x as i32 + dx;
if x2 < left || x2 >= right {
continue; }
let Some(cell) = src.cell((x, y)) else {
continue;
};
let cell = cell.clone();
if let Some(d) = dst.cell_mut((x2 as u16, y)) {
*d = cell;
}
}
}
}
fn read_entries(dir: &Path, sort: Sort) -> Vec<Entry> {
let mut entries = Vec::new();
if let Ok(rd) = fs::read_dir(dir) {
for ent in rd.flatten() {
let name = ent.file_name().to_string_lossy().into_owned();
let path = ent.path();
let meta = ent.metadata().ok();
let is_dir = meta.as_ref().map(|m| m.is_dir()).unwrap_or(false);
let ext = path
.extension()
.map(|e| e.to_string_lossy().to_lowercase())
.unwrap_or_default();
let kind = crate::format::classify(&ext, is_dir, None);
entries.push(Entry {
name,
path,
kind,
size: meta.as_ref().map(|m| m.len()).unwrap_or(0),
modified: meta.and_then(|m| m.modified().ok()),
});
}
}
entries.sort_by(|a, b| sort_cmp(a, b, sort));
entries
}
fn centered_rect(area: Rect, pct_w: u16, pct_h: u16) -> Rect {
let w = area.width * pct_w / 100;
let h = area.height * pct_h / 100;
Rect {
x: area.x + area.width.saturating_sub(w) / 2,
y: area.y + area.height.saturating_sub(h) / 2,
width: w,
height: h,
}
}
fn render_browse_help(f: &mut Frame, area: Rect, sort: Sort) {
let popup = centered_rect(area, 60, 80);
f.render_widget(Clear, popup);
let accent = theme::palette().accent;
let dim = theme::palette().dim;
let heading = |s: &str| {
Line::from(Span::styled(
s.to_string(),
Style::default().fg(accent).add_modifier(Modifier::BOLD),
))
};
let row = |keys: &str, desc: &str| {
Line::from(vec![
Span::styled(format!(" {keys:<12}"), Style::default().fg(accent)),
Span::styled(desc.to_string(), Style::default().fg(dim)),
])
};
let mut lines = vec![
heading(" Navigate"),
row("j / k", "down / up (↑/↓ too)"),
row("d / u", "half-page down / up"),
row("g / G", "top / bottom"),
row("h / l", "parent / open (←/→, Enter)"),
row("x", "open in native app (OS default)"),
row("type…", "jump to a name (typeahead)"),
Line::from(""),
heading(" Find"),
row("/", "filter this folder (kind: ext: size: modified:)"),
row("S", "recursive search (also content:)"),
Line::from(""),
heading(" Sort"),
row("o", "cycle key: name → size → modified → ext"),
row("O", "reverse direction"),
Line::from(""),
heading(" Display"),
row(".", "toggle hidden files"),
row("t", "toggle size / modified column"),
row("M", "cycle layout: auto → miller → double"),
Line::from(""),
heading(" Other"),
row("q / Esc", "quit"),
row("?", "close this help"),
Line::from(""),
Line::from(Span::styled(
format!(" {}", sort.label()),
Style::default().fg(dim).add_modifier(Modifier::ITALIC),
)),
];
let inner_h = popup.height.saturating_sub(2) as usize;
lines.truncate(inner_h);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(accent))
.title(Line::from(Span::styled(
" Keys — any key to close ",
Style::default().fg(accent).add_modifier(Modifier::BOLD),
)));
f.render_widget(Paragraph::new(Text::from(lines)).block(block), popup);
}
fn pretty_dir_name(p: &Path) -> String {
p.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "/".to_string())
}
pub fn dump(path: &str) -> String {
let mut names: Vec<String> = match fs::read_dir(path) {
Ok(rd) => rd
.flatten()
.map(|e| {
let n = e.file_name().to_string_lossy().into_owned();
if e.path().is_dir() {
format!("{n}/")
} else {
n
}
})
.collect(),
Err(e) => return format!("sucher: {path}: {e}\n"),
};
names.sort_by_key(|n| n.to_lowercase());
let mut out = names.join("\n");
out.push('\n');
out
}
fn preview_text_width() -> usize {
let cols = crossterm::terminal::size().map(|(c, _)| c).unwrap_or(80);
((cols as usize * 58 / 100).saturating_sub(2)).max(20)
}
fn read_capped(path: &Path) -> String {
let mut f = match fs::File::open(path) {
Ok(f) => f,
Err(_) => return String::new(),
};
let mut buf = vec![0u8; 256 * 1024];
let n = f.read(&mut buf).unwrap_or(0);
buf.truncate(n);
String::from_utf8_lossy(&buf).into_owned()
}
fn preview_block(title: Line<'static>) -> Block<'static> {
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme::palette().dim))
.title(title)
}
fn preview_caption(s: &str) -> Line<'static> {
Line::from(Span::styled(
s.to_string(),
Style::default()
.fg(theme::palette().accent)
.add_modifier(Modifier::BOLD),
))
}
fn caption_title(caption: &str, area: Rect) -> Line<'static> {
let shown = truncate(caption, area.width.saturating_sub(4) as usize);
preview_caption(&format!(" {shown} "))
}
fn no_preview() -> Line<'static> {
Line::from(Span::styled(
"No preview",
Style::default().fg(theme::palette().dim),
))
}
fn head_text(path: &Path, max_bytes: usize, max_lines: usize) -> Option<String> {
let mut f = fs::File::open(path).ok()?;
let mut buf = vec![0u8; max_bytes];
let n = f.read(&mut buf).ok()?;
buf.truncate(n);
if buf.contains(&0) {
return None; }
let s = String::from_utf8_lossy(&buf);
Some(s.lines().take(max_lines).collect::<Vec<_>>().join("\n"))
}
fn head_spans(head: &git::RepoHead, dirty: bool, icons: IconMode) -> Vec<Span<'static>> {
let name = match (&head.branch, &head.oid_short) {
(Some(branch), _) => branch.clone(),
(None, Some(oid)) => format!("@{oid}"),
(None, None) => return Vec::new(),
};
let p = theme::palette();
let ascii = icons == IconMode::None;
let glyph = match icons {
IconMode::Nerd => "\u{e0a0}",
IconMode::Unicode => "⎇",
IconMode::None => "git:",
};
let mut spans = vec![
Span::styled(glyph.to_string(), Style::default().fg(p.dim)),
Span::raw(" "),
Span::styled(name, Style::default().fg(p.accent)),
];
if let Some((ahead, behind)) = head.ahead_behind {
if ahead > 0 {
let txt = if ascii {
format!(" +{ahead}")
} else {
format!(" ↑{ahead}")
};
spans.push(Span::styled(txt, Style::default().fg(p.sheet)));
}
if behind > 0 {
let txt = if ascii {
format!(" -{behind}")
} else {
format!(" ↓{behind}")
};
spans.push(Span::styled(txt, Style::default().fg(p.pdf)));
}
}
if dirty {
let dot = if ascii { " *" } else { " ●" };
spans.push(Span::styled(dot.to_string(), Style::default().fg(p.doc)));
}
spans.push(Span::raw(" "));
spans
}
fn crumb_segments(cwd: &Path, home: Option<&Path>) -> Vec<(String, PathBuf)> {
if let Some(home) = home {
if let Ok(rest) = cwd.strip_prefix(home) {
let mut out = vec![("~".to_string(), home.to_path_buf())];
let mut acc = home.to_path_buf();
for comp in rest.components() {
let name = comp.as_os_str().to_string_lossy().into_owned();
acc = acc.join(&name);
out.push((name, acc.clone()));
}
return out;
}
}
let mut out = vec![("/".to_string(), PathBuf::from("/"))];
let mut acc = PathBuf::from("/");
for comp in cwd.components() {
if let std::path::Component::Normal(os) = comp {
let name = os.to_string_lossy().into_owned();
acc = acc.join(&name);
out.push((name, acc.clone()));
}
}
out
}
fn crumb_hit(hits: &[(Range<u16>, PathBuf)], x: u16) -> Option<PathBuf> {
hits.iter()
.find(|(range, _)| range.contains(&x))
.map(|(_, target)| target.clone())
}
fn rect_contains(r: Rect, col: u16, row: u16) -> bool {
col >= r.x
&& col < r.x.saturating_add(r.width)
&& row >= r.y
&& row < r.y.saturating_add(r.height)
}
fn row_to_index(
list_area: Rect,
offset: usize,
row: u16,
col: u16,
view_len: usize,
) -> Option<usize> {
if col < list_area.x || col >= list_area.x.saturating_add(list_area.width) {
return None;
}
let first_row = list_area.y.saturating_add(1);
let last_inner = list_area
.y
.saturating_add(list_area.height)
.saturating_sub(2);
if row < first_row || row > last_inner {
return None; }
let idx = offset + (row - first_row) as usize;
(idx < view_len).then_some(idx)
}
fn search_sel(cur: Option<usize>, delta: isize, len: usize) -> Option<usize> {
if len == 0 {
return None;
}
let cur = cur.unwrap_or(0) as isize;
Some((cur + delta).clamp(0, len as isize - 1) as usize)
}
fn snippet_suffix(snippet: Option<&(u64, String)>) -> String {
match snippet {
Some((lnum, text)) => format!(" {lnum}: {text}"),
None => String::new(),
}
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let t: String = s.chars().take(max.saturating_sub(1)).collect();
format!("{t}…")
}
}
fn pad_cell(s: &str, w: usize) -> String {
let t = truncate(s, w);
let len = t.chars().count();
format!("{t}{}", " ".repeat(w.saturating_sub(len)))
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
fn entry(name: &str, kind: Format, size: u64, mtime: Option<SystemTime>) -> Entry {
Entry {
name: name.to_string(),
path: PathBuf::from(name),
kind,
size,
modified: mtime,
}
}
fn sorted_names(mut v: Vec<Entry>, sort: Sort) -> Vec<String> {
v.sort_by(|a, b| sort_cmp(a, b, sort));
v.into_iter().map(|e| e.name).collect()
}
#[test]
fn dirs_always_sort_before_files_regardless_of_key_or_reverse() {
let v = || {
vec![
entry("zzz.txt", Format::Text, 1, None),
entry("adir", Format::Directory, 999, None),
]
};
for reverse in [false, true] {
let names = sorted_names(
v(),
Sort {
key: SortKey::Size,
reverse,
},
);
assert_eq!(names[0], "adir", "dir must lead (reverse={reverse})");
}
}
#[test]
fn name_sort_is_case_insensitive_and_reverses() {
let v = || {
vec![
entry("Banana", Format::Text, 0, None),
entry("apple", Format::Text, 0, None),
entry("Cherry", Format::Text, 0, None),
]
};
assert_eq!(
sorted_names(v(), Sort::default()),
vec!["apple", "Banana", "Cherry"]
);
assert_eq!(
sorted_names(
v(),
Sort {
key: SortKey::Name,
reverse: true
}
),
vec!["Cherry", "Banana", "apple"]
);
}
#[test]
fn size_sort_orders_ascending_then_breaks_ties_by_name() {
let v = vec![
entry("big", Format::Text, 100, None),
entry("small", Format::Text, 10, None),
entry("mid_b", Format::Text, 50, None),
entry("mid_a", Format::Text, 50, None), ];
assert_eq!(
sorted_names(
v,
Sort {
key: SortKey::Size,
reverse: false
}
),
vec!["small", "mid_a", "mid_b", "big"]
);
}
#[test]
fn modified_sort_oldest_first_missing_counts_as_oldest() {
let base = SystemTime::UNIX_EPOCH;
let older = base + Duration::from_secs(100);
let newer = base + Duration::from_secs(200);
let v = vec![
entry("new", Format::Text, 0, Some(newer)),
entry("none", Format::Text, 0, None), entry("old", Format::Text, 0, Some(older)),
];
assert_eq!(
sorted_names(
v,
Sort {
key: SortKey::Modified,
reverse: false
}
),
vec!["none", "old", "new"]
);
}
#[test]
fn ext_sort_groups_by_extension_then_name() {
let v = vec![
entry("b.rs", Format::Text, 0, None),
entry("a.rs", Format::Text, 0, None),
entry("c.md", Format::Text, 0, None),
entry("readme", Format::Text, 0, None), ];
assert_eq!(
sorted_names(
v,
Sort {
key: SortKey::Ext,
reverse: false
}
),
vec!["readme", "c.md", "a.rs", "b.rs"]
);
}
#[test]
fn name_ext_handles_dotfiles_and_missing() {
assert_eq!(name_ext("photo.JPG"), "JPG"); assert_eq!(name_ext("archive.tar.gz"), "gz"); assert_eq!(name_ext("README"), ""); assert_eq!(name_ext(".gitignore"), ""); }
#[test]
fn cmp_name_ci_is_case_insensitive_and_ordered() {
use std::cmp::Ordering;
assert_eq!(cmp_name_ci("Apple", "apple"), Ordering::Equal);
assert_eq!(cmp_name_ci("Apple", "banana"), Ordering::Less);
assert_eq!(cmp_name_ci("apple", "Banana"), Ordering::Less);
assert_eq!(cmp_name_ci("BANANA", "apple"), Ordering::Greater);
assert_eq!(cmp_name_ci("app", "apple"), Ordering::Less);
assert_eq!(cmp_name_ci("apple", "app"), Ordering::Greater);
}
#[test]
fn ext_sort_order_is_case_insensitive() {
let v = vec![
entry("b.RS", Format::Text, 0, None),
entry("a.rs", Format::Text, 0, None),
entry("c.Md", Format::Text, 0, None),
];
assert_eq!(
sorted_names(
v,
Sort {
key: SortKey::Ext,
reverse: false
}
),
vec!["c.Md", "a.rs", "b.RS"] );
}
#[test]
fn search_hits_sort_by_relative_path_grouped_by_folder() {
let hit = |rel: &str| crate::search::Hit {
path: PathBuf::from(rel),
rel: rel.to_string(),
kind: Format::Text,
size: 0,
modified: None,
snippet: None,
};
let mut v = [
hit("zzz.txt"),
hit("src/b.rs"),
hit("src/a.rs"),
hit("readme.md"),
];
v.sort_by(|a, b| sort_cmp(a, b, Sort::default()));
let rels: Vec<&str> = v.iter().map(|h| h.rel.as_str()).collect();
assert_eq!(rels, vec!["readme.md", "src/a.rs", "src/b.rs", "zzz.txt"]);
}
#[test]
fn sort_key_cycles_through_all_four() {
let k = SortKey::Name;
let k = k.cycle();
assert!(matches!(k, SortKey::Size));
let k = k.cycle();
assert!(matches!(k, SortKey::Modified));
let k = k.cycle();
assert!(matches!(k, SortKey::Ext));
let k = k.cycle();
assert!(matches!(k, SortKey::Name)); }
#[test]
fn miller_three_columns_only_when_wide_and_parented() {
assert_eq!(effective_columns(Layout::Miller, MILLER_MIN, true), 3);
assert_eq!(effective_columns(Layout::Miller, 200, true), 3);
assert_eq!(effective_columns(Layout::Miller, MILLER_MIN - 1, true), 2);
assert_eq!(effective_columns(Layout::Miller, 200, false), 2);
}
#[test]
fn double_is_always_two_columns() {
assert_eq!(effective_columns(Layout::Double, 200, true), 2);
assert_eq!(effective_columns(Layout::Double, 40, true), 2);
}
#[test]
fn crumb_segments_under_home_are_tilde_anchored() {
let home = PathBuf::from("/Users/j");
let segs = crumb_segments(Path::new("/Users/j/src/app"), Some(&home));
assert_eq!(
segs,
vec![
("~".to_string(), PathBuf::from("/Users/j")),
("src".to_string(), PathBuf::from("/Users/j/src")),
("app".to_string(), PathBuf::from("/Users/j/src/app")),
]
);
assert_eq!(
crumb_segments(&home, Some(&home)),
vec![("~".to_string(), PathBuf::from("/Users/j"))]
);
}
#[test]
fn crumb_segments_outside_home_are_root_anchored() {
let home = PathBuf::from("/Users/j");
let segs = crumb_segments(Path::new("/usr/local/bin"), Some(&home));
assert_eq!(
segs,
vec![
("/".to_string(), PathBuf::from("/")),
("usr".to_string(), PathBuf::from("/usr")),
("local".to_string(), PathBuf::from("/usr/local")),
("bin".to_string(), PathBuf::from("/usr/local/bin")),
]
);
assert_eq!(
crumb_segments(Path::new("/"), Some(&home)),
vec![("/".to_string(), PathBuf::from("/"))]
);
assert_eq!(
crumb_segments(Path::new("/etc"), None),
vec![
("/".to_string(), PathBuf::from("/")),
("etc".to_string(), PathBuf::from("/etc")),
]
);
}
fn spans_text(spans: &[Span<'_>]) -> String {
spans.iter().map(|s| s.content.as_ref()).collect()
}
#[test]
fn head_spans_branch_ahead_behind_dirty() {
let head = git::RepoHead {
branch: Some("main".to_string()),
oid_short: Some("0123456".to_string()),
ahead_behind: Some((2, 1)),
};
let s = head_spans(&head, true, IconMode::Unicode);
assert_eq!(spans_text(&s), "⎇ main ↑2 ↓1 ● ");
}
#[test]
fn head_spans_in_sync_clean_is_just_the_branch() {
let head = git::RepoHead {
branch: Some("main".to_string()),
oid_short: Some("0123456".to_string()),
ahead_behind: Some((0, 0)),
};
assert_eq!(
spans_text(&head_spans(&head, false, IconMode::Unicode)),
"⎇ main "
);
}
#[test]
fn head_spans_detached_shows_short_oid() {
let head = git::RepoHead {
branch: None,
oid_short: Some("abc1234".to_string()),
ahead_behind: None,
};
assert_eq!(
spans_text(&head_spans(&head, false, IconMode::Nerd)),
"\u{e0a0} @abc1234 "
);
}
#[test]
fn head_spans_ascii_mode_is_pure_ascii() {
let head = git::RepoHead {
branch: Some("feat/x".to_string()),
oid_short: Some("abc1234".to_string()),
ahead_behind: Some((3, 0)),
};
let text = spans_text(&head_spans(&head, true, IconMode::None));
assert_eq!(text, "git: feat/x +3 * ");
assert!(text.is_ascii());
}
#[test]
fn head_spans_unborn_repo_renders_nothing() {
let head = git::RepoHead {
branch: None,
oid_short: None,
ahead_behind: None,
};
assert!(head_spans(&head, true, IconMode::Unicode).is_empty());
}
#[test]
fn crumb_hit_resolves_column_to_target() {
let hits = vec![
(1u16..2u16, PathBuf::from("/Users/j")), (3u16..6u16, PathBuf::from("/Users/j/src")), ];
assert_eq!(crumb_hit(&hits, 1), Some(PathBuf::from("/Users/j")));
assert_eq!(crumb_hit(&hits, 3), Some(PathBuf::from("/Users/j/src")));
assert_eq!(crumb_hit(&hits, 5), Some(PathBuf::from("/Users/j/src")));
assert_eq!(crumb_hit(&hits, 2), None);
assert_eq!(crumb_hit(&hits, 6), None);
assert_eq!(crumb_hit(&hits, 0), None);
}
#[test]
fn row_to_index_maps_clicks_inside_the_pane() {
let area = Rect {
x: 10,
y: 2,
width: 30,
height: 10,
};
assert_eq!(row_to_index(area, 0, 3, 15, 5), Some(0));
assert_eq!(row_to_index(area, 0, 4, 15, 5), Some(1));
assert_eq!(row_to_index(area, 0, 2, 15, 5), None);
assert_eq!(row_to_index(area, 0, 8, 15, 5), None);
assert_eq!(row_to_index(area, 0, 11, 15, 5), None);
assert_eq!(row_to_index(area, 0, 3, 9, 5), None); assert_eq!(row_to_index(area, 0, 3, 40, 5), None); assert_eq!(row_to_index(area, 12, 3, 15, 100), Some(12));
assert_eq!(row_to_index(area, 12, 5, 15, 100), Some(14));
assert_eq!(row_to_index(area, 12, 5, 15, 13), None);
}
#[test]
fn visible_window_scrolls_to_keep_selection_visible() {
assert_eq!(visible_window(0, Some(3), 100, 10), (0, 0..10));
assert_eq!(visible_window(5, Some(8), 100, 10), (5, 5..15));
assert_eq!(visible_window(0, Some(50), 100, 10), (41, 41..51));
assert_eq!(visible_window(0, Some(9), 100, 10), (0, 0..10)); assert_eq!(visible_window(0, Some(10), 100, 10), (1, 1..11)); assert_eq!(visible_window(40, Some(12), 100, 10), (12, 12..22));
assert_eq!(visible_window(999, None, 100, 10), (90, 90..100));
assert_eq!(visible_window(0, Some(99), 100, 10), (90, 90..100));
assert_eq!(visible_window(0, None, 0, 10), (0, 0..0));
assert_eq!(visible_window(7, Some(3), 0, 10), (0, 0..0));
assert_eq!(visible_window(0, Some(0), 100, 0), (0, 0..0));
assert_eq!(visible_window(0, Some(2), 4, 10), (0, 0..4));
assert_eq!(visible_window(30, Some(0), 5, 40), (0, 0..5));
}
#[test]
fn rect_contains_includes_borders_excludes_beyond() {
let r = Rect {
x: 5,
y: 1,
width: 4,
height: 3,
};
assert!(rect_contains(r, 5, 1)); assert!(rect_contains(r, 8, 3)); assert!(!rect_contains(r, 9, 3)); assert!(!rect_contains(r, 8, 4)); assert!(!rect_contains(r, 4, 2)); }
#[test]
fn slide_offsets_endpoints_and_midpoint() {
assert_eq!(slide_offsets(SlideDir::FromRight, 0.0, 40), (0, 40));
assert_eq!(slide_offsets(SlideDir::FromRight, 1.0, 40), (-40, 0));
assert_eq!(slide_offsets(SlideDir::FromRight, 0.5, 40), (-20, 20));
assert_eq!(slide_offsets(SlideDir::FromLeft, 0.0, 40), (0, -40));
assert_eq!(slide_offsets(SlideDir::FromLeft, 1.0, 40), (40, 0));
assert_eq!(slide_offsets(SlideDir::FromLeft, 0.5, 40), (20, -20));
}
#[test]
fn search_sel_clamps_and_handles_empty() {
assert_eq!(search_sel(None, 1, 0), None);
assert_eq!(search_sel(Some(0), -1, 0), None);
assert_eq!(search_sel(None, 0, 5), Some(0));
assert_eq!(search_sel(None, 1, 5), Some(1));
assert_eq!(search_sel(Some(2), 1, 5), Some(3));
assert_eq!(search_sel(Some(2), -1, 5), Some(1));
assert_eq!(search_sel(Some(0), -1, 5), Some(0));
assert_eq!(search_sel(Some(4), 1, 5), Some(4));
assert_eq!(search_sel(Some(4), 10, 5), Some(4));
assert_eq!(search_sel(Some(1), -10, 5), Some(0));
}
#[test]
fn snippet_suffix_formats_or_empties() {
assert_eq!(
snippet_suffix(Some(&(42, "let x = 1;".to_string()))),
" 42: let x = 1;"
);
assert_eq!(snippet_suffix(None), "");
}
#[test]
fn auto_behaves_as_miller_gated_on_width() {
assert_eq!(effective_columns(Layout::Auto, 200, true), 3);
assert_eq!(effective_columns(Layout::Auto, 80, true), 2);
assert_eq!(effective_columns(Layout::Auto, 200, false), 2);
}
}