diff --git a/.gitignore b/.gitignore index ea8c4bf..0a42548 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +debug.log diff --git a/src/kitty.rs b/src/kitty.rs index c805b67..1fb7fbb 100644 --- a/src/kitty.rs +++ b/src/kitty.rs @@ -11,19 +11,26 @@ use kittage::{ AsyncInputReader, ImageDimensions, ImageId, NumberOrId, PixelFormat, action::Action, delete::{ClearOrDelete, DeleteConfig, WhichToDelete}, - display::DisplayConfig, + display::{DisplayConfig, DisplayLocation}, error::TransmitError, image::Image, medium::Medium }; -use ratatui::prelude::Rect; +use ratatui::layout::Position; use crate::converter::MaybeTransferred; +pub struct KittyReadyToDisplay<'tui> { + pub img: &'tui mut MaybeTransferred, + pub page_num: usize, + pub pos: Position, + pub display_loc: DisplayLocation +} + pub enum KittyDisplay<'tui> { NoChange, ClearImages, - DisplayImages(Vec<(usize, &'tui mut MaybeTransferred, Rect)>) + DisplayImages(Vec>) } pub struct DbgWriter { @@ -46,6 +53,7 @@ impl Write for DbgWriter { fn flush(&mut self) -> std::io::Result<()> { #[cfg(debug_assertions)] { + log::debug!("Writing to kitty: {:?}", self.buf); self.buf.clear(); } self.w.flush() @@ -120,10 +128,19 @@ pub async fn display_kitty_images<'es>( }; let mut err = None; - for (page_num, img, area) in images { - let config = DisplayConfig::default(); + for KittyReadyToDisplay { + img, + page_num, + pos, + display_loc + } in images + { + let config = DisplayConfig { + location: display_loc, + ..DisplayConfig::default() + }; - execute!(std::io::stdout(), MoveTo(area.x, area.y)).unwrap(); + execute!(std::io::stdout(), MoveTo(pos.x, pos.y)).unwrap(); let this_err = match img { MaybeTransferred::NotYet(image) => { @@ -155,6 +172,8 @@ pub async fn display_kitty_images<'es>( match res { Ok(img_id) => { + // TODO: Re-add this or at least make sure this sort of thing does happen + // fake_image.unlink_if_shm(); *img = MaybeTransferred::Transferred(img_id); Ok(()) } diff --git a/src/main.rs b/src/main.rs index 80acbf7..0929481 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,7 @@ use core::error::Error; use std::{ borrow::Cow, ffi::OsString, - io::{stdout, BufReader, Read, Stdout, Write}, + io::{stdout, BufReader, Read, Stdout}, num::{NonZeroU32, NonZeroUsize}, path::PathBuf }; @@ -25,7 +25,7 @@ use kittage::{ }; use notify::{Event, EventKind, RecursiveMode, Watcher}; use ratatui::{Terminal, backend::CrosstermBackend}; -use ratatui_image::picker::{Picker, ProtocolType}; +use ratatui_image::{picker::{Picker, ProtocolType}, FontSize}; use tdf::{ PrerenderLimit, converter::{ConvertedPage, ConverterMsg, run_conversion_loop}, @@ -152,68 +152,10 @@ async fn main() -> Result<(), WrappedErr> { })?; if window_size.width == 0 || window_size.height == 0 { - // send the command code to get the terminal window size - print!("\x1b[14t"); - std::io::stdout().flush().unwrap(); + let (w, h) = get_font_size_through_stdio()?; - // we need to enable raw mode here since this bit of output won't print a newline; it'll - // just print the info it wants to tell us. So we want to get all characters as they come - enable_raw_mode().map_err(|e| { - WrappedErr( - format!("Can't enable raw mode, which is necessary to receive input: {e}").into() - ) - })?; - - // read in the returned size until we hit a 't' (which indicates to us it's done) - let input_vec = BufReader::new(std::io::stdin()) - .bytes() - .filter_map(Result::ok) - .take_while(|b| *b != b't') - .collect::>(); - - // and then disable raw mode again in case we return an error in this next section - disable_raw_mode().map_err(|e| { - WrappedErr(format!("Can't put the terminal back into a normal input state: {e}").into()) - })?; - - let input_line = String::from_utf8(input_vec).map_err(|e| { - WrappedErr( - format!( - "The terminal responded to our request for its font size by providing non-utf8 data: {e}" - ) - .into() - ) - })?; - let input_line = input_line - .trim_start_matches("\x1b[4") - .trim_start_matches(';'); - - // it should input it to us as `\e[4;;t`, so we need to split to get the h/w - // ignore the first val - let mut splits = input_line.split([';', 't']); - - let (Some(h), Some(w)) = (splits.next(), splits.next()) else { - return Err(WrappedErr( - format!("Terminal responded with unparseable size response '{input_line}'").into() - )); - }; - - window_size.height = h.parse::().map_err(|_| { - WrappedErr( - format!( - "Your terminal said its height is {h}, but that is not a 16-bit unsigned integer" - ) - .into() - ) - })?; - window_size.width = w.parse::().map_err(|_| { - WrappedErr( - format!( - "Your terminal said its width is {w}, but that is not a 16-bit unsigned integer" - ) - .into() - ) - })?; + window_size.width = w; + window_size.height = h; } // We need to create `picker` on this thread because if we create it on the `renderer` thread, @@ -248,6 +190,8 @@ async fn main() -> Result<(), WrappedErr> { ) }); + let font_size = picker.font_size(); + let mut ev_stream = crossterm::event::EventStream::new(); let (to_converter, from_main) = flume::unbounded(); @@ -326,7 +270,8 @@ async fn main() -> Result<(), WrappedErr> { fullscreen, tui, &mut term, - main_area + main_area, + font_size ) .await .map_err(|e| { @@ -362,7 +307,8 @@ async fn enter_redraw_loop( mut fullscreen: bool, mut tui: Tui, term: &mut Terminal>, - mut main_area: tdf::tui::RenderLayout + mut main_area: tdf::tui::RenderLayout, + font_size: FontSize ) -> Result<(), Box> { loop { let mut needs_redraw = true; @@ -424,7 +370,7 @@ async fn enter_redraw_loop( if needs_redraw { let mut to_display = KittyDisplay::NoChange; term.draw(|f| { - to_display = tui.render(f, &main_area); + to_display = tui.render(f, &main_area, font_size); })?; let maybe_err = display_kitty_images(to_display, &mut ev_stream).await; @@ -499,3 +445,66 @@ fn parse_color_to_i32(cs: &str) -> Result let [r, g, b, _] = color.to_rgba8(); Ok(i32::from_be_bytes([0, r, g, b])) } + +fn get_font_size_through_stdio() -> Result<(u16, u16), WrappedErr> { + // we need to enable raw mode here since this bit of output won't print a newline; it'll + // just print the info it wants to tell us. So we want to get all characters as they come + enable_raw_mode().map_err(|e| { + WrappedErr( + format!("Can't enable raw mode, which is necessary to receive input: {e}").into() + ) + })?; + + // read in the returned size until we hit a 't' (which indicates to us it's done) + let input_vec = BufReader::new(std::io::stdin()) + .bytes() + .filter_map(Result::ok) + .take_while(|b| *b != b't') + .collect::>(); + + // and then disable raw mode again in case we return an error in this next section + disable_raw_mode().map_err(|e| { + WrappedErr(format!("Can't put the terminal back into a normal input state: {e}").into()) + })?; + + let input_line = String::from_utf8(input_vec).map_err(|e| { + WrappedErr( + format!( + "The terminal responded to our request for its font size by providing non-utf8 data: {e}" + ) + .into() + ) + })?; + let input_line = input_line + .trim_start_matches("\x1b[4") + .trim_start_matches(';'); + + // it should input it to us as `\e[4;;t`, so we need to split to get the h/w + // ignore the first val + let mut splits = input_line.split([';', 't']); + + let (Some(h), Some(w)) = (splits.next(), splits.next()) else { + return Err(WrappedErr( + format!("Terminal responded with unparseable size response '{input_line}'").into() + )); + }; + + let h = h.parse::().map_err(|_| { + WrappedErr( + format!( + "Your terminal said its height is {h}, but that is not a 16-bit unsigned integer" + ) + .into() + ) + })?; + let w = w.parse::().map_err(|_| { + WrappedErr( + format!( + "Your terminal said its width is {w}, but that is not a 16-bit unsigned integer" + ) + .into() + ) + })?; + + Ok((w, h)) +} diff --git a/src/tui.rs b/src/tui.rs index d232863..91d9424 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -8,23 +8,24 @@ use crossterm::{ enable_raw_mode } }; +use kittage::display::DisplayLocation; use nix::{ sys::signal::{Signal::SIGSTOP, kill}, unistd::Pid }; use ratatui::{ Frame, - layout::{Constraint, Flex, Layout, Rect}, + layout::{Constraint, Flex, Layout, Position, Rect}, style::{Color, Style}, symbols::border, text::{Span, Text}, widgets::{Block, Borders, Clear, Padding} }; -use ratatui_image::Image; +use ratatui_image::{FontSize, Image}; use crate::{ converter::{ConvertedImage, MaybeTransferred}, - kitty::KittyDisplay, + kitty::{KittyDisplay, KittyReadyToDisplay}, renderer::{RenderError, fill_default}, skip::Skip }; @@ -39,7 +40,8 @@ pub struct Tui { prev_msg: Option, rendered: Vec, page_constraints: PageConstraints, - showing_help_msg: bool + showing_help_msg: bool, + zoom: Option } #[derive(Default)] @@ -71,6 +73,19 @@ struct PageConstraints { r_to_l: bool } +#[derive(Default)] +struct Zoom { + // just how much 'zoom' you have. Doesn't relate to anything specific yet, except that 0 means + // it fills the screen (instead of fits) + level: u16, + // 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 +} + // 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)] @@ -100,7 +115,8 @@ impl Tui { last_render: LastRender::default(), rendered: vec![], page_constraints: PageConstraints { max_wide, r_to_l }, - showing_help_msg: false + showing_help_msg: false, + zoom: None } } @@ -133,106 +149,23 @@ impl Tui { pub fn render<'s>( &'s mut self, frame: &mut Frame<'_>, - full_layout: &RenderLayout + full_layout: &RenderLayout, + font_size: FontSize ) -> KittyDisplay<'s> { if self.showing_help_msg { self.render_help_msg(frame); return KittyDisplay::ClearImages; } - if let Some((top_area, bottom_area)) = full_layout.top_and_bottom { - let top_block = Block::new() - .padding(Padding { - right: 2, - left: 2, - ..Padding::default() - }) - .borders(Borders::BOTTOM); - - let top_area = top_block.inner(top_area); - - let page_nums_text = format!("{} / {}", self.page + 1, self.rendered.len()); - - let top_layout = Layout::horizontal([ - Constraint::Fill(1), - Constraint::Length(page_nums_text.len() as u16) - ]) - .split(top_area); - - let title = Span::styled(&self.name, Style::new().fg(Color::Cyan)); - - let page_nums = Span::styled(&page_nums_text, Style::new().fg(Color::Cyan)); - - frame.render_widget(top_block, top_area); - frame.render_widget(title, top_layout[0]); - frame.render_widget(page_nums, top_layout[1]); - - 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 !self.rendered.is_empty() { - format!( - "Rendered: {}%", - (self.rendered.iter().filter(|i| i.img.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_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 self.bottom_msg { - BottomMessage::Help => ("?: Show help page".into(), Color::Blue), - BottomMessage::Error(ref e) => (e.as_str().into(), Color::Red), - BottomMessage::Input(ref input_state) => ( - match input_state { - InputCommand::GoToPage(page) => format!("Go to: {page}"), - InputCommand::Search(s) => format!("Search: {s}") - } - .into(), - Color::Blue - ), - BottomMessage::SearchResults(ref term) => { - let num_found = self - .rendered - .iter() - .filter_map(|r| r.num_results) - .sum::(); - let num_searched = self - .rendered - .iter() - .filter(|r| r.num_results.is_some()) - .count() * 100; - ( - format!( - "Results for '{term}': {num_found} (searched: {}%)", - num_searched / self.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]); + 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; @@ -246,6 +179,60 @@ impl Tui { frame.render_widget(Skip::new(true), img_area); KittyDisplay::NoChange } else { + if let Some(ref 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 { .. })) + { + log::debug!("we're inside, it's kitty"); + let Some(ConvertedImage::Kitty { ref mut img, area }) = + self.rendered[self.page].img + else { + unreachable!() + }; + + let img_width = f32::from(area.width); + let img_height = f32::from(area.height); + let available_to_real_width_ratio = f32::from(img_area.width) / img_width; + let available_to_real_height_ratio = f32::from(img_area.height) / img_height; + + let (width, height) = + if available_to_real_width_ratio > available_to_real_height_ratio { + (img_width, img_height / available_to_real_width_ratio) + } else { + (img_width / available_to_real_height_ratio, img_height) + }; + + let width = (width * f32::from(font_size.0)) as u32; + let height = (height * f32::from(font_size.1)) as u32; + + return KittyDisplay::DisplayImages(vec![KittyReadyToDisplay { + img, + page_num: self.page, + 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() + } + }]); + } + }; + // 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 @@ -299,7 +286,12 @@ impl Tui { let maybe_img = Self::render_single_page(frame, img, Rect { width, ..img_area }); img_area.x += width; - maybe_img.map(|(img, r)| (idx + self.page, img, r)) + maybe_img.map(|(img, pos)| KittyReadyToDisplay { + img, + page_num: idx + self.page, + pos, + display_loc: DisplayLocation::default() + }) }) .collect::>(); @@ -316,17 +308,15 @@ impl Tui { frame: &mut Frame<'_>, page_img: &'img mut ConvertedImage, img_area: Rect - ) -> Option<(&'img mut MaybeTransferred, 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, area } => Some((img, Rect { + ConvertedImage::Kitty { img, area } => Some((img, Position { x: img_area.x, - y: img_area.y, - width: area.width, - height: area.height + y: img_area.y })) } } @@ -410,6 +400,100 @@ impl Tui { 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 + ) { + let top_block = Block::new() + .padding(Padding { + right: 2, + left: 2, + ..Padding::default() + }) + .borders(Borders::BOTTOM); + + let top_area = top_block.inner(top_area); + + let page_nums_text = format!("{} / {}", page_num + 1, rendered.len()); + + let top_layout = Layout::horizontal([ + Constraint::Fill(1), + Constraint::Length(page_nums_text.len() as u16) + ]) + .split(top_area); + + let title = Span::styled(doc_name, Style::new().fg(Color::Cyan)); + + let page_nums = Span::styled(&page_nums_text, Style::new().fg(Color::Cyan)); + + frame.render_widget(top_block, top_area); + frame.render_widget(title, top_layout[0]); + frame.render_widget(page_nums, top_layout[1]); + + 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, @@ -524,6 +608,42 @@ impl Tui { self.last_render.rect = Rect::default(); Some(InputAction::Redraw) } + 'z' => { + self.zoom = match self.zoom { + None => Some(Zoom::default()), + Some(_) => None + }; + self.last_render.rect = Rect::default(); + Some(InputAction::Redraw) + } + 'L' => { + if let Some(z) = &mut self.zoom { + z.cell_pan_from_left = z.cell_pan_from_left.saturating_add(1); + } + self.last_render.rect = Rect::default(); + Some(InputAction::Redraw) + } + 'H' => { + if let Some(z) = &mut self.zoom { + z.cell_pan_from_left = z.cell_pan_from_left.saturating_sub(1); + } + self.last_render.rect = Rect::default(); + Some(InputAction::Redraw) + } + 'J' => { + if let Some(z) = &mut self.zoom { + z.cell_pan_from_top = z.cell_pan_from_top.saturating_add(1); + } + self.last_render.rect = Rect::default(); + Some(InputAction::Redraw) + } + 'K' => { + if let Some(z) = &mut self.zoom { + z.cell_pan_from_top = z.cell_pan_from_top.saturating_sub(1); + } + self.last_render.rect = Rect::default(); + Some(InputAction::Redraw) + } _ => None } }