New kitty image backend (#74)

* Initial attempt at supporting new backend for kitty images

* it's almost working !!

* it almost basically works

* yaaayyyy it works

* Use github kittage

* Uhhhh various improvements from kittage and psx-shm

* Remove logging

* incorporate recovering from deleted images

* Make it work correctly with ghostty image eviction too

* fall back to stdout if shms don't work

* Make help page work again

* zooming basically does what you'd expect now

* yay zooming woohoo

* clean up top and bottom rendering

* Only allow zooming in kitty

* Add debug logging and fix cursor placement after image display

* yaaaay zooming out once you're already zoomed in and respecting kitty's limits for how big of an image to display

* mmmm maybe it's finally ready to merge...

* Update deps

* Switch around list of items on changelog

* fmt

* Small fixes to avoid panic and allow zooming back in after zooming out
This commit is contained in:
June
2025-08-06 11:34:55 -04:00
committed by GitHub
parent 777705b902
commit b6bc76edbb
13 changed files with 1253 additions and 313 deletions
+183 -82
View File
@@ -2,8 +2,8 @@ use core::error::Error;
use std::{
borrow::Cow,
ffi::OsString,
io::{BufReader, Read, Stdout, Write, stdout},
num::NonZeroUsize,
io::{BufReader, Read, Stdout, stdout},
num::{NonZeroU32, NonZeroUsize},
path::PathBuf
};
@@ -15,14 +15,24 @@ use crossterm::{
enable_raw_mode, window_size
}
};
use flexi_logger::FileSpec;
use flume::{Sender, r#async::RecvStream};
use futures_util::{FutureExt, stream::StreamExt};
use kittage::{
action::Action,
delete::{ClearOrDelete, DeleteConfig, WhichToDelete},
error::{TerminalError, TransmitError}
};
use notify::{Event, EventKind, RecursiveMode, Watcher};
use ratatui::{Terminal, backend::CrosstermBackend};
use ratatui_image::picker::Picker;
use ratatui_image::{
FontSize,
picker::{Picker, ProtocolType}
};
use tdf::{
PrerenderLimit,
converter::{ConvertedPage, ConverterMsg, run_conversion_loop},
kitty::{KittyDisplay, display_kitty_images, do_shms_work, run_action},
renderer::{self, RenderError, RenderInfo, RenderNotif},
tui::{BottomMessage, InputAction, MessageSetting, Tui}
};
@@ -89,8 +99,24 @@ async fn main() -> Result<(), WrappedErr> {
)
})?;
// need to keep it around throughout the lifetime of the program, but don't rly need to use it.
// Just need to make sure it doesn't get dropped yet.
let mut maybe_logger = None;
if std::env::var("RUST_LOG").is_ok() {
maybe_logger = Some(
flexi_logger::Logger::try_with_env()
.map_err(|e| WrappedErr(format!("Couldn't create initial logger: {e}").into()))?
.log_to_file(FileSpec::try_from("./debug.log").map_err(|e| {
WrappedErr(format!("Couldn't create FileSpec for logger: {e}").into())
})?)
.start()
.map_err(|e| WrappedErr(format!("Can't start logger: {e}").into()))?
);
}
let (watch_to_render_tx, render_rx) = flume::unbounded();
let tui_tx = watch_to_render_tx.clone();
let to_renderer = watch_to_render_tx.clone();
let (render_tx, tui_rx) = flume::unbounded();
let watch_to_tui_tx = render_tx.clone();
@@ -128,68 +154,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::<Vec<_>>();
// 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;<height>;<width>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::<u16>().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::<u16>().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,
@@ -208,30 +176,47 @@ async fn main() -> Result<(), WrappedErr> {
.prerender
.and_then(NonZeroUsize::new)
.map_or(PrerenderLimit::All, PrerenderLimit::Limited);
let cell_height_px = window_size.height / window_size.rows;
let cell_width_px = window_size.width / window_size.columns;
std::thread::spawn(move || {
renderer::start_rendering(
&file_path,
render_tx,
render_rx,
window_size,
cell_height_px,
cell_width_px,
prerender,
black,
white
)
});
let ev_stream = crossterm::event::EventStream::new();
let font_size = picker.font_size();
let mut ev_stream = crossterm::event::EventStream::new();
let (to_converter, from_main) = flume::unbounded();
let (to_main, from_converter) = flume::unbounded();
tokio::spawn(run_conversion_loop(to_main, from_main, picker, 20));
let is_kitty = picker.protocol_type() == ProtocolType::Kitty;
let shms_work = is_kitty && do_shms_work(&mut ev_stream).await;
tokio::spawn(run_conversion_loop(
to_main, from_main, picker, 20, shms_work
));
let file_name = path.file_name().map_or_else(
|| "Unknown file".into(),
|n| n.to_string_lossy().to_string()
);
let tui = Tui::new(file_name, flags.max_wide, flags.r_to_l.unwrap_or_default());
let tui = Tui::new(
file_name,
flags.max_wide,
flags.r_to_l.unwrap_or_default(),
is_kitty
);
let backend = CrosstermBackend::new(std::io::stdout());
let mut term = Terminal::new(backend).map_err(|e| {
@@ -258,9 +243,23 @@ async fn main() -> Result<(), WrappedErr> {
)
})?;
if is_kitty {
run_action(
Action::Delete(DeleteConfig {
effect: ClearOrDelete::Delete,
which: WhichToDelete::IdRange(NonZeroU32::new(1).unwrap()..=NonZeroU32::MAX)
}),
&mut ev_stream
)
.await
.map_err(|e| {
WrappedErr(format!("Couldn't delete all previous images from memory: {e}").into())
})?;
}
let fullscreen = flags.fullscreen.unwrap_or_default();
let main_area = Tui::main_layout(&term.get_frame(), fullscreen);
tui_tx
to_renderer
.send(RenderNotif::Area(main_area.page_area))
.map_err(|e| {
WrappedErr(
@@ -273,14 +272,15 @@ async fn main() -> Result<(), WrappedErr> {
enter_redraw_loop(
ev_stream,
tui_tx,
to_renderer,
tui_rx,
to_converter,
from_converter,
fullscreen,
tui,
&mut term,
main_area
main_area,
font_size
)
.await
.map_err(|e| {
@@ -300,6 +300,8 @@ async fn main() -> Result<(), WrappedErr> {
.unwrap();
disable_raw_mode().unwrap();
drop(maybe_logger);
Ok(())
}
@@ -307,20 +309,22 @@ async fn main() -> Result<(), WrappedErr> {
#[expect(clippy::too_many_arguments)]
async fn enter_redraw_loop(
mut ev_stream: EventStream,
tui_tx: Sender<RenderNotif>,
to_renderer: Sender<RenderNotif>,
mut tui_rx: RecvStream<'_, Result<RenderInfo, RenderError>>,
to_converter: Sender<ConverterMsg>,
mut from_converter: RecvStream<'_, Result<ConvertedPage, RenderError>>,
mut fullscreen: bool,
mut tui: Tui,
term: &mut Terminal<CrosstermBackend<Stdout>>,
mut main_area: tdf::tui::RenderLayout
mut main_area: tdf::tui::RenderLayout,
font_size: FontSize
) -> Result<(), Box<dyn Error>> {
loop {
let mut needs_redraw = true;
let next_ev = ev_stream.next().fuse();
tokio::select! {
// First we check if we have any keystrokes
Some(ev) = ev_stream.next().fuse() => {
Some(ev) = next_ev => {
// If we can't get user input, just crash.
let ev = ev.expect("Couldn't get any user input");
@@ -330,12 +334,15 @@ async fn enter_redraw_loop(
InputAction::Redraw => (),
InputAction::QuitApp => return Ok(()),
InputAction::JumpingToPage(page) => {
tui_tx.send(RenderNotif::JumpToPage(page))?;
to_renderer.send(RenderNotif::JumpToPage(page))?;
to_converter.send(ConverterMsg::GoToPage(page))?;
},
InputAction::Search(term) => tui_tx.send(RenderNotif::Search(term))?,
InputAction::Invert => tui_tx.send(RenderNotif::Invert)?,
InputAction::Search(term) => to_renderer.send(RenderNotif::Search(term))?,
InputAction::Invert => to_renderer.send(RenderNotif::Invert)?,
InputAction::Fullscreen => fullscreen = !fullscreen,
InputAction::SwitchRenderZoom(f_or_f) => {
to_renderer.send(RenderNotif::SwitchFitOrFill(f_or_f)).unwrap();
}
}
}
},
@@ -359,7 +366,12 @@ async fn enter_redraw_loop(
}
Some(img_res) = from_converter.next() => {
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);
if num == tui.page {
needs_redraw = true;
}
},
Err(e) => tui.show_error(e),
}
},
@@ -368,15 +380,41 @@ async fn enter_redraw_loop(
let new_area = Tui::main_layout(&term.get_frame(), fullscreen);
if new_area != main_area {
main_area = new_area;
tui_tx.send(RenderNotif::Area(main_area.page_area))?;
to_renderer.send(RenderNotif::Area(main_area.page_area))?;
needs_redraw = true;
}
if needs_redraw {
let mut to_display = KittyDisplay::NoChange;
term.draw(|f| {
tui.render(f, &main_area);
to_display = tui.render(f, &main_area, font_size);
})?;
execute!(stdout(), EndSynchronizedUpdate)?;
let maybe_err = display_kitty_images(to_display, &mut ev_stream).await;
if let Err((to_replace, err_desc, enum_err)) = maybe_err {
match enum_err {
// This is the error that kitty & ghostty provide us when they delete an
// image due to memory constraints, so if we get it, we just fix it by
// re-rendering so it don't display it to the user
//
// [TODO] maybe when we detect that an image was deleted, we probe the
// terminal for the pages around it to see if they were deleted too and if
// they were, we re-render them? idk
TransmitError::Terminal(TerminalError::NoEntity(_)) => (),
_ => tui.set_msg(MessageSetting::Some(BottomMessage::Error(format!(
"{err_desc}: {enum_err}"
))))
}
for page_num in to_replace {
tui.page_failed_display(page_num);
// So that they get re-rendered and sent over again
to_renderer.send(RenderNotif::PageNeedsReRender(page_num))?;
}
}
execute!(stdout().lock(), EndSynchronizedUpdate)?;
}
}
}
@@ -424,3 +462,66 @@ fn parse_color_to_i32(cs: &str) -> Result<i32, csscolorparser::ParseColorError>
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::<Vec<_>>();
// 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;<height>;<width>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::<u16>().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::<u16>().map_err(|_| {
WrappedErr(
format!(
"Your terminal said its width is {w}, but that is not a 16-bit unsigned integer"
)
.into()
)
})?;
Ok((w, h))
}