teksilo_widgets/primitives/live_image.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! LiveImage — shows a picture another thread rewrites many times a second.
5//!
6//! A virtual machine's screen, a video frame, a camera preview: the pixels
7//! never pass through the widget. A producer writes a
8//! [`LiveImageSource`] from any thread through a [`LiveImageWriter`]; the
9//! window's renderer uploads what changed since its last frame and draws it
10//! where this widget's last paint put it. A commit that changes only pixels
11//! runs no `paint()`, marks no widget and repaints nothing else: the window
12//! replays its cached frame. Only a change of the source's size or status
13//! (resized, `Waiting`, `Live`, `Disconnected`) relayouts and repaints this
14//! widget. A producer that hands over whole frames, changed or not, writes
15//! through a [`LiveImageDiffWriter`] instead, which commits only what changed
16//! and nothing for an identical frame.
17//!
18//! # Sizing
19//!
20//! [`LiveImageSizing`] picks the box: `Aspect` (the default) is the largest
21//! box with the picture's aspect ratio inside the proposal, rounded to whole
22//! device pixels; `Fill` takes the whole proposal and letterboxes; `Natural`
23//! is one source pixel per logical pixel ([`device_pixels`](LiveImage::device_pixels)
24//! makes it one per device pixel). [`width`](LiveImage::width),
25//! [`height`](LiveImage::height) and [`size`](LiveImage::size) pin the box
26//! and win over the mode. Inside the box the picture is placed by an
27//! [`ImageFit`] and an [`Alignment`], turned by an [`ImageOrientation`], and
28//! its edges snap to the device-pixel grid.
29//!
30//! ```rust
31//! use teksilo_widgets::primitives::live_image::{LiveImage, LiveImageSizing};
32//! use teksilo_widgets::primitives::live_image::{LiveImageSource, LivePixelFormat};
33//! use teksilo_widgets::primitives::ImageFit;
34//!
35//! let screen = LiveImageSource::new(LivePixelFormat::Bgrx8);
36//! let writer = screen.writer();
37//! // The producer keeps its writer: when the last one drops, the source
38//! // frees its pixels and the picture goes.
39//! std::thread::spawn(move || loop {
40//! let frame = vec![0u8; 720 * 1280 * 4]; // the guest's next frame
41//! writer.write_frame(720, 1280, &frame, 720 * 4).unwrap();
42//! std::thread::sleep(std::time::Duration::from_millis(16));
43//! });
44//! let _view = LiveImage::new(screen)
45//! .sizing(LiveImageSizing::Aspect)
46//! .fit(ImageFit::Contain)
47//! .alt("Virtual machine screen");
48//! ```
49//!
50//! # Input
51//!
52//! `LiveImage` handles no input. An app that forwards pointer input to what
53//! the picture shows attaches its handlers through `WidgetBuilder` and maps
54//! the positions they receive with the widget's [`LiveImageHandle`], taken
55//! before a `WidgetBuilder` method wraps the widget (or injected with
56//! [`with_handle`](LiveImage::with_handle)):
57//! [`map_to_source`](LiveImageHandle::map_to_source) gives the source pixel
58//! a point shows, from the same placement paint drew. A position handed in
59//! window space (`Scroll::window_position`) goes through
60//! `EventContext::to_local` first.
61//!
62//! # Accessibility
63//!
64//! One `Role::Image` node named by [`alt`](LiveImage::alt); a decorative
65//! picture calls [`a11y_hidden`](LiveImage::a11y_hidden) instead. Pixels are
66//! not accessible content, and a commit never changes the node: only a
67//! status change does, when the [`placeholder`](LiveImage::placeholder)
68//! becomes or stops being its description.
69
70use std::cell::{Cell, RefCell};
71use std::rc::{Rc, Weak};
72
73use teksilo_canvas::live_image::LiveImageDraw;
74use teksilo_canvas::{Canvas, Point, Rect, Size, SizeProposal, Transform2D};
75use teksilo_core::accessibility::AccessNodeBuilder;
76use teksilo_core::binding::BindingLevel;
77use teksilo_core::build_context::BuildContext;
78use teksilo_core::color_prop::ColorProp;
79use teksilo_core::environment::LayoutDirection;
80use teksilo_core::signal::{Prop, Signal};
81use teksilo_core::widget::{LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement};
82use teksilo_core::{LiveImageAttachment, LiveImageSignals, WidgetId};
83use teksilo_tokens::{Alignment, Color, TextRole, TextStyleRole};
84
85pub use teksilo_canvas::image_geometry::{ImageFit, ImageGeometry, ImageOrientation, PixelRect};
86pub use teksilo_canvas::live_image::{
87 LiveImageDiffWriter, LiveImageSource, LiveImageStats, LiveImageStatus, LiveImageWriter,
88 LivePixelFormat, ScalingFilter,
89};
90
91/// How a [`LiveImage`] sizes its box. [`LiveImage::width`],
92/// [`LiveImage::height`] and [`LiveImage::size`] pin it and win over the
93/// mode.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
95#[non_exhaustive]
96pub enum LiveImageSizing {
97 /// The largest box with the picture's aspect ratio inside the proposal,
98 /// each side rounded to whole device pixels. It scales up as well as
99 /// down; an axis the proposal leaves open follows the other one. With no
100 /// frame size yet, the proposal on its bounded axes, else zero.
101 #[default]
102 Aspect,
103 /// The whole proposal, growing into a stack's slack; the
104 /// [`fit`](LiveImage::fit) places the picture and the rest is
105 /// letterbox. An axis the proposal leaves open follows the picture's
106 /// aspect ratio.
107 Fill,
108 /// The picture's own size, rigid: one source pixel per logical pixel,
109 /// or per device pixel with [`device_pixels`](LiveImage::device_pixels).
110 /// Zero with no frame size yet.
111 Natural,
112}
113
114/// Shows a [`LiveImageSource`]. See the [module documentation](self).
115pub struct LiveImage {
116 source: Prop<LiveImageSource>,
117 fit: ImageFit,
118 alignment: Alignment,
119 sizing: LiveImageSizing,
120 width: Option<f32>,
121 height: Option<f32>,
122 device_pixels: bool,
123 scaling: ScalingFilter,
124 orientation: ImageOrientation,
125 pixel_snap: bool,
126 background: ColorProp,
127 placeholder: Prop<String>,
128 alt: Option<Prop<String>>,
129 a11y_hidden: bool,
130 pause_when_inactive: Prop<bool>,
131 dim_when_disabled: bool,
132 shared: Rc<Shared>,
133 /// What the mounted widget shares with its handles. The widget owns it,
134 /// so a handle that outlives the widget sees nothing.
135 mounted: Option<Rc<Mounted>>,
136}
137
138/// What a `LiveImage` and its handles share from construction on.
139struct Shared {
140 /// The size and status the window's last layout saw. Kept across a
141 /// switch to another source.
142 signals: LiveImageSignals,
143 /// The source the widget shows; `None` until a widget takes the handle.
144 source: RefCell<Option<LiveImageSource>>,
145 mounted: RefCell<Weak<Mounted>>,
146}
147
148/// One mount of the widget: its attachment, and the placement its last
149/// layout computed.
150struct Mounted {
151 attachment: LiveImageAttachment,
152 placement: Cell<Option<Placement>>,
153}
154
155/// Where the picture lies, as computed for one window-space box.
156#[derive(Debug, Clone, Copy)]
157struct Placement {
158 /// The fit's own placement, widget-local, unsnapped.
159 fitted: ImageGeometry,
160 /// What paint draws and input maps through: `fitted`, its edges on the
161 /// device grid when snapping applies.
162 shown: ImageGeometry,
163 /// The window-space box it was computed for.
164 window: Rect,
165 /// Window space to device pixels, the box's own origin aside: the
166 /// ancestors' transforms, then the device scale. `None` when the
167 /// picture is not snapped.
168 to_device: Option<Transform2D>,
169}
170
171impl Placement {
172 /// The placement of `fitted` in the window-space box `window`.
173 fn new(fitted: ImageGeometry, window: Rect, to_device: Option<Transform2D>) -> Self {
174 let shown = match to_device {
175 Some(device) => {
176 fitted.snapped(Transform2D::translate(window.x, window.y).then(&device))
177 }
178 None => fitted,
179 };
180 Self {
181 fitted,
182 shown,
183 window,
184 to_device,
185 }
186 }
187}
188
189impl LiveImage {
190 /// Show `source`. A `Signal<LiveImageSource>` switches sources: the
191 /// widget rebuilds and attaches the new one, keeping its handle's
192 /// Signals, and the window lets go of the old one's texture at its next
193 /// frame. A small picture its source committed once is kept a while
194 /// instead, in case it comes back.
195 pub fn new(source: impl Into<Prop<LiveImageSource>>) -> Self {
196 let source = source.into();
197 let current = source.get();
198 let shared = Rc::new(Shared {
199 signals: LiveImageSignals::new(¤t),
200 source: RefCell::new(Some(current)),
201 mounted: RefCell::new(Weak::new()),
202 });
203 Self {
204 source,
205 fit: ImageFit::Contain,
206 alignment: Alignment::CENTER,
207 sizing: LiveImageSizing::Aspect,
208 width: None,
209 height: None,
210 device_pixels: false,
211 scaling: ScalingFilter::Linear,
212 orientation: ImageOrientation::Normal,
213 pixel_snap: true,
214 background: ColorProp::Static(Color::TRANSPARENT),
215 placeholder: Prop::Static(String::new()),
216 alt: None,
217 a11y_hidden: false,
218 pause_when_inactive: Prop::Static(false),
219 dim_when_disabled: false,
220 shared,
221 mounted: None,
222 }
223 }
224
225 /// How the picture fills its box: the CSS `object-fit` set. Default
226 /// [`ImageFit::Contain`]. `Cover`, and `None` on a picture larger than
227 /// the box, crop it to the box.
228 pub fn fit(mut self, fit: ImageFit) -> Self {
229 self.fit = fit;
230 self
231 }
232
233 /// Where the picture sits in its box when the fit leaves room or crops.
234 /// Leading and Trailing follow the layout direction. Default
235 /// [`Alignment::CENTER`].
236 pub fn alignment(mut self, alignment: Alignment) -> Self {
237 self.alignment = alignment;
238 self
239 }
240
241 /// How the box is sized. Default [`LiveImageSizing::Aspect`].
242 pub fn sizing(mut self, sizing: LiveImageSizing) -> Self {
243 self.sizing = sizing;
244 self
245 }
246
247 /// Pin the box's width, in logical pixels; its height follows the
248 /// picture's aspect ratio (zero before the first frame).
249 pub fn width(mut self, width: f32) -> Self {
250 self.width = Some(width);
251 self
252 }
253
254 /// Pin the box's height, in logical pixels; its width follows the
255 /// picture's aspect ratio (zero before the first frame).
256 pub fn height(mut self, height: f32) -> Self {
257 self.height = Some(height);
258 self
259 }
260
261 /// Pin the box to `width` × `height` logical pixels.
262 pub fn size(mut self, width: f32, height: f32) -> Self {
263 self.width = Some(width);
264 self.height = Some(height);
265 self
266 }
267
268 /// Measure the picture's own size in device pixels: one source pixel
269 /// per device pixel instead of per logical pixel. The fit starts from
270 /// that size too: `ImageFit::None` draws the whole picture at it, and
271 /// `ImageFit::ScaleDown` never grows it past it. With
272 /// `sizing(Natural)`, `fit(ImageFit::None)` and
273 /// `scaling(ScalingFilter::Nearest)`, every texel lands on one device
274 /// pixel, at any device scale. Default false.
275 pub fn device_pixels(mut self, on: bool) -> Self {
276 self.device_pixels = on;
277 self
278 }
279
280 /// How the picture is sampled when drawn at another size. Default
281 /// [`ScalingFilter::Linear`]; `Nearest` keeps an integer upscale crisp,
282 /// and `Trilinear` keeps a thumbnail drawn below half size from
283 /// aliasing.
284 pub fn scaling(mut self, scaling: ScalingFilter) -> Self {
285 self.scaling = scaling;
286 self
287 }
288
289 /// How the picture is turned or mirrored for display. A quarter turn
290 /// swaps the width and height the box is sized from. Default
291 /// [`ImageOrientation::Normal`].
292 pub fn orientation(mut self, orientation: ImageOrientation) -> Self {
293 self.orientation = orientation;
294 self
295 }
296
297 /// Snap the picture's edges to the device-pixel grid, which keeps a 1:1
298 /// picture sharp. An edge moves by less than one device pixel. Turn it
299 /// off only for a picture whose box is animated, where that step would
300 /// show: the placement is then left where the layout puts it, and the
301 /// renderer does not snap a one-to-one picture either. Snapping applies
302 /// under translations and scales; a rotated or skewed ancestor turns it
303 /// off. Default true.
304 pub fn pixel_snap(mut self, on: bool) -> Self {
305 self.pixel_snap = on;
306 self
307 }
308
309 /// The fill of the letterbox, and of the whole box while the source is
310 /// not `Live`. A role or a `Signal` follows the theme and the window.
311 /// Default transparent.
312 pub fn background(mut self, color: impl Into<ColorProp>) -> Self {
313 self.background = color.into();
314 self
315 }
316
317 /// Text shown centred in the box while the source is not `Live`
318 /// (secondary text, body style, truncated to the box), and the node's
319 /// description then. Painted, so it takes no input. Default empty.
320 pub fn placeholder(mut self, text: impl Into<Prop<String>>) -> Self {
321 self.placeholder = text.into();
322 self
323 }
324
325 /// The accessible name of the picture. A `tr!` string follows the
326 /// locale. Give it, or call [`a11y_hidden`](Self::a11y_hidden): a debug
327 /// build asserts one of them.
328 pub fn alt(mut self, text: impl Into<Prop<String>>) -> Self {
329 self.alt = Some(text.into());
330 self
331 }
332
333 /// Hide a decorative picture from assistive technology: what it shows
334 /// is said by text beside it.
335 pub fn a11y_hidden(mut self) -> Self {
336 self.a11y_hidden = true;
337 self
338 }
339
340 /// Stop uploading while the window is inactive: not focused, or covered
341 /// where the platform reports it. The picture keeps the last frame it
342 /// uploaded and a commit no longer wakes the window; once the window is
343 /// active again, the next frame shows the latest commit, in one upload.
344 /// A change of size or status still applies, a picture with nothing of
345 /// its size uploaded yet still gets its first frame, and a screenshot
346 /// shows the latest commit all the same. The pause belongs to the
347 /// window's texture: while another widget of the window shows the same
348 /// source unpaused, both stay live. A `Signal<bool>` turns it on and off
349 /// as the user decides. Default false.
350 pub fn pause_when_inactive(mut self, pause: impl Into<Prop<bool>>) -> Self {
351 self.pause_when_inactive = pause.into();
352 self
353 }
354
355 /// Dim the picture in a disabled subtree, by the theme's
356 /// [`disabled_content_opacity`](teksilo_tokens::ColorTokens::disabled_content_opacity)
357 /// over the widget's background, which then fills the whole box. By
358 /// default a live picture keeps its full strength when an ancestor is
359 /// disabled: a VM's screen is content, not a control. A commit still
360 /// repaints nothing while dimmed. Default false.
361 pub fn dim_when_disabled(mut self, on: bool) -> Self {
362 self.dim_when_disabled = on;
363 self
364 }
365
366 /// Drive this widget through `handle`, made beforehand with
367 /// [`LiveImageHandle::new`]: the form a `teksu!` tree can use, where
368 /// [`handle`](Self::handle) cannot be called. A handle follows one
369 /// widget; handing it to a second moves it there.
370 pub fn with_handle(mut self, handle: &LiveImageHandle) -> Self {
371 let current = self.source.get();
372 handle.shared.signals.frame_size.set(current.size());
373 handle.shared.signals.status.set(current.status());
374 *handle.shared.source.borrow_mut() = Some(current);
375 self.shared = handle.shared.clone();
376 self
377 }
378
379 /// A handle to this widget, for the UI thread: its source's size and
380 /// status, its placement, and the mapping from widget-local points to
381 /// source pixels. Take it before a `WidgetBuilder` method wraps the
382 /// widget.
383 pub fn handle(&self) -> LiveImageHandle {
384 LiveImageHandle {
385 shared: self.shared.clone(),
386 }
387 }
388
389 /// The picture's own size in logical pixels, turned for display.
390 fn natural(&self, frame: (u32, u32), scale: f32) -> Size {
391 let (w, h) = self.orientation.displayed_size(frame.0, frame.1);
392 let per_pixel = if self.device_pixels { scale } else { 1.0 };
393 Size::new(w as f32 / per_pixel, h as f32 / per_pixel)
394 }
395
396 /// The box this widget asks for under `proposal`.
397 fn box_size(&self, proposal: SizeProposal, scale: f32) -> LayoutResponse {
398 let bounded = |p: Option<f32>| p.filter(|v| v.is_finite());
399 let (pw, ph) = (bounded(proposal.width), bounded(proposal.height));
400 let natural = self
401 .shared
402 .signals
403 .frame_size
404 .get()
405 .map(|frame| self.natural(frame, scale));
406 match (self.width, self.height, natural) {
407 (Some(w), Some(h), _) => Size::new(w, h).into(),
408 (Some(w), None, Some(n)) => Size::new(w, w * n.height / n.width).into(),
409 (None, Some(h), Some(n)) => Size::new(h * n.width / n.height, h).into(),
410 (Some(w), None, None) => Size::new(w, 0.0).into(),
411 (None, Some(h), None) => Size::new(0.0, h).into(),
412 (None, None, natural) => match (self.sizing, natural) {
413 (LiveImageSizing::Natural, Some(n)) => n.into(),
414 (LiveImageSizing::Natural, None) => Size::new(0.0, 0.0).into(),
415 (LiveImageSizing::Aspect, Some(n)) => aspect_box(n, pw, ph, scale).into(),
416 (LiveImageSizing::Fill, Some(n)) => {
417 let fitted = aspect_box(n, pw, ph, scale);
418 LayoutResponse::flexible(
419 Size::new(pw.unwrap_or(fitted.width), ph.unwrap_or(fitted.height)),
420 1.0,
421 )
422 }
423 (LiveImageSizing::Fill, None) => {
424 LayoutResponse::flexible(Size::new(pw.unwrap_or(0.0), ph.unwrap_or(0.0)), 1.0)
425 }
426 (_, None) => Size::new(pw.unwrap_or(0.0), ph.unwrap_or(0.0)).into(),
427 },
428 }
429 }
430
431 /// The placement for the window-space box `window` at device scale
432 /// `scale`, or `None` with no frame size.
433 ///
434 /// The picture is fitted from its natural size, the one its box is
435 /// measured from: with `device_pixels`, one source pixel per device
436 /// pixel. `Fill`, `Contain` and `Cover` do not depend on it; `None`
437 /// draws at it and `ScaleDown` never grows past it, so a picture
438 /// measured in device pixels is neither cropped nor drawn larger.
439 fn place(
440 &self,
441 window: Rect,
442 rtl: bool,
443 scale: f32,
444 to_device: Option<Transform2D>,
445 ) -> Option<Placement> {
446 let frame = self.shared.signals.frame_size.get()?;
447 let bounds = Rect::new(0.0, 0.0, window.width, window.height);
448 let content = self
449 .fit
450 .fitted_rect(self.natural(frame, scale), bounds, self.alignment, rtl);
451 let fitted = ImageGeometry::new(frame, self.orientation, content, bounds);
452 Some(Placement::new(fitted, window, to_device))
453 }
454
455 /// The placement paint draws in `bounds`: the one the last layout
456 /// computed, or, when a parent moved the widget without laying it out
457 /// again, the same one recomputed there and kept.
458 fn placement_for(
459 &self,
460 mounted: &Mounted,
461 bounds: Rect,
462 rtl: bool,
463 scale: f32,
464 ) -> Option<Placement> {
465 let stored = mounted.placement.get();
466 match stored {
467 Some(p) if p.window == bounds => Some(p),
468 Some(p) if p.window.width == bounds.width && p.window.height == bounds.height => {
469 let moved = Placement::new(p.fitted, bounds, p.to_device);
470 mounted.placement.set(Some(moved));
471 Some(moved)
472 }
473 _ => {
474 let to_device = stored.and_then(|p| p.to_device);
475 let fresh = self.place(bounds, rtl, scale, to_device.filter(|_| self.pixel_snap));
476 mounted.placement.set(fresh);
477 fresh
478 }
479 }
480 }
481
482 /// Paint the placeholder centred in `bounds`, truncated to its width.
483 fn paint_placeholder(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
484 let text = self.placeholder.get();
485 if text.is_empty() {
486 return;
487 }
488 let Some(backend) = canvas.text_backend().cloned() else {
489 return;
490 };
491 let style = TextStyleRole::Body.resolve(&ctx.theme.typography);
492 let color =
493 ColorProp::TextRole(TextRole::Secondary).resolve(ctx.theme, ctx.effective_enabled);
494 // The same small epsilon `Canvas::draw_text` adds, so a label that
495 // fits exactly is not truncated.
496 let layout =
497 backend
498 .borrow_mut()
499 .layout_single_line(&text, &style, Some(bounds.width + 0.5));
500 let origin = Point::new(
501 bounds.x + ((bounds.width - layout.width) / 2.0).max(0.0),
502 bounds.y + ((bounds.height - layout.height) / 2.0).max(0.0),
503 );
504 if !canvas.draw_text_layout(&layout, origin, color) {
505 // The layout's glyphs were evicted: shape it again.
506 canvas.draw_text(
507 &text,
508 Rect::new(origin.x, origin.y, bounds.width, layout.height),
509 &style,
510 color,
511 );
512 }
513 }
514}
515
516/// `scale` when it is a device scale, else 1.
517fn usable_scale(scale: f32) -> f32 {
518 if scale.is_finite() && scale > 0.0 {
519 scale
520 } else {
521 1.0
522 }
523}
524
525/// The `Aspect` box: `natural` scaled to the largest size that fits the
526/// bounded axes, each side rounded to whole device pixels and kept inside
527/// the proposal's device grid.
528fn aspect_box(natural: Size, pw: Option<f32>, ph: Option<f32>, scale: f32) -> Size {
529 let factor = match (pw, ph) {
530 (Some(pw), Some(ph)) => (pw / natural.width).min(ph / natural.height),
531 (Some(pw), None) => pw / natural.width,
532 (None, Some(ph)) => ph / natural.height,
533 (None, None) => 1.0,
534 };
535 let round = |side: f32| (side * scale).round() / scale;
536 let within = |side: f32, p: Option<f32>| match p {
537 Some(p) => side.min((p * scale).floor() / scale),
538 None => side,
539 };
540 Size::new(
541 within(round(natural.width * factor), pw).max(0.0),
542 within(round(natural.height * factor), ph).max(0.0),
543 )
544}
545
546/// The parts of `bounds` outside `picture`: up to four rects, above, below,
547/// leading and trailing.
548fn letterbox(bounds: Rect, picture: Rect) -> impl Iterator<Item = Rect> {
549 let top = picture.y - bounds.y;
550 let bottom = bounds.y + bounds.height - (picture.y + picture.height);
551 let left = picture.x - bounds.x;
552 let right = bounds.x + bounds.width - (picture.x + picture.width);
553 [
554 Rect::new(bounds.x, bounds.y, bounds.width, top),
555 Rect::new(bounds.x, picture.y + picture.height, bounds.width, bottom),
556 Rect::new(bounds.x, picture.y, left, picture.height),
557 Rect::new(picture.x + picture.width, picture.y, right, picture.height),
558 ]
559 .into_iter()
560 .filter(|r| r.width > 0.0 && r.height > 0.0)
561}
562
563/// `rect`, widget-local, in the window space of a box at `origin`.
564fn at(rect: Rect, origin: Rect) -> Rect {
565 Rect::new(
566 rect.x + origin.x,
567 rect.y + origin.y,
568 rect.width,
569 rect.height,
570 )
571}
572
573impl Widget for LiveImage {
574 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
575 let id = ctx.self_id();
576 let source = self.source.get();
577 *self.shared.source.borrow_mut() = Some(source.clone());
578 let attachment = ctx.attach_live_image(&source, &self.shared.signals);
579 let registry = ctx.binding_registry();
580 self.source
581 .register_if_bound(id, registry, BindingLevel::Rebuild);
582 let signals = &self.shared.signals;
583 signals
584 .frame_size
585 .bind_to(id, registry, BindingLevel::Relayout);
586 signals
587 .status
588 .bind_to(id, registry, BindingLevel::RepaintOnly);
589 signals
590 .status
591 .bind_to(id, registry, BindingLevel::AccessibilityOnly);
592 self.placeholder
593 .register_if_bound(id, registry, BindingLevel::RepaintOnly);
594 self.placeholder
595 .register_if_bound(id, registry, BindingLevel::AccessibilityOnly);
596 if let Some(alt) = &self.alt {
597 alt.register_if_bound(id, registry, BindingLevel::AccessibilityOnly);
598 }
599 self.background
600 .register_if_bound(id, registry, BindingLevel::RepaintOnly);
601 // A window turning active or inactive repaints every widget, so only
602 // the user's own switch needs a binding.
603 self.pause_when_inactive
604 .register_if_bound(id, registry, BindingLevel::RepaintOnly);
605 // The box rounds to device pixels and the picture snaps to them, so
606 // a move to a display with another scale lays it out again, even
607 // when the window's logical size stays the same.
608 ctx.device_scale_signal()
609 .bind_to(id, registry, BindingLevel::Relayout);
610 let mounted = Rc::new(Mounted {
611 attachment,
612 placement: Cell::new(None),
613 });
614 *self.shared.mounted.borrow_mut() = Rc::downgrade(&mounted);
615 self.mounted = Some(mounted);
616 vec![]
617 }
618
619 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
620 self.box_size(proposal, usable_scale(ctx.scale_factor))
621 }
622
623 /// The placement paint will draw and input maps through, computed once
624 /// per layout: the edges snap in device space, through the widget's
625 /// window origin, its ancestors' transforms and the device scale.
626 fn place_children(
627 &self,
628 bounds: Rect,
629 _proposal: SizeProposal,
630 _children: &mut [WidgetPlacement],
631 ctx: &LayoutContext,
632 ) {
633 let Some(mounted) = &self.mounted else {
634 return;
635 };
636 let snapped_under = self.pixel_snap.then(|| {
637 ctx.arena()
638 .map(|arena| arena.effective_transform(mounted.attachment.widget_id()))
639 .unwrap_or(Transform2D::IDENTITY)
640 });
641 // An ancestor's transform changed later lays this widget out again,
642 // through the attachment, so the snap follows it.
643 mounted.attachment.set_snapped_under(snapped_under);
644 let to_device = snapped_under.map(|ancestors| {
645 ancestors.then(&Transform2D::scale(ctx.scale_factor, ctx.scale_factor))
646 });
647 let placement = self.place(
648 bounds,
649 ctx.is_rtl(),
650 usable_scale(ctx.scale_factor),
651 to_device,
652 );
653 mounted.placement.set(placement);
654 // A source keeps its size once it has one (a cleared buffer's size
655 // stays as its hint), so a placement is never taken back.
656 if let Some(p) = placement {
657 mounted.attachment.set_geometry(p.shown);
658 }
659 }
660
661 /// The background, then the picture's one quad (emitted whatever the
662 /// status, so the window takes the source's commits), inside an opacity
663 /// scope while dimmed, then the placeholder while the source is not
664 /// `Live`.
665 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
666 let Some(mounted) = &self.mounted else {
667 return;
668 };
669 let rtl = matches!(ctx.layout_direction, LayoutDirection::RightToLeft);
670 let shown = self
671 .placement_for(mounted, bounds, rtl, usable_scale(ctx.scale_factor))
672 .map(|p| p.shown);
673 let live = self.shared.signals.status.get() == LiveImageStatus::Live;
674 // The enabled state needs no binding: an ancestor's change repaints
675 // the whole subtree.
676 let dimmed = self.dim_when_disabled && !ctx.effective_enabled;
677
678 let background = self.background.resolve(ctx.theme, ctx.effective_enabled);
679 if background.a() > 0.0 {
680 // A dimmed picture blends over the background, so it fills the
681 // whole box then.
682 match shown.and_then(|g| g.visible()).filter(|_| live && !dimmed) {
683 Some(picture) => {
684 for rect in letterbox(bounds, at(picture, bounds)) {
685 canvas.fill_rect(rect, background);
686 }
687 }
688 None => canvas.fill_rect(bounds, background),
689 }
690 }
691
692 let content = shown
693 .map(|g| at(g.content, bounds))
694 .unwrap_or(Rect::new(bounds.x, bounds.y, 0.0, 0.0));
695 if dimmed {
696 canvas.set_opacity(ctx.theme.colors.disabled_content_opacity());
697 }
698 canvas.draw_live_image(
699 mounted.attachment.consumer(),
700 &LiveImageDraw::new(content, bounds)
701 .filter(self.scaling)
702 .orientation(self.orientation)
703 .paused(self.pause_when_inactive.get() && !ctx.window_active)
704 .pixel_snap(self.pixel_snap),
705 );
706 if dimmed {
707 canvas.restore_opacity();
708 }
709
710 if !live {
711 self.paint_placeholder(bounds, canvas, ctx);
712 }
713 }
714
715 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
716 if self.a11y_hidden {
717 builder.set_hidden();
718 return;
719 }
720 debug_assert!(
721 self.alt.is_some(),
722 "LiveImage has no alt text — call .alt(\"…\") for a meaningful picture or \
723 .a11y_hidden() for a decorative one"
724 );
725 builder.set_role(teksilo_core::accesskit::Role::Image);
726 if let Some(alt) = &self.alt {
727 builder.set_name(alt.get());
728 }
729 if self.shared.signals.status.get() != LiveImageStatus::Live {
730 let placeholder = self.placeholder.get();
731 if !placeholder.is_empty() {
732 builder.set_description(placeholder);
733 }
734 }
735 }
736
737 fn as_any(&self) -> Option<&dyn std::any::Any> {
738 Some(self)
739 }
740
741 fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
742 Some(self)
743 }
744}
745
746impl std::fmt::Debug for LiveImage {
747 /// What the source and the window did, from atomics and the widget's
748 /// own state, never under the source's lock:
749 /// `LiveImage { source: #3 "vm-screen" Bgrx8 720x1280 Live, gen: 5021,
750 /// window_gen: 5019, sizing: Aspect, fit: Contain, orientation: Normal,
751 /// scaling: Linear, content: (0, 0, 424, 754), paints: 4, paused: false }`.
752 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
753 struct Source<'a>(&'a LiveImageSource);
754 impl std::fmt::Debug for Source<'_> {
755 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
756 let source = self.0;
757 write!(f, "{}", source.id())?;
758 if let Some(label) = source.label() {
759 write!(f, " {label:?}")?;
760 }
761 write!(f, " {:?}", source.format())?;
762 if let Some((w, h)) = source.size() {
763 write!(f, " {w}x{h}")?;
764 }
765 write!(f, " {:?}", source.status())
766 }
767 }
768 struct Content(Option<Rect>);
769 impl std::fmt::Debug for Content {
770 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
771 match self.0 {
772 Some(r) => write!(f, "({}, {}, {}, {})", r.x, r.y, r.width, r.height),
773 None => f.write_str("none"),
774 }
775 }
776 }
777 let source = self.shared.source.borrow().clone();
778 let attachment = self
779 .mounted
780 .as_ref()
781 .map(|m| m.attachment.consumer().stats().attachment)
782 .unwrap_or_default();
783 let content = self
784 .mounted
785 .as_ref()
786 .and_then(|m| m.placement.get())
787 .map(|p| p.shown.content);
788 let mut s = f.debug_struct("LiveImage");
789 match &source {
790 Some(source) => s
791 .field("source", &Source(source))
792 .field("gen", &source.generation()),
793 None => s.field("source", &"none"),
794 };
795 s.field("window_gen", &attachment.window_generation)
796 .field("sizing", &self.sizing)
797 .field("fit", &self.fit)
798 .field("orientation", &self.orientation)
799 .field("scaling", &self.scaling)
800 .field("content", &Content(content))
801 .field("paints", &attachment.paints)
802 .field("paused", &attachment.paused)
803 .finish()
804 }
805}
806
807/// A handle to a [`LiveImage`], for the UI thread: the size and status of
808/// what it shows, where it shows it, and the mapping between its points and
809/// the source's pixels. `Clone` is an `Rc` clone; `!Send`.
810///
811/// It owns the widget's Signals from construction, so it works before the
812/// widget mounts, and they stay the same across a switch of source. It does
813/// not keep the widget alive: once the widget is gone, it has no placement.
814#[derive(Clone)]
815pub struct LiveImageHandle {
816 shared: Rc<Shared>,
817}
818
819impl LiveImageHandle {
820 /// A handle for a widget not built yet, which takes it with
821 /// [`LiveImage::with_handle`].
822 pub fn new() -> Self {
823 Self {
824 shared: Rc::new(Shared {
825 signals: LiveImageSignals::default(),
826 source: RefCell::new(None),
827 mounted: RefCell::new(Weak::new()),
828 }),
829 }
830 }
831
832 fn mounted(&self) -> Option<Rc<Mounted>> {
833 self.shared.mounted.borrow().upgrade()
834 }
835
836 /// The widget's id while it is mounted.
837 pub fn widget_id(&self) -> Option<WidgetId> {
838 self.mounted().map(|m| m.attachment.widget_id())
839 }
840
841 /// The source the widget shows; `None` until a widget took the handle.
842 pub fn source(&self) -> Option<LiveImageSource> {
843 self.shared.source.borrow().clone()
844 }
845
846 /// The source's status as the window's last layout saw it: the same
847 /// Signal across switches of source.
848 pub fn status(&self) -> Signal<LiveImageStatus> {
849 self.shared.signals.status.clone()
850 }
851
852 /// The frame size layout uses: the source's buffer, else its size hint,
853 /// as the window's last layout saw it. The same Signal across switches
854 /// of source.
855 pub fn frame_size(&self) -> Signal<Option<(u32, u32)>> {
856 self.shared.signals.frame_size.clone()
857 }
858
859 /// The placement of the last layout, widget-local: the one paint draws
860 /// and the mappings below use. `None` before the first layout, with no
861 /// frame size, or once the widget is gone.
862 pub fn geometry(&self) -> Option<ImageGeometry> {
863 self.mounted()?.placement.get().map(|p| p.shown)
864 }
865
866 /// The source pixel whose displayed square contains the widget-local
867 /// point `local`; `None` on the letterbox, outside the widget, or
868 /// without a placement. A pointer handler's positions are widget-local.
869 pub fn map_to_source(&self, local: Point) -> Option<(u32, u32)> {
870 self.geometry()?.map_to_source(local)
871 }
872
873 /// The source pixel nearest `local`: the point is clamped into the
874 /// picture first, for a drag that leaves it. `None` only without a
875 /// placement.
876 pub fn map_to_source_clamped(&self, local: Point) -> Option<(u32, u32)> {
877 self.geometry()?.map_to_source_clamped(local)
878 }
879
880 /// Continuous source coordinates of `local`, unclamped: pixel `k` spans
881 /// `[k, k + 1)`.
882 pub fn map_to_source_f32(&self, local: Point) -> Option<(f32, f32)> {
883 self.geometry()?.map_to_source_f32(local)
884 }
885
886 /// Where the source pixels of `rect` are displayed, widget-local: to
887 /// place an overlay on what the picture shows.
888 pub fn map_from_source(&self, rect: PixelRect) -> Option<Rect> {
889 self.geometry()?.map_from_source(rect)
890 }
891
892 /// The source's counters and, while the widget is mounted, its
893 /// attachment's; zero attachment counters otherwise.
894 pub fn stats(&self) -> LiveImageStats {
895 if let Some(mounted) = self.mounted() {
896 return mounted.attachment.consumer().stats();
897 }
898 let mut stats = LiveImageStats::default();
899 if let Some(source) = self.source() {
900 stats.source = source.stats();
901 }
902 stats
903 }
904}
905
906impl Default for LiveImageHandle {
907 fn default() -> Self {
908 Self::new()
909 }
910}
911
912impl std::fmt::Debug for LiveImageHandle {
913 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
914 f.debug_struct("LiveImageHandle")
915 .field("widget", &self.widget_id())
916 .field("signals", &self.shared.signals)
917 .finish()
918 }
919}
920
921#[cfg(test)]
922mod dim_tests;
923#[cfg(test)]
924mod pause_tests;
925#[cfg(test)]
926mod tests;