aboutsummaryrefslogtreecommitdiff
path: root/Sources/NorgKeyboardToolbar/NorgKeyboardAccessory.swift
blob: 3538330dd4a3deaf5bbdffa18069217ecfedbbae (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
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