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

commit7aef8b42627431dda7dd7572931472fd144a011d
parente53f678492
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-01 15:09
feat: DocEditor shows frontmatter as the Properties table

While the caret is outside the frontmatter, each line is a row of a
Properties table: a header, keys in the reading view's key column,
inline-styled values (links follow), list values as pills (a one-per-
line YAML list draws its key on the first item), and true/false as a
checkbox that flips the bytes in place. The caret anywhere in the block
shows it raw, as a fenced block does; unclosed blocks, nested maps and
block scalars stay raw. A loaded note's caret starts past the block.

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

 CLAUDE.md                        |  11 ++
 src/widget/doc_editor/layout.rs  |  92 ++++++++++++--
 src/widget/doc_editor/mod.rs     |  89 ++++++++++++-
 src/widget/doc_editor/preview.rs | 268 +++++++++++++++++++++++++++++++++++++++
 4 files changed, 448 insertions(+), 12 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 0869bc9..6c47ff1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -849,6 +849,17 @@ Two opt-in features for the clients that show notes (Obsidian-on-cce):
     widths. `widget::shaping` holds `Measure` / `ShapingMeasure`, shared
     with the reading view; a width includes trailing spaces (max of glyph
     x + w), which is what a run placed after it needs.
+  - **Frontmatter is the Properties table** while the caret is outside
+    it (`preview::properties` gives each line a role, `style_property`
+    its row): a "Properties" header, keys in a column as wide as the
+    reading view's, values inline-styled (links follow), list values as
+    pills — a one-per-line YAML list is one pill per line, its key drawn
+    by the first item and the key line itself zero height — and
+    `true`/`false` as a checkbox that flips the bytes in place
+    (`LineLayout::toggle`, undoable). The caret anywhere in the block
+    shows all of it raw, like a fenced block; an unclosed block, nested
+    maps and block scalars stay raw. `set_text` starts the caret past the
+    block so a note opens on the table.
   - The caret does not blink (a blink is a frame every half second for
     as long as the window is open).
 
diff --git a/src/widget/doc_editor/layout.rs b/src/widget/doc_editor/layout.rs
index d9f65b1..18c34c3 100644
--- a/src/widget/doc_editor/layout.rs
+++ b/src/widget/doc_editor/layout.rs
@@ -11,7 +11,7 @@
 
 use std::ops::Range;
 
-use super::preview::{self, Kind, Look, Marker, Target};
+use super::preview::{self, Kind, Look, Marker, PropShow, Target};
 use crate::scene::layout::Rect;
 use crate::scene::paint::TextAttrs;
 use crate::widget::shaping::{Measure, ShapingMeasure};
@@ -32,6 +32,8 @@ pub struct EditorTheme {
     pub code_bg: [f32; 4],
     pub highlight_bg: [f32; 4],
     pub tag_bg: [f32; 4],
+    /// A list property's item.
+    pub pill_bg: [f32; 4],
     pub accent: [f32; 4],
     pub rule: [f32; 4],
     pub caret: [f32; 4],
@@ -54,6 +56,7 @@ impl EditorTheme {
             code_bg: [1.0, 1.0, 1.0, 0.06],
             highlight_bg: lin([1.0, 0.82, 0.0, 0.40]),
             tag_bg: lin([0.66, 0.55, 0.98, 0.15]),
+            pill_bg: [1.0, 1.0, 1.0, 0.09],
             accent: lin([0.66, 0.55, 0.98, 1.0]),
             rule: [1.0, 1.0, 1.0, 0.14],
             caret: lin([0.85, 0.85, 0.92, 1.0]),
@@ -70,6 +73,15 @@ fn gutter(th: &EditorTheme) -> f32 {
     (th.size * 1.6).round()
 }
 const QUOTE_STEP: f32 = 18.0;
+/// Space either side of a pill's text, inside its background.
+pub const PILL_PAD: f32 = 6.0;
+/// Space between two pills.
+const PILL_GAP: f32 = 4.0;
+
+/// The Properties table's key column, as the reading view sizes it.
+pub fn prop_key_w(width: f32) -> f32 {
+    140f32.min(width * 0.35).round()
+}
 
 #[derive(Clone, Debug)]
 pub struct Run {
@@ -111,6 +123,9 @@ pub struct LineLayout {
     pub links: Vec<Target>,
     /// A task's checkbox hit rect, and the byte of its status char.
     pub task: Option<(Rect, usize)>,
+    /// A boolean property's checkbox hit rect, its `true`/`false` bytes,
+    /// and whether it is ticked.
+    pub toggle: Option<(Rect, Range<usize>, bool)>,
     /// Where an empty line's (or a hidden prefix's) caret sits.
     pub content_x: f32,
     pub content_start: usize,
@@ -123,7 +138,7 @@ fn size_for(kind: &Kind, base: f32) -> f32 {
         Kind::Heading(2) => (base * 1.4).round(),
         Kind::Heading(3) => (base * 1.25).round(),
         Kind::Heading(4) => (base * 1.1).round(),
-        Kind::Code | Kind::Fence | Kind::Frontmatter | Kind::Table => (base * 0.92).round(),
+        Kind::Code | Kind::Fence | Kind::Frontmatter | Kind::Table | Kind::Prop(_) => (base * 0.92).round(),
         _ => base,
     }
 }
@@ -189,6 +204,7 @@ pub fn layout_line(text: &str, line: &preview::Line, active: bool, width: f32, t
             }
         }
         Kind::Quote(d) => content_x = *d as f32 * QUOTE_STEP,
+        Kind::Prop(PropShow::Row { .. }) => content_x = prop_key_w(width),
         _ => {}
     }
 
@@ -238,7 +254,10 @@ pub fn layout_line(text: &str, line: &preview::Line, active: bool, width: f32, t
                     cx += w;
                 }
             } else {
-                let w = m.width(&t[i..j], *size, font, *attrs);
+                let mut w = m.width(&t[i..j], *size, font, *attrs);
+                if seg.look.pill && i == 0 {
+                    w += 2.0 * PILL_PAD + PILL_GAP;
+                }
                 // A word glued to the previous piece (no space between,
                 // other style) moves with it.
                 let glued = pieces.last().is_some_and(|p| p.src.end == src.start && !text[p.src.clone()].ends_with([' ', '\t']));
@@ -290,7 +309,9 @@ pub fn layout_line(text: &str, line: &preview::Line, active: bool, width: f32, t
         } else {
             let (font, size, attrs) = looks[p.seg].clone();
             let color = if done && seg.link.is_none() { th.dim } else { color_for(&seg.look, th) };
-            let bg = if seg.look.code && !seg.look.mono {
+            let bg = if seg.look.pill {
+                Some(th.pill_bg)
+            } else if seg.look.code && !seg.look.mono {
                 Some(th.code_bg)
             } else if seg.look.highlight {
                 Some(th.highlight_bg)
@@ -326,11 +347,50 @@ pub fn layout_line(text: &str, line: &preview::Line, active: bool, width: f32, t
         r.w = offs.last().map(|o| o.1).unwrap_or(0.0);
         r.xs = offs.into_iter().map(|(b, x)| (r.src.start + b, x)).collect();
         r.text = shown;
-        r.x = row_x[r.row];
-        row_x[r.row] += r.w;
+        let pad = if r.look.pill { PILL_PAD } else { 0.0 };
+        r.x = row_x[r.row] + pad;
+        row_x[r.row] += r.w + 2.0 * pad + if r.look.pill { PILL_GAP } else { 0.0 };
     }
     let rows = runs.last().map(|r| r.row + 1).unwrap_or(1);
-    let height = rows as f32 * row_h;
+    let mut height = rows as f32 * row_h;
+
+    // The Properties table's own parts.
+    let mut toggle = None;
+    if let Kind::Prop(show) = &line.kind {
+        let label_in = |text: &str, x: f32, color: [f32; 4]| Deco::Text {
+            text: text.to_string(),
+            x,
+            y: (row_h - base) / 2.0,
+            size: base,
+            color,
+            font: th.body_font.clone(),
+        };
+        let label = |text: &str, x: f32| label_in(text, x, th.dim);
+        match show {
+            PropShow::Header => decos.push(label("Properties", 0.0)),
+            PropShow::Close => {
+                height = (row_h * 0.75).round();
+                decos.push(Deco::Quad(Rect { x: 0.0, y: (height / 2.0).round(), width, height: 1.0 }, th.rule));
+            }
+            PropShow::Hidden => height = 0.0,
+            PropShow::Row { key, check, empty } => {
+                if let Some(k) = key {
+                    decos.push(label(&fit(k, content_x - 10.0, base, &th.body_font, m), 0.0));
+                }
+                if let Some((bytes, on)) = check {
+                    let r = (th.size * 0.42).round();
+                    let (cx, cy) = (content_x + r + 1.0, row_h / 2.0);
+                    decos.push(Deco::Check { cx, cy, r, checked: *on });
+                    let hit = Rect { x: cx - r - 3.0, y: cy - r - 3.0, width: 2.0 * r + 6.0, height: 2.0 * r + 6.0 };
+                    toggle = Some((hit, bytes.clone(), *on));
+                } else if *empty {
+                    // Fainter than any value: text colour has no alpha.
+                    let d = th.dim;
+                    decos.push(label_in("Empty", content_x, [d[0] * 0.45, d[1] * 0.45, d[2] * 0.5, d[3]]));
+                }
+            }
+        }
+    }
 
     // Block decorations that span the line.
     match &line.kind {
@@ -343,7 +403,23 @@ pub fn layout_line(text: &str, line: &preview::Line, active: bool, width: f32, t
         Kind::Code | Kind::Fence => decos.insert(0, Deco::Quad(Rect { x: -8.0, y: 0.0, width: width + 16.0, height }, th.code_bg)),
         _ => {}
     }
-    LineLayout { height, row_h, rows, runs, decos, links: line.links.clone(), task, content_x, content_start: line.content_start, text_size: base }
+    LineLayout { height, row_h, rows, runs, decos, links: line.links.clone(), task, toggle, content_x, content_start: line.content_start, text_size: base }
+}
+
+/// `text` cut to `w` with an ellipsis.
+fn fit(text: &str, w: f32, size: f32, font: &str, m: &mut ShapingMeasure) -> String {
+    if m.width(text, size, font, TextAttrs::default()) <= w {
+        return text.to_string();
+    }
+    let mut chars: Vec<char> = text.chars().collect();
+    while !chars.is_empty() {
+        chars.pop();
+        let t: String = chars.iter().collect::<String>() + "…";
+        if m.width(&t, size, font, TextAttrs::default()) <= w {
+            return t;
+        }
+    }
+    "…".into()
 }
 
 fn color_for(look: &Look, th: &EditorTheme) -> [f32; 4] {
diff --git a/src/widget/doc_editor/mod.rs b/src/widget/doc_editor/mod.rs
index d1cc680..751b2d7 100644
--- a/src/widget/doc_editor/mod.rs
+++ b/src/widget/doc_editor/mod.rs
@@ -36,7 +36,7 @@ use crate::scene::paint::{Cap, PaintCtx};
 use crate::widget::shaping::ShapingMeasure;
 use crate::widget::{Bounds, Key, KeyEvent, MouseScrollDelta, NamedKey, ScrollMotion};
 use layout::{Deco, LineLayout};
-use preview::{Context, Kind, Marker};
+use preview::{Context, Kind, Marker, Prop};
 
 /// What a key or click did, for the host.
 #[derive(Clone, Debug, PartialEq)]
@@ -64,6 +64,8 @@ pub struct DocEditor {
     pub pad: f32,
     measure: ShapingMeasure,
     ctx: Vec<Context>,
+    /// The frontmatter lines' Properties roles (`None` elsewhere).
+    props: Vec<Option<Prop>>,
     layouts: Vec<Option<LineLayout>>,
     heights: Vec<f32>,
     tops: Vec<f32>,
@@ -96,6 +98,7 @@ impl DocEditor {
             pad: 24.0,
             measure: ShapingMeasure::new(system_fonts),
             ctx: Vec::new(),
+            props: Vec::new(),
             layouts: Vec::new(),
             heights: Vec::new(),
             tops: Vec::new(),
@@ -112,6 +115,7 @@ impl DocEditor {
             follow_caret: false,
         };
         e.sync();
+        e.caret_to_body();
         e
     }
 
@@ -121,6 +125,16 @@ impl DocEditor {
         self.motion = ScrollMotion::new();
         self.want_x = None;
         self.sync();
+        self.caret_to_body();
+    }
+
+    /// Start the caret on the body, past a frontmatter block, so the
+    /// Properties table shows rather than its raw YAML.
+    fn caret_to_body(&mut self) {
+        if let Some(close) = self.props.iter().position(|p| *p == Some(Prop::Close)) {
+            self.buf.caret = Pos::new((close + 1).min(self.buf.line_count() - 1), 0);
+            self.sync();
+        }
     }
 
     pub fn text(&self) -> String {
@@ -145,12 +159,21 @@ impl DocEditor {
     // ---- incremental bookkeeping ----------------------------------------
 
     /// The lines shown raw: the caret's (or the selection's), widened to
-    /// a fenced code block the caret is in, so its fences show.
+    /// a fenced code block or the frontmatter the caret is in, so its
+    /// fences (or its raw YAML) show.
     fn active_range(&self) -> (usize, usize) {
         let (mut a, mut b) = match self.buf.selection() {
             Some((a, b)) => (a.line, b.line),
             None => (self.buf.caret.line, self.buf.caret.line),
         };
+        // The frontmatter shows raw while the caret is anywhere in it, as
+        // a fenced block does; elsewhere it is the Properties table.
+        if let Some((x, _)) = preview::frontmatter_block(&self.ctx, a) {
+            a = x;
+        }
+        if let Some((_, y)) = preview::frontmatter_block(&self.ctx, b) {
+            b = y;
+        }
         if let Some((x, _)) = preview::fenced_block(&self.ctx, a) {
             a = x;
         }
@@ -201,6 +224,13 @@ impl DocEditor {
             }
         }
         self.ctx = ctx;
+        let props = preview::properties(self.buf.lines(), &self.ctx);
+        for (i, p) in props.iter().enumerate() {
+            if self.props.get(i) != Some(p) {
+                self.layouts[i] = None;
+            }
+        }
+        self.props = props;
         let act = self.active_range();
         if act != self.shown_active {
             let (a, b) = self.shown_active;
@@ -256,7 +286,16 @@ impl DocEditor {
         let act = self.shown_active;
         let active = self.is_active(i, act);
         let text = self.buf.line(i);
-        let line = preview::style_line(text, self.ctx[i], active);
+        let line = match self.props.get(i) {
+            Some(Some(p)) if !active => {
+                let key = match p {
+                    Prop::Item { key_line: Some(k), .. } => preview::prop_key(self.buf.line(*k)),
+                    _ => None,
+                };
+                preview::style_property(text, p, key)
+            }
+            _ => preview::style_line(text, self.ctx[i], active),
+        };
         let l = layout::layout_line(text, &line, active, self.width, &self.theme, &mut self.measure);
         if (l.height - self.heights[i]).abs() > 0.01 {
             self.heights[i] = l.height;
@@ -642,6 +681,16 @@ impl DocEditor {
                     return self.after_edit(rev, before);
                 }
             }
+            if let Some((r, bytes, on)) = l.toggle.clone() {
+                if x >= r.x && x <= r.x + r.width && ly >= r.y && ly <= r.y + r.height {
+                    // A boolean property's box: flip it in place.
+                    let keep = (self.buf.caret, self.buf.anchor);
+                    let next = if on { "false" } else { "true" };
+                    self.buf.replace(Pos::new(i, bytes.start), Pos::new(i, bytes.end), next, EditKind::Other);
+                    (self.buf.caret, self.buf.anchor) = keep;
+                    return self.after_edit(rev, before);
+                }
+            }
             if !active || ctrl {
                 if let Some(k) = l.link_at(x, ly) {
                     return Response::Follow(l.links[k].clone());
@@ -837,7 +886,8 @@ impl DocEditor {
                 for r in &l.runs {
                     let row_y = top + r.row as f32 * l.row_h;
                     if let Some(bg) = r.bg {
-                        pc.rounded_rect(Rect { x: ox + r.x - 2.0, y: row_y + 2.0, width: r.w + 4.0, height: l.row_h - 4.0 }, 3.0, (true, true, true, true), bg);
+                        let (pad, radius) = if r.look.pill { (layout::PILL_PAD, (l.row_h - 4.0) / 2.0) } else { (2.0, 3.0) };
+                        pc.rounded_rect(Rect { x: ox + r.x - pad, y: row_y + 2.0, width: r.w + 2.0 * pad, height: l.row_h - 4.0 }, radius, (true, true, true, true), bg);
                     }
                     let ty = row_y + (l.row_h - r.size) / 2.0;
                     let color = match r.link {
@@ -971,6 +1021,37 @@ mod tests {
         assert_eq!(e.press(sx, sy, false, false), Response::Follow(Target::Note { target: "Target".into(), subpath: None }));
     }
 
+    #[test]
+    fn the_frontmatter_is_a_table_until_the_caret_enters_it() {
+        let mut e = editor("---\ndone: false\ntags: [x]\n---\nbody");
+        e.buf.caret = Pos::new(4, 0);
+        let mut pc = PaintCtx::new();
+        e.paint(&mut pc, Rect { x: 0.0, y: 0.0, width: 600.0, height: 400.0 }, true);
+        let header = e.layouts[0].as_ref().unwrap();
+        assert!(matches!(&header.decos[0], Deco::Text { text, .. } if text == "Properties"));
+        let row = e.layouts[1].as_ref().unwrap();
+        let (r, _, on) = row.toggle.clone().expect("a checkbox");
+        assert!(!on);
+        // Ticking the box writes `true`, and the caret stays put.
+        let (sx, sy) = (e.origin.0 + r.x + r.width / 2.0, e.origin.1 + e.tops[1] + r.y + r.height / 2.0);
+        assert_eq!(e.press(sx, sy, false, false), Response::Changed);
+        assert_eq!(e.text(), "---\ndone: true\ntags: [x]\n---\nbody");
+        assert_eq!(e.buf.caret, Pos::new(4, 0));
+        e.release();
+        // The pill is padded inside the value column.
+        e.paint(&mut pc, Rect { x: 0.0, y: 0.0, width: 600.0, height: 400.0 }, true);
+        let tags = e.layouts[2].as_ref().unwrap();
+        assert_eq!(tags.runs.len(), 1);
+        assert!(tags.runs[0].look.pill && tags.runs[0].x >= tags.content_x + layout::PILL_PAD - 0.01);
+        // The caret in the block shows all of it raw.
+        e.buf.set_caret(Pos::new(2, 0), false);
+        e.paint(&mut pc, Rect { x: 0.0, y: 0.0, width: 600.0, height: 400.0 }, true);
+        for i in 0..4 {
+            let l = e.layouts[i].as_ref().unwrap();
+            assert_eq!(l.runs.iter().map(|r| r.text.as_str()).collect::<String>(), e.buf.line(i), "line {i} raw");
+        }
+    }
+
     #[test]
     fn a_long_document_shapes_only_what_shows() {
         let text: String = (0..5000).map(|i| format!("line {i} with **some** text\n")).collect();
diff --git a/src/widget/doc_editor/preview.rs b/src/widget/doc_editor/preview.rs
index e7a2b67..4cc9cd3 100644
--- a/src/widget/doc_editor/preview.rs
+++ b/src/widget/doc_editor/preview.rs
@@ -83,6 +83,40 @@ pub enum Kind {
     Fence,
     Frontmatter,
     Table,
+    /// A line of the frontmatter shown as the Properties table.
+    Prop(PropShow),
+}
+
+/// How a frontmatter line draws in the Properties table.
+#[derive(Clone, Debug, PartialEq)]
+pub enum PropShow {
+    /// The opening `---`: the table's "Properties" header.
+    Header,
+    /// The closing `---`: the rule under the table.
+    Close,
+    /// A row: the key in the key column (a list's key is drawn by its first
+    /// item), the value as segments in the value column; a `true`/`false`
+    /// value is a checkbox instead (its bytes, and whether it is ticked).
+    Row { key: Option<String>, check: Option<(Range<usize>, bool)>, empty: bool },
+    /// Blank, comment, and a list's own key line: no height.
+    Hidden,
+}
+
+/// The role of each line of a closed leading frontmatter block, `None`
+/// outside one (or when it never closes, which shows raw).
+#[derive(Clone, Debug, PartialEq)]
+pub enum Prop {
+    Open,
+    Close,
+    /// `key: value` on one line (`value` may be empty).
+    Field { key: Range<usize>, value: Range<usize> },
+    /// `key:` with list items under it; its first item draws the key.
+    ListKey,
+    /// `- item` under a key; the first carries the key line.
+    Item { value: Range<usize>, key_line: Option<usize> },
+    Hidden,
+    /// What the table cannot show (nested maps, block scalars): raw.
+    Raw,
 }
 
 #[derive(Clone, Debug, PartialEq)]
@@ -110,6 +144,8 @@ pub struct Look {
     pub mono: bool,
     /// The dim text of a done task or a fence line.
     pub dim: bool,
+    /// An item of a list property, drawn as a pill.
+    pub pill: bool,
 }
 
 #[derive(Clone, Debug, PartialEq)]
@@ -173,6 +209,193 @@ pub fn fenced_block(ctx: &[Context], line: usize) -> Option<(usize, usize)> {
     open.map(|a| (a, ctx.len() - 1))
 }
 
+/// The leading frontmatter block (open and close lines) when `line` is
+/// in it.
+pub fn frontmatter_block(ctx: &[Context], line: usize) -> Option<(usize, usize)> {
+    if ctx.get(line) != Some(&Context::Frontmatter) {
+        return None;
+    }
+    let end = ctx.iter().take_while(|c| **c == Context::Frontmatter).count();
+    Some((0, end - 1))
+}
+
+/// A `key:` at the start of `line`: the key's byte range and where what
+/// follows the colon starts.
+fn yaml_key(line: &str) -> Option<(Range<usize>, usize)> {
+    if line.starts_with([' ', '\t', '-', '#']) {
+        return None;
+    }
+    let b = line.as_bytes();
+    let colon = (0..b.len()).find(|&i| b[i] == b':' && (i + 1 == b.len() || b[i + 1] == b' ' || b[i + 1] == b'\t'))?;
+    let key = line[..colon].trim_end();
+    (!key.is_empty()).then_some((0..key.len(), colon + 1))
+}
+
+/// The range of `line[from..]` without surrounding whitespace.
+fn trimmed(line: &str, from: usize) -> Range<usize> {
+    let rest = &line[from..];
+    let start = from + (rest.len() - rest.trim_start().len());
+    start..from + rest.trim_end().len()
+}
+
+/// Roles for the frontmatter lines (see [`Prop`]), per line of the
+/// document; `None` past the block.
+pub fn properties(lines: &[String], ctx: &[Context]) -> Vec<Option<Prop>> {
+    let mut out = vec![None; lines.len()];
+    let Some((_, end)) = frontmatter_block(ctx, 0) else { return out };
+    if end == 0 || lines[end].trim_end() != "---" {
+        return out; // never closed: raw
+    }
+    out[0] = Some(Prop::Open);
+    out[end] = Some(Prop::Close);
+    let mut list_key: Option<usize> = None;
+    let mut raw_block = false;
+    for i in 1..end {
+        let line = &lines[i];
+        let t = line.trim_start();
+        let indented = t.len() < line.len();
+        let role = if t.is_empty() || t.starts_with('#') {
+            Prop::Hidden
+        } else if raw_block && indented {
+            Prop::Raw
+        } else if let Some((key, after)) = yaml_key(line) {
+            raw_block = false;
+            let value = trimmed(line, after);
+            let v = &line[value.clone()];
+            if v.starts_with(['|', '>']) {
+                raw_block = true;
+                Prop::Raw
+            } else if v.is_empty() {
+                // A list follows, or a nested map (raw), or nothing.
+                let next = lines[i + 1..end].iter().find(|l| !l.trim().is_empty());
+                match next.map(|l| l.trim_start()) {
+                    Some(n) if n == "-" || n.starts_with("- ") => {
+                        list_key = Some(i);
+                        Prop::ListKey
+                    }
+                    Some(_) if next.is_some_and(|l| l.starts_with([' ', '\t'])) => {
+                        raw_block = true;
+                        Prop::Field { key, value }
+                    }
+                    _ => Prop::Field { key, value },
+                }
+            } else {
+                Prop::Field { key, value }
+            }
+        } else if t == "-" || t.starts_with("- ") {
+            let value = trimmed(line, line.len() - t.len() + 1);
+            Prop::Item { value, key_line: list_key.take() }
+        } else {
+            Prop::Raw
+        };
+        out[i] = Some(role);
+    }
+    out
+}
+
+/// The key of a `key:` line, for a list item drawing its key.
+pub fn prop_key(line: &str) -> Option<&str> {
+    yaml_key(line).map(|(k, _)| &line[k])
+}
+
+/// A quoted YAML scalar's inside.
+fn unquote(line: &str, r: Range<usize>) -> Range<usize> {
+    let v = &line[r.clone()];
+    if v.len() >= 2 && ((v.starts_with('"') && v.ends_with('"')) || (v.starts_with('\'') && v.ends_with('\''))) {
+        r.start + 1..r.end - 1
+    } else {
+        r
+    }
+}
+
+/// The items of an inline `[a, "b", [[c]]]` list: split on commas outside
+/// quotes and `[[…]]`, trimmed and unquoted.
+fn flow_items(line: &str, r: Range<usize>) -> Vec<Range<usize>> {
+    let inner = r.start + 1..r.end - 1;
+    let b = line.as_bytes();
+    let (mut out, mut start, mut quote, mut depth) = (Vec::new(), inner.start, None::<u8>, 0i32);
+    let mut i = inner.start;
+    while i <= inner.end {
+        let c = if i < inner.end { b[i] } else { b',' };
+        match (quote, c) {
+            (Some(q), _) if c == q => quote = None,
+            (Some(_), _) => {}
+            (None, b'"' | b'\'') => quote = Some(c),
+            (None, b'[') => depth += 1,
+            (None, b']') => depth -= 1,
+            (None, b',') if depth <= 0 => {
+                let item = unquote(line, trimmed(&line[..i], start));
+                if !item.is_empty() {
+                    out.push(item);
+                }
+                start = i + 1;
+            }
+            _ => {}
+        }
+        i += 1;
+    }
+    out
+}
+
+/// Inline-style `line[r]` as part of a property value, segments offset into
+/// the line; `pill` marks them as list pills.
+fn value_segs(line: &str, r: Range<usize>, pill: bool, segs: &mut Vec<Seg>, links: &mut Vec<Target>) {
+    let sub = &line[r.clone()];
+    let styled = style_line(sub, Context::Normal, false);
+    let base = links.len();
+    if styled.kind != Kind::Plain || styled.content_start != 0 {
+        segs.push(Seg { range: r, look: Look { pill, ..Look::default() }, link: None });
+        return;
+    }
+    for s in styled.segs {
+        let look = Look { pill, ..s.look };
+        segs.push(Seg { range: s.range.start + r.start..s.range.end + r.start, look, link: s.link.map(|k| k + base) });
+    }
+    links.extend(styled.links);
+}
+
+/// Style a frontmatter line as its row of the Properties table. `key` is
+/// the key an [`Prop::Item`] draws (its key line's), if it is the first.
+pub fn style_property(line: &str, prop: &Prop, key: Option<&str>) -> Line {
+    let mut segs = Vec::new();
+    let mut links = Vec::new();
+    let row = |key: Option<String>, check, empty| Kind::Prop(PropShow::Row { key, check, empty });
+    let (kind, content_start) = match prop {
+        Prop::Open => (Kind::Prop(PropShow::Header), 0),
+        Prop::Close => (Kind::Prop(PropShow::Close), 0),
+        Prop::Hidden | Prop::ListKey => (Kind::Prop(PropShow::Hidden), 0),
+        Prop::Raw => return style_line(line, Context::Frontmatter, false),
+        Prop::Field { key: k, value } => {
+            let v = &line[value.clone()];
+            let key = Some(line[k.clone()].to_string());
+            if v == "true" || v == "false" {
+                (row(key, Some((value.clone(), v == "true")), false), value.start)
+            } else if v.starts_with('[') && v.ends_with(']') && !v.starts_with("[[") {
+                let items = flow_items(line, value.clone());
+                // The separators hide; the layout pads pills apart.
+                for it in &items {
+                    value_segs(line, it.clone(), true, &mut segs, &mut links);
+                }
+                (row(key, None, items.is_empty()), value.start)
+            } else {
+                if !value.is_empty() {
+                    value_segs(line, unquote(line, value.clone()), false, &mut segs, &mut links);
+                }
+                (row(key, None, value.is_empty()), value.start)
+            }
+        }
+        Prop::Item { value, .. } => {
+            let key = key.map(str::to_string);
+            let it = unquote(line, value.clone());
+            if !it.is_empty() {
+                value_segs(line, it, true, &mut segs, &mut links);
+            }
+            (row(key, None, value.is_empty()), value.start)
+        }
+    };
+    Line { kind, segs, links, content_start }
+}
+
 /// Style one line. `active` shows its markers; `ctx` is its block context.
 pub fn style_line(text: &str, ctx: Context, active: bool) -> Line {
     let n = text.len();
@@ -271,6 +494,7 @@ pub fn style_line(text: &str, ctx: Context, active: bool) -> Line {
             comment: f & COMMENT != 0,
             mono: false,
             dim: f & DIM != 0,
+            pill: false,
         };
         let link = link_of[i];
         match segs.last_mut() {
@@ -561,6 +785,50 @@ mod tests {
         assert_eq!(l.segs[3].link, Some(2));
     }
 
+    #[test]
+    fn frontmatter_lines_get_property_roles() {
+        let src = "---\ntitle: \"Hello\"\ntags: [a, \"b c\"]\ndone: true\n\naliases:\n  - one\n  - \"[[Two]]\"\nmeta:\n  nested: x\nempty:\n---\nbody";
+        let lines: Vec<String> = src.split('\n').map(String::from).collect();
+        let ctx = contexts(&lines);
+        let p = properties(&lines, &ctx);
+        assert_eq!(p[0], Some(Prop::Open));
+        assert_eq!(p[11], Some(Prop::Close));
+        assert_eq!(p[12], None);
+        assert!(matches!(p[1], Some(Prop::Field { .. })));
+        assert_eq!(p[4], Some(Prop::Hidden));
+        assert_eq!(p[5], Some(Prop::ListKey));
+        assert!(matches!(p[6], Some(Prop::Item { key_line: Some(5), .. })));
+        assert!(matches!(p[7], Some(Prop::Item { key_line: None, .. })));
+        assert_eq!(p[9], Some(Prop::Raw), "a nested map shows raw");
+        assert_eq!(prop_key(&lines[5]), Some("aliases"));
+
+        let shown = |i: usize, key: Option<&str>| {
+            let l = style_property(&lines[i], p[i].as_ref().unwrap(), key);
+            (l.kind.clone(), l.segs.iter().map(|s| lines[i][s.range.clone()].to_string()).collect::<Vec<_>>(), l)
+        };
+        let (kind, segs, _) = shown(1, None);
+        assert_eq!(kind, Kind::Prop(PropShow::Row { key: Some("title".into()), check: None, empty: false }));
+        assert_eq!(segs, ["Hello"], "quotes hide");
+        let (_, segs, l) = shown(2, None);
+        assert_eq!(segs, ["a", "b c"]);
+        assert!(l.segs.iter().all(|s| s.look.pill));
+        let (kind, segs, _) = shown(3, None);
+        assert!(segs.is_empty());
+        assert_eq!(kind, Kind::Prop(PropShow::Row { key: Some("done".into()), check: Some((6..10, true)), empty: false }));
+        let (kind, segs, _) = shown(6, Some("aliases"));
+        assert_eq!(segs, ["one"]);
+        assert!(matches!(kind, Kind::Prop(PropShow::Row { key: Some(k), .. }) if k == "aliases"));
+        let (_, segs, l) = shown(7, None);
+        assert_eq!(segs, ["Two"], "a link item shows its name");
+        assert_eq!(l.links, [Target::Note { target: "Two".into(), subpath: None }]);
+        let (kind, _, _) = shown(10, None);
+        assert_eq!(kind, Kind::Prop(PropShow::Row { key: Some("empty".into()), check: None, empty: true }));
+
+        // Never closed: no table.
+        let open: Vec<String> = ["---", "a: b"].iter().map(|s| s.to_string()).collect();
+        assert!(properties(&open, &contexts(&open)).iter().all(Option::is_none));
+    }
+
     #[test]
     fn fenced_blocks_are_found_and_fences_hide() {
         let lines: Vec<String> = ["a", "```", "x", "```", "```", "y"].iter().map(|s| s.to_string()).collect();