Skip to main content

rio_graphics/
lib.rs

1// Copyright (c) 2023-present, Raphael Amorim.
2//
3// This source code is licensed under the MIT license found in the
4// LICENSE file in the root directory of this source tree.
5
6//! Terminal graphics value types shared by Rio's VT core and renderer.
7//!
8//! This crate holds the plain data types produced by the terminal's image
9//! protocols (sixel, kitty, iTerm2) and the glyph protocol, with no
10//! rendering, GPU, or windowing dependencies. It is the leaf both
11//! `rio-vt` (the safe terminal core) and `sugarloaf` (the renderer)
12//! depend on, so the core no longer has to pull the render stack just to
13//! model an image.
14
15#![forbid(unsafe_code)]
16
17#[cfg(feature = "image")]
18use image_rs::DynamicImage;
19use std::cmp;
20
21#[cfg(feature = "glyph")]
22pub mod glyph;
23
24/// RGBA color in linear-light 0..1 space. Mirrors `wgpu::Color`'s
25/// shape so callers don't have to depend on `wgpu`. Sugarloaf's public
26/// API takes/returns this type; the wgpu render path converts at the
27/// boundary (see the `wgpu` feature's `From` impls).
28#[repr(C)]
29#[derive(Clone, Copy, Debug, PartialEq)]
30pub struct Color {
31    pub r: f64,
32    pub g: f64,
33    pub b: f64,
34    pub a: f64,
35}
36
37impl Color {
38    pub const TRANSPARENT: Self = Self {
39        r: 0.0,
40        g: 0.0,
41        b: 0.0,
42        a: 0.0,
43    };
44    pub const BLACK: Self = Self {
45        r: 0.0,
46        g: 0.0,
47        b: 0.0,
48        a: 1.0,
49    };
50    pub const WHITE: Self = Self {
51        r: 1.0,
52        g: 1.0,
53        b: 1.0,
54        a: 1.0,
55    };
56}
57
58#[cfg(feature = "wgpu")]
59impl From<Color> for wgpu::Color {
60    fn from(c: Color) -> Self {
61        wgpu::Color {
62            r: c.r,
63            g: c.g,
64            b: c.b,
65            a: c.a,
66        }
67    }
68}
69
70#[cfg(feature = "wgpu")]
71impl From<wgpu::Color> for Color {
72    fn from(c: wgpu::Color) -> Self {
73        Color {
74            r: c.r,
75            g: c.g,
76            b: c.b,
77            a: c.a,
78        }
79    }
80}
81
82pub const MAX_GRAPHIC_DIMENSIONS: [usize; 2] = [4096, 4096];
83
84#[derive(Debug, Clone, Copy, Eq, Hash, PartialEq)]
85pub struct Graphic {
86    pub id: GraphicId,
87    pub offset_x: u16,
88    pub offset_y: u16,
89}
90
91/// Key for `Sugarloaf::image_data` and the renderer's per-image
92/// texture cache: a kitty image, keyed by its protocol id verbatim.
93/// The full u32 space belongs to the client, so atlas graphics live
94/// in a disjoint range above it (see [`atlas_image_key`]).
95#[inline]
96pub fn kitty_image_key(image_id: u32) -> u64 {
97    image_id as u64
98}
99
100/// Key for an atlas graphic (sixel/iTerm2): the sequential
101/// `GraphicId` shifted above the kitty u32 id space so a kitty client
102/// picking a high image id (kitten icat uses random u32 ids) can
103/// never collide with an atlas texture.
104#[inline]
105pub fn atlas_image_key(graphic_id: u64) -> u64 {
106    (1u64 << 32) + graphic_id
107}
108
109/// An overlay image placement.
110/// Used by the renderer to draw images on top of (or behind) terminal content.
111#[derive(Debug, Clone)]
112pub struct GraphicOverlay {
113    /// Image texture key ([`kitty_image_key`] or [`atlas_image_key`]).
114    pub image_id: u64,
115    /// Screen position (physical pixels).
116    pub x: f32,
117    pub y: f32,
118    /// Display dimensions (physical pixels).
119    pub width: f32,
120    pub height: f32,
121    /// Z-index for layering.
122    pub z_index: i32,
123    /// Source rectangle in normalised texture coordinates `[u0, v0, u1, v1]`.
124    /// `[0.0, 0.0, 1.0, 1.0]` (the default) draws the whole image; other
125    /// values draw a slice — used by the kitty Unicode-placeholder path
126    /// where each placeholder cell shows one slice of the image.
127    pub source_rect: [f32; 4],
128}
129
130impl GraphicOverlay {
131    /// Default source rect — full image.
132    pub const FULL_SOURCE_RECT: [f32; 4] = [0.0, 0.0, 1.0, 1.0];
133}
134
135/// Unique identifier for every graphic added to a grid.
136/// An id of 0 represents a temporary, non-referenceable image
137/// (matching kitty's behavior).
138#[derive(Eq, PartialEq, Clone, Debug, Copy, Hash, PartialOrd, Ord)]
139pub struct GraphicId(pub u64);
140
141impl GraphicId {
142    /// Create a new GraphicId from a u64 value.
143    #[inline]
144    pub const fn new(value: u64) -> Self {
145        Self(value)
146    }
147
148    /// Get the inner u64 value.
149    #[inline]
150    pub const fn get(self) -> u64 {
151        self.0
152    }
153}
154
155/// Specifies the format of the pixel data.
156#[derive(Eq, PartialEq, Clone, Debug, Copy)]
157pub enum ColorType {
158    /// 3 bytes per pixel (red, green, blue).
159    Rgb,
160
161    /// 4 bytes per pixel (red, green, blue, alpha).
162    Rgba,
163}
164
165/// Defines a single graphic read from the PTY.
166#[derive(Eq, PartialEq, Clone, Debug)]
167pub struct GraphicData {
168    /// Graphics identifier.
169    pub id: GraphicId,
170
171    /// Width, in pixels, of the graphic.
172    pub width: usize,
173
174    /// Height, in pixels, of the graphic.
175    pub height: usize,
176
177    /// Color type of the pixels.
178    pub color_type: ColorType,
179
180    /// Pixels data.
181    pub pixels: Vec<u8>,
182
183    /// Indicate if there are no transparent pixels.
184    pub is_opaque: bool,
185
186    /// Render graphic in a different size.
187    pub resize: Option<ResizeCommand>,
188
189    /// Display width in pixels (set when GPU scaling is used instead of CPU resize).
190    /// If None, display at the original pixel width.
191    pub display_width: Option<usize>,
192
193    /// Display height in pixels (set when GPU scaling is used instead of CPU resize).
194    /// If None, display at the original pixel height.
195    pub display_height: Option<usize>,
196
197    /// Generation counter for cache invalidation.
198    /// Incremented when image data changes (re-transmission with same ID).
199    pub transmit_time: std::time::Instant,
200}
201
202impl GraphicData {
203    /// Check if the image may contain transparent pixels. If it returns
204    /// `false`, it is guaranteed that there are no transparent pixels.
205    #[inline]
206    pub fn maybe_transparent(&self) -> bool {
207        !self.is_opaque && self.color_type == ColorType::Rgba
208    }
209
210    /// Check if all pixels under a region are opaque.
211    ///
212    /// If the region exceeds the boundaries of the image it is considered as
213    /// not filled.
214    pub fn is_filled(&self, x: usize, y: usize, width: usize, height: usize) -> bool {
215        // If there are pixels outside the picture we assume that the region is
216        // not filled.
217        if x + width >= self.width || y + height >= self.height {
218            return false;
219        }
220
221        // Don't check actual pixels if the image does not contain an alpha
222        // channel.
223        if !self.maybe_transparent() {
224            return true;
225        }
226
227        debug_assert!(self.color_type == ColorType::Rgba);
228
229        for offset_y in y..y + height {
230            let offset = offset_y * self.width * 4;
231            let row = &self.pixels[offset..offset + width * 4];
232
233            if row.chunks_exact(4).any(|pixel| pixel.last() != Some(&255)) {
234                return false;
235            }
236        }
237
238        true
239    }
240
241    #[cfg(feature = "image")]
242    pub fn from_dynamic_image(id: GraphicId, image: DynamicImage) -> Self {
243        let color_type;
244        let width;
245        let height;
246        let pixels;
247
248        match image {
249            // Sugarloaf only accepts rgba8 now
250            // DynamicImage::ImageRgb8(image) => {
251            //     color_type = ColorType::Rgb;
252            //     width = image.width() as usize;
253            //     height = image.height() as usize;
254            //     pixels = image.into_raw();
255            // }
256            DynamicImage::ImageRgba8(image) => {
257                color_type = ColorType::Rgba;
258                width = image.width() as usize;
259                height = image.height() as usize;
260                pixels = image.into_raw();
261            }
262
263            _ => {
264                // Non-RGB image. Convert it to RGBA.
265                let image = image.into_rgba8();
266                color_type = ColorType::Rgba;
267                width = image.width() as usize;
268                height = image.height() as usize;
269                pixels = image.into_raw();
270            }
271        }
272
273        GraphicData {
274            id,
275            width,
276            height,
277            color_type,
278            pixels,
279            is_opaque: false,
280            resize: None,
281            display_width: None,
282            display_height: None,
283            transmit_time: std::time::Instant::now(),
284        }
285    }
286
287    /// Compute the display dimensions for this graphic without modifying pixels.
288    /// Returns (display_width, display_height) in pixels. If no resize is needed,
289    /// returns the original dimensions.
290    pub fn compute_display_dimensions(
291        &self,
292        cell_width: usize,
293        cell_height: usize,
294        view_width: usize,
295        view_height: usize,
296    ) -> (usize, usize) {
297        let resize = match self.resize {
298            Some(resize) => resize,
299            None => return (self.width, self.height),
300        };
301
302        if (resize.width == ResizeParameter::Auto
303            && resize.height == ResizeParameter::Auto)
304            || self.height == 0
305            || self.width == 0
306        {
307            return (self.width, self.height);
308        }
309
310        let mut width = match resize.width {
311            ResizeParameter::Auto => 1,
312            ResizeParameter::Pixels(n) => n as usize,
313            ResizeParameter::Cells(n) => n as usize * cell_width,
314            ResizeParameter::WindowPercent(n) => n as usize * view_width / 100,
315        };
316
317        let mut height = match resize.height {
318            ResizeParameter::Auto => 1,
319            ResizeParameter::Pixels(n) => n as usize,
320            ResizeParameter::Cells(n) => n as usize * cell_height,
321            ResizeParameter::WindowPercent(n) => n as usize * view_height / 100,
322        };
323
324        if width == 0 || height == 0 {
325            return (self.width, self.height);
326        }
327
328        if resize.width == ResizeParameter::Auto {
329            width =
330                (self.width as f64 * height as f64 / self.height as f64).round() as usize;
331        }
332
333        if resize.height == ResizeParameter::Auto {
334            height =
335                (self.height as f64 * width as f64 / self.width as f64).round() as usize;
336        }
337
338        width = cmp::min(width, MAX_GRAPHIC_DIMENSIONS[0]);
339        height = cmp::min(height, MAX_GRAPHIC_DIMENSIONS[1]);
340
341        if resize.preserve_aspect_ratio {
342            // Preserve aspect ratio: fit within width x height
343            let scale_w = width as f64 / self.width as f64;
344            let scale_h = height as f64 / self.height as f64;
345            let scale = scale_w.min(scale_h);
346            width = (self.width as f64 * scale).round() as usize;
347            height = (self.height as f64 * scale).round() as usize;
348        }
349
350        (width, height)
351    }
352
353    /// Resize the graphic according to the dimensions in the `resize` field.
354    #[cfg(feature = "image")]
355    pub fn resized(
356        self,
357        cell_width: usize,
358        cell_height: usize,
359        view_width: usize,
360        view_height: usize,
361    ) -> Option<Self> {
362        let resize = match self.resize {
363            Some(resize) => resize,
364            None => return Some(self),
365        };
366
367        if (resize.width == ResizeParameter::Auto
368            && resize.height == ResizeParameter::Auto)
369            || self.height == 0
370            || self.width == 0
371        {
372            return Some(self);
373        }
374
375        let mut width = match resize.width {
376            ResizeParameter::Auto => 1,
377            ResizeParameter::Pixels(n) => n as usize,
378            ResizeParameter::Cells(n) => n as usize * cell_width,
379            ResizeParameter::WindowPercent(n) => n as usize * view_width / 100,
380        };
381
382        let mut height = match resize.height {
383            ResizeParameter::Auto => 1,
384            ResizeParameter::Pixels(n) => n as usize,
385            ResizeParameter::Cells(n) => n as usize * cell_height,
386            ResizeParameter::WindowPercent(n) => n as usize * view_height / 100,
387        };
388
389        if width == 0 || height == 0 {
390            return None;
391        }
392
393        // Compute "auto" dimensions.
394        if resize.width == ResizeParameter::Auto {
395            width = self.width * height / self.height;
396        }
397
398        if resize.height == ResizeParameter::Auto {
399            height = self.height * width / self.width;
400        }
401
402        // Limit size to MAX_GRAPHIC_DIMENSIONS.
403        width = cmp::min(width, MAX_GRAPHIC_DIMENSIONS[0]);
404        height = cmp::min(height, MAX_GRAPHIC_DIMENSIONS[1]);
405
406        tracing::trace!("Resize new graphic to width={}, height={}", width, height,);
407
408        // Create a new DynamicImage to resize the graphic.
409        let dynimage = match self.color_type {
410            ColorType::Rgb => {
411                let buffer = image_rs::RgbImage::from_raw(
412                    self.width as u32,
413                    self.height as u32,
414                    self.pixels,
415                )?;
416                DynamicImage::ImageRgb8(buffer)
417            }
418
419            ColorType::Rgba => {
420                let buffer = image_rs::RgbaImage::from_raw(
421                    self.width as u32,
422                    self.height as u32,
423                    self.pixels,
424                )?;
425                DynamicImage::ImageRgba8(buffer)
426            }
427        };
428
429        // Finally, use `resize` or `resize_exact` to make the new image.
430        let width = width as u32;
431        let height = height as u32;
432        // https://doc.servo.org/image/imageops/enum.FilterType.html
433        let filter = image_rs::imageops::FilterType::Triangle;
434
435        let new_image = if resize.preserve_aspect_ratio {
436            dynimage.resize(width, height, filter)
437        } else {
438            dynimage.resize_exact(width, height, filter)
439        };
440
441        Some(Self::from_dynamic_image(self.id, new_image))
442    }
443}
444
445/// Unit to specify a dimension to resize the graphic.
446#[derive(Eq, PartialEq, Clone, Copy, Debug)]
447pub enum ResizeParameter {
448    /// Dimension is computed from the original graphic dimensions.
449    Auto,
450
451    /// Size is specified in number of grid cells.
452    Cells(u32),
453
454    /// Size is specified in number pixels.
455    Pixels(u32),
456
457    /// Size is specified in a percent of the window.
458    WindowPercent(u32),
459}
460
461/// Dimensions to resize a graphic.
462#[derive(Eq, PartialEq, Clone, Copy, Debug)]
463pub struct ResizeCommand {
464    pub width: ResizeParameter,
465
466    pub height: ResizeParameter,
467
468    pub preserve_aspect_ratio: bool,
469}
470
471#[test]
472fn check_opaque_region() {
473    let graphic = GraphicData {
474        id: GraphicId::new(1),
475        width: 10,
476        height: 10,
477        color_type: ColorType::Rgb,
478        pixels: vec![255; 10 * 10 * 3],
479        is_opaque: true,
480        resize: None,
481        display_width: None,
482        display_height: None,
483        transmit_time: std::time::Instant::now(),
484    };
485
486    assert!(graphic.is_filled(1, 1, 3, 3));
487    assert!(!graphic.is_filled(8, 8, 10, 10));
488
489    let pixels = {
490        // Put a transparent 3x3 box inside the picture.
491        let mut data = vec![255; 10 * 10 * 4];
492        for y in 3..6 {
493            let offset = y * 10 * 4;
494            data[offset..offset + 3 * 4].fill(0);
495        }
496        data
497    };
498
499    let graphic = GraphicData {
500        id: GraphicId::new(1),
501        pixels,
502        width: 10,
503        height: 10,
504        color_type: ColorType::Rgba,
505        is_opaque: false,
506        resize: None,
507        display_width: None,
508        display_height: None,
509        transmit_time: std::time::Instant::now(),
510    };
511
512    assert!(graphic.is_filled(0, 0, 3, 3));
513    assert!(!graphic.is_filled(1, 1, 4, 4));
514}