Filter API + Blur (#40)
Co-authored-by: Newspicel <newspicel+claude@pm.me>
This commit is contained in:
co-authored by
Newspicel
parent
cfec5ff014
commit
69d467e4d1
@@ -28,6 +28,10 @@ where
|
||||
root: Option<usize>,
|
||||
/// Index of the leaf with the highest ordering (for fast-path lookups).
|
||||
max_leaf: Option<usize>,
|
||||
/// Minimum ordering assigned to any subsequent insert. Raised before painting deferred
|
||||
/// draws so overlays always sort above the main scene (and their orders can't fall inside a
|
||||
/// content-filter order range from the main scene). 0 means no floor.
|
||||
order_floor: u32,
|
||||
/// Reusable stack for tree traversal during insertion.
|
||||
insert_path: Vec<usize>,
|
||||
/// Reusable stack for search operations.
|
||||
@@ -109,10 +113,34 @@ where
|
||||
self.nodes.clear();
|
||||
self.root = None;
|
||||
self.max_leaf = None;
|
||||
self.order_floor = 0;
|
||||
self.insert_path.clear();
|
||||
self.search_stack.clear();
|
||||
}
|
||||
|
||||
/// Raise the minimum ordering for subsequent inserts to `floor`. Relative ordering above the
|
||||
/// floor is preserved (overlapping inserts still step above one another).
|
||||
pub fn set_order_floor(&mut self, floor: u32) {
|
||||
self.order_floor = self.order_floor.max(floor);
|
||||
}
|
||||
|
||||
/// The highest ordering assigned to any bounds so far (0 if empty).
|
||||
pub fn max_order(&self) -> u32 {
|
||||
self.max_leaf.map_or(0, |idx| self.nodes[idx].max_order)
|
||||
}
|
||||
|
||||
/// Inserts bounds with an ordering strictly greater than *every* existing bounds (not just
|
||||
/// intersecting ones), and returns that ordering. Used for content-filter group boundaries
|
||||
/// (which must sort after all previously-painted content so their order range can't collide
|
||||
/// with unrelated non-overlapping content that reuses low orderings) and to raise the order
|
||||
/// floor before painting deferred draws (so overlays always sort above the main scene).
|
||||
pub fn insert_above_all(&mut self, new_bounds: Bounds<U>) -> u32 {
|
||||
let ordering = self.max_order() + 1;
|
||||
let new_leaf_idx = self.insert_leaf(new_bounds, ordering);
|
||||
self.max_leaf = Some(new_leaf_idx);
|
||||
ordering
|
||||
}
|
||||
|
||||
/// Inserts bounds into the tree and returns its assigned ordering.
|
||||
///
|
||||
/// The ordering is one greater than the maximum ordering of any
|
||||
@@ -120,7 +148,7 @@ where
|
||||
pub fn insert(&mut self, new_bounds: Bounds<U>) -> u32 {
|
||||
// Find maximum ordering among intersecting bounds
|
||||
let max_intersecting = self.find_max_ordering(&new_bounds);
|
||||
let ordering = max_intersecting + 1;
|
||||
let ordering = (max_intersecting + 1).max(self.order_floor);
|
||||
|
||||
// Insert the new leaf
|
||||
let new_leaf_idx = self.insert_leaf(new_bounds, ordering);
|
||||
@@ -365,6 +393,7 @@ where
|
||||
nodes: Vec::new(),
|
||||
root: None,
|
||||
max_leaf: None,
|
||||
order_floor: 0,
|
||||
insert_path: Vec::new(),
|
||||
search_stack: Vec::new(),
|
||||
}
|
||||
|
||||
+349
-7
@@ -6,8 +6,9 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
AtlasTextureId, AtlasTile, Background, Bounds, ContentMask, Corners, Edges, Hsla, Pixels,
|
||||
Point, Radians, ScaledPixels, Size, bounds_tree::BoundsTree, point,
|
||||
Point, Radians, ScaledFilter, ScaledPixels, Size, bounds_tree::BoundsTree, point,
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
use std::{
|
||||
fmt::Debug,
|
||||
iter::Peekable,
|
||||
@@ -36,6 +37,8 @@ pub struct Scene {
|
||||
pub subpixel_sprites: Vec<SubpixelSprite>,
|
||||
pub polychrome_sprites: Vec<PolychromeSprite>,
|
||||
pub surfaces: Vec<PaintSurface>,
|
||||
pub backdrop_filters: Vec<BackdropFilter>,
|
||||
pub filter_boundaries: Vec<FilterBoundary>,
|
||||
}
|
||||
|
||||
#[expect(missing_docs)]
|
||||
@@ -52,6 +55,8 @@ impl Scene {
|
||||
self.subpixel_sprites.clear();
|
||||
self.polychrome_sprites.clear();
|
||||
self.surfaces.clear();
|
||||
self.backdrop_filters.clear();
|
||||
self.filter_boundaries.clear();
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
@@ -70,21 +75,50 @@ impl Scene {
|
||||
self.paint_operations.push(PaintOperation::EndLayer);
|
||||
}
|
||||
|
||||
/// Raise the draw-order floor so every primitive inserted afterwards sorts above everything
|
||||
/// inserted before. Called before painting deferred draws so overlays (tooltips, popovers,
|
||||
/// drag images) sort above the main scene — and a deferred backdrop's order can't fall inside
|
||||
/// a content-filter (`filter`) order range left behind by the main scene.
|
||||
pub fn raise_order_floor(&mut self) {
|
||||
let floor = self.primitive_bounds.max_order() + 1;
|
||||
self.primitive_bounds.set_order_floor(floor);
|
||||
}
|
||||
|
||||
pub fn insert_primitive(&mut self, primitive: impl Into<Primitive>) {
|
||||
let mut primitive = primitive.into();
|
||||
let clipped_bounds = primitive
|
||||
.bounds()
|
||||
.intersect(&primitive.content_mask().bounds);
|
||||
|
||||
if clipped_bounds.is_empty() {
|
||||
// Content-filter boundaries must always be inserted as matched pairs — dropping one
|
||||
// (e.g. for an empty clipped region) would orphan its partner and corrupt the renderer's
|
||||
// target stack. Each marker takes an order strictly above ALL prior content, so the start
|
||||
// sorts after everything painted before it and the element's own children (which overlap
|
||||
// the marker bounds) sort strictly above the start. This keeps a marker's order range from
|
||||
// colliding with unrelated non-overlapping content that reuses low orderings (e.g. a
|
||||
// background grid), which would otherwise sweep that content into the group. Content
|
||||
// painted *after* the group is held above it by raising the order floor when the end
|
||||
// marker is inserted (see below) — otherwise a later non-overlapping sibling could reuse a
|
||||
// low order that lands inside the start..end range and be swept into the group.
|
||||
let is_filter_boundary = matches!(primitive, Primitive::FilterBoundary(_));
|
||||
|
||||
if clipped_bounds.is_empty() && !is_filter_boundary {
|
||||
return;
|
||||
}
|
||||
|
||||
let order = self
|
||||
.layer_stack
|
||||
.last()
|
||||
.copied()
|
||||
.unwrap_or_else(|| self.primitive_bounds.insert(clipped_bounds));
|
||||
let order = if is_filter_boundary {
|
||||
let order_bounds = if clipped_bounds.is_empty() {
|
||||
*primitive.bounds()
|
||||
} else {
|
||||
clipped_bounds
|
||||
};
|
||||
self.primitive_bounds.insert_above_all(order_bounds)
|
||||
} else {
|
||||
self.layer_stack
|
||||
.last()
|
||||
.copied()
|
||||
.unwrap_or_else(|| self.primitive_bounds.insert(clipped_bounds))
|
||||
};
|
||||
match &mut primitive {
|
||||
Primitive::Shadow(shadow) => {
|
||||
shadow.order = order;
|
||||
@@ -119,6 +153,22 @@ impl Scene {
|
||||
surface.order = order;
|
||||
self.surfaces.push(surface.clone());
|
||||
}
|
||||
Primitive::BackdropFilter(filter) => {
|
||||
filter.order = order;
|
||||
self.backdrop_filters.push(filter.clone());
|
||||
}
|
||||
Primitive::FilterBoundary(boundary) => {
|
||||
boundary.order = order;
|
||||
if !boundary.is_start {
|
||||
// A closed content-filter group is a draw-order barrier: everything painted
|
||||
// afterwards must sort above the group's end marker so it can't fall back
|
||||
// inside the group's order range (subsequent non-overlapping content otherwise
|
||||
// reuses a low order). Mirrors the floor raised before deferred draws in
|
||||
// `raise_order_floor`.
|
||||
self.primitive_bounds.set_order_floor(order + 1);
|
||||
}
|
||||
self.filter_boundaries.push(boundary.clone());
|
||||
}
|
||||
}
|
||||
self.paint_operations
|
||||
.push(PaintOperation::Primitive(primitive));
|
||||
@@ -146,6 +196,13 @@ impl Scene {
|
||||
self.polychrome_sprites
|
||||
.sort_by_key(|sprite| (sprite.order, sprite.tile.tile_id));
|
||||
self.surfaces.sort_by_key(|surface| surface.order);
|
||||
self.backdrop_filters.sort_by_key(|filter| filter.order);
|
||||
// Markers normally get distinct, monotonically-increasing orders (children overlap
|
||||
// their group bounds and so sort strictly between the start and end). The `!is_start`
|
||||
// tiebreak only matters for a degenerate empty group whose start and end tie: it keeps
|
||||
// the start (false = 0) ahead of the end (true = 1) so the pair stays well-formed.
|
||||
self.filter_boundaries
|
||||
.sort_by_key(|boundary| (boundary.order, !boundary.is_start));
|
||||
}
|
||||
|
||||
#[cfg_attr(
|
||||
@@ -173,6 +230,10 @@ impl Scene {
|
||||
polychrome_sprites_iter: self.polychrome_sprites.iter().peekable(),
|
||||
surfaces_start: 0,
|
||||
surfaces_iter: self.surfaces.iter().peekable(),
|
||||
backdrop_filters_start: 0,
|
||||
backdrop_filters_iter: self.backdrop_filters.iter().peekable(),
|
||||
filter_boundaries_start: 0,
|
||||
filter_boundaries_iter: self.filter_boundaries.iter().peekable(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -186,6 +247,9 @@ impl Scene {
|
||||
allow(dead_code)
|
||||
)]
|
||||
pub(crate) enum PrimitiveKind {
|
||||
// Lowest discriminant: at an equal order, a content-filter group-start is emitted before
|
||||
// the group's own content so the renderer redirects rendering before any child draws.
|
||||
FilterBoundaryStart,
|
||||
Shadow,
|
||||
#[default]
|
||||
Quad,
|
||||
@@ -195,6 +259,10 @@ pub(crate) enum PrimitiveKind {
|
||||
SubpixelSprite,
|
||||
PolychromeSprite,
|
||||
Surface,
|
||||
BackdropFilter,
|
||||
// Highest discriminant: at an equal order, a group-end is emitted after the group's content
|
||||
// so the renderer composites the filtered group only once every child has been drawn.
|
||||
FilterBoundaryEnd,
|
||||
}
|
||||
|
||||
pub(crate) enum PaintOperation {
|
||||
@@ -214,6 +282,8 @@ pub enum Primitive {
|
||||
SubpixelSprite(SubpixelSprite),
|
||||
PolychromeSprite(PolychromeSprite),
|
||||
Surface(PaintSurface),
|
||||
BackdropFilter(BackdropFilter),
|
||||
FilterBoundary(FilterBoundary),
|
||||
}
|
||||
|
||||
#[expect(missing_docs)]
|
||||
@@ -228,6 +298,8 @@ impl Primitive {
|
||||
Primitive::SubpixelSprite(sprite) => &sprite.bounds,
|
||||
Primitive::PolychromeSprite(sprite) => &sprite.bounds,
|
||||
Primitive::Surface(surface) => &surface.bounds,
|
||||
Primitive::BackdropFilter(filter) => &filter.bounds,
|
||||
Primitive::FilterBoundary(boundary) => &boundary.bounds,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,6 +313,8 @@ impl Primitive {
|
||||
Primitive::SubpixelSprite(sprite) => &sprite.content_mask,
|
||||
Primitive::PolychromeSprite(sprite) => &sprite.content_mask,
|
||||
Primitive::Surface(surface) => &surface.content_mask,
|
||||
Primitive::BackdropFilter(filter) => &filter.content_mask,
|
||||
Primitive::FilterBoundary(boundary) => &boundary.content_mask,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -269,6 +343,10 @@ struct BatchIterator<'a> {
|
||||
polychrome_sprites_iter: Peekable<slice::Iter<'a, PolychromeSprite>>,
|
||||
surfaces_start: usize,
|
||||
surfaces_iter: Peekable<slice::Iter<'a, PaintSurface>>,
|
||||
backdrop_filters_start: usize,
|
||||
backdrop_filters_iter: Peekable<slice::Iter<'a, BackdropFilter>>,
|
||||
filter_boundaries_start: usize,
|
||||
filter_boundaries_iter: Peekable<slice::Iter<'a, FilterBoundary>>,
|
||||
}
|
||||
|
||||
impl<'a> Iterator for BatchIterator<'a> {
|
||||
@@ -302,6 +380,20 @@ impl<'a> Iterator for BatchIterator<'a> {
|
||||
self.surfaces_iter.peek().map(|s| s.order),
|
||||
PrimitiveKind::Surface,
|
||||
),
|
||||
(
|
||||
self.backdrop_filters_iter.peek().map(|f| f.order),
|
||||
PrimitiveKind::BackdropFilter,
|
||||
),
|
||||
(
|
||||
self.filter_boundaries_iter.peek().map(|b| b.order),
|
||||
// The same vec yields both start and end markers; the discriminant decides
|
||||
// where the next marker sorts relative to draw batches at an equal order
|
||||
// (start before content, end after).
|
||||
match self.filter_boundaries_iter.peek() {
|
||||
Some(boundary) if boundary.is_start => PrimitiveKind::FilterBoundaryStart,
|
||||
_ => PrimitiveKind::FilterBoundaryEnd,
|
||||
},
|
||||
),
|
||||
];
|
||||
orders_and_kinds.sort_by_key(|(order, kind)| (order.unwrap_or(u32::MAX), *kind));
|
||||
|
||||
@@ -447,6 +539,30 @@ impl<'a> Iterator for BatchIterator<'a> {
|
||||
self.surfaces_start = surfaces_end;
|
||||
Some(PrimitiveBatch::Surfaces(surfaces_start..surfaces_end))
|
||||
}
|
||||
PrimitiveKind::BackdropFilter => {
|
||||
let backdrop_filters_start = self.backdrop_filters_start;
|
||||
let mut backdrop_filters_end = backdrop_filters_start + 1;
|
||||
self.backdrop_filters_iter.next();
|
||||
while self
|
||||
.backdrop_filters_iter
|
||||
.next_if(|filter| (filter.order, batch_kind) < max_order_and_kind)
|
||||
.is_some()
|
||||
{
|
||||
backdrop_filters_end += 1;
|
||||
}
|
||||
self.backdrop_filters_start = backdrop_filters_end;
|
||||
Some(PrimitiveBatch::BackdropFilters(
|
||||
backdrop_filters_start..backdrop_filters_end,
|
||||
))
|
||||
}
|
||||
// Boundaries are emitted one at a time (never merged) so the renderer can switch
|
||||
// render targets at exactly the right point in the batch stream.
|
||||
PrimitiveKind::FilterBoundaryStart | PrimitiveKind::FilterBoundaryEnd => {
|
||||
let index = self.filter_boundaries_start;
|
||||
self.filter_boundaries_iter.next();
|
||||
self.filter_boundaries_start = index + 1;
|
||||
Some(PrimitiveBatch::FilterBoundary(index))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -479,6 +595,11 @@ pub enum PrimitiveBatch {
|
||||
range: Range<usize>,
|
||||
},
|
||||
Surfaces(Range<usize>),
|
||||
BackdropFilters(Range<usize>),
|
||||
/// A single content-filter group boundary; index into [`Scene::filter_boundaries`]. Read
|
||||
/// `is_start` to tell whether this opens the group (switch render target) or closes it
|
||||
/// (filter the offscreen target and composite it back).
|
||||
FilterBoundary(usize),
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Copy, Clone)]
|
||||
@@ -543,6 +664,59 @@ impl From<Shadow> for Primitive {
|
||||
}
|
||||
}
|
||||
|
||||
/// A backdrop filter blurs (and may otherwise filter) the content already rendered behind
|
||||
/// `bounds`, compositing the result into a rounded rectangle — the frosted-glass effect.
|
||||
/// Emitted by [`crate::Window::paint_backdrop_filter`]; produces the CSS `backdrop-filter` effect.
|
||||
#[derive(Default, Debug, Clone)]
|
||||
#[expect(missing_docs)]
|
||||
pub struct BackdropFilter {
|
||||
pub order: DrawOrder,
|
||||
pub bounds: Bounds<ScaledPixels>,
|
||||
pub content_mask: ContentMask<ScaledPixels>,
|
||||
pub corner_radii: Corners<ScaledPixels>,
|
||||
/// The filter chain applied to the backdrop, in scene (device-pixel) space. Identity filters
|
||||
/// are dropped at paint time, so a `BackdropFilter` is only emitted when this is non-empty.
|
||||
///
|
||||
/// Inline capacity is 4: a `SmallVec<[ScaledFilter; 4]>` is the same size as capacity 1 here
|
||||
/// (the heap repr already occupies that space), so chains up to 4 filters avoid allocating
|
||||
/// at no extra struct size.
|
||||
pub filters: SmallVec<[ScaledFilter; 4]>,
|
||||
/// Element opacity captured at paint time, multiplied into the composited result.
|
||||
pub opacity: f32,
|
||||
}
|
||||
|
||||
impl From<BackdropFilter> for Primitive {
|
||||
fn from(filter: BackdropFilter) -> Self {
|
||||
Primitive::BackdropFilter(filter)
|
||||
}
|
||||
}
|
||||
|
||||
/// The start or end marker of a content-filter (`filter`) isolation group. The element's
|
||||
/// subtree is painted between a matched start/end pair; the renderer redirects that span into
|
||||
/// an offscreen target, filters it, and composites it back at `bounds`. Produces the CSS
|
||||
/// `filter` effect (e.g. blurring the element and its children as a single group).
|
||||
#[derive(Debug, Clone)]
|
||||
#[expect(missing_docs)]
|
||||
pub struct FilterBoundary {
|
||||
pub order: DrawOrder,
|
||||
pub bounds: Bounds<ScaledPixels>,
|
||||
pub content_mask: ContentMask<ScaledPixels>,
|
||||
pub corner_radii: Corners<ScaledPixels>,
|
||||
/// The filter chain applied to the isolated group, in scene (device-pixel) space. Identity
|
||||
/// filters are dropped at paint time, so a `FilterBoundary` is only emitted when non-empty.
|
||||
/// Inline capacity 4 (same struct size as 1 here — see [`BackdropFilter::filters`]).
|
||||
pub filters: SmallVec<[ScaledFilter; 4]>,
|
||||
pub opacity: f32,
|
||||
/// `true` for the start marker (opens the group), `false` for the end marker (closes it).
|
||||
pub is_start: bool,
|
||||
}
|
||||
|
||||
impl From<FilterBoundary> for Primitive {
|
||||
fn from(boundary: FilterBoundary) -> Self {
|
||||
Primitive::FilterBoundary(boundary)
|
||||
}
|
||||
}
|
||||
|
||||
/// The style of a border.
|
||||
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
|
||||
#[repr(C)]
|
||||
@@ -903,3 +1077,171 @@ impl PathVertex<Pixels> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Point, Size};
|
||||
|
||||
fn sp(value: f32) -> ScaledPixels {
|
||||
ScaledPixels(value)
|
||||
}
|
||||
|
||||
/// All test primitives cover the same region so the bounds tree assigns strictly
|
||||
/// increasing orders in insertion order — making the expected batch order deterministic.
|
||||
fn full_bounds() -> Bounds<ScaledPixels> {
|
||||
Bounds {
|
||||
origin: Point {
|
||||
x: sp(0.0),
|
||||
y: sp(0.0),
|
||||
},
|
||||
size: Size {
|
||||
width: sp(100.0),
|
||||
height: sp(100.0),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn mask() -> ContentMask<ScaledPixels> {
|
||||
ContentMask {
|
||||
bounds: full_bounds(),
|
||||
}
|
||||
}
|
||||
|
||||
fn quad() -> Quad {
|
||||
Quad {
|
||||
bounds: full_bounds(),
|
||||
content_mask: mask(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A 100x100 quad whose bounds don't overlap `full_bounds()` (used to exercise the
|
||||
/// order-reuse path: non-overlapping content reuses low draw-orders).
|
||||
fn detached_quad() -> Quad {
|
||||
let bounds = Bounds {
|
||||
origin: Point {
|
||||
x: sp(200.0),
|
||||
y: sp(200.0),
|
||||
},
|
||||
size: Size {
|
||||
width: sp(100.0),
|
||||
height: sp(100.0),
|
||||
},
|
||||
};
|
||||
Quad {
|
||||
bounds,
|
||||
content_mask: ContentMask { bounds },
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn boundary(is_start: bool) -> FilterBoundary {
|
||||
FilterBoundary {
|
||||
order: 0,
|
||||
bounds: full_bounds(),
|
||||
content_mask: mask(),
|
||||
corner_radii: Corners::default(),
|
||||
filters: smallvec::smallvec![ScaledFilter::Blur(sp(8.0))],
|
||||
opacity: 1.0,
|
||||
is_start,
|
||||
}
|
||||
}
|
||||
|
||||
fn backdrop() -> BackdropFilter {
|
||||
BackdropFilter {
|
||||
bounds: full_bounds(),
|
||||
content_mask: mask(),
|
||||
corner_radii: Corners::default(),
|
||||
filters: smallvec::smallvec![ScaledFilter::Blur(sp(20.0))],
|
||||
opacity: 1.0,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn batch_kinds(scene: &mut Scene) -> Vec<&'static str> {
|
||||
scene.finish();
|
||||
scene
|
||||
.batches()
|
||||
.map(|batch| match batch {
|
||||
PrimitiveBatch::Quads(_) => "quad",
|
||||
PrimitiveBatch::BackdropFilters(_) => "backdrop",
|
||||
PrimitiveBatch::FilterBoundary(ix) => {
|
||||
if scene.filter_boundaries[ix].is_start {
|
||||
"start"
|
||||
} else {
|
||||
"end"
|
||||
}
|
||||
}
|
||||
_ => "other",
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_filter_group_brackets_its_children() {
|
||||
let mut scene = Scene::default();
|
||||
// Background painted before the filtered element.
|
||||
scene.insert_primitive(quad());
|
||||
// A content-filtered element: start marker, its child, end marker.
|
||||
scene.insert_primitive(boundary(true));
|
||||
scene.insert_primitive(quad());
|
||||
scene.insert_primitive(boundary(false));
|
||||
|
||||
// The start must precede the group's child and the end must follow it, so the
|
||||
// renderer can redirect rendering for exactly the group's span.
|
||||
assert_eq!(
|
||||
batch_kinds(&mut scene),
|
||||
vec!["quad", "start", "quad", "end"]
|
||||
);
|
||||
}
|
||||
|
||||
// Note: this validates only the *scene ordering* of nested filter boundaries (start/child/
|
||||
// end interleaving), not that a renderer actually isolates both levels — that depends on the
|
||||
// backend's group-texture pool (see MAX_FILTER_DEPTH) and is exercised by the `blur` example.
|
||||
#[test]
|
||||
fn nested_content_filters_emit_well_nested_ordering() {
|
||||
let mut scene = Scene::default();
|
||||
scene.insert_primitive(boundary(true)); // outer start
|
||||
scene.insert_primitive(quad()); // outer child
|
||||
scene.insert_primitive(boundary(true)); // inner start
|
||||
scene.insert_primitive(quad()); // inner child
|
||||
scene.insert_primitive(boundary(false)); // inner end
|
||||
scene.insert_primitive(boundary(false)); // outer end
|
||||
|
||||
assert_eq!(
|
||||
batch_kinds(&mut scene),
|
||||
vec!["start", "quad", "start", "quad", "end", "end"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_after_a_filter_group_sorts_above_it() {
|
||||
let mut scene = Scene::default();
|
||||
// A content-filtered element: start marker, its child, end marker.
|
||||
scene.insert_primitive(boundary(true));
|
||||
scene.insert_primitive(quad());
|
||||
scene.insert_primitive(boundary(false));
|
||||
// A sibling painted after the group that does NOT overlap it. Without the close-time
|
||||
// order-floor it would reuse the lowest order, tie with the start marker, and be swept
|
||||
// into the group (start, quad, quad, end); it must instead sort after the end marker.
|
||||
scene.insert_primitive(detached_quad());
|
||||
|
||||
assert_eq!(
|
||||
batch_kinds(&mut scene),
|
||||
vec!["start", "quad", "end", "quad"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backdrop_filter_sorts_before_a_later_overlapping_quad() {
|
||||
let mut scene = Scene::default();
|
||||
// Content behind the frosted panel.
|
||||
scene.insert_primitive(quad());
|
||||
// The panel: its backdrop snapshot, then its (translucent) background quad on top.
|
||||
scene.insert_primitive(backdrop());
|
||||
scene.insert_primitive(quad());
|
||||
|
||||
assert_eq!(batch_kinds(&mut scene), vec!["quad", "backdrop", "quad"]);
|
||||
}
|
||||
}
|
||||
|
||||
+112
-40
@@ -8,7 +8,8 @@ use crate::{
|
||||
AbsoluteLength, App, Background, BackgroundTag, BorderStyle, Bounds, ContentMask, Corners,
|
||||
CornersRefinement, CursorStyle, DefiniteLength, DevicePixels, Edges, EdgesRefinement, Font,
|
||||
FontFallbacks, FontFeatures, FontStyle, FontWeight, GridLocation, Hsla, Length, Pixels, Point,
|
||||
PointRefinement, Rgba, SharedString, Size, SizeRefinement, Styled, TextRun, Window, black, phi,
|
||||
PointRefinement, Rgba, ScaledPixels, SharedString, Size, SizeRefinement, Styled, TextRun,
|
||||
Window, black, phi,
|
||||
point, quad, rems, size,
|
||||
};
|
||||
use collections::HashSet;
|
||||
@@ -287,6 +288,12 @@ pub struct Style {
|
||||
/// Box shadow of the element
|
||||
pub box_shadow: Vec<BoxShadow>,
|
||||
|
||||
/// Filters applied to this element's own content and children (CSS `filter`).
|
||||
pub filter: Vec<Filter>,
|
||||
|
||||
/// Filters applied to the content rendered behind this element (CSS `backdrop-filter`).
|
||||
pub backdrop_filter: Vec<Filter>,
|
||||
|
||||
/// The text style of this element
|
||||
#[refineable]
|
||||
pub text: TextStyleRefinement,
|
||||
@@ -355,6 +362,50 @@ pub struct BoxShadow {
|
||||
pub inset: bool,
|
||||
}
|
||||
|
||||
/// A graphical filter that can be applied either to an element's own content
|
||||
/// (via [`Styled::filter`], like CSS `filter`) or to the content rendered behind
|
||||
/// it (via [`Styled::backdrop_filter`], like CSS `backdrop-filter`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub enum Filter {
|
||||
/// A gaussian blur with the given radius, in logical pixels. Maps to CSS `blur(<px>)`.
|
||||
Blur(Pixels),
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
/// Whether this filter has no visible effect, so painting can skip it entirely (and the
|
||||
/// element can avoid the offscreen isolation pass when *all* of its filters are identities).
|
||||
///
|
||||
/// Each variant declares its own no-op case here rather than the pipeline special-casing
|
||||
/// blur — adding a filter that this returns `true` for is silently dropped before it ever
|
||||
/// reaches the renderer.
|
||||
pub fn is_identity(&self) -> bool {
|
||||
match self {
|
||||
Filter::Blur(radius) => *radius <= Pixels::ZERO,
|
||||
}
|
||||
}
|
||||
|
||||
/// Lower this logical-pixel filter into its scene-space ([`ScaledFilter`]) form for the
|
||||
/// renderer, scaling any pixel magnitudes by `factor` (the window scale factor).
|
||||
pub fn scale(&self, factor: f32) -> ScaledFilter {
|
||||
match self {
|
||||
Filter::Blur(radius) => ScaledFilter::Blur(radius.scale(factor)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The scene-space (device-pixel) form of a [`Filter`], carried on the scene primitives that the
|
||||
/// renderers consume. Produced by [`Filter::scale`]; pixel magnitudes are in [`ScaledPixels`].
|
||||
///
|
||||
/// This is intentionally a separate enum from [`Filter`] (rather than reusing it) so the scene
|
||||
/// stays in device space like every other primitive, and so the renderers `match` on it
|
||||
/// exhaustively — adding a filter variant breaks each backend's match, forcing a deliberate
|
||||
/// implement-or-decline decision per backend instead of silently rendering nothing.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum ScaledFilter {
|
||||
/// A gaussian blur with the given radius, in scaled (device) pixels.
|
||||
Blur(ScaledPixels),
|
||||
}
|
||||
|
||||
/// How to handle whitespace in text
|
||||
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
pub enum WhiteSpace {
|
||||
@@ -669,49 +720,68 @@ impl Style {
|
||||
|
||||
window.paint_drop_shadows(bounds, corner_radii, &self.box_shadow);
|
||||
|
||||
let background_color = self.background.as_ref().and_then(Fill::color);
|
||||
if background_color.is_some_and(|color| !color.is_transparent()) {
|
||||
let mut border_color = match background_color {
|
||||
Some(color) => match color.tag {
|
||||
BackgroundTag::Solid
|
||||
| BackgroundTag::PatternSlash
|
||||
| BackgroundTag::Checkerboard => color.solid,
|
||||
|
||||
BackgroundTag::LinearGradient => color
|
||||
.colors
|
||||
.first()
|
||||
.map(|stop| stop.color)
|
||||
.unwrap_or_default(),
|
||||
},
|
||||
None => Hsla::default(),
|
||||
};
|
||||
border_color.a = 0.;
|
||||
window.paint_quad(quad(
|
||||
bounds,
|
||||
corner_radii,
|
||||
background_color.unwrap_or_default(),
|
||||
Edges::default(),
|
||||
border_color,
|
||||
self.border_style,
|
||||
));
|
||||
// Blur the content behind this element before its (typically translucent) background
|
||||
// is painted on top, so the background tints the frosted backdrop (CSS `backdrop-filter`).
|
||||
if !self.backdrop_filter.is_empty() {
|
||||
window.paint_backdrop_filter(bounds, corner_radii, &self.backdrop_filter);
|
||||
}
|
||||
|
||||
window.paint_inset_shadows(bounds, corner_radii, &self.box_shadow);
|
||||
// The element's own box — background, inset shadows, children, and border — painted as a
|
||||
// unit. A `filter` (CSS `filter`) wraps this whole unit so the renderer blurs the element
|
||||
// and its children together as one group; without a filter it paints directly.
|
||||
let paint_box = |window: &mut Window, cx: &mut App| {
|
||||
let background_color = self.background.as_ref().and_then(Fill::color);
|
||||
if background_color.is_some_and(|color| !color.is_transparent()) {
|
||||
let mut border_color = match background_color {
|
||||
Some(color) => match color.tag {
|
||||
BackgroundTag::Solid
|
||||
| BackgroundTag::PatternSlash
|
||||
| BackgroundTag::Checkerboard => color.solid,
|
||||
|
||||
continuation(window, cx);
|
||||
BackgroundTag::LinearGradient => color
|
||||
.colors
|
||||
.first()
|
||||
.map(|stop| stop.color)
|
||||
.unwrap_or_default(),
|
||||
},
|
||||
None => Hsla::default(),
|
||||
};
|
||||
border_color.a = 0.;
|
||||
window.paint_quad(quad(
|
||||
bounds,
|
||||
corner_radii,
|
||||
background_color.unwrap_or_default(),
|
||||
Edges::default(),
|
||||
border_color,
|
||||
self.border_style,
|
||||
));
|
||||
}
|
||||
|
||||
if self.is_border_visible() {
|
||||
let border_widths = self.border_widths.to_pixels(rem_size);
|
||||
let mut background = self.border_color.unwrap_or_default();
|
||||
background.a = 0.;
|
||||
window.paint_quad(quad(
|
||||
bounds,
|
||||
corner_radii,
|
||||
background,
|
||||
border_widths,
|
||||
self.border_color.unwrap_or_default(),
|
||||
self.border_style,
|
||||
));
|
||||
window.paint_inset_shadows(bounds, corner_radii, &self.box_shadow);
|
||||
|
||||
continuation(window, cx);
|
||||
|
||||
if self.is_border_visible() {
|
||||
let border_widths = self.border_widths.to_pixels(rem_size);
|
||||
let mut background = self.border_color.unwrap_or_default();
|
||||
background.a = 0.;
|
||||
window.paint_quad(quad(
|
||||
bounds,
|
||||
corner_radii,
|
||||
background,
|
||||
border_widths,
|
||||
self.border_color.unwrap_or_default(),
|
||||
self.border_style,
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
if self.filter.is_empty() {
|
||||
paint_box(window, cx);
|
||||
} else {
|
||||
window.with_filter_layer(bounds, corner_radii, &self.filter, |window| {
|
||||
paint_box(window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
@@ -765,6 +835,8 @@ impl Default for Style {
|
||||
border_style: BorderStyle::default(),
|
||||
corner_radii: Corners::default(),
|
||||
box_shadow: Default::default(),
|
||||
filter: Default::default(),
|
||||
backdrop_filter: Default::default(),
|
||||
text: TextStyleRefinement::default(),
|
||||
mouse_cursor: None,
|
||||
opacity: None,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::{
|
||||
self as gpui, AbsoluteLength, AlignContent, AlignItems, AlignSelf, BorderStyle, CursorStyle,
|
||||
DefiniteLength, Display, Fill, FlexDirection, FlexWrap, Font, FontFeatures, FontStyle,
|
||||
FontWeight, GridPlacement, GridTemplate, Hsla, JustifyContent, Length, SharedString,
|
||||
DefiniteLength, Display, Fill, Filter, FlexDirection, FlexWrap, Font, FontFeatures, FontStyle,
|
||||
FontWeight, GridPlacement, GridTemplate, Hsla, JustifyContent, Length, Pixels, SharedString,
|
||||
StrikethroughStyle, StyleRefinement, TemplateColumnMinSize, TextAlign, TextOverflow,
|
||||
TextStyleRefinement, UnderlineStyle, WhiteSpace, px, relative, rems,
|
||||
};
|
||||
@@ -33,6 +33,53 @@ pub trait Styled: Sized {
|
||||
gpui_macros::border_style_methods!();
|
||||
gpui_macros::box_shadow_style_methods!();
|
||||
|
||||
/// Blur this element's own content and children, like CSS `filter: blur(<radius>)`.
|
||||
///
|
||||
/// This isolates the element's subtree, blurs it as a group, and composites the
|
||||
/// result back. To blur the content *behind* the element instead (frosted glass),
|
||||
/// use [`Styled::backdrop_blur`].
|
||||
///
|
||||
/// *Appends* to the element's filter chain, so it composes with other convenience
|
||||
/// setters (`.blur(8.).<other_filter>()`). To replace the whole chain, use
|
||||
/// [`Styled::filter`].
|
||||
fn blur(mut self, radius: impl Into<Pixels>) -> Self {
|
||||
self.style()
|
||||
.filter
|
||||
.get_or_insert_with(Vec::new)
|
||||
.push(Filter::Blur(radius.into()));
|
||||
self
|
||||
}
|
||||
|
||||
/// Set (replacing any existing) the full list of filters applied to this element's own
|
||||
/// content, like CSS `filter`. To add a single filter to the chain instead, use the
|
||||
/// convenience setters such as [`Styled::blur`].
|
||||
fn filter(mut self, filters: impl Into<Vec<Filter>>) -> Self {
|
||||
self.style().filter = Some(filters.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Blur the content rendered behind this element — a frosted-glass effect — like CSS
|
||||
/// `backdrop-filter: blur(<radius>)`. Typically paired with a translucent [`Styled::bg`]
|
||||
/// so the background tints the blurred backdrop.
|
||||
///
|
||||
/// *Appends* to the element's backdrop-filter chain. To replace the whole chain, use
|
||||
/// [`Styled::backdrop_filter`].
|
||||
fn backdrop_blur(mut self, radius: impl Into<Pixels>) -> Self {
|
||||
self.style()
|
||||
.backdrop_filter
|
||||
.get_or_insert_with(Vec::new)
|
||||
.push(Filter::Blur(radius.into()));
|
||||
self
|
||||
}
|
||||
|
||||
/// Set (replacing any existing) the full list of filters applied to the content behind this
|
||||
/// element, like CSS `backdrop-filter`. To add a single filter to the chain instead, use the
|
||||
/// convenience setters such as [`Styled::backdrop_blur`].
|
||||
fn backdrop_filter(mut self, filters: impl Into<Vec<Filter>>) -> Self {
|
||||
self.style().backdrop_filter = Some(filters.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the display type of the element to `block`.
|
||||
/// [Docs](https://tailwindcss.com/docs/display)
|
||||
fn block(mut self) -> Self {
|
||||
|
||||
+104
-6
@@ -2,17 +2,18 @@
|
||||
use crate::Inspector;
|
||||
use crate::{
|
||||
Action, AnyDrag, AnyElement, AnyImageCache, AnyTooltip, AnyView, App, AppContext, Arena, Asset,
|
||||
AsyncWindowContext, AvailableSpace, Background, BorderStyle, Bounds, BoxShadow, Capslock,
|
||||
Context, Corners, CursorHideMode, CursorStyle, Decorations, DevicePixels,
|
||||
AsyncWindowContext, AvailableSpace, BackdropFilter, Background, BorderStyle, Bounds, BoxShadow,
|
||||
Capslock, Context, Corners, CursorHideMode, CursorStyle, Decorations, DevicePixels,
|
||||
DispatchActionListener, DispatchNodeId, DispatchTree, DisplayId, Edges, Effect, Entity,
|
||||
EntityId, EventEmitter, FileDropEvent, FontId, Global, GlobalElementId, GlyphId, GpuSpecs,
|
||||
Hsla, InputHandler, IsZero, KeyBinding, KeyContext, KeyDownEvent, KeyEvent, Keystroke,
|
||||
KeystrokeEvent, LayoutId, Lerp, LineLayoutIndex, Modifiers, ModifiersChangedEvent,
|
||||
EntityId, EventEmitter, FileDropEvent, Filter, FilterBoundary, FontId, Global, GlobalElementId,
|
||||
GlyphId, GpuSpecs, Hsla, InputHandler, IsZero, KeyBinding, KeyContext, KeyDownEvent, KeyEvent,
|
||||
Keystroke, KeystrokeEvent, LayoutId, Lerp, LineLayoutIndex, Modifiers, ModifiersChangedEvent,
|
||||
MonochromeSprite, MouseButton, MouseEvent, MouseMoveEvent, MouseUpEvent, Path, Pixels,
|
||||
PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point,
|
||||
PolychromeSprite, Priority, PromptButton, PromptLevel, Quad, Render, RenderGlyphParams,
|
||||
RenderImage, RenderImageParams, RenderSvgParams, Replay, ResizeEdge, SMOOTH_SVG_SCALE_FACTOR,
|
||||
SUBPIXEL_VARIANTS_X, SUBPIXEL_VARIANTS_Y, ScaledPixels, Scene, Shadow, SharedString, Size,
|
||||
SUBPIXEL_VARIANTS_X, SUBPIXEL_VARIANTS_Y, ScaledFilter, ScaledPixels, Scene, Shadow,
|
||||
SharedString, Size,
|
||||
StrikethroughStyle, Style, SubpixelSprite, SubscriberSet, Subscription, SystemWindowTab,
|
||||
SystemWindowTabController, TabStopMap, TaffyLayoutEngine, Task, TextRenderingMode, TextStyle,
|
||||
TextStyleRefinement, ThermalState, TransformationMatrix, Transition, TransitionState,
|
||||
@@ -2938,6 +2939,11 @@ impl Window {
|
||||
return;
|
||||
}
|
||||
|
||||
// Deferred draws are overlays (tooltips, popovers, drag images) and must sort above the
|
||||
// whole main scene. Raise the order floor so they do — this also keeps a deferred
|
||||
// backdrop's order from falling inside a content-filter order range left by the main scene.
|
||||
self.next_frame.scene.raise_order_floor();
|
||||
|
||||
let traversal_order = self.deferred_draw_traversal_order();
|
||||
let mut deferred_draws = mem::take(&mut self.next_frame.deferred_draws);
|
||||
for deferred_draw_ix in traversal_order {
|
||||
@@ -3677,6 +3683,98 @@ impl Window {
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint a backdrop filter into the scene for the next frame at the current z-index. The
|
||||
/// renderer blurs the content already painted behind `bounds` and composites the result
|
||||
/// into the rounded rectangle described by `bounds` and `corner_radii` — the CSS
|
||||
/// `backdrop-filter` effect (frosted glass). Typically the element then paints a translucent
|
||||
/// background quad on top so its color tints the blurred backdrop.
|
||||
///
|
||||
/// Does nothing when `filters` produce no visible blur.
|
||||
///
|
||||
/// This method should only be called as part of the paint phase of element drawing.
|
||||
pub fn paint_backdrop_filter(
|
||||
&mut self,
|
||||
bounds: Bounds<Pixels>,
|
||||
corner_radii: Corners<Pixels>,
|
||||
filters: &[Filter],
|
||||
) {
|
||||
self.invalidator.debug_assert_paint();
|
||||
|
||||
let scale_factor = self.scale_factor();
|
||||
let filters: SmallVec<[ScaledFilter; 4]> = filters
|
||||
.iter()
|
||||
.filter(|filter| !filter.is_identity())
|
||||
.map(|filter| filter.scale(scale_factor))
|
||||
.collect();
|
||||
if filters.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.next_frame.scene.insert_primitive(BackdropFilter {
|
||||
order: 0,
|
||||
bounds: self.snap_bounds(bounds),
|
||||
content_mask: self.snapped_content_mask(),
|
||||
corner_radii: corner_radii.scale(scale_factor),
|
||||
filters,
|
||||
opacity: self.element_opacity(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Isolate the painting performed by `f` into a content-filter group: the renderer renders
|
||||
/// everything `f` paints into an offscreen target, blurs it as a single layer, and
|
||||
/// composites the result back into the rounded rectangle described by `bounds` and
|
||||
/// `corner_radii` — the CSS `filter` effect (e.g. blurring an element and its children).
|
||||
///
|
||||
/// When `filters` produce no visible blur this simply runs `f` with no offscreen
|
||||
/// indirection.
|
||||
///
|
||||
/// This method should only be called as part of the paint phase of element drawing.
|
||||
pub fn with_filter_layer<R>(
|
||||
&mut self,
|
||||
bounds: Bounds<Pixels>,
|
||||
corner_radii: Corners<Pixels>,
|
||||
filters: &[Filter],
|
||||
f: impl FnOnce(&mut Self) -> R,
|
||||
) -> R {
|
||||
self.invalidator.debug_assert_paint();
|
||||
|
||||
let scale_factor = self.scale_factor();
|
||||
let filters: SmallVec<[ScaledFilter; 4]> = filters
|
||||
.iter()
|
||||
.filter(|filter| !filter.is_identity())
|
||||
.map(|filter| filter.scale(scale_factor))
|
||||
.collect();
|
||||
if filters.is_empty() {
|
||||
return f(self);
|
||||
}
|
||||
|
||||
// Snapshot the (scaled) group parameters once so the start and end markers agree.
|
||||
//
|
||||
// `opacity` is 1.0 — NOT `element_opacity()`. The group's children/bg/border are painted
|
||||
// through the normal paint methods while `element_opacity` is still in effect, so they
|
||||
// already carry the element's opacity (consistent with gpui's per-primitive opacity for
|
||||
// non-filtered elements). Re-applying it at composite time would double it (e.g.
|
||||
// `.blur(r).opacity(0.5)` would render at 0.25 instead of 0.5).
|
||||
let boundary = FilterBoundary {
|
||||
order: 0,
|
||||
bounds: self.snap_bounds(bounds),
|
||||
content_mask: self.snapped_content_mask(),
|
||||
corner_radii: corner_radii.scale(scale_factor),
|
||||
filters,
|
||||
opacity: 1.0,
|
||||
is_start: true,
|
||||
};
|
||||
|
||||
self.next_frame.scene.insert_primitive(boundary.clone());
|
||||
let result = f(self);
|
||||
self.next_frame.scene.insert_primitive(FilterBoundary {
|
||||
is_start: false,
|
||||
..boundary
|
||||
});
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Paint one or more quads into the scene for the next frame at the current stacking context.
|
||||
/// Quads are colored rectangular regions with an optional background, border, and corner radius.
|
||||
/// see [`fill`], [`outline`], and [`quad`] to construct this type.
|
||||
|
||||
Reference in New Issue
Block a user