Skip to main content

rosace_widgets/tree/
image.rs

1use rosace_core::types::{Point, Rect, Size};
2use rosace_render::{Color, DrawCommand};
3
4use super::{Widget, LayoutCtx, PaintCtx};
5use crate::{ImageWidget as ImageWidgetImpl, ImageFit, ImageSource};
6
7/// A tree-compatible image widget that blits a PNG file or bytes onto the canvas.
8///
9/// Wraps [`crate::ImageWidget`] as a [`Widget`] so it can be used as a child
10/// of `Column`, `Row`, `Stack`, etc.
11pub struct Image {
12    inner: ImageWidgetImpl,
13}
14
15impl Image {
16    /// Create an image from a file path.
17    pub fn file(path: impl Into<std::path::PathBuf>) -> Self {
18        Self { inner: ImageWidgetImpl::new().file(path).fit(ImageFit::Contain) }
19    }
20
21    /// Create an image from a bundled **asset** by logical name — resolved
22    /// per-platform via [`rosace_core::asset`] (dev: `assets/<name>`; mobile:
23    /// the app bundle). Portable across platforms and hot-reloads under
24    /// `rsc dev`. Prefer this over [`file`](Self::file) for shipped images.
25    pub fn asset(name: impl rosace_core::asset::AssetRef) -> Self {
26        Self { inner: ImageWidgetImpl::new().asset(name).fit(ImageFit::Contain) }
27    }
28
29    /// Create an image from raw PNG bytes.
30    pub fn bytes(data: Vec<u8>) -> Self {
31        Self { inner: ImageWidgetImpl::new().bytes(data).fit(ImageFit::Contain) }
32    }
33
34    /// Show a colored placeholder rectangle (no image data).
35    pub fn placeholder(color: Color) -> Self {
36        Self { inner: ImageWidgetImpl::new().placeholder_color(color) }
37    }
38
39    pub fn fit(mut self, f: ImageFit) -> Self { self.inner = self.inner.fit(f); self }
40    pub fn width(mut self, w: f32) -> Self    { self.inner = self.inner.width(w); self }
41    pub fn height(mut self, h: f32) -> Self   { self.inner = self.inner.height(h); self }
42    /// Accessible/SEO alt text (D107/Phase 25).
43    pub fn alt(mut self, alt: impl Into<String>) -> Self { self.inner = self.inner.alt(alt); self }
44}
45
46impl Widget for Image {
47    fn layout(&self, _ctx: &LayoutCtx) -> Size {
48        Size { width: self.inner.width, height: self.inner.height }
49    }
50
51    fn paint(&self, ctx: &mut PaintCtx) {
52        // No entry at all for a decorative image (no `.alt(...)` set) —
53        // matches HTML's own convention (see the `alt` field doc).
54        if let Some(alt) = &self.inner.alt {
55            ctx.semantics(super::Semantics::new(rosace_core::Role::Image).label(alt));
56        }
57        let x = ctx.rect.origin.x;
58        let y = ctx.rect.origin.y;
59        let w = self.inner.width;
60        let h = self.inner.height;
61        let dest_rect = Rect { origin: Point { x, y }, size: Size { width: w, height: h } };
62
63        // Decode ONCE via the global cache (Phase 27 — this paint
64        // previously did `fs::read` + PNG decode on EVERY frame, the
65        // "orphaned ImageCache" known issue). The cached `Arc` is recorded
66        // directly: zero pixel copies per frame, and its stable content
67        // gives the compositor a stable GPU-texture key.
68        let decoded = {
69            let mut cache = crate::ImageCache::global().lock().unwrap_or_else(|e| e.into_inner());
70            match &self.inner.source {
71                ImageSource::Placeholder => None,
72                ImageSource::File(path) => cache.get_or_load(path),
73                ImageSource::Bytes(b) => cache.get_or_decode_bytes(b),
74            }
75        };
76
77        // Real image data was requested (file/bytes/asset) but decode came
78        // back empty (missing file, corrupt data) — a BROKEN state,
79        // distinct from an intentional `Image::placeholder()`. Both used to
80        // render identically (found live: no way to tell "no image was
81        // ever asked for" from "the image failed to load").
82        let broken = decoded.is_none() && !matches!(self.inner.source, ImageSource::Placeholder);
83
84        if let Some(img) = decoded {
85            // No default load-in fade (D111 corrects D108/Phase 26 Step
86            // 4): this widget has no stable per-image identity inside a
87            // virtualized list (`ListView` allocates render-tree nodes
88            // positionally by viewport slot, not by data index — see
89            // D111), so an `animate_to`-driven fade here would bind its
90            // animated opacity to whichever image currently occupies a
91            // given on-screen slot, not to the image itself. Full
92            // opacity, always, is the only default that's correct in
93            // every context. `opacity` stays a real per-call parameter
94            // on `DrawCommand::BlitRgba` for callers with real identity
95            // (e.g. Hero transitions) to use deliberately.
96            ctx.record(DrawCommand::BlitRgba {
97                pixels: img.pixels,
98                src_width: img.width,
99                src_height: img.height,
100                dest_rect,
101                opacity: 1.0,
102            });
103            return;
104        }
105
106        if broken {
107            // Broken-image fallback: a red-tinted box with an X icon,
108            // visually distinct from the neutral "intentional placeholder"
109            // box below so a failed load doesn't masquerade as a
110            // deliberate one.
111            ctx.fill_rect(dest_rect, Color::rgb(60, 30, 30));
112            let icon_size = (w.min(h) * 0.4).clamp(16.0, 32.0);
113            let icon_rect = Rect {
114                origin: Point { x: x + (w - icon_size) / 2.0, y: y + (h - icon_size) / 2.0 },
115                size: Size { width: icon_size, height: icon_size },
116            };
117            super::Icon::new(super::IconKind::Close)
118                .size(icon_size)
119                .color(Color::rgb(200, 90, 90))
120                .paint(&mut ctx.child(icon_rect));
121            return;
122        }
123
124        // Placeholder: colored box + icon.
125        ctx.fill_rect(dest_rect, self.inner.placeholder_color);
126        ctx.fill_circle(Point { x: x + w / 2.0, y: y + h / 2.0 - 15.0 }, 12.0, Color::rgb(100, 110, 140));
127        ctx.fill_rect(Rect {
128            origin: Point { x: x + w / 2.0 - 20.0, y: y + h / 2.0 + 5.0 },
129            size: Size { width: 40.0, height: 20.0 },
130        }, Color::rgb(80, 90, 120));
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use rosace_render::FontCache;
138    use rosace_theme::built_in;
139
140    fn make_ctx(_c: rosace_layout::Constraints) -> (FontCache, rosace_theme::ThemeData) {
141        let font = FontCache::system_ui()
142            .or_else(FontCache::system_mono)
143            .expect("no system font");
144        (font, built_in::dark_theme())
145    }
146
147    #[test]
148    fn image_placeholder_layout() {
149        let img = Image::placeholder(Color::rgb(128, 128, 128)).width(320.0).height(200.0);
150        let c = rosace_layout::Constraints::loose(800.0, 600.0);
151        let (font, theme) = make_ctx(c);
152        let ctx = LayoutCtx::new(c, &font, &theme);
153        let size = img.layout(&ctx);
154        assert_eq!(size.width, 320.0);
155        assert_eq!(size.height, 200.0);
156    }
157
158    #[test]
159    fn image_file_layout() {
160        let img = Image::file("assets/photo.png").width(100.0).height(80.0);
161        let c = rosace_layout::Constraints::loose(800.0, 600.0);
162        let (font, theme) = make_ctx(c);
163        let ctx = LayoutCtx::new(c, &font, &theme);
164        let size = img.layout(&ctx);
165        assert_eq!(size.width, 100.0);
166        assert_eq!(size.height, 80.0);
167    }
168}