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