Skip to main content

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(&current),
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;