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 | |
Initial extraction from Norganize
Diffstat (limited to 'Sources')
21 files changed, 762 insertions, 0 deletions
diff --git a/Sources/NorgUI/Extensions/AttributedString+Norg.swift b/Sources/NorgUI/Extensions/AttributedString+Norg.swift new file mode 100644 index 0000000..286e0c8 --- /dev/null +++ b/Sources/NorgUI/Extensions/AttributedString+Norg.swift @@ -0,0 +1,74 @@ +import NorgKit +import SwiftUI + +/// Extensions to AttributedString to generate from NorgKit models. +extension AttributedString { + + /// Builds a styled attributed string from parsed Norg inline spans. + public init(norg spans: [InlineSpan], baseFont: Font = .body, theme: NorgTheme = .default) { + var result = AttributedString() + for span in spans { + result.append(Self.render(span, baseFont: baseFont, theme: theme)) + } + self = result + } + + private static func render(_ span: InlineSpan, baseFont: Font, theme: NorgTheme) + -> AttributedString { + var run = AttributedString(span.text) + run.font = font(for: span.styles, baseFont: baseFont) + applyDecorations(to: &run, styles: span.styles, theme: theme) + if let link = span.link { + applyLink(link, to: &run, theme: theme) + } + return run + } + + private static func font(for styles: InlineStyle, baseFont: Font) -> Font { + var font = baseFont + if styles.contains(.verbatim) || styles.contains(.math) { font = font.monospaced() } + if styles.contains(.bold) { font = font.bold() } + if styles.contains(.italic) { font = font.italic() } + return font + } + + private static func applyDecorations( + to run: inout AttributedString, styles: InlineStyle, theme: NorgTheme + ) { + if styles.contains(.underline) { run.underlineStyle = .single } + if styles.contains(.strikethrough) { run.strikethroughStyle = .single } + if styles.contains(.superscript) { run.baselineOffset = theme.superscriptOffset } + if styles.contains(.subscript) { run.baselineOffset = theme.subscriptOffset } + if styles.contains(.spoiler) { + run.foregroundColor = theme.spoilerForeground + run.backgroundColor = theme.spoilerBackground + } + if styles.contains(.verbatim) { + run.foregroundColor = theme.verbatimColor + } + } + + private static func applyLink( + _ link: InlineLink, to run: inout AttributedString, theme: NorgTheme + ) { + run.foregroundColor = theme.linkColor + run.underlineStyle = .single + run.link = url(for: link) + } + + private static func url(for link: InlineLink) -> URL? { + if link.kind == .link, let target = link.target, let external = externalURL(for: target) { + return external + } + return NorgLinkURL.encode(link) + } + + private static func externalURL(for target: String) -> URL? { + let lower = target.lowercased() + guard lower.hasPrefix("http://") || lower.hasPrefix("https://") || lower.hasPrefix("file://") + else { + return nil + } + return URL(string: target) + } +} diff --git a/Sources/NorgUI/Extensions/TaskStatus+UI.swift b/Sources/NorgUI/Extensions/TaskStatus+UI.swift new file mode 100644 index 0000000..e54e511 --- /dev/null +++ b/Sources/NorgUI/Extensions/TaskStatus+UI.swift @@ -0,0 +1,24 @@ +import Foundation +import NorgKit + +/// Adds UI rendering labels for TaskStatus. +extension TaskStatus { + + /// A localized, human-readable name for the status. + public var displayName: LocalizedStringResource { + switch self { + case .undone: return Self.label("Undone") + case .done: return Self.label("Done") + case .needsInput: return Self.label("Needs input") + case .urgent: return Self.label("Urgent") + case .recurring: return Self.label("Recurring") + case .pending: return Self.label("Pending") + case .onHold: return Self.label("On hold") + case .cancelled: return Self.label("Cancelled") + } + } + + private static func label(_ value: String.LocalizationValue) -> LocalizedStringResource { + LocalizedStringResource(value, bundle: .atURL(Bundle.module.bundleURL)) + } +} diff --git a/Sources/NorgUI/Helpers/NorgLinkURL.swift b/Sources/NorgUI/Helpers/NorgLinkURL.swift new file mode 100644 index 0000000..9b73fe3 --- /dev/null +++ b/Sources/NorgUI/Helpers/NorgLinkURL.swift @@ -0,0 +1,25 @@ +import Foundation +import NorgKit + +public enum NorgLinkURL { + public static let scheme = "norgui-link" + + public static func encode(_ link: InlineLink) -> URL? { + var components = URLComponents() + components.scheme = scheme + components.host = link.kind == .anchor ? "anchor" : "link" + if let target = link.target { + components.queryItems = [URLQueryItem(name: "target", value: target)] + } + return components.url + } + + public static func decode(_ url: URL) -> InlineLink? { + guard url.scheme == scheme else { return nil } + let kind: InlineLink.Kind = url.host == "anchor" ? .anchor : .link + let target = + URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems?.first { $0.name == "target" }?.value + return InlineLink(kind: kind, target: target) + } +} diff --git a/Sources/NorgUI/Helpers/Ordinals.swift b/Sources/NorgUI/Helpers/Ordinals.swift new file mode 100644 index 0000000..649c3e0 --- /dev/null +++ b/Sources/NorgUI/Helpers/Ordinals.swift @@ -0,0 +1,32 @@ +import NorgKit + +/// Assigns sequential numbers to ordered list items. +func orderedOrdinals(_ blocks: [NorgBlock]) -> [Int?] { + var counters: [Int: Int] = [:] + return blocks.map { block in + switch block { + case .orderedListItem(let level, _, _, _): + counters = counters.filter { $0.key <= level } + counters[level, default: 0] += 1 + return counters[level] + case .unorderedListItem(let level, _, _, _): + counters = counters.filter { $0.key < level } + return nil + default: + counters.removeAll() + return nil + } + } +} + +/// Numbers the ordered list items within a single tree branch. +func ordinals(forSiblings nodes: [NorgNode]) -> [Int?] { + var counter = 0 + return nodes.map { node in + if case .orderedListItem = node.block { + counter += 1 + return counter + } + return nil + } +} diff --git a/Sources/NorgUI/Resources/Localizable.xcstrings b/Sources/NorgUI/Resources/Localizable.xcstrings new file mode 100644 index 0000000..8783245 --- /dev/null +++ b/Sources/NorgUI/Resources/Localizable.xcstrings @@ -0,0 +1,33 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "Cancelled" : { + "comment" : "Task status: put down / cancelled" + }, + "Done" : { + "comment" : "Task status: completed" + }, + "Needs input" : { + "comment" : "Task status: needs further input or clarification" + }, + "On hold" : { + "comment" : "Task status: paused" + }, + "Pending" : { + "comment" : "Task status: in progress" + }, + "Recurring" : { + "comment" : "Task status: repeats" + }, + "Task status: %@" : { + "comment" : "Accessibility label for a task's status control; %@ is the status name" + }, + "Undone" : { + "comment" : "Task status: not started" + }, + "Urgent" : { + "comment" : "Task status: urgent" + } + }, + "version" : "1.0" +} diff --git a/Sources/NorgUI/Theme/EnvironmentValues+NorgTheme.swift b/Sources/NorgUI/Theme/EnvironmentValues+NorgTheme.swift new file mode 100644 index 0000000..58c2c00 --- /dev/null +++ b/Sources/NorgUI/Theme/EnvironmentValues+NorgTheme.swift @@ -0,0 +1,19 @@ +import SwiftUI + +private struct NorgThemeKey: EnvironmentKey { + static let defaultValue = NorgTheme.default +} + +/// Makes NorgTheme values accessible via the environment. +extension EnvironmentValues { + public var norgTheme: NorgTheme { + get { self[NorgThemeKey.self] } + set { self[NorgThemeKey.self] = newValue } + } +} + +extension View { + public func norgTheme(_ theme: NorgTheme) -> some View { + environment(\.norgTheme, theme) + } +} diff --git a/Sources/NorgUI/Theme/NorgTheme.swift b/Sources/NorgUI/Theme/NorgTheme.swift new file mode 100644 index 0000000..47dd161 --- /dev/null +++ b/Sources/NorgUI/Theme/NorgTheme.swift @@ -0,0 +1,128 @@ +import NorgKit +import SwiftUI + +/// Typography and colours used when rendering a Norg document. +public struct NorgTheme: Sendable { + + /// Font for a heading at the given (1-based) level. + public var heading: @Sendable (Int) -> Font + /// Base font for body text, list items and paragraphs. + public var body: Font + /// Font for the title of a definition, footnote or table cell. + public var termFont: Font + /// Foreground colour for `verbatim` inline spans. + public var verbatimColor: Color + /// Foreground colour for `spoiler` inline spans. + public var spoilerForeground: Color + /// Background colour for `spoiler` inline spans. + public var spoilerBackground: Color + /// Foreground colour applied to link spans. + public var linkColor: Color + /// Baseline offset applied to `superscript` spans. + public var superscriptOffset: CGFloat + /// Baseline offset applied to `subscript` spans. + public var subscriptOffset: CGFloat + /// Foreground colour for block quotes. + public var quoteColor: Color + /// Fill colour for the vertical bar drawn alongside a quote. + public var quoteBarColor: Color + /// Background fill for fenced code blocks and ranged tags. + public var codeBackground: AnyShapeStyle + /// Font for code block and ranged-tag contents. + public var codeFont: Font + /// Horizontal indentation applied per nesting level. + public var indentWidth: CGFloat + /// Tint for a task's status symbol. + public var statusTint: @Sendable (TaskStatus) -> Color + /// SF Symbol name for a task's status symbol. + public var statusSymbol: @Sendable (TaskStatus) -> String + /// Localized label for a task status, shown in the status menu and used + /// for accessibility. Defaults to ``TaskStatus/displayName``; override to + /// supply wording from your own string catalog. + public var statusLabel: @Sendable (TaskStatus) -> LocalizedStringResource + + public init( + heading: @escaping @Sendable (Int) -> Font, + body: Font, + termFont: Font, + verbatimColor: Color, + spoilerForeground: Color, + spoilerBackground: Color, + linkColor: Color, + superscriptOffset: CGFloat, + subscriptOffset: CGFloat, + quoteColor: Color, + quoteBarColor: Color, + codeBackground: AnyShapeStyle, + codeFont: Font, + indentWidth: CGFloat, + statusTint: @escaping @Sendable (TaskStatus) -> Color, + statusSymbol: @escaping @Sendable (TaskStatus) -> String, + statusLabel: @escaping @Sendable (TaskStatus) -> LocalizedStringResource = { $0.displayName } + ) { + self.heading = heading + self.body = body + self.termFont = termFont + self.verbatimColor = verbatimColor + self.spoilerForeground = spoilerForeground + self.spoilerBackground = spoilerBackground + self.linkColor = linkColor + self.superscriptOffset = superscriptOffset + self.subscriptOffset = subscriptOffset + self.quoteColor = quoteColor + self.quoteBarColor = quoteBarColor + self.codeBackground = codeBackground + self.codeFont = codeFont + self.indentWidth = indentWidth + self.statusTint = statusTint + self.statusSymbol = statusSymbol + self.statusLabel = statusLabel + } + + /// The default theme. + public static let `default` = NorgTheme( + heading: { level in + switch level { + case 1: return .title.bold() + case 2: return .title2.bold() + case 3: return .title3.bold() + case 4: return .headline + case 5: return .subheadline.bold() + default: return .subheadline + } + }, + body: .body, + termFont: .body.bold(), + verbatimColor: .pink, + spoilerForeground: .secondary, + spoilerBackground: .secondary.opacity(0.25), + linkColor: .accentColor, + superscriptOffset: 5, + subscriptOffset: -3, + quoteColor: .secondary, + quoteBarColor: .secondary, + codeBackground: AnyShapeStyle(.quaternary), + codeFont: .system(.callout, design: .monospaced), + indentWidth: 18, + statusTint: { status in + switch status { + case .done: return .green + case .urgent: return .red + case .pending: return .blue + default: return .secondary + } + }, + statusSymbol: { status in + switch status { + case .undone: return "circle" + case .done: return "checkmark.circle.fill" + case .needsInput: return "questionmark.circle" + case .urgent: return "exclamationmark.circle" + case .recurring: return "repeat.circle" + case .pending: return "ellipsis.circle" + case .onHold: return "pause.circle" + case .cancelled: return "xmark.circle" + } + } + ) +} 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)) + } +} |