use std::{ops::Range, rc::Rc, sync::Arc};
use cranpose_core::{MemoryApplier, NodeId};
#[cfg(test)]
use cranpose_render_common::geometry::{expand_blurred_rect, union_rect};
#[cfg(test)]
use cranpose_render_common::primitive_emit::resolve_clip;
use cranpose_render_common::{
Brush, RenderScene,
hit_graph::collect_hits_from_graph,
layer_shadow::layer_shadow_geometry,
layer_transform::{apply_layer_to_rect, layer_uniform_scale},
primitive_emit::{
DrawPrimitiveSink, ImageDrawParams, ShapeDrawParams, TextDrawParams, emit_draw_primitive,
},
};
#[cfg(test)]
use cranpose_ui::TextOverflow;
#[cfg(test)]
use cranpose_ui::layout_text;
#[cfg(test)]
use cranpose_ui::measure_text;
#[cfg(test)]
use cranpose_ui::prepare_text_layout;
#[cfg(test)]
use cranpose_ui::text::{ResolvedTextDirection, TextAlign, resolve_text_direction};
use cranpose_ui::{
LayoutBox, TextLayoutOptions,
text::{TextDecoration, TextDrawStyle, TextStyle},
text_layout_result::TextLayoutResult,
};
use cranpose_ui_graphics::{
BlendMode, Color, DrawPrimitive, GraphicsLayer, LayerShape, Point, Recorded, Rect,
RenderEffect, RoundedCornerShape, RuntimeShader, ShapeRecorder, TileMode,
};
use crate::scene::{CompositorScene, Placement, RunDraw, Scene, ShadowDraw, SnapAnchor, TextDraw};
mod style;
use cranpose_render_common::style_shared::resolve_layer_brush;
use style::{apply_layer_to_brush, apply_layer_to_color, scale_corner_radii};
const GPU_TEXT_BRUSH_EFFECT_MAX_STOPS: usize = 16;
const GPU_TEXT_BRUSH_EFFECT_FIRST_STOP_SLOT: usize = 8;
const DECORATION_SEGMENT_MERGE_EPSILON: f32 = 0.75;
pub(crate) const GPU_TEXT_BRUSH_EFFECT_SHADER: &str =
cranpose_ui_graphics::framework_shaders::GPU_TEXT_BRUSH_EFFECT_WGSL;
pub(crate) trait TextLayoutResolver {
fn layout_text(
&mut self,
text: &cranpose_ui::text::AnnotatedString,
style: &TextStyle,
) -> TextLayoutResult;
}
#[cfg(test)]
pub(crate) struct UiTextLayoutResolver;
#[cfg(test)]
impl TextLayoutResolver for UiTextLayoutResolver {
fn layout_text(
&mut self,
text: &cranpose_ui::text::AnnotatedString,
style: &TextStyle,
) -> TextLayoutResult {
layout_text(text, style)
}
}
fn layer_shadow_run(rect: Rect, color: Color, shape: Option<RoundedCornerShape>) -> RunDraw {
let origin = Point::new(rect.x, rect.y);
let rect = rect.translate(-origin.x, -origin.y);
let brush = Brush::solid(color);
let primitive = match shape {
Some(shape) => DrawPrimitive::RoundRect {
rect,
brush,
radii: shape.radii(),
stroke: None,
},
None => DrawPrimitive::Rect {
rect,
brush,
stroke: None,
},
};
let mut recorder = ShapeRecorder::default();
recorder.push_primitive(primitive);
RunDraw::whole(Arc::new(recorder), Placement::at(origin, None, None)).expect("a shadow rect")
}
fn shadow_occluder(
layer: &GraphicsLayer,
transformed_bounds: Rect,
resolved_shape: Option<&RoundedCornerShape>,
) -> Option<Rect> {
if layer.alpha < 1.0
|| layer.rotation_x.abs() > f32::EPSILON
|| layer.rotation_y.abs() > f32::EPSILON
|| layer.rotation_z.abs() > f32::EPSILON
{
return None;
}
let inset = resolved_shape
.map_or(0.0, |shape| {
let radii = shape.radii();
radii
.top_left
.max(radii.top_right)
.max(radii.bottom_right)
.max(radii.bottom_left)
})
.max(0.0);
let occluder = Rect {
x: transformed_bounds.x + inset,
y: transformed_bounds.y + inset,
width: transformed_bounds.width - inset * 2.0,
height: transformed_bounds.height - inset * 2.0,
};
(occluder.width > 1.0 && occluder.height > 1.0).then_some(occluder)
}
pub(crate) fn push_layer_shadow(
scene: &mut CompositorScene,
layer: &GraphicsLayer,
layer_bounds: Rect,
transformed_bounds: Rect,
clip: Option<Rect>,
) {
let shadow_geometry = layer_shadow_geometry(layer, transformed_bounds);
let resolved_shape = match layer.shape {
LayerShape::Rectangle => None,
LayerShape::Rounded(shape) => {
let scale = layer_uniform_scale(layer).max(0.1);
let resolved = shape.resolve(layer_bounds.width, layer_bounds.height);
Some(RoundedCornerShape::with_radii(scale_corner_radii(
resolved, scale,
)))
}
};
let occluder = shadow_occluder(layer, transformed_bounds, resolved_shape.as_ref());
if let Some(ambient_pass) = shadow_geometry.ambient {
let ambient = Color(
layer.ambient_shadow_color.r(),
layer.ambient_shadow_color.g(),
layer.ambient_shadow_color.b(),
ambient_pass.alpha,
);
scene.push_shadow_draw(ShadowDraw {
shapes: Some(layer_shadow_run(ambient_pass.rect, ambient, resolved_shape)),
post_blur_cutouts: None,
texts: vec![],
blur_radius: ambient_pass.blur_radius,
clip,
rounded_clip: None,
occluder,
z_index: 0,
});
}
if let Some(spot_pass) = shadow_geometry.spot {
let spot = Color(
layer.spot_shadow_color.r(),
layer.spot_shadow_color.g(),
layer.spot_shadow_color.b(),
spot_pass.alpha,
);
scene.push_shadow_draw(ShadowDraw {
shapes: Some(layer_shadow_run(spot_pass.rect, spot, resolved_shape)),
post_blur_cutouts: None,
texts: vec![],
blur_radius: spot_pass.blur_radius,
clip,
rounded_clip: None,
occluder,
z_index: 0,
});
}
}
pub(crate) fn render_layout_tree(root: &LayoutBox, scene: &mut Scene) {
render_layout_tree_with_scale(root, scene, 1.0);
}
pub(crate) fn render_layout_tree_with_scale(root: &LayoutBox, scene: &mut Scene, scale: f32) {
let graph = cranpose_render_common::scene_builder::build_graph_from_layout_tree(root, scale);
collect_hits_from_graph(
&graph.root,
cranpose_render_common::graph::ProjectiveTransform::identity(),
scene,
None,
);
scene.replace_graph(graph);
}
fn resolve_text_color_without_gradient_fallback(text_style: &TextStyle, default: Color) -> Color {
let mut color = text_style
.span_style
.color
.or(match text_style.span_style.brush.as_ref() {
Some(Brush::Solid(color)) => Some(*color),
_ => None,
})
.unwrap_or(default);
if let Some(alpha) = text_style.span_style.alpha {
color.3 *= alpha.clamp(0.0, 1.0);
}
color
}
fn tile_mode_to_shader_uniform(tile_mode: TileMode) -> f32 {
match tile_mode {
TileMode::Clamp => 0.0,
TileMode::Repeated => 1.0,
TileMode::Mirror => 2.0,
TileMode::Decal => 3.0,
}
}
fn normalized_gradient_stops(color_count: usize, stops: Option<&[f32]>) -> Vec<f32> {
if let Some(explicit) = stops.filter(|values| values.len() == color_count) {
return explicit.to_vec();
}
if color_count <= 1 {
return vec![0.0; color_count];
}
(0..color_count)
.map(|index| index as f32 / (color_count - 1) as f32)
.collect()
}
fn set_shader_vec4(shader: &mut RuntimeShader, slot: usize, values: [f32; 4]) {
shader.set_float4(slot * 4, values[0], values[1], values[2], values[3]);
}
fn resolve_gradient_component(extent: f32, value: f32) -> f32 {
if value.is_finite() {
value
} else if value.is_sign_positive() {
extent.max(0.0)
} else {
0.0
}
}
const GPU_TEXT_BRUSH_KIND_LINEAR: f32 = 0.0;
const GPU_TEXT_BRUSH_KIND_RADIAL: f32 = 1.0;
const GPU_TEXT_BRUSH_KIND_SWEEP: f32 = 2.0;
const GPU_TEXT_BRUSH_KIND_SOLID: f32 = 3.0;
const GPU_TEXT_BRUSH_EFFECT_MATERIAL_SLOT: usize = 5;
const GPU_TEXT_DRAW_MODE_FILL: f32 = 0.0;
const GPU_TEXT_DRAW_MODE_STROKE: f32 = 1.0;
const GPU_TEXT_STROKE_EFFECT_EDGE_PAD: f32 = 1.0;
#[derive(Clone, PartialEq)]
enum GpuTextDrawMode {
Fill,
Stroke { width: f32 },
}
#[derive(Clone, PartialEq)]
struct GpuTextMaterial {
brush: Brush,
alpha_multiplier: f32,
draw_mode: GpuTextDrawMode,
}
#[derive(Clone)]
struct GpuTextMaterialBatch {
material: GpuTextMaterial,
visible_ranges: Vec<Range<usize>>,
}
fn stroke_effect_padding_local(stroke_width_local: f32) -> f32 {
if !stroke_width_local.is_finite() || stroke_width_local <= 0.0 {
return 0.0;
}
stroke_width_local * 0.5 + GPU_TEXT_STROKE_EFFECT_EDGE_PAD
}
fn stroke_effect_padding_for_draw_mode(draw_mode: &GpuTextDrawMode) -> f32 {
match draw_mode {
GpuTextDrawMode::Fill => 0.0,
GpuTextDrawMode::Stroke { width } => stroke_effect_padding_local(*width),
}
}
fn expand_text_effect_rect(text_rect: Rect, stroke_padding: f32) -> Rect {
let padding = stroke_padding.max(0.0);
if padding <= 0.0 {
return text_rect;
}
Rect {
x: text_rect.x - padding,
y: text_rect.y - padding,
width: (text_rect.width + padding * 2.0).max(0.0),
height: (text_rect.height + padding * 2.0).max(0.0),
}
}
fn gpu_text_material_for_style(
text_style: &TextStyle,
fallback_color: Color,
text_scale: f32,
) -> GpuTextMaterial {
let scale = if text_scale.is_finite() && text_scale > 0.0 {
text_scale
} else {
1.0
};
let draw_mode = match text_style.span_style.draw_style {
Some(TextDrawStyle::Stroke { width }) if width.is_finite() && width > 0.0 => {
GpuTextDrawMode::Stroke {
width: width * scale,
}
}
_ => GpuTextDrawMode::Fill,
};
let brush = text_style
.span_style
.brush
.clone()
.or_else(|| text_style.span_style.color.map(Brush::solid))
.unwrap_or_else(|| Brush::solid(fallback_color));
let alpha_multiplier = text_style.span_style.alpha.unwrap_or(1.0);
GpuTextMaterial {
brush,
alpha_multiplier,
draw_mode,
}
}
fn build_gpu_text_effect(
material: &GpuTextMaterial,
text_rect: Rect,
) -> Option<(RenderEffect, Rect)> {
if !text_rect.width.is_finite()
|| !text_rect.height.is_finite()
|| text_rect.width <= 0.0
|| text_rect.height <= 0.0
{
return None;
}
let stroke_padding = stroke_effect_padding_for_draw_mode(&material.draw_mode);
let effect_rect = expand_text_effect_rect(text_rect, stroke_padding);
let mut shader = RuntimeShader::new(GPU_TEXT_BRUSH_EFFECT_SHADER);
let logical_width = text_rect.width.max(f32::EPSILON);
let logical_height = text_rect.height.max(f32::EPSILON);
set_shader_vec4(&mut shader, 1, [logical_width, logical_height, 0.0, 0.0]);
let (draw_mode, stroke_width) = match material.draw_mode {
GpuTextDrawMode::Fill => (GPU_TEXT_DRAW_MODE_FILL, 0.0),
GpuTextDrawMode::Stroke { width } => (GPU_TEXT_DRAW_MODE_STROKE, width.max(0.0)),
};
set_shader_vec4(
&mut shader,
GPU_TEXT_BRUSH_EFFECT_MATERIAL_SLOT,
[draw_mode, stroke_width, stroke_padding, 0.0],
);
let alpha = if material.alpha_multiplier.is_finite() {
material.alpha_multiplier.clamp(0.0, 1.0)
} else {
1.0
};
let (brush_type, colors, stops, tile_mode) = match &material.brush {
Brush::LinearGradient {
colors,
stops,
start,
end,
tile_mode,
} => {
let resolved_start_x = resolve_gradient_component(logical_width, start.x);
let resolved_start_y = resolve_gradient_component(logical_height, start.y);
let resolved_end_x = resolve_gradient_component(logical_width, end.x);
let resolved_end_y = resolve_gradient_component(logical_height, end.y);
set_shader_vec4(
&mut shader,
2,
[
resolved_start_x,
resolved_start_y,
resolved_end_x,
resolved_end_y,
],
);
(
GPU_TEXT_BRUSH_KIND_LINEAR,
colors,
stops.as_deref(),
*tile_mode,
)
}
Brush::RadialGradient {
colors,
stops,
center,
radius,
tile_mode,
} => {
set_shader_vec4(&mut shader, 3, [center.x, center.y, *radius, 0.0]);
(
GPU_TEXT_BRUSH_KIND_RADIAL,
colors,
stops.as_deref(),
*tile_mode,
)
}
Brush::SweepGradient {
colors,
stops,
center,
} => {
set_shader_vec4(&mut shader, 4, [center.x, center.y, 0.0, 0.0]);
(
GPU_TEXT_BRUSH_KIND_SWEEP,
colors,
stops.as_deref(),
TileMode::Clamp,
)
}
Brush::Solid(color) => {
set_shader_vec4(
&mut shader,
GPU_TEXT_BRUSH_EFFECT_FIRST_STOP_SLOT,
[color.r(), color.g(), color.b(), color.a()],
);
shader.set_float((GPU_TEXT_BRUSH_EFFECT_FIRST_STOP_SLOT + 1) * 4, 0.0);
set_shader_vec4(
&mut shader,
0,
[
GPU_TEXT_BRUSH_KIND_SOLID,
1.0,
tile_mode_to_shader_uniform(TileMode::Clamp),
alpha,
],
);
return Some((RenderEffect::runtime_shader(shader), effect_rect));
}
};
let stop_count = colors.len();
if stop_count == 0 || stop_count > GPU_TEXT_BRUSH_EFFECT_MAX_STOPS {
return None;
}
set_shader_vec4(
&mut shader,
0,
[
brush_type,
stop_count as f32,
tile_mode_to_shader_uniform(tile_mode),
alpha,
],
);
let resolved_stops = normalized_gradient_stops(stop_count, stops);
for (index, color) in colors.iter().enumerate() {
let color_slot = GPU_TEXT_BRUSH_EFFECT_FIRST_STOP_SLOT + index * 2;
set_shader_vec4(
&mut shader,
color_slot,
[color.r(), color.g(), color.b(), color.a()],
);
shader.set_float((color_slot + 1) * 4, resolved_stops[index]);
}
Some((RenderEffect::runtime_shader(shader), effect_rect))
}
fn gpu_text_effect_for_style(
text_style: &TextStyle,
text_rect: Rect,
fallback_color: Color,
text_scale: f32,
) -> Option<(RenderEffect, Rect)> {
let uses_non_solid_brush = matches!(
text_style.span_style.brush,
Some(
Brush::LinearGradient { .. }
| Brush::RadialGradient { .. }
| Brush::SweepGradient { .. }
)
);
let uses_stroke = matches!(
text_style.span_style.draw_style,
Some(TextDrawStyle::Stroke { width }) if width.is_finite() && width > 0.0
);
if !uses_non_solid_brush && !uses_stroke {
return None;
}
let material = gpu_text_material_for_style(text_style, fallback_color, text_scale);
build_gpu_text_effect(&material, text_rect)
}
fn span_has_foreground_override(span_style: &cranpose_ui::text::SpanStyle) -> bool {
matches!(
span_style.brush.as_ref(),
Some(
cranpose_ui::Brush::LinearGradient { .. }
| cranpose_ui::Brush::RadialGradient { .. }
| cranpose_ui::Brush::SweepGradient { .. }
)
) || span_style.alpha.is_some()
|| span_style.draw_style.is_some()
}
fn text_has_span_foreground_overrides(text: &cranpose_ui::text::AnnotatedString) -> bool {
text.span_styles
.iter()
.any(|span| span_has_foreground_override(&span.item))
}
fn text_spans_override_foreground_color(text: &cranpose_ui::text::AnnotatedString) -> bool {
text.span_styles.iter().any(|span| {
span.item.color.is_some() || matches!(span.item.brush, Some(cranpose_ui::Brush::Solid(_)))
})
}
fn text_for_gpu_mask(
text: &cranpose_ui::text::AnnotatedString,
) -> cranpose_ui::text::AnnotatedString {
if text.span_styles.is_empty() {
return text.clone();
}
let mut mask_text = text.clone();
for span in &mut mask_text.span_styles {
span.item.color = None;
span.item.brush = None;
span.item.alpha = None;
span.item.draw_style = None;
span.item.shadow = None;
}
mask_text
}
fn text_with_layer_transformed_span_paint(
text: &cranpose_ui::text::AnnotatedString,
content_layer: &GraphicsLayer,
) -> cranpose_ui::text::AnnotatedString {
if text.span_styles.is_empty() {
return text.clone();
}
let mut transformed_text = text.clone();
for span in &mut transformed_text.span_styles {
span.item.color = span
.item
.color
.map(|color| apply_layer_to_color(color, content_layer));
span.item.brush = span
.item
.brush
.clone()
.map(|brush| apply_layer_to_brush(brush, content_layer));
}
transformed_text
}
fn merged_span_style_for_range(
text: &cranpose_ui::text::AnnotatedString,
base_span_style: &cranpose_ui::text::SpanStyle,
start: usize,
end: usize,
) -> cranpose_ui::text::SpanStyle {
let mut merged_style = base_span_style.clone();
for span in &text.span_styles {
if span.range.start <= start && span.range.end >= end {
merged_style = merged_style.merge(&span.item);
}
}
merged_style
}
fn gpu_text_material_batches_for_text(
text: &cranpose_ui::text::AnnotatedString,
text_style: &TextStyle,
fallback_color: Color,
text_scale: f32,
) -> Vec<GpuTextMaterialBatch> {
let mut batches: Vec<GpuTextMaterialBatch> = Vec::new();
for window in text.span_boundaries().windows(2) {
let start = window[0];
let end = window[1];
if start == end {
continue;
}
let Some(range_text) = text.text.get(start..end) else {
continue;
};
if !range_text.chars().any(|ch| ch != '\n' && ch != '\r') {
continue;
}
let mut range_style = text_style.clone();
range_style.span_style =
merged_span_style_for_range(text, &text_style.span_style, start, end);
let material = gpu_text_material_for_style(&range_style, fallback_color, text_scale);
if let Some(last_batch) = batches.last_mut()
&& last_batch.material == material
{
if let Some(last_range) = last_batch.visible_ranges.last_mut() {
if last_range.end == start {
last_range.end = end;
} else {
last_batch.visible_ranges.push(start..end);
}
} else {
last_batch.visible_ranges.push(start..end);
}
continue;
}
batches.push(GpuTextMaterialBatch {
material,
visible_ranges: std::iter::once(start..end).collect(),
});
}
batches
}
fn text_for_gpu_mask_batch(
text: &cranpose_ui::text::AnnotatedString,
visible_ranges: &[Range<usize>],
) -> cranpose_ui::text::AnnotatedString {
let mut mask_text = text_for_gpu_mask(text);
let visible_style = cranpose_ui::text::SpanStyle {
color: Some(Color::WHITE),
..Default::default()
};
for range in visible_ranges {
if range.start >= range.end {
continue;
}
mask_text.span_styles.push(cranpose_ui::text::RangeStyle {
item: visible_style.clone(),
range: range.clone(),
});
}
mask_text
}
trait TextStyleDrawSink {
fn current_z(&mut self) -> usize;
fn push_shape(
&mut self,
primitive: DrawPrimitive,
clip: Option<Rect>,
anchor: Option<SnapAnchor>,
);
#[expect(clippy::too_many_arguments)]
fn push_text(
&mut self,
node_id: NodeId,
rect: Rect,
text: Rc<cranpose_ui::text::AnnotatedString>,
color: Color,
text_style: TextStyle,
font_size: f32,
scale: f32,
layout_options: TextLayoutOptions,
clip: Option<Rect>,
);
#[expect(clippy::too_many_arguments)]
fn push_shadow_text(
&mut self,
node_id: NodeId,
rect: Rect,
text: Rc<cranpose_ui::text::AnnotatedString>,
color: Color,
text_style: TextStyle,
font_size: f32,
scale: f32,
layout_options: TextLayoutOptions,
blur_radius: f32,
clip: Option<Rect>,
);
#[expect(clippy::too_many_arguments)]
fn push_effect_layer(
&mut self,
rect: Rect,
clip: Option<Rect>,
effect: Option<RenderEffect>,
blend_mode: BlendMode,
composite_alpha: f32,
z_start: usize,
z_end: usize,
);
#[expect(clippy::too_many_arguments)]
fn push_effect_layer_with_surface(
&mut self,
rect: Rect,
clip: Option<Rect>,
effect: Option<RenderEffect>,
blend_mode: BlendMode,
composite_alpha: f32,
z_start: usize,
z_end: usize,
) {
self.push_effect_layer(
rect,
clip,
effect,
blend_mode,
composite_alpha,
z_start,
z_end,
);
}
}
impl TextStyleDrawSink for CompositorScene {
fn current_z(&mut self) -> usize {
self.next_z()
}
fn push_shape(
&mut self,
primitive: DrawPrimitive,
clip: Option<Rect>,
anchor: Option<SnapAnchor>,
) {
self.push_loose(primitive, Placement::at(Point::default(), anchor, clip));
}
fn push_text(
&mut self,
node_id: NodeId,
rect: Rect,
text: Rc<cranpose_ui::text::AnnotatedString>,
color: Color,
text_style: TextStyle,
font_size: f32,
scale: f32,
layout_options: TextLayoutOptions,
clip: Option<Rect>,
) {
CompositorScene::push_text(
self,
node_id,
rect,
text,
color,
text_style,
font_size,
scale,
layout_options,
clip,
);
}
fn push_shadow_text(
&mut self,
node_id: NodeId,
rect: Rect,
text: Rc<cranpose_ui::text::AnnotatedString>,
color: Color,
text_style: TextStyle,
font_size: f32,
scale: f32,
layout_options: TextLayoutOptions,
blur_radius: f32,
clip: Option<Rect>,
) {
self.push_shadow_draw(ShadowDraw {
shapes: None,
post_blur_cutouts: None,
texts: vec![TextDraw {
node_id,
rect,
snap_anchor: None,
text: crate::scene::render_string_for(&text),
color,
text_style,
font_size,
scale,
layout_options,
clip,
}],
blur_radius,
clip,
rounded_clip: None,
occluder: None,
z_index: 0,
});
}
fn push_effect_layer(
&mut self,
rect: Rect,
clip: Option<Rect>,
effect: Option<RenderEffect>,
blend_mode: BlendMode,
composite_alpha: f32,
z_start: usize,
z_end: usize,
) {
CompositorScene::push_effect_layer(
self,
rect,
clip,
effect,
blend_mode,
composite_alpha,
z_start,
z_end,
);
}
fn push_effect_layer_with_surface(
&mut self,
rect: Rect,
clip: Option<Rect>,
effect: Option<RenderEffect>,
blend_mode: BlendMode,
composite_alpha: f32,
z_start: usize,
z_end: usize,
) {
CompositorScene::push_effect_layer(
self,
rect,
clip,
effect,
blend_mode,
composite_alpha,
z_start,
z_end,
);
}
}
#[cfg(test)]
#[derive(Default)]
struct TextBoundsCollector {
bounds: Option<Rect>,
next_z: usize,
}
#[cfg(test)]
impl TextStyleDrawSink for TextBoundsCollector {
fn current_z(&mut self) -> usize {
self.next_z
}
fn push_shape(
&mut self,
primitive: DrawPrimitive,
_clip: Option<Rect>,
_anchor: Option<SnapAnchor>,
) {
let (DrawPrimitive::Rect { rect, .. } | DrawPrimitive::RoundRect { rect, .. }) = primitive
else {
unreachable!("text decorations are rects");
};
self.bounds = union_rect(self.bounds, rect);
self.next_z += 1;
}
fn push_text(
&mut self,
_node_id: NodeId,
rect: Rect,
_text: Rc<cranpose_ui::text::AnnotatedString>,
_color: Color,
_text_style: TextStyle,
_font_size: f32,
_scale: f32,
_layout_options: TextLayoutOptions,
_clip: Option<Rect>,
) {
self.bounds = union_rect(self.bounds, rect);
self.next_z += 1;
}
fn push_shadow_text(
&mut self,
_node_id: NodeId,
rect: Rect,
_text: Rc<cranpose_ui::text::AnnotatedString>,
_color: Color,
_text_style: TextStyle,
_font_size: f32,
scale: f32,
_layout_options: TextLayoutOptions,
blur_radius: f32,
clip: Option<Rect>,
) {
let shadow_bounds = expand_blurred_rect(rect, blur_radius, scale, clip);
if let Some(shadow_bounds) = shadow_bounds {
self.bounds = union_rect(self.bounds, shadow_bounds);
}
self.next_z += 1;
}
fn push_effect_layer(
&mut self,
rect: Rect,
_clip: Option<Rect>,
_effect: Option<RenderEffect>,
_blend_mode: BlendMode,
_composite_alpha: f32,
_z_start: usize,
_z_end: usize,
) {
self.bounds = union_rect(self.bounds, rect);
}
}
#[expect(clippy::too_many_arguments)]
fn push_span_gpu_text_material_draws<S: TextStyleDrawSink>(
sink: &mut S,
node_id: NodeId,
text_rect: Rect,
content_layer: &GraphicsLayer,
text: &cranpose_ui::text::AnnotatedString,
transformed_text_style: &TextStyle,
fallback_color: Color,
font_size: f32,
text_scale: f32,
options: TextLayoutOptions,
text_clip: Option<Rect>,
) -> bool {
let transformed_span_text = text_with_layer_transformed_span_paint(text, content_layer);
let material_batches = gpu_text_material_batches_for_text(
&transformed_span_text,
transformed_text_style,
fallback_color,
text_scale,
);
if material_batches.is_empty() {
return false;
}
let mut batch_effects = Vec::with_capacity(material_batches.len());
for batch in material_batches {
let Some((effect, effect_rect)) = build_gpu_text_effect(&batch.material, text_rect) else {
return false;
};
batch_effects.push((batch.visible_ranges, effect, effect_rect));
}
let mut mask_text_style = transformed_text_style.clone();
mask_text_style.span_style.brush = None;
mask_text_style.span_style.alpha = None;
mask_text_style.span_style.color = Some(Color(1.0, 1.0, 1.0, 0.0));
mask_text_style.span_style.draw_style = Some(TextDrawStyle::Fill);
for (visible_ranges, effect, effect_rect) in batch_effects {
let z_start = sink.current_z();
let mask_text = text_for_gpu_mask_batch(text, &visible_ranges);
sink.push_text(
node_id,
text_rect,
Rc::new(mask_text),
Color(1.0, 1.0, 1.0, 0.0),
mask_text_style.clone(),
font_size,
text_scale,
options,
text_clip,
);
let z_end = sink.current_z();
sink.push_effect_layer_with_surface(
effect_rect,
text_clip,
Some(effect),
BlendMode::SrcOver,
1.0,
z_start,
z_end,
);
}
true
}
#[expect(clippy::too_many_arguments)]
fn emit_text_style_draws<S: TextStyleDrawSink>(
sink: &mut S,
text_layout: &mut impl TextLayoutResolver,
node_id: NodeId,
rect: Rect,
text_rect: Rect,
content_layer: &GraphicsLayer,
text: &Rc<cranpose_ui::text::AnnotatedString>,
text_style: &TextStyle,
font_size: f32,
options: TextLayoutOptions,
text_clip: Option<Rect>,
snap_anchor: Option<SnapAnchor>,
) {
let text_scale = layer_uniform_scale(content_layer);
let baseline_shift_px = text_style
.span_style
.baseline_shift
.filter(|shift| shift.is_specified())
.map_or(0.0, |shift| -(shift.0 * font_size));
let shifted_text_rect = Rect {
x: text_rect.x,
y: text_rect.y + baseline_shift_px,
width: text_rect.width,
height: text_rect.height,
};
let transformed_shifted_text_rect = apply_layer_to_rect(shifted_text_rect, rect, content_layer);
if let Some(background) = text_style.span_style.background {
let brush = apply_layer_to_brush(Brush::solid(background), content_layer);
sink.push_shape(
DrawPrimitive::Rect {
rect: transformed_shifted_text_rect,
brush,
stroke: None,
},
text_clip,
snap_anchor,
);
}
let text_color =
resolve_text_color_without_gradient_fallback(text_style, Color(1.0, 1.0, 1.0, 1.0));
let transformed_text_color = apply_layer_to_color(text_color, content_layer);
let text_brush = text_style
.span_style
.brush
.clone()
.unwrap_or_else(|| Brush::solid(text_color));
let mut transformed_text_style = text_style.clone();
transformed_text_style.span_style.shadow = None;
transformed_text_style.span_style.brush = text_style
.span_style
.brush
.clone()
.map(|brush| apply_layer_to_brush(brush, content_layer));
if let Some(shadow) = text_style.span_style.shadow {
let shadow_rect = Rect {
x: shifted_text_rect.x + shadow.offset.x,
y: shifted_text_rect.y + shadow.offset.y,
width: shifted_text_rect.width,
height: shifted_text_rect.height,
};
let mut shadow_text_style = transformed_text_style.clone();
shadow_text_style.span_style.brush = None;
let blur_radius = shadow.blur_radius.max(0.0) * text_scale;
sink.push_shadow_text(
node_id,
apply_layer_to_rect(shadow_rect, rect, content_layer),
Rc::clone(text),
apply_layer_to_color(shadow.color, content_layer),
shadow_text_style,
font_size,
text_scale,
options,
blur_radius,
text_clip,
);
}
let has_span_foreground_overrides = text_has_span_foreground_overrides(text);
if has_span_foreground_overrides
&& push_span_gpu_text_material_draws(
sink,
node_id,
transformed_shifted_text_rect,
content_layer,
text,
&transformed_text_style,
transformed_text_color,
font_size,
text_scale,
options,
text_clip,
)
{
push_text_decorations(
sink,
text_layout,
rect,
shifted_text_rect,
content_layer,
text,
text_style,
&text_brush,
text_clip,
snap_anchor,
);
return;
}
if !has_span_foreground_overrides
&& let Some((effect, effect_rect)) = gpu_text_effect_for_style(
&transformed_text_style,
transformed_shifted_text_rect,
transformed_text_color,
text_scale,
)
{
if text_spans_override_foreground_color(text)
&& push_span_gpu_text_material_draws(
sink,
node_id,
transformed_shifted_text_rect,
content_layer,
text,
&transformed_text_style,
transformed_text_color,
font_size,
text_scale,
options,
text_clip,
)
{
return;
}
let z_start = sink.current_z();
let mut mask_text_style = transformed_text_style.clone();
mask_text_style.span_style.brush = None;
mask_text_style.span_style.alpha = None;
mask_text_style.span_style.color = Some(Color::WHITE);
mask_text_style.span_style.draw_style = Some(TextDrawStyle::Fill);
let mask_text = text_for_gpu_mask(text);
sink.push_text(
node_id,
transformed_shifted_text_rect,
Rc::new(mask_text),
Color::WHITE,
mask_text_style,
font_size,
text_scale,
options,
text_clip,
);
let z_end = sink.current_z();
sink.push_effect_layer_with_surface(
effect_rect,
text_clip,
Some(effect),
BlendMode::SrcOver,
1.0,
z_start,
z_end,
);
push_text_decorations(
sink,
text_layout,
rect,
shifted_text_rect,
content_layer,
text,
text_style,
&text_brush,
text_clip,
snap_anchor,
);
return;
}
push_text_draw(
sink,
node_id,
transformed_shifted_text_rect,
Rc::clone(text),
transformed_text_color,
transformed_text_style,
font_size,
text_scale,
options,
text_clip,
);
push_text_decorations(
sink,
text_layout,
rect,
shifted_text_rect,
content_layer,
text,
text_style,
&text_brush,
text_clip,
snap_anchor,
);
}
#[expect(clippy::too_many_arguments)]
fn push_text_draw<S: TextStyleDrawSink>(
sink: &mut S,
node_id: NodeId,
rect: Rect,
text: Rc<cranpose_ui::text::AnnotatedString>,
color: Color,
text_style: TextStyle,
font_size: f32,
scale: f32,
layout_options: TextLayoutOptions,
clip: Option<Rect>,
) {
sink.push_text(
node_id,
rect,
text,
color,
text_style,
font_size,
scale,
layout_options,
clip,
);
}
#[expect(clippy::too_many_arguments)]
pub(crate) fn push_text_style_draws(
scene: &mut CompositorScene,
text_layout: &mut impl TextLayoutResolver,
node_id: NodeId,
rect: Rect,
text_rect: Rect,
content_layer: &GraphicsLayer,
text: &Rc<cranpose_ui::text::AnnotatedString>,
text_style: &TextStyle,
font_size: f32,
options: TextLayoutOptions,
text_clip: Option<Rect>,
snap_anchor: Option<SnapAnchor>,
) {
emit_text_style_draws(
scene,
text_layout,
node_id,
rect,
text_rect,
content_layer,
text,
text_style,
font_size,
options,
text_clip,
snap_anchor,
);
}
#[cfg(test)]
#[expect(clippy::too_many_arguments)]
pub(crate) fn estimate_text_style_draw_bounds(
node_id: NodeId,
rect: Rect,
text_rect: Rect,
content_layer: &GraphicsLayer,
text: &cranpose_ui::text::AnnotatedString,
text_style: &TextStyle,
font_size: f32,
options: TextLayoutOptions,
text_clip: Option<Rect>,
) -> Option<Rect> {
let mut collector = TextBoundsCollector::default();
let mut text_layout = UiTextLayoutResolver;
let text = Rc::new(text.clone());
emit_text_style_draws(
&mut collector,
&mut text_layout,
node_id,
rect,
text_rect,
content_layer,
&text,
text_style,
font_size,
options,
text_clip,
None,
);
collector.bounds
}
#[expect(clippy::too_many_arguments)]
fn push_text_decorations<S: TextStyleDrawSink>(
sink: &mut S,
text_layout: &mut impl TextLayoutResolver,
rect: Rect,
text_rect: Rect,
content_layer: &GraphicsLayer,
annotated_text: &cranpose_ui::text::AnnotatedString,
global_style: &TextStyle,
text_brush: &Brush,
text_clip: Option<Rect>,
snap_anchor: Option<SnapAnchor>,
) {
if annotated_text.is_empty() || !text_has_visible_decoration(annotated_text, global_style) {
return;
}
let layout = text_layout.layout_text(annotated_text, global_style);
let mut segments =
decoration_segments_from_glyph_layouts(annotated_text, global_style, &layout);
if segments.is_empty() {
segments =
decoration_segments_from_logical_lines(text_layout, annotated_text, global_style);
}
for segment in segments {
let Some(decoration) = segment.span_style.text_decoration else {
continue;
};
if decoration == TextDecoration::NONE {
continue;
}
let span_width = segment.width();
if span_width <= 0.0 {
continue;
}
let line_height = segment.line_height.max(1.0);
let font_size = segment.span_style.resolve_font_size(14.0);
let thickness = (font_size * 0.06).clamp(1.0, line_height * 0.25);
let brush = decoration_brush_for_span(&segment.span_style, text_brush, content_layer);
let line_top = text_rect.y + segment.line_top;
let segment_x = text_rect.x + segment.x_start;
if decoration.contains(TextDecoration::UNDERLINE) {
let underline_rect = text_decoration_rect(
segment_x,
line_top + line_height - thickness * 1.35,
span_width,
thickness,
);
let transformed = apply_layer_to_rect(underline_rect, rect, content_layer);
sink.push_shape(
DrawPrimitive::Rect {
rect: transformed,
brush: brush.clone(),
stroke: None,
},
text_clip,
snap_anchor,
);
}
if decoration.contains(TextDecoration::LINE_THROUGH) {
let strike_rect = text_decoration_rect(
segment_x,
line_top + line_height * 0.52 - thickness * 0.5,
span_width,
thickness,
);
let transformed = apply_layer_to_rect(strike_rect, rect, content_layer);
sink.push_shape(
DrawPrimitive::Rect {
rect: transformed,
brush,
stroke: None,
},
text_clip,
snap_anchor,
);
}
}
}
fn text_decoration_rect(x: f32, y: f32, width: f32, thickness: f32) -> Rect {
Rect {
x,
y,
width,
height: thickness.ceil().max(1.0),
}
}
fn text_has_visible_decoration(
text: &cranpose_ui::text::AnnotatedString,
global_style: &TextStyle,
) -> bool {
if global_style
.span_style
.text_decoration
.is_some_and(|decoration| decoration != TextDecoration::NONE)
{
return true;
}
text.span_styles.iter().any(|span| {
span.item
.text_decoration
.is_some_and(|decoration| decoration != TextDecoration::NONE)
})
}
#[derive(Clone, Debug, PartialEq)]
struct DecorationVisualSegment {
line_index: usize,
line_top: f32,
line_height: f32,
logical_start: usize,
logical_end: usize,
x_start: f32,
x_end: f32,
span_style: cranpose_ui::text::SpanStyle,
}
impl DecorationVisualSegment {
fn width(&self) -> f32 {
(self.x_end - self.x_start).max(0.0)
}
}
fn decoration_segments_from_glyph_layouts(
text: &cranpose_ui::text::AnnotatedString,
global_style: &TextStyle,
layout: &cranpose_ui::text_layout_result::TextLayoutResult,
) -> Vec<DecorationVisualSegment> {
let mut glyph_layouts: Vec<_> = layout
.glyph_layouts()
.iter()
.copied()
.filter(|glyph| glyph.end_offset > glyph.start_offset && glyph.width.is_finite())
.collect();
if glyph_layouts.is_empty() {
return Vec::new();
}
glyph_layouts.sort_by(|a, b| {
a.line_index
.cmp(&b.line_index)
.then_with(|| a.x.total_cmp(&b.x))
.then_with(|| a.start_offset.cmp(&b.start_offset))
.then_with(|| a.end_offset.cmp(&b.end_offset))
});
let text_len = text.text.len();
let mut segments: Vec<DecorationVisualSegment> = Vec::new();
for glyph in glyph_layouts {
let start = glyph.start_offset.min(text_len);
let end = glyph.end_offset.min(text_len);
if start >= end {
continue;
}
let merged_style = merged_span_style_for_range(text, &global_style.span_style, start, end);
let Some(decoration) = merged_style.text_decoration else {
continue;
};
if decoration == TextDecoration::NONE {
continue;
}
let glyph_start_x = glyph.x;
let glyph_end_x = (glyph.x + glyph.width.max(0.0)).max(glyph_start_x);
if glyph_end_x <= glyph_start_x {
continue;
}
if let Some(last) = segments.last_mut() {
let same_line = last.line_index == glyph.line_index;
let same_style = last.span_style == merged_style;
let same_vertical_band =
(last.line_top - glyph.y).abs() <= DECORATION_SEGMENT_MERGE_EPSILON;
let touching = glyph_start_x <= last.x_end + DECORATION_SEGMENT_MERGE_EPSILON;
let contiguous_text_range = decoration_ranges_share_contiguous_run(
text,
last.logical_start,
last.logical_end,
start,
end,
);
if same_line && same_style && same_vertical_band && (touching || contiguous_text_range)
{
last.x_start = last.x_start.min(glyph_start_x);
last.x_end = last.x_end.max(glyph_end_x);
last.logical_start = last.logical_start.min(start);
last.logical_end = last.logical_end.max(end);
last.line_height = last.line_height.max(glyph.height.max(1.0));
continue;
}
}
segments.push(DecorationVisualSegment {
line_index: glyph.line_index,
line_top: glyph.y,
line_height: glyph.height.max(1.0),
logical_start: start,
logical_end: end,
x_start: glyph_start_x,
x_end: glyph_end_x,
span_style: merged_style,
});
}
segments
}
fn decoration_segments_from_logical_lines(
text_layout: &mut impl TextLayoutResolver,
text: &cranpose_ui::text::AnnotatedString,
global_style: &TextStyle,
) -> Vec<DecorationVisualSegment> {
let line_height = text_layout
.layout_text(text, global_style)
.line_height
.max(1.0);
let mut line_top = 0.0;
let mut line_index = 0usize;
let mut segments: Vec<DecorationVisualSegment> = Vec::new();
for line in split_annotated_lines_for_decorations(text) {
let mut current_offset = 0.0;
for window in line.span_boundaries().windows(2) {
let start = window[0];
let end = window[1];
if start == end {
continue;
}
let merged_style =
merged_span_style_for_range(&line, &global_style.span_style, start, end);
let mut span_text_style = global_style.clone();
span_text_style.span_style = merged_style.clone();
let span_width = text_layout
.layout_text(&line.subsequence(start..end), &span_text_style)
.width
.max(0.0);
let Some(decoration) = merged_style.text_decoration else {
current_offset += span_width;
continue;
};
if decoration == TextDecoration::NONE || span_width <= 0.0 {
current_offset += span_width;
continue;
}
let x_start = current_offset;
let x_end = current_offset + span_width;
if let Some(last) = segments.last_mut() {
let same_line = last.line_index == line_index;
let same_style = last.span_style == merged_style;
let touching = x_start <= last.x_end + DECORATION_SEGMENT_MERGE_EPSILON;
if same_line && same_style && touching {
last.x_end = last.x_end.max(x_end);
last.logical_start = last.logical_start.min(start);
last.logical_end = last.logical_end.max(end);
current_offset += span_width;
continue;
}
}
segments.push(DecorationVisualSegment {
line_index,
line_top,
line_height,
logical_start: start,
logical_end: end,
x_start,
x_end,
span_style: merged_style,
});
current_offset += span_width;
}
line_index = line_index.saturating_add(1);
line_top += line_height;
}
segments
}
fn half_open_ranges_touch_or_overlap(
first_start: usize,
first_end: usize,
second_start: usize,
second_end: usize,
) -> bool {
first_start <= second_end && second_start <= first_end
}
fn decoration_ranges_share_contiguous_run(
text: &cranpose_ui::text::AnnotatedString,
first_start: usize,
first_end: usize,
second_start: usize,
second_end: usize,
) -> bool {
if half_open_ranges_touch_or_overlap(first_start, first_end, second_start, second_end) {
return true;
}
let source = text.text.as_str();
let gap = if first_end <= second_start {
source.get(first_end..second_start)
} else if second_end <= first_start {
source.get(second_end..first_start)
} else {
None
};
gap.is_some_and(|gap| gap.chars().all(char::is_whitespace))
}
fn split_annotated_lines_for_decorations(
text: &cranpose_ui::text::AnnotatedString,
) -> Vec<cranpose_ui::text::AnnotatedString> {
if text.text.is_empty() {
return vec![cranpose_ui::text::AnnotatedString::from("")];
}
let mut lines = Vec::new();
let mut start = 0usize;
for (idx, ch) in text.text.char_indices() {
if ch == '\n' {
lines.push(text.subsequence(start..idx));
start = idx + ch.len_utf8();
}
}
lines.push(text.subsequence(start..text.text.len()));
lines
}
fn resolved_alpha_multiplier(alpha: Option<f32>) -> f32 {
match alpha {
Some(value) if value.is_finite() => value.clamp(0.0, 1.0),
_ => 1.0,
}
}
fn color_with_alpha_multiplier(color: Color, alpha_multiplier: f32) -> Color {
Color(
color.r(),
color.g(),
color.b(),
(color.a() * alpha_multiplier).clamp(0.0, 1.0),
)
}
fn brush_with_alpha_multiplier(brush: Brush, alpha_multiplier: f32) -> Brush {
match brush {
Brush::Solid(color) => Brush::solid(color_with_alpha_multiplier(color, alpha_multiplier)),
Brush::LinearGradient {
colors,
stops,
start,
end,
tile_mode,
} => Brush::LinearGradient {
colors: colors
.into_iter()
.map(|color| color_with_alpha_multiplier(color, alpha_multiplier))
.collect(),
stops,
start,
end,
tile_mode,
},
Brush::RadialGradient {
colors,
stops,
center,
radius,
tile_mode,
} => Brush::RadialGradient {
colors: colors
.into_iter()
.map(|color| color_with_alpha_multiplier(color, alpha_multiplier))
.collect(),
stops,
center,
radius,
tile_mode,
},
Brush::SweepGradient {
colors,
stops,
center,
} => Brush::SweepGradient {
colors: colors
.into_iter()
.map(|color| color_with_alpha_multiplier(color, alpha_multiplier))
.collect(),
stops,
center,
},
}
}
fn decoration_brush_for_span(
merged_style: &cranpose_ui::text::SpanStyle,
fallback_brush: &Brush,
content_layer: &GraphicsLayer,
) -> Brush {
let brush = merged_style
.brush
.clone()
.or_else(|| merged_style.color.map(Brush::solid))
.unwrap_or_else(|| fallback_brush.clone());
let alpha_multiplier = resolved_alpha_multiplier(merged_style.alpha);
apply_layer_to_brush(
brush_with_alpha_multiplier(brush, alpha_multiplier),
content_layer,
)
}
#[cfg(test)]
use cranpose_render_common::scene_builder::expand_text_bounds_for_baseline_shift;
#[cfg(test)]
fn resolve_text_horizontal_offset(
style: &TextStyle,
text: &str,
content_width: f32,
measured_width: f32,
) -> f32 {
let available_width = content_width.max(0.0);
let remaining = (available_width - measured_width.max(0.0)).max(0.0);
let paragraph_style = &style.paragraph_style;
let direction = resolve_text_direction(text, Some(paragraph_style.text_direction));
match paragraph_style.text_align {
TextAlign::Left => 0.0,
TextAlign::Right => remaining,
TextAlign::Center => remaining * 0.5,
TextAlign::Justify => 0.0,
TextAlign::Start => match direction {
ResolvedTextDirection::Ltr => 0.0,
ResolvedTextDirection::Rtl => remaining,
},
TextAlign::End => match direction {
ResolvedTextDirection::Ltr => remaining,
ResolvedTextDirection::Rtl => 0.0,
},
TextAlign::Unspecified => match direction {
ResolvedTextDirection::Ltr => 0.0,
ResolvedTextDirection::Rtl => remaining,
},
}
}
pub(crate) fn render_from_applier(
applier: &mut MemoryApplier,
root: NodeId,
scene: &mut Scene,
scale: f32,
) {
let Some(mut graph) =
cranpose_render_common::scene_builder::build_graph_from_applier(applier, root, scale)
else {
return;
};
graph.root.recompute_raster_cache_hashes();
collect_hits_from_graph(
&graph.root,
cranpose_render_common::graph::ProjectiveTransform::identity(),
scene,
None,
);
scene.replace_graph(graph);
}
pub(crate) enum SceneUpdateOutcome {
Patched,
Rebuilt,
}
pub(crate) fn update_from_applier(
applier: &mut MemoryApplier,
root: NodeId,
scene: &mut Scene,
scale: f32,
dirty_nodes: &[NodeId],
refresh_hits: bool,
changed_nodes: &mut Vec<NodeId>,
) -> SceneUpdateOutcome {
changed_nodes.clear();
let Some(update_report) = scene.graph.as_mut().map(|graph| {
cranpose_render_common::scene_builder::update_graph_from_applier_report_into(
applier,
graph,
dirty_nodes,
scale,
changed_nodes,
)
}) else {
scene.clear();
render_from_applier(applier, root, scene, scale);
return SceneUpdateOutcome::Rebuilt;
};
if !update_report.applied() {
scene.clear();
render_from_applier(applier, root, scene, scale);
return SceneUpdateOutcome::Rebuilt;
}
if !refresh_hits && !update_report.hit_graph_dirty {
return SceneUpdateOutcome::Patched;
}
scene.clear_hits();
let Some(graph) = scene.graph.take() else {
render_from_applier(applier, root, scene, scale);
return SceneUpdateOutcome::Rebuilt;
};
collect_hits_from_graph(
&graph.root,
cranpose_render_common::graph::ProjectiveTransform::identity(),
scene,
None,
);
scene.replace_graph(graph);
SceneUpdateOutcome::Patched
}
const DRAW_PRIMITIVE_TEXT_NODE_ID: cranpose_core::NodeId = 0;
fn loose_shape(primitive: &DrawPrimitive, layer: &GraphicsLayer) -> Option<DrawPrimitive> {
let painted = |brush: &Brush| resolve_layer_brush(brush, layer).into_brush();
Some(match primitive {
DrawPrimitive::Rect {
rect,
brush,
stroke,
} => DrawPrimitive::Rect {
rect: *rect,
brush: painted(brush),
stroke: *stroke,
},
DrawPrimitive::RoundRect {
rect,
brush,
radii,
stroke,
} => DrawPrimitive::RoundRect {
rect: *rect,
brush: painted(brush),
radii: *radii,
stroke: *stroke,
},
DrawPrimitive::Arc {
rect,
brush,
center,
radius,
start_angle,
sweep_angle,
stroke,
inner_radius,
} => DrawPrimitive::Arc {
rect: *rect,
brush: painted(brush),
center: *center,
radius: *radius,
start_angle: *start_angle,
sweep_angle: *sweep_angle,
stroke: *stroke,
inner_radius: *inner_radius,
},
DrawPrimitive::Blend {
primitive,
blend_mode,
} => DrawPrimitive::Blend {
primitive: Box::new(loose_shape(primitive, layer)?),
blend_mode: *blend_mode,
},
DrawPrimitive::Content
| DrawPrimitive::Image { .. }
| DrawPrimitive::Text(_)
| DrawPrimitive::Shadow(_) => return None,
})
}
fn blended(primitive: DrawPrimitive, blend_mode: Option<BlendMode>) -> DrawPrimitive {
match blend_mode {
Some(blend_mode) if blend_mode != BlendMode::SrcOver => DrawPrimitive::Blend {
primitive: Box::new(primitive),
blend_mode,
},
_ => primitive,
}
}
#[expect(clippy::too_many_arguments)]
pub(crate) fn push_draw_primitive(
primitive: &DrawPrimitive,
layer_bounds: Rect,
layer: &GraphicsLayer,
clip: Option<Rect>,
snap_anchor: Option<SnapAnchor>,
scene: &mut CompositorScene,
blend_mode: Option<BlendMode>,
motion_context_animated: bool,
) {
if let Some(shape) = loose_shape(primitive, layer) {
let placement = Placement::at(
Point::new(layer_bounds.x, layer_bounds.y),
snap_anchor,
clip,
);
scene.push_loose(blended(shape, blend_mode), placement);
return;
}
struct SceneEmitter<'a> {
scene: &'a mut CompositorScene,
snap_anchor: Option<SnapAnchor>,
}
impl DrawPrimitiveSink for SceneEmitter<'_> {
fn push_shape(&mut self, _params: ShapeDrawParams) {
unreachable!("shape primitives are recorded before emission");
}
fn push_image(&mut self, params: ImageDrawParams) {
self.scene.push_image_with_geometry(
params.rect,
params.local_rect,
params.quad,
params.image,
params.alpha,
params.color_filter,
params.sampling,
params.clip,
params.src_rect,
params.blend_mode,
params.motion_context_animated,
);
}
fn push_shadow(
&mut self,
shadow_primitive: &cranpose_ui_graphics::ShadowPrimitive,
layer_bounds: Rect,
layer: &GraphicsLayer,
clip: Option<Rect>,
) {
push_shadow_primitive(
shadow_primitive,
layer_bounds,
layer,
clip,
self.snap_anchor,
self.scene,
);
}
fn push_text(&mut self, params: TextDrawParams) {
self.scene.push_text(
DRAW_PRIMITIVE_TEXT_NODE_ID,
params.rect,
params.text,
params.color,
params.text_style,
params.font_size,
params.scale,
params.layout_options,
params.clip,
);
}
}
let mut emitter = SceneEmitter { scene, snap_anchor };
emit_draw_primitive(
primitive,
layer_bounds,
layer,
clip,
&mut emitter,
blend_mode,
motion_context_animated,
);
}
fn record_shadow_caster(
recorder: &mut Arc<ShapeRecorder>,
primitive: &DrawPrimitive,
layer: &GraphicsLayer,
blend_mode: BlendMode,
) -> bool {
let Some(shape) = loose_shape(primitive, layer) else {
return false;
};
let recorder = Arc::make_mut(recorder);
let recorded = if blend_mode == BlendMode::SrcOver {
recorder.push_primitive(shape)
} else {
recorder.push_shape_primitive(shape, blend_mode)
};
matches!(recorded, Recorded::Shape(_))
}
fn push_shadow_primitive(
shadow_prim: &cranpose_ui_graphics::ShadowPrimitive,
layer_bounds: Rect,
layer: &GraphicsLayer,
clip: Option<Rect>,
snap_anchor: Option<SnapAnchor>,
scene: &mut CompositorScene,
) {
let placement = Placement::at(
Point::new(layer_bounds.x, layer_bounds.y),
snap_anchor,
None,
);
match shadow_prim {
cranpose_ui_graphics::ShadowPrimitive::Drop {
shape,
cutout,
blur_radius,
blend_mode,
} => {
let mut shapes = scene.take_shadow_recorder();
if !record_shadow_caster(&mut shapes, shape, layer, *blend_mode) {
return;
}
let cutouts = if let Some(cutout) = cutout {
let mut recorder = scene.take_shadow_recorder();
if !record_shadow_caster(&mut recorder, cutout, layer, BlendMode::DstOut) {
return;
}
RunDraw::whole(recorder, placement)
} else {
None
};
scene.push_shadow_draw(ShadowDraw {
shapes: RunDraw::whole(shapes, placement),
post_blur_cutouts: cutouts,
texts: vec![],
blur_radius: *blur_radius,
clip,
rounded_clip: None,
occluder: None,
z_index: 0,
});
}
cranpose_ui_graphics::ShadowPrimitive::Inner {
fill,
cutout,
blur_radius,
blend_mode,
clip_rect,
} => {
let mut shapes = scene.take_shadow_recorder();
if !record_shadow_caster(&mut shapes, fill, layer, *blend_mode)
|| !record_shadow_caster(&mut shapes, cutout, layer, BlendMode::DstOut)
{
return;
}
let abs_clip = Rect {
x: clip_rect.x + layer_bounds.x,
y: clip_rect.y + layer_bounds.y,
width: clip_rect.width,
height: clip_rect.height,
};
let transformed_clip = apply_layer_to_rect(abs_clip, layer_bounds, layer);
scene.push_shadow_draw(ShadowDraw {
shapes: RunDraw::whole(shapes, placement),
post_blur_cutouts: None,
texts: vec![],
blur_radius: *blur_radius,
clip: clip.map_or(Some(transformed_clip), |parent_clip| {
parent_clip.intersect(transformed_clip)
}),
rounded_clip: None,
occluder: None,
z_index: 0,
});
}
}
}
#[cfg(test)]
#[path = "tests/pipeline_tests.rs"]
mod tests;