Skip to content

Commit e25483a

Browse files
author
wangnaihe
committed
feat: implement Image component with PNG/JPEG decoding (#2)
Replace placeholder rendering with actual image decoding and display. Images are loaded from local files or HTTP URLs, decoded via the `image` crate, cached per-thread, and rendered through Vello (GPU) or pixel-blitting (CPU). Includes an example app demonstrating the feature. Closes #2 Made-with: Cursor
1 parent c5ed0e9 commit e25483a

File tree

4 files changed

+355
-85
lines changed

4 files changed

+355
-85
lines changed
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
use std::cell::RefCell;
2+
use std::collections::HashMap;
3+
use std::io::Read as _;
4+
use std::sync::Arc;
5+
6+
#[derive(Clone)]
7+
pub struct DecodedImage {
8+
pub width: u32,
9+
pub height: u32,
10+
pub data: Arc<Vec<u8>>,
11+
}
12+
13+
thread_local! {
14+
static CACHE: RefCell<HashMap<String, Option<DecodedImage>>> = RefCell::new(HashMap::new());
15+
}
16+
17+
pub fn get_or_load(src: &str) -> Option<DecodedImage> {
18+
CACHE.with(|cache| {
19+
let mut cache = cache.borrow_mut();
20+
if let Some(entry) = cache.get(src) {
21+
return entry.clone();
22+
}
23+
let result = load_from_source(src);
24+
cache.insert(src.to_string(), result.clone());
25+
result
26+
})
27+
}
28+
29+
fn load_from_source(src: &str) -> Option<DecodedImage> {
30+
let bytes = if src.starts_with("http://") || src.starts_with("https://") {
31+
let resp = match ureq::get(src).call() {
32+
Ok(r) => r,
33+
Err(e) => {
34+
eprintln!("[W3C OS] Failed to fetch image {src}: {e}");
35+
return None;
36+
}
37+
};
38+
let mut buf = Vec::new();
39+
if resp.into_body().as_reader().read_to_end(&mut buf).is_err() {
40+
eprintln!("[W3C OS] Failed to read image response body for {src}");
41+
return None;
42+
}
43+
buf
44+
} else {
45+
match std::fs::read(src) {
46+
Ok(b) => b,
47+
Err(e) => {
48+
eprintln!("[W3C OS] Failed to read image file {src}: {e}");
49+
return None;
50+
}
51+
}
52+
};
53+
54+
match image::load_from_memory(&bytes) {
55+
Ok(img) => {
56+
let rgba = img.to_rgba8();
57+
let (w, h) = (rgba.width(), rgba.height());
58+
Some(DecodedImage {
59+
width: w,
60+
height: h,
61+
data: Arc::new(rgba.into_raw()),
62+
})
63+
}
64+
Err(e) => {
65+
eprintln!("[W3C OS] Failed to decode image {src}: {e}");
66+
None
67+
}
68+
}
69+
}

crates/w3cos-runtime/src/render_cpu.rs

Lines changed: 97 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -167,37 +167,48 @@ fn render_node(
167167
);
168168
}
169169
ComponentKind::Image { src } => {
170-
// Placeholder: draw border and label until PNG/JPEG decoding is implemented
171-
let placeholder_bg = if bg.a == 0 {
172-
apply_opacity(Color::rgb(40, 40, 50), opacity)
173-
} else {
174-
bg
175-
};
176-
draw_rect(pixmap, rect, placeholder_bg, style.border_radius, clip_mask);
177-
let border_color = if style.border_width > 0.0 && style.border_color.a > 0 {
178-
apply_opacity(style.border_color, opacity)
170+
if let Some(decoded) = crate::image_loader::get_or_load(src) {
171+
draw_image_pixels(
172+
pixmap,
173+
rect,
174+
decoded.width,
175+
decoded.height,
176+
&decoded.data,
177+
opacity,
178+
clip_mask,
179+
);
179180
} else {
180-
apply_opacity(Color::rgb(100, 100, 120), opacity)
181-
};
182-
draw_border(
183-
pixmap,
184-
rect,
185-
border_color,
186-
style.border_width.max(1.0),
187-
style.border_radius,
188-
clip_mask,
189-
);
190-
let label = format!("[Image: {}]", src);
191-
draw_text(
192-
pixmap,
193-
rect.x + 8.0,
194-
rect.y + 8.0,
195-
&label,
196-
style.font_size,
197-
text_color,
198-
font,
199-
clip_mask,
200-
);
181+
let placeholder_bg = if bg.a == 0 {
182+
apply_opacity(Color::rgb(40, 40, 50), opacity)
183+
} else {
184+
bg
185+
};
186+
draw_rect(pixmap, rect, placeholder_bg, style.border_radius, clip_mask);
187+
let border_color = if style.border_width > 0.0 && style.border_color.a > 0 {
188+
apply_opacity(style.border_color, opacity)
189+
} else {
190+
apply_opacity(Color::rgb(100, 100, 120), opacity)
191+
};
192+
draw_border(
193+
pixmap,
194+
rect,
195+
border_color,
196+
style.border_width.max(1.0),
197+
style.border_radius,
198+
clip_mask,
199+
);
200+
let label = format!("[Image: {}]", src);
201+
draw_text(
202+
pixmap,
203+
rect.x + 8.0,
204+
rect.y + 8.0,
205+
&label,
206+
style.font_size,
207+
text_color,
208+
font,
209+
clip_mask,
210+
);
211+
}
201212
}
202213
ComponentKind::TextInput { value, placeholder } => {
203214
let display_value = text_input_value.unwrap_or(value.as_str());
@@ -487,6 +498,62 @@ fn draw_blinking_cursor(
487498
}
488499
}
489500

501+
#[allow(clippy::too_many_arguments)]
502+
fn draw_image_pixels(
503+
pixmap: &mut Pixmap,
504+
rect: LayoutRect,
505+
img_w: u32,
506+
img_h: u32,
507+
rgba: &[u8],
508+
opacity: f32,
509+
clip_mask: Option<&Mask>,
510+
) {
511+
let dest_w = rect.width.ceil() as u32;
512+
let dest_h = rect.height.ceil() as u32;
513+
if dest_w == 0 || dest_h == 0 || img_w == 0 || img_h == 0 {
514+
return;
515+
}
516+
let px_w = pixmap.width() as i32;
517+
let px_h = pixmap.height() as i32;
518+
let pixels = pixmap.pixels_mut();
519+
520+
for dy in 0..dest_h {
521+
for dx in 0..dest_w {
522+
let px = rect.x as i32 + dx as i32;
523+
let py = rect.y as i32 + dy as i32;
524+
if px < 0 || py < 0 || px >= px_w || py >= px_h {
525+
continue;
526+
}
527+
if let Some(mask) = clip_mask {
528+
if px >= mask.width() as i32 || py >= mask.height() as i32 {
529+
continue;
530+
}
531+
let mask_idx = (py * mask.width() as i32 + px) as usize;
532+
if mask.data().get(mask_idx).copied().unwrap_or(0) == 0 {
533+
continue;
534+
}
535+
}
536+
let src_x = ((dx as f32 / dest_w as f32) * img_w as f32) as u32;
537+
let src_y = ((dy as f32 / dest_h as f32) * img_h as f32) as u32;
538+
let src_x = src_x.min(img_w - 1);
539+
let src_y = src_y.min(img_h - 1);
540+
let src_idx = ((src_y * img_w + src_x) * 4) as usize;
541+
542+
let r = rgba[src_idx];
543+
let g = rgba[src_idx + 1];
544+
let b = rgba[src_idx + 2];
545+
let a = (rgba[src_idx + 3] as f32 * opacity) as u8;
546+
if a == 0 {
547+
continue;
548+
}
549+
550+
let dst_idx = (py * px_w + px) as usize;
551+
let dst = pixels[dst_idx];
552+
pixels[dst_idx] = blend_pixel(dst, r, g, b, a);
553+
}
554+
}
555+
}
556+
490557
fn blend_pixel(
491558
dst: tiny_skia::PremultipliedColorU8,
492559
sr: u8,

0 commit comments

Comments
 (0)