aboutsummaryrefslogtreecommitdiff
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
Initial extraction from Norganize
-rw-r--r--.gitignore8
-rw-r--r--.swiftlint.yml36
-rw-r--r--Justfile24
-rw-r--r--Package.resolved24
-rw-r--r--Package.swift52
-rw-r--r--README.md68
-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
-rw-r--r--Tests/NorgEditorTests/NorgEditorTests.swift100
12 files changed, 835 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..0023a53
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+.DS_Store
+/.build
+/Packages
+xcuserdata/
+DerivedData/
+.swiftpm/configuration/registries.json
+.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
+.netrc
diff --git a/.swiftlint.yml b/.swiftlint.yml
new file mode 100644
index 0000000..51b479a
--- /dev/null
+++ b/.swiftlint.yml
@@ -0,0 +1,36 @@
+# SwiftLint configuration for NorgKit.
+# Tuned to the existing style rather than forcing churn: the parsers use short,
+# conventional cursor names (i, j, lo, hi, c, s, m) and the recogniser functions
+# are intentionally long, flat state machines.
+
+included:
+ - Sources
+ - Tests
+ - Benchmarks
+
+identifier_name:
+ # Allow short loop/cursor names that read clearly in tight parsing loops.
+ min_length:
+ warning: 1
+ error: 1
+
+line_length:
+ warning: 120
+ error: 160
+ ignores_comments: true
+ ignores_urls: true
+
+function_body_length:
+ warning: 80
+ error: 120
+
+cyclomatic_complexity:
+ warning: 15
+ error: 25
+
+disabled_rules:
+ # "TODO" appears in doc comments describing Norg's TODO-status syntax.
+ - todo
+ # Trailing commas are used selectively (e.g. the modifier tables); leave the
+ # choice to the author rather than enforcing one way.
+ - trailing_comma
diff --git a/Justfile b/Justfile
new file mode 100644
index 0000000..b3b6a9b
--- /dev/null
+++ b/Justfile
@@ -0,0 +1,24 @@
+profile := "debug"
+
+default: build
+
+build:
+ swift build -c {{profile}}
+
+test:
+ swift test
+
+coverage:
+ swift test --enable-code-coverage
+ xcrun llvm-cov report \
+ .build/debug/NorgEditorTests.xctest/Contents/MacOS/NorgEditorTests \
+ -instr-profile=.build/debug/codecov/default.profdata
+
+format:
+ swift-format --in-place --recursive Sources Tests
+ swiftlint --fix Sources Tests
+
+lint:
+ swiftlint Sources Tests
+
+ci: lint test
diff --git a/Package.resolved b/Package.resolved
new file mode 100644
index 0000000..00f90a9
--- /dev/null
+++ b/Package.resolved
@@ -0,0 +1,24 @@
+{
+ "originHash" : "a70b6c15183c654a27316636043f8cacdfbae86106c8613b594ff26a895e56b7",
+ "pins" : [
+ {
+ "identity" : "norg-keyboard-toolbar",
+ "kind" : "remoteSourceControl",
+ "location" : "https://git.sr.ht/~rbdr/norg-keyboard-toolbar",
+ "state" : {
+ "revision" : "692abd44213b32d507849cec74ec847c994cc257",
+ "version" : "1.1.0"
+ }
+ },
+ {
+ "identity" : "norgkit",
+ "kind" : "remoteSourceControl",
+ "location" : "https://git.sr.ht/~rbdr/norgkit",
+ "state" : {
+ "revision" : "647296d5a0799c2e9de05cd41b50bd161cdeac85",
+ "version" : "1.1.0"
+ }
+ }
+ ],
+ "version" : 3
+}
diff --git a/Package.swift b/Package.swift
new file mode 100644
index 0000000..986adaa
--- /dev/null
+++ b/Package.swift
@@ -0,0 +1,52 @@
+// swift-tools-version: 6.4
+// The swift-tools-version declares the minimum version of Swift required to build this package.
+
+import PackageDescription
+
+let package = Package(
+ name: "NorgEditor",
+ platforms: [
+ .iOS(.v18),
+ .macOS(.v15),
+ .visionOS(.v2),
+ ],
+ products: [
+ // Products define the executables and libraries a package produces, making them visible to other packages.
+ .library(
+ name: "NorgEditor",
+ targets: ["NorgEditor"]
+ ),
+ ],
+ dependencies: [
+ .package(url: "https://git.sr.ht/~rbdr/norgkit", from: "1.1.0"),
+ .package(url: "https://git.sr.ht/~rbdr/norg-keyboard-toolbar", from: "1.1.0"),
+ ],
+ targets: [
+ // Targets are the basic building blocks of a package, defining a module or a test suite.
+ // Targets can depend on other targets in this package and products from dependencies.
+ .target(
+ name: "NorgEditor",
+ dependencies: [
+ .product(name: "NorgKit", package: "norgkit"),
+ // UIKit-only input accessory; native macOS uses NSTextView with no
+ // toolbar, so it's linked only where UIKit is available.
+ .product(
+ name: "NorgKeyboardToolbar",
+ package: "norg-keyboard-toolbar",
+ condition: .when(platforms: [.iOS, .visionOS, .macCatalyst])
+ ),
+ ],
+ swiftSettings: [
+ .enableUpcomingFeature("ApproachableConcurrency"),
+ ],
+ ),
+ .testTarget(
+ name: "NorgEditorTests",
+ dependencies: ["NorgEditor"],
+ swiftSettings: [
+ .enableUpcomingFeature("ApproachableConcurrency"),
+ ],
+ ),
+ ],
+ swiftLanguageModes: [.v6]
+)
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..5020ad8
--- /dev/null
+++ b/README.md
@@ -0,0 +1,68 @@
+# NorgEditor
+
+NorgEditor provides a syntax-highlighting and indentation aware SwiftUI source
+editor for `.norg` / [Neorg](https://github.com/nvim-neorg/neorg) documents, built on
+[NorgKit](https://git.sr.ht/~rbdr/norgkit)'s lexer and the [NorgKeyboardToolbar](https://git.sr.ht/~rbdr/norg-keyboard-toolbar) input accessory.
+
+## Installation
+
+Add the package with Xcode package manager: `https://git.r.bdr.sh/norg-editor`,
+and depend on `NorgUI` from your target.
+
+## Usage
+
+Bind `NorgEditor` to a `String` of source. It highlights on every keystroke; on
+iOS/iPadOS it also installs the Norg keyboard toolbar and keeps the caret above
+the keyboard.
+
+```swift
+import SwiftUI
+import NorgEditor
+
+struct NoteEditor: View {
+ @Binding var source: String
+
+ var body: some View {
+ NorgEditor(text: $source)
+ .padding(.horizontal)
+ }
+}
+```
+
+## Theming
+
+Highlighting colours and the base font can be changed by creating an
+`EditorTheme` aund using the `.norgEditorTheme(_:)` modifier.
+
+```swift
+var theme = EditorTheme.default
+theme.font = UIFont(name: "IBMPlexMono-Light", size: 14) ?? theme.font
+theme.headingColor = { _ in .systemTeal }
+theme.linkColor = .systemBlue
+
+NorgEditor(text: $source)
+ .norgEditorTheme(theme)
+```
+
+### EditorTheme API
+
+`PlatformFont` and `PlatformColor` are aliases for `UIFont`/`UIColor` on UIKit
+platforms and `NSFont`/`NSColor` on macOS.
+
+| Property | Type | Styles |
+| --- | --- | --- |
+| `font` | `PlatformFont` | Base font; `bold`/`italic` runs derive traited variants |
+| `textColor` | `PlatformColor` | Plain, unstyled source |
+| `headingColor` | `(Int) -> PlatformColor` | Heading marker runs (`*`…), per level |
+| `markerColor` | `PlatformColor` | List/quote/definition/footnote/table markers, delimiting lines, rules, inline-modifier delimiters, link brackets, escapes |
+| `taskStatusColor` | `(TaskStatus) -> PlatformColor` | Task status markers, e.g. `(x)` |
+| `tagColor` | `PlatformColor` | Ranged-tag header (`@code …`) and `@end` |
+| `verbatimColor` | `PlatformColor` | Inline `` `verbatim` `` and ranged-tag body lines |
+| `commentColor` | `PlatformColor` | Inline comments (`%…%`) |
+| `linkColor` | `PlatformColor` | Link/anchor locations and descriptions |
+| `spoilerColor` | `PlatformColor` | `spoiler` inline runs |
+
+## Requirements
+
+- Swift 6 tools
+- iOS 18 / iPadOS 18 / visionOS 2 (UIKit) or macOS 15 (AppKit)
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
diff --git a/Tests/NorgEditorTests/NorgEditorTests.swift b/Tests/NorgEditorTests/NorgEditorTests.swift
new file mode 100644
index 0000000..6a6caf7
--- /dev/null
+++ b/Tests/NorgEditorTests/NorgEditorTests.swift
@@ -0,0 +1,100 @@
+import Foundation
+import NorgKit
+import Testing
+
+@testable import NorgEditor
+
+@Test func plainTextYieldsNoHighlights() {
+ #expect(NorgHighlighter.highlights(in: "just some prose").isEmpty)
+ #expect(NorgHighlighter.highlights(in: "").isEmpty)
+}
+
+@Test func headingMarkerIsHighlighted() {
+ let highlights = NorgHighlighter.highlights(in: "** Title")
+ let heading = highlights.first
+ #expect(heading?.kind == .heading(level: 2))
+ // The marker run `**` spans the first two UTF-16 units; the title is plain.
+ #expect(heading?.range == NSRange(location: 0, length: 2))
+}
+
+@Test func taskStatusIsHighlightedWithMarkerAndStatus() {
+ let highlights = NorgHighlighter.highlights(in: "- (x) buy milk")
+ #expect(highlights.contains { $0.kind == .unorderedList(level: 1) })
+
+ let status = highlights.first { highlight in
+ if case .taskStatus = highlight.kind { return true }
+ return false
+ }
+ #expect(status?.kind == .taskStatus(.done))
+ // `(x)` — open paren, status char, close paren.
+ #expect(status?.range == NSRange(location: 2, length: 3))
+}
+
+@Test func inlineVerbatimReportsDelimitersAndStyledText() {
+ let highlights = NorgHighlighter.highlights(in: "a `code` b")
+ let styled = highlights.first { highlight in
+ if case .styledText(let style) = highlight.kind { return style.contains(.verbatim) }
+ return false
+ }
+ #expect(styled?.range == NSRange(location: 3, length: 4)) // "code"
+ #expect(highlights.contains { $0.kind == .modifierDelimiter(.verbatim) })
+}
+
+@Test func rangesAreValidForMultibyteSource() {
+ // An emoji is two UTF-16 units; the bold run must land on the right span.
+ let source = "🌟 *bold*"
+ let highlights = NorgHighlighter.highlights(in: source)
+ let nsSource = source as NSString
+ let styled = highlights.first { highlight in
+ if case .styledText(let style) = highlight.kind { return style.contains(.bold) }
+ return false
+ }
+ let range = styled?.range
+ #expect(range.map { nsSource.substring(with: $0) } == "bold")
+}
+
+@Test func linkTargetIsHighlighted() {
+ let highlights = NorgHighlighter.highlights(in: "see {https://example.com}")
+ #expect(highlights.contains { $0.kind == .linkTarget })
+ #expect(highlights.contains { $0.kind == .linkDelimiter })
+}
+
+// MARK: - Auto-indentation (Return key)
+
+/// Indentation at the end of `source` (where Return would land).
+private func indent(after source: String) -> String {
+ NorgAutoIndent.indentation(for: source, newlineAt: (source as NSString).length)
+}
+
+@Test func indentsUnderTheOpenHeading() {
+ // `*** ` is three markers plus a space, so the text starts at column 4.
+ #expect(indent(after: "*** Headline") == " ")
+ #expect(indent(after: "* Top") == " ")
+}
+
+@Test func plainTextWithoutAHeadingHasNoIndent() {
+ #expect(indent(after: "just a paragraph") == "")
+ #expect(indent(after: "") == "")
+}
+
+@Test func indentPersistsAcrossContentLines() {
+ // A paragraph under the heading keeps the heading's indent on the next line.
+ #expect(indent(after: "* A\nsome prose") == " ")
+}
+
+@Test func indentFollowsTheDeepestOpenHeading() {
+ #expect(indent(after: "* A\n** B\n*** C") == " ")
+}
+
+@Test func strongDelimiterClosesAllHeadings() {
+ #expect(indent(after: "* A\n** B\n===\nmore") == "")
+}
+
+@Test func weakDelimiterReturnsToTheParentHeading() {
+ // `---` closes `** B`, leaving `* A` open → align under its text (column 2).
+ #expect(indent(after: "* A\n** B\n---\nmore") == " ")
+}
+
+@Test func horizontalRuleLeavesHeadingScopeIntact() {
+ #expect(indent(after: "* A\n___\nmore") == " ")
+}