Skip to main content

fission_ir/
op.rs

1use super::semantics::{ActionEntry, Semantics};
2use crate::WidgetId;
3use serde::{Deserialize, Serialize};
4
5// The fundamental operations that can be performed in the Core IR.
6// These are low-level, platform-agnostic, and deterministic.
7#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
8pub enum Op {
9    Structural(StructuralOp),
10    Layout(LayoutOp),
11    Paint(PaintOp),
12    Semantics(Semantics),
13}
14
15impl std::hash::Hash for Op {
16    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
17        match self {
18            Self::Structural(s) => {
19                0.hash(state);
20                s.hash(state);
21            }
22            Self::Layout(l) => {
23                1.hash(state);
24                l.hash(state);
25            }
26            Self::Paint(p) => {
27                2.hash(state);
28                p.hash(state);
29            }
30            Self::Semantics(s) => {
31                3.hash(state);
32                s.hash(state);
33            }
34        }
35    }
36}
37
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Hash)]
39pub enum StructuralOp {
40    Group { stable_hash: u64 },
41}
42
43#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
44pub struct CompositeScalar {
45    pub base: f32,
46    pub motion_target: Option<WidgetId>,
47}
48
49impl CompositeScalar {
50    pub fn new(base: f32) -> Self {
51        Self {
52            base,
53            motion_target: None,
54        }
55    }
56
57    pub fn motion(mut self, target: WidgetId) -> Self {
58        self.motion_target = Some(target);
59        self
60    }
61}
62
63impl std::hash::Hash for CompositeScalar {
64    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
65        self.base.to_bits().hash(state);
66        self.motion_target.hash(state);
67    }
68}
69
70#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Hash, Default)]
71pub struct CompositeStyle {
72    pub opacity: Option<CompositeScalar>,
73    pub translate_x: Option<CompositeScalar>,
74    pub translate_y: Option<CompositeScalar>,
75    pub scale: Option<CompositeScalar>,
76    pub rotation: Option<CompositeScalar>,
77    pub clip_to_bounds: bool,
78    pub repaint_boundary: bool,
79}
80
81pub type LayoutUnit = f32;
82
83/// A declarative layout length resolved by the constraint engine.
84#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
85pub enum Length {
86    /// Fixed logical points.
87    Points(LayoutUnit),
88    /// Percentage of the containing axis. `50.0` means 50%, not `0.5`.
89    Percent(f32),
90    /// Percentage of the active viewport width. `100.0` means full viewport width.
91    ViewportWidth(f32),
92    /// Percentage of the active viewport height. `100.0` means full viewport height.
93    ViewportHeight(f32),
94    /// Sum of two length expressions.
95    Add(Box<Length>, Box<Length>),
96    /// Difference between two length expressions.
97    Subtract(Box<Length>, Box<Length>),
98    /// Smallest value from a list of fully resolvable length expressions.
99    Min(Vec<Length>),
100    /// Largest value from a list of fully resolvable length expressions.
101    Max(Vec<Length>),
102    /// Preferred value clamped between lower and upper bounds.
103    Clamp {
104        /// Lower bound.
105        min: Box<Length>,
106        /// Preferred value before clamping.
107        preferred: Box<Length>,
108        /// Upper bound.
109        max: Box<Length>,
110    },
111    /// Size to intrinsic content, optionally capped by a limit.
112    FitContent(Option<Box<Length>>),
113    /// Minimum intrinsic size required by the content.
114    MinContent,
115    /// Preferred intrinsic size of the content without wrapping.
116    MaxContent,
117    /// Let the active layout algorithm choose the size.
118    Auto,
119}
120
121impl Length {
122    /// Creates a fixed logical-point length.
123    pub fn points(value: LayoutUnit) -> Self {
124        Self::Points(value)
125    }
126
127    /// Creates a percentage of the containing axis.
128    pub fn percent(value: f32) -> Self {
129        Self::Percent(value)
130    }
131
132    /// Creates a percentage of the viewport width.
133    pub fn vw(value: f32) -> Self {
134        Self::ViewportWidth(value)
135    }
136
137    /// Creates a percentage of the viewport height.
138    pub fn vh(value: f32) -> Self {
139        Self::ViewportHeight(value)
140    }
141
142    /// Clamps a preferred length between lower and upper bounds.
143    pub fn clamp(min: Length, preferred: Length, max: Length) -> Self {
144        Self::Clamp {
145            min: Box::new(min),
146            preferred: Box::new(preferred),
147            max: Box::new(max),
148        }
149    }
150
151    /// Selects the smallest fully resolved length.
152    pub fn min(values: impl Into<Vec<Length>>) -> Self {
153        Self::Min(values.into())
154    }
155
156    /// Selects the largest fully resolved length.
157    pub fn max(values: impl Into<Vec<Length>>) -> Self {
158        Self::Max(values.into())
159    }
160
161    /// Sizes to content, optionally capped by a resolved limit.
162    pub fn fit_content(limit: impl Into<Option<Length>>) -> Self {
163        Self::FitContent(limit.into().map(Box::new))
164    }
165
166    /// Creates `[left, right, top, bottom]` edges with one shared value.
167    pub fn all(value: Length) -> [Length; 4] {
168        std::array::from_fn(|_| value.clone())
169    }
170
171    /// Creates `[left, right, top, bottom]` edges from axis values.
172    pub fn symmetric(horizontal: Length, vertical: Length) -> [Length; 4] {
173        [horizontal.clone(), horizontal, vertical.clone(), vertical]
174    }
175
176    /// Resolves a numeric length against one axis and the active viewport.
177    ///
178    /// Intrinsic and automatic lengths return `None` because they require a
179    /// layout measurement rather than arithmetic resolution.
180    pub fn resolve(
181        &self,
182        reference: LayoutUnit,
183        viewport_width: LayoutUnit,
184        viewport_height: LayoutUnit,
185    ) -> Option<LayoutUnit> {
186        let resolved = match self {
187            Self::Points(value) => *value,
188            Self::Percent(value) => reference.is_finite().then_some(reference * value / 100.0)?,
189            Self::ViewportWidth(value) => viewport_width * value / 100.0,
190            Self::ViewportHeight(value) => viewport_height * value / 100.0,
191            Self::Add(left, right) => {
192                left.resolve(reference, viewport_width, viewport_height)?
193                    + right.resolve(reference, viewport_width, viewport_height)?
194            }
195            Self::Subtract(left, right) => {
196                left.resolve(reference, viewport_width, viewport_height)?
197                    - right.resolve(reference, viewport_width, viewport_height)?
198            }
199            Self::Min(values) => resolve_length_list(
200                values,
201                reference,
202                viewport_width,
203                viewport_height,
204                LayoutUnit::min,
205            )?,
206            Self::Max(values) => resolve_length_list(
207                values,
208                reference,
209                viewport_width,
210                viewport_height,
211                LayoutUnit::max,
212            )?,
213            Self::Clamp {
214                min,
215                preferred,
216                max,
217            } => {
218                let minimum = min.resolve(reference, viewport_width, viewport_height)?;
219                let maximum = max.resolve(reference, viewport_width, viewport_height)?;
220                preferred
221                    .resolve(reference, viewport_width, viewport_height)?
222                    .clamp(minimum.min(maximum), minimum.max(maximum))
223            }
224            Self::FitContent(_) | Self::MinContent | Self::MaxContent | Self::Auto => return None,
225        };
226        resolved.is_finite().then_some(resolved)
227    }
228}
229
230fn resolve_length_list(
231    values: &[Length],
232    reference: LayoutUnit,
233    viewport_width: LayoutUnit,
234    viewport_height: LayoutUnit,
235    combine: impl Fn(LayoutUnit, LayoutUnit) -> LayoutUnit,
236) -> Option<LayoutUnit> {
237    let mut values = values.iter();
238    let mut resolved = values
239        .next()?
240        .resolve(reference, viewport_width, viewport_height)?;
241    for value in values {
242        resolved = combine(
243            resolved,
244            value.resolve(reference, viewport_width, viewport_height)?,
245        );
246    }
247    Some(resolved)
248}
249
250impl From<LayoutUnit> for Length {
251    fn from(value: LayoutUnit) -> Self {
252        Self::Points(value)
253    }
254}
255
256impl std::ops::Add for Length {
257    type Output = Self;
258
259    fn add(self, rhs: Self) -> Self::Output {
260        Self::Add(Box::new(self), Box::new(rhs))
261    }
262}
263
264impl std::ops::Sub for Length {
265    type Output = Self;
266
267    fn sub(self, rhs: Self) -> Self::Output {
268        Self::Subtract(Box::new(self), Box::new(rhs))
269    }
270}
271
272impl std::hash::Hash for Length {
273    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
274        std::mem::discriminant(self).hash(state);
275        match self {
276            Self::Points(value)
277            | Self::Percent(value)
278            | Self::ViewportWidth(value)
279            | Self::ViewportHeight(value) => value.to_bits().hash(state),
280            Self::Add(left, right) | Self::Subtract(left, right) => {
281                left.hash(state);
282                right.hash(state);
283            }
284            Self::Min(values) | Self::Max(values) => values.hash(state),
285            Self::Clamp {
286                min,
287                preferred,
288                max,
289            } => {
290                min.hash(state);
291                preferred.hash(state);
292                max.hash(state);
293            }
294            Self::FitContent(limit) => limit.hash(state),
295            Self::MinContent | Self::MaxContent | Self::Auto => {}
296        }
297    }
298}
299
300/// Overflow behavior for a common box.
301#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, Hash)]
302pub enum Overflow {
303    /// Let content paint outside the box's assigned rectangle.
304    #[default]
305    Visible,
306    /// Clip content to the box's assigned rectangle.
307    Clip,
308}
309
310/// Alignment of a box's child within its content rectangle.
311#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, Hash)]
312pub enum BoxAlignment {
313    /// Place the child at the start of both axes.
314    #[default]
315    Start,
316    /// Center the child on both axes.
317    Center,
318    /// Place the child at the end of both axes.
319    End,
320    /// Stretch the child to the content rectangle where the child has no explicit size.
321    Stretch,
322}
323
324/// Absolute positioning values for a common box.
325#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, Hash)]
326pub struct BoxPosition {
327    /// Distance from the parent's left edge.
328    pub left: Option<Length>,
329    /// Distance from the parent's top edge.
330    pub top: Option<Length>,
331    /// Distance from the parent's right edge.
332    pub right: Option<Length>,
333    /// Distance from the parent's bottom edge.
334    pub bottom: Option<Length>,
335}
336
337/// Grid placement values for a common box.
338#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, Hash)]
339pub struct BoxGridPlacement {
340    /// Starting row line or automatic placement.
341    pub row_start: GridPlacement,
342    /// Ending row line, span, or automatic placement.
343    pub row_end: GridPlacement,
344    /// Starting column line or automatic placement.
345    pub col_start: GridPlacement,
346    /// Ending column line, span, or automatic placement.
347    pub col_end: GridPlacement,
348}
349
350/// Typed sizing and overflow shared by common box-like widgets.
351///
352/// `BoxStyle` lets widgets expose CSS-like layout capabilities without
353/// embedding CSS or shell-specific behavior in application code.
354///
355/// # Example
356///
357/// ```rust
358/// use fission_ir::op::{BoxAlignment, BoxStyle, Length, Overflow};
359///
360/// let style = BoxStyle::default()
361///     .width(Length::clamp(
362///         Length::points(280.0),
363///         Length::percent(50.0),
364///         Length::points(720.0),
365///     ))
366///     .padding_symmetric(Length::points(24.0), Length::points(16.0))
367///     .align(BoxAlignment::Center)
368///     .overflow(Overflow::Clip);
369/// ```
370#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, Hash)]
371pub struct BoxStyle {
372    /// Preferred width.
373    pub width: Option<Length>,
374    /// Preferred height.
375    pub height: Option<Length>,
376    /// Minimum width constraint.
377    pub min_width: Option<Length>,
378    /// Maximum width constraint.
379    pub max_width: Option<Length>,
380    /// Minimum height constraint.
381    pub min_height: Option<Length>,
382    /// Maximum height constraint.
383    pub max_height: Option<Length>,
384    /// Inner spacing in `[left, right, top, bottom]` order.
385    pub padding: Option<[Length; 4]>,
386    /// Outer spacing in `[left, right, top, bottom]` order.
387    pub margin: Option<[Length; 4]>,
388    /// Width-to-height ratio.
389    pub aspect_ratio: Option<OrderedLayoutUnit>,
390    /// Whether content can paint outside this box.
391    pub overflow: Overflow,
392    /// Child alignment inside the content rectangle.
393    pub alignment: BoxAlignment,
394    /// Optional absolute positioning offsets.
395    pub position: Option<BoxPosition>,
396    /// Optional parent-grid placement.
397    pub grid: Option<BoxGridPlacement>,
398    /// Flex grow participation for box-like widgets.
399    pub flex_grow: Option<OrderedLayoutUnit>,
400    /// Flex shrink participation for box-like widgets.
401    pub flex_shrink: Option<OrderedLayoutUnit>,
402}
403
404impl BoxStyle {
405    /// Sets the preferred width.
406    pub fn width(mut self, value: Length) -> Self {
407        self.width = Some(value);
408        self
409    }
410
411    /// Sets the preferred height.
412    pub fn height(mut self, value: Length) -> Self {
413        self.height = Some(value);
414        self
415    }
416
417    /// Sets the minimum width.
418    pub fn min_width(mut self, value: Length) -> Self {
419        self.min_width = Some(value);
420        self
421    }
422
423    /// Sets the maximum width.
424    pub fn max_width(mut self, value: Length) -> Self {
425        self.max_width = Some(value);
426        self
427    }
428
429    /// Sets the minimum height.
430    pub fn min_height(mut self, value: Length) -> Self {
431        self.min_height = Some(value);
432        self
433    }
434
435    /// Sets the maximum height.
436    pub fn max_height(mut self, value: Length) -> Self {
437        self.max_height = Some(value);
438        self
439    }
440
441    /// Sets `[left, right, top, bottom]` inner spacing.
442    pub fn padding(mut self, edges: [Length; 4]) -> Self {
443        self.padding = Some(edges);
444        self
445    }
446
447    /// Sets equal inner spacing on every edge.
448    pub fn padding_all(self, value: Length) -> Self {
449        self.padding(Length::all(value))
450    }
451
452    /// Sets horizontal and vertical inner spacing.
453    pub fn padding_symmetric(self, horizontal: Length, vertical: Length) -> Self {
454        self.padding(Length::symmetric(horizontal, vertical))
455    }
456
457    /// Sets `[left, right, top, bottom]` outer spacing.
458    pub fn margin(mut self, edges: [Length; 4]) -> Self {
459        self.margin = Some(edges);
460        self
461    }
462
463    /// Sets equal outer spacing on every edge.
464    pub fn margin_all(self, value: Length) -> Self {
465        self.margin(Length::all(value))
466    }
467
468    /// Sets horizontal and vertical outer spacing.
469    pub fn margin_symmetric(self, horizontal: Length, vertical: Length) -> Self {
470        self.margin(Length::symmetric(horizontal, vertical))
471    }
472
473    /// Sets overflow visibility or clipping.
474    pub fn overflow(mut self, overflow: Overflow) -> Self {
475        self.overflow = overflow;
476        self
477    }
478
479    /// Aligns the child within the box's content rectangle.
480    pub fn align(mut self, alignment: BoxAlignment) -> Self {
481        self.alignment = alignment;
482        self
483    }
484
485    /// Sets a non-negative width-to-height ratio.
486    pub fn aspect_ratio(mut self, ratio: LayoutUnit) -> Self {
487        self.aspect_ratio = Some(OrderedLayoutUnit(ratio.max(0.0)));
488        self
489    }
490
491    /// Absolutely positions the box within its positioned parent.
492    pub fn positioned(mut self, position: BoxPosition) -> Self {
493        self.position = Some(position);
494        self
495    }
496
497    /// Places the box in a parent grid.
498    pub fn grid(mut self, placement: BoxGridPlacement) -> Self {
499        self.grid = Some(placement);
500        self
501    }
502
503    /// Sets flex grow and shrink participation.
504    pub fn flex(mut self, grow: LayoutUnit, shrink: LayoutUnit) -> Self {
505        self.flex_grow = Some(OrderedLayoutUnit(grow));
506        self.flex_shrink = Some(OrderedLayoutUnit(shrink));
507        self
508    }
509}
510
511/// Hashable/serializable wrapper for floating-point layout values.
512#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
513pub struct OrderedLayoutUnit(
514    /// Wrapped finite layout value.
515    pub LayoutUnit,
516);
517
518impl std::hash::Hash for OrderedLayoutUnit {
519    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
520        self.0.to_bits().hash(state);
521    }
522}
523
524#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash, Default)]
525pub enum TextAlign {
526    Left,
527    Right,
528    Center,
529    Justify,
530    #[default]
531    Start,
532    End,
533}
534
535#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash, Default)]
536pub enum TextOverflow {
537    Clip,
538    Ellipsis,
539    Fade,
540    #[default]
541    Visible,
542}
543
544#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash, Default)]
545pub enum TextDirection {
546    #[default]
547    Auto,
548    Ltr,
549    Rtl,
550}
551
552#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash, Default)]
553pub enum TextWidthBasis {
554    #[default]
555    Parent,
556    LongestLine,
557}
558
559#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash, Default)]
560pub enum MouseCursor {
561    #[default]
562    Basic,
563    Pointer,
564    Text,
565}
566
567#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
568pub struct TextHeightBehavior {
569    pub apply_height_to_first_ascent: bool,
570    pub apply_height_to_last_descent: bool,
571}
572
573impl Default for TextHeightBehavior {
574    fn default() -> Self {
575        Self {
576            apply_height_to_first_ascent: true,
577            apply_height_to_last_descent: true,
578        }
579    }
580}
581
582#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
583pub struct TextParagraphStyle {
584    pub text_align: TextAlign,
585    pub max_lines: Option<usize>,
586    pub overflow: TextOverflow,
587    #[serde(default)]
588    pub text_direction: TextDirection,
589    #[serde(default)]
590    pub text_width_basis: TextWidthBasis,
591    #[serde(default)]
592    pub strut_line_height: Option<LayoutUnit>,
593    #[serde(default)]
594    pub text_height_behavior: TextHeightBehavior,
595}
596
597impl PartialEq for TextParagraphStyle {
598    fn eq(&self, other: &Self) -> bool {
599        self.text_align == other.text_align
600            && self.max_lines == other.max_lines
601            && self.overflow == other.overflow
602            && self.text_direction == other.text_direction
603            && self.text_width_basis == other.text_width_basis
604            && self.strut_line_height.map(f32::to_bits) == other.strut_line_height.map(f32::to_bits)
605            && self.text_height_behavior == other.text_height_behavior
606    }
607}
608
609impl Eq for TextParagraphStyle {}
610
611impl std::hash::Hash for TextParagraphStyle {
612    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
613        self.text_align.hash(state);
614        self.max_lines.hash(state);
615        self.overflow.hash(state);
616        self.text_direction.hash(state);
617        self.text_width_basis.hash(state);
618        self.strut_line_height.map(f32::to_bits).hash(state);
619        self.text_height_behavior.hash(state);
620    }
621}
622
623const TEXT_PARAGRAPH_ALIGN_BITS: u32 = 0b111;
624const TEXT_PARAGRAPH_OVERFLOW_BITS: u32 = 0b111 << 3;
625const TEXT_PARAGRAPH_MAX_LINES_SHIFT: u32 = 6;
626const TEXT_PARAGRAPH_SENTINEL: u32 = 1;
627const TEXT_PARAGRAPH_MAX_ENCODED_LINES: usize = ((1 << 24) - 1) >> TEXT_PARAGRAPH_MAX_LINES_SHIFT;
628
629const fn text_align_code(align: TextAlign) -> u32 {
630    match align {
631        TextAlign::Start => 0,
632        TextAlign::Left => 1,
633        TextAlign::Center => 2,
634        TextAlign::Right => 3,
635        TextAlign::End => 4,
636        TextAlign::Justify => 5,
637    }
638}
639
640const fn text_overflow_code(overflow: TextOverflow) -> u32 {
641    match overflow {
642        TextOverflow::Visible => 0,
643        TextOverflow::Clip => 1,
644        TextOverflow::Ellipsis => 2,
645        TextOverflow::Fade => 3,
646    }
647}
648
649const fn decode_text_align(code: u32) -> TextAlign {
650    match code {
651        1 => TextAlign::Left,
652        2 => TextAlign::Center,
653        3 => TextAlign::Right,
654        4 => TextAlign::End,
655        5 => TextAlign::Justify,
656        _ => TextAlign::Start,
657    }
658}
659
660const fn decode_text_overflow(code: u32) -> TextOverflow {
661    match code {
662        1 => TextOverflow::Clip,
663        2 => TextOverflow::Ellipsis,
664        3 => TextOverflow::Fade,
665        _ => TextOverflow::Visible,
666    }
667}
668
669pub fn encode_text_paragraph_style(style: TextParagraphStyle) -> Option<LayoutUnit> {
670    if style == TextParagraphStyle::default() {
671        return None;
672    }
673    if style.text_direction != TextDirection::Auto
674        || style.text_width_basis != TextWidthBasis::Parent
675        || style.strut_line_height.is_some()
676        || style.text_height_behavior != TextHeightBehavior::default()
677    {
678        return None;
679    }
680
681    let max_lines = style
682        .max_lines
683        .unwrap_or(0)
684        .min(TEXT_PARAGRAPH_MAX_ENCODED_LINES) as u32;
685    let encoded = TEXT_PARAGRAPH_SENTINEL
686        + text_align_code(style.text_align)
687        + (text_overflow_code(style.overflow) << 3)
688        + (max_lines << TEXT_PARAGRAPH_MAX_LINES_SHIFT);
689
690    Some(-(encoded as LayoutUnit))
691}
692
693pub fn decode_text_paragraph_style(
694    encoded_width: Option<LayoutUnit>,
695) -> Option<TextParagraphStyle> {
696    let encoded_width = encoded_width?;
697    if !encoded_width.is_finite() || encoded_width >= 0.0 {
698        return None;
699    }
700
701    let raw = (-encoded_width).round();
702    if raw < TEXT_PARAGRAPH_SENTINEL as f32 {
703        return None;
704    }
705
706    let bits = raw as u32 - TEXT_PARAGRAPH_SENTINEL;
707    let text_align = decode_text_align(bits & TEXT_PARAGRAPH_ALIGN_BITS);
708    let overflow = decode_text_overflow((bits & TEXT_PARAGRAPH_OVERFLOW_BITS) >> 3);
709    let max_lines = match bits >> TEXT_PARAGRAPH_MAX_LINES_SHIFT {
710        0 => None,
711        lines => Some(lines as usize),
712    };
713
714    Some(TextParagraphStyle {
715        text_align,
716        max_lines,
717        overflow,
718        text_direction: TextDirection::Auto,
719        text_width_basis: TextWidthBasis::Parent,
720        strut_line_height: None,
721        text_height_behavior: TextHeightBehavior::default(),
722    })
723}
724
725#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Hash)]
726pub enum FlexDirection {
727    Row,
728    Column,
729}
730
731impl Default for FlexDirection {
732    fn default() -> Self {
733        FlexDirection::Row
734    }
735}
736
737#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Hash)]
738pub enum EmbedKind {
739    Video,
740    Web,
741    Custom(Vec<u8>),
742}
743
744#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
745pub enum GridTrack {
746    /// Fixed track size in logical points.
747    Points(LayoutUnit),
748    /// Percentage of the available grid axis. `50.0` means 50%.
749    Percent(f32),
750    /// Fraction of remaining free space after fixed and intrinsic tracks.
751    Fr(f32),
752    /// Track sized by the largest participating item's intrinsic size.
753    Auto,
754    /// Track sized by the participating items' minimum intrinsic size.
755    MinContent,
756    /// Track sized by the participating items' preferred intrinsic size.
757    MaxContent,
758    /// Track with independent minimum and maximum sizing functions.
759    MinMax(Box<GridTrack>, Box<GridTrack>),
760    /// Repeats an ordered track list a fixed number of times.
761    Repeat { count: u16, tracks: Vec<GridTrack> },
762    /// Repeats a track to fit available space, dropping empty trailing tracks.
763    AutoFit(Box<GridTrack>),
764    /// Repeats a track to fill available space, retaining empty tracks.
765    AutoFill(Box<GridTrack>),
766}
767
768/// The width source used to evaluate a responsive layout branch.
769#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, Hash)]
770pub enum ResponsiveQuery {
771    /// Compare breakpoints against the application viewport.
772    #[default]
773    Viewport,
774    /// Compare breakpoints against the constraints supplied by the parent.
775    Container,
776}
777
778/// An inclusive lower and exclusive upper width bound for a responsive branch.
779#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
780pub struct ResponsiveCondition {
781    /// Inclusive lower width bound.
782    pub min_width: Option<LayoutUnit>,
783    /// Exclusive upper width bound.
784    pub max_width: Option<LayoutUnit>,
785}
786
787impl ResponsiveCondition {
788    pub fn matches(self, width: LayoutUnit) -> bool {
789        self.min_width.is_none_or(|minimum| width >= minimum)
790            && self.max_width.is_none_or(|maximum| width < maximum)
791    }
792}
793
794impl std::hash::Hash for ResponsiveCondition {
795    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
796        self.min_width.map(f32::to_bits).hash(state);
797        self.max_width.map(f32::to_bits).hash(state);
798    }
799}
800
801impl GridTrack {
802    /// Creates a `minmax(min, max)` grid track.
803    ///
804    /// # Example
805    ///
806    /// ```rust
807    /// use fission_ir::op::GridTrack;
808    ///
809    /// let track = GridTrack::minmax(GridTrack::Points(180.0), GridTrack::Fr(1.0));
810    /// ```
811    pub fn minmax(min: GridTrack, max: GridTrack) -> Self {
812        Self::MinMax(Box::new(min), Box::new(max))
813    }
814
815    /// Repeats `tracks` `count` times.
816    pub fn repeat(count: u16, tracks: impl Into<Vec<GridTrack>>) -> Self {
817        Self::Repeat {
818            count,
819            tracks: tracks.into(),
820        }
821    }
822
823    /// Repeats `track` up to the available space and collapses empty tracks.
824    pub fn auto_fit(track: GridTrack) -> Self {
825        Self::AutoFit(Box::new(track))
826    }
827
828    /// Repeats `track` up to the available space and keeps empty tracks.
829    pub fn auto_fill(track: GridTrack) -> Self {
830        Self::AutoFill(Box::new(track))
831    }
832}
833
834impl std::hash::Hash for GridTrack {
835    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
836        match self {
837            Self::Points(u) => {
838                0.hash(state);
839                u.to_bits().hash(state);
840            }
841            Self::Percent(f) => {
842                1.hash(state);
843                f.to_bits().hash(state);
844            }
845            Self::Fr(f) => {
846                2.hash(state);
847                f.to_bits().hash(state);
848            }
849            Self::Auto => {
850                3.hash(state);
851            }
852            Self::MinContent => {
853                4.hash(state);
854            }
855            Self::MaxContent => {
856                5.hash(state);
857            }
858            Self::MinMax(min, max) => {
859                6.hash(state);
860                min.hash(state);
861                max.hash(state);
862            }
863            Self::Repeat { count, tracks } => {
864                7.hash(state);
865                count.hash(state);
866                tracks.hash(state);
867            }
868            Self::AutoFit(track) => {
869                8.hash(state);
870                track.hash(state);
871            }
872            Self::AutoFill(track) => {
873                9.hash(state);
874                track.hash(state);
875            }
876        }
877    }
878}
879
880#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
881pub enum GridPlacement {
882    /// Let the grid auto-placement algorithm choose the line.
883    Auto,
884    /// A one-based grid line number. Negative values count back from the end.
885    Line(i16),
886    /// Span this many tracks from the resolved start line.
887    Span(u16),
888}
889
890impl Default for GridPlacement {
891    fn default() -> Self {
892        Self::Auto
893    }
894}
895
896#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Hash)]
897pub enum FlexWrap {
898    NoWrap,
899    Wrap,
900    WrapReverse,
901}
902
903impl Default for FlexWrap {
904    fn default() -> Self {
905        FlexWrap::NoWrap
906    }
907}
908
909#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Hash)]
910pub enum AlignItems {
911    Start,
912    End,
913    Center,
914    Stretch,
915    Baseline,
916}
917
918impl Default for AlignItems {
919    fn default() -> Self {
920        AlignItems::Stretch
921    }
922}
923
924#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Hash)]
925pub enum JustifyContent {
926    Start,
927    End,
928    Center,
929    SpaceBetween,
930    SpaceAround,
931    SpaceEvenly,
932}
933
934impl Default for JustifyContent {
935    fn default() -> Self {
936        JustifyContent::Start
937    }
938}
939
940#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
941pub enum LayoutOp {
942    Box {
943        width: Option<LayoutUnit>,
944        height: Option<LayoutUnit>,
945        min_width: Option<LayoutUnit>,
946        max_width: Option<LayoutUnit>,
947        min_height: Option<LayoutUnit>,
948        max_height: Option<LayoutUnit>,
949        padding: [LayoutUnit; 4],
950        flex_grow: LayoutUnit,
951        flex_shrink: LayoutUnit,
952        aspect_ratio: Option<f32>,
953    },
954    /// A common box using declarative length expressions.
955    StyledBox {
956        style: BoxStyle,
957        flex_grow: LayoutUnit,
958        flex_shrink: LayoutUnit,
959    },
960    Flex {
961        direction: FlexDirection,
962        wrap: FlexWrap,
963        flex_grow: LayoutUnit,
964        flex_shrink: LayoutUnit,
965        padding: [LayoutUnit; 4],
966        gap: Option<LayoutUnit>,
967        align_items: AlignItems,
968        justify_content: JustifyContent,
969    },
970    Grid {
971        columns: Vec<GridTrack>,
972        rows: Vec<GridTrack>,
973        column_gap: Option<LayoutUnit>,
974        row_gap: Option<LayoutUnit>,
975        padding: [LayoutUnit; 4],
976    },
977    GridItem {
978        row_start: GridPlacement,
979        row_end: GridPlacement,
980        col_start: GridPlacement,
981        col_end: GridPlacement,
982    },
983    /// Selects one case child or the final fallback child from local constraints.
984    Responsive {
985        query: ResponsiveQuery,
986        cases: Vec<ResponsiveCondition>,
987    },
988    Scroll {
989        direction: FlexDirection,
990        show_scrollbar: bool,
991        width: Option<LayoutUnit>,
992        height: Option<LayoutUnit>,
993        min_width: Option<LayoutUnit>,
994        max_width: Option<LayoutUnit>,
995        min_height: Option<LayoutUnit>,
996        max_height: Option<LayoutUnit>,
997        padding: [LayoutUnit; 4],
998        flex_grow: LayoutUnit,
999        flex_shrink: LayoutUnit,
1000    },
1001    Embed {
1002        kind: EmbedKind,
1003        widget_id: WidgetId,
1004        width: Option<LayoutUnit>,
1005        height: Option<LayoutUnit>,
1006    },
1007    AbsoluteFill,
1008    Positioned {
1009        left: Option<LayoutUnit>,
1010        top: Option<LayoutUnit>,
1011        right: Option<LayoutUnit>,
1012        bottom: Option<LayoutUnit>,
1013        width: Option<LayoutUnit>,
1014        height: Option<LayoutUnit>,
1015    },
1016    /// Absolutely positions a child using typed lengths resolved by layout.
1017    PositionedLengths {
1018        left: Option<Length>,
1019        top: Option<Length>,
1020        right: Option<Length>,
1021        bottom: Option<Length>,
1022        width: Option<Length>,
1023        height: Option<Length>,
1024    },
1025    ZStack,
1026    Align,
1027    Flyout {
1028        anchor: WidgetId,
1029        content: WidgetId,
1030    },
1031    /// Lays out five overlay children around an external anchor.
1032    ///
1033    /// Children are ordered as top, bottom, left, right, and focus ring. The
1034    /// four surrounding regions leave the padded anchor rectangle uncovered.
1035    Spotlight {
1036        anchor: WidgetId,
1037        padding: LayoutUnit,
1038    },
1039    Transform {
1040        transform: [f32; 16],
1041    },
1042    Clip {
1043        path: Option<String>,
1044    },
1045}
1046
1047impl std::hash::Hash for LayoutOp {
1048    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
1049        let hash_unit = |u: LayoutUnit, h: &mut H| u.to_bits().hash(h);
1050        let hash_opt_unit = |u: Option<LayoutUnit>, h: &mut H| u.map(|v| v.to_bits()).hash(h);
1051        let hash_units = |us: [LayoutUnit; 4], h: &mut H| {
1052            for u in us {
1053                u.to_bits().hash(h);
1054            }
1055        };
1056
1057        match self {
1058            Self::Box {
1059                width,
1060                height,
1061                min_width,
1062                max_width,
1063                min_height,
1064                max_height,
1065                padding,
1066                flex_grow,
1067                flex_shrink,
1068                aspect_ratio,
1069            } => {
1070                0.hash(state);
1071                hash_opt_unit(*width, state);
1072                hash_opt_unit(*height, state);
1073                hash_opt_unit(*min_width, state);
1074                hash_opt_unit(*max_width, state);
1075                hash_opt_unit(*min_height, state);
1076                hash_opt_unit(*max_height, state);
1077                hash_units(*padding, state);
1078                hash_unit(*flex_grow, state);
1079                hash_unit(*flex_shrink, state);
1080                aspect_ratio.map(|f| f.to_bits()).hash(state);
1081            }
1082            Self::StyledBox {
1083                style,
1084                flex_grow,
1085                flex_shrink,
1086            } => {
1087                13.hash(state);
1088                style.hash(state);
1089                hash_unit(*flex_grow, state);
1090                hash_unit(*flex_shrink, state);
1091            }
1092            Self::Flex {
1093                direction,
1094                wrap,
1095                flex_grow,
1096                flex_shrink,
1097                padding,
1098                gap,
1099                align_items,
1100                justify_content,
1101            } => {
1102                1.hash(state);
1103                direction.hash(state);
1104                wrap.hash(state);
1105                hash_unit(*flex_grow, state);
1106                hash_unit(*flex_shrink, state);
1107                hash_units(*padding, state);
1108                hash_opt_unit(*gap, state);
1109                align_items.hash(state);
1110                justify_content.hash(state);
1111            }
1112            Self::Grid {
1113                columns,
1114                rows,
1115                column_gap,
1116                row_gap,
1117                padding,
1118            } => {
1119                2.hash(state);
1120                columns.hash(state);
1121                rows.hash(state);
1122                hash_opt_unit(*column_gap, state);
1123                hash_opt_unit(*row_gap, state);
1124                hash_units(*padding, state);
1125            }
1126            Self::GridItem {
1127                row_start,
1128                row_end,
1129                col_start,
1130                col_end,
1131            } => {
1132                3.hash(state);
1133                row_start.hash(state);
1134                row_end.hash(state);
1135                col_start.hash(state);
1136                col_end.hash(state);
1137            }
1138            Self::Responsive { query, cases } => {
1139                14.hash(state);
1140                query.hash(state);
1141                cases.hash(state);
1142            }
1143            Self::Scroll {
1144                direction,
1145                show_scrollbar,
1146                width,
1147                height,
1148                min_width,
1149                max_width,
1150                min_height,
1151                max_height,
1152                padding,
1153                flex_grow,
1154                flex_shrink,
1155            } => {
1156                4.hash(state);
1157                direction.hash(state);
1158                show_scrollbar.hash(state);
1159                hash_opt_unit(*width, state);
1160                hash_opt_unit(*height, state);
1161                hash_opt_unit(*min_width, state);
1162                hash_opt_unit(*max_width, state);
1163                hash_opt_unit(*min_height, state);
1164                hash_opt_unit(*max_height, state);
1165                hash_units(*padding, state);
1166                hash_unit(*flex_grow, state);
1167                hash_unit(*flex_shrink, state);
1168            }
1169            Self::Embed {
1170                kind,
1171                widget_id,
1172                width,
1173                height,
1174            } => {
1175                5.hash(state);
1176                kind.hash(state);
1177                widget_id.hash(state);
1178                hash_opt_unit(*width, state);
1179                hash_opt_unit(*height, state);
1180            }
1181            Self::AbsoluteFill => {
1182                6.hash(state);
1183            }
1184            Self::Positioned {
1185                left,
1186                top,
1187                right,
1188                bottom,
1189                width,
1190                height,
1191            } => {
1192                7.hash(state);
1193                hash_opt_unit(*left, state);
1194                hash_opt_unit(*top, state);
1195                hash_opt_unit(*right, state);
1196                hash_opt_unit(*bottom, state);
1197                hash_opt_unit(*width, state);
1198                hash_opt_unit(*height, state);
1199            }
1200            Self::PositionedLengths {
1201                left,
1202                top,
1203                right,
1204                bottom,
1205                width,
1206                height,
1207            } => {
1208                15.hash(state);
1209                left.hash(state);
1210                top.hash(state);
1211                right.hash(state);
1212                bottom.hash(state);
1213                width.hash(state);
1214                height.hash(state);
1215            }
1216            Self::ZStack => {
1217                8.hash(state);
1218            }
1219            Self::Align => {
1220                9.hash(state);
1221            }
1222            Self::Flyout { anchor, content } => {
1223                10.hash(state);
1224                anchor.hash(state);
1225                content.hash(state);
1226            }
1227            Self::Transform { transform } => {
1228                11.hash(state);
1229                for v in transform {
1230                    v.to_bits().hash(state);
1231                }
1232            }
1233            Self::Clip { path } => {
1234                12.hash(state);
1235                path.hash(state);
1236            }
1237            Self::Spotlight { anchor, padding } => {
1238                16.hash(state);
1239                anchor.hash(state);
1240                hash_unit(*padding, state);
1241            }
1242        }
1243    }
1244}
1245
1246#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Hash)]
1247pub struct Color {
1248    pub r: u8,
1249    pub g: u8,
1250    pub b: u8,
1251    pub a: u8,
1252}
1253
1254impl Color {
1255    pub const TRANSPARENT: Self = Self {
1256        r: 0,
1257        g: 0,
1258        b: 0,
1259        a: 0,
1260    };
1261    pub const BLACK: Self = Self {
1262        r: 0,
1263        g: 0,
1264        b: 0,
1265        a: 255,
1266    };
1267    pub const WHITE: Self = Self {
1268        r: 255,
1269        g: 255,
1270        b: 255,
1271        a: 255,
1272    };
1273    pub const RED: Self = Self {
1274        r: 255,
1275        g: 0,
1276        b: 0,
1277        a: 255,
1278    };
1279    pub const GREEN: Self = Self {
1280        r: 0,
1281        g: 255,
1282        b: 0,
1283        a: 255,
1284    };
1285    pub const BLUE: Self = Self {
1286        r: 0,
1287        g: 0,
1288        b: 255,
1289        a: 255,
1290    };
1291
1292    pub fn with_alpha(mut self, a: u8) -> Self {
1293        self.a = a;
1294        self
1295    }
1296}
1297
1298#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1299pub enum Fill {
1300    Solid(Color),
1301    /// A gradient whose start and end points are normalized to the painted
1302    /// bounds, where `(0.0, 0.0)` is the top-left and `(1.0, 1.0)` is the
1303    /// bottom-right.
1304    LinearGradient {
1305        start: (f32, f32),
1306        end: (f32, f32),
1307        stops: Vec<(f32, Color)>,
1308    },
1309    /// A gradient whose center and radius are normalized to the painted bounds.
1310    RadialGradient {
1311        center: (f32, f32),
1312        radius: f32,
1313        stops: Vec<(f32, Color)>,
1314    },
1315}
1316
1317impl std::hash::Hash for Fill {
1318    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
1319        match self {
1320            Self::Solid(c) => {
1321                0.hash(state);
1322                c.hash(state);
1323            }
1324            Self::LinearGradient { start, end, stops } => {
1325                1.hash(state);
1326                start.0.to_bits().hash(state);
1327                start.1.to_bits().hash(state);
1328                end.0.to_bits().hash(state);
1329                end.1.to_bits().hash(state);
1330                for (off, c) in stops {
1331                    off.to_bits().hash(state);
1332                    c.hash(state);
1333                }
1334            }
1335            Self::RadialGradient {
1336                center,
1337                radius,
1338                stops,
1339            } => {
1340                2.hash(state);
1341                center.0.to_bits().hash(state);
1342                center.1.to_bits().hash(state);
1343                radius.to_bits().hash(state);
1344                for (off, c) in stops {
1345                    off.to_bits().hash(state);
1346                    c.hash(state);
1347                }
1348            }
1349        }
1350    }
1351}
1352
1353#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1354pub enum LineCap {
1355    Butt,
1356    Round,
1357    Square,
1358}
1359
1360#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1361pub enum LineJoin {
1362    Miter,
1363    Round,
1364    Bevel,
1365}
1366
1367#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1368pub struct Stroke {
1369    pub fill: Fill,
1370    pub width: LayoutUnit,
1371    pub dash_array: Option<Vec<f32>>,
1372    pub line_cap: LineCap,
1373    pub line_join: LineJoin,
1374}
1375
1376impl std::hash::Hash for Stroke {
1377    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
1378        self.fill.hash(state);
1379        self.width.to_bits().hash(state);
1380        if let Some(da) = &self.dash_array {
1381            1.hash(state);
1382            for d in da {
1383                d.to_bits().hash(state);
1384            }
1385        } else {
1386            0.hash(state);
1387        }
1388        self.line_cap.hash(state);
1389        self.line_join.hash(state);
1390    }
1391}
1392
1393#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
1394pub struct BoxShadow {
1395    pub color: Color,
1396    pub blur_radius: LayoutUnit,
1397    /// Positive values expand the shadow shape; negative values contract it.
1398    pub spread_radius: LayoutUnit,
1399    pub offset: (LayoutUnit, LayoutUnit),
1400    /// Draws the shadow inside the shape instead of behind it.
1401    pub inset: bool,
1402}
1403
1404impl std::hash::Hash for BoxShadow {
1405    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
1406        self.color.hash(state);
1407        self.blur_radius.to_bits().hash(state);
1408        self.spread_radius.to_bits().hash(state);
1409        self.offset.0.to_bits().hash(state);
1410        self.offset.1.to_bits().hash(state);
1411        self.inset.hash(state);
1412    }
1413}
1414
1415#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Hash)]
1416pub enum ImageFit {
1417    Contain,
1418    Cover,
1419    Fill,
1420    None,
1421}
1422
1423#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash, Default)]
1424pub enum ImageAlignment {
1425    TopStart,
1426    TopCenter,
1427    TopEnd,
1428    CenterStart,
1429    #[default]
1430    Center,
1431    CenterEnd,
1432    BottomStart,
1433    BottomCenter,
1434    BottomEnd,
1435}
1436
1437#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)]
1438pub struct HttpHeader {
1439    pub name: String,
1440    pub value: String,
1441}
1442
1443#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash, Default)]
1444pub enum ImageCachePolicy {
1445    #[default]
1446    Default,
1447    Reload,
1448    MemoryOnly,
1449    Disk,
1450    NoStore,
1451}
1452
1453#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)]
1454pub enum ImageSource {
1455    Asset {
1456        path: String,
1457    },
1458    File {
1459        path: String,
1460    },
1461    Network {
1462        url: String,
1463        #[serde(default)]
1464        headers: Vec<HttpHeader>,
1465        #[serde(default)]
1466        cache_policy: ImageCachePolicy,
1467    },
1468    Memory {
1469        bytes: Vec<u8>,
1470        #[serde(default)]
1471        mime_type: Option<String>,
1472    },
1473    SvgText {
1474        content: String,
1475    },
1476}
1477
1478impl Default for ImageSource {
1479    fn default() -> Self {
1480        Self::Asset {
1481            path: String::new(),
1482        }
1483    }
1484}
1485
1486#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash, Default)]
1487pub enum ImageLoadingBehavior {
1488    #[default]
1489    Empty,
1490    ThemePlaceholder,
1491    BlurHash(String),
1492}
1493
1494#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash, Default)]
1495pub enum ImageErrorBehavior {
1496    #[default]
1497    Empty,
1498    ThemeError,
1499    AltText,
1500}
1501
1502#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash, Default)]
1503pub struct ImageRequest {
1504    pub source: ImageSource,
1505    #[serde(default)]
1506    pub cache_width: Option<u32>,
1507    #[serde(default)]
1508    pub cache_height: Option<u32>,
1509    #[serde(default)]
1510    pub semantic_label: Option<String>,
1511    #[serde(default)]
1512    pub loading: ImageLoadingBehavior,
1513    #[serde(default)]
1514    pub error: ImageErrorBehavior,
1515}
1516
1517impl ImageSource {
1518    pub fn stable_identity(&self) -> String {
1519        match self {
1520            Self::Asset { path } => format!("asset:{path}"),
1521            Self::File { path } => format!("file:{path}"),
1522            Self::Network {
1523                url,
1524                headers,
1525                cache_policy,
1526            } => {
1527                let mut identity = format!("network:{cache_policy:?}:{url}");
1528                for header in headers {
1529                    identity.push('|');
1530                    identity.push_str(&header.name.to_ascii_lowercase());
1531                    identity.push('=');
1532                    identity.push_str(&header.value);
1533                }
1534                identity
1535            }
1536            Self::Memory { bytes, mime_type } => {
1537                let digest = blake3::hash(bytes);
1538                format!("memory:{}:{digest}", mime_type.as_deref().unwrap_or(""))
1539            }
1540            Self::SvgText { content } => {
1541                let digest = blake3::hash(content.as_bytes());
1542                format!("svg:{digest}")
1543            }
1544        }
1545    }
1546
1547    pub fn local_path(&self) -> Option<&str> {
1548        match self {
1549            Self::Asset { path } | Self::File { path } => Some(path),
1550            _ => None,
1551        }
1552    }
1553
1554    pub fn network_url(&self) -> Option<&str> {
1555        match self {
1556            Self::Network { url, .. } => Some(url),
1557            _ => None,
1558        }
1559    }
1560}
1561
1562impl ImageRequest {
1563    pub fn stable_cache_key(&self) -> String {
1564        let mut hasher = blake3::Hasher::new();
1565        hasher.update(self.source.stable_identity().as_bytes());
1566        hasher.update(&self.cache_width.unwrap_or_default().to_le_bytes());
1567        hasher.update(&self.cache_height.unwrap_or_default().to_le_bytes());
1568        hasher.finalize().to_hex().to_string()
1569    }
1570}
1571
1572#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1573pub struct TextStyle {
1574    pub font_size: LayoutUnit,
1575    pub color: Color,
1576    pub underline: bool,
1577    #[serde(default)]
1578    pub font_family: Option<String>,
1579    #[serde(default)]
1580    pub locale: Option<String>,
1581    #[serde(default = "text_weight_default")]
1582    pub font_weight: u16,
1583    #[serde(default)]
1584    pub font_style: FontStyle,
1585    #[serde(default)]
1586    pub line_height: Option<LayoutUnit>,
1587    #[serde(default)]
1588    pub letter_spacing: LayoutUnit,
1589    /// Optional background highlight color for this run (find matches, error squiggles, etc.).
1590    pub background_color: Option<Color>,
1591}
1592
1593impl std::hash::Hash for TextStyle {
1594    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
1595        self.font_size.to_bits().hash(state);
1596        self.color.hash(state);
1597        self.underline.hash(state);
1598        self.font_family.hash(state);
1599        self.locale.hash(state);
1600        self.font_weight.hash(state);
1601        self.font_style.hash(state);
1602        self.line_height.map(f32::to_bits).hash(state);
1603        self.letter_spacing.to_bits().hash(state);
1604        self.background_color.hash(state);
1605    }
1606}
1607
1608#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
1609pub enum FontStyle {
1610    #[default]
1611    Normal,
1612    Italic,
1613}
1614
1615const fn text_weight_default() -> u16 {
1616    400
1617}
1618
1619#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Hash)]
1620pub struct TextRun {
1621    pub text: String,
1622    pub style: TextStyle,
1623}
1624
1625#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)]
1626pub struct RichTextAnnotation {
1627    pub range: std::ops::Range<usize>,
1628    #[serde(default)]
1629    pub semantics_label: Option<String>,
1630    #[serde(default)]
1631    pub semantics_identifier: Option<String>,
1632    #[serde(default)]
1633    pub spell_out: Option<bool>,
1634    #[serde(default)]
1635    pub mouse_cursor: Option<MouseCursor>,
1636    #[serde(default)]
1637    pub actions: Vec<ActionEntry>,
1638}
1639
1640pub const INLINE_WIDGET_MARKER_PREFIX: &str = "__fission_inline_widget__:";
1641
1642#[derive(Debug, Clone, Copy, PartialEq)]
1643pub struct InlineWidgetMarker {
1644    pub id: u64,
1645    pub width: LayoutUnit,
1646    pub height: LayoutUnit,
1647}
1648
1649pub fn encode_inline_widget_marker(id: u64, width: LayoutUnit, height: LayoutUnit) -> String {
1650    format!("{INLINE_WIDGET_MARKER_PREFIX}{id}:{width}:{height}")
1651}
1652
1653pub fn decode_inline_widget_marker(family: Option<&str>) -> Option<InlineWidgetMarker> {
1654    let family = family?;
1655    let encoded = family.strip_prefix(INLINE_WIDGET_MARKER_PREFIX)?;
1656    let mut parts = encoded.split(':');
1657    let id = parts.next()?.parse().ok()?;
1658    let width = parts.next()?.parse().ok()?;
1659    let height = parts.next()?.parse().ok()?;
1660    if parts.next().is_some() {
1661        return None;
1662    }
1663    Some(InlineWidgetMarker { id, width, height })
1664}
1665
1666const fn text_wrap_default() -> bool {
1667    true
1668}
1669
1670/// A filter applied to content already painted behind a widget.
1671#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
1672pub enum BackdropFilter {
1673    /// Applies a Gaussian blur using the supplied standard deviation.
1674    Blur(LayoutUnit),
1675}
1676
1677impl std::hash::Hash for BackdropFilter {
1678    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
1679        match self {
1680            Self::Blur(sigma) => {
1681                0_u8.hash(state);
1682                sigma.to_bits().hash(state);
1683            }
1684        }
1685    }
1686}
1687
1688#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1689pub enum PaintOp {
1690    BackdropFilter {
1691        filter: BackdropFilter,
1692        corner_radius: LayoutUnit,
1693    },
1694    DrawRect {
1695        fill: Option<Fill>,
1696        stroke: Option<Stroke>,
1697        corner_radius: LayoutUnit,
1698        shadow: Option<BoxShadow>,
1699    },
1700    DrawText {
1701        text: String,
1702        size: LayoutUnit,
1703        color: Color,
1704        underline: bool,
1705        #[serde(default = "text_wrap_default")]
1706        wrap: bool,
1707        caret_index: Option<usize>,
1708        #[serde(default)]
1709        caret_color: Option<Color>,
1710        #[serde(default)]
1711        caret_width: Option<LayoutUnit>,
1712        #[serde(default)]
1713        caret_height: Option<LayoutUnit>,
1714        #[serde(default)]
1715        caret_radius: Option<LayoutUnit>,
1716        #[serde(default)]
1717        paragraph_style: Option<TextParagraphStyle>,
1718    },
1719    DrawRichText {
1720        runs: Vec<TextRun>,
1721        #[serde(default = "text_wrap_default")]
1722        wrap: bool,
1723        caret_index: Option<usize>,
1724        #[serde(default)]
1725        caret_color: Option<Color>,
1726        #[serde(default)]
1727        caret_width: Option<LayoutUnit>,
1728        #[serde(default)]
1729        caret_height: Option<LayoutUnit>,
1730        #[serde(default)]
1731        caret_radius: Option<LayoutUnit>,
1732        #[serde(default)]
1733        paragraph_style: Option<TextParagraphStyle>,
1734    },
1735    DrawImage {
1736        request: ImageRequest,
1737        fit: ImageFit,
1738        alignment: ImageAlignment,
1739    },
1740    DrawPath {
1741        path: String,
1742        fill: Option<Fill>,
1743        stroke: Option<Stroke>,
1744    },
1745    DrawSvg {
1746        content: String,
1747        fill: Option<Fill>,
1748        stroke: Option<Stroke>,
1749    },
1750}
1751
1752impl std::hash::Hash for PaintOp {
1753    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
1754        match self {
1755            Self::BackdropFilter {
1756                filter,
1757                corner_radius,
1758            } => {
1759                0_u8.hash(state);
1760                filter.hash(state);
1761                corner_radius.to_bits().hash(state);
1762            }
1763            Self::DrawRect {
1764                fill,
1765                stroke,
1766                corner_radius,
1767                shadow,
1768            } => {
1769                1_u8.hash(state);
1770                fill.hash(state);
1771                stroke.hash(state);
1772                corner_radius.to_bits().hash(state);
1773                shadow.hash(state);
1774            }
1775            Self::DrawText {
1776                text,
1777                size,
1778                color,
1779                underline,
1780                wrap,
1781                caret_index,
1782                caret_color,
1783                caret_width,
1784                caret_height,
1785                caret_radius,
1786                paragraph_style,
1787            } => {
1788                2_u8.hash(state);
1789                text.hash(state);
1790                size.to_bits().hash(state);
1791                color.hash(state);
1792                underline.hash(state);
1793                wrap.hash(state);
1794                caret_index.hash(state);
1795                caret_color.hash(state);
1796                caret_width.map(|w| w.to_bits()).hash(state);
1797                caret_height.map(|h| h.to_bits()).hash(state);
1798                caret_radius.map(|r| r.to_bits()).hash(state);
1799                paragraph_style.hash(state);
1800            }
1801            Self::DrawRichText {
1802                runs,
1803                wrap,
1804                caret_index,
1805                caret_color,
1806                caret_width,
1807                caret_height,
1808                caret_radius,
1809                paragraph_style,
1810            } => {
1811                3_u8.hash(state);
1812                runs.hash(state);
1813                wrap.hash(state);
1814                caret_index.hash(state);
1815                caret_color.hash(state);
1816                caret_width.map(|w| w.to_bits()).hash(state);
1817                caret_height.map(|h| h.to_bits()).hash(state);
1818                caret_radius.map(|r| r.to_bits()).hash(state);
1819                paragraph_style.hash(state);
1820            }
1821            Self::DrawImage {
1822                request,
1823                fit,
1824                alignment,
1825            } => {
1826                4_u8.hash(state);
1827                request.hash(state);
1828                fit.hash(state);
1829                alignment.hash(state);
1830            }
1831            Self::DrawPath { path, fill, stroke } => {
1832                5_u8.hash(state);
1833                path.hash(state);
1834                fill.hash(state);
1835                stroke.hash(state);
1836            }
1837            Self::DrawSvg {
1838                content,
1839                fill,
1840                stroke,
1841            } => {
1842                6_u8.hash(state);
1843                content.hash(state);
1844                fill.hash(state);
1845                stroke.hash(state);
1846            }
1847        }
1848    }
1849}
1850
1851#[cfg(test)]
1852mod tests {
1853    use super::{
1854        decode_inline_widget_marker, decode_text_paragraph_style, encode_inline_widget_marker,
1855        encode_text_paragraph_style, HttpHeader, ImageCachePolicy, ImageRequest, ImageSource,
1856        InlineWidgetMarker, TextAlign, TextDirection, TextHeightBehavior, TextOverflow,
1857        TextParagraphStyle, TextWidthBasis, TEXT_PARAGRAPH_MAX_ENCODED_LINES,
1858    };
1859
1860    #[test]
1861    fn paragraph_style_round_trips_alignment_overflow_and_line_cap() {
1862        let style = TextParagraphStyle {
1863            text_align: TextAlign::Justify,
1864            max_lines: Some(3),
1865            overflow: TextOverflow::Fade,
1866            text_direction: TextDirection::Auto,
1867            text_width_basis: TextWidthBasis::Parent,
1868            strut_line_height: None,
1869            text_height_behavior: TextHeightBehavior::default(),
1870        };
1871
1872        let encoded = encode_text_paragraph_style(style);
1873        assert_eq!(decode_text_paragraph_style(encoded), Some(style));
1874    }
1875
1876    #[test]
1877    fn paragraph_style_clamps_line_count_to_precise_encoding_budget() {
1878        let encoded = encode_text_paragraph_style(TextParagraphStyle {
1879            text_align: TextAlign::End,
1880            max_lines: Some(TEXT_PARAGRAPH_MAX_ENCODED_LINES + 99),
1881            overflow: TextOverflow::Ellipsis,
1882            text_direction: TextDirection::Auto,
1883            text_width_basis: TextWidthBasis::Parent,
1884            strut_line_height: None,
1885            text_height_behavior: TextHeightBehavior::default(),
1886        });
1887
1888        assert_eq!(
1889            decode_text_paragraph_style(encoded),
1890            Some(TextParagraphStyle {
1891                text_align: TextAlign::End,
1892                max_lines: Some(TEXT_PARAGRAPH_MAX_ENCODED_LINES),
1893                overflow: TextOverflow::Ellipsis,
1894                text_direction: TextDirection::Auto,
1895                text_width_basis: TextWidthBasis::Parent,
1896                strut_line_height: None,
1897                text_height_behavior: TextHeightBehavior::default(),
1898            })
1899        );
1900    }
1901
1902    #[test]
1903    fn image_request_cache_key_is_stable_and_dimension_sensitive() {
1904        let request = ImageRequest {
1905            source: ImageSource::Network {
1906                url: "https://cdn.example.com/image.webp".into(),
1907                headers: vec![HttpHeader {
1908                    name: "Accept".into(),
1909                    value: "image/webp".into(),
1910                }],
1911                cache_policy: ImageCachePolicy::Default,
1912            },
1913            cache_width: Some(320),
1914            cache_height: Some(180),
1915            ..Default::default()
1916        };
1917
1918        let same = request.clone();
1919        let mut resized = request.clone();
1920        resized.cache_width = Some(640);
1921
1922        assert_eq!(request.stable_cache_key(), same.stable_cache_key());
1923        assert_ne!(request.stable_cache_key(), resized.stable_cache_key());
1924    }
1925
1926    #[test]
1927    fn image_source_helpers_report_path_and_network_sources() {
1928        assert_eq!(
1929            ImageSource::Asset {
1930                path: "assets/logo.png".into()
1931            }
1932            .local_path(),
1933            Some("assets/logo.png")
1934        );
1935        assert_eq!(
1936            ImageSource::Network {
1937                url: "https://example.com/logo.png".into(),
1938                headers: Vec::new(),
1939                cache_policy: ImageCachePolicy::Default,
1940            }
1941            .network_url(),
1942            Some("https://example.com/logo.png")
1943        );
1944    }
1945
1946    #[test]
1947    fn paragraph_style_compact_encoding_rejects_extended_fields() {
1948        assert_eq!(
1949            encode_text_paragraph_style(TextParagraphStyle {
1950                text_align: TextAlign::Start,
1951                max_lines: Some(2),
1952                overflow: TextOverflow::Visible,
1953                text_direction: TextDirection::Rtl,
1954                text_width_basis: TextWidthBasis::LongestLine,
1955                strut_line_height: Some(24.0),
1956                text_height_behavior: TextHeightBehavior {
1957                    apply_height_to_first_ascent: false,
1958                    apply_height_to_last_descent: true,
1959                },
1960            }),
1961            None
1962        );
1963    }
1964
1965    #[test]
1966    fn inline_widget_marker_round_trips() {
1967        let encoded = encode_inline_widget_marker(7, 24.5, 12.0);
1968        assert_eq!(
1969            decode_inline_widget_marker(Some(encoded.as_str())),
1970            Some(InlineWidgetMarker {
1971                id: 7,
1972                width: 24.5,
1973                height: 12.0,
1974            })
1975        );
1976    }
1977}