use crate::anim::{self, ease_out_cubic, zoom_rect, Anim};
use crate::media::{self, Frame, ImagePane};
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use image::DynamicImage;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Style};
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use ratatui::{DefaultTerminal, Frame as RtFrame};
use std::io;
use std::path::Path;
use std::time::{Duration, Instant};
const ANIM_POLL: Duration = Duration::from_millis(50);
const IDLE_POLL: Duration = Duration::from_millis(1000);
pub fn run(title: String, path: String) -> io::Result<()> {
if is_gif(&path) {
if let Some(frames) = media::decode_frames(Path::new(&path)) {
return show_frames(title, frames, Some(path));
}
}
let img = match crate::util::open_image_reader(Path::new(&path)).map(|r| r.decode()) {
Ok(Ok(img)) => img,
Ok(Err(e)) => {
eprintln!("sucher: {path}: {e}");
return Ok(());
}
Err(e) => {
eprintln!("sucher: {path}: {e}");
return Ok(());
}
};
show(title, img, Some(path))
}
fn is_gif(path: &str) -> bool {
Path::new(path)
.extension()
.map(|e| e.eq_ignore_ascii_case("gif"))
.unwrap_or(false)
}
pub fn show(title: String, img: DynamicImage, open: Option<String>) -> io::Result<()> {
let (w, h) = (img.width(), img.height());
let mut pane = ImagePane::new()?; pane.set(img);
run_pane(title, pane, w, h, open)
}
fn show_frames(title: String, frames: Vec<Frame>, open: Option<String>) -> io::Result<()> {
let (w, h) = (frames[0].img.width(), frames[0].img.height());
let mut pane = ImagePane::new()?;
pane.set_animation(frames);
run_pane(title, pane, w, h, open)
}
fn run_pane(
title: String,
mut pane: ImagePane,
w: u32,
h: u32,
open: Option<String>,
) -> io::Result<()> {
let mut term = ratatui::init();
let res = main_loop(&mut term, &mut pane, &title, w, h, open.as_deref());
ratatui::restore();
res
}
fn main_loop(
term: &mut DefaultTerminal,
pane: &mut ImagePane,
title: &str,
w: u32,
h: u32,
open: Option<&str>,
) -> io::Result<()> {
if anim::enabled() {
zoom_in(term, pane, title, w, h)?;
}
let animated = pane.is_animated();
let poll = if animated { ANIM_POLL } else { IDLE_POLL };
let mut dirty = true;
loop {
if dirty {
term.draw(|f| render(f, pane, title, w, h, open.is_some()))?;
dirty = false;
}
if event::poll(poll)? {
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => {
if let (KeyCode::Char('x'), Some(p)) = (key.code, open) {
crate::util::open_in_native_app(p);
continue;
}
if matches!(key.code, KeyCode::Char('q') | KeyCode::Esc) {
if anim::enabled() {
zoom_out(term, pane, title, w, h)?;
}
return Ok(());
}
}
Event::Resize(..) => dirty = true,
_ => {}
}
} else if animated {
if pane.tick(Instant::now()) {
dirty = true;
}
}
}
}
fn render(f: &mut RtFrame, pane: &mut ImagePane, title: &str, w: u32, h: u32, can_open: bool) {
let hint = if can_open { " [x] open" } else { "" };
draw_zoom(f, pane, title, w, h, 1.0, hint);
}
fn draw_zoom(
f: &mut RtFrame,
pane: &mut ImagePane,
title: &str,
w: u32,
h: u32,
t: f32,
hint: &str,
) {
let area = f.area();
let chunks = Layout::default()
.constraints([Constraint::Min(0), Constraint::Length(1)])
.split(area);
pane.render(f, zoom_rect(chunks[0], t));
status(
f,
chunks[1],
&format!(" {title} {w}×{h}px{hint} [q] quit"),
);
}
const ZOOM_IN_DUR: Duration = Duration::from_millis(150);
const ZOOM_OUT_DUR: Duration = Duration::from_millis(120);
const ZOOM_POLL: Duration = Duration::from_millis(4);
fn zoom_in(
term: &mut DefaultTerminal,
pane: &mut ImagePane,
title: &str,
w: u32,
h: u32,
) -> io::Result<()> {
let anim = Anim::new(Instant::now(), ZOOM_IN_DUR);
let mut frames = 0u32;
loop {
let now = Instant::now();
if anim.done(now) {
break;
}
if event::poll(ZOOM_POLL)? {
break;
}
let t = ease_out_cubic(anim.progress(now));
term.draw(|f| draw_zoom(f, pane, title, w, h, t, ""))?;
frames += 1;
}
anim::record("open-zoom", frames, anim.elapsed(Instant::now()));
Ok(())
}
fn zoom_out(
term: &mut DefaultTerminal,
pane: &mut ImagePane,
title: &str,
w: u32,
h: u32,
) -> io::Result<()> {
let anim = Anim::new(Instant::now(), ZOOM_OUT_DUR);
let mut frames = 0u32;
loop {
let now = Instant::now();
if anim.done(now) {
break;
}
if event::poll(ZOOM_POLL)? {
let _ = event::read()?;
break;
}
let t = 1.0 - ease_out_cubic(anim.progress(now));
term.draw(|f| draw_zoom(f, pane, title, w, h, t, ""))?;
frames += 1;
}
anim::record("close-zoom", frames, anim.elapsed(Instant::now()));
Ok(())
}
fn status(f: &mut RtFrame, area: Rect, text: &str) {
f.render_widget(
Paragraph::new(Line::from(text.to_string()))
.style(Style::default().fg(Color::Rgb(140, 140, 150))),
area,
);
}