From b79970b62f4085510e1c9ab35c269eb2a3b97dea Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Thu, 1 Oct 2026 15:56:56 +0200 Subject: [PATCH] Render org links in the GUI body Body text and list items now render org links ([[target]] and [[target][description]]) as clickable hyperlinks via StyledText. Other text is HTML-escaped and newlines become
so formatting is preserved. Activating a link opens external URL schemes (http(s), mailto, file) in the system handler; internal links (id:, #custom, *headline) are left for a future navigation pass. The pointer cursor shows over hovered links. --- ui/qml/OutlineView.qml | 61 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 57 insertions(+), 4 deletions(-) diff --git a/ui/qml/OutlineView.qml b/ui/qml/OutlineView.qml index 6eb7b61..fd0ea88 100644 --- a/ui/qml/OutlineView.qml +++ b/ui/qml/OutlineView.qml @@ -41,6 +41,45 @@ ListView { 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, ">") + } + + // Render inline org markup (currently links) to StyledText. Org links are + // [[target]] or [[target][description]]; everything else is escaped. Line + // breaks are converted to
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, "
") } + 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, """) + out += '' + escHtml(desc) + '' + last = re.lastIndex + } + out += escBlock(text.slice(last)) + return out + } + + // Open a link target activated from body text. External URL schemes open + // in the system handler; internal org links (id:, #custom, *headline) are + // left for a future navigation pass. + function openLink(target) { + if (/^([a-zA-Z][a-zA-Z0-9+.-]*):\/\//.test(target) + || /^(mailto:|file:)/.test(target)) { + Qt.openUrlExternally(target) + } + } + delegate: Rectangle { id: del width: outline.width @@ -427,12 +466,19 @@ ListView { } Label { Layout.fillWidth: true - text: modelData.content + text: outline.renderInline(modelData.content) color: theme.placeholderTextColor + linkColor: theme.linkColor font.pointSize: outline.baseFontSize + 1 lineHeight: 1.3 wrapMode: Text.WordWrap - textFormat: Text.PlainText + textFormat: Text.StyledText + onLinkActivated: (link) => outline.openLink(link) + + HoverHandler { + enabled: parent.hoveredLink !== "" + cursorShape: Qt.PointingHandCursor + } } } } @@ -497,13 +543,20 @@ ListView { // Item text; checked items are dimmed and struck through. Label { Layout.fillWidth: true - text: modelData.text ?? "" + 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.PlainText + textFormat: Text.StyledText + onLinkActivated: (link) => outline.openLink(link) + + HoverHandler { + enabled: parent.hoveredLink !== "" + cursorShape: Qt.PointingHandCursor + } } } }