notes editor over the vault (Obsidian-compatible)
git clone https://git.lucas.co/cce-notes.git
src/images.rs (10.9K)
1 //! Embedded images (`![[pic.png]]`, ``) for the reading view
2 //! and the live-preview editor.
3 //!
4 //! Both ask by an embed's **link text** while they lay out and paint
5 //! ([`Images::lookup`], shared with the editor through an `Rc`). A link not
6 //! seen yet is only recorded; after the frame, [`Images::pump`] resolves it
7 //! the way every vault link resolves (`Index::resolve_text`, relative to the
8 //! open note), and an image file is decoded on a thread. The decode comes
9 //! back as [`crate::Message::ImageDecoded`] — which also wakes an idle loop —
10 //! and [`Images::decoded`] uploads it; the host then lays out again so the
11 //! link becomes the picture. A decode that fails leaves the link showing.
12 //!
13 //! Ids belong to one renderer: [`Images::renderer_reset`] forgets every one
14 //! after a reconnect and the next frame asks again. Decoded images are kept
15 //! for the last [`KEEP`] used, so walking back and forth between notes does
16 //! not decode the same picture twice.
17
18 use std::cell::RefCell;
19 use std::collections::{HashMap, HashSet};
20 use std::path::{Path, PathBuf};
21 use std::rc::Rc;
22
23 use cce_ui::widget::EmbedImage;
24 use cce_vault::Index;
25
26 /// Decoded images kept across notes.
27 const KEEP: usize = 32;
28 /// The largest texture side: a column is under 800 logical px, 1600 at
29 /// output scale 2, so more is memory nobody sees.
30 const MAX_TEX: u32 = 2048;
31
32 /// A decoded image, straight RGBA8, and the size it shows at before any
33 /// fitting — its own pixels for a raster (even when the texture was made
34 /// smaller), its intrinsic size for an SVG.
35 #[derive(Clone)]
36 pub struct Decoded {
37 pub pixels: Vec<u8>,
38 pub tex: (u32, u32),
39 pub natural: (u32, u32),
40 }
41
42 impl std::fmt::Debug for Decoded {
43 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44 write!(f, "Decoded {{ tex: {:?}, natural: {:?} }}", self.tex, self.natural)
45 }
46 }
47
48 enum Entry {
49 Loading,
50 Failed,
51 Ready { image: EmbedImage, used: u64 },
52 }
53
54 #[derive(Default)]
55 struct Inner {
56 /// Link text → vault path, for the open note (`None`: not an image, or
57 /// it resolves nowhere). Cleared when the note or the vault changes.
58 links: HashMap<String, Option<String>>,
59 /// Link texts asked for that `links` does not know yet.
60 asked: HashSet<String>,
61 entries: HashMap<String, Entry>,
62 clock: u64,
63 }
64
65 #[derive(Clone, Default)]
66 pub struct Images(Rc<RefCell<Inner>>);
67
68 impl Images {
69 /// The uploaded image an embed's link text shows, if it is ready.
70 pub fn lookup(&self, link: &str) -> Option<EmbedImage> {
71 let mut inner = self.0.borrow_mut();
72 let path = match inner.links.get(link) {
73 Some(Some(p)) => p.clone(),
74 Some(None) => return None,
75 None => {
76 inner.asked.insert(link.to_string());
77 return None;
78 }
79 };
80 inner.clock += 1;
81 let now = inner.clock;
82 match inner.entries.get_mut(&path) {
83 Some(Entry::Ready { image, used }) => {
84 *used = now;
85 Some(*image)
86 }
87 _ => None,
88 }
89 }
90
91 /// Resolve what was asked for since the last pump and start decoding
92 /// the images among it. `done` carries a decode back to the app. True
93 /// when a link resolved to an image already decoded: it showed as a
94 /// link this frame, and the host must lay out again to show it.
95 pub fn pump(&self, index: &Index, current: Option<&str>, done: impl Fn(String, Option<Decoded>) + Clone + Send + 'static) -> bool {
96 let mut inner = self.0.borrow_mut();
97 let asked: Vec<String> = inner.asked.drain().collect();
98 let mut ready = false;
99 for link in asked {
100 let path = index.resolve_text(current, &link).filter(|p| cce_vault::markdown::is_image(p));
101 inner.links.insert(link, path.clone());
102 let Some(path) = path else { continue };
103 if let Some(e) = inner.entries.get(&path) {
104 ready |= matches!(e, Entry::Ready { .. });
105 continue;
106 }
107 inner.entries.insert(path.clone(), Entry::Loading);
108 let abs = index.abs(&path);
109 let done = done.clone();
110 std::thread::spawn(move || {
111 let decoded = decode(&abs);
112 if decoded.is_none() {
113 log::warn!("could not decode {}", abs.display());
114 }
115 done(path, decoded);
116 });
117 }
118 ready
119 }
120
121 /// A decode finished: upload it. True when the image is new to the
122 /// screen (the host lays out again).
123 pub fn decoded(&self, path: String, decoded: Option<Decoded>) -> bool {
124 let mut inner = self.0.borrow_mut();
125 // Dropped (vault change, renderer reset) while it decoded.
126 if !matches!(inner.entries.get(&path), Some(Entry::Loading)) {
127 return false;
128 }
129 let Some(d) = decoded else {
130 inner.entries.insert(path, Entry::Failed);
131 return false;
132 };
133 let id = cce_ui::vk::upload_rgba_mipmapped(d.pixels, d.tex.0, d.tex.1);
134 inner.clock += 1;
135 let used = inner.clock;
136 let image = EmbedImage { id, width: d.natural.0, height: d.natural.1 };
137 inner.entries.insert(path, Entry::Ready { image, used });
138 evict(&mut inner);
139 true
140 }
141
142 /// The open note changed: its links resolve afresh.
143 pub fn note_changed(&self) {
144 self.0.borrow_mut().links.clear();
145 }
146
147 /// Files changed in the vault: a changed image decodes again, and with
148 /// `relink` — the vault's set of files changed (one added or removed),
149 /// or an image did — links resolve afresh, since only then can one
150 /// point somewhere new. True when an image on screen went away.
151 ///
152 /// Until 2026-10-06 every change relinked, and the open note's own
153 /// autosave comes back as one: each embed then failed its lookup for a
154 /// frame (drawn as its link text) and the pump that resolved it again
155 /// asked the editor for a second layout — a flicker per save.
156 pub fn vault_changed(&self, index: &Index, paths: &[PathBuf], relink: bool) -> bool {
157 let mut inner = self.0.borrow_mut();
158 if relink {
159 inner.links.clear();
160 }
161 let mut dropped = false;
162 for p in paths {
163 let Some(rel) = index.rel(p) else { continue };
164 if let Some(Entry::Ready { image, .. }) = inner.entries.remove(&rel) {
165 cce_ui::vk::free_image(image.id);
166 dropped = true;
167 }
168 }
169 dropped
170 }
171
172 /// A new renderer: every id is dead (nothing to free). Forget them all.
173 pub fn renderer_reset(&self) {
174 let mut inner = self.0.borrow_mut();
175 inner.entries.clear();
176 inner.links.clear();
177 }
178 }
179
180 /// Free the least recently drawn images past [`KEEP`].
181 fn evict(inner: &mut Inner) {
182 let mut ready: Vec<(u64, String)> = inner
183 .entries
184 .iter()
185 .filter_map(|(p, e)| match e {
186 Entry::Ready { used, .. } => Some((*used, p.clone())),
187 _ => None,
188 })
189 .collect();
190 if ready.len() <= KEEP {
191 return;
192 }
193 ready.sort();
194 for (_, path) in &ready[..ready.len() - KEEP] {
195 if let Some(Entry::Ready { image, .. }) = inner.entries.remove(path) {
196 cce_ui::vk::free_image(image.id);
197 }
198 }
199 }
200
201 /// Read and decode an image file (off the UI thread).
202 pub fn decode(path: &Path) -> Option<Decoded> {
203 let bytes = std::fs::read(path).ok()?;
204 let is_svg = path.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("svg"));
205 if is_svg {
206 return decode_svg(&bytes);
207 }
208 let img = image::load_from_memory(&bytes).ok()?;
209 let natural = (img.width(), img.height());
210 let img = if natural.0.max(natural.1) > MAX_TEX {
211 img.resize(MAX_TEX, MAX_TEX, image::imageops::FilterType::Triangle)
212 } else {
213 img
214 };
215 let rgba = img.into_rgba8();
216 let tex = rgba.dimensions();
217 Some(Decoded { pixels: rgba.into_raw(), tex, natural })
218 }
219
220 /// An SVG shows at its own size; it is rasterised at twice that (sharp at
221 /// output scale 2), within [`MAX_TEX`].
222 fn decode_svg(bytes: &[u8]) -> Option<Decoded> {
223 let tree = resvg::usvg::Tree::from_data(bytes, &resvg::usvg::Options::default(), cce_ui::widget::get_font_db()).ok()?;
224 let size = tree.size();
225 let natural = (size.width().round().max(1.0) as u32, size.height().round().max(1.0) as u32);
226 let px = (natural.0.max(natural.1) * 2).min(MAX_TEX);
227 let (pixels, w, h) = cce_ui::rasterize_svg(bytes, px)?;
228 Some(Decoded { pixels, tex: (w, h), natural })
229 }
230
231 #[cfg(test)]
232 mod tests {
233 use super::*;
234
235 #[test]
236 fn decodes_and_shrinks_big_rasters() {
237 let dir = tempfile::tempdir().unwrap();
238 let small = dir.path().join("s.png");
239 image::RgbaImage::new(30, 20).save(&small).unwrap();
240 let d = decode(&small).unwrap();
241 assert_eq!((d.tex, d.natural), ((30, 20), (30, 20)));
242 assert_eq!(d.pixels.len(), 30 * 20 * 4);
243 let big = dir.path().join("b.png");
244 image::RgbaImage::new(4096, 1024).save(&big).unwrap();
245 let d = decode(&big).unwrap();
246 assert_eq!((d.tex, d.natural), ((2048, 512), (4096, 1024)));
247 assert!(decode(&dir.path().join("missing.png")).is_none());
248 }
249
250 #[test]
251 fn decodes_svg_at_its_own_size() {
252 let dir = tempfile::tempdir().unwrap();
253 let p = dir.path().join("a.svg");
254 std::fs::write(&p, r#"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"><rect width="40" height="20" fill="red"/></svg>"#).unwrap();
255 let d = decode(&p).unwrap();
256 assert_eq!((d.natural, d.tex), ((40, 20), (80, 40)));
257 }
258
259 #[test]
260 fn a_note_edit_keeps_resolved_links_and_an_image_change_drops_them() {
261 let dir = tempfile::tempdir().unwrap();
262 let note = dir.path().join("n.md");
263 std::fs::write(¬e, "![[pic.png]]\n").unwrap();
264 image::RgbaImage::new(4, 4).save(dir.path().join("pic.png")).unwrap();
265 let ix = Index::open(dir.path(), false).unwrap();
266 let images = Images::default();
267 assert_eq!(images.lookup("pic.png"), None);
268 images.pump(&ix, Some("n.md"), |_, _| {});
269 assert_eq!(images.0.borrow().links.get("pic.png"), Some(&Some("pic.png".to_string())));
270 // The note's own autosave: no file came or went.
271 images.vault_changed(&ix, &[note.clone()], false);
272 assert!(images.0.borrow().links.contains_key("pic.png"), "an edit to a note dropped its embeds' links");
273 // The picture itself changed (or a file came or went): relink.
274 images.vault_changed(&ix, &[dir.path().join("pic.png")], true);
275 assert!(!images.0.borrow().links.contains_key("pic.png"));
276 }
277
278 #[test]
279 fn lookups_are_recorded_until_pumped() {
280 let images = Images::default();
281 assert_eq!(images.lookup("a.png"), None);
282 assert!(images.0.borrow().asked.contains("a.png"));
283 }
284 }