ratatu_image/lib.rs
1//! Image widgets for [Ratatui]
2//!
3//! **⚠️ THIS CRATE IS EXPERIMENTAL**
4//!
5//! Render images with graphics protocols in the terminal with [Ratatui].
6//!
7//! ```rust
8//! # use ratatui::{backend::{Backend, TestBackend}, Terminal, terminal::Frame, layout::Rect};
9//! # use ratatu_image::{
10//! # picker::{Picker, BackendType},
11//! # Resize, FixedImage, backend::FixedBackend,
12//! # };
13//! struct App {
14//! image: Box<dyn FixedBackend>,
15//! }
16//!
17//! fn main() -> Result<(), Box<dyn std::error::Error>> {
18//! let font_size = (7, 16); // Or use Picker::from_termios, or let user provide it.
19//! let mut picker = Picker::new(
20//! font_size,
21//! # #[cfg(feature = "sixel")]
22//! BackendType::Sixel,
23//! # #[cfg(not(feature = "sixel"))]
24//! # BackendType::Halfblocks,
25//! None,
26//! )?;
27//! let dyn_img = image::io::Reader::open("./assets/Ada.png")?.decode()?;
28//! let image = picker.new_static_fit(dyn_img, Rect::new(0, 0, 30, 20), Resize::Fit)?;
29//! let mut app = App { image };
30//!
31//! let backend = TestBackend::new(80, 30);
32//! let mut terminal = Terminal::new(backend)?;
33//!
34//! // loop:
35//! terminal.draw(|f| ui(f, &mut app))?;
36//!
37//! Ok(())
38//! }
39//!
40//! fn ui<B: Backend>(f: &mut Frame<B>, app: &mut App) {
41//! let image = FixedImage::new(app.image.as_ref());
42//! f.render_widget(image, f.size());
43//! }
44//! ```
45//!
46//! # TUIs
47//! TUI application revolve around columns and rows of text naturally without the need of any
48//! notions of pixel sizes. [Ratatui] is based on "immediate rendering with intermediate buffers".
49//!
50//! At each frame, widgets are constructed and rendered into some character buffer, and any changes
51//! from respect to the last frame are then diffed and written to the terminal screen.
52//!
53//! # Terminal graphic protocols
54//! Some protocols allow to output image data to terminals that support it.
55//!
56//! The [Sixel] protocol mechanism is, in a nutshell, just printing an escape sequence.
57//! The image will be "dumped" at the cursor position, and the implementation may add enough
58//! carriage returns to scroll the output.
59//!
60//! # Problem
61//! Simply "dumping" an image into a [Ratatui] buffer is not enough. At best, the buffer diff might
62//! not overwrite any characters that are covered by the image in some instances, but the diff
63//! might change at any time due to screen/area resizing or simply other widget's contents
64//! changing. Then the graphics would inmediately get overwritten by the underlying character data.
65//!
66//! # Solution
67//! First it is necessary to suppress the covered character cells' rendering, which is addressed in
68//! a [Ratatui PR for cell skipping].
69//!
70//! Second it is then necessary to get the image's size in columns and rows, which is done by
71//! querying the terminal for it's pixel size and dividing by columns/rows to get the font size in
72//! pixels. Currently this is implemented with `rustix::termios`, but this is subject to change for
73//! a [Ratatui PR for getting window size].
74//!
75//! # Implementation
76//!
77//! The images are always resized so that they fit their nearest rectangle in columns/rows.
78//! This is so that the image shall be drawn in the same "render pass" as all surrounding text, and
79//! cells under the area of the image skip the draw on the ratatui buffer level, so there is no way
80//! to "clear" previous drawn text. This would leave artifacts around the image's right and bottom
81//! borders.
82//!
83//! # Example
84//!
85//! See the [crate::picker::Picker] helper and [`examples/demo`](./examples/demo/main.rs).
86//!
87//! [Ratatui]: https://github.com/ratatui-org/ratatui
88//! [Sixel]: https://en.wikipedia.org/wiki/Sixel
89//! [Ratatui PR for cell skipping]: https://github.com/ratatui-org/ratatui/pull/215
90//! [Ratatui PR for getting window size]: https://github.com/ratatui-org/ratatui/pull/276
91use std::{
92 cmp::{max, min},
93 collections::hash_map::DefaultHasher,
94 error::Error,
95 hash::{Hash, Hasher},
96};
97
98use backend::{FixedBackend, ResizeBackend};
99use image::{
100 imageops::{self, FilterType},
101 DynamicImage, ImageBuffer, Rgb,
102};
103use ratatui::{
104 buffer::Buffer,
105 layout::Rect,
106 widgets::{StatefulWidget, Widget},
107};
108
109pub mod backend;
110pub mod picker;
111
112type Result<T> = std::result::Result<T, Box<dyn Error>>;
113
114/// The terminal's font size in `(width, height)`
115pub type FontSize = (u16, u16);
116
117#[derive(Clone)]
118/// Image source for [crate::backend::ResizeBackend]s
119///
120/// A `[ResizeBackend]` needs to resize the ImageSource to its state when the available area
121/// changes. A `[FixedBackend]` only needs it once.
122///
123/// # Examples
124/// ```text
125/// use image::{DynamicImage, ImageBuffer, Rgb};
126/// use ratatu_image::ImageSource;
127///
128/// let image: ImageBuffer::from_pixel(300, 200, Rgb::<u8>([255, 0, 0])).into();
129/// let source = ImageSource::new(image, "filename.png", (7, 14));
130/// assert_eq!((43, 14), (source.rect.width, source.rect.height));
131/// ```
132///
133pub struct ImageSource {
134 /// The original image without resizing
135 pub image: DynamicImage,
136 /// The font size of the terminal
137 pub font_size: FontSize,
138 /// The area that the [`ImageSource::image`] covers, but not necessarily fills
139 pub desired: Rect,
140 pub hash: u64,
141}
142
143impl ImageSource {
144 /// Create a new image source
145 pub fn new(image: DynamicImage, font_size: FontSize) -> ImageSource {
146 let desired =
147 ImageSource::round_pixel_size_to_cells(image.width(), image.height(), font_size);
148
149 let mut state = DefaultHasher::new();
150 image.as_bytes().hash(&mut state);
151 let hash = state.finish();
152
153 ImageSource {
154 image,
155 font_size,
156 desired,
157 hash,
158 }
159 }
160 /// Round an image pixel size to the nearest matching cell size, given a font size.
161 fn round_pixel_size_to_cells(
162 img_width: u32,
163 img_height: u32,
164 (char_width, char_height): FontSize,
165 ) -> Rect {
166 let width = (img_width as f32 / char_width as f32).ceil() as u16;
167 let height = (img_height as f32 / char_height as f32).ceil() as u16;
168 Rect::new(0, 0, width, height)
169 }
170}
171
172/// Fixed size image widget that uses [FixedBackend].
173///
174/// The widget does *not* react to area resizes, and is not even guaranteed to **not** overdraw.
175/// Its advantage is that the [FixedBackend] it uses needs only one initial resize.
176///
177/// ```rust
178/// # use ratatui::{backend::Backend, terminal::Frame};
179/// # use ratatu_image::{Resize, FixedImage, backend::FixedBackend};
180/// struct App {
181/// image_static: Box<dyn FixedBackend>,
182/// }
183/// fn ui<B: Backend>(f: &mut Frame<B>, app: &mut App) {
184/// let image = FixedImage::new(app.image_static.as_ref());
185/// f.render_widget(image, f.size());
186/// }
187/// ```
188pub struct FixedImage<'a> {
189 image: &'a dyn FixedBackend,
190}
191
192impl<'a> FixedImage<'a> {
193 pub fn new(image: &'a dyn FixedBackend) -> FixedImage<'a> {
194 FixedImage { image }
195 }
196}
197
198impl<'a> Widget for FixedImage<'a> {
199 fn render(self, area: Rect, buf: &mut Buffer) {
200 if area.width == 0 || area.height == 0 {
201 return;
202 }
203
204 self.image.render(area, buf);
205 }
206}
207
208/// Resizeable image widget that uses an [ImageSource] and [ResizeBackend] state.
209///
210/// This stateful widget reacts to area resizes and resizes its image data accordingly.
211///
212/// ```rust
213/// # use ratatui::{backend::Backend, terminal::Frame};
214/// # use ratatu_image::{ImageSource, Resize, ResizeImage, backend::ResizeBackend};
215/// struct App {
216/// image_source: ImageSource,
217/// image_state: Box<dyn ResizeBackend>,
218/// }
219/// fn ui<B: Backend>(f: &mut Frame<B>, app: &mut App) {
220/// let image = ResizeImage::new(&app.image_source, None).resize(Resize::Crop);
221/// f.render_stateful_widget(
222/// image,
223/// f.size(),
224/// &mut app.image_state,
225/// );
226/// }
227/// ```
228pub struct ResizeImage<'a> {
229 image: &'a ImageSource,
230 resize: Resize,
231 background_color: Option<Rgb<u8>>,
232}
233
234impl<'a> ResizeImage<'a> {
235 pub fn new(image: &'a ImageSource, background_color: Option<Rgb<u8>>) -> ResizeImage<'a> {
236 ResizeImage {
237 image,
238 resize: Resize::Fit,
239 background_color,
240 }
241 }
242 pub fn resize(mut self, resize: Resize) -> ResizeImage<'a> {
243 self.resize = resize;
244 self
245 }
246}
247
248impl<'a> StatefulWidget for ResizeImage<'a> {
249 type State = Box<dyn ResizeBackend>;
250 fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
251 state.render(self.image, &self.resize, self.background_color, area, buf)
252 }
253}
254
255#[derive(Debug)]
256/// Resize method
257pub enum Resize {
258 /// Fit to area.
259 ///
260 /// If the width or height is smaller than the area, the image will be resized maintaining
261 /// proportions.
262 Fit,
263 /// Crop to area.
264 ///
265 /// If the width or height is smaller than the area, the image will be cropped.
266 /// The behaviour is the same as using [`FixedImage`] widget with the overhead of resizing,
267 /// but some terminals might misbehave when overdrawing characters over graphics.
268 /// For example, the sixel branch of Alacritty never draws text over a cell that is currently
269 /// being rendered by some sixel sequence, not necessarily originating from the same cell.
270 Crop,
271}
272
273impl Resize {
274 /// Resize if [`ImageSource`]'s "desired" doesn't fit into `area`, or is different than `current`
275 fn resize(
276 &self,
277 source: &ImageSource,
278 current: Rect,
279 area: Rect,
280 background_color: Option<Rgb<u8>>,
281 force: bool,
282 ) -> Option<(DynamicImage, Rect)> {
283 self.needs_resize(source, current, area, force).map(|rect| {
284 let width = (rect.width * source.font_size.0) as u32;
285 let height = (rect.height * source.font_size.1) as u32;
286 // Resize/Crop/etc. but not necessarily fitting cell size
287 let mut image = self.resize_image(source, width, height);
288 // Pad to cell size
289 if image.width() != width || image.height() != height {
290 static DEFAULT_BACKGROUND: Rgb<u8> = Rgb([0, 0, 0]);
291 let color = background_color.unwrap_or(DEFAULT_BACKGROUND);
292 let mut bg: DynamicImage = ImageBuffer::from_pixel(width, height, color).into();
293 imageops::overlay(&mut bg, &image, 0, 0);
294 image = bg;
295 }
296 (image, rect)
297 })
298 }
299
300 /// Check if [`ImageSource`]'s "desired" fits into `area` and is different than `current`.
301 fn needs_resize(
302 &self,
303 image: &ImageSource,
304 current: Rect,
305 area: Rect,
306 force: bool,
307 ) -> Option<Rect> {
308 let desired = image.desired;
309 // Check if resize is needed at all.
310 if desired.width <= area.width && desired.height <= area.height && desired == current {
311 let width = (desired.width * image.font_size.0) as u32;
312 let height = (desired.height * image.font_size.1) as u32;
313 if !force && (image.image.width() == width || image.image.height() == height) {
314 return None;
315 }
316 }
317
318 let rect = self.needs_resize_rect(desired, area);
319 if force || rect != current {
320 return Some(rect);
321 }
322 None
323 }
324
325 fn resize_image(&self, source: &ImageSource, width: u32, height: u32) -> DynamicImage {
326 match self {
327 Self::Fit => source.image.resize(width, height, FilterType::Nearest),
328 Self::Crop => source.image.crop_imm(0, 0, width, height),
329 }
330 }
331
332 fn needs_resize_rect(&self, desired: Rect, area: Rect) -> Rect {
333 match self {
334 Self::Fit => {
335 let (width, height) = resize_pixels(
336 desired.width,
337 desired.height,
338 min(area.width, desired.width),
339 min(area.height, desired.height),
340 );
341 Rect::new(0, 0, width, height)
342 }
343 Self::Crop => Rect::new(
344 0,
345 0,
346 min(desired.width, area.width),
347 min(desired.height, area.height),
348 ),
349 }
350 }
351}
352
353/// Ripped from https://github.com/image-rs/image/blob/master/src/math/utils.rs#L12
354/// Calculates the width and height an image should be resized to.
355/// This preserves aspect ratio, and based on the `fill` parameter
356/// will either fill the dimensions to fit inside the smaller constraint
357/// (will overflow the specified bounds on one axis to preserve
358/// aspect ratio), or will shrink so that both dimensions are
359/// completely contained within the given `width` and `height`,
360/// with empty space on one axis.
361fn resize_pixels(width: u16, height: u16, nwidth: u16, nheight: u16) -> (u16, u16) {
362 let wratio = nwidth as f64 / width as f64;
363 let hratio = nheight as f64 / height as f64;
364
365 let ratio = f64::min(wratio, hratio);
366
367 let nw = max((width as f64 * ratio).round() as u64, 1);
368 let nh = max((height as f64 * ratio).round() as u64, 1);
369
370 if nw > u64::from(u16::MAX) {
371 let ratio = u16::MAX as f64 / width as f64;
372 (u16::MAX, max((height as f64 * ratio).round() as u16, 1))
373 } else if nh > u64::from(u16::MAX) {
374 let ratio = u16::MAX as f64 / height as f64;
375 (max((width as f64 * ratio).round() as u16, 1), u16::MAX)
376 } else {
377 (nw as u16, nh as u16)
378 }
379}
380
381#[cfg(test)]
382mod tests {
383 use image::{ImageBuffer, Rgb};
384
385 use super::*;
386
387 const FONT_SIZE: FontSize = (10, 10);
388
389 fn s(w: u16, h: u16) -> ImageSource {
390 let image: DynamicImage =
391 ImageBuffer::from_pixel(w as _, h as _, Rgb::<u8>([255, 0, 0])).into();
392 ImageSource::new(image, FONT_SIZE)
393 }
394
395 fn r(w: u16, h: u16) -> Rect {
396 Rect::new(0, 0, w, h)
397 }
398
399 #[test]
400 fn needs_resize_fit() {
401 let resize = Resize::Fit;
402
403 let to = resize.needs_resize(&s(100, 100), r(10, 10), r(10, 10), false);
404 assert_eq!(None, to);
405
406 let to = resize.needs_resize(&s(101, 101), r(10, 10), r(10, 10), false);
407 assert_eq!(None, to);
408
409 let to = resize.needs_resize(&s(80, 100), r(8, 10), r(10, 10), false);
410 assert_eq!(None, to);
411
412 let to = resize.needs_resize(&s(100, 100), r(99, 99), r(8, 10), false);
413 assert_eq!(Some(r(8, 8)), to);
414
415 let to = resize.needs_resize(&s(100, 100), r(99, 99), r(10, 8), false);
416 assert_eq!(Some(r(8, 8)), to);
417
418 let to = resize.needs_resize(&s(100, 50), r(99, 99), r(4, 4), false);
419 assert_eq!(Some(r(4, 2)), to);
420
421 let to = resize.needs_resize(&s(50, 100), r(99, 99), r(4, 4), false);
422 assert_eq!(Some(r(2, 4)), to);
423
424 let to = resize.needs_resize(&s(100, 100), r(8, 8), r(11, 11), false);
425 assert_eq!(Some(r(10, 10)), to);
426
427 let to = resize.needs_resize(&s(100, 100), r(10, 10), r(11, 11), false);
428 assert_eq!(None, to);
429 }
430
431 #[test]
432 fn needs_resize_crop() {
433 let resize = Resize::Crop;
434
435 let to = resize.needs_resize(&s(100, 100), r(10, 10), r(10, 10), false);
436 assert_eq!(None, to);
437
438 let to = resize.needs_resize(&s(80, 100), r(8, 10), r(10, 10), false);
439 assert_eq!(None, to);
440
441 let to = resize.needs_resize(&s(100, 100), r(10, 10), r(8, 10), false);
442 assert_eq!(Some(r(8, 10)), to);
443
444 let to = resize.needs_resize(&s(100, 100), r(10, 10), r(10, 8), false);
445 assert_eq!(Some(r(10, 8)), to);
446 }
447}