Skip to main content

devpage_design/
wire.rs

1//! Scenes on the wire: a scene to its `proto::Scene` and back, and the patch
2//! between two scenes. Each image file travels once: a scene carries every
3//! one its nodes show, a patch only those the scene before it didn't have.
4
5use std::collections::BTreeSet;
6use std::sync::Arc;
7
8use crate::proto;
9use crate::resolve::{
10    Align, Color, Fill, Image, ImageFile, ImageFiles, ImageId, Kind, Layout, Node, Origin, Scene,
11    Shadow, Size, TextAlign, TextStyle, Vector,
12};
13
14fn rgba(c: Color) -> u32 {
15    u32::from_be_bytes([c.r, c.g, c.b, c.a])
16}
17
18fn color(v: u32) -> Color {
19    let [r, g, b, a] = v.to_be_bytes();
20    Color { r, g, b, a }
21}
22
23fn size(s: Size) -> f32 {
24    match s {
25        Size::Px(v) => v,
26        Size::Hug => -1.0,
27        Size::Fill => -2.0,
28    }
29}
30
31fn unsize(v: f32) -> Size {
32    if v == -1.0 {
33        Size::Hug
34    } else if v == -2.0 {
35        Size::Fill
36    } else {
37        Size::Px(v)
38    }
39}
40
41fn align(a: Align) -> proto::Align {
42    match a {
43        Align::Start => proto::Align::Start,
44        Align::Center => proto::Align::Center,
45        Align::End => proto::Align::End,
46        Align::SpaceBetween => proto::Align::SpaceBetween,
47    }
48}
49
50fn unalign(a: i32) -> Align {
51    match proto::Align::try_from(a).unwrap_or(proto::Align::Start) {
52        proto::Align::Start => Align::Start,
53        proto::Align::Center => Align::Center,
54        proto::Align::End => Align::End,
55        proto::Align::SpaceBetween => Align::SpaceBetween,
56    }
57}
58
59pub fn encode(n: &Node) -> proto::Node {
60    proto::Node {
61        kind: match n.kind {
62            Kind::Frame => proto::Kind::Frame,
63            Kind::Text => proto::Kind::Text,
64            Kind::Rect => proto::Kind::Rect,
65            Kind::Ellipse => proto::Kind::Ellipse,
66            Kind::Vector => proto::Kind::Vector,
67            Kind::Image => proto::Kind::Image,
68        } as i32,
69        name: n.name.clone(),
70        x: n.x,
71        y: n.y,
72        width: size(n.width),
73        height: size(n.height),
74        opacity: n.opacity,
75        on_click: n.on_click.clone().unwrap_or_default(),
76        fill: match &n.fill {
77            Some(Fill::Solid(c)) => Some(rgba(*c)),
78            _ => None,
79        },
80        gradient: match &n.fill {
81            Some(Fill::Linear { angle, stops }) => Some(proto::Gradient {
82                angle: *angle,
83                stops: stops
84                    .iter()
85                    .map(|(c, at)| proto::Stop {
86                        color: rgba(*c),
87                        at: *at,
88                    })
89                    .collect(),
90            }),
91            _ => None,
92        },
93        vector: n.vector.as_ref().map(|v| proto::Vector {
94            d: v.d.clone(),
95            view_box: v.view_box.to_vec(),
96        }),
97        image: n.image.as_ref().map(|i| proto::Image {
98            src: i.src.clone(),
99            id: i.id.0.to_vec(),
100        }),
101        stroke: n.stroke.map(rgba),
102        stroke_width: n.stroke_width,
103        radius: n.radius.to_vec(),
104        shadow: n.shadow.as_ref().map(|s| proto::Shadow {
105            x: s.x,
106            y: s.y,
107            blur: s.blur,
108            spread: s.spread,
109            color: rgba(s.color),
110        }),
111        clip: n.clip,
112        layout: match n.layout {
113            Layout::None => proto::Layout::None,
114            Layout::Row => proto::Layout::Row,
115            Layout::Column => proto::Layout::Column,
116        } as i32,
117        gap: n.gap,
118        padding: n.padding.to_vec(),
119        align: align(n.align) as i32,
120        justify: align(n.justify) as i32,
121        text: n.text.as_ref().map(|t| proto::Text {
122            content: t.content.clone(),
123            size: t.size,
124            weight: t.weight as u32,
125            color: rgba(t.color),
126            line_height: t.line_height,
127            align: match t.align {
128                TextAlign::Left => proto::TextAlign::Left,
129                TextAlign::Center => proto::TextAlign::Center,
130                TextAlign::Right => proto::TextAlign::Right,
131            } as i32,
132        }),
133        children: n.children.iter().map(encode).collect(),
134    }
135}
136
137fn image_id(bytes: &[u8]) -> Result<ImageId, String> {
138    bytes
139        .try_into()
140        .map(ImageId)
141        .map_err(|_| format!("an image file id of {} bytes, not 32", bytes.len()))
142}
143
144/// A node from the wire, or why it isn't one.
145pub fn decode(n: &proto::Node) -> Result<Node, String> {
146    let four = |v: &[f32]| [0, 1, 2, 3].map(|i| v.get(i).copied().unwrap_or(0.0));
147    Ok(Node {
148        kind: match proto::Kind::try_from(n.kind).unwrap_or(proto::Kind::Frame) {
149            proto::Kind::Frame => Kind::Frame,
150            proto::Kind::Text => Kind::Text,
151            proto::Kind::Rect => Kind::Rect,
152            proto::Kind::Ellipse => Kind::Ellipse,
153            proto::Kind::Vector => Kind::Vector,
154            proto::Kind::Image => Kind::Image,
155        },
156        name: n.name.clone(),
157        x: n.x,
158        y: n.y,
159        width: unsize(n.width),
160        height: unsize(n.height),
161        opacity: n.opacity,
162        on_click: Some(n.on_click.clone()).filter(|s| !s.is_empty()),
163        fill: match (&n.fill, &n.gradient) {
164            (Some(c), _) => Some(Fill::Solid(color(*c))),
165            (None, Some(g)) => Some(Fill::Linear {
166                angle: g.angle,
167                stops: g.stops.iter().map(|s| (color(s.color), s.at)).collect(),
168            }),
169            (None, None) => None,
170        },
171        stroke: n.stroke.map(color),
172        stroke_width: n.stroke_width,
173        radius: four(&n.radius),
174        shadow: n.shadow.as_ref().map(|s| Shadow {
175            x: s.x,
176            y: s.y,
177            blur: s.blur,
178            spread: s.spread,
179            color: color(s.color),
180            at: Origin::default(),
181        }),
182        clip: n.clip,
183        layout: match proto::Layout::try_from(n.layout).unwrap_or(proto::Layout::None) {
184            proto::Layout::None => Layout::None,
185            proto::Layout::Row => Layout::Row,
186            proto::Layout::Column => Layout::Column,
187        },
188        gap: n.gap,
189        padding: four(&n.padding),
190        align: unalign(n.align),
191        justify: unalign(n.justify),
192        text: n.text.as_ref().map(|t| TextStyle {
193            content: t.content.clone(),
194            size: t.size,
195            weight: t.weight as u16,
196            color: color(t.color),
197            line_height: t.line_height,
198            align: match proto::TextAlign::try_from(t.align).unwrap_or(proto::TextAlign::Left) {
199                proto::TextAlign::Left => TextAlign::Left,
200                proto::TextAlign::Center => TextAlign::Center,
201                proto::TextAlign::Right => TextAlign::Right,
202            },
203        }),
204        vector: n.vector.as_ref().map(|v| Vector {
205            d: v.d.clone(),
206            view_box: four(&v.view_box),
207        }),
208        image: match &n.image {
209            Some(i) => Some(Image {
210                src: i.src.clone(),
211                id: image_id(&i.id)?,
212                at: Origin::default(),
213            }),
214            None => None,
215        },
216        children: n.children.iter().map(decode).collect::<Result<_, _>>()?,
217    })
218}
219
220/// A whole scene for the wire: its tree and every image file it shows.
221pub fn scene(scene: &Scene) -> proto::Scene {
222    proto::Scene {
223        root: Some(encode(&scene.root)),
224        images: scene.images.values().map(|f| file(f)).collect(),
225    }
226}
227
228fn file(f: &ImageFile) -> proto::ImageFile {
229    proto::ImageFile {
230        id: f.id.0.to_vec(),
231        data: f.bytes.clone(),
232    }
233}
234
235/// The patch that turns `old`, as last sent, into `new`: the nodes that
236/// changed and the image files `old` didn't have.
237pub fn patch(old: &Scene, new: &Scene) -> proto::Patch {
238    proto::Patch {
239        replace: diff(&old.root, &new.root),
240        images: new
241            .images
242            .values()
243            .filter(|f| !old.images.contains_key(&f.id))
244            .map(|f| file(f))
245            .collect(),
246    }
247}
248
249/// The image files `files` carries, decoded — each taken from `held` instead
250/// when it is already there, so a file is decoded once however often it is
251/// sent.
252fn files(files: &[proto::ImageFile], held: &ImageFiles) -> Result<ImageFiles, String> {
253    let mut out = ImageFiles::new();
254    for f in files {
255        let id = image_id(&f.id)?;
256        let decoded = match held.get(&id) {
257            Some(known) => known.clone(),
258            // The server decoded the same bytes, so they decode here too.
259            None => Arc::new(ImageFile::with_id(id, f.data.clone())?),
260        };
261        out.insert(id, decoded);
262    }
263    Ok(out)
264}
265
266/// Keeps only the image files `root` shows, and says if it shows one that
267/// isn't there.
268fn settle(root: &Node, images: &mut ImageFiles) -> Result<(), String> {
269    fn shown(n: &Node, out: &mut BTreeSet<ImageId>) {
270        if let Some(i) = &n.image {
271            out.insert(i.id);
272        }
273        for c in &n.children {
274            shown(c, out);
275        }
276    }
277    let mut ids = BTreeSet::new();
278    shown(root, &mut ids);
279    if let Some(missing) = ids.iter().find(|id| !images.contains_key(id)) {
280        return Err(format!("the stream never sent the image file {missing:?}"));
281    }
282    images.retain(|id, _| ids.contains(id));
283    Ok(())
284}
285
286/// The scene a stream message carries; image files in `held` aren't decoded
287/// again.
288pub fn decode_scene(message: &proto::Scene, held: &ImageFiles) -> Result<Scene, String> {
289    let root = decode(message.root.as_ref().ok_or("a scene with no root")?)?;
290    let mut images = files(&message.images, held)?;
291    settle(&root, &mut images)?;
292    Ok(Scene {
293        root,
294        images,
295        files: Vec::new(),
296    })
297}
298
299/// The replacements that turn `old` into `new`: the highest nodes whose own
300/// attributes or number of children changed, by id.
301pub fn diff(old: &Node, new: &Node) -> Vec<proto::Replace> {
302    let mut out = Vec::new();
303    walk(old, new, "0", &mut out);
304    out
305}
306
307fn walk(old: &Node, new: &Node, id: &str, out: &mut Vec<proto::Replace>) {
308    if old == new {
309        return;
310    }
311    let same_shell = old.children.len() == new.children.len() && {
312        let (mut a, mut b) = (old.clone(), new.clone());
313        a.children.clear();
314        b.children.clear();
315        a == b
316    };
317    if !same_shell {
318        out.push(proto::Replace {
319            id: id.to_string(),
320            node: Some(encode(new)),
321        });
322        return;
323    }
324    for (i, (o, n)) in old.children.iter().zip(&new.children).enumerate() {
325        walk(o, n, &format!("{id}.{i}"), out);
326    }
327}
328
329/// Applies a patch to `scene`, or says why it can't — leaving the scene as it
330/// was.
331pub fn apply(scene: &mut Scene, patch: &proto::Patch) -> Result<(), String> {
332    let mut next = scene.clone();
333    let added = files(&patch.images, &scene.images)?;
334    next.images.extend(added);
335    for r in &patch.replace {
336        let node = r.node.as_ref().ok_or("a replacement with no node")?;
337        let at = find(&mut next.root, &r.id).ok_or_else(|| format!("no node {}", r.id))?;
338        *at = decode(node)?;
339    }
340    settle(&next.root, &mut next.images)?;
341    *scene = next;
342    Ok(())
343}
344
345fn find<'a>(root: &'a mut Node, id: &str) -> Option<&'a mut Node> {
346    let mut at = root;
347    for step in id.split('.').skip(1) {
348        at = at.children.get_mut(step.parse::<usize>().ok()?)?;
349    }
350    Some(at)
351}
352
353#[cfg(test)]
354mod tests {
355    use std::collections::HashMap;
356
357    use prost::Message;
358
359    use super::*;
360    use crate::Files;
361
362    fn scene(src: &str) -> Scene {
363        let files: HashMap<String, String> = [("a.design".to_string(), src.to_string())].into();
364        crate::resolve(&files, "a.design").unwrap()
365    }
366
367    #[test]
368    fn a_patch_carries_only_what_changed() {
369        let old = scene(
370            r##"<Frame layout="column"><Text>One</Text><Frame><Text>Two</Text></Frame></Frame>"##,
371        );
372        let new = scene(
373            r##"<Frame layout="column"><Text>One</Text><Frame><Text>Three</Text></Frame></Frame>"##,
374        );
375        assert_eq!(decode(&encode(&old.root)).unwrap(), old.root);
376        let patch = patch(&old, &new);
377        assert_eq!(
378            patch
379                .replace
380                .iter()
381                .map(|r| r.id.as_str())
382                .collect::<Vec<_>>(),
383            vec!["0.1.0"]
384        );
385        let mut applied = decode_scene(&super::scene(&old), &ImageFiles::new()).unwrap();
386        apply(&mut applied, &patch).unwrap();
387        assert_eq!(applied.root, new.root);
388    }
389
390    /// Files as bytes, so a test can hold an image.
391    struct Mem(HashMap<String, Vec<u8>>);
392
393    impl Files for Mem {
394        fn bytes(&self, path: &str) -> Option<Vec<u8>> {
395            self.0.get(path).cloned()
396        }
397    }
398
399    fn png(w: u32, h: u32) -> Vec<u8> {
400        let mut out = Vec::new();
401        let mut e = ::png::Encoder::new(&mut out, w, h);
402        e.set_color(::png::ColorType::Rgba);
403        let mut writer = e.write_header().unwrap();
404        writer
405            .write_image_data(&vec![200; (4 * w * h) as usize])
406            .unwrap();
407        drop(writer);
408        out
409    }
410
411    fn shirt() -> Vec<u8> {
412        std::fs::read(concat!(
413            env!("CARGO_MANIFEST_DIR"),
414            "/../docs/designs/checkout/shirt.jpg"
415        ))
416        .unwrap()
417    }
418
419    fn cards(n: usize, extra: &str) -> Mem {
420        let mut page = String::from("import Card from \"./card.design\"\n<Frame layout=\"row\">");
421        for _ in 0..n {
422            page.push_str("<Card />");
423        }
424        page.push_str(extra);
425        page.push_str("</Frame>");
426        Mem([
427            (
428                "card.design".to_string(),
429                br#"<Frame><Image src="shirt.jpg" width={100} height={100} /></Frame>"#.to_vec(),
430            ),
431            ("page.design".to_string(), page.into_bytes()),
432            ("shirt.jpg".to_string(), shirt()),
433            ("copy.jpg".to_string(), shirt()),
434        ]
435        .into())
436    }
437
438    /// One photo shown fifty times is one image file: decoded once and sent
439    /// once, and a patch sends an image file only when the browser doesn't
440    /// have it yet.
441    #[test]
442    fn an_image_file_travels_once() {
443        let fifty = crate::resolve(&cards(50, ""), "page.design").unwrap();
444        assert_eq!(fifty.images.len(), 1);
445        let message = super::scene(&fifty).encode_to_vec().len();
446        let jpeg = shirt().len();
447        assert!(
448            message < jpeg + 50 * 200,
449            "a scene of fifty uses of a {jpeg}-byte file is {message} bytes"
450        );
451        // Another use of the same file, under another name too: nothing new
452        // to send.
453        let more = crate::resolve(
454            &cards(51, r#"<Image src="copy.jpg" width={10} height={10} />"#),
455            "page.design",
456        )
457        .unwrap();
458        assert_eq!(more.images.len(), 1, "copy.jpg is shirt.jpg's bytes");
459        let p = patch(&fifty, &more);
460        assert!(p.images.is_empty());
461        let mut browser = decode_scene(&super::scene(&fifty), &ImageFiles::new()).unwrap();
462        let held = browser.images.values().next().unwrap().clone();
463        apply(&mut browser, &p).unwrap();
464        assert_eq!(browser.root, more.root);
465        // The browser kept the image file it had decoded.
466        assert!(Arc::ptr_eq(browser.images.values().next().unwrap(), &held));
467        // A patch whose nodes show a file the browser lacks carries it.
468        let mut fs = cards(1, r#"<Image src="other.png" width={10} height={10} />"#);
469        fs.0.insert("other.png".into(), png(2, 2));
470        let other = crate::resolve(&fs, "page.design").unwrap();
471        let p = patch(&more, &other);
472        assert_eq!(p.images.len(), 1);
473        apply(&mut browser, &p).unwrap();
474        assert_eq!(browser.images.len(), 2);
475        // A patch whose nodes show a file never sent is refused.
476        let mut lacking = p.clone();
477        lacking.images.clear();
478        let mut kept = decode_scene(&super::scene(&more), &ImageFiles::new()).unwrap();
479        assert!(
480            apply(&mut kept, &lacking)
481                .unwrap_err()
482                .contains("never sent")
483        );
484        assert_eq!(kept.root, more.root, "a refused patch leaves the scene");
485    }
486}