Skip to main content

teksilo_widgets/primitives/
image_widget.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! ImageWidget — displays a raster image (PNG, WebP) with a configurable
5//! sizing policy, content-fit mode, and intra-box alignment.
6//!
7//! Unlike [`IconWidget`](super::icon_widget::IconWidget) which is designed
8//! for small square tintable icons, `ImageWidget` handles arbitrary aspect
9//! ratios and defaults to full-color rendering.
10//!
11//! # Sizing model
12//!
13//! Two independent concerns, mirroring Qt's `QLabel`/`QPixmap`, SwiftUI's
14//! `Image`, and CSS's replaced-element model:
15//!
16//! 1. **Box size** — how big the widget's layout rectangle is.
17//!    - [`width`](ImageWidget::width) / [`height`](ImageWidget::height) /
18//!      [`size`](ImageWidget::size) pin a **fixed** logical extent. A pinned
19//!      axis is *rigid*: it is reported as-is and is never scaled up to a
20//!      parent's proposal (this is the SwiftUI `.frame(width:height:)` /
21//!      Qt fixed-size behaviour). Pinning only one axis derives the other
22//!      from the image's aspect ratio (CSS `width: Npx; height: auto`).
23//!    - With no axis pinned the widget reports its **natural pixel size**.
24//!      By default ([`resizable`](ImageWidget::resizable) `= true`) a
25//!      constraining proposal scales that natural size down/up while
26//!      preserving aspect ratio; `resizable(false)` locks it to the raw
27//!      pixel dimensions (SwiftUI's default non-`.resizable()` image).
28//! 2. **Content fit** — how the image pixels map into that box, via
29//!    [`ImageFit`] (`Contain` / `Cover` / `Fill` / `ScaleDown` / `None`,
30//!    the CSS `object-fit` set) plus
31//!    [`alignment`](ImageWidget::alignment) (the CSS `object-position`
32//!    equivalent) for where slack/overflow lands. Modes that overflow the
33//!    box (`Cover`, and `None` on an oversized image) are clipped to the
34//!    box so the image never bleeds past its layout rectangle.
35//!
36//! For a fixed 32×32 logo: `ImageWidget::new(icon).size(32.0, 32.0)` — the
37//! box is exactly 32×32 and the artwork is letterboxed inside it
38//! (`Contain`, the default).
39//!
40//! ```rust
41//! # use teksilo_canvas::RasterIcon;
42//! # use teksilo_widgets::primitives::image_widget::{ImageWidget, ImageFit};
43//! # use teksilo_widgets::primitives::image_mask::ImageMaskShape;
44//! // A 64×64 source pinned to a fixed 32×32 box, with no masking.
45//! let icon = RasterIcon::from_raw(vec![255; 64 * 64 * 4], 64, 64);
46//! let _logo = ImageWidget::new(&icon).size(32.0, 32.0);
47//!
48//! // Cover a square avatar slot and crop to a circle.
49//! let _avatar = ImageWidget::new(&icon)
50//!     .mask(ImageMaskShape::Circle)
51//!     .fit(ImageFit::Cover)
52//!     .alt("User avatar")
53//!     .size(48.0, 48.0);
54//! ```
55
56use std::rc::Rc;
57use std::sync::Arc;
58
59use teksilo_canvas::live_image::{
60    LiveImageDraw, LiveImageError, LiveImageSource, LiveImageWriter, LivePixelFormat, ScalingFilter,
61};
62use teksilo_canvas::{Canvas, RasterIcon, Rect, Size, SizeProposal};
63use teksilo_core::accessibility::AccessNodeBuilder;
64use teksilo_core::build_context::BuildContext;
65use teksilo_core::environment::LayoutDirection;
66use teksilo_core::widget::{LayoutContext, PaintContext, Widget};
67use teksilo_core::{LiveImageAttachment, LiveImageSignals, WidgetId};
68use teksilo_tokens::Alignment;
69
70use super::image_mask::{ImageMaskShape, apply_alpha_mask, center_crop_square};
71
72/// How the image is fitted within its layout bounds: the CSS `object-fit`
73/// set, shared with every widget that places a picture.
74pub use teksilo_canvas::image_geometry::ImageFit;
75
76/// A picture written once into a live source, and the writer that keeps
77/// it: a source frees its pixels when its last writer drops.
78pub(crate) struct CommittedImage {
79    source: LiveImageSource,
80    _writer: LiveImageWriter,
81}
82
83impl CommittedImage {
84    /// Write `pixels` (`width × height`, RGBA8, tightly packed) once,
85    /// moving the buffer in without a copy; the error when the source
86    /// refuses it (a zero side, a short buffer, a side over its limit).
87    pub(crate) fn commit(
88        pixels: Vec<u8>,
89        width: u32,
90        height: u32,
91    ) -> Result<Rc<Self>, LiveImageError> {
92        let source = LiveImageSource::builder(LivePixelFormat::Rgba8)
93            .label("image")
94            .build();
95        let writer = source.writer();
96        writer
97            .swap_frame(width, height, width as usize * 4, pixels)
98            .map_err(|rejected| rejected.error)?;
99        Ok(Rc::new(Self {
100            source,
101            _writer: writer,
102        }))
103    }
104
105    /// Its size; never zero, the source refused that.
106    pub(crate) fn size(&self) -> (u32, u32) {
107        self.source.size().unwrap_or((0, 0))
108    }
109
110    #[cfg(test)]
111    pub(crate) fn source(&self) -> &LiveImageSource {
112        &self.source
113    }
114}
115
116/// `pixels` (`width × height`, RGBA8) centre-cropped to a square and masked
117/// to `shape`, with the square's side. A buffer too short for its size is
118/// returned as it is: the source refuses it when it is committed.
119pub(crate) fn masked(
120    pixels: Vec<u8>,
121    width: u32,
122    height: u32,
123    shape: ImageMaskShape,
124) -> (Vec<u8>, u32, u32) {
125    let enough = (width as usize)
126        .checked_mul(height as usize)
127        .and_then(|n| n.checked_mul(4))
128        .is_some_and(|needed| width > 0 && height > 0 && pixels.len() >= needed);
129    if matches!(shape, ImageMaskShape::None) || !enough {
130        return (pixels, width, height);
131    }
132    let tight = &pixels[..width as usize * height as usize * 4];
133    let (mut cropped, side) = center_crop_square(tight, width, height);
134    apply_alpha_mask(&mut cropped, side, side, shape);
135    (cropped, side, side)
136}
137
138/// How an image's pixels reach the window.
139enum Pixels {
140    /// `new(&icon)` without a mask: one texture per icon and window, shared
141    /// by every widget showing the icon, named after its identity.
142    Shared { name: String, upload: Arc<[u8]> },
143    /// `from_raw`, or masked: a live source written once at the first
144    /// build, whose texture goes with the first frame that does not draw
145    /// the widget.
146    Owned(Owned),
147}
148
149#[derive(Default)]
150struct Owned {
151    /// The pixels until the first build commits them.
152    pending: Option<Vec<u8>>,
153    committed: Option<Rc<CommittedImage>>,
154    signals: Option<LiveImageSignals>,
155    attachment: Option<LiveImageAttachment>,
156}
157
158/// A widget that displays a raster image (PNG, WebP, or raw RGBA pixels) with configurable fit and alignment.
159pub struct ImageWidget {
160    pixels: Pixels,
161    width: u32,
162    height: u32,
163    fit: ImageFit,
164    /// Where the fitted image sits within the box when the active fit
165    /// leaves slack (`Contain`/`ScaleDown`/`None` smaller than the box) or
166    /// crops (`Cover`/`None` larger than the box). The CSS
167    /// `object-position` analogue. Defaults to centered.
168    alignment: Alignment,
169    /// Optional fixed display size. A pinned axis is rigid — reported as-is
170    /// and never scaled to a parent proposal. `None` means "derive": from
171    /// the aspect ratio when the other axis is pinned, otherwise from the
172    /// natural pixel dimensions.
173    display_width: Option<f32>,
174    display_height: Option<f32>,
175    /// When no axis is pinned, whether a constraining proposal scales the
176    /// natural size (`true`, the default) or the box stays locked to the
177    /// raw pixel dimensions (`false`, SwiftUI's default non-resizable
178    /// image). Has no effect once a dimension is pinned.
179    resizable: bool,
180    /// Accessibility description.
181    alt: Option<String>,
182    /// When true, hide from the accessibility tree entirely —
183    /// appropriate for purely decorative images whose semantic
184    /// content is already conveyed by adjacent text.
185    a11y_hidden: bool,
186}
187
188impl ImageWidget {
189    /// Create from a decoded [`RasterIcon`] (e.g., from `res!()`).
190    ///
191    /// The texture is named after the icon's identity, so every
192    /// `ImageWidget` showing this icon (or a clone of it) in a window shares
193    /// one texture, uploaded once.
194    ///
195    /// That texture, with its mip chain, stays on the GPU for the life of the
196    /// window and is never freed, even once no widget shows the icon. Each
197    /// distinct identity keeps one: a clone of the icon is the same identity,
198    /// but the same bytes decoded again are a new one. Keep decoded images
199    /// in an application cache and clone them, rather than decoding per
200    /// widget or per realization: in a virtualized grid of runtime images
201    /// such as album covers, a cover decoded each time its tile scrolls into
202    /// view leaves one more texture behind each time. Pixels shown once, or
203    /// that change, belong on [`from_raw`](Self::from_raw), whose texture
204    /// goes with the widget; a [`mask`](Self::mask) takes that path too.
205    pub fn new(icon: &RasterIcon) -> Self {
206        Self {
207            pixels: Pixels::Shared {
208                name: format!("_img_{}", icon.texture_key()),
209                upload: Arc::clone(icon.shared_pixels()),
210            },
211            width: icon.width(),
212            height: icon.height(),
213            fit: ImageFit::Contain,
214            alignment: Alignment::CENTER,
215            display_width: None,
216            display_height: None,
217            resizable: true,
218            alt: None,
219            a11y_hidden: false,
220        }
221    }
222
223    /// Create from raw RGBA pixel data, `width × height`, tightly packed.
224    ///
225    /// The pixels are written once into a live source when the widget is
226    /// first built, without a copy. The window's texture for them goes with
227    /// the first frame that does not draw the widget (it was destroyed,
228    /// scrolled out or parked), so GPU memory follows what is on screen,
229    /// and comes back with one upload. Input the source refuses (a zero
230    /// side, a buffer shorter than `width × height × 4`, a side over
231    /// [`LiveImageSource::MAX_DIMENSION`]) draws nothing, and says so once
232    /// on standard error.
233    pub fn from_raw(pixels: Vec<u8>, width: u32, height: u32) -> Self {
234        Self {
235            pixels: Pixels::Owned(Owned {
236                pending: Some(pixels),
237                ..Owned::default()
238            }),
239            width,
240            height,
241            fit: ImageFit::Contain,
242            alignment: Alignment::CENTER,
243            display_width: None,
244            display_height: None,
245            resizable: true,
246            alt: None,
247            a11y_hidden: false,
248        }
249    }
250
251    /// Show a picture already written once, which other widgets may show
252    /// too: `Avatar` keeps one across the rebuilds that leave its image as
253    /// it was.
254    pub(crate) fn from_committed(image: Rc<CommittedImage>) -> Self {
255        let (width, height) = image.size();
256        Self {
257            pixels: Pixels::Owned(Owned {
258                committed: Some(image),
259                ..Owned::default()
260            }),
261            width,
262            height,
263            fit: ImageFit::Contain,
264            alignment: Alignment::CENTER,
265            display_width: None,
266            display_height: None,
267            resizable: true,
268            alt: None,
269            a11y_hidden: false,
270        }
271    }
272
273    /// Apply an anti-aliased alpha mask to the image at construction
274    /// time. The pixels are first centre-cropped to the shorter side
275    /// (so the mask shape is geometrically consistent regardless of
276    /// the source aspect ratio), then their alpha channel is
277    /// modulated by the mask coverage. RGB is preserved.
278    ///
279    /// `Cover` fit is the natural pairing — the masked square fills
280    /// the avatar/thumbnail bounds and the masked-out corners stay
281    /// transparent. `Contain` works but may letterbox. The default
282    /// fit (`Contain`) is left unchanged so callers explicitly pick
283    /// a fit when they apply a mask.
284    ///
285    /// The masked picture is the widget's own, written once into a live
286    /// source as [`from_raw`](Self::from_raw)'s is: the icon's shared
287    /// texture is left alone. `ImageMaskShape::None` is a no-op.
288    pub fn mask(mut self, shape: ImageMaskShape) -> Self {
289        if matches!(shape, ImageMaskShape::None) {
290            return self;
291        }
292        let pixels = match std::mem::replace(&mut self.pixels, Pixels::Owned(Owned::default())) {
293            Pixels::Shared { upload, .. } => upload.to_vec(),
294            Pixels::Owned(Owned {
295                pending: Some(pixels),
296                ..
297            }) => pixels,
298            // Already committed: there is nothing left to crop.
299            Pixels::Owned(owned) => {
300                self.pixels = Pixels::Owned(owned);
301                return self;
302            }
303        };
304        let (masked, width, height) = masked(pixels, self.width, self.height, shape);
305        self.pixels = Pixels::Owned(Owned {
306            pending: Some(masked),
307            ..Owned::default()
308        });
309        self.width = width;
310        self.height = height;
311        self
312    }
313
314    /// Set the content-fit mode — how the image pixels map into the box.
315    /// See [`ImageFit`].
316    pub fn fit(mut self, fit: ImageFit) -> Self {
317        self.fit = fit;
318        self
319    }
320
321    /// Set where the fitted image sits within the box when the active fit
322    /// leaves slack or crops (the CSS `object-position` analogue). Defaults
323    /// to [`Alignment::CENTER`]. Leading/Trailing resolve against the
324    /// active layout direction (RTL-aware).
325    pub fn alignment(mut self, alignment: Alignment) -> Self {
326        self.alignment = alignment;
327        self
328    }
329
330    /// Pin a fixed display width (in logical pixels). The width axis
331    /// becomes rigid — reported as-is and never scaled to a parent
332    /// proposal. With no height pinned, the height derives from the
333    /// image's aspect ratio (CSS `width: Npx; height: auto`).
334    pub fn width(mut self, w: f32) -> Self {
335        self.display_width = Some(w);
336        self
337    }
338
339    /// Pin a fixed display height (in logical pixels). The height axis
340    /// becomes rigid. With no width pinned, the width derives from the
341    /// image's aspect ratio.
342    pub fn height(mut self, h: f32) -> Self {
343        self.display_height = Some(h);
344        self
345    }
346
347    /// Pin both display width and height (in logical pixels). The box is
348    /// exactly this size, rigid on both axes; the image content is fitted
349    /// inside it via the [`fit`](Self::fit) mode. This is the
350    /// fixed-size-logo case — `.size(32.0, 32.0)`.
351    pub fn size(mut self, w: f32, h: f32) -> Self {
352        self.display_width = Some(w);
353        self.display_height = Some(h);
354        self
355    }
356
357    /// Control whether, with no axis pinned, a constraining parent
358    /// proposal scales the natural pixel size (`true`, the default) or the
359    /// box stays locked to the raw pixel dimensions (`false`). Equivalent
360    /// to opting out of SwiftUI's `.resizable()`. No effect once a
361    /// dimension is pinned via [`width`](Self::width) /
362    /// [`height`](Self::height) / [`size`](Self::size).
363    pub fn resizable(mut self, resizable: bool) -> Self {
364        self.resizable = resizable;
365        self
366    }
367
368    /// Set the accessibility alt text.
369    pub fn alt(mut self, text: impl Into<String>) -> Self {
370        self.alt = Some(text.into());
371        self
372    }
373
374    /// Mark this image as decorative — hidden from the accessibility
375    /// tree. Use when the image's semantic content is already conveyed
376    /// by adjacent text (e.g. a hero image next to its caption). ARIA
377    /// equivalent of `alt=""` / `role="presentation"`.
378    pub fn a11y_hidden(mut self) -> Self {
379        self.a11y_hidden = true;
380        self
381    }
382
383    /// Natural aspect ratio (width / height).
384    fn aspect_ratio(&self) -> f32 {
385        if self.height == 0 {
386            1.0
387        } else {
388            self.width as f32 / self.height as f32
389        }
390    }
391
392    /// Compute the image rectangle within `bounds` for the active fit mode,
393    /// positioned by [`alignment`](Self::alignment). `rtl` flips the
394    /// horizontal Leading/Trailing axis.
395    fn fitted_rect(&self, bounds: Rect, rtl: bool) -> Rect {
396        self.fit.fitted_rect(
397            Size::new(self.width as f32, self.height as f32),
398            bounds,
399            self.alignment,
400            rtl,
401        )
402    }
403}
404
405impl std::fmt::Debug for ImageWidget {
406    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
407        let path = match &self.pixels {
408            Pixels::Shared { .. } => "shared",
409            Pixels::Owned(_) => "owned",
410        };
411        f.debug_struct("ImageWidget")
412            .field("width", &self.width)
413            .field("height", &self.height)
414            .field("fit", &self.fit)
415            .field("pixels", &path)
416            .finish()
417    }
418}
419
420impl Widget for ImageWidget {
421    /// An owned picture is committed to its source at the first build, and
422    /// every build attaches it in the widget's window.
423    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
424        let Pixels::Owned(owned) = &mut self.pixels else {
425            return vec![];
426        };
427        if owned.committed.is_none()
428            && let Some(pixels) = owned.pending.take()
429        {
430            let bytes = pixels.len();
431            match CommittedImage::commit(pixels, self.width, self.height) {
432                Ok(image) => owned.committed = Some(image),
433                Err(error) => eprintln!(
434                    "teksilo: an image of {}x{} with {bytes} bytes of pixels was refused \
435                     ({error}): it draws nothing",
436                    self.width, self.height
437                ),
438            }
439        }
440        if let Some(image) = &owned.committed {
441            let signals = owned
442                .signals
443                .get_or_insert_with(|| LiveImageSignals::new(&image.source));
444            owned.attachment = Some(ctx.attach_live_image(&image.source, signals));
445        }
446        vec![]
447    }
448
449    fn layout_response(
450        &self,
451        proposal: SizeProposal,
452        _ctx: &LayoutContext,
453    ) -> teksilo_core::widget::LayoutResponse {
454        let ar = self.aspect_ratio();
455
456        let size = match (self.display_width, self.display_height) {
457            // Both axes pinned → rigid box. The proposal cannot override an
458            // explicit size; the image content is fitted inside via `fit`.
459            (Some(w), Some(h)) => Size::new(w, h),
460            // One axis pinned → the other derives from the aspect ratio
461            // (CSS `width: Npx; height: auto`). Still rigid.
462            (Some(w), None) => Size::new(w, w / ar),
463            (None, Some(h)) => Size::new(h * ar, h),
464            // Neither pinned → natural pixel size, scaled to a constraining
465            // proposal only when `resizable` (the default).
466            (None, None) => {
467                let natural_w = self.width as f32;
468                let natural_h = self.height as f32;
469                if !self.resizable {
470                    Size::new(natural_w, natural_h)
471                } else {
472                    match (proposal.width, proposal.height) {
473                        // Both constrained: fit within, preserving aspect ratio
474                        (Some(pw), Some(ph)) => {
475                            let scale = (pw / natural_w).min(ph / natural_h);
476                            Size::new(natural_w * scale, natural_h * scale)
477                        }
478                        // Width constrained: compute height from aspect ratio
479                        (Some(pw), None) => Size::new(pw, pw / ar),
480                        // Height constrained: compute width from aspect ratio
481                        (None, Some(ph)) => Size::new(ph * ar, ph),
482                        // Unconstrained: natural size
483                        (None, None) => Size::new(natural_w, natural_h),
484                    }
485                }
486            }
487        };
488        size.into()
489    }
490
491    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
492        let rtl = matches!(ctx.layout_direction, LayoutDirection::RightToLeft);
493        let rect = self.fitted_rect(bounds, rtl);
494        let (name, upload) = match &self.pixels {
495            Pixels::Shared { name, upload } => (name, upload),
496            Pixels::Owned(owned) => {
497                // The window's texture holds the pixels; the frame carries
498                // the attachment. `Cover` and an oversized `None` are cut
499                // to the box by texture coordinates, and a thumbnail drawn
500                // small samples the mip chain the window builds.
501                if let Some(attachment) = &owned.attachment {
502                    canvas.draw_live_image(
503                        attachment.consumer(),
504                        &LiveImageDraw::new(rect, bounds).filter(ScalingFilter::Trilinear),
505                    );
506                }
507                return;
508            }
509        };
510        // Queuing shares the pixels; the check spares the name's allocation.
511        if !canvas.has_pending_image(name) {
512            canvas.ensure_shared_image_registered(
513                name,
514                self.width,
515                self.height,
516                Arc::clone(upload),
517            );
518        }
519
520        // Modes that overflow the box (`Cover`, or `None` on an oversized
521        // image) must not bleed past the widget's layout rectangle. Clip
522        // to `bounds` only when the fitted rect actually exceeds it — the
523        // renderer intersects this with any ancestor clip and pops it
524        // cleanly, so it composes with ScrollAreas etc. `Contain` /
525        // `ScaleDown` / a within-box image never overflow, so they skip the
526        // clip commands entirely.
527        const EPS: f32 = 0.01;
528        let overflows = rect.x < bounds.x - EPS
529            || rect.y < bounds.y - EPS
530            || rect.x + rect.width > bounds.x + bounds.width + EPS
531            || rect.y + rect.height > bounds.y + bounds.height + EPS;
532        if overflows {
533            canvas.set_clip(bounds);
534            canvas.draw_image(rect, name);
535            canvas.clear_clip();
536        } else {
537            canvas.draw_image(rect, name);
538        }
539    }
540
541    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
542        if self.a11y_hidden {
543            builder.set_hidden();
544            return;
545        }
546        debug_assert!(
547            self.alt.is_some(),
548            "ImageWidget has no alt text — call .alt(\"…\") for meaningful images or .a11y_hidden() for decorative ones"
549        );
550        builder.set_role(teksilo_core::accesskit::Role::Image);
551        if let Some(ref alt) = self.alt {
552            builder.set_name(alt);
553        }
554    }
555}
556
557#[cfg(test)]
558impl ImageWidget {
559    /// The shared texture's name, for an unmasked icon.
560    fn shared_name(&self) -> Option<&str> {
561        match &self.pixels {
562            Pixels::Shared { name, .. } => Some(name),
563            Pixels::Owned(_) => None,
564        }
565    }
566
567    /// The pixels an owned picture commits at its first build.
568    fn pending_pixels(&self) -> Option<&[u8]> {
569        match &self.pixels {
570            Pixels::Owned(owned) => owned.pending.as_deref(),
571            Pixels::Shared { .. } => None,
572        }
573    }
574}
575
576#[cfg(test)]
577mod tests {
578    use super::*;
579    use teksilo_core::widget_tree::WidgetTree;
580
581    #[test]
582    fn natural_size() {
583        let icon = RasterIcon::from_raw(vec![255; 400], 10, 10);
584        let mut tree = WidgetTree::new();
585        let img = tree.add(ImageWidget::new(&icon));
586        tree.layout(SizeProposal::unspecified());
587        let b = tree.bounds(img);
588        assert!((b.width - 10.0).abs() < 0.01);
589        assert!((b.height - 10.0).abs() < 0.01);
590    }
591
592    #[test]
593    fn explicit_display_size() {
594        let icon = RasterIcon::from_raw(vec![255; 400], 10, 10);
595        let mut tree = WidgetTree::new();
596        let img = tree.add(ImageWidget::new(&icon).size(200.0, 100.0));
597        tree.layout(SizeProposal::unspecified());
598        let b = tree.bounds(img);
599        assert!((b.width - 200.0).abs() < 0.01);
600        assert!((b.height - 100.0).abs() < 0.01);
601    }
602
603    #[test]
604    fn size_that_fits_preserves_aspect_ratio() {
605        let icon = RasterIcon::from_raw(vec![255; 800], 20, 10); // 2:1 aspect
606        let widget = ImageWidget::new(&icon);
607        let theme = teksilo_core::presets::intui::light();
608        let ctx = LayoutContext::for_testing(&theme);
609        // Width constrained to 100, no height constraint → 100x50
610        let size = widget
611            .layout_response(
612                SizeProposal {
613                    width: Some(100.0),
614                    height: None,
615                },
616                &ctx,
617            )
618            .size;
619        assert!((size.width - 100.0).abs() < 0.5, "width: {}", size.width);
620        assert!((size.height - 50.0).abs() < 0.5, "height: {}", size.height);
621    }
622
623    #[test]
624    fn paints_image_quad() {
625        let icon = RasterIcon::from_raw(vec![255; 400], 10, 10);
626        let mut tree = WidgetTree::new();
627        tree.add(ImageWidget::new(&icon));
628        tree.layout(SizeProposal::exact(10.0, 10.0));
629        let frame = tree.render();
630        assert!(!frame.images.is_empty(), "should render an image");
631        assert!(frame.images[0].tint.is_none(), "should be full-color");
632    }
633
634    #[test]
635    fn pending_image_registered() {
636        let icon = RasterIcon::from_raw(vec![255; 400], 10, 10);
637        let mut tree = WidgetTree::new();
638        tree.add(ImageWidget::new(&icon));
639        tree.layout(SizeProposal::exact(10.0, 10.0));
640        let frame = tree.render();
641        assert!(
642            !frame.pending_images.is_empty(),
643            "should register pending image"
644        );
645    }
646
647    #[test]
648    fn new_names_its_texture_after_the_icon_identity() {
649        let icon = RasterIcon::from_raw(vec![255; 16], 2, 2);
650        let a = ImageWidget::new(&icon);
651        let clone = icon.clone();
652        let b = ImageWidget::new(&clone);
653        let expected = format!("_img_{}", icon.texture_key());
654        assert_eq!(a.shared_name(), Some(expected.as_str()));
655        assert_eq!(
656            a.shared_name(),
657            b.shared_name(),
658            "a clone shares the original's texture"
659        );
660
661        // An icon with the same dimensions allocated after the first one is
662        // dropped (often at the same address) must not reuse its texture.
663        let first_name = {
664            let first = Box::new(RasterIcon::from_raw(vec![255; 16], 2, 2));
665            ImageWidget::new(&first).shared_name().map(str::to_owned)
666        };
667        let second = Box::new(RasterIcon::from_raw(vec![0; 16], 2, 2));
668        assert_ne!(
669            ImageWidget::new(&second).shared_name().map(str::to_owned),
670            first_name
671        );
672    }
673
674    #[test]
675    fn from_raw_widgets_each_own_a_source() {
676        // Two raw images of the same size and different bytes used to alias
677        // on a per-size texture name, so one silently showed the other's
678        // pixels. Each now owns a live source: two quads, two sources, and
679        // nothing on the shared path.
680        let mut tree = WidgetTree::new();
681        tree.add(
682            crate::primitives::HStack::new()
683                .child(ImageWidget::from_raw(vec![255; 16], 2, 2))
684                .child(ImageWidget::from_raw(vec![0; 16], 2, 2)),
685        );
686        tree.layout(SizeProposal::exact(100.0, 50.0));
687        let frame = tree.render();
688        assert_eq!(frame.live_images.len(), 2);
689        assert!(
690            !frame.live_images[0]
691                .consumer
692                .source()
693                .ptr_eq(frame.live_images[1].consumer.source())
694        );
695        assert!(frame.images.is_empty() && frame.pending_images.is_empty());
696    }
697
698    #[test]
699    fn fixed_size_is_rigid_against_constraining_proposal() {
700        // Regression: a pinned `.size(32, 32)` must stay 32×32 no matter
701        // what the parent proposes. The old layout path treated the
702        // explicit size as a "natural size" and scaled it up to a
703        // width-constraining proposal — a 512px source logo placed in a
704        // wide title bar ballooned to the bar's full width.
705        let icon = RasterIcon::from_raw(vec![255; 512 * 512 * 4], 512, 512);
706        let widget = ImageWidget::new(&icon).size(32.0, 32.0);
707        let theme = teksilo_core::presets::intui::light();
708        let ctx = LayoutContext::for_testing(&theme);
709        // Wide width proposal, no height constraint — the VStack-cross-axis
710        // case that triggered the skribisto bug.
711        let size = widget
712            .layout_response(
713                SizeProposal {
714                    width: Some(600.0),
715                    height: None,
716                },
717                &ctx,
718            )
719            .size;
720        assert!((size.width - 32.0).abs() < 0.01, "width: {}", size.width);
721        assert!((size.height - 32.0).abs() < 0.01, "height: {}", size.height);
722    }
723
724    #[test]
725    fn single_axis_pin_derives_other_from_aspect_ratio() {
726        let icon = RasterIcon::from_raw(vec![255; 40 * 10 * 4], 40, 10); // 4:1
727        let theme = teksilo_core::presets::intui::light();
728        let ctx = LayoutContext::for_testing(&theme);
729        // Width pinned to 200 → height = 200 / 4 = 50, ignoring proposal.
730        let w_pinned = ImageWidget::new(&icon)
731            .width(200.0)
732            .layout_response(SizeProposal::exact(999.0, 999.0), &ctx)
733            .size;
734        assert!((w_pinned.width - 200.0).abs() < 0.01);
735        assert!(
736            (w_pinned.height - 50.0).abs() < 0.01,
737            "h: {}",
738            w_pinned.height
739        );
740        // Height pinned to 20 → width = 20 * 4 = 80.
741        let h_pinned = ImageWidget::new(&icon)
742            .height(20.0)
743            .layout_response(SizeProposal::exact(999.0, 999.0), &ctx)
744            .size;
745        assert!(
746            (h_pinned.width - 80.0).abs() < 0.01,
747            "w: {}",
748            h_pinned.width
749        );
750        assert!((h_pinned.height - 20.0).abs() < 0.01);
751    }
752
753    #[test]
754    fn resizable_false_locks_natural_pixel_size() {
755        let icon = RasterIcon::from_raw(vec![255; 64 * 64 * 4], 64, 64);
756        let theme = teksilo_core::presets::intui::light();
757        let ctx = LayoutContext::for_testing(&theme);
758        // Default (resizable) scales to a constraining proposal.
759        let scaled = ImageWidget::new(&icon)
760            .layout_response(SizeProposal::exact(16.0, 16.0), &ctx)
761            .size;
762        assert!((scaled.width - 16.0).abs() < 0.01);
763        // resizable(false) ignores the proposal and keeps 64×64.
764        let locked = ImageWidget::new(&icon)
765            .resizable(false)
766            .layout_response(SizeProposal::exact(16.0, 16.0), &ctx)
767            .size;
768        assert!((locked.width - 64.0).abs() < 0.01, "w: {}", locked.width);
769        assert!((locked.height - 64.0).abs() < 0.01);
770    }
771
772    #[test]
773    fn contain_centers_inside_a_wider_box() {
774        // 1:1 image in a 200×100 box → 100×100 letterboxed, centered.
775        let icon = RasterIcon::from_raw(vec![255; 10 * 10 * 4], 10, 10);
776        let widget = ImageWidget::new(&icon).fit(ImageFit::Contain);
777        let r = widget.fitted_rect(Rect::new(0.0, 0.0, 200.0, 100.0), false);
778        assert!((r.width - 100.0).abs() < 0.01);
779        assert!((r.height - 100.0).abs() < 0.01);
780        assert!((r.x - 50.0).abs() < 0.01, "x: {}", r.x); // (200-100)/2
781        assert!((r.y - 0.0).abs() < 0.01);
782    }
783
784    #[test]
785    fn alignment_positions_content_within_box() {
786        use teksilo_tokens::{HAlignment, VAlignment};
787        let icon = RasterIcon::from_raw(vec![255; 10 * 10 * 4], 10, 10);
788        let widget = ImageWidget::new(&icon)
789            .fit(ImageFit::Contain)
790            .alignment(Alignment {
791                horizontal: HAlignment::Trailing,
792                vertical: VAlignment::Bottom,
793            });
794        let r = widget.fitted_rect(Rect::new(0.0, 0.0, 200.0, 100.0), false);
795        // 100×100 pushed to bottom-trailing: x = 200-100, y = 100-100.
796        assert!((r.x - 100.0).abs() < 0.01, "x: {}", r.x);
797        assert!((r.y - 0.0).abs() < 0.01, "y: {}", r.y);
798        // RTL flips Trailing to the left edge.
799        let r_rtl = widget.fitted_rect(Rect::new(0.0, 0.0, 200.0, 100.0), true);
800        assert!((r_rtl.x - 0.0).abs() < 0.01, "rtl x: {}", r_rtl.x);
801    }
802
803    #[test]
804    fn fit_none_draws_at_natural_pixel_size() {
805        let icon = RasterIcon::from_raw(vec![255; 20 * 20 * 4], 20, 20);
806        let widget = ImageWidget::new(&icon).fit(ImageFit::None);
807        // In a 8×8 box the natural 20×20 image overflows; rect stays 20×20.
808        let r = widget.fitted_rect(Rect::new(0.0, 0.0, 8.0, 8.0), false);
809        assert!((r.width - 20.0).abs() < 0.01);
810        assert!((r.height - 20.0).abs() < 0.01);
811        // Centered → negative offset (overflows on all sides).
812        assert!((r.x - (-6.0)).abs() < 0.01, "x: {}", r.x); // (8-20)/2
813    }
814
815    #[test]
816    fn cover_overflow_is_clipped_to_bounds() {
817        // A 2:1 image covering a square box overflows horizontally and must
818        // be clipped — the frame should carry a SetClip/ClearClip pair.
819        let icon = RasterIcon::from_raw(vec![255; 20 * 10 * 4], 20, 10);
820        let mut tree = WidgetTree::new();
821        tree.add(
822            ImageWidget::new(&icon)
823                .size(50.0, 50.0)
824                .fit(ImageFit::Cover),
825        );
826        tree.layout(SizeProposal::exact(50.0, 50.0));
827        let frame = tree.render();
828        let has_set_clip = frame
829            .draw_order
830            .iter()
831            .any(|c| matches!(c, teksilo_canvas::DrawCommand::SetClip(_)));
832        let has_clear_clip = frame
833            .draw_order
834            .iter()
835            .any(|c| matches!(c, teksilo_canvas::DrawCommand::ClearClip));
836        assert!(has_set_clip, "Cover overflow should emit SetClip");
837        assert!(has_clear_clip, "Cover overflow should emit ClearClip");
838    }
839
840    #[test]
841    fn contain_within_box_emits_no_clip() {
842        // Contain never overflows, so no clip commands are emitted.
843        let icon = RasterIcon::from_raw(vec![255; 10 * 10 * 4], 10, 10);
844        let mut tree = WidgetTree::new();
845        tree.add(
846            ImageWidget::new(&icon)
847                .size(50.0, 50.0)
848                .fit(ImageFit::Contain),
849        );
850        tree.layout(SizeProposal::exact(50.0, 50.0));
851        let frame = tree.render();
852        let has_set_clip = frame
853            .draw_order
854            .iter()
855            .any(|c| matches!(c, teksilo_canvas::DrawCommand::SetClip(_)));
856        assert!(!has_set_clip, "Contain should not clip");
857    }
858
859    #[test]
860    fn mask_circle_alpha_zero_at_corners() {
861        // The reusable `.mask(Circle)` modifier — anywhere a photo
862        // needs a circular crop, not just inside Avatar.
863        let icon = RasterIcon::from_raw(vec![255; 32 * 32 * 4], 32, 32);
864        let widget = ImageWidget::from_raw(icon.pixels().to_vec(), icon.width(), icon.height())
865            .mask(ImageMaskShape::Circle);
866        // After cropping to a square (already 32×32 here) and
867        // masking, corner pixels have alpha 0.
868        let stride = (widget.width * 4) as usize;
869        let pixels = widget.pending_pixels().expect("masked pixels");
870        let top_left_alpha = pixels[3];
871        let top_right_alpha = pixels[stride - 1];
872        assert_eq!(top_left_alpha, 0);
873        assert_eq!(top_right_alpha, 0);
874        // Centre pixel still opaque.
875        let center_idx = (((widget.height / 2) * widget.width + widget.width / 2) * 4 + 3) as usize;
876        assert_eq!(pixels[center_idx], 255);
877    }
878
879    #[test]
880    fn mask_none_is_passthrough() {
881        let original = vec![123, 45, 67, 200, 8, 9, 10, 200];
882        let widget = ImageWidget::from_raw(original.clone(), 2, 1).mask(ImageMaskShape::None);
883        assert_eq!(widget.pending_pixels(), Some(original.as_slice()));
884        // And an unmasked icon stays on the shared path.
885        let icon = RasterIcon::from_raw(original.clone(), 2, 1);
886        assert!(
887            ImageWidget::new(&icon)
888                .mask(ImageMaskShape::None)
889                .shared_name()
890                .is_some()
891        );
892    }
893
894    #[test]
895    fn mask_crops_non_square_to_square() {
896        // 8×4 source → centre-cropped to 4×4 inscribed circle.
897        let pixels = vec![255; 8 * 4 * 4];
898        let widget = ImageWidget::from_raw(pixels, 8, 4).mask(ImageMaskShape::Circle);
899        assert_eq!(widget.width, 4);
900        assert_eq!(widget.height, 4);
901    }
902
903    #[test]
904    fn a_masked_icon_owns_its_pixels_and_leaves_the_shared_texture() {
905        // The masked widget must not draw through the icon's shared
906        // texture, which holds the unmasked pixels.
907        let icon = RasterIcon::from_raw(vec![255; 16 * 16 * 4], 16, 16);
908        let unmasked = ImageWidget::new(&icon);
909        let masked = ImageWidget::new(&icon).mask(ImageMaskShape::Circle);
910        assert!(unmasked.shared_name().is_some());
911        assert!(masked.shared_name().is_none());
912        assert!(masked.pending_pixels().is_some());
913    }
914}