Skip to main content

rlx_core/render/
preview.rs

1//! The program preview's intermediate target and its letterbox geometry
2//! (ADR-0143).
3//!
4//! While a secondary surface is attached, the frame is drawn **once** into the
5//! intermediate here and reaches its real destination by
6//! `copy_texture_to_texture` — exact, no shader, no sampling. The same
7//! intermediate is then sampled, scaled and letterboxed onto the console. One
8//! render, one frame, two destinations.
9//!
10//! **Not behind the `text` feature, unlike [`super::aux_target`].** The console
11//! that consumes a preview is text-gated, but the intermediate is a render path
12//! and the property that matters about it — that a frame routed through it is
13//! byte-identical to one drawn straight at the target — is asserted on the
14//! headless capture path, which compiles with glyphon out.
15//!
16//! ## One owner
17//!
18//! `PreviewService` holds every resource the concern has — the intermediate,
19//! the readback and the frame the readback produced — and `Renderer` holds one
20//! of it. Three loose fields there could each be opened, closed, resized and
21//! drained separately, and an accessor that moved two of them and forgot the
22//! third looked exactly like one that moved all three.
23
24// Hot-path panic-denial pragma (Plan 0002 Phase 2; `render/` scan set). The
25// copy runs once per displayed frame while a preview is open.
26#![deny(
27    clippy::unwrap_used,
28    clippy::expect_used,
29    clippy::indexing_slicing,
30    clippy::panic,
31    clippy::unreachable
32)]
33
34use super::preview_readback::PreviewReadback;
35use super::{CaptureImage, PixelOrder, RenderError, gpu};
36use std::sync::atomic::{AtomicU64, Ordering};
37
38/// Everything the program preview owns, as one thing.
39///
40/// `Renderer` reaches the concern through one field. The accessors that open,
41/// close, size and drain it delegate here rather than each touching the
42/// intermediate, the readback and the produced frame separately, which is what
43/// stops one of them from moving two and quietly leaving the third behind:
44/// closing the intermediate without closing the readback would leave a staging
45/// buffer copying out of a destroyed texture.
46///
47/// **Off the frame path.** [`take_target`](Self::take_target) and
48/// [`restore_target`](Self::restore_target) bracket the draw from `render` and
49/// from the headless capture; `draw_frame` itself never sees this — from in
50/// there the intermediate is just the view it was handed.
51pub(super) struct PreviewService {
52    /// The intermediate (ADR-0143), `None` unless a shell has opened one. While
53    /// it is `Some` the frame is drawn into it and reaches the real destination
54    /// by an exact copy; while it is `None` nothing is allocated, no copy is
55    /// encoded and the frame path is what it was — which is what makes the
56    /// console free when it is closed.
57    target: Option<PreviewTarget>,
58    /// The non-blocking readback (Plan 0158 Phase 6), `None` unless a shell
59    /// opened one. While it is `Some` each frame drawn through the intermediate
60    /// records one `copy_texture_to_buffer` and polls the previous frame's map;
61    /// while it is `None` nothing is allocated and the frame path costs one
62    /// `Option` test.
63    readback: Option<PreviewReadback>,
64    /// The most recent frame the readback produced and nothing has taken.
65    ///
66    /// One slot rather than a queue: a preview wants the newest picture, and a
67    /// consumer that fell behind is better served by the current frame than by
68    /// the backlog it missed.
69    frame: Option<CaptureImage>,
70}
71
72impl PreviewService {
73    /// A closed preview: no intermediate, no readback, nothing produced.
74    pub(super) const fn closed() -> Self {
75        Self {
76            target: None,
77            readback: None,
78            frame: None,
79        }
80    }
81
82    /// Build the intermediate at `format` and `width`x`height`, discarding any
83    /// standing one. An already-open preview is rebuilt, which is how a caller
84    /// follows a resize.
85    pub(super) fn open_target(
86        &mut self,
87        device: &wgpu::Device,
88        format: wgpu::TextureFormat,
89        width: u32,
90        height: u32,
91    ) {
92        self.target = Some(PreviewTarget::new(device, format, width, height));
93    }
94
95    /// The open intermediate, or `None`.
96    pub(super) fn target(&self) -> Option<&PreviewTarget> {
97        self.target.as_ref()
98    }
99
100    /// The intermediate's size and identity, or `None` when closed.
101    pub(super) fn state(&self) -> Option<((u32, u32), u64)> {
102        self.target.as_ref().map(|t| (t.size(), t.generation()))
103    }
104
105    /// Whether an intermediate is open.
106    pub(super) fn is_open(&self) -> bool {
107        self.target.is_some()
108    }
109
110    /// Move the intermediate out for the draw, which takes `&mut Renderer`.
111    /// Paired with [`restore_target`](Self::restore_target) in the same frame.
112    pub(super) fn take_target(&mut self) -> Option<PreviewTarget> {
113        self.target.take()
114    }
115
116    /// Put back what [`take_target`](Self::take_target) handed out.
117    pub(super) fn restore_target(&mut self, target: Option<PreviewTarget>) {
118        self.target = target;
119    }
120
121    /// Release the intermediate, the readback and anything the readback had
122    /// produced.
123    ///
124    /// All three together, always: the readback copies out of the intermediate,
125    /// so one left behind would hold a staging buffer for a texture that no
126    /// longer exists and never yield another frame.
127    pub(super) fn close(&mut self) {
128        self.target = None;
129        self.readback = None;
130        self.frame = None;
131    }
132
133    /// Open a readback yielding `width`x`height` frames at the intermediate's
134    /// format, replacing any standing one.
135    ///
136    /// Requires an open intermediate — the blit that fills the readback's tap
137    /// samples it and has nothing to read without one. Refused when the frames
138    /// would come out at a format no consumer can be told the order of, so the
139    /// announcement that follows can always be true (ADR-0187).
140    pub(super) fn open_readback(
141        &mut self,
142        device: &wgpu::Device,
143        width: u32,
144        height: u32,
145    ) -> Result<(), RenderError> {
146        let Some(target) = self.target.as_ref() else {
147            return Err(RenderError::CaptureReadback);
148        };
149        let format = target.format();
150        if PixelOrder::of(format).is_none() {
151            return Err(RenderError::UnnameablePixelOrder(format));
152        }
153        self.readback = Some(PreviewReadback::new(device, format, width, height));
154        Ok(())
155    }
156
157    /// Close the readback and free its staging buffer, leaving the intermediate
158    /// open.
159    pub(super) fn close_readback(&mut self) {
160        self.readback = None;
161    }
162
163    /// The size of the frames the readback yields, or `None` when closed.
164    pub(super) fn readback_size(&self) -> Option<(u32, u32)> {
165        self.readback.as_ref().map(PreviewReadback::size)
166    }
167
168    /// Take the frame the readback produced, if one has landed.
169    pub(super) fn take_frame(&mut self) -> Option<CaptureImage> {
170        self.frame.take()
171    }
172
173    /// The format the readback's fixed-size mirror was built at, or `None` when
174    /// no readback is open.
175    ///
176    /// `#[cfg(test)]`, and the tap is an internal of the readback: the one
177    /// reader is the assertion that every path this renderer announces a pixel
178    /// order for really produces that order.
179    #[cfg(test)]
180    pub(super) fn readback_tap_format(&self) -> Option<wgpu::TextureFormat> {
181        self.readback
182            .as_ref()
183            .map(|readback| readback.tap.texture().format())
184    }
185
186    /// The consume-then-record half, before the frame's submission.
187    ///
188    /// Consumed first: the buffer cannot be recorded into while it is mapped, so
189    /// this frame's copy is only possible once the previous one has been taken. A
190    /// frame the caller never collected is replaced rather than queued — the
191    /// newest picture is the one a preview wants.
192    ///
193    /// Returns whether a copy was recorded, which decides whether
194    /// [`arm_readback`](Self::arm_readback) runs after the submission.
195    pub(super) fn step_readback(
196        &mut self,
197        device: &wgpu::Device,
198        encoder: &mut wgpu::CommandEncoder,
199    ) -> bool {
200        let Self {
201            target,
202            readback,
203            frame,
204        } = self;
205        let (Some(readback), Some(target)) = (readback.as_mut(), target.as_ref()) else {
206            return false;
207        };
208        if let Some(image) = readback.consume(device) {
209            *frame = Some(image);
210        }
211        readback.record(device, encoder, target)
212    }
213
214    /// Ask for the mapping, after the submission that carried the copy.
215    pub(super) fn arm_readback(&mut self) {
216        if let Some(readback) = self.readback.as_mut() {
217            readback.arm();
218        }
219    }
220}
221
222/// Hands out an identity for each intermediate ever built, so a consumer that
223/// caches GPU state against one can tell it has been handed a different
224/// texture. A resize destroys and rebuilds the intermediate at the same size in
225/// principle, and a pointer comparison on `wgpu::Texture` is not available.
226static NEXT_GENERATION: AtomicU64 = AtomicU64::new(1);
227
228/// The offscreen a frame is drawn into while a preview is open, plus the view
229/// and identity its two consumers need.
230///
231/// Sized to the output's configured target and **never resized in place**: the
232/// copy's extent is fixed against `width`x`height`, so a renderer resize
233/// discards this and builds another. [`super::Renderer::open_preview`] and
234/// `resize` are the only things that construct one.
235pub struct PreviewTarget {
236    /// `RENDER_ATTACHMENT | COPY_SRC | TEXTURE_BINDING` — drawn into, copied
237    /// out of, and sampled by the console blit.
238    pub(crate) texture: wgpu::Texture,
239    /// A view of `texture`, held rather than recreated per frame.
240    pub(crate) view: wgpu::TextureView,
241    width: u32,
242    height: u32,
243    format: wgpu::TextureFormat,
244    generation: u64,
245}
246
247impl PreviewTarget {
248    /// Build the intermediate at `format` — which must be the destination's
249    /// format, since `copy_texture_to_texture` refuses a mismatch and that
250    /// refusal is the whole guarantee of exactness.
251    pub(crate) fn new(
252        device: &wgpu::Device,
253        format: wgpu::TextureFormat,
254        width: u32,
255        height: u32,
256    ) -> Self {
257        let (width, height) = (width.max(1), height.max(1));
258        let texture = device.create_texture(&wgpu::TextureDescriptor {
259            label: Some("rlx-preview-intermediate"),
260            size: wgpu::Extent3d {
261                width,
262                height,
263                depth_or_array_layers: 1,
264            },
265            mip_level_count: 1,
266            sample_count: 1,
267            dimension: wgpu::TextureDimension::D2,
268            format,
269            usage: wgpu::TextureUsages::RENDER_ATTACHMENT
270                | wgpu::TextureUsages::COPY_SRC
271                | wgpu::TextureUsages::TEXTURE_BINDING,
272            view_formats: &[],
273        });
274        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
275        Self {
276            texture,
277            view,
278            width,
279            height,
280            format,
281            generation: NEXT_GENERATION.fetch_add(1, Ordering::Relaxed),
282        }
283    }
284
285    /// The pixel size this intermediate was built against.
286    pub fn size(&self) -> (u32, u32) {
287        (self.width, self.height)
288    }
289
290    /// The texture format this intermediate was built at — the destination's,
291    /// since the copy out of it refuses a mismatch.
292    pub(crate) fn format(&self) -> wgpu::TextureFormat {
293        self.format
294    }
295
296    /// This intermediate's identity, unique across every one ever built in this
297    /// process. A consumer caching a bind group against it compares this rather
298    /// than the texture.
299    pub fn generation(&self) -> u64 {
300        self.generation
301    }
302
303    /// Record the exact copy from this intermediate into `dst`.
304    ///
305    /// Both must carry the same format and the same size; the caller builds
306    /// them that way and wgpu rejects the pair if it did not.
307    pub(crate) fn record_copy_to(&self, encoder: &mut wgpu::CommandEncoder, dst: &wgpu::Texture) {
308        encoder.copy_texture_to_texture(
309            self.texture.as_image_copy(),
310            dst.as_image_copy(),
311            wgpu::Extent3d {
312                width: self.width,
313                height: self.height,
314                depth_or_array_layers: 1,
315            },
316        );
317    }
318}
319
320/// The **fixed-size** mirror the preview readback copies out of (ADR-0187).
321///
322/// [`PreviewTarget`] above is the show's own size and is rebuilt whenever the
323/// window is, so a readback taken straight off it changes size mid-run. The
324/// reader on the other end of a byte pipe cannot survive that: the frames carry
325/// no header, a raw frame can hold any byte pattern so no sentinel finds the
326/// boundary, and bytes already in the OS pipe are still the old geometry. This
327/// texture is built once, at the size the caller asked for, and a **sampling
328/// blit** fills it from the intermediate every frame — so the window may be
329/// resized, maximized or thrown fullscreen and the bytes leaving here keep one
330/// shape for the life of the run.
331///
332/// Built at the intermediate's own format, so the blit preserves the channel
333/// order rather than converting it — whoever announces the pipe can then name
334/// what it actually carries.
335///
336/// The show's aspect is **letterboxed** into that fixed shape rather than
337/// stretched to it (see [`fit_rect`]).
338pub(crate) struct PreviewTap {
339    texture: wgpu::Texture,
340    view: wgpu::TextureView,
341    width: u32,
342    height: u32,
343    pipeline: wgpu::RenderPipeline,
344    layout: wgpu::BindGroupLayout,
345    sampler: wgpu::Sampler,
346    /// The bind group for the intermediate, keyed on that intermediate's
347    /// [`generation`](PreviewTarget::generation): a renderer resize builds
348    /// another intermediate, and a group still bound to the old one samples a
349    /// texture nothing owns.
350    bound: Option<(u64, wgpu::BindGroup)>,
351}
352
353/// The blit's fragment stage. Alpha is forced to 1: the pipe's consumer reads
354/// four channels per pixel, and an intermediate carrying anything but opaque
355/// there would paint the preview translucent over whatever is behind it.
356const TAP_WGSL: &str = r#"
357@group(0) @binding(0) var src: texture_2d<f32>;
358@group(0) @binding(1) var samp: sampler;
359
360@fragment
361fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
362    return vec4<f32>(textureSample(src, samp, in.uv).rgb, 1.0);
363}
364"#;
365
366impl PreviewTap {
367    /// Build the tap at `format` — the intermediate's — and the requested size.
368    pub(crate) fn new(
369        device: &wgpu::Device,
370        format: wgpu::TextureFormat,
371        width: u32,
372        height: u32,
373    ) -> Self {
374        let (width, height) = (width.max(1), height.max(1));
375        let texture = device.create_texture(&wgpu::TextureDescriptor {
376            label: Some("rlx-preview-tap"),
377            size: wgpu::Extent3d {
378                width,
379                height,
380                depth_or_array_layers: 1,
381            },
382            mip_level_count: 1,
383            sample_count: 1,
384            dimension: wgpu::TextureDimension::D2,
385            format,
386            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
387            view_formats: &[],
388        });
389        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
390        // `[Texture, Sampler]` — a shape no other layout in `core/src` holds,
391        // which `no_two_layouts_share_a_shape_without_recorded_evidence`
392        // enforces (ADR-0058).
393        let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
394            label: Some("rlx-preview-tap-layout"),
395            entries: &[gpu::texture(0, true), gpu::sampler(1)],
396        });
397        let shader = gpu::fullscreen_shader(
398            device,
399            "rlx-preview-tap",
400            gpu::FULLSCREEN_VS_UV_FLIPPED,
401            TAP_WGSL,
402        );
403        let pipeline = gpu::fullscreen_pipeline(
404            device,
405            &shader,
406            &[&layout],
407            format,
408            wgpu::BlendState::REPLACE,
409            "rlx-preview-tap",
410        );
411        Self {
412            texture,
413            view,
414            width,
415            height,
416            pipeline,
417            layout,
418            sampler: device.create_sampler(&wgpu::SamplerDescriptor {
419                label: Some("rlx-preview-tap-sampler"),
420                // Linear: the tap is a heavy minification of the show in every
421                // ordinary case, and a nearest sample of it aliases into noise
422                // a viewer reads as detail that is not in the picture.
423                mag_filter: wgpu::FilterMode::Linear,
424                min_filter: wgpu::FilterMode::Linear,
425                ..Default::default()
426            }),
427            bound: None,
428        }
429    }
430
431    /// The pixel size this was built at, and the size of every frame copied out
432    /// of it for as long as it lives.
433    pub(crate) fn size(&self) -> (u32, u32) {
434        (self.width, self.height)
435    }
436
437    /// The texture a readback copies out of.
438    pub(crate) fn texture(&self) -> &wgpu::Texture {
439        &self.texture
440    }
441
442    /// Record the scaling, letterboxing blit from `preview` into this target.
443    ///
444    /// Returns whether it recorded. A caller copies out of this texture only
445    /// when it did, so a frame with no bind group is skipped rather than
446    /// published as whatever the tap happened to hold before.
447    pub(crate) fn record_fill_from(
448        &mut self,
449        device: &wgpu::Device,
450        encoder: &mut wgpu::CommandEncoder,
451        preview: &PreviewTarget,
452    ) -> bool {
453        let generation = preview.generation();
454        if self.bound.as_ref().is_none_or(|(g, _)| *g != generation) {
455            self.bound = Some((
456                generation,
457                device.create_bind_group(&wgpu::BindGroupDescriptor {
458                    label: Some("rlx-preview-tap-group"),
459                    layout: &self.layout,
460                    entries: &[
461                        wgpu::BindGroupEntry {
462                            binding: 0,
463                            resource: wgpu::BindingResource::TextureView(&preview.view),
464                        },
465                        wgpu::BindGroupEntry {
466                            binding: 1,
467                            resource: wgpu::BindingResource::Sampler(&self.sampler),
468                        },
469                    ],
470                }),
471            ));
472        }
473        let Some((_, group)) = self.bound.as_ref() else {
474            return false;
475        };
476        let (x, y, width, height) = fit_rect(preview.size(), (self.width, self.height));
477        // Cleared rather than loaded: the letterbox bars are the part of the
478        // target the blit never writes, and a loaded target would keep showing
479        // the previous frame's picture in them.
480        let mut pass = gpu::color_pass(
481            encoder,
482            "rlx-preview-tap-blit",
483            &self.view,
484            wgpu::LoadOp::Clear(wgpu::Color::BLACK),
485        );
486        pass.set_viewport(x as f32, y as f32, width as f32, height as f32, 0.0, 1.0);
487        // The viewport transforms; it does not clip. Without the scissor the
488        // oversized fullscreen triangle rasterizes over the bars too, and the
489        // letterbox is a stretch again.
490        pass.set_scissor_rect(x, y, width, height);
491        pass.set_pipeline(&self.pipeline);
492        pass.set_bind_group(0, group, &[]);
493        pass.draw(0..3, 0..1);
494        true
495    }
496}
497
498/// The largest rectangle carrying `src`'s aspect that fits inside `dst`,
499/// centred — origin top-left, in `dst`'s own pixels.
500///
501/// The tap is a fixed **resolution** and the show's window is any shape, so the
502/// two aspects disagree the moment anyone drags a window edge. Fitting rather
503/// than stretching is ADR-0037's rule at the one place a scaled copy of the show
504/// leaves the engine: bars are honest about the shape, a squash is not.
505///
506/// Every returned dimension is at least 1 — a zero-extent viewport is a wgpu
507/// validation error, and a degenerate `src` is a window mid-minimize rather than
508/// a caller's mistake.
509pub(crate) fn fit_rect(src: (u32, u32), dst: (u32, u32)) -> (u32, u32, u32, u32) {
510    let (dst_w, dst_h) = (dst.0.max(1), dst.1.max(1));
511    let (src_w, src_h) = (f64::from(src.0.max(1)), f64::from(src.1.max(1)));
512    let scale = (f64::from(dst_w) / src_w).min(f64::from(dst_h) / src_h);
513    let width = ((src_w * scale).round() as u32).clamp(1, dst_w);
514    let height = ((src_h * scale).round() as u32).clamp(1, dst_h);
515    ((dst_w - width) / 2, (dst_h - height) / 2, width, height)
516}
517
518/// A rectangle in a console surface's device pixels, origin top-left.
519#[derive(Debug, Clone, Copy, PartialEq)]
520pub struct Rect {
521    /// Distance from the surface's left edge to the rectangle's.
522    pub x: f32,
523    /// Distance from the surface's top edge to the rectangle's.
524    pub y: f32,
525    /// Rectangle width in device pixels.
526    pub width: f32,
527    /// Rectangle height in device pixels.
528    pub height: f32,
529}
530
531impl Rect {
532    /// Width over height. Undefined for a zero-height rectangle, which
533    /// [`preview_rect`] never returns.
534    pub fn aspect(&self) -> f32 {
535        self.width / self.height
536    }
537}
538
539/// The preview slot's longer side, as a fraction of the console surface.
540///
541/// A monitor, not a second show: large enough to read a cut from across a desk,
542/// small enough that the modal list it sits beside keeps most of the window.
543const SLOT_FRACTION: f32 = 0.32;
544
545/// Gap between the slot and the console's bottom and right edges, in device
546/// pixels at any console size — a fixed inset reads as a margin where a
547/// proportional one reads as an error at small sizes.
548const SLOT_MARGIN: f32 = 16.0;
549
550/// Below this, on either side, the preview is not a picture of anything and is
551/// better absent than misleading.
552const MIN_SIDE: f32 = 32.0;
553
554/// Where to draw the program preview inside a console surface, letterboxed.
555///
556/// **The aspect comes from `output` — the render target — and from nothing
557/// else** (ADR-0037). The console window's own aspect and the slot's are both
558/// containers: the returned rectangle fits inside the slot and keeps the
559/// output's shape, so a 16:9 show in a square slot gets bars above and below
560/// rather than a stretch. This project has shipped the other reading twice, and
561/// both times the tests were written where the two sources agree.
562///
563/// `None` when either size is degenerate or the slot comes out too small to be
564/// worth drawing.
565pub fn preview_rect(output: (u32, u32), console: (u32, u32)) -> Option<Rect> {
566    let (out_w, out_h) = (output.0 as f32, output.1 as f32);
567    let (con_w, con_h) = (console.0 as f32, console.1 as f32);
568    if out_w <= 0.0 || out_h <= 0.0 || con_w <= 0.0 || con_h <= 0.0 {
569        return None;
570    }
571    let aspect = out_w / out_h;
572    if !aspect.is_finite() || aspect <= 0.0 {
573        return None;
574    }
575
576    // The slot is a square fraction of the console; the picture is then fitted
577    // inside it. Two steps rather than one so the container's own shape cannot
578    // leak into the picture's.
579    let slot = (con_w.min(con_h) * SLOT_FRACTION).min(con_w - 2.0 * SLOT_MARGIN);
580    if slot < MIN_SIDE {
581        return None;
582    }
583
584    let (width, height) = if aspect >= 1.0 {
585        (slot, slot / aspect)
586    } else {
587        (slot * aspect, slot)
588    };
589    if width < MIN_SIDE || height < MIN_SIDE {
590        return None;
591    }
592
593    Some(Rect {
594        x: con_w - SLOT_MARGIN - width,
595        y: con_h - SLOT_MARGIN - height,
596        width,
597        height,
598    })
599}
600
601#[cfg(test)]
602mod tests {
603    use super::*;
604
605    /// Two sizes chosen so no pair of them shares an aspect: a wide output, a
606    /// tall console, and a square one. At 16:9 against 16:9 — the shape the
607    /// dev box and every golden run at — the target's aspect and the
608    /// container's coincide, and no assertion written there can say which one
609    /// the code read.
610    const WIDE: (u32, u32) = (1920, 1080);
611    const TALL: (u32, u32) = (600, 1000);
612    const SQUARE: (u32, u32) = (900, 900);
613
614    fn approx(a: f32, b: f32) -> bool {
615        (a - b).abs() < 1e-3
616    }
617
618    #[test]
619    fn the_preview_carries_the_output_aspect_and_not_the_consoles() {
620        for console in [TALL, SQUARE, (1280, 400)] {
621            let rect = preview_rect(WIDE, console).expect("a preview fits in every console here");
622            let want = WIDE.0 as f32 / WIDE.1 as f32;
623            assert!(
624                approx(rect.aspect(), want),
625                "console {console:?}: preview aspect {} is not the output's {want} — a \
626                 container's shape reached the picture (ADR-0037)",
627                rect.aspect()
628            );
629        }
630    }
631
632    #[test]
633    fn a_portrait_output_letterboxes_the_other_way() {
634        // The control on the test above: if the code read the container rather
635        // than the target, this case and that one cannot both pass, because
636        // the two aspects cross over.
637        let rect = preview_rect(TALL, WIDE).expect("a preview fits");
638        let want = TALL.0 as f32 / TALL.1 as f32;
639        assert!(
640            approx(rect.aspect(), want),
641            "portrait output came back at {} rather than {want}",
642            rect.aspect()
643        );
644        assert!(
645            rect.height > rect.width,
646            "a portrait output must produce a taller-than-wide rectangle, got \
647             {}x{}",
648            rect.width,
649            rect.height
650        );
651    }
652
653    #[test]
654    fn the_rectangle_stays_inside_the_console_with_its_margin() {
655        for console in [WIDE, TALL, SQUARE] {
656            let rect = preview_rect(WIDE, console).expect("a preview fits");
657            assert!(
658                rect.x >= 0.0 && rect.y >= 0.0,
659                "{rect:?} starts off-surface"
660            );
661            assert!(
662                approx(rect.x + rect.width, console.0 as f32 - SLOT_MARGIN),
663                "{rect:?} is not inset from the right edge of {console:?}"
664            );
665            assert!(
666                approx(rect.y + rect.height, console.1 as f32 - SLOT_MARGIN),
667                "{rect:?} is not inset from the bottom edge of {console:?}"
668            );
669        }
670    }
671
672    #[test]
673    fn the_tap_keeps_the_shows_aspect_and_centres_the_bars() {
674        // 16:10 into 16:9: bars left and right, and the picture centred between
675        // them. The tap is a resolution and not a shape (ADR-0037), so the
676        // show's aspect survives and the tap's does not reach the picture.
677        assert_eq!(fit_rect((1280, 800), (640, 360)), (32, 0, 576, 360));
678        // The other way: 4:3 into 16:9 is the same rule, and 21:9 into 16:9 puts
679        // the bars above and below instead — the case a test written only at
680        // 16:9 cannot tell apart from a stretch.
681        assert_eq!(fit_rect((1024, 768), (640, 360)), (80, 0, 480, 360));
682        assert_eq!(fit_rect((2560, 1080), (640, 360)), (0, 45, 640, 270));
683    }
684
685    #[test]
686    fn a_matching_aspect_fills_the_tap_with_no_bars() {
687        // The control on the test above: where the two aspects agree there is
688        // nothing to letterbox, and a fit that still inset the picture would be
689        // shrinking the show for no reason.
690        assert_eq!(fit_rect((1920, 1080), (640, 360)), (0, 0, 640, 360));
691        assert_eq!(fit_rect((640, 360), (640, 360)), (0, 0, 640, 360));
692    }
693
694    #[test]
695    fn a_degenerate_size_still_produces_a_drawable_rectangle() {
696        // A window mid-minimize reports a zero dimension, and a zero-extent
697        // viewport is a wgpu validation error rather than an empty frame.
698        for (src, dst) in [((0, 0), (640, 360)), ((1920, 0), (640, 360))] {
699            let (_, _, width, height) = fit_rect(src, dst);
700            assert!(
701                width >= 1 && height >= 1,
702                "fit_rect({src:?}, {dst:?}) produced a {width}x{height} viewport"
703            );
704        }
705        assert_eq!(fit_rect((1920, 1080), (0, 0)), (0, 0, 1, 1));
706    }
707
708    #[test]
709    fn a_console_too_small_to_show_anything_gets_no_preview() {
710        assert_eq!(preview_rect(WIDE, (120, 90)), None);
711        assert_eq!(preview_rect(WIDE, (0, 0)), None);
712        assert_eq!(preview_rect((0, 1080), SQUARE), None);
713    }
714}