Skip to main content

photon_ui/components/
image_widget.rs

1use std::sync::atomic::{
2    AtomicU32,
3    Ordering,
4};
5
6use crate::{
7    Component,
8    RenderError,
9    Rendered,
10    image::ImageProtocol,
11    layout::Rect,
12    renderer::ImageCommand,
13};
14
15static NEXT_IMAGE_ID: AtomicU32 = AtomicU32::new(1);
16
17/// Default assumed terminal cell size in pixels.
18///
19/// These values are only used to convert the image's pixel dimensions into a
20/// reasonable default cell size. The actual terminal cell size may differ, but
21/// Kitty scales the image to fit the requested `c=`/`r=` cell rectangle.
22const DEFAULT_CELL_WIDTH_PX: u32 = 10;
23const DEFAULT_CELL_HEIGHT_PX: u32 = 20;
24
25/// Maximum default cell width for an image. Images wider than this are scaled
26/// down so they do not dominate the terminal screen.
27const DEFAULT_MAX_COLS: u16 = 40;
28
29/// Maximum default cell height for an image.
30const DEFAULT_MAX_ROWS: u16 = 20;
31
32/// A widget that displays an inline terminal image.
33///
34/// The component renders placeholder text lines that reserve the same number
35/// of cells the terminal will use for the image, and emits an
36/// [`ImageCommand`](crate::renderer::ImageCommand) so the renderer can upload
37/// the image using the selected terminal graphics protocol (Kitty or iTerm2).
38pub struct ImageWidget {
39    id: u32,
40    data: Vec<u8>,
41    mime_type: String,
42    placeholder: String,
43    protocol: ImageProtocol,
44    cols: u16,
45    rows: u16,
46}
47
48impl ImageWidget {
49    /// Create a new image widget.
50    ///
51    /// `data` is the raw image bytes. `placeholder` defaults to `"[image]"`.
52    /// The widget is assigned a unique image id and uses the Kitty protocol by
53    /// default. The display size in cells is inferred from the image's pixel
54    /// dimensions when possible, falling back to `20×10` cells.
55    pub fn new(data: Vec<u8>, mime_type: impl Into<String>, placeholder: Option<String>) -> Self {
56        let mime_type = mime_type.into();
57        let (cols, rows) = compute_default_size(&data, &mime_type);
58        Self {
59            id: NEXT_IMAGE_ID.fetch_add(1, Ordering::Relaxed),
60            data,
61            mime_type,
62            placeholder: placeholder.unwrap_or_else(|| "[image]".to_string()),
63            protocol: ImageProtocol::default(),
64            cols,
65            rows,
66        }
67    }
68
69    /// Override the terminal image protocol used by this widget.
70    pub fn with_protocol(mut self, protocol: ImageProtocol) -> Self {
71        self.protocol = protocol;
72        self
73    }
74
75    /// Set the on-screen size in terminal cells.
76    ///
77    /// This determines how many placeholder lines the widget renders and the
78    /// `c=`/`r=` values passed to the Kitty graphics protocol. The terminal
79    /// scales the image to fit this cell rectangle.
80    pub fn with_size(mut self, cols: u16, rows: u16) -> Self {
81        self.cols = cols.max(1);
82        self.rows = rows.max(1);
83        self
84    }
85
86    /// Build a placeholder line that fills the requested visual width.
87    fn placeholder_line(&self, width: u16) -> String {
88        let target = width as usize;
89        let placeholder_vw = crate::utils::visible_width(&self.placeholder);
90        if placeholder_vw >= target {
91            return crate::utils::truncate_to_width(&self.placeholder, width, "");
92        }
93        let pad = target - placeholder_vw;
94        let mut line = self.placeholder.clone();
95        line.push_str(&" ".repeat(pad));
96        line
97    }
98}
99
100impl Component for ImageWidget {
101    fn render(&self, width: u16) -> Result<Rendered, RenderError> {
102        let display_cols = self.cols.min(width);
103        let encoded = match self.protocol {
104            | ImageProtocol::Kitty => {
105                crate::image::encode_kitty(self.id, &self.data, display_cols, self.rows)
106            },
107            | ImageProtocol::Iterm2 => crate::image::encode_iterm2(&self.data, &self.mime_type),
108        };
109        let images = if encoded.is_empty() {
110            Vec::new()
111        } else {
112            vec![ImageCommand {
113                id: self.id,
114                data: encoded,
115                row: 0,
116                col: 0,
117            }]
118        };
119
120        let mut lines = Vec::new();
121        if self.rows > 0 {
122            lines.push(self.placeholder_line(display_cols));
123        }
124        for _ in 1..self.rows {
125            lines.push(" ".repeat(display_cols as usize));
126        }
127
128        Ok(Rendered {
129            lines,
130            cursor: None,
131            images,
132        })
133    }
134
135    fn render_rect(&self, rect: Rect) -> Result<Rendered, RenderError> {
136        let mut rendered = match self.render(rect.width) {
137            | Ok(r) => r,
138            | Err(e) => return Err(e),
139        };
140        // Clip placeholder lines to the allocated rect so the widget composes
141        // cleanly inside Cassowary layouts.
142        rendered.lines.truncate(rect.height as usize);
143        Ok(rendered)
144    }
145}
146
147/// Compute a default cell size from the image's pixel dimensions.
148///
149/// The image is scaled to fit within [`DEFAULT_MAX_COLS`] while preserving its
150/// aspect ratio under the assumption of a `10×20` pixel terminal cell. Returns
151/// a fallback `20×10` size when dimensions cannot be parsed.
152fn compute_default_size(data: &[u8], mime_type: &str) -> (u16, u16) {
153    let dims = match mime_type {
154        | "image/png" => crate::image::get_png_dimensions(data),
155        | "image/jpeg" | "image/jpg" => crate::image::get_jpeg_dimensions(data),
156        | "image/gif" => crate::image::get_gif_dimensions(data),
157        | "image/webp" => crate::image::get_webp_dimensions(data),
158        | _ => None,
159    };
160    let (pixel_w, pixel_h) = match dims {
161        | Some(d) => d,
162        | None => return (20, 10),
163    };
164    if pixel_w == 0 || pixel_h == 0 {
165        return (20, 10);
166    }
167
168    let max_cols = DEFAULT_MAX_COLS as u32;
169    let max_rows = DEFAULT_MAX_ROWS as u32;
170    let cols = (pixel_w / DEFAULT_CELL_WIDTH_PX).clamp(1, max_cols);
171    let rows = ((pixel_h * cols * DEFAULT_CELL_WIDTH_PX) / (pixel_w * DEFAULT_CELL_HEIGHT_PX))
172        .clamp(1, max_rows) as u16;
173    let cols = cols as u16;
174    (cols, rows)
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn image_widget_default_size_from_png_dimensions() {
183        // PNG signature + IHDR chunk with 100×200 pixels.
184        let mut data = vec![0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
185        data.extend_from_slice(&[0; 8]);
186        data.extend_from_slice(&100u32.to_be_bytes());
187        data.extend_from_slice(&200u32.to_be_bytes());
188        let widget = ImageWidget::new(data, "image/png", None);
189        // 100px / 10px per cell = 10 cols; aspect-preserving height = 10 rows.
190        assert_eq!(widget.cols, 10);
191        assert_eq!(widget.rows, 10);
192    }
193
194    #[test]
195    fn image_widget_with_size_override() {
196        let widget = ImageWidget::new(vec![], "image/png", None).with_size(15, 8);
197        assert_eq!(widget.cols, 15);
198        assert_eq!(widget.rows, 8);
199    }
200
201    #[test]
202    fn image_widget_render_reserves_multiple_lines() {
203        let widget = ImageWidget::new(vec![0x89, 0x50], "image/png", None).with_size(10, 3);
204        let rendered = widget.render(80).unwrap();
205        assert_eq!(rendered.lines.len(), 3);
206        assert!(rendered.lines[0].starts_with("[image]"));
207        assert_eq!(crate::utils::visible_width(&rendered.lines[1]), 10);
208    }
209
210    #[test]
211    fn image_widget_render_rect_clips_to_height() {
212        let widget = ImageWidget::new(vec![0x89, 0x50], "image/png", None).with_size(5, 5);
213        let rect = Rect::new(0, 0, 80, 2);
214        let rendered = widget.render_rect(rect).unwrap();
215        assert_eq!(rendered.lines.len(), 2);
216    }
217
218    #[test]
219    fn image_widget_render_includes_kitty_dimensions() {
220        let widget = ImageWidget::new(vec![0x89, 0x50], "image/png", None).with_size(12, 6);
221        let rendered = widget.render(80).unwrap();
222        assert_eq!(rendered.images.len(), 1);
223        let data = &rendered.images[0].data;
224        assert!(data.contains("c=12"));
225        assert!(data.contains("r=6"));
226    }
227}