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

commita1204b85f7b83346063aca3d53c735ce6d0de987
parent5819283a20
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-02 09:04
feat: inline image embeds

Reading view (MarkdownView): an image embed inside a sentence is one box
in the word flow, sized like a block embed (|300 honoured, fitted to the
column); its line grows to the image's height, text on the line's bottom
band, as an inline <img> sits. While the image is missing or loading the
embed shows as its link -- by file name when its alias is only a size.

Live preview (DocEditor): rows are one height, so a picture cannot sit
inside a text row; a line's inline embeds show in a row below it
(LineLayout::with_images_below) and the text stays. An embed whose alias
is only a size shows the file name, not "300".

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

 src/widget/doc_editor/layout.rs  |  21 +++++++
 src/widget/doc_editor/mod.rs     |  31 ++++++++++
 src/widget/doc_editor/preview.rs |  65 ++++++++++++++++++++-
 src/widget/markdown.rs           | 119 ++++++++++++++++++++++++++++++++++-----
 4 files changed, 221 insertions(+), 15 deletions(-)

diff --git a/src/widget/doc_editor/layout.rs b/src/widget/doc_editor/layout.rs
index 190ee98..eef780a 100644
--- a/src/widget/doc_editor/layout.rs
+++ b/src/widget/doc_editor/layout.rs
@@ -459,6 +459,27 @@ impl LineLayout {
         }
     }
 
+    /// The line with the images embedded inside it shown in a row below
+    /// it, left to right, wrapping at `width` (live preview cannot sit a
+    /// picture inside a text row: rows are one height).
+    pub fn with_images_below(mut self, images: &[(String, f32, f32)], width: f32) -> LineLayout {
+        let (mut x, mut y, mut row_h) = (0.0f32, self.height + IMAGE_PAD, 0.0f32);
+        for (target, w, h) in images {
+            if x > 0.0 && x + w > width {
+                y += row_h + IMAGE_PAD;
+                x = 0.0;
+                row_h = 0.0;
+            }
+            self.decos.push(Deco::Image { target: target.clone(), rect: Rect { x, y, width: *w, height: *h } });
+            x += w + 2.0 * IMAGE_PAD;
+            row_h = row_h.max(*h);
+        }
+        if !images.is_empty() {
+            self.height = y + row_h + IMAGE_PAD;
+        }
+        self
+    }
+
     /// 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;
diff --git a/src/widget/doc_editor/mod.rs b/src/widget/doc_editor/mod.rs
index ae91c30..acdf4ac 100644
--- a/src/widget/doc_editor/mod.rs
+++ b/src/widget/doc_editor/mod.rs
@@ -318,6 +318,18 @@ impl DocEditor {
                     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) };
                 }
+            } else if text.contains("![") {
+                // Embeds inside the text: their pictures in a row below it.
+                let width = self.width;
+                let found: Vec<(String, f32, f32)> = preview::inline_embeds(text)
+                    .into_iter()
+                    .filter_map(|(target, want_w, want_h)| {
+                        let img = self.images.as_ref().and_then(|f| f(&target))?;
+                        let (w, h) = img.fit(want_w, want_h, width);
+                        Some((target, w, h))
+                    })
+                    .collect();
+                l = l.with_images_below(&found, width);
             }
         }
         if (l.height - self.heights[i]).abs() > 0.01 {
@@ -997,6 +1009,25 @@ mod tests {
         assert!(!e.layouts[1].as_ref().unwrap().decos.iter().any(|d| matches!(d, Deco::Image { .. })));
     }
 
+    #[test]
+    fn embeds_inside_a_line_show_below_it() {
+        let mut e = DocEditor::new("text ![[a.png|100]] and ![[b.png|100]] more\nend", EditorTheme::new(14.0), false);
+        e.set_images(Box::new(|_| Some(EmbedImage { id: 3, width: 200, height: 100 })));
+        let mut pc = PaintCtx::new();
+        e.buf.caret = Pos::new(1, 0);
+        e.paint(&mut pc, Rect { x: 0.0, y: 0.0, width: 600.0, height: 400.0 }, true);
+        let l = e.layouts[0].as_ref().unwrap();
+        assert!(!l.runs.is_empty(), "the text stays");
+        let rects: Vec<Rect> = l.decos.iter().filter_map(|d| match d {
+            Deco::Image { rect, .. } => Some(*rect),
+            _ => None,
+        }).collect();
+        assert_eq!(rects.len(), 2);
+        assert_eq!((rects[0].width, rects[0].height), (100.0, 50.0));
+        assert!(rects[1].x > rects[0].x && rects[1].y == rects[0].y, "side by side");
+        assert!(l.height >= l.row_h + 50.0);
+    }
+
     #[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 d3e50ff..a453cab 100644
--- a/src/widget/doc_editor/preview.rs
+++ b/src/widget/doc_editor/preview.rs
@@ -67,6 +67,37 @@ pub fn standalone_embed(text: &str) -> Option<(String, Option<u32>, Option<u32>)
     Some((target, w, h))
 }
 
+/// Every embed in a line, in order — `![[…]]` and `![…](…)` anywhere in
+/// it — read as [`standalone_embed`] reads one.
+pub fn inline_embeds(text: &str) -> Vec<(String, Option<u32>, Option<u32>)> {
+    let mut out = Vec::new();
+    let mut i = 0;
+    while let Some(off) = text[i..].find("![") {
+        let at = i + off;
+        let rest = &text[at..];
+        let end = if rest.starts_with("![[") {
+            rest.find("]]").map(|e| e + 2)
+        } else {
+            rest.find("](").and_then(|m| rest[m..].find(')').map(|e| m + e + 1))
+        };
+        let Some(end) = end else { break };
+        if let Some(found) = standalone_embed(&rest[..end]) {
+            out.push(found);
+        }
+        i = at + end;
+    }
+    out
+}
+
+/// Whether an embed alias is Obsidian's size form (`300`, `300x200`).
+fn embed_size_spec(alias: &str) -> bool {
+    let num = |v: &str| !v.is_empty() && v.chars().all(|c| c.is_ascii_digit());
+    match alias.trim().split_once('x') {
+        Some((w, h)) => num(w.trim()) && num(h.trim()),
+        None => num(alias.trim()),
+    }
+}
+
 /// Block context per line: fences toggle code; a `---` first line opens
 /// frontmatter, closed by the next `---`.
 pub fn contexts(lines: &[String]) -> Vec<Context> {
@@ -642,10 +673,17 @@ fn inline(text: &str, from: usize, flags: &mut [u16], link_of: &mut [Option<usiz
         };
         let idx = links.len();
         links.push(Target::Note { target: t.trim().to_string(), subpath: sub.filter(|s| !s.is_empty()) });
-        let shown = alias_at.unwrap_or(o + 2)..c;
+        // An embed's alias that is only a size (`![[pic.png|300]]`) is not
+        // a caption: show the file name and hide the `|300`, as for links.
+        let size_alias = start < o && alias_at.is_some_and(|a| embed_size_spec(&text[a..c]));
+        let shown = match alias_at {
+            Some(a) if size_alias => o + 2..a - 1,
+            Some(a) => a..c,
+            None => o + 2..c,
+        };
         mark(flags, start..shown.start, MARKER);
         mark(flags, shown.clone(), LINK);
-        mark(flags, c..c + 2, MARKER);
+        mark(flags, shown.end..c + 2, MARKER);
         for x in &mut link_of[shown] {
             *x = Some(idx);
         }
@@ -777,6 +815,29 @@ fn find_run(b: &[u8], from: usize, c: u8, len: usize) -> Option<usize> {
 mod tests {
     use super::*;
 
+    #[test]
+    fn a_size_alias_shows_the_file_name() {
+        let shown = |t: &str| {
+            let l = style_line(t, Context::Normal, false);
+            l.segs.iter().filter(|g| g.look.link).map(|g| &t[g.range.clone()]).collect::<String>()
+        };
+        assert_eq!(shown("![[pic.png|300]]"), "pic.png");
+        assert_eq!(shown("![[pic.png|300x20]]"), "pic.png");
+        assert_eq!(shown("![[pic.png|a caption]]"), "a caption");
+        assert_eq!(shown("[[Note|300]]"), "300");
+    }
+
+    #[test]
+    fn inline_embeds_in_a_line() {
+        let found = inline_embeds("see ![[a.png|40]] and ![x](b%20c.png) then ![[N]] end");
+        assert_eq!(found, vec![
+            ("a.png".to_string(), Some(40), None),
+            ("b c.png".to_string(), None, None),
+            ("N".to_string(), None, None),
+        ]);
+        assert!(inline_embeds("no embeds ![ here").is_empty());
+    }
+
     #[test]
     fn standalone_embeds() {
         assert_eq!(standalone_embed("![[a.png]]"), Some(("a.png".into(), None, None)));
diff --git a/src/widget/markdown.rs b/src/widget/markdown.rs
index cddd3b5..50599cc 100644
--- a/src/widget/markdown.rs
+++ b/src/widget/markdown.rs
@@ -53,6 +53,12 @@ const HIGHLIGHT_BG: [f32; 4] = [1.0, 0.82, 0.0, 0.40];
 const RULE: [f32; 4] = [1.0, 1.0, 1.0, 0.12];
 const QUOTE_BAR: [f32; 4] = [0.66, 0.55, 0.98, 1.0];
 
+/// What an inline image's link text is swapped for while a paragraph is
+/// tokenized: one non-space char, so the image is one word.
+const IMAGE_MARK: &str = "\u{FFFC}";
+/// Space an inline image keeps above and below it on its line.
+const INLINE_IMAGE_PAD: f32 = 4.0;
+
 /// Indent of a list level, and of a quote's body past its bar.
 const INDENT: f32 = 24.0;
 const BLOCK_GAP: f32 = 10.0;
@@ -212,7 +218,7 @@ pub fn layout_with(
     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 mut l = Layouter { theme, m, resolved, image, out: Layout::default(), pending: Vec::new(), pending_images: Vec::new() };
     let y = l.blocks(blocks, 0.0, width.max(80.0), 0.0, true);
     l.out.height = y;
     l.out
@@ -226,6 +232,8 @@ struct Layouter<'a> {
     out: Layout,
     /// Runs finished on the current line, emitted when the line ends.
     pending: Vec<Run>,
+    /// Inline images placed on the current line: (link text, x, w, h).
+    pending_images: Vec<(String, f32, f32, f32)>,
 }
 
 /// One inline run's look, before it is placed.
@@ -492,6 +500,28 @@ impl<'a> Layouter<'a> {
     fn inline(&mut self, spans: &[Span], x: f32, w: f32, y: f32, base: RunStyle) -> f32 {
         let lh = self.theme.line_h(base.size);
         let right = x + w;
+        // An image embed inside the text is one box in the flow, the size
+        // its image fits at: its link text becomes a single placeholder
+        // word so the tokenizer cannot split it.
+        let boxes: Vec<Option<(f32, f32)>> = spans.iter().map(|s| self.inline_image(s, w)).collect();
+        // One with no image yet (loading, missing) shows as its link — by
+        // file name when its alias is only a size (`|300`), not "300".
+        let sized_link = |s: &Span| matches!(&s.link, Some(SpanLink::Embed { .. })) && cce_vault::markdown::embed_size(&s.text).is_some();
+        let owned: Vec<Span>;
+        let spans: &[Span] = if boxes.iter().any(Option::is_some) || spans.iter().any(sized_link) {
+            owned = spans
+                .iter()
+                .zip(&boxes)
+                .map(|(s, b)| match (&s.link, b) {
+                    (_, Some(_)) => Span { text: IMAGE_MARK.to_string(), ..s.clone() },
+                    (Some(SpanLink::Embed { target, .. }), None) if sized_link(s) => Span { text: target.clone(), ..s.clone() },
+                    _ => s.clone(),
+                })
+                .collect();
+            &owned
+        } else {
+            spans
+        };
         let looks: Vec<Look> = spans.iter().map(|s| self.look(s, base)).collect();
         let spaces: Vec<f32> = looks.iter().map(|l| self.space_width(l)).collect();
         let mut cx = x;
@@ -504,8 +534,7 @@ impl<'a> Layouter<'a> {
         for tok in tokens(spans) {
             match tok {
                 Tok::Break => {
-                    self.flush(&mut run, line_y, lh);
-                    line_y += lh;
+                    line_y += self.flush(&mut run, line_y, lh);
                     cx = x;
                     gap = None;
                 }
@@ -518,21 +547,37 @@ impl<'a> Layouter<'a> {
                     any = true;
                     let widths: Vec<f32> = segs
                         .iter()
-                        .map(|(t, si)| {
-                            let l = &looks[*si];
-                            self.m.width(t, l.size, &l.font, l.attrs)
+                        .map(|(t, si)| match boxes[*si] {
+                            Some((bw, _)) => bw,
+                            None => {
+                                let l = &looks[*si];
+                                self.m.width(t, l.size, &l.font, l.attrs)
+                            }
                         })
                         .collect();
                     let total: f32 = widths.iter().sum();
                     let mut lead = gap.take().unwrap_or(0.0);
                     if cx > x && cx + lead + total > right {
-                        self.flush(&mut run, line_y, lh);
-                        line_y += lh;
+                        line_y += self.flush(&mut run, line_y, lh);
                         cx = x;
                         lead = 0.0;
                     }
                     for ((text, si), mut ww) in segs.into_iter().zip(widths) {
                         let (look, span) = (&looks[si], &spans[si]);
+                        if let Some((bw, bh)) = boxes[si] {
+                            // The text run before it ends here.
+                            if let Some(r) = run.take() {
+                                self.pending.push(r);
+                            }
+                            let target = match &span.link {
+                                Some(SpanLink::Embed { target, .. }) => target.clone(),
+                                _ => unreachable!("only embeds get boxes"),
+                            };
+                            self.pending_images.push((target, cx + lead, bw, bh));
+                            cx += lead + bw;
+                            lead = 0.0;
+                            continue;
+                        }
                         // A word wider than the whole line (a URL) breaks
                         // by characters rather than overflowing.
                         let mut rest = text;
@@ -541,8 +586,7 @@ impl<'a> Layouter<'a> {
                             let (head, tail) = rest.split_at(cut);
                             let hw = self.m.width(head, look.size, &look.font, look.attrs);
                             self.place(&mut run, head, cx + lead, hw, lead, look, span);
-                            self.flush(&mut run, line_y, lh);
-                            line_y += lh;
+                            line_y += self.flush(&mut run, line_y, lh);
                             cx = x;
                             lead = 0.0;
                             rest = tail;
@@ -555,11 +599,20 @@ impl<'a> Layouter<'a> {
                 }
             }
         }
-        self.flush(&mut run, line_y, lh);
+        let last = self.flush(&mut run, line_y, lh);
         if !any && line_y == y {
             return y + lh;
         }
-        line_y + lh
+        line_y + last
+    }
+
+    /// The box an inline image embed takes in a column `w` wide, when the
+    /// host has its image (sized by its `|300` alias, as a block embed).
+    fn inline_image(&self, span: &Span, w: f32) -> Option<(f32, f32)> {
+        let Some(SpanLink::Embed { target, .. }) = &span.link else { return None };
+        let img = (self.image)(target)?;
+        let want = cce_vault::markdown::embed_size(&span.text);
+        Some(img.fit(want.map(|s| s.width), want.and_then(|s| s.height), w))
     }
 
     /// The longest char prefix of `word` fitting `w` (at least one char).
@@ -636,10 +689,21 @@ impl<'a> Layouter<'a> {
         *run = Some(Run { text: word.to_string(), x, w, look: look.clone(), link: span.link.clone() });
     }
 
-    fn flush(&mut self, run: &mut Option<Run>, line_y: f32, lh: f32) {
+    /// Emit the current line at `line_y`; returns its height — `lh`, or
+    /// taller when an inline image is. Text sits on the line's bottom `lh`
+    /// band and images stand on the same bottom, as inline images do.
+    fn flush(&mut self, run: &mut Option<Run>, line_y: f32, lh: f32) -> f32 {
         if let Some(r) = run.take() {
             self.pending.push(r);
         }
+        let images = std::mem::take(&mut self.pending_images);
+        let height = images.iter().fold(lh, |h, (_, _, _, ih)| h.max(*ih + INLINE_IMAGE_PAD));
+        for (target, ix, iw, ih) in images {
+            let rect = Rect { x: ix, y: line_y + height - ih - INLINE_IMAGE_PAD / 2.0, width: iw, height: ih };
+            self.out.draws.push(Draw::Image { target, rect });
+        }
+        // Text runs below sit in the bottom band.
+        let line_y = line_y + height - lh;
         for r in std::mem::take(&mut self.pending) {
             let ty = line_y + (lh - r.look.size) / 2.0;
             let box_ = Rect { x: r.x - 2.0, y: line_y + 2.0, width: r.w + 4.0, height: lh - 4.0 };
@@ -663,6 +727,7 @@ impl<'a> Layouter<'a> {
                 self.out.hits.push((Rect { x: r.x, y: line_y, width: r.w, height: lh }, Hit::Link(link)));
             }
         }
+        height
     }
 }
 
@@ -879,6 +944,34 @@ mod tests {
         assert!(texts(&l).iter().any(|(t, _, _)| t.contains("Note")));
     }
 
+    #[test]
+    fn inline_images_flow_with_the_text_and_heighten_their_line() {
+        let doc = blocks("ab ![[i.png|30]] cd\n\nnext\n");
+        let image = |t: &str| (t == "i.png").then_some(EmbedImage { id: 1, width: 60, height: 40 });
+        let l = layout_with(&doc, 1000.0, &theme(), &mut Fixed, &|_| true, &image);
+        let img = l.draws.iter().find_map(|d| match d {
+            Draw::Image { rect, .. } => Some(*rect),
+            _ => None,
+        });
+        let img = img.expect("an inline image");
+        // |30 keeps the aspect: 30 x 20, after "ab " (Fixed: 10 px a char).
+        assert_eq!((img.width, img.height), (30.0, 20.0));
+        let t = texts(&l);
+        let ab = t.iter().find(|(s, _, _)| s == "ab").unwrap();
+        let cd = t.iter().find(|(s, _, _)| s == "cd").unwrap();
+        assert!(img.x > ab.1 && cd.1 > img.x + img.width, "{t:?} {img:?}");
+        // The line grew for the image: text sits below its top.
+        assert!(ab.2 > img.y);
+        // Without the image the embed shows as its link: the file name, not
+        // the size alias.
+        let l2 = layout(&doc, 1000.0, &theme(), &mut Fixed, &|_| true);
+        assert!(texts(&l2).iter().any(|(s, _, _)| s.contains("i.png")), "{:?}", texts(&l2));
+        assert!(!texts(&l2).iter().any(|(s, _, _)| s.contains("30")));
+        let next_with = t.iter().find(|(s, _, _)| s == "next").unwrap().2;
+        let next_without = texts(&l2).iter().find(|(s, _, _)| s == "next").unwrap().2;
+        assert!(next_with > next_without, "the taller line pushes what follows down");
+    }
+
     #[test]
     fn hard_breaks_and_empty_input() {
         let doc = blocks("one\ntwo\n");