- Remove unused oxipng dep

- throw converter onto its own task
- switch to using multi-thread runtime
- use unbounded channels in a few more places to prevent deadlocks
This commit is contained in:
itsjunetime
2024-05-27 00:30:56 -06:00
parent 492fa10e11
commit 9e4ee1ca97
6 changed files with 173 additions and 398 deletions
+105 -120
View File
@@ -1,138 +1,123 @@
use std::{
pin::Pin,
task::{Context, Poll}
};
use futures_util::Stream;
use image::ImageFormat;
use itertools::Itertools;
use ratatui_image::{picker::Picker, protocol::Protocol, Resize};
use tokio::sync::mpsc::{error::{SendError, TryRecvError}, UnboundedReceiver, UnboundedSender};
use crate::renderer::{PageInfo, RenderError};
use crate::renderer::{fill_default, PageInfo, RenderError};
const MAX_ITER: usize = 20;
pub struct Converter {
images: Vec<Option<PageInfo>>,
picker: Picker,
page: usize,
// once it reaches 20, we're done rendering images
iteration: usize
}
impl Converter {
pub fn new(picker: Picker) -> Self {
Self {
images: vec![],
picker,
page: 0,
iteration: 0
}
}
pub fn add_img(&mut self, page: PageInfo) {
let page_num = page.page;
self.images[page_num] = Some(page);
// just reset it to 0 so we grab this image again next time we try to get an image (if this
// image is in the current list of iterations, so to speak)
self.iteration = 0;
}
pub fn set_n_pages(&mut self, pages: usize) {
self.images = Vec::with_capacity(pages);
for _ in 0..pages {
self.images.push(None);
}
self.page = self.page.min(pages - 1);
}
pub fn go_to_page(&mut self, page: usize) {
self.page = page;
self.iteration = 0;
}
pub fn change_page_by(&mut self, change: isize) {
self.page = (self.page as isize + change) as usize;
// We just reset iteration here. I think there's some heuristic we could do to place
// iteration exactly where it needs to be to render the next page, but trying to determine
// that caused me a lot of bugs, and only causes the slightest inefficiency (down below,
// when we skip `self.iteration` elements in an iterator), so it's like whatever
self.iteration = 0;
}
pub fn get_next_img(&mut self) -> Option<ConversionResult> {
// In this fn, we return Poll::Pending and don't store a Waker 'cause this will be called
// in a loop with tokio::select, and in no other context. The pending that we return on one
// iteration will just be dropped/cancelled as soon as some other action happens, and then
// next time select is called, this'll be checked again, and then we might be in the right
// circumstance to return a Ready
if self.iteration >= MAX_ITER || self.images.is_empty() {
return None;
}
// This kinda mimics the way the renderer alternates between going above and below the
// current page (within the bounds of how many pages there are) until we've done 20
let idx_start = self.page.saturating_sub(MAX_ITER / 2);
let idx_end = idx_start.saturating_add(MAX_ITER).min(self.images.len());
// then we go through all the indices available to us and find the first one that has an
// image available to steal
let (page_info, iteration) = (idx_start..self.page)
.interleave(self.page..idx_end)
.enumerate()
.skip(self.iteration)
.find_map(|(i_idx, p_idx)| self.images[p_idx].take().map(|p| (p, i_idx)))?;
let img_area = page_info.img_data.area;
let dyn_img =
match image::load_from_memory_with_format(&page_info.img_data.data, ImageFormat::Png) {
Ok(dt) => dt,
Err(e) =>
return Some(Err(RenderError::Render(format!(
"Couldn't convert Vec<u8> to DynamicImage: {e}"
)))),
};
// We don't actually want to Crop this image, but we've already
// verified (with the ImageSurface stuff) that the image is the correct
// size for the area given, so to save ratatui the work of having to
// resize it, we tell them to crop it to fit.
let txt_img = match self.picker.new_protocol(dyn_img, img_area, Resize::Crop) {
Ok(img) => img,
Err(e) =>
return Some(Err(RenderError::Render(format!(
"Couldn't convert DynamicImage to ratatui image: {e}"
)))),
};
// update the iteration to the iteration that we stole this image from
self.iteration = iteration;
Some(Ok(ConvertedPage {
page: txt_img,
num: page_info.page,
num_results: page_info.search_results
}))
}
}
pub struct ConvertedPage {
pub page: Box<dyn Protocol>,
pub num: usize,
pub num_results: usize
}
type ConversionResult = Result<ConvertedPage, RenderError>;
pub enum ConverterMsg {
NumPages(usize),
GoToPage(usize),
AddImg(PageInfo)
}
impl Stream for Converter {
type Item = ConversionResult;
pub async fn run_conversion_loop(
sender: UnboundedSender<Result<ConvertedPage, RenderError>>,
mut receiver: UnboundedReceiver<ConverterMsg>,
mut picker: Picker
) -> Result<(), SendError<Result<ConvertedPage, RenderError>>> {
let mut images = vec![];
let mut page: usize = 0;
fn poll_next(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
match self.get_next_img() {
Some(res) => Poll::Ready(Some(res)),
None => Poll::Pending
fn next_page(
images: &mut [Option<PageInfo>],
picker: &mut Picker,
page: usize,
iteration: &mut usize
) -> Result<Option<ConvertedPage>, RenderError> {
if images.is_empty() || *iteration >= MAX_ITER {
return Ok(None)
}
// This kinda mimics the way the renderer alternates between going above and below the
// current page (within the bounds of how many pages there are) until we've done 20
let idx_start = page.saturating_sub(MAX_ITER / 2);
let idx_end = idx_start.saturating_add(MAX_ITER).min(images.len());
// then we go through all the indices available to us and find the first one that has an
// image available to steal
let Some((page_info, new_iter)) = (idx_start..page)
.interleave(page..idx_end)
.enumerate()
.skip(*iteration)
.find_map(|(i_idx, p_idx)| images[p_idx].take().map(|p| (p, i_idx)))
else {
return Ok(None)
};
let img_area = page_info.img_data.area;
let dyn_img = image::load_from_memory_with_format(&page_info.img_data.data, ImageFormat::Png)
.map_err(|e| RenderError::Render(format!("Couldn't convert Vec<u8> to DynamicImage: {e}")))?;
// We don't actually want to Crop this image, but we've already
// verified (with the ImageSurface stuff) that the image is the correct
// size for the area given, so to save ratatui the work of having to
// resize it, we tell them to crop it to fit.
let txt_img = picker.new_protocol(dyn_img, img_area, Resize::Crop)
.map_err(|e| RenderError::Render(format!("Couldn't convert DynamicImage to ratatui image: {e}")))?;
// update the iteration to the iteration that we stole this image from
*iteration = new_iter;
Ok(Some(ConvertedPage {
page: txt_img,
num: page_info.page,
num_results: page_info.search_results
}))
}
fn handle_notif(
msg: ConverterMsg,
images: &mut Vec<Option<PageInfo>>,
page: &mut usize
) {
match msg {
ConverterMsg::AddImg(img) => {
let page_num = img.page;
images[page_num] = Some(img);
},
ConverterMsg::NumPages(n_pages) => {
fill_default(images, n_pages);
*page = (*page).min(n_pages - 1);
},
ConverterMsg::GoToPage(new_page) => *page = new_page,
}
}
'outer: loop {
let mut iteration = 0;
loop {
match receiver.try_recv() {
Ok(msg) => {
handle_notif(msg, &mut images, &mut page);
continue 'outer;
},
Err(TryRecvError::Empty) => (),
Err(TryRecvError::Disconnected) => panic!("Disconnected :(")
}
match next_page(&mut images, &mut picker, page, &mut iteration) {
Ok(None) => break,
Ok(Some(img)) => sender.send(Ok(img))?,
Err(e) => sender.send(Err(e))?
}
}
let Some(msg) = receiver.recv().await else {
break;
};
handle_notif(msg, &mut images, &mut page);
}
Ok(())
}
+33 -35
View File
@@ -2,12 +2,11 @@
use std::{io::stdout, path::PathBuf, str::FromStr};
use converter::{ConvertedPage, Converter};
use converter::{run_conversion_loop, ConvertedPage, ConverterMsg};
use crossterm::{
execute,
terminal::{
disable_raw_mode, enable_raw_mode, EndSynchronizedUpdate, EnterAlternateScreen,
LeaveAlternateScreen
disable_raw_mode, enable_raw_mode, window_size, EndSynchronizedUpdate, EnterAlternateScreen, LeaveAlternateScreen
}
};
use futures_util::stream::StreamExt;
@@ -17,19 +16,20 @@ use ratatui::{backend::CrosstermBackend, Terminal};
use ratatui_image::picker::Picker;
use renderer::{RenderInfo, RenderNotif};
use tui::{InputAction, Tui};
use futures_util::FutureExt;
mod converter;
mod renderer;
mod skip;
mod tui;
#[tokio::main(flavor = "current_thread")]
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut args = std::env::args().skip(1);
let file = args.next().ok_or("Program requires a file to process")?;
let path = PathBuf::from_str(&file)?.canonicalize()?;
let (watch_tx, render_rx) = tokio::sync::mpsc::channel(1);
let (watch_tx, render_rx) = tokio::sync::mpsc::unbounded_channel();
let tui_tx = watch_tx.clone();
// we need to call this outside the recommended_watcher call because if we call it inside, that
@@ -40,7 +40,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// then like the main thread has panicked or something, so it doesn't matter if this panics
// as well
watch_tx
.blocking_send(renderer::RenderNotif::Reload)
.send(renderer::RenderNotif::Reload)
.unwrap();
})?;
@@ -49,20 +49,27 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
watcher.watch(&path, RecursiveMode::NonRecursive)?;
let file_path = format!("file://{}", path.clone().into_os_string().to_string_lossy());
let (render_tx, mut tui_rx) = tokio::sync::mpsc::channel(1);
let (render_tx, mut tui_rx) = tokio::sync::mpsc::unbounded_channel();
let window_size = window_size()?;
// 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
let mut picker = Picker::from_termios()?;
let mut picker = Picker::new((window_size.width / window_size.columns, window_size.height / window_size.rows));
picker.guess_protocol();
// 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,
// since the methods we call in `start_rendering` will panic if called in an async context
std::thread::spawn(move || renderer::start_rendering(file_path, render_tx, render_rx));
std::thread::spawn(move || renderer::start_rendering(file_path, render_tx, render_rx, window_size));
let mut ev_stream = crossterm::event::EventStream::new();
let (to_converter, from_main) = tokio::sync::mpsc::unbounded_channel();
let (to_main, mut from_converter) = tokio::sync::mpsc::unbounded_channel();
tokio::spawn(run_conversion_loop(to_main, from_main, picker));
let file_name = path
.file_name()
.map(|n| n.to_string_lossy())
@@ -78,9 +85,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// document's pages, then it will return `None`, but still log to stderr with CRITICAL level),
// so we want to just ignore all logging since this is a tui app.
glib::log_set_writer_func(noop);
let mut converter = Converter::new(picker);
execute!(
term.backend_mut(),
EnterAlternateScreen,
@@ -89,19 +93,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
enable_raw_mode()?;
let mut main_area = tui::Tui::main_layout(&term.get_frame());
tui_tx.send(RenderNotif::Area(main_area[1])).await?;
tui_tx.send(RenderNotif::Area(main_area[1]))?;
loop {
let mut needs_redraw = tokio::select! {
Some(img_res) = converter.next() => {
match img_res {
Ok(ConvertedPage { page, num, num_results }) => tui.page_ready(page, num, num_results),
Err(e) => tui.show_error(e),
}
true
},
// First we check if we have any keystrokes
Some(ev) = ev_stream.next() => {
Some(ev) = ev_stream.next().fuse() => {
// If we can't get user input, just crash.
let ev = ev.expect("Couldn't get any user input");
@@ -111,12 +108,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
match action {
InputAction::Redraw => (),
InputAction::QuitApp => break,
InputAction::ChangePageBy(change) => converter.change_page_by(change),
InputAction::JumpingToPage(page) => {
tui_tx.send(RenderNotif::JumpToPage(page)).await?;
converter.go_to_page(page);
tui_tx.send(RenderNotif::JumpToPage(page))?;
to_converter.send(ConverterMsg::GoToPage(page))?;
},
InputAction::Search(term) => tui_tx.send(RenderNotif::Search(term)).await?,
InputAction::Search(term) => tui_tx.send(RenderNotif::Search(term))?,
};
true
}
@@ -126,35 +122,37 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
match renderer_msg {
Ok(RenderInfo::NumPages(num)) => {
tui.set_n_pages(num);
converter.set_n_pages(num);
to_converter.send(ConverterMsg::NumPages(num))?;
},
Ok(RenderInfo::Page(info)) => {
tui.got_num_results_on_page(info.page, info.search_results);
converter.add_img(info);
to_converter.send(ConverterMsg::AddImg(info))?;
},
Err(e) => tui.show_error(e),
}
true
}
Some(img_res) = from_converter.recv() => {
match img_res {
Ok(ConvertedPage { page, num, num_results }) => tui.page_ready(page, num, num_results),
Err(e) => tui.show_error(e),
}
true
},
};
let new_area = Tui::main_layout(&term.get_frame());
if new_area != main_area {
main_area = new_area;
tui_tx.send(RenderNotif::Area(main_area[1])).await?;
tui_tx.send(RenderNotif::Area(main_area[1]))?;
needs_redraw = true;
}
if needs_redraw {
let mut end_update = false;
term.draw(|f| {
tui.render(f, &main_area, &mut end_update);
// To be enabled when https://github.com/ratatui-org/ratatui/issues/1116 gets fixed
// f.bypass_diff = true;
tui.render(f, &main_area);
})?;
if end_update {
execute!(stdout(), EndSynchronizedUpdate)?;
}
execute!(stdout(), EndSynchronizedUpdate)?;
}
}
+15 -13
View File
@@ -1,8 +1,9 @@
use cairo::{Antialias, Format};
use crossterm::terminal::WindowSize;
use itertools::Itertools;
use poppler::{Color, Document, FindFlags, Page, Rectangle, SelectionStyle};
use ratatui::layout::Rect;
use tokio::sync::mpsc::{error::TryRecvError, Receiver, Sender};
use tokio::sync::mpsc::{error::TryRecvError, UnboundedReceiver, UnboundedSender};
pub enum RenderNotif {
Area(Rect),
@@ -41,7 +42,7 @@ struct PrevRender {
contained_term: Option<bool>
}
fn fill_default<T: Default>(vec: &mut Vec<T>, size: usize) {
pub fn fill_default<T: Default>(vec: &mut Vec<T>, size: usize) {
vec.clear();
vec.reserve(size.saturating_sub(vec.len()));
for _ in 0..size {
@@ -60,8 +61,9 @@ fn fill_default<T: Default>(vec: &mut Vec<T>, size: usize) {
// we're done.
pub fn start_rendering(
path: String,
sender: Sender<Result<RenderInfo, RenderError>>,
mut receiver: Receiver<RenderNotif>
sender: UnboundedSender<Result<RenderInfo, RenderError>>,
mut receiver: UnboundedReceiver<RenderNotif>,
size: WindowSize
) {
// first, wait 'til we get told what the current starting area is so that we can set it to
// know what to render to
@@ -80,7 +82,7 @@ pub fn start_rendering(
'reload: loop {
let doc = match Document::from_file(&path, None) {
Err(e) => {
sender.blocking_send(Err(RenderError::Doc(e))).unwrap();
sender.send(Err(RenderError::Doc(e))).unwrap();
return;
}
Ok(d) => d
@@ -88,7 +90,7 @@ pub fn start_rendering(
let n_pages = doc.n_pages() as usize;
sender
.blocking_send(Ok(RenderInfo::NumPages(n_pages)))
.send(Ok(RenderInfo::NumPages(n_pages)))
.unwrap();
// We're using this vec of bools to indicate which page numbers have already been rendered,
@@ -190,7 +192,7 @@ pub fn start_rendering(
// We know this is in range 'cause we're iterating over it
let Some(page) = doc.page(num as i32) else {
sender
.blocking_send(Err(RenderError::Render(format!(
.send(Err(RenderError::Render(format!(
"Couldn't get page {num} ({}) of doc?",
num as i32
))))
@@ -202,7 +204,7 @@ pub fn start_rendering(
rendered.successful && rendered.contained_term == Some(false);
// render the page
match render_single_page(page, area, num, &search_term, rendered_with_no_results) {
match render_single_page(page, area, num, &search_term, rendered_with_no_results, &size) {
// If we've already rendered it just fine and we don't need to render it again,
// just continue. We're all good
Ok(None) => (),
@@ -212,10 +214,11 @@ pub fn start_rendering(
// But we first need to store if we already rendered it correctly so that
// the next time we iterate through, it might see that we're already good
rendered.contained_term = Some(img.search_results > 0);
sender.blocking_send(Ok(RenderInfo::Page(img))).unwrap()
rendered.successful = true;
sender.send(Ok(RenderInfo::Page(img))).unwrap()
},
// And if we got an error, then obviously we need to propagate that
Err(e) => sender.blocking_send(Err(RenderError::Render(e))).unwrap()
Err(e) => sender.send(Err(RenderError::Render(e))).unwrap()
}
}
// Then once we've rendered all these pages, wait until we get another notification
@@ -237,7 +240,8 @@ fn render_single_page(
area: Rect,
page_num: usize,
search_term: &Option<String>,
already_rendered_no_results: bool
already_rendered_no_results: bool,
size: &WindowSize
) -> Result<Option<PageInfo>, String> {
let mut result_rects = search_term
.as_ref()
@@ -252,8 +256,6 @@ fn render_single_page(
// First, get the font size; the number of pixels (width x height) per font character (I
// think; it's at least something like that) on this terminal screen.
let size =
crossterm::terminal::window_size().map_err(|e| format!("Couldn't get window size: {e}"))?;
let col_h = size.height / size.rows;
let col_w = size.width / size.columns;
+2 -4
View File
@@ -88,7 +88,7 @@ impl Tui {
}
// TODO: Make a way to fill the width of the screen with one page and scroll down to view it
pub fn render(&mut self, frame: &mut Frame<'_>, main_area: &[Rect], end_update: &mut bool) {
pub fn render(&mut self, frame: &mut Frame<'_>, main_area: &[Rect]) {
let top_block = Block::new()
.padding(Padding {
right: 2,
@@ -220,7 +220,6 @@ impl Tui {
Self::render_loading_in(frame, img_area);
} else {
execute!(stdout(), BeginSynchronizedUpdate).unwrap();
*end_update = true;
let total_width = page_widths.iter().map(|(_, w)| w).sum::<u16>();
@@ -283,7 +282,7 @@ impl Tui {
match self.page as isize - old as isize {
0 => None,
change => Some(InputAction::ChangePageBy(change))
_ => Some(InputAction::JumpingToPage(self.page))
}
}
@@ -517,7 +516,6 @@ impl Tui {
pub enum InputAction {
Redraw,
ChangePageBy(isize),
JumpingToPage(usize),
Search(String),
QuitApp