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 // When a body editor requests a timestamp insertion, it registers itself // here so the shared date picker can insert into it at the cursor. property var insertTarget: null // 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) } // Escape HTML special chars for safe StyledText rendering. function escHtml(s) { return String(s) .replace(/&/g, "&") .replace(//g, ">") } // Render inline org markup (currently links) to StyledText. Org links are // [[target]] or [[target][description]]; everything else is escaped. Line // breaks are converted to
so StyledText preserves them. function renderInline(text) { let re = /\[\[([^\]\[]+?)\](?:\[([^\]]*?)\])?\]/g let out = "" let last = 0 let m function escBlock(s) { return escHtml(s).replace(/\n/g, "
") } while ((m = re.exec(text)) !== null) { out += escBlock(text.slice(last, m.index)) let target = m[1] let desc = (m[2] !== undefined && m[2] !== "") ? m[2] : m[1] let href = escHtml(target).replace(/"/g, """) out += '' + escHtml(desc) + '' last = re.lastIndex } out += escBlock(text.slice(last)) return out } // Open a link target activated from body text. Resolution and dispatch // happen in the model (via //resolve): external URLs open in the system // handler; internal links reveal the target section in the outline. function openLink(target) { docModel.followLink(target) } // Row currently flashing from a reveal (−1 = none). property int flashRow: -1 // When the model reveals a section, select it, scroll it into view, and // flash it briefly. `bodyOffset` is a byte offset into the section's body. // When the match lies inside the body (offset > 0), position the row at the // top of the view, then scroll further so the body segment containing the // offset sits at the top (segment-precise). Otherwise just ensure the row // is contained. Connections { target: docModel function onSectionRevealed(row, bodyOffset) { if (row < 0) return outline.currentIndex = row if (bodyOffset > 0) { // Bring the row's top to the viewport top first; this also // realizes the delegate so its segments can be measured. outline.positionViewAtIndex(row, ListView.Beginning) let item = outline.itemAtIndex(row) if (item && item.offsetToY) { let extra = item.offsetToY(bodyOffset) let maxY = Math.max(outline.originY, outline.contentHeight - outline.height) outline.contentY = Math.min(outline.contentY + extra, maxY) } } else { outline.positionViewAtIndex(row, ListView.Contain) } outline.flashRow = row flashTimer.restart() } } Timer { id: flashTimer interval: 900 onTriggered: outline.flashRow = -1 } // Shared date picker for SCHEDULED/DEADLINE editing and body-timestamp // insertion. Opened via datePicker.open2(row, field, value) for planning // fields, or datePicker.openInsert() with outline.insertTarget set for // inserting a timestamp into a body editor at the cursor. DatePickerDialog { id: datePicker parent: Overlay.overlay onTimestampAccepted: (ts) => { if (outline.insertTarget) { outline.insertTarget.insert(outline.insertTarget.cursorPosition, ts) outline.insertTarget.forceActiveFocus() } } } 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 // Inline tag editing: when true, the tag pills are replaced by a text // field prefilled with the current tags. property bool editingTags: false // Inline body editing: when true, the rendered body segments are // replaced by a multi-line text editor seeded with the raw body. property bool editingBody: false // Transient flash overlay when this row is revealed via a link. Rectangle { anchors.fill: parent radius: parent.radius color: theme.linkColor z: -1 opacity: outline.flashRow === del.index ? 0.35 : 0.0 Behavior on opacity { NumberAnimation { duration: 300 } } } 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. // `baseLine` is the body line index of the first line of `text`, // so each emitted part records the body line where it begins // (used for char/offset-precise scrolling). function pushText(text, cbCounter, baseLine) { if (!text) return; let lines = text.split('\n'); let buf = []; let items = []; let bufStart = -1; let itemsStart = -1; function flushBuf() { if (buf.length > 0) { let t = buf.join('\n').trim(); if (t) parts.push({type: "text", content: t, startLine: bufStart}); buf = []; } } function flushItems() { if (items.length > 0) { parts.push({type: "list", items: items, startLine: itemsStart}); items = []; } } for (let li = 0; li < lines.length; li++) { let ln = lines[li]; let item = parseListItem(ln); if (item) { flushBuf(); if (items.length === 0) itemsStart = baseLine + li; // Assign a stable ordinal to checkbox-bearing items. item.checkboxOrdinal = (item.checkbox !== "") ? cbCounter[0]++ : -1; items.push(item); } else { flushItems(); if (buf.length === 0) bufStart = baseLine + li; buf.push(ln); } } flushItems(); flushBuf(); } // Running checkbox ordinal across the whole section body. let cbCounter = [0]; let lines = body.split('\n'); let currentText = []; let textRunStart = -1; // body line index where currentText began let inCode = false; let codeLang = ""; let codeLines = []; let codeStart = -1; // body line index of the #+BEGIN_SRC let codeCounter = 0; let inTable = false; let tableLines = []; let tableStart = -1; // body line index of the first table row // Append a line to the pending text run, recording its start line. function pushCurrentText(i) { if (currentText.length === 0) textRunStart = i; currentText.push(lines[i]); } 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, textRunStart); currentText = []; } inCode = true; codeStart = i; 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, startLine: codeStart}); 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, textRunStart); currentText = []; } inTable = true; tableStart = i; 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, startLine: tableStart}); tableLines = []; inTable = false; // Process current line as text if not empty if (trimmed) pushCurrentText(i); } } else { pushCurrentText(i); } } // Flush remaining if (inCode && codeLines.length > 0) { parts.push({type: "code", language: codeLang, content: codeLines.join('\n'), codeIndex: codeCounter, startLine: codeStart}); 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, startLine: tableStart}); } else if (currentText.length > 0) { pushText(currentText.join('\n').trim(), cbCounter, textRunStart); } return parts; } // Map a body byte offset to a y position (relative to this delegate's // top) by locating the body segment that contains the offset's line and // returning that segment's rendered y. This keeps the segmented // rendering while scrolling precisely to the matched segment rather // than only the section top. Returns 0 when there is no better target. function offsetToY(bodyOffset) { if (bodyOffset <= 0 || bodyParts.length === 0) return 0; // Target line = number of newlines before the offset in the body. let body = sectionBody; let clamped = Math.min(bodyOffset, body.length); let targetLine = 0; for (let k = 0; k < clamped; k++) if (body.charCodeAt(k) === 10) targetLine++; // Find the last part whose startLine <= targetLine (parts are in // document order with non-decreasing startLine). let pIdx = -1; for (let p = 0; p < bodyParts.length; p++) { let sl = bodyParts[p].startLine; if (sl === undefined || sl < 0) continue; if (sl <= targetLine) pIdx = p; else break; } if (pIdx < 0) return 0; let item = bodyRepeater.itemAt(pIdx); if (!item) return 0; // y of the segment relative to this delegate. return item.mapToItem(del, 0, 0).y; } 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. Left-click / wheel cycles the keyword // (TODO -> NEXT -> ... -> DONE -> none -> TODO); right-click // opens a menu to pick a state directly. The pill stays present // even when there is no keyword (shown as a faint placeholder), // so a keyword can always be re-added. Each change is one 9P // write via the model. Rectangle { id: kwPill readonly property string kw: del.model.keyword ?? "" readonly property bool hasKw: kw !== "" radius: 3 color: kwMouse.containsMouse ? Qt.rgba( outline.keywordColor(kw).r, outline.keywordColor(kw).g, outline.keywordColor(kw).b, 0.15) : "transparent" border.color: hasKw ? outline.keywordColor(kw) : theme.placeholderTextColor border.width: 1 // Faint when empty unless hovered, so it stays discoverable // without cluttering headlines that have no keyword. opacity: hasKw ? 1.0 : (kwMouse.containsMouse ? 0.9 : 0.35) implicitWidth: kwLabel.implicitWidth + 10 implicitHeight: kwLabel.implicitHeight + 4 Label { id: kwLabel anchors.centerIn: parent text: kwPill.hasKw ? kwPill.kw : "+" color: kwPill.hasKw ? outline.keywordColor(kwPill.kw) : theme.placeholderTextColor font.pointSize: outline.baseFontSize - 1 font.bold: true } MouseArea { id: kwMouse anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor acceptedButtons: Qt.LeftButton | Qt.RightButton onClicked: (mouse) => { if (mouse.button === Qt.RightButton) kwMenu.openFor(del.index) else docModel.cycleKeyword(del.index, true) } onWheel: (wheel) => { docModel.cycleKeyword(del.index, wheel.angleDelta.y > 0) wheel.accepted = true } } // Keyword chooser: pick a state directly, or clear it. // Populated from the document's keyword set on open. Menu { id: kwMenu property int targetRow: -1 property var choices: [] function openFor(row) { targetRow = row choices = docModel.keywordChoices() popup() } Repeater { model: kwMenu.choices delegate: MenuItem { required property string modelData text: modelData onTriggered: docModel.setKeyword(kwMenu.targetRow, modelData) } } MenuSeparator {} MenuItem { text: qsTr("None") onTriggered: docModel.setKeyword(kwMenu.targetRow, "") } } } // Priority cookie. Left-click / wheel cycles // (none) -> A -> B -> C -> (none); right-click opens a menu to // pick directly. Stays present (faint placeholder) when empty // so a priority can be re-added. One 9P write per change. Rectangle { id: prioPill readonly property string pri: del.model.priority ?? "" readonly property bool hasPri: pri !== "" radius: 3 color: "transparent" opacity: hasPri ? 1.0 : (prioMouse.containsMouse ? 0.9 : 0.3) implicitWidth: prioLabel.implicitWidth + 4 implicitHeight: prioLabel.implicitHeight + 2 Label { id: prioLabel anchors.centerIn: parent text: prioPill.hasPri ? "[#" + prioPill.pri + "]" : "[#]" color: prioPill.hasPri ? outline.priorityColor(prioPill.pri) : theme.placeholderTextColor font.pointSize: outline.baseFontSize font.bold: true } MouseArea { id: prioMouse anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor acceptedButtons: Qt.LeftButton | Qt.RightButton onClicked: (mouse) => { if (mouse.button === Qt.RightButton) prioMenu.popup() else docModel.cyclePriority(del.index, true) } onWheel: (wheel) => { docModel.cyclePriority(del.index, wheel.angleDelta.y > 0) wheel.accepted = true } } // Priority chooser: pick A/B/C directly, or clear it. Menu { id: prioMenu MenuItem { text: "A"; onTriggered: docModel.setPriority(del.index, "A") } MenuItem { text: "B"; onTriggered: docModel.setPriority(del.index, "B") } MenuItem { text: "C"; onTriggered: docModel.setPriority(del.index, "C") } MenuSeparator {} MenuItem { text: qsTr("None"); onTriggered: docModel.setPriority(del.index, "") } } } // Title. Rendered as StyledText so inline org links in the // headline are clickable; non-link text keeps the done-aware // color and strike-through. Label { text: outline.renderInline(del.model.title ?? "") color: (del.model.isDone ?? false) ? theme.placeholderTextColor : theme.textColor linkColor: theme.linkColor font.pointSize: outline.levelFontSize(del.sectionLevel) font.strikeout: del.model.isDone ?? false elide: Text.ElideRight Layout.fillWidth: true textFormat: Text.StyledText onLinkActivated: (link) => outline.openLink(link) HoverHandler { enabled: parent.hoveredLink !== "" cursorShape: Qt.PointingHandCursor } } // Scheduled timestamp. Click to edit via the date picker. Label { visible: (del.model.scheduled ?? "") !== "" text: "⏱ " + (del.model.scheduled ?? "") color: theme.timestampColor font.pointSize: outline.baseFontSize - 1 MouseArea { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: datePicker.open2(del.index, "SCHEDULED", del.model.scheduled ?? "") } } // Deadline timestamp. Click to edit via the date picker. Label { visible: (del.model.deadline ?? "") !== "" text: "⚑ " + (del.model.deadline ?? "") color: theme.deadlineColor font.pointSize: outline.baseFontSize - 1 MouseArea { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: datePicker.open2(del.index, "DEADLINE", del.model.deadline ?? "") } } // Calendar button: open the date picker to add or change a // SCHEDULED/DEADLINE timestamp. Prefills from whichever field // is already set (SCHEDULED preferred), else today. Label { text: "📅" font.pointSize: outline.baseFontSize - 1 opacity: calMouse.containsMouse ? 1.0 : 0.45 MouseArea { id: calMouse anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { var hasDeadline = (del.model.deadline ?? "") !== "" var hasScheduled = (del.model.scheduled ?? "") !== "" if (hasDeadline && !hasScheduled) datePicker.open2(del.index, "DEADLINE", del.model.deadline) else datePicker.open2(del.index, "SCHEDULED", del.model.scheduled ?? "") } } } // Tags. In display mode, each tag is a pill and clicking any // pill (or the "+" affordance) opens an inline editor. In edit // mode, a text field holds the space/colon/comma-separated tags; // Enter or focus-loss commits via one 9P write, Escape cancels. RowLayout { spacing: 4 visible: !del.editingTags Repeater { model: del.model.tags ?? [] delegate: Rectangle { required property string modelData radius: 3 color: tagMouse.containsMouse ? theme.highlightColor : 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 } MouseArea { id: tagMouse anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: del.editingTags = true } } } // "+" affordance to add/edit tags (always available). Label { text: "+" color: theme.placeholderTextColor font.pointSize: outline.baseFontSize - 1 opacity: addTagMouse.containsMouse ? 1.0 : 0.5 MouseArea { id: addTagMouse anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: del.editingTags = true } } } TextField { id: tagsEditor visible: del.editingTags Layout.preferredWidth: outline.baseFontSize * 14 placeholderText: qsTr("tag1 tag2 …") font.pointSize: outline.baseFontSize - 1 selectByMouse: true // Prefill with the current tags whenever edit mode opens. onVisibleChanged: { if (visible) { text = (del.model.tags ?? []).join(" ") forceActiveFocus() selectAll() } } function commit() { docModel.setTags(del.index, text.trim()) del.editingTags = false } onAccepted: commit() // Commit when focus is lost (click elsewhere), unless the // editor was already closed (e.g. via Escape). onActiveFocusChanged: { if (!activeFocus && del.editingTags) commit() } Keys.onEscapePressed: del.editingTags = false } } // 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 (rendered segments, or an inline editor). The whole // block is shown when there is body text or while editing (so an // empty body can still be opened for editing via the ⤤ affordance). ColumnLayout { visible: del.sectionBody !== "" || del.editingBody Layout.fillWidth: true spacing: 4 // Edit affordance (shown on the display side). Opens the inline // body editor seeded with the raw body text. RowLayout { Layout.fillWidth: true visible: !del.editingBody Item { Layout.fillWidth: true } Label { text: "⤤ edit" color: theme.placeholderTextColor font.pointSize: outline.baseFontSize - 2 opacity: editBodyMouse.containsMouse ? 1.0 : 0.4 MouseArea { id: editBodyMouse anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: del.editingBody = true } } } Repeater { id: bodyRepeater // Hidden while editing: an empty model removes the rendered // segments so the editor below takes their place. model: del.editingBody ? [] : 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: outline.renderInline(modelData.content) color: theme.placeholderTextColor linkColor: theme.linkColor font.pointSize: outline.baseFontSize + 1 lineHeight: 1.3 wrapMode: Text.WordWrap textFormat: Text.StyledText onLinkActivated: (link) => outline.openLink(link) HoverHandler { enabled: parent.hoveredLink !== "" cursorShape: Qt.PointingHandCursor } } } } // 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 !== "" // Checkbox interaction: left-click toggles, // mouse wheel cycles ( ) -> (-) -> (X). MouseArea { anchors.fill: parent enabled: cbGlyph.cb !== "" visible: enabled cursorShape: Qt.PointingHandCursor onClicked: docModel.toggleCheckbox( del.index, modelData.checkboxOrdinal ?? -1) onWheel: (wheel) => { // Scroll up = forward, down = reverse. let forward = wheel.angleDelta.y > 0 docModel.cycleCheckbox( del.index, modelData.checkboxOrdinal ?? -1, forward) wheel.accepted = true } } } // Item text; checked items are dimmed and struck through. Label { Layout.fillWidth: true text: outline.renderInline(modelData.text ?? "") color: (modelData.checkbox === "checked") ? theme.placeholderTextColor : theme.textColor linkColor: theme.linkColor font.pointSize: outline.baseFontSize + 1 font.strikeout: modelData.checkbox === "checked" wrapMode: Text.WordWrap textFormat: Text.StyledText onLinkActivated: (link) => outline.openLink(link) HoverHandler { enabled: parent.hoveredLink !== "" cursorShape: Qt.PointingHandCursor } } } } } } 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 } } } } } } } } } // Inline body editor. Replaces the rendered segments while // editing. Seeded with the raw body text; a toolbar inserts a // timestamp at the cursor; Save writes via setBody (one 9P // write, server reparses), Cancel/Escape discards. ColumnLayout { id: bodyEditColumn visible: del.editingBody Layout.fillWidth: true spacing: 4 function saveBody() { docModel.setBody(del.index, bodyEditor.text) del.editingBody = false } ScrollView { Layout.fillWidth: true Layout.preferredHeight: Math.min( Math.max(bodyEditor.contentHeight + 16, outline.baseFontSize * 6), outline.baseFontSize * 20) clip: true TextArea { id: bodyEditor wrapMode: TextArea.Wrap font.family: "monospace" font.pointSize: outline.baseFontSize selectByMouse: true background: Rectangle { color: theme.baseColor border.color: theme.midColor border.width: 1 radius: 3 } // Seed from the raw body when the editor opens. onVisibleChanged: { if (visible) { text = docModel.bodyText(del.index) forceActiveFocus() cursorPosition = text.length } } // Ctrl+Enter saves; Escape cancels. Keys.onPressed: (ev) => { if ((ev.key === Qt.Key_Return || ev.key === Qt.Key_Enter) && (ev.modifiers & Qt.ControlModifier)) { bodyEditColumn.saveBody() ev.accepted = true } else if (ev.key === Qt.Key_Escape) { del.editingBody = false ev.accepted = true } } } } // Editor toolbar. RowLayout { Layout.fillWidth: true spacing: 6 Button { text: qsTr("Insert timestamp…") onClicked: { outline.insertTarget = bodyEditor datePicker.openInsert() } } Item { Layout.fillWidth: true } Label { text: qsTr("Ctrl+Enter to save, Esc to cancel") color: theme.placeholderTextColor font.pointSize: outline.baseFontSize - 3 } Button { text: qsTr("Cancel") onClicked: del.editingBody = false } Button { text: qsTr("Save") highlighted: true onClicked: bodyEditColumn.saveBody() } } } } } } // Empty state Label { anchors.centerIn: parent visible: outline.count === 0 text: qsTr("No sections") color: theme.placeholderTextColor font.pointSize: outline.baseFontSize } }