aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorRuben Beltran del Rio <jj@r.bdr.sh>2026-06-18 19:06:14 +0200
committerRuben Beltran del Rio <jj@r.bdr.sh>2026-06-18 19:10:49 +0200
commit692abd44213b32d507849cec74ec847c994cc257 (patch)
tree940e149e69bddfae91202c5dbba6a30f477a280a
parente5e024a5f4c8709cf2a28dc3bfab79d8037516e1 (diff)
Add UIKit SupportHEAD1.1.0main
-rw-r--r--README.md22
-rw-r--r--Sources/NorgKeyboardToolbar/EditorSnippet.swift2
-rw-r--r--Sources/NorgKeyboardToolbar/NorgKeyboardAccessory.swift98
3 files changed, 120 insertions, 2 deletions
diff --git a/README.md b/README.md
index dc8cec4..9819fd0 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
# Norg Keyboard Toolbar
This package provides a toolbar you can use whenever you need to edit norg
-files. The toolbar provides the following buttons:
+files in SwiftUI or UIKit apps. The toolbar provides the following buttons:
The heading button creates a new heading, or increases the heading of the
current level. See this table for examples.
@@ -30,6 +30,8 @@ The code button adds a `@code` and `@end` block.
## Usage
+### SwiftUI
+
Add the package to your project by including the repo URL with Xcode's package
manager, and import it:
@@ -54,6 +56,24 @@ struct EditorView: View {
of the screen if a hardware keyboard is connected.) with the buttons described
above.
+### UIKit
+
+Add the package to your project by including the repo URL with Xcode's package
+manager, and import it:
+
+```swift
+import UIKit
+import NorgKeyboardToolbar
+
+...
+
+let textView = UITextView()
+textView.installNorgKeyboardAccessory()
+```
+
+`installNorgKeyboardAccessory` then attaches a toolbar above the keyboard with
+the buttons described above.
+
## Development
A `Justfile` is provided to run common tasks:
diff --git a/Sources/NorgKeyboardToolbar/EditorSnippet.swift b/Sources/NorgKeyboardToolbar/EditorSnippet.swift
index 4773eac..fe0a469 100644
--- a/Sources/NorgKeyboardToolbar/EditorSnippet.swift
+++ b/Sources/NorgKeyboardToolbar/EditorSnippet.swift
@@ -1,7 +1,7 @@
import Foundation
/// The available editor snippets to show in the toolbar.
-enum EditorSnippet: String, CaseIterable, Identifiable, Sendable {
+public enum EditorSnippet: String, CaseIterable, Identifiable, Sendable {
case heading
case task
case weakReverse
diff --git a/Sources/NorgKeyboardToolbar/NorgKeyboardAccessory.swift b/Sources/NorgKeyboardToolbar/NorgKeyboardAccessory.swift
new file mode 100644
index 0000000..3538330
--- /dev/null
+++ b/Sources/NorgKeyboardToolbar/NorgKeyboardAccessory.swift
@@ -0,0 +1,98 @@
+#if canImport(UIKit)
+ import UIKit
+
+ // MARK: - Applying snippets to a UITextView
+
+ extension EditorSnippet {
+
+ /// Applies this snippet to `textView` at its current insertion point,
+ /// updating the text and cursor, then notifying the text view's delegate
+ /// (UITextView does not report programmatic edits on its own).
+ @MainActor
+ public func apply(to textView: UITextView) {
+ let text = textView.text ?? ""
+ let result = apply(
+ to: text, cursor: Self.characterOffset(of: textView.selectedRange, in: text))
+
+ // Replacing `text` wholesale resets the scroll to the top, so preserve
+ // and restore the content offset around the edit.
+ let savedOffset = textView.contentOffset
+ textView.text = result.text
+ textView.layoutIfNeeded()
+ textView.selectedRange = Self.range(forCharacterOffset: result.cursor, in: result.text)
+ textView.setContentOffset(savedOffset, animated: false)
+ textView.delegate?.textViewDidChange?(textView)
+ }
+
+ /// The character (grapheme) offset of a UTF-16 `NSRange`'s start.
+ private static func characterOffset(of nsRange: NSRange, in text: String) -> Int {
+ guard let range = Range(nsRange, in: text) else { return text.count }
+ return text.distance(from: text.startIndex, to: range.lowerBound)
+ }
+
+ /// A zero-length UTF-16 `NSRange` at the given character offset.
+ private static func range(forCharacterOffset offset: Int, in text: String) -> NSRange {
+ let clamped = min(max(offset, 0), text.count)
+ let index = text.index(text.startIndex, offsetBy: clamped)
+ return NSRange(index..<index, in: text)
+ }
+ }
+
+ // MARK: - Input-accessory toolbar
+
+ /// A keyboard input-accessory toolbar with one button per ``EditorSnippet``,
+ /// wired to a `UITextView`. Drop it onto any UIKit text editor:
+ ///
+ /// ```swift
+ /// textView.inputAccessoryView = NorgKeyboardAccessory(textView: textView)
+ /// // or:
+ /// textView.installNorgKeyboardAccessory()
+ /// ```
+ ///
+ /// The toolbar holds the text view weakly, so it does not retain it.
+ public final class NorgKeyboardAccessory: UIToolbar {
+
+ private weak var textView: UITextView?
+
+ public init(textView: UITextView) {
+ self.textView = textView
+ super.init(frame: CGRect(x: 0, y: 0, width: 0, height: 50))
+ autoresizingMask = .flexibleWidth
+ buildItems()
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) is not supported")
+ }
+
+ /// Fixed glyph size so the icons stay consistent regardless of the
+ /// toolbar's height (a taller bar would otherwise scale them up).
+ private static let symbolConfiguration = UIImage.SymbolConfiguration(
+ pointSize: 17, weight: .regular)
+
+ private func buildItems() {
+ let buttons = EditorSnippet.allCases.map { snippet -> UIBarButtonItem in
+ let image = UIImage(
+ systemName: snippet.systemImage, withConfiguration: Self.symbolConfiguration)
+ let action = UIAction(image: image) { [weak self] _ in
+ guard let textView = self?.textView else { return }
+ snippet.apply(to: textView)
+ }
+ let item = UIBarButtonItem(primaryAction: action)
+ item.accessibilityLabel = snippet.label
+ return item
+ }
+ // Center the group of buttons with flexible space on both sides.
+ setItems([.flexibleSpace()] + buttons + [.flexibleSpace()], animated: false)
+ }
+ }
+
+ extension UITextView {
+
+ /// Installs a ``NorgKeyboardAccessory`` as this text view's input accessory.
+ public func installNorgKeyboardAccessory() {
+ inputAccessoryView = NorgKeyboardAccessory(textView: self)
+ }
+ }
+#endif