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:
parent
36a9607576
commit
754051b3a4
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue