use std::{
collections::{HashMap, HashSet, hash_map::Entry},
io,
ops::{
ControlFlow::{Break, Continue},
Range,
},
path::{Path, PathBuf},
time::Duration,
};
use anyhow::Result;
use ratatui::{
DefaultTerminal, Frame,
crossterm::event::KeyCode,
layout::{Constraint, Rect, Size},
style::Stylize,
text::{Line, Span},
widgets::Block,
};
use ratatui_image::{FontSize, protocol::Protocol};
use super::{
Board, Core, Exit, Graphics, HINTS, Loading, Outcome, Step, draw_check, draw_hint,
prefetch::{Prefetch, Shape, Thumb},
protocol,
};
use crate::search::{Hit, Order};
const PHOTO: f32 = 3.0 / 2.0;
const LOADING: usize = 4;
const MIN_TILE: u16 = 2;
#[derive(Default)]
pub struct Grid {
hits: Vec<Hit>,
selected: usize,
geometry: Option<Geometry>,
thumbs: HashMap<PathBuf, Loading<Thumb>>,
images: HashMap<PathBuf, Protocol>,
shown: Scene,
}
#[derive(Default, PartialEq)]
struct Scene {
bounds: Rect,
page: usize,
zoom: bool,
marked: Vec<usize>,
}
impl Grid {
fn page(&self, geometry: Geometry) -> Range<usize> {
let start = self.selected / geometry.len() * geometry.len();
start..(start + geometry.len()).min(self.hits.len())
}
fn select_hit(&mut self, i: Option<usize>) -> bool {
let found = i.filter(|&i| i < self.hits.len());
if let Some(i) = found {
self.selected = i;
}
found.is_some()
}
fn turn(&mut self, step: Step) {
let Some(geometry) = self.geometry else {
return;
};
let start = self.page(geometry).start;
self.select_hit(match step {
Step::Back => start.checked_sub(geometry.len()),
Step::Forward => Some(start + geometry.len()),
});
}
fn request(&mut self, prefetch: &Prefetch, geometry: Geometry) {
let page = self.page(geometry);
let start = page.start.saturating_sub(geometry.len());
let end = (page.start + 2 * geometry.len()).min(self.hits.len());
let path = |i: usize| self.hits[i].path.as_path();
let near: HashSet<&Path> = (start..end).map(path).collect();
let next: Vec<&Path> = (page.start..end)
.chain(start..page.start)
.map(path)
.filter(|p| {
!matches!(
self.thumbs.get(*p),
Some(Loading::Ready(_) | Loading::Failed)
)
})
.take(LOADING)
.collect();
self.thumbs.retain(|p, thumb| match thumb {
Loading::Pending(_) => next.contains(&p.as_path()),
Loading::Ready(_) => near.contains(p.as_path()),
Loading::Failed => true,
});
for p in next {
if let Entry::Vacant(entry) = self.thumbs.entry(p.to_owned()) {
let thumb = prefetch.thumb(p.to_owned(), Shape::Fill(geometry.tile));
entry.insert(Loading::Pending(thumb));
}
}
}
fn scene(&self, core: &Core, geometry: Geometry) -> Scene {
let page = self.page(geometry);
Scene {
bounds: core.bounds,
page: page.start / geometry.len(),
zoom: core.zoom.is_some(),
marked: page.filter(|&i| core.marked(&self.hits[i].path)).collect(),
}
}
}
impl Board for Grid {
fn push(&mut self, hit: Hit) {
self.hits.push(hit);
}
fn clear(&mut self, _: &Order<()>) {
self.hits.clear();
self.selected = 0;
self.shown = Scene::default();
}
fn tick(&mut self, core: &mut Core, _: Duration) -> Result<Outcome> {
if !core.searching && self.hits.is_empty() {
return Ok(Break(Exit::Empty));
}
let images = if core.searching {
HINTS.len()
} else {
self.hits.len()
};
let geometry = Geometry::new(core.room(), core.picker.font_size(), images);
if geometry != self.geometry {
self.thumbs
.retain(|_, thumb| matches!(thumb, Loading::Failed));
self.images.clear();
self.geometry = geometry;
}
let Some(geometry) = geometry else {
return Ok(Continue(()));
};
let page: HashSet<&Path> = self.page(geometry).map(|i| &*self.hits[i].path).collect();
self.images.retain(|path, _| page.contains(path.as_path()));
for thumb in self.thumbs.values_mut() {
thumb.poll(Some);
}
self.request(&core.prefetch, geometry);
Ok(Continue(()))
}
fn idle(&self) -> bool {
!self.thumbs.values().any(Loading::pending)
}
fn key(&mut self, _: &mut Core, code: KeyCode) {
match code {
KeyCode::Right | KeyCode::PageDown | KeyCode::Char(' ') => self.turn(Step::Forward),
KeyCode::Left | KeyCode::PageUp => self.turn(Step::Back),
_ => {}
}
}
fn selected(&self) -> Option<&Path> {
self.hits.get(self.selected).map(|hit| hit.path.as_path())
}
fn select(&mut self, hint: usize) -> bool {
let Some(geometry) = self.geometry else {
return false;
};
let page = self.page(geometry);
self.select_hit(Some(page.start + hint).filter(|i| page.contains(i)))
}
fn step(&mut self, step: Step) -> bool {
self.select_hit(match step {
Step::Back => self.selected.checked_sub(1),
Step::Forward => Some(self.selected + 1),
})
}
fn is_empty(&self) -> bool {
self.hits.is_empty()
}
fn prepare(&mut self, terminal: &mut DefaultTerminal, core: &Core) -> io::Result<()> {
let Some(geometry) = self.geometry else {
return Ok(());
};
if core.zoom.is_none() {
for i in self.page(geometry) {
let path = &self.hits[i].path;
let Some(Loading::Ready(thumb)) = self.thumbs.get(path) else {
continue;
};
if self.images.contains_key(path) {
continue;
}
match protocol(&core.picker, &core.search.progress, thumb.clone()) {
Some(image) => _ = self.images.insert(path.clone(), image),
None => _ = self.thumbs.insert(path.clone(), Loading::Failed),
}
}
}
if core.graphics == Graphics::Layered {
let scene = self.scene(core, geometry);
if scene != self.shown {
terminal.clear()?;
self.shown = scene;
}
}
Ok(())
}
fn draw(&self, frame: &mut Frame, core: &Core) {
let Some(geometry) = self.geometry else {
return;
};
for (n, i) in self.page(geometry).enumerate() {
let rect = geometry.tile(core.bounds, n);
let path = &self.hits[i].path;
let color = core.edge(path, i == self.selected);
if let Some(image) = self.images.get(path) {
core.draw_image(frame, image, rect);
core.tint(frame, rect, color);
} else if matches!(self.thumbs.get(path), Some(Loading::Failed)) {
frame.render_widget(Block::bordered().border_style(color), rect);
let row = rect.centered_vertically(Constraint::Length(1));
frame.render_widget(Line::from("cannot open").centered().dim(), row);
} else {
continue;
}
let hint = Rect::new(rect.x, rect.bottom() - 1, 3, 1).intersection(rect);
draw_hint(frame, hint, n, i == self.selected);
if core.marked(path) {
let x = rect.right().saturating_sub(3).max(rect.x + 1);
draw_check(frame, Rect::new(x, rect.y, rect.right() - x, 1));
}
}
}
fn status(&self) -> Vec<Span<'static>> {
let page = match self.geometry {
Some(geometry) if !self.hits.is_empty() => {
let pages = self.hits.len().div_ceil(geometry.len());
format!("page {}/{pages} ", self.selected / geometry.len() + 1)
}
_ => String::new(),
};
let n = self.hits.len();
let images = if n == 1 { "image" } else { "images" };
vec![Span::raw(format!("{page}{n} {images} "))]
}
fn keys(&self) -> Span<'static> {
"a-z select · A-Z zoom · tab mark · enter pick · ←/→ page · / search · * similar · esc quit"
.dim()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Geometry {
cols: u16,
rows: u16,
tile: Size,
}
impl Geometry {
fn new(bounds: Size, font: FontSize, images: usize) -> Option<Self> {
let most = HINTS.len() as u16;
(1..=most)
.flat_map(|cols| (1..=most / cols).map(move |rows| (cols, rows)))
.map(|(cols, rows)| Self {
cols,
rows,
tile: Size::new(bounds.width / cols, bounds.height / rows),
})
.filter(|g| g.tile.width >= MIN_TILE && g.tile.height >= MIN_TILE)
.max_by(|a, b| a.score(font, images).total_cmp(&b.score(font, images)))
}
fn score(self, font: FontSize, images: usize) -> f32 {
let width = f32::from(self.tile.width) * f32::from(font.width);
let height = f32::from(self.tile.height) * f32::from(font.height);
let photo = width.min(height * PHOTO);
let count = self.len().min(images) as f32;
count * count * photo * photo / PHOTO
}
fn len(self) -> usize {
usize::from(self.cols * self.rows)
}
fn tile(self, bounds: Rect, n: usize) -> Rect {
let (col, row) = (n as u16 % self.cols, n as u16 / self.cols);
let (width, height) = (self.tile.width, self.tile.height);
Rect::new(
bounds.x + (bounds.width - self.cols * width) / 2 + col * width,
bounds.y + (bounds.height - self.rows * height) / 2 + row * height,
width,
height,
)
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
const FONT: FontSize = FontSize::new(10, 20);
fn shape(width: u16, height: u16) -> (u16, u16, Size) {
let g = Geometry::new(Size::new(width, height), FONT, HINTS.len()).unwrap();
(g.cols, g.rows, g.tile)
}
#[test]
fn tiles_get_the_shape_of_photos() {
assert_eq!(shape(200, 49), (6, 4, Size::new(33, 12)));
assert_eq!(shape(80, 23), (6, 4, Size::new(13, 5)));
assert_eq!(shape(200, 30), (8, 3, Size::new(25, 10)));
assert_eq!(shape(60, 60), (3, 8, Size::new(20, 7)));
}
#[test]
fn a_screen_shaped_like_a_photo_gets_more_than_one_tile() {
assert_eq!(shape(160, 49), (5, 5, Size::new(32, 9)));
}
#[test]
fn a_few_images_get_large_tiles_on_one_page() {
let shape = |images| {
let g = Geometry::new(Size::new(200, 49), FONT, images).unwrap();
(g.cols, g.rows)
};
assert_eq!(shape(1), (1, 1));
assert_eq!(shape(2), (2, 1));
assert_eq!(shape(3), (2, 2));
assert_eq!(shape(24), (6, 4));
assert_eq!(shape(500), (6, 4));
}
#[test]
fn tiles_fit_and_get_one_hint_each() {
for width in (1..400).step_by(7) {
for height in (1..120).step_by(5) {
let Some(g) = Geometry::new(Size::new(width, height), FONT, HINTS.len()) else {
assert!(width < MIN_TILE || height < MIN_TILE);
continue;
};
assert!(g.len() <= HINTS.len());
assert!(g.tile.width >= MIN_TILE && g.tile.height >= MIN_TILE);
assert!(g.cols * g.tile.width <= width && g.rows * g.tile.height <= height);
}
}
}
#[test]
fn tiles_touch_in_reading_order_in_the_middle() {
let g = Geometry {
cols: 3,
rows: 2,
tile: Size::new(10, 5),
};
let bounds = Rect::new(0, 0, 33, 11);
assert_eq!(g.tile(bounds, 0), Rect::new(1, 0, 10, 5));
assert_eq!(g.tile(bounds, 1), Rect::new(11, 0, 10, 5));
assert_eq!(g.tile(bounds, 3), Rect::new(1, 5, 10, 5));
assert_eq!(g.tile(bounds, 5), Rect::new(21, 5, 10, 5));
}
#[test]
fn pages_and_steps() {
let mut grid = Grid {
hits: (0..30)
.map(|i| Hit::new(PathBuf::from(i.to_string())))
.collect(),
geometry: Some(Geometry {
cols: 4,
rows: 3,
tile: Size::new(10, 5),
}),
..Grid::default()
};
assert!(grid.select(11));
assert!(!grid.select(12), "the next page has no hints yet");
grid.turn(Step::Back);
assert_eq!(grid.selected, 11, "no page before the first");
grid.turn(Step::Forward);
assert_eq!(grid.selected, 12);
grid.turn(Step::Forward);
assert_eq!(grid.selected(), Some(Path::new("24")));
assert!(!grid.select(6), "the last page holds 24 to 29");
grid.turn(Step::Forward);
assert_eq!(grid.selected, 24, "no page after the last");
assert!(grid.step(Step::Back), "the zoom steps across pages");
assert_eq!(grid.selected, 23);
grid.selected = 29;
assert!(!grid.step(Step::Forward));
grid.turn(Step::Back);
assert_eq!(grid.selected, 12);
}
}