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; let lines = body.split('\n'); let currentText = []; let inCode = false; let codeLang = ""; let codeLines = []; 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) { let text = currentText.join('\n').trim(); if (text) parts.push({type: "text", content: text}); 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')}); inCode = false; codeLang = ""; codeLines = []; } else if (inCode) { codeLines.push(line); } // Table handling 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}); 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')}); } 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) { let text = currentText.join('\n').trim(); if (text) parts.push({type: "text", content: text}); } 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 : textBlockComponent Component { id: textBlockComponent RowLayout { spacing: 8 Rectangle { width: 2 Layout.fillHeight: true Layout.leftMargin: 2 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 } } } Component { id: codeBlockComponent Rectangle { color: theme.alternateBaseColor radius: 4 implicitHeight: codeColumn.implicitHeight + 12 ColumnLayout { id: codeColumn anchors.fill: parent anchors.margins: 6 spacing: 4 // Language label Label { visible: (modelData.language ?? "") !== "" text: modelData.language color: theme.linkColor font.pointSize: outline.baseFontSize - 2 font.bold: true } // 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 { color: theme.alternateBaseColor radius: 4 implicitHeight: tableGrid.implicitHeight + 12 implicitWidth: tableGrid.implicitWidth + 12 GridLayout { id: tableGrid anchors.centerIn: parent anchors.margins: 6 columns: (modelData.rows && modelData.rows.length > 0) ? modelData.rows[0].length : 1 rowSpacing: 4 columnSpacing: 12 Repeater { model: modelData.rows ?? [] Repeater { required property var modelData required property int index property bool isHeader: index === 0 && tableComponent.modelData.hasHeader model: modelData Label { required property string modelData 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 } }