diff options
| author | Ruben Beltran del Rio <jj@r.bdr.sh> | 2026-06-19 19:16:25 +0200 |
|---|---|---|
| committer | Ruben Beltran del Rio <jj@r.bdr.sh> | 2026-06-19 22:32:15 +0200 |
| commit | 25fc48fb17b53fe1c155c4f53f673ed91fdd8f74 (patch) | |
| tree | de96add2a7ae8c5cc79a354fbffa26e80349cc15 | |
Initial extraction from Norganize
| -rw-r--r-- | .gitignore | 8 | ||||
| -rw-r--r-- | .swiftlint.yml | 36 | ||||
| -rw-r--r-- | Justfile | 24 | ||||
| -rw-r--r-- | Package.resolved | 24 | ||||
| -rw-r--r-- | Package.swift | 52 | ||||
| -rw-r--r-- | README.md | 68 | ||||
| -rw-r--r-- | Sources/NorgEditor/EditorTheme.swift | 187 | ||||
| -rw-r--r-- | Sources/NorgEditor/EnvironmentValues+EditorTheme.swift | 21 | ||||
| -rw-r--r-- | Sources/NorgEditor/Highlighting/NorgHighlighter.swift | 23 | ||||
| -rw-r--r-- | Sources/NorgEditor/NorgAutoIndent.swift | 37 | ||||
| -rw-r--r-- | Sources/NorgEditor/NorgEditor.swift | 255 | ||||
| -rw-r--r-- | Tests/NorgEditorTests/NorgEditorTests.swift | 100 |
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") == " ") +} |