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, Eq, PartialEq)]
435pub struct DrawList {
436    bytes: Option<Rc<[u8]>>,
437}
438
439impl std::fmt::Debug for DrawList {
440    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
441        formatter
442            .debug_struct("DrawList")
443            .field("bytes", &self.as_bytes().len())
444            .finish()
445    }
446}
447
448impl DrawList {
449    pub fn builder() -> DrawListBuilder {
450        DrawListBuilder::default()
451    }
452
453    /// Adopts an already encoded list, which is what the protocol vectors carry.
454    pub fn from_bytes(bytes: impl Into<Rc<[u8]>>) -> Self {
455        Self {
456            bytes: Some(bytes.into()),
457        }
458    }
459
460    pub fn as_bytes(&self) -> &[u8] {
461        self.bytes.as_deref().unwrap_or(&[])
462    }
463
464    pub fn is_empty(&self) -> bool {
465        self.as_bytes().is_empty()
466    }
467
468    /// The commands, in order. Errors if the bytes are not a well-formed list.
469    pub fn decode(&self) -> Result<Vec<DrawCommand>, ProtocolError> {
470        decode_commands(self.as_bytes())
471    }
472
473    /// The string a `TextAt` command points at.
474    pub fn text_at(&self, offset: u32, length: u32) -> Result<&str, ProtocolError> {
475        let bytes = self.as_bytes();
476        let start = usize::try_from(offset).map_err(|_| ProtocolError::LengthOverflow)?;
477        let len = usize::try_from(length).map_err(|_| ProtocolError::LengthOverflow)?;
478        let end = start
479            .checked_add(len)
480            .ok_or(ProtocolError::InvalidStringRange)?;
481        let slice = bytes
482            .get(start..end)
483            .ok_or(ProtocolError::InvalidStringRange)?;
484        std::str::from_utf8(slice).map_err(|_| ProtocolError::InvalidUtf8)
485    }
486}
487
488/// Decodes a command list: `n` fixed-size records followed by the `TextAt` strings.
489pub fn decode_commands(bytes: &[u8]) -> Result<Vec<DrawCommand>, ProtocolError> {
490    let mut commands = Vec::new();
491    let mut position = 0;
492    while position + COMMAND_LEN <= bytes.len() {
493        let record = &bytes[position..position + COMMAND_LEN];
494        // The text region begins where the records stop looking like records. A zero tag
495        // cannot be a command, and neither can the first byte of a UTF-8 string that
496        // happens to sit here, so the list ends at the first record that does not decode.
497        let Ok(command) = DrawCommand::read_from(record) else {
498            break;
499        };
500        commands.push(command);
501        position += COMMAND_LEN;
502    }
503    Ok(commands)
504}
505
506/// Builds a command list. Each call appends one fixed-size record; `TextAt` also appends
507/// its bytes to the text region, which is written after every record.
508#[derive(Debug, Default)]
509pub struct DrawListBuilder {
510    records: Vec<u8>,
511    text: Vec<u8>,
512}
513
514impl DrawListBuilder {
515    pub fn with_capacity(commands: usize, text_bytes: usize) -> Self {
516        Self {
517            records: Vec::with_capacity(commands * COMMAND_LEN),
518            text: Vec::with_capacity(text_bytes),
519        }
520    }
521
522    /// Appends any command. `TextAt` offsets are fixed up by [`DrawListBuilder::build`],
523    /// so prefer [`DrawListBuilder::text_at`] for text.
524    pub fn push(mut self, command: DrawCommand) -> Self {
525        command.write_to(&mut self.records);
526        self
527    }
528
529    pub fn line(self, paint: Paint, x1: f32, y1: f32, x2: f32, y2: f32, stroke_width: f32) -> Self {
530        self.push(DrawCommand::Line {
531            paint,
532            x1,
533            y1,
534            x2,
535            y2,
536            stroke_width,
537        })
538    }
539
540    /// A stroke width of zero fills the rectangle.
541    pub fn rect(
542        self,
543        paint: Paint,
544        x: f32,
545        y: f32,
546        width: f32,
547        height: f32,
548        stroke_width: f32,
549    ) -> Self {
550        self.push(DrawCommand::Rect {
551            paint,
552            x,
553            y,
554            width,
555            height,
556            stroke_width,
557        })
558    }
559
560    #[allow(clippy::too_many_arguments)]
561    pub fn round_rect(
562        self,
563        paint: Paint,
564        x: f32,
565        y: f32,
566        width: f32,
567        height: f32,
568        radius: f32,
569        stroke_width: f32,
570    ) -> Self {
571        self.push(DrawCommand::RoundRect {
572            paint,
573            x,
574            y,
575            width,
576            height,
577            radius,
578            stroke_width,
579        })
580    }
581
582    pub fn circle(
583        self,
584        paint: Paint,
585        center_x: f32,
586        center_y: f32,
587        radius: f32,
588        stroke_width: f32,
589    ) -> Self {
590        self.push(DrawCommand::Circle {
591            paint,
592            center_x,
593            center_y,
594            radius,
595            stroke_width,
596        })
597    }
598
599    #[allow(clippy::too_many_arguments)]
600    pub fn arc(
601        self,
602        paint: Paint,
603        center_x: f32,
604        center_y: f32,
605        radius: f32,
606        start_degrees: f32,
607        sweep_degrees: f32,
608        stroke_width: f32,
609    ) -> Self {
610        self.push(DrawCommand::Arc {
611            paint,
612            center_x,
613            center_y,
614            radius,
615            start_degrees,
616            sweep_degrees,
617            stroke_width,
618        })
619    }
620
621    /// Draws a registered point list. The asset holds the points, so the record stays the
622    /// same size however long the path is.
623    pub fn polyline_ref(self, paint: Paint, asset_id: u32, stroke_width: f32) -> Self {
624        self.push(DrawCommand::PolylineRef {
625            paint,
626            asset_id,
627            stroke_width,
628        })
629    }
630
631    pub fn text_at(
632        mut self,
633        paint: Paint,
634        text: &str,
635        x: f32,
636        y: f32,
637        type_role: TypeRole,
638    ) -> Self {
639        // Relative to the text region while building; `build` adds the record region's
640        // length so the offset ends up relative to the whole list.
641        let offset = self.text.len() as u32;
642        self.text.extend_from_slice(text.as_bytes());
643        self.push(DrawCommand::TextAt {
644            paint,
645            text_offset: offset,
646            text_length: text.len() as u32,
647            x,
648            y,
649            type_role,
650        })
651    }
652
653    pub fn build(mut self) -> DrawList {
654        let records_len = self.records.len() as u32;
655        let mut position = 0;
656        while position + COMMAND_LEN <= self.records.len() {
657            if u16::from_le_bytes([self.records[position], self.records[position + 1]])
658                == TAG_TEXT_AT
659            {
660                let field = position + 12;
661                let relative = u32::from_le_bytes(
662                    self.records[field..field + 4]
663                        .try_into()
664                        .expect("four bytes inside a 36 byte record"),
665                );
666                self.records[field..field + 4]
667                    .copy_from_slice(&relative.saturating_add(records_len).to_le_bytes());
668            }
669            position += COMMAND_LEN;
670        }
671        self.records.append(&mut self.text);
672        DrawList::from_bytes(self.records)
673    }
674}
675
676#[cfg(test)]
677mod tests {
678    use super::*;
679    use crate::schema::{Color, ColorRole};
680
681    #[test]
682    fn fr17_every_command_round_trips_in_a_fixed_length_record() {
683        let list = DrawList::builder()
684            .line(Paint::Role(ColorRole::Primary), 1.0, 2.0, 3.0, 4.0, 1.5)
685            .rect(
686                Paint::Literal(Color::rgb(0x1b1b1f)),
687                0.0,
688                0.0,
689                8.0,
690                9.0,
691                0.0,
692            )
693            .round_rect(
694                Paint::Role(ColorRole::Surface),
695                1.0,
696                2.0,
697                3.0,
698                4.0,
699                5.0,
700                6.0,
701            )
702            .circle(Paint::Role(ColorRole::Error), 4.0, 5.0, 6.0, 0.5)
703            .arc(
704                Paint::Role(ColorRole::Outline),
705                1.0,
706                2.0,
707                3.0,
708                0.0,
709                90.0,
710                2.0,
711            )
712            .polyline_ref(Paint::Role(ColorRole::Secondary), 42, 3.0)
713            .text_at(
714                Paint::Role(ColorRole::OnSurface),
715                "한글",
716                7.0,
717                8.0,
718                TypeRole::Label,
719            )
720            .build();
721
722        let commands = list.decode().unwrap();
723
724        assert_eq!(commands.len(), DRAW_COMMAND_SCHEMA.len());
725        assert_eq!(
726            list.as_bytes().len(),
727            DRAW_COMMAND_SCHEMA.len() * COMMAND_LEN + "한글".len(),
728        );
729        for (command, schema) in commands.iter().zip(DRAW_COMMAND_SCHEMA) {
730            assert_eq!(command.tag(), schema.tag, "{} is out of order", schema.name);
731        }
732    }
733
734    #[test]
735    fn fr17_text_offsets_are_relative_to_the_list_itself() {
736        let list = DrawList::builder()
737            .circle(Paint::Role(ColorRole::Primary), 0.0, 0.0, 1.0, 0.0)
738            .text_at(
739                Paint::Role(ColorRole::OnSurface),
740                "hi",
741                1.0,
742                2.0,
743                TypeRole::Body,
744            )
745            .text_at(
746                Paint::Role(ColorRole::OnSurface),
747                "there",
748                3.0,
749                4.0,
750                TypeRole::Body,
751            )
752            .build();
753
754        let texts: Vec<_> = list
755            .decode()
756            .unwrap()
757            .into_iter()
758            .filter_map(|command| match command {
759                DrawCommand::TextAt {
760                    text_offset,
761                    text_length,
762                    ..
763                } => Some(list.text_at(text_offset, text_length).unwrap().to_owned()),
764                _ => None,
765            })
766            .collect();
767
768        assert_eq!(texts, ["hi", "there"]);
769    }
770
771    #[test]
772    fn fr17_paint_carries_a_color_role_into_the_canvas() {
773        let list = DrawList::builder()
774            .line(Paint::Role(ColorRole::Primary), 0.0, 0.0, 1.0, 1.0, 1.0)
775            .build();
776
777        assert_eq!(
778            list.decode().unwrap()[0].paint(),
779            Paint::Role(ColorRole::Primary),
780        );
781    }
782
783    #[test]
784    fn fr17_two_identical_lists_compare_equal() {
785        let build = || {
786            DrawList::builder()
787                .rect(Paint::Role(ColorRole::Surface), 0.0, 0.0, 4.0, 4.0, 0.0)
788                .build()
789        };
790
791        assert_eq!(build(), build());
792    }
793}