aboutsummaryrefslogtreecommitdiff
path: root/Sources/NorgUI/Views
diff options
context:
space:
mode:
authorRuben Beltran del Rio <jj@r.bdr.sh>2026-06-15 14:35:36 +0200
committerRuben Beltran del Rio <jj@r.bdr.sh>2026-06-16 14:47:30 +0200
commit657aa83a5ca24dc35572c040df64a0abb85e8612 (patch)
treefe0b6fd425d8cad9dc6a28ada4ad8600e42b367e /Sources/NorgUI/Views
Initial extraction from Norganize
Diffstat (limited to 'Sources/NorgUI/Views')
-rw-r--r--Sources/NorgUI/Views/Blocks/BlockTaskRow.swift23
-rw-r--r--Sources/NorgUI/Views/Blocks/BlockView.swift49
-rw-r--r--Sources/NorgUI/Views/Blocks/CodeBlockView.swift10
-rw-r--r--Sources/NorgUI/Views/Blocks/HeadingView.swift26
-rw-r--r--Sources/NorgUI/Views/Blocks/ListItemView.swift27
-rw-r--r--Sources/NorgUI/Views/Blocks/ParagraphView.swift12
-rw-r--r--Sources/NorgUI/Views/Blocks/QuoteView.swift29
-rw-r--r--Sources/NorgUI/Views/Blocks/RangeableBlockView.swift22
-rw-r--r--Sources/NorgUI/Views/Blocks/RangedTagView.swift10
-rw-r--r--Sources/NorgUI/Views/FlatBlocksView.swift42
-rw-r--r--Sources/NorgUI/Views/NorgTaskView.swift63
-rw-r--r--Sources/NorgUI/Views/NorgView.swift43
-rw-r--r--Sources/NorgUI/Views/TreeNodesView.swift48
-rw-r--r--Sources/NorgUI/Views/VerbatimBlock.swift23
14 files changed, 427 insertions, 0 deletions
diff --git a/Sources/NorgUI/Views/Blocks/BlockTaskRow.swift b/Sources/NorgUI/Views/Blocks/BlockTaskRow.swift
new file mode 100644
index 0000000..45255cd
--- /dev/null
+++ b/Sources/NorgUI/Views/Blocks/BlockTaskRow.swift
@@ -0,0 +1,23 @@
+import NorgKit
+import SwiftUI
+
+/// Adapts any block with status to a NorgTaskView
+struct BlockTaskRow: View {
+ @Environment(\.norgTheme) private var theme
+
+ let status: TaskStatus
+ let content: [InlineSpan]
+ let font: Font
+ let line: Int
+ let onSetStatus: (Int, TaskStatus) -> Void
+
+ var body: some View {
+ NorgTaskView(
+ status: status,
+ text: AttributedString(norg: content, baseFont: font, theme: theme),
+ font: font,
+ onToggle: { onSetStatus(line, status == .done ? .undone : .done) },
+ onSetStatus: { onSetStatus(line, $0) }
+ )
+ }
+}
diff --git a/Sources/NorgUI/Views/Blocks/BlockView.swift b/Sources/NorgUI/Views/Blocks/BlockView.swift
new file mode 100644
index 0000000..59fcb78
--- /dev/null
+++ b/Sources/NorgUI/Views/Blocks/BlockView.swift
@@ -0,0 +1,49 @@
+import NorgKit
+import SwiftUI
+
+// General block renderer.
+struct BlockView: View {
+ let block: NorgBlock
+ let ordinal: Int?
+ let onSetStatus: (Int, TaskStatus) -> Void
+
+ var body: some View {
+ switch block {
+ case .heading(let level, let status, let content, let line):
+ HeadingView(
+ level: level, status: status, content: content, line: line, onSetStatus: onSetStatus)
+
+ case .paragraph(let content, _):
+ ParagraphView(content: content)
+
+ case .unorderedListItem(_, let status, let content, let line):
+ ListItemView(
+ marker: "•", status: status, content: content, line: line, onSetStatus: onSetStatus)
+
+ case .orderedListItem(_, let status, let content, let line):
+ ListItemView(
+ marker: "\(ordinal ?? 1).", status: status, content: content,
+ line: line, onSetStatus: onSetStatus)
+
+ case .quote(_, let status, let content, let line):
+ QuoteView(status: status, content: content, line: line, onSetStatus: onSetStatus)
+
+ case .codeBlock(let language, let code, _):
+ CodeBlockView(language: language, code: code)
+
+ case .definition(let title, let status, _, let line),
+ .footnote(let title, let status, _, let line),
+ .tableCell(let title, let status, _, let line):
+ RangeableBlockView(title: title, status: status, line: line, onSetStatus: onSetStatus)
+
+ case .rangedTag(let name, _, let content, _):
+ RangedTagView(name: name, content: content)
+
+ case .horizontalRule:
+ Divider()
+
+ case .weakDelimiter, .strongDelimiter:
+ EmptyView()
+ }
+ }
+}
diff --git a/Sources/NorgUI/Views/Blocks/CodeBlockView.swift b/Sources/NorgUI/Views/Blocks/CodeBlockView.swift
new file mode 100644
index 0000000..2f1ebfb
--- /dev/null
+++ b/Sources/NorgUI/Views/Blocks/CodeBlockView.swift
@@ -0,0 +1,10 @@
+import SwiftUI
+
+struct CodeBlockView: View {
+ let language: String?
+ let code: String
+
+ var body: some View {
+ VerbatimBlock(label: language, content: code)
+ }
+}
diff --git a/Sources/NorgUI/Views/Blocks/HeadingView.swift b/Sources/NorgUI/Views/Blocks/HeadingView.swift
new file mode 100644
index 0000000..3b65134
--- /dev/null
+++ b/Sources/NorgUI/Views/Blocks/HeadingView.swift
@@ -0,0 +1,26 @@
+import NorgKit
+import SwiftUI
+
+struct HeadingView: View {
+ @Environment(\.norgTheme) private var theme
+
+ let level: Int
+ let status: TaskStatus?
+ let content: [InlineSpan]
+ let line: Int
+ let onSetStatus: (Int, TaskStatus) -> Void
+
+ var body: some View {
+ let font = theme.heading(level)
+ Group {
+ if let status {
+ BlockTaskRow(
+ status: status, content: content, font: font, line: line, onSetStatus: onSetStatus)
+ } else {
+ Text(AttributedString(norg: content, baseFont: font, theme: theme))
+ .font(font)
+ }
+ }
+ .padding(.top, 4)
+ }
+}
diff --git a/Sources/NorgUI/Views/Blocks/ListItemView.swift b/Sources/NorgUI/Views/Blocks/ListItemView.swift
new file mode 100644
index 0000000..581974b
--- /dev/null
+++ b/Sources/NorgUI/Views/Blocks/ListItemView.swift
@@ -0,0 +1,27 @@
+import NorgKit
+import SwiftUI
+
+struct ListItemView: View {
+ @Environment(\.norgTheme) private var theme
+
+ let marker: String
+ let status: TaskStatus?
+ let content: [InlineSpan]
+ let line: Int
+ let onSetStatus: (Int, TaskStatus) -> Void
+
+ var body: some View {
+ HStack(alignment: .firstTextBaseline, spacing: 8) {
+ if let status {
+ BlockTaskRow(
+ status: status, content: content, font: theme.body, line: line, onSetStatus: onSetStatus
+ )
+ } else {
+ Text(marker)
+ .foregroundStyle(.secondary)
+ .monospacedDigit()
+ Text(AttributedString(norg: content, theme: theme))
+ }
+ }
+ }
+}
diff --git a/Sources/NorgUI/Views/Blocks/ParagraphView.swift b/Sources/NorgUI/Views/Blocks/ParagraphView.swift
new file mode 100644
index 0000000..2ad0dd5
--- /dev/null
+++ b/Sources/NorgUI/Views/Blocks/ParagraphView.swift
@@ -0,0 +1,12 @@
+import NorgKit
+import SwiftUI
+
+struct ParagraphView: View {
+ @Environment(\.norgTheme) private var theme
+
+ let content: [InlineSpan]
+
+ var body: some View {
+ Text(AttributedString(norg: content, theme: theme))
+ }
+}
diff --git a/Sources/NorgUI/Views/Blocks/QuoteView.swift b/Sources/NorgUI/Views/Blocks/QuoteView.swift
new file mode 100644
index 0000000..0347d78
--- /dev/null
+++ b/Sources/NorgUI/Views/Blocks/QuoteView.swift
@@ -0,0 +1,29 @@
+import NorgKit
+import SwiftUI
+
+struct QuoteView: View {
+ @Environment(\.norgTheme) private var theme
+
+ let status: TaskStatus?
+ let content: [InlineSpan]
+ let line: Int
+ let onSetStatus: (Int, TaskStatus) -> Void
+
+ var body: some View {
+ HStack(alignment: .top, spacing: 8) {
+ RoundedRectangle(cornerRadius: 1.5)
+ .fill(theme.quoteBarColor)
+ .frame(width: 3)
+ if let status {
+ BlockTaskRow(
+ status: status, content: content, font: theme.body, line: line, onSetStatus: onSetStatus
+ )
+ } else {
+ Text(AttributedString(norg: content, theme: theme))
+ .foregroundStyle(theme.quoteColor)
+ .italic()
+ }
+ }
+ .fixedSize(horizontal: false, vertical: true)
+ }
+}
diff --git a/Sources/NorgUI/Views/Blocks/RangeableBlockView.swift b/Sources/NorgUI/Views/Blocks/RangeableBlockView.swift
new file mode 100644
index 0000000..433d89e
--- /dev/null
+++ b/Sources/NorgUI/Views/Blocks/RangeableBlockView.swift
@@ -0,0 +1,22 @@
+import NorgKit
+import SwiftUI
+
+struct RangeableBlockView: View {
+ @Environment(\.norgTheme) private var theme
+
+ let title: [InlineSpan]
+ let status: TaskStatus?
+ let line: Int
+ let onSetStatus: (Int, TaskStatus) -> Void
+
+ var body: some View {
+ if let status {
+ BlockTaskRow(
+ status: status, content: title, font: theme.termFont, line: line, onSetStatus: onSetStatus
+ )
+ } else {
+ Text(AttributedString(norg: title, baseFont: theme.termFont, theme: theme))
+ .font(theme.termFont)
+ }
+ }
+}
diff --git a/Sources/NorgUI/Views/Blocks/RangedTagView.swift b/Sources/NorgUI/Views/Blocks/RangedTagView.swift
new file mode 100644
index 0000000..1cdf1d9
--- /dev/null
+++ b/Sources/NorgUI/Views/Blocks/RangedTagView.swift
@@ -0,0 +1,10 @@
+import SwiftUI
+
+struct RangedTagView: View {
+ let name: [String]
+ let content: String
+
+ var body: some View {
+ VerbatimBlock(label: name.joined(separator: "."), content: content)
+ }
+}
diff --git a/Sources/NorgUI/Views/FlatBlocksView.swift b/Sources/NorgUI/Views/FlatBlocksView.swift
new file mode 100644
index 0000000..185d08b
--- /dev/null
+++ b/Sources/NorgUI/Views/FlatBlocksView.swift
@@ -0,0 +1,42 @@
+import NorgKit
+import SwiftUI
+
+struct FlatBlocksView: View {
+ let blocks: [NorgBlock]
+ let onSetStatus: (Int, TaskStatus) -> Void
+ var indent: CGFloat = 0
+
+ @Environment(\.norgTheme) private var theme
+
+ var body: some View {
+ let ordinals = orderedOrdinals(blocks)
+ VStack(alignment: .leading, spacing: 10) {
+ ForEach(Array(blocks.enumerated()), id: \.offset) { index, block in
+ row(block, ordinal: ordinals[index])
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func row(_ block: NorgBlock, ordinal: Int?) -> some View {
+ BlockView(block: block, ordinal: ordinal, onSetStatus: onSetStatus)
+ .padding(.leading, indent + levelIndent(block))
+ .id(block.line)
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ if let body = block.body, !body.isEmpty {
+ FlatBlocksView(blocks: body, onSetStatus: onSetStatus, indent: indent + theme.indentWidth)
+ }
+ }
+
+ private func levelIndent(_ block: NorgBlock) -> CGFloat {
+ switch block {
+ case .unorderedListItem(let level, _, _, _),
+ .orderedListItem(let level, _, _, _),
+ .quote(let level, _, _, _):
+ return CGFloat(max(0, level - 1)) * theme.indentWidth
+ default:
+ return 0
+ }
+ }
+}
diff --git a/Sources/NorgUI/Views/NorgTaskView.swift b/Sources/NorgUI/Views/NorgTaskView.swift
new file mode 100644
index 0000000..cfe48c0
--- /dev/null
+++ b/Sources/NorgUI/Views/NorgTaskView.swift
@@ -0,0 +1,63 @@
+import NorgKit
+import SwiftUI
+
+public struct NorgTaskView: View {
+ @Environment(\.norgTheme) private var theme
+
+ let status: TaskStatus
+ let text: AttributedString
+ var font: Font
+ let onToggle: () -> Void
+ let onSetStatus: (TaskStatus) -> Void
+
+ public init(
+ status: TaskStatus,
+ text: AttributedString,
+ font: Font = .body,
+ onToggle: @escaping () -> Void,
+ onSetStatus: @escaping (TaskStatus) -> Void
+ ) {
+ self.status = status
+ self.text = text
+ self.font = font
+ self.onToggle = onToggle
+ self.onSetStatus = onSetStatus
+ }
+
+ public var body: some View {
+ HStack(alignment: .firstTextBaseline, spacing: 8) {
+ statusButton
+ Text(text)
+ .font(font)
+ .strikethrough(isResolved, color: .secondary)
+ .foregroundStyle(isResolved ? .secondary : .primary)
+ }
+ }
+
+ private var statusButton: some View {
+ Button(action: onToggle) {
+ Image(systemName: theme.statusSymbol(status))
+ .font(font)
+ .foregroundStyle(theme.statusTint(status))
+ }
+ .buttonStyle(.plain)
+ .contextMenu {
+ ForEach(TaskStatus.allCases) { option in
+ Button {
+ onSetStatus(option)
+ } label: {
+ Label {
+ Text(theme.statusLabel(option))
+ } icon: {
+ Image(systemName: theme.statusSymbol(option))
+ }
+ }
+ }
+ }
+ .accessibilityLabel(Text("Task status: \(Text(theme.statusLabel(status)))", bundle: .module))
+ }
+
+ private var isResolved: Bool {
+ status == .done || status == .cancelled
+ }
+}
diff --git a/Sources/NorgUI/Views/NorgView.swift b/Sources/NorgUI/Views/NorgView.swift
new file mode 100644
index 0000000..6f3deac
--- /dev/null
+++ b/Sources/NorgUI/Views/NorgView.swift
@@ -0,0 +1,43 @@
+import NorgKit
+import SwiftUI
+
+public struct NorgView: View {
+ let document: NorgDocument
+ let collapsible: Bool
+ let onSetStatus: (Int, TaskStatus) -> Void
+ let onOpenLink: ((InlineLink) -> Void)?
+
+ public init(
+ document: NorgDocument,
+ collapsible: Bool = false,
+ onSetStatus: @escaping (Int, TaskStatus) -> Void = { _, _ in },
+ onOpenLink: ((InlineLink) -> Void)? = nil
+ ) {
+ self.document = document
+ self.collapsible = collapsible
+ self.onSetStatus = onSetStatus
+ self.onOpenLink = onOpenLink
+ }
+
+ public var body: some View {
+ content
+ .environment(\.openURL, OpenURLAction(handler: handleURL))
+ }
+
+ @ViewBuilder private var content: some View {
+ if collapsible {
+ TreeNodesView(nodes: document.tree(), onSetStatus: onSetStatus)
+ } else {
+ FlatBlocksView(blocks: document.blocks, onSetStatus: onSetStatus)
+ }
+ }
+
+ private func handleURL(_ url: URL) -> OpenURLAction.Result {
+ if let onOpenLink {
+ let link = NorgLinkURL.decode(url) ?? InlineLink(kind: .link, target: url.absoluteString)
+ onOpenLink(link)
+ return .handled
+ }
+ return NorgLinkURL.decode(url) == nil ? .systemAction : .discarded
+ }
+}
diff --git a/Sources/NorgUI/Views/TreeNodesView.swift b/Sources/NorgUI/Views/TreeNodesView.swift
new file mode 100644
index 0000000..a509138
--- /dev/null
+++ b/Sources/NorgUI/Views/TreeNodesView.swift
@@ -0,0 +1,48 @@
+import NorgKit
+import SwiftUI
+
+struct TreeNodesView: View {
+ let nodes: [NorgNode]
+ let onSetStatus: (Int, TaskStatus) -> Void
+
+ var body: some View {
+ let siblingOrdinals = ordinals(forSiblings: nodes)
+ VStack(alignment: .leading, spacing: 10) {
+ ForEach(Array(nodes.enumerated()), id: \.offset) { index, node in
+ NodeView(node: node, ordinal: siblingOrdinals[index], onSetStatus: onSetStatus)
+ .id(node.block.line)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ }
+ }
+}
+
+private struct NodeView: View {
+ let node: NorgNode
+ let ordinal: Int?
+ let onSetStatus: (Int, TaskStatus) -> Void
+
+ @State private var expanded = true
+
+ var body: some View {
+ if !node.children.isEmpty {
+ DisclosureGroup(isExpanded: $expanded) {
+ TreeNodesView(nodes: node.children, onSetStatus: onSetStatus)
+ } label: {
+ label
+ }
+ } else if let body = node.block.body, !body.isEmpty {
+ DisclosureGroup(isExpanded: $expanded) {
+ FlatBlocksView(blocks: body, onSetStatus: onSetStatus)
+ } label: {
+ label
+ }
+ } else {
+ label
+ }
+ }
+
+ private var label: some View {
+ BlockView(block: node.block, ordinal: ordinal, onSetStatus: onSetStatus)
+ }
+}
diff --git a/Sources/NorgUI/Views/VerbatimBlock.swift b/Sources/NorgUI/Views/VerbatimBlock.swift
new file mode 100644
index 0000000..722c3f3
--- /dev/null
+++ b/Sources/NorgUI/Views/VerbatimBlock.swift
@@ -0,0 +1,23 @@
+import SwiftUI
+
+struct VerbatimBlock: View {
+ @Environment(\.norgTheme) private var theme
+
+ let label: String?
+ let content: String
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ if let label, !label.isEmpty {
+ Text(label)
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ }
+ Text(content)
+ .font(theme.codeFont)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .padding(10)
+ .background(theme.codeBackground, in: RoundedRectangle(cornerRadius: 8))
+ }
+}