mirror of
https://github.com/itsjunetime/tdf.git
synced 2026-06-01 23:51:46 -04:00
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:
+183
-82
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user