mod bind;
mod grid;
mod kitty;
mod prefetch;
mod rank;
mod scatter;
use std::{
cell::RefCell,
fmt,
io::{self, Write},
mem,
ops::ControlFlow::{self, Break, Continue},
os::fd::OwnedFd,
panic,
path::{Path, PathBuf},
process::Child,
sync::{atomic::Ordering::Relaxed, mpsc::TryRecvError},
task::Poll,
thread,
time::{Duration, Instant},
};
use anyhow::{Result, anyhow};
use image::Rgba;
use indicatif::ProgressBar;
use ratatui::{
DefaultTerminal, Frame, Terminal,
backend::CrosstermBackend,
buffer::CellDiffOption,
crossterm::{
event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
execute,
terminal::{EnterAlternateScreen, enable_raw_mode},
},
layout::{Constraint, Rect, Size},
style::{Color, Style, Stylize},
symbols::border,
text::{Line, Span},
widgets::Block,
};
use ratatui_image::{
Image, Resize,
picker::{Picker, ProtocolType},
protocol::Protocol,
};
use rustix::{
io::dup,
stdio::{dup2_stdin, dup2_stdout},
};
pub use self::bind::Bind;
use self::{
grid::Grid,
prefetch::{Prefetch, Shape, Thumb, Ticket},
rank::Ranking,
scatter::Scatter,
};
use crate::{
output,
search::{Hit, Order, Progress, Search},
};
#[derive(Clone, Copy)]
pub enum View {
Scatter,
Grid,
}
pub enum Exit {
Picked(Vec<PathBuf>),
Empty,
Aborted,
}
type Outcome = ControlFlow<Exit>;
#[derive(Clone, Copy, PartialEq, Eq)]
enum Graphics {
Cells,
Layered,
Text,
}
impl Graphics {
fn edge(self) -> Rgba<u8> {
let alpha = if self == Self::Cells { 0 } else { u8::MAX };
Rgba([128, 128, 128, alpha])
}
}
const HALF_BLOCK_EDGE: border::Set = border::Set {
top_left: "█",
top_right: "█",
bottom_left: "█",
bottom_right: "█",
vertical_left: "█",
vertical_right: "█",
horizontal_top: "▀",
horizontal_bottom: "▄",
};
const HINTS: &str = "asdfghjklqwertyuiopzxcvbnm";
const FRAME: Duration = Duration::from_millis(33);
pub fn run(search: Search, view: View, binds: Vec<Bind>) -> Result<Exit> {
let name = match view {
View::Scatter => "scatter",
View::Grid => "grid",
};
let _stdio = TtyStdio::swap().map_err(|_| anyhow!("{name} mode needs {}", output::TERMINAL))?;
restore_on_panic();
let _restore = Restore;
let mut terminal = init()?;
let picker = output::picker().unwrap_or_else(|_| Picker::halfblocks());
let core = Core::new(search, picker)?;
match view {
View::Scatter => App::new(Scatter::new(&core.search.order), core, binds).run(&mut terminal),
View::Grid => App::new(Grid::default(), core, binds).run(&mut terminal),
}
}
fn init() -> io::Result<DefaultTerminal> {
enable_raw_mode()?;
execute!(io::stdout(), EnterAlternateScreen)?;
Terminal::new(CrosstermBackend::new(io::stdout()))
}
fn restore_on_panic() {
let ui = thread::current().id();
let hook = panic::take_hook();
panic::set_hook(Box::new(move |info| {
if thread::current().id() == ui {
ratatui::restore();
hook(info);
}
}));
}
struct TtyStdio {
stdin: OwnedFd,
stdout: OwnedFd,
}
impl TtyStdio {
fn swap() -> io::Result<Self> {
let tty = output::tty()?;
let saved = Self {
stdin: dup(io::stdin())?,
stdout: dup(io::stdout())?,
};
dup2_stdin(&tty)?;
dup2_stdout(&tty)?;
Ok(saved)
}
}
impl Drop for TtyStdio {
fn drop(&mut self) {
let _ = io::stdout().flush();
let _ = dup2_stdin(&self.stdin);
let _ = dup2_stdout(&self.stdout);
}
}
struct Restore;
impl Drop for Restore {
fn drop(&mut self) {
ratatui::restore();
}
}
#[derive(Clone, Copy)]
enum Step {
Back,
Forward,
}
struct Zoom {
path: PathBuf,
room: Size,
image: Loading<Protocol>,
}
impl Zoom {
fn open(prefetch: &Prefetch, path: PathBuf, room: Size) -> Self {
let area = f32::from(room.width) * f32::from(room.height);
Self {
image: Loading::Pending(prefetch.thumb(path.clone(), Shape::Fit { area, room })),
path,
room,
}
}
}
enum Loading<T> {
Pending(Ticket),
Ready(T),
Failed,
}
impl<T> Loading<T> {
fn poll(&mut self, make: impl FnOnce(Thumb) -> Option<T>) {
let Self::Pending(ticket) = self else {
return;
};
let Poll::Ready(thumb) = ticket.poll() else {
return;
};
*self = thumb.and_then(make).map_or(Self::Failed, Self::Ready);
}
fn pending(&self) -> bool {
matches!(self, Self::Pending(_))
}
}
struct Core {
search: Search,
searching: bool,
prefetch: Prefetch,
estimate: ProgressBar,
picker: Picker,
graphics: Graphics,
bounds: Rect,
marks: Vec<PathBuf>,
zoom: Option<Zoom>,
transmits: RefCell<Vec<kitty::Transmit>>,
}
impl Core {
fn new(search: Search, picker: Picker) -> Result<Self> {
let graphics = match picker.protocol_type() {
ProtocolType::Kitty => Graphics::Cells,
ProtocolType::Sixel | ProtocolType::Iterm2 => Graphics::Layered,
ProtocolType::Halfblocks => Graphics::Text,
};
Ok(Self {
prefetch: Prefetch::new(picker.font_size(), graphics.edge(), &search.progress)?,
estimate: ProgressBar::hidden(),
graphics,
picker,
search,
searching: true,
bounds: Rect::ZERO,
marks: Vec::new(),
zoom: None,
transmits: RefCell::default(),
})
}
fn drain(&mut self, mut push: impl FnMut(Hit)) -> Result<()> {
while self.searching {
match self.search.hits.try_recv() {
Ok(hit) => push(hit?),
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => self.searching = false,
}
}
Ok(())
}
fn room(&self) -> Size {
self.bounds.as_size()
}
fn load_zoom(&mut self) {
let room = self.room();
let Some(zoom) = &mut self.zoom else {
return;
};
if zoom.room != room {
*zoom = Zoom::open(&self.prefetch, zoom.path.clone(), room);
return;
}
zoom.image
.poll(|thumb| protocol(&self.picker, &self.search.progress, thumb));
}
fn toggle_mark(&mut self, path: PathBuf) {
match self.marks.iter().position(|m| *m == path) {
Some(i) => _ = self.marks.remove(i),
None => self.marks.push(path),
}
}
fn marked(&self, path: &Path) -> bool {
self.marks.iter().any(|m| m == path)
}
fn draw_image(&self, frame: &mut Frame, image: &Protocol, area: Rect) {
frame.render_widget(Image::new(image), area);
match self.graphics {
Graphics::Cells => {
kitty::anchor(frame.buffer_mut(), area);
let transmit = kitty::transmit(frame.buffer_mut(), area);
self.transmits.borrow_mut().extend(transmit);
}
Graphics::Text => output::fit_colors(frame.buffer_mut(), area),
Graphics::Layered => {}
}
}
fn edge(&self, path: &Path, selected: bool) -> Color {
match (selected, self.marked(path)) {
(true, _) => Color::Yellow,
(_, true) => Color::Green,
_ => Color::DarkGray,
}
}
fn tint(&self, frame: &mut Frame, area: Rect, color: Color) {
match self.graphics {
Graphics::Cells => frame.buffer_mut().set_style(area, Style::new().bg(color)),
Graphics::Text => {
let edge = Block::bordered()
.border_set(HALF_BLOCK_EDGE)
.border_style(color);
frame.render_widget(edge, area);
}
Graphics::Layered => {}
}
}
fn draw_zoom(&self, frame: &mut Frame, zoom: &Zoom) {
let message = match &zoom.image {
Loading::Ready(image) => {
let size = image.size();
let rect = self.bounds.centered(
Constraint::Length(size.width),
Constraint::Length(size.height),
);
self.draw_image(frame, image, rect);
self.tint(frame, rect, Color::Yellow);
return;
}
Loading::Pending(_) => "loading",
Loading::Failed => "cannot open this image",
};
self.note(frame, message);
}
fn draw_waiting(&self, frame: &mut Frame) {
let waiting = match self.search.order {
Order::Score(()) => "scoring all images to show the best first",
Order::Date(_) => "sorting all images by date",
Order::Found => return,
};
let left = output::time_left(&self.estimate, &self.search.progress);
self.note(frame, &format!("{waiting}{left}"));
}
fn note(&self, frame: &mut Frame, text: &str) {
let row = self.bounds.centered_vertically(Constraint::Length(1));
frame.render_widget(Line::from(text).centered().dim(), row);
}
}
trait Board {
fn push(&mut self, hit: Hit);
fn clear(&mut self, order: &Order<()>);
fn tick(&mut self, core: &mut Core, elapsed: Duration) -> Result<Outcome>;
fn idle(&self) -> bool;
fn key(&mut self, core: &mut Core, code: KeyCode);
fn selected(&self) -> Option<&Path>;
fn select(&mut self, hint: usize) -> bool;
fn step(&mut self, step: Step) -> bool;
fn is_empty(&self) -> bool;
fn prepare(&mut self, terminal: &mut DefaultTerminal, core: &Core) -> io::Result<()>;
fn draw(&self, frame: &mut Frame, core: &Core);
fn status(&self) -> Vec<Span<'static>>;
fn keys(&self) -> Span<'static>;
}
struct Running {
name: String,
images: usize,
child: Child,
}
struct App<B> {
core: Core,
board: B,
ranking: Ranking,
binds: Vec<Bind>,
running: Vec<Running>,
note: Option<Span<'static>>,
}
impl<B: Board> App<B> {
fn new(board: B, core: Core, binds: Vec<Bind>) -> Self {
Self {
core,
board,
ranking: Ranking::new(),
binds,
running: Vec::new(),
note: None,
}
}
fn run(mut self, terminal: &mut DefaultTerminal) -> Result<Exit> {
let mut last = Instant::now();
loop {
let area = terminal.size()?;
let now = Instant::now();
self.core.bounds = Rect::new(0, 0, area.width, area.height.saturating_sub(1));
self.drain()?;
self.rank();
self.reap();
if let Break(exit) = self.board.tick(&mut self.core, now - last)? {
return Ok(exit);
}
last = now;
self.core.load_zoom();
self.draw(terminal)?;
if (self.idle() || event::poll(FRAME)?)
&& let Event::Key(key) = event::read()?
&& key.kind == KeyEventKind::Press
&& let Break(exit) = self.key(key)
{
return Ok(exit);
}
}
}
fn idle(&self) -> bool {
!self.core.searching
&& !(self.core.zoom.as_ref()).is_some_and(|zoom| zoom.image.pending())
&& self.board.idle()
&& self.ranking.idle()
&& self.running.is_empty()
}
fn drain(&mut self) -> Result<()> {
let Self {
core,
board,
ranking,
..
} = self;
core.drain(|hit| {
ranking.add(&hit);
board.push(hit);
})
}
fn rank(&mut self) {
match self.ranking.poll() {
Ok(Some(hits)) => {
let order = if self.ranking.probing() {
&Order::Score(())
} else {
&self.core.search.order
};
self.board.clear(order);
hits.into_iter().for_each(|hit| self.board.push(hit));
}
Ok(None) => {}
Err(e) => self.fail(&e),
}
}
fn fail(&mut self, e: &dyn fmt::Display) {
self.note = Some(format!("{} ", output::clean(&format!("{e:#}"))).red());
}
fn reap(&mut self) {
let note = &mut self.note;
self.running.retain_mut(|run| {
let (ended, ok) = match run.child.try_wait() {
Ok(None) => return true,
Ok(Some(status)) if status.success() => ("done".to_owned(), true),
Ok(Some(status)) => (format!("failed, {status}"), false),
Err(e) => (format!("failed: {e}"), false),
};
let images = if run.images == 1 { "image" } else { "images" };
let text = format!("{} on {} {images}: {ended} ", run.name, run.images);
*note = Some(if ok { text.green() } else { text.red() });
false
});
}
fn key(&mut self, key: KeyEvent) -> Outcome {
self.note = None;
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
return Break(Exit::Aborted);
}
if self.ranking.typing() {
self.ranking.key(key);
return Continue(());
}
if let Some(bind) = self.binds.iter().find(|b| b.matches(key)) {
self.run_bind(bind.clone());
return Continue(());
}
if key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
{
return Continue(());
}
if let Some(zoom) = self.core.zoom.take() {
return self.zoom_key(key.code, zoom);
}
match key.code {
KeyCode::Esc => return Break(Exit::Aborted),
KeyCode::Enter => match self.picked() {
Some(paths) => return Break(Exit::Picked(paths)),
None => self.board.key(&mut self.core, KeyCode::Enter),
},
KeyCode::Tab => {
if let Some(path) = self.board.selected() {
self.core.toggle_mark(path.to_owned());
}
}
KeyCode::Char(c) if c.is_ascii_alphabetic() => {
let zoom = c.is_ascii_uppercase() || key.modifiers.contains(KeyModifiers::SHIFT);
if let Some(hint) = HINTS.find(c.to_ascii_lowercase())
&& self.board.select(hint)
&& zoom
{
self.zoom();
}
}
KeyCode::Char('/') => {
if let Err(e) = self.ranking.open(&self.core.search) {
self.fail(&e);
}
}
KeyCode::Char('*') => {
if let Some(path) = self.board.selected()
&& let Err(e) = self.ranking.like(path, &self.core.search)
{
self.fail(&e);
}
}
KeyCode::Backspace => self.ranking.back(),
code => self.board.key(&mut self.core, code),
}
Continue(())
}
fn run_bind(&mut self, bind: Bind) {
let paths = if self.core.marks.is_empty() {
self.shown().map(Path::to_path_buf).into_iter().collect()
} else {
self.core.marks.clone()
};
if paths.is_empty() {
return;
}
match bind.spawn(&paths) {
Ok(child) => self.running.push(Running {
name: bind.name,
images: paths.len(),
child,
}),
Err(e) => self.fail(&format!("{}: cannot run sh: {e}", bind.name)),
}
}
fn shown(&self) -> Option<&Path> {
match &self.core.zoom {
Some(zoom) => Some(&zoom.path),
None => self.board.selected(),
}
}
fn zoom_key(&mut self, code: KeyCode, zoom: Zoom) -> Outcome {
match code {
KeyCode::Enter => {
if !self.core.marked(&zoom.path) {
self.core.marks.push(zoom.path);
}
return Break(Exit::Picked(mem::take(&mut self.core.marks)));
}
KeyCode::Tab => {
self.core.toggle_mark(zoom.path.clone());
self.core.zoom = Some(zoom);
}
KeyCode::Left | KeyCode::Right => {
let step = match code {
KeyCode::Left => Step::Back,
_ => Step::Forward,
};
if self.board.step(step) {
self.zoom();
} else {
self.core.zoom = Some(zoom);
}
}
_ => {}
}
Continue(())
}
fn picked(&mut self) -> Option<Vec<PathBuf>> {
if !self.core.marks.is_empty() {
return Some(mem::take(&mut self.core.marks));
}
self.board.selected().map(|path| vec![path.to_owned()])
}
fn zoom(&mut self) {
if let Some(path) = self.board.selected() {
let zoom = Zoom::open(&self.core.prefetch, path.to_owned(), self.core.room());
self.core.zoom = Some(zoom);
}
}
fn draw(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
self.board.prepare(terminal, &self.core)?;
terminal.draw(|frame| {
self.draw_frame(frame);
kitty::keep(frame.buffer_mut(), self.core.transmits.take());
})?;
Ok(())
}
fn draw_frame(&self, frame: &mut Frame) {
if let Some(row) = frame.area().rows().next_back() {
frame.render_widget(self.status(), row);
}
if let Some(zoom) = &self.core.zoom {
self.core.draw_zoom(frame, zoom);
return;
}
self.board.draw(frame, &self.core);
if self.core.searching && self.board.is_empty() {
self.core.draw_waiting(frame);
}
}
fn status(&self) -> Line<'static> {
let mut spans = vec![Span::raw(format!(" {} ", self.core.search.status()))];
spans.extend(self.ranking.status());
spans.extend(self.board.status());
if !self.core.marks.is_empty() {
spans.push(format!("{} marked ", self.core.marks.len()).green());
}
if !self.running.is_empty() {
spans.push(format!("{} running ", self.running.len()).dim());
}
spans.extend(self.note.clone());
if let Some(name) = self.shown().and_then(Path::file_name) {
let name = output::clean_path(Path::new(name));
spans.push(format!("{name} ").yellow().bold());
}
spans.push(match self.core.zoom {
_ if self.ranking.typing() => "type to rank · enter keep · esc back".yellow(),
Some(_) => "ZOOM ←/→ step · tab mark · enter pick · any key back".yellow(),
None => self.board.keys(),
});
Line::from(spans)
}
}
fn tag(text: &str, width: u16) -> Option<String> {
match width {
0 => None,
1 | 2 => Some(text.to_owned()),
_ => Some(format!(" {text} ")),
}
}
fn draw_hint(frame: &mut Frame, area: Rect, hint: usize, selected: bool) {
let style = if selected {
Style::new().black().on_white()
} else {
Style::new().black().on_yellow()
};
draw_label(frame, area, &HINTS[hint..=hint], style.bold());
}
fn draw_check(frame: &mut Frame, area: Rect) {
draw_label(frame, area, "✓", Style::new().black().on_green().bold());
}
fn draw_label(frame: &mut Frame, area: Rect, text: &str, style: Style) {
let Some(label) = tag(text, area.width) else {
return;
};
let line = Line::from(label).style(style);
let width = line.width() as u16;
frame.render_widget(line, area);
for x in area.x..area.x.saturating_add(width) {
if let Some(cell) = frame.buffer_mut().cell_mut((x, area.y)) {
cell.set_diff_option(CellDiffOption::None);
}
}
}
fn protocol(picker: &Picker, progress: &Progress, thumb: Thumb) -> Option<Protocol> {
picker
.new_protocol(thumb.image, thumb.cells, Resize::Fit(None))
.inspect_err(|_| {
progress.errors.fetch_add(1, Relaxed);
})
.ok()
}
#[cfg(test)]
mod tests {
use ratatui::backend::TestBackend;
use super::*;
#[test]
fn labels_show_over_skipped_image_cells() {
let mut terminal = Terminal::new(TestBackend::new(6, 1)).unwrap();
terminal
.draw(|frame| {
for x in 0..6 {
frame.buffer_mut()[(x, 0)]
.set_symbol("x")
.set_diff_option(CellDiffOption::Skip);
}
draw_check(frame, frame.area());
})
.unwrap();
let shown = terminal.backend().buffer();
assert_eq!(shown[(1, 0)].symbol(), "✓");
assert_eq!(shown[(4, 0)].symbol(), " ", "the image cells stay skipped");
}
}