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}