git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit9d5568edf6c63bc5f531a741d0843e2d47ab2e2b
parented5a688bc4
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-01 21:26
feat: image embeds in MarkdownView and DocEditor

A ![[pic.png]] (or ![](pic.png)) paragraph draws as the picture in the
reading view (markdown::layout_with / paint_with) and in live preview,
where the caret on the line shows the raw link with the image below it.
Obsidian's |300 and |300x200 sizes are honoured; an image wider than the
column is scaled down whole (EmbedImage::fit).

The host owns loading: both ask it by the embed's link text for an
EmbedImage (renderer id + size) -- at layout for the size, at paint for
the id, so a re-upload after a reconnect needs no relayout. Anything the
host does not have (not an image, still loading) keeps showing as the
link. DocEditor takes the lookup once (set_images); the host calls
invalidate() when an image arrives.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 src/widget/doc_editor/layout.rs  | 34 +++++++++++++++++++++
 src/widget/doc_editor/mod.rs     | 58 ++++++++++++++++++++++++++++++++++--
 src/widget/doc_editor/preview.rs | 51 ++++++++++++++++++++++++++++++++
 src/widget/markdown.rs           | 64 ++++++++++++++++++++++++++++++++++++++--
 src/widget/mod.rs                | 28 ++++++++++++++++++
 5 files changed, 230 insertions(+), 5 deletions(-)

diff --git a/src/widget/doc_editor/layout.rs b/src/widget/doc_editor/layout.rs
index 18c34c3..190ee98 100644
--- a/src/widget/doc_editor/layout.rs
+++ b/src/widget/doc_editor/layout.rs
@@ -111,6 +111,9 @@ pub enum Deco {
     Check { cx: f32, cy: f32, r: f32, checked: bool },
     /// A list number, drawn in the gutter.
     Text { text: String, x: f32, y: f32, size: f32, color: [f32; 4], font: String },
+    /// An embedded image, by its link text; the host's id is asked for at
+    /// paint.
+    Image { target: String, rect: Rect },
 }
 
 #[derive(Clone, Debug)]
@@ -432,7 +435,38 @@ fn color_for(look: &Look, th: &EditorTheme) -> [f32; 4] {
     }
 }
 
+/// Space above and below an embedded image.
+pub const IMAGE_PAD: f32 = 4.0;
+
 impl LineLayout {
+    /// A line shown as nothing but its embedded image (live preview, the
+    /// caret elsewhere): one row as tall as the image, so a click on it
+    /// lands on the line and reveals the raw text.
+    pub fn image(target: &str, w: f32, h: f32, text_size: f32) -> LineLayout {
+        let height = h + 2.0 * IMAGE_PAD;
+        LineLayout {
+            height,
+            row_h: height,
+            rows: 1,
+            runs: Vec::new(),
+            decos: vec![Deco::Image { target: target.to_string(), rect: Rect { x: 0.0, y: IMAGE_PAD, width: w, height: h } }],
+            links: Vec::new(),
+            task: None,
+            toggle: None,
+            content_x: 0.0,
+            content_start: 0,
+            text_size,
+        }
+    }
+
+    /// The raw line with its image shown below it (the caret on it).
+    pub fn with_image_below(mut self, target: &str, w: f32, h: f32) -> LineLayout {
+        let y = self.height + IMAGE_PAD;
+        self.decos.push(Deco::Image { target: target.to_string(), rect: Rect { x: 0.0, y, width: w, height: h } });
+        self.height = y + h + IMAGE_PAD;
+        self
+    }
+
     /// Where the caret before byte `col` is drawn: (x, row).
     pub fn caret_xy(&self, col: usize) -> (f32, usize) {
         let mut best: Option<(f32, usize)> = None;
diff --git a/src/widget/doc_editor/mod.rs b/src/widget/doc_editor/mod.rs
index 751b2d7..bfd1ff5 100644
--- a/src/widget/doc_editor/mod.rs
+++ b/src/widget/doc_editor/mod.rs
@@ -34,7 +34,7 @@ pub use preview::Target;
 use crate::scene::layout::Rect;
 use crate::scene::paint::{Cap, PaintCtx};
 use crate::widget::shaping::ShapingMeasure;
-use crate::widget::{Bounds, Key, KeyEvent, MouseScrollDelta, NamedKey, ScrollMotion};
+use crate::widget::{Bounds, EmbedImage, Key, KeyEvent, MouseScrollDelta, NamedKey, ScrollMotion};
 use layout::{Deco, LineLayout};
 use preview::{Context, Kind, Marker, Prop};
 
@@ -84,6 +84,8 @@ pub struct DocEditor {
     origin: (f32, f32),
     viewport: Rect,
     follow_caret: bool,
+    /// The host's embedded images, by link text (see [`DocEditor::set_images`]).
+    images: Option<Box<dyn Fn(&str) -> Option<EmbedImage>>>,
 }
 
 impl DocEditor {
@@ -113,6 +115,7 @@ impl DocEditor {
             origin: (0.0, 0.0),
             viewport: Rect { x: 0.0, y: 0.0, width: 0.0, height: 0.0 },
             follow_caret: false,
+            images: None,
         };
         e.sync();
         e.caret_to_body();
@@ -141,6 +144,18 @@ impl DocEditor {
         self.buf.text()
     }
 
+    /// Where embedded images come from: `images` answers an embed's link
+    /// text with the host's uploaded image, or `None` (not an image, or
+    /// not loaded yet — the line shows as text). In live preview a line
+    /// that is one embed shows as its image, and with the caret on it as
+    /// the raw text with the image below. Asked at layout for the size and
+    /// again at paint for the id; call [`DocEditor::invalidate`] when an
+    /// image arrives or changes size.
+    pub fn set_images(&mut self, images: Box<dyn Fn(&str) -> Option<EmbedImage>>) {
+        self.images = Some(images);
+        self.invalidate();
+    }
+
     /// Drop every layout (fonts or the theme changed).
     pub fn invalidate(&mut self) {
         for l in &mut self.layouts {
@@ -296,7 +311,15 @@ impl DocEditor {
             }
             _ => preview::style_line(text, self.ctx[i], active),
         };
-        let l = layout::layout_line(text, &line, active, self.width, &self.theme, &mut self.measure);
+        let mut l = layout::layout_line(text, &line, active, self.width, &self.theme, &mut self.measure);
+        if self.preview && self.ctx[i] == Context::Normal {
+            if let Some((target, want_w, want_h)) = preview::standalone_embed(text) {
+                if let Some(img) = self.images.as_ref().and_then(|f| f(&target)) {
+                    let (w, h) = img.fit(want_w, want_h, self.width);
+                    l = if active { l.with_image_below(&target, w, h) } else { LineLayout::image(&target, w, h, l.text_size) };
+                }
+            }
+        }
         if (l.height - self.heights[i]).abs() > 0.01 {
             self.heights[i] = l.height;
             self.tops_dirty = true;
@@ -881,6 +904,13 @@ impl DocEditor {
                         Deco::Text { text, x, y, size, color, font } => {
                             pc.text_with(text.clone(), ox + x, top + y, *size, srgb_u8(*color), Some(font.clone()), None)
                         }
+                        Deco::Image { target, rect: r } => {
+                            let at = Rect { x: ox + r.x, y: top + r.y, width: r.width, height: r.height };
+                            match self.images.as_ref().and_then(|f| f(target)) {
+                                Some(img) => pc.image(img.id, at, 1.0),
+                                None => pc.rounded_rect(at, 4.0, (true, true, true, true), th.code_bg),
+                            }
+                        }
                     }
                 }
                 for r in &l.runs {
@@ -941,6 +971,30 @@ mod tests {
         e
     }
 
+    #[test]
+    fn an_embed_line_shows_its_image_and_the_raw_text_with_the_caret() {
+        let mut e = DocEditor::new("top\n![[pic.png|200]]\nend", EditorTheme::new(14.0), false);
+        e.set_images(Box::new(|t: &str| (t == "pic.png").then_some(EmbedImage { id: 3, width: 800, height: 400 })));
+        let rect = Rect { x: 0.0, y: 0.0, width: 600.0, height: 400.0 };
+        let mut pc = PaintCtx::new();
+        e.paint(&mut pc, rect, true);
+        let l = e.layouts[1].as_ref().unwrap();
+        assert!(l.runs.is_empty(), "inactive: the image alone");
+        assert!(matches!(&l.decos[0], Deco::Image { rect, .. } if rect.width == 200.0 && rect.height == 100.0));
+        assert_eq!(l.height, 100.0 + 2.0 * layout::IMAGE_PAD);
+        // The caret on it: raw text, image below.
+        e.buf.caret = Pos::new(1, 0);
+        e.buf.anchor = None;
+        e.paint(&mut pc, rect, true);
+        let l = e.layouts[1].as_ref().unwrap();
+        assert!(!l.runs.is_empty());
+        assert!(l.decos.iter().any(|d| matches!(d, Deco::Image { .. })));
+        // Source mode: just text.
+        e.set_preview(false);
+        e.paint(&mut pc, rect, true);
+        assert!(!e.layouts[1].as_ref().unwrap().decos.iter().any(|d| matches!(d, Deco::Image { .. })));
+    }
+
     #[test]
     fn typing_enter_and_list_continuation() {
         let mut e = editor("");
diff --git a/src/widget/doc_editor/preview.rs b/src/widget/doc_editor/preview.rs
index 4cc9cd3..d3e50ff 100644
--- a/src/widget/doc_editor/preview.rs
+++ b/src/widget/doc_editor/preview.rs
@@ -27,6 +27,46 @@ pub enum Context {
     Frontmatter,
 }
 
+/// A line that is nothing but one embed — `![[pic.png|300]]` or
+/// `![alt|300x200](pic%20one.png)` — as (link text, wanted width, wanted
+/// height), the size read as Obsidian reads it. `None` for anything else,
+/// URLs included.
+pub fn standalone_embed(text: &str) -> Option<(String, Option<u32>, Option<u32>)> {
+    let t = text.trim();
+    let (target, alias) = if let Some(inner) = t.strip_prefix("![[").and_then(|r| r.strip_suffix("]]")) {
+        if inner.contains("]]") || inner.contains("[[") {
+            return None;
+        }
+        match inner.split_once('|') {
+            Some((target, alias)) => (target.to_string(), alias),
+            None => (inner.to_string(), ""),
+        }
+    } else {
+        let rest = t.strip_prefix("![")?;
+        let (alt, dest) = rest.split_once("](")?;
+        let dest = dest.strip_suffix(')')?.trim();
+        let dest = dest.strip_prefix('<').and_then(|d| d.strip_suffix('>')).unwrap_or(dest);
+        if dest.contains("://") || dest.contains(')') || alt.contains(']') {
+            return None;
+        }
+        (dest.replace("%20", " "), alt)
+    };
+    let target = target.split('#').next().unwrap_or("").trim().to_string();
+    if target.is_empty() {
+        return None;
+    }
+    let spec = alias.rsplit('|').next().unwrap_or(alias).trim();
+    let num = |v: &str| v.trim().parse::<u32>().ok().filter(|n| *n > 0);
+    let (w, h) = match spec.split_once('x') {
+        Some((w, h)) => match (num(w), num(h)) {
+            (Some(w), Some(h)) => (Some(w), Some(h)),
+            _ => (None, None),
+        },
+        None => (num(spec), None),
+    };
+    Some((target, w, h))
+}
+
 /// Block context per line: fences toggle code; a `---` first line opens
 /// frontmatter, closed by the next `---`.
 pub fn contexts(lines: &[String]) -> Vec<Context> {
@@ -737,6 +777,17 @@ fn find_run(b: &[u8], from: usize, c: u8, len: usize) -> Option<usize> {
 mod tests {
     use super::*;
 
+    #[test]
+    fn standalone_embeds() {
+        assert_eq!(standalone_embed("![[a.png]]"), Some(("a.png".into(), None, None)));
+        assert_eq!(standalone_embed("  ![[Pics/a.png|300]] "), Some(("Pics/a.png".into(), Some(300), None)));
+        assert_eq!(standalone_embed("![[a.png|300x200]]"), Some(("a.png".into(), Some(300), Some(200))));
+        assert_eq!(standalone_embed("![cap|120](my%20pic.png)"), Some(("my pic.png".into(), Some(120), None)));
+        assert_eq!(standalone_embed("![](https://x.y/a.png)"), None);
+        assert_eq!(standalone_embed("see ![[a.png]]"), None);
+        assert_eq!(standalone_embed("![[a.png]] and ![[b.png]]"), None);
+    }
+
     /// The shown text of each segment, `[marker]` for dimmed markup.
     fn shown(text: &str, line: &Line) -> Vec<String> {
         line.segs
diff --git a/src/widget/markdown.rs b/src/widget/markdown.rs
index 1946a4e..cddd3b5 100644
--- a/src/widget/markdown.rs
+++ b/src/widget/markdown.rs
@@ -19,7 +19,8 @@
 
 use crate::scene::layout::Rect;
 use crate::scene::paint::{Cap, PaintCtx, TextAttrs};
-pub use cce_vault::markdown::{blocks, Block, Callout, ListItem, Span, SpanLink, Style};
+pub use cce_vault::markdown::{blocks, Block, Callout, EmbedSize, ListItem, Span, SpanLink, Style};
+pub use crate::widget::EmbedImage;
 
 pub use crate::widget::shaping::{Measure, ShapingMeasure};
 
@@ -74,13 +75,15 @@ pub enum Draw {
     /// A filled disc: a list bullet. Not a `Round` — at a few px the
     /// squircle corner shape draws a small radius as a square.
     Dot { cx: f32, cy: f32, r: f32, color: [f32; 4] },
+    /// An embedded image, by its link text; its id is asked for at paint.
+    Image { target: String, rect: Rect },
 }
 
 impl Draw {
     fn top_bottom(&self) -> (f32, f32) {
         match self {
             Draw::Text { y, size, .. } => (*y, y + size * 1.3),
-            Draw::Quad { rect, .. } | Draw::Round { rect, .. } => (rect.y, rect.y + rect.height),
+            Draw::Quad { rect, .. } | Draw::Round { rect, .. } | Draw::Image { rect, .. } => (rect.y, rect.y + rect.height),
             Draw::Line { y1, y2, width, .. } => (y1.min(*y2) - width, y1.max(*y2) + width),
             Draw::Check { cy, r, .. } | Draw::Dot { cy, r, .. } => (cy - r, cy + r),
         }
@@ -120,6 +123,18 @@ impl Layout {
     /// virtual desktop) and draws at another resolution. Culled to
     /// `viewport`, in the painted space.
     pub fn paint_scaled(&self, pc: &mut PaintCtx, origin: (f32, f32), k: f32, viewport: Rect) {
+        self.paint_scaled_with(pc, origin, k, viewport, &|_| None);
+    }
+
+    /// [`Layout::paint`], drawing embedded images through `image` — the
+    /// same lookup the layout was made with ([`layout_with`]).
+    pub fn paint_with(&self, pc: &mut PaintCtx, origin: (f32, f32), scroll: f32, viewport: Rect, image: &dyn Fn(&str) -> Option<EmbedImage>) {
+        self.paint_scaled_with(pc, (origin.0, origin.1 - scroll), 1.0, viewport, image);
+    }
+
+    /// [`Layout::paint_scaled`] with embedded images. An image the host no
+    /// longer has (gone since layout) leaves a faint placeholder.
+    pub fn paint_scaled_with(&self, pc: &mut PaintCtx, origin: (f32, f32), k: f32, viewport: Rect, image: &dyn Fn(&str) -> Option<EmbedImage>) {
         let (ox, oy) = origin;
         let (top, bottom) = ((viewport.y - oy) / k, (viewport.y + viewport.height - oy) / k);
         let at = |x: f32, y: f32| (ox + x * k, oy + y * k);
@@ -153,6 +168,10 @@ impl Layout {
                     let (x, y) = at(*cx, *cy);
                     pc.circle(x, y, r * k, *color)
                 }
+                Draw::Image { target, rect: r } => match image(target) {
+                    Some(img) => pc.image(img.id, rect(r), 1.0),
+                    None => pc.rounded_rect(rect(r), 4.0 * k, (true, true, true, true), CODE_BG),
+                },
             }
         }
     }
@@ -177,7 +196,23 @@ pub fn layout(
     m: &mut dyn Measure,
     resolved: &dyn Fn(&SpanLink) -> bool,
 ) -> Layout {
-    let mut l = Layouter { theme, m, resolved, out: Layout::default(), pending: Vec::new() };
+    layout_with(blocks, width, theme, m, resolved, &|_| None)
+}
+
+/// [`layout`], with `image` answering for an embed's link text: an image
+/// it has draws in place of the embed's link, sized by [`EmbedImage::fit`]
+/// (Obsidian's `|300` / `|300x200` honoured). One it does not have — not
+/// an image, or still loading — keeps the link; lay out again when it
+/// arrives.
+pub fn layout_with(
+    blocks: &[Block],
+    width: f32,
+    theme: &Theme,
+    m: &mut dyn Measure,
+    resolved: &dyn Fn(&SpanLink) -> bool,
+    image: &dyn Fn(&str) -> Option<EmbedImage>,
+) -> Layout {
+    let mut l = Layouter { theme, m, resolved, image, out: Layout::default(), pending: Vec::new() };
     let y = l.blocks(blocks, 0.0, width.max(80.0), 0.0, true);
     l.out.height = y;
     l.out
@@ -187,6 +222,7 @@ struct Layouter<'a> {
     theme: &'a Theme,
     m: &'a mut dyn Measure,
     resolved: &'a dyn Fn(&SpanLink) -> bool,
+    image: &'a dyn Fn(&str) -> Option<EmbedImage>,
     out: Layout,
     /// Runs finished on the current line, emitted when the line ends.
     pending: Vec<Run>,
@@ -239,6 +275,12 @@ impl<'a> Layouter<'a> {
                 end
             }
             Block::Paragraph { spans, .. } => self.inline(spans, x, w, y, self.plain_style()),
+            Block::Embed { target, size: want, .. } if (self.image)(target).is_some() => {
+                let img = (self.image)(target).expect("checked by the guard");
+                let (iw, ih) = img.fit(want.map(|s| s.width), want.and_then(|s| s.height), w);
+                self.out.draws.push(Draw::Image { target: target.clone(), rect: Rect { x, y, width: iw, height: ih } });
+                y + ih
+            }
             Block::Embed { target, subpath, .. } => {
                 let shown = match subpath {
                     Some(s) => format!("↳ {target}#{s}"),
@@ -821,6 +863,22 @@ mod tests {
         assert!(l.y_of_line(5) > l.y_of_line(2));
     }
 
+    #[test]
+    fn image_embeds_draw_sized_and_others_stay_links() {
+        let doc = blocks("![[a.png|200]]\n\n![[big.png]]\n\n![[Note]]\n");
+        let image = |t: &str| (t != "Note").then_some(EmbedImage { id: 7, width: 1000, height: 500 });
+        let l = layout_with(&doc, 400.0, &theme(), &mut Fixed, &|_| true, &image);
+        let rects: Vec<Rect> = l.draws.iter().filter_map(|d| match d {
+            Draw::Image { rect, .. } => Some(*rect),
+            _ => None,
+        }).collect();
+        assert_eq!((rects[0].width, rects[0].height), (200.0, 100.0));
+        // Wider than the column: scaled down whole.
+        assert_eq!((rects[1].width, rects[1].height), (400.0, 200.0));
+        assert_eq!(rects.len(), 2);
+        assert!(texts(&l).iter().any(|(t, _, _)| t.contains("Note")));
+    }
+
     #[test]
     fn hard_breaks_and_empty_input() {
         let doc = blocks("one\ntwo\n");
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index 4828e7b..ea9710f 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -593,6 +593,34 @@ pub mod layout_helper;
 pub mod shaping;
 #[cfg(feature = "markdown")]
 pub mod markdown;
+
+/// An image a host has ready for a Markdown embed (`![[pic.png]]`): its id
+/// from `vk::upload_rgba` and its size in px. `MarkdownView` and
+/// `DocEditor` ask the host for one by the embed's link text — sizing at
+/// layout, the id again at paint, so a re-upload after a reconnect needs
+/// no relayout.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct EmbedImage {
+    pub id: u32,
+    pub width: u32,
+    pub height: u32,
+}
+
+impl EmbedImage {
+    /// The size it draws at in a column `max_w` wide: the requested width
+    /// (else its own), the requested height (else the aspect's), scaled
+    /// down as a whole to fit the column.
+    pub fn fit(&self, want_w: Option<u32>, want_h: Option<u32>, max_w: f32) -> (f32, f32) {
+        let (iw, ih) = (self.width.max(1) as f32, self.height.max(1) as f32);
+        let w = want_w.map_or(iw, |w| w as f32);
+        let h = want_h.map_or(w * ih / iw, |h| h as f32);
+        if w > max_w {
+            (max_w, h * max_w / w)
+        } else {
+            (w, h)
+        }
+    }
+}
 #[cfg(feature = "doc_editor")]
 pub mod doc_editor;
 pub mod model;