This commit is contained in:
2026-03-21 02:25:17 -04:00
parent 6954c8c74d
commit c70f42704c
11 changed files with 1430 additions and 98 deletions

161
lib/ui/src/image.rs Normal file
View File

@@ -0,0 +1,161 @@
use std::fmt;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use image::ImageReader;
use ruin_runtime::channel::oneshot;
use crate::UiSize;
static NEXT_IMAGE_RESOURCE_ID: AtomicU64 = AtomicU64::new(1);
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum ImageFit {
Contain,
Cover,
Fill,
}
#[derive(Clone)]
pub struct ImageResource {
inner: Arc<ImageResourceInner>,
}
struct ImageResourceInner {
id: u64,
width: u32,
height: u32,
pixels: Arc<[u8]>,
}
#[derive(Debug)]
pub enum ImageResourceError {
Io(std::io::Error),
Decode(image::ImageError),
DecodeTaskClosed,
InvalidRgbaPixels { width: u32, height: u32, len: usize },
}
impl ImageResource {
pub async fn load_path(path: impl AsRef<Path>) -> Result<Self, ImageResourceError> {
let bytes = ruin_runtime::fs::read(path.as_ref())
.await
.map_err(ImageResourceError::Io)?;
Self::decode_encoded_bytes(bytes).await
}
pub async fn decode_encoded_bytes(
bytes: impl Into<Vec<u8>>,
) -> Result<Self, ImageResourceError> {
let bytes = bytes.into();
let (tx, mut rx) = oneshot::channel();
std::thread::Builder::new()
.name("ruin-ui-image-decode".to_owned())
.spawn(move || {
let result = Self::decode_encoded_bytes_sync(&bytes);
let _ = tx.send(result);
})
.map_err(ImageResourceError::Io)?;
rx.recv()
.await
.map_err(|_| ImageResourceError::DecodeTaskClosed)?
}
pub fn from_rgba8(
width: u32,
height: u32,
pixels: impl Into<Vec<u8>>,
) -> Result<Self, ImageResourceError> {
let pixels = pixels.into();
let expected_len = width as usize * height as usize * 4;
if pixels.len() != expected_len {
return Err(ImageResourceError::InvalidRgbaPixels {
width,
height,
len: pixels.len(),
});
}
Ok(Self {
inner: Arc::new(ImageResourceInner {
id: NEXT_IMAGE_RESOURCE_ID.fetch_add(1, Ordering::Relaxed),
width,
height,
pixels: pixels.into(),
}),
})
}
fn decode_encoded_bytes_sync(bytes: &[u8]) -> Result<Self, ImageResourceError> {
let image = ImageReader::new(std::io::Cursor::new(bytes))
.with_guessed_format()
.map_err(ImageResourceError::Io)?
.decode()
.map_err(ImageResourceError::Decode)?;
let rgba = image.to_rgba8();
Self::from_rgba8(rgba.width(), rgba.height(), rgba.into_raw())
}
pub fn id(&self) -> u64 {
self.inner.id
}
pub fn width(&self) -> u32 {
self.inner.width
}
pub fn height(&self) -> u32 {
self.inner.height
}
pub fn size(&self) -> UiSize {
UiSize::new(self.inner.width as f32, self.inner.height as f32)
}
pub fn pixels(&self) -> &[u8] {
&self.inner.pixels
}
}
impl fmt::Debug for ImageResource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ImageResource")
.field("id", &self.inner.id)
.field("width", &self.inner.width)
.field("height", &self.inner.height)
.finish()
}
}
impl PartialEq for ImageResource {
fn eq(&self, other: &Self) -> bool {
self.inner.id == other.inner.id
}
}
impl Eq for ImageResource {}
impl std::hash::Hash for ImageResource {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.inner.id.hash(state);
}
}
impl fmt::Display for ImageResourceError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io(error) => write!(f, "{error}"),
Self::Decode(error) => write!(f, "{error}"),
Self::DecodeTaskClosed => {
f.write_str("image decode task closed before producing a result")
}
Self::InvalidRgbaPixels { width, height, len } => write!(
f,
"expected {} RGBA bytes for {width}x{height} image, got {len}",
*width as usize * *height as usize * 4
),
}
}
}
impl std::error::Error for ImageResourceError {}

View File

@@ -167,6 +167,7 @@ mod tests {
pointer_events: false,
focusable: false,
cursor: CursorIcon::Default,
prepared_image: None,
prepared_text: None,
children: vec![
LayoutNode {
@@ -176,6 +177,7 @@ mod tests {
pointer_events: true,
focusable: false,
cursor: CursorIcon::Default,
prepared_image: None,
prepared_text: None,
children: Vec::new(),
},
@@ -186,6 +188,7 @@ mod tests {
pointer_events: true,
focusable: false,
cursor: CursorIcon::Default,
prepared_image: None,
prepared_text: None,
children: Vec::new(),
},
@@ -203,6 +206,7 @@ mod tests {
pointer_events: false,
focusable: false,
cursor: CursorIcon::Default,
prepared_image: None,
prepared_text: None,
children: vec![LayoutNode {
path: LayoutPath::root().child(0),
@@ -211,6 +215,7 @@ mod tests {
pointer_events: true,
focusable: false,
cursor: CursorIcon::Default,
prepared_image: None,
prepared_text: None,
children: vec![LayoutNode {
path: LayoutPath::root().child(0).child(0),
@@ -219,6 +224,7 @@ mod tests {
pointer_events: true,
focusable: false,
cursor: CursorIcon::Default,
prepared_image: None,
prepared_text: None,
children: Vec::new(),
}],

View File

@@ -1,8 +1,9 @@
use std::time::Instant;
use crate::scene::{PreparedText, Rect, SceneSnapshot, UiSize};
use crate::ImageFit;
use crate::scene::{PreparedImage, PreparedText, Rect, SceneSnapshot, UiSize};
use crate::text::TextSystem;
use crate::tree::{CursorIcon, Edges, Element, ElementId, FlexDirection};
use crate::tree::{CursorIcon, Edges, Element, ElementId, FlexDirection, ImageNode};
pub fn layout_scene(version: u64, logical_size: UiSize, root: &Element) -> SceneSnapshot {
let mut text_system = TextSystem::new();
@@ -60,6 +61,7 @@ pub struct LayoutNode {
pub pointer_events: bool,
pub focusable: bool,
pub cursor: CursorIcon,
pub prepared_image: Option<PreparedImage>,
pub prepared_text: Option<PreparedText>,
pub children: Vec<LayoutNode>,
}
@@ -188,6 +190,7 @@ fn layout_element(
pointer_events: element.style.pointer_events,
focusable: element.style.focusable,
cursor,
prepared_image: None,
prepared_text: None,
children: Vec::new(),
};
@@ -223,6 +226,16 @@ fn layout_element(
return interaction;
}
if let Some(image) = element.image_node() {
let content = inset_rect(rect, element.style.padding);
if content.size.width > 0.0 && content.size.height > 0.0 {
let prepared = prepare_image(image, content, element.id);
scene.push_image(prepared.clone());
interaction.prepared_image = Some(prepared);
}
return interaction;
}
perf_stats.container_nodes += 1;
if element.children.is_empty() {
@@ -427,6 +440,11 @@ fn intrinsic_main_size(
return main_axis_size(content, direction) + padding;
}
if let Some(image) = child.image_node() {
let resolved = resolve_image_element_size(child, image.resource.size());
return main_axis_size(resolved, direction);
}
let available_size = match direction {
FlexDirection::Row => UiSize::new(available_main.max(0.0), cross_size.max(0.0)),
FlexDirection::Column => UiSize::new(cross_size.max(0.0), available_main.max(0.0)),
@@ -461,6 +479,14 @@ fn intrinsic_size(
);
}
if let Some(image) = element.image_node() {
let resolved = resolve_image_element_size(element, image.resource.size());
return UiSize::new(
resolved.width + element.style.padding.left + element.style.padding.right,
resolved.height + element.style.padding.top + element.style.padding.bottom,
);
}
let explicit_width = element.style.width;
let explicit_height = element.style.height;
let content_width = explicit_width.unwrap_or(available_size.width).max(0.0)
@@ -484,9 +510,7 @@ fn intrinsic_size(
let mut width: f32 = 0.0;
let mut height = gap_total;
for child in &element.children {
if child.style.flex_grow > 0.0 && child.style.height.is_none() {
continue;
}
let skip_main = child.style.flex_grow > 0.0 && child.style.height.is_none();
let child_size = intrinsic_size(
child,
UiSize::new(
@@ -497,15 +521,23 @@ fn intrinsic_size(
perf_stats,
);
width = width.max(child.style.width.unwrap_or(child_size.width));
height += child.style.height.unwrap_or(child_size.height);
if !skip_main {
height += child.style.height.unwrap_or(child_size.height);
}
}
(width, height)
}
FlexDirection::Row => {
let mut width = gap_total;
let mut height: f32 = 0.0;
let mut fixed_main = 0.0;
let mut flex_total = 0.0;
let mut child_main_sizes = Vec::with_capacity(element.children.len());
for child in &element.children {
if child.style.flex_grow > 0.0 && child.style.width.is_none() {
let is_flex = child.style.flex_grow > 0.0 && child.style.width.is_none();
if is_flex {
flex_total += child_flex_weight(child);
child_main_sizes.push(None);
continue;
}
let child_size = intrinsic_size(
@@ -517,7 +549,32 @@ fn intrinsic_size(
text_system,
perf_stats,
);
width += child.style.width.unwrap_or(child_size.width);
let child_main = child.style.width.unwrap_or(child_size.width);
fixed_main += child_main;
child_main_sizes.push(Some(child_main));
}
let remaining_main = (content_size.width - gap_total - fixed_main).max(0.0);
for (child, measured_main) in element.children.iter().zip(child_main_sizes.iter()) {
let skip_main = child.style.flex_grow > 0.0 && child.style.width.is_none();
let child_main = measured_main.unwrap_or_else(|| {
if flex_total <= 0.0 {
0.0
} else {
remaining_main * (child_flex_weight(child) / flex_total)
}
});
let child_size = intrinsic_size(
child,
UiSize::new(
child_main,
child.style.height.unwrap_or(content_size.height),
),
text_system,
perf_stats,
);
if !skip_main {
width += child_main;
}
height = height.max(child.style.height.unwrap_or(child_size.height));
}
(width, height)
@@ -569,6 +626,67 @@ impl LayoutPerfStats {
}
}
fn prepare_image(image: &ImageNode, rect: Rect, element_id: Option<ElementId>) -> PreparedImage {
let source_size = image.resource.size();
let source_aspect = if source_size.height > 0.0 {
source_size.width / source_size.height
} else {
1.0
};
let rect_aspect = if rect.size.height > 0.0 {
rect.size.width / rect.size.height
} else {
source_aspect
};
let (draw_rect, uv_rect) = match image.fit {
ImageFit::Fill => (rect, (0.0, 0.0, 1.0, 1.0)),
ImageFit::Contain => {
let scale = (rect.size.width / source_size.width)
.min(rect.size.height / source_size.height)
.max(0.0);
let width = source_size.width * scale;
let height = source_size.height * scale;
let x = rect.origin.x + (rect.size.width - width) * 0.5;
let y = rect.origin.y + (rect.size.height - height) * 0.5;
(Rect::new(x, y, width, height), (0.0, 0.0, 1.0, 1.0))
}
ImageFit::Cover => {
if rect_aspect > source_aspect {
let visible_height = (source_aspect / rect_aspect).clamp(0.0, 1.0);
let inset = (1.0 - visible_height) * 0.5;
(rect, (0.0, inset, 1.0, 1.0 - inset))
} else {
let visible_width = (rect_aspect / source_aspect).clamp(0.0, 1.0);
let inset = (1.0 - visible_width) * 0.5;
(rect, (inset, 0.0, 1.0 - inset, 1.0))
}
}
};
PreparedImage {
element_id,
resource: image.resource.clone(),
rect: draw_rect,
uv_rect,
}
}
fn resolve_image_element_size(element: &Element, intrinsic: UiSize) -> UiSize {
match (element.style.width, element.style.height) {
(Some(width), Some(height)) => UiSize::new(width.max(0.0), height.max(0.0)),
(Some(width), None) if intrinsic.width > 0.0 => UiSize::new(
width.max(0.0),
(width * intrinsic.height / intrinsic.width).max(0.0),
),
(None, Some(height)) if intrinsic.height > 0.0 => UiSize::new(
(height * intrinsic.width / intrinsic.height).max(0.0),
height.max(0.0),
),
_ => intrinsic,
}
}
fn inset_rect(rect: Rect, edges: Edges) -> Rect {
let width = (rect.size.width - edges.left - edges.right).max(0.0);
let height = (rect.size.height - edges.top - edges.bottom).max(0.0);
@@ -773,6 +891,60 @@ mod tests {
);
}
#[test]
fn row_container_counts_flex_child_height_in_intrinsic_size() {
let row_color = Color::rgb(0x12, 0x24, 0x36);
let root = Element::column().child(
Element::row()
.padding(Edges::all(12.0))
.gap(16.0)
.background(row_color)
.children([
Element::new()
.width(120.0)
.height(60.0)
.background(Color::rgb(0x44, 0x55, 0x66)),
Element::column().flex(1.0).child(Element::paragraph(
"A flex child with wrapped text should make the row grow tall enough to contain it.",
TextStyle::new(18.0, Color::rgb(0xFF, 0xFF, 0xFF))
.with_line_height(24.0)
.with_wrap(TextWrap::Word),
)),
]),
);
let scene = layout_scene(1, UiSize::new(360.0, 320.0), &root);
let row_quad = scene
.items
.iter()
.filter_map(|item| match item {
DisplayItem::Quad(quad) if quad.color == row_color => Some(*quad),
_ => None,
})
.next()
.expect("row container should emit a background quad");
let text = scene
.items
.iter()
.find_map(|item| match item {
DisplayItem::Text(text) => Some(text),
_ => None,
})
.expect("row should emit a text display item");
let text_bounds = text.bounds.expect("text layout should provide bounds");
let row_bottom = row_quad.rect.origin.y + row_quad.rect.size.height;
let text_bottom = text.origin.y + text_bounds.height;
assert!(row_quad.rect.size.height > 84.0);
assert!(row_bottom >= text_bottom);
assert!(
scene
.items
.iter()
.any(|item| matches!(item, DisplayItem::Text(_)))
);
}
#[test]
fn interaction_tree_hit_test_returns_deepest_pointer_target() {
let root = Element::column()

View File

@@ -10,6 +10,7 @@ pub(crate) mod trace_targets {
pub const SCENE: &str = "ruin_ui::scene";
}
mod image;
mod interaction;
mod keyboard;
mod layout;
@@ -20,6 +21,7 @@ mod text;
mod tree;
mod window;
pub use image::{ImageFit, ImageResource, ImageResourceError};
pub use interaction::{
PointerButton, PointerEvent, PointerEventKind, PointerRouter, RoutedPointerEvent,
RoutedPointerEventKind,
@@ -36,8 +38,8 @@ pub use platform::{
};
pub use runtime::{EventStreamClosed, UiRuntime, WindowController};
pub use scene::{
Color, DisplayItem, GlyphInstance, Point, PreparedText, PreparedTextLine, Quad, Rect,
SceneSnapshot, Translation, UiSize,
Color, DisplayItem, GlyphInstance, Point, PreparedImage, PreparedText, PreparedTextLine, Quad,
Rect, SceneSnapshot, Translation, UiSize,
};
pub use text::{
TextAlign, TextFontFamily, TextSelectionStyle, TextSpan, TextSpanSlant, TextSpanWeight,

View File

@@ -5,6 +5,7 @@ use std::ops::Range;
use cosmic_text::CacheKey;
use tracing::debug;
use crate::ImageResource;
use crate::text::TextSelectionStyle;
use crate::trace_targets;
use crate::tree::ElementId;
@@ -133,6 +134,14 @@ pub struct PreparedText {
pub glyphs: Vec<GlyphInstance>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct PreparedImage {
pub element_id: Option<ElementId>,
pub resource: ImageResource,
pub rect: Rect,
pub uv_rect: (f32, f32, f32, f32),
}
impl PreparedText {
pub fn monospace(
text: impl Into<String>,
@@ -456,6 +465,7 @@ fn classify_word_char(ch: char) -> WordClass {
#[derive(Clone, Debug, PartialEq)]
pub enum DisplayItem {
Quad(Quad),
Image(PreparedImage),
Text(PreparedText),
PushClip(Rect),
PopClip,
@@ -506,6 +516,10 @@ impl SceneSnapshot {
self.push_item(DisplayItem::Text(text))
}
pub fn push_image(&mut self, image: PreparedImage) -> &mut Self {
self.push_item(DisplayItem::Image(image))
}
pub fn push_clip(&mut self, rect: Rect) -> &mut Self {
self.push_item(DisplayItem::PushClip(rect))
}

View File

@@ -1,5 +1,6 @@
use crate::scene::Color;
use crate::text::{TextSpan, TextStyle, TextWrap};
use crate::{ImageFit, ImageResource};
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct ElementId(u64);
@@ -96,6 +97,7 @@ impl Default for Style {
#[derive(Clone, Debug, PartialEq)]
enum ElementContent {
Container,
Image(ImageNode),
Text(TextNode),
}
@@ -105,6 +107,12 @@ pub(crate) struct TextNode {
pub style: TextStyle,
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct ImageNode {
pub resource: ImageResource,
pub fit: ImageFit,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Element {
pub id: Option<ElementId>,
@@ -143,6 +151,18 @@ impl Element {
Self::text(text, style.with_wrap(TextWrap::Word))
}
pub fn image(resource: ImageResource) -> Self {
Self {
id: None,
style: Style::default(),
children: Vec::new(),
content: ElementContent::Image(ImageNode {
resource,
fit: ImageFit::Contain,
}),
}
}
pub fn rich_paragraph(spans: impl IntoIterator<Item = TextSpan>, style: TextStyle) -> Self {
Self::spans(spans, style.with_wrap(TextWrap::Word))
}
@@ -210,6 +230,14 @@ impl Element {
self
}
pub fn image_fit(mut self, fit: ImageFit) -> Self {
let ElementContent::Image(image) = &mut self.content else {
panic!("only image elements can set image_fit");
};
image.fit = fit;
self
}
pub fn child(mut self, child: Element) -> Self {
self.assert_container();
self.children.push(child);
@@ -225,14 +253,21 @@ impl Element {
pub(crate) fn text_node(&self) -> Option<&TextNode> {
match &self.content {
ElementContent::Text(text) => Some(text),
ElementContent::Container => None,
ElementContent::Container | ElementContent::Image(_) => None,
}
}
pub(crate) fn image_node(&self) -> Option<&ImageNode> {
match &self.content {
ElementContent::Image(image) => Some(image),
ElementContent::Container | ElementContent::Text(_) => None,
}
}
fn assert_container(&self) {
assert!(
matches!(self.content, ElementContent::Container),
"text elements cannot contain children"
"non-container elements cannot contain children"
);
}
}