Skip to main content

fission_ir/
op.rs

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