Skip to main content

cranpose_ui/widgets/
image.rs

1//! Image composable and painter primitives.
2
3#![expect(non_snake_case)]
4#![expect(clippy::too_many_arguments)]
5
6#[cfg(feature = "svg")]
7use std::sync::{Mutex, MutexGuard};
8use std::{
9    hash::{Hash, Hasher},
10    sync::Arc,
11};
12
13use cranpose_core::NodeId;
14use cranpose_ui_graphics::{ColorFilter, DrawScope, ImageBitmap, ImageSampling};
15use cranpose_ui_layout::{Constraints, MeasurePolicy, MeasureResult, MeasureScope, Placement};
16use thiserror::Error;
17
18use crate::{
19    composable,
20    layout::core::{Alignment, Measurable},
21    modifier::{Modifier, Rect, Size},
22    nine_patch::{NinePatchInsets, PatchFill, PatchQuad, nine_patch_quads, tile_quads},
23    widgets::Layout,
24};
25
26#[cfg(feature = "svg")]
27#[path = "image_svg.rs"]
28mod image_svg;
29
30pub const DEFAULT_ALPHA: f32 = 1.0;
31#[cfg(feature = "svg")]
32const SVG_RASTER_CACHE_LIMIT: usize = 8;
33
34#[derive(Clone, Copy, Debug, PartialEq)]
35pub enum ContentScale {
36    Fit,
37    Crop,
38    FillBounds,
39    FillWidth,
40    FillHeight,
41    Inside,
42    None,
43}
44
45impl ContentScale {
46    pub fn scaled_size(self, src_size: Size, dst_size: Size) -> Size {
47        if src_size.width <= 0.0
48            || src_size.height <= 0.0
49            || dst_size.width <= 0.0
50            || dst_size.height <= 0.0
51        {
52            return Size::ZERO;
53        }
54
55        let scale_x = dst_size.width / src_size.width;
56        let scale_y = dst_size.height / src_size.height;
57
58        let (factor_x, factor_y) = match self {
59            Self::Fit => {
60                let factor = scale_x.min(scale_y);
61                (factor, factor)
62            }
63            Self::Crop => {
64                let factor = scale_x.max(scale_y);
65                (factor, factor)
66            }
67            Self::FillBounds => (scale_x, scale_y),
68            Self::FillWidth => (scale_x, scale_x),
69            Self::FillHeight => (scale_y, scale_y),
70            Self::Inside => {
71                if src_size.width <= dst_size.width && src_size.height <= dst_size.height {
72                    (1.0, 1.0)
73                } else {
74                    let factor = scale_x.min(scale_y);
75                    (factor, factor)
76                }
77            }
78            Self::None => (1.0, 1.0),
79        };
80
81        Size {
82            width: src_size.width * factor_x,
83            height: src_size.height * factor_y,
84        }
85    }
86}
87
88#[derive(Clone, Debug, PartialEq, Eq, Hash)]
89pub struct Painter {
90    kind: PainterKind,
91}
92
93#[derive(Clone, Debug, PartialEq, Eq, Hash)]
94enum PainterKind {
95    Bitmap(ImageBitmap),
96    BitmapRegion {
97        bitmap: ImageBitmap,
98        source: Quad,
99        sampling: ImageSampling,
100    },
101    /// A source repeated at its own size across whatever space it is given,
102    /// rather than scaled to fit it.
103    BitmapTiled {
104        bitmap: ImageBitmap,
105        source: Quad,
106        sampling: ImageSampling,
107    },
108    /// A source whose corners keep their size while its edges and middle grow.
109    NinePatch {
110        bitmap: ImageBitmap,
111        source: Quad,
112        insets: Quad,
113        center: PatchFill,
114        edges: PatchFill,
115        sampling: ImageSampling,
116    },
117    Svg(SvgPainter),
118}
119
120/// Four `f32`s carried as bits.
121///
122/// A painter is compared and hashed so a composable can skip when it has not
123/// changed, and floats are neither `Eq` nor `Hash`. Keeping the bits makes the
124/// comparison exact — two painters built from the same numbers are the same
125/// painter — without asking the caller to think about it.
126#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
127struct Quad {
128    a: u32,
129    b: u32,
130    c: u32,
131    d: u32,
132}
133
134impl Quad {
135    fn of(a: f32, b: f32, c: f32, d: f32) -> Self {
136        Self {
137            a: a.to_bits(),
138            b: b.to_bits(),
139            c: c.to_bits(),
140            d: d.to_bits(),
141        }
142    }
143
144    fn values(self) -> (f32, f32, f32, f32) {
145        (
146            f32::from_bits(self.a),
147            f32::from_bits(self.b),
148            f32::from_bits(self.c),
149            f32::from_bits(self.d),
150        )
151    }
152
153    fn from_rect(source: Rect) -> Self {
154        Self::of(source.x, source.y, source.width, source.height)
155    }
156
157    fn rect(self) -> Rect {
158        let (x, y, width, height) = self.values();
159        Rect {
160            x,
161            y,
162            width,
163            height,
164        }
165    }
166
167    fn from_insets(insets: NinePatchInsets) -> Self {
168        Self::of(insets.left, insets.top, insets.right, insets.bottom)
169    }
170
171    fn insets(self) -> NinePatchInsets {
172        let (left, top, right, bottom) = self.values();
173        NinePatchInsets::new(left, top, right, bottom)
174    }
175}
176
177impl Painter {
178    pub fn from_bitmap(bitmap: ImageBitmap) -> Self {
179        Self {
180            kind: PainterKind::Bitmap(bitmap),
181        }
182    }
183
184    /// Creates a painter for one source region of a bitmap atlas.
185    pub fn from_bitmap_region(bitmap: ImageBitmap, source: Rect, sampling: ImageSampling) -> Self {
186        Self {
187            kind: PainterKind::BitmapRegion {
188                bitmap,
189                source: Quad::from_rect(source),
190                sampling,
191            },
192        }
193    }
194
195    /// Creates a painter that repeats `source` at its own size across whatever
196    /// space it is given, instead of scaling it to fit.
197    ///
198    /// A texture, a hatch or a skin background covers an area of any size
199    /// without going soft. `source` is a region of `bitmap`, so an application
200    /// tiles one sprite out of an atlas and never the whole sheet.
201    pub fn from_bitmap_tiled(bitmap: ImageBitmap, source: Rect, sampling: ImageSampling) -> Self {
202        Self {
203            kind: PainterKind::BitmapTiled {
204                bitmap,
205                source: Quad::from_rect(source),
206                sampling,
207            },
208        }
209    }
210
211    /// Creates a painter whose corners keep their own size while its edges and
212    /// middle grow — one drawing of a button, panel or trough used at every
213    /// size.
214    ///
215    /// `center` and `edges` decide whether the growing parts are stretched or
216    /// tiled. Insets that leave no middle, or a destination with no room for
217    /// the corners, fall back to a plain scale rather than drawing corners over
218    /// each other.
219    pub fn from_nine_patch(
220        bitmap: ImageBitmap,
221        source: Rect,
222        insets: NinePatchInsets,
223        center: PatchFill,
224        edges: PatchFill,
225        sampling: ImageSampling,
226    ) -> Self {
227        Self {
228            kind: PainterKind::NinePatch {
229                bitmap,
230                source: Quad::from_rect(source),
231                insets: Quad::from_insets(insets),
232                center,
233                edges,
234                sampling,
235            },
236        }
237    }
238
239    pub fn from_svg(svg: SvgPainter) -> Self {
240        Self {
241            kind: PainterKind::Svg(svg),
242        }
243    }
244
245    pub fn intrinsic_size(&self) -> Size {
246        match &self.kind {
247            PainterKind::Bitmap(bitmap) => bitmap.intrinsic_size(),
248            PainterKind::BitmapRegion { source, .. }
249            | PainterKind::BitmapTiled { source, .. }
250            | PainterKind::NinePatch { source, .. } => {
251                let source = source.rect();
252                Size::new(source.width.max(0.0), source.height.max(0.0))
253            }
254            PainterKind::Svg(svg) => svg.intrinsic_size(),
255        }
256    }
257
258    pub fn as_bitmap(&self) -> Option<&ImageBitmap> {
259        match &self.kind {
260            PainterKind::Bitmap(bitmap) => Some(bitmap),
261            PainterKind::BitmapRegion { bitmap, .. }
262            | PainterKind::BitmapTiled { bitmap, .. }
263            | PainterKind::NinePatch { bitmap, .. } => Some(bitmap),
264            PainterKind::Svg(_) => None,
265        }
266    }
267
268    /// Returns the underlying bitmap when this painter is bitmap-backed.
269    pub fn bitmap(&self) -> Option<&ImageBitmap> {
270        self.as_bitmap()
271    }
272}
273
274impl From<ImageBitmap> for Painter {
275    fn from(value: ImageBitmap) -> Self {
276        Self::from_bitmap(value)
277    }
278}
279
280impl From<SvgPainter> for Painter {
281    fn from(value: SvgPainter) -> Self {
282        Self::from_svg(value)
283    }
284}
285
286pub fn BitmapPainter(bitmap: ImageBitmap) -> Painter {
287    Painter::from_bitmap(bitmap)
288}
289
290/// Creates a painter that draws one region from a bitmap atlas.
291pub fn BitmapRegionPainter(bitmap: ImageBitmap, source: Rect, sampling: ImageSampling) -> Painter {
292    Painter::from_bitmap_region(bitmap, source, sampling)
293}
294
295/// Creates a painter that repeats one region of a bitmap across its bounds.
296pub fn TiledPainter(bitmap: ImageBitmap, source: Rect, sampling: ImageSampling) -> Painter {
297    Painter::from_bitmap_tiled(bitmap, source, sampling)
298}
299
300/// Creates a painter whose corners stay put while its edges and middle grow.
301pub fn NinePatchPainter(
302    bitmap: ImageBitmap,
303    source: Rect,
304    insets: NinePatchInsets,
305    center: PatchFill,
306    edges: PatchFill,
307    sampling: ImageSampling,
308) -> Painter {
309    Painter::from_nine_patch(bitmap, source, insets, center, edges, sampling)
310}
311
312/// Errors returned while parsing or rasterizing an SVG painter.
313#[derive(Debug, Clone, PartialEq, Eq, Error)]
314pub enum SvgPainterError {
315    #[error("SVG support is disabled; enable the cranpose-ui `svg` feature")]
316    SvgFeatureDisabled,
317    #[error("failed to parse SVG: {0}")]
318    Parse(String),
319    #[error("SVG raster dimensions must be greater than zero")]
320    InvalidRasterDimensions,
321    #[error("SVG raster dimensions are too large")]
322    RasterDimensionsTooLarge,
323    #[error("failed to allocate SVG raster {width}x{height}")]
324    RasterAllocationFailed { width: u32, height: u32 },
325    #[error("SVG raster cache is unavailable")]
326    RasterCacheUnavailable,
327    #[error(transparent)]
328    ImageBitmap(#[from] cranpose_ui_graphics::ImageBitmapError),
329}
330
331/// Parsed SVG image data that rasterizes on demand for the requested draw size.
332#[derive(Clone)]
333pub struct SvgPainter {
334    inner: Arc<SvgPainterInner>,
335}
336
337struct SvgPainterInner {
338    #[cfg(feature = "svg")]
339    document: image_svg::SvgDocument,
340    intrinsic_size: Size,
341    #[cfg(feature = "svg")]
342    cache: Mutex<SvgRasterCache>,
343}
344
345#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
346#[cfg(feature = "svg")]
347struct SvgRasterKey {
348    width: u32,
349    height: u32,
350}
351
352#[derive(Clone, Debug)]
353#[cfg(feature = "svg")]
354struct SvgRasterEntry {
355    key: SvgRasterKey,
356    bitmap: ImageBitmap,
357}
358
359#[derive(Default, Debug)]
360#[cfg(feature = "svg")]
361struct SvgRasterCache {
362    entries: Vec<SvgRasterEntry>,
363}
364
365impl SvgPainter {
366    pub fn from_bytes(bytes: &[u8]) -> Result<Self, SvgPainterError> {
367        #[cfg(not(feature = "svg"))]
368        {
369            let _ = bytes;
370            Err(SvgPainterError::SvgFeatureDisabled)
371        }
372        #[cfg(feature = "svg")]
373        {
374            let document = image_svg::parse_svg_document(bytes)?;
375            let intrinsic_size = document.intrinsic_size();
376
377            Ok(Self {
378                inner: Arc::new(SvgPainterInner {
379                    document,
380                    intrinsic_size,
381                    cache: Mutex::new(SvgRasterCache::default()),
382                }),
383            })
384        }
385    }
386
387    pub fn id(&self) -> u64 {
388        Arc::as_ptr(&self.inner) as usize as u64
389    }
390
391    pub fn intrinsic_size(&self) -> Size {
392        self.inner.intrinsic_size
393    }
394
395    pub fn rasterize(&self, pixel_size: Size) -> Result<ImageBitmap, SvgPainterError> {
396        #[cfg(not(feature = "svg"))]
397        {
398            let _ = pixel_size;
399            Err(SvgPainterError::SvgFeatureDisabled)
400        }
401        #[cfg(feature = "svg")]
402        {
403            let key = svg_raster_key(pixel_size)?;
404            if let Some(bitmap) = self.cached_bitmap(key)? {
405                return Ok(bitmap);
406            }
407
408            let bitmap = self.rasterize_uncached(key)?;
409            self.cache_bitmap(key, bitmap.clone())?;
410            Ok(bitmap)
411        }
412    }
413
414    #[cfg(feature = "svg")]
415    fn cached_bitmap(&self, key: SvgRasterKey) -> Result<Option<ImageBitmap>, SvgPainterError> {
416        let mut cache = self.lock_cache();
417        Ok(cache.get(key))
418    }
419
420    #[cfg(feature = "svg")]
421    fn cache_bitmap(&self, key: SvgRasterKey, bitmap: ImageBitmap) -> Result<(), SvgPainterError> {
422        let mut cache = self.lock_cache();
423        cache.insert(key, bitmap);
424        Ok(())
425    }
426
427    #[cfg(feature = "svg")]
428    fn lock_cache(&self) -> MutexGuard<'_, SvgRasterCache> {
429        self.inner
430            .cache
431            .lock()
432            .unwrap_or_else(std::sync::PoisonError::into_inner)
433    }
434
435    #[cfg(feature = "svg")]
436    fn rasterize_uncached(&self, key: SvgRasterKey) -> Result<ImageBitmap, SvgPainterError> {
437        (key.width as usize)
438            .checked_mul(key.height as usize)
439            .and_then(|value| value.checked_mul(4))
440            .ok_or(SvgPainterError::RasterDimensionsTooLarge)?;
441
442        let mut pixmap = tiny_skia::Pixmap::new(key.width, key.height).ok_or(
443            SvgPainterError::RasterAllocationFailed {
444                width: key.width,
445                height: key.height,
446            },
447        )?;
448        image_svg::rasterize_svg_document(&self.inner.document, &mut pixmap);
449        let pixels = image_svg::demultiplied_rgba_pixels(&pixmap);
450        Ok(ImageBitmap::from_rgba8(key.width, key.height, pixels)?)
451    }
452}
453
454impl std::fmt::Debug for SvgPainter {
455    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
456        f.debug_struct("SvgPainter")
457            .field("id", &self.id())
458            .field("intrinsic_size", &self.intrinsic_size())
459            .finish_non_exhaustive()
460    }
461}
462
463impl PartialEq for SvgPainter {
464    fn eq(&self, other: &Self) -> bool {
465        self.id() == other.id()
466    }
467}
468
469impl Eq for SvgPainter {}
470
471impl Hash for SvgPainter {
472    fn hash<H: Hasher>(&self, state: &mut H) {
473        self.id().hash(state);
474    }
475}
476
477#[cfg(feature = "svg")]
478impl SvgRasterCache {
479    fn get(&mut self, key: SvgRasterKey) -> Option<ImageBitmap> {
480        let position = self.entries.iter().position(|entry| entry.key == key)?;
481        let entry = self.entries.remove(position);
482        let bitmap = entry.bitmap.clone();
483        self.entries.push(entry);
484        Some(bitmap)
485    }
486
487    fn insert(&mut self, key: SvgRasterKey, bitmap: ImageBitmap) {
488        if let Some(position) = self.entries.iter().position(|entry| entry.key == key) {
489            self.entries.remove(position);
490        } else if self.entries.len() >= SVG_RASTER_CACHE_LIMIT {
491            self.entries.remove(0);
492        }
493        self.entries.push(SvgRasterEntry { key, bitmap });
494    }
495}
496
497#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
498struct SvgBytesKey {
499    ptr: usize,
500    len: usize,
501}
502
503impl SvgBytesKey {
504    fn new(bytes: &'static [u8]) -> Self {
505        Self {
506            ptr: bytes.as_ptr() as usize,
507            len: bytes.len(),
508        }
509    }
510}
511
512pub fn rememberSvg(bytes: &'static [u8]) -> Result<SvgPainter, SvgPainterError> {
513    let key = SvgBytesKey::new(bytes);
514    cranpose_core::withCurrentComposer(|composer| {
515        composer.with_key(&key, |composer| {
516            composer
517                .remember(|| SvgPainter::from_bytes(bytes))
518                .with(Clone::clone)
519        })
520    })
521}
522
523/// Measure policy for Image that preserves aspect ratio when constraints
524/// force the image smaller than its intrinsic size.
525///
526/// Unlike [`LeafMeasurePolicy`] which clamps width and height independently,
527/// this scales both dimensions by the same factor so the image is never
528/// distorted by layout constraints.
529#[derive(Clone, Debug, PartialEq)]
530struct ImageMeasurePolicy {
531    intrinsic_size: Size,
532}
533
534impl MeasurePolicy for ImageMeasurePolicy {
535    fn measure(
536        &self,
537        scope: &dyn MeasureScope,
538        _measurables: &[Box<dyn Measurable>],
539        constraints: Constraints,
540    ) -> MeasureResult {
541        let mut placements = Vec::new();
542        let measurement = self.measure_into(scope, &[], constraints, &mut placements);
543        MeasureResult::new(measurement, placements)
544    }
545
546    fn measure_into(
547        &self,
548        _scope: &dyn MeasureScope,
549        _measurables: &[Box<dyn Measurable>],
550        constraints: Constraints,
551        placements: &mut Vec<Placement>,
552    ) -> cranpose_ui_layout::Measurement {
553        placements.clear();
554        let iw = self.intrinsic_size.width;
555        let ih = self.intrinsic_size.height;
556
557        if iw <= 0.0 || ih <= 0.0 {
558            let (w, h) = constraints.constrain(0.0, 0.0);
559            return Size {
560                width: w,
561                height: h,
562            }
563            .into();
564        }
565
566        let cw = iw.clamp(constraints.min_width, constraints.max_width);
567        let ch = ih.clamp(constraints.min_height, constraints.max_height);
568
569        let scale_x = cw / iw;
570        let scale_y = ch / ih;
571
572        let (width, height) = if scale_x < 1.0 || scale_y < 1.0 {
573            let factor = scale_x.min(scale_y);
574            let w = (iw * factor).clamp(constraints.min_width, constraints.max_width);
575            let h = (ih * factor).clamp(constraints.min_height, constraints.max_height);
576            (w, h)
577        } else {
578            (cw, ch)
579        };
580
581        Size { width, height }.into()
582    }
583
584    fn min_intrinsic_width(&self, _measurables: &[Box<dyn Measurable>], _height: f32) -> f32 {
585        self.intrinsic_size.width
586    }
587
588    fn max_intrinsic_width(&self, _measurables: &[Box<dyn Measurable>], _height: f32) -> f32 {
589        self.intrinsic_size.width
590    }
591
592    fn min_intrinsic_height(&self, _measurables: &[Box<dyn Measurable>], _width: f32) -> f32 {
593        self.intrinsic_size.height
594    }
595
596    fn max_intrinsic_height(&self, _measurables: &[Box<dyn Measurable>], _width: f32) -> f32 {
597        self.intrinsic_size.height
598    }
599}
600
601/// Where the painter draws in the container, as Compose's painter modifier
602/// places it: scaled, then aligned on sizes rounded to whole device pixels
603/// of `density`, overflowing the container where the scale makes it larger.
604fn destination_rect(
605    src_size: Size,
606    dst_size: Size,
607    alignment: Alignment,
608    content_scale: ContentScale,
609    density: f32,
610) -> Rect {
611    use cranpose_ui_layout::round_to_px;
612
613    let draw_size = content_scale.scaled_size(src_size, dst_size);
614    Rect {
615        x: alignment.horizontal.align(
616            round_to_px(dst_size.width, density),
617            round_to_px(draw_size.width, density),
618            density,
619        ),
620        y: alignment.vertical.align(
621            round_to_px(dst_size.height, density),
622            round_to_px(draw_size.height, density),
623            density,
624        ),
625        width: draw_size.width,
626        height: draw_size.height,
627    }
628}
629
630fn map_destination_clip_to_source(
631    src_rect: Rect,
632    dst_rect: Rect,
633    clipped_dst_rect: Rect,
634) -> Option<Rect> {
635    if src_rect.width <= 0.0
636        || src_rect.height <= 0.0
637        || dst_rect.width <= 0.0
638        || dst_rect.height <= 0.0
639        || clipped_dst_rect.width <= 0.0
640        || clipped_dst_rect.height <= 0.0
641    {
642        return None;
643    }
644
645    let scale_x = src_rect.width / dst_rect.width;
646    let scale_y = src_rect.height / dst_rect.height;
647
648    let src_min_x = src_rect.x;
649    let src_min_y = src_rect.y;
650    let src_max_x = src_rect.x + src_rect.width;
651    let src_max_y = src_rect.y + src_rect.height;
652
653    let raw_left = src_rect.x + (clipped_dst_rect.x - dst_rect.x) * scale_x;
654    let raw_top = src_rect.y + (clipped_dst_rect.y - dst_rect.y) * scale_y;
655    let raw_right =
656        src_rect.x + ((clipped_dst_rect.x + clipped_dst_rect.width) - dst_rect.x) * scale_x;
657    let raw_bottom =
658        src_rect.y + ((clipped_dst_rect.y + clipped_dst_rect.height) - dst_rect.y) * scale_y;
659
660    let left = raw_left.clamp(src_min_x, src_max_x);
661    let top = raw_top.clamp(src_min_y, src_max_y);
662    let right = raw_right.clamp(src_min_x, src_max_x);
663    let bottom = raw_bottom.clamp(src_min_y, src_max_y);
664    let width = right - left;
665    let height = bottom - top;
666
667    if width <= 0.0 || height <= 0.0 {
668        None
669    } else {
670        Some(Rect {
671            x: left,
672            y: top,
673            width,
674            height,
675        })
676    }
677}
678
679fn image_destination_clip(
680    src_size: Size,
681    container_size: Size,
682    alignment: Alignment,
683    content_scale: ContentScale,
684    density: f32,
685) -> Option<(Rect, Rect)> {
686    let dst_rect = destination_rect(src_size, container_size, alignment, content_scale, density);
687    if dst_rect.width <= 0.0 || dst_rect.height <= 0.0 {
688        return None;
689    }
690
691    let container_rect = Rect::from_size(container_size);
692    let clipped_dst_rect = dst_rect.intersect(container_rect)?;
693    Some((dst_rect, clipped_dst_rect))
694}
695
696fn draw_bitmap_painter(
697    scope: &mut dyn DrawScope,
698    bitmap: ImageBitmap,
699    intrinsic_size: Size,
700    alignment: Alignment,
701    content_scale: ContentScale,
702    alpha: f32,
703    color_filter: Option<ColorFilter>,
704    density: f32,
705) {
706    let container_size = scope.size();
707    let Some((dst_rect, clipped_dst_rect)) = image_destination_clip(
708        intrinsic_size,
709        container_size,
710        alignment,
711        content_scale,
712        density,
713    ) else {
714        return;
715    };
716    let full_src_rect = Rect::from_size(Size::new(bitmap.width() as f32, bitmap.height() as f32));
717    let Some(clipped_src_rect) =
718        map_destination_clip_to_source(full_src_rect, dst_rect, clipped_dst_rect)
719    else {
720        return;
721    };
722    scope.draw_image_src_sampled(
723        bitmap,
724        clipped_src_rect,
725        clipped_dst_rect,
726        alpha,
727        color_filter,
728        ImageSampling::Linear,
729    );
730}
731
732fn draw_bitmap_region_painter(
733    scope: &mut dyn DrawScope,
734    bitmap: ImageBitmap,
735    source: Rect,
736    alignment: Alignment,
737    content_scale: ContentScale,
738    alpha: f32,
739    color_filter: Option<ColorFilter>,
740    sampling: ImageSampling,
741    density: f32,
742) {
743    let source_size = Size::new(source.width.max(0.0), source.height.max(0.0));
744    let Some((dst_rect, clipped_dst_rect)) =
745        image_destination_clip(source_size, scope.size(), alignment, content_scale, density)
746    else {
747        return;
748    };
749    let Some(clipped_source) = map_destination_clip_to_source(source, dst_rect, clipped_dst_rect)
750    else {
751        return;
752    };
753    scope.draw_image_src_sampled(
754        bitmap,
755        clipped_source,
756        clipped_dst_rect,
757        alpha,
758        color_filter,
759        sampling,
760    );
761}
762
763/// Draws a list of source-to-destination quads, clipped to the container.
764///
765/// Every fill that is not a plain scale — tiling, nine-patch — reduces to this,
766/// so the clipping rule and the sampling choice are stated once.
767fn draw_patch_quads(
768    scope: &mut dyn DrawScope,
769    bitmap: &ImageBitmap,
770    quads: &[PatchQuad],
771    container: Rect,
772    alpha: f32,
773    color_filter: Option<ColorFilter>,
774    sampling: ImageSampling,
775) {
776    for quad in quads {
777        let Some(clipped_destination) = intersect(quad.destination, container) else {
778            continue;
779        };
780        let Some(clipped_source) =
781            map_destination_clip_to_source(quad.source, quad.destination, clipped_destination)
782        else {
783            continue;
784        };
785        scope.draw_image_src_sampled(
786            bitmap.clone(),
787            clipped_source,
788            clipped_destination,
789            alpha,
790            color_filter,
791            sampling,
792        );
793    }
794}
795
796fn intersect(rect: Rect, bounds: Rect) -> Option<Rect> {
797    let left = rect.x.max(bounds.x);
798    let top = rect.y.max(bounds.y);
799    let right = (rect.x + rect.width).min(bounds.x + bounds.width);
800    let bottom = (rect.y + rect.height).min(bounds.y + bounds.height);
801    if right <= left || bottom <= top {
802        return None;
803    }
804    Some(Rect {
805        x: left,
806        y: top,
807        width: right - left,
808        height: bottom - top,
809    })
810}
811
812/// Draws a tiled painter across the whole container.
813///
814/// Content scale and alignment do not apply: a tiled fill covers its bounds by
815/// definition, and scaling the source would defeat the reason for tiling it.
816fn draw_bitmap_tiled_painter(
817    scope: &mut dyn DrawScope,
818    bitmap: ImageBitmap,
819    source: Rect,
820    alpha: f32,
821    color_filter: Option<ColorFilter>,
822    sampling: ImageSampling,
823) {
824    let container = Rect::from_size(scope.size());
825    let quads = tile_quads(source, container);
826    draw_patch_quads(
827        scope,
828        &bitmap,
829        &quads,
830        container,
831        alpha,
832        color_filter,
833        sampling,
834    );
835}
836
837/// Draws a nine-patch painter across the whole container.
838///
839/// Like tiling, this fills its bounds by construction, so content scale and
840/// alignment have nothing left to decide.
841#[expect(clippy::too_many_arguments)]
842fn draw_nine_patch_painter(
843    scope: &mut dyn DrawScope,
844    bitmap: ImageBitmap,
845    source: Rect,
846    insets: NinePatchInsets,
847    center: PatchFill,
848    edges: PatchFill,
849    alpha: f32,
850    color_filter: Option<ColorFilter>,
851    sampling: ImageSampling,
852) {
853    let container = Rect::from_size(scope.size());
854    let quads = nine_patch_quads(source, container, insets, center, edges);
855    draw_patch_quads(
856        scope,
857        &bitmap,
858        &quads,
859        container,
860        alpha,
861        color_filter,
862        sampling,
863    );
864}
865
866fn draw_svg_painter(
867    scope: &mut dyn DrawScope,
868    svg: SvgPainter,
869    intrinsic_size: Size,
870    alignment: Alignment,
871    content_scale: ContentScale,
872    alpha: f32,
873    color_filter: Option<ColorFilter>,
874    density: f32,
875) {
876    let container_size = scope.size();
877    let Some((dst_rect, clipped_dst_rect)) = image_destination_clip(
878        intrinsic_size,
879        container_size,
880        alignment,
881        content_scale,
882        density,
883    ) else {
884        return;
885    };
886    let pixel_size = Size::new(dst_rect.width * density, dst_rect.height * density);
887    let bitmap = match svg.rasterize(pixel_size) {
888        Ok(bitmap) => bitmap,
889        Err(error) => {
890            log::warn!("failed to rasterize SVG painter: {error}");
891            return;
892        }
893    };
894    let full_src_rect = Rect::from_size(Size::new(bitmap.width() as f32, bitmap.height() as f32));
895    let Some(clipped_src_rect) =
896        map_destination_clip_to_source(full_src_rect, dst_rect, clipped_dst_rect)
897    else {
898        return;
899    };
900    scope.draw_image_src_sampled(
901        bitmap,
902        clipped_src_rect,
903        clipped_dst_rect,
904        alpha,
905        color_filter,
906        ImageSampling::Linear,
907    );
908}
909
910#[cfg(feature = "svg")]
911fn svg_raster_key(pixel_size: Size) -> Result<SvgRasterKey, SvgPainterError> {
912    let width = svg_raster_axis(pixel_size.width)?;
913    let height = svg_raster_axis(pixel_size.height)?;
914    width
915        .checked_mul(height)
916        .and_then(|value| value.checked_mul(4))
917        .ok_or(SvgPainterError::RasterDimensionsTooLarge)?;
918    Ok(SvgRasterKey { width, height })
919}
920
921#[cfg(feature = "svg")]
922fn svg_raster_axis(value: f32) -> Result<u32, SvgPainterError> {
923    if !value.is_finite() || value <= 0.0 {
924        return Err(SvgPainterError::InvalidRasterDimensions);
925    }
926
927    let rounded = value.ceil();
928    if rounded > u32::MAX as f32 {
929        return Err(SvgPainterError::RasterDimensionsTooLarge);
930    }
931    Ok(rounded as u32)
932}
933
934/// Displays a painter with an optional accessible description.
935///
936/// Images use the image accessibility role unless `modifier` supplies another role.
937/// Without a description or modifier semantics, the image is decorative.
938#[composable]
939pub fn Image<P>(
940    painter: P,
941    content_description: Option<String>,
942    modifier: Modifier,
943    alignment: Alignment,
944    content_scale: ContentScale,
945    alpha: f32,
946    color_filter: Option<ColorFilter>,
947) -> NodeId
948where
949    P: Into<Painter> + Clone + PartialEq + 'static,
950{
951    let painter = painter.into();
952    let intrinsic_dp = painter.intrinsic_size();
953    let draw_alpha = alpha.clamp(0.0, 1.0);
954
955    let semantics_modifier = Modifier::empty().stable_semantics(move |config| {
956        config.content_description.clone_from(&content_description);
957    });
958
959    // The grid the image is aligned on: the composition's, as Text keeps it.
960    let density = crate::density::density().density();
961    let image_modifier = semantics_modifier
962        .then(modifier)
963        .stable_semantics(|config| {
964            if config.role.is_none()
965                && config
966                    .content_description
967                    .as_ref()
968                    .is_some_and(|label| !label.trim().is_empty())
969            {
970                config.role = Some(crate::SemanticsWidgetRole::Image);
971            }
972        })
973        .draw_behind(move |scope: &mut dyn DrawScope| {
974            if draw_alpha <= 0.0 {
975                return;
976            }
977            let container_size = scope.size();
978            if container_size.width <= 0.0 || container_size.height <= 0.0 {
979                return;
980            }
981            match &painter.kind {
982                PainterKind::Bitmap(bitmap) => draw_bitmap_painter(
983                    scope,
984                    bitmap.clone(),
985                    intrinsic_dp,
986                    alignment,
987                    content_scale,
988                    draw_alpha,
989                    color_filter,
990                    density,
991                ),
992                PainterKind::BitmapRegion {
993                    bitmap,
994                    source,
995                    sampling,
996                } => draw_bitmap_region_painter(
997                    scope,
998                    bitmap.clone(),
999                    source.rect(),
1000                    alignment,
1001                    content_scale,
1002                    draw_alpha,
1003                    color_filter,
1004                    *sampling,
1005                    density,
1006                ),
1007                PainterKind::BitmapTiled {
1008                    bitmap,
1009                    source,
1010                    sampling,
1011                } => draw_bitmap_tiled_painter(
1012                    scope,
1013                    bitmap.clone(),
1014                    source.rect(),
1015                    draw_alpha,
1016                    color_filter,
1017                    *sampling,
1018                ),
1019                PainterKind::NinePatch {
1020                    bitmap,
1021                    source,
1022                    insets,
1023                    center,
1024                    edges,
1025                    sampling,
1026                } => draw_nine_patch_painter(
1027                    scope,
1028                    bitmap.clone(),
1029                    source.rect(),
1030                    insets.insets(),
1031                    *center,
1032                    *edges,
1033                    draw_alpha,
1034                    color_filter,
1035                    *sampling,
1036                ),
1037                PainterKind::Svg(svg) => draw_svg_painter(
1038                    scope,
1039                    svg.clone(),
1040                    intrinsic_dp,
1041                    alignment,
1042                    content_scale,
1043                    draw_alpha,
1044                    color_filter,
1045                    density,
1046                ),
1047            }
1048        });
1049
1050    Layout(
1051        image_modifier,
1052        ImageMeasurePolicy {
1053            intrinsic_size: intrinsic_dp,
1054        },
1055        || {},
1056    )
1057}
1058
1059#[cfg(test)]
1060#[path = "tests/image_tests.rs"]
1061mod tests;