675 lines
31 KiB
QML
675 lines
31 KiB
QML
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)
|
|
}
|
|
|
|
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
|
|
|
|
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: modelData.content
|
|
color: theme.placeholderTextColor
|
|
font.pointSize: outline.baseFontSize + 1
|
|
lineHeight: 1.3
|
|
wrapMode: Text.WordWrap
|
|
textFormat: Text.PlainText
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 !== ""
|
|
|
|
// Clickable only for checkbox items; toggles via the model.
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
enabled: cbGlyph.cb !== ""
|
|
visible: enabled
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: docModel.toggleCheckbox(
|
|
del.index, modelData.checkboxOrdinal ?? -1)
|
|
}
|
|
}
|
|
|
|
// Item text; checked items are dimmed and struck through.
|
|
Label {
|
|
Layout.fillWidth: true
|
|
text: modelData.text ?? ""
|
|
color: (modelData.checkbox === "checked")
|
|
? theme.placeholderTextColor : theme.textColor
|
|
font.pointSize: outline.baseFontSize + 1
|
|
font.strikeout: modelData.checkbox === "checked"
|
|
wrapMode: Text.WordWrap
|
|
textFormat: Text.PlainText
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|