blockworx/document/image.rs
1use egui::Rect;
2use internment::ArcIntern;
3
4/// The image a [`Image`] draws: either an SVG document (kept verbatim) or a PNG
5/// whose bytes are embedded in the document, so an image always saves and loads
6/// self-contained.
7#[derive(Clone, Debug, PartialEq, Eq, Hash)]
8pub enum ImageData {
9 /// Verbatim SVG document text, as read from the source file.
10 Svg(String),
11 /// Embedded PNG bytes, as read from the source file.
12 Png(Vec<u8>),
13}
14
15/// Interned image content: every placement holding the same image shares one
16/// allocation, process-wide and across undo snapshots. Equality and hashing are
17/// pointer operations, which is what keeps the per-frame document comparison off
18/// the image bytes; the allocation is freed when the last placement drops.
19///
20/// Content is immutable — an image is replaced, never edited in place.
21pub type Asset = ArcIntern<ImageData>;
22
23/// A free-floating image annotation drawn stretched to fill its rectangle
24/// [`inner`](Self::inner) — the image's aspect ratio is not preserved, so the box
25/// can be resized freely by its corners (a deliberate choice; an aspect-lock is a
26/// future opt-in).
27///
28/// [`image`](Self::image) is opaque interned content we never edit — either
29/// verbatim SVG text or embedded PNG bytes (see [`ImageData`]). A `Image` is used
30/// two ways: as a background [`Image`](super::Block::images) annotation stored
31/// per-block (like [`TextBox`](super::TextBox)es and [`Comment`](super::Comment)s
32/// — no pins, no routing, not in the hierarchy), and as a block's foreground
33/// [`icon`](super::Block::icon). The render backend caches the image bytes
34/// internally (a `bytes → ImageHandle` table inside the canvas), so the model
35/// never holds a canvas handle.
36#[derive(Clone, Debug, PartialEq)]
37pub struct Image {
38 /// The drawn image.
39 pub image: Asset,
40 /// The rectangle the image is stretched to fill. Unlike blocks and comments,
41 /// images/icons are *not* grid-quantized — they size and position freely, so
42 /// this is a plain [`Rect`], not a `GridRect`.
43 pub inner: Rect,
44}
45
46impl Image {
47 /// A new image holding `image`, covering `rect` (free, no grid snapping).
48 pub fn new(image: impl Into<Asset>, rect: Rect) -> Self {
49 Self {
50 image: image.into(),
51 inner: rect,
52 }
53 }
54}
55
56#[cfg(test)]
57mod tests {
58 use super::*;
59
60 /// Interning is global, and so are refcounts: content unique to each test
61 /// keeps concurrently-running tests from perturbing each other's counts.
62 fn svg(marker: &str) -> ImageData {
63 ImageData::Svg(format!("<svg viewBox=\"0 0 1 1\"><!--{marker}--></svg>"))
64 }
65
66 #[test]
67 fn equal_content_is_one_allocation_and_distinct_content_is_not() {
68 let a = Asset::from(svg("equal-content"));
69 let b = Asset::from(svg("equal-content"));
70 // `ArcIntern`'s equality is pointer equality, so this *is* the identity
71 // assertion — and the refcount proves one allocation backs both.
72 assert_eq!(a, b);
73 assert_eq!(a.refcount(), 2);
74 assert_ne!(a, Asset::from(svg("other-content")));
75 }
76
77 #[test]
78 fn a_clone_shares_the_original_allocation() {
79 // The property the undo stack rides on: cloning a document (and with it
80 // every image placement) copies refcounts, not image bytes.
81 let placement = Image::new(svg("clone-shares"), Rect::ZERO);
82 let before = placement.image.refcount();
83 let copy = placement.clone();
84 assert_eq!(copy.image, placement.image);
85 assert_eq!(placement.image.refcount(), before + 1);
86 }
87
88 #[test]
89 fn dropping_the_last_placement_frees_the_content() {
90 let kept = Asset::from(svg("freed-when-unused"));
91 {
92 let _placement = Image::new(svg("freed-when-unused"), Rect::ZERO);
93 assert_eq!(kept.refcount(), 2);
94 }
95 assert_eq!(kept.refcount(), 1);
96 }
97}