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 image shown at natural size 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::borrow::Cow;
57use std::sync::atomic::{AtomicU64, Ordering};
58
59use teksilo_canvas::{Canvas, RasterIcon, Rect, Size, SizeProposal};
60use teksilo_core::accessibility::AccessNodeBuilder;
61use teksilo_core::environment::LayoutDirection;
62use teksilo_core::widget::{LayoutContext, PaintContext, Widget};
63use teksilo_tokens::Alignment;
64
65use super::image_mask::{ImageMaskShape, apply_alpha_mask, center_crop_square};
66
67/// How the image is fitted within its layout bounds.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
69pub enum ImageFit {
70    /// Scale to fit entirely within bounds, preserving aspect ratio.
71    /// May leave empty space (letterboxing).
72    #[default]
73    Contain,
74    /// Scale to cover the entire bounds, preserving aspect ratio.
75    /// May crop the image.
76    Cover,
77    /// Stretch to fill bounds exactly, ignoring aspect ratio.
78    Fill,
79    /// Like Contain but never upscales — if the image is smaller than
80    /// bounds, it is centered at its natural size.
81    ScaleDown,
82    /// Draw the image at its natural pixel size, neither scaling up nor
83    /// down. If the image is larger than the box it is cropped to the box
84    /// (positioned by [`alignment`](ImageWidget::alignment)); if smaller it
85    /// sits inside with empty space. CSS `object-fit: none`.
86    None,
87}
88
89/// A widget that displays a raster image (PNG, WebP, or raw RGBA pixels) with configurable fit and alignment.
90pub struct ImageWidget {
91    name: String,
92    width: u32,
93    height: u32,
94    upload_pixels: Vec<u8>,
95    fit: ImageFit,
96    /// Where the fitted image sits within the box when the active fit
97    /// leaves slack (`Contain`/`ScaleDown`/`None` smaller than the box) or
98    /// crops (`Cover`/`None` larger than the box). The CSS
99    /// `object-position` analogue. Defaults to centered.
100    alignment: Alignment,
101    /// Optional fixed display size. A pinned axis is rigid — reported as-is
102    /// and never scaled to a parent proposal. `None` means "derive": from
103    /// the aspect ratio when the other axis is pinned, otherwise from the
104    /// natural pixel dimensions.
105    display_width: Option<f32>,
106    display_height: Option<f32>,
107    /// When no axis is pinned, whether a constraining proposal scales the
108    /// natural size (`true`, the default) or the box stays locked to the
109    /// raw pixel dimensions (`false`, SwiftUI's default non-resizable
110    /// image). Has no effect once a dimension is pinned.
111    resizable: bool,
112    /// Accessibility description.
113    alt: Option<String>,
114    /// When true, hide from the accessibility tree entirely —
115    /// appropriate for purely decorative images whose semantic
116    /// content is already conveyed by adjacent text.
117    a11y_hidden: bool,
118}
119
120impl ImageWidget {
121    /// Create from a decoded [`RasterIcon`] (e.g., from `res!()`).
122    pub fn new(icon: &RasterIcon) -> Self {
123        let name = format!("_img_{:p}", icon as *const RasterIcon);
124        Self {
125            name,
126            width: icon.width(),
127            height: icon.height(),
128            upload_pixels: icon.pixels().to_vec(),
129            fit: ImageFit::Contain,
130            alignment: Alignment::CENTER,
131            display_width: None,
132            display_height: None,
133            resizable: true,
134            alt: None,
135            a11y_hidden: false,
136        }
137    }
138
139    /// Create from raw RGBA pixel data.
140    ///
141    /// Each call gets a unique texture-atlas key (via a process-local
142    /// atomic counter), so two `from_raw` widgets with the same
143    /// dimensions but different bytes don't alias in the renderer's
144    /// pending-image cache. Without this, the first writer per frame
145    /// would silently win and subsequent ones would render the wrong
146    /// pixels — a latent bug fixed alongside the dynamic-image use
147    /// cases that need many short-lived `from_raw` widgets.
148    pub fn from_raw(pixels: Vec<u8>, width: u32, height: u32) -> Self {
149        static NEXT_RAW_ID: AtomicU64 = AtomicU64::new(0);
150        let id = NEXT_RAW_ID.fetch_add(1, Ordering::Relaxed);
151        Self {
152            name: format!("_img_raw_{id}_{width}x{height}"),
153            width,
154            height,
155            upload_pixels: pixels,
156            fit: ImageFit::Contain,
157            alignment: Alignment::CENTER,
158            display_width: None,
159            display_height: None,
160            resizable: true,
161            alt: None,
162            a11y_hidden: false,
163        }
164    }
165
166    /// Apply an anti-aliased alpha mask to the image at construction
167    /// time. The pixels are first centre-cropped to the shorter side
168    /// (so the mask shape is geometrically consistent regardless of
169    /// the source aspect ratio), then their alpha channel is
170    /// modulated by the mask coverage. RGB is preserved.
171    ///
172    /// `Cover` fit is the natural pairing — the masked square fills
173    /// the avatar/thumbnail bounds and the masked-out corners stay
174    /// transparent. `Contain` works but may letterbox. The default
175    /// fit (`Contain`) is left unchanged so callers explicitly pick
176    /// a fit when they apply a mask.
177    ///
178    /// `ImageMaskShape::None` is a no-op. Re-uploading is keyed off a
179    /// fresh per-mask name so the un-masked version of the same
180    /// source doesn't shadow the masked one in the texture atlas.
181    pub fn mask(mut self, shape: ImageMaskShape) -> Self {
182        if matches!(shape, ImageMaskShape::None) {
183            return self;
184        }
185        let (mut cropped, side) = center_crop_square(&self.upload_pixels, self.width, self.height);
186        apply_alpha_mask(&mut cropped, side, side, shape);
187        self.upload_pixels = cropped;
188        self.width = side;
189        self.height = side;
190        // Bump the texture name so the old un-masked entry is
191        // distinct in the per-frame `pending_images` map.
192        static NEXT_MASK_ID: AtomicU64 = AtomicU64::new(0);
193        let id = NEXT_MASK_ID.fetch_add(1, Ordering::Relaxed);
194        self.name = format!("{}_masked_{id}", self.name);
195        self
196    }
197
198    /// Set the content-fit mode — how the image pixels map into the box.
199    /// See [`ImageFit`].
200    pub fn fit(mut self, fit: ImageFit) -> Self {
201        self.fit = fit;
202        self
203    }
204
205    /// Set where the fitted image sits within the box when the active fit
206    /// leaves slack or crops (the CSS `object-position` analogue). Defaults
207    /// to [`Alignment::CENTER`]. Leading/Trailing resolve against the
208    /// active layout direction (RTL-aware).
209    pub fn alignment(mut self, alignment: Alignment) -> Self {
210        self.alignment = alignment;
211        self
212    }
213
214    /// Pin a fixed display width (in logical pixels). The width axis
215    /// becomes rigid — reported as-is and never scaled to a parent
216    /// proposal. With no height pinned, the height derives from the
217    /// image's aspect ratio (CSS `width: Npx; height: auto`).
218    pub fn width(mut self, w: f32) -> Self {
219        self.display_width = Some(w);
220        self
221    }
222
223    /// Pin a fixed display height (in logical pixels). The height axis
224    /// becomes rigid. With no width pinned, the width derives from the
225    /// image's aspect ratio.
226    pub fn height(mut self, h: f32) -> Self {
227        self.display_height = Some(h);
228        self
229    }
230
231    /// Pin both display width and height (in logical pixels). The box is
232    /// exactly this size, rigid on both axes; the image content is fitted
233    /// inside it via the [`fit`](Self::fit) mode. This is the
234    /// fixed-size-logo case — `.size(32.0, 32.0)`.
235    pub fn size(mut self, w: f32, h: f32) -> Self {
236        self.display_width = Some(w);
237        self.display_height = Some(h);
238        self
239    }
240
241    /// Control whether, with no axis pinned, a constraining parent
242    /// proposal scales the natural pixel size (`true`, the default) or the
243    /// box stays locked to the raw pixel dimensions (`false`). Equivalent
244    /// to opting out of SwiftUI's `.resizable()`. No effect once a
245    /// dimension is pinned via [`width`](Self::width) /
246    /// [`height`](Self::height) / [`size`](Self::size).
247    pub fn resizable(mut self, resizable: bool) -> Self {
248        self.resizable = resizable;
249        self
250    }
251
252    /// Set the accessibility alt text.
253    pub fn alt(mut self, text: impl Into<String>) -> Self {
254        self.alt = Some(text.into());
255        self
256    }
257
258    /// Mark this image as decorative — hidden from the accessibility
259    /// tree. Use when the image's semantic content is already conveyed
260    /// by adjacent text (e.g. a hero image next to its caption). ARIA
261    /// equivalent of `alt=""` / `role="presentation"`.
262    pub fn a11y_hidden(mut self) -> Self {
263        self.a11y_hidden = true;
264        self
265    }
266
267    /// Natural aspect ratio (width / height).
268    fn aspect_ratio(&self) -> f32 {
269        if self.height == 0 {
270            1.0
271        } else {
272            self.width as f32 / self.height as f32
273        }
274    }
275
276    /// Compute the image rectangle within `bounds` for the active fit mode,
277    /// positioned by [`alignment`](Self::alignment). `rtl` flips the
278    /// horizontal Leading/Trailing axis.
279    fn fitted_rect(&self, bounds: Rect, rtl: bool) -> Rect {
280        let img_w = self.width as f32;
281        let img_h = self.height as f32;
282        if img_w <= 0.0 || img_h <= 0.0 {
283            return bounds;
284        }
285
286        let (content_w, content_h) = match self.fit {
287            ImageFit::Fill => (bounds.width, bounds.height),
288            ImageFit::Contain => {
289                let scale = (bounds.width / img_w).min(bounds.height / img_h);
290                (img_w * scale, img_h * scale)
291            }
292            ImageFit::Cover => {
293                let scale = (bounds.width / img_w).max(bounds.height / img_h);
294                (img_w * scale, img_h * scale)
295            }
296            ImageFit::ScaleDown => {
297                let scale = (bounds.width / img_w).min(bounds.height / img_h).min(1.0);
298                (img_w * scale, img_h * scale)
299            }
300            ImageFit::None => (img_w, img_h),
301        };
302
303        let x = bounds.x
304            + self
305                .alignment
306                .horizontal
307                .resolve(content_w, bounds.width, rtl);
308        let y = bounds.y + self.alignment.vertical.resolve(content_h, bounds.height);
309        Rect::new(x, y, content_w, content_h)
310    }
311}
312
313impl std::fmt::Debug for ImageWidget {
314    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
315        f.debug_struct("ImageWidget")
316            .field("width", &self.width)
317            .field("height", &self.height)
318            .field("fit", &self.fit)
319            .finish()
320    }
321}
322
323impl Widget for ImageWidget {
324    fn layout_response(
325        &self,
326        proposal: SizeProposal,
327        _ctx: &LayoutContext,
328    ) -> teksilo_core::widget::LayoutResponse {
329        let ar = self.aspect_ratio();
330
331        let size = match (self.display_width, self.display_height) {
332            // Both axes pinned → rigid box. The proposal cannot override an
333            // explicit size; the image content is fitted inside via `fit`.
334            (Some(w), Some(h)) => Size::new(w, h),
335            // One axis pinned → the other derives from the aspect ratio
336            // (CSS `width: Npx; height: auto`). Still rigid.
337            (Some(w), None) => Size::new(w, w / ar),
338            (None, Some(h)) => Size::new(h * ar, h),
339            // Neither pinned → natural pixel size, scaled to a constraining
340            // proposal only when `resizable` (the default).
341            (None, None) => {
342                let natural_w = self.width as f32;
343                let natural_h = self.height as f32;
344                if !self.resizable {
345                    Size::new(natural_w, natural_h)
346                } else {
347                    match (proposal.width, proposal.height) {
348                        // Both constrained: fit within, preserving aspect ratio
349                        (Some(pw), Some(ph)) => {
350                            let scale = (pw / natural_w).min(ph / natural_h);
351                            Size::new(natural_w * scale, natural_h * scale)
352                        }
353                        // Width constrained: compute height from aspect ratio
354                        (Some(pw), None) => Size::new(pw, pw / ar),
355                        // Height constrained: compute width from aspect ratio
356                        (None, Some(ph)) => Size::new(ph * ar, ph),
357                        // Unconstrained: natural size
358                        (None, None) => Size::new(natural_w, natural_h),
359                    }
360                }
361            }
362        };
363        size.into()
364    }
365
366    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
367        // Only clone pixels if not already queued — avoid per-frame allocation
368        if !canvas.has_pending_image(&self.name) {
369            canvas.ensure_image_registered(
370                &self.name,
371                self.width,
372                self.height,
373                Cow::Owned(self.upload_pixels.clone()),
374            );
375        }
376        let rtl = matches!(ctx.layout_direction, LayoutDirection::RightToLeft);
377        let rect = self.fitted_rect(bounds, rtl);
378
379        // Modes that overflow the box (`Cover`, or `None` on an oversized
380        // image) must not bleed past the widget's layout rectangle. Clip
381        // to `bounds` only when the fitted rect actually exceeds it — the
382        // renderer intersects this with any ancestor clip and pops it
383        // cleanly, so it composes with ScrollAreas etc. `Contain` /
384        // `ScaleDown` / a within-box image never overflow, so they skip the
385        // clip commands entirely.
386        const EPS: f32 = 0.01;
387        let overflows = rect.x < bounds.x - EPS
388            || rect.y < bounds.y - EPS
389            || rect.x + rect.width > bounds.x + bounds.width + EPS
390            || rect.y + rect.height > bounds.y + bounds.height + EPS;
391        if overflows {
392            canvas.set_clip(bounds);
393            canvas.draw_image(rect, &self.name);
394            canvas.clear_clip();
395        } else {
396            canvas.draw_image(rect, &self.name);
397        }
398    }
399
400    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
401        if self.a11y_hidden {
402            builder.set_hidden();
403            return;
404        }
405        debug_assert!(
406            self.alt.is_some(),
407            "ImageWidget has no alt text — call .alt(\"…\") for meaningful images or .a11y_hidden() for decorative ones"
408        );
409        builder.set_role(teksilo_core::accesskit::Role::Image);
410        if let Some(ref alt) = self.alt {
411            builder.set_name(alt);
412        }
413    }
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419    use teksilo_core::widget_tree::WidgetTree;
420
421    #[test]
422    fn natural_size() {
423        let icon = RasterIcon::from_raw(vec![255; 400], 10, 10);
424        let mut tree = WidgetTree::new();
425        let img = tree.add(ImageWidget::new(&icon));
426        tree.layout(SizeProposal::unspecified());
427        let b = tree.bounds(img);
428        assert!((b.width - 10.0).abs() < 0.01);
429        assert!((b.height - 10.0).abs() < 0.01);
430    }
431
432    #[test]
433    fn explicit_display_size() {
434        let icon = RasterIcon::from_raw(vec![255; 400], 10, 10);
435        let mut tree = WidgetTree::new();
436        let img = tree.add(ImageWidget::new(&icon).size(200.0, 100.0));
437        tree.layout(SizeProposal::unspecified());
438        let b = tree.bounds(img);
439        assert!((b.width - 200.0).abs() < 0.01);
440        assert!((b.height - 100.0).abs() < 0.01);
441    }
442
443    #[test]
444    fn size_that_fits_preserves_aspect_ratio() {
445        let icon = RasterIcon::from_raw(vec![255; 800], 20, 10); // 2:1 aspect
446        let widget = ImageWidget::new(&icon);
447        let theme = teksilo_core::presets::intui::light();
448        let ctx = LayoutContext::for_testing(&theme);
449        // Width constrained to 100, no height constraint → 100x50
450        let size = widget
451            .layout_response(
452                SizeProposal {
453                    width: Some(100.0),
454                    height: None,
455                },
456                &ctx,
457            )
458            .size;
459        assert!((size.width - 100.0).abs() < 0.5, "width: {}", size.width);
460        assert!((size.height - 50.0).abs() < 0.5, "height: {}", size.height);
461    }
462
463    #[test]
464    fn paints_image_quad() {
465        let icon = RasterIcon::from_raw(vec![255; 400], 10, 10);
466        let mut tree = WidgetTree::new();
467        tree.add(ImageWidget::new(&icon));
468        tree.layout(SizeProposal::exact(10.0, 10.0));
469        let frame = tree.render();
470        assert!(!frame.images.is_empty(), "should render an image");
471        assert!(frame.images[0].tint.is_none(), "should be full-color");
472    }
473
474    #[test]
475    fn pending_image_registered() {
476        let icon = RasterIcon::from_raw(vec![255; 400], 10, 10);
477        let mut tree = WidgetTree::new();
478        tree.add(ImageWidget::new(&icon));
479        tree.layout(SizeProposal::exact(10.0, 10.0));
480        let frame = tree.render();
481        assert!(
482            !frame.pending_images.is_empty(),
483            "should register pending image"
484        );
485    }
486
487    #[test]
488    fn from_raw_unique_name_per_call() {
489        // Two ImageWidgets with identical dimensions but different
490        // bytes used to alias on the per-frame `pending_images` key
491        // (`_img_raw_{w}x{h}`), causing one to silently render the
492        // other's pixels. The atomic-counter-tagged name fixes this.
493        let a = ImageWidget::from_raw(vec![255; 16], 2, 2);
494        let b = ImageWidget::from_raw(vec![0; 16], 2, 2);
495        assert_ne!(a.name, b.name);
496    }
497
498    #[test]
499    fn fixed_size_is_rigid_against_constraining_proposal() {
500        // Regression: a pinned `.size(32, 32)` must stay 32×32 no matter
501        // what the parent proposes. The old layout path treated the
502        // explicit size as a "natural size" and scaled it up to a
503        // width-constraining proposal — a 512px source logo placed in a
504        // wide title bar ballooned to the bar's full width.
505        let icon = RasterIcon::from_raw(vec![255; 512 * 512 * 4], 512, 512);
506        let widget = ImageWidget::new(&icon).size(32.0, 32.0);
507        let theme = teksilo_core::presets::intui::light();
508        let ctx = LayoutContext::for_testing(&theme);
509        // Wide width proposal, no height constraint — the VStack-cross-axis
510        // case that triggered the skribisto bug.
511        let size = widget
512            .layout_response(
513                SizeProposal {
514                    width: Some(600.0),
515                    height: None,
516                },
517                &ctx,
518            )
519            .size;
520        assert!((size.width - 32.0).abs() < 0.01, "width: {}", size.width);
521        assert!((size.height - 32.0).abs() < 0.01, "height: {}", size.height);
522    }
523
524    #[test]
525    fn single_axis_pin_derives_other_from_aspect_ratio() {
526        let icon = RasterIcon::from_raw(vec![255; 40 * 10 * 4], 40, 10); // 4:1
527        let theme = teksilo_core::presets::intui::light();
528        let ctx = LayoutContext::for_testing(&theme);
529        // Width pinned to 200 → height = 200 / 4 = 50, ignoring proposal.
530        let w_pinned = ImageWidget::new(&icon)
531            .width(200.0)
532            .layout_response(SizeProposal::exact(999.0, 999.0), &ctx)
533            .size;
534        assert!((w_pinned.width - 200.0).abs() < 0.01);
535        assert!(
536            (w_pinned.height - 50.0).abs() < 0.01,
537            "h: {}",
538            w_pinned.height
539        );
540        // Height pinned to 20 → width = 20 * 4 = 80.
541        let h_pinned = ImageWidget::new(&icon)
542            .height(20.0)
543            .layout_response(SizeProposal::exact(999.0, 999.0), &ctx)
544            .size;
545        assert!(
546            (h_pinned.width - 80.0).abs() < 0.01,
547            "w: {}",
548            h_pinned.width
549        );
550        assert!((h_pinned.height - 20.0).abs() < 0.01);
551    }
552
553    #[test]
554    fn resizable_false_locks_natural_pixel_size() {
555        let icon = RasterIcon::from_raw(vec![255; 64 * 64 * 4], 64, 64);
556        let theme = teksilo_core::presets::intui::light();
557        let ctx = LayoutContext::for_testing(&theme);
558        // Default (resizable) scales to a constraining proposal.
559        let scaled = ImageWidget::new(&icon)
560            .layout_response(SizeProposal::exact(16.0, 16.0), &ctx)
561            .size;
562        assert!((scaled.width - 16.0).abs() < 0.01);
563        // resizable(false) ignores the proposal and keeps 64×64.
564        let locked = ImageWidget::new(&icon)
565            .resizable(false)
566            .layout_response(SizeProposal::exact(16.0, 16.0), &ctx)
567            .size;
568        assert!((locked.width - 64.0).abs() < 0.01, "w: {}", locked.width);
569        assert!((locked.height - 64.0).abs() < 0.01);
570    }
571
572    #[test]
573    fn contain_centers_inside_a_wider_box() {
574        // 1:1 image in a 200×100 box → 100×100 letterboxed, centered.
575        let icon = RasterIcon::from_raw(vec![255; 10 * 10 * 4], 10, 10);
576        let widget = ImageWidget::new(&icon).fit(ImageFit::Contain);
577        let r = widget.fitted_rect(Rect::new(0.0, 0.0, 200.0, 100.0), false);
578        assert!((r.width - 100.0).abs() < 0.01);
579        assert!((r.height - 100.0).abs() < 0.01);
580        assert!((r.x - 50.0).abs() < 0.01, "x: {}", r.x); // (200-100)/2
581        assert!((r.y - 0.0).abs() < 0.01);
582    }
583
584    #[test]
585    fn alignment_positions_content_within_box() {
586        use teksilo_tokens::{HAlignment, VAlignment};
587        let icon = RasterIcon::from_raw(vec![255; 10 * 10 * 4], 10, 10);
588        let widget = ImageWidget::new(&icon)
589            .fit(ImageFit::Contain)
590            .alignment(Alignment {
591                horizontal: HAlignment::Trailing,
592                vertical: VAlignment::Bottom,
593            });
594        let r = widget.fitted_rect(Rect::new(0.0, 0.0, 200.0, 100.0), false);
595        // 100×100 pushed to bottom-trailing: x = 200-100, y = 100-100.
596        assert!((r.x - 100.0).abs() < 0.01, "x: {}", r.x);
597        assert!((r.y - 0.0).abs() < 0.01, "y: {}", r.y);
598        // RTL flips Trailing to the left edge.
599        let r_rtl = widget.fitted_rect(Rect::new(0.0, 0.0, 200.0, 100.0), true);
600        assert!((r_rtl.x - 0.0).abs() < 0.01, "rtl x: {}", r_rtl.x);
601    }
602
603    #[test]
604    fn fit_none_draws_at_natural_pixel_size() {
605        let icon = RasterIcon::from_raw(vec![255; 20 * 20 * 4], 20, 20);
606        let widget = ImageWidget::new(&icon).fit(ImageFit::None);
607        // In a 8×8 box the natural 20×20 image overflows; rect stays 20×20.
608        let r = widget.fitted_rect(Rect::new(0.0, 0.0, 8.0, 8.0), false);
609        assert!((r.width - 20.0).abs() < 0.01);
610        assert!((r.height - 20.0).abs() < 0.01);
611        // Centered → negative offset (overflows on all sides).
612        assert!((r.x - (-6.0)).abs() < 0.01, "x: {}", r.x); // (8-20)/2
613    }
614
615    #[test]
616    fn cover_overflow_is_clipped_to_bounds() {
617        // A 2:1 image covering a square box overflows horizontally and must
618        // be clipped — the frame should carry a SetClip/ClearClip pair.
619        let icon = RasterIcon::from_raw(vec![255; 20 * 10 * 4], 20, 10);
620        let mut tree = WidgetTree::new();
621        tree.add(
622            ImageWidget::new(&icon)
623                .size(50.0, 50.0)
624                .fit(ImageFit::Cover),
625        );
626        tree.layout(SizeProposal::exact(50.0, 50.0));
627        let frame = tree.render();
628        let has_set_clip = frame
629            .draw_order
630            .iter()
631            .any(|c| matches!(c, teksilo_canvas::DrawCommand::SetClip(_)));
632        let has_clear_clip = frame
633            .draw_order
634            .iter()
635            .any(|c| matches!(c, teksilo_canvas::DrawCommand::ClearClip));
636        assert!(has_set_clip, "Cover overflow should emit SetClip");
637        assert!(has_clear_clip, "Cover overflow should emit ClearClip");
638    }
639
640    #[test]
641    fn contain_within_box_emits_no_clip() {
642        // Contain never overflows, so no clip commands are emitted.
643        let icon = RasterIcon::from_raw(vec![255; 10 * 10 * 4], 10, 10);
644        let mut tree = WidgetTree::new();
645        tree.add(
646            ImageWidget::new(&icon)
647                .size(50.0, 50.0)
648                .fit(ImageFit::Contain),
649        );
650        tree.layout(SizeProposal::exact(50.0, 50.0));
651        let frame = tree.render();
652        let has_set_clip = frame
653            .draw_order
654            .iter()
655            .any(|c| matches!(c, teksilo_canvas::DrawCommand::SetClip(_)));
656        assert!(!has_set_clip, "Contain should not clip");
657    }
658
659    #[test]
660    fn mask_circle_alpha_zero_at_corners() {
661        // The reusable `.mask(Circle)` modifier — anywhere a photo
662        // needs a circular crop, not just inside Avatar.
663        let icon = RasterIcon::from_raw(vec![255; 32 * 32 * 4], 32, 32);
664        let widget = ImageWidget::from_raw(icon.pixels().to_vec(), icon.width(), icon.height())
665            .mask(ImageMaskShape::Circle);
666        // After cropping to a square (already 32×32 here) and
667        // masking, corner pixels have alpha 0.
668        let stride = (widget.width * 4) as usize;
669        let top_left_alpha = widget.upload_pixels[3];
670        let top_right_alpha = widget.upload_pixels[stride - 1];
671        assert_eq!(top_left_alpha, 0);
672        assert_eq!(top_right_alpha, 0);
673        // Centre pixel still opaque.
674        let center_idx = (((widget.height / 2) * widget.width + widget.width / 2) * 4 + 3) as usize;
675        assert_eq!(widget.upload_pixels[center_idx], 255);
676    }
677
678    #[test]
679    fn mask_none_is_passthrough() {
680        let original = vec![123, 45, 67, 200, 8, 9, 10, 200];
681        let widget = ImageWidget::from_raw(original.clone(), 2, 1).mask(ImageMaskShape::None);
682        assert_eq!(widget.upload_pixels, original);
683    }
684
685    #[test]
686    fn mask_crops_non_square_to_square() {
687        // 8×4 source → centre-cropped to 4×4 inscribed circle.
688        let pixels = vec![255; 8 * 4 * 4];
689        let widget = ImageWidget::from_raw(pixels, 8, 4).mask(ImageMaskShape::Circle);
690        assert_eq!(widget.width, 4);
691        assert_eq!(widget.height, 4);
692    }
693
694    #[test]
695    fn mask_bumps_name_to_avoid_atlas_collision() {
696        // The masked widget must not share a name with the un-masked
697        // version, otherwise the renderer would reuse the un-masked
698        // pixels in the atlas.
699        let icon = RasterIcon::from_raw(vec![255; 16 * 16 * 4], 16, 16);
700        let unmasked = ImageWidget::new(&icon);
701        let masked = ImageWidget::new(&icon).mask(ImageMaskShape::Circle);
702        assert_ne!(unmasked.name, masked.name);
703    }
704}