orkmode/ui/qml/OutlineView.qml

846 lines
39 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
// 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, "&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
}
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
// Transient flash overlay when this row is revealed via a link.
Rectangle {
anchors.fill: parent
radius: parent.radius
color: theme.linkColor
z: -1
opacity: outline.flashRow === del.index ? 0.35 : 0.0
Behavior on opacity { NumberAnimation { duration: 300 } }
}
readonly property string sectionBody: (model.body ?? "").trim()
readonly property int sectionLevel: model.level ?? 1
readonly property int indent: (sectionLevel - 1) * outline.indentWidth
// Parse body into text, code block, and table segments
readonly property var bodyParts: {
let parts = [];
let body = sectionBody;
if (!body) return parts;
// Match an org list item, capturing indent, bullet/number, optional
// checkbox, and the item text. Returns null for non-list lines.
function parseListItem(line) {
// Unordered: - / + bullet (indented * also allowed)
let m = line.match(/^(\s*)([-+])\s+(\[([ xX-])\]\s*)?(.*)$/);
let ordered = false;
if (!m) {
// Ordered: 1. or 1)
m = line.match(/^(\s*)(\d+)[.)]\s+(\[([ xX-])\]\s*)?(.*)$/);
if (!m) return null;
ordered = true;
}
let cb = "";
if (m[3] !== undefined) {
switch ((m[4] || " ")) {
case "x": case "X": cb = "checked"; break;
case "-": cb = "partial"; break;
default: cb = "unchecked"; break;
}
}
return {
indent: m[1].length,
ordered: ordered,
bullet: m[2],
checkbox: cb,
text: m[5]
};
}
// Push a run of text, splitting out any contiguous list items into
// dedicated "list" parts so they render with bullets/checkboxes.
// `cbCounter` is a single-element array used as a mutable counter so
// checkbox ordinals are stable across the whole section body.
// `baseLine` is the body line index of the first line of `text`,
// so each emitted part records the body line where it begins
// (used for char/offset-precise scrolling).
function pushText(text, cbCounter, baseLine) {
if (!text) return;
let lines = text.split('\n');
let buf = [];
let items = [];
let bufStart = -1;
let itemsStart = -1;
function flushBuf() {
if (buf.length > 0) {
let t = buf.join('\n').trim();
if (t) parts.push({type: "text", content: t, startLine: bufStart});
buf = [];
}
}
function flushItems() {
if (items.length > 0) {
parts.push({type: "list", items: items, startLine: itemsStart});
items = [];
}
}
for (let li = 0; li < lines.length; li++) {
let ln = lines[li];
let item = parseListItem(ln);
if (item) {
flushBuf();
if (items.length === 0) itemsStart = baseLine + li;
// Assign a stable ordinal to checkbox-bearing items.
item.checkboxOrdinal = (item.checkbox !== "")
? cbCounter[0]++ : -1;
items.push(item);
} else {
flushItems();
if (buf.length === 0) bufStart = baseLine + li;
buf.push(ln);
}
}
flushItems();
flushBuf();
}
// Running checkbox ordinal across the whole section body.
let cbCounter = [0];
let lines = body.split('\n');
let currentText = [];
let textRunStart = -1; // body line index where currentText began
let inCode = false;
let codeLang = "";
let codeLines = [];
let codeStart = -1; // body line index of the #+BEGIN_SRC
let codeCounter = 0;
let inTable = false;
let tableLines = [];
let tableStart = -1; // body line index of the first table row
// Append a line to the pending text run, recording its start line.
function pushCurrentText(i) {
if (currentText.length === 0) textRunStart = i;
currentText.push(lines[i]);
}
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
let trimmed = line.trim();
// Code block handling
if (!inCode && !inTable && trimmed.toUpperCase().startsWith("#+BEGIN_SRC")) {
// Flush pending text
if (currentText.length > 0) {
pushText(currentText.join('\n').trim(), cbCounter, textRunStart);
currentText = [];
}
inCode = true;
codeStart = i;
codeLang = trimmed.substring(11).trim().split(/\s+/)[0] || "";
codeLines = [];
} else if (inCode && trimmed.toUpperCase() === "#+END_SRC") {
parts.push({type: "code", language: codeLang, content: codeLines.join('\n'), codeIndex: codeCounter, startLine: codeStart});
codeCounter++;
inCode = false;
codeLang = "";
codeLines = [];
} else if (inCode) {
codeLines.push(line);
}
// Table handling
else if (!inTable && trimmed.startsWith("|")) {
// Flush pending text
if (currentText.length > 0) {
pushText(currentText.join('\n').trim(), cbCounter, textRunStart);
currentText = [];
}
inTable = true;
tableStart = i;
tableLines = [trimmed];
} else if (inTable && trimmed.startsWith("|")) {
tableLines.push(trimmed);
} else if (inTable) {
// End of table - skip #+TBLFM lines
if (!trimmed.startsWith("#+TBLFM")) {
// Parse table
let rows = [];
let hasHeader = false;
for (let tl of tableLines) {
if (tl.match(/^\|\s*[-+]+\s*\|/)) {
hasHeader = rows.length > 0;
} else {
let cells = tl.split("|").slice(1, -1).map(c => c.trim());
rows.push(cells);
}
}
parts.push({type: "table", rows: rows, hasHeader: hasHeader, startLine: tableStart});
tableLines = [];
inTable = false;
// Process current line as text if not empty
if (trimmed) pushCurrentText(i);
}
} else {
pushCurrentText(i);
}
}
// Flush remaining
if (inCode && codeLines.length > 0) {
parts.push({type: "code", language: codeLang, content: codeLines.join('\n'), codeIndex: codeCounter, startLine: codeStart});
codeCounter++;
} else if (inTable && tableLines.length > 0) {
let rows = [];
let hasHeader = false;
for (let tl of tableLines) {
if (tl.match(/^\|\s*[-+]+\s*\|/)) {
hasHeader = rows.length > 0;
} else {
let cells = tl.split("|").slice(1, -1).map(c => c.trim());
rows.push(cells);
}
}
parts.push({type: "table", rows: rows, hasHeader: hasHeader, startLine: tableStart});
} else if (currentText.length > 0) {
pushText(currentText.join('\n').trim(), cbCounter, textRunStart);
}
return parts;
}
// Map a body byte offset to a y position (relative to this delegate's
// top) by locating the body segment that contains the offset's line and
// returning that segment's rendered y. This keeps the segmented
// rendering while scrolling precisely to the matched segment rather
// than only the section top. Returns 0 when there is no better target.
function offsetToY(bodyOffset) {
if (bodyOffset <= 0 || bodyParts.length === 0)
return 0;
// Target line = number of newlines before the offset in the body.
let body = sectionBody;
let clamped = Math.min(bodyOffset, body.length);
let targetLine = 0;
for (let k = 0; k < clamped; k++)
if (body.charCodeAt(k) === 10) targetLine++;
// Find the last part whose startLine <= targetLine (parts are in
// document order with non-decreasing startLine).
let pIdx = -1;
for (let p = 0; p < bodyParts.length; p++) {
let sl = bodyParts[p].startLine;
if (sl === undefined || sl < 0) continue;
if (sl <= targetLine) pIdx = p; else break;
}
if (pIdx < 0)
return 0;
let item = bodyRepeater.itemAt(pIdx);
if (!item)
return 0;
// y of the segment relative to this delegate.
return item.mapToItem(del, 0, 0).y;
}
MouseArea {
anchors.fill: parent
onClicked: outline.currentIndex = del.index
}
ColumnLayout {
id: contentColumn
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.leftMargin: del.indent + 8
anchors.rightMargin: 8
anchors.topMargin: outline.rowPad
spacing: 4
RowLayout {
id: headlineRow
Layout.fillWidth: true
spacing: 8
// Expand/collapse indicator for sections with children
Label {
visible: (del.model.hasChildren ?? false)
text: (del.model.expanded ?? true) ? "▼" : "▶"
color: theme.placeholderTextColor
font.pointSize: outline.baseFontSize - 2
MouseArea {
anchors.fill: parent
onClicked: {
// Toggle expansion via model
if (del.model.expanded !== undefined) {
docModel.toggleExpanded(del.index)
}
}
}
}
// TODO keyword pill
Rectangle {
visible: (del.model.keyword ?? "") !== ""
radius: 3
color: "transparent"
border.color: outline.keywordColor(del.model.keyword ?? "")
border.width: 1
implicitWidth: kwLabel.implicitWidth + 10
implicitHeight: kwLabel.implicitHeight + 4
Label {
id: kwLabel
anchors.centerIn: parent
text: del.model.keyword ?? ""
color: outline.keywordColor(del.model.keyword ?? "")
font.pointSize: outline.baseFontSize - 1
font.bold: true
}
}
// Priority cookie
Label {
visible: (del.model.priority ?? "") !== ""
text: "[#" + (del.model.priority ?? "") + "]"
color: outline.priorityColor(del.model.priority ?? "")
font.pointSize: outline.baseFontSize
font.bold: true
}
// Title. 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
Label {
visible: (del.model.scheduled ?? "") !== ""
text: "⏱ " + (del.model.scheduled ?? "")
color: theme.timestampColor
font.pointSize: outline.baseFontSize - 1
}
// Deadline timestamp
Label {
visible: (del.model.deadline ?? "") !== ""
text: "⚑ " + (del.model.deadline ?? "")
color: theme.deadlineColor
font.pointSize: outline.baseFontSize - 1
}
// Tags
Repeater {
model: del.model.tags ?? []
delegate: Rectangle {
required property string modelData
radius: 3
color: theme.alternateBaseColor
implicitWidth: tagLabel.implicitWidth + 8
implicitHeight: tagLabel.implicitHeight + 3
Label {
id: tagLabel
anchors.centerIn: parent
text: parent.modelData
color: theme.tagColor
font.pointSize: outline.baseFontSize - 2
}
}
}
}
// Properties drawer (collapsible)
RowLayout {
visible: Object.keys(del.model.properties ?? {}).length > 0
Layout.fillWidth: true
spacing: 4
Label {
id: drawerToggle
text: del.drawerOpen ? "▼" : "▶"
color: theme.placeholderTextColor
font.pointSize: outline.baseFontSize - 2
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: del.drawerOpen = !del.drawerOpen
}
}
Label {
text: ":PROPERTIES:"
color: theme.placeholderTextColor
font.pointSize: outline.baseFontSize - 1
font.family: "monospace"
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: del.drawerOpen = !del.drawerOpen
}
}
}
// Properties content (when expanded)
ColumnLayout {
visible: del.drawerOpen && Object.keys(del.model.properties ?? {}).length > 0
Layout.fillWidth: true
Layout.leftMargin: 16
spacing: 2
Repeater {
model: Object.keys(del.model.properties ?? {})
delegate: RowLayout {
required property string modelData
spacing: 8
Label {
text: ":" + modelData + ":"
color: theme.linkColor
font.pointSize: outline.baseFontSize - 1
font.family: "monospace"
}
Label {
text: del.model.properties[modelData] ?? ""
color: theme.textColor
font.pointSize: outline.baseFontSize - 1
font.family: "monospace"
}
}
}
}
// Body content (text and code blocks)
ColumnLayout {
visible: del.sectionBody !== ""
Layout.fillWidth: true
spacing: 4
Repeater {
id: bodyRepeater
model: 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
}
}
}
}
}
}
}
}
}
}
}
}
// Empty state
Label {
anchors.centerIn: parent
visible: outline.count === 0
text: qsTr("No sections")
color: theme.placeholderTextColor
font.pointSize: outline.baseFontSize
}
}