edamame 0.1.4

A fast TUI Markdown editor and viewer
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
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
//! Decoded-image cache retained across reparses; see docs/dev/media-export.md.
//!
//! `ParsedDoc` is rebuilt on every buffer mutation, so decoded bytes and their expensive
//! `StatefulProtocol` encodings live on `EditorState` keyed by URL instead.  Protocols are keyed
//! additionally by target cell dimensions, so a resize invalidates only the affected entries.

use std::collections::{HashMap, VecDeque};
use std::sync::{mpsc, Arc};

use image::DynamicImage;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::widgets::StatefulWidget;
use ratatui_image::picker::{Picker, ProtocolType};
use ratatui_image::thread::{ResizeRequest, ResizeResponse, ThreadProtocol};
use ratatui_image::{Resize, StatefulImage};

/// Encode `image` as halfblocks at `rect`, returning the rendered cells.
///
/// **Only `picker`'s font size is used; the protocol is forced to `Halfblocks`.** The scratch
/// must be position-independent cells so `paint_halfblocks_partial` can clip it by row, while
/// keeping the native protocol's aspect ratio so the image doesn't change shape crossing the
/// native↔halfblocks boundary mid-scroll.  A native encoding puts the whole image in one cell as
/// a single escape sequence, which cannot be clipped at all.
///
/// Cheap (low single-digit ms on pre-resized images), so either thread may call it; the decode
/// worker does so right after pre-resizing, leaving the UI thread's first paint a cache hit.
pub fn render_halfblocks_scratch(picker: &Picker, image: DynamicImage, rect: Rect) -> Buffer {
    let mut picker = picker.clone();
    picker.set_protocol_type(ProtocolType::Halfblocks);
    let mut protocol = picker.new_resize_protocol(image);
    let mut buf = Buffer::empty(rect);
    StatefulImage::default()
        .resize(Resize::Fit(None))
        .render(rect, &mut buf, &mut protocol);
    buf
}

/// Free-function twin of [`ImageCache::aspect_rows`] over a borrowed image, for the decode
/// worker's scratch-height calculation.
///
/// Mirrors the paint path's `Resize::Fit(None)`, which scales down but never up, so the height is
/// capped at the image's own pixel height — without that cap a small image reserves the rows it
/// *would* fill at column width, leaving a blank band below it.
pub fn aspect_rows_of(
    image: &DynamicImage,
    max_width_cells: u16,
    max_height_cells: u16,
    font_size: (u16, u16),
) -> usize {
    let (fw, fh) = (u32::from(font_size.0.max(1)), u32::from(font_size.1.max(1)));
    let box_w_px = u32::from(max_width_cells).saturating_mul(fw);
    let box_h_px = u32::from(max_height_cells).saturating_mul(fh);
    let iw = image.width().max(1);
    let ih = image.height().max(1);
    if box_w_px == 0 || box_h_px == 0 {
        return 0;
    }
    let h_if_width_binds = (u64::from(ih) * u64::from(box_w_px)) / u64::from(iw);
    let fitted_h_px = h_if_width_binds.min(u64::from(box_h_px)).min(u64::from(ih));
    let rows = fitted_h_px.div_ceil(u64::from(fh));
    (rows.clamp(1, u64::from(max_height_cells)) as usize).max(1)
}

/// Status of a decode attempt for a URL.
pub enum DecodeStatus {
    /// Decode in flight; `paint_images` shows the `[Image: alt]` placeholder meanwhile.
    Pending,
    /// Decode succeeded.  The pixels are kept in an `Arc` so a protocol can be rebuilt at a new
    /// size without re-running the slow PNG/JPEG decode or duplicating the bytes.
    Ready(Arc<DynamicImage>),
    /// Decode failed (IO, remote-blocked, corrupt bytes).  Never retried automatically.  The
    /// message is captured for future surfacing but has no consumer yet.
    Failed(#[allow(dead_code)] String),
}

/// Metadata for an in-flight resize-encode request.  ratatui-image's `ResizeResponse` carries
/// only a protocol-local id with no public accessor, so responses are routed back by keeping our
/// own FIFO — exact, because the worker is serial.
struct PendingResize {
    url: String,
    width: u16,
    height: u16,
}

/// Record of a native-protocol transmission that is *still on screen*, so an unchanged image at
/// an unchanged rect can be marked `skip` instead of re-rendered.
///
/// Sixel and iTerm2 re-deliver the whole PNG on every render, writing the entire base64 payload
/// into one cell's `symbol`.  Re-emitting that is doubly wrong: the escape starts with an ECH
/// sweep, so the terminal blanks and redraws (a flash); and `Buffer::diff` carries
/// `invalidated = max(symbol.width(), invalidated) - 1` forward, so a 100 000-column payload
/// symbol forces **every** later cell — including another image's payload — to re-emit each
/// frame, which reads as a ~2 Hz flicker.
///
/// A record is honored only on the *immediately* following frame, so any frame that paints the
/// scratch there, suppresses the block, or scrolls it off screen invalidates it automatically.
/// Kitty needs none of this, but the bookkeeping is protocol-blind and costs it nothing.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct NativePaint {
    /// Screen rect the escape was rendered at.
    pub rect: Rect,
    /// `ProtocolPair::native_generation` at render time.
    pub generation: u64,
    /// `ImageCache::frame_seq` at render time.
    pub frame: u64,
}

/// Both encoded representations of one image at one target size.
///
/// `native` is the terminal's graphics protocol (Kitty / Sixel / iTerm2) in a `ThreadProtocol`,
/// so its first encode — up to hundreds of milliseconds — runs on the encoder worker.  It is
/// `None` when the detected protocol IS halfblocks, since the scratch is then the rendering.
///
/// `halfblocks_scratch` is built synchronously on the cold path (fast on pre-resized images) so a
/// finished decode shows immediately with no placeholder flash; paint upgrades to `native` once
/// `native_ready` is set by `apply_resize_response`.
pub struct ProtocolPair {
    pub native: Option<ThreadProtocol>,
    pub native_ready: bool,
    /// Bumped whenever the worker hands back freshly encoded native bytes; with the screen rect
    /// it identifies "the bytes currently on screen" for [`Self::last_native_paint`].
    ///
    /// Defense in depth: the branch that bumps it also clears `last_native_paint`, so
    /// `paint_native` never sees a mismatch today — it exists so a future path that swaps the
    /// bytes *without* clearing the record can't license a stale skip.
    pub native_generation: u64,
    /// The last frame the native escape was written on, and what it carried.  See [`NativePaint`].
    pub last_native_paint: Option<NativePaint>,
    /// Pre-rendered halfblocks cells for this `(url, width, height)`: the fallback rendering while
    /// `native` encodes, during scroll on non-Kitty terminals, and during partial visibility.
    pub halfblocks_scratch: Option<Buffer>,
}

/// Cache of decoded images + per-size protocol encodings.
#[derive(Default)]
pub struct ImageCache {
    /// URL → decode status.
    decoded: HashMap<String, DecodeStatus>,
    /// (URL, cell-width, cell-height) → encoded protocol pair, built lazily on first draw at that
    /// size.  A plain `HashMap` with no LRU: the working set is bounded by the visible images.
    protocols: HashMap<(String, u16, u16), ProtocolPair>,
    /// Halfblocks scratches pre-built on the decode worker, awaiting the `get_protocol_pair` call
    /// that claims them.  Entries that never match (terminal resized between decode and first
    /// paint) stay until `set_decoded` or `invalidate_protocols` clears them.
    prebuilt_scratches: HashMap<(String, u16, u16), Buffer>,
    /// Outstanding encode requests, FIFO in dispatch order.
    pending: VecDeque<PendingResize>,
    /// Sender into the encoder worker, cloned into each `ThreadProtocol`.  `None` disables image
    /// rendering entirely (tests, terminals without image support).
    resize_tx: Option<mpsc::Sender<ResizeRequest>>,
    /// Monotonic frame counter, bumped once per `terminal.draw`.  See [`NativePaint`].
    frame_seq: u64,
}

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

    /// Attach the encoder worker's sender, from `App::run` once the worker is spawned.
    ///
    /// Doing so drops every cached protocol: their internal sender is tied to the old channel and
    /// would ship requests into a dead endpoint.  Decoded pixels are retained.
    pub fn attach_resize_sender(&mut self, tx: mpsc::Sender<ResizeRequest>) {
        self.resize_tx = Some(tx);
        self.protocols.clear();
        self.pending.clear();
        self.prebuilt_scratches.clear();
    }

    /// Whether an encoder-worker sender has been attached.  Exists for the App-level test that a
    /// document swap re-attaches it — without one every image paints as a placeholder.
    pub fn has_resize_sender(&self) -> bool {
        self.resize_tx.is_some()
    }

    // ── Native-transmission bookkeeping ───────────────────────────────

    /// Advance the frame counter.  Driven from `App::draw_frame`, *not* the paint pass: Raw and
    /// Diff modes draw without painting images, and counting only painted frames would make a
    /// pre-Raw transmission look adjacent to the first frame back in Rendered.
    pub fn begin_frame(&mut self) {
        self.frame_seq = self.frame_seq.wrapping_add(1);
    }

    pub fn frame_seq(&self) -> u64 {
        self.frame_seq
    }

    /// Forget every recorded native transmission, forcing a re-transmit on the next paint.  For
    /// anything that invalidates the screen outside the paint pass: a resize, or the
    /// `terminal.clear()` after the external editor returns.
    pub fn invalidate_native_paints(&mut self) {
        for pair in self.protocols.values_mut() {
            pair.last_native_paint = None;
        }
    }

    /// Mark `url` as `Pending` iff it has no prior entry, returning true when a decode job should
    /// be dispatched.  A `Ready` or `Failed` URL is a no-op: there is no auto-retry.
    pub fn request(&mut self, url: &str) -> bool {
        if self.decoded.contains_key(url) {
            return false;
        }
        self.decoded.insert(url.to_owned(), DecodeStatus::Pending);
        true
    }

    /// Record a successful decode.  Used by tests; production goes through
    /// [`Self::set_decoded_with_prebuilt`] so the halfblocks scratch is captured too.
    #[allow(dead_code)]
    pub fn set_decoded(&mut self, url: &str, image: DynamicImage) {
        self.set_decoded_with_prebuilt(url, image, None);
    }

    /// [`Self::set_decoded`] plus a halfblocks scratch the decode worker already rendered; the
    /// next `get_protocol_pair` at the same dims claims it instead of encoding on the UI thread.
    pub fn set_decoded_with_prebuilt(
        &mut self,
        url: &str,
        image: DynamicImage,
        prebuilt_scratch: Option<(Rect, Buffer)>,
    ) {
        self.decoded
            .insert(url.to_owned(), DecodeStatus::Ready(Arc::new(image)));
        self.protocols.retain(|(u, _, _), _| u != url);
        self.prebuilt_scratches.retain(|(u, _, _), _| u != url);
        if let Some((rect, buf)) = prebuilt_scratch {
            self.prebuilt_scratches
                .insert((url.to_owned(), rect.width, rect.height), buf);
        }
    }

    /// Record a decode failure.
    pub fn set_failed(&mut self, url: &str, message: String) {
        self.decoded
            .insert(url.to_owned(), DecodeStatus::Failed(message));
        self.protocols.retain(|(u, _, _), _| u != url);
        self.prebuilt_scratches.retain(|(u, _, _), _| u != url);
    }

    /// Look up the decode status for `url`.  Used by integration tests.
    #[allow(dead_code)]
    pub fn status(&self, url: &str) -> Option<&DecodeStatus> {
        self.decoded.get(url)
    }

    /// Mutable protocol pair for `(url, width, height)`, building the async native protocol and
    /// the sync halfblocks scratch on a cold miss.
    ///
    /// `None` when the URL is `Pending` or `Failed`, when no `native_picker` is supplied (no image
    /// support), or when no `resize_tx` is attached.  When the native protocol IS halfblocks the
    /// pair's `native` stays `None` — the scratch is both the preferred and fallback rendering.
    pub fn get_protocol_pair(
        &mut self,
        url: &str,
        width: u16,
        height: u16,
        native_picker: Option<&Picker>,
        halfblocks_picker: Option<&Picker>,
    ) -> Option<&mut ProtocolPair> {
        let native_picker = native_picker?;
        let resize_tx = self.resize_tx.as_ref()?.clone();
        if !matches!(self.decoded.get(url), Some(DecodeStatus::Ready(_))) {
            return None;
        }
        let key = (url.to_owned(), width, height);
        if !self.protocols.contains_key(&key) {
            let image_arc = match self.decoded.get(url) {
                Some(DecodeStatus::Ready(img)) => Arc::clone(img),
                _ => return None,
            };
            let full_rect = Rect::new(0, 0, width, height);
            let is_halfblocks_native = native_picker.protocol_type() == ProtocolType::Halfblocks;

            // Prefer the worker's prebuilt scratch, taken by `remove` so it isn't held twice.
            // A missing or wrong-dimension one falls back to a ~5-20 ms sync encode here, rare
            // enough not to regress scroll.
            let halfblocks_scratch = if let Some(buf) = self.prebuilt_scratches.remove(&key) {
                Some(buf)
            } else if is_halfblocks_native {
                Some(render_halfblocks_scratch(
                    native_picker,
                    (*image_arc).clone(),
                    full_rect,
                ))
            } else {
                halfblocks_picker
                    .map(|p| render_halfblocks_scratch(p, (*image_arc).clone(), full_rect))
            };

            // A ThreadProtocol runs the slow native encode on the worker; unnecessary when the
            // native protocol is halfblocks, since the scratch above is then the rendering.
            let native = if is_halfblocks_native {
                None
            } else {
                let native_inner = native_picker.new_resize_protocol((*image_arc).clone());
                Some(ThreadProtocol::new(resize_tx, Some(native_inner)))
            };

            self.protocols.insert(
                key.clone(),
                ProtocolPair {
                    native,
                    native_ready: false,
                    native_generation: 0,
                    last_native_paint: None,
                    halfblocks_scratch,
                },
            );
        }
        self.protocols.get_mut(&key)
    }

    /// Look up an existing pair without `get_protocol_pair`'s Picker-dependent cold-path
    /// rebuild.  Callers should have ensured it exists earlier in the same frame.
    pub fn protocol_pair_mut(
        &mut self,
        url: &str,
        width: u16,
        height: u16,
    ) -> Option<&mut ProtocolPair> {
        self.protocols.get_mut(&(url.to_owned(), width, height))
    }

    /// Record a `resize_encode` request dispatched to the encoder worker, so
    /// [`Self::apply_resize_response`] can route its response back.
    pub fn track_pending_resize(&mut self, url: &str, width: u16, height: u16) {
        self.pending.push_back(PendingResize {
            url: url.to_owned(),
            width,
            height,
        });
    }

    /// Drop the oldest pending entry without applying a response — for a worker error, where the
    /// FIFO must still advance so the next response lines up with its originating protocol.
    pub fn drop_pending_front(&mut self) {
        self.pending.pop_front();
    }

    /// Route an encoded `ResizeResponse` back to its originating `ThreadProtocol` by popping the
    /// oldest pending entry; the worker is serial, so the orders match.
    ///
    /// A response whose pair has since been dropped is discarded, and
    /// `update_resized_protocol` additionally rejects responses superseded by a later request.
    pub fn apply_resize_response(&mut self, resp: ResizeResponse) {
        let Some(pending) = self.pending.pop_front() else {
            return;
        };
        let key = (pending.url, pending.width, pending.height);
        if let Some(pair) = self.protocols.get_mut(&key) {
            if let Some(native) = pair.native.as_mut() {
                if native.update_resized_protocol(resp) {
                    pair.native_ready = true;
                    // New bytes: whatever is on screen is stale, so the next paint must transmit.
                    pair.native_generation = pair.native_generation.wrapping_add(1);
                    pair.last_native_paint = None;
                }
            }
        }
    }

    /// Drop every protocol entry, e.g. on terminal resize.  Pending requests stay queued; their
    /// responses become orphan pops that `apply_resize_response` discards.  Used by tests.
    #[allow(dead_code)]
    pub fn invalidate_protocols(&mut self) {
        self.protocols.clear();
        // Prebuilt scratches are keyed by the old dims, so they are stale after a resize.
        self.prebuilt_scratches.clear();
    }

    /// Rows a decoded image occupies when fitted into `max_width_cells × max_height_cells` at
    /// `font_size` pixels per cell.  `None` for anything but a `Ready` decode — both `Pending`
    /// *and* `Failed` answer `None`, unlike [`Self::reserved_rows`], which collapses `Failed` to
    /// the single placeholder row.  The math preview band relies on that difference: an invalid
    /// formula (a `Failed` decode) must hold the block's last resolved height rather than snap to
    /// one row mid-typing, so it asks here, not through `reserved_rows`.
    pub fn aspect_rows(
        &self,
        url: &str,
        max_width_cells: u16,
        max_height_cells: u16,
        font_size: (u16, u16),
    ) -> Option<usize> {
        let Some(DecodeStatus::Ready(img)) = self.decoded.get(url) else {
            return None;
        };
        Some(aspect_rows_of(
            img,
            max_width_cells,
            max_height_cells,
            font_size,
        ))
    }

    /// Rows the renderer should reserve for this image's block: the fitted height when `Ready`,
    /// `Some(1)` when `Failed` (collapsing to the placeholder row), and `None` while `Pending`,
    /// where the renderer falls back to `image_max_height` so layout stays stable.
    pub fn reserved_rows(
        &self,
        url: &str,
        max_width_cells: u16,
        max_height_cells: u16,
        font_size: (u16, u16),
    ) -> Option<usize> {
        match self.decoded.get(url) {
            Some(DecodeStatus::Ready(img)) => Some(aspect_rows_of(
                img,
                max_width_cells,
                max_height_cells,
                font_size,
            )),
            Some(DecodeStatus::Failed(_)) => Some(1),
            Some(DecodeStatus::Pending) | None => None,
        }
    }

    /// Clear `Failed` entries so a later `request` retries — for when the user promotes the
    /// remote-image policy and a `RemoteBlocked` failure could now succeed.
    pub fn clear_failures_for_remote_reopening(&mut self) {
        self.decoded
            .retain(|_, status| !matches!(status, DecodeStatus::Failed(_)));
    }

    /// Drop one URL's entries so a later `request` treats it as never seen.  Used when a
    /// worker's result arrives that the *current* settings forbid (the worker captured the policy
    /// at spawn time); the per-frame dispatch re-resolves it if the settings permit again.
    pub fn forget(&mut self, url: &str) {
        self.decoded.remove(url);
        self.protocols.retain(|(u, _, _), _| u != url);
        self.prebuilt_scratches.retain(|(u, _, _), _| u != url);
    }

    /// Drop every entry for a remote URL, so the next dispatch re-resolves it under a changed
    /// remote-image policy: decoded images disappear when it tightens, memoized `RemoteBlocked`
    /// failures retry when it loosens.
    pub fn evict_remote(&mut self) {
        self.decoded
            .retain(|url, _| !crate::image::loader::is_remote(url));
        self.protocols
            .retain(|(url, _, _), _| !crate::image::loader::is_remote(url));
        self.prebuilt_scratches
            .retain(|(url, _, _), _| !crate::image::loader::is_remote(url));
        // `pending` is deliberately untouched: responses for evicted URLs become orphan pops,
        // which is what keeps the FIFO pairing correct.
    }

    /// Drop every entry whose URL is not in `live`, run after each reparse.  Editing inside a
    /// mermaid block mints a new synthetic URL each time, so without this the maps grow unbounded.
    pub fn gc(&mut self, live: &std::collections::HashSet<String>) {
        self.decoded.retain(|url, _| live.contains(url));
        self.protocols.retain(|(url, _, _), _| live.contains(url));
        self.prebuilt_scratches
            .retain(|(url, _, _), _| live.contains(url));
    }

    #[cfg(test)]
    pub fn protocol_count(&self) -> usize {
        self.protocols.len()
    }

    #[cfg(test)]
    pub fn pending_count(&self) -> usize {
        self.pending.len()
    }

    #[cfg(test)]
    pub fn prebuilt_scratch_count(&self) -> usize {
        self.prebuilt_scratches.len()
    }
}

#[cfg(test)]
// `Picker::from_fontsize` is deprecated in ratatui-image 9, but `Picker::halfblocks` can't set a
// specific font size, which several assertions below depend on.
#[allow(deprecated)]
mod tests {
    use super::*;

    /// A picker guaranteed to encode **halfblocks** at a known font size.  The bare
    /// `Picker::from_fontsize` infers its protocol from `$TERM_PROGRAM` / `$LC_TERMINAL`, so
    /// tests built on it pass or fail depending on the terminal `cargo test` ran from.
    fn halfblocks_picker() -> Picker {
        let mut picker = Picker::from_fontsize((1, 2).into());
        picker.set_protocol_type(ProtocolType::Halfblocks);
        picker
    }

    /// A deliberately non-halfblocks picker, so the native-plus-scratch branch is exercisable on
    /// any machine.  iTerm2 is the cheapest native protocol to encode and needs no real support.
    fn native_picker() -> Picker {
        let mut picker = Picker::from_fontsize((1, 2).into());
        picker.set_protocol_type(ProtocolType::Iterm2);
        picker
    }

    // ── aspect_rows_of ────────────────────────────────────────────────

    #[test]
    fn small_image_reserves_natural_height_not_width_filled() {
        // A 190×65 logo in a 640×640 px box: width-filled it would be 14 rows, but Fit never
        // upscales, so it paints at its natural 65 px and must reserve only that.
        let img = DynamicImage::new_rgba8(190, 65);
        let rows = aspect_rows_of(&img, 80, 40, (8, 16));
        assert_eq!(rows, 65_u32.div_ceil(16) as usize);
    }

    #[test]
    fn wide_image_still_binds_on_width() {
        // Wider than the column, so Fit downscales to the width and the natural-height cap must
        // not interfere: 1080 * 640 / 1920 = 360 px → ceil(360/16) = 23 rows.
        let img = DynamicImage::new_rgba8(1920, 1080);
        let rows = aspect_rows_of(&img, 80, 40, (8, 16));
        assert_eq!(rows, 23);
    }

    #[test]
    fn forget_drops_one_url_and_allows_a_re_request() {
        let mut cache = ImageCache::new();
        cache.set_decoded("a.png", DynamicImage::new_rgba8(1, 1));
        cache.set_decoded("b.png", DynamicImage::new_rgba8(1, 1));
        cache.forget("a.png");
        assert!(cache.status("a.png").is_none());
        assert!(cache.status("b.png").is_some(), "other entries untouched");
        assert!(cache.request("a.png"), "forgotten URL can be re-requested");
    }

    #[test]
    fn evict_remote_drops_remote_entries_and_keeps_local() {
        let mut cache = ImageCache::new();
        cache.set_decoded("https://example.com/a.png", DynamicImage::new_rgba8(1, 1));
        cache.set_failed("http://example.com/b.png", "blocked".into());
        cache.set_decoded("local/c.png", DynamicImage::new_rgba8(1, 1));
        cache.evict_remote();
        assert!(cache.status("https://example.com/a.png").is_none());
        assert!(cache.status("http://example.com/b.png").is_none());
        assert!(matches!(
            cache.status("local/c.png"),
            Some(DecodeStatus::Ready(_))
        ));
        assert!(cache.request("https://example.com/a.png"));
    }

    #[test]
    fn request_returns_true_first_time_only() {
        let mut cache = ImageCache::new();
        assert!(cache.request("a.png"));
        assert!(!cache.request("a.png"));
        assert!(matches!(cache.status("a.png"), Some(DecodeStatus::Pending)));
    }

    #[test]
    fn set_decoded_transitions_from_pending_to_ready() {
        let mut cache = ImageCache::new();
        cache.request("a.png");
        let img = DynamicImage::new_rgba8(1, 1);
        cache.set_decoded("a.png", img);
        assert!(matches!(
            cache.status("a.png"),
            Some(DecodeStatus::Ready(_))
        ));
        assert!(!cache.request("a.png"));
    }

    #[test]
    fn set_failed_transitions_and_blocks_retry() {
        let mut cache = ImageCache::new();
        cache.request("a.png");
        cache.set_failed("a.png", "io error".to_owned());
        assert!(matches!(
            cache.status("a.png"),
            Some(DecodeStatus::Failed(_))
        ));
        assert!(!cache.request("a.png"));
    }

    /// A cache whose resize channel stays alive (the receiver is leaked, since only the
    /// channel's liveness matters) so `get_protocol_pair` can clone the sender.
    fn cache_with_sender() -> ImageCache {
        let (tx, rx) = mpsc::channel::<ResizeRequest>();
        let mut cache = ImageCache::new();
        cache.attach_resize_sender(tx);
        std::mem::forget(rx);
        cache
    }

    #[test]
    fn set_decoded_clears_stale_protocol_entries() {
        let mut cache = cache_with_sender();
        cache.request("a.png");
        cache.set_decoded("a.png", DynamicImage::new_rgba8(1, 1));
        let picker = halfblocks_picker();
        assert!(cache
            .get_protocol_pair("a.png", 10, 10, Some(&picker), Some(&picker))
            .is_some());
        assert_eq!(cache.protocol_count(), 1);
        cache.set_decoded("a.png", DynamicImage::new_rgba8(2, 2));
        assert_eq!(cache.protocol_count(), 0);
    }

    #[test]
    fn set_decoded_with_prebuilt_stashes_scratch_for_matching_dims() {
        let mut cache = cache_with_sender();
        cache.request("a.png");
        let rect = Rect::new(0, 0, 8, 4);
        let prebuilt = Buffer::empty(rect);
        cache.set_decoded_with_prebuilt(
            "a.png",
            DynamicImage::new_rgba8(8, 4),
            Some((rect, prebuilt)),
        );
        assert_eq!(cache.prebuilt_scratch_count(), 1);

        let picker = halfblocks_picker();
        let pair = cache
            .get_protocol_pair("a.png", 8, 4, Some(&picker), Some(&picker))
            .expect("pair for ready image");
        assert!(pair.halfblocks_scratch.is_some());
        // Prebuilt map was drained.
        assert_eq!(cache.prebuilt_scratch_count(), 0);
    }

    #[test]
    fn set_decoded_with_prebuilt_falls_back_to_sync_on_mismatched_dims() {
        // A request at different dims (terminal resized between decode and first paint) misses
        // the prebuilt entry, runs the sync render, and leaves the prebuilt in place.
        let mut cache = cache_with_sender();
        cache.request("a.png");
        let prebuilt_rect = Rect::new(0, 0, 8, 4);
        let prebuilt = Buffer::empty(prebuilt_rect);
        cache.set_decoded_with_prebuilt(
            "a.png",
            DynamicImage::new_rgba8(8, 4),
            Some((prebuilt_rect, prebuilt)),
        );

        let picker = halfblocks_picker();
        let pair = cache
            .get_protocol_pair("a.png", 16, 4, Some(&picker), Some(&picker))
            .expect("pair for ready image");
        assert!(pair.halfblocks_scratch.is_some());
        // The un-claimed prebuilt remains, for a future paint at matching dims.
        assert_eq!(cache.prebuilt_scratch_count(), 1);
    }

    #[test]
    fn invalidate_protocols_also_clears_prebuilt_scratches() {
        let mut cache = cache_with_sender();
        cache.request("a.png");
        let rect = Rect::new(0, 0, 8, 4);
        cache.set_decoded_with_prebuilt(
            "a.png",
            DynamicImage::new_rgba8(8, 4),
            Some((rect, Buffer::empty(rect))),
        );
        assert_eq!(cache.prebuilt_scratch_count(), 1);
        cache.invalidate_protocols();
        assert_eq!(cache.prebuilt_scratch_count(), 0);
    }

    #[test]
    fn invalidate_protocols_clears_all_protocol_entries_only() {
        let mut cache = cache_with_sender();
        cache.request("a.png");
        cache.set_decoded("a.png", DynamicImage::new_rgba8(1, 1));
        let picker = halfblocks_picker();
        cache
            .get_protocol_pair("a.png", 1, 1, Some(&picker), Some(&picker))
            .expect("pair for ready image");
        cache.invalidate_protocols();
        assert_eq!(cache.protocol_count(), 0);
        assert!(matches!(
            cache.status("a.png"),
            Some(DecodeStatus::Ready(_))
        ));
    }

    #[test]
    fn protocol_pair_from_halfblocks_native_skips_native_thread_protocol() {
        let mut cache = cache_with_sender();
        cache.request("a.png");
        cache.set_decoded("a.png", DynamicImage::new_rgba8(4, 4));
        let picker = halfblocks_picker();
        let pair = cache
            .get_protocol_pair("a.png", 8, 4, Some(&picker), Some(&picker))
            .expect("pair for ready image");
        assert!(pair.native.is_none());
        assert!(pair.halfblocks_scratch.is_some());
    }

    #[test]
    fn protocol_pair_with_non_halfblocks_native_builds_both() {
        let mut cache = cache_with_sender();
        cache.request("b.png");
        cache.set_decoded("b.png", DynamicImage::new_rgba8(4, 4));
        let pair = cache
            .get_protocol_pair(
                "b.png",
                8,
                4,
                Some(&native_picker()),
                Some(&halfblocks_picker()),
            )
            .expect("pair for ready image");
        assert!(pair.native.is_some(), "native encode shipped off-thread");
        assert!(pair.halfblocks_scratch.is_some(), "fallback scratch built");
    }

    /// Guard for the iTerm2 scroll bug: a native picker must still yield halfblock *cells*, or
    /// the whole image lands in one unclippable escape sequence and flickers during scroll.
    #[test]
    fn scratch_holds_halfblock_cells_even_from_a_native_picker() {
        let rect = Rect::new(0, 0, 8, 4);
        let img = DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
            16,
            16,
            image::Rgba([40, 80, 120, 255]),
        ));
        let buf = render_halfblocks_scratch(&native_picker(), img, rect);
        // Halfblocks paints the color into *every* cell; a native encode would put one escape
        // sequence in cell (0, 0) and leave the rest default-and-skipped.
        let expected = ratatui::style::Color::Rgb(40, 80, 120);
        for y in 0..rect.height {
            for x in 0..rect.width {
                let cell = buf.cell((x, y)).expect("cell in rect");
                assert!(
                    !cell.symbol().contains('\u{1b}'),
                    "cell ({x},{y}) carries an escape sequence, not a halfblock"
                );
                assert_eq!(cell.fg, expected, "cell ({x},{y}) fg");
                assert_eq!(cell.bg, expected, "cell ({x},{y}) bg");
            }
        }
    }

    #[test]
    fn get_protocol_pair_returns_none_without_native_picker() {
        let mut cache = cache_with_sender();
        cache.request("a.png");
        cache.set_decoded("a.png", DynamicImage::new_rgba8(1, 1));
        assert!(cache.get_protocol_pair("a.png", 8, 4, None, None).is_none());
    }

    #[test]
    fn get_protocol_pair_returns_none_without_resize_sender() {
        // With no sender (the default before `App::run` spawns the worker), the pair must be
        // `None` rather than a `ThreadProtocol` over a dead channel.
        let mut cache = ImageCache::new();
        cache.request("a.png");
        cache.set_decoded("a.png", DynamicImage::new_rgba8(1, 1));
        let picker = halfblocks_picker();
        assert!(cache
            .get_protocol_pair("a.png", 8, 4, Some(&picker), Some(&picker))
            .is_none());
    }

    #[test]
    fn get_protocol_pair_returns_none_for_pending() {
        let mut cache = cache_with_sender();
        cache.request("a.png");
        let picker = halfblocks_picker();
        assert!(cache
            .get_protocol_pair("a.png", 8, 4, Some(&picker), Some(&picker))
            .is_none());
    }

    #[test]
    fn apply_resize_response_pops_pending_fifo_even_when_target_gone() {
        // A pair invalidated before its response returns must still pop the FIFO, or later
        // responses route to the wrong protocol.
        let mut cache = cache_with_sender();
        cache.request("a.png");
        cache.set_decoded("a.png", DynamicImage::new_rgba8(4, 4));
        let picker = halfblocks_picker();
        cache
            .get_protocol_pair("a.png", 8, 4, Some(&picker), Some(&picker))
            .expect("pair built");
        cache.track_pending_resize("a.png", 8, 4);
        assert_eq!(cache.pending.len(), 1);
        cache.invalidate_protocols();
        // A `ResizeResponse` can't be constructed in a test, so assert the pending state directly.
        assert_eq!(cache.pending_count(), 1);
    }

    #[test]
    fn aspect_rows_returns_none_before_decode() {
        let mut cache = ImageCache::new();
        cache.request("a.png");
        assert!(cache.aspect_rows("a.png", 80, 24, (10, 20)).is_none());
    }

    #[test]
    fn aspect_rows_wide_image_returns_fewer_rows_than_max() {
        // 1600×400 in an 800×480 px box: width binds at 200 px → ceil(200/20) = 10 rows.
        let mut cache = ImageCache::new();
        cache.request("wide.png");
        cache.set_decoded("wide.png", DynamicImage::new_rgba8(1600, 400));
        assert_eq!(cache.aspect_rows("wide.png", 80, 24, (10, 20)), Some(10));
    }

    #[test]
    fn aspect_rows_tall_image_clamps_to_max_height() {
        // Height binds and overflows max_height, so the row count clamps.
        let mut cache = ImageCache::new();
        cache.request("tall.png");
        cache.set_decoded("tall.png", DynamicImage::new_rgba8(400, 1600));
        assert_eq!(cache.aspect_rows("tall.png", 80, 24, (10, 20)), Some(24));
    }

    #[test]
    fn aspect_rows_square_image_kept_at_natural_height() {
        // Width-fill would give 800 px, but Fit never upscales past the image's own 400 px →
        // ceil(400/20) = 20 rows.
        let mut cache = ImageCache::new();
        cache.request("sq.png");
        cache.set_decoded("sq.png", DynamicImage::new_rgba8(400, 400));
        assert_eq!(cache.aspect_rows("sq.png", 80, 24, (10, 20)), Some(20));
    }

    #[test]
    fn aspect_rows_small_image_reserves_natural_height() {
        // Width-fill would give 8 rows; Fit paints the 2 px image at natural size, so 1 row.
        let mut cache = ImageCache::new();
        cache.request("thin.png");
        cache.set_decoded("thin.png", DynamicImage::new_rgba8(10, 2));
        assert_eq!(cache.aspect_rows("thin.png", 80, 24, (10, 20)), Some(1));
    }

    #[test]
    fn reserved_rows_collapses_failed_to_one() {
        let mut cache = ImageCache::new();
        cache.request("broken.png");
        cache.set_failed("broken.png", "RemoteBlocked".to_owned());
        assert_eq!(cache.reserved_rows("broken.png", 80, 24, (10, 20)), Some(1));
    }

    #[test]
    fn reserved_rows_pending_returns_none() {
        let mut cache = ImageCache::new();
        cache.request("in_flight.png");
        assert!(cache
            .reserved_rows("in_flight.png", 80, 24, (10, 20))
            .is_none());
    }

    #[test]
    fn reserved_rows_ready_returns_aspect_rows() {
        let mut cache = ImageCache::new();
        cache.request("wide.png");
        cache.set_decoded("wide.png", DynamicImage::new_rgba8(1600, 400));
        assert_eq!(cache.reserved_rows("wide.png", 80, 24, (10, 20)), Some(10));
    }
}