import QtQuick import QtQuick.Controls import QtQuick.Layouts // Document outline rendered as a flat ListView with indentation. // Each section is a row; nesting is shown via left margin. // This gives us full control over layout, selection, and editing. ListView { id: outline clip: true spacing: 4 readonly property int rowPad: 6 readonly property int baseFontSize: theme.fontSize > 0 ? theme.fontSize : 12 readonly property int indentWidth: 20 // Palette for TODO keywords by state. function keywordColor(kw) { switch (kw) { case "TODO": return theme.todoColor case "NEXT": return theme.todoColor case "WAITING": return theme.waitingColor case "DONE": return theme.doneColor case "CANCELLED": return theme.cancelledColor default: return theme.linkColor } } // Priority color by level. function priorityColor(pri) { switch (pri) { case "A": return theme.priorityAColor case "B": return theme.priorityBColor case "C": return theme.priorityCColor default: return theme.textColor } } // Headline text scales down slightly with depth. function levelFontSize(level) { return Math.max(outline.baseFontSize - 1, outline.baseFontSize + 5 - level) } delegate: Rectangle { id: del width: outline.width implicitHeight: contentColumn.implicitHeight + outline.rowPad * 2 color: ListView.isCurrentItem ? Qt.rgba(theme.highlightColor.r, theme.highlightColor.g, theme.highlightColor.b, 0.2) : "transparent" radius: 4 required property int index required property var model property bool drawerOpen: false readonly property string sectionBody: (model.body ?? "").trim() readonly property int sectionLevel: model.level ?? 1 readonly property int indent: (sectionLevel - 1) * outline.indentWidth // Parse body into text, code block, and table segments readonly property var bodyParts: { 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. // `cbCounter` is a single-element array used as a mutable counter so // checkbox ordinals are stable across the whole section body. function pushText(text, cbCounter) { 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(); // Assign a stable ordinal to checkbox-bearing items. item.checkboxOrdinal = (item.checkbox !== "") ? cbCounter[0]++ : -1; items.push(item); } else { flushItems(); buf.push(ln); } } flushItems(); flushBuf(); } // Running checkbox ordinal across the whole section body. let cbCounter = [0]; let lines = body.split('\n'); let currentText = []; let inCode = false; let codeLang = ""; let codeLines = []; let codeCounter = 0; let inTable = false; let tableLines = []; for (let i = 0; i < lines.length; i++) { let line = lines[i]; let trimmed = line.trim(); // Code block handling if (!inCode && !inTable && trimmed.toUpperCase().startsWith("#+BEGIN_SRC")) { // Flush pending text if (currentText.length > 0) { pushText(currentText.join('\n').trim(), cbCounter); currentText = []; } inCode = true; codeLang = trimmed.substring(11).trim().split(/\s+/)[0] || ""; codeLines = []; } else if (inCode && trimmed.toUpperCase() === "#+END_SRC") { parts.push({type: "code", language: codeLang, content: codeLines.join('\n'), codeIndex: codeCounter}); codeCounter++; inCode = false; codeLang = ""; codeLines = []; } else if (inCode) { codeLines.push(line); } // Table handling else if (!inTable && trimmed.startsWith("|")) { // Flush pending text if (currentText.length > 0) { pushText(currentText.join('\n').trim(), cbCounter); currentText = []; } inTable = true; tableLines = [trimmed]; } else if (inTable && trimmed.startsWith("|")) { tableLines.push(trimmed); } else if (inTable) { // End of table - skip #+TBLFM lines if (!trimmed.startsWith("#+TBLFM")) { // Parse table let rows = []; let hasHeader = false; for (let tl of tableLines) { if (tl.match(/^\|\s*[-+]+\s*\|/)) { hasHeader = rows.length > 0; } else { let cells = tl.split("|").slice(1, -1).map(c => c.trim()); rows.push(cells); } } parts.push({type: "table", rows: rows, hasHeader: hasHeader}); tableLines = []; inTable = false; // Process current line as text if not empty if (trimmed) currentText.push(line); } } else { currentText.push(line); } } // Flush remaining if (inCode && codeLines.length > 0) { parts.push({type: "code", language: codeLang, content: codeLines.join('\n'), codeIndex: codeCounter}); codeCounter++; } else if (inTable && tableLines.length > 0) { let rows = []; let hasHeader = false; for (let tl of tableLines) { if (tl.match(/^\|\s*[-+]+\s*\|/)) { hasHeader = rows.length > 0; } else { let cells = tl.split("|").slice(1, -1).map(c => c.trim()); rows.push(cells); } } parts.push({type: "table", rows: rows, hasHeader: hasHeader}); } else if (currentText.length > 0) { pushText(currentText.join('\n').trim(), cbCounter); } return parts; } MouseArea { anchors.fill: parent onClicked: outline.currentIndex = del.index } ColumnLayout { id: contentColumn anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top anchors.leftMargin: del.indent + 8 anchors.rightMargin: 8 anchors.topMargin: outline.rowPad spacing: 4 RowLayout { id: headlineRow Layout.fillWidth: true spacing: 8 // Expand/collapse indicator for sections with children Label { visible: (del.model.hasChildren ?? false) text: (del.model.expanded ?? true) ? "▼" : "▶" color: theme.placeholderTextColor font.pointSize: outline.baseFontSize - 2 MouseArea { anchors.fill: parent onClicked: { // Toggle expansion via model if (del.model.expanded !== undefined) { docModel.toggleExpanded(del.index) } } } } // TODO keyword pill Rectangle { visible: (del.model.keyword ?? "") !== "" radius: 3 color: "transparent" border.color: outline.keywordColor(del.model.keyword ?? "") border.width: 1 implicitWidth: kwLabel.implicitWidth + 10 implicitHeight: kwLabel.implicitHeight + 4 Label { id: kwLabel anchors.centerIn: parent text: del.model.keyword ?? "" color: outline.keywordColor(del.model.keyword ?? "") font.pointSize: outline.baseFontSize - 1 font.bold: true } } // Priority cookie Label { visible: (del.model.priority ?? "") !== "" text: "[#" + (del.model.priority ?? "") + "]" color: outline.priorityColor(del.model.priority ?? "") font.pointSize: outline.baseFontSize font.bold: true } // Title Label { text: del.model.title ?? "" color: (del.model.isDone ?? false) ? theme.placeholderTextColor : theme.textColor font.pointSize: outline.levelFontSize(del.sectionLevel) font.strikeout: del.model.isDone ?? false elide: Text.ElideRight Layout.fillWidth: true } // Scheduled timestamp Label { visible: (del.model.scheduled ?? "") !== "" text: "⏱ " + (del.model.scheduled ?? "") color: theme.timestampColor font.pointSize: outline.baseFontSize - 1 } // Deadline timestamp Label { visible: (del.model.deadline ?? "") !== "" text: "⚑ " + (del.model.deadline ?? "") color: theme.deadlineColor font.pointSize: outline.baseFontSize - 1 } // Tags Repeater { model: del.model.tags ?? [] delegate: Rectangle { required property string modelData radius: 3 color: theme.alternateBaseColor implicitWidth: tagLabel.implicitWidth + 8 implicitHeight: tagLabel.implicitHeight + 3 Label { id: tagLabel anchors.centerIn: parent text: parent.modelData color: theme.tagColor font.pointSize: outline.baseFontSize - 2 } } } } // Properties drawer (collapsible) RowLayout { visible: Object.keys(del.model.properties ?? {}).length > 0 Layout.fillWidth: true spacing: 4 Label { id: drawerToggle text: del.drawerOpen ? "▼" : "▶" color: theme.placeholderTextColor font.pointSize: outline.baseFontSize - 2 MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: del.drawerOpen = !del.drawerOpen } } Label { text: ":PROPERTIES:" color: theme.placeholderTextColor font.pointSize: outline.baseFontSize - 1 font.family: "monospace" MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: del.drawerOpen = !del.drawerOpen } } } // Properties content (when expanded) ColumnLayout { visible: del.drawerOpen && Object.keys(del.model.properties ?? {}).length > 0 Layout.fillWidth: true Layout.leftMargin: 16 spacing: 2 Repeater { model: Object.keys(del.model.properties ?? {}) delegate: RowLayout { required property string modelData spacing: 8 Label { text: ":" + modelData + ":" color: theme.linkColor font.pointSize: outline.baseFontSize - 1 font.family: "monospace" } Label { text: del.model.properties[modelData] ?? "" color: theme.textColor font.pointSize: outline.baseFontSize - 1 font.family: "monospace" } } } } // Body content (text and code blocks) ColumnLayout { visible: del.sectionBody !== "" Layout.fillWidth: true spacing: 4 Repeater { model: del.bodyParts delegate: Loader { required property var modelData Layout.fillWidth: true sourceComponent: modelData.type === "code" ? codeBlockComponent : modelData.type === "table" ? tableComponent : modelData.type === "list" ? listComponent : textBlockComponent Component { id: textBlockComponent RowLayout { spacing: outline.baseFontSize * 0.5 Rectangle { width: outline.baseFontSize / 6 Layout.fillHeight: true Layout.leftMargin: outline.baseFontSize / 6 color: theme.midColor } Label { Layout.fillWidth: true text: modelData.content color: theme.placeholderTextColor font.pointSize: outline.baseFontSize + 1 lineHeight: 1.3 wrapMode: Text.WordWrap textFormat: Text.PlainText } } } // 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 { id: cbGlyph 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 !== "" // Clickable only for checkbox items; toggles via the model. MouseArea { anchors.fill: parent enabled: cbGlyph.cb !== "" visible: enabled cursorShape: Qt.PointingHandCursor onClicked: docModel.toggleCheckbox( del.index, modelData.checkboxOrdinal ?? -1) } } // 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 Rectangle { id: codeBlock color: theme.alternateBaseColor radius: outline.baseFontSize / 3 implicitHeight: codeColumn.implicitHeight + outline.baseFontSize // Last execution status for this block: "", "running", "ok", "error" property string runState: "" property int runStatus: 0 ColumnLayout { id: codeColumn anchors.fill: parent anchors.margins: outline.baseFontSize * 0.5 spacing: outline.baseFontSize * 0.3 // Header row: language label + run control RowLayout { Layout.fillWidth: true spacing: outline.baseFontSize * 0.5 // Language label Label { visible: (modelData.language ?? "") !== "" text: modelData.language color: theme.linkColor font.pointSize: outline.baseFontSize - 2 font.bold: true } Item { Layout.fillWidth: true } // Run status indicator Label { visible: codeBlock.runState !== "" text: { switch (codeBlock.runState) { case "running": return "running…" case "ok": return "✓ exit 0" case "error": return "✗ exit " + codeBlock.runStatus default: return "" } } color: codeBlock.runState === "error" ? theme.cancelledColor : (codeBlock.runState === "ok" ? theme.doneColor : theme.placeholderTextColor) font.pointSize: outline.baseFontSize - 2 } // Run button — only for executable languages Rectangle { id: runButton visible: docModel.isLanguageSupported(modelData.language ?? "") radius: 3 implicitWidth: runLabel.implicitWidth + 14 implicitHeight: runLabel.implicitHeight + 6 color: runMouse.containsMouse ? theme.highlightColor : "transparent" border.color: theme.linkColor border.width: 1 Label { id: runLabel anchors.centerIn: parent text: "▶ Run" color: theme.linkColor font.pointSize: outline.baseFontSize - 2 font.bold: true } MouseArea { id: runMouse anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { codeBlock.runState = "running" let out = docModel.executeBlock(del.index, modelData.codeIndex ?? 0) // The report contains a "status: N" line per block. let m = out.match(/status:\s*(\d+)/) let hadError = /\berror:/.test(out) if (m) { codeBlock.runStatus = parseInt(m[1]) codeBlock.runState = codeBlock.runStatus === 0 ? "ok" : "error" } else { codeBlock.runStatus = hadError ? 1 : 0 codeBlock.runState = hadError ? "error" : "ok" } } } } } // Code content Label { Layout.fillWidth: true text: modelData.content color: theme.textColor font.family: "monospace" font.pointSize: outline.baseFontSize wrapMode: Text.WrapAnywhere textFormat: Text.PlainText } } } } Component { id: tableComponent Rectangle { id: tableRect color: theme.alternateBaseColor radius: outline.baseFontSize / 3 implicitHeight: tableColumn.implicitHeight + outline.baseFontSize * 1.5 property var tableData: modelData ColumnLayout { id: tableColumn anchors.left: parent.left anchors.top: parent.top anchors.margins: outline.baseFontSize * 0.75 spacing: outline.baseFontSize * 0.25 Repeater { model: tableRect.tableData.rows ?? [] RowLayout { required property var modelData required property int index property bool isHeader: index === 0 && (tableRect.tableData.hasHeader ?? false) spacing: outline.baseFontSize * 1.5 Repeater { model: modelData Label { required property string modelData leftPadding: outline.baseFontSize * 0.5 rightPadding: outline.baseFontSize * 0.5 topPadding: outline.baseFontSize * 0.25 bottomPadding: outline.baseFontSize * 0.25 text: modelData color: parent.isHeader ? theme.textColor : theme.placeholderTextColor font.bold: parent.isHeader font.pointSize: outline.baseFontSize horizontalAlignment: Text.AlignLeft } } } } } } } } } } } } // Empty state Label { anchors.centerIn: parent visible: outline.count === 0 text: qsTr("No sections") color: theme.placeholderTextColor font.pointSize: outline.baseFontSize } }