Render list items and checkboxes in the GUI body

The body parser now recognizes org list items (- / + / ordered) and
splits them out of text runs into dedicated "list" parts. Each item
captures its indent, bullet, optional checkbox state, and text.

A new list component renders checkbox items with ☐ / ▣ / ☑ glyphs
(unchecked / partial / checked), colored by state, with checked items
dimmed and struck through. Plain list items render with their bullet.
This commit is contained in:
Levi Neely 2026-10-01 15:40:50 +02:00
parent 36a9607576
commit 754051b3a4
1 changed files with 121 additions and 8 deletions

View File

@ -62,7 +62,70 @@ ListView {
let parts = []; let parts = [];
let body = sectionBody; let body = sectionBody;
if (!body) return parts; if (!body) return parts;
// Match an org list item, capturing indent, bullet/number, optional
// checkbox, and the item text. Returns null for non-list lines.
function parseListItem(line) {
// Unordered: - / + bullet (indented * also allowed)
let m = line.match(/^(\s*)([-+])\s+(\[([ xX-])\]\s+)?(.*)$/);
let ordered = false;
if (!m) {
// Ordered: 1. or 1)
m = line.match(/^(\s*)(\d+)[.)]\s+(\[([ xX-])\]\s+)?(.*)$/);
if (!m) return null;
ordered = true;
}
let cb = "";
if (m[3] !== undefined) {
switch ((m[4] || " ")) {
case "x": case "X": cb = "checked"; break;
case "-": cb = "partial"; break;
default: cb = "unchecked"; break;
}
}
return {
indent: m[1].length,
ordered: ordered,
bullet: m[2],
checkbox: cb,
text: m[5]
};
}
// Push a run of text, splitting out any contiguous list items into
// dedicated "list" parts so they render with bullets/checkboxes.
function pushText(text) {
if (!text) return;
let lines = text.split('\n');
let buf = [];
let items = [];
function flushBuf() {
if (buf.length > 0) {
let t = buf.join('\n').trim();
if (t) parts.push({type: "text", content: t});
buf = [];
}
}
function flushItems() {
if (items.length > 0) {
parts.push({type: "list", items: items});
items = [];
}
}
for (let ln of lines) {
let item = parseListItem(ln);
if (item) {
flushBuf();
items.push(item);
} else {
flushItems();
buf.push(ln);
}
}
flushItems();
flushBuf();
}
let lines = body.split('\n'); let lines = body.split('\n');
let currentText = []; let currentText = [];
let inCode = false; let inCode = false;
@ -80,8 +143,7 @@ ListView {
if (!inCode && !inTable && trimmed.toUpperCase().startsWith("#+BEGIN_SRC")) { if (!inCode && !inTable && trimmed.toUpperCase().startsWith("#+BEGIN_SRC")) {
// Flush pending text // Flush pending text
if (currentText.length > 0) { if (currentText.length > 0) {
let text = currentText.join('\n').trim(); pushText(currentText.join('\n').trim());
if (text) parts.push({type: "text", content: text});
currentText = []; currentText = [];
} }
inCode = true; inCode = true;
@ -100,8 +162,7 @@ ListView {
else if (!inTable && trimmed.startsWith("|")) { else if (!inTable && trimmed.startsWith("|")) {
// Flush pending text // Flush pending text
if (currentText.length > 0) { if (currentText.length > 0) {
let text = currentText.join('\n').trim(); pushText(currentText.join('\n').trim());
if (text) parts.push({type: "text", content: text});
currentText = []; currentText = [];
} }
inTable = true; inTable = true;
@ -151,8 +212,7 @@ ListView {
} }
parts.push({type: "table", rows: rows, hasHeader: hasHeader}); parts.push({type: "table", rows: rows, hasHeader: hasHeader});
} else if (currentText.length > 0) { } else if (currentText.length > 0) {
let text = currentText.join('\n').trim(); pushText(currentText.join('\n').trim());
if (text) parts.push({type: "text", content: text});
} }
return parts; return parts;
@ -344,7 +404,8 @@ ListView {
required property var modelData required property var modelData
Layout.fillWidth: true Layout.fillWidth: true
sourceComponent: modelData.type === "code" ? codeBlockComponent : sourceComponent: modelData.type === "code" ? codeBlockComponent :
modelData.type === "table" ? tableComponent : textBlockComponent modelData.type === "table" ? tableComponent :
modelData.type === "list" ? listComponent : textBlockComponent
Component { Component {
id: textBlockComponent id: textBlockComponent
@ -367,6 +428,58 @@ ListView {
} }
} }
} }
// Plain / checkbox list rendering
Component {
id: listComponent
ColumnLayout {
spacing: outline.baseFontSize * 0.2
Repeater {
model: modelData.items ?? []
delegate: RowLayout {
required property var modelData
spacing: outline.baseFontSize * 0.4
// Indentation: 2 spaces of org indent ≈ one level.
Layout.leftMargin: outline.baseFontSize
+ (modelData.indent / 2) * outline.indentWidth
// Checkbox glyph, or a plain bullet when no checkbox.
Label {
readonly property string cb: modelData.checkbox ?? ""
text: {
switch (cb) {
case "checked": return "☑"
case "partial": return "▣"
case "unchecked": return "☐"
default:
return (modelData.ordered ?? false) ? "•" : (modelData.bullet ?? "•")
}
}
color: cb === "checked" ? theme.doneColor
: cb === "partial" ? theme.waitingColor
: cb === "unchecked" ? theme.todoColor
: theme.placeholderTextColor
font.pointSize: outline.baseFontSize + 1
font.bold: cb !== ""
}
// Item text; checked items are dimmed and struck through.
Label {
Layout.fillWidth: true
text: modelData.text ?? ""
color: (modelData.checkbox === "checked")
? theme.placeholderTextColor : theme.textColor
font.pointSize: outline.baseFontSize + 1
font.strikeout: modelData.checkbox === "checked"
wrapMode: Text.WordWrap
textFormat: Text.PlainText
}
}
}
}
}
Component { Component {
id: codeBlockComponent id: codeBlockComponent