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 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
50pub struct Img {
63 props: Props,
64 slot: Slot,
65 state: ImgState,
66 kitty: Option<(u32, u16, u16)>,
67 auto: AutoBox,
69 top: String,
70 bottom: String,
71}
72
73impl Img {
74 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 pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
89 self.props.set(prop, value);
90 self
91 }
92
93 pub fn with_str(mut self, prop: Prop, value: &str) -> Self {
95 self.props.set(prop, value);
96 self
97 }
98
99 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 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 pub(crate) fn apply_decoded(&mut self, state: ImgState) {
166 if self.state.phase == Load::Loading {
167 self.state = state;
168 }
169 }
170}
171
172fn 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 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}
346pub 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
374fn 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 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
500const PAINT_GATE: u64 = 128;
503const 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
543fn 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 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 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 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 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 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 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 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}