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}