aboutsummaryrefslogtreecommitdiff
path: root/Sources
diff options
context:
space:
mode:
authorRuben Beltran del Rio <jj@r.bdr.sh>2026-06-19 19:16:25 +0200
committerRuben Beltran del Rio <jj@r.bdr.sh>2026-06-19 22:32:15 +0200
commit25fc48fb17b53fe1c155c4f53f673ed91fdd8f74 (patch)
treede96add2a7ae8c5cc79a354fbffa26e80349cc15 /Sources
Initial extraction from Norganize
Diffstat (limited to 'Sources')
-rw-r--r--Sources/NorgEditor/EditorTheme.swift187
-rw-r--r--Sources/NorgEditor/EnvironmentValues+EditorTheme.swift21
-rw-r--r--Sources/NorgEditor/Highlighting/NorgHighlighter.swift23
-rw-r--r--Sources/NorgEditor/NorgAutoIndent.swift37
-rw-r--r--Sources/NorgEditor/NorgEditor.swift255
5 files changed, 523 insertions, 0 deletions
diff --git a/Sources/NorgEditor/EditorTheme.swift b/Sources/NorgEditor/EditorTheme.swift
new file mode 100644
index 0000000..2ceb596
--- /dev/null
+++ b/Sources/NorgEditor/EditorTheme.swift
@@ -0,0 +1,187 @@
+#if canImport(UIKit)
+ import UIKit
+
+ public typealias PlatformFont = UIFont
+ public typealias PlatformColor = UIColor
+#elseif canImport(AppKit)
+ import AppKit
+
+ public typealias PlatformFont = NSFont
+ public typealias PlatformColor = NSColor
+#endif
+
+#if canImport(UIKit) || canImport(AppKit)
+ import NorgKit
+
+ /// Typography and colours used when highlighting a Norg document.
+ public struct EditorTheme {
+
+ /// Base font for source text. Inline `bold`/`italic` runs derive bold and
+ /// italic variants from this font's descriptor.
+ public var font: PlatformFont
+ /// Base foreground colour for plain, unstyled source.
+ public var textColor: PlatformColor
+ /// Color for a heading marker run (`*`…) at the given 1-based level.
+ public var headingColor: @Sendable (Int) -> PlatformColor
+ /// Color for structural markers and delimiters: list/quote/definition/
+ /// footnote/table-cell runs, delimiting lines, rules, inline-modifier
+ /// delimiters, link brackets and escapes.
+ public var markerColor: PlatformColor
+ /// Color for a task status marker, e.g. `(x)`.
+ public var taskStatusColor: @Sendable (TaskStatus) -> PlatformColor
+ /// Color for a ranged-tag header: the `@` and the tag name/parameters.
+ public var tagColor: PlatformColor
+ /// Color for verbatim content: inline `` `verbatim` `` and ranged-tag
+ /// (`@code` … `@end`) body lines.
+ public var verbatimColor: PlatformColor
+ /// Color for inline comments (`%…%`).
+ public var commentColor: PlatformColor
+ /// Color for link/anchor locations and descriptions.
+ public var linkColor: PlatformColor
+ /// Color for `spoiler` inline runs.
+ public var spoilerColor: PlatformColor
+
+ public init(
+ font: PlatformFont,
+ textColor: PlatformColor,
+ headingColor: @escaping @Sendable (Int) -> PlatformColor,
+ markerColor: PlatformColor,
+ taskStatusColor: @escaping @Sendable (TaskStatus) -> PlatformColor,
+ tagColor: PlatformColor,
+ verbatimColor: PlatformColor,
+ commentColor: PlatformColor,
+ linkColor: PlatformColor,
+ spoilerColor: PlatformColor
+ ) {
+ self.font = font
+ self.textColor = textColor
+ self.headingColor = headingColor
+ self.markerColor = markerColor
+ self.taskStatusColor = taskStatusColor
+ self.tagColor = tagColor
+ self.verbatimColor = verbatimColor
+ self.commentColor = commentColor
+ self.linkColor = linkColor
+ self.spoilerColor = spoilerColor
+ }
+
+ public static let `default` = EditorTheme(
+ font: .monospacedSystemFont(ofSize: 16, weight: .regular),
+ textColor: .norgLabel,
+ headingColor: { _ in .systemBlue },
+ markerColor: .norgSecondaryLabel,
+ taskStatusColor: { status in
+ switch status {
+ case .done, .recurring: return .systemGreen
+ case .urgent: return .systemRed
+ case .pending, .onHold: return .systemBlue
+ case .cancelled: return .norgTertiaryLabel
+ case .undone, .needsInput: return .norgSecondaryLabel
+ }
+ },
+ tagColor: .systemPurple,
+ verbatimColor: .systemPink,
+ commentColor: .norgTertiaryLabel,
+ linkColor: .norgLink,
+ spoilerColor: .norgSecondaryLabel
+ )
+ }
+
+ #if canImport(UIKit)
+ extension EditorTheme: Sendable {}
+ #elseif canImport(AppKit)
+ extension EditorTheme: @unchecked Sendable {}
+ #endif
+
+ extension EditorTheme {
+
+ public func attributes(for kind: NorgToken.Kind) -> [NSAttributedString.Key: Any] {
+ switch kind {
+ case .heading(let level):
+ return [.foregroundColor: headingColor(level)]
+ case .unorderedList, .orderedList, .quote, .definition, .footnote,
+ .tableCell, .weakDelimiter, .strongDelimiter, .horizontalRule,
+ .modifierDelimiter, .linkDelimiter, .escape:
+ return [.foregroundColor: markerColor]
+ case .taskStatus(let status):
+ return [.foregroundColor: taskStatusColor(status)]
+ case .tagDelimiter, .tagName:
+ return [.foregroundColor: tagColor]
+ case .verbatimBlock:
+ return [.foregroundColor: verbatimColor]
+ case .comment:
+ return [.foregroundColor: commentColor]
+ case .linkTarget:
+ return [
+ .foregroundColor: linkColor,
+ .underlineStyle: NSUnderlineStyle.single.rawValue,
+ ]
+ case .linkDescription:
+ return [.foregroundColor: linkColor]
+ case .styledText(let style):
+ return styledAttributes(style)
+ }
+ }
+
+ private func styledAttributes(_ style: InlineStyle) -> [NSAttributedString.Key: Any] {
+ var attributes: [NSAttributedString.Key: Any] = [:]
+
+ if let traited = traitedFont(bold: style.contains(.bold), italic: style.contains(.italic)) {
+ attributes[.font] = traited
+ }
+
+ if style.contains(.underline) {
+ attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
+ }
+ if style.contains(.strikethrough) {
+ attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
+ }
+ if style.contains(.verbatim) || style.contains(.math) {
+ attributes[.foregroundColor] = verbatimColor
+ } else if style.contains(.spoiler) {
+ attributes[.foregroundColor] = spoilerColor
+ }
+ return attributes
+ }
+
+ private func traitedFont(bold: Bool, italic: Bool) -> PlatformFont? {
+ guard bold || italic else { return nil }
+ #if canImport(UIKit)
+ var traits: UIFontDescriptor.SymbolicTraits = []
+ if bold { traits.insert(.traitBold) }
+ if italic { traits.insert(.traitItalic) }
+ guard let descriptor = font.fontDescriptor.withSymbolicTraits(traits) else { return nil }
+ return UIFont(descriptor: descriptor, size: font.pointSize)
+ #elseif canImport(AppKit)
+ var traits: NSFontDescriptor.SymbolicTraits = []
+ if bold { traits.insert(.bold) }
+ if italic { traits.insert(.italic) }
+ let descriptor = font.fontDescriptor.withSymbolicTraits(traits)
+ return NSFont(descriptor: descriptor, size: font.pointSize)
+ #endif
+ }
+
+ public func applyHighlighting(to storage: NSTextStorage) {
+ let text = storage.string
+ let full = NSRange(location: 0, length: (text as NSString).length)
+ storage.setAttributes([.font: font, .foregroundColor: textColor], range: full)
+ for highlight in NorgHighlighter.highlights(in: text) {
+ storage.addAttributes(attributes(for: highlight.kind), range: highlight.range)
+ }
+ }
+ }
+
+ extension PlatformColor {
+ #if canImport(UIKit)
+ fileprivate static var norgLabel: PlatformColor { .label }
+ fileprivate static var norgSecondaryLabel: PlatformColor { .secondaryLabel }
+ fileprivate static var norgTertiaryLabel: PlatformColor { .tertiaryLabel }
+ fileprivate static var norgLink: PlatformColor { .link }
+ #elseif canImport(AppKit)
+ fileprivate static var norgLabel: PlatformColor { .labelColor }
+ fileprivate static var norgSecondaryLabel: PlatformColor { .secondaryLabelColor }
+ fileprivate static var norgTertiaryLabel: PlatformColor { .tertiaryLabelColor }
+ fileprivate static var norgLink: PlatformColor { .linkColor }
+ #endif
+ }
+#endif
diff --git a/Sources/NorgEditor/EnvironmentValues+EditorTheme.swift b/Sources/NorgEditor/EnvironmentValues+EditorTheme.swift
new file mode 100644
index 0000000..f0bc3c5
--- /dev/null
+++ b/Sources/NorgEditor/EnvironmentValues+EditorTheme.swift
@@ -0,0 +1,21 @@
+#if canImport(UIKit) || canImport(AppKit)
+ import SwiftUI
+
+ private struct NorgEditorThemeKey: EnvironmentKey {
+ static let defaultValue = EditorTheme.default
+ }
+
+ /// Makes NorgTheme values accessible via the environment.
+ extension EnvironmentValues {
+ public var norgEditorTheme: EditorTheme {
+ get { self[NorgEditorThemeKey.self] }
+ set { self[NorgEditorThemeKey.self] = newValue }
+ }
+ }
+
+ extension View {
+ public func norgEditorTheme(_ theme: EditorTheme) -> some View {
+ environment(\.norgEditorTheme, theme)
+ }
+ }
+#endif
diff --git a/Sources/NorgEditor/Highlighting/NorgHighlighter.swift b/Sources/NorgEditor/Highlighting/NorgHighlighter.swift
new file mode 100644
index 0000000..c8bd2de
--- /dev/null
+++ b/Sources/NorgEditor/Highlighting/NorgHighlighter.swift
@@ -0,0 +1,23 @@
+import Foundation
+import NorgKit
+
+/// A syntax highlight span.
+public struct NorgHighlight: Equatable, Sendable {
+
+ public let range: NSRange
+ public let kind: NorgToken.Kind
+
+ public init(range: NSRange, kind: NorgToken.Kind) {
+ self.range = range
+ self.kind = kind
+ }
+}
+
+public enum NorgHighlighter {
+
+ public static func highlights(in text: String) -> [NorgHighlight] {
+ NorgLexer.tokenize(text).map { token in
+ NorgHighlight(range: NSRange(token.range, in: text), kind: token.kind)
+ }
+ }
+}
diff --git a/Sources/NorgEditor/NorgAutoIndent.swift b/Sources/NorgEditor/NorgAutoIndent.swift
new file mode 100644
index 0000000..2e6fa4b
--- /dev/null
+++ b/Sources/NorgEditor/NorgAutoIndent.swift
@@ -0,0 +1,37 @@
+import Foundation
+import NorgKit
+
+/// Computes the leading whitespace to insert when the operator presses return.
+public enum NorgAutoIndent {
+
+ /// The whitespace to insert after a newline.
+ public static func indentation(for text: String, newlineAt location: Int) -> String {
+ let nsText = text as NSString
+ guard location >= 0, location <= nsText.length else { return "" }
+
+ let lineRange = nsText.lineRange(for: NSRange(location: location, length: 0))
+ let prefix = nsText.substring(to: NSMaxRange(lineRange))
+ return indentation(forPrefix: prefix)
+ }
+
+ static func indentation(forPrefix prefix: String) -> String {
+ var openHeadingLevels: [Int] = []
+ for token in NorgLexer.tokenize(prefix) {
+ switch token.kind {
+ case .heading(let level):
+ while let deepest = openHeadingLevels.last, deepest >= level {
+ openHeadingLevels.removeLast()
+ }
+ openHeadingLevels.append(level)
+ case .strongDelimiter:
+ openHeadingLevels.removeAll()
+ case .weakDelimiter:
+ if !openHeadingLevels.isEmpty { openHeadingLevels.removeLast() }
+ default:
+ break
+ }
+ }
+ guard let level = openHeadingLevels.last else { return "" }
+ return String(repeating: " ", count: level + 1)
+ }
+}
diff --git a/Sources/NorgEditor/NorgEditor.swift b/Sources/NorgEditor/NorgEditor.swift
new file mode 100644
index 0000000..d026daa
--- /dev/null
+++ b/Sources/NorgEditor/NorgEditor.swift
@@ -0,0 +1,255 @@
+#if canImport(UIKit) || canImport(AppKit)
+ import SwiftUI
+
+ /// A SwiftUI source editor for `.norg` documents with syntax highlighting
+ /// and context-aware indentation.
+ ///
+ /// ```swift
+ /// NorgEditor(text: $draft)
+ /// .norgEditorTheme(myTheme)
+ /// ```
+ public struct NorgEditor {
+
+ @Binding private var text: String
+ @Environment(\.norgEditorTheme) private var theme
+
+ public init(text: Binding<String>) {
+ self._text = text
+ }
+ }
+#endif
+
+#if canImport(UIKit)
+ import UIKit
+ import NorgKeyboardToolbar
+
+ extension NorgEditor: UIViewRepresentable {
+
+ public func makeUIView(context: Context) -> UITextView {
+ let textView = UITextView()
+ textView.delegate = context.coordinator
+ textView.backgroundColor = .clear
+ textView.textContainerInset = UIEdgeInsets(top: 8, left: 0, bottom: 8, right: 0)
+ textView.textContainer.lineFragmentPadding = 0
+ textView.alwaysBounceVertical = true
+ textView.contentInsetAdjustmentBehavior = .never
+
+ textView.installNorgKeyboardAccessory()
+
+ textView.smartQuotesType = .yes
+ textView.smartDashesType = .no
+ textView.smartInsertDeleteType = .no
+ textView.dataDetectorTypes = []
+ textView.autocorrectionType = .default
+ textView.spellCheckingType = .default
+ textView.autocapitalizationType = .sentences
+
+ #if os(iOS)
+ context.coordinator.observeKeyboard(for: textView)
+ #endif
+ textView.text = text
+ context.coordinator.apply(theme, to: textView)
+ return textView
+ }
+
+ public func updateUIView(_ textView: UITextView, context: Context) {
+ context.coordinator.parent = self
+
+ if textView.text != text {
+ textView.text = text
+ context.coordinator.highlight(textView)
+ }
+ }
+
+ public func makeCoordinator() -> Coordinator { Coordinator(self) }
+
+ public final class Coordinator: NSObject, UITextViewDelegate {
+ fileprivate var parent: NorgEditor
+ private var theme: EditorTheme = .default
+ private var isAutoIndenting = false
+
+ init(_ parent: NorgEditor) { self.parent = parent }
+
+ deinit { NotificationCenter.default.removeObserver(self) }
+
+ // MARK: - Auto-indentation
+
+ public func textView(
+ _ textView: UITextView,
+ shouldChangeTextIn range: NSRange,
+ replacementText text: String
+ ) -> Bool {
+ guard !isAutoIndenting, text == "\n" else { return true }
+
+ let indent = NorgAutoIndent.indentation(
+ for: textView.text, newlineAt: range.location)
+ guard !indent.isEmpty else { return true }
+
+ guard
+ let start = textView.position(
+ from: textView.beginningOfDocument, offset: range.location),
+ let end = textView.position(from: start, offset: range.length),
+ let textRange = textView.textRange(from: start, to: end)
+ else { return true }
+
+ isAutoIndenting = true
+ textView.replace(textRange, withText: "\n" + indent)
+ isAutoIndenting = false
+ return false
+ }
+
+ // MARK: - Highlighting
+
+ func apply(_ theme: EditorTheme, to textView: UITextView) {
+ self.theme = theme
+ textView.font = theme.font
+ textView.typingAttributes = [
+ .font: theme.font,
+ .foregroundColor: theme.textColor,
+ ]
+ highlight(textView)
+ }
+
+ func highlight(_ textView: UITextView) {
+ guard textView.markedTextRange == nil else { return }
+ let storage = textView.textStorage
+ let selection = textView.selectedRange
+ storage.beginEditing()
+ theme.applyHighlighting(to: storage)
+ storage.endEditing()
+ textView.selectedRange = selection
+ }
+
+ public func textViewDidChange(_ textView: UITextView) {
+ parent.text = textView.text
+ highlight(textView)
+ }
+
+ // MARK: - Keyboard avoidance
+
+ #if os(iOS)
+ private weak var textView: UITextView?
+
+ func observeKeyboard(for textView: UITextView) {
+ self.textView = textView
+ NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(keyboardFrameChanged(_:)),
+ name: UIResponder.keyboardWillChangeFrameNotification,
+ object: nil
+ )
+ }
+
+ @objc private func keyboardFrameChanged(_ note: Notification) {
+ guard let textView, let window = textView.window,
+ let endFrame = note.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect
+ else { return }
+
+ let keyboardInWindow = window.convert(endFrame, from: window.screen.fixedCoordinateSpace)
+ let textViewInWindow = textView.convert(textView.bounds, to: window)
+ let overlap = max(0, textViewInWindow.maxY - keyboardInWindow.minY)
+
+ textView.contentInset.bottom = overlap
+ textView.verticalScrollIndicatorInsets.bottom = overlap
+ }
+ #endif
+ }
+ }
+#elseif canImport(AppKit)
+ import AppKit
+
+ extension NorgEditor: NSViewRepresentable {
+
+ public func makeNSView(context: Context) -> NSScrollView {
+ let scrollView = NSTextView.scrollableTextView()
+ scrollView.drawsBackground = false
+ guard let textView = scrollView.documentView as? NSTextView else { return scrollView }
+
+ textView.delegate = context.coordinator
+ textView.drawsBackground = false
+ textView.isRichText = false
+ textView.allowsUndo = true
+ textView.textContainerInset = NSSize(width: 0, height: 8)
+ textView.textContainer?.lineFragmentPadding = 0
+
+ textView.isAutomaticQuoteSubstitutionEnabled = true
+ textView.isAutomaticDashSubstitutionEnabled = false
+ textView.isAutomaticTextReplacementEnabled = false
+ textView.isAutomaticLinkDetectionEnabled = false
+
+ textView.string = text
+ context.coordinator.apply(theme, to: textView)
+ return scrollView
+ }
+
+ public func updateNSView(_ scrollView: NSScrollView, context: Context) {
+ context.coordinator.parent = self
+ guard let textView = scrollView.documentView as? NSTextView else { return }
+
+ if textView.string != text {
+ textView.string = text
+ context.coordinator.highlight(textView)
+ }
+ }
+
+ public func makeCoordinator() -> Coordinator { Coordinator(self) }
+
+ // AppKit's `NSTextViewDelegate` isn't `@MainActor`-annotated (UIKit's is),
+ // so isolation isn't inferred for these callbacks even though the text view
+ // only ever calls them on the main thread; state the isolation explicitly.
+ @MainActor
+ public final class Coordinator: NSObject, NSTextViewDelegate {
+ fileprivate var parent: NorgEditor
+ private var theme: EditorTheme = .default
+ private var isAutoIndenting = false
+
+ init(_ parent: NorgEditor) { self.parent = parent }
+
+ // MARK: - Auto-indentation
+
+ public func textView(
+ _ textView: NSTextView,
+ shouldChangeTextIn affectedCharRange: NSRange,
+ replacementString: String?
+ ) -> Bool {
+ guard !isAutoIndenting, replacementString == "\n" else { return true }
+
+ let indent = NorgAutoIndent.indentation(
+ for: textView.string, newlineAt: affectedCharRange.location)
+ guard !indent.isEmpty else { return true }
+
+ isAutoIndenting = true
+ textView.insertText("\n" + indent, replacementRange: affectedCharRange)
+ isAutoIndenting = false
+ return false
+ }
+
+ // MARK: - Highlighting
+
+ func apply(_ theme: EditorTheme, to textView: NSTextView) {
+ self.theme = theme
+ textView.font = theme.font
+ textView.typingAttributes = [
+ .font: theme.font,
+ .foregroundColor: theme.textColor,
+ ]
+ highlight(textView)
+ }
+
+ func highlight(_ textView: NSTextView) {
+ guard !textView.hasMarkedText(), let storage = textView.textStorage else { return }
+ let selection = textView.selectedRange()
+ storage.beginEditing()
+ theme.applyHighlighting(to: storage)
+ storage.endEditing()
+ textView.setSelectedRange(selection)
+ }
+
+ public func textDidChange(_ notification: Notification) {
+ guard let textView = notification.object as? NSTextView else { return }
+ parent.text = textView.string
+ highlight(textView)
+ }
+ }
+ }
+#endif