repose_core/view.rs
1use crate::units::{Dp, Px, Sp};
2use crate::{
3 BaselineShift, Brush, ClipOp, Color, DrawStyle, FontStyle, FontSynthesis, FontWeight, Modifier,
4 Rect, TextAlign, TextDecoration, TextDirection, TextSpan, Transform, Vec2,
5};
6use std::{fmt::Formatter, sync::Arc};
7
8/// The constraints that will be passed to a subcomposed child. Values are in
9/// [`Dp`], matching the units used by `Modifier`.
10#[derive(Clone, Copy, Debug, PartialEq)]
11pub struct SubcomposeScope {
12 pub min_width: Dp,
13 pub max_width: Dp,
14 pub min_height: Dp,
15 pub max_height: Dp,
16}
17
18impl SubcomposeScope {
19 /// A scope with no finite upper bounds. Values are density-independent
20 /// pixels (`Dp`).
21 pub const UNBOUNDED: Self = Self {
22 min_width: Dp(0.0),
23 max_width: Dp(f32::INFINITY),
24 min_height: Dp(0.0),
25 max_height: Dp(f32::INFINITY),
26 };
27
28 /// Construct a scope from raw min/max [`Dp`] values. Non-finite minimums
29 /// and negative values are coerced to zero, a NaN maximum becomes
30 /// unbounded, and inverted bounds collapse to the maximum.
31 pub fn new(min_width: Dp, max_width: Dp, min_height: Dp, max_height: Dp) -> Self {
32 fn minimum(value: f32) -> f32 {
33 if value.is_nan() || value < 0.0 || !value.is_finite() {
34 0.0
35 } else {
36 value
37 }
38 }
39 fn maximum(value: f32) -> f32 {
40 if value.is_nan() || value < 0.0 {
41 if value.is_nan() { f32::INFINITY } else { 0.0 }
42 } else {
43 value
44 }
45 }
46 let mut scope = Self {
47 min_width: Dp(minimum(min_width.0)),
48 max_width: Dp(maximum(max_width.0)),
49 min_height: Dp(minimum(min_height.0)),
50 max_height: Dp(maximum(max_height.0)),
51 };
52 if scope.min_width.0 > scope.max_width.0 {
53 scope.min_width = scope.max_width;
54 }
55 if scope.min_height.0 > scope.max_height.0 {
56 scope.min_height = scope.max_height;
57 }
58 scope
59 }
60
61 pub fn has_bounded_width(&self) -> bool {
62 self.max_width.0.is_finite()
63 }
64
65 pub fn has_bounded_height(&self) -> bool {
66 self.max_height.0.is_finite()
67 }
68}
69
70/// Scope passed to [`BoxWithConstraints`](crate::prelude::BoxWithConstraints)
71/// content. All values are in [`Dp`].
72#[derive(Clone, Copy, Debug, PartialEq)]
73pub struct BoxWithConstraintsScope {
74 pub min_width: Dp,
75 pub max_width: Dp,
76 pub min_height: Dp,
77 pub max_height: Dp,
78}
79
80impl BoxWithConstraintsScope {
81 /// `true` if the width is bounded by the parent (i.e. not infinite).
82 pub fn has_bounded_width(&self) -> bool {
83 self.max_width.0.is_finite()
84 }
85
86 /// `true` if the height is bounded by the parent (i.e. not infinite).
87 pub fn has_bounded_height(&self) -> bool {
88 self.max_height.0.is_finite()
89 }
90}
91
92pub type ViewId = u64;
93
94pub type ImageHandle = u64;
95#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
96pub enum ImageFilter {
97 #[default]
98 Linear,
99 Nearest,
100}
101
102#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
103pub struct ImageSourceRect {
104 pub x: u32,
105 pub y: u32,
106 pub width: u32,
107 pub height: u32,
108}
109
110impl ImageSourceRect {
111 pub const fn new(x: u32, y: u32, width: u32, height: u32) -> Self {
112 Self {
113 x,
114 y,
115 width,
116 height,
117 }
118 }
119
120 pub const fn is_empty(self) -> bool {
121 self.width == 0 || self.height == 0
122 }
123}
124
125#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
126#[non_exhaustive]
127pub enum ImageFit {
128 /// ContentScale.Fit - default in Compose Image
129 #[default]
130 Contain,
131 /// ContentScale.Crop
132 Cover,
133 /// ContentScale.FillWidth
134 FitWidth,
135 /// ContentScale.FillHeight
136 FitHeight,
137 /// ContentScale.FillBounds - stretch, ignore aspect
138 FillBounds,
139 /// ContentScale.Inside - like Contain but never upscales
140 Inside,
141 /// ContentScale.None - no scaling; placement comes from [`ImageAlignment`]
142 None,
143 /// Repeat the source to fill the bounds (Godot `STRETCH_SCALE` + tile).
144 /// Unlike every other mode this ignores aspect ratio and may draw past a
145 /// `source_rect` edge, wrapping instead of clipping.
146 Tile,
147}
148
149/// Placement of scaled content inside the view bounds. Mirrors Godot's
150/// `AspectRatioContainer::AlignmentMode` and covers every fixed `Alignment` in
151/// Compose (`Begin`/`End` are physical left/right and top/bottom, not
152/// direction-aware).
153#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
154pub enum ImageAlignment {
155 /// Alignment.Center
156 #[default]
157 Center,
158 /// Alignment.TopLeft / BottomStart - left and top
159 Begin,
160 /// Alignment.TopRight / BottomEnd - right and bottom
161 End,
162}
163
164impl ImageFit {
165 /// Per-axis scale factors for drawing a `src`-sized source into `dst`.
166 /// Mirrors Compose's `ContentScale.computeScaleFactor`, which layout and
167 /// paint both need: layout sizes to it, paint draws with it.
168 pub fn scale_factor(self, src: (f32, f32), dst: (f32, f32)) -> (f32, f32) {
169 let fill_w = dst.0 / src.0;
170 let fill_h = dst.1 / src.1;
171 let uniform = |v: f32| (v, v);
172 match self {
173 Self::Contain => uniform(fill_w.min(fill_h)),
174 Self::Cover => uniform(fill_w.max(fill_h)),
175 Self::FitWidth => uniform(fill_w),
176 Self::FitHeight => uniform(fill_h),
177 Self::FillBounds => (fill_w, fill_h),
178 Self::Inside => {
179 if src.0 <= dst.0 && src.1 <= dst.1 {
180 uniform(1.0)
181 } else {
182 uniform(fill_w.min(fill_h))
183 }
184 }
185 Self::None | Self::Tile => uniform(1.0),
186 }
187 }
188}
189
190#[derive(Clone)]
191pub struct OverlayEntry {
192 pub id: u64,
193 pub view: Box<View>,
194}
195
196#[derive(Clone)]
197#[non_exhaustive]
198pub enum ViewKind {
199 Box,
200 Row,
201 Column,
202 ZStack,
203 OverlayHost,
204 Text {
205 text: String,
206 color: Color,
207 font_size: Sp,
208 soft_wrap: bool,
209 max_lines: Option<usize>,
210 overflow: TextOverflow,
211 font_family: Option<&'static str>,
212 annotations: Option<Arc<[TextSpan]>>,
213 text_align: TextAlign,
214 font_weight: FontWeight,
215 font_style: FontStyle,
216 text_decoration: TextDecoration,
217 letter_spacing: Sp,
218 line_height: Sp,
219 /// URL for clickable link text.
220 url: Option<Arc<str>>,
221 /// OpenType font variation settings (e.g. "wght 700, opsz 24").
222 font_variation_settings: Option<Arc<str>>,
223 /// Fill, outline, or both (faux-bold). Default `Fill`.
224 draw_style: DrawStyle,
225 },
226
227 Image {
228 handle: ImageHandle,
229 tint: Color,
230 fit: ImageFit,
231 filter: ImageFilter,
232 source_rect: Option<ImageSourceRect>,
233 alignment: ImageAlignment,
234 },
235 /// A layout whose children are produced by calling `content` with the
236 /// current `SubcomposeScope`. The closure is invoked during reconciliation
237 /// and returns a list of `(slot_id, view)` pairs. Each slot id is a stable
238 /// identity used to reconcile the returned view across frames. This is
239 /// the building block for `BoxWithConstraints` and other
240 /// constraints-driven layouts.
241 ///
242 /// Note: any `Modifier::key` set on a returned view is overwritten by its
243 /// slot id so the slot's identity is stable across frames.
244 SubcomposeLayout {
245 content: Arc<dyn Fn(&SubcomposeScope) -> Vec<(u64, View)>>,
246 },
247}
248
249impl std::fmt::Debug for ViewKind {
250 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
251 match self {
252 Self::Box => f.write_str("Box"),
253 Self::Row => f.write_str("Row"),
254 Self::Column => f.write_str("Column"),
255 Self::ZStack => f.write_str("ZStack"),
256 Self::OverlayHost => f.write_str("OverlayHost"),
257
258 Self::Image {
259 handle,
260 tint,
261 fit,
262 filter,
263 source_rect,
264 alignment,
265 } => f
266 .debug_struct("Image")
267 .field("handle", handle)
268 .field("tint", tint)
269 .field("fit", fit)
270 .field("filter", filter)
271 .field("source_rect", source_rect)
272 .field("alignment", alignment)
273 .finish(),
274 Self::SubcomposeLayout { .. } => f.write_str("SubcomposeLayout"),
275 Self::Text { text, .. } => write!(f, "Text({:?})", text),
276 }
277 }
278}
279
280#[derive(Clone, Debug)]
281pub struct View {
282 pub id: ViewId,
283 pub kind: ViewKind,
284 pub modifier: Modifier,
285 pub children: Vec<View>,
286 pub semantics: Option<crate::semantics::Semantics>,
287 /// Set by `scope!` macro to mark this as a scope boundary node.
288 /// Carries the scope key (e.g., "title", "color_buttons") for per-scope
289 /// TaffyTree isolation.
290 pub scope_key: Option<String>,
291}
292
293impl View {
294 pub fn new(id: ViewId, kind: ViewKind) -> Self {
295 View {
296 id,
297 kind,
298 modifier: Modifier::default(),
299 children: vec![],
300 semantics: None,
301 scope_key: None,
302 }
303 }
304 pub fn modifier(mut self, m: Modifier) -> Self {
305 self.modifier = m;
306 self
307 }
308 /// Mark this view as disabled - ignores pointer events.
309 pub fn disabled(mut self) -> Self {
310 self.modifier.disabled = true;
311 self
312 }
313 pub fn with_children(mut self, kids: Vec<View>) -> Self {
314 self.children = kids;
315 self
316 }
317 pub fn children(mut self, kids: impl Into<Vec<View>>) -> Self {
318 self.children = kids.into();
319 self
320 }
321 pub fn semantics(mut self, s: crate::semantics::Semantics) -> Self {
322 self.semantics = Some(s);
323 self
324 }
325}
326
327/// Renderable scene
328#[derive(Clone, Debug, Default)]
329pub struct Scene {
330 pub clear_color: Color,
331 pub nodes: Vec<SceneNode>,
332}
333
334/// Tunable text properties bundled for ergonomic [`Default`], so a
335/// [`SceneNode::Text`] stays `{ rect, text, color, size, style }` and simple
336/// nodes don't spell out every knob. `color` and `size` stay on the variant
337/// because a default for either would silently draw invisible text.
338#[derive(Clone, Debug, PartialEq, Default)]
339pub struct TextPaintStyle {
340 pub font_family: Option<&'static str>,
341 pub text_align: TextAlign,
342 pub font_weight: FontWeight,
343 pub font_style: FontStyle,
344 pub text_decoration: TextDecoration,
345 pub letter_spacing: Px,
346 pub line_height: Px,
347 pub text_direction: TextDirection,
348 /// Whether the font renderer may synthesize bold/italic/small-caps.
349 pub font_synthesis: FontSynthesis,
350 pub baseline_shift: BaselineShift,
351 /// Fill, outline, or both.
352 pub draw_style: DrawStyle,
353 /// URL for clickable link text.
354 pub url: Option<Arc<str>>,
355 /// OpenType font variation settings (e.g. "wght 700, opsz 24").
356 pub font_variation_settings: Option<Arc<str>>,
357}
358
359/// How an image is sampled and placed inside its [`Rect`], bundled for
360/// ergonomic [`Default`]. `tint` stays on [`SceneNode::Image`] because
361/// untinted is an explicit choice, not a neutral one.
362#[derive(Clone, Copy, Debug, PartialEq, Default)]
363pub struct ImagePaintStyle {
364 pub fit: ImageFit,
365 pub filter: ImageFilter,
366 /// Sub-region of the source texture to sample.
367 pub source_rect: Option<ImageSourceRect>,
368 pub alignment: ImageAlignment,
369}
370
371#[derive(Clone, Copy, Debug)]
372pub struct PaintCallbackInfo {
373 /// Viewport in physical pixels where the callback should paint.
374 pub viewport: Rect,
375 /// Clip rect in physical pixels (intersection of callback rect and current clip).
376 pub clip_rect: Rect,
377 /// Pixels per point (DPI scale).
378 pub pixels_per_point: f32,
379 /// Screen size in physical pixels.
380 pub screen_size_px: [u32; 2],
381}
382
383pub type PaintCallbackPayload = Arc<dyn std::any::Any + Send + Sync>;
384
385/// Paint-space scene graph. `Rect`/`Vec2` compounds carry physical pixels
386/// (like Compose `Offset`/`Size`/`Rect`); scalar lengths use [`Px`] so the
387/// dp→px boundary is explicit instead of unitless `f32`.
388#[derive(Clone, Debug)]
389#[non_exhaustive]
390pub enum SceneNode {
391 Rect {
392 rect: Rect,
393 brush: Brush,
394 radius: [Px; 4],
395 },
396 Border {
397 rect: Rect,
398 brush: Brush,
399 width: Px,
400 radius: [Px; 4],
401 },
402 Text {
403 rect: Rect,
404 text: Arc<str>,
405 color: Color,
406 size: Px,
407 style: TextPaintStyle,
408 },
409 Ellipse {
410 rect: Rect,
411 brush: Brush,
412 },
413 EllipseBorder {
414 rect: Rect,
415 brush: Brush,
416 width: Px,
417 },
418 PushClip {
419 rect: Rect,
420 radius: [Px; 4],
421 op: ClipOp,
422 },
423 PopClip,
424 PushTransform {
425 transform: Transform,
426 },
427 PopTransform,
428 Image {
429 rect: Rect,
430 handle: ImageHandle,
431 tint: Color,
432 style: ImagePaintStyle,
433 },
434 /// [`Image`](Self::Image) composited with an explicit blend mode. Kept
435 /// additive so the existing `Image` constructors keep meaning Normal
436 /// source-over. Separable modes (`Add`, `Multiply`, `Screen`, `Darken`,
437 /// `Lighten`) use fixed-function blending; the rest isolate the image
438 /// into a graphics layer and composite it with the backdrop-blend shader,
439 /// exactly like [`VectorMesh`](Self::VectorMesh).
440 ImageBlended {
441 rect: Rect,
442 handle: ImageHandle,
443 tint: Color,
444 style: ImagePaintStyle,
445 blend: BlendMode,
446 },
447 /// Tinted A8 coverage mask: samples `handle` (registered with
448 /// `register_coverage_a8`) as coverage and composites `color` with
449 /// source-over. Lets hosts rasterize geometry once (e.g. cached glyph
450 /// coverage tiles) and re-composite it per frame with a new color or
451 /// position without re-uploading. `rect` positions the tile's top-left;
452 /// its size is the registered tile size.
453 Coverage {
454 rect: Rect,
455 handle: ImageHandle,
456 color: Color,
457 },
458 /// Shadow behind a rounded rect, typically driven by `StateElevation`.
459 /// The `elevation` field controls offset and alpha.
460 Shadow {
461 rect: Rect,
462 radius: [Px; 4],
463 elevation: Px,
464 color: Color,
465 },
466 /// Mark the start of a graphics layer: the contained subtree is rendered
467 /// into an offscreen texture and then composited back into the parent.
468 /// `alpha` is the group-compositing alpha applied at composite time.
469 /// `blur_radius_x` / `blur_radius_y` are the gaussian blur radii in [`Px`]
470 /// applied to the layer before compositing (zero = no blur on that axis).
471 /// `rectangle_edge` true = clamp edge pixels (Rectangle). False = transparent out-of-bounds (Unbounded).
472 BeginLayer {
473 rect: Rect,
474 layer_id: u32,
475 alpha: f32,
476 blur_radius_x: Px,
477 blur_radius_y: Px,
478 rectangle_edge: bool,
479 },
480 /// Closes the graphics layer opened by the matching `BeginLayer`.
481 EndLayer {
482 layer_id: u32,
483 },
484 /// Draws a blurred drop shadow underneath a previously-rendered layer.
485 /// Emitted between `EndLayer` and the layer's `CompositeLayer`. The
486 /// quad samples the layer's texture with a 3x3 Gaussian blur and an
487 /// optional vertical offset.
488 CompositeShadow {
489 layer_id: u32,
490 blur_px: Px,
491 offset_px: (Px, Px),
492 color: Color,
493 /// Corner radii shaping the shadow silhouette, in px. All zero = the
494 /// silhouette follows the layer alpha unchanged.
495 shape_px: [Px; 4],
496 },
497 /// Arc stroke
498 Arc {
499 rect: Rect,
500 start_angle: f32,
501 sweep_angle: f32,
502 stroke_width: Px,
503 brush: Brush,
504 cap: StrokeCap,
505 },
506 /// Pre-tessellated vector mesh (fill or stroke geometry produced by the
507 /// host, e.g. lyon tessellation). Vertices live in the mesh's own local
508 /// space; `transform` is a 2x3 affine that maps local -> world pixels as
509 /// `[m00, m01, m10, m11, tx, ty]` (`out = M * local + t`; identity is
510 /// `[1.0, 0.0, 0.0, 1.0, 0.0, 0.0]`) and is applied in the vertex shader.
511 /// The current scene `PushTransform` stack is folded in on top of
512 /// `transform`.
513 VectorMesh {
514 mesh: Arc<VectorMeshData>,
515 transform: [f32; 6],
516 paint: PaintDesc,
517 /// Reserved for explicit clip assignment. Clipping is otherwise
518 /// structural via `PushVectorClip`/`PopVectorClip`.
519 clip: Option<u32>,
520 blend: BlendMode,
521 },
522 /// One mesh, one paint, many placements, drawn as a single indexed draw
523 /// with one instance per transform. Placement order is the transform
524 /// order, so an alpha-blended run composites front-to-back last-wins
525 /// exactly as N separate `VectorMesh` nodes would. Use it when the same
526 /// geometry repeats - sprite frames, repeated icons, tiled fills - since
527 /// `VectorMesh` costs one draw and one paint uniform per placement.
528 VectorMeshInstanced {
529 mesh: Arc<VectorMeshData>,
530 transforms: Vec<[f32; 6]>,
531 paint: PaintDesc,
532 blend: BlendMode,
533 },
534 /// Screen-space overlays (handles, rubber bands, playhead). Each mesh
535 /// is positioned in final device pixels and ignores the world
536 /// PushTransform stack. Emit outside the viewport's world transform.
537 VectorOverlay {
538 meshes: Arc<[VectorMeshData]>,
539 },
540 /// Start a vector clip: the mesh is rendered into the stencil buffer
541 /// (increment) and subsequent content is masked to it. Mirrors the
542 /// rect-based `PushClip` but for arbitrary tessellated masks.
543 /// `op` selects intersection (keep content inside the mask, the common
544 /// case) or difference (cut the mask out, e.g. ASS `\iclip` drawings).
545 /// Difference is exact for a lone mask and for a mask nested inside
546 /// intersect clips; a normal clip nested inside a difference mask is
547 /// best-effort (see the renderer docs).
548 PushVectorClip {
549 mesh: Arc<VectorMeshData>,
550 op: ClipOp,
551 },
552 /// End a vector clip opened by `PushVectorClip`.
553 PopVectorClip,
554 /// Custom GPU paint callback (check `egui::PaintCallback`).
555 Callback {
556 rect: Rect,
557 payload: PaintCallbackPayload,
558 },
559}
560
561/// Graphics-layer ids live in non-overlapping bands because a single `Scene`
562/// routinely mixes layers from several producers. `1..` counts up from the
563/// producer side (`repose-ui`), the wgpu translator owns `0xE000_0000..` for
564/// separable-blur scratch textures and `0xF000_0000..` for perspective
565/// flattening, and canvas hosts own
566/// `CANVAS_LAYER_ID_BASE..0xE000_0000`.
567pub const CANVAS_LAYER_ID_BASE: u32 = 0x8000_0000;
568
569/// Shared vertex/index buffers for a tessellated vector mesh.
570#[derive(Clone, Debug, Default)]
571pub struct VectorMeshData {
572 pub vertices: Arc<[VectorVertex]>,
573 pub indices: Arc<[u32]>,
574}
575
576/// Pre-tessellated vertex: local position, premultiplied-linear color, and a
577/// free-form uv channel (unused for solid fills, reserved for texture/gradient
578/// sampling).
579#[derive(Clone, Copy, Debug, PartialEq)]
580#[repr(C)]
581pub struct VectorVertex {
582 pub pos: [f32; 2],
583 pub color: [f32; 4],
584 pub uv: [f32; 2],
585}
586
587/// Ceiling on the stops a multi-stop paint may carry. The renderer packs the
588/// table into the per-mesh uniform, so this is a hard structural limit rather
589/// than a soft recommendation; hosts should split longer ramps themselves.
590pub const MAX_GRADIENT_STOPS: usize = 8;
591
592/// One gradient stop. `offset` is parametric along the gradient axis (0 at the
593/// start, 1 at the end). A stored offset is unconstrained; packing clamps it to
594/// `0..=1` and forces it non-decreasing for the GPU, so a caller may set it
595/// freely through [`GradientStops::as_mut_slice`].
596#[derive(Clone, Copy, Debug, PartialEq, Default)]
597pub struct GradientStop {
598 pub offset: f32,
599 pub color: Color,
600}
601
602/// Inline stop table for [`PaintDesc::LinearStops`] / [`PaintDesc::RadialStops`].
603///
604/// Stored by value (no heap, no lifetime) so `PaintDesc` stays `Copy` and
605/// `SceneNode` stays lifetime-free. Longer input is truncated to the first
606/// [`MAX_GRADIENT_STOPS`]: [`from_slice`](Self::from_slice) logs the overflow,
607/// [`try_from_slice`](Self::try_from_slice) reports it instead.
608#[derive(Clone, Copy, Debug, PartialEq, Default)]
609pub struct GradientStops {
610 stops: [GradientStop; MAX_GRADIENT_STOPS],
611 len: u8,
612}
613
614impl GradientStops {
615 /// The first [`MAX_GRADIENT_STOPS`] of `slice`; logs how many were dropped.
616 pub fn from_slice(slice: &[GradientStop]) -> Self {
617 let mut stops = Self::default();
618 for stop in slice {
619 if !stops.push(*stop) {
620 log::warn!(
621 "gradient has {} stops; keeping the first {MAX_GRADIENT_STOPS}",
622 slice.len()
623 );
624 break;
625 }
626 }
627 stops
628 }
629
630 /// Like [`from_slice`](Self::from_slice) but `None` when `slice` does not
631 /// fit, so hosts can validate before submitting a draw.
632 pub fn try_from_slice(slice: &[GradientStop]) -> Option<Self> {
633 if slice.len() > MAX_GRADIENT_STOPS {
634 return None;
635 }
636 let mut stops = Self::default();
637 for stop in slice {
638 if !stops.push(*stop) {
639 return None;
640 }
641 }
642 Some(stops)
643 }
644
645 /// Append a stop; `false` when the table is already full.
646 pub fn push(&mut self, stop: GradientStop) -> bool {
647 let len = self.len as usize;
648 if len >= MAX_GRADIENT_STOPS {
649 return false;
650 }
651 self.stops[len] = stop;
652 self.len = (len + 1) as u8;
653 true
654 }
655
656 pub fn as_slice(&self) -> &[GradientStop] {
657 &self.stops[..self.len as usize]
658 }
659
660 pub fn as_mut_slice(&mut self) -> &mut [GradientStop] {
661 &mut self.stops[..self.len as usize]
662 }
663
664 pub fn len(&self) -> usize {
665 self.len as usize
666 }
667
668 pub fn is_empty(&self) -> bool {
669 self.len == 0
670 }
671
672 /// Lowest stop alpha in `0..=1`; `1.0` when empty (nothing is painted, so
673 /// the translucency fast path may treat it as opaque).
674 pub fn min_alpha(&self) -> f32 {
675 self.as_slice()
676 .iter()
677 .map(|stop| stop.color.3 as f32 / 255.0)
678 .fold(1.0, f32::min)
679 }
680}
681
682impl<'a> From<&'a [GradientStop]> for GradientStops {
683 fn from(slice: &'a [GradientStop]) -> Self {
684 Self::from_slice(slice)
685 }
686}
687
688/// How a `VectorMesh` is painted. Mirrors the [`Brush`] variants;
689/// gradient endpoints live in the mesh's local space.
690#[derive(Clone, Copy, Debug, PartialEq)]
691#[non_exhaustive]
692pub enum PaintDesc {
693 /// Use per-vertex color.
694 Solid,
695 /// Two-stop linear gradient in the mesh's local space.
696 Linear {
697 start: Vec2,
698 end: Vec2,
699 start_color: Color,
700 end_color: Color,
701 },
702 /// Radial gradient centered at `center` (mesh-local) with `radius`.
703 Radial {
704 center: Vec2,
705 radius: f32,
706 start_color: Color,
707 end_color: Color,
708 },
709 /// Angular sweep around `center` (mesh-local), clockwise from 3 o'clock.
710 Sweep {
711 center: Vec2,
712 start_color: Color,
713 end_color: Color,
714 },
715 /// Multi-stop linear gradient from `start` to `end` (mesh-local), evaluated
716 /// per fragment. With two stops at offsets 0 and 1 this renders identically
717 /// to [`Linear`](Self::Linear); beyond the end stops the color clamps
718 /// (`TileMode::Clamp`).
719 LinearStops {
720 start: Vec2,
721 end: Vec2,
722 stops: GradientStops,
723 },
724 /// Multi-stop radial gradient around `center` with `radius` (mesh-local),
725 /// evaluated per fragment. Two stops at offsets 0 and 1 render identically
726 /// to [`Radial`](Self::Radial).
727 RadialStops {
728 center: Vec2,
729 radius: f32,
730 stops: GradientStops,
731 },
732}
733
734impl PaintDesc {
735 /// Lowest alpha this paint can produce, for the renderer's translucency
736 /// fast path. `None` when translucency comes from per-vertex colors alone.
737 pub fn min_alpha(self) -> Option<f32> {
738 match self {
739 PaintDesc::Solid => None,
740 PaintDesc::Linear {
741 start_color,
742 end_color,
743 ..
744 }
745 | PaintDesc::Radial {
746 start_color,
747 end_color,
748 ..
749 }
750 | PaintDesc::Sweep {
751 start_color,
752 end_color,
753 ..
754 } => Some((start_color.3.min(end_color.3) as f32 / 255.0).clamp(0.0, 1.0)),
755 PaintDesc::LinearStops { stops, .. } | PaintDesc::RadialStops { stops, .. } => {
756 Some(stops.min_alpha())
757 }
758 }
759 }
760}
761
762/// Blend mode for a `VectorMesh`, following the CSS `mix-blend-mode`
763/// vocabulary used by SVG/Lottie-style artwork. All variants are wired into
764/// the renderer: separable modes use fixed-function blending, the rest
765/// (marked below) isolate the mesh into a graphics layer and composite it
766/// with a custom shader.
767#[derive(Clone, Copy, Debug, PartialEq, Eq)]
768#[non_exhaustive]
769#[derive(Default)]
770pub enum BlendMode {
771 /// Standard premultiplied alpha blending (`normal`).
772 #[default]
773 Alpha,
774 /// `screen`: `1 - (1 - S) * (1 - D)`. Fixed-function.
775 Screen,
776 /// `overlay`: `Multiply` for dark backdrops, `Screen` for light ones.
777 Overlay,
778 /// `darken`: per-channel `min(S, D)`. Fixed-function.
779 Darken,
780 /// `lighten`: per-channel `max(S, D)`. Fixed-function.
781 Lighten,
782 /// `color-dodge`: brightens the backdrop toward the source color.
783 ColorDodge,
784 /// `color-burn`: darkens the backdrop toward the source color.
785 ColorBurn,
786 /// `hard-light`: `Overlay` with source and backdrop swapped.
787 HardLight,
788 /// `soft-light`: subtle `Overlay` variant.
789 SoftLight,
790 /// `difference`: per-channel `|S - D|`.
791 /// Needs the backdrop (fixed-function subtract only covers `S - D`),
792 /// so it composites via an isolated layer.
793 Difference,
794 /// `exclusion`: like `difference` with lower contrast.
795 Exclusion,
796 /// `hue`: source hue with backdrop saturation and luminosity.
797 Hue,
798 /// `saturation`: source saturation with backdrop hue and luminosity.
799 Saturation,
800 /// `color`: source hue and saturation with backdrop luminosity.
801 Color,
802 /// `luminosity`: source luminosity with backdrop hue and saturation.
803 Luminosity,
804 /// Additive (screen-space light). Fixed-function.
805 Add,
806 /// `multiply`: per-channel `S * D`. Fixed-function.
807 Multiply,
808}
809
810impl BlendMode {
811 /// Modes implemented with fixed-function hardware blending on the mesh
812 /// pipeline. Everything else isolates the mesh into a graphics layer and
813 /// composites it with the backdrop-blend shader.
814 pub fn needs_isolation(self) -> bool {
815 !matches!(
816 self,
817 BlendMode::Alpha
818 | BlendMode::Add
819 | BlendMode::Multiply
820 | BlendMode::Screen
821 | BlendMode::Darken
822 | BlendMode::Lighten
823 )
824 }
825
826 /// Discriminant consumed by `blend_layer.wgsl` (`mode` flat varying).
827 pub fn shader_mode(self) -> u32 {
828 match self {
829 BlendMode::Alpha => 0,
830 BlendMode::Add => 1,
831 BlendMode::Multiply => 2,
832 BlendMode::Screen => 3,
833 BlendMode::Overlay => 4,
834 BlendMode::Darken => 5,
835 BlendMode::Lighten => 6,
836 BlendMode::ColorDodge => 7,
837 BlendMode::ColorBurn => 8,
838 BlendMode::HardLight => 9,
839 BlendMode::SoftLight => 10,
840 BlendMode::Difference => 11,
841 BlendMode::Exclusion => 12,
842 BlendMode::Hue => 13,
843 BlendMode::Saturation => 14,
844 BlendMode::Color => 15,
845 BlendMode::Luminosity => 16,
846 }
847 }
848}
849
850#[derive(Clone, Copy, Debug, PartialEq, Eq)]
851#[non_exhaustive]
852pub enum TextOverflow {
853 Visible,
854 Clip,
855 Ellipsis,
856}
857
858/// Controls how line segments are joined in a stroked path.
859#[derive(Clone, Copy, Debug, PartialEq, Default)]
860pub enum StrokeJoin {
861 #[default]
862 /// Sharp corner joins.
863 Miter,
864 /// Semi-circular joins.
865 Round,
866 /// Beveled (flat) joins.
867 Bevel,
868}
869
870/// Controls how the endpoints of a stroked arc are drawn.
871#[derive(Clone, Copy, Debug, PartialEq, Default)]
872pub enum StrokeCap {
873 #[default]
874 /// Flat ends at the exact arc endpoint. No extension.
875 Butt,
876 /// Semicircle with diameter equal to the stroke width, centered at the
877 /// arc endpoint.
878 Round,
879 /// Flat-ended rectangle extending half the stroke width beyond the arc
880 /// endpoint.
881 Square,
882}
883
884#[cfg(test)]
885mod tests {
886 use super::*;
887
888 #[test]
889 fn subcompose_scope_unbounded_has_infinite_max() {
890 let s = SubcomposeScope::UNBOUNDED;
891 assert!(!s.max_width.0.is_finite());
892 assert!(!s.max_height.0.is_finite());
893 assert_eq!(s.min_width, Dp(0.0));
894 assert_eq!(s.min_height, Dp(0.0));
895 }
896
897 #[test]
898 fn subcompose_scope_new_round_trips() {
899 let s = SubcomposeScope::new(Dp(10.0), Dp(200.0), Dp(20.0), Dp(300.0));
900 assert_eq!(s.min_width, Dp(10.0));
901 assert_eq!(s.max_width, Dp(200.0));
902 assert_eq!(s.min_height, Dp(20.0));
903 assert_eq!(s.max_height, Dp(300.0));
904 }
905
906 #[test]
907 fn box_with_constraints_scope_bounded_predicates() {
908 let bounded = BoxWithConstraintsScope {
909 min_width: Dp(0.0),
910 max_width: Dp(360.0),
911 min_height: Dp(0.0),
912 max_height: Dp(640.0),
913 };
914 assert!(bounded.has_bounded_width());
915 assert!(bounded.has_bounded_height());
916
917 let unbounded = BoxWithConstraintsScope {
918 min_width: Dp(0.0),
919 max_width: Dp(f32::INFINITY),
920 min_height: Dp(0.0),
921 max_height: Dp(f32::INFINITY),
922 };
923 assert!(!unbounded.has_bounded_width());
924 assert!(!unbounded.has_bounded_height());
925 }
926
927 #[test]
928 fn view_kind_subcompose_layout_holds_closure() {
929 let v: View = View {
930 id: 0,
931 kind: ViewKind::SubcomposeLayout {
932 content: std::sync::Arc::new(|scope| {
933 let _ = scope.max_width;
934 vec![(0, View::new(0, ViewKind::Box))]
935 }),
936 },
937 modifier: Modifier::default(),
938 children: vec![],
939 scope_key: None,
940 semantics: None,
941 };
942 match &v.kind {
943 ViewKind::SubcomposeLayout { .. } => {}
944 _ => panic!("expected SubcomposeLayout"),
945 }
946 }
947
948 #[test]
949 fn view_kind_subcompose_layout_supports_multiple_slots() {
950 let v: View = View {
951 id: 0,
952 kind: ViewKind::SubcomposeLayout {
953 content: std::sync::Arc::new(|_scope| {
954 vec![
955 (1, View::new(0, ViewKind::Box)),
956 (2, View::new(0, ViewKind::Box)),
957 (3, View::new(0, ViewKind::Box)),
958 ]
959 }),
960 },
961 modifier: Modifier::default(),
962 children: vec![],
963 scope_key: None,
964 semantics: None,
965 };
966 if let ViewKind::SubcomposeLayout { content } = &v.kind {
967 let slots = content(&SubcomposeScope::UNBOUNDED);
968 assert_eq!(slots.len(), 3);
969 assert_eq!(slots[0].0, 1);
970 assert_eq!(slots[1].0, 2);
971 assert_eq!(slots[2].0, 3);
972 } else {
973 panic!("expected SubcomposeLayout");
974 }
975 }
976}