diff --git a/examples/text_paragraph_demo/src/main.rs b/examples/text_paragraph_demo/src/main.rs index 9c81252..8109fcc 100644 --- a/examples/text_paragraph_demo/src/main.rs +++ b/examples/text_paragraph_demo/src/main.rs @@ -4,12 +4,12 @@ use std::time::{Duration, Instant}; use ruin_runtime::{TimeoutHandle, clear_timeout, set_timeout}; use ruin_ui::{ - Color, CursorIcon, DisplayItem, Edges, Element, ElementId, ImageFit, ImageResource, - InteractionTree, KeyboardEvent, KeyboardEventKind, KeyboardKey, LayoutSnapshot, PlatformEvent, - PointerButton, PointerEvent, PointerEventKind, PointerRouter, PreparedText, Quad, - RoutedPointerEventKind, SceneSnapshot, TextAlign, TextFontFamily, TextSelectionStyle, TextSpan, - TextSpanSlant, TextSpanWeight, TextStyle, TextSystem, TextWrap, UiSize, WindowController, - WindowSpec, WindowUpdate, layout_snapshot_with_text_system, + BoxShadow, BoxShadowKind, Color, CursorIcon, DisplayItem, Edges, Element, ElementId, ImageFit, + ImageResource, InteractionTree, KeyboardEvent, KeyboardEventKind, KeyboardKey, LayoutSnapshot, + PlatformEvent, PointerButton, PointerEvent, PointerEventKind, PointerRouter, PreparedText, + Quad, RoutedPointerEventKind, SceneSnapshot, TextAlign, TextFontFamily, TextSelectionStyle, + TextSpan, TextSpanSlant, TextSpanWeight, TextStyle, TextSystem, TextWrap, UiSize, + WindowController, WindowSpec, WindowUpdate, layout_snapshot_with_text_system, }; use ruin_ui_platform_wayland::start_wayland_ui; use tracing_subscriber::layer::SubscriberExt; @@ -1475,6 +1475,10 @@ fn build_document_tree( .padding(Edges::all(gutter)) .gap(gutter * 0.45) .background(card_background(NEXT_CARD_ID, hovered_card)) + .border(2.0, card_border_color(NEXT_CARD_ID, hovered_card)) + .corner_radius(gutter * 0.6) + .shadow(card_key_shadow(NEXT_CARD_ID, hovered_card)) + .shadow(card_ambient_shadow(NEXT_CARD_ID, hovered_card)) .children([ Element::paragraph( "Next direction", @@ -1669,3 +1673,44 @@ fn card_title_color(id: ElementId, hovered_card: Option) -> Color { } Color::rgb(0xF4, 0xF7, 0xFF) } + +fn card_border_color(id: ElementId, hovered_card: Option) -> Color { + if hovered_card == Some(id) { + return Color::rgb(0xF5, 0xD0, 0x74); + } + + match id { + NEXT_CARD_ID => Color::rgba(0x8B, 0x99, 0xAD, 0x78), + _ => Color::rgba(0x00, 0x00, 0x00, 0x00), + } +} + +fn card_key_shadow(id: ElementId, hovered_card: Option) -> BoxShadow { + let hovered = hovered_card == Some(id); + BoxShadow::new( + ruin_ui::Point::new(0.0, if hovered { 16.0 } else { 12.0 }), + if hovered { 28.0 } else { 22.0 }, + if hovered { -4.0 } else { -6.0 }, + if hovered { + Color::rgba(0x04, 0x07, 0x0D, 0xC4) + } else { + Color::rgba(0x04, 0x07, 0x0D, 0x96) + }, + BoxShadowKind::Outer, + ) +} + +fn card_ambient_shadow(id: ElementId, hovered_card: Option) -> BoxShadow { + let hovered = hovered_card == Some(id); + BoxShadow::new( + ruin_ui::Point::new(0.0, if hovered { 4.0 } else { 2.0 }), + if hovered { 10.0 } else { 8.0 }, + 0.0, + if hovered { + Color::rgba(0x0C, 0x16, 0x27, 0x88) + } else { + Color::rgba(0x0C, 0x16, 0x27, 0x62) + }, + BoxShadowKind::Outer, + ) +} diff --git a/lib/ui/src/interaction.rs b/lib/ui/src/interaction.rs index 5c34a13..7dd7692 100644 --- a/lib/ui/src/interaction.rs +++ b/lib/ui/src/interaction.rs @@ -164,6 +164,7 @@ mod tests { path: LayoutPath::root(), element_id: None, rect: Rect::new(0.0, 0.0, 200.0, 120.0), + corner_radius: 0.0, pointer_events: false, focusable: false, cursor: CursorIcon::Default, @@ -174,6 +175,7 @@ mod tests { path: LayoutPath::root().child(0), element_id: Some(ElementId::new(1)), rect: Rect::new(0.0, 0.0, 120.0, 120.0), + corner_radius: 0.0, pointer_events: true, focusable: false, cursor: CursorIcon::Default, @@ -185,6 +187,7 @@ mod tests { path: LayoutPath::root().child(1), element_id: Some(ElementId::new(2)), rect: Rect::new(80.0, 0.0, 120.0, 120.0), + corner_radius: 0.0, pointer_events: true, focusable: false, cursor: CursorIcon::Default, @@ -203,6 +206,7 @@ mod tests { path: LayoutPath::root(), element_id: None, rect: Rect::new(0.0, 0.0, 200.0, 120.0), + corner_radius: 0.0, pointer_events: false, focusable: false, cursor: CursorIcon::Default, @@ -212,6 +216,7 @@ mod tests { path: LayoutPath::root().child(0), element_id: Some(ElementId::new(1)), rect: Rect::new(0.0, 0.0, 160.0, 120.0), + corner_radius: 0.0, pointer_events: true, focusable: false, cursor: CursorIcon::Default, @@ -221,6 +226,7 @@ mod tests { path: LayoutPath::root().child(0).child(0), element_id: Some(ElementId::new(2)), rect: Rect::new(16.0, 16.0, 80.0, 40.0), + corner_radius: 0.0, pointer_events: true, focusable: false, cursor: CursorIcon::Default, diff --git a/lib/ui/src/layout.rs b/lib/ui/src/layout.rs index 25759d7..7276bd2 100644 --- a/lib/ui/src/layout.rs +++ b/lib/ui/src/layout.rs @@ -1,9 +1,11 @@ use std::time::Instant; use crate::ImageFit; -use crate::scene::{PreparedImage, PreparedText, Rect, SceneSnapshot, UiSize}; +use crate::scene::{ + PreparedImage, PreparedText, Rect, RoundedRect, SceneSnapshot, ShadowRect, UiSize, +}; use crate::text::TextSystem; -use crate::tree::{CursorIcon, Edges, Element, ElementId, FlexDirection, ImageNode}; +use crate::tree::{CursorIcon, Edges, Element, ElementId, FlexDirection, ImageNode, Style}; pub fn layout_scene(version: u64, logical_size: UiSize, root: &Element) -> SceneSnapshot { let mut text_system = TextSystem::new(); @@ -58,6 +60,7 @@ pub struct LayoutNode { pub path: LayoutPath, pub element_id: Option, pub rect: Rect, + pub corner_radius: f32, pub pointer_events: bool, pub focusable: bool, pub cursor: CursorIcon, @@ -187,6 +190,7 @@ fn layout_element( path, element_id: element.id, rect, + corner_radius: uniform_corner_radius(&element.style, rect), pointer_events: element.style.pointer_events, focusable: element.style.focusable, cursor, @@ -199,14 +203,32 @@ fn layout_element( return interaction; } - if let Some(color) = element.style.background { - perf_stats.background_quads += 1; - scene.push_quad(rect, color); - } + push_box_shadows( + scene, + rect, + &element.style, + crate::BoxShadowKind::Outer, + perf_stats, + ); + push_container_fill_and_border(scene, rect, &element.style, perf_stats); + push_box_shadows( + scene, + rect, + &element.style, + crate::BoxShadowKind::Inner, + perf_stats, + ); + let clip_radius = uniform_corner_radius(&element.style, rect); + let pushed_clip = if clip_radius > 0.0 { + scene.push_clip(rect, clip_radius); + true + } else { + false + }; if let Some(text) = element.text_node() { perf_stats.text_nodes += 1; - let content = inset_rect(rect, element.style.padding); + let content = inset_rect(rect, content_insets(&element.style)); if content.size.width > 0.0 && content.size.height > 0.0 { perf_stats.text_prepare_calls += 1; let prepare_started = perf_stats.enabled.then(Instant::now); @@ -223,16 +245,22 @@ fn layout_element( perf_stats.text_prepare_ms += prepare_started.elapsed().as_secs_f64() * 1_000.0; } } + if pushed_clip { + scene.pop_clip(); + } return interaction; } if let Some(image) = element.image_node() { - let content = inset_rect(rect, element.style.padding); + let content = inset_rect(rect, content_insets(&element.style)); 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); } + if pushed_clip { + scene.pop_clip(); + } return interaction; } @@ -242,7 +270,7 @@ fn layout_element( return interaction; } - let content = inset_rect(rect, element.style.padding); + let content = inset_rect(rect, content_insets(&element.style)); if content.size.width <= 0.0 || content.size.height <= 0.0 { return interaction; } @@ -331,11 +359,15 @@ fn layout_element( cursor += child_main.max(0.0) + element.style.gap; } + if pushed_clip { + scene.pop_clip(); + } + interaction } fn hit_path_node(node: &LayoutNode, point: crate::scene::Point) -> Option> { - if !node.rect.contains(point) { + if !point_hits_node_shape(node, point) { return None; } @@ -368,7 +400,7 @@ fn hit_path_node(node: &LayoutNode, point: crate::scene::Point) -> Option Option { - if !node.rect.contains(point) { + if !point_hits_node_shape(node, point) { return None; } @@ -414,6 +446,49 @@ fn text_for_element_node(node: &LayoutNode, element_id: ElementId) -> Option<&Pr None } +fn point_hits_node_shape(node: &LayoutNode, point: crate::scene::Point) -> bool { + node.rect.contains(point) + && (node.corner_radius <= 0.0 + || point_in_rounded_rect(point, node.rect, node.corner_radius)) +} + +fn point_in_rounded_rect(point: crate::scene::Point, rect: Rect, radius: f32) -> bool { + let radius = radius + .max(0.0) + .min(rect.size.width * 0.5) + .min(rect.size.height * 0.5); + if radius <= 0.0 { + return true; + } + + let left = rect.origin.x; + let top = rect.origin.y; + let right = rect.origin.x + rect.size.width; + let bottom = rect.origin.y + rect.size.height; + + if point.x >= left + radius && point.x < right - radius + || point.y >= top + radius && point.y < bottom - radius + { + return true; + } + + let corner_center = if point.x < left + radius { + if point.y < top + radius { + crate::scene::Point::new(left + radius, top + radius) + } else { + crate::scene::Point::new(left + radius, bottom - radius) + } + } else if point.y < top + radius { + crate::scene::Point::new(right - radius, top + radius) + } else { + crate::scene::Point::new(right - radius, bottom - radius) + }; + + let dx = point.x - corner_center.x; + let dy = point.y - corner_center.y; + dx * dx + dy * dy <= radius * radius +} + #[derive(Clone, Copy, Debug)] struct MeasuredChild { cross: f32, @@ -436,7 +511,7 @@ fn intrinsic_main_size( }; let content = text_system.measure_spans(&text.spans, &text.style, constraints.0, constraints.1); - let padding = main_axis_padding(child.style.padding, direction); + let padding = main_axis_padding(content_insets(&child.style), direction); return main_axis_size(content, direction) + padding; } @@ -462,6 +537,7 @@ fn intrinsic_size( perf_stats: &mut LayoutPerfStats, ) -> UiSize { perf_stats.intrinsic_size_calls += 1; + let insets = content_insets(&element.style); if let Some(text) = element.text_node() { let measured = text_system.measure_spans( &text.spans, @@ -470,37 +546,37 @@ fn intrinsic_size( Some(available_size.height.max(0.0)), ); return UiSize::new( - element.style.width.unwrap_or( - measured.width + element.style.padding.left + element.style.padding.right, - ), - element.style.height.unwrap_or( - measured.height + element.style.padding.top + element.style.padding.bottom, - ), + element + .style + .width + .unwrap_or(measured.width + horizontal_insets(insets)), + element + .style + .height + .unwrap_or(measured.height + vertical_insets(insets)), ); } 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, + resolved.width + horizontal_insets(insets), + resolved.height + vertical_insets(insets), ); } 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) - - element.style.padding.left - - element.style.padding.right; - let content_height = explicit_height.unwrap_or(available_size.height).max(0.0) - - element.style.padding.top - - element.style.padding.bottom; + let content_width = + explicit_width.unwrap_or(available_size.width).max(0.0) - horizontal_insets(insets); + let content_height = + explicit_height.unwrap_or(available_size.height).max(0.0) - vertical_insets(insets); let content_size = UiSize::new(content_width.max(0.0), content_height.max(0.0)); if element.children.is_empty() { return UiSize::new( - explicit_width.unwrap_or(element.style.padding.left + element.style.padding.right), - explicit_height.unwrap_or(element.style.padding.top + element.style.padding.bottom), + explicit_width.unwrap_or(horizontal_insets(insets)), + explicit_height.unwrap_or(vertical_insets(insets)), ); } @@ -582,12 +658,8 @@ fn intrinsic_size( }; UiSize::new( - explicit_width.unwrap_or( - intrinsic_content_width + element.style.padding.left + element.style.padding.right, - ), - explicit_height.unwrap_or( - intrinsic_content_height + element.style.padding.top + element.style.padding.bottom, - ), + explicit_width.unwrap_or(intrinsic_content_width + horizontal_insets(insets)), + explicit_height.unwrap_or(intrinsic_content_height + vertical_insets(insets)), ) } @@ -687,6 +759,150 @@ fn resolve_image_element_size(element: &Element, intrinsic: UiSize) -> UiSize { } } +fn push_box_shadows( + scene: &mut SceneSnapshot, + rect: Rect, + style: &Style, + kind: crate::BoxShadowKind, + perf_stats: &mut LayoutPerfStats, +) { + let source_radius = uniform_corner_radius(style, rect); + for shadow in &style.box_shadows { + let shadow_rect = Rect::new( + rect.origin.x + shadow.offset.x - shadow.spread, + rect.origin.y + shadow.offset.y - shadow.spread, + rect.size.width + shadow.spread * 2.0, + rect.size.height + shadow.spread * 2.0, + ); + if shadow.kind != kind || shadow_rect.size.width <= 0.0 || shadow_rect.size.height <= 0.0 { + continue; + } + + perf_stats.background_quads += 1; + scene.push_shadow_rect(ShadowRect { + rect: shadow_rect, + source_rect: rect, + color: shadow.color, + blur: shadow.blur.max(0.0), + radius: clamp_corner_radius(source_radius + shadow.spread, shadow_rect), + source_radius, + kind, + }); + } +} + +fn push_container_fill_and_border( + scene: &mut SceneSnapshot, + rect: Rect, + style: &Style, + perf_stats: &mut LayoutPerfStats, +) { + let radius = uniform_corner_radius(style, rect); + let border_width = style + .border + .map(|border| { + border + .width + .clamp(0.0, rect.size.width * 0.5) + .min(rect.size.height * 0.5) + }) + .unwrap_or(0.0); + let border_color = style.border.map(|border| border.color); + + if radius > 0.0 && (style.background.is_some() || border_width > 0.0) { + perf_stats.background_quads += 1; + scene.push_rounded_rect(RoundedRect { + rect, + fill: style.background, + border_color, + border_width, + radius, + }); + return; + } + + if let Some(color) = style.background { + perf_stats.background_quads += 1; + scene.push_quad(rect, color); + } + + let Some(border) = style.border else { + return; + }; + if border_width <= 0.0 { + return; + } + + let top = Rect::new(rect.origin.x, rect.origin.y, rect.size.width, border_width); + let bottom = Rect::new( + rect.origin.x, + rect.origin.y + rect.size.height - border_width, + rect.size.width, + border_width, + ); + let middle_height = (rect.size.height - border_width * 2.0).max(0.0); + let left = Rect::new( + rect.origin.x, + rect.origin.y + border_width, + border_width, + middle_height, + ); + let right = Rect::new( + rect.origin.x + rect.size.width - border_width, + rect.origin.y + border_width, + border_width, + middle_height, + ); + + for border_rect in [top, bottom, left, right] { + if border_rect.size.width <= 0.0 || border_rect.size.height <= 0.0 { + continue; + } + perf_stats.background_quads += 1; + scene.push_quad(border_rect, border.color); + } +} + +fn content_insets(style: &Style) -> Edges { + let border = style + .border + .map(|border| border.width.max(0.0)) + .unwrap_or(0.0); + Edges { + top: style.padding.top + border, + right: style.padding.right + border, + bottom: style.padding.bottom + border, + left: style.padding.left + border, + } +} + +fn uniform_corner_radius(style: &Style, rect: Rect) -> f32 { + clamp_corner_radius( + style + .corner_radius + .top_left + .max(style.corner_radius.top_right) + .max(style.corner_radius.bottom_right) + .max(style.corner_radius.bottom_left), + rect, + ) +} + +fn clamp_corner_radius(radius: f32, rect: Rect) -> f32 { + radius + .max(0.0) + .min(rect.size.width * 0.5) + .min(rect.size.height * 0.5) +} + +fn horizontal_insets(edges: Edges) -> f32 { + edges.left + edges.right +} + +fn vertical_insets(edges: Edges) -> f32 { + edges.top + edges.bottom +} + 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); @@ -891,6 +1107,43 @@ mod tests { ); } + #[test] + fn bordered_container_insets_text_and_emits_border_quads() { + let border_color = Color::rgb(0xF5, 0xD0, 0x74); + let root = Element::column().child( + Element::column() + .background(Color::rgb(0x22, 0x33, 0x44)) + .border(4.0, border_color) + .padding(Edges::all(10.0)) + .child(Element::paragraph( + "Bordered content should be inset past the stroke.", + TextStyle::new(18.0, Color::rgb(0xFF, 0xFF, 0xFF)).with_line_height(24.0), + )), + ); + + let scene = layout_scene(1, UiSize::new(320.0, 160.0), &root); + let text = scene + .items + .iter() + .find_map(|item| match item { + DisplayItem::Text(text) => Some(text), + _ => None, + }) + .expect("bordered container should emit text"); + let border_quads: Vec = scene + .items + .iter() + .filter_map(|item| match item { + DisplayItem::Quad(quad) if quad.color == border_color => Some(*quad), + _ => None, + }) + .collect(); + + assert_eq!(text.origin.x, 14.0); + assert_eq!(text.origin.y, 14.0); + assert_eq!(border_quads.len(), 4); + } + #[test] fn row_container_counts_flex_child_height_in_intrinsic_size() { let row_color = Color::rgb(0x12, 0x24, 0x36); @@ -945,6 +1198,78 @@ mod tests { ); } + #[test] + fn rounded_container_emits_rounded_rect_and_clip_items() { + let root = Element::column().child( + Element::column() + .background(Color::rgb(0x18, 0x20, 0x2F)) + .border(2.0, Color::rgb(0xF5, 0xD0, 0x74)) + .corner_radius(18.0) + .padding(Edges::all(12.0)) + .child(Element::paragraph( + "Rounded containers should emit a rounded paint item and clip their children.", + TextStyle::new(18.0, Color::rgb(0xFF, 0xFF, 0xFF)).with_line_height(24.0), + )), + ); + + let scene = layout_scene(1, UiSize::new(320.0, 180.0), &root); + assert!( + scene + .items + .iter() + .any(|item| matches!(item, DisplayItem::RoundedRect(_))) + ); + assert!( + scene + .items + .iter() + .any(|item| matches!(item, DisplayItem::PushClip(_))) + ); + assert!( + scene + .items + .iter() + .any(|item| matches!(item, DisplayItem::PopClip)) + ); + } + + #[test] + fn shadowed_container_emits_shadow_rect_items() { + let root = Element::column().child( + Element::column() + .background(Color::rgb(0x18, 0x20, 0x2F)) + .corner_radius(18.0) + .shadow(crate::BoxShadow::new( + crate::Point::new(0.0, 8.0), + 18.0, + -2.0, + Color::rgba(0x05, 0x08, 0x0F, 0x90), + crate::BoxShadowKind::Outer, + )) + .shadow(crate::BoxShadow::new( + crate::Point::new(0.0, 4.0), + 8.0, + 0.0, + Color::rgba(0x00, 0x00, 0x00, 0x70), + crate::BoxShadowKind::Inner, + )) + .child(Element::paragraph( + "Shadowed containers should emit dedicated shadow paint items.", + TextStyle::new(18.0, Color::rgb(0xFF, 0xFF, 0xFF)).with_line_height(24.0), + )), + ); + + let scene = layout_scene(1, UiSize::new(320.0, 180.0), &root); + assert!( + scene + .items + .iter() + .filter(|item| matches!(item, DisplayItem::ShadowRect(_))) + .count() + >= 2 + ); + } + #[test] fn interaction_tree_hit_test_returns_deepest_pointer_target() { let root = Element::column() @@ -995,6 +1320,31 @@ mod tests { assert_eq!(hit.element_id, Some(ElementId::new(2))); } + #[test] + fn rounded_corner_hit_test_excludes_clipped_corner_region() { + let root = Element::column().pointer_events(false).child( + Element::column() + .id(ElementId::new(2)) + .width(120.0) + .height(80.0) + .corner_radius(20.0) + .background(Color::rgb(0x22, 0x33, 0x44)), + ); + + let snapshot = layout_snapshot(1, UiSize::new(160.0, 120.0), &root); + assert!( + snapshot + .interaction_tree + .hit_test(Point::new(4.0, 4.0)) + .is_none() + ); + let hit = snapshot + .interaction_tree + .hit_test(Point::new(24.0, 24.0)) + .expect("point inside rounded body should hit child"); + assert_eq!(hit.element_id, Some(ElementId::new(2))); + } + #[test] fn interaction_tree_hit_path_includes_pointer_ancestors() { let root = Element::column().pointer_events(false).child( diff --git a/lib/ui/src/lib.rs b/lib/ui/src/lib.rs index 263763b..4be9176 100644 --- a/lib/ui/src/lib.rs +++ b/lib/ui/src/lib.rs @@ -38,14 +38,17 @@ pub use platform::{ }; pub use runtime::{EventStreamClosed, UiRuntime, WindowController}; pub use scene::{ - Color, DisplayItem, GlyphInstance, Point, PreparedImage, PreparedText, PreparedTextLine, Quad, - Rect, SceneSnapshot, Translation, UiSize, + ClipRegion, Color, DisplayItem, GlyphInstance, Point, PreparedImage, PreparedText, + PreparedTextLine, Quad, Rect, RoundedRect, SceneSnapshot, ShadowRect, Translation, UiSize, }; pub use text::{ TextAlign, TextFontFamily, TextSelectionStyle, TextSpan, TextSpanSlant, TextSpanWeight, TextStyle, TextSystem, TextWrap, }; -pub use tree::{CursorIcon, Edges, Element, ElementId, FlexDirection, Style}; +pub use tree::{ + Border, BoxShadow, BoxShadowKind, CornerRadius, CursorIcon, Edges, Element, ElementId, + FlexDirection, Style, +}; pub use window::{ DecorationMode, WindowConfigured, WindowId, WindowLifecycle, WindowSpec, WindowUpdate, }; diff --git a/lib/ui/src/scene.rs b/lib/ui/src/scene.rs index fae969e..636ca5b 100644 --- a/lib/ui/src/scene.rs +++ b/lib/ui/src/scene.rs @@ -8,7 +8,7 @@ use tracing::debug; use crate::ImageResource; use crate::text::TextSelectionStyle; use crate::trace_targets; -use crate::tree::ElementId; +use crate::tree::{BoxShadowKind, ElementId}; pub type SceneVersion = u64; @@ -100,6 +100,32 @@ impl Quad { } } +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RoundedRect { + pub rect: Rect, + pub fill: Option, + pub border_color: Option, + pub border_width: f32, + pub radius: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ShadowRect { + pub rect: Rect, + pub source_rect: Rect, + pub color: Color, + pub blur: f32, + pub radius: f32, + pub source_radius: f32, + pub kind: BoxShadowKind, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ClipRegion { + pub rect: Rect, + pub radius: f32, +} + #[derive(Clone, Debug, PartialEq)] pub struct GlyphInstance { pub position: Point, @@ -465,9 +491,11 @@ fn classify_word_char(ch: char) -> WordClass { #[derive(Clone, Debug, PartialEq)] pub enum DisplayItem { Quad(Quad), + RoundedRect(RoundedRect), + ShadowRect(ShadowRect), Image(PreparedImage), Text(PreparedText), - PushClip(Rect), + PushClip(ClipRegion), PopClip, PushTransform(Translation), PopTransform, @@ -512,6 +540,14 @@ impl SceneSnapshot { self.push_item(DisplayItem::Quad(Quad::new(rect, color))) } + pub fn push_rounded_rect(&mut self, rounded_rect: RoundedRect) -> &mut Self { + self.push_item(DisplayItem::RoundedRect(rounded_rect)) + } + + pub fn push_shadow_rect(&mut self, shadow_rect: ShadowRect) -> &mut Self { + self.push_item(DisplayItem::ShadowRect(shadow_rect)) + } + pub fn push_text(&mut self, text: PreparedText) -> &mut Self { self.push_item(DisplayItem::Text(text)) } @@ -520,8 +556,8 @@ impl SceneSnapshot { self.push_item(DisplayItem::Image(image)) } - pub fn push_clip(&mut self, rect: Rect) -> &mut Self { - self.push_item(DisplayItem::PushClip(rect)) + pub fn push_clip(&mut self, rect: Rect, radius: f32) -> &mut Self { + self.push_item(DisplayItem::PushClip(ClipRegion { rect, radius })) } pub fn pop_clip(&mut self) -> &mut Self { diff --git a/lib/ui/src/tree.rs b/lib/ui/src/tree.rs index a7e5957..3c32645 100644 --- a/lib/ui/src/tree.rs +++ b/lib/ui/src/tree.rs @@ -1,4 +1,4 @@ -use crate::scene::Color; +use crate::scene::{Color, Point}; use crate::text::{TextSpan, TextStyle, TextWrap}; use crate::{ImageFit, ImageResource}; @@ -63,6 +63,77 @@ impl Edges { } } +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Border { + pub width: f32, + pub color: Color, +} + +impl Border { + pub const fn new(width: f32, color: Color) -> Self { + Self { width, color } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CornerRadius { + pub top_left: f32, + pub top_right: f32, + pub bottom_right: f32, + pub bottom_left: f32, +} + +impl CornerRadius { + pub const ZERO: Self = Self { + top_left: 0.0, + top_right: 0.0, + bottom_right: 0.0, + bottom_left: 0.0, + }; + + pub const fn all(radius: f32) -> Self { + Self { + top_left: radius, + top_right: radius, + bottom_right: radius, + bottom_left: radius, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BoxShadowKind { + Outer, + Inner, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BoxShadow { + pub offset: Point, + pub blur: f32, + pub spread: f32, + pub color: Color, + pub kind: BoxShadowKind, +} + +impl BoxShadow { + pub const fn new( + offset: Point, + blur: f32, + spread: f32, + color: Color, + kind: BoxShadowKind, + ) -> Self { + Self { + offset, + blur, + spread, + color, + kind, + } + } +} + #[derive(Clone, Debug, PartialEq)] pub struct Style { pub direction: FlexDirection, @@ -72,6 +143,9 @@ pub struct Style { pub gap: f32, pub padding: Edges, pub background: Option, + pub border: Option, + pub corner_radius: CornerRadius, + pub box_shadows: Vec, pub pointer_events: bool, pub focusable: bool, pub cursor: Option, @@ -87,6 +161,9 @@ impl Default for Style { gap: 0.0, padding: Edges::ZERO, background: None, + border: None, + corner_radius: CornerRadius::ZERO, + box_shadows: Vec::new(), pointer_events: true, focusable: false, cursor: None, @@ -210,6 +287,21 @@ impl Element { self } + pub fn border(mut self, width: f32, color: Color) -> Self { + self.style.border = Some(Border::new(width.max(0.0), color)); + self + } + + pub fn corner_radius(mut self, radius: f32) -> Self { + self.style.corner_radius = CornerRadius::all(radius.max(0.0)); + self + } + + pub fn shadow(mut self, shadow: BoxShadow) -> Self { + self.style.box_shadows.push(shadow); + self + } + pub fn id(mut self, id: ElementId) -> Self { self.id = Some(id); self diff --git a/lib/ui_renderer_wgpu/src/lib.rs b/lib/ui_renderer_wgpu/src/lib.rs index 75db6fa..83b259a 100644 --- a/lib/ui_renderer_wgpu/src/lib.rs +++ b/lib/ui_renderer_wgpu/src/lib.rs @@ -7,7 +7,8 @@ use cosmic_text::{ }; use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; use ruin_ui::{ - Color, DisplayItem, Point, PreparedImage, PreparedText, Rect, SceneSnapshot, UiSize, + BoxShadowKind, ClipRegion, Color, DisplayItem, Point, PreparedImage, PreparedText, Rect, + RoundedRect, SceneSnapshot, ShadowRect, UiSize, }; use tracing::trace; use wgpu::util::DeviceExt; @@ -16,15 +17,34 @@ use wgpu::util::DeviceExt; #[derive(Clone, Copy, Pod, Zeroable)] struct Vertex { position: [f32; 2], - color: [f32; 4], + world_position: [f32; 2], + rect: [f32; 4], + fill_color: [f32; 4], + border_color: [f32; 4], + shape_params: [f32; 4], + clip_rect: [f32; 4], + rounded_clip_rect: [f32; 4], + clip_params: [f32; 2], + shadow_base_rect: [f32; 4], + shadow_base_params: [f32; 2], } #[repr(C)] #[derive(Clone, Copy, Pod, Zeroable)] struct TextVertex { position: [f32; 2], + world_position: [f32; 2], uv: [f32; 2], color: [f32; 4], + clip_rect: [f32; 4], + rounded_clip_rect: [f32; 4], + clip_params: [f32; 2], +} + +#[derive(Clone, Copy, Debug, Default)] +struct ActiveClip { + rect: Option, + rounded: Option<(Rect, f32)>, } #[derive(Clone, Copy, Debug)] @@ -77,6 +97,7 @@ struct CachedImageTexture { bind_group: wgpu::BindGroup, } +#[allow(dead_code)] struct GlyphAtlas { texture: wgpu::Texture, bind_group: wgpu::BindGroup, @@ -87,6 +108,7 @@ struct GlyphAtlas { } #[derive(Clone, Copy, Debug)] +#[allow(dead_code)] struct AtlasGlyph { atlas_rect: AtlasRect, placement_left: i32, @@ -94,6 +116,7 @@ struct AtlasGlyph { } #[derive(Clone, Copy, Debug)] +#[allow(dead_code)] struct AtlasRect { x: u32, y: u32, @@ -119,6 +142,7 @@ struct UploadedImage { vertex_count: u32, } +#[allow(dead_code)] struct UploadedAtlasText { vertex_buffer: wgpu::Buffer, vertex_count: u32, @@ -143,10 +167,100 @@ struct TextTextureGlyph { cache_key: Option, } -const VERTEX_ATTRIBUTES: [wgpu::VertexAttribute; 2] = - wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x4]; -const TEXT_VERTEX_ATTRIBUTES: [wgpu::VertexAttribute; 3] = - wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Float32x4]; +const VERTEX_ATTRIBUTES: [wgpu::VertexAttribute; 11] = [ + wgpu::VertexAttribute { + offset: 0, + shader_location: 0, + format: wgpu::VertexFormat::Float32x2, + }, + wgpu::VertexAttribute { + offset: 8, + shader_location: 1, + format: wgpu::VertexFormat::Float32x2, + }, + wgpu::VertexAttribute { + offset: 16, + shader_location: 2, + format: wgpu::VertexFormat::Float32x4, + }, + wgpu::VertexAttribute { + offset: 32, + shader_location: 3, + format: wgpu::VertexFormat::Float32x4, + }, + wgpu::VertexAttribute { + offset: 48, + shader_location: 4, + format: wgpu::VertexFormat::Float32x4, + }, + wgpu::VertexAttribute { + offset: 64, + shader_location: 5, + format: wgpu::VertexFormat::Float32x4, + }, + wgpu::VertexAttribute { + offset: 80, + shader_location: 6, + format: wgpu::VertexFormat::Float32x4, + }, + wgpu::VertexAttribute { + offset: 96, + shader_location: 7, + format: wgpu::VertexFormat::Float32x4, + }, + wgpu::VertexAttribute { + offset: 112, + shader_location: 8, + format: wgpu::VertexFormat::Float32x2, + }, + wgpu::VertexAttribute { + offset: 120, + shader_location: 9, + format: wgpu::VertexFormat::Float32x4, + }, + wgpu::VertexAttribute { + offset: 136, + shader_location: 10, + format: wgpu::VertexFormat::Float32x2, + }, +]; +const TEXT_VERTEX_ATTRIBUTES: [wgpu::VertexAttribute; 7] = [ + wgpu::VertexAttribute { + offset: 0, + shader_location: 0, + format: wgpu::VertexFormat::Float32x2, + }, + wgpu::VertexAttribute { + offset: 8, + shader_location: 1, + format: wgpu::VertexFormat::Float32x2, + }, + wgpu::VertexAttribute { + offset: 16, + shader_location: 2, + format: wgpu::VertexFormat::Float32x2, + }, + wgpu::VertexAttribute { + offset: 24, + shader_location: 3, + format: wgpu::VertexFormat::Float32x4, + }, + wgpu::VertexAttribute { + offset: 40, + shader_location: 4, + format: wgpu::VertexFormat::Float32x4, + }, + wgpu::VertexAttribute { + offset: 56, + shader_location: 5, + format: wgpu::VertexFormat::Float32x4, + }, + wgpu::VertexAttribute { + offset: 72, + shader_location: 6, + format: wgpu::VertexFormat::Float32x2, + }, +]; impl Vertex { const LAYOUT: wgpu::VertexBufferLayout<'static> = wgpu::VertexBufferLayout { @@ -196,12 +310,14 @@ pub struct WgpuSceneRenderer { text_cache_order: VecDeque, image_cache: HashMap, image_cache_order: VecDeque, + #[allow(dead_code)] glyph_atlas: GlyphAtlas, } const MAX_TEXT_CACHE_ENTRIES: usize = 64; const MAX_IMAGE_CACHE_ENTRIES: usize = 64; const GLYPH_ATLAS_SIZE: u32 = 2048; +#[allow(dead_code)] const GLYPH_ATLAS_PADDING: u32 = 1; impl WgpuSceneRenderer { @@ -245,25 +361,137 @@ impl WgpuSceneRenderer { r#" struct VertexIn { @location(0) position: vec2, - @location(1) color: vec4, + @location(1) world_position: vec2, + @location(2) rect: vec4, + @location(3) fill_color: vec4, + @location(4) border_color: vec4, + @location(5) shape_params: vec4, + @location(6) clip_rect: vec4, + @location(7) rounded_clip_rect: vec4, + @location(8) clip_params: vec2, + @location(9) shadow_base_rect: vec4, + @location(10) shadow_base_params: vec2, }; struct VertexOut { @builtin(position) position: vec4, - @location(0) color: vec4, + @location(0) world_position: vec2, + @location(1) rect: vec4, + @location(2) fill_color: vec4, + @location(3) border_color: vec4, + @location(4) shape_params: vec4, + @location(5) clip_rect: vec4, + @location(6) rounded_clip_rect: vec4, + @location(7) clip_params: vec2, + @location(8) shadow_base_rect: vec4, + @location(9) shadow_base_params: vec2, }; +fn sd_round_rect(point: vec2, rect: vec4, radius: f32) -> f32 { + let center = vec2((rect.x + rect.z) * 0.5, (rect.y + rect.w) * 0.5); + let half_size = vec2((rect.z - rect.x) * 0.5, (rect.w - rect.y) * 0.5); + let effective_radius = max(radius, 0.0); + let q = abs(point - center) - max(half_size - vec2(effective_radius, effective_radius), vec2(0.0, 0.0)); + return length(max(q, vec2(0.0, 0.0))) + min(max(q.x, q.y), 0.0) - effective_radius; +} + +fn rounded_rect_alpha(point: vec2, rect: vec4, radius: f32) -> f32 { + let distance = sd_round_rect(point, rect, radius); + return 1.0 - smoothstep(0.0, 1.0, distance); +} + +fn blurred_round_rect_alpha(point: vec2, rect: vec4, radius: f32, blur: f32) -> f32 { + let softness = max(blur, 0.001); + let distance = sd_round_rect(point, rect, radius); + return 1.0 - smoothstep(0.0, softness, distance); +} + +fn apply_clip_alpha(point: vec2, clip_rect: vec4, rounded_clip_rect: vec4, clip_params: vec2) -> f32 { + var alpha = 1.0; + if clip_params.x > 0.5 { + if point.x < clip_rect.x || point.y < clip_rect.y || point.x > clip_rect.z || point.y > clip_rect.w { + return 0.0; + } + } + if clip_params.y > 0.0 { + alpha = alpha * rounded_rect_alpha(point, rounded_clip_rect, clip_params.y); + } + return alpha; +} + @vertex fn vs_main(input: VertexIn) -> VertexOut { var out: VertexOut; out.position = vec4(input.position, 0.0, 1.0); - out.color = input.color; + out.world_position = input.world_position; + out.rect = input.rect; + out.fill_color = input.fill_color; + out.border_color = input.border_color; + out.shape_params = input.shape_params; + out.clip_rect = input.clip_rect; + out.rounded_clip_rect = input.rounded_clip_rect; + out.clip_params = input.clip_params; + out.shadow_base_rect = input.shadow_base_rect; + out.shadow_base_params = input.shadow_base_params; return out; } @fragment fn fs_main(input: VertexOut) -> @location(0) vec4 { - return input.color; + var color = vec4(0.0, 0.0, 0.0, 0.0); + if input.shape_params.z < 0.5 { + let outer = rounded_rect_alpha(input.world_position, input.rect, input.shape_params.x); + var fill_mask = outer; + var border_mask = 0.0; + if input.shape_params.y > 0.0 { + let inner_rect = vec4( + input.rect.x + input.shape_params.y, + input.rect.y + input.shape_params.y, + input.rect.z - input.shape_params.y, + input.rect.w - input.shape_params.y, + ); + let inner = rounded_rect_alpha(input.world_position, inner_rect, max(input.shape_params.x - input.shape_params.y, 0.0)); + fill_mask = inner; + border_mask = max(outer - inner, 0.0); + } + color = input.fill_color * fill_mask + input.border_color * border_mask; + } else if input.shape_params.z < 1.5 { + let shadow = blurred_round_rect_alpha( + input.world_position, + input.rect, + input.shape_params.x, + input.shape_params.w, + ); + let source = rounded_rect_alpha( + input.world_position, + input.shadow_base_rect, + input.shadow_base_params.x, + ); + color = input.fill_color * max(shadow * (1.0 - source), 0.0); + } else { + let inside = rounded_rect_alpha( + input.world_position, + input.shadow_base_rect, + input.shadow_base_params.x, + ); + let shifted = blurred_round_rect_alpha( + input.world_position, + input.rect, + input.shape_params.x, + input.shape_params.w, + ); + color = input.fill_color * max(inside * (1.0 - shifted), 0.0); + } + color.a = color.a * apply_clip_alpha( + input.world_position, + input.clip_rect, + input.rounded_clip_rect, + input.clip_params, + ); + if color.a <= 0.001 { + discard; + } + return color; } "# .into(), @@ -276,31 +504,79 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { r#" struct VertexIn { @location(0) position: vec2, - @location(1) uv: vec2, - @location(2) color: vec4, + @location(1) world_position: vec2, + @location(2) uv: vec2, + @location(3) color: vec4, + @location(4) clip_rect: vec4, + @location(5) rounded_clip_rect: vec4, + @location(6) clip_params: vec2, }; struct VertexOut { @builtin(position) position: vec4, - @location(0) uv: vec2, - @location(1) color: vec4, + @location(0) world_position: vec2, + @location(1) uv: vec2, + @location(2) color: vec4, + @location(3) clip_rect: vec4, + @location(4) rounded_clip_rect: vec4, + @location(5) clip_params: vec2, }; @group(0) @binding(0) var text_texture: texture_2d; @group(0) @binding(1) var text_sampler: sampler; +fn sd_round_rect(point: vec2, rect: vec4, radius: f32) -> f32 { + let center = vec2((rect.x + rect.z) * 0.5, (rect.y + rect.w) * 0.5); + let half_size = vec2((rect.z - rect.x) * 0.5, (rect.w - rect.y) * 0.5); + let effective_radius = max(radius, 0.0); + let q = abs(point - center) - max(half_size - vec2(effective_radius, effective_radius), vec2(0.0, 0.0)); + return length(max(q, vec2(0.0, 0.0))) + min(max(q.x, q.y), 0.0) - effective_radius; +} + +fn rounded_rect_alpha(point: vec2, rect: vec4, radius: f32) -> f32 { + let distance = sd_round_rect(point, rect, radius); + return 1.0 - smoothstep(0.0, 1.0, distance); +} + +fn apply_clip_alpha(point: vec2, clip_rect: vec4, rounded_clip_rect: vec4, clip_params: vec2) -> f32 { + var alpha = 1.0; + if clip_params.x > 0.5 { + if point.x < clip_rect.x || point.y < clip_rect.y || point.x > clip_rect.z || point.y > clip_rect.w { + return 0.0; + } + } + if clip_params.y > 0.0 { + alpha = alpha * rounded_rect_alpha(point, rounded_clip_rect, clip_params.y); + } + return alpha; +} + @vertex fn vs_main(input: VertexIn) -> VertexOut { var out: VertexOut; out.position = vec4(input.position, 0.0, 1.0); + out.world_position = input.world_position; out.uv = input.uv; out.color = input.color; + out.clip_rect = input.clip_rect; + out.rounded_clip_rect = input.rounded_clip_rect; + out.clip_params = input.clip_params; return out; } @fragment fn fs_main(input: VertexOut) -> @location(0) vec4 { - return textureSample(text_texture, text_sampler, input.uv) * input.color; + var color = textureSample(text_texture, text_sampler, input.uv) * input.color; + color.a = color.a * apply_clip_alpha( + input.world_position, + input.clip_rect, + input.rounded_clip_rect, + input.clip_params, + ); + if color.a <= 0.001 { + discard; + } + return color; } "# .into(), @@ -452,27 +728,37 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { 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 mut uploaded_images = Vec::new(); + let mut uploaded_texts = Vec::new(); let uploaded_atlas_text = self.prepare_uploaded_atlas_text(scene); - let uploaded_texts: Vec<_> = scene - .items - .iter() - .filter_map(|item| match item { - DisplayItem::Text(text) - if text.glyphs.iter().any(|glyph| glyph.cache_key.is_none()) => - { - self.prepare_uploaded_text(text, scene.logical_size) + let mut clip_stack = Vec::new(); + let mut active_clip = ActiveClip::default(); + for item in &scene.items { + match item { + DisplayItem::PushClip(region) => { + push_clip_state(&mut clip_stack, &mut active_clip, *region) } - _ => None, - }) - .collect(); + DisplayItem::PopClip => pop_clip_state(&mut clip_stack, &mut active_clip), + DisplayItem::Image(image) => { + if let Some(uploaded) = + self.prepare_uploaded_image(image, scene.logical_size, active_clip) + { + uploaded_images.push(uploaded); + } + } + DisplayItem::Text(text) => { + if text.glyphs.iter().all(|glyph| glyph.cache_key.is_some()) { + continue; + } + if let Some(uploaded) = + self.prepare_uploaded_text(text, scene.logical_size, active_clip) + { + uploaded_texts.push(uploaded); + } + } + _ => {} + } + } let text_prepare_ms = text_prepare_start.elapsed().as_secs_f64() * 1_000.0; let mut encoder = self .device @@ -692,47 +978,56 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { fn prepare_uploaded_atlas_text(&mut self, scene: &SceneSnapshot) -> Option { let mut vertices = Vec::new(); + let mut clip_stack = Vec::new(); + let mut active_clip = ActiveClip::default(); for item in &scene.items { - let DisplayItem::Text(text) = item else { - continue; - }; - if text.glyphs.iter().any(|glyph| glyph.cache_key.is_none()) { - continue; - } + match item { + DisplayItem::PushClip(region) => { + push_clip_state(&mut clip_stack, &mut active_clip, *region) + } + DisplayItem::PopClip => pop_clip_state(&mut clip_stack, &mut active_clip), + DisplayItem::Text(text) => { + if text.glyphs.iter().any(|glyph| glyph.cache_key.is_none()) { + continue; + } - let clip_rect = text.bounds.map(|bounds| PixelRect { - left: text.origin.x.floor() as i32, - top: text.origin.y.floor() as i32, - right: (text.origin.x + bounds.width).ceil() as i32, - bottom: (text.origin.y + bounds.height).ceil() as i32, - }); + let text_bounds_clip = text.bounds.map(|bounds| { + Rect::new(text.origin.x, text.origin.y, bounds.width, bounds.height) + }); + let clip_rect = + intersect_rects(active_clip.rect, text_bounds_clip).map(rect_to_pixel_rect); - for glyph in &text.glyphs { - let Some(cache_key) = glyph.cache_key else { - continue; - }; - let Some(atlas_glyph) = self.ensure_atlas_glyph(cache_key, glyph.color) else { - continue; - }; + for glyph in &text.glyphs { + let Some(cache_key) = glyph.cache_key else { + continue; + }; + let Some(atlas_glyph) = self.ensure_atlas_glyph(cache_key, glyph.color) + else { + continue; + }; - let glyph_rect = PixelRect { - left: glyph.position.x.round() as i32 + atlas_glyph.placement_left, - top: glyph.position.y.round() as i32 - atlas_glyph.placement_top, - right: glyph.position.x.round() as i32 - + atlas_glyph.placement_left - + atlas_glyph.atlas_rect.width as i32, - bottom: glyph.position.y.round() as i32 - atlas_glyph.placement_top - + atlas_glyph.atlas_rect.height as i32, - }; - push_glyph_vertices( - &mut vertices, - glyph_rect, - atlas_glyph.atlas_rect, - clip_rect, - scene.logical_size, - glyph.color, - ); + let glyph_rect = PixelRect { + left: glyph.position.x.round() as i32 + atlas_glyph.placement_left, + top: glyph.position.y.round() as i32 - atlas_glyph.placement_top, + right: glyph.position.x.round() as i32 + + atlas_glyph.placement_left + + atlas_glyph.atlas_rect.width as i32, + bottom: glyph.position.y.round() as i32 - atlas_glyph.placement_top + + atlas_glyph.atlas_rect.height as i32, + }; + push_glyph_vertices( + &mut vertices, + glyph_rect, + atlas_glyph.atlas_rect, + clip_rect, + scene.logical_size, + glyph.color, + active_clip, + ); + } + } + _ => {} } } @@ -753,6 +1048,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { }) } + #[allow(dead_code)] fn ensure_atlas_glyph(&mut self, cache_key: CacheKey, color: Color) -> Option { let key = AtlasGlyphKey { cache_key, @@ -815,6 +1111,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { Some(glyph) } + #[allow(dead_code)] fn reserve_atlas_rect(&mut self, width: u32, height: u32) -> Option { if width == 0 || height == 0 || width > GLYPH_ATLAS_SIZE || height > GLYPH_ATLAS_SIZE { return None; @@ -864,6 +1161,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { &mut self, image: &PreparedImage, logical_size: UiSize, + clip: ActiveClip, ) -> Option { let key = image.resource.id(); if !self.image_cache.contains_key(&key) { @@ -875,7 +1173,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { .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 vertices = build_image_vertices(image.rect, image.uv_rect, logical_size, clip); let vertex_buffer = self .device .create_buffer_init(&wgpu::util::BufferInitDescriptor { @@ -894,6 +1192,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { &mut self, text: &PreparedText, logical_size: UiSize, + clip: ActiveClip, ) -> Option { let key = text_texture_key(text); if !self.text_cache.contains_key(&key) { @@ -911,7 +1210,7 @@ fn fs_main(input: VertexOut) -> @location(0) vec4 { text.origin.x + cached.origin_offset.x, text.origin.y + cached.origin_offset.y, ); - let vertices = build_text_vertices(origin, cached.size, logical_size); + let vertices = build_text_vertices(origin, cached.size, logical_size, clip); let vertex_buffer = self .device .create_buffer_init(&wgpu::util::BufferInitDescriptor { @@ -1122,6 +1421,7 @@ fn color_from_cosmic(color: cosmic_text::Color) -> Color { Color::rgba(color.r(), color.g(), color.b(), color.a()) } +#[allow(dead_code)] fn rasterize_image_rgba(image: &SwashImage, color: Color) -> Vec { let width = image.placement.width as usize; let height = image.placement.height as usize; @@ -1330,51 +1630,37 @@ fn blend_rgba(dst: &mut [u8], src: [u8; 4]) { dst[3] = (out_alpha * 255.0).round().clamp(0.0, 255.0) as u8; } -fn build_text_vertices(origin: Point, size: UiSize, logical_size: UiSize) -> [TextVertex; 6] { - let left = to_ndc_x(origin.x, logical_size.width.max(1.0)); - let right = to_ndc_x(origin.x + size.width, logical_size.width.max(1.0)); - let top = to_ndc_y(origin.y, logical_size.height.max(1.0)); - let bottom = to_ndc_y(origin.y + size.height, logical_size.height.max(1.0)); - let color = [1.0, 1.0, 1.0, 1.0]; - - [ - TextVertex { - position: [left, top], - uv: [0.0, 0.0], - color, - }, - TextVertex { - position: [left, bottom], - uv: [0.0, 1.0], - color, - }, - TextVertex { - position: [right, top], - uv: [1.0, 0.0], - color, - }, - TextVertex { - position: [right, top], - uv: [1.0, 0.0], - color, - }, - TextVertex { - position: [left, bottom], - uv: [0.0, 1.0], - color, - }, - TextVertex { - position: [right, bottom], - uv: [1.0, 1.0], - color, - }, - ] +fn build_text_vertices( + origin: Point, + size: UiSize, + logical_size: UiSize, + clip: ActiveClip, +) -> [TextVertex; 6] { + let rect = Rect::new(origin.x, origin.y, size.width, size.height); + build_textured_vertices( + rect, + (0.0, 0.0, 1.0, 1.0), + [1.0, 1.0, 1.0, 1.0], + logical_size, + clip, + ) } fn build_image_vertices( rect: Rect, uv_rect: (f32, f32, f32, f32), logical_size: UiSize, + clip: ActiveClip, +) -> [TextVertex; 6] { + build_textured_vertices(rect, uv_rect, [1.0, 1.0, 1.0, 1.0], logical_size, clip) +} + +fn build_textured_vertices( + rect: Rect, + uv_rect: (f32, f32, f32, f32), + color: [f32; 4], + logical_size: UiSize, + clip: ActiveClip, ) -> [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)); @@ -1384,42 +1670,71 @@ fn build_image_vertices( logical_size.height.max(1.0), ); let (u0, v0, u1, v1) = uv_rect; - let color = [1.0, 1.0, 1.0, 1.0]; + let clip_rect = clip_rect_array(clip.rect); + let (rounded_clip_rect, clip_params) = rounded_clip_arrays(clip); [ TextVertex { position: [left, top], + world_position: [rect.origin.x, rect.origin.y], uv: [u0, v0], color, + clip_rect, + rounded_clip_rect, + clip_params, }, TextVertex { position: [left, bottom], + world_position: [rect.origin.x, rect.origin.y + rect.size.height], uv: [u0, v1], color, + clip_rect, + rounded_clip_rect, + clip_params, }, TextVertex { position: [right, top], + world_position: [rect.origin.x + rect.size.width, rect.origin.y], uv: [u1, v0], color, + clip_rect, + rounded_clip_rect, + clip_params, }, TextVertex { position: [right, top], + world_position: [rect.origin.x + rect.size.width, rect.origin.y], uv: [u1, v0], color, + clip_rect, + rounded_clip_rect, + clip_params, }, TextVertex { position: [left, bottom], + world_position: [rect.origin.x, rect.origin.y + rect.size.height], uv: [u0, v1], color, + clip_rect, + rounded_clip_rect, + clip_params, }, TextVertex { position: [right, bottom], + world_position: [ + rect.origin.x + rect.size.width, + rect.origin.y + rect.size.height, + ], uv: [u1, v1], color, + clip_rect, + rounded_clip_rect, + clip_params, }, ] } +#[allow(dead_code)] fn push_glyph_vertices( vertices: &mut Vec, glyph_rect: PixelRect, @@ -1427,6 +1742,7 @@ fn push_glyph_vertices( clip_rect: Option, logical_size: UiSize, color: Color, + clip: ActiveClip, ) { let Some((dest_rect, uv_rect)) = clipped_glyph_quad(glyph_rect, atlas_rect, clip_rect) else { return; @@ -1438,36 +1754,62 @@ fn push_glyph_vertices( let bottom = to_ndc_y(dest_rect.bottom as f32, logical_size.height.max(1.0)); let color = color_to_f32(color); + let clip_rect = clip_rect_array(clip.rect); + let (rounded_clip_rect, clip_params) = rounded_clip_arrays(clip); vertices.extend_from_slice(&[ TextVertex { position: [left, top], + world_position: [dest_rect.left as f32, dest_rect.top as f32], uv: [uv_rect.0, uv_rect.1], color, + clip_rect, + rounded_clip_rect, + clip_params, }, TextVertex { position: [left, bottom], + world_position: [dest_rect.left as f32, dest_rect.bottom as f32], uv: [uv_rect.0, uv_rect.3], color, + clip_rect, + rounded_clip_rect, + clip_params, }, TextVertex { position: [right, top], + world_position: [dest_rect.right as f32, dest_rect.top as f32], uv: [uv_rect.2, uv_rect.1], color, + clip_rect, + rounded_clip_rect, + clip_params, }, TextVertex { position: [right, top], + world_position: [dest_rect.right as f32, dest_rect.top as f32], uv: [uv_rect.2, uv_rect.1], color, + clip_rect, + rounded_clip_rect, + clip_params, }, TextVertex { position: [left, bottom], + world_position: [dest_rect.left as f32, dest_rect.bottom as f32], uv: [uv_rect.0, uv_rect.3], color, + clip_rect, + rounded_clip_rect, + clip_params, }, TextVertex { position: [right, bottom], + world_position: [dest_rect.right as f32, dest_rect.bottom as f32], uv: [uv_rect.2, uv_rect.3], color, + clip_rect, + rounded_clip_rect, + clip_params, }, ]); } @@ -1485,10 +1827,30 @@ fn build_vertices(scene: &SceneSnapshot) -> Vec { let width = scene.logical_size.width.max(1.0); let height = scene.logical_size.height.max(1.0); let mut vertices = Vec::new(); + let mut clip_stack = Vec::new(); + let mut active_clip = ActiveClip::default(); for item in &scene.items { - if let DisplayItem::Quad(quad) = item { - push_quad_vertices(&mut vertices, quad.rect, quad.color, width, height); + match item { + DisplayItem::PushClip(region) => { + push_clip_state(&mut clip_stack, &mut active_clip, *region) + } + DisplayItem::PopClip => pop_clip_state(&mut clip_stack, &mut active_clip), + DisplayItem::Quad(quad) => push_quad_vertices( + &mut vertices, + quad.rect, + quad.color, + width, + height, + active_clip, + ), + DisplayItem::RoundedRect(rounded_rect) => { + push_rounded_rect_vertices(&mut vertices, *rounded_rect, width, height, active_clip) + } + DisplayItem::ShadowRect(shadow_rect) => { + push_shadow_rect_vertices(&mut vertices, *shadow_rect, width, height, active_clip) + } + _ => {} } } @@ -1501,46 +1863,279 @@ fn push_quad_vertices( color: Color, width: f32, height: f32, + clip: ActiveClip, ) { - let left = to_ndc_x(rect.origin.x, width); - let right = to_ndc_x(rect.origin.x + rect.size.width, width); - let top = to_ndc_y(rect.origin.y, height); - let bottom = to_ndc_y(rect.origin.y + rect.size.height, height); - let color = [ - color.r as f32 / 255.0, - color.g as f32 / 255.0, - color.b as f32 / 255.0, - color.a as f32 / 255.0, - ]; + push_shape_vertices( + vertices, + rect, + rect, + Some(color), + None, + [0.0, 0.0, 0.0, 0.0], + width, + height, + clip, + None, + 0.0, + ); +} + +fn push_rounded_rect_vertices( + vertices: &mut Vec, + rounded_rect: RoundedRect, + width: f32, + height: f32, + clip: ActiveClip, +) { + push_shape_vertices( + vertices, + rounded_rect.rect, + rounded_rect.rect, + rounded_rect.fill, + rounded_rect.border_color, + [rounded_rect.radius, rounded_rect.border_width, 0.0, 0.0], + width, + height, + clip, + None, + 0.0, + ); +} + +fn push_shadow_rect_vertices( + vertices: &mut Vec, + shadow_rect: ShadowRect, + width: f32, + height: f32, + clip: ActiveClip, +) { + let blur_extent = shadow_blur_extent(shadow_rect.blur); + let geometry_rect = match shadow_rect.kind { + BoxShadowKind::Outer => expand_rect(shadow_rect.rect, blur_extent), + BoxShadowKind::Inner => shadow_rect.source_rect, + }; + if geometry_rect.size.width <= 0.0 || geometry_rect.size.height <= 0.0 { + return; + } + + let shadow_kind = match shadow_rect.kind { + BoxShadowKind::Outer => 1.0, + BoxShadowKind::Inner => 2.0, + }; + + push_shape_vertices( + vertices, + geometry_rect, + shadow_rect.rect, + Some(shadow_rect.color), + None, + [shadow_rect.radius, 0.0, shadow_kind, blur_extent], + width, + height, + clip, + Some(shadow_rect.source_rect), + shadow_rect.source_radius, + ); +} + +#[allow(clippy::too_many_arguments)] +fn push_shape_vertices( + vertices: &mut Vec, + geometry_rect: Rect, + shader_rect: Rect, + fill_color: Option, + border_color: Option, + shape_params: [f32; 4], + width: f32, + height: f32, + clip: ActiveClip, + shadow_base_rect: Option, + shadow_base_radius: f32, +) { + let left = to_ndc_x(geometry_rect.origin.x, width); + let right = to_ndc_x(geometry_rect.origin.x + geometry_rect.size.width, width); + let top = to_ndc_y(geometry_rect.origin.y, height); + let bottom = to_ndc_y(geometry_rect.origin.y + geometry_rect.size.height, height); + let rect_data = rect_to_array(shader_rect); + let fill_color = fill_color.map_or([0.0, 0.0, 0.0, 0.0], color_to_f32); + let border_color = border_color.map_or([0.0, 0.0, 0.0, 0.0], color_to_f32); + let clip_rect = clip_rect_array(clip.rect); + let (rounded_clip_rect, clip_params) = rounded_clip_arrays(clip); + let shadow_base_rect = shadow_base_rect.map_or([0.0, 0.0, 0.0, 0.0], rect_to_array); vertices.extend_from_slice(&[ Vertex { position: [left, top], - color, + world_position: [geometry_rect.origin.x, geometry_rect.origin.y], + rect: rect_data, + fill_color, + border_color, + shape_params, + clip_rect, + rounded_clip_rect, + clip_params, + shadow_base_rect, + shadow_base_params: [shadow_base_radius, 0.0], }, Vertex { position: [left, bottom], - color, + world_position: [ + geometry_rect.origin.x, + geometry_rect.origin.y + geometry_rect.size.height, + ], + rect: rect_data, + fill_color, + border_color, + shape_params, + clip_rect, + rounded_clip_rect, + clip_params, + shadow_base_rect, + shadow_base_params: [shadow_base_radius, 0.0], }, Vertex { position: [right, top], - color, + world_position: [ + geometry_rect.origin.x + geometry_rect.size.width, + geometry_rect.origin.y, + ], + rect: rect_data, + fill_color, + border_color, + shape_params, + clip_rect, + rounded_clip_rect, + clip_params, + shadow_base_rect, + shadow_base_params: [shadow_base_radius, 0.0], }, Vertex { position: [right, top], - color, + world_position: [ + geometry_rect.origin.x + geometry_rect.size.width, + geometry_rect.origin.y, + ], + rect: rect_data, + fill_color, + border_color, + shape_params, + clip_rect, + rounded_clip_rect, + clip_params, + shadow_base_rect, + shadow_base_params: [shadow_base_radius, 0.0], }, Vertex { position: [left, bottom], - color, + world_position: [ + geometry_rect.origin.x, + geometry_rect.origin.y + geometry_rect.size.height, + ], + rect: rect_data, + fill_color, + border_color, + shape_params, + clip_rect, + rounded_clip_rect, + clip_params, + shadow_base_rect, + shadow_base_params: [shadow_base_radius, 0.0], }, Vertex { position: [right, bottom], - color, + world_position: [ + geometry_rect.origin.x + geometry_rect.size.width, + geometry_rect.origin.y + geometry_rect.size.height, + ], + rect: rect_data, + fill_color, + border_color, + shape_params, + clip_rect, + rounded_clip_rect, + clip_params, + shadow_base_rect, + shadow_base_params: [shadow_base_radius, 0.0], }, ]); } +fn rect_to_array(rect: Rect) -> [f32; 4] { + [ + rect.origin.x, + rect.origin.y, + rect.origin.x + rect.size.width, + rect.origin.y + rect.size.height, + ] +} + +fn expand_rect(rect: Rect, inset: f32) -> Rect { + Rect::new( + rect.origin.x - inset, + rect.origin.y - inset, + rect.size.width + inset * 2.0, + rect.size.height + inset * 2.0, + ) +} + +fn rect_to_pixel_rect(rect: Rect) -> PixelRect { + PixelRect { + left: rect.origin.x.floor() as i32, + top: rect.origin.y.floor() as i32, + right: (rect.origin.x + rect.size.width).ceil() as i32, + bottom: (rect.origin.y + rect.size.height).ceil() as i32, + } +} + +fn shadow_blur_extent(blur: f32) -> f32 { + blur.max(0.0) * 2.0 +} + +fn clip_rect_array(rect: Option) -> [f32; 4] { + rect.map_or([0.0, 0.0, 0.0, 0.0], rect_to_array) +} + +fn rounded_clip_arrays(clip: ActiveClip) -> ([f32; 4], [f32; 2]) { + let rounded_clip_rect = clip + .rounded + .map_or([0.0, 0.0, 0.0, 0.0], |(rect, _)| rect_to_array(rect)); + let clip_params = [ + if clip.rect.is_some() { 1.0 } else { 0.0 }, + clip.rounded.map_or(0.0, |(_, radius)| radius), + ]; + (rounded_clip_rect, clip_params) +} + +fn push_clip_state(stack: &mut Vec, active: &mut ActiveClip, region: ClipRegion) { + stack.push(*active); + if region.radius > 0.0 { + active.rounded = Some((region.rect, region.radius)); + } + active.rect = Some(intersect_rects(active.rect, Some(region.rect)).unwrap_or(region.rect)); +} + +fn pop_clip_state(stack: &mut Vec, active: &mut ActiveClip) { + *active = stack.pop().unwrap_or_default(); +} + +fn intersect_rects(first: Option, second: Option) -> Option { + match (first, second) { + (Some(a), Some(b)) => { + let left = a.origin.x.max(b.origin.x); + let top = a.origin.y.max(b.origin.y); + let right = (a.origin.x + a.size.width).min(b.origin.x + b.size.width); + let bottom = (a.origin.y + a.size.height).min(b.origin.y + b.size.height); + if right <= left || bottom <= top { + None + } else { + Some(Rect::new(left, top, right - left, bottom - top)) + } + } + (Some(rect), None) | (None, Some(rect)) => Some(rect), + (None, None) => None, + } +} + fn to_ndc_x(x: f32, width: f32) -> f32 { (x / width) * 2.0 - 1.0 } @@ -1549,6 +2144,7 @@ fn to_ndc_y(y: f32, height: f32) -> f32 { 1.0 - (y / height) * 2.0 } +#[allow(dead_code)] fn clipped_glyph_quad( glyph_rect: PixelRect, atlas_rect: AtlasRect,