frust_widgets/texture_view.rs
1//! The `TextureView`/`TextureWidget` leaf: draws an externally rendered scene
2//! texture — a video frame, a camera preview, a 3D view — by the id its
3//! producer registered it under.
4//!
5//! [`texture_view`] takes that id and nothing else: the renderer's frame bus
6//! reports it (`TextureRegistration::scene_texture_id`), as does any other
7//! external pass's own `SceneTextureId::get`. This crate never sees the
8//! texture itself; it paints a `draw_scene_texture(id, dest)` command and the
9//! engine composites whatever is bound under the id that frame (nothing, if
10//! nothing is bound yet — never an error).
11//!
12//! `.fit(..)` places the texture in the widget's box like [`crate::Image`]'s
13//! fit does, given the texture's own extent from `.source_size(..)` (the
14//! widget cannot read it back from the GPU); without one every fit fills the
15//! box. `.corner_radius(..)` clips the box to rounded corners. Sizing is
16//! `.expand()` by default, or `.size(..)`.
17//!
18//! # Inside a retained bracket
19//!
20//! The texture changes behind the display list's back — a new frame is
21//! uploaded without the widget repainting — so a retained subtree holding a
22//! texture view cannot be replayed from a cached body: the engine accepts the
23//! draw inside a retained bracket but marks that body unretainable and paints
24//! it every frame. Keep a texture view outside a repaint boundary's subtree
25//! when the rest of that subtree is worth retaining.
26//!
27//! ```
28//! use frust_core::View;
29//! use frust_widgets::{TextureFit, texture_view};
30//! use kurbo::Size;
31//!
32//! fn player(video_id: u64) -> impl View<()> + use<> {
33//! texture_view(video_id)
34//! .source_size(Size::new(1920.0, 1080.0))
35//! .fit(TextureFit::Contain)
36//! .corner_radius(12.0)
37//! .label("Video")
38//! }
39//! # let _ = player;
40//! ```
41
42use frust_core::accesskit::Role;
43use frust_core::{
44 BoxConstraints, BuildCtx, ChangeFlags, LayoutCtx, PaintCtx, PaintScene, SemanticsCtx, View,
45 Widget,
46};
47use kurbo::{Rect, Size};
48
49/// How a [`TextureView`] places its texture in the widget's box.
50#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
51pub enum TextureFit {
52 /// Scale (preserving aspect ratio) to fit entirely inside the box,
53 /// centred; the other axis is letterboxed.
54 #[default]
55 Contain,
56 /// Scale (preserving aspect ratio) to cover the box entirely, centred;
57 /// the overflow is clipped to the box.
58 Cover,
59 /// Stretch to exactly the box, ignoring aspect ratio.
60 Fill,
61}
62
63/// How a [`TextureView`] resolves its layout size.
64#[derive(Clone, Copy, Debug, PartialEq)]
65enum TextureSize {
66 /// Fill the incoming constraints' maximum.
67 Expand,
68 /// A fixed size, clamped into the incoming constraints.
69 Explicit(Size),
70}
71
72/// The rect (relative to the widget's own origin) a `natural`-sized texture
73/// is drawn into inside a `dest`-sized box under `fit`.
74///
75/// `Fill` — or a degenerate `natural`/`dest`, which has no aspect ratio to
76/// keep — is exactly the box. `Contain` is centred and no larger than the box
77/// on either axis; `Cover` is centred and no smaller (the caller clips).
78#[must_use]
79pub fn texture_fit_rect(natural: Size, dest: Size, fit: TextureFit) -> Rect {
80 let degenerate = !(natural.width > 0.0
81 && natural.height > 0.0
82 && dest.width > 0.0
83 && dest.height > 0.0
84 && natural.width.is_finite()
85 && natural.height.is_finite());
86 let fit_width = dest.width / natural.width;
87 let fit_height = dest.height / natural.height;
88 let scale = match fit {
89 _ if degenerate => return Rect::from_origin_size((0.0, 0.0), dest),
90 TextureFit::Fill => return Rect::from_origin_size((0.0, 0.0), dest),
91 TextureFit::Contain => fit_width.min(fit_height),
92 TextureFit::Cover => fit_width.max(fit_height),
93 };
94 let width = natural.width * scale;
95 let height = natural.height * scale;
96 let x = (dest.width - width) / 2.0;
97 let y = (dest.height - height) / 2.0;
98 Rect::new(x, y, x + width, y + height)
99}
100
101/// A declarative view of an externally rendered scene texture. See the
102/// [module docs](self).
103#[derive(Clone, Debug, PartialEq)]
104pub struct TextureView {
105 id: u64,
106 fit: TextureFit,
107 source_size: Option<Size>,
108 size: TextureSize,
109 corner_radius: f64,
110 label: Option<String>,
111}
112
113/// A view drawing the scene texture registered under `scene_texture_id`,
114/// full-bleed and [`TextureFit::Contain`] by default. See the
115/// [module docs](self).
116#[must_use]
117pub fn texture_view(scene_texture_id: u64) -> TextureView {
118 TextureView {
119 id: scene_texture_id,
120 fit: TextureFit::default(),
121 source_size: None,
122 size: TextureSize::Expand,
123 corner_radius: 0.0,
124 label: None,
125 }
126}
127
128impl TextureView {
129 /// How the texture is placed in the box. Needs [`Self::source_size`] to
130 /// keep an aspect ratio; without it every fit fills the box.
131 #[must_use]
132 pub fn fit(mut self, fit: TextureFit) -> Self {
133 self.fit = fit;
134 self
135 }
136
137 /// The texture's own extent (a video's resolution), which `fit` keeps the
138 /// aspect ratio of. Only the ratio matters.
139 #[must_use]
140 pub fn source_size(mut self, size: Size) -> Self {
141 self.source_size = Some(size);
142 self
143 }
144
145 /// A fixed size, clamped into the parent's constraints. Overrides
146 /// [`Self::expand`] when called after it.
147 #[must_use]
148 pub fn size(mut self, size: Size) -> Self {
149 self.size = TextureSize::Explicit(size);
150 self
151 }
152
153 /// Fill the parent's maximum constraints — the default.
154 #[must_use]
155 pub fn expand(mut self) -> Self {
156 self.size = TextureSize::Expand;
157 self
158 }
159
160 /// Clip the box to corners of `radius`; `0.0` (the default) is square.
161 #[must_use]
162 pub fn corner_radius(mut self, radius: f64) -> Self {
163 self.corner_radius = radius.max(0.0);
164 self
165 }
166
167 /// An accessible label, contributing a [`Role::Image`] semantics node; an
168 /// unlabelled texture view is decorative and reports nothing.
169 #[must_use]
170 pub fn label(mut self, label: impl Into<String>) -> Self {
171 self.label = Some(label.into());
172 self
173 }
174}
175
176impl<State: 'static> View<State> for TextureView {
177 type Element = TextureWidget;
178
179 fn build(&self, _ctx: &mut BuildCtx<'_>) -> TextureWidget {
180 TextureWidget { view: self.clone() }
181 }
182
183 fn rebuild(
184 &self,
185 prev: &Self,
186 element: &mut TextureWidget,
187 _ctx: &mut BuildCtx<'_>,
188 ) -> ChangeFlags {
189 if prev == self {
190 return ChangeFlags::NONE;
191 }
192 let mut flags = ChangeFlags::PAINT;
193 if prev.size != self.size {
194 flags |= ChangeFlags::LAYOUT;
195 }
196 element.view = self.clone();
197 flags
198 }
199}
200
201/// The retained widget for a [`TextureView`].
202pub struct TextureWidget {
203 view: TextureView,
204}
205
206impl TextureWidget {
207 /// The scene texture id this widget draws.
208 #[must_use]
209 pub fn scene_texture_id(&self) -> u64 {
210 self.view.id
211 }
212}
213
214impl Widget for TextureWidget {
215 fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
216 match self.view.size {
217 TextureSize::Explicit(size) => bc.constrain(size),
218 TextureSize::Expand => bc.max(),
219 }
220 }
221
222 fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
223 let origin = ctx.origin();
224 let size = ctx.size();
225 let natural = self.view.source_size.unwrap_or(size);
226 let local = texture_fit_rect(natural, size, self.view.fit);
227 let dest = local + origin.to_vec2();
228
229 let overflows =
230 local.x0 < 0.0 || local.y0 < 0.0 || local.x1 > size.width || local.y1 > size.height;
231 let clip = if self.view.corner_radius > 0.0 {
232 scene.push_clip_rounded(origin, size, self.view.corner_radius);
233 true
234 } else if overflows {
235 scene.push_clip(origin, size);
236 true
237 } else {
238 false
239 };
240 scene.draw_scene_texture(self.view.id, dest);
241 if clip {
242 scene.pop_clip();
243 }
244 }
245
246 fn semantics(&self, ctx: &mut SemanticsCtx) {
247 if let Some(label) = &self.view.label {
248 ctx.push_node(Role::Image, |node| {
249 node.set_label(label.as_str());
250 });
251 }
252 }
253}