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    Transform {
1032        transform: [f32; 16],
1033    },
1034    Clip {
1035        path: Option<String>,
1036    },
1037}
1038
1039impl std::hash::Hash for LayoutOp {
1040    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
1041        let hash_unit = |u: LayoutUnit, h: &mut H| u.to_bits().hash(h);
1042        let hash_opt_unit = |u: Option<LayoutUnit>, h: &mut H| u.map(|v| v.to_bits()).hash(h);
1043        let hash_units = |us: [LayoutUnit; 4], h: &mut H| {
1044            for u in us {
1045                u.to_bits().hash(h);
1046            }
1047        };
1048
1049        match self {
1050            Self::Box {
1051                width,
1052                height,
1053                min_width,
1054                max_width,
1055                min_height,
1056                max_height,
1057                padding,
1058                flex_grow,
1059                flex_shrink,
1060                aspect_ratio,
1061            } => {
1062                0.hash(state);
1063                hash_opt_unit(*width, state);
1064                hash_opt_unit(*height, state);
1065                hash_opt_unit(*min_width, state);
1066                hash_opt_unit(*max_width, state);
1067                hash_opt_unit(*min_height, state);
1068                hash_opt_unit(*max_height, state);
1069                hash_units(*padding, state);
1070                hash_unit(*flex_grow, state);
1071                hash_unit(*flex_shrink, state);
1072                aspect_ratio.map(|f| f.to_bits()).hash(state);
1073            }
1074            Self::StyledBox {
1075                style,
1076                flex_grow,
1077                flex_shrink,
1078            } => {
1079                13.hash(state);
1080                style.hash(state);
1081                hash_unit(*flex_grow, state);
1082                hash_unit(*flex_shrink, state);
1083            }
1084            Self::Flex {
1085                direction,
1086                wrap,
1087                flex_grow,
1088                flex_shrink,
1089                padding,
1090                gap,
1091                align_items,
1092                justify_content,
1093            } => {
1094                1.hash(state);
1095                direction.hash(state);
1096                wrap.hash(state);
1097                hash_unit(*flex_grow, state);
1098                hash_unit(*flex_shrink, state);
1099                hash_units(*padding, state);
1100                hash_opt_unit(*gap, state);
1101                align_items.hash(state);
1102                justify_content.hash(state);
1103            }
1104            Self::Grid {
1105                columns,
1106                rows,
1107                column_gap,
1108                row_gap,
1109                padding,
1110            } => {
1111                2.hash(state);
1112                columns.hash(state);
1113                rows.hash(state);
1114                hash_opt_unit(*column_gap, state);
1115                hash_opt_unit(*row_gap, state);
1116                hash_units(*padding, state);
1117            }
1118            Self::GridItem {
1119                row_start,
1120                row_end,
1121                col_start,
1122                col_end,
1123            } => {
1124                3.hash(state);
1125                row_start.hash(state);
1126                row_end.hash(state);
1127                col_start.hash(state);
1128                col_end.hash(state);
1129            }
1130            Self::Responsive { query, cases } => {
1131                14.hash(state);
1132                query.hash(state);
1133                cases.hash(state);
1134            }
1135            Self::Scroll {
1136                direction,
1137                show_scrollbar,
1138                width,
1139                height,
1140                min_width,
1141                max_width,
1142                min_height,
1143                max_height,
1144                padding,
1145                flex_grow,
1146                flex_shrink,
1147            } => {
1148                4.hash(state);
1149                direction.hash(state);
1150                show_scrollbar.hash(state);
1151                hash_opt_unit(*width, state);
1152                hash_opt_unit(*height, state);
1153                hash_opt_unit(*min_width, state);
1154                hash_opt_unit(*max_width, state);
1155                hash_opt_unit(*min_height, state);
1156                hash_opt_unit(*max_height, state);
1157                hash_units(*padding, state);
1158                hash_unit(*flex_grow, state);
1159                hash_unit(*flex_shrink, state);
1160            }
1161            Self::Embed {
1162                kind,
1163                widget_id,
1164                width,
1165                height,
1166            } => {
1167                5.hash(state);
1168                kind.hash(state);
1169                widget_id.hash(state);
1170                hash_opt_unit(*width, state);
1171                hash_opt_unit(*height, state);
1172            }
1173            Self::AbsoluteFill => {
1174                6.hash(state);
1175            }
1176            Self::Positioned {
1177                left,
1178                top,
1179                right,
1180                bottom,
1181                width,
1182                height,
1183            } => {
1184                7.hash(state);
1185                hash_opt_unit(*left, state);
1186                hash_opt_unit(*top, state);
1187                hash_opt_unit(*right, state);
1188                hash_opt_unit(*bottom, state);
1189                hash_opt_unit(*width, state);
1190                hash_opt_unit(*height, state);
1191            }
1192            Self::PositionedLengths {
1193                left,
1194                top,
1195                right,
1196                bottom,
1197                width,
1198                height,
1199            } => {
1200                15.hash(state);
1201                left.hash(state);
1202                top.hash(state);
1203                right.hash(state);
1204                bottom.hash(state);
1205                width.hash(state);
1206                height.hash(state);
1207            }
1208            Self::ZStack => {
1209                8.hash(state);
1210            }
1211            Self::Align => {
1212                9.hash(state);
1213            }
1214            Self::Flyout { anchor, content } => {
1215                10.hash(state);
1216                anchor.hash(state);
1217                content.hash(state);
1218            }
1219            Self::Transform { transform } => {
1220                11.hash(state);
1221                for v in transform {
1222                    v.to_bits().hash(state);
1223                }
1224            }
1225            Self::Clip { path } => {
1226                12.hash(state);
1227                path.hash(state);
1228            }
1229        }
1230    }
1231}
1232
1233#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Hash)]
1234pub struct Color {
1235    pub r: u8,
1236    pub g: u8,
1237    pub b: u8,
1238    pub a: u8,
1239}
1240
1241impl Color {
1242    pub const TRANSPARENT: Self = Self {
1243        r: 0,
1244        g: 0,
1245        b: 0,
1246        a: 0,
1247    };
1248    pub const BLACK: Self = Self {
1249        r: 0,
1250        g: 0,
1251        b: 0,
1252        a: 255,
1253    };
1254    pub const WHITE: Self = Self {
1255        r: 255,
1256        g: 255,
1257        b: 255,
1258        a: 255,
1259    };
1260    pub const RED: Self = Self {
1261        r: 255,
1262        g: 0,
1263        b: 0,
1264        a: 255,
1265    };
1266    pub const GREEN: Self = Self {
1267        r: 0,
1268        g: 255,
1269        b: 0,
1270        a: 255,
1271    };
1272    pub const BLUE: Self = Self {
1273        r: 0,
1274        g: 0,
1275        b: 255,
1276        a: 255,
1277    };
1278
1279    pub fn with_alpha(mut self, a: u8) -> Self {
1280        self.a = a;
1281        self
1282    }
1283}
1284
1285#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1286pub enum Fill {
1287    Solid(Color),
1288    LinearGradient {
1289        start: (f32, f32),
1290        end: (f32, f32),
1291        stops: Vec<(f32, Color)>,
1292    },
1293    RadialGradient {
1294        center: (f32, f32),
1295        radius: f32,
1296        stops: Vec<(f32, Color)>,
1297    },
1298}
1299
1300impl std::hash::Hash for Fill {
1301    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
1302        match self {
1303            Self::Solid(c) => {
1304                0.hash(state);
1305                c.hash(state);
1306            }
1307            Self::LinearGradient { start, end, stops } => {
1308                1.hash(state);
1309                start.0.to_bits().hash(state);
1310                start.1.to_bits().hash(state);
1311                end.0.to_bits().hash(state);
1312                end.1.to_bits().hash(state);
1313                for (off, c) in stops {
1314                    off.to_bits().hash(state);
1315                    c.hash(state);
1316                }
1317            }
1318            Self::RadialGradient {
1319                center,
1320                radius,
1321                stops,
1322            } => {
1323                2.hash(state);
1324                center.0.to_bits().hash(state);
1325                center.1.to_bits().hash(state);
1326                radius.to_bits().hash(state);
1327                for (off, c) in stops {
1328                    off.to_bits().hash(state);
1329                    c.hash(state);
1330                }
1331            }
1332        }
1333    }
1334}
1335
1336#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1337pub enum LineCap {
1338    Butt,
1339    Round,
1340    Square,
1341}
1342
1343#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1344pub enum LineJoin {
1345    Miter,
1346    Round,
1347    Bevel,
1348}
1349
1350#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1351pub struct Stroke {
1352    pub fill: Fill,
1353    pub width: LayoutUnit,
1354    pub dash_array: Option<Vec<f32>>,
1355    pub line_cap: LineCap,
1356    pub line_join: LineJoin,
1357}
1358
1359impl std::hash::Hash for Stroke {
1360    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
1361        self.fill.hash(state);
1362        self.width.to_bits().hash(state);
1363        if let Some(da) = &self.dash_array {
1364            1.hash(state);
1365            for d in da {
1366                d.to_bits().hash(state);
1367            }
1368        } else {
1369            0.hash(state);
1370        }
1371        self.line_cap.hash(state);
1372        self.line_join.hash(state);
1373    }
1374}
1375
1376#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
1377pub struct BoxShadow {
1378    pub color: Color,
1379    pub blur_radius: LayoutUnit,
1380    /// Positive values expand the shadow shape; negative values contract it.
1381    pub spread_radius: LayoutUnit,
1382    pub offset: (LayoutUnit, LayoutUnit),
1383    /// Draws the shadow inside the shape instead of behind it.
1384    pub inset: bool,
1385}
1386
1387impl std::hash::Hash for BoxShadow {
1388    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
1389        self.color.hash(state);
1390        self.blur_radius.to_bits().hash(state);
1391        self.spread_radius.to_bits().hash(state);
1392        self.offset.0.to_bits().hash(state);
1393        self.offset.1.to_bits().hash(state);
1394        self.inset.hash(state);
1395    }
1396}
1397
1398#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Hash)]
1399pub enum ImageFit {
1400    Contain,
1401    Cover,
1402    Fill,
1403    None,
1404}
1405
1406#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash, Default)]
1407pub enum ImageAlignment {
1408    TopStart,
1409    TopCenter,
1410    TopEnd,
1411    CenterStart,
1412    #[default]
1413    Center,
1414    CenterEnd,
1415    BottomStart,
1416    BottomCenter,
1417    BottomEnd,
1418}
1419
1420#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)]
1421pub struct HttpHeader {
1422    pub name: String,
1423    pub value: String,
1424}
1425
1426#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash, Default)]
1427pub enum ImageCachePolicy {
1428    #[default]
1429    Default,
1430    Reload,
1431    MemoryOnly,
1432    Disk,
1433    NoStore,
1434}
1435
1436#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)]
1437pub enum ImageSource {
1438    Asset {
1439        path: String,
1440    },
1441    File {
1442        path: String,
1443    },
1444    Network {
1445        url: String,
1446        #[serde(default)]
1447        headers: Vec<HttpHeader>,
1448        #[serde(default)]
1449        cache_policy: ImageCachePolicy,
1450    },
1451    Memory {
1452        bytes: Vec<u8>,
1453        #[serde(default)]
1454        mime_type: Option<String>,
1455    },
1456    SvgText {
1457        content: String,
1458    },
1459}
1460
1461impl Default for ImageSource {
1462    fn default() -> Self {
1463        Self::Asset {
1464            path: String::new(),
1465        }
1466    }
1467}
1468
1469#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash, Default)]
1470pub enum ImageLoadingBehavior {
1471    #[default]
1472    Empty,
1473    ThemePlaceholder,
1474    BlurHash(String),
1475}
1476
1477#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash, Default)]
1478pub enum ImageErrorBehavior {
1479    #[default]
1480    Empty,
1481    ThemeError,
1482    AltText,
1483}
1484
1485#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash, Default)]
1486pub struct ImageRequest {
1487    pub source: ImageSource,
1488    #[serde(default)]
1489    pub cache_width: Option<u32>,
1490    #[serde(default)]
1491    pub cache_height: Option<u32>,
1492    #[serde(default)]
1493    pub semantic_label: Option<String>,
1494    #[serde(default)]
1495    pub loading: ImageLoadingBehavior,
1496    #[serde(default)]
1497    pub error: ImageErrorBehavior,
1498}
1499
1500impl ImageSource {
1501    pub fn stable_identity(&self) -> String {
1502        match self {
1503            Self::Asset { path } => format!("asset:{path}"),
1504            Self::File { path } => format!("file:{path}"),
1505            Self::Network {
1506                url,
1507                headers,
1508                cache_policy,
1509            } => {
1510                let mut identity = format!("network:{cache_policy:?}:{url}");
1511                for header in headers {
1512                    identity.push('|');
1513                    identity.push_str(&header.name.to_ascii_lowercase());
1514                    identity.push('=');
1515                    identity.push_str(&header.value);
1516                }
1517                identity
1518            }
1519            Self::Memory { bytes, mime_type } => {
1520                let digest = blake3::hash(bytes);
1521                format!("memory:{}:{digest}", mime_type.as_deref().unwrap_or(""))
1522            }
1523            Self::SvgText { content } => {
1524                let digest = blake3::hash(content.as_bytes());
1525                format!("svg:{digest}")
1526            }
1527        }
1528    }
1529
1530    pub fn local_path(&self) -> Option<&str> {
1531        match self {
1532            Self::Asset { path } | Self::File { path } => Some(path),
1533            _ => None,
1534        }
1535    }
1536
1537    pub fn network_url(&self) -> Option<&str> {
1538        match self {
1539            Self::Network { url, .. } => Some(url),
1540            _ => None,
1541        }
1542    }
1543}
1544
1545impl ImageRequest {
1546    pub fn stable_cache_key(&self) -> String {
1547        let mut hasher = blake3::Hasher::new();
1548        hasher.update(self.source.stable_identity().as_bytes());
1549        hasher.update(&self.cache_width.unwrap_or_default().to_le_bytes());
1550        hasher.update(&self.cache_height.unwrap_or_default().to_le_bytes());
1551        hasher.finalize().to_hex().to_string()
1552    }
1553}
1554
1555#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1556pub struct TextStyle {
1557    pub font_size: LayoutUnit,
1558    pub color: Color,
1559    pub underline: bool,
1560    #[serde(default)]
1561    pub font_family: Option<String>,
1562    #[serde(default)]
1563    pub locale: Option<String>,
1564    #[serde(default = "text_weight_default")]
1565    pub font_weight: u16,
1566    #[serde(default)]
1567    pub font_style: FontStyle,
1568    #[serde(default)]
1569    pub line_height: Option<LayoutUnit>,
1570    #[serde(default)]
1571    pub letter_spacing: LayoutUnit,
1572    /// Optional background highlight color for this run (find matches, error squiggles, etc.).
1573    pub background_color: Option<Color>,
1574}
1575
1576impl std::hash::Hash for TextStyle {
1577    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
1578        self.font_size.to_bits().hash(state);
1579        self.color.hash(state);
1580        self.underline.hash(state);
1581        self.font_family.hash(state);
1582        self.locale.hash(state);
1583        self.font_weight.hash(state);
1584        self.font_style.hash(state);
1585        self.line_height.map(f32::to_bits).hash(state);
1586        self.letter_spacing.to_bits().hash(state);
1587        self.background_color.hash(state);
1588    }
1589}
1590
1591#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
1592pub enum FontStyle {
1593    #[default]
1594    Normal,
1595    Italic,
1596}
1597
1598const fn text_weight_default() -> u16 {
1599    400
1600}
1601
1602#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Hash)]
1603pub struct TextRun {
1604    pub text: String,
1605    pub style: TextStyle,
1606}
1607
1608#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)]
1609pub struct RichTextAnnotation {
1610    pub range: std::ops::Range<usize>,
1611    #[serde(default)]
1612    pub semantics_label: Option<String>,
1613    #[serde(default)]
1614    pub semantics_identifier: Option<String>,
1615    #[serde(default)]
1616    pub spell_out: Option<bool>,
1617    #[serde(default)]
1618    pub mouse_cursor: Option<MouseCursor>,
1619    #[serde(default)]
1620    pub actions: Vec<ActionEntry>,
1621}
1622
1623pub const INLINE_WIDGET_MARKER_PREFIX: &str = "__fission_inline_widget__:";
1624
1625#[derive(Debug, Clone, Copy, PartialEq)]
1626pub struct InlineWidgetMarker {
1627    pub id: u64,
1628    pub width: LayoutUnit,
1629    pub height: LayoutUnit,
1630}
1631
1632pub fn encode_inline_widget_marker(id: u64, width: LayoutUnit, height: LayoutUnit) -> String {
1633    format!("{INLINE_WIDGET_MARKER_PREFIX}{id}:{width}:{height}")
1634}
1635
1636pub fn decode_inline_widget_marker(family: Option<&str>) -> Option<InlineWidgetMarker> {
1637    let family = family?;
1638    let encoded = family.strip_prefix(INLINE_WIDGET_MARKER_PREFIX)?;
1639    let mut parts = encoded.split(':');
1640    let id = parts.next()?.parse().ok()?;
1641    let width = parts.next()?.parse().ok()?;
1642    let height = parts.next()?.parse().ok()?;
1643    if parts.next().is_some() {
1644        return None;
1645    }
1646    Some(InlineWidgetMarker { id, width, height })
1647}
1648
1649const fn text_wrap_default() -> bool {
1650    true
1651}
1652
1653/// A filter applied to content already painted behind a widget.
1654#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
1655pub enum BackdropFilter {
1656    /// Applies a Gaussian blur using the supplied standard deviation.
1657    Blur(LayoutUnit),
1658}
1659
1660impl std::hash::Hash for BackdropFilter {
1661    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
1662        match self {
1663            Self::Blur(sigma) => {
1664                0_u8.hash(state);
1665                sigma.to_bits().hash(state);
1666            }
1667        }
1668    }
1669}
1670
1671#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1672pub enum PaintOp {
1673    BackdropFilter {
1674        filter: BackdropFilter,
1675        corner_radius: LayoutUnit,
1676    },
1677    DrawRect {
1678        fill: Option<Fill>,
1679        stroke: Option<Stroke>,
1680        corner_radius: LayoutUnit,
1681        shadow: Option<BoxShadow>,
1682    },
1683    DrawText {
1684        text: String,
1685        size: LayoutUnit,
1686        color: Color,
1687        underline: bool,
1688        #[serde(default = "text_wrap_default")]
1689        wrap: bool,
1690        caret_index: Option<usize>,
1691        #[serde(default)]
1692        caret_color: Option<Color>,
1693        #[serde(default)]
1694        caret_width: Option<LayoutUnit>,
1695        #[serde(default)]
1696        caret_height: Option<LayoutUnit>,
1697        #[serde(default)]
1698        caret_radius: Option<LayoutUnit>,
1699        #[serde(default)]
1700        paragraph_style: Option<TextParagraphStyle>,
1701    },
1702    DrawRichText {
1703        runs: Vec<TextRun>,
1704        #[serde(default = "text_wrap_default")]
1705        wrap: bool,
1706        caret_index: Option<usize>,
1707        #[serde(default)]
1708        caret_color: Option<Color>,
1709        #[serde(default)]
1710        caret_width: Option<LayoutUnit>,
1711        #[serde(default)]
1712        caret_height: Option<LayoutUnit>,
1713        #[serde(default)]
1714        caret_radius: Option<LayoutUnit>,
1715        #[serde(default)]
1716        paragraph_style: Option<TextParagraphStyle>,
1717    },
1718    DrawImage {
1719        request: ImageRequest,
1720        fit: ImageFit,
1721        alignment: ImageAlignment,
1722    },
1723    DrawPath {
1724        path: String,
1725        fill: Option<Fill>,
1726        stroke: Option<Stroke>,
1727    },
1728    DrawSvg {
1729        content: String,
1730        fill: Option<Fill>,
1731        stroke: Option<Stroke>,
1732    },
1733}
1734
1735impl std::hash::Hash for PaintOp {
1736    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
1737        match self {
1738            Self::BackdropFilter {
1739                filter,
1740                corner_radius,
1741            } => {
1742                0_u8.hash(state);
1743                filter.hash(state);
1744                corner_radius.to_bits().hash(state);
1745            }
1746            Self::DrawRect {
1747                fill,
1748                stroke,
1749                corner_radius,
1750                shadow,
1751            } => {
1752                1_u8.hash(state);
1753                fill.hash(state);
1754                stroke.hash(state);
1755                corner_radius.to_bits().hash(state);
1756                shadow.hash(state);
1757            }
1758            Self::DrawText {
1759                text,
1760                size,
1761                color,
1762                underline,
1763                wrap,
1764                caret_index,
1765                caret_color,
1766                caret_width,
1767                caret_height,
1768                caret_radius,
1769                paragraph_style,
1770            } => {
1771                2_u8.hash(state);
1772                text.hash(state);
1773                size.to_bits().hash(state);
1774                color.hash(state);
1775                underline.hash(state);
1776                wrap.hash(state);
1777                caret_index.hash(state);
1778                caret_color.hash(state);
1779                caret_width.map(|w| w.to_bits()).hash(state);
1780                caret_height.map(|h| h.to_bits()).hash(state);
1781                caret_radius.map(|r| r.to_bits()).hash(state);
1782                paragraph_style.hash(state);
1783            }
1784            Self::DrawRichText {
1785                runs,
1786                wrap,
1787                caret_index,
1788                caret_color,
1789                caret_width,
1790                caret_height,
1791                caret_radius,
1792                paragraph_style,
1793            } => {
1794                3_u8.hash(state);
1795                runs.hash(state);
1796                wrap.hash(state);
1797                caret_index.hash(state);
1798                caret_color.hash(state);
1799                caret_width.map(|w| w.to_bits()).hash(state);
1800                caret_height.map(|h| h.to_bits()).hash(state);
1801                caret_radius.map(|r| r.to_bits()).hash(state);
1802                paragraph_style.hash(state);
1803            }
1804            Self::DrawImage {
1805                request,
1806                fit,
1807                alignment,
1808            } => {
1809                4_u8.hash(state);
1810                request.hash(state);
1811                fit.hash(state);
1812                alignment.hash(state);
1813            }
1814            Self::DrawPath { path, fill, stroke } => {
1815                5_u8.hash(state);
1816                path.hash(state);
1817                fill.hash(state);
1818                stroke.hash(state);
1819            }
1820            Self::DrawSvg {
1821                content,
1822                fill,
1823                stroke,
1824            } => {
1825                6_u8.hash(state);
1826                content.hash(state);
1827                fill.hash(state);
1828                stroke.hash(state);
1829            }
1830        }
1831    }
1832}
1833
1834#[cfg(test)]
1835mod tests {
1836    use super::{
1837        decode_inline_widget_marker, decode_text_paragraph_style, encode_inline_widget_marker,
1838        encode_text_paragraph_style, HttpHeader, ImageCachePolicy, ImageRequest, ImageSource,
1839        InlineWidgetMarker, TextAlign, TextDirection, TextHeightBehavior, TextOverflow,
1840        TextParagraphStyle, TextWidthBasis, TEXT_PARAGRAPH_MAX_ENCODED_LINES,
1841    };
1842
1843    #[test]
1844    fn paragraph_style_round_trips_alignment_overflow_and_line_cap() {
1845        let style = TextParagraphStyle {
1846            text_align: TextAlign::Justify,
1847            max_lines: Some(3),
1848            overflow: TextOverflow::Fade,
1849            text_direction: TextDirection::Auto,
1850            text_width_basis: TextWidthBasis::Parent,
1851            strut_line_height: None,
1852            text_height_behavior: TextHeightBehavior::default(),
1853        };
1854
1855        let encoded = encode_text_paragraph_style(style);
1856        assert_eq!(decode_text_paragraph_style(encoded), Some(style));
1857    }
1858
1859    #[test]
1860    fn paragraph_style_clamps_line_count_to_precise_encoding_budget() {
1861        let encoded = encode_text_paragraph_style(TextParagraphStyle {
1862            text_align: TextAlign::End,
1863            max_lines: Some(TEXT_PARAGRAPH_MAX_ENCODED_LINES + 99),
1864            overflow: TextOverflow::Ellipsis,
1865            text_direction: TextDirection::Auto,
1866            text_width_basis: TextWidthBasis::Parent,
1867            strut_line_height: None,
1868            text_height_behavior: TextHeightBehavior::default(),
1869        });
1870
1871        assert_eq!(
1872            decode_text_paragraph_style(encoded),
1873            Some(TextParagraphStyle {
1874                text_align: TextAlign::End,
1875                max_lines: Some(TEXT_PARAGRAPH_MAX_ENCODED_LINES),
1876                overflow: TextOverflow::Ellipsis,
1877                text_direction: TextDirection::Auto,
1878                text_width_basis: TextWidthBasis::Parent,
1879                strut_line_height: None,
1880                text_height_behavior: TextHeightBehavior::default(),
1881            })
1882        );
1883    }
1884
1885    #[test]
1886    fn image_request_cache_key_is_stable_and_dimension_sensitive() {
1887        let request = ImageRequest {
1888            source: ImageSource::Network {
1889                url: "https://cdn.example.com/image.webp".into(),
1890                headers: vec![HttpHeader {
1891                    name: "Accept".into(),
1892                    value: "image/webp".into(),
1893                }],
1894                cache_policy: ImageCachePolicy::Default,
1895            },
1896            cache_width: Some(320),
1897            cache_height: Some(180),
1898            ..Default::default()
1899        };
1900
1901        let same = request.clone();
1902        let mut resized = request.clone();
1903        resized.cache_width = Some(640);
1904
1905        assert_eq!(request.stable_cache_key(), same.stable_cache_key());
1906        assert_ne!(request.stable_cache_key(), resized.stable_cache_key());
1907    }
1908
1909    #[test]
1910    fn image_source_helpers_report_path_and_network_sources() {
1911        assert_eq!(
1912            ImageSource::Asset {
1913                path: "assets/logo.png".into()
1914            }
1915            .local_path(),
1916            Some("assets/logo.png")
1917        );
1918        assert_eq!(
1919            ImageSource::Network {
1920                url: "https://example.com/logo.png".into(),
1921                headers: Vec::new(),
1922                cache_policy: ImageCachePolicy::Default,
1923            }
1924            .network_url(),
1925            Some("https://example.com/logo.png")
1926        );
1927    }
1928
1929    #[test]
1930    fn paragraph_style_compact_encoding_rejects_extended_fields() {
1931        assert_eq!(
1932            encode_text_paragraph_style(TextParagraphStyle {
1933                text_align: TextAlign::Start,
1934                max_lines: Some(2),
1935                overflow: TextOverflow::Visible,
1936                text_direction: TextDirection::Rtl,
1937                text_width_basis: TextWidthBasis::LongestLine,
1938                strut_line_height: Some(24.0),
1939                text_height_behavior: TextHeightBehavior {
1940                    apply_height_to_first_ascent: false,
1941                    apply_height_to_last_descent: true,
1942                },
1943            }),
1944            None
1945        );
1946    }
1947
1948    #[test]
1949    fn inline_widget_marker_round_trips() {
1950        let encoded = encode_inline_widget_marker(7, 24.5, 12.0);
1951        assert_eq!(
1952            decode_inline_widget_marker(Some(encoded.as_str())),
1953            Some(InlineWidgetMarker {
1954                id: 7,
1955                width: 24.5,
1956                height: 12.0,
1957            })
1958        );
1959    }
1960}