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