orkmode/ui/qml/OutlineView.qml

1417 lines
66 KiB
QML
Raw Permalink 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 ──────────────────────────────
// 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, "&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 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
}
}