GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
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  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");