From 8bd7d12c4f0e541d317e38f06e6e176632ffd7ab Mon Sep 17 00:00:00 2001 From: Bruce Mitchener Date: Sun, 12 Apr 2026 14:25:48 +0700 Subject: [PATCH 1/3] Introduce VisualLayerPlan and paint boundaries --- masonry_core/Cargo.toml | 4 +- masonry_core/src/app/mod.rs | 7 +- masonry_core/src/app/render_layers.rs | 165 +++++++ masonry_core/src/app/render_root.rs | 9 +- masonry_core/src/app/tracing_backend.rs | 5 + masonry_core/src/core/layer.rs | 38 ++ masonry_core/src/core/widget.rs | 15 +- masonry_core/src/lib.rs | 5 +- masonry_core/src/passes/paint.rs | 360 ++++++++++----- masonry_core/src/passes/paint/tests.rs | 571 ++++++++++++++++++++++++ masonry_imaging/Cargo.toml | 1 + masonry_imaging/src/lib.rs | 316 +++++++++---- masonry_imaging/src/texture_render.rs | 94 +++- masonry_testing/src/harness.rs | 21 +- masonry_testing/src/modular_widget.rs | 18 +- masonry_testing/src/recorder_widget.rs | 9 +- masonry_winit/src/app_driver.rs | 65 ++- masonry_winit/src/event_loop_runner.rs | 210 +++++---- masonry_winit/src/lib.rs | 11 +- 19 files changed, 1594 insertions(+), 330 deletions(-) create mode 100644 masonry_core/src/app/render_layers.rs create mode 100644 masonry_core/src/passes/paint/tests.rs diff --git a/masonry_core/Cargo.toml b/masonry_core/Cargo.toml index eba0e5c41b..f85076e226 100644 --- a/masonry_core/Cargo.toml +++ b/masonry_core/Cargo.toml @@ -32,7 +32,6 @@ kurbo.workspace = true parley.workspace = true peniko.workspace = true smallvec.workspace = true -time = { workspace = true, features = ["macros", "formatting"] } tracing = { workspace = true, features = ["default"] } tracing-core = { version = "0.1.36", default-features = false } tracing-subscriber = { version = "0.3.23", features = ["env-filter", "time"] } @@ -40,6 +39,9 @@ tracing-tracy = { version = "0.11.4", optional = true } tree_arena.workspace = true ui-events.workspace = true +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +time = { workspace = true, features = ["macros", "formatting"] } + [target.'cfg(target_arch = "wasm32")'.dependencies] console_error_panic_hook.workspace = true tracing-wasm.workspace = true diff --git a/masonry_core/src/app/mod.rs b/masonry_core/src/app/mod.rs index 69d8bf7479..2e484633e4 100644 --- a/masonry_core/src/app/mod.rs +++ b/masonry_core/src/app/mod.rs @@ -4,13 +4,14 @@ //! Types needed for running a Masonry app. mod layer_stack; +mod render_layers; mod render_root; mod tracing_backend; +pub use render_layers::{ + ExternalLayerKind, VisualLayer, VisualLayerBoundary, VisualLayerKind, VisualLayerPlan, +}; pub use render_root::{RenderRoot, RenderRootOptions, RenderRootSignal, WindowSizePolicy}; - -// Re-export paint result types for consumers of `RenderRoot::redraw()`. -pub use crate::passes::paint::{PaintResult, PaintedLayer}; pub use tracing_backend::{ TracingSubscriberHasBeenSetError, default_tracing_subscriber, try_init_test_tracing, try_init_tracing, diff --git a/masonry_core/src/app/render_layers.rs b/masonry_core/src/app/render_layers.rs new file mode 100644 index 0000000000..eb0ad890c6 --- /dev/null +++ b/masonry_core/src/app/render_layers.rs @@ -0,0 +1,165 @@ +// Copyright 2026 the Xilem Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Logical visual layers emitted by Masonry paint. +//! +//! These types are the paint-time/render-time view of Masonry layers. +//! They are distinct from the internal `LayerStack`, which owns persistent widget roots. + +use crate::core::WidgetId; +use crate::imaging::PaintSink; +use crate::imaging::record::{Scene, replay_transformed}; +use kurbo::{Affine, Rect}; + +/// The kind of host-owned external layer preserved in the visual layer plan. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum ExternalLayerKind { + /// A host-managed surface slot reserved within the widget tree. + Surface, +} + +/// Where a visual layer boundary came from in the widget model. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum VisualLayerBoundary { + /// A top-level layer root from `LayerStack`. + LayerRoot, + /// An in-tree widget boundary created during paint. + WidgetBoundary, +} + +/// The content realization of a visual layer. +pub enum VisualLayerKind { + /// Masonry-painted retained content, in the layer's local coordinate space. + Scene(Scene), + /// Host-owned external/native content identified by the layer root widget. + External(ExternalLayerKind), +} + +impl core::fmt::Debug for VisualLayerKind { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Scene(_) => f.write_str("Scene(..)"), + Self::External(kind) => f.debug_tuple("External").field(kind).finish(), + } + } +} + +/// A painted visual layer, ready for compositing or host realization. +/// +/// Scene-backed layers contain retained `imaging` content in the layer's local coordinate space. +/// External layers preserve a host-managed layer boundary. In both cases, apply +/// [`transform`](Self::transform) to place the layer in window space. +pub struct VisualLayer { + /// The content realization of this layer. + pub kind: VisualLayerKind, + /// Where this visual layer boundary originated. + pub boundary: VisualLayerBoundary, + /// Axis-aligned bounds of this layer's content in layer-local coordinates. + pub bounds: Rect, + /// Optional clip to apply in layer-local coordinates when realizing the layer. + pub clip: Option, + /// Transform from layer-local space to window space. + pub transform: Affine, + /// The root widget ID of this layer. + pub root_id: WidgetId, +} + +impl VisualLayer { + /// Create a scene-backed layer. + pub fn scene( + scene: Scene, + boundary: VisualLayerBoundary, + bounds: Rect, + clip: Option, + transform: Affine, + root_id: WidgetId, + ) -> Self { + Self { + kind: VisualLayerKind::Scene(scene), + boundary, + bounds, + clip, + transform, + root_id, + } + } + + /// Create an externally realized layer. + pub fn external( + kind: ExternalLayerKind, + boundary: VisualLayerBoundary, + bounds: Rect, + clip: Option, + transform: Affine, + root_id: WidgetId, + ) -> Self { + Self { + kind: VisualLayerKind::External(kind), + boundary, + bounds, + clip, + transform, + root_id, + } + } + + /// Returns the external-layer kind, if this is host-owned content. + pub fn external_kind(&self) -> Option { + match self.kind { + VisualLayerKind::External(kind) => Some(kind), + VisualLayerKind::Scene(_) => None, + } + } + + /// Returns the axis-aligned bounds in window coordinates. + pub fn window_bounds(&self) -> Rect { + self.transform.transform_rect_bbox(self.bounds) + } + + /// Returns the axis-aligned clip in window coordinates, if any. + pub fn window_clip_bounds(&self) -> Option { + self.clip + .map(|clip| self.transform.transform_rect_bbox(clip)) + } +} + +/// Ordered visual layers emitted by Masonry paint. +/// +/// Layers are ordered from bottom to top (painter order). The first layer is the base +/// application content. Additional layers represent tooltips, menus, isolated scene chunks, +/// and external/native layer boundaries. +pub struct VisualLayerPlan { + /// Ordered visual layers in painter order. + pub layers: Vec, +} + +impl VisualLayerPlan { + /// Replay all scene-backed layers into a sink in window coordinate space. + /// + /// This is the backend-agnostic way to consume Masonry's retained paint output. + pub fn replay_into(&self, sink: &mut S) + where + S: PaintSink + ?Sized, + { + for layer in &self.layers { + if let VisualLayerKind::Scene(scene) = &layer.kind { + replay_transformed(scene, sink, layer.transform); + } + } + } + + /// Iterate the external layers in painter order together with their external-layer index. + pub fn external_layers(&self) -> impl Iterator { + self.layers + .iter() + .filter(|layer| layer.external_kind().is_some()) + .enumerate() + } + + /// Returns whether this plan contains any host-owned external layers. + pub fn has_external_layers(&self) -> bool { + self.layers + .iter() + .any(|layer| layer.external_kind().is_some()) + } +} diff --git a/masonry_core/src/app/render_root.rs b/masonry_core/src/app/render_root.rs index 74dffe40e9..012e8446d2 100644 --- a/masonry_core/src/app/render_root.rs +++ b/masonry_core/src/app/render_root.rs @@ -16,6 +16,7 @@ use tracing::{debug, info_span, warn}; use tree_arena::{ArenaMut, TreeArena}; use crate::app::layer_stack::LayerStack; +use crate::app::render_layers::VisualLayerPlan; use crate::core::{ AccessCtx, AccessEvent, BrushIndex, CursorIcon, DefaultProperties, ErasedAction, FromDynWidget, Handled, Ime, LayerType, NewWidget, PointerEvent, PropertiesRef, PropertyArena, QueryCtx, @@ -32,7 +33,7 @@ use crate::passes::event::{ }; use crate::passes::layout::run_layout_pass; use crate::passes::mutate::{mutate_widget, run_mutate_pass}; -use crate::passes::paint::{PaintResult, run_paint_pass}; +use crate::passes::paint::run_paint_pass; use crate::passes::update::{ run_update_disabled_pass, run_update_focus_pass, run_update_focusable_pass, run_update_fonts_pass, run_update_pointer_pass, run_update_props_pass, run_update_scroll_pass, @@ -572,16 +573,16 @@ impl RenderRoot { /// /// Returns an update to the accessibility tree and retained `imaging` scenes representing /// the widget tree's current state. - pub fn redraw(&mut self) -> (PaintResult, Option) { + pub fn redraw(&mut self) -> (VisualLayerPlan, Option) { self.run_rewrite_passes(); let access_tree_active = self.global_state.access_tree_active; // TODO - Handle invalidation regions - let paint_result = run_paint_pass(self); + let visual_layers = run_paint_pass(self); let tree_update = access_tree_active .then(|| run_accessibility_pass(self, self.global_state.scale_factor)); - (paint_result, tree_update) + (visual_layers, tree_update) } /// Returns the current icon that the mouse should display. diff --git a/masonry_core/src/app/tracing_backend.rs b/masonry_core/src/app/tracing_backend.rs index 8ee82b5d19..510ef18fb3 100644 --- a/masonry_core/src/app/tracing_backend.rs +++ b/masonry_core/src/app/tracing_backend.rs @@ -15,13 +15,18 @@ use std::error::Error; use std::fmt; +#[cfg(not(target_arch = "wasm32"))] use std::fs::File; +#[cfg(not(target_arch = "wasm32"))] use std::time::UNIX_EPOCH; +#[cfg(not(target_arch = "wasm32"))] use time::macros::format_description; use tracing::Subscriber; +#[cfg(not(target_arch = "wasm32"))] use tracing_subscriber::EnvFilter; use tracing_subscriber::filter::LevelFilter; +#[cfg(not(target_arch = "wasm32"))] use tracing_subscriber::fmt::time::UtcTime; use tracing_subscriber::prelude::*; diff --git a/masonry_core/src/core/layer.rs b/masonry_core/src/core/layer.rs index 79b9aafe42..850b2134dc 100644 --- a/masonry_core/src/core/layer.rs +++ b/masonry_core/src/core/layer.rs @@ -25,6 +25,34 @@ pub enum LayerType { Other, } +/// How a layer root wants its content to be realized by the host. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum LayerRealization { + /// Masonry paints this layer into a retained `imaging` scene. + #[default] + Scene, + /// Masonry preserves this layer boundary for host-managed realization. + /// + /// This is intended for content such as foreign surfaces, 3D viewports, or + /// platform-native compositor layers. + External, +} + +/// How a widget subtree participates in the ordered paint layer plan. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum PaintLayerMode { + /// Paint inline into the parent scene chunk. + #[default] + Inline, + /// Paint the subtree into its own retained scene layer while preserving painter order. + IsolatedScene, + /// Reserve the subtree as a host-managed external layer. + /// + /// Masonry still traverses the subtree to clear paint invalidation flags, but its retained + /// scene output is discarded and the host is expected to realize the layer separately. + External, +} + /// The trait implemented by widgets which are meant to be at the root of /// a [layer](crate::doc::masonry_concepts#layers). pub trait Layer: Widget { @@ -40,4 +68,14 @@ pub trait Layer: Widget { props: &mut PropertiesMut<'_>, event: &PointerEvent, ); + + /// Returns how this layer wants its contents to be realized. + /// + /// The default is [`LayerRealization::Scene`], which means Masonry paints the layer into + /// a retained `imaging` scene. Layers that represent foreign or host-native content can + /// override this to [`LayerRealization::External`] so render backends preserve the layer + /// boundary instead of flattening it. + fn realization(&self) -> LayerRealization { + LayerRealization::Scene + } } diff --git a/masonry_core/src/core/widget.rs b/masonry_core/src/core/widget.rs index 439128e04d..07cc83a131 100644 --- a/masonry_core/src/core/widget.rs +++ b/masonry_core/src/core/widget.rs @@ -14,9 +14,9 @@ use tracing::{Span, trace_span}; use crate::core::{ AccessCtx, AccessEvent, ActionCtx, ComposeCtx, CursorIcon, ErasedAction, EventCtx, Layer, - LayoutCtx, MeasureCtx, NewWidget, PaintCtx, PointerEvent, PropertiesMut, PropertiesRef, - PropertySet, QueryCtx, RegisterCtx, TextEvent, Update, UpdateCtx, WidgetMut, WidgetRef, - pre_paint, + LayoutCtx, MeasureCtx, NewWidget, PaintCtx, PaintLayerMode, PointerEvent, PropertiesMut, + PropertiesRef, PropertySet, QueryCtx, RegisterCtx, TextEvent, Update, UpdateCtx, WidgetMut, + WidgetRef, pre_paint, }; use crate::imaging::Painter; use crate::layout::LenReq; @@ -455,6 +455,15 @@ pub trait Widget: AsDynWidget + Any { None } + /// Returns how this widget subtree should participate in Masonry's ordered paint layer plan. + /// + /// Most widgets paint [`PaintLayerMode::Inline`] into their parent's retained scene chunk. + /// Widgets that need a separate compositing unit can opt into + /// [`PaintLayerMode::IsolatedScene`] or [`PaintLayerMode::External`]. + fn paint_layer_mode(&self) -> PaintLayerMode { + PaintLayerMode::Inline + } + /// Whether this widget gets pointer events and [hovered] status. True by default. /// /// If false, the widget will be treated as "transparent" for the pointer, meaning diff --git a/masonry_core/src/lib.rs b/masonry_core/src/lib.rs index e606775726..50d09c5748 100644 --- a/masonry_core/src/lib.rs +++ b/masonry_core/src/lib.rs @@ -75,7 +75,10 @@ #![cfg_attr(not(debug_assertions), expect(unused, reason = "Deferred: Noisy"))] #![expect(missing_debug_implementations, reason = "Deferred: Noisy")] #![expect(clippy::cast_possible_truncation, reason = "Deferred: Noisy")] -#![expect(clippy::large_enum_variant, reason = "FIXME: NewWidget is too large")] +#![cfg_attr( + not(target_arch = "wasm32"), + expect(clippy::large_enum_variant, reason = "FIXME: NewWidget is too large") +)] pub use imaging; pub use peniko::color::palette; diff --git a/masonry_core/src/passes/paint.rs b/masonry_core/src/passes/paint.rs index 9cd15a378e..89a3316cbb 100644 --- a/masonry_core/src/passes/paint.rs +++ b/masonry_core/src/passes/paint.rs @@ -3,61 +3,85 @@ use std::collections::HashMap; -use kurbo::{Affine, Line, Stroke}; +use kurbo::{Affine, Line, Rect, Stroke}; use peniko::{Color, Fill}; use tracing::{info_span, trace}; use tree_arena::ArenaMut; -use crate::app::{RenderRoot, RenderRootState}; +use crate::app::{ + ExternalLayerKind, RenderRoot, RenderRootState, VisualLayer, VisualLayerBoundary, + VisualLayerKind, VisualLayerPlan, +}; use crate::core::{ - DefaultProperties, PaintCtx, PropertiesRef, PropertyArena, WidgetArenaNode, WidgetId, + DefaultProperties, LayerRealization, PaintCtx, PaintLayerMode, PropertiesRef, PropertyArena, + WidgetArenaNode, WidgetId, }; -use crate::imaging::record::{Clip, Geometry, Scene, replay, replay_transformed}; +use crate::imaging::record::{Clip, Geometry, Scene}; use crate::imaging::{PaintSink, Painter}; use crate::passes::{enter_span_if, recurse_on_children}; use crate::util::get_debug_color; -/// A painted overlay layer, ready for compositing. -/// -/// The retained `imaging` scene is in the layer's local coordinate space. To composite into -/// window space, apply the layer's [`transform`](Self::transform). -pub struct PaintedLayer { - /// The retained `imaging` scene for this layer, in the layer's local coordinate space. - pub scene: Scene, - /// Transform from layer-local space to window space. - /// - /// For layers placed at a simple position, this is a translation. - /// Apply this transform when compositing the layer's scene into window space. - pub transform: Affine, - /// The root widget ID of this layer. - pub root_id: WidgetId, +fn flush_scene_layer( + scene: &mut Scene, + layers: &mut Vec, + boundary: VisualLayerBoundary, + bounds: Rect, + clip: Option, + transform: Affine, + root_id: WidgetId, +) { + if !scene.commands().is_empty() { + layers.push(VisualLayer::scene( + std::mem::take(scene), + boundary, + bounds, + clip, + transform, + root_id, + )); + } } -/// Result of the paint pass — one retained `imaging` scene per layer. -/// -/// The base layer contains the main application content in window coordinate space. -/// Overlay layers contain tooltips, menus, and other popups in layer-local coordinate -/// space, ordered from bottom to top (painter's order). -pub struct PaintResult { - /// The base retained `imaging` scene (main application content) in window coordinate space. - pub base: Scene, - /// Overlay layer scenes in z-order (bottom to top), each in layer-local coordinates. - pub overlays: Vec, +#[derive(Clone, Copy)] +struct LayerPaintState { + root_id: WidgetId, + boundary: VisualLayerBoundary, + bounds: Rect, + clip: Option, + transform: Affine, + window_to_layer_transform: Affine, } -impl PaintResult { - /// Replay all layers into a sink in window coordinate space. - /// - /// This is the backend-agnostic way to consume Masonry's retained paint output. - pub fn replay_into(&self, sink: &mut S) - where - S: PaintSink + ?Sized, - { - replay(&self.base, sink); - for layer in &self.overlays { - replay_transformed(&layer.scene, sink, layer.transform); - } - } +fn paint_subtree_as_layers( + global_state: &mut RenderRootState, + default_properties: &DefaultProperties, + property_arena: &PropertyArena, + scene_cache: &mut HashMap, + layer: LayerPaintState, + node: ArenaMut<'_, WidgetArenaNode>, +) -> Vec { + let mut current_scene = Scene::new(); + let mut layers = Vec::new(); + paint_widget( + global_state, + default_properties, + property_arena, + &mut current_scene, + &mut layers, + scene_cache, + layer, + node, + ); + flush_scene_layer( + &mut current_scene, + &mut layers, + layer.boundary, + layer.bounds, + layer.clip, + layer.transform, + layer.root_id, + ); + layers } // --- MARK: PAINT WIDGET @@ -65,9 +89,10 @@ fn paint_widget( global_state: &mut RenderRootState, default_properties: &DefaultProperties, property_arena: &PropertyArena, - complete_scene: &mut Scene, + current_scene: &mut Scene, + layers: &mut Vec, scene_cache: &mut HashMap, - window_to_layer_transform: &Affine, + current_layer: LayerPaintState, node: ArenaMut<'_, WidgetArenaNode>, ) { let mut children = node.children; @@ -134,7 +159,8 @@ fn paint_widget( state.request_post_paint = false; state.needs_paint = false; - let border_box_to_layer_transform = *window_to_layer_transform * state.window_transform; + let border_box_to_layer_transform = + current_layer.window_to_layer_transform * state.window_transform; let content_box_to_layer_transform = border_box_to_layer_transform.pre_translate(state.border_box_translation()); let has_clip = state.clip_path.is_some(); @@ -146,18 +172,18 @@ fn paint_widget( return; }; - complete_scene.append_transformed(pre_scene, content_box_to_layer_transform); + current_scene.append_transformed(pre_scene, content_box_to_layer_transform); if let Some(clip) = state.clip_path { // The clip path is stored in border-box space, so need to use that transform. - complete_scene.push_clip(Clip::Fill { + current_scene.push_clip(Clip::Fill { transform: border_box_to_layer_transform, shape: Geometry::Rect(clip), fill_rule: Fill::NonZero, }); } - complete_scene.append_transformed(scene, content_box_to_layer_transform); + current_scene.append_transformed(scene, content_box_to_layer_transform); } let parent_state = &mut *state; @@ -167,15 +193,70 @@ fn paint_widget( // - Some widgets can paint outside of their layout box. // - Once we implement compositor layers, we may want to paint outside of the clip path anyway in anticipation of user scrolling. // - We still want to reset needs_paint and request_paint flags. - paint_widget( - global_state, - default_properties, - property_arena, - complete_scene, - scene_cache, - window_to_layer_transform, - node.reborrow_mut(), - ); + let child_mode = node.item.widget.paint_layer_mode(); + match child_mode { + PaintLayerMode::Inline => paint_widget( + global_state, + default_properties, + property_arena, + current_scene, + layers, + scene_cache, + current_layer, + node.reborrow_mut(), + ), + PaintLayerMode::IsolatedScene | PaintLayerMode::External => { + flush_scene_layer( + current_scene, + layers, + current_layer.boundary, + current_layer.bounds, + current_layer.clip, + current_layer.transform, + current_layer.root_id, + ); + + let child_transform = node.item.state.window_transform; + let child_layer = LayerPaintState { + root_id: node.item.state.id, + boundary: VisualLayerBoundary::WidgetBoundary, + bounds: child_transform + .inverse() + .transform_rect_bbox(node.item.state.bounding_box), + clip: node.item.state.clip_path, + transform: child_transform, + window_to_layer_transform: child_transform.inverse(), + }; + + if child_mode == PaintLayerMode::IsolatedScene { + layers.extend(paint_subtree_as_layers( + global_state, + default_properties, + property_arena, + scene_cache, + child_layer, + node.reborrow_mut(), + )); + } else { + let _ = paint_subtree_as_layers( + global_state, + default_properties, + property_arena, + scene_cache, + child_layer, + node.reborrow_mut(), + ); + layers.push(VisualLayer::external( + ExternalLayerKind::Surface, + child_layer.boundary, + child_layer.bounds, + child_layer.clip, + child_layer.transform, + child_layer.root_id, + )); + } + } + } parent_state.merge_up(&mut node.item.state); }); @@ -186,7 +267,7 @@ fn paint_widget( let color = get_debug_color(id.to_raw()); let rect = state.bounding_box.inset(BORDER_WIDTH / -2.0); let border_style = Stroke::new(BORDER_WIDTH); - let mut painter = Painter::new(&mut *complete_scene); + let mut painter = Painter::new(&mut *current_scene); painter.stroke(rect, &border_style, color).draw(); // Draw the widget's explicit baselines @@ -207,7 +288,7 @@ fn paint_widget( } if has_clip { - complete_scene.pop_clip(); + current_scene.pop_clip(); } let Some((_, _, post_scene)) = &mut scene_cache.get(&id) else { @@ -217,13 +298,13 @@ fn paint_widget( return; }; - complete_scene.append_transformed(post_scene, content_box_to_layer_transform); + current_scene.append_transformed(post_scene, content_box_to_layer_transform); } } // --- MARK: ROOT /// See the [passes documentation](crate::doc::pass_system#render-passes). -pub(crate) fn run_paint_pass(root: &mut RenderRoot) -> PaintResult { +pub(crate) fn run_paint_pass(root: &mut RenderRoot) -> VisualLayerPlan { let _span = info_span!("paint").entered(); // TODO - This is a bit of a hack until we refactor widget tree mutation. @@ -233,39 +314,72 @@ pub(crate) fn run_paint_pass(root: &mut RenderRoot) -> PaintResult { let root_id = root.root_id(); let layer_root_ids = root.layer_root_ids(); - // Paint each layer into its own scene. - let mut base_scene = Scene::new(); - let mut overlays = Vec::new(); + let mut layers = Vec::new(); for (idx, &layer_widget_id) in layer_root_ids.iter().enumerate() { + let (layer_to_window_transform, layer_bounds, layer_clip) = { + let layer_state = root.widget_arena.get_state(layer_widget_id); + let transform = layer_state.window_transform; + let bounds = transform + .inverse() + .transform_rect_bbox(layer_state.bounding_box); + (transform, bounds, layer_state.clip_path) + }; if idx == 0 { - paint_layer( + layers.extend(paint_layer( root, - &mut base_scene, &mut scene_cache, root_id, layer_widget_id, + VisualLayerBoundary::LayerRoot, + layer_bounds, + layer_clip, + layer_to_window_transform, + )); + continue; + } + + let layer_realization = root + .widget_arena + .get_node_mut(layer_widget_id) + .item + .widget + .as_layer() + .map(|layer| layer.realization()) + .unwrap_or(LayerRealization::Scene); + + if layer_realization == LayerRealization::External { + // Still run the paint traversal so we clear paint flags and keep cache state + // consistent, but the resulting retained scene is intentionally discarded. + let _ = paint_layer( + root, + &mut scene_cache, + root_id, + layer_widget_id, + VisualLayerBoundary::LayerRoot, + layer_bounds, + layer_clip, + layer_to_window_transform, ); + layers.push(VisualLayer::external( + ExternalLayerKind::Surface, + VisualLayerBoundary::LayerRoot, + layer_bounds, + layer_clip, + layer_to_window_transform, + layer_widget_id, + )); } else { - let mut layer_scene = Scene::new(); - paint_layer( + layers.extend(paint_layer( root, - &mut layer_scene, &mut scene_cache, root_id, layer_widget_id, - ); - - let layer_to_window_transform = root - .widget_arena - .get_state(layer_widget_id) - .window_transform; - - overlays.push(PaintedLayer { - scene: layer_scene, - transform: layer_to_window_transform, - root_id: layer_widget_id, - }); + VisualLayerBoundary::LayerRoot, + layer_bounds, + layer_clip, + layer_to_window_transform, + )); } } @@ -295,42 +409,37 @@ pub(crate) fn run_paint_pass(root: &mut RenderRoot) -> PaintResult { let border_box_to_layer_transform = window_to_layer_transform * border_box_to_window_transform; - // Draw the hover rect in the owning layer's scene. - if layer_root == layer_root_ids[0] { - Painter::new(&mut base_scene) - .fill(rect, HOVER_FILL_COLOR) - .transform(border_box_to_layer_transform) - .draw(); - } else if let Some(layer) = overlays.iter_mut().find(|l| l.root_id == layer_root) { - Painter::new(&mut layer.scene) - .fill(rect, HOVER_FILL_COLOR) - .transform(border_box_to_layer_transform) - .draw(); - } else { - Painter::new(&mut base_scene) + // Draw the hover rect in the owning layer's most-recent scene chunk. + if let Some(layer) = layers + .iter_mut() + .rev() + .find(|layer| layer.root_id == layer_root) + && let VisualLayerKind::Scene(scene) = &mut layer.kind + { + Painter::new(scene) .fill(rect, HOVER_FILL_COLOR) .transform(border_box_to_layer_transform) .draw(); } } - PaintResult { - base: base_scene, - overlays, - } + VisualLayerPlan { layers } } -/// Paint a single layer's widget subtree into `target_scene`. +/// Paint a single layer root into ordered render layers. /// /// This is a helper that handles the split borrows needed to access /// `global_state`, `default_properties`, and `widget_arena` simultaneously. fn paint_layer( root: &mut RenderRoot, - target_scene: &mut Scene, scene_cache: &mut HashMap, root_id: WidgetId, layer_widget_id: WidgetId, -) { + layer_boundary: VisualLayerBoundary, + layer_bounds: Rect, + layer_clip: Option, + layer_transform: Affine, +) -> Vec { // Clear the LayerStack's own paint flags (its paint is a no-op). // This is idempotent so safe to call per-layer. { @@ -348,18 +457,47 @@ fn paint_layer( debug_panic!( "Error in paint pass: cannot find layer child {layer_widget_id:?} in LayerStack" ); - return; + return Vec::new(); }; - let window_to_layer_transform = layer_node.item.state.window_transform.inverse(); + let layer_state = LayerPaintState { + root_id: layer_widget_id, + boundary: layer_boundary, + bounds: layer_bounds, + clip: layer_clip, + transform: layer_transform, + window_to_layer_transform: layer_node.item.state.window_transform.inverse(), + }; + let paint_layer_mode = layer_node.item.widget.paint_layer_mode(); - paint_widget( - &mut root.global_state, - &root.property_arena.default_properties, - &root.property_arena, - target_scene, - scene_cache, - &window_to_layer_transform, - layer_node, - ); + if paint_layer_mode == PaintLayerMode::External { + let _ = paint_subtree_as_layers( + &mut root.global_state, + &root.property_arena.default_properties, + &root.property_arena, + scene_cache, + layer_state, + layer_node, + ); + vec![VisualLayer::external( + ExternalLayerKind::Surface, + layer_state.boundary, + layer_state.bounds, + layer_state.clip, + layer_state.transform, + layer_state.root_id, + )] + } else { + paint_subtree_as_layers( + &mut root.global_state, + &root.property_arena.default_properties, + &root.property_arena, + scene_cache, + layer_state, + layer_node, + ) + } } + +#[cfg(test)] +mod tests; diff --git a/masonry_core/src/passes/paint/tests.rs b/masonry_core/src/passes/paint/tests.rs new file mode 100644 index 0000000000..a4b01dc571 --- /dev/null +++ b/masonry_core/src/passes/paint/tests.rs @@ -0,0 +1,571 @@ +// Copyright 2026 the Xilem Authors +// SPDX-License-Identifier: Apache-2.0 + +use std::sync::Arc; + +use accesskit::{Node, Role}; + +use super::run_paint_pass; +use crate::app::{ + ExternalLayerKind, RenderRoot, RenderRootOptions, VisualLayer, VisualLayerBoundary, + VisualLayerKind, VisualLayerPlan, WindowSizePolicy, +}; +use crate::core::{ + AccessCtx, AccessEvent, ChildrenIds, DefaultProperties, LayoutCtx, MeasureCtx, NewWidget, + NoAction, PaintCtx, PaintLayerMode, PointerEvent, PropertiesMut, PropertiesRef, RegisterCtx, + TextEvent, Update, UpdateCtx, Widget, WidgetId, WidgetPod, +}; +use crate::dpi::PhysicalSize; +use crate::imaging::Painter; +use crate::imaging::record::Scene; +use crate::kurbo::{Axis, Point, Rect, Size}; +use crate::layout::{LenReq, SizeDef}; +use crate::peniko::Color; + +struct PaintLeaf { + color: Color, + paint_layer_mode: PaintLayerMode, +} + +impl Widget for PaintLeaf { + type Action = NoAction; + + fn register_children(&mut self, _ctx: &mut RegisterCtx<'_>) {} + + fn update( + &mut self, + _ctx: &mut UpdateCtx<'_>, + _props: &mut PropertiesMut<'_>, + _event: &Update, + ) { + } + + fn on_pointer_event( + &mut self, + _ctx: &mut crate::core::EventCtx<'_>, + _props: &mut PropertiesMut<'_>, + _event: &PointerEvent, + ) { + } + + fn on_text_event( + &mut self, + _ctx: &mut crate::core::EventCtx<'_>, + _props: &mut PropertiesMut<'_>, + _event: &TextEvent, + ) { + } + + fn on_access_event( + &mut self, + _ctx: &mut crate::core::EventCtx<'_>, + _props: &mut PropertiesMut<'_>, + _event: &AccessEvent, + ) { + } + + fn measure( + &mut self, + _ctx: &mut MeasureCtx<'_>, + _props: &PropertiesRef<'_>, + axis: Axis, + _len_req: LenReq, + _cross_length: Option, + ) -> f64 { + if axis == Axis::Horizontal { 10.0 } else { 8.0 } + } + + fn layout(&mut self, _ctx: &mut LayoutCtx<'_>, _props: &PropertiesRef<'_>, _size: Size) {} + + fn paint( + &mut self, + ctx: &mut PaintCtx<'_>, + _props: &PropertiesRef<'_>, + painter: &mut Painter<'_>, + ) { + painter.fill(ctx.content_box(), self.color).draw(); + } + + fn accessibility_role(&self) -> Role { + Role::GenericContainer + } + + fn accessibility( + &mut self, + _ctx: &mut AccessCtx<'_>, + _props: &PropertiesRef<'_>, + _node: &mut Node, + ) { + } + + fn children_ids(&self) -> ChildrenIds { + ChildrenIds::new() + } + + fn paint_layer_mode(&self) -> PaintLayerMode { + self.paint_layer_mode + } +} + +struct TripleRow { + left: WidgetPod, + middle: WidgetPod, + right: WidgetPod, +} + +impl Widget for TripleRow { + type Action = NoAction; + + fn register_children(&mut self, ctx: &mut RegisterCtx<'_>) { + ctx.register_child(&mut self.left); + ctx.register_child(&mut self.middle); + ctx.register_child(&mut self.right); + } + + fn update( + &mut self, + _ctx: &mut UpdateCtx<'_>, + _props: &mut PropertiesMut<'_>, + _event: &Update, + ) { + } + + fn on_pointer_event( + &mut self, + _ctx: &mut crate::core::EventCtx<'_>, + _props: &mut PropertiesMut<'_>, + _event: &PointerEvent, + ) { + } + + fn on_text_event( + &mut self, + _ctx: &mut crate::core::EventCtx<'_>, + _props: &mut PropertiesMut<'_>, + _event: &TextEvent, + ) { + } + + fn on_access_event( + &mut self, + _ctx: &mut crate::core::EventCtx<'_>, + _props: &mut PropertiesMut<'_>, + _event: &AccessEvent, + ) { + } + + fn measure( + &mut self, + _ctx: &mut MeasureCtx<'_>, + _props: &PropertiesRef<'_>, + axis: Axis, + _len_req: LenReq, + _cross_length: Option, + ) -> f64 { + if axis == Axis::Horizontal { 30.0 } else { 8.0 } + } + + fn layout(&mut self, ctx: &mut LayoutCtx<'_>, _props: &PropertiesRef<'_>, _size: Size) { + let child_size = ctx.compute_size( + &mut self.left, + SizeDef::fixed(Size::new(10.0, 8.0)), + Size::new(10.0, 8.0).into(), + ); + ctx.run_layout(&mut self.left, child_size); + ctx.place_child(&mut self.left, Point::new(0.0, 0.0)); + + let child_size = ctx.compute_size( + &mut self.middle, + SizeDef::fixed(Size::new(10.0, 8.0)), + Size::new(10.0, 8.0).into(), + ); + ctx.run_layout(&mut self.middle, child_size); + ctx.place_child(&mut self.middle, Point::new(10.0, 0.0)); + + let child_size = ctx.compute_size( + &mut self.right, + SizeDef::fixed(Size::new(10.0, 8.0)), + Size::new(10.0, 8.0).into(), + ); + ctx.run_layout(&mut self.right, child_size); + ctx.place_child(&mut self.right, Point::new(20.0, 0.0)); + } + + fn paint( + &mut self, + _ctx: &mut PaintCtx<'_>, + _props: &PropertiesRef<'_>, + _painter: &mut Painter<'_>, + ) { + } + + fn accessibility_role(&self) -> Role { + Role::GenericContainer + } + + fn accessibility( + &mut self, + _ctx: &mut AccessCtx<'_>, + _props: &PropertiesRef<'_>, + _node: &mut Node, + ) { + } + + fn children_ids(&self) -> ChildrenIds { + [self.left.id(), self.middle.id(), self.right.id()] + .into_iter() + .collect() + } +} + +struct OffsetBox { + child: WidgetPod, + offset: Point, + size: Size, +} + +impl Widget for OffsetBox { + type Action = NoAction; + + fn register_children(&mut self, ctx: &mut RegisterCtx<'_>) { + ctx.register_child(&mut self.child); + } + + fn update( + &mut self, + _ctx: &mut UpdateCtx<'_>, + _props: &mut PropertiesMut<'_>, + _event: &Update, + ) { + } + + fn on_pointer_event( + &mut self, + _ctx: &mut crate::core::EventCtx<'_>, + _props: &mut PropertiesMut<'_>, + _event: &PointerEvent, + ) { + } + + fn on_text_event( + &mut self, + _ctx: &mut crate::core::EventCtx<'_>, + _props: &mut PropertiesMut<'_>, + _event: &TextEvent, + ) { + } + + fn on_access_event( + &mut self, + _ctx: &mut crate::core::EventCtx<'_>, + _props: &mut PropertiesMut<'_>, + _event: &AccessEvent, + ) { + } + + fn measure( + &mut self, + _ctx: &mut MeasureCtx<'_>, + _props: &PropertiesRef<'_>, + axis: Axis, + _len_req: LenReq, + _cross_length: Option, + ) -> f64 { + if axis == Axis::Horizontal { + self.size.width + } else { + self.size.height + } + } + + fn layout(&mut self, ctx: &mut LayoutCtx<'_>, _props: &PropertiesRef<'_>, _size: Size) { + ctx.set_clip_path(self.size.to_rect()); + let child_size = + ctx.compute_size(&mut self.child, SizeDef::fixed(self.size), self.size.into()); + ctx.run_layout(&mut self.child, child_size); + ctx.place_child(&mut self.child, self.offset); + } + + fn paint( + &mut self, + _ctx: &mut PaintCtx<'_>, + _props: &PropertiesRef<'_>, + _painter: &mut Painter<'_>, + ) { + } + + fn accessibility_role(&self) -> Role { + Role::GenericContainer + } + + fn accessibility( + &mut self, + _ctx: &mut AccessCtx<'_>, + _props: &PropertiesRef<'_>, + _node: &mut Node, + ) { + } + + fn children_ids(&self) -> ChildrenIds { + [self.child.id()].into_iter().collect() + } +} + +struct MixedTripleRow { + left: WidgetPod, + middle: WidgetPod, + right: WidgetPod, +} + +impl Widget for MixedTripleRow { + type Action = NoAction; + + fn register_children(&mut self, ctx: &mut RegisterCtx<'_>) { + ctx.register_child(&mut self.left); + ctx.register_child(&mut self.middle); + ctx.register_child(&mut self.right); + } + + fn update( + &mut self, + _ctx: &mut UpdateCtx<'_>, + _props: &mut PropertiesMut<'_>, + _event: &Update, + ) { + } + + fn on_pointer_event( + &mut self, + _ctx: &mut crate::core::EventCtx<'_>, + _props: &mut PropertiesMut<'_>, + _event: &PointerEvent, + ) { + } + + fn on_text_event( + &mut self, + _ctx: &mut crate::core::EventCtx<'_>, + _props: &mut PropertiesMut<'_>, + _event: &TextEvent, + ) { + } + + fn on_access_event( + &mut self, + _ctx: &mut crate::core::EventCtx<'_>, + _props: &mut PropertiesMut<'_>, + _event: &AccessEvent, + ) { + } + + fn measure( + &mut self, + _ctx: &mut MeasureCtx<'_>, + _props: &PropertiesRef<'_>, + axis: Axis, + _len_req: LenReq, + _cross_length: Option, + ) -> f64 { + if axis == Axis::Horizontal { 30.0 } else { 8.0 } + } + + fn layout(&mut self, ctx: &mut LayoutCtx<'_>, _props: &PropertiesRef<'_>, _size: Size) { + let child_size = ctx.compute_size( + &mut self.left, + SizeDef::fixed(Size::new(10.0, 8.0)), + Size::new(10.0, 8.0).into(), + ); + ctx.run_layout(&mut self.left, child_size); + ctx.place_child(&mut self.left, Point::new(0.0, 0.0)); + + let child_size = ctx.compute_size( + &mut self.middle, + SizeDef::fixed(Size::new(10.0, 8.0)), + Size::new(10.0, 8.0).into(), + ); + ctx.run_layout(&mut self.middle, child_size); + ctx.place_child(&mut self.middle, Point::new(10.0, 0.0)); + + let child_size = ctx.compute_size( + &mut self.right, + SizeDef::fixed(Size::new(10.0, 8.0)), + Size::new(10.0, 8.0).into(), + ); + ctx.run_layout(&mut self.right, child_size); + ctx.place_child(&mut self.right, Point::new(20.0, 0.0)); + } + + fn paint( + &mut self, + _ctx: &mut PaintCtx<'_>, + _props: &PropertiesRef<'_>, + _painter: &mut Painter<'_>, + ) { + } + + fn accessibility_role(&self) -> Role { + Role::GenericContainer + } + + fn accessibility( + &mut self, + _ctx: &mut AccessCtx<'_>, + _props: &PropertiesRef<'_>, + _node: &mut Node, + ) { + } + + fn children_ids(&self) -> ChildrenIds { + [self.left.id(), self.middle.id(), self.right.id()] + .into_iter() + .collect() + } +} + +fn paint_result_for_middle(mode: PaintLayerMode) -> VisualLayerPlan { + let root = TripleRow { + left: NewWidget::new(PaintLeaf { + color: Color::from_rgb8(255, 0, 0), + paint_layer_mode: PaintLayerMode::Inline, + }) + .to_pod(), + middle: NewWidget::new(PaintLeaf { + color: Color::from_rgb8(0, 255, 0), + paint_layer_mode: mode, + }) + .to_pod(), + right: NewWidget::new(PaintLeaf { + color: Color::from_rgb8(0, 0, 255), + paint_layer_mode: PaintLayerMode::Inline, + }) + .to_pod(), + }; + let mut render_root = RenderRoot::new( + NewWidget::new(root), + |_| {}, + RenderRootOptions { + default_properties: Arc::new(DefaultProperties::new()), + use_system_fonts: false, + size_policy: WindowSizePolicy::User, + size: PhysicalSize::new(30, 8), + scale_factor: 1.0, + test_font: None, + }, + ); + render_root.run_rewrite_passes(); + run_paint_pass(&mut render_root) +} + +#[test] +fn replay_ignores_external_layers() { + let base = Scene::new(); + let external = VisualLayer::external( + ExternalLayerKind::Surface, + VisualLayerBoundary::WidgetBoundary, + Rect::ZERO, + None, + kurbo::Affine::IDENTITY, + WidgetId::next(), + ); + let result = VisualLayerPlan { + layers: vec![ + VisualLayer::scene( + base, + VisualLayerBoundary::LayerRoot, + Rect::ZERO, + None, + kurbo::Affine::IDENTITY, + WidgetId::next(), + ), + external, + ], + }; + + let mut sink = Scene::new(); + result.replay_into(&mut sink); + + assert!(matches!( + result.layers[1].kind, + VisualLayerKind::External(ExternalLayerKind::Surface) + )); +} + +#[test] +fn isolated_scene_widget_splits_ordered_layers() { + let result = paint_result_for_middle(PaintLayerMode::IsolatedScene); + + assert_eq!(result.layers.len(), 3); + assert!(matches!(result.layers[0].kind, VisualLayerKind::Scene(_))); + assert!(matches!(result.layers[1].kind, VisualLayerKind::Scene(_))); + assert!(matches!(result.layers[2].kind, VisualLayerKind::Scene(_))); + assert_eq!(result.layers[1].transform.translation(), (10.0, 0.0).into()); + assert_ne!(result.layers[0].root_id, result.layers[1].root_id); + assert_eq!(result.layers[0].root_id, result.layers[2].root_id); +} + +#[test] +fn external_widget_splits_ordered_layers() { + let result = paint_result_for_middle(PaintLayerMode::External); + + assert_eq!(result.layers.len(), 3); + assert!(matches!(result.layers[0].kind, VisualLayerKind::Scene(_))); + assert!(matches!( + result.layers[1].kind, + VisualLayerKind::External(ExternalLayerKind::Surface) + )); + assert!(matches!(result.layers[2].kind, VisualLayerKind::Scene(_))); + assert_eq!(result.layers[1].transform.translation(), (10.0, 0.0).into()); +} + +#[test] +fn nested_external_widget_preserves_ancestor_offsets() { + let root = MixedTripleRow { + left: NewWidget::new(PaintLeaf { + color: Color::from_rgb8(255, 0, 0), + paint_layer_mode: PaintLayerMode::Inline, + }) + .to_pod(), + middle: NewWidget::new(OffsetBox { + child: NewWidget::new(PaintLeaf { + color: Color::from_rgb8(0, 255, 0), + paint_layer_mode: PaintLayerMode::External, + }) + .to_pod(), + offset: Point::new(7.0, 5.0), + size: Size::new(10.0, 8.0), + }) + .to_pod(), + right: NewWidget::new(PaintLeaf { + color: Color::from_rgb8(0, 0, 255), + paint_layer_mode: PaintLayerMode::Inline, + }) + .to_pod(), + }; + let mut render_root = RenderRoot::new( + NewWidget::new(root), + |_| {}, + RenderRootOptions { + default_properties: Arc::new(DefaultProperties::new()), + use_system_fonts: false, + size_policy: WindowSizePolicy::User, + size: PhysicalSize::new(30, 8), + scale_factor: 1.0, + test_font: None, + }, + ); + render_root.run_rewrite_passes(); + let result = run_paint_pass(&mut render_root); + + assert_eq!(result.layers.len(), 3); + assert!(matches!( + result.layers[1].kind, + VisualLayerKind::External(ExternalLayerKind::Surface) + )); + assert_eq!(result.layers[1].transform.translation(), (17.0, 5.0).into()); + assert_eq!(result.layers[1].bounds, Rect::new(0.0, 0.0, 10.0, 8.0)); + assert_eq!(result.layers[1].clip, None); + assert_eq!( + result.layers[1].window_bounds(), + Rect::new(17.0, 5.0, 27.0, 13.0) + ); +} diff --git a/masonry_imaging/Cargo.toml b/masonry_imaging/Cargo.toml index ae10d76520..d2101fbd27 100644 --- a/masonry_imaging/Cargo.toml +++ b/masonry_imaging/Cargo.toml @@ -24,6 +24,7 @@ imaging_vello_cpu = ["dep:imaging_vello_cpu"] imaging_skia = ["dep:imaging_skia"] [dependencies] +masonry_core.workspace = true imaging.workspace = true imaging_vello = { workspace = true, optional = true } imaging_vello_hybrid = { workspace = true, optional = true } diff --git a/masonry_imaging/src/lib.rs b/masonry_imaging/src/lib.rs index 7247359203..fca73d9f1c 100644 --- a/masonry_imaging/src/lib.rs +++ b/masonry_imaging/src/lib.rs @@ -9,7 +9,7 @@ //! `masonry_imaging` owns the bridge between Masonry paint output and concrete imaging backends. //! In this first slice that means: //! -//! - preparing flattened Masonry frame content from base content plus overlays +//! - preparing ordered Masonry render layers from base content plus overlays //! - exposing backend-specific renderer modules for `imaging_vello`, //! `imaging_vello_hybrid`, `imaging_vello_cpu`, and `imaging_skia` //! - exposing host-neutral texture rendering helpers for writing into caller-provided WGPU @@ -37,6 +37,7 @@ use imaging::record::{Scene, ValidateError, replay_transformed}; use imaging::render::RenderSource; use imaging::{PaintSink, Painter}; use kurbo::{Affine, Rect}; +use masonry_core::app::{ExternalLayerKind, VisualLayerBoundary, VisualLayerKind, VisualLayerPlan}; use peniko::Color; #[cfg(any(feature = "imaging_vello", feature = "imaging_vello_hybrid"))] @@ -140,136 +141,269 @@ pub mod image_render { } } -/// A Masonry overlay layer ready to be composited into window space. +/// Opaque reference to a host-owned external layer. +/// +/// This is a placeholder for content such as a 3D viewport or platform-native compositor layer. +/// Current render-source adapters do not realize external layers; compositor-aware hosts are +/// expected to handle them directly from a higher-level render plan. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ExternalLayerRef { + /// Stable layer identifier chosen by the host/widget integration. + pub id: u64, + /// Logical kind of external layer requested by Masonry. + pub kind: ExternalLayerKind, +} + +/// The content realization for a prepared layer. +#[derive(Clone, Copy, Debug)] +pub enum LayerKind<'a> { + /// Masonry-painted retained scene content. + Scene(&'a Scene), + /// Host-owned external/native content. + External(ExternalLayerRef), +} + +/// A Masonry render layer ready to be composited into window space. #[derive(Clone, Copy)] -pub struct Layer<'a> { - /// The retained scene for this layer in layer-local coordinates. - pub scene: &'a Scene, +pub struct PreparedLayer<'a> { + /// The content of this layer. + pub kind: LayerKind<'a>, + /// Where this layer boundary originated in the widget model. + pub boundary: VisualLayerBoundary, + /// Axis-aligned bounds of this layer's content in layer-local coordinates. + pub bounds: Rect, + /// Optional clip to apply in layer-local coordinates when realizing the layer. + pub clip: Option, /// Transform from layer-local coordinates into window coordinates. pub transform: Affine, } -impl core::fmt::Debug for Layer<'_> { +impl core::fmt::Debug for PreparedLayer<'_> { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - f.debug_struct("Layer") - .field("scene", &"(Scene)") + f.debug_struct("PreparedLayer") + .field("kind", &self.kind) + .field("boundary", &self.boundary) + .field("bounds", &self.bounds) + .field("clip", &self.clip) .field("transform", &self.transform) .finish() } } -/// A flattened Masonry frame ready to be adapted to a concrete render target. -/// -/// This is intentionally a single-target convenience type for Masonry's current rendering paths. -/// Future compositor-oriented work is expected to preserve more layer structure above this level. -#[derive(Clone, Copy, Debug)] -pub struct PreparedFrame<'a> { - /// Frame width in physical pixels. - pub width: u32, - /// Frame height in physical pixels. - pub height: u32, - /// Window scale factor. - pub scale_factor: f64, - /// Background color to paint before replaying scene content. - pub background_color: Color, - /// Base retained scene in root coordinates. - pub base: &'a Scene, - /// Overlay layers in painter order. - pub overlays: &'a [Layer<'a>], -} +impl<'a> PreparedLayer<'a> { + /// Create a Masonry-painted scene layer. + pub fn scene( + scene: &'a Scene, + boundary: VisualLayerBoundary, + bounds: Rect, + clip: Option, + transform: Affine, + ) -> Self { + Self { + kind: LayerKind::Scene(scene), + boundary, + bounds, + clip, + transform, + } + } -impl<'a> PreparedFrame<'a> { - /// Create a flattened Masonry frame from base content plus overlays. - pub fn new( - width: u32, - height: u32, - scale_factor: f64, - background_color: Color, - base: &'a Scene, - overlays: &'a [Layer<'a>], + /// Create a host-owned external layer placeholder. + pub fn external( + external: ExternalLayerRef, + boundary: VisualLayerBoundary, + bounds: Rect, + clip: Option, + transform: Affine, ) -> Self { Self { - width, - height, - scale_factor, - background_color, - base, - overlays, + kind: LayerKind::External(external), + boundary, + bounds, + clip, + transform, } } +} + +/// Compatibility alias for the old layer name. +pub type Layer<'a> = PreparedLayer<'a>; + +#[derive(Clone, Copy)] +enum LayerSource<'a> { + Prepared(&'a [PreparedLayer<'a>]), + Visual(&'a VisualLayerPlan), +} - /// Create a render source for window output in physical pixels. - pub fn window_source(self) -> WindowSource<'a> { - WindowSource { frame: self } +impl core::fmt::Debug for LayerSource<'_> { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Prepared(layers) => f.debug_tuple("Prepared").field(&layers.len()).finish(), + Self::Visual(layers) => f.debug_tuple("Visual").field(&layers.layers.len()).finish(), + } } +} - /// Create a screenshot render source with optional root padding. - pub fn snapshot_source(self, root_padding: u32) -> SnapshotSource<'a> { - SnapshotSource::new(self, root_padding) +impl LayerSource<'_> { + fn validate(self) -> Result<(), ValidateError> { + match self { + Self::Prepared(layers) => validate_prepared_layers(layers), + Self::Visual(layers) => validate_visual_layers(layers), + } + } + + fn replay_into(self, sink: &mut dyn PaintSink, transform: Affine) { + match self { + Self::Prepared(layers) => { + for layer in layers { + if let LayerKind::Scene(scene) = layer.kind { + replay_transformed(scene, sink, transform * layer.transform); + } + } + } + Self::Visual(layers) => { + for layer in &layers.layers { + if let VisualLayerKind::Scene(scene) = &layer.kind { + replay_transformed(scene, sink, transform * layer.transform); + } + } + } + } } } /// Masonry render source for a window-sized frame. #[derive(Clone, Copy, Debug)] pub struct WindowSource<'a> { - frame: PreparedFrame<'a>, + width: u32, + height: u32, + scale_factor: f64, + background_color: Color, + layers: LayerSource<'a>, } impl<'a> WindowSource<'a> { - /// Create a render source for window output in physical pixels. - pub fn new(frame: PreparedFrame<'a>) -> Self { - Self { frame } + /// Create a render source directly from Masonry-imaging prepared layers. + pub fn from_prepared_layers( + width: u32, + height: u32, + scale_factor: f64, + background_color: Color, + layers: &'a [PreparedLayer<'a>], + ) -> Self { + Self { + width, + height, + scale_factor, + background_color, + layers: LayerSource::Prepared(layers), + } + } + + /// Create a render source directly from Masonry visual layers. + pub fn from_visual_layers( + width: u32, + height: u32, + scale_factor: f64, + background_color: Color, + layers: &'a VisualLayerPlan, + ) -> Self { + Self { + width, + height, + scale_factor, + background_color, + layers: LayerSource::Visual(layers), + } } } impl RenderSource for WindowSource<'_> { fn validate(&self) -> Result<(), ValidateError> { - validate_layers(self.frame.base, self.frame.overlays) + self.layers.validate() } fn paint_into(&mut self, sink: &mut dyn PaintSink) { { let mut painter = Painter::new(sink); painter.fill_rect( - Rect::new( - 0.0, - 0.0, - f64::from(self.frame.width), - f64::from(self.frame.height), - ), - self.frame.background_color, + Rect::new(0.0, 0.0, f64::from(self.width), f64::from(self.height)), + self.background_color, ); } - let scale = Affine::scale(self.frame.scale_factor); - replay_transformed(self.frame.base, sink, scale); - for layer in self.frame.overlays { - replay_transformed(layer.scene, sink, scale * layer.transform); - } + self.layers + .replay_into(sink, Affine::scale(self.scale_factor)); } } /// Masonry render source for screenshot-style output with optional root padding. #[derive(Clone, Copy, Debug)] pub struct SnapshotSource<'a> { - frame: PreparedFrame<'a>, + background_color: Color, + layers: LayerSource<'a>, width: u32, height: u32, root_padding: u32, } impl<'a> SnapshotSource<'a> { - /// Create a screenshot render source. - pub fn new(frame: PreparedFrame<'a>, root_padding: u32) -> Self { - let (width, height) = padded_dimensions(frame.width, frame.height, root_padding); + /// Create a screenshot render source directly from Masonry-imaging prepared layers. + pub fn from_prepared_layers( + width: u32, + height: u32, + scale_factor: f64, + background_color: Color, + layers: &'a [PreparedLayer<'a>], + root_padding: u32, + ) -> Self { + Self::from_parts( + width, + height, + scale_factor, + background_color, + LayerSource::Prepared(layers), + root_padding, + ) + } + + fn from_parts( + width: u32, + height: u32, + _scale_factor: f64, + background_color: Color, + layers: LayerSource<'a>, + root_padding: u32, + ) -> Self { + let (width, height) = padded_dimensions(width, height, root_padding); Self { - frame, + background_color, + layers, width, height, root_padding, } } + /// Create a screenshot render source directly from Masonry visual layers. + pub fn from_visual_layers( + width: u32, + height: u32, + scale_factor: f64, + background_color: Color, + layers: &'a VisualLayerPlan, + root_padding: u32, + ) -> Self { + Self::from_parts( + width, + height, + scale_factor, + background_color, + LayerSource::Visual(layers), + root_padding, + ) + } + /// Output width in pixels. pub fn width(&self) -> u32 { self.width @@ -283,7 +417,7 @@ impl<'a> SnapshotSource<'a> { impl RenderSource for SnapshotSource<'_> { fn validate(&self) -> Result<(), ValidateError> { - validate_layers(self.frame.base, self.frame.overlays) + self.layers.validate() } fn paint_into(&mut self, sink: &mut dyn PaintSink) { @@ -291,7 +425,7 @@ impl RenderSource for SnapshotSource<'_> { let mut painter = Painter::new(sink); painter.fill_rect( Rect::new(0.0, 0.0, f64::from(self.width), f64::from(self.height)), - self.frame.background_color, + self.background_color, ); if self.root_padding != 0 { @@ -310,10 +444,7 @@ impl RenderSource for SnapshotSource<'_> { let padding_transform = Affine::translate((f64::from(self.root_padding), f64::from(self.root_padding))); - replay_transformed(self.frame.base, sink, padding_transform); - for layer in self.frame.overlays { - replay_transformed(layer.scene, sink, padding_transform * layer.transform); - } + self.layers.replay_into(sink, padding_transform); } } @@ -334,21 +465,44 @@ fn padding_rects(width: u32, height: u32, padding: u32) -> [[u32; 4]; 4] { ] } -fn validate_layers(base: &Scene, overlays: &[Layer<'_>]) -> Result<(), ValidateError> { - base.validate()?; - for layer in overlays { - layer.scene.validate()?; +fn validate_prepared_layers(layers: &[PreparedLayer<'_>]) -> Result<(), ValidateError> { + for layer in layers { + if let LayerKind::Scene(scene) = layer.kind { + scene.validate()?; + } + } + Ok(()) +} + +fn validate_visual_layers(layers: &VisualLayerPlan) -> Result<(), ValidateError> { + for layer in &layers.layers { + if let VisualLayerKind::Scene(scene) = &layer.kind { + scene.validate()?; + } } Ok(()) } #[cfg(test)] mod tests { - use super::padded_dimensions; + use masonry_core::app::VisualLayerPlan; + use peniko::Color; + + use super::{SnapshotSource, padded_dimensions}; #[test] fn padded_dimensions_avoid_zero() { assert_eq!(padded_dimensions(0, 0, 0), (1, 1)); assert_eq!(padded_dimensions(0, 2, 5), (11, 12)); } + + #[test] + fn snapshot_source_from_visual_layers_uses_padded_dimensions() { + let layers = VisualLayerPlan { layers: Vec::new() }; + let source = SnapshotSource::from_visual_layers(0, 2, 1.0, Color::WHITE, &layers, 5); + + assert_eq!(source.width(), 11); + assert_eq!(source.height(), 12); + assert!(imaging::render::RenderSource::validate(&source).is_ok()); + } } diff --git a/masonry_imaging/src/texture_render.rs b/masonry_imaging/src/texture_render.rs index 61dffcb602..c6a37b39e1 100644 --- a/masonry_imaging/src/texture_render.rs +++ b/masonry_imaging/src/texture_render.rs @@ -3,12 +3,13 @@ //! Host-neutral texture rendering helpers. //! -//! This module owns backend state for rendering Masonry paint output into a caller-provided WGPU +//! This module owns backend state for rendering Masonry visual layers into a caller-provided WGPU //! texture target. It does not own window surfaces or presentation. use wgpu; -use crate::PreparedFrame; +use masonry_core::app::VisualLayerPlan; +use peniko::Color; /// GPU target that Masonry content should be rendered into. #[derive(Clone, Copy, Debug)] @@ -26,10 +27,49 @@ pub struct RenderTarget<'a> { } /// Masonry paint output prepared for texture rendering. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy)] pub struct RenderInput<'a> { - /// Flattened Masonry frame content prepared for rendering. - pub frame: PreparedFrame<'a>, + /// Output width in physical pixels. + pub width: u32, + /// Output height in physical pixels. + pub height: u32, + /// Output scale factor. + pub scale_factor: f64, + /// Background color to paint before replaying Masonry scene layers. + pub background_color: Color, + /// Ordered visual layers to render. + pub visual_layers: &'a VisualLayerPlan, +} + +impl core::fmt::Debug for RenderInput<'_> { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("RenderInput") + .field("width", &self.width) + .field("height", &self.height) + .field("scale_factor", &self.scale_factor) + .field("background_color", &self.background_color) + .field("visual_layer_count", &self.visual_layers.layers.len()) + .finish() + } +} + +impl<'a> RenderInput<'a> { + /// Create texture-render input directly from Masonry visual layers. + pub fn new( + width: u32, + height: u32, + scale_factor: f64, + background_color: Color, + visual_layers: &'a VisualLayerPlan, + ) -> Self { + Self { + width, + height, + scale_factor, + background_color, + visual_layers, + } + } } /// Errors that can occur while rendering Masonry content into a target texture. @@ -83,8 +123,6 @@ impl Default for Renderer { mod non_vello { use imaging::render::TextureRenderer; - use crate::PreparedFrame; - #[derive(Debug)] pub(super) struct CachedRenderer { key: Option, @@ -121,13 +159,19 @@ mod non_vello { pub(super) fn render_window_source_to_texture( renderer: &mut R, - frame: PreparedFrame<'_>, + input: super::RenderInput<'_>, target: R::TextureTarget<'_>, ) -> Result<(), R::Error> where R: TextureRenderer, { - let mut source = frame.window_source(); + let mut source = crate::WindowSource::from_visual_layers( + input.width, + input.height, + input.scale_factor, + input.background_color, + input.visual_layers, + ); renderer.render_source_to_texture(&mut source, target) } } @@ -239,12 +283,8 @@ mod imp { }, )?; - render_window_source_to_texture( - renderer, - input.frame, - TextureTarget::new(target.texture), - ) - .map_err(Error::Render) + render_window_source_to_texture(renderer, input, TextureTarget::new(target.texture)) + .map_err(Error::Render) } } } @@ -336,9 +376,17 @@ mod imp { target: RenderTarget<'_>, input: RenderInput<'_>, ) -> Result<(), Error> { - let mut source = input.frame.window_source(); - let scene = build_scene_from_source(&mut source, input.frame.width, input.frame.height) - .map_err(Error::BuildScene)?; + let width = input.width; + let height = input.height; + let mut source = crate::WindowSource::from_visual_layers( + input.width, + input.height, + input.scale_factor, + input.background_color, + input.visual_layers, + ); + let scene = + build_scene_from_source(&mut source, width, height).map_err(Error::BuildScene)?; if self.inner.is_none() { let renderer_options = RendererOptions { @@ -372,8 +420,8 @@ mod imp { // WindowSource already paints the background into the scene, so keep // Vello's target clear transparent here instead of applying the base color twice. base_color: peniko::Color::from_rgba8(0, 0, 0, 0), - width: input.frame.width, - height: input.frame.height, + width, + height, antialiasing_method: AaConfig::Area, }; renderer @@ -504,6 +552,8 @@ mod imp { target: RenderTarget<'_>, input: RenderInput<'_>, ) -> Result<(), Error> { + let width = input.width; + let height = input.height; let renderer = self.inner.get_or_try_init( ( target.device as *const _ as usize, @@ -519,8 +569,8 @@ mod imp { render_window_source_to_texture( renderer, - input.frame, - TextureTarget::new(target.view, input.frame.width, input.frame.height), + input, + TextureTarget::new(target.view, width, height), ) .map_err(Error::Render) } diff --git a/masonry_testing/src/harness.rs b/masonry_testing/src/harness.rs index 07c2adf2bb..a49d2fb312 100644 --- a/masonry_testing/src/harness.rs +++ b/masonry_testing/src/harness.rs @@ -13,10 +13,10 @@ use std::time::UNIX_EPOCH; use image::{DynamicImage, ImageFormat, ImageReader, Rgba, RgbaImage}; use masonry_imaging::ImageRenderer as _; +use masonry_imaging::SnapshotSource; use masonry_imaging::image_render::{ BACKEND_NAME as IMAGING_BACKEND_NAME, Renderer as ImagingRenderer, new_headless_renderer, }; -use masonry_imaging::{Layer as ImagingLayer, PreparedFrame}; use oxipng::{Options, optimize_from_memory}; use tracing::debug; @@ -492,23 +492,18 @@ impl TestHarness { return RgbaImage::from_pixel(1, 1, Rgba([255, 255, 255, 255])); } - let overlays: Vec<_> = paint_result - .overlays - .iter() - .map(|layer| ImagingLayer { - scene: &layer.scene, - transform: layer.transform, - }) - .collect(); - let frame = PreparedFrame::new( + assert!( + !paint_result.has_external_layers(), + "masonry_testing does not support rendering external layers yet" + ); + let mut source = SnapshotSource::from_visual_layers( self.window_size.width, self.window_size.height, 1.0, self.background_color, - &paint_result.base, - &overlays, + &paint_result, + self.root_padding, ); - let mut source = frame.snapshot_source(self.root_padding); let width = source.width(); let height = source.height(); if self.renderer.is_none() { diff --git a/masonry_testing/src/modular_widget.rs b/masonry_testing/src/modular_widget.rs index ef313e86b6..e225ef9479 100644 --- a/masonry_testing/src/modular_widget.rs +++ b/masonry_testing/src/modular_widget.rs @@ -6,9 +6,9 @@ use std::any::TypeId; use masonry_core::accesskit::{Node, Role}; use masonry_core::core::{ AccessCtx, AccessEvent, ActionCtx, ChildrenIds, ComposeCtx, CursorIcon, ErasedAction, EventCtx, - Layer, LayoutCtx, MeasureCtx, NewWidget, NoAction, PaintCtx, PointerEvent, PropertiesMut, - PropertiesRef, PropertySet, QueryCtx, RegisterCtx, TextEvent, Update, UpdateCtx, Widget, - WidgetId, WidgetPod, WidgetRef, find_widget_under_pointer, pre_paint, + Layer, LayoutCtx, MeasureCtx, NewWidget, NoAction, PaintCtx, PaintLayerMode, PointerEvent, + PropertiesMut, PropertiesRef, PropertySet, QueryCtx, RegisterCtx, TextEvent, Update, UpdateCtx, + Widget, WidgetId, WidgetPod, WidgetRef, find_widget_under_pointer, pre_paint, }; use masonry_core::imaging::Painter; use masonry_core::kurbo::{Axis, Point, Size}; @@ -45,6 +45,7 @@ pub struct ModularWidget { /// The state passed to all the callbacks of this widget pub state: S, icon: CursorIcon, + paint_layer_mode: PaintLayerMode, accepts_pointer_interaction: bool, propagates_pointer_interaction: bool, accepts_focus: bool, @@ -77,6 +78,7 @@ impl ModularWidget { Self { state, icon: CursorIcon::Default, + paint_layer_mode: PaintLayerMode::Inline, accepts_pointer_interaction: true, propagates_pointer_interaction: true, accepts_focus: false, @@ -195,6 +197,12 @@ impl ModularWidget { self } + /// See [`Widget::paint_layer_mode`] + pub fn paint_layer_mode(mut self, mode: PaintLayerMode) -> Self { + self.paint_layer_mode = mode; + self + } + /// See [`Widget::propagates_pointer_interaction`] pub fn propagates_pointer_interaction(mut self, flag: bool) -> Self { self.propagates_pointer_interaction = flag; @@ -532,6 +540,10 @@ impl Widget for ModularWidget { } } + fn paint_layer_mode(&self) -> PaintLayerMode { + self.paint_layer_mode + } + fn as_layer(&mut self) -> Option<&mut dyn Layer> { None } diff --git a/masonry_testing/src/recorder_widget.rs b/masonry_testing/src/recorder_widget.rs index 05fcb4a8ef..0ccca2d943 100644 --- a/masonry_testing/src/recorder_widget.rs +++ b/masonry_testing/src/recorder_widget.rs @@ -16,8 +16,9 @@ use std::rc::Rc; use masonry_core::accesskit::{Node, Role}; use masonry_core::core::{ AccessCtx, AccessEvent, ActionCtx, ChildrenIds, ComposeCtx, CursorIcon, ErasedAction, EventCtx, - Layer, LayoutCtx, MeasureCtx, NewWidget, PaintCtx, PointerEvent, PropertiesMut, PropertiesRef, - PropertySet, QueryCtx, RegisterCtx, TextEvent, Update, UpdateCtx, Widget, WidgetId, WidgetRef, + Layer, LayoutCtx, MeasureCtx, NewWidget, PaintCtx, PaintLayerMode, PointerEvent, PropertiesMut, + PropertiesRef, PropertySet, QueryCtx, RegisterCtx, TextEvent, Update, UpdateCtx, Widget, + WidgetId, WidgetRef, }; use masonry_core::imaging::Painter; use masonry_core::kurbo::{Axis, Point, Size}; @@ -295,6 +296,10 @@ impl Widget for Recorder { self.child.children_ids() } + fn paint_layer_mode(&self) -> PaintLayerMode { + self.child.paint_layer_mode() + } + fn as_layer(&mut self) -> Option<&mut dyn Layer> { None } diff --git a/masonry_winit/src/app_driver.rs b/masonry_winit/src/app_driver.rs index 56963ea61e..219eea5f07 100644 --- a/masonry_winit/src/app_driver.rs +++ b/masonry_winit/src/app_driver.rs @@ -6,8 +6,9 @@ use std::hash::Hash; use std::num::NonZeroU64; use std::sync::atomic::{AtomicU64, Ordering}; -use masonry_core::app::RenderRoot; +use masonry_core::app::{RenderRoot, VisualLayerPlan}; use masonry_core::core::{ErasedAction, WidgetId}; +use masonry_core::peniko::Color; #[cfg(feature = "imaging_vello")] use masonry_core::peniko::ImageData; use tracing::field::DisplayValue; @@ -66,6 +67,30 @@ pub struct WgpuContext<'a> { pub queue: &'a wgpu::Queue, } +/// The surface target for a single presentation pass. +/// +/// This is provided to [`AppDriver::present_visual_layers`] when an application wants to +/// override Masonry Winit's default flattened rendering path and present a [`VisualLayerPlan`] +/// directly, for example via a compositor such as `subduction`. +pub struct PresentationTarget<'a> { + /// The WGPU adapter used to create the device. + pub adapter: &'a wgpu::Adapter, + /// The shared WGPU device. + pub device: &'a wgpu::Device, + /// The shared WGPU queue. + pub queue: &'a wgpu::Queue, + /// The surface output format. + pub format: wgpu::TextureFormat, + /// The surface size in physical pixels. + pub size: winit::dpi::PhysicalSize, + /// The window scale factor. + pub scale_factor: f64, + /// The window base color requested by Masonry. + pub base_color: Color, + /// The view to render into for this frame. + pub view: &'a wgpu::TextureView, +} + /// Strategy for selecting `wgpu::Limits` when requesting the WGPU device. #[derive(Clone, Debug, Default)] pub enum WgpuLimits { @@ -127,6 +152,44 @@ pub trait AppDriver { /// Called when Masonry has created its WGPU device. fn on_wgpu_ready(&mut self, _wgpu: &WgpuContext<'_>) {} + + /// Called on redraw with the current ordered visual layer plan for a window. + /// + /// This hook runs after Masonry has produced its paint-time layer plan and before the + /// retained scene is rendered into the window texture. The plan reflects the current painter + /// order of both Masonry scene layers and host-managed external layers in the widget tree. + /// + /// Hosts that want to integrate with a compositor or native surface system should inspect + /// this plan directly. External layers identify host-managed surface slots; scene layers mark + /// Masonry-painted content in the same ordering. + /// + /// Masonry Winit does not realize host-managed layers itself. If the application ignores + /// external layers in this plan, those surfaces will be absent from the final presentation. + fn on_visual_layers( + &mut self, + window_id: WindowId, + ctx: &mut DriverCtx<'_, '_>, + layers: &VisualLayerPlan, + ) { + } + + /// Called when the application wants to override Masonry Winit's default flattened + /// presentation path and render a [`VisualLayerPlan`] directly. + /// + /// Return `true` if the visual layers were fully rendered into `target.view`. Masonry Winit + /// will then skip its default rendering path and only present the surface. Return `false` to + /// fall back to Masonry Winit's built-in flattened imaging renderer. + /// + /// This hook is intended for compositor integrations such as `subduction`, where the host + /// wants to interleave Masonry scene layers and host-managed external layers in one output. + fn present_visual_layers( + &mut self, + window_id: WindowId, + target: PresentationTarget<'_>, + layers: &VisualLayerPlan, + ) -> bool { + false + } } impl DriverCtx<'_, '_> { diff --git a/masonry_winit/src/event_loop_runner.rs b/masonry_winit/src/event_loop_runner.rs index 1404328565..73cd85dc04 100644 --- a/masonry_winit/src/event_loop_runner.rs +++ b/masonry_winit/src/event_loop_runner.rs @@ -20,7 +20,6 @@ use masonry_imaging::texture_render::{ RenderInput as ImagingRenderInput, RenderTarget as ImagingRenderTarget, Renderer as ImagingRenderer, }; -use masonry_imaging::{Layer as ImagingLayer, PreparedFrame}; use tracing::{info, info_span, trace}; use ui_events_winit::{WindowEventReducer, WindowEventTranslation}; use winit::application::ApplicationHandler; @@ -31,8 +30,8 @@ use winit::event_loop::ActiveEventLoop; use winit::window::{Window as WindowHandle, WindowAttributes, WindowId as HandleId}; use crate::app::{ - AppDriver, DriverCtx, WgpuContext, WgpuLimits, masonry_resize_direction_to_winit, - winit_ime_to_masonry, + AppDriver, DriverCtx, PresentationTarget, WgpuContext, WgpuLimits, + masonry_resize_direction_to_winit, winit_ime_to_masonry, }; use crate::app_driver::WindowId; use crate::vello_util::{RenderContext, RenderSurface}; @@ -581,18 +580,58 @@ impl MasonryState<'_> { } // --- MARK: REDRAW - fn redraw(&mut self, handle_id: HandleId, app_driver: &mut dyn AppDriver) { + fn redraw( + &mut self, + handle_id: HandleId, + event_loop: &ActiveEventLoop, + app_driver: &mut dyn AppDriver, + ) { let _span = info_span!("redraw"); - let window = self.windows.get_mut(&handle_id).unwrap(); - let size = window.render_root.size(); - if size.width == 0 || size.height == 0 { - // Surface can't have a dimension of zero, remove the stale surface to save memory. + let (window_id, window_handle, size, scale_factor, base_color, visual_layers, tree_update) = { + let window = self.windows.get_mut(&handle_id).unwrap(); + let size = window.render_root.size(); + if size.width == 0 || size.height == 0 { + self.surfaces.remove(&handle_id); + return; + } + + let now = Instant::now(); + // TODO: this calculation uses wall-clock time of the paint call, which + // potentially has jitter. + // + // See https://github.com/linebender/druid/issues/85 for discussion. + let last = self.last_anim.take(); + let elapsed = last.map(|t| now.duration_since(t)).unwrap_or_default(); + + window + .render_root + .handle_window_event(WindowEvent::AnimFrame(elapsed)); + + let animation_continues = window.render_root.needs_anim(); + self.last_anim = animation_continues.then_some(now); + + let (visual_layers, tree_update) = window.render_root.redraw(); + ( + window.id, + window.handle.clone(), + size, + window.handle.scale_factor(), + window.base_color, + visual_layers, + tree_update, + ) + }; + { + let mut ctx = DriverCtx::new(self, event_loop); + app_driver.on_visual_layers(window_id, &mut ctx, &visual_layers); + } + if !self.windows.contains_key(&handle_id) { self.surfaces.remove(&handle_id); return; } - // Get the existing surface or create a new one + // Get the existing surface or create a new one. let surface = if let Some(surface) = self.surfaces.get_mut(&handle_id) { #[cfg(target_os = "macos")] if self.resized_window == Some(handle_id) { @@ -607,7 +646,7 @@ impl MasonryState<'_> { surface } else { let devices_before = self.render_cx.devices.len(); - let surface = create_surface(&mut self.render_cx, window.handle.clone(), size); + let surface = create_surface(&mut self.render_cx, window_handle, size); let dev_id = surface.dev_id; self.surfaces.insert(handle_id, surface); let surface = self.surfaces.get_mut(&handle_id).unwrap(); @@ -625,48 +664,20 @@ impl MasonryState<'_> { surface }; - - let now = Instant::now(); - // TODO: this calculation uses wall-clock time of the paint call, which - // potentially has jitter. - // - // See https://github.com/linebender/druid/issues/85 for discussion. - let last = self.last_anim.take(); - let elapsed = last.map(|t| now.duration_since(t)).unwrap_or_default(); - - window - .render_root - .handle_window_event(WindowEvent::AnimFrame(elapsed)); - - // If this animation will continue, store the time. - // If a new animation starts, then it will have zero reported elapsed time. - let animation_continues = window.render_root.needs_anim(); - self.last_anim = animation_continues.then_some(now); - - #[cfg(target_os = "macos")] - if self.resized_window == Some(handle_id) { - self.render_cx.on_window_resize_state_change(surface, true); - } - - let (paint_result, tree_update) = window.render_root.redraw(); - let overlays: Vec<_> = paint_result - .overlays - .iter() - .map(|layer| ImagingLayer { - scene: &layer.scene, - transform: layer.transform, - }) - .collect(); - let size = window.render_root.size(); - let frame = PreparedFrame::new( - size.width, - size.height, - window.handle.scale_factor(), - window.base_color, - &paint_result.base, - &overlays, + let window = self + .windows + .get_mut(&handle_id) + .expect("window state should exist for redraw"); + Self::render( + surface, + window, + &visual_layers, + scale_factor, + base_color, + &self.render_cx, + &mut self.renderer, + app_driver, ); - Self::render(surface, window, frame, &self.render_cx, &mut self.renderer); #[cfg(feature = "tracy")] drop(self.frame.take()); if let Some(tree_update) = tree_update { @@ -678,17 +689,43 @@ impl MasonryState<'_> { fn render( surface: &mut RenderSurface<'_>, window: &mut Window, - frame: PreparedFrame<'_>, + visual_layers: &masonry_core::app::VisualLayerPlan, + scale_factor: f64, + base_color: Color, render_cx: &RenderContext, renderer: &mut ImagingRenderer, + app_driver: &mut dyn AppDriver, ) { let dev_id = surface.dev_id; + let adapter = &render_cx.devices[dev_id].adapter; let device = &render_cx.devices[dev_id].device; let queue = &render_cx.devices[dev_id].queue; let Some(surface_texture) = Self::acquire_surface_texture(surface, window, render_cx) else { return; }; + let output_view = surface_texture + .texture + .create_view(&wgpu::TextureViewDescriptor::default()); + + if app_driver.present_visual_layers( + window.id, + PresentationTarget { + adapter, + device, + queue, + format: surface.config.format, + size: PhysicalSize::new(surface.config.width, surface.config.height), + scale_factor, + base_color, + view: &output_view, + }, + visual_layers, + ) { + window.handle.pre_present_notify(); + surface_texture.present(); + return; + } let _render_span = tracing::info_span!( "Rendering Masonry window", @@ -703,7 +740,13 @@ impl MasonryState<'_> { texture: &surface.target_texture, view: &surface.target_view, }, - ImagingRenderInput { frame }, + ImagingRenderInput::new( + surface.config.width, + surface.config.height, + scale_factor, + base_color, + visual_layers, + ), ) { tracing::error!( backend = ImagingRenderer::BACKEND_NAME, @@ -714,6 +757,34 @@ impl MasonryState<'_> { Self::present_surface(surface, surface_texture, &window.handle, device, queue); } + fn present_surface( + surface: &RenderSurface<'_>, + surface_texture: wgpu::SurfaceTexture, + window: &WindowHandle, + device: &wgpu::Device, + queue: &wgpu::Queue, + ) { + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("Surface Blit"), + }); + surface.blitter.copy( + device, + &mut encoder, + &surface.target_view, + &surface_texture + .texture + .create_view(&wgpu::TextureViewDescriptor::default()), + ); + queue.submit([encoder.finish()]); + window.pre_present_notify(); + surface_texture.present(); + { + let _render_poll_span = + tracing::info_span!("Waiting for GPU to finish rendering").entered(); + device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); + } + } + fn acquire_surface_texture( surface: &mut RenderSurface<'_>, window: &Window, @@ -744,35 +815,6 @@ impl MasonryState<'_> { } } - fn present_surface( - surface: &RenderSurface<'_>, - surface_texture: wgpu::SurfaceTexture, - window: &WindowHandle, - device: &wgpu::Device, - queue: &wgpu::Queue, - ) { - // Copy the new surface content to the surface. - let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("Surface Blit"), - }); - surface.blitter.copy( - device, - &mut encoder, - &surface.target_view, - &surface_texture - .texture - .create_view(&wgpu::TextureViewDescriptor::default()), - ); - queue.submit([encoder.finish()]); - window.pre_present_notify(); - surface_texture.present(); - { - let _render_poll_span = - tracing::info_span!("Waiting for GPU to finish rendering").entered(); - device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); - } - } - // --- MARK: WINDOW_EVENT /// Delegate method for [`ApplicationHandler::window_event()`]. pub fn handle_window_event( @@ -869,7 +911,7 @@ impl MasonryState<'_> { .handle_window_event(WindowEvent::Rescale(scale_factor)); } WinitWindowEvent::RedrawRequested => { - self.redraw(handle_id, app_driver); + self.redraw(handle_id, event_loop, app_driver); } WinitWindowEvent::CloseRequested => { app_driver.on_close_requested(window.id, &mut DriverCtx::new(self, event_loop)); @@ -1100,7 +1142,7 @@ impl MasonryState<'_> { // If an app creates a visible window, we firstly create it as invisible // and then render the first frame before making it visible to avoid flashing. for handle_id in std::mem::take(&mut self.need_first_frame) { - self.redraw(handle_id, app_driver); + self.redraw(handle_id, event_loop, app_driver); let window = self.windows.get_mut(&handle_id).unwrap(); window.handle.set_visible(true); } diff --git a/masonry_winit/src/lib.rs b/masonry_winit/src/lib.rs index c81c7ffdb2..52f4fa0d04 100644 --- a/masonry_winit/src/lib.rs +++ b/masonry_winit/src/lib.rs @@ -92,17 +92,26 @@ use vello as _; mod app_driver; mod convert_winit_event; mod event_loop_runner; +#[cfg(all(feature = "subduction", feature = "imaging_vello"))] +mod subduction_presenter; mod vello_util; pub use winit; /// Types needed for running a Masonry app. pub mod app { - pub use super::app_driver::{AppDriver, DriverCtx, WgpuContext, WgpuLimits, WindowId}; + pub use super::app_driver::{ + AppDriver, DriverCtx, PresentationTarget, WgpuContext, WgpuLimits, WindowId, + }; pub use super::event_loop_runner::{ EventLoop, EventLoopBuilder, EventLoopProxy, MasonryState, MasonryUserEvent, NewWindow, Window, run, run_with, }; + #[cfg(all(feature = "subduction", feature = "imaging_vello"))] + pub use super::subduction_presenter::{ + ExternalLayerRenderer, ExternalLayerTarget, PresentError as SubductionPresentError, + SubductionPresenter, + }; pub(crate) use super::convert_winit_event::{ masonry_resize_direction_to_winit, winit_ime_to_masonry, From 5b31a4340a297a3d3ea95573c2a333e416dde8c5 Mon Sep 17 00:00:00 2001 From: Bruce Mitchener Date: Sun, 12 Apr 2026 14:26:02 +0700 Subject: [PATCH 2/3] Add an ExternalSurface widget slot --- masonry/src/widgets/external_surface.rs | 233 ++++++++++++++++++++++++ masonry/src/widgets/mod.rs | 2 + 2 files changed, 235 insertions(+) create mode 100644 masonry/src/widgets/external_surface.rs diff --git a/masonry/src/widgets/external_surface.rs b/masonry/src/widgets/external_surface.rs new file mode 100644 index 0000000000..022ebc5a78 --- /dev/null +++ b/masonry/src/widgets/external_surface.rs @@ -0,0 +1,233 @@ +// Copyright 2026 the Xilem Authors +// SPDX-License-Identifier: Apache-2.0 + +use accesskit::{Node, Role}; +use tracing::{Span, trace_span}; + +use crate::core::{ + AccessCtx, ArcStr, ChildrenIds, LayoutCtx, MeasureCtx, NoAction, PaintCtx, PaintLayerMode, + PropertiesRef, RegisterCtx, Widget, WidgetId, +}; +use crate::imaging::Painter; +use crate::kurbo::{Axis, Size}; +use crate::layout::{LenReq, Length}; + +/// The preferred size of an unconstrained external surface. +const DEFAULT_LENGTH: Length = Length::const_px(100.); + +/// A widget that reserves an in-tree slot for host-managed external content. +/// +/// `ExternalSurface` participates in layout like a normal leaf widget, but Masonry does not +/// paint its contents into the retained `imaging` scene. Instead, it marks its subtree as an +/// external paint layer so hosts such as `masonry_winit` can realize it as a foreign surface, +/// 3D viewport, or compositor-managed layer. +/// +/// Hosts discover these slots through `masonry_winit::app::AppDriver::on_visual_layers`, which +/// reports the ordered visual layer plan each frame. +#[derive(Default)] +pub struct ExternalSurface { + alt_text: Option, +} + +impl ExternalSurface { + /// Create a new external surface slot. + pub fn new() -> Self { + Self::default() + } + + /// Sets the text that will describe the external surface to screen readers. + /// + /// If the surface is decorative, use `""`. If no useful text description is available, + /// leave this unset. + pub fn with_alt_text(mut self, alt_text: impl Into) -> Self { + self.alt_text = Some(alt_text.into()); + self + } +} + +impl Widget for ExternalSurface { + type Action = NoAction; + + fn register_children(&mut self, _ctx: &mut RegisterCtx<'_>) {} + + fn measure( + &mut self, + _ctx: &mut MeasureCtx<'_>, + _props: &PropertiesRef<'_>, + _axis: Axis, + len_req: LenReq, + _cross_length: Option, + ) -> f64 { + match len_req { + LenReq::FitContent(space) => space, + _ => DEFAULT_LENGTH.get(), + } + } + + fn layout(&mut self, ctx: &mut LayoutCtx<'_>, _props: &PropertiesRef<'_>, size: Size) { + // External content is expected to stay within the widget's content box. + ctx.set_clip_path(size.to_rect()); + } + + fn paint( + &mut self, + _ctx: &mut PaintCtx<'_>, + _props: &PropertiesRef<'_>, + _painter: &mut Painter<'_>, + ) { + } + + fn accessibility_role(&self) -> Role { + Role::Canvas + } + + fn accessibility( + &mut self, + _ctx: &mut AccessCtx<'_>, + _props: &PropertiesRef<'_>, + node: &mut Node, + ) { + if let Some(alt_text) = &self.alt_text { + node.set_description(&**alt_text); + } + } + + fn children_ids(&self) -> ChildrenIds { + ChildrenIds::new() + } + + fn paint_layer_mode(&self) -> PaintLayerMode { + PaintLayerMode::External + } + + fn make_trace_span(&self, widget_id: WidgetId) -> Span { + trace_span!("ExternalSurface", id = widget_id.trace()) + } + + fn get_debug_text(&self) -> Option { + self.alt_text.as_ref().map(ToString::to_string) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use crate::app::{ExternalLayerKind, RenderRoot, RenderRootOptions, WindowSizePolicy}; + use crate::core::{DefaultProperties, NewWidget, Widget, WidgetTag}; + use crate::dpi::PhysicalSize; + use crate::kurbo::{Point, Rect, Size}; + use crate::layout::AsUnit; + use crate::widgets::{ExternalSurface, Flex, SizedBox}; + + #[test] + fn marks_itself_as_external_layer() { + let surface = ExternalSurface::new(); + assert_eq!( + Widget::paint_layer_mode(&surface), + crate::core::PaintLayerMode::External + ); + } + + #[test] + fn emits_external_layer_from_within_widget_tree() { + let tag = WidgetTag::::named("external-surface"); + let widget = Flex::row() + .with_fixed(NewWidget::new(SizedBox::empty().size(20.0.px(), 20.0.px()))) + .with_fixed(NewWidget::new(ExternalSurface::new()).with_tag(tag)) + .with_auto_id(); + + let mut render_root = RenderRoot::new( + widget, + |_| {}, + RenderRootOptions { + default_properties: Arc::new(DefaultProperties::new()), + use_system_fonts: false, + size_policy: WindowSizePolicy::User, + size: PhysicalSize::new(120, 40), + scale_factor: 1.0, + test_font: None, + }, + ); + + let (visual_layers, _) = render_root.redraw(); + let surface_ref = render_root.get_widget_with_tag(tag).unwrap(); + let external = visual_layers + .external_layers() + .map(|(_, layer)| layer) + .find(|layer| matches!(layer.kind, ExternalLayerKind::Surface)) + .expect("missing external layer"); + + assert_eq!(external.root_id, surface_ref.id()); + assert_eq!( + external.transform.translation(), + Point::new(20.0, 0.0).to_vec2() + ); + assert_eq!(external.bounds.size(), Size::new(100.0, 40.0)); + } + + #[test] + fn emits_external_layer_as_base_root() { + let tag = WidgetTag::::named("external-root"); + let mut render_root = RenderRoot::new( + NewWidget::new(ExternalSurface::new().with_alt_text("viewport")).with_tag(tag), + |_| {}, + RenderRootOptions { + default_properties: Arc::new(DefaultProperties::new()), + use_system_fonts: false, + size_policy: WindowSizePolicy::User, + size: PhysicalSize::new(80, 60), + scale_factor: 1.0, + test_font: None, + }, + ); + + let (visual_layers, _) = render_root.redraw(); + let surface_ref = render_root.get_widget_with_tag(tag).unwrap(); + let layers: Vec<_> = visual_layers + .external_layers() + .map(|(_, layer)| layer) + .collect(); + + assert_eq!(layers.len(), 1); + assert_eq!(layers[0].root_id, surface_ref.id()); + assert_eq!(layers[0].bounds.size(), Size::new(80.0, 60.0)); + } + + #[test] + fn reports_window_bounds_for_centered_surface_slot() { + let tag = WidgetTag::::named("external-surface"); + let widget = Flex::column() + .with_fixed(NewWidget::new( + SizedBox::new(NewWidget::new(ExternalSurface::new()).with_tag(tag)) + .size(280.0.px(), 140.0.px()), + )) + .with_auto_id(); + + let mut render_root = RenderRoot::new( + widget, + |_| {}, + RenderRootOptions { + default_properties: Arc::new(DefaultProperties::new()), + use_system_fonts: false, + size_policy: WindowSizePolicy::User, + size: PhysicalSize::new(800, 600), + scale_factor: 1.0, + test_font: None, + }, + ); + + let (visual_layers, _) = render_root.redraw(); + let surface_ref = render_root.get_widget_with_tag(tag).unwrap(); + let external = visual_layers + .external_layers() + .map(|(_, layer)| layer) + .find(|layer| layer.root_id == surface_ref.id()) + .expect("missing external layer"); + + assert_eq!( + external.window_bounds(), + Rect::new(260.0, 0.0, 540.0, 140.0) + ); + } +} diff --git a/masonry/src/widgets/mod.rs b/masonry/src/widgets/mod.rs index 73034e51d4..dd57b339fd 100644 --- a/masonry/src/widgets/mod.rs +++ b/masonry/src/widgets/mod.rs @@ -12,6 +12,7 @@ mod checkbox; mod collapse_panel; mod disclosure_button; mod divider; +mod external_surface; mod flex; mod grid; mod image; @@ -53,6 +54,7 @@ pub use self::checkbox::*; pub use self::collapse_panel::*; pub use self::disclosure_button::*; pub use self::divider::*; +pub use self::external_surface::*; pub use self::flex::*; pub use self::grid::*; pub use self::image::*; From 1d58c2baf75499ddfffd0c8a63af9e290774b572 Mon Sep 17 00:00:00 2001 From: Bruce Mitchener Date: Sun, 12 Apr 2026 14:26:15 +0700 Subject: [PATCH 3/3] Add subduction presentation support to masonry_winit --- Cargo.lock | 46 +- masonry/src/widgets/external_surface.rs | 8 +- masonry_core/src/app/mod.rs | 2 +- masonry_core/src/app/render_layers.rs | 74 +-- masonry_core/src/passes/paint.rs | 9 +- masonry_core/src/passes/paint/tests.rs | 58 +- masonry_imaging/src/lib.rs | 252 +-------- masonry_imaging/src/texture_render.rs | 78 ++- masonry_imaging/src/vello.rs | 44 +- masonry_winit/Cargo.toml | 6 + .../examples/external_surface_subduction.rs | 502 ++++++++++++++++++ masonry_winit/src/app_driver.rs | 35 +- masonry_winit/src/event_loop_runner.rs | 50 +- masonry_winit/src/lib.rs | 10 +- masonry_winit/src/subduction_presenter.rs | 496 +++++++++++++++++ masonry_winit/src/surface_presenter.rs | 45 ++ 16 files changed, 1317 insertions(+), 398 deletions(-) create mode 100644 masonry_winit/examples/external_surface_subduction.rs create mode 100644 masonry_winit/src/subduction_presenter.rs create mode 100644 masonry_winit/src/surface_presenter.rs diff --git a/Cargo.lock b/Cargo.lock index 658b3bc466..6daeef3df6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1120,7 +1120,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2134,6 +2134,15 @@ dependencies = [ "tempfile", ] +[[package]] +name = "invalidation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f67508d5f48cfefd99f534c87837a755edd94c60f87d8cfc3b14e521a7f07a6" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -2248,6 +2257,7 @@ checksum = "7564e90fe3c0d5771e1f0bc95322b21baaeaa0d9213fa6a0b61c99f8b17b3bfb" dependencies = [ "arrayvec", "euclid", + "libm", "smallvec", ] @@ -2459,6 +2469,7 @@ dependencies = [ "imaging_vello_cpu", "imaging_vello_hybrid", "kurbo", + "masonry_core", "peniko", "vello", "wgpu", @@ -2489,6 +2500,8 @@ dependencies = [ "masonry_core", "masonry_imaging", "pollster", + "subduction_backend_wgpu", + "subduction_core", "tracing", "tracing-tracy", "ui-events-winit", @@ -2712,7 +2725,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3860,7 +3873,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4324,7 +4337,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4369,6 +4382,25 @@ dependencies = [ "float-cmp 0.9.0", ] +[[package]] +name = "subduction_backend_wgpu" +version = "0.0.1" +source = "git+https://github.com/forest-rs/subduction.git?rev=dcabbd3a9b1566a1dd7a6f2ad9f7d40d7be9a39b#dcabbd3a9b1566a1dd7a6f2ad9f7d40d7be9a39b" +dependencies = [ + "bytemuck", + "subduction_core", + "wgpu", +] + +[[package]] +name = "subduction_core" +version = "0.0.1" +source = "git+https://github.com/forest-rs/subduction.git?rev=dcabbd3a9b1566a1dd7a6f2ad9f7d40d7be9a39b#dcabbd3a9b1566a1dd7a6f2ad9f7d40d7be9a39b" +dependencies = [ + "invalidation", + "kurbo", +] + [[package]] name = "subtle" version = "2.6.1" @@ -4469,7 +4501,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5014,7 +5046,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5789,7 +5821,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] diff --git a/masonry/src/widgets/external_surface.rs b/masonry/src/widgets/external_surface.rs index 022ebc5a78..1112a27c8f 100644 --- a/masonry/src/widgets/external_surface.rs +++ b/masonry/src/widgets/external_surface.rs @@ -22,8 +22,8 @@ const DEFAULT_LENGTH: Length = Length::const_px(100.); /// external paint layer so hosts such as `masonry_winit` can realize it as a foreign surface, /// 3D viewport, or compositor-managed layer. /// -/// Hosts discover these slots through `masonry_winit::app::AppDriver::on_visual_layers`, which -/// reports the ordered visual layer plan each frame. +/// Hosts discover these slots when they inspect the current visual layer plan during +/// `masonry_winit::app::AppDriver::present_visual_layers`. #[derive(Default)] pub struct ExternalSurface { alt_text: Option, @@ -113,7 +113,7 @@ impl Widget for ExternalSurface { mod tests { use std::sync::Arc; - use crate::app::{ExternalLayerKind, RenderRoot, RenderRootOptions, WindowSizePolicy}; + use crate::app::{RenderRoot, RenderRootOptions, WindowSizePolicy}; use crate::core::{DefaultProperties, NewWidget, Widget, WidgetTag}; use crate::dpi::PhysicalSize; use crate::kurbo::{Point, Rect, Size}; @@ -155,7 +155,7 @@ mod tests { let external = visual_layers .external_layers() .map(|(_, layer)| layer) - .find(|layer| matches!(layer.kind, ExternalLayerKind::Surface)) + .next() .expect("missing external layer"); assert_eq!(external.root_id, surface_ref.id()); diff --git a/masonry_core/src/app/mod.rs b/masonry_core/src/app/mod.rs index 2e484633e4..826e0352c4 100644 --- a/masonry_core/src/app/mod.rs +++ b/masonry_core/src/app/mod.rs @@ -9,7 +9,7 @@ mod render_root; mod tracing_backend; pub use render_layers::{ - ExternalLayerKind, VisualLayer, VisualLayerBoundary, VisualLayerKind, VisualLayerPlan, + VisualLayer, VisualLayerBoundary, VisualLayerId, VisualLayerKind, VisualLayerPlan, }; pub use render_root::{RenderRoot, RenderRootOptions, RenderRootSignal, WindowSizePolicy}; pub use tracing_backend::{ diff --git a/masonry_core/src/app/render_layers.rs b/masonry_core/src/app/render_layers.rs index eb0ad890c6..a2521c0327 100644 --- a/masonry_core/src/app/render_layers.rs +++ b/masonry_core/src/app/render_layers.rs @@ -7,15 +7,25 @@ //! They are distinct from the internal `LayerStack`, which owns persistent widget roots. use crate::core::WidgetId; -use crate::imaging::PaintSink; -use crate::imaging::record::{Scene, replay_transformed}; +use crate::imaging::record::Scene; use kurbo::{Affine, Rect}; -/// The kind of host-owned external layer preserved in the visual layer plan. +/// Stable identifier for one visual layer within a layer root. +/// +/// Visual-layer ids are stable for a given `(root_id, ordinal)` pair, where `ordinal` +/// is the painter-order index of that visual layer within the same layer root. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub enum ExternalLayerKind { - /// A host-managed surface slot reserved within the widget tree. - Surface, +pub struct VisualLayerId { + /// The root widget that owns this visual layer. + pub root_id: WidgetId, + /// Painter-order index of the visual layer within its owning root. + pub ordinal: u32, +} + +impl VisualLayerId { + fn new(root_id: WidgetId, ordinal: u32) -> Self { + Self { root_id, ordinal } + } } /// Where a visual layer boundary came from in the widget model. @@ -32,14 +42,14 @@ pub enum VisualLayerKind { /// Masonry-painted retained content, in the layer's local coordinate space. Scene(Scene), /// Host-owned external/native content identified by the layer root widget. - External(ExternalLayerKind), + External, } impl core::fmt::Debug for VisualLayerKind { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { Self::Scene(_) => f.write_str("Scene(..)"), - Self::External(kind) => f.debug_tuple("External").field(kind).finish(), + Self::External => f.write_str("External"), } } } @@ -50,9 +60,10 @@ impl core::fmt::Debug for VisualLayerKind { /// External layers preserve a host-managed layer boundary. In both cases, apply /// [`transform`](Self::transform) to place the layer in window space. pub struct VisualLayer { + id: VisualLayerId, /// The content realization of this layer. pub kind: VisualLayerKind, - /// Where this visual layer boundary originated. + /// Whether this layer came from a top-level `LayerStack` root or an in-tree paint split. pub boundary: VisualLayerBoundary, /// Axis-aligned bounds of this layer's content in layer-local coordinates. pub bounds: Rect, @@ -75,6 +86,7 @@ impl VisualLayer { root_id: WidgetId, ) -> Self { Self { + id: VisualLayerId::new(root_id, 0), kind: VisualLayerKind::Scene(scene), boundary, bounds, @@ -86,7 +98,6 @@ impl VisualLayer { /// Create an externally realized layer. pub fn external( - kind: ExternalLayerKind, boundary: VisualLayerBoundary, bounds: Rect, clip: Option, @@ -94,7 +105,8 @@ impl VisualLayer { root_id: WidgetId, ) -> Self { Self { - kind: VisualLayerKind::External(kind), + id: VisualLayerId::new(root_id, 0), + kind: VisualLayerKind::External, boundary, bounds, clip, @@ -103,12 +115,9 @@ impl VisualLayer { } } - /// Returns the external-layer kind, if this is host-owned content. - pub fn external_kind(&self) -> Option { - match self.kind { - VisualLayerKind::External(kind) => Some(kind), - VisualLayerKind::Scene(_) => None, - } + /// Stable identifier for this visual layer. + pub fn id(&self) -> VisualLayerId { + self.id } /// Returns the axis-aligned bounds in window coordinates. @@ -134,25 +143,17 @@ pub struct VisualLayerPlan { } impl VisualLayerPlan { - /// Replay all scene-backed layers into a sink in window coordinate space. - /// - /// This is the backend-agnostic way to consume Masonry's retained paint output. - pub fn replay_into(&self, sink: &mut S) - where - S: PaintSink + ?Sized, - { - for layer in &self.layers { - if let VisualLayerKind::Scene(scene) = &layer.kind { - replay_transformed(scene, sink, layer.transform); - } - } + /// Create a visual-layer plan and assign stable visual-layer ids. + pub fn new(mut layers: Vec) -> Self { + assign_visual_layer_ids(&mut layers); + Self { layers } } /// Iterate the external layers in painter order together with their external-layer index. - pub fn external_layers(&self) -> impl Iterator { + pub fn external_layers(&self) -> impl Iterator + '_ { self.layers .iter() - .filter(|layer| layer.external_kind().is_some()) + .filter(|layer| matches!(layer.kind, VisualLayerKind::External)) .enumerate() } @@ -160,6 +161,15 @@ impl VisualLayerPlan { pub fn has_external_layers(&self) -> bool { self.layers .iter() - .any(|layer| layer.external_kind().is_some()) + .any(|layer| matches!(layer.kind, VisualLayerKind::External)) + } +} + +fn assign_visual_layer_ids(layers: &mut [VisualLayer]) { + let mut ordinals = std::collections::HashMap::::new(); + for layer in layers { + let ordinal = ordinals.entry(layer.root_id).or_insert(0); + layer.id = VisualLayerId::new(layer.root_id, *ordinal); + *ordinal += 1; } } diff --git a/masonry_core/src/passes/paint.rs b/masonry_core/src/passes/paint.rs index 89a3316cbb..b4ebb0ebb7 100644 --- a/masonry_core/src/passes/paint.rs +++ b/masonry_core/src/passes/paint.rs @@ -9,8 +9,8 @@ use tracing::{info_span, trace}; use tree_arena::ArenaMut; use crate::app::{ - ExternalLayerKind, RenderRoot, RenderRootState, VisualLayer, VisualLayerBoundary, - VisualLayerKind, VisualLayerPlan, + RenderRoot, RenderRootState, VisualLayer, VisualLayerBoundary, VisualLayerKind, + VisualLayerPlan, }; use crate::core::{ DefaultProperties, LayerRealization, PaintCtx, PaintLayerMode, PropertiesRef, PropertyArena, @@ -247,7 +247,6 @@ fn paint_widget( node.reborrow_mut(), ); layers.push(VisualLayer::external( - ExternalLayerKind::Surface, child_layer.boundary, child_layer.bounds, child_layer.clip, @@ -362,7 +361,6 @@ pub(crate) fn run_paint_pass(root: &mut RenderRoot) -> VisualLayerPlan { layer_to_window_transform, ); layers.push(VisualLayer::external( - ExternalLayerKind::Surface, VisualLayerBoundary::LayerRoot, layer_bounds, layer_clip, @@ -423,7 +421,7 @@ pub(crate) fn run_paint_pass(root: &mut RenderRoot) -> VisualLayerPlan { } } - VisualLayerPlan { layers } + VisualLayerPlan::new(layers) } /// Paint a single layer root into ordered render layers. @@ -480,7 +478,6 @@ fn paint_layer( layer_node, ); vec![VisualLayer::external( - ExternalLayerKind::Surface, layer_state.boundary, layer_state.bounds, layer_state.clip, diff --git a/masonry_core/src/passes/paint/tests.rs b/masonry_core/src/passes/paint/tests.rs index a4b01dc571..81dfdc9742 100644 --- a/masonry_core/src/passes/paint/tests.rs +++ b/masonry_core/src/passes/paint/tests.rs @@ -7,21 +7,21 @@ use accesskit::{Node, Role}; use super::run_paint_pass; use crate::app::{ - ExternalLayerKind, RenderRoot, RenderRootOptions, VisualLayer, VisualLayerBoundary, - VisualLayerKind, VisualLayerPlan, WindowSizePolicy, + RenderRoot, RenderRootOptions, VisualLayerKind, VisualLayerPlan, WindowSizePolicy, }; use crate::core::{ AccessCtx, AccessEvent, ChildrenIds, DefaultProperties, LayoutCtx, MeasureCtx, NewWidget, NoAction, PaintCtx, PaintLayerMode, PointerEvent, PropertiesMut, PropertiesRef, RegisterCtx, - TextEvent, Update, UpdateCtx, Widget, WidgetId, WidgetPod, + TextEvent, Update, UpdateCtx, Widget, WidgetPod, }; use crate::dpi::PhysicalSize; use crate::imaging::Painter; -use crate::imaging::record::Scene; use crate::kurbo::{Axis, Point, Rect, Size}; use crate::layout::{LenReq, SizeDef}; use crate::peniko::Color; +/// Minimal leaf widget used to produce deterministic painted content with a selectable +/// `PaintLayerMode`. struct PaintLeaf { color: Color, paint_layer_mode: PaintLayerMode, @@ -107,6 +107,10 @@ impl Widget for PaintLeaf { } } +/// Three fixed-width leaf widgets laid out in a row. +/// +/// This is the basic fixture for assertions about how an isolated middle child splits the +/// ordered visual layer output around it. struct TripleRow { left: WidgetPod, middle: WidgetPod, @@ -218,6 +222,10 @@ impl Widget for TripleRow { } } +/// Single-child wrapper that applies an offset and clip. +/// +/// This exists to exercise ancestor transforms and clipping when a descendant becomes its own +/// visual layer. struct OffsetBox { child: WidgetPod, offset: Point, @@ -311,6 +319,10 @@ impl Widget for OffsetBox { } } +/// Variant of `TripleRow` whose middle slot is an `OffsetBox`. +/// +/// This fixture is used for nested-boundary tests where an external layer must preserve +/// ancestor offset and clip information. struct MixedTripleRow { left: WidgetPod, middle: WidgetPod, @@ -456,40 +468,6 @@ fn paint_result_for_middle(mode: PaintLayerMode) -> VisualLayerPlan { run_paint_pass(&mut render_root) } -#[test] -fn replay_ignores_external_layers() { - let base = Scene::new(); - let external = VisualLayer::external( - ExternalLayerKind::Surface, - VisualLayerBoundary::WidgetBoundary, - Rect::ZERO, - None, - kurbo::Affine::IDENTITY, - WidgetId::next(), - ); - let result = VisualLayerPlan { - layers: vec![ - VisualLayer::scene( - base, - VisualLayerBoundary::LayerRoot, - Rect::ZERO, - None, - kurbo::Affine::IDENTITY, - WidgetId::next(), - ), - external, - ], - }; - - let mut sink = Scene::new(); - result.replay_into(&mut sink); - - assert!(matches!( - result.layers[1].kind, - VisualLayerKind::External(ExternalLayerKind::Surface) - )); -} - #[test] fn isolated_scene_widget_splits_ordered_layers() { let result = paint_result_for_middle(PaintLayerMode::IsolatedScene); @@ -511,7 +489,7 @@ fn external_widget_splits_ordered_layers() { assert!(matches!(result.layers[0].kind, VisualLayerKind::Scene(_))); assert!(matches!( result.layers[1].kind, - VisualLayerKind::External(ExternalLayerKind::Surface) + VisualLayerKind::External )); assert!(matches!(result.layers[2].kind, VisualLayerKind::Scene(_))); assert_eq!(result.layers[1].transform.translation(), (10.0, 0.0).into()); @@ -559,7 +537,7 @@ fn nested_external_widget_preserves_ancestor_offsets() { assert_eq!(result.layers.len(), 3); assert!(matches!( result.layers[1].kind, - VisualLayerKind::External(ExternalLayerKind::Surface) + VisualLayerKind::External )); assert_eq!(result.layers[1].transform.translation(), (17.0, 5.0).into()); assert_eq!(result.layers[1].bounds, Rect::new(0.0, 0.0, 10.0, 8.0)); diff --git a/masonry_imaging/src/lib.rs b/masonry_imaging/src/lib.rs index fca73d9f1c..d44e263717 100644 --- a/masonry_imaging/src/lib.rs +++ b/masonry_imaging/src/lib.rs @@ -33,11 +33,11 @@ // END LINEBENDER LINT SET #![cfg_attr(docsrs, feature(doc_cfg))] -use imaging::record::{Scene, ValidateError, replay_transformed}; +use imaging::record::{ValidateError, replay_transformed}; use imaging::render::RenderSource; use imaging::{PaintSink, Painter}; use kurbo::{Affine, Rect}; -use masonry_core::app::{ExternalLayerKind, VisualLayerBoundary, VisualLayerKind, VisualLayerPlan}; +use masonry_core::app::{VisualLayerKind, VisualLayerPlan}; use peniko::Color; #[cfg(any(feature = "imaging_vello", feature = "imaging_vello_hybrid"))] @@ -141,238 +141,35 @@ pub mod image_render { } } -/// Opaque reference to a host-owned external layer. -/// -/// This is a placeholder for content such as a 3D viewport or platform-native compositor layer. -/// Current render-source adapters do not realize external layers; compositor-aware hosts are -/// expected to handle them directly from a higher-level render plan. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub struct ExternalLayerRef { - /// Stable layer identifier chosen by the host/widget integration. - pub id: u64, - /// Logical kind of external layer requested by Masonry. - pub kind: ExternalLayerKind, -} - -/// The content realization for a prepared layer. -#[derive(Clone, Copy, Debug)] -pub enum LayerKind<'a> { - /// Masonry-painted retained scene content. - Scene(&'a Scene), - /// Host-owned external/native content. - External(ExternalLayerRef), -} - -/// A Masonry render layer ready to be composited into window space. -#[derive(Clone, Copy)] -pub struct PreparedLayer<'a> { - /// The content of this layer. - pub kind: LayerKind<'a>, - /// Where this layer boundary originated in the widget model. - pub boundary: VisualLayerBoundary, - /// Axis-aligned bounds of this layer's content in layer-local coordinates. - pub bounds: Rect, - /// Optional clip to apply in layer-local coordinates when realizing the layer. - pub clip: Option, - /// Transform from layer-local coordinates into window coordinates. - pub transform: Affine, -} - -impl core::fmt::Debug for PreparedLayer<'_> { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - f.debug_struct("PreparedLayer") - .field("kind", &self.kind) - .field("boundary", &self.boundary) - .field("bounds", &self.bounds) - .field("clip", &self.clip) - .field("transform", &self.transform) - .finish() - } -} - -impl<'a> PreparedLayer<'a> { - /// Create a Masonry-painted scene layer. - pub fn scene( - scene: &'a Scene, - boundary: VisualLayerBoundary, - bounds: Rect, - clip: Option, - transform: Affine, - ) -> Self { - Self { - kind: LayerKind::Scene(scene), - boundary, - bounds, - clip, - transform, - } - } - - /// Create a host-owned external layer placeholder. - pub fn external( - external: ExternalLayerRef, - boundary: VisualLayerBoundary, - bounds: Rect, - clip: Option, - transform: Affine, - ) -> Self { - Self { - kind: LayerKind::External(external), - boundary, - bounds, - clip, - transform, - } - } -} - -/// Compatibility alias for the old layer name. -pub type Layer<'a> = PreparedLayer<'a>; - -#[derive(Clone, Copy)] -enum LayerSource<'a> { - Prepared(&'a [PreparedLayer<'a>]), - Visual(&'a VisualLayerPlan), -} - -impl core::fmt::Debug for LayerSource<'_> { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - match self { - Self::Prepared(layers) => f.debug_tuple("Prepared").field(&layers.len()).finish(), - Self::Visual(layers) => f.debug_tuple("Visual").field(&layers.layers.len()).finish(), - } - } -} - -impl LayerSource<'_> { - fn validate(self) -> Result<(), ValidateError> { - match self { - Self::Prepared(layers) => validate_prepared_layers(layers), - Self::Visual(layers) => validate_visual_layers(layers), - } - } - - fn replay_into(self, sink: &mut dyn PaintSink, transform: Affine) { - match self { - Self::Prepared(layers) => { - for layer in layers { - if let LayerKind::Scene(scene) = layer.kind { - replay_transformed(scene, sink, transform * layer.transform); - } - } - } - Self::Visual(layers) => { - for layer in &layers.layers { - if let VisualLayerKind::Scene(scene) = &layer.kind { - replay_transformed(scene, sink, transform * layer.transform); - } - } - } - } - } -} - -/// Masonry render source for a window-sized frame. -#[derive(Clone, Copy, Debug)] -pub struct WindowSource<'a> { - width: u32, - height: u32, - scale_factor: f64, - background_color: Color, - layers: LayerSource<'a>, -} - -impl<'a> WindowSource<'a> { - /// Create a render source directly from Masonry-imaging prepared layers. - pub fn from_prepared_layers( - width: u32, - height: u32, - scale_factor: f64, - background_color: Color, - layers: &'a [PreparedLayer<'a>], - ) -> Self { - Self { - width, - height, - scale_factor, - background_color, - layers: LayerSource::Prepared(layers), - } - } - - /// Create a render source directly from Masonry visual layers. - pub fn from_visual_layers( - width: u32, - height: u32, - scale_factor: f64, - background_color: Color, - layers: &'a VisualLayerPlan, - ) -> Self { - Self { - width, - height, - scale_factor, - background_color, - layers: LayerSource::Visual(layers), - } - } -} - -impl RenderSource for WindowSource<'_> { - fn validate(&self) -> Result<(), ValidateError> { - self.layers.validate() - } - - fn paint_into(&mut self, sink: &mut dyn PaintSink) { - { - let mut painter = Painter::new(sink); - painter.fill_rect( - Rect::new(0.0, 0.0, f64::from(self.width), f64::from(self.height)), - self.background_color, - ); - } - - self.layers - .replay_into(sink, Affine::scale(self.scale_factor)); - } -} - /// Masonry render source for screenshot-style output with optional root padding. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy)] pub struct SnapshotSource<'a> { background_color: Color, - layers: LayerSource<'a>, + layers: &'a VisualLayerPlan, width: u32, height: u32, root_padding: u32, } -impl<'a> SnapshotSource<'a> { - /// Create a screenshot render source directly from Masonry-imaging prepared layers. - pub fn from_prepared_layers( - width: u32, - height: u32, - scale_factor: f64, - background_color: Color, - layers: &'a [PreparedLayer<'a>], - root_padding: u32, - ) -> Self { - Self::from_parts( - width, - height, - scale_factor, - background_color, - LayerSource::Prepared(layers), - root_padding, - ) +impl core::fmt::Debug for SnapshotSource<'_> { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("SnapshotSource") + .field("width", &self.width) + .field("height", &self.height) + .field("root_padding", &self.root_padding) + .field("background_color", &self.background_color) + .field("visual_layer_count", &self.layers.layers.len()) + .finish() } +} +impl<'a> SnapshotSource<'a> { fn from_parts( width: u32, height: u32, _scale_factor: f64, background_color: Color, - layers: LayerSource<'a>, + layers: &'a VisualLayerPlan, root_padding: u32, ) -> Self { let (width, height) = padded_dimensions(width, height, root_padding); @@ -399,7 +196,7 @@ impl<'a> SnapshotSource<'a> { height, scale_factor, background_color, - LayerSource::Visual(layers), + layers, root_padding, ) } @@ -417,7 +214,7 @@ impl<'a> SnapshotSource<'a> { impl RenderSource for SnapshotSource<'_> { fn validate(&self) -> Result<(), ValidateError> { - self.layers.validate() + validate_visual_layers(self.layers) } fn paint_into(&mut self, sink: &mut dyn PaintSink) { @@ -444,7 +241,7 @@ impl RenderSource for SnapshotSource<'_> { let padding_transform = Affine::translate((f64::from(self.root_padding), f64::from(self.root_padding))); - self.layers.replay_into(sink, padding_transform); + replay_visual_layers(self.layers, sink, padding_transform); } } @@ -465,13 +262,12 @@ fn padding_rects(width: u32, height: u32, padding: u32) -> [[u32; 4]; 4] { ] } -fn validate_prepared_layers(layers: &[PreparedLayer<'_>]) -> Result<(), ValidateError> { - for layer in layers { - if let LayerKind::Scene(scene) = layer.kind { - scene.validate()?; +fn replay_visual_layers(layers: &VisualLayerPlan, sink: &mut dyn PaintSink, transform: Affine) { + for layer in &layers.layers { + if let VisualLayerKind::Scene(scene) = &layer.kind { + replay_transformed(scene, sink, transform * layer.transform); } } - Ok(()) } fn validate_visual_layers(layers: &VisualLayerPlan) -> Result<(), ValidateError> { @@ -498,7 +294,7 @@ mod tests { #[test] fn snapshot_source_from_visual_layers_uses_padded_dimensions() { - let layers = VisualLayerPlan { layers: Vec::new() }; + let layers = VisualLayerPlan::new(Vec::new()); let source = SnapshotSource::from_visual_layers(0, 2, 1.0, Color::WHITE, &layers, 5); assert_eq!(source.width(), 11); diff --git a/masonry_imaging/src/texture_render.rs b/masonry_imaging/src/texture_render.rs index c6a37b39e1..cb5ee9cb87 100644 --- a/masonry_imaging/src/texture_render.rs +++ b/masonry_imaging/src/texture_render.rs @@ -6,9 +6,13 @@ //! This module owns backend state for rendering Masonry visual layers into a caller-provided WGPU //! texture target. It does not own window surfaces or presentation. +use imaging::record::{ValidateError, replay_transformed}; +use imaging::render::RenderSource; +use imaging::{PaintSink, Painter}; +use kurbo::{Affine, Rect}; use wgpu; -use masonry_core::app::VisualLayerPlan; +use masonry_core::app::{VisualLayerKind, VisualLayerPlan}; use peniko::Color; /// GPU target that Masonry content should be rendered into. @@ -72,6 +76,72 @@ impl<'a> RenderInput<'a> { } } +#[derive(Clone, Copy)] +struct WindowSource<'a> { + width: u32, + height: u32, + scale_factor: f64, + background_color: Color, + layers: &'a VisualLayerPlan, +} + +impl core::fmt::Debug for WindowSource<'_> { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("WindowSource") + .field("width", &self.width) + .field("height", &self.height) + .field("scale_factor", &self.scale_factor) + .field("background_color", &self.background_color) + .field("visual_layer_count", &self.layers.layers.len()) + .finish() + } +} + +impl<'a> WindowSource<'a> { + fn from_visual_layers( + width: u32, + height: u32, + scale_factor: f64, + background_color: Color, + layers: &'a VisualLayerPlan, + ) -> Self { + Self { + width, + height, + scale_factor, + background_color, + layers, + } + } +} + +impl RenderSource for WindowSource<'_> { + fn validate(&self) -> Result<(), ValidateError> { + for layer in &self.layers.layers { + if let VisualLayerKind::Scene(scene) = &layer.kind { + scene.validate()?; + } + } + Ok(()) + } + + fn paint_into(&mut self, sink: &mut dyn PaintSink) { + { + let mut painter = Painter::new(sink); + painter.fill_rect( + Rect::new(0.0, 0.0, f64::from(self.width), f64::from(self.height)), + self.background_color, + ); + } + + for layer in &self.layers.layers { + if let VisualLayerKind::Scene(scene) = &layer.kind { + replay_transformed(scene, sink, Affine::scale(self.scale_factor) * layer.transform); + } + } + } +} + /// Errors that can occur while rendering Masonry content into a target texture. #[derive(Debug)] pub struct Error(imp::Error); @@ -222,7 +292,7 @@ mod imp { use super::non_vello::{CachedRenderer, render_window_source_to_texture}; use crate::skia::{TargetRenderer, TextureTarget, new_target_renderer}; - use super::{RenderInput, RenderTarget}; + use super::{RenderInput, RenderTarget, WindowSource}; /// Errors that can occur while rendering Masonry content with Skia. #[derive(Debug)] @@ -310,7 +380,7 @@ mod imp { use crate::vello::build_scene_from_source; - use super::{RenderInput, RenderTarget}; + use super::{RenderInput, RenderTarget, WindowSource}; /// Errors that can occur while rendering Masonry content with Vello. #[derive(Debug)] @@ -378,7 +448,7 @@ mod imp { ) -> Result<(), Error> { let width = input.width; let height = input.height; - let mut source = crate::WindowSource::from_visual_layers( + let mut source = WindowSource::from_visual_layers( input.width, input.height, input.scale_factor, diff --git a/masonry_imaging/src/vello.rs b/masonry_imaging/src/vello.rs index f747efa73d..d1e007ee37 100644 --- a/masonry_imaging/src/vello.rs +++ b/masonry_imaging/src/vello.rs @@ -3,8 +3,10 @@ use core::fmt; +use imaging::record::{Scene, replay_transformed}; use imaging::render::RenderSource; -use kurbo::Rect; +use kurbo::{Affine, Rect}; +use masonry_core::app::{VisualLayer, VisualLayerKind}; use crate::headless_wgpu; @@ -34,12 +36,26 @@ pub const BACKEND_NAME: &str = "imaging_vello"; /// Masonry alias for the selected Vello renderer type. pub type Renderer = imaging_vello::VelloRenderer; +/// Masonry alias for the selected Vello texture renderer type. +pub type TargetRenderer = imaging_vello::VelloTargetRenderer; + +/// Masonry alias for the selected Vello texture target wrapper. +pub type TextureTarget<'a> = imaging_vello::TextureTarget<'a>; + /// Create a reusable headless Vello renderer. pub fn new_headless_renderer() -> Result { let (device, queue) = headless_wgpu::try_init_device_and_queue().map_err(|_| Error::Init)?; imaging_vello::VelloRenderer::new(device, queue).map_err(Error::Backend) } +/// Create a reusable Vello target renderer bound to an existing WGPU device and queue. +pub fn new_target_renderer( + device: wgpu::Device, + queue: wgpu::Queue, +) -> Result { + imaging_vello::VelloTargetRenderer::new(device, queue).map_err(Error::Backend) +} + /// Build a native Vello scene from any render source. pub fn build_scene_from_source( source: &mut S, @@ -58,3 +74,29 @@ pub fn build_scene_from_source( sink.finish().map_err(Error::Backend)?; Ok(native) } + +/// Render one Masonry scene visual layer directly into a caller-provided texture view. +pub fn render_scene_layer_to_texture( + renderer: &mut TargetRenderer, + texture_view: &wgpu::TextureView, + width: u32, + height: u32, + scale_factor: f64, + layer: &VisualLayer, +) -> Result<(), Error> { + let VisualLayerKind::Scene(scene_layer) = &layer.kind else { + panic!("render_scene_layer_to_texture requires a scene-backed visual layer"); + }; + let mut scene = Scene::new(); + replay_transformed( + scene_layer, + &mut scene, + Affine::scale(scale_factor) * layer.transform, + ); + let native = renderer + .encode_scene(&scene, width, height) + .map_err(Error::Backend)?; + renderer + .render_to_texture_view(&native, texture_view, width, height) + .map_err(Error::Backend) +} diff --git a/masonry_winit/Cargo.toml b/masonry_winit/Cargo.toml index 862137c806..633dbbdec1 100644 --- a/masonry_winit/Cargo.toml +++ b/masonry_winit/Cargo.toml @@ -17,6 +17,10 @@ targets = [] # rustdoc-scrape-examples tracking issue https://github.com/rust-lang/rust/issues/88791 cargo-args = ["-Zunstable-options", "-Zrustdoc-scrape-examples"] +[[example]] +name = "external_surface_subduction" +required-features = ["imaging_vello"] + [features] default = ["imaging_vello"] imaging_vello = ["masonry_imaging/imaging_vello"] @@ -46,6 +50,8 @@ vello = { workspace = true, optional = true } wgpu.workspace = true wgpu-profiler = { optional = true, version = "0.26.0", default-features = false } copypasta = "0.10.2" +subduction_core = { git = "https://github.com/forest-rs/subduction.git", rev = "dcabbd3a9b1566a1dd7a6f2ad9f7d40d7be9a39b" } +subduction_backend_wgpu = { git = "https://github.com/forest-rs/subduction.git", rev = "dcabbd3a9b1566a1dd7a6f2ad9f7d40d7be9a39b" } [target.'cfg(target_os = "macos")'.dependencies] # We need Metal to interact with CoreAnimation during window resizing. diff --git a/masonry_winit/examples/external_surface_subduction.rs b/masonry_winit/examples/external_surface_subduction.rs new file mode 100644 index 0000000000..380fbc177f --- /dev/null +++ b/masonry_winit/examples/external_surface_subduction.rs @@ -0,0 +1,502 @@ +// Copyright 2026 the Xilem Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Demonstrates an `ExternalSurface` realized through `subduction`. +//! +//! This example composites Masonry scene layers and an animated host-managed `wgpu` +//! viewport into the same window surface. The overlay caption inside the slot is a normal +//! Masonry widget rendered above the external viewport. + +#![cfg_attr(not(test), windows_subsystem = "windows")] + +use std::collections::HashMap; + +use masonry::accesskit::{Node, Role}; +use masonry::app::{VisualLayer, VisualLayerPlan}; +use masonry::core::{ + AccessCtx, ChildrenIds, ErasedAction, LayoutCtx, MeasureCtx, NewWidget, NoAction, PaintCtx, + PropertiesRef, RegisterCtx, Widget, WidgetPod, +}; +use masonry::dpi::PhysicalSize; +use masonry::imaging::Painter; +use masonry::kurbo::{Axis, Point, Rect, Size as KurboSize, Stroke}; +use masonry::layout::{AsUnit, LenReq, UnitPoint}; +use masonry::peniko::Color; +use masonry::properties::Padding; +use masonry::theme::default_property_set; +use masonry::widgets::{ChildAlignment, ExternalSurface, Flex, Label, SizedBox, ZStack}; +use wgpu::StoreOp; + +use masonry_winit::app::{ + AppDriver, DriverCtx, LayerTextureTarget, NewWindow, PresentationTarget, + SubductionPresenter, WindowId, +}; +use masonry_winit::winit::dpi::LogicalSize; +use masonry_winit::winit::window::Window; + +const TITLE: &str = "External Surface Subduction Demo"; +const SLOT_SIZE: (f64, f64) = (640.0, 360.0); +const OVERLAY_SIZE: (f64, f64) = (300.0, 48.0); +struct Driver { + compositor: SubductionPresenter, + demo_renderer: DemoRenderer, +} + +impl AppDriver for Driver { + fn on_action( + &mut self, + _window_id: WindowId, + _ctx: &mut DriverCtx<'_, '_>, + _widget_id: masonry::core::WidgetId, + _action: ErasedAction, + ) { + } + + fn present_visual_layers( + &mut self, + _window_id: WindowId, + target: PresentationTarget<'_>, + layers: &VisualLayerPlan, + ) -> Option { + match self + .compositor + .present(target, layers, true, |target, layer| { + self.demo_renderer.render_external_layer(target, layer) + }) + { + Ok(request_redraw) => Some(request_redraw), + Err(err) => { + tracing::error!("subduction compositor demo failed: {err}"); + None + } + } + } +} + +#[expect( + clippy::cast_possible_truncation, + reason = "scissor coordinates are clamped into the valid output texture extent" +)] +fn rect_to_scissor(rect: Rect, output_size: PhysicalSize) -> Option<(u32, u32, u32, u32)> { + let x0 = rect.x0.floor().max(0.0).min(f64::from(output_size.width)); + let y0 = rect.y0.floor().max(0.0).min(f64::from(output_size.height)); + let x1 = rect.x1.ceil().max(0.0).min(f64::from(output_size.width)); + let y1 = rect.y1.ceil().max(0.0).min(f64::from(output_size.height)); + let width = (x1 - x0).max(0.0) as u32; + let height = (y1 - y0).max(0.0) as u32; + (width > 0 && height > 0).then_some((x0 as u32, y0 as u32, width, height)) +} + +#[expect( + clippy::cast_possible_truncation, + reason = "demo shader uniforms intentionally narrow window-space values to f32 for GPU upload" +)] +fn as_f32(value: f64) -> f32 { + value as f32 +} + +struct FramedSlot { + child: WidgetPod, + border: Color, + fill: Color, + corner_radius: f64, +} + +impl FramedSlot { + fn new(child: NewWidget, border: Color, fill: Color) -> Self { + Self { + child: child.erased().to_pod(), + border, + fill, + corner_radius: 0.0, + } + } + + fn with_corner_radius(mut self, corner_radius: f64) -> Self { + self.corner_radius = corner_radius; + self + } +} + +impl Widget for FramedSlot { + type Action = NoAction; + + fn register_children(&mut self, ctx: &mut RegisterCtx<'_>) { + ctx.register_child(&mut self.child); + } + + fn measure( + &mut self, + ctx: &mut MeasureCtx<'_>, + _props: &PropertiesRef<'_>, + axis: Axis, + _len_req: LenReq, + cross_length: Option, + ) -> f64 { + ctx.redirect_measurement(&mut self.child, axis, cross_length) + } + + fn layout(&mut self, ctx: &mut LayoutCtx<'_>, _props: &PropertiesRef<'_>, size: KurboSize) { + ctx.run_layout(&mut self.child, size); + ctx.place_child(&mut self.child, Point::ORIGIN); + ctx.derive_baselines(&self.child); + } + + fn paint( + &mut self, + ctx: &mut PaintCtx<'_>, + _props: &PropertiesRef<'_>, + painter: &mut Painter<'_>, + ) { + let rect = ctx.content_box(); + let shape = rect.to_rounded_rect(self.corner_radius); + painter.fill(shape, self.fill).draw(); + painter + .stroke( + rect.inset(-1.0).to_rounded_rect(self.corner_radius + 1.0), + &Stroke::new(2.0), + self.border, + ) + .draw(); + } + + fn accessibility_role(&self) -> Role { + Role::GenericContainer + } + + fn accessibility( + &mut self, + _ctx: &mut AccessCtx<'_>, + _props: &PropertiesRef<'_>, + _node: &mut Node, + ) { + } + + fn children_ids(&self) -> ChildrenIds { + ChildrenIds::from_slice(&[self.child.id()]) + } +} + +struct DemoRenderer { + start_time: std::time::Instant, + pipelines: HashMap, +} + +struct DemoPipeline { + pipeline: wgpu::RenderPipeline, + bind_group: wgpu::BindGroup, + uniforms: wgpu::Buffer, +} + +impl Default for DemoRenderer { + fn default() -> Self { + Self { + start_time: std::time::Instant::now(), + pipelines: HashMap::new(), + } + } +} + +impl DemoRenderer { + fn render( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + view: &wgpu::TextureView, + format: wgpu::TextureFormat, + output_size: PhysicalSize, + slot_bounds: Rect, + clip_bounds: Option, + ) { + let pipeline = self + .pipelines + .entry(format) + .or_insert_with(|| Self::create_pipeline(device, format)); + + let elapsed = self.start_time.elapsed().as_secs_f32(); + queue.write_buffer( + &pipeline.uniforms, + 0, + &uniform_bytes(elapsed, output_size, slot_bounds), + ); + + let scissor = rect_to_scissor(clip_bounds.unwrap_or(slot_bounds), output_size); + + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("subduction demo viewport encoder"), + }); + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("subduction demo viewport pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + if let Some((x, y, width, height)) = scissor { + pass.set_scissor_rect(x, y, width, height); + } + pass.set_pipeline(&pipeline.pipeline); + pass.set_bind_group(0, &pipeline.bind_group, &[]); + pass.draw(0..3, 0..1); + } + queue.submit([encoder.finish()]); + } + + fn create_pipeline(device: &wgpu::Device, format: wgpu::TextureFormat) -> DemoPipeline { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("subduction demo shader"), + source: wgpu::ShaderSource::Wgsl(DEMO_SHADER.into()), + }); + let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("subduction demo bind group layout"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: Some( + wgpu::BufferSize::new(32).expect("uniform buffer size should be non-zero"), + ), + }, + count: None, + }], + }); + let uniforms = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("subduction demo uniforms"), + size: 32, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("subduction demo bind group"), + layout: &bind_group_layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: uniforms.as_entire_binding(), + }], + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("subduction demo pipeline layout"), + bind_group_layouts: &[&bind_group_layout], + immediate_size: 0, + }); + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("subduction demo pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }), + primitive: wgpu::PrimitiveState::default(), + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }); + DemoPipeline { + pipeline, + bind_group, + uniforms, + } + } +} + +impl DemoRenderer { + fn render_external_layer( + &mut self, + target: LayerTextureTarget<'_>, + layer: &VisualLayer, + ) -> Result<(), Box> { + self.render( + target.device, + target.queue, + target.view, + target.format, + target.output_size, + layer.window_bounds().scale_from_origin(target.scale_factor), + layer + .window_clip_bounds() + .map(|rect| rect.scale_from_origin(target.scale_factor)), + ); + Ok(()) + } +} + +fn uniform_bytes(time: f32, output_size: PhysicalSize, slot_bounds: Rect) -> [u8; 32] { + let values = [ + time, + output_size.width as f32, + output_size.height as f32, + 0.0, + as_f32(slot_bounds.x0), + as_f32(slot_bounds.y0), + as_f32(slot_bounds.width()), + as_f32(slot_bounds.height()), + ]; + let mut bytes = [0_u8; 32]; + for (index, value) in values.into_iter().enumerate() { + bytes[index * 4..(index + 1) * 4].copy_from_slice(&value.to_ne_bytes()); + } + bytes +} + +const DEMO_SHADER: &str = r#" +struct DemoUniforms { + time_and_size: vec4, + slot: vec4, +}; + +@group(0) @binding(0) +var uniforms: DemoUniforms; + +struct VertexOut { + @builtin(position) position: vec4, +}; + +@vertex +fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOut { + var positions = array, 3>( + vec2(-1.0, -3.0), + vec2(-1.0, 1.0), + vec2( 3.0, 1.0), + ); + var out: VertexOut; + out.position = vec4(positions[vertex_index], 0.0, 1.0); + return out; +} + +fn palette(t: f32) -> vec3 { + let a = vec3(0.14, 0.18, 0.24); + let b = vec3(0.44, 0.29, 0.22); + let c = vec3(0.55, 0.52, 0.42); + let d = vec3(0.95, 0.84, 0.55); + return a + b * cos(6.28318 * (c * t + d)); +} + +@fragment +fn fs_main(@builtin(position) frag_pos: vec4) -> @location(0) vec4 { + let time = uniforms.time_and_size.x; + let slot = uniforms.slot; + let slot_uv = (frag_pos.xy - slot.xy) / slot.zw; + let aspect = slot.z / max(slot.w, 1.0); + var z = vec2( + (slot_uv.x - 0.5) * aspect * 2.4, + (slot_uv.y - 0.5) * 2.4 + ); + let c = vec2( + -0.79 + 0.11 * sin(time * 0.19), + 0.16 + 0.08 * cos(time * 0.13) + ); + + var iter: u32 = 0u; + var smooth_iter = 0.0; + loop { + if (iter >= 96u || dot(z, z) > 64.0) { + break; + } + z = vec2(z.x * z.x - z.y * z.y, 2.0 * z.x * z.y) + c; + iter = iter + 1u; + } + + if (iter < 96u) { + let mag = length(z); + smooth_iter = f32(iter) + 1.0 - log2(log2(max(mag, 1.0001))); + } else { + smooth_iter = f32(iter); + } + + let glow = clamp(1.0 - length(slot_uv - vec2(0.5, 0.5)) * 1.4, 0.0, 1.0); + let t = smooth_iter / 96.0; + let color = palette(t * 0.85 + time * 0.02) + vec3(0.12, 0.08, 0.03) * glow; + return vec4(color, 1.0); +} +"#; + +fn make_widget_tree() -> NewWidget { + let slot = NewWidget::new( + SizedBox::new(NewWidget::new( + ZStack::new() + .with( + NewWidget::new(FramedSlot::new( + ExternalSurface::new() + .with_alt_text("Animated wgpu viewport composited by subduction") + .with_auto_id(), + Color::from_rgb8(0x57, 0x97, 0xb8), + Color::from_rgba8(0x57, 0x97, 0xb8, 0x20), + )), + ChildAlignment::ParentAligned, + ) + .with( + NewWidget::new( + SizedBox::new(NewWidget::new( + FramedSlot::new( + Label::new("Masonry overlay above the compositor layer") + .with_props(Padding::from_vh(10.0, 16.0)), + Color::from_rgba8(0xe7, 0xf1, 0xf8, 0xcc), + Color::from_rgba8(0x0d, 0x14, 0x1b, 0x88), + ) + .with_corner_radius(14.0), + )) + .size(OVERLAY_SIZE.0.px(), OVERLAY_SIZE.1.px()), + ), + UnitPoint::TOP_LEFT, + ), + )) + .size(SLOT_SIZE.0.px(), SLOT_SIZE.1.px()), + ); + + Flex::column() + .with_fixed(NewWidget::new(Label::new( + "Subduction compositor: a host-managed wgpu viewport inside the widget tree", + ))) + .with_fixed_spacer(14.0_f64.px()) + .with_fixed(slot) + .with_fixed_spacer(12.0_f64.px()) + .with_fixed(NewWidget::new(Label::new( + "The caption inside the slot is a normal Masonry scene layer composited above the external surface.", + ))) + .with_auto_id() +} + +fn main() { + let window_attributes = Window::default_attributes() + .with_title(TITLE) + .with_inner_size(LogicalSize::new(760.0, 520.0)) + .with_min_inner_size(LogicalSize::new(520.0, 420.0)); + + masonry_winit::app::run( + vec![NewWindow::new( + window_attributes, + make_widget_tree().erased(), + )], + Driver { + compositor: SubductionPresenter::new(), + demo_renderer: DemoRenderer::default(), + }, + default_property_set(), + ) + .unwrap(); +} diff --git a/masonry_winit/src/app_driver.rs b/masonry_winit/src/app_driver.rs index 219eea5f07..0e0cb47abe 100644 --- a/masonry_winit/src/app_driver.rs +++ b/masonry_winit/src/app_driver.rs @@ -153,42 +153,23 @@ pub trait AppDriver { /// Called when Masonry has created its WGPU device. fn on_wgpu_ready(&mut self, _wgpu: &WgpuContext<'_>) {} - /// Called on redraw with the current ordered visual layer plan for a window. - /// - /// This hook runs after Masonry has produced its paint-time layer plan and before the - /// retained scene is rendered into the window texture. The plan reflects the current painter - /// order of both Masonry scene layers and host-managed external layers in the widget tree. - /// - /// Hosts that want to integrate with a compositor or native surface system should inspect - /// this plan directly. External layers identify host-managed surface slots; scene layers mark - /// Masonry-painted content in the same ordering. - /// - /// Masonry Winit does not realize host-managed layers itself. If the application ignores - /// external layers in this plan, those surfaces will be absent from the final presentation. - fn on_visual_layers( - &mut self, - window_id: WindowId, - ctx: &mut DriverCtx<'_, '_>, - layers: &VisualLayerPlan, - ) { - } - /// Called when the application wants to override Masonry Winit's default flattened /// presentation path and render a [`VisualLayerPlan`] directly. /// - /// Return `true` if the visual layers were fully rendered into `target.view`. Masonry Winit - /// will then skip its default rendering path and only present the surface. Return `false` to - /// fall back to Masonry Winit's built-in flattened imaging renderer. + /// Return `Some(request_redraw)` if the visual layers were fully rendered into `target.view`. + /// Masonry Winit will then skip its default rendering path and only present the surface. + /// Return `None` to fall back to Masonry Winit's built-in flattened imaging renderer. /// - /// This hook is intended for compositor integrations such as `subduction`, where the host - /// wants to interleave Masonry scene layers and host-managed external layers in one output. + /// This hook is the real host override seam for compositor integrations such as `subduction`, + /// where the host wants to interleave Masonry scene layers and host-managed external layers in + /// one output. fn present_visual_layers( &mut self, window_id: WindowId, target: PresentationTarget<'_>, layers: &VisualLayerPlan, - ) -> bool { - false + ) -> Option { + None } } diff --git a/masonry_winit/src/event_loop_runner.rs b/masonry_winit/src/event_loop_runner.rs index 73cd85dc04..1ca3ad7536 100644 --- a/masonry_winit/src/event_loop_runner.rs +++ b/masonry_winit/src/event_loop_runner.rs @@ -34,6 +34,7 @@ use crate::app::{ masonry_resize_direction_to_winit, winit_ime_to_masonry, }; use crate::app_driver::WindowId; +use crate::surface_presenter::present_surface; use crate::vello_util::{RenderContext, RenderSurface}; /// The custom event type that we inject into winit's [`EventLoop`](winit::event_loop::EventLoop). @@ -583,12 +584,12 @@ impl MasonryState<'_> { fn redraw( &mut self, handle_id: HandleId, - event_loop: &ActiveEventLoop, + _event_loop: &ActiveEventLoop, app_driver: &mut dyn AppDriver, ) { let _span = info_span!("redraw"); - let (window_id, window_handle, size, scale_factor, base_color, visual_layers, tree_update) = { + let (window_handle, size, scale_factor, base_color, visual_layers, tree_update) = { let window = self.windows.get_mut(&handle_id).unwrap(); let size = window.render_root.size(); if size.width == 0 || size.height == 0 { @@ -613,7 +614,6 @@ impl MasonryState<'_> { let (visual_layers, tree_update) = window.render_root.redraw(); ( - window.id, window.handle.clone(), size, window.handle.scale_factor(), @@ -622,15 +622,6 @@ impl MasonryState<'_> { tree_update, ) }; - { - let mut ctx = DriverCtx::new(self, event_loop); - app_driver.on_visual_layers(window_id, &mut ctx, &visual_layers); - } - if !self.windows.contains_key(&handle_id) { - self.surfaces.remove(&handle_id); - return; - } - // Get the existing surface or create a new one. let surface = if let Some(surface) = self.surfaces.get_mut(&handle_id) { #[cfg(target_os = "macos")] @@ -708,7 +699,7 @@ impl MasonryState<'_> { .texture .create_view(&wgpu::TextureViewDescriptor::default()); - if app_driver.present_visual_layers( + if let Some(request_redraw) = app_driver.present_visual_layers( window.id, PresentationTarget { adapter, @@ -724,6 +715,9 @@ impl MasonryState<'_> { ) { window.handle.pre_present_notify(); surface_texture.present(); + if request_redraw { + window.handle.request_redraw(); + } return; } @@ -754,35 +748,7 @@ impl MasonryState<'_> { ); return; } - Self::present_surface(surface, surface_texture, &window.handle, device, queue); - } - - fn present_surface( - surface: &RenderSurface<'_>, - surface_texture: wgpu::SurfaceTexture, - window: &WindowHandle, - device: &wgpu::Device, - queue: &wgpu::Queue, - ) { - let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("Surface Blit"), - }); - surface.blitter.copy( - device, - &mut encoder, - &surface.target_view, - &surface_texture - .texture - .create_view(&wgpu::TextureViewDescriptor::default()), - ); - queue.submit([encoder.finish()]); - window.pre_present_notify(); - surface_texture.present(); - { - let _render_poll_span = - tracing::info_span!("Waiting for GPU to finish rendering").entered(); - device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); - } + present_surface(surface, surface_texture, &window.handle, device, queue); } fn acquire_surface_texture( diff --git a/masonry_winit/src/lib.rs b/masonry_winit/src/lib.rs index 52f4fa0d04..8ae21c5603 100644 --- a/masonry_winit/src/lib.rs +++ b/masonry_winit/src/lib.rs @@ -92,8 +92,9 @@ use vello as _; mod app_driver; mod convert_winit_event; mod event_loop_runner; -#[cfg(all(feature = "subduction", feature = "imaging_vello"))] +#[cfg(feature = "imaging_vello")] mod subduction_presenter; +mod surface_presenter; mod vello_util; pub use winit; @@ -107,11 +108,8 @@ pub mod app { EventLoop, EventLoopBuilder, EventLoopProxy, MasonryState, MasonryUserEvent, NewWindow, Window, run, run_with, }; - #[cfg(all(feature = "subduction", feature = "imaging_vello"))] - pub use super::subduction_presenter::{ - ExternalLayerRenderer, ExternalLayerTarget, PresentError as SubductionPresentError, - SubductionPresenter, - }; + #[cfg(feature = "imaging_vello")] + pub use super::subduction_presenter::{LayerTextureTarget, SubductionPresenter}; pub(crate) use super::convert_winit_event::{ masonry_resize_direction_to_winit, winit_ime_to_masonry, diff --git a/masonry_winit/src/subduction_presenter.rs b/masonry_winit/src/subduction_presenter.rs new file mode 100644 index 0000000000..2cefd58f97 --- /dev/null +++ b/masonry_winit/src/subduction_presenter.rs @@ -0,0 +1,496 @@ +// Copyright 2026 the Xilem Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Reusable `subduction`-backed presentation for Masonry visual layers. +//! +//! `SubductionPresenter` owns the compositor state and the default Masonry scene-layer renderer. +//! Hosts only provide texture contents for external visual layers. + +use std::collections::{HashMap, HashSet}; + +use masonry_core::app::{VisualLayer, VisualLayerId, VisualLayerKind, VisualLayerPlan}; +use masonry_core::kurbo::Size; +use masonry_imaging::vello::{TargetRenderer as VelloTargetRenderer, new_target_renderer}; +use subduction_backend_wgpu::WgpuPresenter; +use subduction_core::backend::Presenter; +use subduction_core::layer::{ClipShape, LayerId, LayerStore, SurfaceId}; +use wgpu::StoreOp; + +use crate::app_driver::PresentationTarget; + +type BoxError = Box; + +/// The GPU target for rendering one visual layer texture. +pub struct LayerTextureTarget<'a> { + /// Device used for rendering. + pub device: &'a wgpu::Device, + /// Queue used for uploads and submission. + pub queue: &'a wgpu::Queue, + /// Texture view owned by the subduction presenter for this layer. + pub view: &'a wgpu::TextureView, + /// The format used for layer textures in the presenter. + pub format: wgpu::TextureFormat, + /// Output size in physical pixels. + pub output_size: winit::dpi::PhysicalSize, + /// Window scale factor used to convert logical layer geometry into pixels. + pub scale_factor: f64, +} + +/// Stateful `subduction` presenter for compositing Masonry visual layers into one output. +#[derive(Debug, Default)] +pub struct SubductionPresenter { + compositor: Option, + scene_renderer: Option, +} + +#[derive(Debug)] +struct CompositorState { + device_key: usize, + queue_key: usize, + output_format: wgpu::TextureFormat, + output_size: winit::dpi::PhysicalSize, + presenter: WgpuPresenter, + layers: LayerSyncState, +} + +#[derive(Debug)] +struct VelloSceneRendererState { + device_key: usize, + queue_key: usize, + renderer: VelloTargetRenderer, +} + +#[derive(Clone, Copy, Debug)] +struct PresentedLayer { + layer_id: LayerId, + surface_id: SurfaceId, +} + +#[derive(Debug)] +struct LayerSyncState { + store: LayerStore, + root: LayerId, + layers: HashMap, + ordered_ids: Vec, + next_surface_id: u32, +} + +impl CompositorState { + fn new(target: &PresentationTarget<'_>) -> Self { + let presenter = WgpuPresenter::new( + target.device.clone(), + target.queue.clone(), + target.format, + (target.size.width, target.size.height), + (target.size.width.max(1), target.size.height.max(1)), + ); + Self { + device_key: target.device as *const _ as usize, + queue_key: target.queue as *const _ as usize, + output_format: target.format, + output_size: target.size, + presenter, + layers: LayerSyncState::new(), + } + } + + fn matches(&self, target: &PresentationTarget<'_>) -> bool { + self.device_key == target.device as *const _ as usize + && self.queue_key == target.queue as *const _ as usize + && self.output_format == target.format + && self.output_size == target.size + } +} + +impl VelloSceneRendererState { + fn new(target: &LayerTextureTarget<'_>) -> Result { + let renderer = new_target_renderer(target.device.clone(), target.queue.clone())?; + Ok(Self { + device_key: target.device as *const _ as usize, + queue_key: target.queue as *const _ as usize, + renderer, + }) + } + + fn matches(&self, target: &LayerTextureTarget<'_>) -> bool { + self.device_key == target.device as *const _ as usize + && self.queue_key == target.queue as *const _ as usize + } +} + +impl LayerSyncState { + fn new() -> Self { + let mut store = LayerStore::new(); + let root = store.create_layer(); + + Self { + store, + root, + layers: HashMap::new(), + ordered_ids: Vec::new(), + next_surface_id: 1, + } + } + + fn sync( + &mut self, + layers: &VisualLayerPlan, + output_size: winit::dpi::PhysicalSize, + scale_factor: f64, + ) -> &LayerStore { + let desired_ids: Vec<_> = layers.layers.iter().map(|layer| layer.id()).collect(); + let live_ids: HashSet<_> = desired_ids.iter().copied().collect(); + let stale_ids: Vec<_> = self + .layers + .keys() + .copied() + .filter(|layer_id| !live_ids.contains(layer_id)) + .collect(); + for layer_id in stale_ids { + let presented = self + .layers + .remove(&layer_id) + .expect("stale layer should have presentation state"); + self.store.destroy_layer(presented.layer_id); + } + + for layer in &layers.layers { + let presented = self.layers.entry(layer.id()).or_insert_with(|| { + let layer_id = self.store.create_layer(); + let surface_id = SurfaceId(self.next_surface_id); + self.next_surface_id += 1; + PresentedLayer { + layer_id, + surface_id, + } + }); + Self::sync_layer_properties( + &mut self.store, + *presented, + layer, + output_size, + scale_factor, + ); + } + + if self.ordered_ids != desired_ids { + for layer_id in &desired_ids { + let presented = self + .layers + .get(layer_id) + .copied() + .expect("ordered visual layer should have presentation state"); + self.store.reparent(presented.layer_id, self.root); + } + self.ordered_ids = desired_ids; + } + + &self.store + } + + fn surface_id_for_layer(&self, id: VisualLayerId) -> Option { + self.layers.get(&id).map(|layer| layer.surface_id) + } + + fn sync_layer_properties( + store: &mut LayerStore, + presented: PresentedLayer, + layer: &VisualLayer, + output_size: winit::dpi::PhysicalSize, + scale_factor: f64, + ) { + let full_window = Size::new(f64::from(output_size.width), f64::from(output_size.height)); + if store.content(presented.layer_id) != Some(presented.surface_id) { + store.set_content(presented.layer_id, Some(presented.surface_id)); + } + if store.bounds(presented.layer_id) != full_window { + store.set_bounds(presented.layer_id, full_window); + } + + let clip = layer + .window_clip_bounds() + .map(|clip| ClipShape::Rect(clip.scale_from_origin(scale_factor))); + if store.clip(presented.layer_id) != clip { + store.set_clip(presented.layer_id, clip); + } + } +} + +impl SubductionPresenter { + /// Create an empty presenter state. + pub fn new() -> Self { + Self::default() + } + + /// Present the ordered Masonry visual layers into `target.view`. + /// + /// Masonry scene layers are rendered internally through `masonry_imaging`'s Vello target + /// renderer. `external_renderer` supplies the texture contents for host-owned external + /// layers. + pub fn present( + &mut self, + target: PresentationTarget<'_>, + layers: &VisualLayerPlan, + request_redraw: bool, + mut render_external_layer: impl FnMut( + LayerTextureTarget<'_>, + &VisualLayer, + ) -> Result<(), BoxError>, + ) -> Result { + if self + .compositor + .as_ref() + .is_none_or(|state| !state.matches(&target)) + { + self.compositor = Some(CompositorState::new(&target)); + } + + let layer_format = { + let compositor = self + .compositor + .as_mut() + .expect("compositor state should exist"); + compositor + .layers + .sync(layers, target.size, target.scale_factor); + let changes = compositor.layers.store.evaluate(); + compositor + .presenter + .apply(&compositor.layers.store, &changes); + compositor.presenter.layer_format() + }; + + let rgba = target.base_color.to_rgba8(); + // TODO: This clear belongs in `subduction_backend_wgpu`, alongside final composition. + // Masonry should describe visual layers, not own compositor output initialization policy. + clear_texture_view( + target.device, + target.queue, + target.view, + wgpu::Color { + r: f64::from(rgba.r) / 255.0, + g: f64::from(rgba.g) / 255.0, + b: f64::from(rgba.b) / 255.0, + a: f64::from(rgba.a) / 255.0, + }, + ); + + for layer in &layers.layers { + let surface_id = self + .compositor + .as_ref() + .expect("compositor state should exist") + .layers + .surface_id_for_layer(layer.id()) + .expect("visual layer should have a stable surface id"); + let Some(view) = self + .compositor + .as_ref() + .expect("compositor state should exist") + .presenter + .texture_for_surface(surface_id) + .cloned() + else { + continue; + }; + let layer_target = LayerTextureTarget { + device: target.device, + queue: target.queue, + view: &view, + format: layer_format, + output_size: target.size, + scale_factor: target.scale_factor, + }; + + if matches!(layer.kind, VisualLayerKind::Scene(_)) { + self.render_scene_layer(layer_target, layer)?; + } else if matches!(layer.kind, VisualLayerKind::External) { + render_external_layer(layer_target, layer)?; + } + } + + let composite = { + let compositor = self + .compositor + .as_mut() + .expect("compositor state should exist"); + let store = &compositor.layers.store; + compositor.presenter.composite(store, target.view) + }; + target.queue.submit([composite]); + Ok(request_redraw) + } + + fn render_scene_layer( + &mut self, + target: LayerTextureTarget<'_>, + layer: &VisualLayer, + ) -> Result<(), BoxError> { + if self + .scene_renderer + .as_ref() + .is_none_or(|state| !state.matches(&target)) + { + self.scene_renderer = Some(VelloSceneRendererState::new(&target).map_err(Box::new)?); + } + let state = self + .scene_renderer + .as_mut() + .expect("scene renderer state should exist"); + // TODO: `subduction_backend_wgpu` should expose the presenter-owned `wgpu::Texture`, not + // just its `TextureView`, so Masonry can render scene layers through the backend-neutral + // imaging path instead of keeping this Vello-specific direct-to-view fallback. + masonry_imaging::vello::render_scene_layer_to_texture( + &mut state.renderer, + target.view, + target.output_size.width, + target.output_size.height, + target.scale_factor, + layer, + ) + .map_err(Box::new) + .map_err(|err| err as BoxError) + } +} + +fn clear_texture_view( + device: &wgpu::Device, + queue: &wgpu::Queue, + view: &wgpu::TextureView, + color: wgpu::Color, +) { + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("subduction clear encoder"), + }); + { + let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("subduction clear pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(color), + store: StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + } + queue.submit([encoder.finish()]); +} + +#[cfg(test)] +mod tests { + use super::LayerSyncState; + use masonry_core::accesskit::{Node, Role}; + use masonry_core::app::{VisualLayer, VisualLayerBoundary, VisualLayerPlan}; + use masonry_core::core::{ + AccessCtx, ChildrenIds, LayoutCtx, MeasureCtx, NoAction, PaintCtx, PropertiesRef, + RegisterCtx, Widget, WidgetPod, + }; + use masonry_core::imaging::Painter; + use masonry_core::kurbo::{Axis, Rect, Size}; + use masonry_core::layout::LenReq; + use winit::dpi::PhysicalSize; + + struct TestWidget; + + impl Widget for TestWidget { + type Action = NoAction; + + fn register_children(&mut self, _ctx: &mut RegisterCtx<'_>) {} + + fn measure( + &mut self, + _ctx: &mut MeasureCtx<'_>, + _props: &PropertiesRef<'_>, + _axis: Axis, + _len_req: LenReq, + _cross_length: Option, + ) -> f64 { + 10.0 + } + + fn layout(&mut self, _ctx: &mut LayoutCtx<'_>, _props: &PropertiesRef<'_>, _size: Size) {} + + fn paint( + &mut self, + _ctx: &mut PaintCtx<'_>, + _props: &PropertiesRef<'_>, + _painter: &mut Painter<'_>, + ) { + } + + fn accessibility_role(&self) -> Role { + Role::GenericContainer + } + + fn accessibility( + &mut self, + _ctx: &mut AccessCtx<'_>, + _props: &PropertiesRef<'_>, + _node: &mut Node, + ) { + } + + fn children_ids(&self) -> ChildrenIds { + ChildrenIds::new() + } + } + + fn external_plan(root_id: masonry_core::core::WidgetId, layer_count: usize) -> VisualLayerPlan { + let layers = (0..layer_count) + .map(|index| { + let x = 10.0 + (index as f64) * 30.0; + VisualLayer::external( + VisualLayerBoundary::WidgetBoundary, + Rect::new(0.0, 0.0, 20.0, 20.0), + Some(Rect::new(0.0, 0.0, 20.0, 20.0)), + masonry_core::kurbo::Affine::translate((x, 5.0)), + root_id, + ) + }) + .collect(); + VisualLayerPlan::new(layers) + } + + #[test] + fn sync_reuses_layer_ids_for_unchanged_plan() { + let output_size = PhysicalSize::new(200, 100); + let mut sync = LayerSyncState::new(); + let root_id = WidgetPod::new(TestWidget).id(); + let plan = external_plan(root_id, 2); + + sync.sync(&plan, output_size, 2.0); + let first = sync.store.evaluate(); + assert_eq!(first.added.len(), 3); + + sync.sync(&plan, output_size, 2.0); + let second = sync.store.evaluate(); + assert!(second.added.is_empty()); + assert!(second.removed.is_empty()); + assert!(second.content.is_empty()); + assert!(second.bounds.is_empty()); + assert!(second.clips.is_empty()); + assert!(!second.topology_changed); + } + + #[test] + fn sync_removes_stale_visual_layers_without_readding_the_rest() { + let output_size = PhysicalSize::new(200, 100); + let mut sync = LayerSyncState::new(); + let root_id = WidgetPod::new(TestWidget).id(); + + sync.sync(&external_plan(root_id, 2), output_size, 1.0); + let _ = sync.store.evaluate(); + + sync.sync(&external_plan(root_id, 1), output_size, 1.0); + let changes = sync.store.evaluate(); + assert!(changes.added.is_empty()); + assert_eq!(changes.removed.len(), 1); + } +} diff --git a/masonry_winit/src/surface_presenter.rs b/masonry_winit/src/surface_presenter.rs new file mode 100644 index 0000000000..628baa6f5f --- /dev/null +++ b/masonry_winit/src/surface_presenter.rs @@ -0,0 +1,45 @@ +// Copyright 2026 the Xilem Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Internal presentation helpers for Masonry Winit. +//! +//! This module owns the last-mile host/presenter policy inside `masonry_winit`: +//! blitting the rendered target texture into the platform surface. +//! +//! It does not own widget paint semantics, backend rendering, visual-layer adaptation, or +//! window-event orchestration. + +use std::sync::Arc; + +use winit::window::Window as WindowHandle; + +use crate::vello_util::RenderSurface; + +/// Blit the rendered intermediate target into the platform surface and present it. +pub(crate) fn present_surface( + surface: &RenderSurface<'_>, + surface_texture: wgpu::SurfaceTexture, + window: &Arc, + device: &wgpu::Device, + queue: &wgpu::Queue, +) { + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("Surface Blit"), + }); + surface.blitter.copy( + device, + &mut encoder, + &surface.target_view, + &surface_texture + .texture + .create_view(&wgpu::TextureViewDescriptor::default()), + ); + queue.submit([encoder.finish()]); + window.pre_present_notify(); + surface_texture.present(); + { + let _render_poll_span = + tracing::info_span!("Waiting for GPU to finish rendering").entered(); + device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); + } +}