trail-fm 1.12.2

A terminal-first workspace for navigating, inspecting and acting on the filesystem
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
//! Terminal inline-image capability detection and protocol construction.
//!
//! Trail draws real pixel previews through [`ratatui_image`]. Which escape
//! sequences the terminal understands cannot be discovered portably, so this
//! module resolves the protocol from a user override first and from
//! environment variables second, and it keeps the resulting
//! [`Picker`] as process-wide state so every preview reuses one decision.
//!
//! # Why environment variables, and why a Halfblocks floor
//!
//! The reliable way to identify graphics support is to write a query escape
//! sequence to the tty and read the reply. That needs a raw, non-blocking read
//! on the same stdin the event loop later owns; on Windows it is not available
//! at all without dropping to the console API, which
//! `#![forbid(unsafe_code)]` rules out. Environment variables are therefore the
//! detection mechanism, and every terminal Trail cannot identify falls back to
//! [`ImageProtocol::Halfblocks`] — Unicode half-blocks with foreground and
//! background colour, which need no protocol support at all. The result is that
//! an image always renders as an image; the only question is fidelity.
//!
//! # Cell size
//!
//! Pixel protocols are told how big the image is *in pixels*, and the terminal
//! lays it out by dividing that by its own character cell size. Trail therefore
//! has to know the cell size to fit an image to the preview pane, and the
//! answer is only sometimes available: [`measure_cell_size`] gets it exactly
//! where the platform reports it, but `crossterm::terminal::window_size` is
//! explicitly unimplemented on Windows and `ratatui-image`'s own probes are
//! `#[cfg(unix)]`, so on Windows there is nothing to ask.
//!
//! When it cannot be measured, [`DEFAULT_CELL_SIZE`] is assumed, and the
//! assumption is deliberately biased *small*, because the two ways of being
//! wrong are not equally bad. An image is encoded at
//! `pane_cells × assumed_cell` pixels and the terminal draws it across
//! `encoded_pixels / real_cell` cells, so:
//!
//! - Assuming cells are **larger** than they are makes the image cover *more*
//!   cells than the pane reserved. Trail paints the surrounding UI over the
//!   overflow, and terminals drop the entire image rather than the covered
//!   part of it — the preview disappears.
//! - Assuming cells are **smaller** than they are makes the image cover *fewer*
//!   cells than the pane. It draws correctly, just with a margin around it.
//!
//! A missing preview is far worse than a small one, so the fallback
//! underestimates. `[preview] image_cell_width` / `image_cell_height` override
//! it for anyone who wants the pane filled exactly, and the preview caption
//! prints the values in use so they can be tuned by eye.

use std::env;
use std::sync::{Mutex, MutexGuard, OnceLock, PoisonError};

use image::DynamicImage;
use ratatui::layout::Rect;
use ratatui_image::picker::{Picker, ProtocolType};
use ratatui_image::protocol::StatefulProtocol;

/// Character cell size, in pixels, assumed when the terminal will not report
/// its own.
///
/// Chosen to be no larger than the smallest cell a real terminal is likely to
/// have, so that an unmeasurable terminal errs towards a small image rather
/// than an invisible one. See the module documentation for why that direction
/// is the safe one.
pub const DEFAULT_CELL_SIZE: (u16, u16) = (7, 14);

/// The `[preview]` cell size that asks Trail to work the value out itself.
///
/// Zero is not a size, so it is free to carry this meaning; config validation
/// admits it for exactly that reason.
pub const AUTO_CELL_SIZE: (u16, u16) = (0, 0);

/// Longest edge, in pixels, an image is scaled down to before it is handed to
/// the render path.
///
/// Resizing and encoding happen on the UI thread every time the preview pane
/// changes size, so a 6000-pixel-wide photo would stall rendering. Downscaling
/// once here, on the worker thread, bounds that cost. The limit is well above
/// any realistic preview pane, so nothing visible is lost.
const MAX_SOURCE_EDGE: u32 = 1920;

// ── Protocol ──────────────────────────────────────────────────────────────────

/// The mechanism used to draw pixel previews in the terminal.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImageProtocol {
    /// Kitty graphics protocol — Kitty, Ghostty, Konsole, WezTerm.
    Kitty,
    /// iTerm2 inline images — iTerm2, WezTerm, mintty, Tabby, Hyper, VS Code.
    Iterm2,
    /// Sixel graphics — mlterm, foot, xterm built with sixel support.
    Sixel,
    /// Unicode half-blocks. Needs no terminal support, so it is the floor
    /// rather than a failure case.
    Halfblocks,
    /// Image previews disabled; show metadata only.
    None,
}

impl ImageProtocol {
    /// The name used for this protocol in config files and in the preview
    /// caption.
    pub fn label(self) -> &'static str {
        match self {
            ImageProtocol::Kitty => "kitty",
            ImageProtocol::Iterm2 => "iterm2",
            ImageProtocol::Sixel => "sixel",
            ImageProtocol::Halfblocks => "halfblocks",
            ImageProtocol::None => "none",
        }
    }

    /// Parses a `[preview] image_protocol` value.
    ///
    /// Returns `None` for an unrecognised name so the config layer can report
    /// it. `"auto"` is not handled here — it is the absence of an override.
    pub fn parse(value: &str) -> Option<ImageProtocol> {
        match value.trim().to_ascii_lowercase().as_str() {
            "kitty" => Some(ImageProtocol::Kitty),
            "iterm2" | "iterm" => Some(ImageProtocol::Iterm2),
            "sixel" => Some(ImageProtocol::Sixel),
            "halfblocks" | "halfblock" => Some(ImageProtocol::Halfblocks),
            "none" | "off" => Some(ImageProtocol::None),
            _ => None,
        }
    }

    /// Whether the terminal, rather than ratatui's cells, draws the picture.
    ///
    /// True of every protocol but half-blocks, which is ordinary coloured text.
    /// Such a picture covers cells ratatui is told to leave alone, and only the
    /// terminal knows how many of them it really reached — see
    /// [`ImagePreview::placed_at`].
    pub fn draws_over_cells(self) -> bool {
        matches!(
            self,
            ImageProtocol::Kitty | ImageProtocol::Iterm2 | ImageProtocol::Sixel
        )
    }

    /// The `ratatui-image` protocol backing this variant, if it draws pixels.
    fn picker_type(self) -> Option<ProtocolType> {
        match self {
            ImageProtocol::Kitty => Some(ProtocolType::Kitty),
            ImageProtocol::Iterm2 => Some(ProtocolType::Iterm2),
            ImageProtocol::Sixel => Some(ProtocolType::Sixel),
            ImageProtocol::Halfblocks => Some(ProtocolType::Halfblocks),
            ImageProtocol::None => None,
        }
    }
}

// ── Detection ─────────────────────────────────────────────────────────────────

/// The environment Trail reads to identify the host terminal.
///
/// Detection is split from the `std::env` lookups so that every terminal in the
/// matrix can be exercised as a plain value. Reading the process environment
/// inside the decision would make the rules testable only for whichever
/// terminal happened to run the suite, and `std::env::set_var` is not a way
/// out: the suite is multi-threaded, so one test mutating the environment would
/// race every other.
#[derive(Debug, Default, Clone)]
struct TerminalEnv {
    /// `TERM`, lowercased.
    term: String,
    /// `TERM_PROGRAM`, lowercased.
    term_program: String,
    /// `LC_TERMINAL`, lowercased.
    lc_terminal: String,
    /// Whether `KITTY_WINDOW_ID` is set to a non-empty value.
    kitty_window_id: bool,
    /// Whether `ITERM_SESSION_ID` is set to a non-empty value.
    iterm_session_id: bool,
    /// Whether `WEZTERM_EXECUTABLE` is set to a non-empty value.
    wezterm_executable: bool,
}

impl TerminalEnv {
    /// Reads the variables detection depends on from the process environment.
    fn from_env() -> TerminalEnv {
        TerminalEnv {
            term: lowercased("TERM"),
            term_program: lowercased("TERM_PROGRAM"),
            lc_terminal: lowercased("LC_TERMINAL"),
            kitty_window_id: is_set("KITTY_WINDOW_ID"),
            iterm_session_id: is_set("ITERM_SESSION_ID"),
            wezterm_executable: is_set("WEZTERM_EXECUTABLE"),
        }
    }
}

/// Identifies the host terminal's best inline-image protocol from the
/// environment.
///
/// Kitty is probed before iTerm2 because terminals that speak both (WezTerm,
/// Konsole) advertise themselves through the kitty variables only when they are
/// actually kitty-compatible, while `TERM_PROGRAM` is set unconditionally.
/// Never returns [`ImageProtocol::None`]: an unrecognised terminal gets
/// [`ImageProtocol::Halfblocks`], which always works.
pub fn detect_from_env() -> ImageProtocol {
    detect(&TerminalEnv::from_env())
}

/// Decides the protocol for an already-read environment.
///
/// The probe order is the contract documented on [`detect_from_env`]; the
/// comments below record why each individual rule exists.
fn detect(env: &TerminalEnv) -> ImageProtocol {
    if env.kitty_window_id || env.term.contains("kitty") {
        return ImageProtocol::Kitty;
    }

    if env.iterm_session_id
        || env.term_program.contains("iterm")
        || env.lc_terminal.contains("iterm")
    {
        return ImageProtocol::Iterm2;
    }

    // WezTerm speaks both the kitty and the iTerm2 protocols. It is identified
    // by WEZTERM_EXECUTABLE (set on every platform, including Windows) and by
    // TERM_PROGRAM=WezTerm, while TERM stays the generic xterm-256color — which
    // is why a TERM-only probe misses it entirely.
    if env.wezterm_executable || env.term_program.contains("wezterm") {
        return ImageProtocol::Iterm2;
    }

    // Terminals that implement the iTerm2 sequence without a dedicated marker.
    if ["mintty", "vscode", "tabby", "hyper", "rio", "warpterminal"]
        .iter()
        .any(|name| env.term_program.contains(name))
    {
        return ImageProtocol::Iterm2;
    }

    if env.term.contains("sixel")
        || env.term == "mlterm"
        || env.term == "yaft-256color"
        || env.term == "foot"
    {
        return ImageProtocol::Sixel;
    }

    ImageProtocol::Halfblocks
}

/// The value of environment variable `name`, lowercased, or empty if unset.
fn lowercased(name: &str) -> String {
    env::var(name).unwrap_or_default().to_ascii_lowercase()
}

/// Whether `name` is present in the environment with a non-empty value.
fn is_set(name: &str) -> bool {
    env::var_os(name).is_some_and(|value| !value.is_empty())
}

/// Asks the platform for the terminal's character cell size, in pixels.
///
/// Returns `None` whenever the answer is missing or unusable rather than
/// guessing from it: the call is unimplemented on Windows, and on Unix the
/// pixel fields of `TIOCGWINSZ` are documented as "unused" and are commonly
/// reported as zero. A `None` here is normal, not an error — the caller falls
/// back to [`DEFAULT_CELL_SIZE`].
pub fn measure_cell_size() -> Option<(u16, u16)> {
    let size = crossterm::terminal::window_size().ok()?;
    if size.columns == 0 || size.rows == 0 {
        return None;
    }
    let cell = (size.width / size.columns, size.height / size.rows);
    (cell.0 > 0 && cell.1 > 0).then_some(cell)
}

/// Resolves the cell size to assume from the `[preview]` configuration.
///
/// Zero means "work it out": measure the terminal if it will say, and fall back
/// to [`DEFAULT_CELL_SIZE`] if it will not. Each axis is resolved on its own so
/// that pinning one — the common case, since terminal cells vary far more in
/// height than in width — does not discard the automatic value for the other.
fn resolve_cell_size(configured: (u16, u16)) -> (u16, u16) {
    resolve_cell_size_with(configured, measure_cell_size())
}

/// The body of [`resolve_cell_size`], with the measurement passed in so that
/// tests can exercise both the measured and the unmeasurable terminal without
/// depending on whichever one happens to be running them.
fn resolve_cell_size_with(configured: (u16, u16), measured: Option<(u16, u16)>) -> (u16, u16) {
    let (auto_width, auto_height) = measured.unwrap_or(DEFAULT_CELL_SIZE);
    (
        if configured.0 == 0 {
            auto_width
        } else {
            configured.0
        },
        if configured.1 == 0 {
            auto_height
        } else {
            configured.1
        },
    )
}

/// Whether this process is running inside tmux, which requires graphics escape
/// sequences to be wrapped in a passthrough envelope.
fn in_tmux() -> bool {
    is_set("TMUX")
        || env::var("TERM").unwrap_or_default().starts_with("tmux")
        || env::var("TERM_PROGRAM").unwrap_or_default() == "tmux"
}

// ── Process-wide state ────────────────────────────────────────────────────────

/// The resolved protocol plus the picker that builds images for it.
struct Graphics {
    /// Protocol in effect for new previews.
    protocol: ImageProtocol,
    /// Cell size the picker was built with, shown in the preview caption.
    cell_size: (u16, u16),
    /// `None` when `protocol` is [`ImageProtocol::None`].
    picker: Option<Picker>,
}

impl Graphics {
    /// Resolves `preference` (a `[preview] image_protocol` value) against the
    /// environment and builds the matching picker.
    ///
    /// `cell_size` is the configured `image_cell_width`/`image_cell_height`
    /// pair, where zero in either axis asks for automatic resolution.
    fn new(preference: &str, cell_size: (u16, u16)) -> Graphics {
        let cell_size = resolve_cell_size(cell_size);
        let protocol = if preference.trim().eq_ignore_ascii_case("auto") {
            detect_from_env()
        } else {
            // An unparseable value is rejected by config validation before it
            // reaches here; treating it as "auto" keeps this total.
            ImageProtocol::parse(preference).unwrap_or_else(detect_from_env)
        };

        let picker = protocol.picker_type().map(|protocol_type| {
            let mut picker = Picker::new(cell_size);
            picker.protocol_type = protocol_type;
            picker.is_tmux = in_tmux();
            picker
        });

        tracing::info!(
            protocol = protocol.label(),
            cell_width = cell_size.0,
            cell_height = cell_size.1,
            preference,
            "resolved inline image protocol"
        );

        Graphics {
            protocol,
            cell_size,
            picker,
        }
    }
}

/// Process-wide graphics state, initialised on first use.
static GRAPHICS: OnceLock<Mutex<Graphics>> = OnceLock::new();

/// Returns the shared state, detecting from the environment if
/// [`configure`] has not run.
///
/// A poisoned lock is recovered rather than propagated: the guarded value is a
/// plain struct with no invariant a panic could have broken, and refusing to
/// render previews afterwards would be a worse outcome than continuing.
fn graphics() -> MutexGuard<'static, Graphics> {
    GRAPHICS
        .get_or_init(|| Mutex::new(Graphics::new("auto", AUTO_CELL_SIZE)))
        .lock()
        .unwrap_or_else(PoisonError::into_inner)
}

/// Applies the `[preview]` configuration, replacing any earlier decision.
///
/// Called at startup once the config is loaded, and again after a runtime
/// `:set preview.…`. Previews already on screen keep the protocol they were
/// built with; the change takes effect on the next selection.
pub fn configure(preference: &str, cell_size: (u16, u16)) {
    let next = Graphics::new(preference, cell_size);

    // Seed the cell directly rather than going through `graphics()`: on the
    // first call that helper would build and log a whole environment-only
    // default just to have it overwritten a line later. If another thread won
    // the race to initialise, fall back to overwriting what it stored.
    if let Err(next) = GRAPHICS.set(Mutex::new(next)) {
        *graphics() = next.into_inner().unwrap_or_else(PoisonError::into_inner);
    }
}

/// The protocol in effect for new previews.
pub fn active() -> ImageProtocol {
    graphics().protocol
}

/// The character cell size, in pixels, currently assumed.
pub fn cell_size() -> (u16, u16) {
    graphics().cell_size
}

/// Builds a renderable protocol state for `image`.
///
/// Returns `None` when image previews are disabled
/// ([`ImageProtocol::None`]), in which case the caller should fall back to a
/// metadata-only preview.
pub fn build(image: DynamicImage) -> Option<Box<dyn StatefulProtocol>> {
    let image = downscale(image);
    let mut state = graphics();
    let picker = state.picker.as_mut()?;
    Some(picker.new_resize_protocol(image))
}

/// Scales `image` down so its longest edge is at most [`MAX_SOURCE_EDGE`].
///
/// Images already within the limit are returned untouched, so the common case
/// of a small icon or screenshot costs nothing.
fn downscale(image: DynamicImage) -> DynamicImage {
    let (width, height) = (image.width(), image.height());
    if width <= MAX_SOURCE_EDGE && height <= MAX_SOURCE_EDGE {
        return image;
    }
    image.resize(
        MAX_SOURCE_EDGE,
        MAX_SOURCE_EDGE,
        image::imageops::FilterType::Triangle,
    )
}

// ── Preview payload ───────────────────────────────────────────────────────────

/// A decoded image ready to be drawn into the preview pane.
///
/// Held in `PreviewContent::Image`. The protocol state is mutable because
/// `ratatui-image` re-encodes the image whenever the pane it is drawn into
/// changes size, caching the result until the next resize.
#[derive(Clone)]
pub struct ImagePreview {
    /// Encoder state for the active protocol.
    pub protocol: Box<dyn StatefulProtocol>,
    /// One-line summary drawn beneath the image (format, dimensions, size and
    /// the protocol in use).
    pub caption: String,
    /// Whether the terminal draws this image over cells ratatui skips, fixed
    /// when the image is built because a preview keeps its protocol.
    pub draws_over_cells: bool,
    /// The preview pane this image was last drawn into, or `None` if it has not
    /// been drawn or the pane was hidden since.
    ///
    /// The cells an image reserves are never repainted by ratatui, yet the
    /// picture may not reach all of them: Trail assumes a cell size, and where
    /// the real cells are larger the image stops short. Whatever the screen held
    /// in that gap therefore stays visible. `ui::render` clears the screen when
    /// this differs from the pane about to be drawn, which is the only moment
    /// such leftovers can appear.
    pub placed_at: Option<Rect>,
}

impl ImagePreview {
    /// Wraps a built encoder as a preview that has not been drawn yet, under the
    /// protocol currently in effect.
    pub fn new(protocol: Box<dyn StatefulProtocol>, caption: String) -> ImagePreview {
        ImagePreview {
            protocol,
            caption,
            draws_over_cells: active().draws_over_cells(),
            placed_at: None,
        }
    }
}

impl std::fmt::Debug for ImagePreview {
    /// `StatefulProtocol` is a trait object with no `Debug` bound, so the
    /// encoder state is summarised rather than printed.
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("ImagePreview")
            .field("protocol", &"<encoder>")
            .field("caption", &self.caption)
            .field("draws_over_cells", &self.draws_over_cells)
            .field("placed_at", &self.placed_at)
            .finish()
    }
}

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

    #[test]
    fn protocol_labels_round_trip_through_parse() {
        for protocol in [
            ImageProtocol::Kitty,
            ImageProtocol::Iterm2,
            ImageProtocol::Sixel,
            ImageProtocol::Halfblocks,
            ImageProtocol::None,
        ] {
            assert_eq!(ImageProtocol::parse(protocol.label()), Some(protocol));
        }
    }

    #[test]
    fn parse_rejects_unknown_names() {
        assert_eq!(ImageProtocol::parse("sixels"), None);
        assert_eq!(ImageProtocol::parse(""), None);
        // "auto" is the absence of an override, not a protocol.
        assert_eq!(ImageProtocol::parse("auto"), None);
    }

    #[test]
    fn parse_is_case_and_whitespace_insensitive() {
        assert_eq!(ImageProtocol::parse("  KiTTy "), Some(ImageProtocol::Kitty));
    }

    #[test]
    fn detection_never_reports_no_support() {
        // Whatever terminal the tests run under — including none at all, as on
        // CI — detection must land on a protocol that can draw something.
        assert_ne!(detect_from_env(), ImageProtocol::None);
    }

    /// A terminal environment with only `TERM` and `TERM_PROGRAM` set, which
    /// is what most emulators actually give Trail to work with.
    fn env_with(term: &str, term_program: &str) -> TerminalEnv {
        TerminalEnv {
            term: term.to_owned(),
            term_program: term_program.to_owned(),
            ..TerminalEnv::default()
        }
    }

    #[test]
    fn wezterm_gets_pixel_previews_on_every_platform() {
        // The bug this guards: WezTerm leaves TERM at the generic
        // xterm-256color and sets no kitty or iTerm2 marker, so a probe that
        // only reads TERM files it under "unknown terminal" and the pane falls
        // back to metadata text. Either WezTerm signal on its own must be
        // enough, because WEZTERM_EXECUTABLE is what identifies it on Windows.
        let by_executable = TerminalEnv {
            term: "xterm-256color".to_owned(),
            wezterm_executable: true,
            ..TerminalEnv::default()
        };
        assert_eq!(detect(&by_executable), ImageProtocol::Iterm2);
        assert_eq!(
            detect(&env_with("xterm-256color", "wezterm")),
            ImageProtocol::Iterm2
        );
    }

    #[test]
    fn kitty_wins_over_a_terminal_that_also_speaks_iterm2() {
        // WezTerm speaks both; the kitty marker is only set when the terminal
        // really is kitty-compatible, so it is the stronger signal.
        let both = TerminalEnv {
            term: "xterm-256color".to_owned(),
            term_program: "wezterm".to_owned(),
            kitty_window_id: true,
            wezterm_executable: true,
            ..TerminalEnv::default()
        };
        assert_eq!(detect(&both), ImageProtocol::Kitty);
        assert_eq!(detect(&env_with("xterm-kitty", "")), ImageProtocol::Kitty);
    }

    #[test]
    fn iterm2_family_terminals_are_recognised() {
        for program in ["iterm.app", "mintty", "vscode", "tabby", "hyper", "rio"] {
            assert_eq!(
                detect(&env_with("xterm-256color", program)),
                ImageProtocol::Iterm2,
                "expected iterm2 for TERM_PROGRAM={program}"
            );
        }
    }

    #[test]
    fn sixel_terminals_are_recognised_from_term() {
        for term in ["mlterm", "foot", "xterm-sixel"] {
            assert_eq!(
                detect(&env_with(term, "")),
                ImageProtocol::Sixel,
                "expected sixel for TERM={term}"
            );
        }
    }

    #[test]
    fn an_unknown_terminal_floors_at_halfblocks() {
        assert_eq!(
            detect(&env_with("xterm-256color", "")),
            ImageProtocol::Halfblocks
        );
        assert_eq!(detect(&TerminalEnv::default()), ImageProtocol::Halfblocks);
    }

    #[test]
    fn downscale_leaves_small_images_untouched() {
        let image = DynamicImage::new_rgb8(64, 32);
        let out = downscale(image);
        assert_eq!((out.width(), out.height()), (64, 32));
    }

    #[test]
    fn downscale_bounds_the_longest_edge_and_keeps_aspect() {
        let image = DynamicImage::new_rgb8(MAX_SOURCE_EDGE * 2, MAX_SOURCE_EDGE);
        let out = downscale(image);
        assert_eq!(out.width(), MAX_SOURCE_EDGE);
        assert_eq!(out.height(), MAX_SOURCE_EDGE / 2);
    }

    #[test]
    fn disabled_protocol_builds_no_encoder() {
        // Uses its own Graphics rather than the process-wide one so the test
        // cannot race other tests through the shared OnceLock.
        let state = Graphics::new("none", DEFAULT_CELL_SIZE);
        assert_eq!(state.protocol, ImageProtocol::None);
        assert!(state.picker.is_none());
    }

    #[test]
    fn unmeasurable_terminal_falls_back_to_the_default_cell_size() {
        assert_eq!(
            resolve_cell_size_with(AUTO_CELL_SIZE, None),
            DEFAULT_CELL_SIZE
        );
    }

    #[test]
    fn a_measured_cell_size_beats_the_default() {
        assert_eq!(
            resolve_cell_size_with(AUTO_CELL_SIZE, Some((9, 19))),
            (9, 19)
        );
    }

    #[test]
    fn configured_axes_win_and_are_resolved_independently() {
        // Pinning the width must not throw away a measured height.
        assert_eq!(resolve_cell_size_with((8, 0), Some((9, 19))), (8, 19));
        assert_eq!(resolve_cell_size_with((0, 16), Some((9, 19))), (9, 16));
        assert_eq!(resolve_cell_size_with((8, 16), Some((9, 19))), (8, 16));
        assert_eq!(resolve_cell_size_with((8, 16), None), (8, 16));
    }

    #[test]
    fn the_default_cell_size_errs_small() {
        // Guessing high is the failure that hides the image: the terminal
        // spreads the encoded pixels over more cells than the preview pane
        // reserved, Trail paints the UI over the overflow, and the image is
        // dropped whole. The default must therefore stay at or below the
        // smallest cell a real terminal is likely to have.
        const SMALLEST_REALISTIC_CELL: (u16, u16) = (7, 14);
        assert!(DEFAULT_CELL_SIZE.0 <= SMALLEST_REALISTIC_CELL.0);
        assert!(DEFAULT_CELL_SIZE.1 <= SMALLEST_REALISTIC_CELL.1);
    }

    #[test]
    fn explicit_preference_overrides_the_environment() {
        let state = Graphics::new("halfblocks", (7, 15));
        assert_eq!(state.protocol, ImageProtocol::Halfblocks);
        assert_eq!(state.cell_size, (7, 15));
        assert!(state.picker.is_some());
    }
}