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
// ── Drag-and-drop structural move state ──────────────────────────────
// A drag is scene-level (Qt Drag + DropArea) so the view can spring-load
// to another file mid-drag without cancelling it. dragActive is true for
// the duration; dropInfo is the live resolved target over the current
// outline; the indicator geometry is in content coordinates.
property bool dragActive: false
property var dropInfo: null // {valid, level, parentId, parentTitle, beforeId}
property real dropIndicatorY: 0 // content-y of the insertion bar
property bool dropOverOutline: false // pointer currently over the outline
// Recompute the drop target from a pointer position in content coords.
// The dragged section is tracked by the model (beginDrag), so resolveDrop
// needs only the gap and desired level.
function updateDrop(contentX, contentY) {
if (!dragActive) return
var r = indexAt(1, contentY)
if (r < 0)
r = count - 1
var item = itemAtIndex(r)
var gapAbove
var gapY
if (item) {
var mid = item.y + item.height / 2
if (contentY < mid) { gapAbove = r - 1; gapY = item.y }
else { gapAbove = r; gapY = item.y + item.height }
} else {
gapAbove = r; gapY = contentY
}
// Desired level from horizontal indent (row left padding = indent + 8).
var desired = Math.round((contentX - 8) / outline.indentWidth) + 1
if (desired < 1) desired = 1
outline.dropInfo = docModel.resolveDrop(gapAbove, desired)
outline.dropIndicatorY = gapY
}
// Commit the current drop into the current document (may be a different
// file than the drag started in — the model holds the source).
function commitDrop() {
if (dragActive && dropOverOutline && dropInfo && dropInfo.valid)
docModel.commitDrag(dropInfo.parentId, dropInfo.beforeId)
else
docModel.endDrag()
dragActive = false
dropInfo = null
dropOverOutline = false
}
// 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()
}
}
}
// Drop target covering the outline viewport. Receives the scene drag
// (started from a row's grip) and resolves the in-file position live.
// Because the drag is scene-level, this DropArea keeps working even after
// the view springs to a different file mid-drag.
DropArea {
id: outlineDrop
anchors.fill: parent
z: 90
keys: ["ork-section"]
onEntered: outline.dropOverOutline = true
onExited: outline.dropOverOutline = false
onPositionChanged: (drag) => {
outline.dropOverOutline = true
// DropArea-local coords are viewport coords; add contentY for the
// content coordinate the row geometry uses.
outline.updateDrop(drag.x, drag.y + outline.contentY)
}
onDropped: (drop) => {
outline.dropOverOutline = true
outline.commitDrop()
drop.accept()
}
}
// Drop indicator shown while dragging a section. A horizontal insertion
// bar at the resolved gap, indented to the resolved level, with a ghost
// label naming the resolved level and parent. Driven entirely by
// outline.dropInfo so the preview matches exactly what commitDrop() does.
Item {
id: dropIndicator
z: 100
visible: outline.dragActive && outline.dropOverOutline
&& outline.dropInfo && outline.dropInfo.valid
// Convert the content-y gap position to viewport coordinates.
y: outline.dropIndicatorY - outline.contentY
x: 0
width: outline.width
height: 2
// Indent of the insertion bar = left padding for the resolved level.
readonly property int barIndent: visible
? 8 + (outline.dropInfo.level - 1) * outline.indentWidth : 0
Rectangle {
x: dropIndicator.barIndent
width: parent.width - x - 8
height: 2
y: -1
color: theme.linkColor
radius: 1
}
// Left cap circle at the insertion point.
Rectangle {
x: dropIndicator.barIndent - 3
y: -3
width: 6; height: 6; radius: 3
color: theme.linkColor
}
// Ghost label: "L{level} under \"{parent}\"" (or "top level").
Rectangle {
x: dropIndicator.barIndent
y: 2
width: ghostLabel.implicitWidth + 8
height: ghostLabel.implicitHeight + 2
radius: 3
color: theme.windowColor
border.color: theme.linkColor
border.width: 1
Label {
id: ghostLabel
anchors.centerIn: parent
text: {
if (!outline.dropInfo || !outline.dropInfo.valid) return ""
var lvl = "L" + outline.dropInfo.level
var p = (outline.dropInfo.parentTitle && outline.dropInfo.parentTitle.length > 0)
? qsTr("under \"%1\"").arg(outline.dropInfo.parentTitle)
: qsTr("at top level")
return lvl + " " + p
}
color: theme.textColor
font.pointSize: outline.baseFontSize - 3
}
}
}
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
// Drag grip (left gutter): starts a scene-level drag of this
// section (and its subtree). The drag is a Qt Automatic drag so
// it is delivered to DropAreas across the window and survives
// the view springing to another file mid-drag. The outline's
// DropArea resolves the in-file position; a sidebar doc's
// DropArea spring-loads to that file.
Rectangle {
id: gripLabel
Layout.preferredWidth: outline.baseFontSize + 8
Layout.preferredHeight: outline.baseFontSize + 8
radius: 3
readonly property bool active: gripMouse.pressed
|| gripMouse.drag.active
color: active ? theme.highlightColor
: (gripMouse.containsMouse ? theme.alternateBaseColor : "transparent")
Label {
anchors.centerIn: parent
text: "⠿"
color: gripLabel.active ? theme.highlightedTextColor
: theme.placeholderTextColor
font.pointSize: outline.baseFontSize - 1
opacity: gripMouse.containsMouse || gripLabel.active ? 1.0 : 0.4
}
// Invisible proxy that carries the drag payload. Binding
// Drag.active to the MouseArea's drag.active and using
// dragType Automatic makes Qt start an OS-level drag by
// itself (no startDrag() call) when the drag threshold is
// crossed. The OS drag is scene/window-level, so it is
// delivered to DropAreas and survives the view springing to
// another file mid-drag.
Item {
id: dragProxy
visible: false
width: 1; height: 1
Drag.active: gripMouse.drag.active
Drag.dragType: Drag.Automatic
Drag.supportedActions: Qt.MoveAction
Drag.proposedAction: Qt.MoveAction
Drag.keys: ["ork-section"]
Drag.onActiveChanged: {
if (!Drag.active && outline.dragActive) {
// Drag ended. A drop on the outline committed via
// the DropArea; otherwise clean up here.
outline.commitDrop()
}
}
}
MouseArea {
id: gripMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: gripLabel.active ? Qt.ClosedHandCursor
: Qt.OpenHandCursor
preventStealing: true
// Dragging this (invisible, unanchored) proxy provides
// the drag.active signal the proxy's Drag binds to.
drag.target: dragProxy
drag.threshold: 4
onPressed: {
// Record the drag source and payload up front; the
// Automatic drag auto-starts once the threshold is
// crossed.
docModel.beginDrag(del.index)
outline.dragActive = true
dragProxy.Drag.mimeData = {
"text/plain": docModel.dragSourceDoc()
+ "\t" + docModel.dragSourceId()
}
}
onReleased: {
// A plain click (no drag) never activated the drag:
// clear the state we set in onPressed.
if (outline.dragActive && !drag.active)
outline.commitDrop()
}
}
}
// 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. Loaded lazily (only when the user opens
// the editor) so the TextArea is not instantiated per row —
// avoiding a Breeze style instantiation error and saving memory.
Loader {
id: bodyEditLoader
active: del.editingBody
Layout.fillWidth: true
sourceComponent: ColumnLayout {
id: bodyEditColumn
spacing: 4
function saveBody() {
docModel.setBody(del.index, bodyEditor.text)
del.editingBody = false
}
// Bordered scroll frame holding the editor. Uses the
// Flickable + TextArea.flickable idiom (not ScrollView),
// which the Breeze style accepts; a custom background on a
// styled TextArea trips Breeze's TextArea internals.
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: Math.min(
Math.max(bodyEditor.contentHeight + 16, outline.baseFontSize * 6),
outline.baseFontSize * 20)
color: theme.baseColor
border.color: theme.midColor
border.width: 1
radius: 3
Flickable {
id: bodyFlick
anchors.fill: parent
anchors.margins: 4
clip: true
contentWidth: width
contentHeight: bodyEditor.contentHeight
boundsBehavior: Flickable.StopAtBounds
ScrollBar.vertical: ScrollBar {}
TextArea.flickable: TextArea {
id: bodyEditor
wrapMode: TextArea.Wrap
font.family: "monospace"
font.pointSize: outline.baseFontSize
selectByMouse: true
background: null
// 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
}
}