aboutsummaryrefslogtreecommitdiff
path: root/Sources
diff options
context:
space:
mode:
Diffstat (limited to 'Sources')
-rw-r--r--Sources/NorgKeyboardToolbar/EditorSnippet+Presentation.swift27
-rw-r--r--Sources/NorgKeyboardToolbar/EditorSnippet.swift90
-rw-r--r--Sources/NorgKeyboardToolbar/LineScanner.swift21
-rw-r--r--Sources/NorgKeyboardToolbar/NorgKeyboardToolbar.swift59
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