Images!
This commit is contained in:
@@ -5,6 +5,7 @@ edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
cosmic-text = "0.18.2"
|
||||
image = { version = "0.25", default-features = false, features = ["rayon", "avif", "bmp", "dds", "exr", "gif", "hdr", "ico", "jpeg", "png", "pnm", "qoi", "tga", "tiff", "webp"] }
|
||||
ruin_reactivity = { path = "../reactivity" }
|
||||
ruin_runtime = { package = "ruin-runtime", path = "../runtime" }
|
||||
tracing = { version = "0.1", default-features = false, features = ["std"] }
|
||||
|
||||
161
lib/ui/src/image.rs
Normal file
161
lib/ui/src/image.rs
Normal 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 {}
|
||||
@@ -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(),
|
||||
}],
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,11 +294,11 @@ impl WaylandWindow {
|
||||
let seat: wl_seat::WlSeat = globals.bind(&qh, 1..=9, ())?;
|
||||
let wm_base: xdg_wm_base::XdgWmBase = globals.bind(&qh, 1..=6, ())?;
|
||||
let clipboard_manager = globals.bind(&qh, 1..=3, ()).ok();
|
||||
let clipboard_device = clipboard_manager
|
||||
.as_ref()
|
||||
.map(|manager: &wl_data_device_manager::WlDataDeviceManager| {
|
||||
let clipboard_device = clipboard_manager.as_ref().map(
|
||||
|manager: &wl_data_device_manager::WlDataDeviceManager| {
|
||||
manager.get_data_device(&seat, &qh, ())
|
||||
});
|
||||
},
|
||||
);
|
||||
let cursor_shape_manager = globals.bind(&qh, 1..=2, ()).ok();
|
||||
let primary_selection_manager = globals.bind(&qh, 1..=1, ()).ok();
|
||||
let primary_selection_device = primary_selection_manager.as_ref().map(
|
||||
@@ -619,7 +619,8 @@ impl WaylandWindow {
|
||||
}
|
||||
|
||||
pub fn read_primary_selection_text(&mut self) -> Result<Option<String>, Box<dyn Error>> {
|
||||
let preferred_mime = preferred_plain_text_mime(&self.state.primary_selection_offer_mime_types);
|
||||
let preferred_mime =
|
||||
preferred_plain_text_mime(&self.state.primary_selection_offer_mime_types);
|
||||
let Some(mime_type) = preferred_mime else {
|
||||
return Ok(self.state.primary_selection_text.clone());
|
||||
};
|
||||
@@ -1097,10 +1098,11 @@ fn spawn_window_worker(
|
||||
let mut state_ref = state.borrow_mut();
|
||||
match state_ref.window.read_clipboard_text() {
|
||||
Ok(Some(text)) => {
|
||||
let _ = state_ref.event_tx.send(PlatformEvent::ClipboardText {
|
||||
window_id: state_ref.window_id,
|
||||
text,
|
||||
});
|
||||
let _ =
|
||||
state_ref.event_tx.send(PlatformEvent::ClipboardText {
|
||||
window_id: state_ref.window_id,
|
||||
text,
|
||||
});
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
|
||||
@@ -6,7 +6,9 @@ use cosmic_text::{
|
||||
Attrs, Buffer, CacheKey, FontSystem, Metrics, Shaping, SwashCache, SwashContent, SwashImage,
|
||||
};
|
||||
use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
|
||||
use ruin_ui::{Color, DisplayItem, Point, PreparedText, Rect, SceneSnapshot, UiSize};
|
||||
use ruin_ui::{
|
||||
Color, DisplayItem, Point, PreparedImage, PreparedText, Rect, SceneSnapshot, UiSize,
|
||||
};
|
||||
use tracing::trace;
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
@@ -70,6 +72,11 @@ struct CachedTextTexture {
|
||||
size: UiSize,
|
||||
}
|
||||
|
||||
struct CachedImageTexture {
|
||||
_texture: wgpu::Texture,
|
||||
bind_group: wgpu::BindGroup,
|
||||
}
|
||||
|
||||
struct GlyphAtlas {
|
||||
texture: wgpu::Texture,
|
||||
bind_group: wgpu::BindGroup,
|
||||
@@ -106,6 +113,12 @@ struct UploadedText {
|
||||
vertex_count: u32,
|
||||
}
|
||||
|
||||
struct UploadedImage {
|
||||
bind_group: wgpu::BindGroup,
|
||||
vertex_buffer: wgpu::Buffer,
|
||||
vertex_count: u32,
|
||||
}
|
||||
|
||||
struct UploadedAtlasText {
|
||||
vertex_buffer: wgpu::Buffer,
|
||||
vertex_count: u32,
|
||||
@@ -181,10 +194,13 @@ pub struct WgpuSceneRenderer {
|
||||
swash_cache: SwashCache,
|
||||
text_cache: HashMap<TextTextureKey, CachedTextTexture>,
|
||||
text_cache_order: VecDeque<TextTextureKey>,
|
||||
image_cache: HashMap<u64, CachedImageTexture>,
|
||||
image_cache_order: VecDeque<u64>,
|
||||
glyph_atlas: GlyphAtlas,
|
||||
}
|
||||
|
||||
const MAX_TEXT_CACHE_ENTRIES: usize = 64;
|
||||
const MAX_IMAGE_CACHE_ENTRIES: usize = 64;
|
||||
const GLYPH_ATLAS_SIZE: u32 = 2048;
|
||||
const GLYPH_ATLAS_PADDING: u32 = 1;
|
||||
|
||||
@@ -397,6 +413,8 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
|
||||
swash_cache: SwashCache::new(),
|
||||
text_cache: HashMap::new(),
|
||||
text_cache_order: VecDeque::new(),
|
||||
image_cache: HashMap::new(),
|
||||
image_cache_order: VecDeque::new(),
|
||||
glyph_atlas,
|
||||
})
|
||||
}
|
||||
@@ -434,6 +452,14 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
let text_prepare_start = std::time::Instant::now();
|
||||
let uploaded_images: Vec<_> = scene
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
DisplayItem::Image(image) => self.prepare_uploaded_image(image, scene.logical_size),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let uploaded_atlas_text = self.prepare_uploaded_atlas_text(scene);
|
||||
let uploaded_texts: Vec<_> = scene
|
||||
.items
|
||||
@@ -480,6 +506,14 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
|
||||
pass.set_vertex_buffer(0, vertex_buffer.slice(..));
|
||||
pass.draw(0..vertices.len() as u32, 0..1);
|
||||
}
|
||||
if !uploaded_images.is_empty() {
|
||||
pass.set_pipeline(&self.text_pipeline);
|
||||
for image in &uploaded_images {
|
||||
pass.set_bind_group(0, &image.bind_group, &[]);
|
||||
pass.set_vertex_buffer(0, image.vertex_buffer.slice(..));
|
||||
pass.draw(0..image.vertex_count, 0..1);
|
||||
}
|
||||
}
|
||||
if let Some(atlas_text) = uploaded_atlas_text.as_ref() {
|
||||
pass.set_pipeline(&self.text_pipeline);
|
||||
pass.set_bind_group(0, &self.glyph_atlas.bind_group, &[]);
|
||||
@@ -501,6 +535,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
|
||||
target: "ruin_ui_renderer_wgpu::perf",
|
||||
scene_version = scene.version,
|
||||
quad_vertices = vertices.len(),
|
||||
image_batches = uploaded_images.len(),
|
||||
atlas_text_vertices = uploaded_atlas_text
|
||||
.as_ref()
|
||||
.map_or(0_u32, |text| text.vertex_count),
|
||||
@@ -825,6 +860,36 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
|
||||
Some(rect)
|
||||
}
|
||||
|
||||
fn prepare_uploaded_image(
|
||||
&mut self,
|
||||
image: &PreparedImage,
|
||||
logical_size: UiSize,
|
||||
) -> Option<UploadedImage> {
|
||||
let key = image.resource.id();
|
||||
if !self.image_cache.contains_key(&key) {
|
||||
let cached = self.create_cached_image_texture(image);
|
||||
self.image_cache.insert(key, cached);
|
||||
}
|
||||
self.touch_image_cache_entry(key);
|
||||
let cached = self
|
||||
.image_cache
|
||||
.get(&key)
|
||||
.expect("image cache entry should exist after insertion");
|
||||
let vertices = build_image_vertices(image.rect, image.uv_rect, logical_size);
|
||||
let vertex_buffer = self
|
||||
.device
|
||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("ruin-ui-renderer-wgpu-image-vertices"),
|
||||
contents: bytemuck::cast_slice(&vertices),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
Some(UploadedImage {
|
||||
bind_group: cached.bind_group.clone(),
|
||||
vertex_buffer,
|
||||
vertex_count: vertices.len() as u32,
|
||||
})
|
||||
}
|
||||
|
||||
fn prepare_uploaded_text(
|
||||
&mut self,
|
||||
text: &PreparedText,
|
||||
@@ -879,6 +944,23 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
|
||||
}
|
||||
}
|
||||
|
||||
fn touch_image_cache_entry(&mut self, key: u64) {
|
||||
if let Some(position) = self
|
||||
.image_cache_order
|
||||
.iter()
|
||||
.position(|existing| *existing == key)
|
||||
{
|
||||
self.image_cache_order.remove(position);
|
||||
}
|
||||
self.image_cache_order.push_back(key);
|
||||
while self.image_cache_order.len() > MAX_IMAGE_CACHE_ENTRIES {
|
||||
let Some(evicted) = self.image_cache_order.pop_front() else {
|
||||
break;
|
||||
};
|
||||
self.image_cache.remove(&evicted);
|
||||
}
|
||||
}
|
||||
|
||||
fn create_cached_text_texture(&self, text: &RasterizedText) -> CachedTextTexture {
|
||||
let texture = self.device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("ruin-ui-renderer-wgpu-texture"),
|
||||
@@ -935,6 +1017,61 @@ fn fs_main(input: VertexOut) -> @location(0) vec4<f32> {
|
||||
size: text.size,
|
||||
}
|
||||
}
|
||||
|
||||
fn create_cached_image_texture(&self, image: &PreparedImage) -> CachedImageTexture {
|
||||
let texture = self.device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("ruin-ui-renderer-wgpu-image-texture"),
|
||||
size: wgpu::Extent3d {
|
||||
width: image.resource.width(),
|
||||
height: image.resource.height(),
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8UnormSrgb,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
self.queue.write_texture(
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture: &texture,
|
||||
mip_level: 0,
|
||||
origin: wgpu::Origin3d::ZERO,
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
image.resource.pixels(),
|
||||
wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(image.resource.width() * 4),
|
||||
rows_per_image: Some(image.resource.height()),
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width: image.resource.width(),
|
||||
height: image.resource.height(),
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("ruin-ui-renderer-wgpu-image-bind-group"),
|
||||
layout: &self.text_bind_group_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(&texture_view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::Sampler(&self.text_sampler),
|
||||
},
|
||||
],
|
||||
});
|
||||
CachedImageTexture {
|
||||
_texture: texture,
|
||||
bind_group,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn create_glyph_atlas(
|
||||
@@ -1234,6 +1371,55 @@ fn build_text_vertices(origin: Point, size: UiSize, logical_size: UiSize) -> [Te
|
||||
]
|
||||
}
|
||||
|
||||
fn build_image_vertices(
|
||||
rect: Rect,
|
||||
uv_rect: (f32, f32, f32, f32),
|
||||
logical_size: UiSize,
|
||||
) -> [TextVertex; 6] {
|
||||
let left = to_ndc_x(rect.origin.x, logical_size.width.max(1.0));
|
||||
let right = to_ndc_x(rect.origin.x + rect.size.width, logical_size.width.max(1.0));
|
||||
let top = to_ndc_y(rect.origin.y, logical_size.height.max(1.0));
|
||||
let bottom = to_ndc_y(
|
||||
rect.origin.y + rect.size.height,
|
||||
logical_size.height.max(1.0),
|
||||
);
|
||||
let (u0, v0, u1, v1) = uv_rect;
|
||||
let color = [1.0, 1.0, 1.0, 1.0];
|
||||
|
||||
[
|
||||
TextVertex {
|
||||
position: [left, top],
|
||||
uv: [u0, v0],
|
||||
color,
|
||||
},
|
||||
TextVertex {
|
||||
position: [left, bottom],
|
||||
uv: [u0, v1],
|
||||
color,
|
||||
},
|
||||
TextVertex {
|
||||
position: [right, top],
|
||||
uv: [u1, v0],
|
||||
color,
|
||||
},
|
||||
TextVertex {
|
||||
position: [right, top],
|
||||
uv: [u1, v0],
|
||||
color,
|
||||
},
|
||||
TextVertex {
|
||||
position: [left, bottom],
|
||||
uv: [u0, v1],
|
||||
color,
|
||||
},
|
||||
TextVertex {
|
||||
position: [right, bottom],
|
||||
uv: [u1, v1],
|
||||
color,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn push_glyph_vertices(
|
||||
vertices: &mut Vec<TextVertex>,
|
||||
glyph_rect: PixelRect,
|
||||
|
||||
Reference in New Issue
Block a user