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