abstracttui 0.2.7

A reactive, compositor-grade terminal UI engine: fine-grained signals, layered rendering with damage tracking, images (kitty/iTerm2/sixel/mosaic), software-rasterized 3D (GLB), themes and animation.
Documentation
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
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
//! The one image pipeline: capability ladder + a facade that turns a
//! Bitmap + target cell rect into either mosaic cell patches or a
//! protocol byte payload for `Presenter::external_write(bytes, at)`
//! (damage contract §6 — gfx never touches the terminal).
//!
//! Ladder: kitty > iterm2 > sixel > mosaic (docs/design/gfx-three.md
//! §1). Every degradation is labeled with a `#FALLBACK` warning string
//! on the result — never silent.

use crate::base::{Point, Rect, Rgba};
use crate::gfx::bitmap::Bitmap;
use crate::gfx::mosaic::{blit_into, CellPatch, MosaicMode, MosaicRenderer};
use crate::gfx::proto::{iterm2, kitty, sixel};
use crate::term::caps::{GraphicsCaps, WrapKind};

/// Which output channel a render used (or would use).
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum Channel {
    Kitty,
    Iterm2,
    Sixel,
    Mosaic,
}

/// Capability ladder, best channel first. Pure over capability bits:
/// kitty and iTerm2 scale server-side via cell-fit keys, so they only
/// need their protocol bit; sixel additionally needs the cell pixel
/// geometry to rasterize at the right size — without it the ladder
/// falls through to mosaic (the caller gets a labeled warning from
/// [`ImageRenderer::render`], which knows a rung was skipped).
pub fn choose_channel(caps: &GraphicsCaps) -> Channel {
    if caps.kitty_graphics {
        Channel::Kitty
    } else if caps.iterm2_images {
        Channel::Iterm2
    } else if caps.sixel && caps.cell_pixel_size.is_some_and(|p| !p.is_empty()) {
        Channel::Sixel
    } else {
        Channel::Mosaic
    }
}

/// What to do with a rendered image.
#[derive(Clone, Debug)]
pub enum ImageOutput {
    /// Write these cells into a surface (RENDER's `blit_mosaic` or the
    /// `CellPatch` bridge).
    Cells(Vec<CellPatch>),
    /// Hand these bytes to `Presenter::external_write(bytes, at)`.
    Bytes { bytes: Vec<u8>, at: Point },
}

/// A render result: channel used, output, labeled degradations.
#[derive(Clone, Debug)]
pub struct RenderedImage {
    pub channel: Channel,
    pub output: ImageOutput,
    /// `#FALLBACK`-prefixed, human-readable degradation labels.
    pub warnings: Vec<String>,
    /// The kitty image id when `channel == Kitty` (callers need it to
    /// delete/re-place the image later).
    pub kitty_id: Option<u32>,
}

/// Mosaic rung options (cycle-4 quality pass).
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct MosaicOpts {
    /// Glyph family.
    pub mode: MosaicMode,
    /// Pre-quantize + Floyd–Steinberg the source to this many colors
    /// before cell fitting. `None` = truecolor (the default — mosaic
    /// itself is truecolor; dithering earns its keep when the OUTPUT
    /// terminal is 256/16-color, where the presenter's quantization
    /// would otherwise band gradients).
    pub dither: Option<u16>,
}

impl Default for MosaicOpts {
    fn default() -> Self {
        MosaicOpts {
            mode: MosaicMode::HalfBlock,
            dither: None,
        }
    }
}

/// Pipeline configuration (all fields have working defaults).
#[derive(Clone, Debug)]
pub struct RenderConfig {
    /// Mosaic rung options (glyph family + optional dither).
    pub mosaic: MosaicOpts,
    /// Kitty wire format. RGBA+zlib is the deterministic default;
    /// `Png` is smaller for flat-color content.
    pub kitty_format: kitty::Format,
    /// z-index for pixel placements (negative = under text).
    pub z: i32,
    /// Sixel register budget before caps clamping.
    pub sixel_registers: u16,
    /// Sixel dithering.
    pub sixel_dither: bool,
}

impl Default for RenderConfig {
    fn default() -> Self {
        RenderConfig {
            mosaic: MosaicOpts::default(),
            kitty_format: kitty::Format::Rgba32,
            z: 0,
            sixel_registers: 64,
            sixel_dither: true,
        }
    }
}

/// Reusable facade: owns the scratch buffers (mosaic renderer, sixel
/// scaling bitmap, patch vec) and the kitty image-id counter so frame
/// loops allocate nothing in steady state.
pub struct ImageRenderer {
    pub config: RenderConfig,
    mosaic: MosaicRenderer,
    patches: Vec<CellPatch>,
    sixel_scratch: Bitmap,
    mosaic_scratch: Bitmap,
    next_kitty_id: u32,
}

/// Where pixel-protocol bytes go: RENDER's `Presenter::external_write`
/// bracket, behind a one-method seam so app code and tests do not
/// depend on the concrete presenter type.
pub trait ExternalSink {
    fn external_write(&mut self, bytes: &[u8], at: Point);
}

/// App-level image presentation through the full capability ladder —
/// the PROTOCOL path the `widgets::Image` draw closure deliberately
/// cannot take (draw closures own cells, not the wire; damage contract
/// §6 puts escape bytes under presenter custody AFTER the cell diff).
///
/// Byte channels (kitty/iTerm2/sixel) are written to `sink`
/// immediately; the mosaic fallback comes back as cell patches for the
/// caller to blit into a surface. Returns the full `RenderedImage`
/// (channel, warnings, kitty id for later deletes).
pub fn present_image(
    renderer: &mut ImageRenderer,
    sink: &mut dyn ExternalSink,
    img: &Bitmap,
    rect: Rect,
    caps: &GraphicsCaps,
) -> RenderedImage {
    let rendered = renderer.render(img, rect, caps);
    if let ImageOutput::Bytes { bytes, at } = &rendered.output {
        sink.external_write(bytes, *at);
    }
    rendered
}

impl Default for ImageRenderer {
    fn default() -> Self {
        ImageRenderer {
            config: RenderConfig::default(),
            mosaic: MosaicRenderer::new(),
            patches: Vec::new(),
            sixel_scratch: Bitmap::default(),
            mosaic_scratch: Bitmap::default(),
            next_kitty_id: 1,
        }
    }
}

impl ImageRenderer {
    pub fn new() -> ImageRenderer {
        ImageRenderer::default()
    }

    /// Render `img` into the cell rect `rect` using the best channel
    /// `caps` offers. `rect` is in screen cells; `rect.origin()` is
    /// where the presenter must park the cursor for byte payloads.
    pub fn render(&mut self, img: &Bitmap, rect: Rect, caps: &GraphicsCaps) -> RenderedImage {
        let mut warnings = Vec::new();
        if rect.is_empty() || img.is_empty() {
            return RenderedImage {
                channel: Channel::Mosaic,
                output: ImageOutput::Cells(Vec::new()),
                warnings,
                kitty_id: None,
            };
        }
        let (cols, rows) = (rect.w as u32, rect.h as u32);
        let channel = choose_channel(caps);
        // A skipped sixel rung is a degradation the user should see.
        if caps.sixel && channel == Channel::Mosaic {
            warnings.push(
                "#FALLBACK sixel advertised but cell pixel size unknown; using unicode mosaic"
                    .to_string(),
            );
        }

        let mut rendered = match channel {
            Channel::Kitty => {
                let id = self.next_kitty_id;
                // Wrapping add skipping 0: id 0 means "terminal picks"
                // and would make deletes impossible.
                self.next_kitty_id = self.next_kitty_id.checked_add(1).unwrap_or(1);
                let opts = kitty::Options {
                    id,
                    format: self.config.kitty_format,
                    fit_cols: Some(cols),
                    fit_rows: Some(rows),
                    z: self.config.z,
                    compress: true,
                };
                RenderedImage {
                    channel,
                    output: ImageOutput::Bytes {
                        bytes: kitty::transmit_display(img, &opts),
                        at: rect.origin(),
                    },
                    warnings,
                    kitty_id: Some(id),
                }
            }
            Channel::Iterm2 => {
                let opts = iterm2::Options {
                    fit_cols: Some(cols),
                    fit_rows: Some(rows),
                    preserve_aspect: true,
                };
                RenderedImage {
                    channel,
                    output: ImageOutput::Bytes {
                        bytes: iterm2::inline_png(img, &opts),
                        at: rect.origin(),
                    },
                    warnings,
                    kitty_id: None,
                }
            }
            Channel::Sixel => {
                let cell = caps.cell_pixel_size.expect("choose_channel checked");
                let (pw, ph) = (cols * cell.w as u32, rows * cell.h as u32);
                // Honor the terminal's register report; keep our
                // default budget when it is silent or generous.
                let mut registers = self.config.sixel_registers;
                if let Some(max) = caps.sixel_max_registers {
                    if max < registers {
                        registers = max;
                        warnings.push(format!(
                            "#FALLBACK terminal caps sixel palette at {max} registers"
                        ));
                    }
                }
                let source: &Bitmap = if img.width() == pw && img.height() == ph {
                    img
                } else {
                    if self.sixel_scratch.width() != pw || self.sixel_scratch.height() != ph {
                        self.sixel_scratch = Bitmap::new(pw, ph, Rgba::TRANSPARENT);
                    }
                    img.resize_bilinear_into(&mut self.sixel_scratch);
                    &self.sixel_scratch
                };
                let opts = sixel::Options {
                    max_registers: registers,
                    dither: self.config.sixel_dither,
                    register_base: 0,
                };
                RenderedImage {
                    channel,
                    output: ImageOutput::Bytes {
                        bytes: sixel::encode(source, &opts),
                        at: rect.origin(),
                    },
                    warnings,
                    kitty_id: None,
                }
            }
            Channel::Mosaic => {
                let opts = self.config.mosaic;
                let source: &Bitmap = match opts.dither {
                    None => img,
                    Some(colors) => {
                        // Low-color pre-pass: quantize + error-diffuse
                        // into the reusable scratch, then cell-fit.
                        if self.mosaic_scratch.width() != img.width()
                            || self.mosaic_scratch.height() != img.height()
                        {
                            self.mosaic_scratch =
                                Bitmap::new(img.width(), img.height(), Rgba::TRANSPARENT);
                        }
                        self.mosaic_scratch
                            .pixels_mut()
                            .copy_from_slice(img.pixels());
                        let palette =
                            crate::gfx::quantize::median_cut(img.pixels(), colors.max(2) as usize);
                        crate::gfx::dither::floyd_steinberg(&mut self.mosaic_scratch, &palette);
                        &self.mosaic_scratch
                    }
                };
                self.mosaic.render(source, cols, rows, opts.mode);
                blit_into(self.mosaic.grid(), rect.origin(), &mut self.patches);
                RenderedImage {
                    channel,
                    output: ImageOutput::Cells(std::mem::take(&mut self.patches)),
                    warnings,
                    kitty_id: None,
                }
            }
        };

        // Multiplexer passthrough (KERNEL's cycle-4 detection): when the
        // outer terminal must receive the payload THROUGH tmux, every
        // escape payload is wrapped — verified passthrough only, so this
        // is routing, not degradation (no warning label).
        if let (Some(WrapKind::Tmux), ImageOutput::Bytes { bytes, .. }) =
            (caps.wrap, &mut rendered.output)
        {
            *bytes = tmux_wrap_per_escape(bytes);
        }
        rendered
    }
}

/// tmux passthrough with ONE wrapper per escape frame. tmux discards
/// any single input sequence over 1 MiB (hard input-buffer cap, tmux
/// issue #487), so wrapping a whole chunked kitty transmission in a
/// single `ESC Ptmux; … ESC \` frame silently drops large images at
/// the multiplexer. Splitting at the ST boundaries our emitters
/// produce keeps every wrapper ~4 KiB (one APC chunk). A payload
/// without an ST terminator (the BEL-terminated iTerm2 OSC) is a
/// single escape by construction and wraps whole; a sixel DCS is one
/// ST-terminated frame and is unchanged by the split.
fn tmux_wrap_per_escape(bytes: &[u8]) -> Vec<u8> {
    let mut out = Vec::with_capacity(bytes.len() + bytes.len() / 8 + 32);
    let mut rest = bytes;
    while !rest.is_empty() {
        match rest.windows(2).position(|w| w == b"\x1b\\") {
            Some(p) => {
                out.extend_from_slice(&crate::term::tmux_wrap(&rest[..p + 2]));
                rest = &rest[p + 2..];
            }
            None => {
                out.extend_from_slice(&crate::term::tmux_wrap(rest));
                break;
            }
        }
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::base::PixelSize;

    fn caps(kitty: bool, iterm2: bool, sixel: bool, px: Option<PixelSize>) -> GraphicsCaps {
        GraphicsCaps {
            wrap: None,
            kitty_graphics: kitty,
            iterm2_images: iterm2,
            sixel,
            sixel_max_registers: None,
            cell_pixel_size: px,
        }
    }

    #[test]
    fn ladder_order() {
        let px = Some(PixelSize::new(8, 16));
        assert_eq!(choose_channel(&caps(true, true, true, px)), Channel::Kitty);
        assert_eq!(
            choose_channel(&caps(false, true, true, px)),
            Channel::Iterm2
        );
        assert_eq!(
            choose_channel(&caps(false, false, true, px)),
            Channel::Sixel
        );
        assert_eq!(
            choose_channel(&caps(false, false, false, px)),
            Channel::Mosaic
        );
        // Sixel without pixel geometry cannot rasterize honestly.
        assert_eq!(
            choose_channel(&caps(false, false, true, None)),
            Channel::Mosaic
        );
        assert_eq!(
            choose_channel(&caps(false, false, true, Some(PixelSize::new(0, 0)))),
            Channel::Mosaic
        );
    }

    #[test]
    fn kitty_render_bytes_and_ids() {
        let mut r = ImageRenderer::new();
        let img = Bitmap::new(4, 4, Rgba::rgb(9, 9, 9));
        let rect = Rect::new(3, 2, 10, 5);
        let out = r.render(&img, rect, &caps(true, false, false, None));
        assert_eq!(out.channel, Channel::Kitty);
        assert_eq!(out.kitty_id, Some(1));
        let ImageOutput::Bytes { bytes, at } = &out.output else {
            panic!("expected bytes")
        };
        assert_eq!(*at, Point::new(3, 2));
        let s = String::from_utf8_lossy(bytes);
        assert!(s.contains("c=10") && s.contains("r=5"));
        // Ids advance per image.
        let out2 = r.render(&img, rect, &caps(true, false, false, None));
        assert_eq!(out2.kitty_id, Some(2));
        assert!(out.warnings.is_empty());
    }

    #[test]
    fn iterm2_render_shape() {
        let mut r = ImageRenderer::new();
        let img = Bitmap::new(4, 4, Rgba::rgb(1, 2, 3));
        let out = r.render(&img, Rect::new(0, 0, 8, 4), &caps(false, true, false, None));
        assert_eq!(out.channel, Channel::Iterm2);
        let ImageOutput::Bytes { bytes, .. } = &out.output else {
            panic!()
        };
        assert!(bytes.starts_with(b"\x1b]1337;File="));
    }

    #[test]
    fn sixel_render_scales_to_cell_pixels() {
        let mut r = ImageRenderer::new();
        let img = Bitmap::new(4, 4, Rgba::rgb(255, 0, 0));
        let px = Some(PixelSize::new(10, 20));
        let out = r.render(&img, Rect::new(0, 0, 3, 2), &caps(false, false, true, px));
        assert_eq!(out.channel, Channel::Sixel);
        let ImageOutput::Bytes { bytes, .. } = &out.output else {
            panic!()
        };
        let s = String::from_utf8_lossy(bytes);
        // Raster attributes carry the pixel geometry 30x40.
        assert!(s.contains("\"1;1;30;40"), "{s}");
    }

    #[test]
    fn sixel_register_cap_warns_labeled() {
        let mut r = ImageRenderer::new();
        let img = Bitmap::from_fn(8, 8, |x, y| Rgba::rgb((x * 30) as u8, (y * 30) as u8, 0));
        let mut c = caps(false, false, true, Some(PixelSize::new(4, 8)));
        c.sixel_max_registers = Some(16);
        let out = r.render(&img, Rect::new(0, 0, 4, 2), &c);
        assert!(out
            .warnings
            .iter()
            .any(|w| w.starts_with("#FALLBACK") && w.contains("16")));
    }

    #[test]
    fn mosaic_fallback_with_label_when_sixel_unusable() {
        let mut r = ImageRenderer::new();
        let img = Bitmap::new(4, 4, Rgba::rgb(0, 200, 0));
        let out = r.render(&img, Rect::new(1, 1, 2, 2), &caps(false, false, true, None));
        assert_eq!(out.channel, Channel::Mosaic);
        let ImageOutput::Cells(cells) = &out.output else {
            panic!()
        };
        assert_eq!(cells.len(), 4);
        assert_eq!(cells[0].pos, Point::new(1, 1));
        assert!(out.warnings.iter().any(|w| w.starts_with("#FALLBACK")));
    }

    #[test]
    fn plain_mosaic_has_no_warning() {
        let mut r = ImageRenderer::new();
        let img = Bitmap::new(2, 2, Rgba::WHITE);
        let out = r.render(
            &img,
            Rect::new(0, 0, 1, 1),
            &caps(false, false, false, None),
        );
        assert_eq!(out.channel, Channel::Mosaic);
        assert!(
            out.warnings.is_empty(),
            "no degradation happened: {:?}",
            out.warnings
        );
    }

    #[test]
    fn empty_inputs_are_noops() {
        let mut r = ImageRenderer::new();
        let img = Bitmap::new(2, 2, Rgba::WHITE);
        let out = r.render(&img, Rect::ZERO, &caps(true, false, false, None));
        assert!(matches!(&out.output, ImageOutput::Cells(c) if c.is_empty()));
        let out = r.render(
            &Bitmap::default(),
            Rect::new(0, 0, 2, 2),
            &caps(true, false, false, None),
        );
        assert!(matches!(&out.output, ImageOutput::Cells(c) if c.is_empty()));
    }

    #[test]
    fn tmux_wrap_is_per_escape_so_the_1mib_input_cap_cannot_bite() {
        // A 64x64 uncompressed RGBA transmit chunks into several APCs;
        // the tmux route must wrap EACH ONE (tmux discards any single
        // input sequence over 1 MiB — issue #487 — so one wrapper
        // around the whole stream silently drops big images).
        let mut r = ImageRenderer::new();
        r.config.kitty_format = kitty::Format::Rgba32;
        // Hash noise defeats the zlib pass, so the payload stays
        // multi-chunk (the property below must cover the chunked form,
        // not collapse into the trivial single-frame case).
        let img = Bitmap::from_fn(96, 96, |x, y| {
            let mut h = x.wrapping_mul(0x9E37_79B9) ^ y.wrapping_mul(0x85EB_CA6B);
            h ^= h >> 13;
            h = h.wrapping_mul(0xC2B2_AE35);
            h ^= h >> 16;
            Rgba::new((h >> 16) as u8, (h >> 8) as u8, h as u8, 255)
        });
        let mut c = caps(true, false, false, None);
        c.wrap = Some(WrapKind::Tmux);
        let out = r.render(&img, Rect::new(0, 0, 8, 4), &c);
        let ImageOutput::Bytes { bytes, .. } = &out.output else {
            panic!("kitty bytes expected")
        };
        let wrappers = bytes.windows(7).filter(|w| *w == b"\x1bPtmux;").count();
        let unwrapped = crate::testing::unwrap_tmux(bytes);
        let inner_frames = unwrapped.windows(3).filter(|w| *w == b"\x1b_G").count();
        assert!(inner_frames > 1, "noise payload must chunk: {inner_frames}");
        assert_eq!(
            wrappers, inner_frames,
            "every APC escape gets its own tmux wrapper"
        );
        // Guard the BOUND, not just the census: each wrapper is one APC
        // chunk (≤4096 encoded bytes) + keys + ESC doubling + framing —
        // a few KiB, three orders of magnitude under tmux's 1 MiB
        // discard cap. A chunking regression must not hide behind a
        // passing wrapper count. (Wrappers are emitted back to back, so
        // consecutive header offsets delimit them.)
        let starts: Vec<usize> = (0..bytes.len())
            .filter(|&i| bytes[i..].starts_with(b"\x1bPtmux;"))
            .collect();
        let max_wrapper = starts
            .iter()
            .enumerate()
            .map(|(i, &s)| starts.get(i + 1).copied().unwrap_or(bytes.len()) - s)
            .max()
            .unwrap_or(0);
        assert!(
            max_wrapper <= 16 * 1024,
            "a single tmux wrapper reached {max_wrapper} bytes — the 1 MiB discard cap is back in range"
        );
        // Byte-exact round trip: unwrapping reproduces the direct
        // emission exactly.
        let mut direct = ImageRenderer::new();
        let plain_caps = caps(true, false, false, None);
        let direct_out = direct.render(&img, Rect::new(0, 0, 8, 4), &plain_caps);
        let ImageOutput::Bytes { bytes: plain, .. } = &direct_out.output else {
            panic!()
        };
        assert_eq!(&unwrapped, plain, "unwrap(wrap(x)) must be byte-exact");
    }

    #[test]
    fn tmux_wrap_applies_to_protocol_bytes_only() {
        let mut r = ImageRenderer::new();
        let img = Bitmap::new(2, 2, Rgba::WHITE);
        let mut c = caps(true, false, false, None);
        c.wrap = Some(WrapKind::Tmux);
        let out = r.render(&img, Rect::new(0, 0, 2, 2), &c);
        let ImageOutput::Bytes { bytes, .. } = &out.output else {
            panic!("kitty channel expected")
        };
        assert!(bytes.starts_with(b"\x1bPtmux;"), "passthrough header");
        assert!(bytes.ends_with(b"\x1b\\"));
        // Inner ESCs doubled: the kitty APC intro appears as ESC ESC _G.
        assert!(
            bytes.windows(4).any(|w| w == b"\x1b\x1b_G"),
            "inner escapes must be doubled"
        );
        // Mosaic output is cells — never wrapped.
        let mut c2 = caps(false, false, false, None);
        c2.wrap = Some(WrapKind::Tmux);
        let out = r.render(&img, Rect::new(0, 0, 1, 1), &c2);
        assert!(matches!(out.output, ImageOutput::Cells(_)));
    }

    #[test]
    fn present_image_routes_bytes_to_the_sink() {
        struct Sink(Vec<(Vec<u8>, Point)>);
        impl ExternalSink for Sink {
            fn external_write(&mut self, bytes: &[u8], at: Point) {
                self.0.push((bytes.to_vec(), at));
            }
        }
        let mut r = ImageRenderer::new();
        let mut sink = Sink(Vec::new());
        let img = Bitmap::new(2, 2, Rgba::WHITE);

        // Kitty channel: bytes reach the sink with the rect origin.
        let out = present_image(
            &mut r,
            &mut sink,
            &img,
            Rect::new(4, 2, 6, 3),
            &caps(true, false, false, None),
        );
        assert_eq!(out.channel, Channel::Kitty);
        assert_eq!(sink.0.len(), 1);
        assert_eq!(sink.0[0].1, Point::new(4, 2));

        // Mosaic channel: nothing written, cells returned.
        let out = present_image(
            &mut r,
            &mut sink,
            &img,
            Rect::new(0, 0, 2, 1),
            &caps(false, false, false, None),
        );
        assert_eq!(sink.0.len(), 1, "mosaic writes no bytes");
        assert!(matches!(&out.output, ImageOutput::Cells(c) if !c.is_empty()));
    }

    #[test]
    fn mosaic_grid_cell_patches_iterator_shape() {
        // The exact tuple shape RENDER's blit_mosaic consumes.
        let img = Bitmap::from_fn(2, 2, |x, _| {
            if x == 0 {
                Rgba::rgb(255, 0, 0)
            } else {
                Rgba::rgb(0, 0, 255)
            }
        });
        let grid = crate::gfx::mosaic::render(&img, 2, 1, MosaicMode::HalfBlock);
        let v: Vec<(Point, char, Rgba, Rgba)> = grid.cell_patches(Point::new(5, 6)).collect();
        assert_eq!(v.len(), 2);
        assert_eq!(v[0].0, Point::new(5, 6));
        assert_eq!(v[1].0, Point::new(6, 6));
        assert_eq!(v[0].2, Rgba::rgb(255, 0, 0)); // uniform column -> bg==fg
    }
}