diff options
| author | Ruben Beltran del Rio <jj@r.bdr.sh> | 2026-06-15 14:35:36 +0200 |
|---|---|---|
| committer | Ruben Beltran del Rio <jj@r.bdr.sh> | 2026-06-16 14:47:30 +0200 |
| commit | 657aa83a5ca24dc35572c040df64a0abb85e8612 (patch) | |
| tree | fe0b6fd425d8cad9dc6a28ada4ad8600e42b367e /Sources/NorgUI/Views | |
Initial extraction from Norganize
Diffstat (limited to 'Sources/NorgUI/Views')
| -rw-r--r-- | Sources/NorgUI/Views/Blocks/BlockTaskRow.swift | 23 | ||||
| -rw-r--r-- | Sources/NorgUI/Views/Blocks/BlockView.swift | 49 | ||||
| -rw-r--r-- | Sources/NorgUI/Views/Blocks/CodeBlockView.swift | 10 | ||||
| -rw-r--r-- | Sources/NorgUI/Views/Blocks/HeadingView.swift | 26 | ||||
| -rw-r--r-- | Sources/NorgUI/Views/Blocks/ListItemView.swift | 27 | ||||
| -rw-r--r-- | Sources/NorgUI/Views/Blocks/ParagraphView.swift | 12 | ||||
| -rw-r--r-- | Sources/NorgUI/Views/Blocks/QuoteView.swift | 29 | ||||
| -rw-r--r-- | Sources/NorgUI/Views/Blocks/RangeableBlockView.swift | 22 | ||||
| -rw-r--r-- | Sources/NorgUI/Views/Blocks/RangedTagView.swift | 10 | ||||
| -rw-r--r-- | Sources/NorgUI/Views/FlatBlocksView.swift | 42 | ||||
| -rw-r--r-- | Sources/NorgUI/Views/NorgTaskView.swift | 63 | ||||
| -rw-r--r-- | Sources/NorgUI/Views/NorgView.swift | 43 | ||||
| -rw-r--r-- | Sources/NorgUI/Views/TreeNodesView.swift | 48 | ||||
| -rw-r--r-- | Sources/NorgUI/Views/VerbatimBlock.swift | 23 |
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)) + } +} |