GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
feat: image embeds in MarkdownView and DocEditor
A ![[pic.png]] (or ) 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
+/// `` — 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 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(""), Some(("my pic.png".into(), Some(120), None)));
+ assert_eq!(standalone_embed(""), 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;