diff --git a/ui/qml/OutlineView.qml b/ui/qml/OutlineView.qml index d5aadb7..f7923db 100644 --- a/ui/qml/OutlineView.qml +++ b/ui/qml/OutlineView.qml @@ -62,7 +62,70 @@ ListView { let parts = []; let body = sectionBody; 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 currentText = []; let inCode = false; @@ -80,8 +143,7 @@ ListView { if (!inCode && !inTable && trimmed.toUpperCase().startsWith("#+BEGIN_SRC")) { // Flush pending text if (currentText.length > 0) { - let text = currentText.join('\n').trim(); - if (text) parts.push({type: "text", content: text}); + pushText(currentText.join('\n').trim()); currentText = []; } inCode = true; @@ -100,8 +162,7 @@ ListView { else if (!inTable && trimmed.startsWith("|")) { // Flush pending text if (currentText.length > 0) { - let text = currentText.join('\n').trim(); - if (text) parts.push({type: "text", content: text}); + pushText(currentText.join('\n').trim()); currentText = []; } inTable = true; @@ -151,8 +212,7 @@ ListView { } parts.push({type: "table", rows: rows, hasHeader: hasHeader}); } else if (currentText.length > 0) { - let text = currentText.join('\n').trim(); - if (text) parts.push({type: "text", content: text}); + pushText(currentText.join('\n').trim()); } return parts; @@ -344,7 +404,8 @@ ListView { required property var modelData Layout.fillWidth: true sourceComponent: modelData.type === "code" ? codeBlockComponent : - modelData.type === "table" ? tableComponent : textBlockComponent + modelData.type === "table" ? tableComponent : + modelData.type === "list" ? listComponent : textBlockComponent Component { 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 { id: codeBlockComponent