diff options
| author | Ruben Beltran del Rio <jj@r.bdr.sh> | 2026-06-18 19:06:14 +0200 |
|---|---|---|
| committer | Ruben Beltran del Rio <jj@r.bdr.sh> | 2026-06-18 19:10:49 +0200 |
| commit | 692abd44213b32d507849cec74ec847c994cc257 (patch) | |
| tree | 940e149e69bddfae91202c5dbba6a30f477a280a | |
| parent | e5e024a5f4c8709cf2a28dc3bfab79d8037516e1 (diff) | |
| -rw-r--r-- | README.md | 22 | ||||
| -rw-r--r-- | Sources/NorgKeyboardToolbar/EditorSnippet.swift | 2 | ||||
| -rw-r--r-- | Sources/NorgKeyboardToolbar/NorgKeyboardAccessory.swift | 98 |
3 files changed, 120 insertions, 2 deletions
@@ -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 |