Skip to main content

trail/preview/
graphics.rs

1//! Terminal inline-image capability detection and protocol construction.
2//!
3//! Trail draws real pixel previews through [`ratatui_image`]. Which escape
4//! sequences the terminal understands cannot be discovered portably, so this
5//! module resolves the protocol from a user override first and from
6//! environment variables second, and it keeps the resulting
7//! [`Picker`] as process-wide state so every preview reuses one decision.
8//!
9//! # Why environment variables, and why a Halfblocks floor
10//!
11//! The reliable way to identify graphics support is to write a query escape
12//! sequence to the tty and read the reply. That needs a raw, non-blocking read
13//! on the same stdin the event loop later owns; on Windows it is not available
14//! at all without dropping to the console API, which
15//! `#![forbid(unsafe_code)]` rules out. Environment variables are therefore the
16//! detection mechanism, and every terminal Trail cannot identify falls back to
17//! [`ImageProtocol::Halfblocks`] — Unicode half-blocks with foreground and
18//! background colour, which need no protocol support at all. The result is that
19//! an image always renders as an image; the only question is fidelity.
20//!
21//! # Cell size
22//!
23//! Pixel protocols are told how big the image is *in pixels*, and the terminal
24//! lays it out by dividing that by its own character cell size. Trail therefore
25//! has to know the cell size to fit an image to the preview pane, and the
26//! answer is only sometimes available: [`measure_cell_size`] gets it exactly
27//! where the platform reports it, but `crossterm::terminal::window_size` is
28//! explicitly unimplemented on Windows and `ratatui-image`'s own probes are
29//! `#[cfg(unix)]`, so on Windows there is nothing to ask.
30//!
31//! When it cannot be measured, [`DEFAULT_CELL_SIZE`] is assumed, and the
32//! assumption is deliberately biased *small*, because the two ways of being
33//! wrong are not equally bad. An image is encoded at
34//! `pane_cells × assumed_cell` pixels and the terminal draws it across
35//! `encoded_pixels / real_cell` cells, so:
36//!
37//! - Assuming cells are **larger** than they are makes the image cover *more*
38//!   cells than the pane reserved. Trail paints the surrounding UI over the
39//!   overflow, and terminals drop the entire image rather than the covered
40//!   part of it — the preview disappears.
41//! - Assuming cells are **smaller** than they are makes the image cover *fewer*
42//!   cells than the pane. It draws correctly, just with a margin around it.
43//!
44//! A missing preview is far worse than a small one, so the fallback
45//! underestimates. `[preview] image_cell_width` / `image_cell_height` override
46//! it for anyone who wants the pane filled exactly, and the preview caption
47//! prints the values in use so they can be tuned by eye.
48
49use std::env;
50use std::sync::{Mutex, MutexGuard, OnceLock, PoisonError};
51
52use image::DynamicImage;
53use ratatui::layout::Rect;
54use ratatui_image::picker::{Picker, ProtocolType};
55use ratatui_image::protocol::StatefulProtocol;
56
57/// Character cell size, in pixels, assumed when the terminal will not report
58/// its own.
59///
60/// Chosen to be no larger than the smallest cell a real terminal is likely to
61/// have, so that an unmeasurable terminal errs towards a small image rather
62/// than an invisible one. See the module documentation for why that direction
63/// is the safe one.
64pub const DEFAULT_CELL_SIZE: (u16, u16) = (7, 14);
65
66/// The `[preview]` cell size that asks Trail to work the value out itself.
67///
68/// Zero is not a size, so it is free to carry this meaning; config validation
69/// admits it for exactly that reason.
70pub const AUTO_CELL_SIZE: (u16, u16) = (0, 0);
71
72/// Longest edge, in pixels, an image is scaled down to before it is handed to
73/// the render path.
74///
75/// Resizing and encoding happen on the UI thread every time the preview pane
76/// changes size, so a 6000-pixel-wide photo would stall rendering. Downscaling
77/// once here, on the worker thread, bounds that cost. The limit is well above
78/// any realistic preview pane, so nothing visible is lost.
79const MAX_SOURCE_EDGE: u32 = 1920;
80
81// ── Protocol ──────────────────────────────────────────────────────────────────
82
83/// The mechanism used to draw pixel previews in the terminal.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum ImageProtocol {
86    /// Kitty graphics protocol — Kitty, Ghostty, Konsole, WezTerm.
87    Kitty,
88    /// iTerm2 inline images — iTerm2, WezTerm, mintty, Tabby, Hyper, VS Code.
89    Iterm2,
90    /// Sixel graphics — mlterm, foot, xterm built with sixel support.
91    Sixel,
92    /// Unicode half-blocks. Needs no terminal support, so it is the floor
93    /// rather than a failure case.
94    Halfblocks,
95    /// Image previews disabled; show metadata only.
96    None,
97}
98
99impl ImageProtocol {
100    /// The name used for this protocol in config files and in the preview
101    /// caption.
102    pub fn label(self) -> &'static str {
103        match self {
104            ImageProtocol::Kitty => "kitty",
105            ImageProtocol::Iterm2 => "iterm2",
106            ImageProtocol::Sixel => "sixel",
107            ImageProtocol::Halfblocks => "halfblocks",
108            ImageProtocol::None => "none",
109        }
110    }
111
112    /// Parses a `[preview] image_protocol` value.
113    ///
114    /// Returns `None` for an unrecognised name so the config layer can report
115    /// it. `"auto"` is not handled here — it is the absence of an override.
116    pub fn parse(value: &str) -> Option<ImageProtocol> {
117        match value.trim().to_ascii_lowercase().as_str() {
118            "kitty" => Some(ImageProtocol::Kitty),
119            "iterm2" | "iterm" => Some(ImageProtocol::Iterm2),
120            "sixel" => Some(ImageProtocol::Sixel),
121            "halfblocks" | "halfblock" => Some(ImageProtocol::Halfblocks),
122            "none" | "off" => Some(ImageProtocol::None),
123            _ => None,
124        }
125    }
126
127    /// Whether the terminal, rather than ratatui's cells, draws the picture.
128    ///
129    /// True of every protocol but half-blocks, which is ordinary coloured text.
130    /// Such a picture covers cells ratatui is told to leave alone, and only the
131    /// terminal knows how many of them it really reached — see
132    /// [`ImagePreview::placed_at`].
133    pub fn draws_over_cells(self) -> bool {
134        matches!(
135            self,
136            ImageProtocol::Kitty | ImageProtocol::Iterm2 | ImageProtocol::Sixel
137        )
138    }
139
140    /// The `ratatui-image` protocol backing this variant, if it draws pixels.
141    fn picker_type(self) -> Option<ProtocolType> {
142        match self {
143            ImageProtocol::Kitty => Some(ProtocolType::Kitty),
144            ImageProtocol::Iterm2 => Some(ProtocolType::Iterm2),
145            ImageProtocol::Sixel => Some(ProtocolType::Sixel),
146            ImageProtocol::Halfblocks => Some(ProtocolType::Halfblocks),
147            ImageProtocol::None => None,
148        }
149    }
150}
151
152// ── Detection ─────────────────────────────────────────────────────────────────
153
154/// The environment Trail reads to identify the host terminal.
155///
156/// Detection is split from the `std::env` lookups so that every terminal in the
157/// matrix can be exercised as a plain value. Reading the process environment
158/// inside the decision would make the rules testable only for whichever
159/// terminal happened to run the suite, and `std::env::set_var` is not a way
160/// out: the suite is multi-threaded, so one test mutating the environment would
161/// race every other.
162#[derive(Debug, Default, Clone)]
163struct TerminalEnv {
164    /// `TERM`, lowercased.
165    term: String,
166    /// `TERM_PROGRAM`, lowercased.
167    term_program: String,
168    /// `LC_TERMINAL`, lowercased.
169    lc_terminal: String,
170    /// Whether `KITTY_WINDOW_ID` is set to a non-empty value.
171    kitty_window_id: bool,
172    /// Whether `ITERM_SESSION_ID` is set to a non-empty value.
173    iterm_session_id: bool,
174    /// Whether `WEZTERM_EXECUTABLE` is set to a non-empty value.
175    wezterm_executable: bool,
176}
177
178impl TerminalEnv {
179    /// Reads the variables detection depends on from the process environment.
180    fn from_env() -> TerminalEnv {
181        TerminalEnv {
182            term: lowercased("TERM"),
183            term_program: lowercased("TERM_PROGRAM"),
184            lc_terminal: lowercased("LC_TERMINAL"),
185            kitty_window_id: is_set("KITTY_WINDOW_ID"),
186            iterm_session_id: is_set("ITERM_SESSION_ID"),
187            wezterm_executable: is_set("WEZTERM_EXECUTABLE"),
188        }
189    }
190}
191
192/// Identifies the host terminal's best inline-image protocol from the
193/// environment.
194///
195/// Kitty is probed before iTerm2 because terminals that speak both (WezTerm,
196/// Konsole) advertise themselves through the kitty variables only when they are
197/// actually kitty-compatible, while `TERM_PROGRAM` is set unconditionally.
198/// Never returns [`ImageProtocol::None`]: an unrecognised terminal gets
199/// [`ImageProtocol::Halfblocks`], which always works.
200pub fn detect_from_env() -> ImageProtocol {
201    detect(&TerminalEnv::from_env())
202}
203
204/// Decides the protocol for an already-read environment.
205///
206/// The probe order is the contract documented on [`detect_from_env`]; the
207/// comments below record why each individual rule exists.
208fn detect(env: &TerminalEnv) -> ImageProtocol {
209    if env.kitty_window_id || env.term.contains("kitty") {
210        return ImageProtocol::Kitty;
211    }
212
213    if env.iterm_session_id
214        || env.term_program.contains("iterm")
215        || env.lc_terminal.contains("iterm")
216    {
217        return ImageProtocol::Iterm2;
218    }
219
220    // WezTerm speaks both the kitty and the iTerm2 protocols. It is identified
221    // by WEZTERM_EXECUTABLE (set on every platform, including Windows) and by
222    // TERM_PROGRAM=WezTerm, while TERM stays the generic xterm-256color — which
223    // is why a TERM-only probe misses it entirely.
224    if env.wezterm_executable || env.term_program.contains("wezterm") {
225        return ImageProtocol::Iterm2;
226    }
227
228    // Terminals that implement the iTerm2 sequence without a dedicated marker.
229    if ["mintty", "vscode", "tabby", "hyper", "rio", "warpterminal"]
230        .iter()
231        .any(|name| env.term_program.contains(name))
232    {
233        return ImageProtocol::Iterm2;
234    }
235
236    if env.term.contains("sixel")
237        || env.term == "mlterm"
238        || env.term == "yaft-256color"
239        || env.term == "foot"
240    {
241        return ImageProtocol::Sixel;
242    }
243
244    ImageProtocol::Halfblocks
245}
246
247/// The value of environment variable `name`, lowercased, or empty if unset.
248fn lowercased(name: &str) -> String {
249    env::var(name).unwrap_or_default().to_ascii_lowercase()
250}
251
252/// Whether `name` is present in the environment with a non-empty value.
253fn is_set(name: &str) -> bool {
254    env::var_os(name).is_some_and(|value| !value.is_empty())
255}
256
257/// Asks the platform for the terminal's character cell size, in pixels.
258///
259/// Returns `None` whenever the answer is missing or unusable rather than
260/// guessing from it: the call is unimplemented on Windows, and on Unix the
261/// pixel fields of `TIOCGWINSZ` are documented as "unused" and are commonly
262/// reported as zero. A `None` here is normal, not an error — the caller falls
263/// back to [`DEFAULT_CELL_SIZE`].
264pub fn measure_cell_size() -> Option<(u16, u16)> {
265    let size = crossterm::terminal::window_size().ok()?;
266    if size.columns == 0 || size.rows == 0 {
267        return None;
268    }
269    let cell = (size.width / size.columns, size.height / size.rows);
270    (cell.0 > 0 && cell.1 > 0).then_some(cell)
271}
272
273/// Resolves the cell size to assume from the `[preview]` configuration.
274///
275/// Zero means "work it out": measure the terminal if it will say, and fall back
276/// to [`DEFAULT_CELL_SIZE`] if it will not. Each axis is resolved on its own so
277/// that pinning one — the common case, since terminal cells vary far more in
278/// height than in width — does not discard the automatic value for the other.
279fn resolve_cell_size(configured: (u16, u16)) -> (u16, u16) {
280    resolve_cell_size_with(configured, measure_cell_size())
281}
282
283/// The body of [`resolve_cell_size`], with the measurement passed in so that
284/// tests can exercise both the measured and the unmeasurable terminal without
285/// depending on whichever one happens to be running them.
286fn resolve_cell_size_with(configured: (u16, u16), measured: Option<(u16, u16)>) -> (u16, u16) {
287    let (auto_width, auto_height) = measured.unwrap_or(DEFAULT_CELL_SIZE);
288    (
289        if configured.0 == 0 {
290            auto_width
291        } else {
292            configured.0
293        },
294        if configured.1 == 0 {
295            auto_height
296        } else {
297            configured.1
298        },
299    )
300}
301
302/// Whether this process is running inside tmux, which requires graphics escape
303/// sequences to be wrapped in a passthrough envelope.
304fn in_tmux() -> bool {
305    is_set("TMUX")
306        || env::var("TERM").unwrap_or_default().starts_with("tmux")
307        || env::var("TERM_PROGRAM").unwrap_or_default() == "tmux"
308}
309
310// ── Process-wide state ────────────────────────────────────────────────────────
311
312/// The resolved protocol plus the picker that builds images for it.
313struct Graphics {
314    /// Protocol in effect for new previews.
315    protocol: ImageProtocol,
316    /// Cell size the picker was built with, shown in the preview caption.
317    cell_size: (u16, u16),
318    /// `None` when `protocol` is [`ImageProtocol::None`].
319    picker: Option<Picker>,
320}
321
322impl Graphics {
323    /// Resolves `preference` (a `[preview] image_protocol` value) against the
324    /// environment and builds the matching picker.
325    ///
326    /// `cell_size` is the configured `image_cell_width`/`image_cell_height`
327    /// pair, where zero in either axis asks for automatic resolution.
328    fn new(preference: &str, cell_size: (u16, u16)) -> Graphics {
329        let cell_size = resolve_cell_size(cell_size);
330        let protocol = if preference.trim().eq_ignore_ascii_case("auto") {
331            detect_from_env()
332        } else {
333            // An unparseable value is rejected by config validation before it
334            // reaches here; treating it as "auto" keeps this total.
335            ImageProtocol::parse(preference).unwrap_or_else(detect_from_env)
336        };
337
338        let picker = protocol.picker_type().map(|protocol_type| {
339            let mut picker = Picker::new(cell_size);
340            picker.protocol_type = protocol_type;
341            picker.is_tmux = in_tmux();
342            picker
343        });
344
345        tracing::info!(
346            protocol = protocol.label(),
347            cell_width = cell_size.0,
348            cell_height = cell_size.1,
349            preference,
350            "resolved inline image protocol"
351        );
352
353        Graphics {
354            protocol,
355            cell_size,
356            picker,
357        }
358    }
359}
360
361/// Process-wide graphics state, initialised on first use.
362static GRAPHICS: OnceLock<Mutex<Graphics>> = OnceLock::new();
363
364/// Returns the shared state, detecting from the environment if
365/// [`configure`] has not run.
366///
367/// A poisoned lock is recovered rather than propagated: the guarded value is a
368/// plain struct with no invariant a panic could have broken, and refusing to
369/// render previews afterwards would be a worse outcome than continuing.
370fn graphics() -> MutexGuard<'static, Graphics> {
371    GRAPHICS
372        .get_or_init(|| Mutex::new(Graphics::new("auto", AUTO_CELL_SIZE)))
373        .lock()
374        .unwrap_or_else(PoisonError::into_inner)
375}
376
377/// Applies the `[preview]` configuration, replacing any earlier decision.
378///
379/// Called at startup once the config is loaded, and again after a runtime
380/// `:set preview.…`. Previews already on screen keep the protocol they were
381/// built with; the change takes effect on the next selection.
382pub fn configure(preference: &str, cell_size: (u16, u16)) {
383    let next = Graphics::new(preference, cell_size);
384
385    // Seed the cell directly rather than going through `graphics()`: on the
386    // first call that helper would build and log a whole environment-only
387    // default just to have it overwritten a line later. If another thread won
388    // the race to initialise, fall back to overwriting what it stored.
389    if let Err(next) = GRAPHICS.set(Mutex::new(next)) {
390        *graphics() = next.into_inner().unwrap_or_else(PoisonError::into_inner);
391    }
392}
393
394/// The protocol in effect for new previews.
395pub fn active() -> ImageProtocol {
396    graphics().protocol
397}
398
399/// The character cell size, in pixels, currently assumed.
400pub fn cell_size() -> (u16, u16) {
401    graphics().cell_size
402}
403
404/// Builds a renderable protocol state for `image`.
405///
406/// Returns `None` when image previews are disabled
407/// ([`ImageProtocol::None`]), in which case the caller should fall back to a
408/// metadata-only preview.
409pub fn build(image: DynamicImage) -> Option<Box<dyn StatefulProtocol>> {
410    let image = downscale(image);
411    let mut state = graphics();
412    let picker = state.picker.as_mut()?;
413    Some(picker.new_resize_protocol(image))
414}
415
416/// Scales `image` down so its longest edge is at most [`MAX_SOURCE_EDGE`].
417///
418/// Images already within the limit are returned untouched, so the common case
419/// of a small icon or screenshot costs nothing.
420fn downscale(image: DynamicImage) -> DynamicImage {
421    let (width, height) = (image.width(), image.height());
422    if width <= MAX_SOURCE_EDGE && height <= MAX_SOURCE_EDGE {
423        return image;
424    }
425    image.resize(
426        MAX_SOURCE_EDGE,
427        MAX_SOURCE_EDGE,
428        image::imageops::FilterType::Triangle,
429    )
430}
431
432// ── Preview payload ───────────────────────────────────────────────────────────
433
434/// A decoded image ready to be drawn into the preview pane.
435///
436/// Held in `PreviewContent::Image`. The protocol state is mutable because
437/// `ratatui-image` re-encodes the image whenever the pane it is drawn into
438/// changes size, caching the result until the next resize.
439#[derive(Clone)]
440pub struct ImagePreview {
441    /// Encoder state for the active protocol.
442    pub protocol: Box<dyn StatefulProtocol>,
443    /// One-line summary drawn beneath the image (format, dimensions, size and
444    /// the protocol in use).
445    pub caption: String,
446    /// Whether the terminal draws this image over cells ratatui skips, fixed
447    /// when the image is built because a preview keeps its protocol.
448    pub draws_over_cells: bool,
449    /// The preview pane this image was last drawn into, or `None` if it has not
450    /// been drawn or the pane was hidden since.
451    ///
452    /// The cells an image reserves are never repainted by ratatui, yet the
453    /// picture may not reach all of them: Trail assumes a cell size, and where
454    /// the real cells are larger the image stops short. Whatever the screen held
455    /// in that gap therefore stays visible. `ui::render` clears the screen when
456    /// this differs from the pane about to be drawn, which is the only moment
457    /// such leftovers can appear.
458    pub placed_at: Option<Rect>,
459}
460
461impl ImagePreview {
462    /// Wraps a built encoder as a preview that has not been drawn yet, under the
463    /// protocol currently in effect.
464    pub fn new(protocol: Box<dyn StatefulProtocol>, caption: String) -> ImagePreview {
465        ImagePreview {
466            protocol,
467            caption,
468            draws_over_cells: active().draws_over_cells(),
469            placed_at: None,
470        }
471    }
472}
473
474impl std::fmt::Debug for ImagePreview {
475    /// `StatefulProtocol` is a trait object with no `Debug` bound, so the
476    /// encoder state is summarised rather than printed.
477    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
478        f.debug_struct("ImagePreview")
479            .field("protocol", &"<encoder>")
480            .field("caption", &self.caption)
481            .field("draws_over_cells", &self.draws_over_cells)
482            .field("placed_at", &self.placed_at)
483            .finish()
484    }
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490
491    #[test]
492    fn protocol_labels_round_trip_through_parse() {
493        for protocol in [
494            ImageProtocol::Kitty,
495            ImageProtocol::Iterm2,
496            ImageProtocol::Sixel,
497            ImageProtocol::Halfblocks,
498            ImageProtocol::None,
499        ] {
500            assert_eq!(ImageProtocol::parse(protocol.label()), Some(protocol));
501        }
502    }
503
504    #[test]
505    fn parse_rejects_unknown_names() {
506        assert_eq!(ImageProtocol::parse("sixels"), None);
507        assert_eq!(ImageProtocol::parse(""), None);
508        // "auto" is the absence of an override, not a protocol.
509        assert_eq!(ImageProtocol::parse("auto"), None);
510    }
511
512    #[test]
513    fn parse_is_case_and_whitespace_insensitive() {
514        assert_eq!(ImageProtocol::parse("  KiTTy "), Some(ImageProtocol::Kitty));
515    }
516
517    #[test]
518    fn detection_never_reports_no_support() {
519        // Whatever terminal the tests run under — including none at all, as on
520        // CI — detection must land on a protocol that can draw something.
521        assert_ne!(detect_from_env(), ImageProtocol::None);
522    }
523
524    /// A terminal environment with only `TERM` and `TERM_PROGRAM` set, which
525    /// is what most emulators actually give Trail to work with.
526    fn env_with(term: &str, term_program: &str) -> TerminalEnv {
527        TerminalEnv {
528            term: term.to_owned(),
529            term_program: term_program.to_owned(),
530            ..TerminalEnv::default()
531        }
532    }
533
534    #[test]
535    fn wezterm_gets_pixel_previews_on_every_platform() {
536        // The bug this guards: WezTerm leaves TERM at the generic
537        // xterm-256color and sets no kitty or iTerm2 marker, so a probe that
538        // only reads TERM files it under "unknown terminal" and the pane falls
539        // back to metadata text. Either WezTerm signal on its own must be
540        // enough, because WEZTERM_EXECUTABLE is what identifies it on Windows.
541        let by_executable = TerminalEnv {
542            term: "xterm-256color".to_owned(),
543            wezterm_executable: true,
544            ..TerminalEnv::default()
545        };
546        assert_eq!(detect(&by_executable), ImageProtocol::Iterm2);
547        assert_eq!(
548            detect(&env_with("xterm-256color", "wezterm")),
549            ImageProtocol::Iterm2
550        );
551    }
552
553    #[test]
554    fn kitty_wins_over_a_terminal_that_also_speaks_iterm2() {
555        // WezTerm speaks both; the kitty marker is only set when the terminal
556        // really is kitty-compatible, so it is the stronger signal.
557        let both = TerminalEnv {
558            term: "xterm-256color".to_owned(),
559            term_program: "wezterm".to_owned(),
560            kitty_window_id: true,
561            wezterm_executable: true,
562            ..TerminalEnv::default()
563        };
564        assert_eq!(detect(&both), ImageProtocol::Kitty);
565        assert_eq!(detect(&env_with("xterm-kitty", "")), ImageProtocol::Kitty);
566    }
567
568    #[test]
569    fn iterm2_family_terminals_are_recognised() {
570        for program in ["iterm.app", "mintty", "vscode", "tabby", "hyper", "rio"] {
571            assert_eq!(
572                detect(&env_with("xterm-256color", program)),
573                ImageProtocol::Iterm2,
574                "expected iterm2 for TERM_PROGRAM={program}"
575            );
576        }
577    }
578
579    #[test]
580    fn sixel_terminals_are_recognised_from_term() {
581        for term in ["mlterm", "foot", "xterm-sixel"] {
582            assert_eq!(
583                detect(&env_with(term, "")),
584                ImageProtocol::Sixel,
585                "expected sixel for TERM={term}"
586            );
587        }
588    }
589
590    #[test]
591    fn an_unknown_terminal_floors_at_halfblocks() {
592        assert_eq!(
593            detect(&env_with("xterm-256color", "")),
594            ImageProtocol::Halfblocks
595        );
596        assert_eq!(detect(&TerminalEnv::default()), ImageProtocol::Halfblocks);
597    }
598
599    #[test]
600    fn downscale_leaves_small_images_untouched() {
601        let image = DynamicImage::new_rgb8(64, 32);
602        let out = downscale(image);
603        assert_eq!((out.width(), out.height()), (64, 32));
604    }
605
606    #[test]
607    fn downscale_bounds_the_longest_edge_and_keeps_aspect() {
608        let image = DynamicImage::new_rgb8(MAX_SOURCE_EDGE * 2, MAX_SOURCE_EDGE);
609        let out = downscale(image);
610        assert_eq!(out.width(), MAX_SOURCE_EDGE);
611        assert_eq!(out.height(), MAX_SOURCE_EDGE / 2);
612    }
613
614    #[test]
615    fn disabled_protocol_builds_no_encoder() {
616        // Uses its own Graphics rather than the process-wide one so the test
617        // cannot race other tests through the shared OnceLock.
618        let state = Graphics::new("none", DEFAULT_CELL_SIZE);
619        assert_eq!(state.protocol, ImageProtocol::None);
620        assert!(state.picker.is_none());
621    }
622
623    #[test]
624    fn unmeasurable_terminal_falls_back_to_the_default_cell_size() {
625        assert_eq!(
626            resolve_cell_size_with(AUTO_CELL_SIZE, None),
627            DEFAULT_CELL_SIZE
628        );
629    }
630
631    #[test]
632    fn a_measured_cell_size_beats_the_default() {
633        assert_eq!(
634            resolve_cell_size_with(AUTO_CELL_SIZE, Some((9, 19))),
635            (9, 19)
636        );
637    }
638
639    #[test]
640    fn configured_axes_win_and_are_resolved_independently() {
641        // Pinning the width must not throw away a measured height.
642        assert_eq!(resolve_cell_size_with((8, 0), Some((9, 19))), (8, 19));
643        assert_eq!(resolve_cell_size_with((0, 16), Some((9, 19))), (9, 16));
644        assert_eq!(resolve_cell_size_with((8, 16), Some((9, 19))), (8, 16));
645        assert_eq!(resolve_cell_size_with((8, 16), None), (8, 16));
646    }
647
648    #[test]
649    fn the_default_cell_size_errs_small() {
650        // Guessing high is the failure that hides the image: the terminal
651        // spreads the encoded pixels over more cells than the preview pane
652        // reserved, Trail paints the UI over the overflow, and the image is
653        // dropped whole. The default must therefore stay at or below the
654        // smallest cell a real terminal is likely to have.
655        const SMALLEST_REALISTIC_CELL: (u16, u16) = (7, 14);
656        assert!(DEFAULT_CELL_SIZE.0 <= SMALLEST_REALISTIC_CELL.0);
657        assert!(DEFAULT_CELL_SIZE.1 <= SMALLEST_REALISTIC_CELL.1);
658    }
659
660    #[test]
661    fn explicit_preference_overrides_the_environment() {
662        let state = Graphics::new("halfblocks", (7, 15));
663        assert_eq!(state.protocol, ImageProtocol::Halfblocks);
664        assert_eq!(state.cell_size, (7, 15));
665        assert!(state.picker.is_some());
666    }
667}