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 /Sources | |
Initial extraction from Norganize
Diffstat (limited to 'Sources')
| -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 |
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 |