Skip to main content

compose_rust/
drawing.rs

1//! The closed drawing command set a `Canvas` node carries.
2//!
3//! The Renderer is compiled ahead of time, so drawing code cannot cross the boundary. A
4//! fixed vocabulary of commands does instead. Every command is the same 36 bytes, which is
5//! what lets a reader skip a command it does not know and what keeps the list free of
6//! pointers: a path with many points is registered once as an asset and referenced by id.
7//!
8//! Colour is a [`Paint`], never a raw colour, so a canvas can draw in a `ColorRole` and the
9//! design system still decides what that role looks like.
10
11use crate::protocol::ProtocolError;
12use crate::schema::{Paint, TypeRole};
13use std::rc::Rc;
14
15/// Bytes of one command record: tag, length, paint, then six 32-bit words.
16pub const COMMAND_LEN: usize = 36;
17
18pub const TAG_LINE: u16 = 1;
19pub const TAG_RECT: u16 = 2;
20pub const TAG_ROUND_RECT: u16 = 3;
21pub const TAG_CIRCLE: u16 = 4;
22pub const TAG_ARC: u16 = 5;
23pub const TAG_POLYLINE_REF: u16 = 6;
24pub const TAG_TEXT_AT: u16 = 7;
25
26/// How one of the six words of a command is read back.
27#[derive(Clone, Copy, Debug, Eq, PartialEq)]
28pub enum DrawFieldType {
29    /// A dp measurement, or degrees for an arc.
30    Float,
31    /// An asset id or a string offset.
32    U32,
33    /// A role enum, named so codegen can emit the matching Kotlin type.
34    Role(&'static str),
35}
36
37#[derive(Clone, Copy, Debug, Eq, PartialEq)]
38pub struct DrawFieldSchema {
39    pub name: &'static str,
40    pub ty: DrawFieldType,
41    /// Which of the six words, counting from zero.
42    pub word: u8,
43}
44
45#[derive(Clone, Copy, Debug, Eq, PartialEq)]
46pub struct DrawCommandSchema {
47    pub name: &'static str,
48    pub tag: u16,
49    pub fields: &'static [DrawFieldSchema],
50}
51
52const fn float(name: &'static str, word: u8) -> DrawFieldSchema {
53    DrawFieldSchema {
54        name,
55        ty: DrawFieldType::Float,
56        word,
57    }
58}
59
60const fn unsigned(name: &'static str, word: u8) -> DrawFieldSchema {
61    DrawFieldSchema {
62        name,
63        ty: DrawFieldType::U32,
64        word,
65    }
66}
67
68/// Every command in wire order. Appending is the only allowed edit.
69pub const DRAW_COMMAND_SCHEMA: &[DrawCommandSchema] = &[
70    DrawCommandSchema {
71        name: "Line",
72        tag: TAG_LINE,
73        fields: &[
74            float("x1", 0),
75            float("y1", 1),
76            float("x2", 2),
77            float("y2", 3),
78            float("strokeWidth", 4),
79        ],
80    },
81    DrawCommandSchema {
82        name: "Rect",
83        tag: TAG_RECT,
84        fields: &[
85            float("x", 0),
86            float("y", 1),
87            float("width", 2),
88            float("height", 3),
89            // Zero fills the rectangle rather than stroking an invisible outline.
90            float("strokeWidth", 4),
91        ],
92    },
93    DrawCommandSchema {
94        name: "RoundRect",
95        tag: TAG_ROUND_RECT,
96        fields: &[
97            float("x", 0),
98            float("y", 1),
99            float("width", 2),
100            float("height", 3),
101            float("radius", 4),
102            float("strokeWidth", 5),
103        ],
104    },
105    DrawCommandSchema {
106        name: "Circle",
107        tag: TAG_CIRCLE,
108        fields: &[
109            float("centerX", 0),
110            float("centerY", 1),
111            float("radius", 2),
112            float("strokeWidth", 3),
113        ],
114    },
115    DrawCommandSchema {
116        name: "Arc",
117        tag: TAG_ARC,
118        fields: &[
119            float("centerX", 0),
120            float("centerY", 1),
121            float("radius", 2),
122            float("startDegrees", 3),
123            float("sweepDegrees", 4),
124            float("strokeWidth", 5),
125        ],
126    },
127    DrawCommandSchema {
128        name: "PolylineRef",
129        tag: TAG_POLYLINE_REF,
130        fields: &[unsigned("assetId", 0), float("strokeWidth", 1)],
131    },
132    DrawCommandSchema {
133        name: "TextAt",
134        tag: TAG_TEXT_AT,
135        fields: &[
136            unsigned("textOffset", 0),
137            unsigned("textLength", 1),
138            float("x", 2),
139            float("y", 3),
140            DrawFieldSchema {
141                name: "typeRole",
142                ty: DrawFieldType::Role("TypeRole"),
143                word: 4,
144            },
145        ],
146    },
147];
148
149/// One drawing command. Coordinates are dp from the widget's top-left corner.
150#[derive(Clone, Copy, Debug, PartialEq)]
151pub enum DrawCommand {
152    Line {
153        paint: Paint,
154        x1: f32,
155        y1: f32,
156        x2: f32,
157        y2: f32,
158        stroke_width: f32,
159    },
160    /// A stroke width of zero fills the rectangle.
161    Rect {
162        paint: Paint,
163        x: f32,
164        y: f32,
165        width: f32,
166        height: f32,
167        stroke_width: f32,
168    },
169    RoundRect {
170        paint: Paint,
171        x: f32,
172        y: f32,
173        width: f32,
174        height: f32,
175        radius: f32,
176        stroke_width: f32,
177    },
178    Circle {
179        paint: Paint,
180        center_x: f32,
181        center_y: f32,
182        radius: f32,
183        stroke_width: f32,
184    },
185    Arc {
186        paint: Paint,
187        center_x: f32,
188        center_y: f32,
189        radius: f32,
190        start_degrees: f32,
191        sweep_degrees: f32,
192        stroke_width: f32,
193    },
194    /// Points live in a registered asset, so a path of any length costs one record.
195    PolylineRef {
196        paint: Paint,
197        asset_id: u32,
198        stroke_width: f32,
199    },
200    /// The text bytes follow the command array inside the same list, and the offset is
201    /// measured from the start of that list, so the list decodes on its own.
202    TextAt {
203        paint: Paint,
204        text_offset: u32,
205        text_length: u32,
206        x: f32,
207        y: f32,
208        type_role: TypeRole,
209    },
210}
211
212impl DrawCommand {
213    pub const fn tag(&self) -> u16 {
214        match self {
215            Self::Line { .. } => TAG_LINE,
216            Self::Rect { .. } => TAG_RECT,
217            Self::RoundRect { .. } => TAG_ROUND_RECT,
218            Self::Circle { .. } => TAG_CIRCLE,
219            Self::Arc { .. } => TAG_ARC,
220            Self::PolylineRef { .. } => TAG_POLYLINE_REF,
221            Self::TextAt { .. } => TAG_TEXT_AT,
222        }
223    }
224
225    pub const fn paint(&self) -> Paint {
226        match *self {
227            Self::Line { paint, .. }
228            | Self::Rect { paint, .. }
229            | Self::RoundRect { paint, .. }
230            | Self::Circle { paint, .. }
231            | Self::Arc { paint, .. }
232            | Self::PolylineRef { paint, .. }
233            | Self::TextAt { paint, .. } => paint,
234        }
235    }
236
237    /// The six words, in order, as raw 32-bit patterns.
238    fn words(&self) -> [u32; 6] {
239        let mut words = [0_u32; 6];
240        match *self {
241            Self::Line {
242                x1,
243                y1,
244                x2,
245                y2,
246                stroke_width,
247                ..
248            } => {
249                words[0] = x1.to_bits();
250                words[1] = y1.to_bits();
251                words[2] = x2.to_bits();
252                words[3] = y2.to_bits();
253                words[4] = stroke_width.to_bits();
254            }
255            Self::Rect {
256                x,
257                y,
258                width,
259                height,
260                stroke_width,
261                ..
262            } => {
263                words[0] = x.to_bits();
264                words[1] = y.to_bits();
265                words[2] = width.to_bits();
266                words[3] = height.to_bits();
267                words[4] = stroke_width.to_bits();
268            }
269            Self::RoundRect {
270                x,
271                y,
272                width,
273                height,
274                radius,
275                stroke_width,
276                ..
277            } => {
278                words[0] = x.to_bits();
279                words[1] = y.to_bits();
280                words[2] = width.to_bits();
281                words[3] = height.to_bits();
282                words[4] = radius.to_bits();
283                words[5] = stroke_width.to_bits();
284            }
285            Self::Circle {
286                center_x,
287                center_y,
288                radius,
289                stroke_width,
290                ..
291            } => {
292                words[0] = center_x.to_bits();
293                words[1] = center_y.to_bits();
294                words[2] = radius.to_bits();
295                words[3] = stroke_width.to_bits();
296            }
297            Self::Arc {
298                center_x,
299                center_y,
300                radius,
301                start_degrees,
302                sweep_degrees,
303                stroke_width,
304                ..
305            } => {
306                words[0] = center_x.to_bits();
307                words[1] = center_y.to_bits();
308                words[2] = radius.to_bits();
309                words[3] = start_degrees.to_bits();
310                words[4] = sweep_degrees.to_bits();
311                words[5] = stroke_width.to_bits();
312            }
313            Self::PolylineRef {
314                asset_id,
315                stroke_width,
316                ..
317            } => {
318                words[0] = asset_id;
319                words[1] = stroke_width.to_bits();
320            }
321            Self::TextAt {
322                text_offset,
323                text_length,
324                x,
325                y,
326                type_role,
327                ..
328            } => {
329                words[0] = text_offset;
330                words[1] = text_length;
331                words[2] = x.to_bits();
332                words[3] = y.to_bits();
333                words[4] = u32::from(type_role as u16);
334            }
335        }
336        words
337    }
338
339    fn write_to(&self, output: &mut Vec<u8>) {
340        output.extend_from_slice(&self.tag().to_le_bytes());
341        output.extend_from_slice(&(COMMAND_LEN as u16).to_le_bytes());
342        output.extend_from_slice(&self.paint().to_bits().to_le_bytes());
343        for word in self.words() {
344            output.extend_from_slice(&word.to_le_bytes());
345        }
346    }
347
348    fn read_from(record: &[u8]) -> Result<Self, ProtocolError> {
349        let tag = u16::from_le_bytes([record[0], record[1]]);
350        let length = u16::from_le_bytes([record[2], record[3]]);
351        if usize::from(length) != COMMAND_LEN {
352            return Err(ProtocolError::InvalidRecordLength);
353        }
354        let paint_bits = u64::from_le_bytes(record[4..12].try_into().expect("eight bytes"));
355        let paint = Paint::from_bits(paint_bits).ok_or(ProtocolError::InvalidValueKind(0))?;
356        let word = |index: usize| {
357            let start = 12 + index * 4;
358            u32::from_le_bytes(
359                record[start..start + 4]
360                    .try_into()
361                    .expect("four bytes inside a 36 byte record"),
362            )
363        };
364        let real = |index: usize| f32::from_bits(word(index));
365        Ok(match tag {
366            TAG_LINE => Self::Line {
367                paint,
368                x1: real(0),
369                y1: real(1),
370                x2: real(2),
371                y2: real(3),
372                stroke_width: real(4),
373            },
374            TAG_RECT => Self::Rect {
375                paint,
376                x: real(0),
377                y: real(1),
378                width: real(2),
379                height: real(3),
380                stroke_width: real(4),
381            },
382            TAG_ROUND_RECT => Self::RoundRect {
383                paint,
384                x: real(0),
385                y: real(1),
386                width: real(2),
387                height: real(3),
388                radius: real(4),
389                stroke_width: real(5),
390            },
391            TAG_CIRCLE => Self::Circle {
392                paint,
393                center_x: real(0),
394                center_y: real(1),
395                radius: real(2),
396                stroke_width: real(3),
397            },
398            TAG_ARC => Self::Arc {
399                paint,
400                center_x: real(0),
401                center_y: real(1),
402                radius: real(2),
403                start_degrees: real(3),
404                sweep_degrees: real(4),
405                stroke_width: real(5),
406            },
407            TAG_POLYLINE_REF => Self::PolylineRef {
408                paint,
409                asset_id: word(0),
410                stroke_width: real(1),
411            },
412            TAG_TEXT_AT => {
413                let raw_role = u16::try_from(word(4))
414                    .map_err(|_| ProtocolError::InvalidValueKind(u16::MAX))?;
415                Self::TextAt {
416                    paint,
417                    text_offset: word(0),
418                    text_length: word(1),
419                    x: real(2),
420                    y: real(3),
421                    type_role: TypeRole::try_from(raw_role)
422                        .map_err(|()| ProtocolError::InvalidValueKind(raw_role))?,
423                }
424            }
425            other => return Err(ProtocolError::InvalidTag(other)),
426        })
427    }
428}
429
430/// An encoded drawing command list, ready to go on the wire as one property value.
431///
432/// Cloning shares the bytes, and equality compares them, so a frame that rebuilds the same
433/// list produces no `SetProp` at all: the runtime compares the attribute and skips it.
434#[derive(Clone, Default, PartialEq)]
435pub struct DrawList {
436    bytes: Option<Rc<[u8]>>,
437    view_box: Option<(f32, f32)>,
438}
439
440impl Eq for DrawList {}
441
442impl std::fmt::Debug for DrawList {
443    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
444        formatter
445            .debug_struct("DrawList")
446            .field("bytes", &self.as_bytes().len())
447            .field("view_box", &self.view_box)
448            .finish()
449    }
450}
451
452impl DrawList {
453    pub fn builder() -> DrawListBuilder {
454        DrawListBuilder::default()
455    }
456
457    /// Adopts an already encoded list, which is what the protocol vectors carry.
458    pub fn from_bytes(bytes: impl Into<Rc<[u8]>>) -> Self {
459        Self {
460            bytes: Some(bytes.into()),
461            view_box: None,
462        }
463    }
464
465    /// The reference `(width, height)` coordinate space this list's commands were written
466    /// in, or `None` when coordinates are in dp.
467    pub fn view_box(&self) -> Option<(f32, f32)> {
468        self.view_box
469    }
470
471    pub fn as_bytes(&self) -> &[u8] {
472        self.bytes.as_deref().unwrap_or(&[])
473    }
474
475    pub fn is_empty(&self) -> bool {
476        self.as_bytes().is_empty()
477    }
478
479    /// The commands, in order. Errors if the bytes are not a well-formed list.
480    pub fn decode(&self) -> Result<Vec<DrawCommand>, ProtocolError> {
481        decode_commands(self.as_bytes())
482    }
483
484    /// The string a `TextAt` command points at.
485    pub fn text_at(&self, offset: u32, length: u32) -> Result<&str, ProtocolError> {
486        let bytes = self.as_bytes();
487        let start = usize::try_from(offset).map_err(|_| ProtocolError::LengthOverflow)?;
488        let len = usize::try_from(length).map_err(|_| ProtocolError::LengthOverflow)?;
489        let end = start
490            .checked_add(len)
491            .ok_or(ProtocolError::InvalidStringRange)?;
492        let slice = bytes
493            .get(start..end)
494            .ok_or(ProtocolError::InvalidStringRange)?;
495        std::str::from_utf8(slice).map_err(|_| ProtocolError::InvalidUtf8)
496    }
497}
498
499/// Decodes a command list: `n` fixed-size records followed by the `TextAt` strings.
500pub fn decode_commands(bytes: &[u8]) -> Result<Vec<DrawCommand>, ProtocolError> {
501    let mut commands = Vec::new();
502    let mut position = 0;
503    while position + COMMAND_LEN <= bytes.len() {
504        let record = &bytes[position..position + COMMAND_LEN];
505        // The text region begins where the records stop looking like records. A zero tag
506        // cannot be a command, and neither can the first byte of a UTF-8 string that
507        // happens to sit here, so the list ends at the first record that does not decode.
508        let Ok(command) = DrawCommand::read_from(record) else {
509            break;
510        };
511        commands.push(command);
512        position += COMMAND_LEN;
513    }
514    Ok(commands)
515}
516
517/// Builds a command list. Each call appends one fixed-size record; `TextAt` also appends
518/// its bytes to the text region, which is written after every record.
519#[derive(Debug, Default)]
520pub struct DrawListBuilder {
521    records: Vec<u8>,
522    text: Vec<u8>,
523    view_box: Option<(f32, f32)>,
524}
525
526impl DrawListBuilder {
527    pub fn with_capacity(commands: usize, text_bytes: usize) -> Self {
528        Self {
529            records: Vec::with_capacity(commands * COMMAND_LEN),
530            text: Vec::with_capacity(text_bytes),
531            view_box: None,
532        }
533    }
534
535    /// Interprets command coordinates in a `(width, height)` reference coordinate space
536    /// scaled to the `Canvas` container's measured size. Stroke widths stay in dp.
537    pub fn view_box(mut self, width: f32, height: f32) -> Self {
538        self.view_box = Some((width, height));
539        self
540    }
541
542    /// Interprets command coordinates as `0.0..=1.0` fractions of the `Canvas` container's
543    /// width and height. Stroke widths stay in dp.
544    pub fn fractional(self) -> Self {
545        self.view_box(1.0, 1.0)
546    }
547
548    /// Appends any command. `TextAt` offsets are fixed up by [`DrawListBuilder::build`],
549    /// so prefer [`DrawListBuilder::text_at`] for text.
550    pub fn push(mut self, command: DrawCommand) -> Self {
551        command.write_to(&mut self.records);
552        self
553    }
554
555    pub fn line(self, paint: Paint, x1: f32, y1: f32, x2: f32, y2: f32, stroke_width: f32) -> Self {
556        self.push(DrawCommand::Line {
557            paint,
558            x1,
559            y1,
560            x2,
561            y2,
562            stroke_width,
563        })
564    }
565
566    /// A stroke width of zero fills the rectangle.
567    pub fn rect(
568        self,
569        paint: Paint,
570        x: f32,
571        y: f32,
572        width: f32,
573        height: f32,
574        stroke_width: f32,
575    ) -> Self {
576        self.push(DrawCommand::Rect {
577            paint,
578            x,
579            y,
580            width,
581            height,
582            stroke_width,
583        })
584    }
585
586    #[allow(clippy::too_many_arguments)]
587    pub fn round_rect(
588        self,
589        paint: Paint,
590        x: f32,
591        y: f32,
592        width: f32,
593        height: f32,
594        radius: f32,
595        stroke_width: f32,
596    ) -> Self {
597        self.push(DrawCommand::RoundRect {
598            paint,
599            x,
600            y,
601            width,
602            height,
603            radius,
604            stroke_width,
605        })
606    }
607
608    pub fn circle(
609        self,
610        paint: Paint,
611        center_x: f32,
612        center_y: f32,
613        radius: f32,
614        stroke_width: f32,
615    ) -> Self {
616        self.push(DrawCommand::Circle {
617            paint,
618            center_x,
619            center_y,
620            radius,
621            stroke_width,
622        })
623    }
624
625    #[allow(clippy::too_many_arguments)]
626    pub fn arc(
627        self,
628        paint: Paint,
629        center_x: f32,
630        center_y: f32,
631        radius: f32,
632        start_degrees: f32,
633        sweep_degrees: f32,
634        stroke_width: f32,
635    ) -> Self {
636        self.push(DrawCommand::Arc {
637            paint,
638            center_x,
639            center_y,
640            radius,
641            start_degrees,
642            sweep_degrees,
643            stroke_width,
644        })
645    }
646
647    /// Draws a registered point list. The asset holds the points, so the record stays the
648    /// same size however long the path is.
649    pub fn polyline_ref(self, paint: Paint, asset_id: u32, stroke_width: f32) -> Self {
650        self.push(DrawCommand::PolylineRef {
651            paint,
652            asset_id,
653            stroke_width,
654        })
655    }
656
657    pub fn text_at(
658        mut self,
659        paint: Paint,
660        text: &str,
661        x: f32,
662        y: f32,
663        type_role: TypeRole,
664    ) -> Self {
665        // Relative to the text region while building; `build` adds the record region's
666        // length so the offset ends up relative to the whole list.
667        let offset = self.text.len() as u32;
668        self.text.extend_from_slice(text.as_bytes());
669        self.push(DrawCommand::TextAt {
670            paint,
671            text_offset: offset,
672            text_length: text.len() as u32,
673            x,
674            y,
675            type_role,
676        })
677    }
678
679    pub fn build(mut self) -> DrawList {
680        let records_len = self.records.len() as u32;
681        let mut position = 0;
682        while position + COMMAND_LEN <= self.records.len() {
683            if u16::from_le_bytes([self.records[position], self.records[position + 1]])
684                == TAG_TEXT_AT
685            {
686                let field = position + 12;
687                let relative = u32::from_le_bytes(
688                    self.records[field..field + 4]
689                        .try_into()
690                        .expect("four bytes inside a 36 byte record"),
691                );
692                self.records[field..field + 4]
693                    .copy_from_slice(&relative.saturating_add(records_len).to_le_bytes());
694            }
695            position += COMMAND_LEN;
696        }
697        self.records.append(&mut self.text);
698        let mut list = DrawList::from_bytes(self.records);
699        list.view_box = self.view_box;
700        list
701    }
702}
703
704#[cfg(test)]
705mod tests {
706    use super::*;
707    use crate::schema::{Color, ColorRole};
708
709    #[test]
710    fn fr17_every_command_round_trips_in_a_fixed_length_record() {
711        let list = DrawList::builder()
712            .line(Paint::Role(ColorRole::Primary), 1.0, 2.0, 3.0, 4.0, 1.5)
713            .rect(
714                Paint::Literal(Color::rgb(0x1b1b1f)),
715                0.0,
716                0.0,
717                8.0,
718                9.0,
719                0.0,
720            )
721            .round_rect(
722                Paint::Role(ColorRole::Surface),
723                1.0,
724                2.0,
725                3.0,
726                4.0,
727                5.0,
728                6.0,
729            )
730            .circle(Paint::Role(ColorRole::Error), 4.0, 5.0, 6.0, 0.5)
731            .arc(
732                Paint::Role(ColorRole::Outline),
733                1.0,
734                2.0,
735                3.0,
736                0.0,
737                90.0,
738                2.0,
739            )
740            .polyline_ref(Paint::Role(ColorRole::Secondary), 42, 3.0)
741            .text_at(
742                Paint::Role(ColorRole::OnSurface),
743                "한글",
744                7.0,
745                8.0,
746                TypeRole::Label,
747            )
748            .build();
749
750        let commands = list.decode().unwrap();
751
752        assert_eq!(commands.len(), DRAW_COMMAND_SCHEMA.len());
753        assert_eq!(
754            list.as_bytes().len(),
755            DRAW_COMMAND_SCHEMA.len() * COMMAND_LEN + "한글".len(),
756        );
757        for (command, schema) in commands.iter().zip(DRAW_COMMAND_SCHEMA) {
758            assert_eq!(command.tag(), schema.tag, "{} is out of order", schema.name);
759        }
760    }
761
762    #[test]
763    fn fr17_text_offsets_are_relative_to_the_list_itself() {
764        let list = DrawList::builder()
765            .circle(Paint::Role(ColorRole::Primary), 0.0, 0.0, 1.0, 0.0)
766            .text_at(
767                Paint::Role(ColorRole::OnSurface),
768                "hi",
769                1.0,
770                2.0,
771                TypeRole::Body,
772            )
773            .text_at(
774                Paint::Role(ColorRole::OnSurface),
775                "there",
776                3.0,
777                4.0,
778                TypeRole::Body,
779            )
780            .build();
781
782        let texts: Vec<_> = list
783            .decode()
784            .unwrap()
785            .into_iter()
786            .filter_map(|command| match command {
787                DrawCommand::TextAt {
788                    text_offset,
789                    text_length,
790                    ..
791                } => Some(list.text_at(text_offset, text_length).unwrap().to_owned()),
792                _ => None,
793            })
794            .collect();
795
796        assert_eq!(texts, ["hi", "there"]);
797    }
798
799    #[test]
800    fn fr17_paint_carries_a_color_role_into_the_canvas() {
801        let list = DrawList::builder()
802            .line(Paint::Role(ColorRole::Primary), 0.0, 0.0, 1.0, 1.0, 1.0)
803            .build();
804
805        assert_eq!(
806            list.decode().unwrap()[0].paint(),
807            Paint::Role(ColorRole::Primary),
808        );
809    }
810
811    #[test]
812    fn fr17_two_identical_lists_compare_equal() {
813        let build = || {
814            DrawList::builder()
815                .rect(Paint::Role(ColorRole::Surface), 0.0, 0.0, 4.0, 4.0, 0.0)
816                .build()
817        };
818
819        assert_eq!(build(), build());
820    }
821}