orkmode/ui/qml/OutlineView.qml

768 lines
35 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.
Connections {
target: docModel
function onSectionRevealed(row) {
if (row < 0)
return
outline.currentIndex = row
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.
function pushText(text, cbCounter) {
if (!text) return;
let lines = text.split('\n');
let buf = [];
let items = [];
function flushBuf() {
if (buf.length > 0) {
let t = buf.join('\n').trim();
if (t) parts.push({type: "text", content: t});
buf = [];
}
}
function flushItems() {
if (items.length > 0) {
parts.push({type: "list", items: items});
items = [];
}
}
for (let ln of lines) {
let item = parseListItem(ln);
if (item) {
flushBuf();
// Assign a stable ordinal to checkbox-bearing items.
item.checkboxOrdinal = (item.checkbox !== "")
? cbCounter[0]++ : -1;
items.push(item);
} else {
flushItems();
buf.push(ln);
}
}
flushItems();
flushBuf();
}
// Running checkbox ordinal across the whole section body.
let cbCounter = [0];
let lines = body.split('\n');
let currentText = [];
let inCode = false;
let codeLang = "";
let codeLines = [];
let codeCounter = 0;
let inTable = false;
let tableLines = [];
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);
currentText = [];
}
inCode = true;
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});
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);
currentText = [];
}
inTable = true;
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});
tableLines = [];
inTable = false;
// Process current line as text if not empty
if (trimmed) currentText.push(line);
}
} else {
currentText.push(line);
}
}
// Flush remaining
if (inCode && codeLines.length > 0) {
parts.push({type: "code", language: codeLang, content: codeLines.join('\n'), codeIndex: codeCounter});
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});
} else if (currentText.length > 0) {
pushText(currentText.join('\n').trim(), cbCounter);
}
return parts;
}
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
Label {
text: del.model.title ?? ""
color: (del.model.isDone ?? false) ? theme.placeholderTextColor : theme.textColor
font.pointSize: outline.levelFontSize(del.sectionLevel)
font.strikeout: del.model.isDone ?? false
elide: Text.ElideRight
Layout.fillWidth: true
}
// 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 {
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
}
}