2026-02-06 18:59:53 +00:00
|
|
|
import SwiftUI
|
|
|
|
|
import Foundation
|
2026-02-18 19:19:49 +00:00
|
|
|
import OSLog
|
|
|
|
|
|
|
|
|
|
private let syntaxHighlightSignposter = OSSignposter(subsystem: "h3p.Neon-Vision-Editor", category: "SyntaxHighlight")
|
2026-02-06 18:59:53 +00:00
|
|
|
|
2026-02-20 15:53:22 +00:00
|
|
|
private enum EmmetExpander {
|
|
|
|
|
struct Node {
|
|
|
|
|
var tag: String
|
|
|
|
|
var id: String?
|
|
|
|
|
var classes: [String]
|
|
|
|
|
var count: Int
|
|
|
|
|
var children: [Node]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static let allowedChars = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.#>+*-_")
|
|
|
|
|
private static let voidTags: Set<String> = ["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]
|
|
|
|
|
|
|
|
|
|
static func expansionIfPossible(in text: String, cursorUTF16Location: Int, language: String) -> (range: NSRange, expansion: String, caretOffset: Int)? {
|
|
|
|
|
guard language == "html" || language == "php" else { return nil }
|
|
|
|
|
if language == "php" && !isHTMLContextInPHP(text: text, cursorUTF16Location: cursorUTF16Location) {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let ns = text as NSString
|
|
|
|
|
let clamped = min(max(0, cursorUTF16Location), ns.length)
|
|
|
|
|
guard let range = abbreviationRange(in: ns, cursor: clamped), range.length > 0 else { return nil }
|
|
|
|
|
let raw = ns.substring(with: range).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
guard !raw.isEmpty, !raw.contains("<"), !raw.contains("> ") else { return nil }
|
|
|
|
|
guard let nodes = parseChain(raw), !nodes.isEmpty else { return nil }
|
|
|
|
|
let indent = leadingIndentationForLine(in: ns, at: range.location)
|
|
|
|
|
let rendered = render(nodes: nodes, indent: indent, level: 0)
|
|
|
|
|
if let first = rendered.range(of: "></") {
|
|
|
|
|
let caretUTF16 = rendered[..<first.lowerBound].utf16.count + 1
|
|
|
|
|
return (range, rendered, caretUTF16)
|
|
|
|
|
}
|
|
|
|
|
return (range, rendered, rendered.utf16.count)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static func abbreviationRange(in text: NSString, cursor: Int) -> NSRange? {
|
|
|
|
|
guard text.length > 0, cursor > 0 else { return nil }
|
|
|
|
|
var start = cursor
|
|
|
|
|
while start > 0 {
|
|
|
|
|
let scalar = text.character(at: start - 1)
|
|
|
|
|
guard let uni = UnicodeScalar(scalar), allowedChars.contains(uni) else { break }
|
|
|
|
|
start -= 1
|
|
|
|
|
}
|
|
|
|
|
guard start < cursor else { return nil }
|
|
|
|
|
return NSRange(location: start, length: cursor - start)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static func leadingIndentationForLine(in text: NSString, at location: Int) -> String {
|
|
|
|
|
let lineRange = text.lineRange(for: NSRange(location: max(0, min(location, text.length)), length: 0))
|
|
|
|
|
let line = text.substring(with: lineRange)
|
|
|
|
|
return String(line.prefix { $0 == " " || $0 == "\t" })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static func isHTMLContextInPHP(text: String, cursorUTF16Location: Int) -> Bool {
|
|
|
|
|
let ns = text as NSString
|
|
|
|
|
let clamped = min(max(0, cursorUTF16Location), ns.length)
|
|
|
|
|
let search = NSRange(location: 0, length: clamped)
|
|
|
|
|
let openRanges = [
|
|
|
|
|
ns.range(of: "<?php", options: .backwards, range: search),
|
|
|
|
|
ns.range(of: "<?=", options: .backwards, range: search),
|
|
|
|
|
ns.range(of: "<?", options: .backwards, range: search)
|
|
|
|
|
]
|
|
|
|
|
let latestOpen = openRanges.compactMap { $0.location == NSNotFound ? nil : $0.location }.max() ?? -1
|
|
|
|
|
let latestCloseRange = ns.range(of: "?>", options: .backwards, range: search)
|
|
|
|
|
let latestClose = latestCloseRange.location
|
|
|
|
|
return latestOpen == -1 || (latestClose != NSNotFound && latestClose > latestOpen)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static func parseChain(_ raw: String) -> [Node]? {
|
|
|
|
|
let hierarchyParts = raw.split(separator: ">", omittingEmptySubsequences: false).map(String.init)
|
|
|
|
|
guard !hierarchyParts.isEmpty else { return nil }
|
|
|
|
|
|
|
|
|
|
var levels: [[Node]] = []
|
|
|
|
|
for part in hierarchyParts {
|
|
|
|
|
let siblings = part.split(separator: "+", omittingEmptySubsequences: false).map(String.init)
|
|
|
|
|
var levelNodes: [Node] = []
|
|
|
|
|
for sibling in siblings {
|
|
|
|
|
guard let node = parseNode(sibling), !node.tag.isEmpty else { return nil }
|
|
|
|
|
levelNodes.append(node)
|
|
|
|
|
}
|
|
|
|
|
guard !levelNodes.isEmpty else { return nil }
|
|
|
|
|
levels.append(levelNodes)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for level in stride(from: levels.count - 2, through: 0, by: -1) {
|
|
|
|
|
let children = levels[level + 1]
|
|
|
|
|
for idx in levels[level].indices {
|
|
|
|
|
levels[level][idx].children = children
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return levels.first
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static func parseNode(_ token: String) -> Node? {
|
|
|
|
|
let source = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
guard !source.isEmpty else { return nil }
|
|
|
|
|
|
|
|
|
|
var count = 1
|
|
|
|
|
var core = source
|
|
|
|
|
if let star = source.lastIndex(of: "*") {
|
|
|
|
|
let multiplier = String(source[source.index(after: star)...])
|
|
|
|
|
if let n = Int(multiplier), n > 0 {
|
|
|
|
|
count = n
|
|
|
|
|
core = String(source[..<star])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var tag = ""
|
|
|
|
|
var id: String?
|
|
|
|
|
var classes: [String] = []
|
|
|
|
|
var i = core.startIndex
|
|
|
|
|
while i < core.endIndex {
|
|
|
|
|
let ch = core[i]
|
|
|
|
|
if ch == "." || ch == "#" { break }
|
|
|
|
|
tag.append(ch)
|
|
|
|
|
i = core.index(after: i)
|
|
|
|
|
}
|
|
|
|
|
if tag.isEmpty { tag = "div" }
|
|
|
|
|
|
|
|
|
|
while i < core.endIndex {
|
|
|
|
|
let marker = core[i]
|
|
|
|
|
guard marker == "." || marker == "#" else { return nil }
|
|
|
|
|
i = core.index(after: i)
|
|
|
|
|
var value = ""
|
|
|
|
|
while i < core.endIndex {
|
|
|
|
|
let c = core[i]
|
|
|
|
|
if c == "." || c == "#" { break }
|
|
|
|
|
value.append(c)
|
|
|
|
|
i = core.index(after: i)
|
|
|
|
|
}
|
|
|
|
|
guard !value.isEmpty else { return nil }
|
|
|
|
|
if marker == "#" { id = value } else { classes.append(value) }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Node(tag: tag, id: id, classes: classes, count: count, children: [])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static func render(nodes: [Node], indent: String, level: Int) -> String {
|
|
|
|
|
nodes.map { render(node: $0, indent: indent, level: level) }.joined(separator: "\n")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static func render(node: Node, indent: String, level: Int) -> String {
|
|
|
|
|
var lines: [String] = []
|
|
|
|
|
for _ in 0..<max(1, node.count) {
|
|
|
|
|
let pad = indent + String(repeating: " ", count: level)
|
|
|
|
|
let attrs = attributes(for: node)
|
|
|
|
|
if node.children.isEmpty {
|
|
|
|
|
if voidTags.contains(node.tag.lowercased()) {
|
|
|
|
|
lines.append("\(pad)<\(node.tag)\(attrs)>")
|
|
|
|
|
} else {
|
|
|
|
|
lines.append("\(pad)<\(node.tag)\(attrs)></\(node.tag)>")
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
lines.append("\(pad)<\(node.tag)\(attrs)>")
|
|
|
|
|
lines.append(render(nodes: node.children, indent: indent, level: level + 1))
|
|
|
|
|
lines.append("\(pad)</\(node.tag)>")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return lines.joined(separator: "\n")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static func attributes(for node: Node) -> String {
|
|
|
|
|
var attrs: [String] = []
|
|
|
|
|
if let id = node.id { attrs.append("id=\"\(id)\"") }
|
|
|
|
|
if !node.classes.isEmpty { attrs.append("class=\"\(node.classes.joined(separator: " "))\"") }
|
|
|
|
|
return attrs.isEmpty ? "" : " " + attrs.joined(separator: " ")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 13:24:01 +00:00
|
|
|
///MARK: - Paste Notifications
|
2026-02-11 10:20:17 +00:00
|
|
|
extension Notification.Name {
|
|
|
|
|
static let pastedFileURL = Notification.Name("pastedFileURL")
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 13:24:01 +00:00
|
|
|
///MARK: - Scope Match Models
|
|
|
|
|
// Bracket-based scope data used for highlighting and guide rendering.
|
2026-02-12 22:20:39 +00:00
|
|
|
private struct BracketScopeMatch {
|
|
|
|
|
let openRange: NSRange
|
|
|
|
|
let closeRange: NSRange
|
|
|
|
|
let scopeRange: NSRange?
|
|
|
|
|
let guideMarkerRanges: [NSRange]
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 13:24:01 +00:00
|
|
|
// Indentation-based scope data used for Python/YAML style highlighting.
|
2026-02-12 22:20:39 +00:00
|
|
|
private struct IndentationScopeMatch {
|
|
|
|
|
let scopeRange: NSRange
|
|
|
|
|
let guideMarkerRanges: [NSRange]
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 13:24:01 +00:00
|
|
|
///MARK: - Bracket/Indent Scope Helpers
|
2026-02-12 22:20:39 +00:00
|
|
|
private func matchingOpeningBracket(for closing: unichar) -> unichar? {
|
|
|
|
|
switch UnicodeScalar(closing) {
|
|
|
|
|
case "}": return unichar(UnicodeScalar("{").value)
|
|
|
|
|
case "]": return unichar(UnicodeScalar("[").value)
|
|
|
|
|
case ")": return unichar(UnicodeScalar("(").value)
|
|
|
|
|
default: return nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func matchingClosingBracket(for opening: unichar) -> unichar? {
|
|
|
|
|
switch UnicodeScalar(opening) {
|
|
|
|
|
case "{": return unichar(UnicodeScalar("}").value)
|
|
|
|
|
case "[": return unichar(UnicodeScalar("]").value)
|
|
|
|
|
case "(": return unichar(UnicodeScalar(")").value)
|
|
|
|
|
default: return nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func isBracket(_ c: unichar) -> Bool {
|
|
|
|
|
matchesAny(c, ["{", "}", "[", "]", "(", ")"])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func matchesAny(_ c: unichar, _ chars: [Character]) -> Bool {
|
|
|
|
|
guard let scalar = UnicodeScalar(c) else { return false }
|
|
|
|
|
return chars.contains(Character(scalar))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func computeBracketScopeMatch(text: String, caretLocation: Int) -> BracketScopeMatch? {
|
|
|
|
|
let ns = text as NSString
|
|
|
|
|
let length = ns.length
|
|
|
|
|
guard length > 0 else { return nil }
|
|
|
|
|
|
|
|
|
|
func matchFrom(start: Int) -> BracketScopeMatch? {
|
|
|
|
|
guard start >= 0 && start < length else { return nil }
|
|
|
|
|
let startChar = ns.character(at: start)
|
|
|
|
|
let openIndex: Int
|
|
|
|
|
let closeIndex: Int
|
|
|
|
|
|
|
|
|
|
if let wantedClose = matchingClosingBracket(for: startChar) {
|
|
|
|
|
var depth = 0
|
|
|
|
|
var found: Int?
|
|
|
|
|
for i in start..<length {
|
|
|
|
|
let c = ns.character(at: i)
|
|
|
|
|
if c == startChar { depth += 1 }
|
|
|
|
|
if c == wantedClose {
|
|
|
|
|
depth -= 1
|
|
|
|
|
if depth == 0 {
|
|
|
|
|
found = i
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
guard let found else { return nil }
|
|
|
|
|
openIndex = start
|
|
|
|
|
closeIndex = found
|
|
|
|
|
} else if let wantedOpen = matchingOpeningBracket(for: startChar) {
|
|
|
|
|
var depth = 0
|
|
|
|
|
var found: Int?
|
|
|
|
|
var i = start
|
|
|
|
|
while i >= 0 {
|
|
|
|
|
let c = ns.character(at: i)
|
|
|
|
|
if c == startChar { depth += 1 }
|
|
|
|
|
if c == wantedOpen {
|
|
|
|
|
depth -= 1
|
|
|
|
|
if depth == 0 {
|
|
|
|
|
found = i
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
i -= 1
|
|
|
|
|
}
|
|
|
|
|
guard let found else { return nil }
|
|
|
|
|
openIndex = found
|
|
|
|
|
closeIndex = start
|
|
|
|
|
} else {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let openRange = NSRange(location: openIndex, length: 1)
|
|
|
|
|
let closeRange = NSRange(location: closeIndex, length: 1)
|
|
|
|
|
let scopeLength = max(0, closeIndex - openIndex - 1)
|
|
|
|
|
let scopeRange: NSRange? = scopeLength > 0 ? NSRange(location: openIndex + 1, length: scopeLength) : nil
|
|
|
|
|
|
|
|
|
|
let openLineRange = ns.lineRange(for: NSRange(location: openIndex, length: 0))
|
|
|
|
|
let closeLineRange = ns.lineRange(for: NSRange(location: closeIndex, length: 0))
|
|
|
|
|
let column = openIndex - openLineRange.location
|
|
|
|
|
|
|
|
|
|
var markers: [NSRange] = []
|
|
|
|
|
var lineStart = openLineRange.location
|
|
|
|
|
while lineStart <= closeLineRange.location && lineStart < length {
|
|
|
|
|
let lineRange = ns.lineRange(for: NSRange(location: lineStart, length: 0))
|
|
|
|
|
let lineEndExcludingNewline = lineRange.location + max(0, lineRange.length - 1)
|
|
|
|
|
if lineEndExcludingNewline > lineRange.location {
|
|
|
|
|
let markerLoc = min(lineRange.location + column, lineEndExcludingNewline - 1)
|
|
|
|
|
if markerLoc >= lineRange.location && markerLoc < lineEndExcludingNewline {
|
|
|
|
|
markers.append(NSRange(location: markerLoc, length: 1))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let nextLineStart = lineRange.location + lineRange.length
|
|
|
|
|
if nextLineStart <= lineStart { break }
|
|
|
|
|
lineStart = nextLineStart
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return BracketScopeMatch(
|
|
|
|
|
openRange: openRange,
|
|
|
|
|
closeRange: closeRange,
|
|
|
|
|
scopeRange: scopeRange,
|
|
|
|
|
guideMarkerRanges: markers
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let safeCaret = max(0, min(caretLocation, length))
|
|
|
|
|
var probeIndices: [Int] = [safeCaret]
|
|
|
|
|
if safeCaret > 0 { probeIndices.append(safeCaret - 1) }
|
|
|
|
|
|
|
|
|
|
var candidateIndices: [Int] = []
|
|
|
|
|
var seenCandidates = Set<Int>()
|
|
|
|
|
func addCandidate(_ index: Int) {
|
|
|
|
|
guard index >= 0 && index < length else { return }
|
|
|
|
|
if seenCandidates.insert(index).inserted {
|
|
|
|
|
candidateIndices.append(index)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for idx in probeIndices where idx >= 0 && idx < length {
|
|
|
|
|
if isBracket(ns.character(at: idx)) {
|
|
|
|
|
addCandidate(idx)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If caret is not directly on a bracket, find the nearest enclosing opening
|
|
|
|
|
// bracket whose matching close still contains the caret.
|
|
|
|
|
var stack: [Int] = []
|
|
|
|
|
if safeCaret > 0 {
|
|
|
|
|
for i in 0..<safeCaret {
|
|
|
|
|
let c = ns.character(at: i)
|
|
|
|
|
if matchingClosingBracket(for: c) != nil {
|
|
|
|
|
stack.append(i)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if let wantedOpen = matchingOpeningBracket(for: c), let last = stack.last, ns.character(at: last) == wantedOpen {
|
|
|
|
|
stack.removeLast()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
while let candidate = stack.popLast() {
|
|
|
|
|
let c = ns.character(at: candidate)
|
|
|
|
|
guard let wantedClose = matchingClosingBracket(for: c) else { continue }
|
|
|
|
|
var depth = 0
|
|
|
|
|
var foundClose: Int?
|
|
|
|
|
for i in candidate..<length {
|
|
|
|
|
let current = ns.character(at: i)
|
|
|
|
|
if current == c { depth += 1 }
|
|
|
|
|
if current == wantedClose {
|
|
|
|
|
depth -= 1
|
|
|
|
|
if depth == 0 {
|
|
|
|
|
foundClose = i
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if let close = foundClose, safeCaret >= candidate && safeCaret <= close {
|
|
|
|
|
addCandidate(candidate)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Add all brackets by nearest distance so we still find a valid scope even if
|
|
|
|
|
// early candidates are unmatched (e.g. bracket chars inside strings/comments).
|
|
|
|
|
let allBracketIndices = (0..<length).filter { isBracket(ns.character(at: $0)) }
|
|
|
|
|
let sortedByDistance = allBracketIndices.sorted { abs($0 - safeCaret) < abs($1 - safeCaret) }
|
|
|
|
|
for idx in sortedByDistance {
|
|
|
|
|
addCandidate(idx)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for candidate in candidateIndices {
|
|
|
|
|
if let match = matchFrom(start: candidate) {
|
|
|
|
|
return match
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func supportsIndentationScopes(language: String) -> Bool {
|
|
|
|
|
let lang = language.lowercased()
|
|
|
|
|
return lang == "python" || lang == "yaml" || lang == "yml"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func computeIndentationScopeMatch(text: String, caretLocation: Int) -> IndentationScopeMatch? {
|
|
|
|
|
let ns = text as NSString
|
|
|
|
|
let length = ns.length
|
|
|
|
|
guard length > 0 else { return nil }
|
|
|
|
|
|
|
|
|
|
struct LineInfo {
|
|
|
|
|
let range: NSRange
|
|
|
|
|
let contentEnd: Int
|
|
|
|
|
let indent: Int?
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func lineIndent(_ lineRange: NSRange) -> Int? {
|
|
|
|
|
guard lineRange.length > 0 else { return nil }
|
|
|
|
|
let line = ns.substring(with: lineRange)
|
|
|
|
|
var indent = 0
|
|
|
|
|
var sawContent = false
|
|
|
|
|
for ch in line {
|
|
|
|
|
if ch == " " {
|
|
|
|
|
indent += 1
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if ch == "\t" {
|
|
|
|
|
indent += 4
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if ch == "\n" || ch == "\r" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
sawContent = true
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
return sawContent ? indent : nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var lines: [LineInfo] = []
|
|
|
|
|
var lineStart = 0
|
|
|
|
|
while lineStart < length {
|
|
|
|
|
let lr = ns.lineRange(for: NSRange(location: lineStart, length: 0))
|
|
|
|
|
let contentEnd = lr.location + max(0, lr.length - 1)
|
|
|
|
|
lines.append(LineInfo(range: lr, contentEnd: contentEnd, indent: lineIndent(lr)))
|
|
|
|
|
let next = lr.location + lr.length
|
|
|
|
|
if next <= lineStart { break }
|
|
|
|
|
lineStart = next
|
|
|
|
|
}
|
|
|
|
|
guard !lines.isEmpty else { return nil }
|
|
|
|
|
|
|
|
|
|
let safeCaret = max(0, min(caretLocation, max(0, length - 1)))
|
|
|
|
|
guard let caretLineIndex = lines.firstIndex(where: { NSLocationInRange(safeCaret, $0.range) }) else { return nil }
|
|
|
|
|
|
|
|
|
|
var blockStart = caretLineIndex
|
|
|
|
|
var baseIndent: Int? = lines[caretLineIndex].indent
|
|
|
|
|
|
|
|
|
|
// If caret is on a block header line (e.g. Python ":"), use the next indented line.
|
|
|
|
|
if baseIndent == nil || baseIndent == 0 {
|
|
|
|
|
let currentLine = ns.substring(with: lines[caretLineIndex].range).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
if currentLine.hasSuffix(":") {
|
|
|
|
|
var next = caretLineIndex + 1
|
|
|
|
|
while next < lines.count {
|
|
|
|
|
if let nextIndent = lines[next].indent, nextIndent > 0 {
|
|
|
|
|
baseIndent = nextIndent
|
|
|
|
|
blockStart = next
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
next += 1
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
guard let indentLevel = baseIndent, indentLevel > 0 else { return nil }
|
|
|
|
|
|
|
|
|
|
var start = blockStart
|
|
|
|
|
while start > 0 {
|
|
|
|
|
let prev = lines[start - 1]
|
|
|
|
|
guard let prevIndent = prev.indent else {
|
|
|
|
|
start -= 1
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if prevIndent >= indentLevel {
|
|
|
|
|
start -= 1
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var end = blockStart
|
|
|
|
|
var idx = blockStart + 1
|
|
|
|
|
while idx < lines.count {
|
|
|
|
|
let info = lines[idx]
|
|
|
|
|
if let infoIndent = info.indent {
|
|
|
|
|
if infoIndent < indentLevel { break }
|
|
|
|
|
end = idx
|
|
|
|
|
idx += 1
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
// Keep blank lines inside the current block.
|
|
|
|
|
end = idx
|
|
|
|
|
idx += 1
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let startLoc = lines[start].range.location
|
|
|
|
|
let endLoc = lines[end].contentEnd
|
|
|
|
|
guard endLoc > startLoc else { return nil }
|
|
|
|
|
|
|
|
|
|
var guideMarkers: [NSRange] = []
|
|
|
|
|
for i in start...end {
|
|
|
|
|
let info = lines[i]
|
|
|
|
|
guard info.contentEnd > info.range.location else { continue }
|
|
|
|
|
guard let infoIndent = info.indent, infoIndent >= indentLevel else { continue }
|
|
|
|
|
let marker = min(info.range.location + max(0, indentLevel - 1), info.contentEnd - 1)
|
|
|
|
|
if marker >= info.range.location && marker < info.contentEnd {
|
|
|
|
|
guideMarkers.append(NSRange(location: marker, length: 1))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return IndentationScopeMatch(
|
|
|
|
|
scopeRange: NSRange(location: startLoc, length: endLoc - startLoc),
|
|
|
|
|
guideMarkerRanges: guideMarkers
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func isValidRange(_ range: NSRange, utf16Length: Int) -> Bool {
|
|
|
|
|
guard range.location != NSNotFound, range.length >= 0, range.location >= 0 else { return false }
|
|
|
|
|
return NSMaxRange(range) <= utf16Length
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-07 10:51:52 +00:00
|
|
|
#if os(macOS)
|
|
|
|
|
import AppKit
|
|
|
|
|
|
2026-02-06 18:59:53 +00:00
|
|
|
final class AcceptingTextView: NSTextView {
|
|
|
|
|
override var acceptsFirstResponder: Bool { true }
|
2026-02-08 00:06:06 +00:00
|
|
|
override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
|
2026-02-06 18:59:53 +00:00
|
|
|
override var mouseDownCanMoveWindow: Bool { false }
|
|
|
|
|
override var isOpaque: Bool { false }
|
2026-02-08 00:06:06 +00:00
|
|
|
private let vimModeDefaultsKey = "EditorVimModeEnabled"
|
2026-02-08 00:08:36 +00:00
|
|
|
private let vimInterceptionDefaultsKey = "EditorVimInterceptionEnabled"
|
2026-02-08 00:06:06 +00:00
|
|
|
private var isVimInsertMode: Bool = true
|
|
|
|
|
private var vimObservers: [NSObjectProtocol] = []
|
2026-02-11 14:31:24 +00:00
|
|
|
private var activityObservers: [NSObjectProtocol] = []
|
2026-02-08 00:06:06 +00:00
|
|
|
private var didConfigureVimMode: Bool = false
|
2026-02-11 10:20:17 +00:00
|
|
|
private var didApplyDeepInvisibleDisable: Bool = false
|
2026-02-11 14:31:24 +00:00
|
|
|
private var defaultsObserver: NSObjectProtocol?
|
2026-02-08 11:14:49 +00:00
|
|
|
private let dropReadChunkSize = 64 * 1024
|
2026-02-08 11:57:41 +00:00
|
|
|
fileprivate var isApplyingDroppedContent: Bool = false
|
2026-02-09 10:21:50 +00:00
|
|
|
private var inlineSuggestion: String?
|
|
|
|
|
private var inlineSuggestionLocation: Int?
|
|
|
|
|
private var inlineSuggestionView: NSTextField?
|
|
|
|
|
fileprivate var isApplyingInlineSuggestion: Bool = false
|
|
|
|
|
fileprivate var recentlyAcceptedInlineSuggestion: Bool = false
|
2026-02-11 10:20:17 +00:00
|
|
|
fileprivate var isApplyingPaste: Bool = false
|
|
|
|
|
var autoIndentEnabled: Bool = true
|
|
|
|
|
var autoCloseBracketsEnabled: Bool = true
|
2026-02-20 15:53:22 +00:00
|
|
|
var emmetLanguage: String = "plain"
|
2026-02-11 10:20:17 +00:00
|
|
|
var indentStyle: String = "spaces"
|
|
|
|
|
var indentWidth: Int = 4
|
|
|
|
|
var highlightCurrentLine: Bool = true
|
|
|
|
|
private let editorInsetX: CGFloat = 12
|
2026-02-06 18:59:53 +00:00
|
|
|
|
|
|
|
|
// We want the caret at the *start* of the paste.
|
|
|
|
|
private var pendingPasteCaretLocation: Int?
|
|
|
|
|
|
2026-02-08 00:06:06 +00:00
|
|
|
deinit {
|
2026-02-11 14:31:24 +00:00
|
|
|
if let defaultsObserver {
|
|
|
|
|
NotificationCenter.default.removeObserver(defaultsObserver)
|
|
|
|
|
}
|
|
|
|
|
for observer in activityObservers {
|
|
|
|
|
NotificationCenter.default.removeObserver(observer)
|
|
|
|
|
}
|
2026-02-08 00:06:06 +00:00
|
|
|
for observer in vimObservers {
|
|
|
|
|
NotificationCenter.default.removeObserver(observer)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
override func viewDidMoveToWindow() {
|
|
|
|
|
super.viewDidMoveToWindow()
|
|
|
|
|
if !didConfigureVimMode {
|
|
|
|
|
configureVimMode()
|
|
|
|
|
didConfigureVimMode = true
|
|
|
|
|
}
|
2026-02-11 14:31:24 +00:00
|
|
|
configureActivityObservers()
|
2026-02-08 00:06:06 +00:00
|
|
|
DispatchQueue.main.async { [weak self] in
|
|
|
|
|
guard let self else { return }
|
|
|
|
|
self.window?.makeFirstResponder(self)
|
|
|
|
|
}
|
2026-02-11 10:20:17 +00:00
|
|
|
textContainerInset = NSSize(width: editorInsetX, height: 12)
|
2026-02-11 14:31:24 +00:00
|
|
|
if defaultsObserver == nil {
|
|
|
|
|
defaultsObserver = NotificationCenter.default.addObserver(
|
|
|
|
|
forName: UserDefaults.didChangeNotification,
|
|
|
|
|
object: nil,
|
|
|
|
|
queue: .main
|
|
|
|
|
) { [weak self] _ in
|
|
|
|
|
self?.forceDisableInvisibleGlyphRendering(deep: true)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-11 10:20:17 +00:00
|
|
|
forceDisableInvisibleGlyphRendering(deep: true)
|
2026-02-08 00:06:06 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-11 14:31:24 +00:00
|
|
|
override func draw(_ dirtyRect: NSRect) {
|
|
|
|
|
// Keep invisibles/control markers hard-disabled even during inactive-window redraw passes.
|
|
|
|
|
forceDisableInvisibleGlyphRendering()
|
|
|
|
|
super.draw(dirtyRect)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 00:06:06 +00:00
|
|
|
override func mouseDown(with event: NSEvent) {
|
2026-02-08 16:47:47 +00:00
|
|
|
cancelPendingPasteCaretEnforcement()
|
2026-02-09 10:21:50 +00:00
|
|
|
clearInlineSuggestion()
|
2026-02-08 00:06:06 +00:00
|
|
|
super.mouseDown(with: event)
|
|
|
|
|
window?.makeFirstResponder(self)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 16:47:47 +00:00
|
|
|
override func scrollWheel(with event: NSEvent) {
|
2026-02-11 10:20:17 +00:00
|
|
|
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
|
|
|
|
|
if flags.contains([.shift, .option]) {
|
|
|
|
|
let delta = event.scrollingDeltaY
|
|
|
|
|
if abs(delta) > 0.1 {
|
|
|
|
|
let step: Double = delta > 0 ? 1 : -1
|
|
|
|
|
NotificationCenter.default.post(name: .zoomEditorFontRequested, object: step)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-08 16:47:47 +00:00
|
|
|
cancelPendingPasteCaretEnforcement()
|
|
|
|
|
super.scrollWheel(with: event)
|
2026-02-09 10:21:50 +00:00
|
|
|
updateInlineSuggestionPosition()
|
2026-02-08 16:47:47 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-08 00:06:06 +00:00
|
|
|
override func becomeFirstResponder() -> Bool {
|
|
|
|
|
let didBecome = super.becomeFirstResponder()
|
|
|
|
|
if didBecome, UserDefaults.standard.bool(forKey: vimModeDefaultsKey) {
|
2026-02-08 00:36:06 +00:00
|
|
|
// Re-enter NORMAL whenever Vim mode is active.
|
|
|
|
|
isVimInsertMode = false
|
2026-02-08 00:06:06 +00:00
|
|
|
postVimModeState()
|
|
|
|
|
}
|
|
|
|
|
return didBecome
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-09 10:21:50 +00:00
|
|
|
override func layout() {
|
|
|
|
|
super.layout()
|
|
|
|
|
updateInlineSuggestionPosition()
|
2026-02-11 10:20:17 +00:00
|
|
|
forceDisableInvisibleGlyphRendering()
|
2026-02-09 10:21:50 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-14 13:24:01 +00:00
|
|
|
///MARK: - Drag and Drop
|
2026-02-06 18:59:53 +00:00
|
|
|
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
|
2026-02-13 08:53:21 +00:00
|
|
|
let pb = sender.draggingPasteboard
|
|
|
|
|
let canReadFileURL = pb.canReadObject(forClasses: [NSURL.self], options: [
|
2026-02-06 18:59:53 +00:00
|
|
|
.urlReadingFileURLsOnly: true
|
|
|
|
|
])
|
2026-02-13 08:53:21 +00:00
|
|
|
let canReadPlainText = pasteboardPlainString(from: pb)?.isEmpty == false
|
|
|
|
|
return (canReadFileURL || canReadPlainText) ? .copy : []
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
|
|
|
|
|
let pb = sender.draggingPasteboard
|
|
|
|
|
if let nsurls = pb.readObjects(forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true]) as? [NSURL],
|
2026-02-11 10:20:17 +00:00
|
|
|
!nsurls.isEmpty {
|
|
|
|
|
let urls = nsurls.map { $0 as URL }
|
|
|
|
|
if urls.count == 1 {
|
|
|
|
|
NotificationCenter.default.post(name: .pastedFileURL, object: urls[0])
|
|
|
|
|
} else {
|
|
|
|
|
NotificationCenter.default.post(name: .pastedFileURL, object: urls)
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
2026-02-11 10:20:17 +00:00
|
|
|
// Do not insert content; let higher-level controller open a new tab.
|
2026-02-08 11:14:49 +00:00
|
|
|
return true
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
2026-02-13 08:53:21 +00:00
|
|
|
if let droppedString = pasteboardPlainString(from: pb), !droppedString.isEmpty {
|
|
|
|
|
let sanitized = sanitizedPlainText(droppedString)
|
|
|
|
|
let dropRange = insertionRangeForDrop(sender)
|
|
|
|
|
isApplyingPaste = true
|
|
|
|
|
if let storage = textStorage {
|
|
|
|
|
storage.beginEditing()
|
|
|
|
|
replaceCharacters(in: dropRange, with: sanitized)
|
|
|
|
|
storage.endEditing()
|
|
|
|
|
} else {
|
|
|
|
|
let current = string as NSString
|
|
|
|
|
if dropRange.location <= current.length &&
|
|
|
|
|
dropRange.location + dropRange.length <= current.length {
|
|
|
|
|
string = current.replacingCharacters(in: dropRange, with: sanitized)
|
|
|
|
|
} else {
|
|
|
|
|
isApplyingPaste = false
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
isApplyingPaste = false
|
|
|
|
|
|
|
|
|
|
NotificationCenter.default.post(name: .pastedText, object: sanitized)
|
|
|
|
|
didChangeText()
|
|
|
|
|
return true
|
|
|
|
|
}
|
2026-02-06 18:59:53 +00:00
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 08:53:21 +00:00
|
|
|
private func insertionRangeForDrop(_ sender: NSDraggingInfo) -> NSRange {
|
|
|
|
|
let windowPoint = sender.draggingLocation
|
|
|
|
|
let viewPoint = convert(windowPoint, from: nil)
|
|
|
|
|
let insertionIndex = characterIndexForInsertion(at: viewPoint)
|
|
|
|
|
let clamped = clampedRange(NSRange(location: insertionIndex, length: 0), forTextLength: (string as NSString).length)
|
|
|
|
|
setSelectedRange(clamped)
|
|
|
|
|
return clamped
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 11:14:49 +00:00
|
|
|
private func applyDroppedContentInChunks(
|
|
|
|
|
_ content: String,
|
|
|
|
|
at selection: NSRange,
|
|
|
|
|
fileName: String,
|
|
|
|
|
largeFileMode: Bool,
|
|
|
|
|
completion: @escaping (Bool, Int) -> Void
|
|
|
|
|
) {
|
|
|
|
|
let nsContent = content as NSString
|
|
|
|
|
let safeSelection = clampedRange(selection, forTextLength: (string as NSString).length)
|
|
|
|
|
let total = nsContent.length
|
|
|
|
|
if total == 0 {
|
|
|
|
|
completion(true, 0)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
NotificationCenter.default.post(
|
|
|
|
|
name: .droppedFileLoadProgress,
|
|
|
|
|
object: nil,
|
|
|
|
|
userInfo: [
|
|
|
|
|
"fraction": 0.70,
|
|
|
|
|
"fileName": "Applying file",
|
|
|
|
|
"largeFileMode": largeFileMode
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-08 11:57:41 +00:00
|
|
|
// Large payloads: prefer one atomic replace after yielding one runloop turn so
|
|
|
|
|
// progress updates can render before the heavy text-system mutation begins.
|
|
|
|
|
if total >= 300_000 {
|
|
|
|
|
isApplyingDroppedContent = true
|
|
|
|
|
NotificationCenter.default.post(
|
|
|
|
|
name: .droppedFileLoadProgress,
|
|
|
|
|
object: nil,
|
|
|
|
|
userInfo: [
|
|
|
|
|
"fraction": 0.90,
|
|
|
|
|
"fileName": "Applying file",
|
|
|
|
|
"largeFileMode": largeFileMode,
|
|
|
|
|
"isDeterminate": true
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
DispatchQueue.main.async { [weak self] in
|
|
|
|
|
guard let self else {
|
|
|
|
|
completion(false, 0)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let replaceSucceeded: Bool
|
|
|
|
|
if let storage = self.textStorage {
|
|
|
|
|
let liveSafeSelection = self.clampedRange(safeSelection, forTextLength: storage.length)
|
|
|
|
|
self.undoManager?.disableUndoRegistration()
|
|
|
|
|
storage.beginEditing()
|
|
|
|
|
if storage.length == 0 && liveSafeSelection.location == 0 && liveSafeSelection.length == 0 {
|
|
|
|
|
storage.mutableString.setString(content)
|
|
|
|
|
} else {
|
|
|
|
|
storage.mutableString.replaceCharacters(in: liveSafeSelection, with: content)
|
|
|
|
|
}
|
|
|
|
|
storage.endEditing()
|
|
|
|
|
self.undoManager?.enableUndoRegistration()
|
|
|
|
|
replaceSucceeded = true
|
|
|
|
|
} else {
|
|
|
|
|
let current = self.string as NSString
|
|
|
|
|
if safeSelection.location <= current.length &&
|
|
|
|
|
safeSelection.location + safeSelection.length <= current.length {
|
|
|
|
|
let replaced = current.replacingCharacters(in: safeSelection, with: content)
|
|
|
|
|
self.string = replaced
|
|
|
|
|
replaceSucceeded = true
|
|
|
|
|
} else {
|
|
|
|
|
replaceSucceeded = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
self.isApplyingDroppedContent = false
|
|
|
|
|
NotificationCenter.default.post(
|
|
|
|
|
name: .droppedFileLoadProgress,
|
|
|
|
|
object: nil,
|
|
|
|
|
userInfo: [
|
|
|
|
|
"fraction": replaceSucceeded ? 1.0 : 0.0,
|
|
|
|
|
"fileName": replaceSucceeded ? "Reading file" : "Import failed",
|
|
|
|
|
"largeFileMode": largeFileMode,
|
|
|
|
|
"isDeterminate": true
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
completion(replaceSucceeded, replaceSucceeded ? total : 0)
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 11:14:49 +00:00
|
|
|
let replaceSucceeded: Bool
|
|
|
|
|
if let storage = textStorage {
|
|
|
|
|
undoManager?.disableUndoRegistration()
|
|
|
|
|
storage.beginEditing()
|
|
|
|
|
storage.mutableString.replaceCharacters(in: safeSelection, with: content)
|
|
|
|
|
storage.endEditing()
|
|
|
|
|
undoManager?.enableUndoRegistration()
|
|
|
|
|
replaceSucceeded = true
|
|
|
|
|
} else {
|
|
|
|
|
// Fallback for environments where textStorage is temporarily unavailable.
|
|
|
|
|
let current = string as NSString
|
|
|
|
|
if safeSelection.location <= current.length &&
|
|
|
|
|
safeSelection.location + safeSelection.length <= current.length {
|
|
|
|
|
let replaced = current.replacingCharacters(in: safeSelection, with: content)
|
|
|
|
|
string = replaced
|
|
|
|
|
replaceSucceeded = true
|
|
|
|
|
} else {
|
|
|
|
|
replaceSucceeded = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
NotificationCenter.default.post(
|
|
|
|
|
name: .droppedFileLoadProgress,
|
|
|
|
|
object: nil,
|
|
|
|
|
userInfo: [
|
|
|
|
|
"fraction": replaceSucceeded ? 1.0 : 0.0,
|
|
|
|
|
"fileName": replaceSucceeded ? "Reading file" : "Import failed",
|
|
|
|
|
"largeFileMode": largeFileMode
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
completion(replaceSucceeded, replaceSucceeded ? total : 0)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func clampedSelectionRange() -> NSRange {
|
|
|
|
|
clampedRange(selectedRange(), forTextLength: (string as NSString).length)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func clampedRange(_ range: NSRange, forTextLength length: Int) -> NSRange {
|
|
|
|
|
guard length >= 0 else { return NSRange(location: 0, length: 0) }
|
|
|
|
|
if range.location == NSNotFound {
|
|
|
|
|
return NSRange(location: length, length: 0)
|
|
|
|
|
}
|
|
|
|
|
let safeLocation = min(max(0, range.location), length)
|
|
|
|
|
let maxLen = max(0, length - safeLocation)
|
|
|
|
|
let safeLength = min(max(0, range.length), maxLen)
|
|
|
|
|
return NSRange(location: safeLocation, length: safeLength)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func readDroppedFileData(
|
|
|
|
|
at url: URL,
|
|
|
|
|
totalBytes: Int64,
|
|
|
|
|
progress: @escaping (Double) -> Void
|
|
|
|
|
) throws -> Data {
|
|
|
|
|
do {
|
|
|
|
|
let handle = try FileHandle(forReadingFrom: url)
|
|
|
|
|
defer { try? handle.close() }
|
|
|
|
|
|
|
|
|
|
var data = Data()
|
|
|
|
|
if totalBytes > 0, totalBytes <= Int64(Int.max) {
|
|
|
|
|
data.reserveCapacity(Int(totalBytes))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var loadedBytes: Int64 = 0
|
|
|
|
|
var lastReported: Double = -1
|
|
|
|
|
|
|
|
|
|
while true {
|
|
|
|
|
let chunk = try handle.read(upToCount: dropReadChunkSize) ?? Data()
|
|
|
|
|
if chunk.isEmpty { break }
|
|
|
|
|
data.append(chunk)
|
|
|
|
|
loadedBytes += Int64(chunk.count)
|
|
|
|
|
|
|
|
|
|
if totalBytes > 0 {
|
|
|
|
|
let fraction = min(1.0, Double(loadedBytes) / Double(totalBytes))
|
|
|
|
|
if fraction - lastReported >= 0.02 || fraction >= 1.0 {
|
|
|
|
|
lastReported = fraction
|
|
|
|
|
DispatchQueue.main.async {
|
|
|
|
|
progress(fraction)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if totalBytes > 0, lastReported < 1.0 {
|
|
|
|
|
DispatchQueue.main.async {
|
|
|
|
|
progress(1.0)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return data
|
|
|
|
|
} catch {
|
|
|
|
|
// Fallback path for URLs/FileHandle edge cases in sandboxed drag-drop.
|
|
|
|
|
let data = try Data(contentsOf: url, options: [.mappedIfSafe])
|
|
|
|
|
DispatchQueue.main.async {
|
|
|
|
|
progress(1.0)
|
|
|
|
|
}
|
|
|
|
|
return data
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func decodeDroppedFileText(_ data: Data, fileURL: URL) -> String {
|
|
|
|
|
let encodings: [String.Encoding] = [.utf8, .utf16, .utf16LittleEndian, .utf16BigEndian, .windowsCP1252, .isoLatin1]
|
|
|
|
|
for encoding in encodings {
|
|
|
|
|
if let decoded = String(data: data, encoding: encoding) {
|
2026-02-11 10:20:17 +00:00
|
|
|
return Self.sanitizePlainText(decoded)
|
2026-02-08 11:14:49 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if let fallback = try? String(contentsOf: fileURL, encoding: .utf8) {
|
2026-02-11 10:20:17 +00:00
|
|
|
return Self.sanitizePlainText(fallback)
|
2026-02-08 11:14:49 +00:00
|
|
|
}
|
2026-02-11 10:20:17 +00:00
|
|
|
return Self.sanitizePlainText(String(decoding: data, as: UTF8.self))
|
2026-02-08 11:14:49 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-14 13:24:01 +00:00
|
|
|
///MARK: - Typing Helpers
|
2026-02-06 18:59:53 +00:00
|
|
|
override func insertText(_ insertString: Any, replacementRange: NSRange) {
|
2026-02-09 10:21:50 +00:00
|
|
|
if !isApplyingInlineSuggestion {
|
|
|
|
|
clearInlineSuggestion()
|
|
|
|
|
}
|
2026-02-06 18:59:53 +00:00
|
|
|
guard let s = insertString as? String else {
|
|
|
|
|
super.insertText(insertString, replacementRange: replacementRange)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-02-11 10:20:17 +00:00
|
|
|
let sanitized = sanitizedPlainText(s)
|
|
|
|
|
|
|
|
|
|
// Ensure invisibles off after insertion
|
|
|
|
|
self.layoutManager?.showsInvisibleCharacters = false
|
|
|
|
|
self.layoutManager?.showsControlCharacters = false
|
2026-02-06 18:59:53 +00:00
|
|
|
|
|
|
|
|
// Auto-indent by copying leading whitespace
|
2026-02-11 10:20:17 +00:00
|
|
|
if sanitized == "\n" && autoIndentEnabled {
|
2026-02-06 18:59:53 +00:00
|
|
|
let ns = (string as NSString)
|
|
|
|
|
let sel = selectedRange()
|
|
|
|
|
let lineRange = ns.lineRange(for: NSRange(location: sel.location, length: 0))
|
|
|
|
|
let currentLine = ns.substring(with: NSRange(
|
|
|
|
|
location: lineRange.location,
|
|
|
|
|
length: max(0, sel.location - lineRange.location)
|
|
|
|
|
))
|
|
|
|
|
let indent = currentLine.prefix { $0 == " " || $0 == "\t" }
|
|
|
|
|
super.insertText("\n" + indent, replacementRange: replacementRange)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Auto-close common bracket/quote pairs
|
|
|
|
|
let pairs: [String: String] = ["(": ")", "[": "]", "{": "}", "\"": "\"", "'": "'"]
|
2026-02-11 10:20:17 +00:00
|
|
|
if autoCloseBracketsEnabled, let closing = pairs[sanitized] {
|
2026-02-06 18:59:53 +00:00
|
|
|
let sel = selectedRange()
|
2026-02-11 10:20:17 +00:00
|
|
|
super.insertText(sanitized + closing, replacementRange: replacementRange)
|
2026-02-06 18:59:53 +00:00
|
|
|
setSelectedRange(NSRange(location: sel.location + 1, length: 0))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-11 10:20:17 +00:00
|
|
|
super.insertText(sanitized, replacementRange: replacementRange)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static func sanitizePlainText(_ input: String) -> String {
|
2026-02-14 13:24:01 +00:00
|
|
|
// Reuse model-level sanitizer to keep all text paths consistent.
|
2026-02-11 10:20:17 +00:00
|
|
|
EditorTextSanitizer.sanitize(input)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-11 12:56:57 +00:00
|
|
|
private static func containsGlyphArtifacts(_ input: String) -> Bool {
|
|
|
|
|
for scalar in input.unicodeScalars {
|
|
|
|
|
let value = scalar.value
|
|
|
|
|
if value == 0x2581 || (0x2400...0x243F).contains(value) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-11 10:20:17 +00:00
|
|
|
private func sanitizedPlainText(_ input: String) -> String {
|
|
|
|
|
Self.sanitizePlainText(input)
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-08 00:06:06 +00:00
|
|
|
override func keyDown(with event: NSEvent) {
|
2026-02-09 10:21:50 +00:00
|
|
|
if event.keyCode == 48 { // Tab
|
|
|
|
|
if acceptInlineSuggestion() {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-08 00:08:36 +00:00
|
|
|
// Safety default: bypass Vim interception unless explicitly enabled.
|
|
|
|
|
if !UserDefaults.standard.bool(forKey: vimInterceptionDefaultsKey) {
|
|
|
|
|
super.keyDown(with: event)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-02-08 00:06:06 +00:00
|
|
|
|
|
|
|
|
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
|
|
|
|
|
if flags.contains(.command) || flags.contains(.option) || flags.contains(.control) {
|
|
|
|
|
super.keyDown(with: event)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let vimModeEnabled = UserDefaults.standard.bool(forKey: vimModeDefaultsKey)
|
|
|
|
|
guard vimModeEnabled else {
|
|
|
|
|
super.keyDown(with: event)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !isVimInsertMode {
|
|
|
|
|
let key = event.charactersIgnoringModifiers ?? ""
|
|
|
|
|
switch key {
|
|
|
|
|
case "h":
|
|
|
|
|
moveLeft(nil)
|
|
|
|
|
case "j":
|
|
|
|
|
moveDown(nil)
|
|
|
|
|
case "k":
|
|
|
|
|
moveUp(nil)
|
|
|
|
|
case "l":
|
|
|
|
|
moveRight(nil)
|
|
|
|
|
case "w":
|
|
|
|
|
moveWordForward(nil)
|
|
|
|
|
case "b":
|
|
|
|
|
moveWordBackward(nil)
|
|
|
|
|
case "0":
|
|
|
|
|
moveToBeginningOfLine(nil)
|
|
|
|
|
case "$":
|
|
|
|
|
moveToEndOfLine(nil)
|
|
|
|
|
case "x":
|
|
|
|
|
deleteForward(nil)
|
|
|
|
|
case "p":
|
|
|
|
|
paste(nil)
|
|
|
|
|
case "i":
|
|
|
|
|
isVimInsertMode = true
|
|
|
|
|
postVimModeState()
|
|
|
|
|
case "a":
|
|
|
|
|
moveRight(nil)
|
|
|
|
|
isVimInsertMode = true
|
|
|
|
|
postVimModeState()
|
|
|
|
|
case "\u{1B}":
|
|
|
|
|
break
|
|
|
|
|
default:
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Escape exits insert mode.
|
|
|
|
|
if event.keyCode == 53 || event.characters == "\u{1B}" {
|
|
|
|
|
isVimInsertMode = false
|
|
|
|
|
postVimModeState()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
super.keyDown(with: event)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-09 10:21:50 +00:00
|
|
|
override func insertTab(_ sender: Any?) {
|
|
|
|
|
if acceptInlineSuggestion() {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-02-20 15:53:22 +00:00
|
|
|
if let expansion = EmmetExpander.expansionIfPossible(
|
|
|
|
|
in: string,
|
|
|
|
|
cursorUTF16Location: selectedRange().location,
|
|
|
|
|
language: emmetLanguage
|
|
|
|
|
) {
|
|
|
|
|
textStorage?.beginEditing()
|
|
|
|
|
replaceCharacters(in: expansion.range, with: expansion.expansion)
|
|
|
|
|
textStorage?.endEditing()
|
|
|
|
|
let caretLocation = expansion.range.location + expansion.caretOffset
|
|
|
|
|
setSelectedRange(NSRange(location: caretLocation, length: 0))
|
|
|
|
|
didChangeText()
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-02-11 10:20:17 +00:00
|
|
|
// Keep Tab insertion deterministic and avoid platform-level invisible glyph rendering.
|
|
|
|
|
let insertion: String
|
|
|
|
|
if indentStyle == "tabs" {
|
|
|
|
|
insertion = "\t"
|
|
|
|
|
} else {
|
|
|
|
|
insertion = String(repeating: " ", count: max(1, indentWidth))
|
|
|
|
|
}
|
|
|
|
|
super.insertText(sanitizedPlainText(insertion), replacementRange: selectedRange())
|
|
|
|
|
forceDisableInvisibleGlyphRendering()
|
2026-02-09 10:21:50 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-06 18:59:53 +00:00
|
|
|
// Paste: capture insertion point and enforce caret position after paste across async updates.
|
|
|
|
|
override func paste(_ sender: Any?) {
|
2026-02-11 10:20:17 +00:00
|
|
|
let pasteboard = NSPasteboard.general
|
|
|
|
|
if let nsurls = pasteboard.readObjects(forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true]) as? [NSURL],
|
|
|
|
|
!nsurls.isEmpty {
|
|
|
|
|
let urls = nsurls.map { $0 as URL }
|
|
|
|
|
if urls.count == 1 {
|
|
|
|
|
NotificationCenter.default.post(name: .pastedFileURL, object: urls[0])
|
|
|
|
|
} else {
|
|
|
|
|
NotificationCenter.default.post(name: .pastedFileURL, object: urls)
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if let urls = fileURLsFromPasteboard(pasteboard), !urls.isEmpty {
|
|
|
|
|
if urls.count == 1 {
|
|
|
|
|
NotificationCenter.default.post(name: .pastedFileURL, object: urls[0])
|
|
|
|
|
} else {
|
|
|
|
|
NotificationCenter.default.post(name: .pastedFileURL, object: urls)
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-02-20 01:16:58 +00:00
|
|
|
// After paste, jump back to the first line.
|
|
|
|
|
pendingPasteCaretLocation = 0
|
2026-02-06 18:59:53 +00:00
|
|
|
|
2026-02-11 10:20:17 +00:00
|
|
|
if let raw = pasteboardPlainString(from: pasteboard), !raw.isEmpty {
|
|
|
|
|
if let pathURL = fileURLFromString(raw) {
|
|
|
|
|
NotificationCenter.default.post(name: .pastedFileURL, object: pathURL)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
let sanitized = sanitizedPlainText(raw)
|
|
|
|
|
isApplyingPaste = true
|
|
|
|
|
textStorage?.beginEditing()
|
|
|
|
|
replaceCharacters(in: selectedRange(), with: sanitized)
|
|
|
|
|
textStorage?.endEditing()
|
|
|
|
|
isApplyingPaste = false
|
|
|
|
|
|
|
|
|
|
// Ensure invisibles are off after paste
|
|
|
|
|
self.layoutManager?.showsInvisibleCharacters = false
|
|
|
|
|
self.layoutManager?.showsControlCharacters = false
|
|
|
|
|
|
|
|
|
|
NotificationCenter.default.post(name: .pastedText, object: sanitized)
|
|
|
|
|
didChangeText()
|
2026-02-06 18:59:53 +00:00
|
|
|
|
2026-02-11 10:20:17 +00:00
|
|
|
schedulePasteCaretEnforcement()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
isApplyingPaste = true
|
2026-02-06 18:59:53 +00:00
|
|
|
super.paste(sender)
|
2026-02-11 10:20:17 +00:00
|
|
|
DispatchQueue.main.async { [weak self] in
|
|
|
|
|
self?.isApplyingPaste = false
|
2026-02-06 18:59:53 +00:00
|
|
|
|
2026-02-11 10:20:17 +00:00
|
|
|
// Ensure invisibles are off after async paste
|
|
|
|
|
self?.layoutManager?.showsInvisibleCharacters = false
|
|
|
|
|
self?.layoutManager?.showsControlCharacters = false
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Enforce caret after paste (multiple ticks beats late selection changes)
|
|
|
|
|
schedulePasteCaretEnforcement()
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-11 10:20:17 +00:00
|
|
|
private func pasteboardPlainString(from pasteboard: NSPasteboard) -> String? {
|
|
|
|
|
if let raw = pasteboard.string(forType: .string), !raw.isEmpty {
|
|
|
|
|
return raw
|
|
|
|
|
}
|
|
|
|
|
if let strings = pasteboard.readObjects(forClasses: [NSString.self], options: nil) as? [NSString],
|
|
|
|
|
let first = strings.first,
|
|
|
|
|
first.length > 0 {
|
|
|
|
|
return first as String
|
|
|
|
|
}
|
|
|
|
|
if let rtf = pasteboard.data(forType: .rtf),
|
|
|
|
|
let attributed = try? NSAttributedString(
|
|
|
|
|
data: rtf,
|
|
|
|
|
options: [.documentType: NSAttributedString.DocumentType.rtf],
|
|
|
|
|
documentAttributes: nil
|
|
|
|
|
),
|
|
|
|
|
!attributed.string.isEmpty {
|
|
|
|
|
return attributed.string
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func fileURLsFromPasteboard(_ pasteboard: NSPasteboard) -> [URL]? {
|
|
|
|
|
if let fileURLString = pasteboard.string(forType: .fileURL),
|
|
|
|
|
let url = URL(string: fileURLString),
|
|
|
|
|
url.isFileURL,
|
|
|
|
|
FileManager.default.fileExists(atPath: url.path) {
|
|
|
|
|
return [url]
|
|
|
|
|
}
|
|
|
|
|
let filenamesType = NSPasteboard.PasteboardType("NSFilenamesPboardType")
|
|
|
|
|
if let list = pasteboard.propertyList(forType: filenamesType) as? [String] {
|
|
|
|
|
let urls = list.map { URL(fileURLWithPath: $0) }.filter { FileManager.default.fileExists(atPath: $0.path) }
|
|
|
|
|
if !urls.isEmpty { return urls }
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func fileURLFromString(_ text: String) -> URL? {
|
|
|
|
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
if let url = URL(string: trimmed), url.isFileURL, FileManager.default.fileExists(atPath: url.path) {
|
|
|
|
|
return url
|
|
|
|
|
}
|
|
|
|
|
// Plain paths (with spaces or ~)
|
|
|
|
|
let expanded = (trimmed as NSString).expandingTildeInPath
|
|
|
|
|
if FileManager.default.fileExists(atPath: expanded) {
|
|
|
|
|
return URL(fileURLWithPath: expanded)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-06 18:59:53 +00:00
|
|
|
override func didChangeText() {
|
|
|
|
|
super.didChangeText()
|
2026-02-11 12:56:57 +00:00
|
|
|
forceDisableInvisibleGlyphRendering(deep: true)
|
|
|
|
|
if let storage = textStorage {
|
|
|
|
|
let raw = storage.string
|
|
|
|
|
if Self.containsGlyphArtifacts(raw) {
|
|
|
|
|
let sanitized = Self.sanitizePlainText(raw)
|
|
|
|
|
let sel = selectedRange()
|
|
|
|
|
storage.beginEditing()
|
|
|
|
|
storage.replaceCharacters(in: NSRange(location: 0, length: (raw as NSString).length), with: sanitized)
|
|
|
|
|
storage.endEditing()
|
|
|
|
|
setSelectedRange(NSRange(location: min(sel.location, (sanitized as NSString).length), length: 0))
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-09 10:21:50 +00:00
|
|
|
if !isApplyingInlineSuggestion {
|
|
|
|
|
clearInlineSuggestion()
|
|
|
|
|
}
|
2026-02-06 18:59:53 +00:00
|
|
|
// Pasting triggers didChangeText; schedule enforcement again.
|
|
|
|
|
schedulePasteCaretEnforcement()
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-11 10:20:17 +00:00
|
|
|
private func forceDisableInvisibleGlyphRendering(deep: Bool = false) {
|
2026-02-11 14:31:24 +00:00
|
|
|
let defaults = UserDefaults.standard
|
|
|
|
|
if defaults.bool(forKey: "NSShowAllInvisibles") || defaults.bool(forKey: "NSShowControlCharacters") {
|
|
|
|
|
defaults.set(false, forKey: "NSShowAllInvisibles")
|
|
|
|
|
defaults.set(false, forKey: "NSShowControlCharacters")
|
|
|
|
|
}
|
2026-02-11 10:20:17 +00:00
|
|
|
layoutManager?.showsInvisibleCharacters = false
|
|
|
|
|
layoutManager?.showsControlCharacters = false
|
|
|
|
|
|
|
|
|
|
guard deep, !didApplyDeepInvisibleDisable else { return }
|
|
|
|
|
didApplyDeepInvisibleDisable = true
|
|
|
|
|
|
|
|
|
|
let selectors = [
|
|
|
|
|
"setShowsInvisibleCharacters:",
|
|
|
|
|
"setShowsControlCharacters:",
|
|
|
|
|
"setDisplaysInvisibleCharacters:",
|
|
|
|
|
"setDisplaysControlCharacters:"
|
|
|
|
|
]
|
|
|
|
|
for selectorName in selectors {
|
|
|
|
|
let selector = NSSelectorFromString(selectorName)
|
|
|
|
|
let value = NSNumber(value: false)
|
|
|
|
|
if responds(to: selector) {
|
|
|
|
|
_ = perform(selector, with: value)
|
|
|
|
|
}
|
|
|
|
|
if let lm = layoutManager, lm.responds(to: selector) {
|
|
|
|
|
_ = lm.perform(selector, with: value)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if #available(macOS 12.0, *) {
|
|
|
|
|
if let tlm = value(forKey: "textLayoutManager") as? NSObject {
|
|
|
|
|
for selectorName in selectors {
|
|
|
|
|
let selector = NSSelectorFromString(selectorName)
|
|
|
|
|
if tlm.responds(to: selector) {
|
|
|
|
|
_ = tlm.perform(selector, with: NSNumber(value: false))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-11 14:31:24 +00:00
|
|
|
private func configureActivityObservers() {
|
|
|
|
|
guard activityObservers.isEmpty else { return }
|
|
|
|
|
let center = NotificationCenter.default
|
|
|
|
|
let names: [Notification.Name] = [
|
|
|
|
|
NSApplication.didBecomeActiveNotification,
|
|
|
|
|
NSApplication.didResignActiveNotification,
|
|
|
|
|
NSWindow.didBecomeKeyNotification,
|
|
|
|
|
NSWindow.didResignKeyNotification
|
|
|
|
|
]
|
|
|
|
|
for name in names {
|
|
|
|
|
let token = center.addObserver(forName: name, object: nil, queue: .main) { [weak self] notif in
|
|
|
|
|
guard let self else { return }
|
|
|
|
|
if let targetWindow = notif.object as? NSWindow, targetWindow != self.window {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
self.forceDisableInvisibleGlyphRendering(deep: true)
|
|
|
|
|
}
|
|
|
|
|
activityObservers.append(token)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-09 10:21:50 +00:00
|
|
|
func showInlineSuggestion(_ suggestion: String, at location: Int) {
|
|
|
|
|
guard !suggestion.isEmpty else {
|
|
|
|
|
clearInlineSuggestion()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
inlineSuggestion = suggestion
|
|
|
|
|
inlineSuggestionLocation = location
|
|
|
|
|
if inlineSuggestionView == nil {
|
|
|
|
|
let label = NSTextField(labelWithString: suggestion)
|
|
|
|
|
label.isBezeled = false
|
|
|
|
|
label.isEditable = false
|
|
|
|
|
label.isSelectable = false
|
|
|
|
|
label.drawsBackground = false
|
|
|
|
|
label.textColor = NSColor.secondaryLabelColor.withAlphaComponent(0.6)
|
|
|
|
|
label.font = font ?? NSFont.monospacedSystemFont(ofSize: 13, weight: .regular)
|
|
|
|
|
label.lineBreakMode = .byClipping
|
|
|
|
|
label.maximumNumberOfLines = 1
|
|
|
|
|
inlineSuggestionView = label
|
|
|
|
|
addSubview(label)
|
|
|
|
|
} else {
|
|
|
|
|
inlineSuggestionView?.stringValue = suggestion
|
|
|
|
|
inlineSuggestionView?.font = font ?? NSFont.monospacedSystemFont(ofSize: 13, weight: .regular)
|
|
|
|
|
}
|
|
|
|
|
updateInlineSuggestionPosition()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func clearInlineSuggestion() {
|
|
|
|
|
inlineSuggestion = nil
|
|
|
|
|
inlineSuggestionLocation = nil
|
|
|
|
|
inlineSuggestionView?.removeFromSuperview()
|
|
|
|
|
inlineSuggestionView = nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func acceptInlineSuggestion() -> Bool {
|
|
|
|
|
guard let suggestion = inlineSuggestion,
|
|
|
|
|
let loc = inlineSuggestionLocation else { return false }
|
2026-02-11 10:20:17 +00:00
|
|
|
let sanitizedSuggestion = sanitizedPlainText(suggestion)
|
|
|
|
|
guard !sanitizedSuggestion.isEmpty else {
|
|
|
|
|
clearInlineSuggestion()
|
|
|
|
|
return false
|
|
|
|
|
}
|
2026-02-09 10:21:50 +00:00
|
|
|
let sel = selectedRange()
|
|
|
|
|
guard sel.length == 0, sel.location == loc else {
|
|
|
|
|
clearInlineSuggestion()
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
isApplyingInlineSuggestion = true
|
2026-02-11 10:20:17 +00:00
|
|
|
textStorage?.replaceCharacters(in: NSRange(location: loc, length: 0), with: sanitizedSuggestion)
|
|
|
|
|
setSelectedRange(NSRange(location: loc + (sanitizedSuggestion as NSString).length, length: 0))
|
2026-02-09 10:21:50 +00:00
|
|
|
isApplyingInlineSuggestion = false
|
|
|
|
|
recentlyAcceptedInlineSuggestion = true
|
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
|
|
|
|
|
self?.recentlyAcceptedInlineSuggestion = false
|
|
|
|
|
}
|
|
|
|
|
clearInlineSuggestion()
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func updateInlineSuggestionPosition() {
|
|
|
|
|
guard let suggestion = inlineSuggestion,
|
|
|
|
|
let loc = inlineSuggestionLocation,
|
|
|
|
|
let label = inlineSuggestionView,
|
|
|
|
|
let window else { return }
|
|
|
|
|
let sel = selectedRange()
|
|
|
|
|
if sel.location != loc || sel.length != 0 {
|
|
|
|
|
clearInlineSuggestion()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
let rectInScreen = firstRect(forCharacterRange: NSRange(location: loc, length: 0), actualRange: nil)
|
|
|
|
|
let rectInWindow = window.convertFromScreen(rectInScreen)
|
|
|
|
|
let rectInView = convert(rectInWindow, from: nil)
|
|
|
|
|
label.stringValue = suggestion
|
|
|
|
|
label.sizeToFit()
|
|
|
|
|
label.frame.origin = NSPoint(x: rectInView.origin.x, y: rectInView.origin.y)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-06 18:59:53 +00:00
|
|
|
// Re-apply the desired caret position over multiple runloop ticks to beat late layout/async work.
|
|
|
|
|
private func schedulePasteCaretEnforcement() {
|
|
|
|
|
guard pendingPasteCaretLocation != nil else { return }
|
|
|
|
|
|
|
|
|
|
// Cancel previously queued enforcement to avoid spamming
|
|
|
|
|
NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(applyPendingPasteCaret), object: nil)
|
|
|
|
|
|
|
|
|
|
// Run next turn
|
|
|
|
|
perform(#selector(applyPendingPasteCaret), with: nil, afterDelay: 0)
|
|
|
|
|
|
|
|
|
|
// Run again next runloop tick (beats "snap back" from late async work)
|
|
|
|
|
DispatchQueue.main.async { [weak self] in
|
|
|
|
|
self?.applyPendingPasteCaret()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Run once more with a tiny delay (beats slower async highlight passes)
|
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) { [weak self] in
|
|
|
|
|
self?.applyPendingPasteCaret()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 16:47:47 +00:00
|
|
|
private func cancelPendingPasteCaretEnforcement() {
|
|
|
|
|
pendingPasteCaretLocation = nil
|
|
|
|
|
NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(applyPendingPasteCaret), object: nil)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-06 18:59:53 +00:00
|
|
|
@objc private func applyPendingPasteCaret() {
|
|
|
|
|
guard let desired = pendingPasteCaretLocation else { return }
|
|
|
|
|
|
|
|
|
|
let length = (string as NSString).length
|
|
|
|
|
let loc = min(max(0, desired), length)
|
|
|
|
|
let range = NSRange(location: loc, length: 0)
|
|
|
|
|
|
|
|
|
|
// Set caret and keep it visible
|
|
|
|
|
setSelectedRange(range)
|
|
|
|
|
|
|
|
|
|
if let container = textContainer {
|
|
|
|
|
layoutManager?.ensureLayout(for: container)
|
|
|
|
|
}
|
|
|
|
|
scrollRangeToVisible(range)
|
|
|
|
|
|
|
|
|
|
// Important: clear only after we've enforced at least once.
|
|
|
|
|
// The delayed calls will no-op once this is nil.
|
|
|
|
|
pendingPasteCaretLocation = nil
|
|
|
|
|
}
|
2026-02-08 00:06:06 +00:00
|
|
|
|
|
|
|
|
private func postVimModeState() {
|
|
|
|
|
NotificationCenter.default.post(
|
|
|
|
|
name: .vimModeStateDidChange,
|
|
|
|
|
object: nil,
|
|
|
|
|
userInfo: ["insertMode": isVimInsertMode]
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 08:44:24 +00:00
|
|
|
private func insertBracketHelperToken(_ token: String) {
|
|
|
|
|
guard isEditable else { return }
|
|
|
|
|
clearInlineSuggestion()
|
|
|
|
|
let selection = selectedRange()
|
|
|
|
|
|
|
|
|
|
let pairMap: [String: (String, String)] = [
|
|
|
|
|
"()": ("(", ")"),
|
|
|
|
|
"{}": ("{", "}"),
|
|
|
|
|
"[]": ("[", "]"),
|
|
|
|
|
"\"\"": ("\"", "\""),
|
|
|
|
|
"''": ("'", "'")
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
if let pair = pairMap[token] {
|
|
|
|
|
let insertion = pair.0 + pair.1
|
|
|
|
|
textStorage?.replaceCharacters(in: selection, with: insertion)
|
|
|
|
|
setSelectedRange(NSRange(location: selection.location + (pair.0 as NSString).length, length: 0))
|
|
|
|
|
didChangeText()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
textStorage?.replaceCharacters(in: selection, with: token)
|
|
|
|
|
setSelectedRange(NSRange(location: selection.location + (token as NSString).length, length: 0))
|
|
|
|
|
didChangeText()
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-11 10:20:17 +00:00
|
|
|
private func scalarName(for value: UInt32) -> String {
|
|
|
|
|
switch value {
|
|
|
|
|
case 0x20: return "SPACE"
|
|
|
|
|
case 0x09: return "TAB"
|
|
|
|
|
case 0x0A: return "LF"
|
|
|
|
|
case 0x0D: return "CR"
|
|
|
|
|
case 0x2581: return "LOWER_ONE_EIGHTH_BLOCK"
|
|
|
|
|
default: return "U+\(String(format: "%04X", value))"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func inspectWhitespaceScalarsAtCaret() {
|
|
|
|
|
let ns = string as NSString
|
|
|
|
|
let length = ns.length
|
|
|
|
|
let caret = min(max(selectedRange().location, 0), max(length - 1, 0))
|
|
|
|
|
let lineRange = ns.lineRange(for: NSRange(location: caret, length: 0))
|
|
|
|
|
let line = ns.substring(with: lineRange)
|
|
|
|
|
let lineOneBased = ns.substring(to: lineRange.location).reduce(1) { $1 == "\n" ? $0 + 1 : $0 }
|
|
|
|
|
var counts: [UInt32: Int] = [:]
|
|
|
|
|
for scalar in line.unicodeScalars {
|
|
|
|
|
let value = scalar.value
|
|
|
|
|
let isWhitespace = scalar.properties.generalCategory == .spaceSeparator || value == 0x20 || value == 0x09
|
|
|
|
|
let isLineBreak = value == 0x0A || value == 0x0D
|
|
|
|
|
let isControlPicture = (0x2400...0x243F).contains(value)
|
|
|
|
|
let isLowBlock = value == 0x2581
|
|
|
|
|
if isWhitespace || isLineBreak || isControlPicture || isLowBlock {
|
|
|
|
|
counts[value, default: 0] += 1
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let header = "Line \(lineOneBased) at UTF16@\(selectedRange().location), whitespace scalars:"
|
|
|
|
|
let body: String
|
|
|
|
|
if counts.isEmpty {
|
|
|
|
|
body = "none detected"
|
|
|
|
|
} else {
|
|
|
|
|
let rows = counts.keys.sorted().map { key in
|
|
|
|
|
"\(scalarName(for: key)) x\(counts[key] ?? 0)"
|
|
|
|
|
}
|
|
|
|
|
body = rows.joined(separator: ", ")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let windowNumber = window?.windowNumber ?? -1
|
|
|
|
|
NotificationCenter.default.post(
|
|
|
|
|
name: .whitespaceScalarInspectionResult,
|
|
|
|
|
object: nil,
|
|
|
|
|
userInfo: [
|
|
|
|
|
EditorCommandUserInfo.windowNumber: windowNumber,
|
|
|
|
|
EditorCommandUserInfo.inspectionMessage: "\(header)\n\(body)"
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 00:06:06 +00:00
|
|
|
private func configureVimMode() {
|
2026-02-08 00:36:06 +00:00
|
|
|
// Vim enabled starts in NORMAL; disabled uses regular insert typing.
|
|
|
|
|
isVimInsertMode = !UserDefaults.standard.bool(forKey: vimModeDefaultsKey)
|
2026-02-08 00:06:06 +00:00
|
|
|
postVimModeState()
|
|
|
|
|
|
|
|
|
|
let observer = NotificationCenter.default.addObserver(
|
|
|
|
|
forName: .toggleVimModeRequested,
|
|
|
|
|
object: nil,
|
|
|
|
|
queue: .main
|
|
|
|
|
) { [weak self] _ in
|
|
|
|
|
guard let self else { return }
|
2026-02-08 00:36:06 +00:00
|
|
|
// Enter NORMAL when Vim mode is enabled; INSERT when disabled.
|
|
|
|
|
let enabled = UserDefaults.standard.bool(forKey: self.vimModeDefaultsKey)
|
|
|
|
|
self.isVimInsertMode = !enabled
|
2026-02-08 00:06:06 +00:00
|
|
|
self.postVimModeState()
|
|
|
|
|
}
|
|
|
|
|
vimObservers.append(observer)
|
2026-02-11 10:20:17 +00:00
|
|
|
|
|
|
|
|
let inspectorObserver = NotificationCenter.default.addObserver(
|
|
|
|
|
forName: .inspectWhitespaceScalarsRequested,
|
|
|
|
|
object: nil,
|
|
|
|
|
queue: .main
|
|
|
|
|
) { [weak self] notif in
|
|
|
|
|
guard let self else { return }
|
|
|
|
|
if let target = notif.userInfo?[EditorCommandUserInfo.windowNumber] as? Int,
|
|
|
|
|
let own = self.window?.windowNumber,
|
|
|
|
|
target != own {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
self.inspectWhitespaceScalarsAtCaret()
|
|
|
|
|
}
|
|
|
|
|
vimObservers.append(inspectorObserver)
|
2026-02-19 08:44:24 +00:00
|
|
|
|
|
|
|
|
let bracketHelperObserver = NotificationCenter.default.addObserver(
|
|
|
|
|
forName: .insertBracketHelperTokenRequested,
|
|
|
|
|
object: nil,
|
|
|
|
|
queue: .main
|
|
|
|
|
) { [weak self] notif in
|
|
|
|
|
guard let self else { return }
|
|
|
|
|
if let target = notif.userInfo?[EditorCommandUserInfo.windowNumber] as? Int,
|
|
|
|
|
let own = self.window?.windowNumber,
|
|
|
|
|
target != own {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
guard let token = notif.userInfo?[EditorCommandUserInfo.bracketToken] as? String else { return }
|
|
|
|
|
self.insertBracketHelperToken(token)
|
|
|
|
|
}
|
|
|
|
|
vimObservers.append(bracketHelperObserver)
|
2026-02-11 10:20:17 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func trimTrailingWhitespaceIfEnabled() {
|
|
|
|
|
let enabled = UserDefaults.standard.bool(forKey: "SettingsTrimTrailingWhitespace")
|
|
|
|
|
guard enabled else { return }
|
|
|
|
|
let original = self.string
|
|
|
|
|
let lines = original.components(separatedBy: .newlines)
|
|
|
|
|
var changed = false
|
|
|
|
|
let trimmedLines = lines.map { line -> String in
|
|
|
|
|
let trimmed = line.replacingOccurrences(of: #"[\t\x20]+$"#, with: "", options: .regularExpression)
|
|
|
|
|
if trimmed != line { changed = true }
|
|
|
|
|
return trimmed
|
|
|
|
|
}
|
|
|
|
|
guard changed else { return }
|
|
|
|
|
let newString = trimmedLines.joined(separator: "\n")
|
|
|
|
|
let oldSelected = self.selectedRange()
|
|
|
|
|
self.textStorage?.beginEditing()
|
|
|
|
|
self.string = newString
|
|
|
|
|
self.textStorage?.endEditing()
|
|
|
|
|
let newLoc = min(oldSelected.location, (newString as NSString).length)
|
|
|
|
|
self.setSelectedRange(NSRange(location: newLoc, length: 0))
|
|
|
|
|
self.didChangeText()
|
2026-02-08 00:06:06 +00:00
|
|
|
}
|
2026-02-11 10:20:17 +00:00
|
|
|
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NSViewRepresentable wrapper around NSTextView to integrate with SwiftUI.
|
|
|
|
|
struct CustomTextEditor: NSViewRepresentable {
|
|
|
|
|
@Binding var text: String
|
|
|
|
|
let language: String
|
|
|
|
|
let colorScheme: ColorScheme
|
|
|
|
|
let fontSize: CGFloat
|
|
|
|
|
@Binding var isLineWrapEnabled: Bool
|
2026-02-08 11:14:49 +00:00
|
|
|
let isLargeFileMode: Bool
|
2026-02-06 18:59:53 +00:00
|
|
|
let translucentBackgroundEnabled: Bool
|
2026-02-19 14:29:53 +00:00
|
|
|
let showKeyboardAccessoryBar: Bool
|
2026-02-11 10:20:17 +00:00
|
|
|
let showLineNumbers: Bool
|
|
|
|
|
let showInvisibleCharacters: Bool
|
|
|
|
|
let highlightCurrentLine: Bool
|
2026-02-12 22:20:39 +00:00
|
|
|
let highlightMatchingBrackets: Bool
|
|
|
|
|
let showScopeGuides: Bool
|
|
|
|
|
let highlightScopeBackground: Bool
|
2026-02-11 10:20:17 +00:00
|
|
|
let indentStyle: String
|
|
|
|
|
let indentWidth: Int
|
|
|
|
|
let autoIndentEnabled: Bool
|
|
|
|
|
let autoCloseBracketsEnabled: Bool
|
|
|
|
|
let highlightRefreshToken: Int
|
|
|
|
|
|
|
|
|
|
private var fontName: String {
|
|
|
|
|
UserDefaults.standard.string(forKey: "SettingsEditorFontName") ?? ""
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 22:20:39 +00:00
|
|
|
private var useSystemFont: Bool {
|
|
|
|
|
UserDefaults.standard.bool(forKey: "SettingsUseSystemFont")
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-11 10:20:17 +00:00
|
|
|
private var lineHeightMultiple: CGFloat {
|
|
|
|
|
let stored = UserDefaults.standard.double(forKey: "SettingsLineHeight")
|
|
|
|
|
return CGFloat(stored > 0 ? stored : 1.0)
|
|
|
|
|
}
|
2026-02-06 18:59:53 +00:00
|
|
|
|
|
|
|
|
// Toggle soft-wrapping by adjusting text container sizing and scroller visibility.
|
|
|
|
|
private func applyWrapMode(isWrapped: Bool, textView: NSTextView, scrollView: NSScrollView) {
|
|
|
|
|
if isWrapped {
|
|
|
|
|
// Wrap: track the text view width, no horizontal scrolling
|
|
|
|
|
textView.isHorizontallyResizable = false
|
|
|
|
|
textView.textContainer?.widthTracksTextView = true
|
|
|
|
|
textView.textContainer?.heightTracksTextView = false
|
|
|
|
|
scrollView.hasHorizontalScroller = false
|
|
|
|
|
// Ensure the container width matches the visible content width right now
|
|
|
|
|
let contentWidth = scrollView.contentSize.width
|
|
|
|
|
let width = contentWidth > 0 ? contentWidth : scrollView.frame.size.width
|
|
|
|
|
textView.textContainer?.containerSize = NSSize(width: width, height: CGFloat.greatestFiniteMagnitude)
|
|
|
|
|
} else {
|
|
|
|
|
// No wrap: allow horizontal expansion and horizontal scrolling
|
|
|
|
|
textView.isHorizontallyResizable = true
|
|
|
|
|
textView.textContainer?.widthTracksTextView = false
|
|
|
|
|
textView.textContainer?.heightTracksTextView = false
|
|
|
|
|
scrollView.hasHorizontalScroller = true
|
|
|
|
|
textView.textContainer?.containerSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Force layout update so the change takes effect immediately
|
|
|
|
|
if let container = textView.textContainer, let lm = textView.layoutManager {
|
|
|
|
|
lm.invalidateLayout(forCharacterRange: NSRange(location: 0, length: (textView.string as NSString).length), actualCharacterRange: nil)
|
|
|
|
|
lm.ensureLayout(for: container)
|
|
|
|
|
}
|
|
|
|
|
scrollView.reflectScrolledClipView(scrollView.contentView)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-11 10:20:17 +00:00
|
|
|
private func resolvedFont() -> NSFont {
|
2026-02-12 22:20:39 +00:00
|
|
|
if useSystemFont {
|
|
|
|
|
return NSFont.systemFont(ofSize: fontSize)
|
|
|
|
|
}
|
2026-02-11 10:20:17 +00:00
|
|
|
if let named = NSFont(name: fontName, size: fontSize) {
|
|
|
|
|
return named
|
|
|
|
|
}
|
|
|
|
|
return NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func paragraphStyle() -> NSParagraphStyle {
|
|
|
|
|
let style = NSMutableParagraphStyle()
|
|
|
|
|
style.lineHeightMultiple = max(0.9, lineHeightMultiple)
|
|
|
|
|
return style
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-11 14:31:24 +00:00
|
|
|
private func effectiveBaseTextColor() -> NSColor {
|
|
|
|
|
if colorScheme == .light && !translucentBackgroundEnabled {
|
|
|
|
|
return NSColor.textColor
|
|
|
|
|
}
|
|
|
|
|
let theme = currentEditorTheme(colorScheme: colorScheme)
|
|
|
|
|
return NSColor(theme.text)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-11 10:20:17 +00:00
|
|
|
private func applyInvisibleCharacterPreference(_ textView: NSTextView) {
|
|
|
|
|
// Hard-disable invisible/control glyph rendering in editor text.
|
2026-02-11 14:31:24 +00:00
|
|
|
let defaults = UserDefaults.standard
|
|
|
|
|
defaults.set(false, forKey: "NSShowAllInvisibles")
|
|
|
|
|
defaults.set(false, forKey: "NSShowControlCharacters")
|
|
|
|
|
defaults.set(false, forKey: "SettingsShowInvisibleCharacters")
|
2026-02-11 10:20:17 +00:00
|
|
|
textView.layoutManager?.showsInvisibleCharacters = false
|
|
|
|
|
textView.layoutManager?.showsControlCharacters = false
|
|
|
|
|
let value = NSNumber(value: false)
|
|
|
|
|
let selectors = [
|
|
|
|
|
"setShowsInvisibleCharacters:",
|
|
|
|
|
"setShowsControlCharacters:",
|
|
|
|
|
"setDisplaysInvisibleCharacters:",
|
|
|
|
|
"setDisplaysControlCharacters:"
|
|
|
|
|
]
|
|
|
|
|
for selectorName in selectors {
|
|
|
|
|
let selector = NSSelectorFromString(selectorName)
|
|
|
|
|
if textView.responds(to: selector) {
|
|
|
|
|
let enabled = selectorName.contains("ControlCharacters") ? NSNumber(value: false) : value
|
|
|
|
|
textView.perform(selector, with: enabled)
|
|
|
|
|
}
|
|
|
|
|
if let layoutManager = textView.layoutManager, layoutManager.responds(to: selector) {
|
|
|
|
|
let enabled = selectorName.contains("ControlCharacters") ? NSNumber(value: false) : value
|
|
|
|
|
_ = layoutManager.perform(selector, with: enabled)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if #available(macOS 12.0, *) {
|
|
|
|
|
if let tlm = textView.value(forKey: "textLayoutManager") as? NSObject {
|
|
|
|
|
for selectorName in selectors {
|
|
|
|
|
let selector = NSSelectorFromString(selectorName)
|
|
|
|
|
if tlm.responds(to: selector) {
|
|
|
|
|
let enabled = selectorName.contains("ControlCharacters") ? NSNumber(value: false) : value
|
|
|
|
|
_ = tlm.perform(selector, with: enabled)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-06 18:59:53 +00:00
|
|
|
func makeNSView(context: Context) -> NSScrollView {
|
|
|
|
|
// Build scroll view and text view
|
|
|
|
|
let scrollView = NSScrollView()
|
|
|
|
|
scrollView.drawsBackground = false
|
|
|
|
|
scrollView.autohidesScrollers = true
|
|
|
|
|
scrollView.hasVerticalScroller = true
|
|
|
|
|
scrollView.contentView.postsBoundsChangedNotifications = true
|
|
|
|
|
|
|
|
|
|
let textView = AcceptingTextView(frame: .zero)
|
2026-02-11 10:20:17 +00:00
|
|
|
textView.identifier = NSUserInterfaceItemIdentifier("NeonEditorTextView")
|
2026-02-06 18:59:53 +00:00
|
|
|
// Configure editing behavior and visuals
|
|
|
|
|
textView.isEditable = true
|
|
|
|
|
textView.isRichText = false
|
|
|
|
|
textView.usesFindBar = true
|
2026-02-11 10:20:17 +00:00
|
|
|
textView.usesInspectorBar = false
|
|
|
|
|
textView.usesFontPanel = false
|
2026-02-06 18:59:53 +00:00
|
|
|
textView.isVerticallyResizable = true
|
|
|
|
|
textView.isHorizontallyResizable = false
|
2026-02-12 22:20:39 +00:00
|
|
|
textView.font = resolvedFont()
|
2026-02-06 18:59:53 +00:00
|
|
|
|
2026-02-11 10:20:17 +00:00
|
|
|
// Apply visibility preference from Settings (off by default).
|
|
|
|
|
applyInvisibleCharacterPreference(textView)
|
|
|
|
|
textView.textStorage?.beginEditing()
|
|
|
|
|
if let storage = textView.textStorage {
|
|
|
|
|
let fullRange = NSRange(location: 0, length: storage.length)
|
|
|
|
|
storage.removeAttribute(.backgroundColor, range: fullRange)
|
|
|
|
|
storage.removeAttribute(.underlineStyle, range: fullRange)
|
|
|
|
|
storage.removeAttribute(.strikethroughStyle, range: fullRange)
|
|
|
|
|
}
|
|
|
|
|
textView.textStorage?.endEditing()
|
|
|
|
|
|
|
|
|
|
let theme = currentEditorTheme(colorScheme: colorScheme)
|
2026-02-06 18:59:53 +00:00
|
|
|
if translucentBackgroundEnabled {
|
|
|
|
|
textView.backgroundColor = .clear
|
|
|
|
|
textView.drawsBackground = false
|
|
|
|
|
} else {
|
2026-02-11 10:20:17 +00:00
|
|
|
let bg = (colorScheme == .light) ? NSColor.textBackgroundColor : NSColor(theme.background)
|
|
|
|
|
textView.backgroundColor = bg
|
2026-02-06 18:59:53 +00:00
|
|
|
textView.drawsBackground = true
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-11 10:20:17 +00:00
|
|
|
// Use NSRulerView line numbering (v0.4.4-beta behavior).
|
2026-02-06 18:59:53 +00:00
|
|
|
textView.minSize = NSSize(width: 0, height: 0)
|
|
|
|
|
textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
|
|
|
|
|
textView.isSelectable = true
|
|
|
|
|
textView.allowsUndo = true
|
2026-02-11 14:31:24 +00:00
|
|
|
let baseTextColor = effectiveBaseTextColor()
|
2026-02-11 10:20:17 +00:00
|
|
|
textView.textColor = baseTextColor
|
|
|
|
|
textView.insertionPointColor = (colorScheme == .light && !translucentBackgroundEnabled) ? NSColor.labelColor : NSColor(theme.cursor)
|
|
|
|
|
textView.selectedTextAttributes = [
|
|
|
|
|
.backgroundColor: NSColor(theme.selection)
|
|
|
|
|
]
|
|
|
|
|
textView.usesInspectorBar = false
|
|
|
|
|
textView.usesFontPanel = false
|
2026-02-08 11:57:41 +00:00
|
|
|
textView.layoutManager?.allowsNonContiguousLayout = true
|
2026-02-16 11:09:54 +00:00
|
|
|
// Keep a fixed left gutter gap so content never visually collides with line numbers.
|
|
|
|
|
textView.textContainerInset = NSSize(width: 6, height: 8)
|
|
|
|
|
textView.textContainer?.lineFragmentPadding = 4
|
2026-02-06 18:59:53 +00:00
|
|
|
|
2026-02-11 10:20:17 +00:00
|
|
|
// Keep horizontal rulers disabled; vertical ruler is dedicated to line numbers.
|
|
|
|
|
textView.usesRuler = true
|
|
|
|
|
textView.isRulerVisible = showLineNumbers
|
|
|
|
|
scrollView.hasHorizontalRuler = false
|
|
|
|
|
scrollView.horizontalRulerView = nil
|
|
|
|
|
scrollView.hasVerticalRuler = showLineNumbers
|
|
|
|
|
scrollView.rulersVisible = showLineNumbers
|
|
|
|
|
scrollView.verticalRulerView = showLineNumbers ? LineNumberRulerView(textView: textView) : nil
|
|
|
|
|
|
|
|
|
|
applyInvisibleCharacterPreference(textView)
|
|
|
|
|
textView.autoIndentEnabled = autoIndentEnabled
|
|
|
|
|
textView.autoCloseBracketsEnabled = autoCloseBracketsEnabled
|
2026-02-20 15:53:22 +00:00
|
|
|
textView.emmetLanguage = language
|
2026-02-11 10:20:17 +00:00
|
|
|
textView.indentStyle = indentStyle
|
|
|
|
|
textView.indentWidth = indentWidth
|
|
|
|
|
textView.highlightCurrentLine = highlightCurrentLine
|
|
|
|
|
|
2026-02-06 18:59:53 +00:00
|
|
|
// Disable smart substitutions/detections that can interfere with selection when recoloring
|
|
|
|
|
textView.isAutomaticTextCompletionEnabled = false
|
|
|
|
|
textView.isAutomaticQuoteSubstitutionEnabled = false
|
|
|
|
|
textView.isAutomaticDashSubstitutionEnabled = false
|
|
|
|
|
textView.isAutomaticDataDetectionEnabled = false
|
|
|
|
|
textView.isAutomaticLinkDetectionEnabled = false
|
|
|
|
|
textView.isGrammarCheckingEnabled = false
|
|
|
|
|
textView.isContinuousSpellCheckingEnabled = false
|
|
|
|
|
textView.smartInsertDeleteEnabled = false
|
|
|
|
|
|
|
|
|
|
textView.registerForDraggedTypes([.fileURL, .URL])
|
|
|
|
|
|
|
|
|
|
// Embed the text view in the scroll view
|
|
|
|
|
scrollView.documentView = textView
|
|
|
|
|
|
|
|
|
|
// Configure the text view delegate
|
|
|
|
|
textView.delegate = context.coordinator
|
|
|
|
|
|
|
|
|
|
// Apply wrapping and seed initial content
|
2026-02-08 11:14:49 +00:00
|
|
|
applyWrapMode(isWrapped: isLineWrapEnabled && !isLargeFileMode, textView: textView, scrollView: scrollView)
|
2026-02-06 18:59:53 +00:00
|
|
|
|
2026-02-11 10:20:17 +00:00
|
|
|
// Seed initial text (strip control pictures when invisibles are hidden)
|
|
|
|
|
let seeded = AcceptingTextView.sanitizePlainText(text)
|
|
|
|
|
textView.string = seeded
|
|
|
|
|
if seeded != text {
|
|
|
|
|
// Keep binding clean of control-picture glyphs.
|
|
|
|
|
DispatchQueue.main.async {
|
|
|
|
|
if self.text != seeded {
|
|
|
|
|
self.text = seeded
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-12 22:20:39 +00:00
|
|
|
context.coordinator.scheduleHighlightIfNeeded(currentText: text, immediate: true)
|
2026-02-06 18:59:53 +00:00
|
|
|
|
|
|
|
|
// Keep container width in sync when the scroll view resizes
|
|
|
|
|
NotificationCenter.default.addObserver(forName: NSView.boundsDidChangeNotification, object: scrollView.contentView, queue: .main) { [weak textView, weak scrollView] _ in
|
|
|
|
|
guard let tv = textView, let sv = scrollView else { return }
|
|
|
|
|
if tv.textContainer?.widthTracksTextView == true {
|
|
|
|
|
tv.textContainer?.containerSize.width = sv.contentSize.width
|
|
|
|
|
if let container = tv.textContainer {
|
|
|
|
|
tv.layoutManager?.ensureLayout(for: container)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
context.coordinator.textView = textView
|
|
|
|
|
return scrollView
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Keep NSTextView in sync with SwiftUI state and schedule highlighting when needed.
|
|
|
|
|
func updateNSView(_ nsView: NSScrollView, context: Context) {
|
|
|
|
|
if let textView = nsView.documentView as? NSTextView {
|
2026-02-08 00:06:06 +00:00
|
|
|
textView.isEditable = true
|
|
|
|
|
textView.isSelectable = true
|
2026-02-09 10:21:50 +00:00
|
|
|
let acceptingView = textView as? AcceptingTextView
|
|
|
|
|
let isDropApplyInFlight = acceptingView?.isApplyingDroppedContent ?? false
|
2026-02-11 10:20:17 +00:00
|
|
|
|
|
|
|
|
// Sanitize and avoid publishing binding during update
|
|
|
|
|
let target = AcceptingTextView.sanitizePlainText(text)
|
|
|
|
|
if textView.string != target {
|
|
|
|
|
textView.string = target
|
2026-02-11 11:07:07 +00:00
|
|
|
context.coordinator.invalidateHighlightCache()
|
2026-02-11 10:20:17 +00:00
|
|
|
DispatchQueue.main.async {
|
|
|
|
|
if self.text != target {
|
|
|
|
|
self.text = target
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
2026-02-11 10:20:17 +00:00
|
|
|
|
|
|
|
|
let targetFont = resolvedFont()
|
|
|
|
|
if textView.font != targetFont {
|
|
|
|
|
textView.font = targetFont
|
2026-02-11 11:07:07 +00:00
|
|
|
context.coordinator.invalidateHighlightCache()
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
2026-02-16 11:09:54 +00:00
|
|
|
if textView.textContainerInset.width != 6 || textView.textContainerInset.height != 8 {
|
|
|
|
|
textView.textContainerInset = NSSize(width: 6, height: 8)
|
|
|
|
|
}
|
|
|
|
|
if textView.textContainer?.lineFragmentPadding != 4 {
|
|
|
|
|
textView.textContainer?.lineFragmentPadding = 4
|
|
|
|
|
}
|
2026-02-11 10:20:17 +00:00
|
|
|
let style = paragraphStyle()
|
2026-02-11 13:54:04 +00:00
|
|
|
let currentLineHeight = textView.defaultParagraphStyle?.lineHeightMultiple ?? 1.0
|
|
|
|
|
if abs(currentLineHeight - style.lineHeightMultiple) > 0.0001 {
|
2026-02-11 10:20:17 +00:00
|
|
|
textView.defaultParagraphStyle = style
|
|
|
|
|
textView.typingAttributes[.paragraphStyle] = style
|
|
|
|
|
let nsLen = (textView.string as NSString).length
|
|
|
|
|
if nsLen <= 200_000, let storage = textView.textStorage {
|
|
|
|
|
storage.beginEditing()
|
|
|
|
|
storage.addAttribute(.paragraphStyle, value: style, range: NSRange(location: 0, length: nsLen))
|
|
|
|
|
storage.endEditing()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Defensive: sanitize and clear style attributes to prevent control-picture glyphs and ruler-driven styles.
|
|
|
|
|
let sanitized = AcceptingTextView.sanitizePlainText(textView.string)
|
|
|
|
|
if sanitized != textView.string {
|
|
|
|
|
textView.string = sanitized
|
2026-02-11 11:07:07 +00:00
|
|
|
context.coordinator.invalidateHighlightCache()
|
2026-02-11 10:20:17 +00:00
|
|
|
DispatchQueue.main.async {
|
|
|
|
|
if self.text != sanitized {
|
|
|
|
|
self.text = sanitized
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if let storage = textView.textStorage {
|
|
|
|
|
storage.beginEditing()
|
|
|
|
|
let fullRange = NSRange(location: 0, length: storage.length)
|
|
|
|
|
storage.removeAttribute(.underlineStyle, range: fullRange)
|
|
|
|
|
storage.removeAttribute(.strikethroughStyle, range: fullRange)
|
|
|
|
|
storage.endEditing()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let theme = currentEditorTheme(colorScheme: colorScheme)
|
|
|
|
|
|
|
|
|
|
let effectiveHighlightCurrentLine = highlightCurrentLine
|
|
|
|
|
let effectiveWrap = (isLineWrapEnabled && !isLargeFileMode)
|
|
|
|
|
|
2026-02-06 18:59:53 +00:00
|
|
|
// Background color adjustments for translucency
|
|
|
|
|
if translucentBackgroundEnabled {
|
|
|
|
|
nsView.drawsBackground = false
|
|
|
|
|
textView.backgroundColor = .clear
|
|
|
|
|
textView.drawsBackground = false
|
|
|
|
|
} else {
|
|
|
|
|
nsView.drawsBackground = false
|
2026-02-11 10:20:17 +00:00
|
|
|
let bg = (colorScheme == .light) ? NSColor.textBackgroundColor : NSColor(theme.background)
|
|
|
|
|
textView.backgroundColor = bg
|
2026-02-06 18:59:53 +00:00
|
|
|
textView.drawsBackground = true
|
|
|
|
|
}
|
2026-02-11 14:31:24 +00:00
|
|
|
let baseTextColor = effectiveBaseTextColor()
|
2026-02-11 13:54:04 +00:00
|
|
|
let caretColor = (colorScheme == .light && !translucentBackgroundEnabled) ? NSColor.labelColor : NSColor(theme.cursor)
|
|
|
|
|
if textView.insertionPointColor != caretColor {
|
|
|
|
|
textView.insertionPointColor = caretColor
|
|
|
|
|
}
|
2026-02-11 14:31:24 +00:00
|
|
|
textView.typingAttributes[.foregroundColor] = baseTextColor
|
2026-02-11 10:20:17 +00:00
|
|
|
textView.selectedTextAttributes = [
|
|
|
|
|
.backgroundColor: NSColor(theme.selection)
|
|
|
|
|
]
|
|
|
|
|
let showLineNumbersByDefault = showLineNumbers
|
2026-02-12 22:20:39 +00:00
|
|
|
textView.usesRuler = showLineNumbersByDefault
|
|
|
|
|
textView.isRulerVisible = showLineNumbersByDefault
|
2026-02-11 10:20:17 +00:00
|
|
|
nsView.hasHorizontalRuler = false
|
|
|
|
|
nsView.horizontalRulerView = nil
|
|
|
|
|
nsView.hasVerticalRuler = showLineNumbersByDefault
|
|
|
|
|
nsView.rulersVisible = showLineNumbersByDefault
|
|
|
|
|
if showLineNumbersByDefault {
|
|
|
|
|
if !(nsView.verticalRulerView is LineNumberRulerView) {
|
|
|
|
|
nsView.verticalRulerView = LineNumberRulerView(textView: textView)
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
nsView.verticalRulerView = nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Defensive clear of underline/strikethrough styles (always clear)
|
|
|
|
|
if let storage = textView.textStorage {
|
|
|
|
|
storage.beginEditing()
|
|
|
|
|
let fullRange = NSRange(location: 0, length: storage.length)
|
|
|
|
|
storage.removeAttribute(.underlineStyle, range: fullRange)
|
|
|
|
|
storage.removeAttribute(.strikethroughStyle, range: fullRange)
|
|
|
|
|
storage.endEditing()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Re-apply invisible-character visibility preference after style updates.
|
|
|
|
|
applyInvisibleCharacterPreference(textView)
|
|
|
|
|
|
|
|
|
|
nsView.tile()
|
2026-02-06 18:59:53 +00:00
|
|
|
// Keep the text container width in sync & relayout
|
2026-02-11 10:20:17 +00:00
|
|
|
acceptingView?.autoIndentEnabled = autoIndentEnabled
|
|
|
|
|
acceptingView?.autoCloseBracketsEnabled = autoCloseBracketsEnabled
|
2026-02-20 15:53:22 +00:00
|
|
|
acceptingView?.emmetLanguage = language
|
2026-02-11 10:20:17 +00:00
|
|
|
acceptingView?.indentStyle = indentStyle
|
|
|
|
|
acceptingView?.indentWidth = indentWidth
|
|
|
|
|
acceptingView?.highlightCurrentLine = effectiveHighlightCurrentLine
|
2026-02-20 15:53:22 +00:00
|
|
|
if context.coordinator.lastAppliedWrapMode != effectiveWrap {
|
|
|
|
|
applyWrapMode(isWrapped: effectiveWrap, textView: textView, scrollView: nsView)
|
|
|
|
|
context.coordinator.lastAppliedWrapMode = effectiveWrap
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
textView.invalidateIntrinsicContentSize()
|
|
|
|
|
nsView.reflectScrolledClipView(nsView.contentView)
|
|
|
|
|
|
|
|
|
|
// Only schedule highlight if needed (e.g., language/color scheme changes or external text updates)
|
|
|
|
|
context.coordinator.parent = self
|
2026-02-11 10:20:17 +00:00
|
|
|
|
2026-02-08 11:57:41 +00:00
|
|
|
if !isDropApplyInFlight {
|
|
|
|
|
context.coordinator.scheduleHighlightIfNeeded()
|
|
|
|
|
}
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func makeCoordinator() -> Coordinator {
|
|
|
|
|
Coordinator(self)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-11 10:20:17 +00:00
|
|
|
|
2026-02-06 18:59:53 +00:00
|
|
|
// Coordinator: NSTextViewDelegate that bridges NSText changes to SwiftUI and manages highlighting.
|
|
|
|
|
class Coordinator: NSObject, NSTextViewDelegate {
|
|
|
|
|
var parent: CustomTextEditor
|
|
|
|
|
weak var textView: NSTextView?
|
|
|
|
|
|
|
|
|
|
// Background queue + debouncer for regex-based highlighting
|
|
|
|
|
private let highlightQueue = DispatchQueue(label: "NeonVision.SyntaxHighlight", qos: .userInitiated)
|
|
|
|
|
// Snapshots of last highlighted state to avoid redundant work
|
|
|
|
|
private var pendingHighlight: DispatchWorkItem?
|
|
|
|
|
private var lastHighlightedText: String = ""
|
|
|
|
|
private var lastLanguage: String?
|
|
|
|
|
private var lastColorScheme: ColorScheme?
|
2026-02-11 10:20:17 +00:00
|
|
|
var lastLineHeight: CGFloat?
|
|
|
|
|
private var lastHighlightToken: Int = 0
|
2026-02-12 22:20:39 +00:00
|
|
|
private var lastSelectionLocation: Int = -1
|
2026-02-15 00:11:01 +00:00
|
|
|
private var lastTranslucencyEnabled: Bool?
|
2026-02-12 22:20:39 +00:00
|
|
|
private var isApplyingHighlight = false
|
|
|
|
|
private var highlightGeneration: Int = 0
|
2026-02-18 19:19:49 +00:00
|
|
|
private var pendingEditedRange: NSRange?
|
2026-02-20 15:53:22 +00:00
|
|
|
var lastAppliedWrapMode: Bool?
|
2026-02-06 18:59:53 +00:00
|
|
|
|
|
|
|
|
init(_ parent: CustomTextEditor) {
|
|
|
|
|
self.parent = parent
|
|
|
|
|
super.init()
|
|
|
|
|
NotificationCenter.default.addObserver(self, selector: #selector(moveToLine(_:)), name: .moveCursorToLine, object: nil)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
deinit {
|
|
|
|
|
NotificationCenter.default.removeObserver(self)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-11 11:07:07 +00:00
|
|
|
func invalidateHighlightCache() {
|
|
|
|
|
lastHighlightedText = ""
|
|
|
|
|
lastLanguage = nil
|
|
|
|
|
lastColorScheme = nil
|
|
|
|
|
lastLineHeight = nil
|
|
|
|
|
lastHighlightToken = 0
|
2026-02-12 22:20:39 +00:00
|
|
|
lastSelectionLocation = -1
|
2026-02-15 00:11:01 +00:00
|
|
|
lastTranslucencyEnabled = nil
|
2026-02-11 11:07:07 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-12 22:20:39 +00:00
|
|
|
func scheduleHighlightIfNeeded(currentText: String? = nil, immediate: Bool = false) {
|
2026-02-06 18:59:53 +00:00
|
|
|
guard textView != nil else { return }
|
|
|
|
|
|
|
|
|
|
// Query NSApp.modalWindow on the main thread to avoid thread-check warnings
|
|
|
|
|
let isModalPresented: Bool = {
|
|
|
|
|
if Thread.isMainThread {
|
|
|
|
|
return NSApp.modalWindow != nil
|
|
|
|
|
} else {
|
|
|
|
|
var result = false
|
|
|
|
|
DispatchQueue.main.sync { result = (NSApp.modalWindow != nil) }
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
if isModalPresented {
|
|
|
|
|
pendingHighlight?.cancel()
|
|
|
|
|
let work = DispatchWorkItem { [weak self] in
|
|
|
|
|
self?.scheduleHighlightIfNeeded(currentText: currentText)
|
|
|
|
|
}
|
|
|
|
|
pendingHighlight = work
|
|
|
|
|
highlightQueue.asyncAfter(deadline: .now() + 0.3, execute: work)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let lang = parent.language
|
|
|
|
|
let scheme = parent.colorScheme
|
2026-02-11 10:20:17 +00:00
|
|
|
let lineHeightValue: CGFloat = parent.lineHeightMultiple
|
|
|
|
|
let token = parent.highlightRefreshToken
|
2026-02-15 00:11:01 +00:00
|
|
|
let translucencyEnabled = parent.translucentBackgroundEnabled
|
2026-02-12 22:20:39 +00:00
|
|
|
let selectionLocation: Int = {
|
|
|
|
|
if Thread.isMainThread {
|
|
|
|
|
return textView?.selectedRange().location ?? 0
|
|
|
|
|
}
|
|
|
|
|
var result = 0
|
|
|
|
|
DispatchQueue.main.sync {
|
|
|
|
|
result = textView?.selectedRange().location ?? 0
|
|
|
|
|
}
|
|
|
|
|
return result
|
|
|
|
|
}()
|
2026-02-06 18:59:53 +00:00
|
|
|
let text: String = {
|
|
|
|
|
if let currentText = currentText {
|
|
|
|
|
return currentText
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if Thread.isMainThread {
|
|
|
|
|
return textView?.string ?? ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var result = ""
|
|
|
|
|
DispatchQueue.main.sync {
|
|
|
|
|
result = textView?.string ?? ""
|
|
|
|
|
}
|
|
|
|
|
return result
|
|
|
|
|
}()
|
|
|
|
|
|
2026-02-08 11:14:49 +00:00
|
|
|
if parent.isLargeFileMode {
|
|
|
|
|
self.lastHighlightedText = text
|
|
|
|
|
self.lastLanguage = lang
|
|
|
|
|
self.lastColorScheme = scheme
|
2026-02-11 10:20:17 +00:00
|
|
|
self.lastLineHeight = lineHeightValue
|
|
|
|
|
self.lastHighlightToken = token
|
2026-02-12 22:20:39 +00:00
|
|
|
self.lastSelectionLocation = selectionLocation
|
2026-02-15 00:11:01 +00:00
|
|
|
self.lastTranslucencyEnabled = self.parent.translucentBackgroundEnabled
|
2026-02-08 11:14:49 +00:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-06 18:59:53 +00:00
|
|
|
// Skip expensive highlighting for very large documents
|
|
|
|
|
let nsLen = (text as NSString).length
|
|
|
|
|
if nsLen > 200_000 { // ~200k UTF-16 code units
|
|
|
|
|
self.lastHighlightedText = text
|
|
|
|
|
self.lastLanguage = lang
|
|
|
|
|
self.lastColorScheme = scheme
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 22:20:39 +00:00
|
|
|
if text == lastHighlightedText &&
|
|
|
|
|
lastLanguage == lang &&
|
|
|
|
|
lastColorScheme == scheme &&
|
|
|
|
|
lastLineHeight == lineHeightValue &&
|
|
|
|
|
lastHighlightToken == token &&
|
2026-02-16 14:53:30 +00:00
|
|
|
lastSelectionLocation == selectionLocation &&
|
2026-02-15 00:11:01 +00:00
|
|
|
lastTranslucencyEnabled == translucencyEnabled {
|
2026-02-06 18:59:53 +00:00
|
|
|
return
|
|
|
|
|
}
|
2026-02-18 19:19:49 +00:00
|
|
|
let incrementalRange: NSRange? = {
|
|
|
|
|
guard token == lastHighlightToken,
|
|
|
|
|
lang == lastLanguage,
|
|
|
|
|
scheme == lastColorScheme,
|
|
|
|
|
!immediate,
|
|
|
|
|
text.utf16.count < 120_000,
|
|
|
|
|
let edit = pendingEditedRange else { return nil }
|
|
|
|
|
return expandedHighlightRange(around: edit, in: text as NSString)
|
|
|
|
|
}()
|
|
|
|
|
pendingEditedRange = nil
|
2026-02-12 22:20:39 +00:00
|
|
|
let shouldRunImmediate = immediate || lastHighlightedText.isEmpty || lastHighlightToken != token
|
|
|
|
|
highlightGeneration &+= 1
|
|
|
|
|
let generation = highlightGeneration
|
2026-02-18 19:19:49 +00:00
|
|
|
rehighlight(token: token, generation: generation, immediate: shouldRunImmediate, targetRange: incrementalRange)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func expandedHighlightRange(around range: NSRange, in text: NSString, maxUTF16Padding: Int = 6000) -> NSRange {
|
|
|
|
|
let start = max(0, range.location - maxUTF16Padding)
|
|
|
|
|
let end = min(text.length, NSMaxRange(range) + maxUTF16Padding)
|
|
|
|
|
let startLine = text.lineRange(for: NSRange(location: start, length: 0)).location
|
|
|
|
|
let endAnchor = max(startLine, min(text.length - 1, max(0, end - 1)))
|
|
|
|
|
let endLine = NSMaxRange(text.lineRange(for: NSRange(location: endAnchor, length: 0)))
|
|
|
|
|
return NSRange(location: startLine, length: max(0, endLine - startLine))
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-19 08:09:35 +00:00
|
|
|
private func preferredHighlightRange(
|
|
|
|
|
in textView: NSTextView,
|
|
|
|
|
text: NSString,
|
|
|
|
|
explicitRange: NSRange?,
|
|
|
|
|
immediate: Bool
|
|
|
|
|
) -> NSRange {
|
|
|
|
|
let fullRange = NSRange(location: 0, length: text.length)
|
|
|
|
|
if let explicitRange {
|
|
|
|
|
return explicitRange
|
|
|
|
|
}
|
|
|
|
|
// For very large buffers, prioritize visible content while typing.
|
2026-02-20 15:53:22 +00:00
|
|
|
guard text.length >= 100_000 else { return fullRange }
|
2026-02-19 08:09:35 +00:00
|
|
|
guard let layoutManager = textView.layoutManager,
|
|
|
|
|
let textContainer = textView.textContainer else { return fullRange }
|
|
|
|
|
layoutManager.ensureLayout(for: textContainer)
|
|
|
|
|
let visibleGlyphRange = layoutManager.glyphRange(forBoundingRect: textView.visibleRect, in: textContainer)
|
|
|
|
|
let visibleCharacterRange = layoutManager.characterRange(forGlyphRange: visibleGlyphRange, actualGlyphRange: nil)
|
|
|
|
|
guard visibleCharacterRange.length > 0 else { return fullRange }
|
|
|
|
|
return expandedHighlightRange(around: visibleCharacterRange, in: text, maxUTF16Padding: 12_000)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-18 19:19:49 +00:00
|
|
|
func rehighlight(token: Int, generation: Int, immediate: Bool = false, targetRange: NSRange? = nil) {
|
2026-02-16 13:39:27 +00:00
|
|
|
if !Thread.isMainThread {
|
|
|
|
|
DispatchQueue.main.async { [weak self] in
|
2026-02-18 19:19:49 +00:00
|
|
|
self?.rehighlight(token: token, generation: generation, immediate: immediate, targetRange: targetRange)
|
2026-02-16 13:39:27 +00:00
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-02-06 18:59:53 +00:00
|
|
|
guard let textView = textView else { return }
|
|
|
|
|
// Snapshot current state
|
|
|
|
|
let textSnapshot = textView.string
|
|
|
|
|
let language = parent.language
|
|
|
|
|
let scheme = parent.colorScheme
|
2026-02-11 10:20:17 +00:00
|
|
|
let lineHeightValue: CGFloat = parent.lineHeightMultiple
|
2026-02-06 18:59:53 +00:00
|
|
|
let selected = textView.selectedRange()
|
2026-02-16 11:52:36 +00:00
|
|
|
let theme = currentEditorTheme(colorScheme: scheme)
|
|
|
|
|
let colors = SyntaxColors(
|
|
|
|
|
keyword: theme.syntax.keyword,
|
|
|
|
|
string: theme.syntax.string,
|
|
|
|
|
number: theme.syntax.number,
|
|
|
|
|
comment: theme.syntax.comment,
|
|
|
|
|
attribute: theme.syntax.attribute,
|
|
|
|
|
variable: theme.syntax.variable,
|
|
|
|
|
def: theme.syntax.def,
|
|
|
|
|
property: theme.syntax.property,
|
|
|
|
|
meta: theme.syntax.meta,
|
|
|
|
|
tag: theme.syntax.tag,
|
|
|
|
|
atom: theme.syntax.atom,
|
|
|
|
|
builtin: theme.syntax.builtin,
|
|
|
|
|
type: theme.syntax.type
|
|
|
|
|
)
|
2026-02-06 18:59:53 +00:00
|
|
|
let patterns = getSyntaxPatterns(for: language, colors: colors)
|
2026-02-19 08:09:35 +00:00
|
|
|
let nsText = textSnapshot as NSString
|
|
|
|
|
let fullRange = NSRange(location: 0, length: nsText.length)
|
|
|
|
|
let applyRange = preferredHighlightRange(
|
|
|
|
|
in: textView,
|
|
|
|
|
text: nsText,
|
|
|
|
|
explicitRange: targetRange,
|
|
|
|
|
immediate: immediate
|
|
|
|
|
)
|
2026-02-06 18:59:53 +00:00
|
|
|
|
|
|
|
|
// Cancel any in-flight work
|
|
|
|
|
pendingHighlight?.cancel()
|
|
|
|
|
|
|
|
|
|
let work = DispatchWorkItem { [weak self] in
|
2026-02-18 19:19:49 +00:00
|
|
|
let interval = syntaxHighlightSignposter.beginInterval("rehighlight_macos")
|
2026-02-06 18:59:53 +00:00
|
|
|
// Compute matches off the main thread
|
|
|
|
|
var coloredRanges: [(NSRange, Color)] = []
|
|
|
|
|
for (pattern, color) in patterns {
|
2026-02-16 14:30:02 +00:00
|
|
|
guard let regex = cachedSyntaxRegex(pattern: pattern, options: [.anchorsMatchLines]) else { continue }
|
2026-02-18 19:19:49 +00:00
|
|
|
let matches = regex.matches(in: textSnapshot, range: applyRange)
|
2026-02-06 18:59:53 +00:00
|
|
|
for match in matches {
|
|
|
|
|
coloredRanges.append((match.range, color))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
DispatchQueue.main.async { [weak self] in
|
2026-02-18 19:19:49 +00:00
|
|
|
guard let self = self, let tv = self.textView else {
|
|
|
|
|
syntaxHighlightSignposter.endInterval("rehighlight_macos", interval)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
defer { syntaxHighlightSignposter.endInterval("rehighlight_macos", interval) }
|
2026-02-12 22:20:39 +00:00
|
|
|
guard generation == self.highlightGeneration else { return }
|
2026-02-06 18:59:53 +00:00
|
|
|
// Discard if text changed since we started
|
|
|
|
|
guard tv.string == textSnapshot else { return }
|
2026-02-11 14:31:24 +00:00
|
|
|
let baseColor = self.parent.effectiveBaseTextColor()
|
2026-02-12 22:20:39 +00:00
|
|
|
self.isApplyingHighlight = true
|
|
|
|
|
defer { self.isApplyingHighlight = false }
|
2026-02-06 18:59:53 +00:00
|
|
|
|
|
|
|
|
tv.textStorage?.beginEditing()
|
2026-02-18 19:19:49 +00:00
|
|
|
tv.textStorage?.removeAttribute(.foregroundColor, range: applyRange)
|
|
|
|
|
tv.textStorage?.removeAttribute(.backgroundColor, range: applyRange)
|
|
|
|
|
tv.textStorage?.removeAttribute(.underlineStyle, range: applyRange)
|
|
|
|
|
tv.textStorage?.addAttribute(.foregroundColor, value: baseColor, range: applyRange)
|
2026-02-06 18:59:53 +00:00
|
|
|
// Apply colored ranges
|
|
|
|
|
for (range, color) in coloredRanges {
|
|
|
|
|
tv.textStorage?.addAttribute(.foregroundColor, value: NSColor(color), range: range)
|
|
|
|
|
}
|
2026-02-12 22:20:39 +00:00
|
|
|
|
|
|
|
|
let selectedLocation = min(max(0, selected.location), max(0, fullRange.length))
|
|
|
|
|
let wantsBracketTokens = self.parent.highlightMatchingBrackets
|
|
|
|
|
let wantsScopeBackground = self.parent.highlightScopeBackground
|
|
|
|
|
let wantsScopeGuides = self.parent.showScopeGuides && !self.parent.isLineWrapEnabled && self.parent.language.lowercased() != "swift"
|
|
|
|
|
let bracketMatch = computeBracketScopeMatch(text: textSnapshot, caretLocation: selectedLocation)
|
|
|
|
|
let indentationMatch: IndentationScopeMatch? = {
|
|
|
|
|
guard supportsIndentationScopes(language: self.parent.language) else { return nil }
|
|
|
|
|
return computeIndentationScopeMatch(text: textSnapshot, caretLocation: selectedLocation)
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
if wantsBracketTokens, let match = bracketMatch {
|
|
|
|
|
let textLength = fullRange.length
|
2026-02-16 14:53:30 +00:00
|
|
|
let tokenColor = NSColor.systemOrange
|
2026-02-12 22:20:39 +00:00
|
|
|
if isValidRange(match.openRange, utf16Length: textLength) {
|
2026-02-16 14:53:30 +00:00
|
|
|
tv.textStorage?.addAttribute(.foregroundColor, value: tokenColor, range: match.openRange)
|
2026-02-12 22:20:39 +00:00
|
|
|
tv.textStorage?.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: match.openRange)
|
|
|
|
|
tv.textStorage?.addAttribute(.backgroundColor, value: NSColor.systemOrange.withAlphaComponent(0.22), range: match.openRange)
|
|
|
|
|
}
|
|
|
|
|
if isValidRange(match.closeRange, utf16Length: textLength) {
|
2026-02-16 14:53:30 +00:00
|
|
|
tv.textStorage?.addAttribute(.foregroundColor, value: tokenColor, range: match.closeRange)
|
2026-02-12 22:20:39 +00:00
|
|
|
tv.textStorage?.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: match.closeRange)
|
|
|
|
|
tv.textStorage?.addAttribute(.backgroundColor, value: NSColor.systemOrange.withAlphaComponent(0.22), range: match.closeRange)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if wantsScopeBackground || wantsScopeGuides {
|
|
|
|
|
let textLength = fullRange.length
|
|
|
|
|
let scopeRange = bracketMatch?.scopeRange ?? indentationMatch?.scopeRange
|
|
|
|
|
let guideRanges = bracketMatch?.guideMarkerRanges ?? indentationMatch?.guideMarkerRanges ?? []
|
|
|
|
|
|
|
|
|
|
if wantsScopeBackground, let scope = scopeRange, isValidRange(scope, utf16Length: textLength) {
|
|
|
|
|
tv.textStorage?.addAttribute(.backgroundColor, value: NSColor.systemOrange.withAlphaComponent(0.18), range: scope)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if wantsScopeGuides {
|
|
|
|
|
for marker in guideRanges {
|
|
|
|
|
if isValidRange(marker, utf16Length: textLength) {
|
|
|
|
|
tv.textStorage?.addAttribute(.backgroundColor, value: NSColor.systemBlue.withAlphaComponent(0.36), range: marker)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if self.parent.highlightCurrentLine {
|
|
|
|
|
let caret = NSRange(location: selectedLocation, length: 0)
|
|
|
|
|
let lineRange = nsText.lineRange(for: caret)
|
|
|
|
|
tv.textStorage?.addAttribute(.backgroundColor, value: NSColor.selectedTextBackgroundColor.withAlphaComponent(0.12), range: lineRange)
|
|
|
|
|
}
|
2026-02-06 18:59:53 +00:00
|
|
|
tv.textStorage?.endEditing()
|
2026-02-11 14:31:24 +00:00
|
|
|
tv.typingAttributes[.foregroundColor] = baseColor
|
2026-02-06 18:59:53 +00:00
|
|
|
|
2026-02-11 10:20:17 +00:00
|
|
|
self.parent.applyInvisibleCharacterPreference(tv)
|
|
|
|
|
|
2026-02-06 18:59:53 +00:00
|
|
|
// Restore selection only if it hasn't changed since we started
|
|
|
|
|
if NSEqualRanges(tv.selectedRange(), selected) {
|
|
|
|
|
tv.setSelectedRange(selected)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update last highlighted state
|
|
|
|
|
self.lastHighlightedText = textSnapshot
|
|
|
|
|
self.lastLanguage = language
|
|
|
|
|
self.lastColorScheme = scheme
|
2026-02-11 10:20:17 +00:00
|
|
|
self.lastLineHeight = lineHeightValue
|
|
|
|
|
self.lastHighlightToken = token
|
2026-02-12 22:20:39 +00:00
|
|
|
self.lastSelectionLocation = selectedLocation
|
2026-02-15 00:11:01 +00:00
|
|
|
self.lastTranslucencyEnabled = self.parent.translucentBackgroundEnabled
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pendingHighlight = work
|
2026-02-12 22:20:39 +00:00
|
|
|
// Run immediately on first paint/explicit refresh, debounce while typing.
|
|
|
|
|
if immediate {
|
|
|
|
|
highlightQueue.async(execute: work)
|
|
|
|
|
} else {
|
2026-02-19 08:09:35 +00:00
|
|
|
let delay: TimeInterval
|
|
|
|
|
if targetRange != nil {
|
|
|
|
|
delay = 0.08
|
|
|
|
|
} else if textSnapshot.utf16.count >= 120_000 {
|
|
|
|
|
delay = 0.22
|
|
|
|
|
} else {
|
|
|
|
|
delay = 0.12
|
|
|
|
|
}
|
|
|
|
|
highlightQueue.asyncAfter(deadline: .now() + delay, execute: work)
|
2026-02-12 22:20:39 +00:00
|
|
|
}
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func textDidChange(_ notification: Notification) {
|
|
|
|
|
guard let textView = notification.object as? NSTextView else { return }
|
2026-02-08 11:57:41 +00:00
|
|
|
if let accepting = textView as? AcceptingTextView, accepting.isApplyingDroppedContent {
|
|
|
|
|
// Drop-import chunking mutates storage many times; defer expensive binding/highlight work
|
|
|
|
|
// until the final didChangeText emitted after import completion.
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-02-11 10:20:17 +00:00
|
|
|
let sanitized = AcceptingTextView.sanitizePlainText(textView.string)
|
|
|
|
|
if sanitized != textView.string {
|
|
|
|
|
textView.string = sanitized
|
|
|
|
|
}
|
2026-02-11 11:07:07 +00:00
|
|
|
let normalizedStyle = NSMutableParagraphStyle()
|
|
|
|
|
normalizedStyle.lineHeightMultiple = max(0.9, parent.lineHeightMultiple)
|
|
|
|
|
textView.defaultParagraphStyle = normalizedStyle
|
|
|
|
|
textView.typingAttributes[.paragraphStyle] = normalizedStyle
|
|
|
|
|
if let storage = textView.textStorage {
|
|
|
|
|
let len = storage.length
|
|
|
|
|
if len <= 200_000 {
|
|
|
|
|
storage.beginEditing()
|
|
|
|
|
storage.addAttribute(.paragraphStyle, value: normalizedStyle, range: NSRange(location: 0, length: len))
|
|
|
|
|
storage.endEditing()
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-11 10:20:17 +00:00
|
|
|
if sanitized != parent.text {
|
|
|
|
|
parent.text = sanitized
|
|
|
|
|
parent.applyInvisibleCharacterPreference(textView)
|
|
|
|
|
}
|
|
|
|
|
if let accepting = textView as? AcceptingTextView, accepting.isApplyingPaste {
|
|
|
|
|
parent.applyInvisibleCharacterPreference(textView)
|
|
|
|
|
let snapshot = textView.string
|
|
|
|
|
highlightQueue.asyncAfter(deadline: .now() + 0.2) { [weak self] in
|
|
|
|
|
DispatchQueue.main.async {
|
|
|
|
|
self?.parent.text = snapshot
|
|
|
|
|
self?.scheduleHighlightIfNeeded(currentText: snapshot)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
parent.applyInvisibleCharacterPreference(textView)
|
2026-02-06 18:59:53 +00:00
|
|
|
// Update SwiftUI binding, caret status, and rehighlight.
|
|
|
|
|
parent.text = textView.string
|
2026-02-18 19:19:49 +00:00
|
|
|
let nsText = textView.string as NSString
|
|
|
|
|
let caretLocation = min(nsText.length, textView.selectedRange().location)
|
|
|
|
|
pendingEditedRange = nsText.lineRange(for: NSRange(location: caretLocation, length: 0))
|
|
|
|
|
updateCaretStatusAndHighlight(triggerHighlight: false)
|
2026-02-06 18:59:53 +00:00
|
|
|
scheduleHighlightIfNeeded(currentText: parent.text)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func textViewDidChangeSelection(_ notification: Notification) {
|
2026-02-12 22:20:39 +00:00
|
|
|
if isApplyingHighlight { return }
|
2026-02-09 10:21:50 +00:00
|
|
|
if let tv = notification.object as? AcceptingTextView {
|
|
|
|
|
tv.clearInlineSuggestion()
|
|
|
|
|
}
|
2026-02-06 18:59:53 +00:00
|
|
|
updateCaretStatusAndHighlight()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Compute (line, column), broadcast, and highlight the current line.
|
2026-02-18 19:19:49 +00:00
|
|
|
private func updateCaretStatusAndHighlight(triggerHighlight: Bool = true) {
|
2026-02-06 18:59:53 +00:00
|
|
|
guard let tv = textView else { return }
|
|
|
|
|
let ns = tv.string as NSString
|
|
|
|
|
let sel = tv.selectedRange()
|
|
|
|
|
let location = sel.location
|
2026-02-08 11:57:41 +00:00
|
|
|
if parent.isLargeFileMode || ns.length > 300_000 {
|
|
|
|
|
NotificationCenter.default.post(
|
|
|
|
|
name: .caretPositionDidChange,
|
|
|
|
|
object: nil,
|
|
|
|
|
userInfo: ["line": 0, "column": location]
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-02-06 18:59:53 +00:00
|
|
|
let prefix = ns.substring(to: min(location, ns.length))
|
|
|
|
|
let line = prefix.reduce(1) { $1 == "\n" ? $0 + 1 : $0 }
|
|
|
|
|
let col: Int = {
|
|
|
|
|
if let lastNL = prefix.lastIndex(of: "\n") {
|
|
|
|
|
return prefix.distance(from: lastNL, to: prefix.endIndex) - 1
|
|
|
|
|
} else {
|
|
|
|
|
return prefix.count
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
NotificationCenter.default.post(name: .caretPositionDidChange, object: nil, userInfo: ["line": line, "column": col])
|
2026-02-18 19:19:49 +00:00
|
|
|
if triggerHighlight {
|
2026-02-20 16:44:08 +00:00
|
|
|
// Caret/line feedback should feel immediate while navigating with mouse/keyboard.
|
|
|
|
|
scheduleHighlightIfNeeded(currentText: tv.string, immediate: true)
|
2026-02-18 19:19:49 +00:00
|
|
|
}
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@objc func moveToLine(_ notification: Notification) {
|
|
|
|
|
guard let lineOneBased = notification.object as? Int,
|
|
|
|
|
let textView = textView else { return }
|
|
|
|
|
|
|
|
|
|
// If there's no text, nothing to do
|
|
|
|
|
let currentText = textView.string
|
|
|
|
|
guard !currentText.isEmpty else { return }
|
|
|
|
|
|
|
|
|
|
// Cancel any in-flight highlight to prevent it from restoring an old selection
|
|
|
|
|
pendingHighlight?.cancel()
|
|
|
|
|
|
|
|
|
|
// Work with NSString/UTF-16 indices to match NSTextView expectations
|
|
|
|
|
let ns = currentText as NSString
|
|
|
|
|
let totalLength = ns.length
|
|
|
|
|
|
|
|
|
|
// Clamp target line to available line count (1-based input)
|
|
|
|
|
let linesArray = currentText.components(separatedBy: .newlines)
|
|
|
|
|
let clampedLineIndex = max(1, min(lineOneBased, linesArray.count)) - 1 // 0-based index
|
|
|
|
|
|
|
|
|
|
// Compute the UTF-16 location by summing UTF-16 lengths of preceding lines + newline characters
|
|
|
|
|
var location = 0
|
|
|
|
|
if clampedLineIndex > 0 {
|
|
|
|
|
for i in 0..<(clampedLineIndex) {
|
|
|
|
|
let lineNSString = linesArray[i] as NSString
|
|
|
|
|
location += lineNSString.length
|
|
|
|
|
// Add one for the newline that separates lines, as components(separatedBy:) drops separators
|
|
|
|
|
location += 1
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Safety clamp
|
|
|
|
|
location = max(0, min(location, totalLength))
|
|
|
|
|
|
|
|
|
|
// Move caret and scroll into view on the main thread
|
|
|
|
|
DispatchQueue.main.async { [weak self] in
|
|
|
|
|
guard let self = self, let tv = self.textView else { return }
|
|
|
|
|
tv.window?.makeFirstResponder(tv)
|
|
|
|
|
// Ensure layout is up-to-date before scrolling
|
|
|
|
|
if let textContainer = tv.textContainer {
|
|
|
|
|
tv.layoutManager?.ensureLayout(for: textContainer)
|
|
|
|
|
}
|
|
|
|
|
tv.setSelectedRange(NSRange(location: location, length: 0))
|
|
|
|
|
tv.scrollRangeToVisible(NSRange(location: location, length: 0))
|
|
|
|
|
|
2026-02-12 22:20:39 +00:00
|
|
|
self.scheduleHighlightIfNeeded(currentText: tv.string, immediate: true)
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-07 10:51:52 +00:00
|
|
|
#else
|
|
|
|
|
import UIKit
|
|
|
|
|
|
2026-02-16 11:52:36 +00:00
|
|
|
final class EditorInputTextView: UITextView {
|
2026-02-20 10:43:51 +00:00
|
|
|
private final class BracketAccessoryHostView: UIView {
|
|
|
|
|
override var intrinsicContentSize: CGSize {
|
|
|
|
|
CGSize(width: UIView.noIntrinsicMetric, height: 46)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 08:44:24 +00:00
|
|
|
private let bracketTokens: [String] = ["(", ")", "{", "}", "[", "]", "<", ">", "'", "\"", "`", "()", "{}", "[]", "\"\"", "''"]
|
|
|
|
|
|
|
|
|
|
private lazy var bracketAccessoryView: UIView = {
|
2026-02-20 10:43:51 +00:00
|
|
|
let host = BracketAccessoryHostView()
|
2026-02-19 08:44:24 +00:00
|
|
|
host.backgroundColor = UIColor.secondarySystemBackground.withAlphaComponent(0.95)
|
|
|
|
|
host.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
|
|
|
|
|
let scroll = UIScrollView()
|
|
|
|
|
scroll.showsHorizontalScrollIndicator = false
|
|
|
|
|
scroll.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
|
|
|
|
|
let stack = UIStackView()
|
|
|
|
|
stack.axis = .horizontal
|
|
|
|
|
stack.spacing = 8
|
|
|
|
|
stack.alignment = .center
|
|
|
|
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
|
|
|
|
|
for token in bracketTokens {
|
|
|
|
|
let button = UIButton(type: .system)
|
|
|
|
|
button.setTitle(token, for: .normal)
|
|
|
|
|
button.titleLabel?.font = UIFont.monospacedSystemFont(ofSize: 15, weight: .semibold)
|
|
|
|
|
button.accessibilityIdentifier = token
|
2026-02-19 09:42:27 +00:00
|
|
|
if #available(iOS 15.0, *) {
|
|
|
|
|
var config = button.configuration ?? .plain()
|
|
|
|
|
config.contentInsets = NSDirectionalEdgeInsets(top: 6, leading: 10, bottom: 6, trailing: 10)
|
|
|
|
|
button.configuration = config
|
|
|
|
|
} else {
|
|
|
|
|
button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 10)
|
|
|
|
|
}
|
2026-02-19 08:44:24 +00:00
|
|
|
button.layer.cornerRadius = 8
|
|
|
|
|
button.layer.masksToBounds = true
|
|
|
|
|
button.backgroundColor = UIColor.systemBlue.withAlphaComponent(0.14)
|
|
|
|
|
button.addTarget(self, action: #selector(insertBracketToken(_:)), for: .touchUpInside)
|
|
|
|
|
stack.addArrangedSubview(button)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
host.addSubview(scroll)
|
|
|
|
|
scroll.addSubview(stack)
|
|
|
|
|
|
|
|
|
|
NSLayoutConstraint.activate([
|
|
|
|
|
host.heightAnchor.constraint(equalToConstant: 46),
|
|
|
|
|
|
|
|
|
|
scroll.leadingAnchor.constraint(equalTo: host.leadingAnchor, constant: 10),
|
|
|
|
|
scroll.trailingAnchor.constraint(equalTo: host.trailingAnchor, constant: -10),
|
|
|
|
|
scroll.topAnchor.constraint(equalTo: host.topAnchor, constant: 6),
|
|
|
|
|
scroll.bottomAnchor.constraint(equalTo: host.bottomAnchor, constant: -6),
|
|
|
|
|
|
|
|
|
|
stack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor),
|
|
|
|
|
stack.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor),
|
|
|
|
|
stack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor),
|
|
|
|
|
stack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor),
|
|
|
|
|
stack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor)
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
return host
|
|
|
|
|
}()
|
2026-02-19 14:29:53 +00:00
|
|
|
private var isBracketAccessoryVisible: Bool = true
|
2026-02-19 08:44:24 +00:00
|
|
|
|
|
|
|
|
override init(frame: CGRect, textContainer: NSTextContainer?) {
|
|
|
|
|
super.init(frame: frame, textContainer: textContainer)
|
|
|
|
|
inputAccessoryView = bracketAccessoryView
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
required init?(coder: NSCoder) {
|
|
|
|
|
super.init(coder: coder)
|
|
|
|
|
inputAccessoryView = bracketAccessoryView
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 14:29:53 +00:00
|
|
|
func setBracketAccessoryVisible(_ visible: Bool) {
|
|
|
|
|
guard isBracketAccessoryVisible != visible else { return }
|
|
|
|
|
isBracketAccessoryVisible = visible
|
|
|
|
|
inputAccessoryView = visible ? bracketAccessoryView : nil
|
|
|
|
|
if isFirstResponder {
|
|
|
|
|
reloadInputViews()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-16 11:52:36 +00:00
|
|
|
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
|
|
|
|
if action == #selector(paste(_:)) {
|
|
|
|
|
return isEditable && (UIPasteboard.general.hasStrings || UIPasteboard.general.hasURLs)
|
|
|
|
|
}
|
|
|
|
|
return super.canPerformAction(action, withSender: sender)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
override func paste(_ sender: Any?) {
|
|
|
|
|
// Force plain-text fallback so simulator/device paste remains reliable
|
|
|
|
|
// even when the pasteboard advertises rich content first.
|
|
|
|
|
if let raw = UIPasteboard.general.string, !raw.isEmpty {
|
|
|
|
|
let sanitized = EditorTextSanitizer.sanitize(raw)
|
|
|
|
|
if let selection = selectedTextRange {
|
|
|
|
|
replace(selection, withText: sanitized)
|
|
|
|
|
} else {
|
|
|
|
|
insertText(sanitized)
|
|
|
|
|
}
|
2026-02-20 01:16:58 +00:00
|
|
|
DispatchQueue.main.async { [weak self] in
|
|
|
|
|
guard let self else { return }
|
|
|
|
|
self.selectedRange = NSRange(location: 0, length: 0)
|
|
|
|
|
self.scrollRangeToVisible(NSRange(location: 0, length: 0))
|
|
|
|
|
}
|
2026-02-16 11:52:36 +00:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
super.paste(sender)
|
2026-02-20 01:16:58 +00:00
|
|
|
DispatchQueue.main.async { [weak self] in
|
|
|
|
|
guard let self else { return }
|
|
|
|
|
self.selectedRange = NSRange(location: 0, length: 0)
|
|
|
|
|
self.scrollRangeToVisible(NSRange(location: 0, length: 0))
|
|
|
|
|
}
|
2026-02-16 11:52:36 +00:00
|
|
|
}
|
2026-02-19 08:44:24 +00:00
|
|
|
|
|
|
|
|
@objc private func insertBracketToken(_ sender: UIButton) {
|
|
|
|
|
guard isEditable, let token = sender.accessibilityIdentifier else { return }
|
|
|
|
|
becomeFirstResponder()
|
|
|
|
|
|
|
|
|
|
let selection = selectedRange
|
|
|
|
|
if let pair = pairForToken(token) {
|
|
|
|
|
textStorage.replaceCharacters(in: selection, with: pair.open + pair.close)
|
|
|
|
|
selectedRange = NSRange(location: selection.location + pair.open.count, length: 0)
|
|
|
|
|
delegate?.textViewDidChange?(self)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
textStorage.replaceCharacters(in: selection, with: token)
|
|
|
|
|
selectedRange = NSRange(location: selection.location + token.count, length: 0)
|
|
|
|
|
delegate?.textViewDidChange?(self)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func pairForToken(_ token: String) -> (open: String, close: String)? {
|
|
|
|
|
switch token {
|
|
|
|
|
case "()": return ("(", ")")
|
|
|
|
|
case "{}": return ("{", "}")
|
|
|
|
|
case "[]": return ("[", "]")
|
|
|
|
|
case "\"\"": return ("\"", "\"")
|
|
|
|
|
case "''": return ("'", "'")
|
|
|
|
|
default: return nil
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-16 11:52:36 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-07 10:51:52 +00:00
|
|
|
final class LineNumberedTextViewContainer: UIView {
|
|
|
|
|
let lineNumberView = UITextView()
|
2026-02-16 11:52:36 +00:00
|
|
|
let textView = EditorInputTextView()
|
|
|
|
|
private var lineNumberWidthConstraint: NSLayoutConstraint?
|
2026-02-16 14:30:02 +00:00
|
|
|
private var cachedLineCount: Int = 0
|
|
|
|
|
private var cachedFontPointSize: CGFloat = 0
|
2026-02-07 10:51:52 +00:00
|
|
|
|
|
|
|
|
override init(frame: CGRect) {
|
|
|
|
|
super.init(frame: frame)
|
2026-02-12 17:31:51 +00:00
|
|
|
configureViews()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
required init?(coder: NSCoder) {
|
|
|
|
|
super.init(coder: coder)
|
|
|
|
|
configureViews()
|
|
|
|
|
}
|
2026-02-07 10:51:52 +00:00
|
|
|
|
2026-02-12 17:31:51 +00:00
|
|
|
private func configureViews() {
|
2026-02-07 10:51:52 +00:00
|
|
|
lineNumberView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
textView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
|
|
|
|
|
lineNumberView.isEditable = false
|
|
|
|
|
lineNumberView.isSelectable = false
|
|
|
|
|
lineNumberView.isScrollEnabled = true
|
2026-02-16 11:04:09 +00:00
|
|
|
lineNumberView.bounces = false
|
2026-02-07 10:51:52 +00:00
|
|
|
lineNumberView.isUserInteractionEnabled = false
|
2026-02-20 02:41:08 +00:00
|
|
|
lineNumberView.contentInsetAdjustmentBehavior = .never
|
2026-02-07 10:51:52 +00:00
|
|
|
lineNumberView.backgroundColor = UIColor.secondarySystemBackground.withAlphaComponent(0.65)
|
|
|
|
|
lineNumberView.textColor = .secondaryLabel
|
|
|
|
|
lineNumberView.textAlignment = .right
|
|
|
|
|
lineNumberView.textContainerInset = UIEdgeInsets(top: 8, left: 4, bottom: 8, right: 6)
|
|
|
|
|
lineNumberView.textContainer.lineFragmentPadding = 0
|
|
|
|
|
|
2026-02-20 02:41:08 +00:00
|
|
|
textView.contentInsetAdjustmentBehavior = .never
|
2026-02-20 10:34:22 +00:00
|
|
|
textView.keyboardDismissMode = .onDrag
|
2026-02-07 10:51:52 +00:00
|
|
|
textView.textContainerInset = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)
|
|
|
|
|
|
|
|
|
|
let divider = UIView()
|
|
|
|
|
divider.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
divider.backgroundColor = UIColor.separator.withAlphaComponent(0.6)
|
|
|
|
|
|
|
|
|
|
addSubview(lineNumberView)
|
|
|
|
|
addSubview(divider)
|
|
|
|
|
addSubview(textView)
|
|
|
|
|
|
|
|
|
|
NSLayoutConstraint.activate([
|
|
|
|
|
lineNumberView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
|
|
|
lineNumberView.topAnchor.constraint(equalTo: topAnchor),
|
|
|
|
|
lineNumberView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
|
|
|
|
|
|
|
|
divider.leadingAnchor.constraint(equalTo: lineNumberView.trailingAnchor),
|
|
|
|
|
divider.topAnchor.constraint(equalTo: topAnchor),
|
|
|
|
|
divider.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
|
|
|
divider.widthAnchor.constraint(equalToConstant: 1),
|
|
|
|
|
|
|
|
|
|
textView.leadingAnchor.constraint(equalTo: divider.trailingAnchor),
|
|
|
|
|
textView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
|
|
|
textView.topAnchor.constraint(equalTo: topAnchor),
|
|
|
|
|
textView.bottomAnchor.constraint(equalTo: bottomAnchor)
|
|
|
|
|
])
|
2026-02-16 11:52:36 +00:00
|
|
|
|
|
|
|
|
let widthConstraint = lineNumberView.widthAnchor.constraint(equalToConstant: 46)
|
|
|
|
|
widthConstraint.isActive = true
|
|
|
|
|
lineNumberWidthConstraint = widthConstraint
|
2026-02-07 10:51:52 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func updateLineNumbers(for text: String, fontSize: CGFloat) {
|
2026-02-16 14:30:02 +00:00
|
|
|
let lineCount = lineCountForText(text)
|
2026-02-16 11:52:36 +00:00
|
|
|
let numberFont = UIFont.monospacedDigitSystemFont(ofSize: max(11, fontSize - 1), weight: .regular)
|
2026-02-16 14:30:02 +00:00
|
|
|
if abs(cachedFontPointSize - numberFont.pointSize) > 0.01 || lineNumberView.font == nil {
|
|
|
|
|
lineNumberView.font = numberFont
|
|
|
|
|
cachedFontPointSize = numberFont.pointSize
|
|
|
|
|
}
|
|
|
|
|
if lineCount != cachedLineCount {
|
|
|
|
|
lineNumberView.text = lineNumberString(for: lineCount)
|
|
|
|
|
cachedLineCount = lineCount
|
|
|
|
|
}
|
2026-02-16 11:52:36 +00:00
|
|
|
let digits = max(2, String(lineCount).count)
|
|
|
|
|
let glyphWidth = NSString(string: "8").size(withAttributes: [.font: numberFont]).width
|
|
|
|
|
let targetWidth = ceil((glyphWidth * CGFloat(digits)) + 14)
|
|
|
|
|
if abs((lineNumberWidthConstraint?.constant ?? 46) - targetWidth) > 0.5 {
|
|
|
|
|
lineNumberWidthConstraint?.constant = targetWidth
|
|
|
|
|
setNeedsLayout()
|
|
|
|
|
layoutIfNeeded()
|
|
|
|
|
}
|
2026-02-16 11:04:09 +00:00
|
|
|
lineNumberView.layoutIfNeeded()
|
2026-02-07 10:51:52 +00:00
|
|
|
}
|
2026-02-16 14:30:02 +00:00
|
|
|
|
|
|
|
|
private func lineCountForText(_ text: String) -> Int {
|
|
|
|
|
var count = 1
|
|
|
|
|
for unit in text.utf16 where unit == 10 { // '\n'
|
|
|
|
|
count += 1
|
|
|
|
|
}
|
|
|
|
|
return max(1, count)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func lineNumberString(for lineCount: Int) -> String {
|
|
|
|
|
var result = String()
|
|
|
|
|
result.reserveCapacity(max(16, lineCount * 3))
|
|
|
|
|
for line in 1...lineCount {
|
|
|
|
|
if line > 1 {
|
|
|
|
|
result.append("\n")
|
|
|
|
|
}
|
|
|
|
|
result.append(String(line))
|
|
|
|
|
}
|
|
|
|
|
return result
|
|
|
|
|
}
|
2026-02-07 10:51:52 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct CustomTextEditor: UIViewRepresentable {
|
|
|
|
|
@Binding var text: String
|
|
|
|
|
let language: String
|
|
|
|
|
let colorScheme: ColorScheme
|
|
|
|
|
let fontSize: CGFloat
|
|
|
|
|
@Binding var isLineWrapEnabled: Bool
|
2026-02-08 11:14:49 +00:00
|
|
|
let isLargeFileMode: Bool
|
2026-02-07 10:51:52 +00:00
|
|
|
let translucentBackgroundEnabled: Bool
|
2026-02-19 14:29:53 +00:00
|
|
|
let showKeyboardAccessoryBar: Bool
|
2026-02-11 10:20:17 +00:00
|
|
|
let showLineNumbers: Bool
|
|
|
|
|
let showInvisibleCharacters: Bool
|
|
|
|
|
let highlightCurrentLine: Bool
|
2026-02-12 22:20:39 +00:00
|
|
|
let highlightMatchingBrackets: Bool
|
|
|
|
|
let showScopeGuides: Bool
|
|
|
|
|
let highlightScopeBackground: Bool
|
2026-02-11 10:20:17 +00:00
|
|
|
let indentStyle: String
|
|
|
|
|
let indentWidth: Int
|
|
|
|
|
let autoIndentEnabled: Bool
|
|
|
|
|
let autoCloseBracketsEnabled: Bool
|
|
|
|
|
let highlightRefreshToken: Int
|
|
|
|
|
|
|
|
|
|
private var fontName: String {
|
|
|
|
|
UserDefaults.standard.string(forKey: "SettingsEditorFontName") ?? ""
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 22:20:39 +00:00
|
|
|
private var useSystemFont: Bool {
|
|
|
|
|
UserDefaults.standard.bool(forKey: "SettingsUseSystemFont")
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-11 10:20:17 +00:00
|
|
|
private var lineHeightMultiple: CGFloat {
|
|
|
|
|
let stored = UserDefaults.standard.double(forKey: "SettingsLineHeight")
|
|
|
|
|
return CGFloat(stored > 0 ? stored : 1.0)
|
|
|
|
|
}
|
2026-02-07 10:51:52 +00:00
|
|
|
|
2026-02-12 22:20:39 +00:00
|
|
|
private func resolvedUIFont(size: CGFloat? = nil) -> UIFont {
|
|
|
|
|
let targetSize = size ?? fontSize
|
|
|
|
|
if useSystemFont {
|
|
|
|
|
return UIFont.systemFont(ofSize: targetSize)
|
|
|
|
|
}
|
|
|
|
|
if let named = UIFont(name: fontName, size: targetSize) {
|
|
|
|
|
return named
|
|
|
|
|
}
|
|
|
|
|
return UIFont.monospacedSystemFont(ofSize: targetSize, weight: .regular)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-07 10:51:52 +00:00
|
|
|
func makeUIView(context: Context) -> LineNumberedTextViewContainer {
|
|
|
|
|
let container = LineNumberedTextViewContainer()
|
|
|
|
|
let textView = container.textView
|
2026-02-14 20:57:32 +00:00
|
|
|
let theme = currentEditorTheme(colorScheme: colorScheme)
|
2026-02-07 10:51:52 +00:00
|
|
|
|
|
|
|
|
textView.delegate = context.coordinator
|
2026-02-12 22:20:39 +00:00
|
|
|
let initialFont = resolvedUIFont()
|
|
|
|
|
textView.font = initialFont
|
2026-02-11 10:20:17 +00:00
|
|
|
let paragraphStyle = NSMutableParagraphStyle()
|
|
|
|
|
paragraphStyle.lineHeightMultiple = max(0.9, lineHeightMultiple)
|
2026-02-14 20:57:32 +00:00
|
|
|
let baseColor = UIColor(theme.text)
|
2026-02-12 22:20:39 +00:00
|
|
|
var typing = textView.typingAttributes
|
|
|
|
|
typing[.paragraphStyle] = paragraphStyle
|
|
|
|
|
typing[.foregroundColor] = baseColor
|
|
|
|
|
typing[.font] = textView.font ?? initialFont
|
|
|
|
|
textView.typingAttributes = typing
|
2026-02-07 10:51:52 +00:00
|
|
|
textView.text = text
|
2026-02-11 10:20:17 +00:00
|
|
|
if text.count <= 200_000 {
|
|
|
|
|
textView.textStorage.beginEditing()
|
|
|
|
|
textView.textStorage.addAttribute(.paragraphStyle, value: paragraphStyle, range: NSRange(location: 0, length: textView.textStorage.length))
|
|
|
|
|
textView.textStorage.endEditing()
|
|
|
|
|
}
|
2026-02-07 10:51:52 +00:00
|
|
|
textView.autocorrectionType = .no
|
|
|
|
|
textView.autocapitalizationType = .none
|
|
|
|
|
textView.smartDashesType = .no
|
|
|
|
|
textView.smartQuotesType = .no
|
|
|
|
|
textView.smartInsertDeleteType = .no
|
|
|
|
|
textView.backgroundColor = translucentBackgroundEnabled ? .clear : .systemBackground
|
2026-02-19 14:29:53 +00:00
|
|
|
textView.setBracketAccessoryVisible(showKeyboardAccessoryBar)
|
2026-02-08 11:14:49 +00:00
|
|
|
textView.textContainer.lineBreakMode = (isLineWrapEnabled && !isLargeFileMode) ? .byWordWrapping : .byClipping
|
|
|
|
|
textView.textContainer.widthTracksTextView = isLineWrapEnabled && !isLargeFileMode
|
2026-02-07 10:51:52 +00:00
|
|
|
|
2026-02-12 22:20:39 +00:00
|
|
|
if isLargeFileMode || !showLineNumbers {
|
2026-02-08 11:14:49 +00:00
|
|
|
container.lineNumberView.isHidden = true
|
|
|
|
|
} else {
|
|
|
|
|
container.lineNumberView.isHidden = false
|
|
|
|
|
container.updateLineNumbers(for: text, fontSize: fontSize)
|
|
|
|
|
}
|
2026-02-07 10:51:52 +00:00
|
|
|
context.coordinator.container = container
|
|
|
|
|
context.coordinator.textView = textView
|
2026-02-12 22:20:39 +00:00
|
|
|
context.coordinator.scheduleHighlightIfNeeded(currentText: text, immediate: true)
|
2026-02-07 10:51:52 +00:00
|
|
|
return container
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func updateUIView(_ uiView: LineNumberedTextViewContainer, context: Context) {
|
|
|
|
|
let textView = uiView.textView
|
|
|
|
|
context.coordinator.parent = self
|
|
|
|
|
if textView.text != text {
|
2026-02-20 02:41:08 +00:00
|
|
|
let priorSelection = textView.selectedRange
|
|
|
|
|
let priorOffset = textView.contentOffset
|
|
|
|
|
let wasFirstResponder = textView.isFirstResponder
|
2026-02-07 10:51:52 +00:00
|
|
|
textView.text = text
|
2026-02-20 02:41:08 +00:00
|
|
|
if wasFirstResponder {
|
|
|
|
|
let length = (textView.text as NSString).length
|
|
|
|
|
let clampedLocation = min(priorSelection.location, length)
|
|
|
|
|
let clampedLength = min(priorSelection.length, max(0, length - clampedLocation))
|
|
|
|
|
textView.selectedRange = NSRange(location: clampedLocation, length: clampedLength)
|
|
|
|
|
textView.setContentOffset(priorOffset, animated: false)
|
|
|
|
|
}
|
2026-02-07 10:51:52 +00:00
|
|
|
}
|
2026-02-12 22:20:39 +00:00
|
|
|
let targetFont = resolvedUIFont()
|
|
|
|
|
if textView.font?.fontName != targetFont.fontName || textView.font?.pointSize != targetFont.pointSize {
|
|
|
|
|
textView.font = targetFont
|
2026-02-07 10:51:52 +00:00
|
|
|
}
|
2026-02-11 10:20:17 +00:00
|
|
|
let paragraphStyle = NSMutableParagraphStyle()
|
|
|
|
|
paragraphStyle.lineHeightMultiple = max(0.9, lineHeightMultiple)
|
|
|
|
|
textView.typingAttributes[.paragraphStyle] = paragraphStyle
|
|
|
|
|
if context.coordinator.lastLineHeight != lineHeightMultiple {
|
|
|
|
|
let len = textView.textStorage.length
|
|
|
|
|
if len > 0 && len <= 200_000 {
|
|
|
|
|
textView.textStorage.beginEditing()
|
|
|
|
|
textView.textStorage.addAttribute(.paragraphStyle, value: paragraphStyle, range: NSRange(location: 0, length: len))
|
|
|
|
|
textView.textStorage.endEditing()
|
|
|
|
|
}
|
|
|
|
|
context.coordinator.lastLineHeight = lineHeightMultiple
|
|
|
|
|
}
|
|
|
|
|
let theme = currentEditorTheme(colorScheme: colorScheme)
|
2026-02-14 22:59:13 +00:00
|
|
|
let baseColor = UIColor(theme.text)
|
2026-02-11 10:20:17 +00:00
|
|
|
textView.tintColor = UIColor(theme.cursor)
|
|
|
|
|
textView.backgroundColor = translucentBackgroundEnabled ? .clear : UIColor(theme.background)
|
2026-02-19 14:29:53 +00:00
|
|
|
textView.setBracketAccessoryVisible(showKeyboardAccessoryBar)
|
2026-02-08 11:14:49 +00:00
|
|
|
textView.textContainer.lineBreakMode = (isLineWrapEnabled && !isLargeFileMode) ? .byWordWrapping : .byClipping
|
|
|
|
|
textView.textContainer.widthTracksTextView = isLineWrapEnabled && !isLargeFileMode
|
2026-02-20 10:34:22 +00:00
|
|
|
textView.keyboardDismissMode = .onDrag
|
2026-02-14 22:59:13 +00:00
|
|
|
textView.typingAttributes[.foregroundColor] = baseColor
|
2026-02-12 22:20:39 +00:00
|
|
|
if isLargeFileMode || !showLineNumbers {
|
2026-02-08 11:14:49 +00:00
|
|
|
uiView.lineNumberView.isHidden = true
|
|
|
|
|
} else {
|
|
|
|
|
uiView.lineNumberView.isHidden = false
|
|
|
|
|
uiView.updateLineNumbers(for: text, fontSize: fontSize)
|
|
|
|
|
}
|
2026-02-07 10:51:52 +00:00
|
|
|
context.coordinator.syncLineNumberScroll()
|
|
|
|
|
context.coordinator.scheduleHighlightIfNeeded(currentText: text)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func makeCoordinator() -> Coordinator {
|
|
|
|
|
Coordinator(self)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class Coordinator: NSObject, UITextViewDelegate {
|
|
|
|
|
var parent: CustomTextEditor
|
|
|
|
|
weak var container: LineNumberedTextViewContainer?
|
2026-02-19 14:29:53 +00:00
|
|
|
weak var textView: EditorInputTextView?
|
2026-02-07 10:51:52 +00:00
|
|
|
private let highlightQueue = DispatchQueue(label: "NeonVision.iOS.SyntaxHighlight", qos: .userInitiated)
|
|
|
|
|
private var pendingHighlight: DispatchWorkItem?
|
|
|
|
|
private var lastHighlightedText: String = ""
|
|
|
|
|
private var lastLanguage: String?
|
|
|
|
|
private var lastColorScheme: ColorScheme?
|
2026-02-11 10:20:17 +00:00
|
|
|
var lastLineHeight: CGFloat?
|
|
|
|
|
private var lastHighlightToken: Int = 0
|
2026-02-12 22:20:39 +00:00
|
|
|
private var lastSelectionLocation: Int = -1
|
2026-02-15 00:11:01 +00:00
|
|
|
private var lastTranslucencyEnabled: Bool?
|
2026-02-07 10:51:52 +00:00
|
|
|
private var isApplyingHighlight = false
|
2026-02-12 22:20:39 +00:00
|
|
|
private var highlightGeneration: Int = 0
|
2026-02-07 10:51:52 +00:00
|
|
|
|
|
|
|
|
init(_ parent: CustomTextEditor) {
|
|
|
|
|
self.parent = parent
|
2026-02-12 22:20:39 +00:00
|
|
|
super.init()
|
2026-02-20 10:34:22 +00:00
|
|
|
NotificationCenter.default.addObserver(self, selector: #selector(moveToLine(_:)), name: .moveCursorToLine, object: nil)
|
2026-02-12 22:20:39 +00:00
|
|
|
NotificationCenter.default.addObserver(self, selector: #selector(moveToRange(_:)), name: .moveCursorToRange, object: nil)
|
2026-02-19 14:29:53 +00:00
|
|
|
NotificationCenter.default.addObserver(self, selector: #selector(updateKeyboardAccessoryVisibility(_:)), name: .keyboardAccessoryBarVisibilityChanged, object: nil)
|
2026-02-12 22:20:39 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
deinit {
|
|
|
|
|
NotificationCenter.default.removeObserver(self)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 14:29:53 +00:00
|
|
|
@objc private func updateKeyboardAccessoryVisibility(_ notification: Notification) {
|
|
|
|
|
guard let textView else { return }
|
|
|
|
|
let isVisible: Bool
|
|
|
|
|
if let explicit = notification.object as? Bool {
|
|
|
|
|
isVisible = explicit
|
|
|
|
|
} else {
|
|
|
|
|
isVisible = UserDefaults.standard.object(forKey: "SettingsShowKeyboardAccessoryBarIOS") as? Bool ?? false
|
|
|
|
|
}
|
|
|
|
|
textView.setBracketAccessoryVisible(isVisible)
|
2026-02-20 10:43:51 +00:00
|
|
|
if isVisible && !textView.isFirstResponder {
|
|
|
|
|
textView.becomeFirstResponder()
|
|
|
|
|
}
|
2026-02-19 14:29:53 +00:00
|
|
|
textView.reloadInputViews()
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 22:20:39 +00:00
|
|
|
@objc private func moveToRange(_ notification: Notification) {
|
|
|
|
|
guard let textView else { return }
|
|
|
|
|
guard let location = notification.userInfo?[EditorCommandUserInfo.rangeLocation] as? Int,
|
|
|
|
|
let length = notification.userInfo?[EditorCommandUserInfo.rangeLength] as? Int else { return }
|
|
|
|
|
let textLength = (textView.text as NSString?)?.length ?? 0
|
|
|
|
|
guard location >= 0, length >= 0, location + length <= textLength else { return }
|
|
|
|
|
let range = NSRange(location: location, length: length)
|
|
|
|
|
textView.becomeFirstResponder()
|
|
|
|
|
textView.selectedRange = range
|
|
|
|
|
textView.scrollRangeToVisible(range)
|
2026-02-07 10:51:52 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-20 10:34:22 +00:00
|
|
|
@objc private func moveToLine(_ notification: Notification) {
|
|
|
|
|
guard let lineOneBased = notification.object as? Int, lineOneBased > 0 else { return }
|
|
|
|
|
guard let textView else { return }
|
|
|
|
|
let nsText = (textView.text ?? "") as NSString
|
|
|
|
|
guard nsText.length > 0 else { return }
|
|
|
|
|
|
|
|
|
|
let lineFragments = (textView.text ?? "").components(separatedBy: .newlines)
|
|
|
|
|
let clampedLineIndex = max(1, min(lineOneBased, lineFragments.count)) - 1
|
|
|
|
|
var location = 0
|
|
|
|
|
if clampedLineIndex > 0 {
|
|
|
|
|
for i in 0..<clampedLineIndex {
|
|
|
|
|
location += (lineFragments[i] as NSString).length + 1
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
location = max(0, min(location, nsText.length))
|
|
|
|
|
let target = NSRange(location: location, length: 0)
|
|
|
|
|
textView.becomeFirstResponder()
|
|
|
|
|
textView.selectedRange = target
|
|
|
|
|
textView.scrollRangeToVisible(target)
|
|
|
|
|
scheduleHighlightIfNeeded(currentText: textView.text ?? "", immediate: true)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 22:20:39 +00:00
|
|
|
func scheduleHighlightIfNeeded(currentText: String? = nil, immediate: Bool = false) {
|
2026-02-07 10:51:52 +00:00
|
|
|
guard let textView else { return }
|
|
|
|
|
let text = currentText ?? textView.text ?? ""
|
|
|
|
|
let lang = parent.language
|
|
|
|
|
let scheme = parent.colorScheme
|
2026-02-11 10:20:17 +00:00
|
|
|
let lineHeight = parent.lineHeightMultiple
|
|
|
|
|
let token = parent.highlightRefreshToken
|
2026-02-15 00:11:01 +00:00
|
|
|
let translucencyEnabled = parent.translucentBackgroundEnabled
|
2026-02-12 22:20:39 +00:00
|
|
|
let selectionLocation = textView.selectedRange.location
|
2026-02-07 10:51:52 +00:00
|
|
|
|
2026-02-08 11:14:49 +00:00
|
|
|
if parent.isLargeFileMode {
|
|
|
|
|
lastHighlightedText = text
|
|
|
|
|
lastLanguage = lang
|
|
|
|
|
lastColorScheme = scheme
|
2026-02-11 10:20:17 +00:00
|
|
|
lastLineHeight = lineHeight
|
|
|
|
|
lastHighlightToken = token
|
2026-02-12 22:20:39 +00:00
|
|
|
lastSelectionLocation = selectionLocation
|
2026-02-15 00:11:01 +00:00
|
|
|
lastTranslucencyEnabled = translucencyEnabled
|
2026-02-08 11:14:49 +00:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 22:20:39 +00:00
|
|
|
if text == lastHighlightedText &&
|
|
|
|
|
lang == lastLanguage &&
|
|
|
|
|
scheme == lastColorScheme &&
|
|
|
|
|
lineHeight == lastLineHeight &&
|
|
|
|
|
lastHighlightToken == token &&
|
2026-02-16 14:53:30 +00:00
|
|
|
lastSelectionLocation == selectionLocation &&
|
2026-02-15 00:11:01 +00:00
|
|
|
lastTranslucencyEnabled == translucencyEnabled {
|
2026-02-07 10:51:52 +00:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pendingHighlight?.cancel()
|
2026-02-12 22:20:39 +00:00
|
|
|
highlightGeneration &+= 1
|
|
|
|
|
let generation = highlightGeneration
|
2026-02-19 08:09:35 +00:00
|
|
|
let applyRange = preferredHighlightRange(textView: textView, text: text as NSString, immediate: immediate)
|
2026-02-07 10:51:52 +00:00
|
|
|
let work = DispatchWorkItem { [weak self] in
|
2026-02-18 22:56:46 +00:00
|
|
|
self?.rehighlight(
|
|
|
|
|
text: text,
|
|
|
|
|
language: lang,
|
|
|
|
|
colorScheme: scheme,
|
|
|
|
|
token: token,
|
|
|
|
|
generation: generation,
|
2026-02-19 08:09:35 +00:00
|
|
|
applyRange: applyRange
|
2026-02-18 22:56:46 +00:00
|
|
|
)
|
2026-02-07 10:51:52 +00:00
|
|
|
}
|
|
|
|
|
pendingHighlight = work
|
2026-02-12 22:20:39 +00:00
|
|
|
if immediate || lastHighlightedText.isEmpty || lastHighlightToken != token {
|
|
|
|
|
highlightQueue.async(execute: work)
|
|
|
|
|
} else {
|
2026-02-19 08:09:35 +00:00
|
|
|
let delay: TimeInterval
|
|
|
|
|
if text.utf16.count >= 120_000 {
|
|
|
|
|
delay = 0.24
|
|
|
|
|
} else if text.utf16.count >= 80_000 {
|
|
|
|
|
delay = 0.18
|
|
|
|
|
} else {
|
|
|
|
|
delay = 0.1
|
|
|
|
|
}
|
2026-02-18 19:19:49 +00:00
|
|
|
highlightQueue.asyncAfter(deadline: .now() + delay, execute: work)
|
2026-02-12 22:20:39 +00:00
|
|
|
}
|
2026-02-07 10:51:52 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-19 08:09:35 +00:00
|
|
|
private func expandedRange(around range: NSRange, in text: NSString, maxUTF16Padding: Int = 8000) -> NSRange {
|
|
|
|
|
let start = max(0, range.location - maxUTF16Padding)
|
|
|
|
|
let end = min(text.length, NSMaxRange(range) + maxUTF16Padding)
|
|
|
|
|
let startLine = text.lineRange(for: NSRange(location: start, length: 0)).location
|
|
|
|
|
let endAnchor = max(startLine, min(text.length - 1, max(0, end - 1)))
|
|
|
|
|
let endLine = NSMaxRange(text.lineRange(for: NSRange(location: endAnchor, length: 0)))
|
|
|
|
|
return NSRange(location: startLine, length: max(0, endLine - startLine))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func preferredHighlightRange(
|
|
|
|
|
textView: UITextView,
|
|
|
|
|
text: NSString,
|
|
|
|
|
immediate: Bool
|
|
|
|
|
) -> NSRange {
|
|
|
|
|
let fullRange = NSRange(location: 0, length: text.length)
|
|
|
|
|
// iOS rehighlight builds attributed text for the whole buffer; for very large files
|
|
|
|
|
// keep syntax matching focused on visible content while typing.
|
|
|
|
|
guard !immediate, text.length >= 100_000 else { return fullRange }
|
|
|
|
|
let visibleRect = CGRect(origin: textView.contentOffset, size: textView.bounds.size).insetBy(dx: 0, dy: -80)
|
2026-02-18 22:56:46 +00:00
|
|
|
let glyphRange = textView.layoutManager.glyphRange(forBoundingRect: visibleRect, in: textView.textContainer)
|
2026-02-19 08:09:35 +00:00
|
|
|
let charRange = textView.layoutManager.characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil)
|
|
|
|
|
guard charRange.length > 0 else { return fullRange }
|
|
|
|
|
return expandedRange(around: charRange, in: text, maxUTF16Padding: 12_000)
|
2026-02-18 22:56:46 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func rehighlight(
|
|
|
|
|
text: String,
|
|
|
|
|
language: String,
|
|
|
|
|
colorScheme: ColorScheme,
|
|
|
|
|
token: Int,
|
|
|
|
|
generation: Int,
|
2026-02-19 08:09:35 +00:00
|
|
|
applyRange: NSRange
|
2026-02-18 22:56:46 +00:00
|
|
|
) {
|
2026-02-18 19:19:49 +00:00
|
|
|
let interval = syntaxHighlightSignposter.beginInterval("rehighlight_ios")
|
|
|
|
|
defer { syntaxHighlightSignposter.endInterval("rehighlight_ios", interval) }
|
2026-02-07 10:51:52 +00:00
|
|
|
let nsText = text as NSString
|
|
|
|
|
let fullRange = NSRange(location: 0, length: nsText.length)
|
2026-02-14 20:57:32 +00:00
|
|
|
let theme = currentEditorTheme(colorScheme: colorScheme)
|
|
|
|
|
let baseColor = UIColor(theme.text)
|
2026-02-12 22:20:39 +00:00
|
|
|
let baseFont: UIFont
|
|
|
|
|
if parent.useSystemFont {
|
|
|
|
|
baseFont = UIFont.systemFont(ofSize: parent.fontSize)
|
|
|
|
|
} else if let named = UIFont(name: parent.fontName, size: parent.fontSize) {
|
|
|
|
|
baseFont = named
|
|
|
|
|
} else {
|
|
|
|
|
baseFont = UIFont.monospacedSystemFont(ofSize: parent.fontSize, weight: .regular)
|
|
|
|
|
}
|
2026-02-07 10:51:52 +00:00
|
|
|
|
2026-02-19 08:09:35 +00:00
|
|
|
let attributed = NSMutableAttributedString(
|
|
|
|
|
string: text,
|
|
|
|
|
attributes: [
|
|
|
|
|
.foregroundColor: baseColor,
|
|
|
|
|
.font: baseFont
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-14 20:57:32 +00:00
|
|
|
let colors = SyntaxColors(
|
|
|
|
|
keyword: theme.syntax.keyword,
|
|
|
|
|
string: theme.syntax.string,
|
|
|
|
|
number: theme.syntax.number,
|
|
|
|
|
comment: theme.syntax.comment,
|
|
|
|
|
attribute: theme.syntax.attribute,
|
|
|
|
|
variable: theme.syntax.variable,
|
|
|
|
|
def: theme.syntax.def,
|
|
|
|
|
property: theme.syntax.property,
|
|
|
|
|
meta: theme.syntax.meta,
|
|
|
|
|
tag: theme.syntax.tag,
|
|
|
|
|
atom: theme.syntax.atom,
|
|
|
|
|
builtin: theme.syntax.builtin,
|
|
|
|
|
type: theme.syntax.type
|
|
|
|
|
)
|
2026-02-07 10:51:52 +00:00
|
|
|
let patterns = getSyntaxPatterns(for: language, colors: colors)
|
|
|
|
|
|
|
|
|
|
for (pattern, color) in patterns {
|
2026-02-16 14:30:02 +00:00
|
|
|
guard let regex = cachedSyntaxRegex(pattern: pattern, options: [.anchorsMatchLines]) else { continue }
|
2026-02-18 22:56:46 +00:00
|
|
|
let matches = regex.matches(in: text, range: applyRange)
|
2026-02-07 10:51:52 +00:00
|
|
|
let uiColor = UIColor(color)
|
|
|
|
|
for match in matches {
|
2026-02-19 08:09:35 +00:00
|
|
|
attributed.addAttribute(.foregroundColor, value: uiColor, range: match.range)
|
2026-02-07 10:51:52 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
DispatchQueue.main.async { [weak self] in
|
|
|
|
|
guard let self, let textView = self.textView else { return }
|
2026-02-12 22:20:39 +00:00
|
|
|
guard generation == self.highlightGeneration else { return }
|
2026-02-07 10:51:52 +00:00
|
|
|
guard textView.text == text else { return }
|
|
|
|
|
let selectedRange = textView.selectedRange
|
2026-02-20 02:41:08 +00:00
|
|
|
let priorOffset = textView.contentOffset
|
|
|
|
|
let wasFirstResponder = textView.isFirstResponder
|
2026-02-07 10:51:52 +00:00
|
|
|
self.isApplyingHighlight = true
|
2026-02-19 08:09:35 +00:00
|
|
|
textView.attributedText = attributed
|
2026-02-12 22:20:39 +00:00
|
|
|
let wantsBracketTokens = self.parent.highlightMatchingBrackets
|
|
|
|
|
let wantsScopeBackground = self.parent.highlightScopeBackground
|
|
|
|
|
let wantsScopeGuides = self.parent.showScopeGuides && !self.parent.isLineWrapEnabled && self.parent.language.lowercased() != "swift"
|
|
|
|
|
let bracketMatch = computeBracketScopeMatch(text: text, caretLocation: selectedRange.location)
|
|
|
|
|
let indentationMatch: IndentationScopeMatch? = {
|
|
|
|
|
guard supportsIndentationScopes(language: self.parent.language) else { return nil }
|
|
|
|
|
return computeIndentationScopeMatch(text: text, caretLocation: selectedRange.location)
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
if wantsBracketTokens, let match = bracketMatch {
|
|
|
|
|
let textLength = fullRange.length
|
|
|
|
|
if isValidRange(match.openRange, utf16Length: textLength) {
|
2026-02-16 14:53:30 +00:00
|
|
|
textView.textStorage.addAttribute(.foregroundColor, value: UIColor.systemOrange, range: match.openRange)
|
2026-02-12 22:20:39 +00:00
|
|
|
textView.textStorage.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: match.openRange)
|
|
|
|
|
textView.textStorage.addAttribute(.backgroundColor, value: UIColor.systemOrange.withAlphaComponent(0.22), range: match.openRange)
|
|
|
|
|
}
|
|
|
|
|
if isValidRange(match.closeRange, utf16Length: textLength) {
|
2026-02-16 14:53:30 +00:00
|
|
|
textView.textStorage.addAttribute(.foregroundColor, value: UIColor.systemOrange, range: match.closeRange)
|
2026-02-12 22:20:39 +00:00
|
|
|
textView.textStorage.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: match.closeRange)
|
|
|
|
|
textView.textStorage.addAttribute(.backgroundColor, value: UIColor.systemOrange.withAlphaComponent(0.22), range: match.closeRange)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if wantsScopeBackground || wantsScopeGuides {
|
|
|
|
|
let textLength = fullRange.length
|
|
|
|
|
let scopeRange = bracketMatch?.scopeRange ?? indentationMatch?.scopeRange
|
|
|
|
|
let guideRanges = bracketMatch?.guideMarkerRanges ?? indentationMatch?.guideMarkerRanges ?? []
|
|
|
|
|
|
|
|
|
|
if wantsScopeBackground, let scope = scopeRange, isValidRange(scope, utf16Length: textLength) {
|
|
|
|
|
textView.textStorage.addAttribute(.backgroundColor, value: UIColor.systemOrange.withAlphaComponent(0.18), range: scope)
|
|
|
|
|
}
|
|
|
|
|
if wantsScopeGuides {
|
|
|
|
|
for marker in guideRanges {
|
|
|
|
|
if isValidRange(marker, utf16Length: textLength) {
|
|
|
|
|
textView.textStorage.addAttribute(.backgroundColor, value: UIColor.systemBlue.withAlphaComponent(0.36), range: marker)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-11 10:20:17 +00:00
|
|
|
if self.parent.highlightCurrentLine {
|
|
|
|
|
let ns = text as NSString
|
|
|
|
|
let lineRange = ns.lineRange(for: selectedRange)
|
2026-02-19 08:09:35 +00:00
|
|
|
textView.textStorage.addAttribute(.backgroundColor, value: UIColor.secondarySystemFill, range: lineRange)
|
2026-02-11 10:20:17 +00:00
|
|
|
}
|
2026-02-07 10:51:52 +00:00
|
|
|
textView.selectedRange = selectedRange
|
2026-02-20 02:41:08 +00:00
|
|
|
if wasFirstResponder {
|
|
|
|
|
textView.setContentOffset(priorOffset, animated: false)
|
|
|
|
|
}
|
2026-02-07 10:51:52 +00:00
|
|
|
textView.typingAttributes = [
|
|
|
|
|
.foregroundColor: baseColor,
|
|
|
|
|
.font: baseFont
|
|
|
|
|
]
|
|
|
|
|
self.isApplyingHighlight = false
|
|
|
|
|
self.lastHighlightedText = text
|
|
|
|
|
self.lastLanguage = language
|
|
|
|
|
self.lastColorScheme = colorScheme
|
2026-02-11 10:20:17 +00:00
|
|
|
self.lastLineHeight = self.parent.lineHeightMultiple
|
|
|
|
|
self.lastHighlightToken = token
|
2026-02-12 22:20:39 +00:00
|
|
|
self.lastSelectionLocation = selectedRange.location
|
2026-02-15 00:11:01 +00:00
|
|
|
self.lastTranslucencyEnabled = self.parent.translucentBackgroundEnabled
|
2026-02-07 10:51:52 +00:00
|
|
|
self.container?.updateLineNumbers(for: text, fontSize: self.parent.fontSize)
|
|
|
|
|
self.syncLineNumberScroll()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func textViewDidChange(_ textView: UITextView) {
|
|
|
|
|
guard !isApplyingHighlight else { return }
|
|
|
|
|
parent.text = textView.text
|
|
|
|
|
container?.updateLineNumbers(for: textView.text, fontSize: parent.fontSize)
|
|
|
|
|
scheduleHighlightIfNeeded(currentText: textView.text)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 22:20:39 +00:00
|
|
|
func textViewDidChangeSelection(_ textView: UITextView) {
|
|
|
|
|
guard !isApplyingHighlight else { return }
|
|
|
|
|
scheduleHighlightIfNeeded(currentText: textView.text, immediate: true)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-11 10:20:17 +00:00
|
|
|
func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
|
2026-02-20 15:53:22 +00:00
|
|
|
if text == "\t" {
|
|
|
|
|
if let expansion = EmmetExpander.expansionIfPossible(
|
|
|
|
|
in: textView.text ?? "",
|
|
|
|
|
cursorUTF16Location: range.location,
|
|
|
|
|
language: parent.language
|
|
|
|
|
) {
|
|
|
|
|
textView.textStorage.replaceCharacters(in: expansion.range, with: expansion.expansion)
|
|
|
|
|
let caretLocation = expansion.range.location + expansion.caretOffset
|
|
|
|
|
textView.selectedRange = NSRange(location: caretLocation, length: 0)
|
|
|
|
|
textViewDidChange(textView)
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
let insertion: String
|
|
|
|
|
if parent.indentStyle == "tabs" {
|
|
|
|
|
insertion = "\t"
|
|
|
|
|
} else {
|
|
|
|
|
insertion = String(repeating: " ", count: max(1, parent.indentWidth))
|
|
|
|
|
}
|
|
|
|
|
textView.textStorage.replaceCharacters(in: range, with: insertion)
|
|
|
|
|
textView.selectedRange = NSRange(location: range.location + insertion.count, length: 0)
|
|
|
|
|
textViewDidChange(textView)
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-11 10:20:17 +00:00
|
|
|
if text == "\n", parent.autoIndentEnabled {
|
|
|
|
|
let ns = textView.text as NSString
|
|
|
|
|
let lineRange = ns.lineRange(for: NSRange(location: range.location, length: 0))
|
|
|
|
|
let currentLine = ns.substring(with: NSRange(
|
|
|
|
|
location: lineRange.location,
|
|
|
|
|
length: max(0, range.location - lineRange.location)
|
|
|
|
|
))
|
|
|
|
|
let indent = currentLine.prefix { $0 == " " || $0 == "\t" }
|
|
|
|
|
let normalized = normalizedIndentation(String(indent))
|
|
|
|
|
let replacement = "\n" + normalized
|
|
|
|
|
textView.textStorage.replaceCharacters(in: range, with: replacement)
|
|
|
|
|
textView.selectedRange = NSRange(location: range.location + replacement.count, length: 0)
|
|
|
|
|
textViewDidChange(textView)
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if parent.autoCloseBracketsEnabled, text.count == 1 {
|
|
|
|
|
let pairs: [String: String] = ["(": ")", "[": "]", "{": "}", "\"": "\"", "'": "'"]
|
|
|
|
|
if let closing = pairs[text] {
|
|
|
|
|
let insertion = text + closing
|
|
|
|
|
textView.textStorage.replaceCharacters(in: range, with: insertion)
|
|
|
|
|
textView.selectedRange = NSRange(location: range.location + 1, length: 0)
|
|
|
|
|
textViewDidChange(textView)
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func normalizedIndentation(_ indent: String) -> String {
|
|
|
|
|
let width = max(1, parent.indentWidth)
|
|
|
|
|
switch parent.indentStyle {
|
|
|
|
|
case "tabs":
|
|
|
|
|
let spacesCount = indent.filter { $0 == " " }.count
|
|
|
|
|
let tabsCount = indent.filter { $0 == "\t" }.count
|
|
|
|
|
let totalSpaces = spacesCount + (tabsCount * width)
|
|
|
|
|
let tabs = String(repeating: "\t", count: totalSpaces / width)
|
|
|
|
|
let leftover = String(repeating: " ", count: totalSpaces % width)
|
|
|
|
|
return tabs + leftover
|
|
|
|
|
default:
|
|
|
|
|
let tabsCount = indent.filter { $0 == "\t" }.count
|
|
|
|
|
let spacesCount = indent.filter { $0 == " " }.count
|
|
|
|
|
let totalSpaces = spacesCount + (tabsCount * width)
|
|
|
|
|
return String(repeating: " ", count: totalSpaces)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-07 10:51:52 +00:00
|
|
|
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
|
|
|
|
syncLineNumberScroll()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func syncLineNumberScroll() {
|
|
|
|
|
guard let textView, let lineView = container?.lineNumberView else { return }
|
2026-02-16 11:04:09 +00:00
|
|
|
let targetY = textView.contentOffset.y + textView.adjustedContentInset.top - lineView.adjustedContentInset.top
|
|
|
|
|
let minY = -lineView.adjustedContentInset.top
|
|
|
|
|
let maxY = max(minY, lineView.contentSize.height - lineView.bounds.height + lineView.adjustedContentInset.bottom)
|
|
|
|
|
let clampedY = min(max(targetY, minY), maxY)
|
|
|
|
|
lineView.setContentOffset(CGPoint(x: 0, y: clampedY), animated: false)
|
2026-02-07 10:51:52 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
#endif
|