Skip to main content

omp_tui/components/
img.rs

1use omp_core::str::IntoStr;
2
3use crate::{
4	component::{Component, PaintCtx, Slot, next_slot},
5	context::{Graphics, UiContext},
6	frame::{Color, Rect, Style},
7	imagefmt::{self, ImageDimensions},
8	kitty::PLACEHOLDER_LIMIT,
9	markup::{Border, Dim},
10	props::{Prop, PropValue, Props},
11};
12
13type Rgb = [u8; 3];
14type CellColors = (Option<Rgb>, Option<Rgb>);
15
16#[derive(Clone, Copy, Default)]
17enum AutoBox {
18	#[default]
19	Unresolved,
20	Resolved(Option<(u32, u16, u16)>),
21}
22
23#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
24pub enum Load {
25	#[default]
26	Unloaded,
27	Loading,
28	Ready,
29	Boxed,
30}
31
32#[derive(Default)]
33pub struct ImgState {
34	/// Half-block colors per cell; `None` halves are transparent and leave
35	/// the underlying background visible.
36	cells: Box<[CellColors]>,
37	width: u16,
38	rows:  u16,
39	phase: Load,
40}
41
42impl ImgState {
43	fn row(&self, index: u16) -> &[CellColors] {
44		let stride = usize::from(self.width);
45		let start = usize::from(index) * stride;
46		&self.cells[start..start + stride]
47	}
48}
49
50/// A terminal-rendered image backing the `<img>` markup tag.
51///
52/// On the Kitty-placeholder graphics tier a PNG `src` renders as real pixels
53/// with no further setup: the source is interned process-wide, uploaded by
54/// the renderer on first reference, and placed in the cell box derived from
55/// `w`/`h` (aspect-derived when `h` is omitted). On every other tier, PNG
56/// and binary PPM sources decode to colored half-block cells. JPEG, GIF,
57/// and WebP sources are header-probed only: the component reserves their
58/// aspect-correct cell box and paints a themed placeholder. The `trim` flag
59/// crops fully transparent margins before half-block sampling (terminal
60/// compositors always show the full source), so padded logo sources stay
61/// visible even as tiny thumbnails.
62pub struct Img {
63	props:  Props,
64	slot:   Slot,
65	state:  ImgState,
66	kitty:  Option<(u32, u16, u16)>,
67	/// Cached `src`-interned placeholder box, resolved at most once.
68	auto:   AutoBox,
69	top:    String,
70	bottom: String,
71}
72
73impl Img {
74	/// Creates an image with no source.
75	pub fn new() -> Self {
76		Self {
77			props:  Props::new(),
78			slot:   next_slot(),
79			state:  ImgState::default(),
80			kitty:  None,
81			auto:   AutoBox::Unresolved,
82			top:    String::new(),
83			bottom: String::new(),
84		}
85	}
86
87	/// Sets one image property.
88	pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
89		self.props.set(prop, value);
90		self
91	}
92
93	/// Sets one image property from a string.
94	pub fn with_str(mut self, prop: Prop, value: &str) -> Self {
95		self.props.set(prop, value);
96		self
97	}
98
99	/// Uses a renderer-registered image ID in a fixed cell box on every
100	/// pixel-capable graphics tier, overriding the `src` placeholder path.
101	///
102	/// Pair with [`crate::Renderer::register_image`]. Rebuild the component
103	/// with new dimensions after a resize. Dimensions beyond Kitty's
104	/// 297-entry coordinate table leave the component on its cell fallback.
105	pub const fn kitty(mut self, id: u32, rows: u16, cols: u16) -> Self {
106		if rows > 0 && cols > 0 && rows <= PLACEHOLDER_LIMIT && cols <= PLACEHOLDER_LIMIT {
107			self.kitty = Some((id, rows, cols));
108		}
109		self
110	}
111
112	/// The typed-cell box for this context: the explicit [`Img::kitty`] box
113	/// on any pixel tier, else a `src`-interned placeholder box on the
114	/// Kitty-placeholder tier. `None` selects the half-block/box fallback.
115	fn cell_box(&mut self, ctx: &UiContext) -> Option<(u32, u16, u16)> {
116		if ctx.graphics == Graphics::Cells {
117			return None;
118		}
119		if self.kitty.is_some() {
120			return self.kitty;
121		}
122		if ctx.graphics != Graphics::KittyPlaceholders {
123			return None;
124		}
125		if matches!(self.auto, AutoBox::Unresolved) {
126			self.auto = AutoBox::Resolved(resolve_placeholder_box(&self.props));
127		}
128		let AutoBox::Resolved(cell_box) = self.auto else {
129			unreachable!("placeholder resolution was initialized");
130		};
131		cell_box
132	}
133
134	fn requested_width(&self, available: u16) -> u16 {
135		match self.props.w() {
136			Some(Dim::Cells(cells)) => cells,
137			Some(Dim::Pct(percent)) => (u32::from(available) * u32::from(percent) / 100).max(1) as u16,
138			None => 24,
139		}
140		.min(available.max(1))
141	}
142
143	fn ensure_decoded(&mut self, ctx: &UiContext, available: u16) {
144		if self.state.phase != Load::Unloaded {
145			return;
146		}
147		let source = self
148			.props
149			.str_of(Prop::Src)
150			.map_or("", |value| value.as_str());
151		let width = self.requested_width(available);
152		let trim = self.props.flag(Prop::Trim);
153		if let Some(loader) = &ctx.loader {
154			loader.request(self.slot, source.to_str(), width, self.props.h(), trim);
155			self.state.phase = Load::Loading;
156			self.state.width = width;
157			self.state.rows = 3;
158		} else {
159			self.state = decode_source(source, width, self.props.h(), trim);
160		}
161	}
162
163	/// Installs an off-thread decode result; ignores stale deliveries after
164	/// the state already settled.
165	pub(crate) fn apply_decoded(&mut self, state: ImgState) {
166		if self.state.phase == Load::Loading {
167			self.state = state;
168		}
169	}
170}
171
172/// Resolves an interned placeholder box from `src`, `w`, and `h` props:
173/// PNG-only, fixed-cell widths only, aspect-derived rows when `h` is
174/// omitted, bounded by Kitty's diacritic table.
175fn resolve_placeholder_box(props: &Props) -> Option<(u32, u16, u16)> {
176	let source = props.str_of(Prop::Src)?;
177	let interned = crate::imagereg::intern(source.as_str())?;
178	let cols = match props.w() {
179		Some(Dim::Cells(cells)) => cells,
180		Some(Dim::Pct(_)) => return None,
181		None => 24,
182	};
183	let rows = props.h().unwrap_or_else(|| {
184		let scaled = u64::from(cols) * u64::from(interned.dimensions.height);
185		let denominator = u64::from(interned.dimensions.width) * 2;
186		((scaled + denominator / 2) / denominator)
187			.max(1)
188			.min(u64::from(PLACEHOLDER_LIMIT)) as u16
189	});
190	(rows > 0 && cols > 0 && rows <= PLACEHOLDER_LIMIT && cols <= PLACEHOLDER_LIMIT).then_some((
191		interned.id,
192		rows,
193		cols,
194	))
195}
196
197impl Default for Img {
198	fn default() -> Self {
199		Self::new()
200	}
201}
202
203impl Component for Img {
204	fn props(&self) -> &Props {
205		&self.props
206	}
207
208	fn props_mut(&mut self) -> &mut Props {
209		&mut self.props
210	}
211
212	fn slot(&self) -> Slot {
213		self.slot
214	}
215
216	fn measure(&mut self, ctx: &UiContext) -> (u16, u16) {
217		if let Some((_, rows, cols)) = self.cell_box(ctx) {
218			return (cols, rows);
219		}
220		let width = match self.props.w() {
221			Some(Dim::Cells(width)) => width,
222			_ => 24,
223		};
224		(width, width)
225	}
226
227	fn height(&mut self, ctx: &UiContext, width: u16) -> u16 {
228		if let Some((_, rows, _)) = self.cell_box(ctx) {
229			return rows;
230		}
231		self.ensure_decoded(ctx, width);
232		self.state.rows
233	}
234
235	fn place(&mut self, ctx: &UiContext, content: Rect) {
236		if self.cell_box(ctx).is_none() {
237			self.ensure_decoded(ctx, content.width);
238		}
239	}
240
241	fn paint(&mut self, pc: &mut PaintCtx<'_>, rect: Rect) {
242		if let Some((id, rows, cols)) = self.cell_box(pc.ctx) {
243			for row in 0..rows.min(rect.height) {
244				let y = rect.y + row;
245				if y >= pc.clip {
246					break;
247				}
248				for col in 0..cols.min(rect.width) {
249					pc.frame
250						.put_image_cell(rect.x + col, y, id, row, col, rows, cols);
251				}
252			}
253			return;
254		}
255		self.ensure_decoded(pc.ctx, rect.width);
256		if self.state.phase != Load::Ready {
257			let source = self
258				.props
259				.str_of(Prop::Src)
260				.map_or("", |value| value.as_str());
261			let name = source.rsplit('/').next().unwrap_or("image");
262			let width = self.state.width.min(rect.width);
263			let rows = self.state.rows.min(rect.height);
264			if width == 0 || rows == 0 {
265				return;
266			}
267			let (tl, tr, bl, br, horizontal, _) = pc.ctx.charset.border(Border::Square);
268			self.top.clear();
269			self.bottom.clear();
270			self.top.reserve(usize::from(width));
271			self.bottom.reserve(usize::from(width));
272			self.top.push(tl);
273			self.bottom.push(bl);
274			for _ in 0..width.saturating_sub(2) {
275				self.top.push(horizontal);
276				self.bottom.push(horizontal);
277			}
278			if width > 1 {
279				self.top.push(tr);
280				self.bottom.push(br);
281			}
282			let style = Style::new().fg(pc.ctx.theme.muted);
283			for row in 0..rows {
284				let y = rect.y + row;
285				if y >= pc.clip {
286					break;
287				}
288				if row == 0 {
289					pc.frame.put(rect.x, y, &self.top, style);
290				} else if row + 1 == rows {
291					pc.frame.put(rect.x, y, &self.bottom, style);
292				} else {
293					let rail = pc.ctx.charset.icon(crate::Icon::PlaceholderRail);
294					pc.frame.put(rect.x, y, rail, style);
295					if row == rows / 2 && width > 4 {
296						let mut x = pc.frame.put(rect.x + 2, y, "[img: ", style);
297						x = pc.frame.put(x, y, name, style);
298						pc.frame.put(x, y, "]", style);
299					}
300					if width > 1 {
301						pc.frame.put(rect.x + width - 1, y, rail, style);
302					}
303				}
304			}
305			return;
306		}
307		for row_index in 0..self.state.rows {
308			let y = rect.y + row_index;
309			if y >= pc.clip {
310				break;
311			}
312			let mut x = rect.x;
313			for (upper, lower) in self.state.row(row_index) {
314				// Transparent halves stay unpainted so the terminal or
315				// container background shows through.
316				let cell = match (upper, lower) {
317					(Some(upper), Some(lower)) => Some((
318						crate::Icon::UpperHalf,
319						Style::new()
320							.fg(Color::Rgb(upper[0], upper[1], upper[2]))
321							.bg(Color::Rgb(lower[0], lower[1], lower[2])),
322					)),
323					(Some(upper), None) => Some((
324						crate::Icon::UpperHalf,
325						Style::new().fg(Color::Rgb(upper[0], upper[1], upper[2])),
326					)),
327					(None, Some(lower)) => Some((
328						crate::Icon::LowerHalf,
329						Style::new().fg(Color::Rgb(lower[0], lower[1], lower[2])),
330					)),
331					(None, None) => None,
332				};
333				x = match cell {
334					Some((icon, style)) => pc.frame.put(x, y, pc.ctx.charset.icon(icon), style),
335					None => x.saturating_add(1),
336				};
337			}
338		}
339	}
340}
341
342enum DecodedImage {
343	Pixels(Vec<Vec<[u8; 4]>>),
344	Placeholder(ImageDimensions),
345}
346/// Reads, decodes, and cell-samples `source` at `width_cells`. Never
347/// panics; a settled no-pixel outcome (failure or probe-only format)
348/// returns a [`Load::Boxed`] state.
349pub fn decode_source(
350	source: &str,
351	width_cells: u16,
352	height_cells: Option<u16>,
353	trim: bool,
354) -> ImgState {
355	match decode_image(source) {
356		Some(DecodedImage::Pixels(mut pixels))
357			if !pixels.is_empty() && pixels.first().is_some_and(|row| !row.is_empty()) =>
358		{
359			if trim {
360				pixels = trim_transparent(pixels);
361			}
362			let gate = if trim { TRIMMED_PAINT_GATE } else { PAINT_GATE };
363			sample_cells(&pixels, width_cells, height_cells, gate)
364		},
365		Some(DecodedImage::Placeholder(dimensions)) => {
366			placeholder_state(dimensions, width_cells, height_cells)
367		},
368		_ => {
369			ImgState { cells: Box::default(), width: width_cells.max(1), rows: 3, phase: Load::Boxed }
370		},
371	}
372}
373
374/// Crops rows and columns whose pixels are all (nearly) transparent, so a
375/// padded logo fills its cell box instead of averaging away. A fully
376/// transparent image is returned unchanged.
377fn trim_transparent(pixels: Vec<Vec<[u8; 4]>>) -> Vec<Vec<[u8; 4]>> {
378	const VISIBLE: u8 = 8;
379	let mut top = None;
380	let mut bottom = 0_usize;
381	let mut left = usize::MAX;
382	let mut right = 0_usize;
383	for (y, row) in pixels.iter().enumerate() {
384		for (x, pixel) in row.iter().enumerate() {
385			if pixel[3] >= VISIBLE {
386				top.get_or_insert(y);
387				bottom = y;
388				left = left.min(x);
389				right = right.max(x);
390			}
391		}
392	}
393	let Some(top) = top else {
394		return pixels;
395	};
396	pixels[top..=bottom]
397		.iter()
398		.map(|row| row[left.min(row.len() - 1)..=right.min(row.len() - 1)].to_vec())
399		.collect()
400}
401
402fn decode_image(path: &str) -> Option<DecodedImage> {
403	let bytes = std::fs::read(path).ok()?;
404	if bytes.starts_with(b"P6") {
405		return decode_ppm(&bytes).map(DecodedImage::Pixels);
406	}
407	let dimensions = imagefmt::dimensions(&bytes)?;
408	if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
409		return Some(DecodedImage::Placeholder(dimensions));
410	}
411	decode_png(&bytes)
412		.map(DecodedImage::Pixels)
413		.or(Some(DecodedImage::Placeholder(dimensions)))
414}
415
416fn decode_png(bytes: &[u8]) -> Option<Vec<Vec<[u8; 4]>>> {
417	let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
418	// Official logos frequently ship indexed palettes (with tRNS alpha) or
419	// 16-bit channels; normalize so `samples()` below always describes
420	// plain 8-bit gray/RGB(A) output instead of palette indices.
421	decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
422	let mut reader = decoder.read_info().ok()?;
423	let mut buffer = vec![0_u8; reader.output_buffer_size()?];
424	let info = reader.next_frame(&mut buffer).ok()?;
425	let (width, height) = (info.width as usize, info.height as usize);
426	let stride = info.color_type.samples();
427	let mut rows = Vec::with_capacity(height);
428	for y in 0..height {
429		let mut row = Vec::with_capacity(width);
430		for x in 0..width {
431			let at = y * width * stride + x * stride;
432			row.push(match stride {
433				1 => [buffer[at], buffer[at], buffer[at], 255],
434				2 => [buffer[at], buffer[at], buffer[at], buffer[at + 1]],
435				3 => [buffer[at], buffer[at + 1], buffer[at + 2], 255],
436				_ => [buffer[at], buffer[at + 1], buffer[at + 2], buffer[at + 3]],
437			});
438		}
439		rows.push(row);
440	}
441	Some(rows)
442}
443
444fn decode_ppm(bytes: &[u8]) -> Option<Vec<Vec<[u8; 4]>>> {
445	let mut fields = Vec::new();
446	let mut at = 2_usize;
447	while fields.len() < 3 && at < bytes.len() {
448		while at < bytes.len() && bytes[at].is_ascii_whitespace() {
449			at += 1;
450		}
451		if bytes.get(at) == Some(&b'#') {
452			while at < bytes.len() && bytes[at] != b'\n' {
453				at += 1;
454			}
455			continue;
456		}
457		let start = at;
458		while at < bytes.len() && bytes[at].is_ascii_digit() {
459			at += 1;
460		}
461		fields.push(
462			std::str::from_utf8(&bytes[start..at])
463				.ok()?
464				.parse::<usize>()
465				.ok()?,
466		);
467	}
468	at += 1;
469	let (&width, &height) = (fields.first()?, fields.get(1)?);
470	let data = bytes.get(at..at + width * height * 3)?;
471	Some(
472		data
473			.chunks_exact(width * 3)
474			.map(|row| {
475				row.as_chunks::<3>()
476					.0
477					.iter()
478					.map(|pixel| [pixel[0], pixel[1], pixel[2], 255])
479					.collect()
480			})
481			.collect(),
482	)
483}
484
485fn placeholder_state(
486	dimensions: ImageDimensions,
487	width_cells: u16,
488	height_cells: Option<u16>,
489) -> ImgState {
490	let rows = height_cells.unwrap_or_else(|| {
491		let scaled = u64::from(width_cells) * u64::from(dimensions.height);
492		let denominator = u64::from(dimensions.width) * 2;
493		((scaled + denominator / 2) / denominator)
494			.max(1)
495			.min(u64::from(u16::MAX)) as u16
496	});
497	ImgState { cells: Box::default(), width: width_cells.max(1), rows, phase: Load::Boxed }
498}
499
500/// Paint gate for untrimmed sources: a half-cell must be at least half
501/// covered, so transparent logo padding never paints.
502const PAINT_GATE: u64 = 128;
503/// Paint gate for trimmed thumbnails: the crop already removed padding, so
504/// any half-cell with meaningful coverage (≥ 12.5%) keeps its glyph color.
505const TRIMMED_PAINT_GATE: u64 = 32;
506
507fn sample_cells(
508	pixels: &[Vec<[u8; 4]>],
509	width_cells: u16,
510	height_cells: Option<u16>,
511	gate: u64,
512) -> ImgState {
513	let source_height = pixels.len();
514	let source_width = pixels[0].len();
515	let width = usize::from(width_cells.max(1));
516	let height = usize::from(height_cells.unwrap_or_else(|| {
517		let ratio = source_height as f32 / source_width as f32;
518		((f32::from(width_cells) * ratio) / 2.0).round().max(1.0) as u16
519	}));
520	let mut cells = Vec::with_capacity(width * height);
521	for cell_y in 0..height {
522		let upper_y0 = cell_y * 2 * source_height / (height * 2);
523		let upper_y1 = ((cell_y * 2 + 1) * source_height / (height * 2)).max(upper_y0 + 1);
524		let lower_y0 = (cell_y * 2 + 1) * source_height / (height * 2);
525		let lower_y1 = ((cell_y * 2 + 2) * source_height / (height * 2)).max(lower_y0 + 1);
526		for cell_x in 0..width {
527			let x0 = cell_x * source_width / width;
528			let x1 = ((cell_x + 1) * source_width / width).max(x0 + 1);
529			cells.push((
530				average_pixels(pixels, x0, x1, upper_y0, upper_y1, gate),
531				average_pixels(pixels, x0, x1, lower_y0, lower_y1, gate),
532			));
533		}
534	}
535	ImgState {
536		cells: cells.into_boxed_slice(),
537		width: width as u16,
538		rows:  height as u16,
539		phase: Load::Ready,
540	}
541}
542
543/// Alpha-weighted mean of one half-cell's source block; `None` when the
544/// block is mostly transparent, so logo padding never paints.
545fn average_pixels(
546	pixels: &[Vec<[u8; 4]>],
547	x0: usize,
548	x1: usize,
549	y0: usize,
550	y1: usize,
551	gate: u64,
552) -> Option<[u8; 3]> {
553	let mut color = [0_u64; 3];
554	let mut alpha = 0_u64;
555	let mut count = 0_u64;
556	for row in &pixels[y0.min(pixels.len() - 1)..y1.min(pixels.len())] {
557		for pixel in &row[x0.min(row.len() - 1)..x1.min(row.len())] {
558			let weight = u64::from(pixel[3]);
559			color[0] += u64::from(pixel[0]) * weight;
560			color[1] += u64::from(pixel[1]) * weight;
561			color[2] += u64::from(pixel[2]) * weight;
562			alpha += weight;
563			count += 1;
564		}
565	}
566	if count == 0 || alpha < count * gate {
567		return None;
568	}
569	Some([(color[0] / alpha) as u8, (color[1] / alpha) as u8, (color[2] / alpha) as u8])
570}
571
572#[cfg(test)]
573mod tests {
574	use super::*;
575	use crate::{
576		component::PaintCtx,
577		frame::{CellContent, Frame, Size},
578		test_support::frame_row_text,
579	};
580
581	#[test]
582	fn invalid_inline_base64_source_paints_placeholder_without_panicking() {
583		let mut image = Img::new()
584			.with(Prop::Src, "data:image/png;base64,AAAA")
585			.with(Prop::W, 12_u16);
586		let ctx = UiContext::default();
587		assert_eq!(image.height(&ctx, 12), 3);
588		let mut frame = Frame::new(Size::new(20, 3));
589		let mut hits = Vec::new();
590		image.paint(
591			&mut PaintCtx::new(&mut frame, &ctx, &mut hits, &mut Vec::new()),
592			Rect::new(0, 0, 12, 3),
593		);
594		assert!(frame_row_text(&frame, 1).contains("[img:"));
595	}
596
597	#[test]
598	fn indexed_palette_with_trns_expands_to_rgba() {
599		let mut bytes = Vec::new();
600		{
601			let mut encoder = png::Encoder::new(&mut bytes, 2, 2);
602			encoder.set_color(png::ColorType::Indexed);
603			encoder.set_depth(png::BitDepth::Eight);
604			encoder.set_palette(vec![255, 0, 0, 0, 0, 255]);
605			encoder.set_trns(vec![255, 0]);
606			let mut writer = encoder.write_header().unwrap();
607			writer.write_image_data(&[0, 0, 1, 1]).unwrap();
608		}
609		let pixels = decode_png(&bytes).unwrap();
610		assert_eq!(pixels[0][0], [255, 0, 0, 255], "palette index 0 is opaque red");
611		assert_eq!(pixels[1][1], [0, 0, 255, 0], "palette index 1 is transparent blue");
612	}
613
614	#[test]
615	fn transparent_pixels_sample_to_unpainted_halves() {
616		// 4x2 source at two cells: one pixel block per half-cell.
617		let red = [255_u8, 0, 0, 255];
618		let clear = [0_u8, 0, 0, 0];
619		let pixels = vec![vec![red, red, clear, clear], vec![clear, clear, clear, clear]];
620		let state = sample_cells(&pixels, 2, None, PAINT_GATE);
621		assert_eq!(state.rows, 1);
622		assert_eq!(&*state.cells, &[(Some([255, 0, 0]), None), (None, None)]);
623	}
624
625	#[test]
626	fn trim_recovers_padded_logos_at_thumbnail_sizes() {
627		// A 2x2 opaque glyph centered in an 8x8 transparent canvas: at one
628		// cell, every half-block averages under the alpha threshold.
629		let blue = [0_u8, 0, 255, 255];
630		let clear = [0_u8, 0, 0, 0];
631		let mut pixels = vec![vec![clear; 8]; 8];
632		for row in pixels.iter_mut().skip(3).take(2) {
633			for pixel in row.iter_mut().skip(3).take(2) {
634				*pixel = blue;
635			}
636		}
637		let padded = sample_cells(&pixels, 1, None, PAINT_GATE);
638		assert_eq!(&*padded.cells, &[(None, None)], "padding averages the glyph away");
639
640		let trimmed = sample_cells(&trim_transparent(pixels), 1, None, TRIMMED_PAINT_GATE);
641		assert_eq!(
642			&*trimmed.cells,
643			&[(Some([0, 0, 255]), Some([0, 0, 255]))],
644			"trimming crops to the glyph before sampling"
645		);
646
647		let empty = vec![vec![clear; 4]; 4];
648		assert_eq!(trim_transparent(empty).len(), 4, "fully transparent stays unchanged");
649	}
650
651	#[test]
652	fn alpha_background_stays_unpainted_in_cells_mode() {
653		let mut bytes = Vec::new();
654		{
655			let mut encoder = png::Encoder::new(&mut bytes, 4, 2);
656			encoder.set_color(png::ColorType::Rgba);
657			encoder.set_depth(png::BitDepth::Eight);
658			let mut writer = encoder.write_header().unwrap();
659			let mut data = vec![0_u8; 4 * 2 * 4];
660			// Opaque red in the top-left pixel block; everything else clear.
661			for x in 0..2 {
662				data[x * 4] = 255;
663				data[x * 4 + 3] = 255;
664			}
665			writer.write_image_data(&data).unwrap();
666		}
667		let path = std::env::temp_dir().join(format!("omp-tui-img-alpha-{}.png", std::process::id()));
668		std::fs::write(&path, bytes).unwrap();
669		let mut image = Img::new()
670			.with(Prop::Src, path.to_string_lossy().as_ref())
671			.with(Prop::W, 2_u16);
672		let ctx = UiContext::default();
673		assert_eq!(image.height(&ctx, 2), 1);
674		std::fs::remove_file(path).unwrap();
675
676		let mut frame = Frame::new(Size::new(3, 1));
677		image.paint(
678			&mut PaintCtx::new(&mut frame, &ctx, &mut Vec::new(), &mut Vec::new()),
679			Rect::new(0, 0, 2, 1),
680		);
681		// Opaque top half paints a foreground-only half block …
682		let painted = frame.cell(0, 0);
683		assert_eq!(painted.style.foreground_color(), Color::Rgb(255, 0, 0));
684		assert_eq!(painted.style.background_color(), Color::Default);
685		// … and the fully transparent cell is never touched.
686		assert_eq!(frame_row_text(&frame, 0), "▀");
687	}
688
689	#[test]
690	fn jpeg_header_reserves_aspect_correct_placeholder() {
691		let path = std::env::temp_dir().join(format!("omp-tui-img-jpeg-{}.jpg", std::process::id()));
692		let jpeg = [0xff, 0xd8, 0xff, 0xc0, 0x00, 0x08, 8, 0x00, 80, 0x00, 160, 1];
693		std::fs::write(&path, jpeg).unwrap();
694		let mut image = Img::new()
695			.with(Prop::Src, path.to_string_lossy().as_ref())
696			.with(Prop::W, 20_u16);
697		let ctx = UiContext::default();
698		assert_eq!(image.height(&ctx, 20), 5);
699		std::fs::remove_file(path).unwrap();
700
701		let mut frame = Frame::new(Size::new(20, 5));
702		image.paint(
703			&mut PaintCtx::new(&mut frame, &ctx, &mut Vec::new(), &mut Vec::new()),
704			Rect::new(0, 0, 20, 5),
705		);
706		assert!(frame_row_text(&frame, 2).contains("[img:"));
707		assert_ne!(frame_row_text(&frame, 4).trim(), "");
708	}
709
710	#[test]
711	fn kitty_mode_paints_typed_image_cells() {
712		let mut image = Img::new().kitty(0x12_34_56, 2, 3);
713		let ctx = UiContext { graphics: Graphics::KittyPlaceholders, ..UiContext::default() };
714		assert_eq!(image.height(&ctx, 20), 2);
715		let mut frame = Frame::new(Size::new(3, 2));
716		image.paint(
717			&mut PaintCtx::new(&mut frame, &ctx, &mut Vec::new(), &mut Vec::new()),
718			Rect::new(0, 0, 3, 2),
719		);
720		assert!(matches!(frame.cell(2, 1).content, CellContent::Image {
721			id:   0x12_34_56,
722			row:  1,
723			col:  2,
724			rows: 2,
725			cols: 3,
726		}));
727	}
728
729	#[test]
730	fn png_src_auto_interns_placeholder_cells_without_registration() {
731		let logo = concat!(env!("CARGO_MANIFEST_DIR"), "/examples/assets/login/anthropic.png");
732		let mut image = Img::new()
733			.with(Prop::Src, logo)
734			.with(Prop::W, 2_u16)
735			.with(Prop::H, 1_u16);
736		let ctx = UiContext { graphics: Graphics::KittyPlaceholders, ..UiContext::default() };
737		assert_eq!(image.measure(&ctx), (2, 1));
738		let mut frame = Frame::new(Size::new(3, 1));
739		image.paint(
740			&mut PaintCtx::new(&mut frame, &ctx, &mut Vec::new(), &mut Vec::new()),
741			Rect::new(0, 0, 2, 1),
742		);
743		let CellContent::Image { id, row: 0, col: 1, rows: 1, cols: 2 } = frame.cell(1, 0).content
744		else {
745			panic!("src image paints typed placeholder cells: {:?}", frame.cell(1, 0).content);
746		};
747		assert!(id > 0x00f0_0000, "registry IDs allocate from the top of the 24-bit range");
748
749		// The same source in a second component shares the interned ID.
750		let mut sibling = Img::new()
751			.with(Prop::Src, logo)
752			.with(Prop::W, 2_u16)
753			.with(Prop::H, 1_u16);
754		let mut second = Frame::new(Size::new(3, 1));
755		sibling.paint(
756			&mut PaintCtx::new(&mut second, &ctx, &mut Vec::new(), &mut Vec::new()),
757			Rect::new(0, 0, 2, 1),
758		);
759		assert!(
760			matches!(second.cell(0, 0).content, CellContent::Image { id: other, .. } if other == id)
761		);
762
763		// Cells tier ignores the interned box and keeps the half-block path.
764		let cells_ctx = UiContext::default();
765		let mut fallback = Img::new()
766			.with(Prop::Src, logo)
767			.with(Prop::W, 2_u16)
768			.with(Prop::H, 1_u16);
769		assert_eq!(fallback.height(&cells_ctx, 2), 1);
770	}
771}