Skip to main content

devpage_design/scene/
mod.rs

1//! What a resolved design is, and how one becomes the next. A [`Scene`] is
2//! a tree of [`Node`]s — each with its own attributes, colours in both
3//! [`Theme`]s, and an id that survives saves — with the image and font files
4//! it shows, read through the [`Files`] port. An [`Edit`] names its nodes by
5//! id; [`Edits`] are a list of them with the files they bring, and applying
6//! them ([`apply`]) is all or nothing, undone on a refusal, at the cost of
7//! the edits alone. [`Applied`] says what they did, for a drawing kept
8//! beside the scene to follow.
9//!
10//! It is what every other part of the engine speaks: [`crate::resolve`]
11//! makes scenes and their edits, [`crate::render`] draws them, and
12//! [`crate::wire`] carries both to the browser.
13
14use std::collections::HashMap;
15use std::sync::Arc;
16
17use peniko::{Blob, ImageAlphaType, ImageData, ImageFormat};
18
19use crate::Problem;
20use crate::fonts::{FontFile, FontFiles, FontId};
21use crate::parse::{self, Pos};
22use crate::tree::{Children, NodeId, Order, OrderKey};
23
24/// One color's value: straight-alpha RGBA.
25#[derive(Debug, Clone, Copy, PartialEq)]
26pub struct Rgba {
27    pub r: u8,
28    pub g: u8,
29    pub b: u8,
30    pub a: u8,
31}
32
33/// Which of a design's two themes it is drawn in.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
35pub enum Theme {
36    #[default]
37    Light,
38    Dark,
39}
40
41impl Theme {
42    /// `"light"` or `"dark"` as a theme, as a PNG's `?theme=` names one.
43    pub fn named(name: &str) -> Option<Theme> {
44        match name {
45            "light" => Some(Theme::Light),
46            "dark" => Some(Theme::Dark),
47            _ => None,
48        }
49    }
50}
51
52/// A color in a scene: its value in each theme. A color written as `#…` has
53/// the same value in both; `theme.name` has the themes' two.
54#[derive(Debug, Clone, Copy, PartialEq)]
55pub struct Color {
56    pub light: Rgba,
57    pub dark: Rgba,
58}
59
60impl Color {
61    /// The same value in both themes.
62    pub const fn fixed(c: Rgba) -> Color {
63        Color { light: c, dark: c }
64    }
65
66    /// The value in `theme`.
67    pub fn get(self, theme: Theme) -> Rgba {
68        match theme {
69            Theme::Light => self.light,
70            Theme::Dark => self.dark,
71        }
72    }
73}
74
75#[derive(Debug, Clone, Copy, PartialEq)]
76pub enum Size {
77    Px(f32),
78    Hug,
79    Fill,
80}
81
82#[derive(Debug, Clone, Copy, PartialEq)]
83pub enum Layout {
84    None,
85    Row,
86    Column,
87}
88
89#[derive(Debug, Clone, Copy, PartialEq)]
90pub enum Align {
91    Start,
92    Center,
93    End,
94    SpaceBetween,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq)]
98pub enum Kind {
99    Frame,
100    Text,
101    Rect,
102    Ellipse,
103    Vector,
104    Image,
105}
106
107/// How a box is filled: one color, or a CSS-style linear gradient.
108#[derive(Debug, Clone, PartialEq)]
109pub enum Fill {
110    Solid(Color),
111    /// `angle` in degrees, CSS's: 0 points up, 90 right, 180 down. Each stop
112    /// is a color at an offset from 0 to 1 along the gradient line.
113    Linear {
114        angle: f32,
115        stops: Vec<(Color, f32)>,
116    },
117}
118
119#[derive(Debug, Clone, Copy, PartialEq)]
120pub enum TextAlign {
121    Left,
122    Center,
123    Right,
124}
125
126/// A `Vector`'s path and the box of path coordinates it is drawn from.
127#[derive(Debug, Clone, PartialEq)]
128pub struct Vector {
129    pub d: String,
130    /// `[minX, minY, width, height]`.
131    pub view_box: [f32; 4],
132}
133
134/// An image file's identity: the SHA-256 of its bytes. Two `Image`s showing
135/// the same bytes — the same file used twice, or two copies of it — share one
136/// [`ImageFile`], decoded once and sent once.
137#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
138pub struct ImageId(pub [u8; 32]);
139
140impl ImageId {
141    pub fn of(bytes: &[u8]) -> ImageId {
142        use sha2::Digest;
143        ImageId(sha2::Sha256::digest(bytes).into())
144    }
145}
146
147impl std::fmt::Debug for ImageId {
148    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149        for b in &self.0[..6] {
150            write!(f, "{b:02x}")?;
151        }
152        Ok(())
153    }
154}
155
156/// The most pixels a side an image is drawn from. The GPU keeps every image a
157/// drawing paints in one atlas of [`crate::atlas::SIDE`] pixels a side, so a
158/// larger file is resampled to fit this, keeping its aspect, when it is
159/// decoded — and both backends draw those same pixels.
160pub const MAX_IMAGE_SIDE: u32 = 4096;
161
162/// A decoded image file: its bytes, its own size, and the pixels it is drawn
163/// from — the file's own, or resampled to at most [`MAX_IMAGE_SIDE`] a side.
164#[derive(Debug)]
165pub struct ImageFile {
166    pub id: ImageId,
167    pub bytes: Vec<u8>,
168    /// The file's size in pixels: what an `Image` hugs by.
169    pub width: u32,
170    pub height: u32,
171    /// Straight-alpha RGBA, `pixels_width` × `pixels_height`.
172    pub pixels: Arc<Vec<u8>>,
173    pub pixels_width: u32,
174    pub pixels_height: u32,
175}
176
177impl ImageFile {
178    /// Decodes `bytes`, a PNG or JPEG file, or says why it can't.
179    pub fn new(bytes: Vec<u8>) -> Result<ImageFile, String> {
180        ImageFile::with_id(ImageId::of(&bytes), bytes)
181    }
182
183    /// Decodes `bytes`, already known to be the file `id`.
184    pub fn with_id(id: ImageId, bytes: Vec<u8>) -> Result<ImageFile, String> {
185        let decoded = decode(&bytes)?;
186        let (width, height) = (decoded.width, decoded.height);
187        let (pixels, pixels_width, pixels_height) =
188            fit(decoded.data.data(), width, height, MAX_IMAGE_SIDE);
189        Ok(ImageFile {
190            id,
191            bytes,
192            width,
193            height,
194            pixels: Arc::new(pixels),
195            pixels_width,
196            pixels_height,
197        })
198    }
199
200    /// The pixels as an image for one drawing to paint, under a blob of its
201    /// own that shares the pixels: see `render::Renderer::drawing`.
202    pub fn image_data(&self) -> ImageData {
203        ImageData {
204            data: Blob::new(self.pixels.clone()),
205            format: ImageFormat::Rgba8,
206            alpha_type: ImageAlphaType::Alpha,
207            width: self.pixels_width,
208            height: self.pixels_height,
209        }
210    }
211}
212
213/// `rgba`, `width` × `height`, resampled to at most `max` pixels a side with
214/// its aspect kept, or as it is when it already fits. Each new pixel is the
215/// area-weighted average of the pixels under it, in premultiplied alpha so a
216/// transparent pixel lends no color.
217pub fn fit(rgba: &[u8], width: u32, height: u32, max: u32) -> (Vec<u8>, u32, u32) {
218    if width <= max && height <= max {
219        return (rgba.to_vec(), width, height);
220    }
221    let scale = max as f64 / width.max(height) as f64;
222    let w = ((width as f64 * scale).round() as u32).clamp(1, max);
223    let h = ((height as f64 * scale).round() as u32).clamp(1, max);
224    // For each output index along one axis, the input indices it covers and
225    // how much of each.
226    let weights = |from: u32, to: u32| -> Vec<Vec<(usize, f32)>> {
227        let ratio = from as f64 / to as f64;
228        (0..to)
229            .map(|o| {
230                let (a, b) = (o as f64 * ratio, (o + 1) as f64 * ratio);
231                (a.floor() as usize..(b.ceil() as usize).min(from as usize))
232                    .map(|i| {
233                        let cover = (b.min(i as f64 + 1.0) - a.max(i as f64)) / ratio;
234                        (i, cover as f32)
235                    })
236                    .filter(|&(_, c)| c > 0.0)
237                    .collect()
238            })
239            .collect()
240    };
241    let (across, down) = (weights(width, w), weights(height, h));
242    // Across each row first, premultiplied, then down each column.
243    let mut rows = vec![0f32; 4 * w as usize * height as usize];
244    for y in 0..height as usize {
245        let line = &rgba[4 * y * width as usize..][..4 * width as usize];
246        for (x, ws) in across.iter().enumerate() {
247            let mut sum = [0f32; 4];
248            for &(i, c) in ws {
249                let p = &line[4 * i..][..4];
250                let a = p[3] as f32 * c;
251                sum[0] += p[0] as f32 * a;
252                sum[1] += p[1] as f32 * a;
253                sum[2] += p[2] as f32 * a;
254                sum[3] += a;
255            }
256            rows[4 * (y * w as usize + x)..][..4].copy_from_slice(&sum);
257        }
258    }
259    let mut out = vec![0u8; 4 * w as usize * h as usize];
260    for (y, ws) in down.iter().enumerate() {
261        for x in 0..w as usize {
262            let mut sum = [0f32; 4];
263            for &(i, c) in ws {
264                let p = &rows[4 * (i * w as usize + x)..][..4];
265                for k in 0..4 {
266                    sum[k] += p[k] * c;
267                }
268            }
269            let px = &mut out[4 * (y * w as usize + x)..][..4];
270            if sum[3] > 0.0 {
271                for k in 0..3 {
272                    px[k] = (sum[k] / sum[3]).round().clamp(0.0, 255.0) as u8;
273                }
274                px[3] = sum[3].round().clamp(0.0, 255.0) as u8;
275            }
276        }
277    }
278    (out, w, h)
279}
280
281/// Where an attribute was written, for a problem found only once the design
282/// is laid out and painted. It is never part of what a node draws: two nodes
283/// that differ only in it are equal, and it doesn't travel on the wire.
284#[derive(Debug, Clone)]
285pub struct Origin {
286    pub file: String,
287    pub pos: Pos,
288}
289
290impl Default for Origin {
291    fn default() -> Origin {
292        Origin {
293            file: String::new(),
294            pos: Pos { line: 1, col: 1 },
295        }
296    }
297}
298
299impl PartialEq for Origin {
300    fn eq(&self, _: &Origin) -> bool {
301        true
302    }
303}
304
305/// An `Image`'s file: its address in the served folder and which
306/// [`ImageFile`] in the scene it shows.
307#[derive(Debug, Clone, PartialEq)]
308pub struct Image {
309    pub src: String,
310    pub id: ImageId,
311    /// Where `src` was written.
312    pub at: Origin,
313}
314
315/// A PNG or JPEG file's pixels, as straight-alpha RGBA, or why it has none.
316pub fn decode(data: &[u8]) -> Result<ImageData, String> {
317    let (pixels, width, height) = if data.starts_with(b"\x89PNG\r\n\x1a\n") {
318        let broken = |e: &dyn std::fmt::Display| format!("is a PNG that doesn't decode: {e}");
319        let mut decoder = png::Decoder::new(std::io::Cursor::new(data));
320        decoder.set_transformations(png::Transformations::normalize_to_color8());
321        let mut reader = decoder.read_info().map_err(|e| broken(&e))?;
322        let size = reader
323            .output_buffer_size()
324            .ok_or_else(|| broken(&"too big to hold"))?;
325        let mut buf = vec![0; size];
326        let info = reader.next_frame(&mut buf).map_err(|e| broken(&e))?;
327        buf.truncate(info.buffer_size());
328        let rgba = match info.color_type {
329            png::ColorType::Rgba => buf,
330            png::ColorType::Rgb => buf
331                .chunks(3)
332                .flat_map(|p| [p[0], p[1], p[2], 255])
333                .collect(),
334            png::ColorType::GrayscaleAlpha => buf
335                .chunks(2)
336                .flat_map(|p| [p[0], p[0], p[0], p[1]])
337                .collect(),
338            png::ColorType::Grayscale => buf.iter().flat_map(|&g| [g, g, g, 255]).collect(),
339            // Expanded to RGB by `normalize_to_color8`.
340            png::ColorType::Indexed => return Err(broken(&"an unexpanded palette")),
341        };
342        (rgba, info.width, info.height)
343    } else if data.starts_with(&[0xff, 0xd8, 0xff]) {
344        use zune_jpeg::JpegDecoder;
345        use zune_jpeg::zune_core::colorspace::ColorSpace;
346        use zune_jpeg::zune_core::options::DecoderOptions;
347        let broken = |e: &dyn std::fmt::Display| format!("is a JPEG that doesn't decode: {e}");
348        let options = DecoderOptions::default()
349            .set_strict_mode(true)
350            .jpeg_set_out_colorspace(ColorSpace::RGBA);
351        let mut decoder = JpegDecoder::new_with_options(std::io::Cursor::new(data), options);
352        let pixels = decoder.decode().map_err(|e| broken(&e))?;
353        let info = decoder.info().ok_or_else(|| broken(&"no header"))?;
354        (pixels, info.width as u32, info.height as u32)
355    } else {
356        return Err("is not a PNG or JPEG file".into());
357    };
358    if width == 0 || height == 0 || pixels.len() != width as usize * height as usize * 4 {
359        return Err(format!(
360            "is an image of {width}×{height} pixels that doesn't decode whole"
361        ));
362    }
363    Ok(ImageData {
364        data: Blob::new(Arc::new(pixels)),
365        format: ImageFormat::Rgba8,
366        alpha_type: ImageAlphaType::Alpha,
367        width,
368        height,
369    })
370}
371
372#[derive(Debug, Clone, PartialEq)]
373pub struct Shadow {
374    pub x: f32,
375    pub y: f32,
376    pub blur: f32,
377    pub spread: f32,
378    pub color: Color,
379    /// Where `shadow` was written.
380    pub at: Origin,
381}
382
383#[derive(Debug, Clone, PartialEq)]
384pub struct TextStyle {
385    /// The font file the text is set in, the face its weight picked; `None`
386    /// for Inter.
387    pub font: Option<FontId>,
388    pub content: String,
389    pub size: f32,
390    pub weight: u16,
391    pub color: Color,
392    pub line_height: f32,
393    pub align: TextAlign,
394}
395
396/// One node of a scene.
397#[derive(Debug, Clone, PartialEq)]
398pub struct Node {
399    /// Where the node came from — see [`NodeId`] — which no edit around it
400    /// changes.
401    pub id: NodeId,
402    pub kind: Kind,
403    pub name: String,
404    pub x: f32,
405    pub y: f32,
406    pub width: Size,
407    pub height: Size,
408    pub opacity: f32,
409    pub on_click: Option<String>,
410    pub fill: Option<Fill>,
411    pub stroke: Option<Color>,
412    pub stroke_width: f32,
413    pub radius: [f32; 4],
414    pub shadow: Option<Shadow>,
415    pub clip: bool,
416    pub layout: Layout,
417    pub gap: f32,
418    pub padding: [f32; 4],
419    pub align: Align,
420    pub justify: Align,
421    pub text: Option<TextStyle>,
422    pub vector: Option<Vector>,
423    pub image: Option<Image>,
424    pub children: Children<Node>,
425}
426
427impl Node {
428    /// Whether two nodes' own attributes are the same, their children aside.
429    pub fn same_own(&self, b: &Node) -> bool {
430        let a = self;
431        a.kind == b.kind
432            && a.name == b.name
433            && a.x == b.x
434            && a.y == b.y
435            && a.width == b.width
436            && a.height == b.height
437            && a.opacity == b.opacity
438            && a.on_click == b.on_click
439            && a.fill == b.fill
440            && a.stroke == b.stroke
441            && a.stroke_width == b.stroke_width
442            && a.radius == b.radius
443            && a.shadow == b.shadow
444            && a.clip == b.clip
445            && a.layout == b.layout
446            && a.gap == b.gap
447            && a.padding == b.padding
448            && a.align == b.align
449            && a.justify == b.justify
450            && a.text == b.text
451            && a.vector == b.vector
452            && a.image == b.image
453    }
454
455    /// The node's own attributes, without its children.
456    pub fn own(&self) -> Node {
457        Node {
458            id: self.id.clone(),
459            kind: self.kind,
460            name: self.name.clone(),
461            x: self.x,
462            y: self.y,
463            width: self.width,
464            height: self.height,
465            opacity: self.opacity,
466            on_click: self.on_click.clone(),
467            fill: self.fill.clone(),
468            stroke: self.stroke,
469            stroke_width: self.stroke_width,
470            radius: self.radius,
471            shadow: self.shadow.clone(),
472            clip: self.clip,
473            layout: self.layout,
474            gap: self.gap,
475            padding: self.padding,
476            align: self.align,
477            justify: self.justify,
478            text: self.text.clone(),
479            vector: self.vector.clone(),
480            image: self.image.clone(),
481            children: Children::new(),
482        }
483    }
484
485    /// A node of `kind` with every attribute at its default.
486    pub(crate) fn new(kind: Kind, name: &str) -> Node {
487        Node {
488            id: NodeId::default(),
489            kind,
490            name: name.to_string(),
491            x: 0.0,
492            y: 0.0,
493            width: Size::Hug,
494            height: Size::Hug,
495            opacity: 1.0,
496            on_click: None,
497            fill: None,
498            stroke: None,
499            stroke_width: 1.0,
500            radius: [0.0; 4],
501            shadow: None,
502            clip: false,
503            layout: Layout::None,
504            gap: 0.0,
505            padding: [0.0; 4],
506            align: Align::Start,
507            justify: Align::Start,
508            text: None,
509            vector: None,
510            image: None,
511            children: Children::new(),
512        }
513    }
514}
515
516/// Reads a design's files by their path from the top of the served folder.
517pub trait Files {
518    /// A file's bytes: a design's text, an `Image`'s file or a font file.
519    fn bytes(&self, path: &str) -> Option<Vec<u8>>;
520    /// The names of the files directly in the folder at `path`, sorted;
521    /// `None` when there is no such folder. A font folder is read this way.
522    fn list(&self, path: &str) -> Option<Vec<String>>;
523    /// A design file's text.
524    fn read(&self, path: &str) -> Option<String> {
525        String::from_utf8(self.bytes(path)?).ok()
526    }
527    /// The design file at `path`, whose text is `source`, parsed. A store
528    /// that keeps files between resolves matches it against the file's last
529    /// parse ([`crate::ids::matched`]), so its elements keep their ids — and
530    /// the nodes they make theirs — across saves.
531    fn parse(&self, path: &str, source: &str) -> Result<Arc<parse::File>, Problem> {
532        let _ = path;
533        parse::parse(source).map(Arc::new)
534    }
535    /// The image file at `path`, decoded, or why it doesn't decode; `None`
536    /// when there is no such file. A store that keeps files between resolves
537    /// keeps their decoded images too, so a save doesn't decode them again.
538    fn image(&self, path: &str) -> Option<Result<Arc<ImageFile>, String>> {
539        self.bytes(path)
540            .map(|bytes| ImageFile::new(bytes).map(Arc::new))
541    }
542    /// The font file at `path`, checked, or why it isn't one; `None` when
543    /// there is no such file. A store that keeps files between resolves
544    /// keeps them checked too.
545    fn font(&self, path: &str) -> Option<Result<Arc<FontFile>, String>> {
546        self.bytes(path)
547            .map(|bytes| FontFile::new(bytes).map(Arc::new))
548    }
549}
550
551/// The names of the files directly in `folder`, from every path a store
552/// holds — `None` when it holds none there.
553pub fn listed<'a>(paths: impl Iterator<Item = &'a String>, folder: &str) -> Option<Vec<String>> {
554    let prefix = format!("{folder}/");
555    let mut names: Vec<String> = paths
556        .filter_map(|p| p.strip_prefix(&prefix))
557        .filter(|rest| !rest.contains('/'))
558        .map(str::to_string)
559        .collect();
560    names.sort();
561    (!names.is_empty()).then_some(names)
562}
563
564impl Files for HashMap<String, String> {
565    fn bytes(&self, path: &str) -> Option<Vec<u8>> {
566        self.get(path).map(|s| s.clone().into_bytes())
567    }
568    fn list(&self, path: &str) -> Option<Vec<String>> {
569        listed(self.keys(), path)
570    }
571}
572
573/// A resolved design: its tree, each distinct image file its `Image`s show
574/// and font file its texts are set in, and whether it is **themed** — whether any `theme.` reference reached its
575/// nodes.
576#[derive(Debug, Clone)]
577pub struct Scene {
578    pub root: Node,
579    pub images: ImageFiles,
580    pub fonts: FontFiles,
581    pub themed: bool,
582    /// Where each node is, by its id: what a patch finds its nodes by.
583    pub ids: Ids,
584}
585
586/// Two scenes are the same when they hold the same nodes, image and font
587/// files and themes, however their children were placed.
588impl PartialEq for Scene {
589    fn eq(&self, other: &Scene) -> bool {
590        self.root == other.root
591            && self.images == other.images
592            && self.fonts == other.fonts
593            && self.themed == other.themed
594    }
595}
596
597impl Scene {
598    /// A scene of `root` and its files, its ids indexed.
599    pub fn new(root: Node, images: ImageFiles, fonts: FontFiles, themed: bool) -> Scene {
600        let ids = Ids::of(&root);
601        Scene {
602            root,
603            images,
604            fonts,
605            themed,
606            ids,
607        }
608    }
609
610    /// The node with id `id`, if the scene has it.
611    pub fn node(&self, id: &NodeId) -> Option<&Node> {
612        self.at(self.ids.get(id)?)
613    }
614
615    /// The node at `order`, if any.
616    pub fn at(&self, order: &[OrderKey]) -> Option<&Node> {
617        let mut at = &self.root;
618        for k in order {
619            at = at.children.get(k)?;
620        }
621        Some(at)
622    }
623
624    /// The node at `order`, to change, if any.
625    pub fn at_mut(&mut self, order: &[OrderKey]) -> Option<&mut Node> {
626        let mut at = &mut self.root;
627        for k in order {
628            at = at.children.get_mut(k)?;
629        }
630        Some(at)
631    }
632}
633
634/// Where each node of a scene is, by its id: what a patch finds its nodes
635/// by.
636#[derive(Clone, Debug, Default)]
637pub struct Ids(HashMap<NodeId, Order>);
638
639impl Ids {
640    /// Every node of the tree under `root`.
641    pub fn of(root: &Node) -> Ids {
642        let mut ids = Ids::default();
643        ids.add(root, Vec::new());
644        ids
645    }
646
647    /// `n` and the nodes under it, `n` at `order`.
648    pub fn add(&mut self, n: &Node, order: Order) {
649        for (k, c) in n.children.entries() {
650            let mut o = order.clone();
651            o.push(k.clone());
652            self.add(c, o);
653        }
654        self.0.insert(n.id.clone(), order);
655    }
656
657    /// Forgets `n` and the nodes under it.
658    pub fn remove(&mut self, n: &Node) {
659        self.0.remove(&n.id);
660        for c in &n.children {
661            self.remove(c);
662        }
663    }
664
665    pub fn get(&self, id: &NodeId) -> Option<&Order> {
666        self.0.get(id)
667    }
668
669    pub fn contains(&self, id: &NodeId) -> bool {
670        self.0.contains_key(id)
671    }
672
673    pub fn len(&self) -> usize {
674        self.0.len()
675    }
676
677    pub fn is_empty(&self) -> bool {
678        self.0.is_empty()
679    }
680}
681
682/// A scene's image files by id, each decoded once however many `Image`s
683/// show it.
684pub type ImageFiles = std::collections::BTreeMap<ImageId, Arc<ImageFile>>;
685
686/// Image files compare by their identity, which is their bytes.
687impl PartialEq for ImageFile {
688    fn eq(&self, other: &ImageFile) -> bool {
689        self.id == other.id
690    }
691}
692
693mod edit;
694#[cfg(test)]
695mod tests;
696#[cfg(test)]
697pub(crate) use edit::Bare;
698pub use edit::{Applied, Edit, Edits, Holder, Undo, Uses, applied, apply, subtree};