diff --git a/Cargo.lock b/Cargo.lock index 0dcac79..524b232 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -634,6 +634,12 @@ dependencies = [ "rayon", ] +[[package]] +name = "indoc" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" + [[package]] name = "inotify" version = "0.9.6" @@ -1005,24 +1011,28 @@ dependencies = [ [[package]] name = "ratatui" version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a564a852040e82671dc50a37d88f3aa83bbc690dfc6844cfe7a2591620206a80" dependencies = [ "bitflags 2.5.0", "cassowary", "compact_str", "crossterm", + "indoc", "itertools", "lru", "paste", "stability", "strum", "unicode-segmentation", - "unicode-truncate", "unicode-width", ] [[package]] name = "ratatui-image" version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2264bdb808c89e8395480cfce32c197e75a3d6171063e913bca12e7919a333da" dependencies = [ "base64", "dyn-clone", @@ -1430,16 +1440,6 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" -[[package]] -name = "unicode-truncate" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5fbabedabe362c618c714dbefda9927b5afc8e2a8102f47f081089a9019226" -dependencies = [ - "itertools", - "unicode-width", -] - [[package]] name = "unicode-width" version = "0.1.12" diff --git a/Cargo.toml b/Cargo.toml index 1dd5044..c26c399 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,12 +4,12 @@ version = "0.1.0" edition = "2021" [dependencies] -poppler-rs = { version = "0.23.0" } +poppler-rs = { version = "0.23.0", features = ["v21_5"] } cairo-rs = { version = "0.19.4", features = ["png"] } -# ratatui = "0.26.2" -ratatui = { path = "./ratatui" } -# ratatui-image = { version = "1.0.0", features = ["rustix"], default-features = false } -ratatui-image = { path = "./ratatui-image", features = ["rustix"], default-features = false } +ratatui = "0.26.2" +# ratatui = { path = "./ratatui" } +ratatui-image = { version = "1.0.0", features = ["rustix"], default-features = false } +# ratatui-image = { path = "./ratatui-image", features = ["rustix"], default-features = false } crossterm = { version = "0.27.0", features = ["event-stream"] } image = { version = "0.24.9", features = ["png", "rayon"], default-features = false } notify = "6.1.1" diff --git a/ratatui-image b/ratatui-image index e7be613..b779d29 160000 --- a/ratatui-image +++ b/ratatui-image @@ -1 +1 @@ -Subproject commit e7be6130b498d8dc408da7cff30ca37acb6ee262 +Subproject commit b779d29d6b40cf760ca4706ceb2bb6d75784dfa5 diff --git a/src/converter.rs b/src/converter.rs index 3ce2625..0915994 100644 --- a/src/converter.rs +++ b/src/converter.rs @@ -5,12 +5,12 @@ use image::ImageFormat; use itertools::Itertools; use ratatui_image::{picker::Picker, protocol::Protocol, Resize}; -use crate::renderer::{ImageData, RenderError}; +use crate::renderer::{PageInfo, RenderError}; const MAX_ITER: usize = 20; pub struct Converter { - images: Vec>, + images: Vec>, picker: Picker, page: usize, // once it reaches 20, we're done rendering images @@ -27,8 +27,12 @@ impl Converter { } } - pub fn add_img(&mut self, image: ImageData, idx: usize) { - self.images[idx] = Some(image); + 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) { @@ -71,17 +75,17 @@ impl Converter { // then we go through all the indices available to us and find the first one that has an // image available to steal - let (img_data, iteration, new_idx) = (idx_start..self.page) + 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, p_idx)) + self.images[p_idx].take().map(|p| (p, i_idx)) )?; - let img_area = img_data.area; + let img_area = page_info.img_data.area; - let dyn_img = match image::load_from_memory_with_format(&img_data.data, ImageFormat::Png) { + 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 to DynamicImage: {e}")))) }; @@ -98,11 +102,21 @@ impl Converter { // update the iteration to the iteration that we stole this image from self.iteration = iteration; - Some(Ok((txt_img, new_idx))) + Some(Ok(ConvertedPage { + page: txt_img, + num: page_info.page, + num_results: page_info.search_results + })) } } -type ConversionResult = Result<(Box, usize), RenderError>; +pub struct ConvertedPage { + pub page: Box, + pub num: usize, + pub num_results: usize +} + +type ConversionResult = Result; impl Stream for Converter { type Item = ConversionResult; diff --git a/src/main.rs b/src/main.rs index 4f2537b..ae73321 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,8 @@ +#![feature(if_let_guard)] + use std::{io::stdout, path::PathBuf, str::FromStr}; -use converter::Converter; +use converter::{Converter, ConvertedPage}; use crossterm::{execute, terminal::{disable_raw_mode, enable_raw_mode, EndSynchronizedUpdate, EnterAlternateScreen, LeaveAlternateScreen}}; use glib::{LogField, LogLevel, LogWriterOutput}; use notify::{RecursiveMode, Watcher}; @@ -18,7 +20,7 @@ mod skip; #[tokio::main(flavor = "current_thread")] async fn main() -> Result<(), Box> { let mut args = std::env::args().skip(1); - let file = args.next().expect("Program requires a file to process"); + 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); @@ -80,54 +82,54 @@ async fn main() -> Result<(), Box> { tui_tx.send(RenderNotif::Area(main_area[1])).await?; loop { - let mut needs_redraw; - - tokio::select! { + let mut needs_redraw = tokio::select! { Some(img_res) = converter.next() => { match img_res { - Ok((img, page)) => tui.page_ready(img, page), + Ok(ConvertedPage { page, num, num_results }) => tui.page_ready(page, num, num_results), Err(e) => tui.show_error(e), } - needs_redraw = true; + true }, // First we check if we have any keystrokes Some(ev) = ev_stream.next() => { // If we can't get user input, just crash. let ev = ev.expect("Couldn't get any user input"); - needs_redraw = match tui.handle_event(ev) { + match tui.handle_event(ev) { None => false, - Some(InputAction::Redraw) => true, - Some(InputAction::QuitApp) => break, - Some(InputAction::ChangePageBy(change)) => { - converter.change_page_by(change); - true - }, - Some(InputAction::JumpingToPage(page)) => { - tui_tx.send(RenderNotif::JumpToPage(page)).await?; - converter.go_to_page(page); + Some(action) => { + 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); + }, + InputAction::Search(term) => tui_tx.send(RenderNotif::Search(term)).await?, + }; true } - }; + } }, Some(renderer_msg) = tui_rx.recv() => { - needs_redraw = match renderer_msg { + match renderer_msg { Ok(RenderInfo::NumPages(num)) => { tui.set_n_pages(num); converter.set_n_pages(num); true }, - Ok(RenderInfo::Page(img, page_num)) => { - converter.add_img(img, page_num); + Ok(RenderInfo::Page(info)) => { + converter.add_img(info); false }, Err(e) => { tui.show_error(e); true } - }; + } } - } + }; let new_area = Tui::main_layout(&term.get_frame()); if new_area != main_area { @@ -140,6 +142,7 @@ async fn main() -> Result<(), Box> { 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; })?; if end_update { diff --git a/src/renderer.rs b/src/renderer.rs index e177392..1cacdd6 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -1,12 +1,13 @@ use cairo::{Antialias, Format}; use itertools::Itertools; -use poppler::{Document, Page}; +use poppler::{Color, Document, FindFlags, Page, SelectionStyle, Rectangle}; use ratatui::layout::Rect; use tokio::sync::mpsc::{error::TryRecvError, Receiver, Sender}; pub enum RenderNotif { Area(Rect), JumpToPage(usize), + Search(String), Reload } @@ -20,7 +21,13 @@ pub enum RenderError { pub enum RenderInfo { NumPages(usize), - Page(ImageData, usize) + Page(PageInfo), +} + +pub struct PageInfo { + pub img_data: ImageData, + pub page: usize, + pub search_results: usize } pub struct ImageData { @@ -71,6 +78,7 @@ pub fn start_rendering( // then we can split at that page and render at both sides of it let mut rendered = vec![false; n_pages]; let mut start_point = 0; + let mut search_term = None; // This is kinda a weird way of doing this, but if we get a notification that the area // changed, we want to start re-rending all of the pages, but we don't want to reload the @@ -98,6 +106,11 @@ pub fn start_rendering( RenderNotif::JumpToPage(page) => { start_point = page; continue 'render_pages; + }, + RenderNotif::Search(term) => { + rendered = vec![false; n_pages]; + search_term = Some(term); + continue 'render_pages; } } } @@ -131,8 +144,8 @@ pub fn start_rendering( let page = doc.page(num as i32).unwrap(); // render the page - let to_send = render_single_page(page, area) - .map(|data| RenderInfo::Page(data, num)) + let to_send = render_single_page(page, area, num, &search_term) + .map(RenderInfo::Page) .map_err(RenderError::Render); // then send it over @@ -157,7 +170,9 @@ pub fn start_rendering( fn render_single_page( page: Page, area: Rect, -) -> Result { + page_num: usize, + search_term: &Option +) -> Result { // 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() @@ -214,21 +229,57 @@ fn render_single_page( // The default background color of PDFs (at least, I think) is white, so we need to set // that as the background color, then paint, then render. ctx.set_source_rgba(1.0, 1.0, 1.0, 1.0); + ctx.set_antialias(Antialias::Best); ctx.paint().map_err(|e| format!("Couldn't paint Context: {e}"))?; - page.render_for_printing(&ctx); + page.render(&ctx); + + let mut result_rects = search_term + .as_ref() + .map(|term| page.find_text_with_options(term, FindFlags::DEFAULT | FindFlags::MULTILINE)) + .unwrap_or_default(); + + let num_results = result_rects.iter() + .filter(|rect| !rect.find_get_match_continued()) + .count(); + + let mut highlight_color = Color::new(); + highlight_color.set_red((u16::MAX / 5) * 4); + highlight_color.set_green((u16::MAX / 5) * 4); + + let mut old_rect = Rectangle::new(); + for rect in result_rects.iter_mut() { + // 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 + rect.set_y1(p_height - rect.y1()); + rect.set_y2(p_height - rect.y2()); + + page.render_selection( + &ctx, + rect, + &mut old_rect, + SelectionStyle::Glyph, + &mut Color::new(), + &mut highlight_color + ); + } + ctx.scale(1. / scale_factor, 1. / scale_factor); let mut img_data = Vec::new(); ctx.target().write_to_png(&mut img_data) .map_err(|e| format!("Couldn't write surface to png: {e}"))?; - Ok(ImageData { - data: img_data, - area: Rect { - width: surface_width as u16 / col_w, - height: surface_height as u16 / col_h, - ..Rect::default() - } + Ok(PageInfo { + img_data: ImageData { + data: img_data, + area: Rect { + width: surface_width as u16 / col_w, + height: surface_height as u16 / col_h, + ..Rect::default() + } + }, + page: page_num, + search_results: num_results }) } diff --git a/src/tui.rs b/src/tui.rs index 4577798..7918b68 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -12,7 +12,7 @@ pub struct Tui { error: Option, input_state: Option, last_render: LastRender, - rendered: Vec>>, + rendered: Vec>, } #[derive(Default, Debug)] @@ -25,7 +25,13 @@ struct LastRender { } enum InputCommand { - GoToPage(usize) + GoToPage(usize), + Search(String) +} + +struct RenderedInfo { + img: Box, + num_results: usize } impl Tui { @@ -98,16 +104,20 @@ impl Tui { frame.render_widget(bottom_block, main_area[2]); - let rendered_str = format!( - "Rendered: {}%", - (self.rendered.iter().filter(|i| i.is_some()).count() * 100) / self.rendered.len() - ); - + let rendered_str = if !self.rendered.is_empty() { + format!( + "Rendered: {}%", + (self.rendered.iter().filter(|i| i.is_some()).count() * 100) / self.rendered.len() + ) + } else { + String::new() + }; let bottom_layout = Layout::horizontal([ Constraint::Fill(1), Constraint::Length(rendered_str.len() as u16) ]).split(bottom_area); + let rendered_span = Span::styled( &rendered_str, Style::new() @@ -123,16 +133,13 @@ impl Tui { ); frame.render_widget(span, bottom_layout[0]); } else if let Some(ref cmd) = self.input_state { - match cmd { - InputCommand::GoToPage(page) => { - let span = Span::styled( - format!("Go to: {page}"), - Style::new() - .fg(Color::Blue) - ); - frame.render_widget(span, bottom_layout[0]); - } - } + let cmd_str = match cmd { + InputCommand::GoToPage(page) => format!("Go to: {page}"), + InputCommand::Search(s) => format!("Search: {s}"), + }; + + let span = Span::styled(cmd_str, Style::new().fg(Color::Blue)); + frame.render_widget(span, bottom_layout[0]); } let mut img_area = main_area[1]; @@ -158,7 +165,7 @@ impl Tui { .flat_map(|(idx, page)| page.as_ref().map(|img| ( idx, - img.rect().width, + img.img.rect().width, )) ) // and then take them as long as they won't overflow the available area. @@ -211,7 +218,7 @@ impl Tui { fn render_single_page(&mut self, frame: &mut Frame<'_>, page_idx: usize, img_area: Rect) { match self.rendered[page_idx] { - 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), img_area), None => Self::render_loading_in(frame, img_area) }; } @@ -254,12 +261,12 @@ impl Tui { self.page = self.page.min(n_pages - 1); } - pub fn page_ready(&mut self, img: Box, page_num: usize) { + pub fn page_ready(&mut self, img: Box, page_num: usize, num_results: usize) { // 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 // the last rect marker so that all images are forced to redraw on next render and this one // is drawn with them - if page_num == self.page { + if page_num >= self.page && page_num <= self.page + self.last_render.pages_shown { self.last_render.rect = Rect::default(); } else { let img_w = img.rect().width; @@ -274,40 +281,63 @@ impl Tui { // We always just set this here because we handle reloading in the `set_n_pages` function. // If the document was reloaded, then It'll have the `set_n_pages` called to set the new // number of pages, so the vec will already be cleared - self.rendered[page_num] = Some(img); + self.rendered[page_num] = Some(RenderedInfo { img, num_results }) } pub fn handle_event(&mut self, ev: Event) -> Option { match ev { Event::Key(key) => { match key.code { - KeyCode::Right | KeyCode::Char('l') => self.change_page(PageChange::Next, ChangeAmount::Single), - KeyCode::Down | KeyCode::Char('j') => self.change_page(PageChange::Next, ChangeAmount::WholeScreen), - KeyCode::Left | KeyCode::Char('h') => self.change_page(PageChange::Prev, ChangeAmount::Single), - KeyCode::Up | KeyCode::Char('k') => self.change_page(PageChange::Prev, ChangeAmount::WholeScreen), - KeyCode::Esc | KeyCode::Char('q') => Some(InputAction::QuitApp), - KeyCode::Char('g') => { - self.input_state = Some(InputCommand::GoToPage(0)); + KeyCode::Char(c) if let Some(InputCommand::Search(ref mut term)) = self.input_state => { + term.push(c); Some(InputAction::Redraw) }, - KeyCode::Char(c) => { - let Some(InputCommand::GoToPage(ref mut page)) = self.input_state else { - return None; - }; - + KeyCode::Char(c) if let Some(InputCommand::GoToPage(ref mut page)) = self.input_state => { c.to_digit(10) .map(|input_num| { *page = (*page * 10) + input_num as usize; InputAction::Redraw }) }, + KeyCode::Right | KeyCode::Char('l') => self.change_page(PageChange::Next, ChangeAmount::Single), + KeyCode::Down | KeyCode::Char('j') => self.change_page(PageChange::Next, ChangeAmount::WholeScreen), + KeyCode::Left | KeyCode::Char('h') => self.change_page(PageChange::Prev, ChangeAmount::Single), + KeyCode::Up | KeyCode::Char('k') => self.change_page(PageChange::Prev, ChangeAmount::WholeScreen), + KeyCode::Esc | KeyCode::Char('q') => { + if self.input_state.is_some() { + self.input_state = None; + Some(InputAction::Redraw) + } else { + Some(InputAction::QuitApp) + } + }, + KeyCode::Char('g') => { + self.input_state = Some(InputCommand::GoToPage(0)); + Some(InputAction::Redraw) + }, + KeyCode::Char('/') => { + self.input_state = Some(InputCommand::Search(String::new())); + Some(InputAction::Redraw) + }, + KeyCode::Char('n') => { + let next_page = self.rendered[self.page..] + .iter() + .enumerate() + .filter_map(|(idx, p)| p.as_ref().map(|p| (idx, p))) + .find_map(|(idx, p)| (p.num_results > 0).then_some(idx)); + if let Some(page) = next_page { + self.page = page; + } + next_page.map(|_| InputAction::Redraw) + }, KeyCode::Enter => self.input_state.take() .and_then(|cmd| match cmd { // Only forward the command if it's within range InputCommand::GoToPage(page) => (page < self.rendered.len()).then(|| { self.set_page(page); InputAction::JumpingToPage(page) - }) + }), + InputCommand::Search(term) => Some(InputAction::Search(term)), }), _ => None, } @@ -346,6 +376,7 @@ pub enum InputAction { Redraw, ChangePageBy(isize), JumpingToPage(usize), + Search(String), QuitApp }