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