aboutsummaryrefslogtreecommitdiff
path: root/Sources/NorgKeyboardToolbar/EditorSnippet.swift
blob: b87d2b1f8b2622d6a116b6eeb4a5f5f2c5efb244 (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
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)
  }
}