import Foundation /// The available editor snippets to show in the toolbar. public 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 = ["*", "-", "~", ">", "$", "^", ":"] /// 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) -> Bool { let lineStart = LineScanner.lineStart(in: characters, at: position) let prefix = characters[lineStart.. 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..