Skip to main content

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}