Skip to main content

ratatui_image/
lib.rs

1//! # Image widgets with multiple graphics protocol backends for [ratatui]
2//!
3//! **Unify terminal image rendering across Sixels, Kitty, and iTerm2 protocols.**
4//!
5//! [ratatui] is an immediate-mode TUI library.
6//! ratatui-image tackles 3 general problems when rendering images with an immediate-mode TUI:
7//!
8//! **Query the terminal for available graphics protocols**
9//!
10//! Some terminals may implement one or more graphics protocols, such as Sixels, or the iTerm2 or
11//! Kitty graphics protocols. Guess by env vars. If that fails, query the terminal with some
12//! control sequences.
13//! Fallback to "halfblocks" which uses some unicode half-block characters with fore- and
14//! background colors.
15//!
16//! **Query the terminal for the font-size in pixels.**
17//!
18//! If there is an actual graphics protocol available, it is necessary to know the font-size to
19//! be able to map the image pixels to character cell area.
20//! Query the terminal with some control sequences for either the font-size directly, or the
21//! window-size in pixels and derive the font-size together with row/column count.
22//!
23//! **Render the image by the means of the guessed protocol.**
24//!
25//! Some protocols, like Sixels, are essentially "immediate-mode", but we still need to avoid the
26//! TUI from overwriting the image area, even with blank characters.
27//! Other protocols, like Kitty, are essentially stateful, but at least provide a way to re-render
28//! an image that has been loaded, at a different or same position.
29//! Since we have the font-size in pixels, we can precisely map the characters/cells/rows-columns
30//! that will be covered by the image and skip drawing over the image.
31//!
32//! # Quick start
33//! ```rust
34//! use ratatui::{backend::TestBackend, Terminal, Frame};
35//! use ratatui_image::{picker::Picker, StatefulImage, protocol::StatefulProtocol};
36//!
37//! struct App {
38//!     // We need to hold the render state.
39//!     image: StatefulProtocol,
40//! }
41//!
42//! fn main() -> Result<(), Box<dyn std::error::Error>> {
43//!     let backend = TestBackend::new(80, 30);
44//!     let mut terminal = Terminal::new(backend)?;
45//!
46//!     // Should use Picker::from_query_stdio() to get the font size and protocol,
47//!     // but we can't put that here because that would break doctests!
48//!     let mut picker = Picker::halfblocks();
49//!
50//!     // Load an image with the image crate.
51//!     let dyn_img = image::ImageReader::open("./assets/Ada.png")?.decode()?;
52//!
53//!     // Create the Protocol which will be used by the widget.
54//!     let image = picker.new_resize_protocol(dyn_img);
55//!
56//!     let mut app = App { image };
57//!
58//!     // This would be your typical `loop {` in a real app:
59//!     terminal.draw(|f| ui(f, &mut app))?;
60//!     // It is recommended to handle the encoding result
61//!     app.image.last_encoding_result().unwrap()?;
62//!     Ok(())
63//! }
64//!
65//! fn ui(f: &mut Frame<'_>, app: &mut App) {
66//!     // The image widget.
67//!     let image = StatefulImage::default();
68//!     // Render with the protocol state.
69//!     f.render_stateful_widget(image, f.area(), &mut app.image);
70//! }
71//! ```
72//!
73//! The [picker::Picker] helper is there to do all this font-size and graphics-protocol guessing,
74//! and also to map character-cell-size to pixel size so that we can e.g. "fit" an image inside
75//! a desired columns+rows bound, and so on.
76//!
77//! # Widget choice
78//! * The [Image] widget has a fixed size in rows/columns. If the image pixel size exceeds the
79//!   pixel area of the rows/columns, the image is scaled down proportionally to "fit" once.
80//!   If the actual rendering area is smaller than the initial rows/columns, it is simply not
81//!   rendered at all.
82//!   The big upside is that this widget is _stateless_ (in terms of ratatui, i.e. immediate-mode),
83//!   and thus can never block the rendering thread/task. A lot of ratatui apps only use stateless
84//!   widgets, so this factor is also important when chosing.
85//! * The [StatefulImage] widget adapts to its render area at render-time. It can be set to fit,
86//!   crop, or scale to the available render area.
87//!   This means the widget must be stateful, i.e. use `render_stateful_widget` which takes a
88//!   mutable state parameter.
89//!   The resizing and encoding is blocking, and since it happens at render-time it is a good idea
90//!   to offload that to another thread or async task, if the UI must be responsive (see
91//!   `examples/thread.rs` and `examples/tokio.rs`).
92//!
93//! # Examples
94//!
95//! * `examples/demo.rs` is a fully fledged demo.
96//! * `examples/thread.rs` shows how to offload resize and encoding to another thread, to avoid
97//!   blocking the UI thread.
98//! * `examples/tokio.rs` same as `thread.rs` but with tokio.
99//!
100//! The lib also includes a binary that renders an image file, but it is focused on testing.
101//!
102//! # Features
103//!
104//! ### Backend
105//!
106//! * `crossterm` (default) if this matches your ratatui backend (most likely).
107//! * `termion` if this matches your ratatui backend.
108//! * `termwiz` is available, but not working correctly with ratatui-image.
109//!
110//! ### Chafa library
111//!
112//! * `chafa-dyn` (default) to use the amazing [chafa](https://hpjansson.org/chafa/) library for
113//!   rendering without image protocols. Dynamically link against libchafa.so at compile time.
114//!   Requires libchafa to be available at runtime in the same way.
115//! * `chafa-static` to statically link against libchafa.a at compile time. The library is embedded
116//!   in the binary.
117//! * If you absolutely don't want to deal with libchafa, then you should use
118//!   `--no-default-features --features image-defaults,crossterm` or a variation thereof.
119//!
120//! Note: The chafa features are mutually exclusive - only enable one at a time.
121//!
122//! ### Others
123//!
124//! * `image-defaults` (default) just enables `image/defaults` (`image` has `default-features =
125//!   false`). To only support a selection of image formats and cut down dependencies, disable this
126//!   feature, add `image` to your crate, and enable its features/formats as desired. See
127//!   <https://doc.rust-lang.org/cargo/reference/features.html#feature-unification/>.
128//! * `serde` for `#[derive]`s on [picker::ProtocolType] for convenience, because it might be
129//!   useful to save it in some user configuration.
130//! * `tokio` whether to use tokio's `UnboundedSender` in `ThreadProtocol`.
131//!
132//!
133//! [ratatui]: https://github.com/ratatui-org/ratatui
134//! [sixel]: https://en.wikipedia.org/wiki/Sixel
135//! [`render_stateful_widget`]: https://docs.rs/ratatui/latest/ratatui/terminal/struct.Frame.html#method.render_stateful_widget
136use std::{
137    cmp::{max, min},
138    marker::PhantomData,
139};
140
141use image::{DynamicImage, ImageBuffer, Rgba, imageops};
142use protocol::{ImageSource, Protocol};
143use ratatui::{
144    buffer::Buffer,
145    layout::Rect,
146    widgets::{StatefulWidget, Widget},
147};
148
149pub mod errors;
150pub mod picker;
151pub mod protocol;
152pub mod thread;
153pub use image::imageops::FilterType;
154
155type Result<T> = std::result::Result<T, errors::Errors>;
156
157/// The terminal's font size in `(width, height)`
158pub type FontSize = (u16, u16);
159
160/// Fixed size image widget that uses [Protocol].
161///
162/// The widget does **not** react to area resizes.
163/// Its advantage lies in that the [Protocol] needs only one initial resize.
164///
165/// ```rust
166/// # use ratatui::Frame;
167/// # use ratatui_image::{Resize, Image, protocol::Protocol};
168/// struct App {
169///     image_static: Protocol,
170/// }
171/// fn ui(f: &mut Frame<'_>, app: &mut App) {
172///     let image = Image::new(&mut app.image_static);
173///     f.render_widget(image, f.size());
174/// }
175/// ```
176pub struct Image<'a> {
177    image: &'a Protocol,
178}
179
180impl<'a> Image<'a> {
181    pub fn new(image: &'a Protocol) -> Self {
182        Self { image }
183    }
184}
185
186impl Widget for Image<'_> {
187    fn render(self, area: Rect, buf: &mut Buffer) {
188        if area.width == 0 || area.height == 0 {
189            return;
190        }
191
192        self.image.render(area, buf);
193    }
194}
195
196pub trait ResizeEncodeRender {
197    /// Resize and encode if necessary, and render immediately.
198    fn resize_encode_render(&mut self, resize: &Resize, area: Rect, buf: &mut Buffer) {
199        if let Some(rect) = self.needs_resize(resize, area) {
200            self.resize_encode(resize, rect);
201        }
202        self.render(area, buf);
203    }
204
205    /// Resize the image and encode it for rendering. The result should be stored statefully so
206    /// that next call for the given area does not need to redo the work.
207    ///
208    /// This can be done in a background thread, and the result is stored in this [protocol::StatefulProtocol].
209    fn resize_encode(&mut self, resize: &Resize, area: Rect);
210
211    /// Render the currently resized and encoded data to the buffer.
212    fn render(&mut self, area: Rect, buf: &mut Buffer);
213    /// Check if the current image state would need resizing (grow or shrink) for the given area.
214    ///
215    /// This can be called by the UI thread to check if this [protocol::StatefulProtocol] should be sent off
216    /// to some background thread/task to do the resizing and encoding, instead of rendering. The
217    /// thread should then return the [protocol::StatefulProtocol] so that it can be rendered.
218    fn needs_resize(&self, resize: &Resize, area: Rect) -> Option<Rect>;
219}
220
221/// Resizeable image widget that uses a [protocol::StatefulProtocol] state.
222///
223/// This stateful widget reacts to area resizes and resizes its image data accordingly.
224///
225/// ```rust
226/// # use ratatui::Frame;
227/// # use ratatui_image::{Resize, StatefulImage, protocol::{StatefulProtocol}};
228/// struct App {
229///     image_state: StatefulProtocol,
230/// }
231/// fn ui(f: &mut Frame<'_>, app: &mut App) {
232///     let image = StatefulImage::default().resize(Resize::Crop(None));
233///     f.render_stateful_widget(
234///         image,
235///         f.area(),
236///         &mut app.image_state,
237///     );
238/// }
239/// ```
240pub struct StatefulImage<T>
241where
242    T: ResizeEncodeRender,
243{
244    resize: Resize,
245    phantom: PhantomData<T>,
246}
247
248impl<T> Default for StatefulImage<T>
249where
250    T: ResizeEncodeRender,
251{
252    fn default() -> Self {
253        Self::new()
254    }
255}
256impl<T> StatefulImage<T>
257where
258    T: ResizeEncodeRender,
259{
260    pub const fn resize(self, resize: Resize) -> Self {
261        Self { resize, ..self }
262    }
263
264    pub const fn new() -> Self {
265        Self {
266            resize: Resize::Fit(None),
267            phantom: PhantomData,
268        }
269    }
270}
271
272impl<T> StatefulWidget for StatefulImage<T>
273where
274    T: ResizeEncodeRender,
275{
276    type State = T;
277    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
278        if area.width == 0 || area.height == 0 {
279            return;
280        }
281
282        state.resize_encode_render(&self.resize, area, buf);
283    }
284}
285
286#[derive(Debug, Clone)]
287/// Resize method
288pub enum Resize {
289    /// Fit to area.
290    ///
291    /// If the width or height is smaller than the area, the image will be resized maintaining
292    /// proportions.
293    ///
294    /// The [FilterType] (re-exported from the [image] crate) defaults to [FilterType::Nearest].
295    Fit(Option<FilterType>),
296    /// Crop to area.
297    ///
298    /// If the width or height is smaller than the area, the image will be cropped.
299    /// The behaviour is the same as using [`Image`] widget with the overhead of resizing,
300    /// but some terminals might misbehave when overdrawing characters over graphics.
301    /// For example, the sixel branch of Alacritty never draws text over a cell that is currently
302    /// being rendered by some sixel sequence, not necessarily originating from the same cell.
303    ///
304    /// The [CropOptions] defaults to clipping the bottom and the right sides.
305    Crop(Option<CropOptions>),
306    /// Scale the image
307    ///
308    /// Same as `Resize::Fit` except it resizes the image even if the image is smaller than the render area
309    Scale(Option<FilterType>),
310}
311
312impl Default for Resize {
313    fn default() -> Self {
314        Self::Fit(None)
315    }
316}
317
318#[derive(Debug, Clone, Copy, PartialEq, Eq)]
319/// Specifies which sides to be clipped when cropping an image.
320pub struct CropOptions {
321    /// If `true`, the top side should be clipped.
322    pub clip_top: bool,
323    /// If `true`, the left side should be clipped.
324    pub clip_left: bool,
325}
326
327impl Resize {
328    /// Resize [`ImageSource`] to fit the `area`.
329    fn resize(
330        &self,
331        source: &ImageSource,
332        font_size: FontSize,
333        area: Rect,
334        background_color: Rgba<u8>,
335    ) -> DynamicImage {
336        let width = (area.width * font_size.0) as u32;
337        let height = (area.height * font_size.1) as u32;
338
339        // Resize/Crop/etc., fitting a multiple of font-size, but not necessarily the area.
340        let mut image = self.resize_image(source, width, height);
341
342        if image.width() != width || image.height() != height {
343            let mut bg: DynamicImage =
344                ImageBuffer::from_pixel(width, height, background_color).into();
345            imageops::overlay(&mut bg, &image, 0, 0);
346            image = bg;
347        }
348        image
349    }
350
351    /// Check if [`ImageSource`]'s "desired" fits into `area` and is different than `current`.
352    ///
353    /// The returned `Rect` is the area the image needs to be resized to, depending on the resize
354    /// type.
355    pub fn needs_resize(
356        &self,
357        image: &ImageSource,
358        font_size: FontSize,
359        current: Rect,
360        area: Rect,
361        force: bool,
362    ) -> Option<Rect> {
363        let desired = image.desired;
364        // Check if resize is needed at all.
365        if !force
366            && !matches!(self, &Resize::Scale(_))
367            && desired.width <= area.width
368            && desired.height <= area.height
369            && desired == current
370        {
371            let width = (desired.width * font_size.0) as u32;
372            let height = (desired.height * font_size.1) as u32;
373            if image.image.width() == width || image.image.height() == height {
374                return None;
375            }
376        }
377
378        let rect = self.render_area(image, font_size, area);
379        debug_assert!(rect.width <= area.width, "needs_resize exceeds area width");
380        debug_assert!(
381            rect.height <= area.height,
382            "needs_resize exceeds area height"
383        );
384        if force || rect != current {
385            return Some(rect);
386        }
387        None
388    }
389
390    pub fn render_area(&self, image: &ImageSource, font_size: FontSize, available: Rect) -> Rect {
391        let (width, height) = self.needs_resize_pixels(
392            &image.image,
393            (available.width as u32) * (font_size.0 as u32),
394            (available.height as u32) * (font_size.1 as u32),
395        );
396        ImageSource::round_pixel_size_to_cells(width, height, font_size)
397    }
398
399    fn resize_image(&self, source: &ImageSource, width: u32, height: u32) -> DynamicImage {
400        const DEFAULT_FILTER_TYPE: FilterType = FilterType::Nearest;
401        const DEFAULT_CROP_OPTIONS: CropOptions = CropOptions {
402            clip_top: false,
403            clip_left: false,
404        };
405        let image = &source.image;
406        match self {
407            Self::Fit(filter_type) | Self::Scale(filter_type) => {
408                image.resize(width, height, filter_type.unwrap_or(DEFAULT_FILTER_TYPE))
409            }
410            Self::Crop(options) => {
411                let options = options.as_ref().unwrap_or(&DEFAULT_CROP_OPTIONS);
412                let y = if options.clip_top {
413                    image.height().saturating_sub(height)
414                } else {
415                    0
416                };
417                let x = if options.clip_left {
418                    image.width().saturating_sub(width)
419                } else {
420                    0
421                };
422                image.crop_imm(x, y, width, height)
423            }
424        }
425    }
426
427    fn needs_resize_pixels(&self, image: &DynamicImage, width: u32, height: u32) -> (u32, u32) {
428        match self {
429            Self::Fit(_) => fit_area_proportionally(
430                image.width(),
431                image.height(),
432                min(width, image.width()),
433                min(height, image.height()),
434            ),
435
436            Self::Crop(_) => (min(image.width(), width), min(image.height(), height)),
437            Self::Scale(_) => fit_area_proportionally(image.width(), image.height(), width, height),
438        }
439    }
440}
441
442/// Ripped from https://github.com/image-rs/image/blob/master/src/math/utils.rs#L12
443/// Calculates the width and height an image should be resized to.
444/// This preserves aspect ratio, and based on the `fill` parameter
445/// will either fill the dimensions to fit inside the smaller constraint
446/// (will overflow the specified bounds on one axis to preserve
447/// aspect ratio), or will shrink so that both dimensions are
448/// completely contained within the given `width` and `height`,
449/// with empty space on one axis.
450fn fit_area_proportionally(width: u32, height: u32, nwidth: u32, nheight: u32) -> (u32, u32) {
451    let wratio = nwidth as f64 / width as f64;
452    let hratio = nheight as f64 / height as f64;
453
454    let ratio = f64::min(wratio, hratio);
455
456    let nw = max((width as f64 * ratio).round() as u64, 1);
457    let nh = max((height as f64 * ratio).round() as u64, 1);
458
459    if nw > u64::from(u16::MAX) {
460        let ratio = u16::MAX as f64 / width as f64;
461        (u32::MAX, max((height as f64 * ratio).round() as u32, 1))
462    } else if nh > u64::from(u16::MAX) {
463        let ratio = u16::MAX as f64 / height as f64;
464        (max((width as f64 * ratio).round() as u32, 1), u32::MAX)
465    } else {
466        (nw as u32, nh as u32)
467    }
468}
469
470#[cfg(test)]
471mod tests {
472    use image::{ImageBuffer, Rgba};
473
474    use super::*;
475
476    const FONT_SIZE: FontSize = (10, 10);
477
478    fn s(w: u16, h: u16) -> ImageSource {
479        let image: DynamicImage =
480            ImageBuffer::from_pixel(w as _, h as _, Rgba::<u8>([255, 0, 0, 255])).into();
481        ImageSource::new(image, FONT_SIZE, [0, 0, 0, 0].into())
482    }
483
484    fn r(w: u16, h: u16) -> Rect {
485        Rect::new(0, 0, w, h)
486    }
487
488    #[test]
489    fn needs_resize_fit() {
490        let resize = Resize::Fit(None);
491
492        let to = resize.needs_resize(&s(100, 100), FONT_SIZE, r(10, 10), r(10, 10), false);
493        assert_eq!(None, to);
494
495        let to = resize.needs_resize(&s(101, 101), FONT_SIZE, r(10, 10), r(10, 10), false);
496        assert_eq!(None, to);
497
498        let to = resize.needs_resize(&s(80, 100), FONT_SIZE, r(8, 10), r(10, 10), false);
499        assert_eq!(None, to);
500
501        let to = resize.needs_resize(&s(100, 100), FONT_SIZE, r(99, 99), r(8, 10), false);
502        assert_eq!(Some(r(8, 8)), to);
503
504        let to = resize.needs_resize(&s(100, 100), FONT_SIZE, r(99, 99), r(10, 8), false);
505        assert_eq!(Some(r(8, 8)), to);
506
507        let to = resize.needs_resize(&s(100, 50), FONT_SIZE, r(99, 99), r(4, 4), false);
508        assert_eq!(Some(r(4, 2)), to);
509
510        let to = resize.needs_resize(&s(50, 100), FONT_SIZE, r(99, 99), r(4, 4), false);
511        assert_eq!(Some(r(2, 4)), to);
512
513        let to = resize.needs_resize(&s(100, 100), FONT_SIZE, r(8, 8), r(11, 11), false);
514        assert_eq!(Some(r(10, 10)), to);
515
516        let to = resize.needs_resize(&s(100, 100), FONT_SIZE, r(10, 10), r(11, 11), false);
517        assert_eq!(None, to);
518    }
519
520    #[test]
521    fn needs_resize_crop() {
522        let resize = Resize::Crop(None);
523
524        let to = resize.needs_resize(&s(100, 100), FONT_SIZE, r(10, 10), r(10, 10), false);
525        assert_eq!(None, to);
526
527        let to = resize.needs_resize(&s(80, 100), FONT_SIZE, r(8, 10), r(10, 10), false);
528        assert_eq!(None, to);
529
530        let to = resize.needs_resize(&s(100, 100), FONT_SIZE, r(10, 10), r(8, 10), false);
531        assert_eq!(Some(r(8, 10)), to);
532
533        let to = resize.needs_resize(&s(100, 100), FONT_SIZE, r(10, 10), r(10, 8), false);
534        assert_eq!(Some(r(10, 8)), to);
535    }
536}