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