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}