1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
//! The [`Image`] view: cell reservation and the text fallback.
//!
//! An image is the one component whose pixels tuika cannot paint into the cell
//! buffer, so this view owns only the half that *is* cells — it reserves a
//! `cols × rows` footprint, keeps those cells blank when the picture will cover
//! them, and paints alt text when it won't. The pixels themselves are recorded
//! into an [`ImageLayer`] and emitted after the frame by
//! [`term::image`](crate::term::image), which owns the graphics protocols.
use ratatui_core::layout::Rect;
use ratatui_core::style::{Modifier, Style};
use crate::geometry::Size;
use crate::surface::Surface;
use crate::term::image::{ImageData, ImageLayer, ImageSupport};
use crate::view::{RenderCtx, View};
/// A view that displays a decoded image over the cells it reserves.
///
/// It always reserves `cols × rows` cells (bounded by the area it's given) and
/// always paints those cells — blank when the image will cover them, or the alt
/// text when the terminal can't. When [`ImageSupport::Kitty`] and a layer are
/// both set, it additionally records itself into the layer so the host's
/// [`ImageLayer::emit`] paints the picture over the reserved cells.
pub struct Image {
data: ImageData,
cols: u16,
rows: u16,
alt: String,
support: ImageSupport,
layer: Option<ImageLayer>,
}
impl Image {
/// An image occupying `cols × rows` cells. It renders as a text placeholder
/// until a graphics [`ImageSupport`] and an [`ImageLayer`] are attached with
/// [`support`](Self::support) and [`in_layer`](Self::in_layer).
pub fn new(data: ImageData, cols: u16, rows: u16) -> Self {
Self {
data,
cols,
rows,
alt: String::new(),
support: ImageSupport::None,
layer: None,
}
}
/// Set the graphics protocol to use (usually [`ImageSupport::detect`]).
pub fn support(mut self, support: ImageSupport) -> Self {
self.support = support;
self
}
/// Register this image with a layer so it is emitted after the frame. Without
/// a layer the view only ever paints its fallback.
pub fn in_layer(mut self, layer: &ImageLayer) -> Self {
self.layer = Some(layer.clone());
self
}
/// Text shown (dimmed) when the image can't be painted — a supportless
/// terminal, or before a layer is attached.
pub fn alt(mut self, alt: impl Into<String>) -> Self {
self.alt = alt.into();
self
}
/// Whether this image will be emitted through a graphics protocol (as
/// opposed to falling back to text).
fn will_paint(&self) -> bool {
matches!(
self.support,
ImageSupport::Kitty | ImageSupport::ITerm2 | ImageSupport::Sixel
) && self.layer.is_some()
}
}
impl View for Image {
fn measure(&self, available: Size) -> Size {
Size::new(self.cols, self.rows).clamp_to(available)
}
fn render(&self, area: Rect, surface: &mut Surface, ctx: &RenderCtx) {
if area.width == 0 || area.height == 0 {
return;
}
if self.will_paint() {
// The graphics escape will cover these cells after the frame; keep
// them blank so nothing shows through at the image's edges and the
// ratatui diff over the region is stable.
surface.fill(Style::default().bg(ctx.theme.background));
if let Some(layer) = &self.layer {
layer.record(area, self.data.clone(), self.support);
}
} else {
self.render_fallback(area, surface, ctx);
}
}
}
impl Image {
/// Paint the alt-text placeholder centered in `area` — what a terminal
/// without graphics support shows.
fn render_fallback(&self, area: Rect, surface: &mut Surface, ctx: &RenderCtx) {
surface.fill(Style::default().bg(ctx.theme.background));
let label = if self.alt.is_empty() {
"[image]".to_string()
} else {
format!("[image: {}]", self.alt)
};
let style = Style::default()
.fg(ctx.theme.muted)
.add_modifier(Modifier::ITALIC);
// Center within the reserved box; truncation is handled by set_string's
// clip against the surface.
let width = crate::width::str_cols(&label);
let x = area.x + area.width.saturating_sub(width) / 2;
let y = area.y + area.height / 2;
surface.set_string(x, y, &label, style);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::style::Theme;
use crate::testing::render;
use crate::view::element;
/// A tiny solid RGBA image: `w × h` pixels, all the same color.
fn solid(w: u32, h: u32, rgba: [u8; 4]) -> ImageData {
let buf: Vec<u8> = std::iter::repeat_n(rgba, (w * h) as usize)
.flatten()
.collect();
ImageData::from_rgba(w, h, buf).expect("valid rgba")
}
#[test]
fn image_reserves_its_cell_footprint() {
let img = Image::new(solid(2, 2, [0, 0, 0, 255]), 6, 3);
assert_eq!(img.measure(Size::new(80, 24)), Size::new(6, 3));
// Clamped to the available area.
assert_eq!(img.measure(Size::new(4, 2)), Size::new(4, 2));
}
#[test]
fn supported_image_records_a_placement_and_leaves_cells_blank() {
let theme = Theme::default();
let layer = ImageLayer::new();
let view = element(
Image::new(solid(2, 2, [0, 0, 0, 255]), 4, 2)
.support(ImageSupport::Kitty)
.in_layer(&layer)
.alt("cat"),
);
let buf = render(&view, 4, 2, &theme);
// The reserved cells are blank (the image will cover them post-frame),
// so no alt text leaks into the buffer.
let text: String = (0..buf.area.width).map(|x| buf[(x, 0)].symbol()).collect();
assert!(
!text.contains("cat"),
"supported image must not paint alt text"
);
assert_eq!(layer.len(), 1, "a placement was recorded");
assert!(!layer.is_empty());
}
#[test]
fn unsupported_image_paints_alt_text_and_records_nothing() {
let theme = Theme::default();
let layer = ImageLayer::new();
// Support::None → fallback, even with a layer attached.
let view = element(
Image::new(solid(2, 2, [0, 0, 0, 255]), 20, 3)
.in_layer(&layer)
.alt("cat"),
);
let buf = render(&view, 20, 3, &theme);
let mut whole = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
whole.push_str(buf[(x, y)].symbol());
}
}
assert!(
whole.contains("[image: cat]"),
"fallback should show alt text"
);
assert!(layer.is_empty(), "fallback records no placement");
}
}