diff options
Diffstat (limited to 'Sources')
4 files changed, 197 insertions, 0 deletions
diff --git a/Sources/NorgKeyboardToolbar/EditorSnippet+Presentation.swift b/Sources/NorgKeyboardToolbar/EditorSnippet+Presentation.swift new file mode 100644 index 0000000..f21916a --- /dev/null +++ b/Sources/NorgKeyboardToolbar/EditorSnippet+Presentation.swift @@ -0,0 +1,27 @@ +import Foundation + +/// Extensions for how the toolbar buttons are presented. +extension EditorSnippet { + + /// Accessibility label for the toolbar button. + public var label: String { + switch self { + case .heading: return "Heading" + case .task: return "Task" + case .weakReverse: return "Close one level" + case .strongReverse: return "Close all levels" + case .code: return "Code block" + } + } + + /// SF Symbol shown on the toolbar button. + public var systemImage: String { + switch self { + case .heading: return "asterisk" + case .task: return "checkmark.circle" + case .weakReverse: return "chevron.left" + case .strongReverse: return "chevron.left.2" + case .code: return "chevron.left.forwardslash.chevron.right" + } + } +} diff --git a/Sources/NorgKeyboardToolbar/EditorSnippet.swift b/Sources/NorgKeyboardToolbar/EditorSnippet.swift new file mode 100644 index 0000000..b87d2b1 --- /dev/null +++ b/Sources/NorgKeyboardToolbar/EditorSnippet.swift @@ -0,0 +1,90 @@ +import Foundation + +/// The available editor snippets to show in the toolbar. +enum EditorSnippet: String, CaseIterable, Identifiable, Sendable { + case heading + case task + case weakReverse + case strongReverse + case code + + public var id: String { rawValue } + + /// The text inserted, with the cursor offset (in characters). + public var template: (text: String, cursor: Int) { + switch self { + case .heading: return ("* ", 2) + case .task: return ("- ( ) ", 6) + case .weakReverse: return ("---", 3) + case .strongReverse: return ("===", 3) + case .code: return ("@code\n\t\n@end", 7) + } + } + + /// Applies the snippet, returning the updated text and new cursor offset. + public func apply(to text: String, cursor: Int) -> (text: String, cursor: Int) { + let characters = Array(text) + let position = min(max(cursor, 0), characters.count) + + switch self { + case .heading where isInBareMarker(characters, at: position, markers: ["*"]): + return promoteHeading(characters, at: position) + case .task where isInBareMarker(characters, at: position, markers: ["*", "~"]): + return addTask(toMarker: characters, at: position) + default: + return insertOnNewLine(in: characters, cursor: position) + } + } + + // MARK: - Marker-aware behaviours + + /// Whether the is at a position with only `markers` and spaces, and contains + /// at least one marker. + private func isInBareMarker(_ characters: [Character], at position: Int, markers: Set<Character>) + -> Bool { + let lineStart = LineScanner.lineStart(in: characters, at: position) + let prefix = characters[lineStart..<position] + return prefix.contains(where: markers.contains) + && prefix.allSatisfy { markers.contains($0) || $0 == " " } + } + + /// Adds a `*` after the leading run of spaces and stars on the cursor's line. + private func promoteHeading(_ characters: [Character], at position: Int) -> ( + text: String, cursor: Int + ) { + let lineStart = LineScanner.lineStart(in: characters, at: position) + + var insertAt = lineStart + while insertAt < characters.count, characters[insertAt] == " " { insertAt += 1 } + while insertAt < characters.count, characters[insertAt] == "*" { insertAt += 1 } + + var result = characters + result.insert("*", at: insertAt) + return (String(result), insertAt <= position ? position + 1 : position) + } + + /// Inserts a task marker `( ) ` at the cursor, turning a heading or + /// ordered-list item into a task. + private func addTask(toMarker characters: [Character], at position: Int) -> ( + text: String, cursor: Int + ) { + var result = characters + result.insert(contentsOf: "( ) ", at: position) + return (String(result), position + 4) + } + + /// Inserts the snippet's text on a new line beneath the cursor's line. + private func insertOnNewLine(in characters: [Character], cursor position: Int) -> ( + text: String, cursor: Int + ) { + // An empty document takes the snippet as-is, with no leading newline. + guard !characters.isEmpty else { return template } + + let lineEnd = LineScanner.lineEnd(in: characters, at: position) + + let prefix = String(characters[0..<lineEnd]) + let suffix = String(characters[lineEnd...]) + let newText = prefix + "\n" + template.text + suffix + return (newText, lineEnd + 1 + template.cursor) + } +} diff --git a/Sources/NorgKeyboardToolbar/LineScanner.swift b/Sources/NorgKeyboardToolbar/LineScanner.swift new file mode 100644 index 0000000..53d443d --- /dev/null +++ b/Sources/NorgKeyboardToolbar/LineScanner.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Tools to find the start and end of a line. +enum LineScanner { + + /// Given a current position in a character array, find the index of the + /// start of the line. + static func lineStart(in characters: [Character], at position: Int) -> Int { + var start = position + while start > 0, characters[start - 1] != "\n" { start -= 1 } + return start + } + + /// Given a current position in a character array, find the index right + /// after the next newline or end of buffer. + static func lineEnd(in characters: [Character], at position: Int) -> Int { + var end = position + while end < characters.count, characters[end] != "\n" { end += 1 } + return end + } +} diff --git a/Sources/NorgKeyboardToolbar/NorgKeyboardToolbar.swift b/Sources/NorgKeyboardToolbar/NorgKeyboardToolbar.swift new file mode 100644 index 0000000..fdd114b --- /dev/null +++ b/Sources/NorgKeyboardToolbar/NorgKeyboardToolbar.swift @@ -0,0 +1,59 @@ +#if canImport(SwiftUI) + import SwiftUI + + /// `ToolbarContent` that provides one button per `EditorSnippet`. + /// + /// It takes the same `text` and `selection` bindings used with `TextEditor`. + /// + /// ```swift + /// TextEditor(text: $draft, selection: $selection) + /// .toolbar { NorgKeyboardToolbar(text: $draft, selection: $selection) } + /// ``` + /// + /// Tapping a button applies the corresponding snippet to `text` and moves the + /// insertion point to the snippet's cursor offset. + @available(iOS 18.0, macOS 15.0, visionOS 2.0, *) + public struct NorgKeyboardToolbar: ToolbarContent { + @Binding private var text: String + @Binding private var selection: TextSelection? + + public init(text: Binding<String>, selection: Binding<TextSelection?>) { + self._text = text + self._selection = selection + } + + public var body: some ToolbarContent { + ToolbarItemGroup(placement: .keyboard) { + ForEach(EditorSnippet.allCases) { snippet in + Button(snippet.label, systemImage: snippet.systemImage) { insert(snippet) } + .labelStyle(.iconOnly) + } + } + } + + private func insert(_ snippet: EditorSnippet) { + let result = snippet.apply(to: text, cursor: cursorOffset()) + text = result.text + + // Apply the new cursor after the text change commits, otherwise the + // editor resets the insertion point to the end. + DispatchQueue.main.async { + let clamped = min(max(result.cursor, 0), text.count) + selection = TextSelection(insertionPoint: text.index(text.startIndex, offsetBy: clamped)) + } + } + + private func cursorOffset() -> Int { + guard let selection else { return text.count } + switch selection.indices { + case .selection(let range): + return text.distance(from: text.startIndex, to: range.lowerBound) + case .multiSelection(let rangeSet): + guard let first = rangeSet.ranges.first else { return text.count } + return text.distance(from: text.startIndex, to: first.lowerBound) + @unknown default: + return text.count + } + } + } +#endif |