use std::{borrow::Cow, io::stdout, num::NonZeroUsize}; use crossterm::{ event::{Event, KeyCode, KeyModifiers, MouseEventKind}, execute, terminal::{ BeginSynchronizedUpdate, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode } }; use kittage::display::DisplayLocation; use nix::{ sys::signal::{Signal::SIGSTOP, kill}, unistd::Pid }; use ratatui::{ Frame, layout::{Constraint, Flex, Layout, Position, Rect}, prelude::{Line, Text}, style::{Color, Style}, symbols::border, text::Span, widgets::{Block, Borders, Clear, Padding, Paragraph, Wrap} }; use ratatui_image::{FontSize, Image}; use crate::{ FitOrFill, converter::{ConvertedImage, MaybeTransferred}, kitty::{KittyDisplay, KittyReadyToDisplay}, renderer::{RenderError, fill_default}, skip::Skip }; pub struct Tui { name: String, pub page: usize, last_render: LastRender, bottom_msg: BottomMessage, // we use `prev_msg` to, for example, restore the 'search results' message on the bottom after // jumping to a specific page prev_msg: Option, rendered: Vec, page_constraints: PageConstraints, showing_help_msg: bool, is_kitty: bool, zoom: Option } #[derive(Default)] struct LastRender { // Used as a way to track if we need to draw the images, to save ratatui from doing a lot of // diffing work rect: Rect, pages_shown: usize, unused_width: u16 } #[derive(Default)] pub enum BottomMessage { #[default] Help, SearchResults(String), Error(String), Input(InputCommand), Reloaded } pub enum InputCommand { GoToPage(usize), Search(String) } struct PageConstraints { max_wide: Option, r_to_l: bool } #[derive(Default, Debug)] struct Zoom { // just how much 'zoom' you have. 0 means it fills the screen (instead of fits), such // that one axis is fully on-screen level: i16, // how many terminal-cells worth of content overflow the left side of the screen (and are thus // not displayed) cell_pan_from_left: u16, // how many terminal-cells worth of content overflow the top side of the screen (and are thus // not displayed) cell_pan_from_top: u16 } impl Zoom { /// Returns the zoom factor, where 1 is the default and means fill-screen fn factor(&self) -> f32 { // TODO: Make these configurable once we have a good way to set options after startup const ZOOM_RATE: f32 = 1.1; const ZOOM_RATE_GRANULAR: f32 = 1.05; if self.level > 0 { ZOOM_RATE.powi(self.level.into()) } else { // use a more granular zoom rate for the steps between fit-screen and fill-screen ZOOM_RATE_GRANULAR.powi(self.level.into()) } } fn step_in(&mut self) { self.level = self.level.saturating_add(1); } fn step_out(&mut self) { self.level = self.level.saturating_sub(1); } // TODO: Make this configurable, maybe allow fractional steps? // With fractional steps, it might also be a good idea to have these // have the same ratio as the font aspect ratio. const PAN_STEP_X: i16 = 2; const PAN_STEP_Y: i16 = 1; fn pan(&mut self, direction: Direction) { let (target, sign) = match direction { Direction::Up => (&mut self.cell_pan_from_top, -1), Direction::Down => (&mut self.cell_pan_from_top, 1), Direction::Left => (&mut self.cell_pan_from_left, -1), Direction::Right => (&mut self.cell_pan_from_left, 1) }; let step = if direction.is_vertical() { Self::PAN_STEP_Y } else { Self::PAN_STEP_X }; *target = target.saturating_add_signed(sign * step); } fn pan_bottom(&mut self) { self.cell_pan_from_top = 0; } fn pan_top(&mut self) { self.cell_pan_from_top = u16::MAX; } } #[derive(Clone, Copy, Debug)] enum Direction { Up, Down, Left, Right } impl Direction { /// Flips the directions for vertical and horizonal panning. fn flip_mouse_xy(self) -> Self { match self { Self::Up => Self::Left, Self::Left => Self::Up, Self::Down => Self::Right, Self::Right => Self::Down } } fn is_vertical(self) -> bool { match self { Self::Up | Self::Down => true, Self::Left | Self::Right => false } } } // 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 #[derive(Default)] pub struct RenderedInfo { // The image, if it has been rendered by `Converter` to that struct img: Option, // 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 // Also this isn't the most efficient representation of this value, but it's accurate, so like // whatever I guess num_results: Option } #[derive(PartialEq)] pub struct RenderLayout { pub page_area: Rect, pub top_and_bottom: Option<(Rect, Rect)> } impl Tui { #[must_use] pub fn new(name: String, max_wide: Option, r_to_l: bool, is_kitty: bool) -> Self { Self { name, page: 0, prev_msg: None, bottom_msg: BottomMessage::Help, last_render: LastRender::default(), rendered: vec![], page_constraints: PageConstraints { max_wide, r_to_l }, showing_help_msg: false, is_kitty, zoom: None } } #[must_use] pub fn main_layout(frame: &Frame<'_>, fullscreened: bool) -> RenderLayout { if fullscreened { RenderLayout { page_area: frame.area(), top_and_bottom: None } } else { let layout = Layout::default() .constraints([ Constraint::Length(3), Constraint::Fill(1), Constraint::Length(3) ]) .horizontal_margin(2) .vertical_margin(1) .split(frame.area()); RenderLayout { page_area: layout[1], top_and_bottom: Some((layout[0], layout[2])) } } } fn render_zoomed<'s>( // area of the 'fit-screen' page mut img_area: Rect, font_size: FontSize, zoom: &mut Zoom, img: &'s mut MaybeTransferred, page_num: usize, img_cell_w: u16, img_cell_h: u16 ) -> KittyDisplay<'s> { log::debug!("zoom is {zoom:#?}"); log::debug!("page area is {img_area:#?}"); log::debug!("img dimensions are {img_cell_w}x{img_cell_h}"); // Dimensions of the section of the image to be displayed. // Kittage calls this the "image area to display". // We need to shrink this or the page area in order to zoom in or out, // respectively. let mut img_section_w = f32::from(img_cell_w); let mut img_section_h = f32::from(img_cell_h); let zoom_factor = zoom.factor(); if zoom_factor >= 1.0 { // Use a smaller section of the image. This efficively zooms into that section. img_section_w /= zoom_factor; img_section_h /= zoom_factor; } else { // Shrink the page area, such that the fill-screen conversion // will zoom out of the image. let initial_page_w = f32::from(img_area.width); let initial_page_h = f32::from(img_area.height); // how many pages the image is wide/high let img_page_w_ratio = img_section_w / initial_page_w; let img_page_h_ratio = img_section_h / initial_page_h; let shrink_move_page = |dim: &mut u16, pos: &mut u16, axis_zoom_factor: f32| { let old_dim = *dim; // The axis zoom factor tells us what portion of the axis // we need to show. *dim = (f32::from(*dim) * axis_zoom_factor) as u16; *pos += old_dim .checked_sub(*dim) .expect("zooming out should shrink the image") / 2; }; // TODO: Detect max zoom-out in zoom levels if img_page_w_ratio < img_page_h_ratio { // vertical scroll / tall image. zooming out means decreasing the width of the page area shrink_move_page( &mut img_area.width, &mut img_area.x, // disallow zooming out past fit-screen zoom_factor.max(1.0 / img_page_h_ratio) ); } else { // horizontal scroll / wide image. zooming out means decreasing the width of the page area shrink_move_page( &mut img_area.height, &mut img_area.y, // disallow zooming out past fit-screen zoom_factor.max(1.0 / img_page_w_ratio) ); } } log::debug!("after adjustment, page area is {img_area:#?}"); // Crop the image such that in the end, the aspect ratio of the section // is the same as that of the page area. This effectively performs the // conversion to fill-screen. // Note that this only works because cell_w, cell_h is in fit-screen // format, i.e. the cell size and the page area already share at // least one dimension. { let page_area_w = f32::from(img_area.width); let page_area_h = f32::from(img_area.height); // how many pages the image is wide/high // Note that this is not the same as during the // zoom-out calculation, since it changed the page // dimensions. let img_page_w_ratio = img_section_w / page_area_w; let img_page_h_ratio = img_section_h / page_area_h; if img_page_w_ratio < img_page_h_ratio { img_section_h = page_area_h * img_page_w_ratio; } else { img_section_w = page_area_w * img_page_h_ratio; } } let width = (img_section_w * f32::from(font_size.0)) as u32; let height = (img_section_h * f32::from(font_size.1)) as u32; zoom.cell_pan_from_left = zoom .cell_pan_from_left .min(img_cell_w.saturating_sub(img_section_w.ceil() as u16)); zoom.cell_pan_from_top = zoom .cell_pan_from_top .min(img_cell_h.saturating_sub(img_section_h.ceil() as u16)); KittyDisplay::DisplayImages(vec![KittyReadyToDisplay { img, page_num, pos: Position { x: img_area.x, y: img_area.y }, display_loc: DisplayLocation { x: u32::from(zoom.cell_pan_from_left) * u32::from(font_size.0), y: u32::from(zoom.cell_pan_from_top) * u32::from(font_size.1), width, height, columns: img_area.width, rows: img_area.height, ..DisplayLocation::default() } }]) } // TODO: Make a way to fill the width of the screen with one page and scroll down to view it #[must_use] pub fn render<'s>( &'s mut self, frame: &mut Frame<'_>, full_layout: &RenderLayout, font_size: FontSize ) -> KittyDisplay<'s> { if self.showing_help_msg { self.render_help_msg(frame); return KittyDisplay::ClearImages; } if let Some(t_and_b) = full_layout.top_and_bottom { Self::render_top_and_bottom( t_and_b, self.page, &self.rendered, &self.name, frame, &self.bottom_msg ); } let mut img_area = full_layout.page_area; let size = frame.area(); if size == self.last_render.rect { // If we haven't resized (and haven't used the Rect as a way to mark that we need to // resize this time), then go through every element in the buffer where any Image would // be written and set to skip it so that ratatui doesn't spend a lot of time diffing it // each re-render frame.render_widget(Skip::new(true), img_area); return KittyDisplay::NoChange; } if let Some(ref mut zoom) = self.zoom { // yes this is ugly and I hate it. it's due to the limitations that currently exist // in the borrow checker. Once `-Zpolonius=next` is stabilized, we can rework this // to look like what we expect. // See https://github.com/rust-lang/rfcs/blob/master/text/2094-nll.md#problem-case-3-conditional-control-flow-across-functions // You can also rewrite this to just if an `if let` and run it under // `RUSTFLAGS="-Zpolonius=next"` and see that it works if self.rendered[self.page] .img .as_ref() .is_some_and(|c| matches!(c, ConvertedImage::Kitty { .. })) { let Some(ConvertedImage::Kitty { ref mut img, cell_w, cell_h }) = self.rendered[self.page].img else { unreachable!() }; self.last_render = LastRender { rect: size, pages_shown: 1, unused_width: 0 }; return Self::render_zoomed( img_area, font_size, zoom, img, self.page, cell_w, cell_h ); } } // here we calculate how many pages can fit in the available area. let mut test_area_w = img_area.width; // go through our pages, starting at the first one we want to view let mut page_sizes = self.rendered[self.page..] .iter_mut() // and get this to represent a count of how many we're looking at so far to render .enumerate() // and only take as many as are ready to be rendered .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) .filter_map(|(_, page)| { page.img.as_mut().map(|img| { let (w, h) = img.w_h(); (w, h, img) }) }) // and then take them as long as they won't overflow the available area. .take_while(|(width, _, _)| match test_area_w.checked_sub(*width) { Some(new_val) => { test_area_w = new_val; true } None => false }) .collect::>(); if self.page_constraints.r_to_l { page_sizes.reverse(); } if page_sizes.is_empty() { // If none are ready to render, just show the loading thing Self::render_loading_in(frame, img_area); KittyDisplay::ClearImages } else { execute!(stdout(), BeginSynchronizedUpdate).unwrap(); let total_width = page_sizes.iter().map(|(w, _, _)| w).sum::(); self.last_render.pages_shown = page_sizes.len(); let unused_width = img_area.width - total_width; self.last_render.unused_width = unused_width; img_area.x += unused_width / 2; if let Some(total_height) = page_sizes.iter().map(|(_, h, _)| h).max() { // This subtraction might sporadicly fail while shrinking the window. if let Some(unused_height) = img_area.height.checked_sub(*total_height) { img_area.y += unused_height / 2; } } let to_display = page_sizes .into_iter() .enumerate() .filter_map(|(idx, (width, _, img))| { let maybe_img = Self::render_single_page(frame, img, Rect { width, ..img_area }); img_area.x += width; maybe_img.map(|(img, pos)| KittyReadyToDisplay { img, page_num: idx + self.page, pos, display_loc: DisplayLocation::default() }) }) .collect::>(); // we want to set this at the very end so it doesn't get set somewhere halfway through and // then the whole diffing thing messes it up self.last_render.rect = size; KittyDisplay::DisplayImages(to_display) } } fn render_single_page<'img>( frame: &mut Frame<'_>, page_img: &'img mut ConvertedImage, img_area: Rect ) -> Option<(&'img mut MaybeTransferred, Position)> { match page_img { ConvertedImage::Generic(page_img) => { frame.render_widget(Image::new(page_img), img_area); None } ConvertedImage::Kitty { img, cell_h: _, cell_w: _ } => Some((img, Position { x: img_area.x, y: img_area.y })) } } fn render_loading_in(frame: &mut Frame<'_>, area: Rect) { const LOADING_STR: &str = "Loading..."; let inner_space = Layout::horizontal([Constraint::Length(const { LOADING_STR.len() as u16 })]) .flex(Flex::Center) .split(area); let loading_span = Span::styled(LOADING_STR, Style::new().fg(Color::Cyan)); frame.render_widget(loading_span, inner_space[0]); } fn change_page(&mut self, mut change: PageChange, amt: ChangeAmount) -> Option { let diff = match amt { ChangeAmount::Single => 1, 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; match change { PageChange::Next => self.set_page((self.page + diff).min(self.rendered.len().saturating_sub(1))), PageChange::Prev => self.set_page(self.page.saturating_sub(diff)) } // Yes these conversions could wrap around if you have > isize::MAX pages, but we already // decided that you deserve to suffer if you have more than u32::MAX pages, so that's fine. match self.page as isize - old as isize { 0 => None, _ => Some(InputAction::JumpingToPage(self.page)) } } pub fn set_n_pages(&mut self, n_pages: usize) { fill_default(&mut self.rendered, n_pages); self.page = self.page.min(n_pages - 1); } pub fn page_ready(&mut self, img: ConvertedImage, 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 && page_num <= self.page + self.last_render.pages_shown { self.last_render.rect = Rect::default(); } else { let img_w = img.w_h().0; if img_w <= self.last_render.unused_width { let num_fit = self.last_render.unused_width / img_w; if page_num >= self.page && (self.page + num_fit as usize) >= page_num { self.last_render.rect = Rect::default(); } } } // 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] = RenderedInfo { img: Some(img), num_results: Some(num_results) }; } pub fn page_failed_display(&mut self, page_num: usize) { self.rendered[page_num].img = None; } pub fn got_num_results_on_page(&mut self, page_num: usize, num_results: usize) { self.rendered[page_num].num_results = Some(num_results); } pub fn render_top_and_bottom( (top_area, bottom_area): (Rect, Rect), page_num: usize, rendered: &[RenderedInfo], doc_name: &str, frame: &mut Frame<'_>, bottom_msg: &BottomMessage ) { // use the extra space here to add some padding to the right side let page_nums_text = format!("{} / {} ", page_num + 1, rendered.len()); let top_block = Block::new() // use this first title to add a bit of padding to the left side .title_top(" ") .title_top(Span::styled(doc_name, Style::new().fg(Color::Cyan))) .title_top( Span::styled(&page_nums_text, Style::new().fg(Color::Cyan)) .into_right_aligned_line() ) .padding(Padding { bottom: 1, ..Padding::default() }) .borders(Borders::BOTTOM); frame.render_widget(top_block, top_area); let bottom_block = Block::new() .padding(Padding { top: 1, right: 2, left: 2, bottom: 0 }) .borders(Borders::TOP); let bottom_inside_block = bottom_block.inner(bottom_area); frame.render_widget(bottom_block, bottom_area); let rendered_str = if !rendered.is_empty() { format!( "Rendered: {}%", (rendered.iter().filter(|i| i.img.is_some()).count() * 100) / rendered.len() ) } else { String::new() }; let bottom_layout = Layout::horizontal([ Constraint::Fill(1), Constraint::Length(rendered_str.len() as u16) ]) .split(bottom_inside_block); let rendered_span = Span::styled(&rendered_str, Style::new().fg(Color::Cyan)); frame.render_widget(rendered_span, bottom_layout[1]); let (msg_str, color): (Cow<'_, str>, _) = match bottom_msg { BottomMessage::Help => ("?: Show help page".into(), Color::Blue), BottomMessage::Error(e) => (e.as_str().into(), Color::Red), BottomMessage::Input(input_state) => ( match input_state { InputCommand::GoToPage(page) => format!("Go to: {page}"), InputCommand::Search(s) => format!("Search: {s}") } .into(), Color::Blue ), BottomMessage::SearchResults(term) => { let num_found = rendered.iter().filter_map(|r| r.num_results).sum::(); let num_searched = rendered.iter().filter(|r| r.num_results.is_some()).count() * 100; ( format!( "Results for '{term}': {num_found} (searched: {}%)", num_searched / rendered.len() ) .into(), Color::Blue ) } BottomMessage::Reloaded => ("Document was reloaded!".into(), Color::Blue) }; let span = Span::styled(msg_str, Style::new().fg(color)); frame.render_widget(span, bottom_layout[0]); } pub fn handle_event(&mut self, ev: &Event) -> Option { fn jump_to_page(page: &mut usize, rect: &mut Rect, new_page: usize) -> InputAction { *page = new_page; // Make sure we re-render *rect = Rect::default(); InputAction::JumpingToPage(new_page) } let can_zoom = self.is_kitty && self.zoom.is_some(); match ev { Event::Key(key) => { match key.code { KeyCode::Char(c) => { // TODO: refactor back to `if let` arm guards when those are stabilized if let BottomMessage::Input(InputCommand::Search(ref mut term)) = self.bottom_msg { term.push(c); return Some(InputAction::Redraw); } if let BottomMessage::Input(InputCommand::GoToPage(ref mut page)) = self.bottom_msg { if c == 'g' && self.is_kitty { self.update_zoom(Zoom::pan_bottom); self.set_msg(MessageSetting::Pop); return Some(InputAction::Redraw); } return c.to_digit(10).map(|input_num| { *page = (*page * 10) + input_num as usize; InputAction::Redraw }); } match c { 'l' => self.change_page(PageChange::Next, ChangeAmount::Single), 'j' => self.change_page(PageChange::Next, ChangeAmount::WholeScreen), 'h' => self.change_page(PageChange::Prev, ChangeAmount::Single), 'k' => self.change_page(PageChange::Prev, ChangeAmount::WholeScreen), 'q' => Some(InputAction::QuitApp), 'g' => { self.set_msg(MessageSetting::Some(BottomMessage::Input( InputCommand::GoToPage(0) ))); Some(InputAction::Redraw) } '/' => { self.set_msg(MessageSetting::Some(BottomMessage::Input( InputCommand::Search(String::new()) ))); Some(InputAction::Redraw) } 'i' => Some(InputAction::Invert), '?' => { self.showing_help_msg = true; Some(InputAction::Redraw) } 'f' => Some(InputAction::Fullscreen), '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? 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) }) .map(|next_page| { jump_to_page( &mut self.page, &mut self.last_render.rect, next_page ) }) } 'N' if self.page > 0 => 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)) }) .map(|prev_page| { jump_to_page( &mut self.page, &mut self.last_render.rect, prev_page ) }), 'z' if key.modifiers.contains(KeyModifiers::CONTROL) => { // [todo] better error handling here? let mut backend = stdout(); execute!( &mut backend, LeaveAlternateScreen, crossterm::cursor::Show, crossterm::event::DisableMouseCapture ) .unwrap(); disable_raw_mode().unwrap(); // This process will hang after the SIGSTOP call until we get // foregrounded again by something else, at which point we need to // re-setup everything so that it all gets drawn again. kill(Pid::this(), SIGSTOP).unwrap(); enable_raw_mode().unwrap(); execute!( &mut backend, EnterAlternateScreen, crossterm::cursor::Hide, crossterm::event::EnableMouseCapture ) .unwrap(); self.last_render.rect = Rect::default(); Some(InputAction::Redraw) } 'z' if self.is_kitty => { let (zoom, f_or_f) = match self.zoom { None => (Some(Zoom::default()), FitOrFill::Fill), Some(_) => (None, FitOrFill::Fit) }; self.zoom = zoom; self.last_render.rect = Rect::default(); Some(InputAction::SwitchRenderZoom(f_or_f)) } 'o' if can_zoom => self.update_zoom(Zoom::step_in), 'O' if can_zoom => self.update_zoom(Zoom::step_out), 'L' if can_zoom => self.update_zoom(|z| z.pan(Direction::Right)), 'H' if can_zoom => self.update_zoom(|z| z.pan(Direction::Left)), 'J' if can_zoom => self.update_zoom(|z| z.pan(Direction::Down)), 'K' if can_zoom => self.update_zoom(|z| z.pan(Direction::Up)), 'G' if can_zoom => self.update_zoom(Zoom::pan_top), _ => 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 | KeyCode::PageDown => self.change_page(PageChange::Next, ChangeAmount::WholeScreen), KeyCode::Left => self.change_page(PageChange::Prev, ChangeAmount::Single), KeyCode::Up | KeyCode::PageUp => self.change_page(PageChange::Prev, ChangeAmount::WholeScreen), KeyCode::Esc => match (self.showing_help_msg, &self.bottom_msg) { (false, BottomMessage::Help) => Some(InputAction::QuitApp), _ => { // When we hit escape, we just want to pop off the current message and // show the underlying one. self.set_msg(MessageSetting::Pop); Some(InputAction::Redraw) } }, KeyCode::Enter => { let mut default = BottomMessage::default(); std::mem::swap(&mut self.bottom_msg, &mut default); let BottomMessage::Input(ref cmd) = default else { std::mem::swap(&mut self.bottom_msg, &mut default); return None; }; match cmd { // Only forward the command if it's within range InputCommand::GoToPage(page) => { // We need to subtract 1 b/c they're tracked internally as // 0-indexed but input and displayed as 1-indexed let zero_page = page.saturating_sub(1); let rendered_len = self.rendered.len(); if zero_page < rendered_len { self.set_page(zero_page); Some(InputAction::JumpingToPage(zero_page)) } else { self.set_msg(MessageSetting::Some(BottomMessage::Error( format!( "Cannot jump to page {page}; there are only {rendered_len} pages in the document" ) ))); Some(InputAction::Redraw) } } InputCommand::Search(term) => { let term = term.clone(); // We only want to show search results if there would actually be // data to show if !term.is_empty() { self.set_msg(MessageSetting::Some( BottomMessage::SearchResults(term.clone()) )); } else { // else, if it's not empty, we just want to reset the bottom // area to show the default data; we don't want it to like show // the data from a previous search self.set_msg(MessageSetting::Reset); } // Reset all the search results for img in &mut self.rendered { img.num_results = None; } // but we still want to tell the rest of the system that we set the // search term to '' so that they can re-render the pages wthout // the highlighting Some(InputAction::Search(term)) } } } _ => None } } Event::Mouse(mouse) => { let mut handle_scroll = |mut direction: Direction| { if can_zoom { if mouse.modifiers.contains(KeyModifiers::CONTROL) { match direction { Direction::Up => self.update_zoom(Zoom::step_in), Direction::Down => self.update_zoom(Zoom::step_out), _ => None } } else { if mouse.modifiers.contains(KeyModifiers::SHIFT) { direction = direction.flip_mouse_xy(); } self.update_zoom(|z| z.pan(direction)) } } else { let (change, amount) = match direction { Direction::Right => (PageChange::Next, ChangeAmount::Single), Direction::Down => (PageChange::Next, ChangeAmount::WholeScreen), Direction::Left => (PageChange::Prev, ChangeAmount::Single), Direction::Up => (PageChange::Prev, ChangeAmount::WholeScreen) }; self.change_page(change, amount) } }; match mouse.kind { MouseEventKind::ScrollRight => handle_scroll(Direction::Right), MouseEventKind::ScrollDown => handle_scroll(Direction::Down), MouseEventKind::ScrollLeft => handle_scroll(Direction::Left), MouseEventKind::ScrollUp => handle_scroll(Direction::Up), _ => None } } Event::Resize(_, _) => Some(InputAction::Redraw), _ => None } } // I want this to always return an option 'cause I just use it to return from `Self::handle_event` #[expect(clippy::unnecessary_wraps)] fn update_zoom(&mut self, f: impl FnOnce(&mut Zoom)) -> Option { if let Some(z) = &mut self.zoom { f(z); } self.last_render.rect = Rect::default(); Some(InputAction::Redraw) } pub fn show_error(&mut self, err: RenderError) { self.set_msg(MessageSetting::Some(BottomMessage::Error(match err { RenderError::Notify(e) => format!("Auto-reload failed: {e}"), RenderError::Doc(e) => format!("Couldn't process document: {e}"), RenderError::Converting(e) => format!("Couldn't convert page after rendering: {e}") }))); } fn set_page(&mut self, page: usize) { if page != self.page { // mark that we need to re-render the images self.last_render.rect = Rect::default(); self.page = page; } } // We have `msg` as optional so that if they reset it to none, it'll replace it with // `prev_msg`, but if they reset it to something else, it'll put the current thing in prev_msg pub fn set_msg(&mut self, msg: MessageSetting) { match msg { MessageSetting::Some(mut msg) => { std::mem::swap(&mut self.bottom_msg, &mut msg); self.prev_msg = Some(msg); } MessageSetting::Default => self.set_msg(MessageSetting::Some(BottomMessage::default())), MessageSetting::Reset => { self.prev_msg = None; self.bottom_msg = BottomMessage::default(); } MessageSetting::Pop => if self.showing_help_msg { self.last_render.rect = Rect::default(); self.showing_help_msg = false; } else { self.bottom_msg = self.prev_msg.take().unwrap_or_default(); }, } } pub fn render_help_msg(&self, frame: &mut Frame<'_>) { let frame_area = frame.area(); frame.render_widget(Clear, frame_area); let block = Block::new() .title("Help") .padding(Padding::proportional(1)) .borders(Borders::ALL) .border_set(border::ROUNDED) .border_style(Color::Blue); let help_sections = [ Text::from(HELP_PAGE), // just some spacing Text::from(""), if self.is_kitty { Text::from(KITTY_HELP) } else { Text::from("Not using kitty, kitty-specific keybindings hidden") .style(Color::DarkGray) } ]; let max_w: u16 = help_sections .iter() .flat_map(|section| section.lines.as_slice()) // We don't really need full unicode-width since we're using all ascii for the help // pages, but this is the function they give us. .map(Line::width) .max() .unwrap_or_default() .try_into() .expect("Every help text line must be shorter than u16::MAX"); let layout = Layout::horizontal([ Constraint::Fill(1), Constraint::Length(max_w + 6), Constraint::Fill(1) ]) .split(frame_area); let block_area = Layout::vertical([ Constraint::Fill(1), Constraint::Length( u16::try_from(help_sections.iter().map(|s| s.lines.len()).sum::()).unwrap() + 4 ), Constraint::Fill(1) ]) .split(layout[1]); let mut block_inner = block.inner(block_area[1]); frame.render_widget(block, block_area[1]); for section in help_sections { let section_lines = section.lines.len(); let span = Paragraph::new(section).wrap(Wrap { trim: false }); frame.render_widget(span, block_inner); block_inner.y += u16::try_from(section_lines).unwrap(); } } } static HELP_PAGE: &str = "\ l, h, left, right: Go forward/backwards a single page j, k, down, up: Go forwards/backwards a screen's worth of pages q, esc: Quit g: Go to specific page (type numbers after 'g') /: Search n, N: Next/Previous search result i: Invert colors f: Remove borders/fullscreen ?: Show this page ctrl+z: Suspend & background tdf \ "; static KITTY_HELP: &str = "\ When using Kitty Protocol: z: Toggle between fill-screen and fit-screen o/O (when on fill-screen): Zoom in and out, respectively gg/G (when on fill-screen): Scroll to top/bottom of page H, J, K, L (when zoomed in): Pan direction around page "; pub enum InputAction { Redraw, JumpingToPage(usize), Search(String), QuitApp, Invert, Fullscreen, SwitchRenderZoom(crate::FitOrFill) } #[derive(Copy, Clone)] enum PageChange { Prev, Next } #[derive(Copy, Clone)] enum ChangeAmount { WholeScreen, Single } pub enum MessageSetting { Some(BottomMessage), Default, Reset, Pop }