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 animation_target: Option<WidgetId>,
47}
48
49impl CompositeScalar {
50    pub fn new(base: f32) -> Self {
51        Self {
52            base,
53            animation_target: None,
54        }
55    }
56
57    pub fn animated(mut self, target: WidgetId) -> Self {
58        self.animation_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.animation_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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash, Default)]
84pub enum TextAlign {
85    Left,
86    Right,
87    Center,
88    Justify,
89    #[default]
90    Start,
91    End,
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash, Default)]
95pub enum TextOverflow {
96    Clip,
97    Ellipsis,
98    Fade,
99    #[default]
100    Visible,
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash, Default)]
104pub enum TextDirection {
105    #[default]
106    Auto,
107    Ltr,
108    Rtl,
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash, Default)]
112pub enum TextWidthBasis {
113    #[default]
114    Parent,
115    LongestLine,
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash, Default)]
119pub enum MouseCursor {
120    #[default]
121    Basic,
122    Pointer,
123    Text,
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
127pub struct TextHeightBehavior {
128    pub apply_height_to_first_ascent: bool,
129    pub apply_height_to_last_descent: bool,
130}
131
132impl Default for TextHeightBehavior {
133    fn default() -> Self {
134        Self {
135            apply_height_to_first_ascent: true,
136            apply_height_to_last_descent: true,
137        }
138    }
139}
140
141#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
142pub struct TextParagraphStyle {
143    pub text_align: TextAlign,
144    pub max_lines: Option<usize>,
145    pub overflow: TextOverflow,
146    #[serde(default)]
147    pub text_direction: TextDirection,
148    #[serde(default)]
149    pub text_width_basis: TextWidthBasis,
150    #[serde(default)]
151    pub strut_line_height: Option<LayoutUnit>,
152    #[serde(default)]
153    pub text_height_behavior: TextHeightBehavior,
154}
155
156impl PartialEq for TextParagraphStyle {
157    fn eq(&self, other: &Self) -> bool {
158        self.text_align == other.text_align
159            && self.max_lines == other.max_lines
160            && self.overflow == other.overflow
161            && self.text_direction == other.text_direction
162            && self.text_width_basis == other.text_width_basis
163            && self.strut_line_height.map(f32::to_bits) == other.strut_line_height.map(f32::to_bits)
164            && self.text_height_behavior == other.text_height_behavior
165    }
166}
167
168impl Eq for TextParagraphStyle {}
169
170impl std::hash::Hash for TextParagraphStyle {
171    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
172        self.text_align.hash(state);
173        self.max_lines.hash(state);
174        self.overflow.hash(state);
175        self.text_direction.hash(state);
176        self.text_width_basis.hash(state);
177        self.strut_line_height.map(f32::to_bits).hash(state);
178        self.text_height_behavior.hash(state);
179    }
180}
181
182const TEXT_PARAGRAPH_ALIGN_BITS: u32 = 0b111;
183const TEXT_PARAGRAPH_OVERFLOW_BITS: u32 = 0b111 << 3;
184const TEXT_PARAGRAPH_MAX_LINES_SHIFT: u32 = 6;
185const TEXT_PARAGRAPH_SENTINEL: u32 = 1;
186const TEXT_PARAGRAPH_MAX_ENCODED_LINES: usize = ((1 << 24) - 1) >> TEXT_PARAGRAPH_MAX_LINES_SHIFT;
187
188const fn text_align_code(align: TextAlign) -> u32 {
189    match align {
190        TextAlign::Start => 0,
191        TextAlign::Left => 1,
192        TextAlign::Center => 2,
193        TextAlign::Right => 3,
194        TextAlign::End => 4,
195        TextAlign::Justify => 5,
196    }
197}
198
199const fn text_overflow_code(overflow: TextOverflow) -> u32 {
200    match overflow {
201        TextOverflow::Visible => 0,
202        TextOverflow::Clip => 1,
203        TextOverflow::Ellipsis => 2,
204        TextOverflow::Fade => 3,
205    }
206}
207
208const fn decode_text_align(code: u32) -> TextAlign {
209    match code {
210        1 => TextAlign::Left,
211        2 => TextAlign::Center,
212        3 => TextAlign::Right,
213        4 => TextAlign::End,
214        5 => TextAlign::Justify,
215        _ => TextAlign::Start,
216    }
217}
218
219const fn decode_text_overflow(code: u32) -> TextOverflow {
220    match code {
221        1 => TextOverflow::Clip,
222        2 => TextOverflow::Ellipsis,
223        3 => TextOverflow::Fade,
224        _ => TextOverflow::Visible,
225    }
226}
227
228pub fn encode_text_paragraph_style(style: TextParagraphStyle) -> Option<LayoutUnit> {
229    if style == TextParagraphStyle::default() {
230        return None;
231    }
232    if style.text_direction != TextDirection::Auto
233        || style.text_width_basis != TextWidthBasis::Parent
234        || style.strut_line_height.is_some()
235        || style.text_height_behavior != TextHeightBehavior::default()
236    {
237        return None;
238    }
239
240    let max_lines = style
241        .max_lines
242        .unwrap_or(0)
243        .min(TEXT_PARAGRAPH_MAX_ENCODED_LINES) as u32;
244    let encoded = TEXT_PARAGRAPH_SENTINEL
245        + text_align_code(style.text_align)
246        + (text_overflow_code(style.overflow) << 3)
247        + (max_lines << TEXT_PARAGRAPH_MAX_LINES_SHIFT);
248
249    Some(-(encoded as LayoutUnit))
250}
251
252pub fn decode_text_paragraph_style(
253    encoded_width: Option<LayoutUnit>,
254) -> Option<TextParagraphStyle> {
255    let encoded_width = encoded_width?;
256    if !encoded_width.is_finite() || encoded_width >= 0.0 {
257        return None;
258    }
259
260    let raw = (-encoded_width).round();
261    if raw < TEXT_PARAGRAPH_SENTINEL as f32 {
262        return None;
263    }
264
265    let bits = raw as u32 - TEXT_PARAGRAPH_SENTINEL;
266    let text_align = decode_text_align(bits & TEXT_PARAGRAPH_ALIGN_BITS);
267    let overflow = decode_text_overflow((bits & TEXT_PARAGRAPH_OVERFLOW_BITS) >> 3);
268    let max_lines = match bits >> TEXT_PARAGRAPH_MAX_LINES_SHIFT {
269        0 => None,
270        lines => Some(lines as usize),
271    };
272
273    Some(TextParagraphStyle {
274        text_align,
275        max_lines,
276        overflow,
277        text_direction: TextDirection::Auto,
278        text_width_basis: TextWidthBasis::Parent,
279        strut_line_height: None,
280        text_height_behavior: TextHeightBehavior::default(),
281    })
282}
283
284#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Hash)]
285pub enum FlexDirection {
286    Row,
287    Column,
288}
289
290impl Default for FlexDirection {
291    fn default() -> Self {
292        FlexDirection::Row
293    }
294}
295
296#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Hash)]
297pub enum EmbedKind {
298    Video,
299    Web,
300    Custom(Vec<u8>),
301}
302
303#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
304pub enum GridTrack {
305    Points(LayoutUnit),
306    Percent(f32),
307    Fr(f32),
308    Auto,
309    MinContent,
310    MaxContent,
311}
312
313impl std::hash::Hash for GridTrack {
314    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
315        match self {
316            Self::Points(u) => {
317                0.hash(state);
318                u.to_bits().hash(state);
319            }
320            Self::Percent(f) => {
321                1.hash(state);
322                f.to_bits().hash(state);
323            }
324            Self::Fr(f) => {
325                2.hash(state);
326                f.to_bits().hash(state);
327            }
328            Self::Auto => {
329                3.hash(state);
330            }
331            Self::MinContent => {
332                4.hash(state);
333            }
334            Self::MaxContent => {
335                5.hash(state);
336            }
337        }
338    }
339}
340
341#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Hash)]
342pub enum GridPlacement {
343    Auto,
344    Line(i16),
345    Span(u16),
346}
347
348impl Default for GridPlacement {
349    fn default() -> Self {
350        Self::Auto
351    }
352}
353
354#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Hash)]
355pub enum FlexWrap {
356    NoWrap,
357    Wrap,
358    WrapReverse,
359}
360
361impl Default for FlexWrap {
362    fn default() -> Self {
363        FlexWrap::NoWrap
364    }
365}
366
367#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Hash)]
368pub enum AlignItems {
369    Start,
370    End,
371    Center,
372    Stretch,
373    Baseline,
374}
375
376impl Default for AlignItems {
377    fn default() -> Self {
378        AlignItems::Stretch
379    }
380}
381
382#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Hash)]
383pub enum JustifyContent {
384    Start,
385    End,
386    Center,
387    SpaceBetween,
388    SpaceAround,
389    SpaceEvenly,
390}
391
392impl Default for JustifyContent {
393    fn default() -> Self {
394        JustifyContent::Start
395    }
396}
397
398#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
399pub enum LayoutOp {
400    Box {
401        width: Option<LayoutUnit>,
402        height: Option<LayoutUnit>,
403        min_width: Option<LayoutUnit>,
404        max_width: Option<LayoutUnit>,
405        min_height: Option<LayoutUnit>,
406        max_height: Option<LayoutUnit>,
407        padding: [LayoutUnit; 4],
408        flex_grow: LayoutUnit,
409        flex_shrink: LayoutUnit,
410        aspect_ratio: Option<f32>,
411    },
412    Flex {
413        direction: FlexDirection,
414        wrap: FlexWrap,
415        flex_grow: LayoutUnit,
416        flex_shrink: LayoutUnit,
417        padding: [LayoutUnit; 4],
418        gap: Option<LayoutUnit>,
419        align_items: AlignItems,
420        justify_content: JustifyContent,
421    },
422    Grid {
423        columns: Vec<GridTrack>,
424        rows: Vec<GridTrack>,
425        column_gap: Option<LayoutUnit>,
426        row_gap: Option<LayoutUnit>,
427        padding: [LayoutUnit; 4],
428    },
429    GridItem {
430        row_start: GridPlacement,
431        row_end: GridPlacement,
432        col_start: GridPlacement,
433        col_end: GridPlacement,
434    },
435    Scroll {
436        direction: FlexDirection,
437        show_scrollbar: bool,
438        width: Option<LayoutUnit>,
439        height: Option<LayoutUnit>,
440        min_width: Option<LayoutUnit>,
441        max_width: Option<LayoutUnit>,
442        min_height: Option<LayoutUnit>,
443        max_height: Option<LayoutUnit>,
444        padding: [LayoutUnit; 4],
445        flex_grow: LayoutUnit,
446        flex_shrink: LayoutUnit,
447    },
448    Embed {
449        kind: EmbedKind,
450        widget_id: WidgetId,
451        width: Option<LayoutUnit>,
452        height: Option<LayoutUnit>,
453    },
454    AbsoluteFill,
455    Positioned {
456        left: Option<LayoutUnit>,
457        top: Option<LayoutUnit>,
458        right: Option<LayoutUnit>,
459        bottom: Option<LayoutUnit>,
460        width: Option<LayoutUnit>,
461        height: Option<LayoutUnit>,
462    },
463    ZStack,
464    Align,
465    Flyout {
466        anchor: WidgetId,
467        content: WidgetId,
468    },
469    Transform {
470        transform: [f32; 16],
471    },
472    Clip {
473        path: Option<String>,
474    },
475}
476
477impl std::hash::Hash for LayoutOp {
478    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
479        let hash_unit = |u: LayoutUnit, h: &mut H| u.to_bits().hash(h);
480        let hash_opt_unit = |u: Option<LayoutUnit>, h: &mut H| u.map(|v| v.to_bits()).hash(h);
481        let hash_units = |us: [LayoutUnit; 4], h: &mut H| {
482            for u in us {
483                u.to_bits().hash(h);
484            }
485        };
486
487        match self {
488            Self::Box {
489                width,
490                height,
491                min_width,
492                max_width,
493                min_height,
494                max_height,
495                padding,
496                flex_grow,
497                flex_shrink,
498                aspect_ratio,
499            } => {
500                0.hash(state);
501                hash_opt_unit(*width, state);
502                hash_opt_unit(*height, state);
503                hash_opt_unit(*min_width, state);
504                hash_opt_unit(*max_width, state);
505                hash_opt_unit(*min_height, state);
506                hash_opt_unit(*max_height, state);
507                hash_units(*padding, state);
508                hash_unit(*flex_grow, state);
509                hash_unit(*flex_shrink, state);
510                aspect_ratio.map(|f| f.to_bits()).hash(state);
511            }
512            Self::Flex {
513                direction,
514                wrap,
515                flex_grow,
516                flex_shrink,
517                padding,
518                gap,
519                align_items,
520                justify_content,
521            } => {
522                1.hash(state);
523                direction.hash(state);
524                wrap.hash(state);
525                hash_unit(*flex_grow, state);
526                hash_unit(*flex_shrink, state);
527                hash_units(*padding, state);
528                hash_opt_unit(*gap, state);
529                align_items.hash(state);
530                justify_content.hash(state);
531            }
532            Self::Grid {
533                columns,
534                rows,
535                column_gap,
536                row_gap,
537                padding,
538            } => {
539                2.hash(state);
540                columns.hash(state);
541                rows.hash(state);
542                hash_opt_unit(*column_gap, state);
543                hash_opt_unit(*row_gap, state);
544                hash_units(*padding, state);
545            }
546            Self::GridItem {
547                row_start,
548                row_end,
549                col_start,
550                col_end,
551            } => {
552                3.hash(state);
553                row_start.hash(state);
554                row_end.hash(state);
555                col_start.hash(state);
556                col_end.hash(state);
557            }
558            Self::Scroll {
559                direction,
560                show_scrollbar,
561                width,
562                height,
563                min_width,
564                max_width,
565                min_height,
566                max_height,
567                padding,
568                flex_grow,
569                flex_shrink,
570            } => {
571                4.hash(state);
572                direction.hash(state);
573                show_scrollbar.hash(state);
574                hash_opt_unit(*width, state);
575                hash_opt_unit(*height, state);
576                hash_opt_unit(*min_width, state);
577                hash_opt_unit(*max_width, state);
578                hash_opt_unit(*min_height, state);
579                hash_opt_unit(*max_height, state);
580                hash_units(*padding, state);
581                hash_unit(*flex_grow, state);
582                hash_unit(*flex_shrink, state);
583            }
584            Self::Embed {
585                kind,
586                widget_id,
587                width,
588                height,
589            } => {
590                5.hash(state);
591                kind.hash(state);
592                widget_id.hash(state);
593                hash_opt_unit(*width, state);
594                hash_opt_unit(*height, state);
595            }
596            Self::AbsoluteFill => {
597                6.hash(state);
598            }
599            Self::Positioned {
600                left,
601                top,
602                right,
603                bottom,
604                width,
605                height,
606            } => {
607                7.hash(state);
608                hash_opt_unit(*left, state);
609                hash_opt_unit(*top, state);
610                hash_opt_unit(*right, state);
611                hash_opt_unit(*bottom, state);
612                hash_opt_unit(*width, state);
613                hash_opt_unit(*height, state);
614            }
615            Self::ZStack => {
616                8.hash(state);
617            }
618            Self::Align => {
619                9.hash(state);
620            }
621            Self::Flyout { anchor, content } => {
622                10.hash(state);
623                anchor.hash(state);
624                content.hash(state);
625            }
626            Self::Transform { transform } => {
627                11.hash(state);
628                for v in transform {
629                    v.to_bits().hash(state);
630                }
631            }
632            Self::Clip { path } => {
633                12.hash(state);
634                path.hash(state);
635            }
636        }
637    }
638}
639
640#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Hash)]
641pub struct Color {
642    pub r: u8,
643    pub g: u8,
644    pub b: u8,
645    pub a: u8,
646}
647
648impl Color {
649    pub const BLACK: Self = Self {
650        r: 0,
651        g: 0,
652        b: 0,
653        a: 255,
654    };
655    pub const WHITE: Self = Self {
656        r: 255,
657        g: 255,
658        b: 255,
659        a: 255,
660    };
661    pub const RED: Self = Self {
662        r: 255,
663        g: 0,
664        b: 0,
665        a: 255,
666    };
667    pub const GREEN: Self = Self {
668        r: 0,
669        g: 255,
670        b: 0,
671        a: 255,
672    };
673    pub const BLUE: Self = Self {
674        r: 0,
675        g: 0,
676        b: 255,
677        a: 255,
678    };
679
680    pub fn with_alpha(mut self, a: u8) -> Self {
681        self.a = a;
682        self
683    }
684}
685
686#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
687pub enum Fill {
688    Solid(Color),
689    LinearGradient {
690        start: (f32, f32),
691        end: (f32, f32),
692        stops: Vec<(f32, Color)>,
693    },
694    RadialGradient {
695        center: (f32, f32),
696        radius: f32,
697        stops: Vec<(f32, Color)>,
698    },
699}
700
701impl std::hash::Hash for Fill {
702    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
703        match self {
704            Self::Solid(c) => {
705                0.hash(state);
706                c.hash(state);
707            }
708            Self::LinearGradient { start, end, stops } => {
709                1.hash(state);
710                start.0.to_bits().hash(state);
711                start.1.to_bits().hash(state);
712                end.0.to_bits().hash(state);
713                end.1.to_bits().hash(state);
714                for (off, c) in stops {
715                    off.to_bits().hash(state);
716                    c.hash(state);
717                }
718            }
719            Self::RadialGradient {
720                center,
721                radius,
722                stops,
723            } => {
724                2.hash(state);
725                center.0.to_bits().hash(state);
726                center.1.to_bits().hash(state);
727                radius.to_bits().hash(state);
728                for (off, c) in stops {
729                    off.to_bits().hash(state);
730                    c.hash(state);
731                }
732            }
733        }
734    }
735}
736
737#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
738pub enum LineCap {
739    Butt,
740    Round,
741    Square,
742}
743
744#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
745pub enum LineJoin {
746    Miter,
747    Round,
748    Bevel,
749}
750
751#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
752pub struct Stroke {
753    pub fill: Fill,
754    pub width: LayoutUnit,
755    pub dash_array: Option<Vec<f32>>,
756    pub line_cap: LineCap,
757    pub line_join: LineJoin,
758}
759
760impl std::hash::Hash for Stroke {
761    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
762        self.fill.hash(state);
763        self.width.to_bits().hash(state);
764        if let Some(da) = &self.dash_array {
765            1.hash(state);
766            for d in da {
767                d.to_bits().hash(state);
768            }
769        } else {
770            0.hash(state);
771        }
772        self.line_cap.hash(state);
773        self.line_join.hash(state);
774    }
775}
776
777#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
778pub struct BoxShadow {
779    pub color: Color,
780    pub blur_radius: LayoutUnit,
781    pub offset: (LayoutUnit, LayoutUnit),
782}
783
784impl std::hash::Hash for BoxShadow {
785    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
786        self.color.hash(state);
787        self.blur_radius.to_bits().hash(state);
788        self.offset.0.to_bits().hash(state);
789        self.offset.1.to_bits().hash(state);
790    }
791}
792
793#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Hash)]
794pub enum ImageFit {
795    Contain,
796    Cover,
797    Fill,
798    None,
799}
800
801#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash, Default)]
802pub enum ImageAlignment {
803    TopStart,
804    TopCenter,
805    TopEnd,
806    CenterStart,
807    #[default]
808    Center,
809    CenterEnd,
810    BottomStart,
811    BottomCenter,
812    BottomEnd,
813}
814
815#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)]
816pub struct HttpHeader {
817    pub name: String,
818    pub value: String,
819}
820
821#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash, Default)]
822pub enum ImageCachePolicy {
823    #[default]
824    Default,
825    Reload,
826    MemoryOnly,
827    Disk,
828    NoStore,
829}
830
831#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)]
832pub enum ImageSource {
833    Asset {
834        path: String,
835    },
836    File {
837        path: String,
838    },
839    Network {
840        url: String,
841        #[serde(default)]
842        headers: Vec<HttpHeader>,
843        #[serde(default)]
844        cache_policy: ImageCachePolicy,
845    },
846    Memory {
847        bytes: Vec<u8>,
848        #[serde(default)]
849        mime_type: Option<String>,
850    },
851    SvgText {
852        content: String,
853    },
854}
855
856impl Default for ImageSource {
857    fn default() -> Self {
858        Self::Asset {
859            path: String::new(),
860        }
861    }
862}
863
864#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash, Default)]
865pub enum ImageLoadingBehavior {
866    #[default]
867    Empty,
868    ThemePlaceholder,
869    BlurHash(String),
870}
871
872#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash, Default)]
873pub enum ImageErrorBehavior {
874    #[default]
875    Empty,
876    ThemeError,
877    AltText,
878}
879
880#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash, Default)]
881pub struct ImageRequest {
882    pub source: ImageSource,
883    #[serde(default)]
884    pub cache_width: Option<u32>,
885    #[serde(default)]
886    pub cache_height: Option<u32>,
887    #[serde(default)]
888    pub semantic_label: Option<String>,
889    #[serde(default)]
890    pub loading: ImageLoadingBehavior,
891    #[serde(default)]
892    pub error: ImageErrorBehavior,
893}
894
895impl ImageSource {
896    pub fn stable_identity(&self) -> String {
897        match self {
898            Self::Asset { path } => format!("asset:{path}"),
899            Self::File { path } => format!("file:{path}"),
900            Self::Network {
901                url,
902                headers,
903                cache_policy,
904            } => {
905                let mut identity = format!("network:{cache_policy:?}:{url}");
906                for header in headers {
907                    identity.push('|');
908                    identity.push_str(&header.name.to_ascii_lowercase());
909                    identity.push('=');
910                    identity.push_str(&header.value);
911                }
912                identity
913            }
914            Self::Memory { bytes, mime_type } => {
915                let digest = blake3::hash(bytes);
916                format!("memory:{}:{digest}", mime_type.as_deref().unwrap_or(""))
917            }
918            Self::SvgText { content } => {
919                let digest = blake3::hash(content.as_bytes());
920                format!("svg:{digest}")
921            }
922        }
923    }
924
925    pub fn local_path(&self) -> Option<&str> {
926        match self {
927            Self::Asset { path } | Self::File { path } => Some(path),
928            _ => None,
929        }
930    }
931
932    pub fn network_url(&self) -> Option<&str> {
933        match self {
934            Self::Network { url, .. } => Some(url),
935            _ => None,
936        }
937    }
938}
939
940impl ImageRequest {
941    pub fn stable_cache_key(&self) -> String {
942        let mut hasher = blake3::Hasher::new();
943        hasher.update(self.source.stable_identity().as_bytes());
944        hasher.update(&self.cache_width.unwrap_or_default().to_le_bytes());
945        hasher.update(&self.cache_height.unwrap_or_default().to_le_bytes());
946        hasher.finalize().to_hex().to_string()
947    }
948}
949
950#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
951pub struct TextStyle {
952    pub font_size: LayoutUnit,
953    pub color: Color,
954    pub underline: bool,
955    #[serde(default)]
956    pub font_family: Option<String>,
957    #[serde(default)]
958    pub locale: Option<String>,
959    #[serde(default = "text_weight_default")]
960    pub font_weight: u16,
961    #[serde(default)]
962    pub font_style: FontStyle,
963    #[serde(default)]
964    pub line_height: Option<LayoutUnit>,
965    #[serde(default)]
966    pub letter_spacing: LayoutUnit,
967    /// Optional background highlight color for this run (find matches, error squiggles, etc.).
968    pub background_color: Option<Color>,
969}
970
971impl std::hash::Hash for TextStyle {
972    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
973        self.font_size.to_bits().hash(state);
974        self.color.hash(state);
975        self.underline.hash(state);
976        self.font_family.hash(state);
977        self.locale.hash(state);
978        self.font_weight.hash(state);
979        self.font_style.hash(state);
980        self.line_height.map(f32::to_bits).hash(state);
981        self.letter_spacing.to_bits().hash(state);
982        self.background_color.hash(state);
983    }
984}
985
986#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
987pub enum FontStyle {
988    #[default]
989    Normal,
990    Italic,
991}
992
993const fn text_weight_default() -> u16 {
994    400
995}
996
997#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Hash)]
998pub struct TextRun {
999    pub text: String,
1000    pub style: TextStyle,
1001}
1002
1003#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)]
1004pub struct RichTextAnnotation {
1005    pub range: std::ops::Range<usize>,
1006    #[serde(default)]
1007    pub semantics_label: Option<String>,
1008    #[serde(default)]
1009    pub semantics_identifier: Option<String>,
1010    #[serde(default)]
1011    pub spell_out: Option<bool>,
1012    #[serde(default)]
1013    pub mouse_cursor: Option<MouseCursor>,
1014    #[serde(default)]
1015    pub actions: Vec<ActionEntry>,
1016}
1017
1018pub const INLINE_WIDGET_MARKER_PREFIX: &str = "__fission_inline_widget__:";
1019
1020#[derive(Debug, Clone, Copy, PartialEq)]
1021pub struct InlineWidgetMarker {
1022    pub id: u64,
1023    pub width: LayoutUnit,
1024    pub height: LayoutUnit,
1025}
1026
1027pub fn encode_inline_widget_marker(id: u64, width: LayoutUnit, height: LayoutUnit) -> String {
1028    format!("{INLINE_WIDGET_MARKER_PREFIX}{id}:{width}:{height}")
1029}
1030
1031pub fn decode_inline_widget_marker(family: Option<&str>) -> Option<InlineWidgetMarker> {
1032    let family = family?;
1033    let encoded = family.strip_prefix(INLINE_WIDGET_MARKER_PREFIX)?;
1034    let mut parts = encoded.split(':');
1035    let id = parts.next()?.parse().ok()?;
1036    let width = parts.next()?.parse().ok()?;
1037    let height = parts.next()?.parse().ok()?;
1038    if parts.next().is_some() {
1039        return None;
1040    }
1041    Some(InlineWidgetMarker { id, width, height })
1042}
1043
1044const fn text_wrap_default() -> bool {
1045    true
1046}
1047
1048#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1049pub enum PaintOp {
1050    DrawRect {
1051        fill: Option<Fill>,
1052        stroke: Option<Stroke>,
1053        corner_radius: LayoutUnit,
1054        shadow: Option<BoxShadow>,
1055    },
1056    DrawText {
1057        text: String,
1058        size: LayoutUnit,
1059        color: Color,
1060        underline: bool,
1061        #[serde(default = "text_wrap_default")]
1062        wrap: bool,
1063        caret_index: Option<usize>,
1064        #[serde(default)]
1065        caret_color: Option<Color>,
1066        #[serde(default)]
1067        caret_width: Option<LayoutUnit>,
1068        #[serde(default)]
1069        caret_height: Option<LayoutUnit>,
1070        #[serde(default)]
1071        caret_radius: Option<LayoutUnit>,
1072        #[serde(default)]
1073        paragraph_style: Option<TextParagraphStyle>,
1074    },
1075    DrawRichText {
1076        runs: Vec<TextRun>,
1077        #[serde(default = "text_wrap_default")]
1078        wrap: bool,
1079        caret_index: Option<usize>,
1080        #[serde(default)]
1081        caret_color: Option<Color>,
1082        #[serde(default)]
1083        caret_width: Option<LayoutUnit>,
1084        #[serde(default)]
1085        caret_height: Option<LayoutUnit>,
1086        #[serde(default)]
1087        caret_radius: Option<LayoutUnit>,
1088        #[serde(default)]
1089        paragraph_style: Option<TextParagraphStyle>,
1090    },
1091    DrawImage {
1092        request: ImageRequest,
1093        fit: ImageFit,
1094        alignment: ImageAlignment,
1095    },
1096    DrawPath {
1097        path: String,
1098        fill: Option<Fill>,
1099        stroke: Option<Stroke>,
1100    },
1101    DrawSvg {
1102        content: String,
1103        fill: Option<Fill>,
1104        stroke: Option<Stroke>,
1105    },
1106}
1107
1108impl std::hash::Hash for PaintOp {
1109    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
1110        match self {
1111            Self::DrawRect {
1112                fill,
1113                stroke,
1114                corner_radius,
1115                shadow,
1116            } => {
1117                0.hash(state);
1118                fill.hash(state);
1119                stroke.hash(state);
1120                corner_radius.to_bits().hash(state);
1121                shadow.hash(state);
1122            }
1123            Self::DrawText {
1124                text,
1125                size,
1126                color,
1127                underline,
1128                wrap,
1129                caret_index,
1130                caret_color,
1131                caret_width,
1132                caret_height,
1133                caret_radius,
1134                paragraph_style,
1135            } => {
1136                1.hash(state);
1137                text.hash(state);
1138                size.to_bits().hash(state);
1139                color.hash(state);
1140                underline.hash(state);
1141                wrap.hash(state);
1142                caret_index.hash(state);
1143                caret_color.hash(state);
1144                caret_width.map(|w| w.to_bits()).hash(state);
1145                caret_height.map(|h| h.to_bits()).hash(state);
1146                caret_radius.map(|r| r.to_bits()).hash(state);
1147                paragraph_style.hash(state);
1148            }
1149            Self::DrawRichText {
1150                runs,
1151                wrap,
1152                caret_index,
1153                caret_color,
1154                caret_width,
1155                caret_height,
1156                caret_radius,
1157                paragraph_style,
1158            } => {
1159                2.hash(state);
1160                runs.hash(state);
1161                wrap.hash(state);
1162                caret_index.hash(state);
1163                caret_color.hash(state);
1164                caret_width.map(|w| w.to_bits()).hash(state);
1165                caret_height.map(|h| h.to_bits()).hash(state);
1166                caret_radius.map(|r| r.to_bits()).hash(state);
1167                paragraph_style.hash(state);
1168            }
1169            Self::DrawImage {
1170                request,
1171                fit,
1172                alignment,
1173            } => {
1174                3.hash(state);
1175                request.hash(state);
1176                fit.hash(state);
1177                alignment.hash(state);
1178            }
1179            Self::DrawPath { path, fill, stroke } => {
1180                4.hash(state);
1181                path.hash(state);
1182                fill.hash(state);
1183                stroke.hash(state);
1184            }
1185            Self::DrawSvg {
1186                content,
1187                fill,
1188                stroke,
1189            } => {
1190                5.hash(state);
1191                content.hash(state);
1192                fill.hash(state);
1193                stroke.hash(state);
1194            }
1195        }
1196    }
1197}
1198
1199#[cfg(test)]
1200mod tests {
1201    use super::{
1202        decode_inline_widget_marker, decode_text_paragraph_style, encode_inline_widget_marker,
1203        encode_text_paragraph_style, HttpHeader, ImageCachePolicy, ImageRequest, ImageSource,
1204        InlineWidgetMarker, TextAlign, TextDirection, TextHeightBehavior, TextOverflow,
1205        TextParagraphStyle, TextWidthBasis, TEXT_PARAGRAPH_MAX_ENCODED_LINES,
1206    };
1207
1208    #[test]
1209    fn paragraph_style_round_trips_alignment_overflow_and_line_cap() {
1210        let style = TextParagraphStyle {
1211            text_align: TextAlign::Justify,
1212            max_lines: Some(3),
1213            overflow: TextOverflow::Fade,
1214            text_direction: TextDirection::Auto,
1215            text_width_basis: TextWidthBasis::Parent,
1216            strut_line_height: None,
1217            text_height_behavior: TextHeightBehavior::default(),
1218        };
1219
1220        let encoded = encode_text_paragraph_style(style);
1221        assert_eq!(decode_text_paragraph_style(encoded), Some(style));
1222    }
1223
1224    #[test]
1225    fn paragraph_style_clamps_line_count_to_precise_encoding_budget() {
1226        let encoded = encode_text_paragraph_style(TextParagraphStyle {
1227            text_align: TextAlign::End,
1228            max_lines: Some(TEXT_PARAGRAPH_MAX_ENCODED_LINES + 99),
1229            overflow: TextOverflow::Ellipsis,
1230            text_direction: TextDirection::Auto,
1231            text_width_basis: TextWidthBasis::Parent,
1232            strut_line_height: None,
1233            text_height_behavior: TextHeightBehavior::default(),
1234        });
1235
1236        assert_eq!(
1237            decode_text_paragraph_style(encoded),
1238            Some(TextParagraphStyle {
1239                text_align: TextAlign::End,
1240                max_lines: Some(TEXT_PARAGRAPH_MAX_ENCODED_LINES),
1241                overflow: TextOverflow::Ellipsis,
1242                text_direction: TextDirection::Auto,
1243                text_width_basis: TextWidthBasis::Parent,
1244                strut_line_height: None,
1245                text_height_behavior: TextHeightBehavior::default(),
1246            })
1247        );
1248    }
1249
1250    #[test]
1251    fn image_request_cache_key_is_stable_and_dimension_sensitive() {
1252        let request = ImageRequest {
1253            source: ImageSource::Network {
1254                url: "https://cdn.example.com/image.webp".into(),
1255                headers: vec![HttpHeader {
1256                    name: "Accept".into(),
1257                    value: "image/webp".into(),
1258                }],
1259                cache_policy: ImageCachePolicy::Default,
1260            },
1261            cache_width: Some(320),
1262            cache_height: Some(180),
1263            ..Default::default()
1264        };
1265
1266        let same = request.clone();
1267        let mut resized = request.clone();
1268        resized.cache_width = Some(640);
1269
1270        assert_eq!(request.stable_cache_key(), same.stable_cache_key());
1271        assert_ne!(request.stable_cache_key(), resized.stable_cache_key());
1272    }
1273
1274    #[test]
1275    fn image_source_helpers_report_path_and_network_sources() {
1276        assert_eq!(
1277            ImageSource::Asset {
1278                path: "assets/logo.png".into()
1279            }
1280            .local_path(),
1281            Some("assets/logo.png")
1282        );
1283        assert_eq!(
1284            ImageSource::Network {
1285                url: "https://example.com/logo.png".into(),
1286                headers: Vec::new(),
1287                cache_policy: ImageCachePolicy::Default,
1288            }
1289            .network_url(),
1290            Some("https://example.com/logo.png")
1291        );
1292    }
1293
1294    #[test]
1295    fn paragraph_style_compact_encoding_rejects_extended_fields() {
1296        assert_eq!(
1297            encode_text_paragraph_style(TextParagraphStyle {
1298                text_align: TextAlign::Start,
1299                max_lines: Some(2),
1300                overflow: TextOverflow::Visible,
1301                text_direction: TextDirection::Rtl,
1302                text_width_basis: TextWidthBasis::LongestLine,
1303                strut_line_height: Some(24.0),
1304                text_height_behavior: TextHeightBehavior {
1305                    apply_height_to_first_ascent: false,
1306                    apply_height_to_last_descent: true,
1307                },
1308            }),
1309            None
1310        );
1311    }
1312
1313    #[test]
1314    fn inline_widget_marker_round_trips() {
1315        let encoded = encode_inline_widget_marker(7, 24.5, 12.0);
1316        assert_eq!(
1317            decode_inline_widget_marker(Some(encoded.as_str())),
1318            Some(InlineWidgetMarker {
1319                id: 7,
1320                width: 24.5,
1321                height: 12.0,
1322            })
1323        );
1324    }
1325}