orkmode/ui/qml/OutlineView.qml

1365 lines
64 KiB
QML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 ──────────────────────────────
// The visible row currently being dragged (-1 = none), the live resolved
// drop target from docModel.resolveDrop, and the gap geometry for drawing
// the insertion indicator.
property int dragRow: -1
property var dropInfo: null // {valid, level, parentId, parentTitle, beforeId}
property real dropIndicatorY: 0 // content-y of the insertion bar
property int dropGapAbove: -2 // visible row above the gap (-1 = top)
// Recompute the drop target from a pointer position in content coords.
function updateDrop(contentX, contentY) {
if (dragRow < 0) return
// Which row is the pointer over? The gap is above or below its middle.
var r = indexAt(1, contentY)
if (r < 0) {
// Past the last row → gap at the very bottom.
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 is
// indent(level) + 8; one level ≈ indentWidth px.
var desired = Math.round((contentX - 8) / outline.indentWidth) + 1
if (desired < 1) desired = 1
var info = docModel.resolveDrop(dragRow, gapAbove, desired)
outline.dropInfo = info
outline.dropGapAbove = gapAbove
outline.dropIndicatorY = gapY
}
// Commit the current drop (called on drag release).
function commitDrop() {
if (dragRow >= 0 && dropInfo && dropInfo.valid) {
docModel.refileSection(dragRow, dropInfo.parentId, dropInfo.beforeId)
}
dragRow = -1
dropInfo = null
dropGapAbove = -2
}
// 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
}
// Render inline org markup (currently links) to StyledText. Org links are
// [[target]] or [[target][description]]; everything else is escaped. Line
// breaks are converted to <br> 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, "<br>") }
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, "&quot;")
out += '<a href="' + href + '">' + escHtml(desc) + '</a>'
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 /<doc>/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 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.dragRow >= 0 && 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): a dedicated drag control. Pressing
// it disables ListView flicking for the duration of the press,
// so the grip — not the enclosing Flickable — owns the gesture;
// dragging then restructures the section (and its subtree) and
// releasing commits a refile. Horizontal position chooses the
// depth, vertical the gap. Flicking is restored on release.
Rectangle {
id: gripLabel
Layout.preferredWidth: outline.baseFontSize + 8
Layout.preferredHeight: outline.baseFontSize + 8
radius: 3
readonly property bool active: outline.dragRow === del.index
|| gripPress.pressed
color: active ? theme.highlightColor
: (gripHover.hovered ? theme.alternateBaseColor : "transparent")
Label {
anchors.centerIn: parent
text: "⠿"
color: gripLabel.active ? theme.highlightedTextColor
: theme.placeholderTextColor
font.pointSize: outline.baseFontSize - 1
opacity: gripHover.hovered || gripLabel.active ? 1.0 : 0.4
}
HoverHandler {
id: gripHover
cursorShape: gripLabel.active ? Qt.ClosedHandCursor : Qt.OpenHandCursor
}
// Own the gesture: while the grip is pressed, the ListView
// must not flick. This is what makes the gutter behave as a
// separate control rather than fighting the Flickable.
TapHandler {
id: gripPress
onPressedChanged: outline.interactive = !pressed
}
// The structural drag. The Flickable is non-interactive
// while pressed, so the handler takes the grab cleanly.
DragHandler {
id: gripDrag
target: null // track only; don't move the item
dragThreshold: 3
grabPermissions: PointerHandler.CanTakeOverFromItems
| PointerHandler.CanTakeOverFromHandlersOfDifferentType
onActiveChanged: {
if (active) {
outline.dragRow = del.index
} else {
outline.commitDrop()
outline.interactive = true // belt-and-suspenders
}
}
onCentroidChanged: {
if (!active || outline.dragRow < 0) return
// Map the scene pointer position to ListView content
// coordinates. mapFromItem(null, ...) treats the
// source coordinates as scene coordinates.
var p = outline.contentItem.mapFromItem(
null,
centroid.scenePosition.x,
centroid.scenePosition.y)
outline.updateDrop(p.x, p.y)
}
}
}
// 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
}
}