aboutsummaryrefslogtreecommitdiff
path: root/Sources/NorgKeyboardToolbar/EditorSnippet.swift
blob: 4773eac728611776a4b1ce188ad319a047cd1ee5 (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
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
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 isInBareDetachedModifier(characters, at: position):
      return addTask(toMarker: characters, at: position)
    default:
      return insertOnNewLine(in: characters, cursor: position)
    }
  }

  // MARK: - Marker-aware behaviours

  /// The detached-modifier markers that can carry a task status, matching
  /// NorgKit's task scanner. Any of these — headings, un/ordered lists,
  /// quotes, definitions, footnotes — may be turned into a task in place.
  private static let detachedModifierMarkers: Set<Character> = ["*", "-", "~", ">", "$", "^", ":"]

  /// 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 == " " }
  }

  /// Whether the cursor sits just past a bare detached modifier: leading
  /// spaces, a run of a single marker, then at least one space, with nothing
  /// else before the cursor. The trailing space requirement keeps delimiters
  /// such as `---` (which carry no whitespace) from being treated as tasks.
  private func isInBareDetachedModifier(_ characters: [Character], at position: Int) -> Bool {
    let lineStart = LineScanner.lineStart(in: characters, at: position)
    var index = lineStart
    while index < position, characters[index] == " " { index += 1 }

    guard index < position, Self.detachedModifierMarkers.contains(characters[index]) else {
      return false
    }
    let marker = characters[index]
    while index < position, characters[index] == marker { index += 1 }

    // A space must separate the marker run from the cursor, and the cursor may
    // only sit within that run of separating spaces (i.e. before any content).
    guard index < position, characters[index] == " " else { return false }
    while index < position, characters[index] == " " { index += 1 }
    return index == position
  }

  /// 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)
  }
}