Compare commits

..

7 Commits

Author SHA1 Message Date
itsjunetime 9e9053acbc Add note to README that contributions will be licensed as MPL-2.0 2024-12-01 11:30:08 -07:00
itsjunetime e67a2ec421 Relicense to GPLv3 since poppler is GPL and we must not violate that license 2024-11-15 23:14:53 -07:00
itsjunetime 40d46f1e2d Make tdf run on stable with --no-default-features 2024-11-15 14:14:54 -07:00
itsjunetime 927a9cb587 Update ratatui deps 2024-11-13 10:16:05 -07:00
itsjunetime e51e9d3464 Add --r-to-l and --max-wide flags to cli args 2024-11-03 16:41:58 -07:00
itsjunetime 7c13054383 Fix a few more small clippy issues 2024-10-26 15:45:20 -06:00
itsjunetime b1e26bc96b Add fields to Cargo.toml and add CHANGELOG.md 2024-10-26 15:34:11 -06:00
13 changed files with 1494 additions and 606 deletions
+11
View File
@@ -0,0 +1,11 @@
# Unreleased
- Add `--r-to-l` flag to support displaying pdfs that read from right to left
- Add `--max-wide` flag to restrict amount of pages that can appear on the screen at a time
- Small internal changes to accomodate a few more clippy lints
- Update `ratatui` and `ratatui-image` git dependencies to latest upstream
- Move `ratatui-image/vb64` support under `nightly` feature, enabled by default
# v0.1.0
Initial tag :)
Generated
+595 -69
View File
File diff suppressed because it is too large Load Diff
+14 -5
View File
@@ -1,8 +1,15 @@
[package] [package]
name = "tdf" name = "tdf"
version = "0.1.0" version = "0.1.0"
authors = ["June Welker <junewelker@gmail.com>"]
edition = "2021" edition = "2021"
license = "MPL-2.0" description = "A terminal viewer for PDFs"
readme = "README.md"
homepage = "https://github.com/itsjunetime/tdf"
repository = "https://github.com/itsjunetime/tdf"
license = "GPL-3.0-or-later"
keywords = ["pdf", "tui", "cli", "terminal"]
categories = ["command-line-utilities", "text-processing", "visualization"]
default-run = "tdf" default-run = "tdf"
[[bin]] [[bin]]
@@ -17,10 +24,10 @@ poppler-rs = { version = "0.24.1", default-features = false, features = ["v23_7"
cairo-rs = { version = "0.20.0", default-features = false, features = ["png"] } cairo-rs = { version = "0.20.0", default-features = false, features = ["png"] }
# we're using this branch because it has significant performance fixes that I'm waiting on responses from the upstream devs to get upstreamed. See https://github.com/ratatui-org/ratatui/issues/1116 # we're using this branch because it has significant performance fixes that I'm waiting on responses from the upstream devs to get upstreamed. See https://github.com/ratatui-org/ratatui/issues/1116
ratatui = { git = "https://github.com/itsjunetime/ratatui.git" } ratatui = { git = "https://github.com/itsjunetime/ratatui.git" }
# ratatui = { path = "./ratatui" } # ratatui = { path = "./ratatui/ratatui" }
# We're using this to have the vb64 feature (for faster base64 encoding, since that does take up a good bit of time when converting images to the Box<dyn ratatui_image::Protocol>. It also just includes a few more features that I'm waiting on main to upstream # We're using this to have the vb64 feature (for faster base64 encoding, since that does take up a good bit of time when converting images to the Box<dyn ratatui_image::Protocol>. It also just includes a few more features that I'm waiting on main to upstream
ratatui-image = { git = "https://github.com/itsjunetime/ratatui-image.git", branch = "vb64_on_personal", features = ["rustix", "vb64"], default-features = false } ratatui-image = { git = "https://github.com/itsjunetime/ratatui-image.git", branch = "vb64_on_personal", default-features = false }
# ratatui-image = { path = "./ratatui-image", features = ["rustix", "vb64"], default-features = false } # ratatui-image = { path = "./ratatui-image", features = ["vb64"], default-features = false }
crossterm = { version = "0.28.1", features = ["event-stream"] } crossterm = { version = "0.28.1", features = ["event-stream"] }
image = { version = "0.25.1", features = ["png", "rayon"], default-features = false } image = { version = "0.25.1", features = ["png", "rayon"], default-features = false }
notify = { version = "7.0.0", features = ["crossbeam-channel"] } notify = { version = "7.0.0", features = ["crossbeam-channel"] }
@@ -29,6 +36,7 @@ futures-util = { version = "0.3.30", default-features = false }
glib = "0.20.0" glib = "0.20.0"
itertools = "*" itertools = "*"
flume = { version = "0.11.0", default-features = false, features = ["async"] } flume = { version = "0.11.0", default-features = false, features = ["async"] }
xflags = "0.4.0-pre.2"
# for tracing with tokio-console # for tracing with tokio-console
console-subscriber = { version = "0.4.0", optional = true } console-subscriber = { version = "0.4.0", optional = true }
@@ -38,7 +46,8 @@ inherits = "release"
lto = "fat" lto = "fat"
[features] [features]
default = [] default = ["nightly"]
nightly = ["ratatui-image/vb64"]
tracing = ["tokio/tracing", "dep:console-subscriber"] tracing = ["tokio/tracing", "dep:console-subscriber"]
[dev-dependencies] [dev-dependencies]
+674 -373
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -29,3 +29,5 @@ I dunno. Just for fun, mostly.
## Can I contribute? ## Can I contribute?
Yeah, sure. Please do. Yeah, sure. Please do.
Please note, though, that all contributions will be treated as licensed under MPL-2.0. This is so that we can relicense to MPL-2.0 at some point in the future if we manage to move away from poppler as a backend (since that is the only dependency, at time of writing, which requires the GPLv3 license).
+4 -4
View File
@@ -75,7 +75,7 @@ pub fn start_rendering_loop(
width: columns * FONT_SIZE.0 width: columns * FONT_SIZE.0
}; };
std::thread::spawn(move || start_rendering(str_path, to_main_tx, from_main_rx, size)); std::thread::spawn(move || start_rendering(&str_path, to_main_tx, from_main_rx, size));
let main_area = Rect { let main_area = Rect {
x: 0, x: 0,
@@ -98,8 +98,8 @@ pub fn start_converting_loop(
let (to_converter_tx, from_main_rx) = unbounded(); let (to_converter_tx, from_main_rx) = unbounded();
let (to_main_tx, from_converter_rx) = unbounded(); let (to_main_tx, from_converter_rx) = unbounded();
let mut picker = Picker::new(FONT_SIZE); let mut picker = Picker::from_fontsize(FONT_SIZE);
picker.protocol_type = ProtocolType::Kitty; picker.set_protocol_type(ProtocolType::Kitty);
tokio::spawn(run_conversion_loop( tokio::spawn(run_conversion_loop(
to_main_tx, to_main_tx,
@@ -136,7 +136,7 @@ pub async fn render_doc(path: impl AsRef<Path>) {
to_render_tx to_render_tx
} = start_all_rendering(path); } = start_all_rendering(path);
while pages.is_empty() || pages.iter().any(|p| p.is_none()) { while pages.is_empty() || pages.iter().any(Option::is_none) {
tokio::select! { tokio::select! {
Some(renderer_msg) = from_render_rx.next() => { Some(renderer_msg) = from_render_rx.next() => {
handle_renderer_msg(renderer_msg, &mut pages, &mut to_converter_tx); handle_renderer_msg(renderer_msg, &mut pages, &mut to_converter_tx);
+1 -1
Submodule ratatui updated: a3fd887eaa...8bf0c1ef77
+1 -1
View File
@@ -7,7 +7,7 @@ use ratatui_image::{picker::Picker, protocol::Protocol, Resize};
use crate::renderer::{fill_default, PageInfo, RenderError}; use crate::renderer::{fill_default, PageInfo, RenderError};
pub struct ConvertedPage { pub struct ConvertedPage {
pub page: Box<dyn Protocol>, pub page: Protocol,
pub num: usize, pub num: usize,
pub num_results: usize pub num_results: usize
} }
-2
View File
@@ -1,5 +1,3 @@
#![feature(if_let_guard)]
pub mod converter; pub mod converter;
pub mod renderer; pub mod renderer;
pub mod skip; pub mod skip;
+62 -59
View File
@@ -1,9 +1,7 @@
#![feature(if_let_guard)]
use std::{ use std::{
io::{stdout, Read, Write}, io::{stdout, Read, Write},
path::PathBuf, num::NonZeroUsize,
str::FromStr path::PathBuf
}; };
use converter::{run_conversion_loop, ConvertedPage, ConverterMsg}; use converter::{run_conversion_loop, ConvertedPage, ConverterMsg};
@@ -44,42 +42,33 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
#[cfg(feature = "tracing")] #[cfg(feature = "tracing")]
console_subscriber::init(); console_subscriber::init();
let file = std::env::args() let flags = xflags::parse_or_exit! {
.nth(1) /// Display the pdf with the pages starting at the right hand size and moving left and
.ok_or("Program requires a file to process")?; /// adjust input keys to match
let path = PathBuf::from_str(&file)?.canonicalize()?; optional -r,--r-to-l r_to_l: bool
/// The maximum number of pages to display together, horizontally, at a time
optional -m,--max-wide max_wide: NonZeroUsize
/// PDF file to read
required file: PathBuf
};
let (watch_tx, render_rx) = flume::unbounded(); let path = flags.file.canonicalize()?;
let tui_tx = watch_tx.clone();
let (watch_to_render_tx, render_rx) = flume::unbounded();
let tui_tx = watch_to_render_tx.clone();
let (render_tx, tui_rx) = flume::unbounded(); let (render_tx, tui_rx) = flume::unbounded();
let watch_to_tui_tx = render_tx.clone(); let watch_to_tui_tx = render_tx.clone();
// we need to call this outside the recommended_watcher call because if we call it inside, that let mut watcher =
// will be calling it from a thread not owned by the tokio runtime (since it's created by notify::recommended_watcher(on_notify_ev(watch_to_tui_tx, watch_to_render_tx))?;
// calling thread::spawn) and that will cause a panic
let mut watcher = notify::recommended_watcher(move |res: notify::Result<Event>| match res {
// If we get an error here, and then an error sending, everything's going wrong. Just give
// up lol.
Err(e) => watch_to_tui_tx.send(Err(RenderError::Notify(e))).unwrap(),
// TODO: Should we match EventKind::Rename and propogate that so that the other parts of the
// process know that too? Or should that be
Ok(ev) => match ev.kind {
EventKind::Access(_) => (),
EventKind::Remove(_) =>
drop(watch_to_tui_tx.send(Err(RenderError::Render("File was deleted".into())))),
// This shouldn't fail to send unless the receiver gets disconnected. If that's
// happened, then like the main thread has panicked or something, so it doesn't matter
// we don't handle the error here.
EventKind::Other | EventKind::Any | EventKind::Create(_) | EventKind::Modify(_) =>
drop(watch_tx.send(renderer::RenderNotif::Reload)),
}
})?;
// We're making this nonrecursive 'cause we're just watching a single file, so there's nothing // We're making this nonrecursive 'cause we're just watching a single file, so there's nothing
// to recurse into // to recurse into
watcher.watch(&path, RecursiveMode::NonRecursive)?; watcher.watch(&path, RecursiveMode::NonRecursive)?;
// TODO: Handle non-utf8 file names? Maybe by constructing a CString and passing that in to the
// poppler stuff instead of a rust string?
let file_path = format!("file://{}", path.clone().into_os_string().to_string_lossy()); let file_path = format!("file://{}", path.clone().into_os_string().to_string_lossy());
let mut window_size = window_size()?; let mut window_size = window_size()?;
@@ -96,7 +85,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// read in the returned size until we hit a 't' (which indicates to us it's done) // read in the returned size until we hit a 't' (which indicates to us it's done)
let input_vec = std::io::stdin() let input_vec = std::io::stdin()
.bytes() .bytes()
.flat_map(|b| b.ok()) .filter_map(Result::ok)
.take_while(|b| *b != b't') .take_while(|b| *b != b't')
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@@ -134,17 +123,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// We need to create `picker` on this thread because if we create it on the `renderer` thread, // We need to create `picker` on this thread because if we create it on the `renderer` thread,
// it messes up something with user input. Input never makes it to the crossterm thing // it messes up something with user input. Input never makes it to the crossterm thing
let mut picker = Picker::new(( let picker = Picker::from_query_stdio()?;
window_size.width / window_size.columns,
window_size.height / window_size.rows
));
picker.guess_protocol();
// then we want to spawn off the rendering task // then we want to spawn off the rendering task
// We need to use the thread::spawn API so that this exists in a thread not owned by tokio, // We need to use the thread::spawn API so that this exists in a thread not owned by tokio,
// since the methods we call in `start_rendering` will panic if called in an async context // since the methods we call in `start_rendering` will panic if called in an async context
std::thread::spawn(move || { std::thread::spawn(move || {
renderer::start_rendering(file_path, render_tx, render_rx, window_size) renderer::start_rendering(&file_path, render_tx, render_rx, window_size)
}); });
let mut ev_stream = crossterm::event::EventStream::new(); let mut ev_stream = crossterm::event::EventStream::new();
@@ -154,12 +139,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
tokio::spawn(run_conversion_loop(to_main, from_main, picker, 20)); tokio::spawn(run_conversion_loop(to_main, from_main, picker, 20));
let file_name = path let file_name = path.file_name().map_or_else(
.file_name() || "Unknown file".into(),
.map(|n| n.to_string_lossy()) |n| n.to_string_lossy().to_string()
.unwrap_or_else(|| "Unknown file".into()) );
.to_string(); let mut tui = tui::Tui::new(file_name, flags.max_wide, flags.r_to_l.unwrap_or_default());
let mut tui = tui::Tui::new(file_name);
let backend = CrosstermBackend::new(std::io::stdout()); let backend = CrosstermBackend::new(std::io::stdout());
let mut term = Terminal::new(backend)?; let mut term = Terminal::new(backend)?;
@@ -184,25 +168,23 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut from_converter = from_converter.into_stream(); let mut from_converter = from_converter.into_stream();
loop { loop {
let mut needs_redraw = tokio::select! { let mut needs_redraw = true;
tokio::select! {
// First we check if we have any keystrokes // First we check if we have any keystrokes
Some(ev) = ev_stream.next().fuse() => { Some(ev) = ev_stream.next().fuse() => {
// If we can't get user input, just crash. // If we can't get user input, just crash.
let ev = ev.expect("Couldn't get any user input"); let ev = ev.expect("Couldn't get any user input");
match tui.handle_event(ev) { match tui.handle_event(&ev) {
None => false, None => needs_redraw = false,
Some(action) => { Some(action) => match action {
match action { InputAction::Redraw => (),
InputAction::Redraw => (), InputAction::QuitApp => break,
InputAction::QuitApp => break, InputAction::JumpingToPage(page) => {
InputAction::JumpingToPage(page) => { tui_tx.send(RenderNotif::JumpToPage(page))?;
tui_tx.send(RenderNotif::JumpToPage(page))?; to_converter.send(ConverterMsg::GoToPage(page))?;
to_converter.send(ConverterMsg::GoToPage(page))?; },
}, InputAction::Search(term) => tui_tx.send(RenderNotif::Search(term))?,
InputAction::Search(term) => tui_tx.send(RenderNotif::Search(term))?,
};
true
} }
} }
}, },
@@ -225,14 +207,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}, },
Err(e) => tui.show_error(e), Err(e) => tui.show_error(e),
} }
true
} }
Some(img_res) = from_converter.next() => { Some(img_res) = from_converter.next() => {
match img_res { match img_res {
Ok(ConvertedPage { page, num, num_results }) => tui.page_ready(page, num, num_results), Ok(ConvertedPage { page, num, num_results }) => tui.page_ready(page, num, num_results),
Err(e) => tui.show_error(e), Err(e) => tui.show_error(e),
} }
true
}, },
}; };
@@ -261,6 +241,29 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
Ok(()) Ok(())
} }
fn on_notify_ev(
to_tui_tx: flume::Sender<Result<RenderInfo, RenderError>>,
to_render_tx: flume::Sender<RenderNotif>
) -> impl Fn(notify::Result<Event>) {
move |res| match res {
// If we get an error here, and then an error sending, everything's going wrong. Just give
// up lol.
Err(e) => to_tui_tx.send(Err(RenderError::Notify(e))).unwrap(),
// TODO: Should we match EventKind::Rename and propogate that so that the other parts of the
// process know that too? Or should that be
Ok(ev) => match ev.kind {
EventKind::Access(_) => (),
EventKind::Remove(_) =>
drop(to_tui_tx.send(Err(RenderError::Render("File was deleted".into())))),
// This shouldn't fail to send unless the receiver gets disconnected. If that's
// happened, then like the main thread has panicked or something, so it doesn't matter
// we don't handle the error here.
EventKind::Other | EventKind::Any | EventKind::Create(_) | EventKind::Modify(_) =>
drop(to_render_tx.send(renderer::RenderNotif::Reload)),
}
}
}
fn noop(_: LogLevel, _: &[LogField<'_>]) -> LogWriterOutput { fn noop(_: LogLevel, _: &[LogField<'_>]) -> LogWriterOutput {
LogWriterOutput::Handled LogWriterOutput::Handled
} }
+16 -12
View File
@@ -64,8 +64,12 @@ pub fn fill_default<T: Default>(vec: &mut Vec<T>, size: usize) {
// Also we just kinda 'unwrap' all of the send/recv calls here 'cause if they return an error, that // Also we just kinda 'unwrap' all of the send/recv calls here 'cause if they return an error, that
// means the other side's disconnected, which means that the main thread has panicked, which means // means the other side's disconnected, which means that the main thread has panicked, which means
// we're done. // we're done.
// We're allowing passing by value here because this is only called once, at the beginning of the
// program, and the arguments that 'should' be passed by value (`receiver` and `size`) would
// probably be more performant if accessed by-value instead of through a reference. Probably.
#[allow(clippy::needless_pass_by_value)]
pub fn start_rendering( pub fn start_rendering(
path: String, path: &str,
mut sender: Sender<Result<RenderInfo, RenderError>>, mut sender: Sender<Result<RenderInfo, RenderError>>,
receiver: Receiver<RenderNotif>, receiver: Receiver<RenderNotif>,
size: WindowSize size: WindowSize
@@ -90,7 +94,7 @@ pub fn start_rendering(
let col_h = size.height / size.rows; let col_h = size.height / size.rows;
'reload: loop { 'reload: loop {
let doc = match Document::from_file(&path, None) { let doc = match Document::from_file(path, None) {
Err(e) => { Err(e) => {
// if there's an error, tell the main loop // if there's an error, tell the main loop
sender.send(Err(RenderError::Doc(e)))?; sender.send(Err(RenderError::Doc(e)))?;
@@ -189,8 +193,8 @@ pub fn start_rendering(
.map(|(idx, p)| (start_point - (idx + 1), p)) .map(|(idx, p)| (start_point - (idx + 1), p))
); );
let area_w = area.width as f64 * col_w as f64; let area_w = f64::from(area.width) * f64::from(col_w);
let area_h = area.height as f64 * col_h as f64; let area_h = f64::from(area.height) * f64::from(col_h);
// we go through each page // we go through each page
for (num, rendered) in page_iter { for (num, rendered) in page_iter {
@@ -227,8 +231,8 @@ pub fn start_rendering(
// render the page // render the page
match render_single_page_to_ctx( match render_single_page_to_ctx(
page, &page,
&search_term, search_term.as_deref(),
rendered_with_no_results, rendered_with_no_results,
(area_w, area_h) (area_w, area_h)
) { ) {
@@ -251,11 +255,11 @@ pub fn start_rendering(
// since the effects of parallelizing that will be noticeable if the user // since the effects of parallelizing that will be noticeable if the user
// tries to move through pages more quickly // tries to move through pages more quickly
if num == start_point { if num == start_point {
render_ctx_to_png(ctx, &mut sender, (col_w, col_h), num)?; render_ctx_to_png(&ctx, &mut sender, (col_w, col_h), num)?;
} else { } else {
let mut sender = sender.clone(); let mut sender = sender.clone();
thread::spawn(move || { thread::spawn(move || {
render_ctx_to_png(ctx, &mut sender, (col_w, col_h), num) render_ctx_to_png(&ctx, &mut sender, (col_w, col_h), num)
}); });
} }
} }
@@ -298,8 +302,8 @@ struct RenderedContext {
unsafe impl Send for RenderedContext {} unsafe impl Send for RenderedContext {}
fn render_single_page_to_ctx( fn render_single_page_to_ctx(
page: Page, page: &Page,
search_term: &Option<String>, search_term: Option<&str>,
already_rendered_no_results: bool, already_rendered_no_results: bool,
(area_w, area_h): (f64, f64) (area_w, area_h): (f64, f64)
) -> Result<Option<RenderedContext>, String> { ) -> Result<Option<RenderedContext>, String> {
@@ -375,7 +379,7 @@ fn render_single_page_to_ctx(
highlight_color.set_green((u16::MAX / 5) * 4); highlight_color.set_green((u16::MAX / 5) * 4);
let mut old_rect = Rectangle::new(); let mut old_rect = Rectangle::new();
for rect in result_rects.iter_mut() { for rect in &mut result_rects {
// According to https://gitlab.freedesktop.org/poppler/poppler/-/issues/763, these rects // According to https://gitlab.freedesktop.org/poppler/poppler/-/issues/763, these rects
// need to be corrected since they use different references as the y-coordinate base // need to be corrected since they use different references as the y-coordinate base
rect.set_y1(p_height - rect.y1()); rect.set_y1(p_height - rect.y1());
@@ -401,7 +405,7 @@ fn render_single_page_to_ctx(
} }
fn render_ctx_to_png( fn render_ctx_to_png(
ctx: RenderedContext, ctx: &RenderedContext,
sender: &mut Sender<Result<RenderInfo, RenderError>>, sender: &mut Sender<Result<RenderInfo, RenderError>>,
(col_w, col_h): (u16, u16), (col_w, col_h): (u16, u16),
page: usize page: usize
+113 -79
View File
@@ -1,4 +1,4 @@
use std::{io::stdout, rc::Rc}; use std::{io::stdout, num::NonZeroUsize, rc::Rc};
use crossterm::{ use crossterm::{
event::{Event, KeyCode, MouseEventKind}, event::{Event, KeyCode, MouseEventKind},
@@ -24,7 +24,8 @@ pub struct Tui {
// we use `prev_msg` to, for example, restore the 'search results' message on the bottom after // we use `prev_msg` to, for example, restore the 'search results' message on the bottom after
// jumping to a specific page // jumping to a specific page
prev_msg: Option<BottomMessage>, prev_msg: Option<BottomMessage>,
rendered: Vec<RenderedInfo> rendered: Vec<RenderedInfo>,
page_constraints: PageConstraints
} }
#[derive(Default, Debug)] #[derive(Default, Debug)]
@@ -50,12 +51,17 @@ pub enum InputCommand {
Search(String) Search(String)
} }
struct PageConstraints {
max_wide: Option<NonZeroUsize>,
r_to_l: bool
}
// This seems like a kinda weird struct because it holds two optionals but any representation // This seems like a kinda weird struct because it holds two optionals but any representation
// within it is valid; I think it's the best way to represent it // within it is valid; I think it's the best way to represent it
#[derive(Default)] #[derive(Default)]
struct RenderedInfo { struct RenderedInfo {
// The image, if it has been rendered by `Converter` to that struct // The image, if it has been rendered by `Converter` to that struct
img: Option<Box<dyn Protocol>>, img: Option<Protocol>,
// The number of results for the current search term that have been found on this page. None if // The number of results for the current search term that have been found on this page. None if
// we haven't checked this page yet // we haven't checked this page yet
// Also this isn't the most efficient representation of this value, but it's accurate, so like // Also this isn't the most efficient representation of this value, but it's accurate, so like
@@ -64,14 +70,15 @@ struct RenderedInfo {
} }
impl Tui { impl Tui {
pub fn new(name: String) -> Tui { pub fn new(name: String, max_wide: Option<NonZeroUsize>, r_to_l: bool) -> Tui {
Self { Self {
name, name,
page: 0, page: 0,
prev_msg: None, prev_msg: None,
bottom_msg: BottomMessage::Help, bottom_msg: BottomMessage::Help,
last_render: LastRender::default(), last_render: LastRender::default(),
rendered: vec![] rendered: vec![],
page_constraints: PageConstraints { max_wide, r_to_l }
} }
} }
@@ -196,15 +203,21 @@ impl Tui {
// here we calculate how many pages can fit in the available area. // here we calculate how many pages can fit in the available area.
let mut test_area_w = img_area.width; let mut test_area_w = img_area.width;
// go through our pages, starting at the first one we want to view // go through our pages, starting at the first one we want to view
let page_widths = self.rendered[self.page..] let mut page_widths = self.rendered[self.page..]
.iter() .iter()
// and get their indices (I know it's offset, we fix it down below when we actually // and get their indices (I know it's offset, we fix it down below when we actually
// render each page) // render each page)
.enumerate() .enumerate()
// and only take as many as are ready to be rendered // and only take as many as are ready to be rendered
.take_while(|(_, page)| page.img.is_some()) .take_while(|(idx, page)| {
let mut take = page.img.is_some();
if let Some(max) = self.page_constraints.max_wide {
take &= *idx < max.get();
}
take
})
// and map it to their width (in cells on the terminal, not pixels) // and map it to their width (in cells on the terminal, not pixels)
.flat_map(|(idx, page)| page.img.as_ref().map(|img| (idx, img.rect().width))) .filter_map(|(idx, page)| page.img.as_ref().map(|img| (idx, img.rect().width)))
// and then take them as long as they won't overflow the available area. // and then take them as long as they won't overflow the available area.
.take_while(|(_, width)| match test_area_w.checked_sub(*width) { .take_while(|(_, width)| match test_area_w.checked_sub(*width) {
Some(new_val) => { Some(new_val) => {
@@ -215,6 +228,10 @@ impl Tui {
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
if self.page_constraints.r_to_l {
page_widths.reverse();
}
if page_widths.is_empty() { if page_widths.is_empty() {
// If none are ready to render, just show the loading thing // If none are ready to render, just show the loading thing
Self::render_loading_in(frame, img_area); Self::render_loading_in(frame, img_area);
@@ -252,7 +269,7 @@ impl Tui {
fn render_single_page(&mut self, frame: &mut Frame<'_>, page_idx: usize, img_area: Rect) { fn render_single_page(&mut self, frame: &mut Frame<'_>, page_idx: usize, img_area: Rect) {
match self.rendered[page_idx].img { match self.rendered[page_idx].img {
Some(ref page_img) => frame.render_widget(Image::new(&**page_img), img_area), Some(ref page_img) => frame.render_widget(Image::new(page_img), img_area),
None => Self::render_loading_in(frame, img_area) None => Self::render_loading_in(frame, img_area)
}; };
} }
@@ -268,12 +285,23 @@ impl Tui {
frame.render_widget(loading_span, inner_space[0]); frame.render_widget(loading_span, inner_space[0]);
} }
fn change_page(&mut self, change: PageChange, amt: ChangeAmount) -> Option<InputAction> { fn change_page(&mut self, mut change: PageChange, amt: ChangeAmount) -> Option<InputAction> {
let diff = match amt { let diff = match amt {
ChangeAmount::Single => 1, ChangeAmount::Single => 1,
ChangeAmount::WholeScreen => self.last_render.pages_shown ChangeAmount::WholeScreen => self.last_render.pages_shown
}; };
// This is a kinda weird way to switch around the controls for this sort of thing but it
// allows it to be pretty centralized and avoids annoyingly duplicated match arms (since
// we'd have to do `match key { 'h' if r_to_l | 'l' => {}}` and that doesn't play well with
// `if` guards on match arms)
if self.page_constraints.r_to_l {
change = match change {
PageChange::Next => PageChange::Prev,
PageChange::Prev => PageChange::Next
};
}
let old = self.page; let old = self.page;
match change { match change {
PageChange::Next => self.set_page((self.page + diff).min(self.rendered.len() - 1)), PageChange::Next => self.set_page((self.page + diff).min(self.rendered.len() - 1)),
@@ -293,7 +321,7 @@ impl Tui {
self.page = self.page.min(n_pages - 1); self.page = self.page.min(n_pages - 1);
} }
pub fn page_ready(&mut self, img: Box<dyn Protocol>, page_num: usize, num_results: usize) { pub fn page_ready(&mut self, img: Protocol, page_num: usize, num_results: usize) {
// If this new image woulda fit within the available space on the last render AND it's // If this new image woulda fit within the available space on the last render AND it's
// within the range where it might've been rendered with the last shown pages, then reset // within the range where it might've been rendered with the last shown pages, then reset
// the last rect marker so that all images are forced to redraw on next render and this one // the last rect marker so that all images are forced to redraw on next render and this one
@@ -323,7 +351,7 @@ impl Tui {
self.rendered[page_num].num_results = Some(num_results); self.rendered[page_num].num_results = Some(num_results);
} }
pub fn handle_event(&mut self, ev: Event) -> Option<InputAction> { pub fn handle_event(&mut self, ev: &Event) -> Option<InputAction> {
fn jump_to_page( fn jump_to_page(
page: &mut usize, page: &mut usize,
rect: &mut Rect, rect: &mut Rect,
@@ -340,35 +368,77 @@ impl Tui {
match ev { match ev {
Event::Key(key) => { Event::Key(key) => {
match key.code { match key.code {
KeyCode::Char(c) KeyCode::Char(c) => {
if let BottomMessage::Input(InputCommand::Search(ref mut term)) = // TODO: refactor back to `if let` arm guards when those are stabilized
self.bottom_msg => if let BottomMessage::Input(InputCommand::Search(ref mut term)) = self.bottom_msg {
{ term.push(c);
term.push(c); return Some(InputAction::Redraw);
Some(InputAction::Redraw) }
}
KeyCode::Backspace if let BottomMessage::Input(InputCommand::GoToPage(ref mut page)) = self.bottom_msg {
if let BottomMessage::Input(InputCommand::Search(ref mut term)) = return c.to_digit(10).map(|input_num| {
self.bottom_msg => *page = (*page * 10) + input_num as usize;
{ InputAction::Redraw
term.pop(); });
Some(InputAction::Redraw) }
}
KeyCode::Char(c) match c {
if let BottomMessage::Input(InputCommand::GoToPage(ref mut page)) = 'l' => self.change_page(PageChange::Next, ChangeAmount::Single),
self.bottom_msg => 'j' => self.change_page(PageChange::Next, ChangeAmount::WholeScreen),
c.to_digit(10).map(|input_num| { 'h' => self.change_page(PageChange::Prev, ChangeAmount::Single),
*page = (*page * 10) + input_num as usize; 'k' => self.change_page(PageChange::Prev, ChangeAmount::WholeScreen),
InputAction::Redraw 'q' => Some(InputAction::QuitApp),
}), 'g' => {
KeyCode::Right | KeyCode::Char('l') => self.set_bottom_msg(Some(BottomMessage::Input(InputCommand::GoToPage(0))));
self.change_page(PageChange::Next, ChangeAmount::Single), Some(InputAction::Redraw)
KeyCode::Down | KeyCode::Char('j') => }
self.change_page(PageChange::Next, ChangeAmount::WholeScreen), '/' => {
KeyCode::Left | KeyCode::Char('h') => self.set_bottom_msg(Some(BottomMessage::Input(InputCommand::Search(
self.change_page(PageChange::Prev, ChangeAmount::Single), String::new()
KeyCode::Up | KeyCode::Char('k') => ))));
self.change_page(PageChange::Prev, ChangeAmount::WholeScreen), Some(InputAction::Redraw)
}
'n' if self.page < self.rendered.len() - 1 => {
// TODO: If we can't find one, then maybe like block until we've verified
// all the pages have been checked?
let next_page = self.rendered[(self.page + 1)..]
.iter()
.enumerate()
.find_map(|(idx, p)| {
p.num_results
.is_some_and(|num| num > 0)
.then_some(self.page + 1 + idx)
});
jump_to_page(&mut self.page, &mut self.last_render.rect, next_page)
}
'N' if self.page > 0 => {
let prev_page = self.rendered[..(self.page)]
.iter()
.rev()
.enumerate()
.find_map(|(idx, p)| {
p.num_results
.is_some_and(|num| num > 0)
.then_some(self.page - (idx + 1))
});
jump_to_page(&mut self.page, &mut self.last_render.rect, prev_page)
},
_ => None
}
},
KeyCode::Backspace => {
if let BottomMessage::Input(InputCommand::Search(ref mut term)) = self.bottom_msg {
term.pop();
return Some(InputAction::Redraw);
}
None
},
KeyCode::Right => self.change_page(PageChange::Next, ChangeAmount::Single),
KeyCode::Down => self.change_page(PageChange::Next, ChangeAmount::WholeScreen),
KeyCode::Left => self.change_page(PageChange::Prev, ChangeAmount::Single),
KeyCode::Up => self.change_page(PageChange::Prev, ChangeAmount::WholeScreen),
KeyCode::Esc => match self.bottom_msg { KeyCode::Esc => match self.bottom_msg {
BottomMessage::Input(_) => { BottomMessage::Input(_) => {
self.set_bottom_msg(None); self.set_bottom_msg(None);
@@ -376,44 +446,6 @@ impl Tui {
} }
_ => Some(InputAction::QuitApp) _ => Some(InputAction::QuitApp)
}, },
KeyCode::Char('q') => Some(InputAction::QuitApp),
KeyCode::Char('g') => {
self.set_bottom_msg(Some(BottomMessage::Input(InputCommand::GoToPage(0))));
Some(InputAction::Redraw)
}
KeyCode::Char('/') => {
self.set_bottom_msg(Some(BottomMessage::Input(InputCommand::Search(
String::new()
))));
Some(InputAction::Redraw)
}
KeyCode::Char('n') if self.page < self.rendered.len() - 1 => {
// TODO: If we can't find one, then maybe like block until we've verified
// all the pages have been checked?
let next_page = self.rendered[(self.page + 1)..]
.iter()
.enumerate()
.find_map(|(idx, p)| {
p.num_results
.is_some_and(|num| num > 0)
.then_some(self.page + 1 + idx)
});
jump_to_page(&mut self.page, &mut self.last_render.rect, next_page)
}
KeyCode::Char('N') if self.page > 0 => {
let prev_page = self.rendered[..(self.page)]
.iter()
.rev()
.enumerate()
.find_map(|(idx, p)| {
p.num_results
.is_some_and(|num| num > 0)
.then_some(self.page - (idx + 1))
});
jump_to_page(&mut self.page, &mut self.last_render.rect, prev_page)
}
KeyCode::Enter => { KeyCode::Enter => {
let BottomMessage::Input(_) = self.bottom_msg else { let BottomMessage::Input(_) = self.bottom_msg else {
return None; return None;
@@ -522,11 +554,13 @@ pub enum InputAction {
QuitApp QuitApp
} }
#[derive(Copy, Clone)]
enum PageChange { enum PageChange {
Prev, Prev,
Next Next
} }
#[derive(Copy, Clone)]
enum ChangeAmount { enum ChangeAmount {
WholeScreen, WholeScreen,
Single Single