diff --git a/Cargo.lock b/Cargo.lock index 83b1455..11d6748 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -49,9 +49,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.3.1" +version = "2.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6776fc96284a0bb647b615056fc496d1fe1644a7ab01829818a6d91cae888b84" +checksum = "630be753d4e58660abd17930c71b647fe46c27ea6b63cc59e1e3851406972e42" [[package]] name = "block-buffer" @@ -95,6 +95,12 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" +[[package]] +name = "cc" +version = "1.0.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f" + [[package]] name = "cfg-if" version = "1.0.0" @@ -170,10 +176,11 @@ dependencies = [ [[package]] name = "crossterm" -version = "0.26.1" -source = "git+https://github.com/benjajaja/crossterm?rev=ad84a3829d55eac27cfdb7791b2e34c1732040ad#ad84a3829d55eac27cfdb7791b2e34c1732040ad" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67" dependencies = [ - "bitflags 2.3.1", + "bitflags 1.3.2", "crossterm_winapi", "libc", "mio", @@ -186,9 +193,9 @@ dependencies = [ [[package]] name = "crossterm" version = "0.26.1" -source = "git+https://github.com/benjajaja/crossterm?rev=db515a16f95b36b4871488aa543f564bc929d62e#db515a16f95b36b4871488aa543f564bc929d62e" +source = "git+https://github.com/benjajaja/crossterm?rev=ad84a3829d55eac27cfdb7791b2e34c1732040ad#ad84a3829d55eac27cfdb7791b2e34c1732040ad" dependencies = [ - "bitflags 2.3.1", + "bitflags 2.3.3", "crossterm_winapi", "libc", "mio", @@ -290,6 +297,27 @@ version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91" +[[package]] +name = "errno" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bcfec3a70f97c962c307b2d2c56e358cf1d00b558d74262b5f929ee8cc7e73a" +dependencies = [ + "errno-dragonfly", + "libc", + "windows-sys", +] + +[[package]] +name = "errno-dragonfly" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" +dependencies = [ + "cc", + "libc", +] + [[package]] name = "exr" version = "1.6.4" @@ -499,9 +527,15 @@ checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8" [[package]] name = "libc" -version = "0.2.146" +version = "0.2.147" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4668fb0ea861c1df094127ac5f1da3409a82116a4ba74fca2e58ef927159bb3" + +[[package]] +name = "linux-raw-sys" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f92be4933c13fd498862a9e02a3055f8a8d9c039ce33db97306fd5a6caa7f29b" +checksum = "09fc20d2ca12cb9f044c93e3bd6d32d523e6e2ec3db4f7b2939cd99026ecd3f0" [[package]] name = "lock_api" @@ -900,13 +934,15 @@ checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" [[package]] name = "ratatu-image" -version = "0.1.0" +version = "0.1.1" dependencies = [ - "crossterm 0.26.1 (git+https://github.com/benjajaja/crossterm?rev=db515a16f95b36b4871488aa543f564bc929d62e)", + "crossterm 0.25.0", "dyn-clone", "image", "ratatui", - "sixel-rs", + "rustix", + "serde", + "sixel-bytes", "termion", "termwiz", ] @@ -916,9 +952,9 @@ name = "ratatui" version = "0.21.0" source = "git+https://github.com/benjajaja/ratatui?rev=8729b61fab885dc141c6e0968b4863386c544d9d#8729b61fab885dc141c6e0968b4863386c544d9d" dependencies = [ - "bitflags 2.3.1", + "bitflags 2.3.3", "cassowary", - "crossterm 0.26.1 (git+https://github.com/benjajaja/crossterm?rev=ad84a3829d55eac27cfdb7791b2e34c1732040ad)", + "crossterm 0.26.1", "indoc", "termion", "termwiz", @@ -1003,6 +1039,19 @@ version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "436b050e76ed2903236f032a59761c1eb99e1b0aead2c257922771dab1fc8c78" +[[package]] +name = "rustix" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a962918ea88d644592894bc6dc55acc6c0956488adcebbfb6e273506b7fd6e5" +dependencies = [ + "bitflags 2.3.3", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "scopeguard" version = "1.1.0" @@ -1027,6 +1076,12 @@ dependencies = [ "pest", ] +[[package]] +name = "serde" +version = "1.0.174" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b88756493a5bd5e5395d53baa70b194b05764ab85b59e43e4b8f4e1192fa9b1" + [[package]] name = "sha2" version = "0.9.9" @@ -1104,10 +1159,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7bd3e3206899af3f8b12af284fafc038cc1dc2b41d1b89dd17297221c5d225de" [[package]] -name = "sixel-rs" -version = "0.3.3" +name = "sixel-bytes" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfa95c014543113a192d906e5971d0c8d1e8b4cc1e61026539687a7016644ce5" +checksum = "f0a6652737ffaf02e436669b7cd22c8a6942e1363145f06ae6d354811870711a" dependencies = [ "sixel-sys", ] diff --git a/Cargo.toml b/Cargo.toml index 2fd92f7..17d5870 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ratatu-image" -version = "0.1.0" +version = "0.1.1" edition = "2021" autoexamples = true authors = ["Benjamin Große "] @@ -16,22 +16,28 @@ exclude = [ rust-version = "1.65.0" [features] -default = ["crossterm", "sixel"] +default = ["crossterm", "sixel", "rustix"] crossterm = ["dep:crossterm"] termion = ["dep:termion"] termwiz = ["dep:termwiz"] -sixel = ["dep:sixel-rs"] +sixel = ["dep:sixel-bytes"] +serde = ["dep:serde"] +rustix = ["dep:rustix"] [dependencies] dyn-clone = "1.0.11" image = { version = "0.24.5" } # ratatui with "cell skipping" and "window_size", https://github.com/tui-rs-revival/ratatui/pull/215 and https://github.com/tui-rs-revival/ratatui/pull/276 ratatui = { git = "https://github.com/benjajaja/ratatui", rev = "8729b61fab885dc141c6e0968b4863386c544d9d", features = ["crossterm", "termion", "termwiz" ] } -sixel-rs = { version = "0.3.3", optional = true } +# sixel-rs = { version = "0.3.3", optional = true } +sixel-bytes = { version = "0.2.1", optional = true } # crossterm with "window_size", https://github.com/crossterm-rs/crossterm/pull/790 -crossterm = { git = "https://github.com/benjajaja/crossterm", rev = "db515a16f95b36b4871488aa543f564bc929d62e" , optional = true } +# crossterm = { git = "https://github.com/benjajaja/crossterm", rev = "db515a16f95b36b4871488aa543f564bc929d62e" , optional = true } +crossterm = { version = "0.25", optional = true } termion = { version = "2.0", optional = true } termwiz = { version = "0.20", optional = true } +serde = { version = "^1.0", optional = true } +rustix = { version = "^0.38.4", optional = true, features = ["stdio", "termios"] } [[example]] name = "demo" diff --git a/README.md b/README.md index 315b455..a2e88cb 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,10 @@ -# Ratatu-image +# ratatu-image + +![Screenshot](./assets/Screenshot.png) + +Image widgets for [Ratatui] + +[Ratatui]: https://github.com/tui-rs-revival/ratatui **THIS CRATE IS EXPERIMENTAL!** @@ -6,7 +12,7 @@ Render images with supported graphics protocols in the terminal with ratatui. While this generally might seem *contra natura* and something fragile, it can be worthwhile in some applications. -![Screenshot](./assets/screenshot.png) +## Implementation The images are always resized so that they fit their nearest rectangle in columns/rows. The reason for this is because the image shall be drawn in the same "render pass" as all @@ -15,6 +21,8 @@ level, so there is no way to "clear" previous drawn text. This would leave artif image border. For this resizing it is necessary to query the terminal font size in width/height. +## Widgets + The [`FixedImage`] widget does not react to area resizes other than not overdrawing. Note that some image protocols or their implementations might not behave correctly in this aspect and overdraw or flicker outside of the image area. @@ -26,12 +34,14 @@ this widget may have a much bigger performance impact. Each widget is backed by a "backend" implementation of a given image protocol. +## Backends + Currently supported backends/protocols: -## Halfblocks +### Halfblocks Uses the unicode character `▀` combined with foreground and background color. Assumes that the font aspect ratio is roughly 1:2. Should work in all terminals. -## Sixel +### Sixel Experimental: uses temporary files. Uses [`sixel-rs`] to draw image pixels, if the terminal [supports] the [Sixel] protocol. @@ -39,13 +49,13 @@ Uses [`sixel-rs`] to draw image pixels, if the terminal [supports] the [Sixel] p [supports]: https://arewesixelyet.com [Sixel]: https://en.wikipedia.org/wiki/Sixel -# Examples +## Examples For a more streamlined experience, see the [`crate::picker::Picker`] helper. ```rust use image::{DynamicImage, ImageBuffer, Rgb}; -use ratatui_imagine::{ +use ratatu_image::{ backend::{ FixedBackend, halfblocks::FixedHalfblocks, @@ -55,7 +65,7 @@ use ratatui_imagine::{ let image: DynamicImage = ImageBuffer::, Vec>::new(300, 200).into(); -let source = ImageSource::new(image, (7, 14), None); +let source = ImageSource::new(image, "filename.png".into(), (7, 14), None); let static_image = Box::new(FixedHalfblocks::from_source( &source, @@ -68,3 +78,6 @@ assert_eq!(15, static_image.rect().height); let image_widget = FixedImage::new(&static_image); ``` +Current version: 0.1.1 + +License: MIT diff --git a/README.tpl b/README.tpl new file mode 100644 index 0000000..e08407b --- /dev/null +++ b/README.tpl @@ -0,0 +1,10 @@ +# {{crate}} + +![Screenshot](./assets/Screenshot.png) + +{{readme}} + +Current version: {{version}} + +License: {{license}} + diff --git a/assets/Jenkins.jpg b/assets/Jenkins.jpg new file mode 100644 index 0000000..097d652 Binary files /dev/null and b/assets/Jenkins.jpg differ diff --git a/examples/demo/main.rs b/examples/demo/main.rs index 6e046ad..1028b51 100644 --- a/examples/demo/main.rs +++ b/examples/demo/main.rs @@ -16,8 +16,8 @@ use std::{error::Error, time::Duration}; use ratatu_image::{ backend::{FixedBackend, ResizeBackend}, - picker::Picker, - FixedImage, Resize, ResizeImage, + picker::{BackendType, Picker}, + FixedImage, ImageSource, Resize, ResizeImage, }; use ratatui::{ backend::Backend, @@ -40,16 +40,24 @@ fn main() -> Result<(), Box> { Ok(()) } -pub struct App<'a> { +enum ShowImages { + All, + Fixed, + Resized, +} + +struct App<'a> { pub title: &'a str, pub should_quit: bool, pub tick_rate: Duration, pub background: String, pub split_percent: u16, - pub show_resizable_images: bool, + pub show_resizable_images: ShowImages, pub picker: Picker, + pub image_source: ImageSource, + pub image_static: Box, pub image_static_offset: (u16, u16), @@ -57,21 +65,24 @@ pub struct App<'a> { pub image_crop_state: Box, } -// Since terminal cell proportion is around 1:2, this roughly results in a "portrait" proportion. -fn static_fit() -> Rect { - Rect::new(0, 0, 30, 20) -} - impl<'a> App<'a> { pub fn new(title: &'a str, terminal: &mut Terminal) -> App<'a> { - let dyn_img = image::io::Reader::open("./assets/Ada.png") - .unwrap() - .decode() - .unwrap(); + let ada = "./assets/Ada.png"; + let dyn_img = image::io::Reader::open(ada).unwrap().decode().unwrap(); - let picker = Picker::guess(dyn_img, terminal, None).unwrap(); + let image_source = + ImageSource::with_terminal(dyn_img.clone(), ada.into(), terminal, None).unwrap(); - let image_static = picker.new_static_fit(Resize::Fit, static_fit()).unwrap(); + let backend_type = if cfg!(feature = "sixel") { + BackendType::Sixel + } else { + BackendType::Halfblocks + }; + let picker = Picker::from_ioctl(backend_type, None, Some(Rect::new(0, 0, 30, 20))).unwrap(); + + let image_static = picker + .new_static_fit(dyn_img, ada.into(), Resize::Fit) + .unwrap(); let image_fit_state = picker.new_state(); let image_crop_state = image_fit_state.clone(); @@ -88,13 +99,16 @@ impl<'a> App<'a> { should_quit: false, tick_rate: Duration::from_millis(1000), background, - show_resizable_images: true, + show_resizable_images: ShowImages::All, split_percent: 70, picker, + image_source, + image_static, - image_static_offset: (0, 0), image_fit_state, image_crop_state, + + image_static_offset: (0, 0), } } pub fn on_key(&mut self, c: char) { @@ -103,17 +117,45 @@ impl<'a> App<'a> { self.should_quit = true; } 't' => { - self.show_resizable_images = !self.show_resizable_images; + self.show_resizable_images = match self.show_resizable_images { + ShowImages::All => ShowImages::Fixed, + ShowImages::Fixed => ShowImages::Resized, + ShowImages::Resized => ShowImages::All, + } } 'i' => { - self.picker.next(); + let next = match self.picker.backend_type() { + BackendType::Halfblocks => BackendType::Sixel, + BackendType::Sixel => BackendType::Halfblocks, + }; + self.picker.set_backend(next); + self.image_static = self .picker - .new_static_fit(Resize::Fit, static_fit()) + .new_static_fit( + self.image_source.image.clone(), + self.image_source.path.clone(), + Resize::Fit, + ) .unwrap(); + self.image_fit_state = self.picker.new_state(); self.image_crop_state = self.picker.new_state(); } + 'o' => { + let path = match self.image_source.path.to_str() { + Some("./assets/Ada.png") => "./assets/Jenkins.jpg", + _ => "./assets/Ada.png", + }; + let dyn_img = image::io::Reader::open(path).unwrap().decode().unwrap(); + self.image_source = + ImageSource::new(dyn_img.clone(), path.into(), self.picker.font_size(), None); + + self.image_static = self + .picker + .new_static_fit(dyn_img, path.into(), Resize::Fit) + .unwrap(); + } 'H' => { if self.split_percent >= 10 { self.split_percent -= 10; @@ -147,7 +189,7 @@ impl<'a> App<'a> { pub fn on_tick(&mut self) {} } -pub fn ui(f: &mut Frame, app: &mut App) { +fn ui(f: &mut Frame, app: &mut App) { let outer_block = Block::default().borders(Borders::TOP).title(app.title); let chunks = Layout::default() @@ -178,8 +220,13 @@ pub fn ui(f: &mut Frame, app: &mut App) { area, ); f.render_widget(block_left_top, left_chunks[0]); - let image = FixedImage::new(app.image_static.as_ref()); - f.render_widget(image, area); + match app.show_resizable_images { + ShowImages::Resized => {} + _ => { + let image = FixedImage::new(app.image_static.as_ref()); + f.render_widget(image, area); + } + } let block_left_bottom = Block::default().borders(Borders::ALL).title("Crop"); let area = block_left_bottom.inner(left_chunks[1]); @@ -187,13 +234,16 @@ pub fn ui(f: &mut Frame, app: &mut App) { Paragraph::new(app.background.as_str()).wrap(Wrap { trim: true }), area, ); - if app.show_resizable_images { - let image = ResizeImage::new(&app.picker.source).resize(Resize::Crop); - f.render_stateful_widget( - image, - block_left_bottom.inner(left_chunks[1]), - &mut app.image_fit_state, - ); + match app.show_resizable_images { + ShowImages::Fixed => {} + _ => { + let image = ResizeImage::new(&app.image_source).resize(Resize::Crop); + f.render_stateful_widget( + image, + block_left_bottom.inner(left_chunks[1]), + &mut app.image_fit_state, + ); + } } f.render_widget(block_left_bottom, left_chunks[1]); @@ -203,13 +253,16 @@ pub fn ui(f: &mut Frame, app: &mut App) { Paragraph::new(app.background.as_str()).wrap(Wrap { trim: true }), area, ); - if app.show_resizable_images { - let image = ResizeImage::new(&app.picker.source).resize(Resize::Fit); - f.render_stateful_widget( - image, - block_right_top.inner(right_chunks[0]), - &mut app.image_crop_state, - ); + match app.show_resizable_images { + ShowImages::Fixed => {} + _ => { + let image = ResizeImage::new(&app.image_source).resize(Resize::Fit); + f.render_stateful_widget( + image, + block_right_top.inner(right_chunks[0]), + &mut app.image_crop_state, + ); + } } f.render_widget(block_right_top, right_chunks[0]); @@ -221,10 +274,11 @@ pub fn ui(f: &mut Frame, app: &mut App) { Line::from("H/L: resize"), Line::from(format!( "i: cycle image backends (current: {:?})", - app.picker.current() + app.picker.backend_type() )), + Line::from("o: cycle image"), Line::from("t: toggle rendering dynamic image widgets"), - Line::from(format!("Font size: {:?}", app.picker.source.font_size)), + Line::from(format!("Font size: {:?}", app.picker.font_size())), ]) .wrap(Wrap { trim: true }), area, diff --git a/src/backend/halfblocks/mod.rs b/src/backend/halfblocks/mod.rs index afff5d7..d335108 100644 --- a/src/backend/halfblocks/mod.rs +++ b/src/backend/halfblocks/mod.rs @@ -14,7 +14,7 @@ pub struct FixedHalfblocks { rect: Rect, } -#[derive(Clone)] +#[derive(Clone, Debug)] struct HalfBlock { upper: Color, lower: Color, @@ -84,4 +84,7 @@ impl FixedBackend for FixedHalfblocks { .set_char('▀'); } } + fn data(&self) -> String { + format!("{:?}", self.data) + } } diff --git a/src/backend/mod.rs b/src/backend/mod.rs index 3e00598..fc01db9 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -11,14 +11,15 @@ pub mod halfblocks; pub mod sixel; // A static image backend that just holds image data and character size -pub trait FixedBackend { +pub trait FixedBackend: Send + Sync { fn rect(&self) -> Rect; fn render(&self, area: Rect, buf: &mut Buffer); + fn data(&self) -> String; } // A resizeable imagen backend // Resizes itself from `[ResizableImageBackend]`'s render -pub trait ResizeBackend: DynClone { +pub trait ResizeBackend: Send + Sync + DynClone { fn render(&mut self, source: &ImageSource, resize: &Resize, area: Rect, buf: &mut Buffer); } diff --git a/src/backend/picker.rs b/src/backend/picker.rs deleted file mode 100644 index 4f7d7ac..0000000 --- a/src/backend/picker.rs +++ /dev/null @@ -1,95 +0,0 @@ -use image::DynamicImage; -use ratatui::{backend::Backend, Terminal}; - -#[cfg(feature = "sixel")] -use super::sixel::{resizeable::SixelState, FixedSixel}; -use super::{ - halfblocks::{resizeable::HalfblocksState, FixedHalfblocks}, - round_pixel_size_to_cells, FixedBackend, ImageSource, ResizeBackend, -}; -use crate::Result; - -pub struct Picker { - pub source: ImageSource, - current: Current, - available: Vec, -} - -#[derive(PartialEq, Clone, Debug)] -pub enum Current { - Halfblocks, - #[cfg(feature = "sixel")] - Sixel, -} - -/// Helper for picking a backend -/// -/// Does not hold the static or dynamic states, only the ImageSource. -impl Picker { - /// Pick a default backend (always picks Halfblocks) - pub fn guess(image: DynamicImage, terminal: &mut Terminal) -> Result { - let source = ImageSource::with_terminal(image, terminal)?; - let mut available = vec![]; - available.push(Current::Halfblocks); - #[cfg(feature = "sixel")] - available.push(Current::Sixel); - - // TODO: guess current. Can we guess sixel support reliably? - Ok(Picker { - source, - current: available[0].clone(), - available, - }) - } - - /// Set a specific backend - pub fn set(&mut self, r#type: Current) { - self.current = r#type; - } - - /// Returns a new backend for static images that matches the image native size. - pub fn new_static(&mut self) -> Result> { - let rect = round_pixel_size_to_cells( - self.source.image.width(), - self.source.image.height(), - self.source.font_size, - ); - self.new_static_fit((rect.width, rect.height)) - } - - /// Returns a new backend for static images that fits into the given size. - pub fn new_static_fit(&mut self, (width, height): (u16, u16)) -> Result> { - // let rect = Rect::new(0, 0, width, height); - let source = self.source.resize((width, height)); - match self.current { - Current::Halfblocks => Ok(Box::new(FixedHalfblocks::from_source(source)?)), - #[cfg(feature = "sixel")] - Current::Sixel => Ok(Box::new(FixedSixel::from_source(source)?)), - } - } - - /// Returns a new state for dynamic images. - pub fn new_state(&self) -> Box { - match self.current { - Current::Halfblocks => Box::::default(), - #[cfg(feature = "sixel")] - Current::Sixel => Box::::default(), - } - } - - /// Cycles through available backends - pub fn next(&mut self) { - if let Some(mut i) = self.available.iter().position(|a| a == &self.current) { - if i >= self.available.len() - 1 { - i = 0; - } else { - i += 1; - } - self.current = self.available[i].clone(); - } - } - - pub fn current(&self) -> String { - format!("{:?}", self.current) - } -} diff --git a/src/backend/sixel/mod.rs b/src/backend/sixel/mod.rs index 8c32ed7..ba5a8d8 100644 --- a/src/backend/sixel/mod.rs +++ b/src/backend/sixel/mod.rs @@ -1,12 +1,6 @@ -use image::DynamicImage; use ratatui::{buffer::Buffer, layout::Rect}; -use sixel_rs::{ - encoder::{Encoder, QuickFrameBuilder}, - optflags::EncodePolicy, - status::Error, - sys::PixelFormat, -}; -use std::{cmp::min, fs, io, path::Path}; +use sixel_bytes::{sixel_string, DiffusionMethod, PixelFormat, SixelError}; +use std::{cmp::min, io}; use super::FixedBackend; use crate::{ImageSource, Resize, Result}; @@ -22,41 +16,33 @@ pub struct FixedSixel { impl FixedSixel { pub fn from_source(source: &ImageSource, resize: Resize, area: Rect) -> Result { - let (image, rect) = resize - .resize(source, Rect::default(), area) - .unwrap_or_else(|| (source.image.clone(), source.desired)); - - let data = encode(&image)?; + let (data, rect) = encode(source, &resize, area)?; Ok(Self { data, rect }) } } -// TODO: work around this abomination! There has to be a way to get the bytes without files. -const TMP_FILE: &str = "/tmp/test_out.sixel"; // TODO: change E to sixel_rs::status::Error and map when calling -pub fn encode(img: &DynamicImage) -> Result { - let (w, h) = (img.width(), img.height()); - let bytes = img.to_rgba8().as_raw().to_vec(); - - let encoder = Encoder::new().map_err(sixel_err)?; - encoder.set_output(Path::new(TMP_FILE)).map_err(sixel_err)?; - encoder - .set_encode_policy(EncodePolicy::Fast) - .map_err(sixel_err)?; - let frame = QuickFrameBuilder::new() - .width(w as _) - .height(h as _) - .format(PixelFormat::RGBA8888) - .pixels(bytes); +pub fn encode(source: &ImageSource, resize: &Resize, area: Rect) -> Result<(String, Rect)> { + let (img, rect) = resize + .resize(source, Rect::default(), area) + .unwrap_or_else(|| (source.image.clone(), source.desired)); - encoder.encode_bytes(frame).map_err(sixel_err)?; + let (w, h) = (img.width(), img.height()); + let img_rgba8 = img.to_rgba8(); + let bytes = img_rgba8.as_raw(); - let data = fs::read_to_string(TMP_FILE)?; - fs::remove_file(TMP_FILE)?; - Ok(data) + let data = sixel_string( + bytes, + w as _, + h as _, + PixelFormat::RGBA8888, + DiffusionMethod::Stucki, + ) + .map_err(sixel_err)?; + Ok((data, rect)) } -fn sixel_err(err: Error) -> io::Error { +fn sixel_err(err: SixelError) -> io::Error { io::Error::new(io::ErrorKind::Other, format!("{err:?}")) } @@ -84,4 +70,7 @@ impl FixedBackend for FixedSixel { .set_skip(Some(false)) .set_symbol(&self.data); } + fn data(&self) -> String { + self.data.clone() + } } diff --git a/src/backend/sixel/resizeable.rs b/src/backend/sixel/resizeable.rs index f80ecba..3f3ebe6 100644 --- a/src/backend/sixel/resizeable.rs +++ b/src/backend/sixel/resizeable.rs @@ -11,13 +11,11 @@ pub struct SixelState { impl ResizeBackend for SixelState { fn render(&mut self, source: &ImageSource, resize: &Resize, area: Rect, buf: &mut Buffer) { - if let Some((img, rect)) = resize.resize(source, self.current.rect, area) { - if let Ok(data) = encode(&img) { - let current = FixedSixel { data, rect }; - self.current = current - } - // TODO: save Err() in struct and expose in trait? + if let Ok((data, rect)) = encode(source, resize, area) { + let current = FixedSixel { data, rect }; + self.current = current } + // TODO: save Err() in struct and expose in trait? FixedSixel::render(&self.current, area, buf); } } diff --git a/src/lib.rs b/src/lib.rs index 60922c9..b83943d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,11 +1,15 @@ -//! Ratatu-image: image widgets for [Ratatui] +//! Image widgets for [Ratatui] //! //! [Ratatui]: https://github.com/tui-rs-revival/ratatui //! +//! **THIS CRATE IS EXPERIMENTAL!** +//! //! Render images with supported graphics protocols in the terminal with ratatui. //! While this generally might seem *contra natura* and something fragile, it can be worthwhile in //! some applications. //! +//! # Implementation +//! //! The images are always resized so that they fit their nearest rectangle in columns/rows. //! The reason for this is because the image shall be drawn in the same "render pass" as all //! surrounding text, and cells under the area of the image skip the draw on the ratatui buffer @@ -13,6 +17,8 @@ //! image border. //! For this resizing it is necessary to query the terminal font size in width/height. //! +//! # Widgets +//! //! The [`FixedImage`] widget does not react to area resizes other than not overdrawing. Note that //! some image protocols or their implementations might not behave correctly in this aspect and //! overdraw or flicker outside of the image area. @@ -24,6 +30,8 @@ //! //! Each widget is backed by a "backend" implementation of a given image protocol. //! +//! # Backends +//! //! Currently supported backends/protocols: //! //! ## Halfblocks @@ -43,7 +51,7 @@ //! //! ```rust //! use image::{DynamicImage, ImageBuffer, Rgb}; -//! use ratatui_imagine::{ +//! use ratatu_image::{ //! backend::{ //! FixedBackend, //! halfblocks::FixedHalfblocks, @@ -53,7 +61,7 @@ //! //! let image: DynamicImage = ImageBuffer::, Vec>::new(300, 200).into(); //! -//! let source = ImageSource::new(image, (7, 14), None); +//! let source = ImageSource::new(image, "filename.png".into(), (7, 14), None); //! //! let static_image = Box::new(FixedHalfblocks::from_source( //! &source, @@ -66,7 +74,11 @@ //! let image_widget = FixedImage::new(&static_image); //! ``` -use std::{cmp::min, error::Error}; +use std::{ + cmp::{max, min}, + error::Error, + path::PathBuf, +}; use backend::{FixedBackend, ResizeBackend}; use image::{ @@ -86,6 +98,9 @@ pub mod picker; type Result = std::result::Result>; +/// The terminal's font size in `(width, height)` +pub type FontSize = (u16, u16); + #[derive(Clone)] /// Image source for backends /// @@ -95,18 +110,20 @@ type Result = std::result::Result>; /// # Examples /// ```text /// use image::{DynamicImage, ImageBuffer, Rgb}; -/// use ratatui_imagine::ImageSource; +/// use ratatu_image::ImageSource; /// /// let image: ImageBuffer::from_pixel(300, 200, Rgb::([255, 0, 0])).into(); -/// let source = ImageSource::new(image, (7, 14)); +/// let source = ImageSource::new(image, "filename.png", (7, 14)); /// assert_eq!((43, 14), (source.rect.width, source.rect.height)); /// ``` /// pub struct ImageSource { /// The original image without resizing pub image: DynamicImage, + /// The original image path + pub path: PathBuf, /// The font size of the terminal - pub font_size: (u16, u16), + pub font_size: FontSize, /// The area that the [`ImageSource::image`] covers, but not necessarily fills pub desired: Rect, /// The background color to fill when resizing @@ -117,12 +134,14 @@ impl ImageSource { /// Create a new image source pub fn new( image: DynamicImage, - font_size: (u16, u16), + path: PathBuf, + font_size: FontSize, background_color: Option>, ) -> ImageSource { let desired = round_pixel_size_to_cells(image.width(), image.height(), font_size); ImageSource { image, + path, font_size, desired, background_color, @@ -131,11 +150,12 @@ impl ImageSource { /// Create a new image source from a [Terminal](ratatui::Terminal) pub fn with_terminal( image: DynamicImage, + path: PathBuf, terminal: &mut Terminal, background_color: Option>, ) -> Result { let font_size = terminal.backend_mut().font_size()?; - Ok(ImageSource::new(image, font_size, background_color)) + Ok(ImageSource::new(image, path, font_size, background_color)) } } @@ -143,7 +163,7 @@ impl ImageSource { fn round_pixel_size_to_cells( img_width: u32, img_height: u32, - (char_width, char_height): (u16, u16), + (char_width, char_height): FontSize, ) -> Rect { let width = (img_width as f32 / char_width as f32).ceil() as u16; let height = (img_height as f32 / char_height as f32).ceil() as u16; @@ -173,14 +193,14 @@ impl<'a> Widget for FixedImage<'a> { /// Resizeable image widget pub struct ResizeImage<'a> { - source: &'a ImageSource, + image: &'a ImageSource, resize: Resize, } impl<'a> ResizeImage<'a> { - pub fn new(source: &'a ImageSource) -> ResizeImage<'a> { + pub fn new(image: &'a ImageSource) -> ResizeImage<'a> { ResizeImage { - source, + image, resize: Resize::Fit, } } @@ -197,7 +217,7 @@ impl<'a> StatefulWidget for ResizeImage<'a> { return; } - state.render(self.source, &self.resize, area, buf) + state.render(self.image, &self.resize, area, buf) } } @@ -254,70 +274,79 @@ impl Resize { } /// Check if [`ImageSource`]'s "desired" fits into `area` and is different than `current`. - fn needs_resize(&self, source: &ImageSource, current: Rect, area: Rect) -> Option { - match self { - Self::Fit => { - let desired = source.desired; - if desired.width <= area.width - && desired.height <= area.height - && desired == current - { - let width = (desired.width * source.font_size.0) as u32; - let height = (desired.height * source.font_size.1) as u32; - if source.image.width() == width || source.image.height() == height { - return None; - } - return Some(desired); - } - let mut resized = desired; - if desired.width > area.width { - resized.width = area.width; - resized.height = ((desired.height as f32) - * (area.width as f32 / desired.width as f32)) - .round() as u16; - } else if desired.height > area.height { - resized.height = area.height; - resized.width = ((desired.width as f32) - * (area.height as f32 / desired.height as f32)) - .round() as u16; - } - Some(resized) + fn needs_resize(&self, image: &ImageSource, current: Rect, area: Rect) -> Option { + let desired = image.desired; + // Check if resize is needed at all. + if desired.width <= area.width && desired.height <= area.height && desired == current { + let width = (desired.width * image.font_size.0) as u32; + let height = (desired.height * image.font_size.1) as u32; + if image.image.width() == width || image.image.height() == height { + return None; } - Self::Crop => { - let desired = source.desired; - if desired.width <= area.width - && desired.height <= area.height - && desired == current - { - let width = (desired.width * source.font_size.0) as u32; - let height = (desired.height * source.font_size.1) as u32; - if source.image.width() == width || source.image.height() == height { - return None; - } - return Some(desired); - } + // XXX: why is needed? + return Some(desired); + } - Some(Rect::new( - 0, - 0, - min(desired.width, area.width), - min(desired.height, area.height), - )) + match self { + Self::Fit => { + let (width, height) = resize( + desired.width, + desired.height, + min(area.width, desired.width), + min(area.height, desired.height), + ); + Some(Rect::new(0, 0, width, height)) } + Self::Crop => Some(Rect::new( + 0, + 0, + min(desired.width, area.width), + min(desired.height, area.height), + )), } } } +/// Ripped from https://github.com/image-rs/image/blob/master/src/math/utils.rs#L12 +/// Calculates the width and height an image should be resized to. +/// This preserves aspect ratio, and based on the `fill` parameter +/// will either fill the dimensions to fit inside the smaller constraint +/// (will overflow the specified bounds on one axis to preserve +/// aspect ratio), or will shrink so that both dimensions are +/// completely contained within the given `width` and `height`, +/// with empty space on one axis. +fn resize(width: u16, height: u16, nwidth: u16, nheight: u16) -> (u16, u16) { + let wratio = nwidth as f64 / width as f64; + let hratio = nheight as f64 / height as f64; + + let ratio = f64::min(wratio, hratio); + + let nw = max((width as f64 * ratio).round() as u64, 1); + let nh = max((height as f64 * ratio).round() as u64, 1); + + if nw > u64::from(u16::MAX) { + let ratio = u16::MAX as f64 / width as f64; + (u16::MAX, max((height as f64 * ratio).round() as u16, 1)) + } else if nh > u64::from(u16::MAX) { + let ratio = u16::MAX as f64 / height as f64; + (max((width as f64 * ratio).round() as u16, 1), u16::MAX) + } else { + (nw as u16, nh as u16) + } +} + #[cfg(test)] mod tests { use image::{ImageBuffer, Rgb}; use super::*; - fn s(w: u16, h: u16, font_size: (u16, u16)) -> ImageSource { + const FONT_SIZE: FontSize = (10, 10); + + fn s(w: u16, h: u16) -> ImageSource { let image: DynamicImage = ImageBuffer::from_pixel(w as _, h as _, Rgb::([255, 0, 0])).into(); - ImageSource::new(image, font_size, None) + ImageSource::new(image, "test".into(), FONT_SIZE, None) } fn r(w: u16, h: u16) -> Rect { @@ -328,33 +357,45 @@ mod tests { fn needs_resize_fit() { let resize = Resize::Fit; - let to = resize.needs_resize(&s(100, 100, (10, 10)), r(10, 10), r(10, 10)); + let to = resize.needs_resize(&s(100, 100), r(10, 10), r(10, 10)); assert_eq!(None, to); - let to = resize.needs_resize(&s(80, 100, (10, 10)), r(8, 10), r(10, 10)); + let to = resize.needs_resize(&s(80, 100), r(8, 10), r(10, 10)); assert_eq!(None, to); - let to = resize.needs_resize(&s(100, 100, (10, 10)), r(10, 10), r(8, 10)); + let to = resize.needs_resize(&s(100, 100), r(99, 99), r(8, 10)); assert_eq!(Some(r(8, 8)), to); - let to = resize.needs_resize(&s(100, 100, (10, 10)), r(10, 10), r(10, 8)); + let to = resize.needs_resize(&s(100, 100), r(99, 99), r(10, 8)); assert_eq!(Some(r(8, 8)), to); + + let to = resize.needs_resize(&s(100, 50), r(99, 99), r(4, 4)); + assert_eq!(Some(r(4, 2)), to); + + let to = resize.needs_resize(&s(50, 100), r(99, 99), r(4, 4)); + assert_eq!(Some(r(2, 4)), to); + + let to = resize.needs_resize(&s(100, 100), r(8, 8), r(11, 11)); + assert_eq!(Some(r(10, 10)), to); + + let to = resize.needs_resize(&s(100, 100), r(10, 10), r(11, 11)); + assert_eq!(None, to); } #[test] fn needs_resize_crop() { let resize = Resize::Crop; - let to = resize.needs_resize(&s(100, 100, (10, 10)), r(10, 10), r(10, 10)); + let to = resize.needs_resize(&s(100, 100), r(10, 10), r(10, 10)); assert_eq!(None, to); - let to = resize.needs_resize(&s(80, 100, (10, 10)), r(8, 10), r(10, 10)); + let to = resize.needs_resize(&s(80, 100), r(8, 10), r(10, 10)); assert_eq!(None, to); - let to = resize.needs_resize(&s(100, 100, (10, 10)), r(10, 10), r(8, 10)); + let to = resize.needs_resize(&s(100, 100), r(10, 10), r(8, 10)); assert_eq!(Some(r(8, 10)), to); - let to = resize.needs_resize(&s(100, 100, (10, 10)), r(10, 10), r(10, 8)); + let to = resize.needs_resize(&s(100, 100), r(10, 10), r(10, 8)); assert_eq!(Some(r(10, 8)), to); } } diff --git a/src/picker.rs b/src/picker.rs index a314d23..c37d95d 100644 --- a/src/picker.rs +++ b/src/picker.rs @@ -1,6 +1,10 @@ //! Helper module to build a backend, and swap backends at runtime +use std::path::PathBuf; + use image::{DynamicImage, Rgb}; -use ratatui::{backend::Backend, layout::Rect, Terminal}; +use ratatui::layout::Rect; +#[cfg(feature = "serde")] +use serde::Deserialize; #[cfg(feature = "sixel")] use crate::backend::sixel::{resizeable::SixelState, FixedSixel}; @@ -10,38 +14,38 @@ use crate::{ halfblocks::{resizeable::HalfblocksState, FixedHalfblocks}, FixedBackend, ResizeBackend, }, - ImageSource, Resize, Result, + FontSize, ImageSource, Resize, Result, }; +#[derive(Clone, Copy)] pub struct Picker { - pub source: ImageSource, - current: Current, - available: Vec, + font_size: FontSize, + background_color: Option>, + backend_type: BackendType, + fixed_size: Option, } -#[derive(PartialEq, Clone, Debug)] -pub enum Current { +#[cfg_attr( + feature = "serde", + derive(Deserialize), + serde(rename_all = "lowercase") +)] +#[derive(PartialEq, Clone, Debug, Copy)] +pub enum BackendType { Halfblocks, #[cfg(feature = "sixel")] Sixel, } -/// Backend builder -/// -/// Does not hold the static or dynamic states, only the ImageSource. +/// Helper for building widgets impl Picker { - /// Pick backends for the widgets - /// - /// TODO: does not really guess and always picks `Sixel`. - /// - /// Can cycle through backends with `next()`, or use `set(Current)` e.g. from a user - /// configuration. + /// Create a picker from a given terminal [FontSize]. /// /// # Example /// ```rust /// use std::io; - /// use ratatui_imagine::{ - /// picker::Picker, + /// use ratatu_image::{ + /// picker::{BackendType, Picker}, /// Resize, /// }; /// use ratatui::{ @@ -50,80 +54,125 @@ impl Picker { /// Terminal, /// }; /// - /// let mut stdout = io::stdout(); - /// let backend = TestBackend::new(80, 35); - /// let mut terminal = Terminal::new(backend).unwrap(); /// let dyn_img = image::io::Reader::open("./assets/Ada.png").unwrap().decode().unwrap(); - /// let picker = Picker::guess(dyn_img, &mut terminal, None).unwrap(); + /// let picker = Picker::new( + /// (7, 14), + /// BackendType::Halfblocks, + /// None, + /// Some(Rect::new(0, 0, 15, 5)), + /// ).unwrap(); + /// /// // For FixedImage: - /// let image_static = picker.new_static_fit(Resize::Fit, Rect::new(0, 0, 15, 5)).unwrap(); + /// let image_static = picker.new_static_fit( + /// dyn_img, + /// "./assets/Ada.png".into(), + /// Resize::Fit, + /// ).unwrap(); /// // For ResizeImage: /// let image_fit_state = picker.new_state(); /// ``` - #[allow(clippy::vec_init_then_push)] - pub fn guess( - image: DynamicImage, - terminal: &mut Terminal, + pub fn new( + font_size: FontSize, + backend_type: BackendType, background_color: Option>, + fixed_size: Option, ) -> Result { - let source = ImageSource::with_terminal(image, terminal, background_color)?; - let mut available = vec![]; - #[cfg(feature = "sixel")] - available.push(Current::Sixel); - available.push(Current::Halfblocks); - - // TODO: guess current. Can we guess sixel support reliably? Ok(Picker { - source, - current: available[0].clone(), - available, + font_size, + background_color, + backend_type, + fixed_size, }) } + /// Query the terminal window size with I/O for font size. + #[cfg(feature = "rustix")] + pub fn from_ioctl( + backend_type: BackendType, + background_color: Option>, + fixed_size: Option, + ) -> Result { + let stdout = rustix::stdio::stdout(); + let winsize = rustix::termios::tcgetwinsize(stdout)?; + Picker::new( + font_size( + winsize.ws_xpixel, + winsize.ws_ypixel, + winsize.ws_col, + winsize.ws_row, + )?, + backend_type, + background_color, + fixed_size, + ) + } + /// Set a specific backend - pub fn set(&mut self, r#type: Current) { - self.current = r#type; + pub fn set(&mut self, r#type: BackendType) { + self.backend_type = r#type; } /// Returns a new *static* backend for [`crate::FixedImage`] widgets that fits into the given size. - pub fn new_static_fit(&self, resize: Resize, area: Rect) -> Result> { - match self.current { - Current::Halfblocks => Ok(Box::new(FixedHalfblocks::from_source( - &self.source, - resize, - area, - )?)), - #[cfg(feature = "sixel")] - Current::Sixel => Ok(Box::new(FixedSixel::from_source( - &self.source, - resize, - area, - )?)), + pub fn new_static_fit( + &self, + image: DynamicImage, + path: PathBuf, + resize: Resize, + ) -> Result> { + match self.fixed_size { + Some(fixed_size) => { + let source = ImageSource::new(image, path, self.font_size, self.background_color); + match self.backend_type { + BackendType::Halfblocks => Ok(Box::new(FixedHalfblocks::from_source( + &source, resize, fixed_size, + )?)), + #[cfg(feature = "sixel")] + BackendType::Sixel => Ok(Box::new(FixedSixel::from_source( + &source, resize, fixed_size, + )?)), + } + } + None => Err(String::from("Picker is missing fixed_size").into()), } } /// Returns a new *state* backend for [`crate::ResizeImage`]. pub fn new_state(&self) -> Box { - match self.current { - Current::Halfblocks => Box::::default(), + match self.backend_type { + BackendType::Halfblocks => Box::::default(), #[cfg(feature = "sixel")] - Current::Sixel => Box::::default(), + BackendType::Sixel => Box::::default(), } } /// Cycles through available backends - pub fn next(&mut self) { - if let Some(mut i) = self.available.iter().position(|a| a == &self.current) { - if i >= self.available.len() - 1 { - i = 0; - } else { - i += 1; - } - self.current = self.available[i].clone(); - } + pub fn set_backend(&mut self, backend_type: BackendType) { + self.backend_type = backend_type; + } + + pub fn backend_type(&self) -> &BackendType { + &self.backend_type + } + + pub fn font_size(&self) -> FontSize { + self.font_size + } +} + +fn font_size(x: u16, y: u16, cols: u16, rows: u16) -> Result { + if x == 0 || y == 0 || cols == 0 || rows == 0 { + return Err(String::from("font_size zero value").into()); } + Ok((x / cols, y / rows)) +} + +#[cfg(test)] +mod tests { + use crate::picker::font_size; - pub fn current(&self) -> String { - format!("{:?}", self.current) + #[test] + fn test_font_size() { + assert_eq!(true, font_size(0, 0, 10, 10).is_err()); + assert_eq!(true, font_size(100, 100, 0, 0).is_err()); } }