aboutsummaryrefslogtreecommitdiff
path: root/Sources/NorgKeyboardToolbar/NorgKeyboardToolbar.swift
blob: fdd114b46daffa8123a06f20d446c88d81a94090 (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
#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