git.lucas.co / cce-notes
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]]`, `![](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(&note, "![[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 }