mirror of
https://github.com/h3pdesign/Neon-Vision-Editor
synced 2026-04-21 13:27:16 +00:00
Improve large-file responsiveness and staged loading
This commit is contained in:
parent
2f5bae7ba6
commit
474ec53cab
5 changed files with 283 additions and 50 deletions
|
|
@ -358,7 +358,7 @@
|
|||
CODE_SIGNING_ALLOWED = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 309;
|
||||
CURRENT_PROJECT_VERSION = 310;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = CS727NF72U;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
|
|
@ -439,7 +439,7 @@
|
|||
CODE_SIGNING_ALLOWED = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 309;
|
||||
CURRENT_PROJECT_VERSION = 310;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = CS727NF72U;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ struct SyntaxColors {
|
|||
enum SyntaxPatternProfile {
|
||||
case full
|
||||
case htmlFast
|
||||
case csvFast
|
||||
}
|
||||
|
||||
// Regex patterns per language mapped to colors. Keep light-weight for performance.
|
||||
|
|
@ -368,6 +369,14 @@ func getSyntaxPatterns(
|
|||
#"(?m)#.*$"#: colors.comment
|
||||
]
|
||||
case "csv":
|
||||
if profile == .csvFast {
|
||||
return [
|
||||
// Fast CSV profile for large datasets: keep only separators/headers/quoted chunks.
|
||||
#"(?m)^[^\n,]+(,\s*[^\n,]+)*$"#: colors.meta,
|
||||
#"\"([^\"\n]|\"\")*\""#: colors.string,
|
||||
#","#: colors.property
|
||||
]
|
||||
}
|
||||
return [
|
||||
#"\A([^\n,]+)(,\s*[^\n,]+)*"#: colors.meta,
|
||||
#"\"([^\"\n]|\"\")*\""#: colors.string,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import UIKit
|
|||
// Normalizes pasted and loaded text before it reaches editor state.
|
||||
enum EditorTextSanitizer {
|
||||
// Converts control/marker glyphs into safe spaces/newlines and removes unsupported scalars.
|
||||
static func sanitize(_ input: String) -> String {
|
||||
nonisolated static func sanitize(_ input: String) -> String {
|
||||
// Normalize line endings first so CRLF does not become double newlines.
|
||||
let normalized = input
|
||||
.replacingOccurrences(of: "\r\n", with: "\n")
|
||||
|
|
@ -53,6 +53,29 @@ enum EditorTextSanitizer {
|
|||
}
|
||||
}
|
||||
|
||||
private enum EditorLoadHelper {
|
||||
nonisolated static let fastLoadSanitizeByteThreshold = 2_000_000
|
||||
nonisolated static let largeFileCandidateByteThreshold = 2_000_000
|
||||
nonisolated static let skipFingerprintByteThreshold = 4_000_000
|
||||
nonisolated static let stagedAttachByteThreshold = 1_500_000
|
||||
nonisolated static let stagedFirstChunkUTF16Length = 180_000
|
||||
|
||||
nonisolated static func sanitizeTextForFileLoad(_ input: String, useFastPath: Bool) -> String {
|
||||
if useFastPath {
|
||||
// Fast path for large files: preserve visible content, normalize line endings,
|
||||
// and only strip NUL which frequently breaks text system behavior.
|
||||
if !input.contains("\0") && !input.contains("\r") {
|
||||
return input
|
||||
}
|
||||
return input
|
||||
.replacingOccurrences(of: "\0", with: "")
|
||||
.replacingOccurrences(of: "\r\n", with: "\n")
|
||||
.replacingOccurrences(of: "\r", with: "\n")
|
||||
}
|
||||
return EditorTextSanitizer.sanitize(input)
|
||||
}
|
||||
}
|
||||
|
||||
///MARK: - Tab Model
|
||||
// Represents one editor tab and its mutable editing state.
|
||||
struct TabData: Identifiable {
|
||||
|
|
@ -64,6 +87,8 @@ struct TabData: Identifiable {
|
|||
var languageLocked: Bool = false
|
||||
var isDirty: Bool = false
|
||||
var lastSavedFingerprint: UInt64?
|
||||
var isLoadingContent: Bool = false
|
||||
var isLargeFileCandidate: Bool = false
|
||||
}
|
||||
|
||||
///MARK: - Editor View Model
|
||||
|
|
@ -171,6 +196,12 @@ class EditorViewModel: ObservableObject {
|
|||
// Updates tab text and applies language detection/locking heuristics.
|
||||
func updateTabContent(tab: TabData, content: String) {
|
||||
if let index = tabs.firstIndex(where: { $0.id == tab.id }) {
|
||||
if tabs[index].isLoadingContent {
|
||||
// During staged file load, content updates are system-driven; do not mark dirty
|
||||
// and do not run language detection on partial content.
|
||||
tabs[index].content = content
|
||||
return
|
||||
}
|
||||
let previous = tabs[index].content
|
||||
tabs[index].content = content
|
||||
if content != previous {
|
||||
|
|
@ -408,22 +439,60 @@ class EditorViewModel: ObservableObject {
|
|||
// Loads a file into a new tab unless the file is already open.
|
||||
func openFile(url: URL) {
|
||||
if focusTabIfOpen(for: url) { return }
|
||||
do {
|
||||
let raw = try String(contentsOf: url, encoding: .utf8)
|
||||
let content = sanitizeTextForEditor(raw)
|
||||
let extLang = LanguageDetector.shared.preferredLanguage(for: url) ?? languageMap[url.pathExtension.lowercased()]
|
||||
let detectedLang = extLang ?? LanguageDetector.shared.detect(text: content, name: url.lastPathComponent, fileURL: url).lang
|
||||
let newTab = TabData(name: url.lastPathComponent,
|
||||
content: content,
|
||||
language: detectedLang,
|
||||
fileURL: url,
|
||||
languageLocked: extLang != nil,
|
||||
isDirty: false,
|
||||
lastSavedFingerprint: contentFingerprint(content))
|
||||
tabs.append(newTab)
|
||||
selectedTabID = newTab.id
|
||||
} catch {
|
||||
debugLog("Failed to open file.")
|
||||
let extLangHint = LanguageDetector.shared.preferredLanguage(for: url) ?? languageMap[url.pathExtension.lowercased()]
|
||||
let fileSize = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0
|
||||
let isLargeCandidate = fileSize >= EditorLoadHelper.largeFileCandidateByteThreshold
|
||||
let placeholderTab = TabData(
|
||||
name: url.lastPathComponent,
|
||||
content: "",
|
||||
language: extLangHint ?? "plain",
|
||||
fileURL: url,
|
||||
languageLocked: extLangHint != nil,
|
||||
isDirty: false,
|
||||
lastSavedFingerprint: nil,
|
||||
isLoadingContent: true,
|
||||
isLargeFileCandidate: isLargeCandidate
|
||||
)
|
||||
tabs.append(placeholderTab)
|
||||
selectedTabID = placeholderTab.id
|
||||
|
||||
let tabID = placeholderTab.id
|
||||
Task.detached(priority: .userInitiated) { [url, extLangHint, tabID] in
|
||||
do {
|
||||
let data = try Data(contentsOf: url, options: [.mappedIfSafe])
|
||||
let raw = String(decoding: data, as: UTF8.self)
|
||||
let content = EditorLoadHelper.sanitizeTextForFileLoad(
|
||||
raw,
|
||||
useFastPath: data.count >= EditorLoadHelper.fastLoadSanitizeByteThreshold
|
||||
)
|
||||
let detectedLang: String
|
||||
if let extLangHint {
|
||||
detectedLang = extLangHint
|
||||
} else {
|
||||
detectedLang = "plain"
|
||||
}
|
||||
let fingerprint: UInt64? = data.count >= EditorLoadHelper.skipFingerprintByteThreshold
|
||||
? nil
|
||||
: Self.contentFingerprintValue(content)
|
||||
let useStagedAttach = data.count >= EditorLoadHelper.stagedAttachByteThreshold
|
||||
|
||||
await self.applyLoadedContent(
|
||||
tabID: tabID,
|
||||
content: content,
|
||||
language: detectedLang,
|
||||
languageLocked: extLangHint != nil,
|
||||
fingerprint: fingerprint,
|
||||
isLargeCandidate: data.count >= EditorLoadHelper.largeFileCandidateByteThreshold,
|
||||
useStagedAttach: useStagedAttach
|
||||
)
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
if let index = self.tabs.firstIndex(where: { $0.id == tabID }) {
|
||||
self.tabs[index].isLoadingContent = false
|
||||
}
|
||||
self.debugLog("Failed to open file.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -431,13 +500,58 @@ class EditorViewModel: ObservableObject {
|
|||
EditorTextSanitizer.sanitize(input)
|
||||
}
|
||||
|
||||
private func contentFingerprint(_ text: String) -> UInt64 {
|
||||
private nonisolated static func contentFingerprintValue(_ text: String) -> UInt64 {
|
||||
var hasher = Hasher()
|
||||
hasher.combine(text)
|
||||
let value = hasher.finalize()
|
||||
return UInt64(bitPattern: Int64(value))
|
||||
}
|
||||
|
||||
private func applyLoadedContent(
|
||||
tabID: UUID,
|
||||
content: String,
|
||||
language: String,
|
||||
languageLocked: Bool,
|
||||
fingerprint: UInt64?,
|
||||
isLargeCandidate: Bool,
|
||||
useStagedAttach: Bool
|
||||
) async {
|
||||
guard let index = tabs.firstIndex(where: { $0.id == tabID }) else { return }
|
||||
|
||||
tabs[index].language = language
|
||||
tabs[index].languageLocked = languageLocked
|
||||
tabs[index].isDirty = false
|
||||
tabs[index].lastSavedFingerprint = fingerprint
|
||||
tabs[index].isLargeFileCandidate = isLargeCandidate
|
||||
|
||||
guard useStagedAttach else {
|
||||
tabs[index].content = content
|
||||
tabs[index].isLoadingContent = false
|
||||
return
|
||||
}
|
||||
|
||||
let nsContent = content as NSString
|
||||
let firstLength = min(EditorLoadHelper.stagedFirstChunkUTF16Length, nsContent.length)
|
||||
let firstChunk = nsContent.substring(with: NSRange(location: 0, length: firstLength))
|
||||
tabs[index].content = firstChunk
|
||||
|
||||
// Yield one runloop turn to present the tab quickly before attaching full content.
|
||||
await Task.yield()
|
||||
try? await Task.sleep(nanoseconds: 45_000_000)
|
||||
|
||||
guard let refreshedIndex = tabs.firstIndex(where: { $0.id == tabID }) else { return }
|
||||
if tabs[refreshedIndex].isDirty {
|
||||
tabs[refreshedIndex].isLoadingContent = false
|
||||
return
|
||||
}
|
||||
tabs[refreshedIndex].content = content
|
||||
tabs[refreshedIndex].isLoadingContent = false
|
||||
}
|
||||
|
||||
private func contentFingerprint(_ text: String) -> UInt64 {
|
||||
Self.contentFingerprintValue(text)
|
||||
}
|
||||
|
||||
|
||||
func hasOpenFile(url: URL) -> Bool {
|
||||
indexOfOpenTab(for: url) != nil
|
||||
|
|
|
|||
|
|
@ -33,7 +33,10 @@ extension String {
|
|||
struct ContentView: View {
|
||||
private enum EditorPerformanceThresholds {
|
||||
static let largeFileBytes = 12_000_000
|
||||
static let largeFileBytesHTMLCSV = 4_000_000
|
||||
static let heavyFeatureUTF16Length = 450_000
|
||||
static let largeFileLineBreaks = 40_000
|
||||
static let largeFileLineBreaksHTMLCSV = 15_000
|
||||
}
|
||||
private static let completionSignposter = OSSignposter(subsystem: "h3p.Neon-Vision-Editor", category: "InlineCompletion")
|
||||
|
||||
|
|
@ -1134,7 +1137,13 @@ struct ContentView: View {
|
|||
}
|
||||
}
|
||||
.onChange(of: viewModel.selectedTab?.id) { _, _ in
|
||||
updateLargeFileMode(for: currentContentBinding.wrappedValue)
|
||||
if viewModel.selectedTab?.isLargeFileCandidate == true {
|
||||
if !largeFileModeEnabled {
|
||||
largeFileModeEnabled = true
|
||||
}
|
||||
} else {
|
||||
updateLargeFileMode(for: currentContentBinding.wrappedValue)
|
||||
}
|
||||
scheduleHighlightRefresh()
|
||||
}
|
||||
.onChange(of: currentLanguage) { _, newValue in
|
||||
|
|
@ -1204,10 +1213,45 @@ struct ContentView: View {
|
|||
}
|
||||
|
||||
func updateLargeFileMode(for text: String) {
|
||||
if viewModel.selectedTab?.isLargeFileCandidate == true {
|
||||
if !largeFileModeEnabled {
|
||||
largeFileModeEnabled = true
|
||||
scheduleHighlightRefresh()
|
||||
}
|
||||
return
|
||||
}
|
||||
let lowerLanguage = currentLanguage.lowercased()
|
||||
let isHTMLLike = ["html", "htm", "xml", "svg", "xhtml"].contains(lowerLanguage)
|
||||
let isCSVLike = ["csv", "tsv"].contains(lowerLanguage)
|
||||
let useAggressiveThresholds = isHTMLLike || isCSVLike
|
||||
let byteThreshold = useAggressiveThresholds
|
||||
? EditorPerformanceThresholds.largeFileBytesHTMLCSV
|
||||
: EditorPerformanceThresholds.largeFileBytes
|
||||
let lineThreshold = useAggressiveThresholds
|
||||
? EditorPerformanceThresholds.largeFileLineBreaksHTMLCSV
|
||||
: EditorPerformanceThresholds.largeFileLineBreaks
|
||||
let byteCount = text.utf8.count
|
||||
let exceedsByteThreshold = byteCount >= byteThreshold
|
||||
let exceedsLineThreshold: Bool = {
|
||||
if exceedsByteThreshold { return true }
|
||||
var lineBreaks = 0
|
||||
for codeUnit in text.utf16 {
|
||||
if codeUnit == 10 { // '\n'
|
||||
lineBreaks += 1
|
||||
if lineBreaks >= lineThreshold {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}()
|
||||
#if os(iOS)
|
||||
let isLarge = forceLargeFileMode || text.utf8.count >= EditorPerformanceThresholds.largeFileBytes
|
||||
let isLarge = forceLargeFileMode
|
||||
|| exceedsByteThreshold
|
||||
|| exceedsLineThreshold
|
||||
#else
|
||||
let isLarge = text.utf8.count >= EditorPerformanceThresholds.largeFileBytes
|
||||
let isLarge = exceedsByteThreshold
|
||||
|| exceedsLineThreshold
|
||||
#endif
|
||||
if largeFileModeEnabled != isLarge {
|
||||
largeFileModeEnabled = isLarge
|
||||
|
|
|
|||
|
|
@ -8,6 +8,12 @@ private enum EditorRuntimeLimits {
|
|||
// Above this, keep editing responsive by skipping regex-heavy syntax passes.
|
||||
static let syntaxMinimalUTF16Length = 1_200_000
|
||||
static let htmlFastProfileUTF16Length = 250_000
|
||||
static let csvFastProfileUTF16Length = 180_000
|
||||
static let scopeComputationMaxUTF16Length = 300_000
|
||||
static let cursorRehighlightMaxUTF16Length = 220_000
|
||||
static let nonImmediateHighlightMaxUTF16Length = 220_000
|
||||
static let bindingDebounceUTF16Length = 250_000
|
||||
static let bindingDebounceDelay: TimeInterval = 0.18
|
||||
}
|
||||
|
||||
private enum EmmetExpander {
|
||||
|
|
@ -1931,6 +1937,7 @@ struct CustomTextEditor: NSViewRepresentable {
|
|||
private var isApplyingHighlight = false
|
||||
private var highlightGeneration: Int = 0
|
||||
private var pendingEditedRange: NSRange?
|
||||
private var pendingBindingSync: DispatchWorkItem?
|
||||
var lastAppliedWrapMode: Bool?
|
||||
|
||||
init(_ parent: CustomTextEditor) {
|
||||
|
|
@ -1953,6 +1960,19 @@ struct CustomTextEditor: NSViewRepresentable {
|
|||
lastTranslucencyEnabled = nil
|
||||
}
|
||||
|
||||
private func syncBindingText(_ text: String, immediate: Bool = false) {
|
||||
pendingBindingSync?.cancel()
|
||||
if immediate || (text as NSString).length < EditorRuntimeLimits.bindingDebounceUTF16Length {
|
||||
parent.text = text
|
||||
return
|
||||
}
|
||||
let work = DispatchWorkItem { [weak self] in
|
||||
self?.parent.text = text
|
||||
}
|
||||
pendingBindingSync = work
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + EditorRuntimeLimits.bindingDebounceDelay, execute: work)
|
||||
}
|
||||
|
||||
func scheduleHighlightIfNeeded(currentText: String? = nil, immediate: Bool = false) {
|
||||
guard textView != nil else { return }
|
||||
|
||||
|
|
@ -2106,6 +2126,9 @@ struct CustomTextEditor: NSViewRepresentable {
|
|||
if lower == "html" && nsText.length >= EditorRuntimeLimits.htmlFastProfileUTF16Length {
|
||||
return .htmlFast
|
||||
}
|
||||
if lower == "csv" && nsText.length >= EditorRuntimeLimits.csvFastProfileUTF16Length {
|
||||
return .csvFast
|
||||
}
|
||||
return .full
|
||||
}()
|
||||
let colors = SyntaxColors(
|
||||
|
|
@ -2175,7 +2198,8 @@ struct CustomTextEditor: NSViewRepresentable {
|
|||
let wantsBracketTokens = self.parent.highlightMatchingBrackets
|
||||
let wantsScopeBackground = self.parent.highlightScopeBackground
|
||||
let wantsScopeGuides = self.parent.showScopeGuides && !self.parent.isLineWrapEnabled && self.parent.language.lowercased() != "swift"
|
||||
let needsScopeComputation = wantsBracketTokens || wantsScopeBackground || wantsScopeGuides
|
||||
let needsScopeComputation = (wantsBracketTokens || wantsScopeBackground || wantsScopeGuides)
|
||||
&& nsText.length < EditorRuntimeLimits.scopeComputationMaxUTF16Length
|
||||
let bracketMatch = needsScopeComputation ? computeBracketScopeMatch(text: textSnapshot, caretLocation: selectedLocation) : nil
|
||||
let indentationMatch: IndentationScopeMatch? = {
|
||||
guard needsScopeComputation, supportsIndentationScopes(language: self.parent.language) else { return nil }
|
||||
|
|
@ -2285,16 +2309,14 @@ struct CustomTextEditor: NSViewRepresentable {
|
|||
storage.endEditing()
|
||||
}
|
||||
}
|
||||
if sanitized != parent.text {
|
||||
parent.text = sanitized
|
||||
parent.applyInvisibleCharacterPreference(textView)
|
||||
}
|
||||
syncBindingText(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?.syncBindingText(snapshot, immediate: true)
|
||||
self?.scheduleHighlightIfNeeded(currentText: snapshot)
|
||||
}
|
||||
}
|
||||
|
|
@ -2302,7 +2324,6 @@ struct CustomTextEditor: NSViewRepresentable {
|
|||
}
|
||||
parent.applyInvisibleCharacterPreference(textView)
|
||||
// Update SwiftUI binding, caret status, and rehighlight.
|
||||
parent.text = textView.string
|
||||
let nsText = textView.string as NSString
|
||||
let caretLocation = min(nsText.length, textView.selectedRange().location)
|
||||
pendingEditedRange = nsText.lineRange(for: NSRange(location: caretLocation, length: 0))
|
||||
|
|
@ -2782,6 +2803,7 @@ struct CustomTextEditor: UIViewRepresentable {
|
|||
textView.setBracketAccessoryVisible(showKeyboardAccessoryBar)
|
||||
textView.textContainer.lineBreakMode = (isLineWrapEnabled && !isLargeFileMode) ? .byWordWrapping : .byClipping
|
||||
textView.textContainer.widthTracksTextView = isLineWrapEnabled && !isLargeFileMode
|
||||
textView.layoutManager.allowsNonContiguousLayout = true
|
||||
textView.keyboardDismissMode = .onDrag
|
||||
textView.typingAttributes[.foregroundColor] = baseColor
|
||||
if isLargeFileMode || !showLineNumbers {
|
||||
|
|
@ -2804,6 +2826,7 @@ struct CustomTextEditor: UIViewRepresentable {
|
|||
weak var textView: EditorInputTextView?
|
||||
private let highlightQueue = DispatchQueue(label: "NeonVision.iOS.SyntaxHighlight", qos: .userInitiated)
|
||||
private var pendingHighlight: DispatchWorkItem?
|
||||
private var pendingBindingSync: DispatchWorkItem?
|
||||
private var lastHighlightedText: String = ""
|
||||
private var lastLanguage: String?
|
||||
private var lastColorScheme: ColorScheme?
|
||||
|
|
@ -2823,9 +2846,28 @@ struct CustomTextEditor: UIViewRepresentable {
|
|||
}
|
||||
|
||||
deinit {
|
||||
pendingBindingSync?.cancel()
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
private func shouldRenderLineNumbers() -> Bool {
|
||||
guard let lineView = container?.lineNumberView else { return false }
|
||||
return parent.showLineNumbers && !parent.isLargeFileMode && !lineView.isHidden
|
||||
}
|
||||
|
||||
private func syncBindingText(_ text: String, immediate: Bool = false) {
|
||||
pendingBindingSync?.cancel()
|
||||
if immediate || (text as NSString).length < EditorRuntimeLimits.bindingDebounceUTF16Length {
|
||||
parent.text = text
|
||||
return
|
||||
}
|
||||
let work = DispatchWorkItem { [weak self] in
|
||||
self?.parent.text = text
|
||||
}
|
||||
pendingBindingSync = work
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + EditorRuntimeLimits.bindingDebounceDelay, execute: work)
|
||||
}
|
||||
|
||||
@objc private func updateKeyboardAccessoryVisibility(_ notification: Notification) {
|
||||
guard let textView else { return }
|
||||
let isVisible: Bool
|
||||
|
|
@ -2917,6 +2959,19 @@ struct CustomTextEditor: UIViewRepresentable {
|
|||
return
|
||||
}
|
||||
|
||||
let styleStateUnchanged = lang == lastLanguage &&
|
||||
scheme == lastColorScheme &&
|
||||
lineHeight == lastLineHeight &&
|
||||
lastHighlightToken == token &&
|
||||
lastTranslucencyEnabled == translucencyEnabled
|
||||
let selectionOnlyChange = text == lastHighlightedText &&
|
||||
styleStateUnchanged &&
|
||||
lastSelectionLocation != selectionLocation
|
||||
if selectionOnlyChange && textLength >= EditorRuntimeLimits.cursorRehighlightMaxUTF16Length {
|
||||
lastSelectionLocation = selectionLocation
|
||||
return
|
||||
}
|
||||
|
||||
pendingHighlight?.cancel()
|
||||
highlightGeneration &+= 1
|
||||
let generation = highlightGeneration
|
||||
|
|
@ -2932,7 +2987,8 @@ struct CustomTextEditor: UIViewRepresentable {
|
|||
)
|
||||
}
|
||||
pendingHighlight = work
|
||||
if immediate || lastHighlightedText.isEmpty || lastHighlightToken != token {
|
||||
let allowImmediate = textLength < EditorRuntimeLimits.nonImmediateHighlightMaxUTF16Length
|
||||
if (immediate || lastHighlightedText.isEmpty || lastHighlightToken != token) && allowImmediate {
|
||||
highlightQueue.async(execute: work)
|
||||
} else {
|
||||
let delay: TimeInterval
|
||||
|
|
@ -2962,9 +3018,8 @@ struct CustomTextEditor: UIViewRepresentable {
|
|||
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 }
|
||||
// Keep syntax matching focused on visible content for very large buffers.
|
||||
guard text.length >= 100_000 else { return fullRange }
|
||||
let visibleRect = CGRect(origin: textView.contentOffset, size: textView.bounds.size).insetBy(dx: 0, dy: -80)
|
||||
let glyphRange = textView.layoutManager.glyphRange(forBoundingRect: visibleRect, in: textView.textContainer)
|
||||
let charRange = textView.layoutManager.characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil)
|
||||
|
|
@ -2995,14 +3050,6 @@ struct CustomTextEditor: UIViewRepresentable {
|
|||
baseFont = UIFont.monospacedSystemFont(ofSize: parent.fontSize, weight: .regular)
|
||||
}
|
||||
|
||||
let attributed = NSMutableAttributedString(
|
||||
string: text,
|
||||
attributes: [
|
||||
.foregroundColor: baseColor,
|
||||
.font: baseFont
|
||||
]
|
||||
)
|
||||
|
||||
let colors = SyntaxColors(
|
||||
keyword: theme.syntax.keyword,
|
||||
string: theme.syntax.string,
|
||||
|
|
@ -3023,16 +3070,21 @@ struct CustomTextEditor: UIViewRepresentable {
|
|||
if lower == "html" && nsText.length >= EditorRuntimeLimits.htmlFastProfileUTF16Length {
|
||||
return .htmlFast
|
||||
}
|
||||
if lower == "csv" && nsText.length >= EditorRuntimeLimits.csvFastProfileUTF16Length {
|
||||
return .csvFast
|
||||
}
|
||||
return .full
|
||||
}()
|
||||
let patterns = getSyntaxPatterns(for: language, colors: colors, profile: syntaxProfile)
|
||||
var coloredRanges: [(NSRange, UIColor)] = []
|
||||
|
||||
for (pattern, color) in patterns {
|
||||
guard let regex = cachedSyntaxRegex(pattern: pattern, options: [.anchorsMatchLines]) else { continue }
|
||||
let matches = regex.matches(in: text, range: applyRange)
|
||||
let uiColor = UIColor(color)
|
||||
for match in matches {
|
||||
attributed.addAttribute(.foregroundColor, value: uiColor, range: match.range)
|
||||
guard isValidRange(match.range, utf16Length: fullRange.length) else { continue }
|
||||
coloredRanges.append((match.range, uiColor))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3044,11 +3096,20 @@ struct CustomTextEditor: UIViewRepresentable {
|
|||
let priorOffset = textView.contentOffset
|
||||
let wasFirstResponder = textView.isFirstResponder
|
||||
self.isApplyingHighlight = true
|
||||
textView.attributedText = attributed
|
||||
textView.textStorage.beginEditing()
|
||||
textView.textStorage.removeAttribute(.foregroundColor, range: applyRange)
|
||||
textView.textStorage.removeAttribute(.backgroundColor, range: applyRange)
|
||||
textView.textStorage.removeAttribute(.underlineStyle, range: applyRange)
|
||||
textView.textStorage.addAttribute(.foregroundColor, value: baseColor, range: applyRange)
|
||||
textView.textStorage.addAttribute(.font, value: baseFont, range: applyRange)
|
||||
for (range, color) in coloredRanges {
|
||||
textView.textStorage.addAttribute(.foregroundColor, value: color, range: range)
|
||||
}
|
||||
let wantsBracketTokens = self.parent.highlightMatchingBrackets
|
||||
let wantsScopeBackground = self.parent.highlightScopeBackground
|
||||
let wantsScopeGuides = self.parent.showScopeGuides && !self.parent.isLineWrapEnabled && self.parent.language.lowercased() != "swift"
|
||||
let needsScopeComputation = wantsBracketTokens || wantsScopeBackground || wantsScopeGuides
|
||||
let needsScopeComputation = (wantsBracketTokens || wantsScopeBackground || wantsScopeGuides)
|
||||
&& nsText.length < EditorRuntimeLimits.scopeComputationMaxUTF16Length
|
||||
let bracketMatch = needsScopeComputation ? computeBracketScopeMatch(text: text, caretLocation: selectedRange.location) : nil
|
||||
let indentationMatch: IndentationScopeMatch? = {
|
||||
guard needsScopeComputation, supportsIndentationScopes(language: self.parent.language) else { return nil }
|
||||
|
|
@ -3090,6 +3151,7 @@ struct CustomTextEditor: UIViewRepresentable {
|
|||
let lineRange = ns.lineRange(for: selectedRange)
|
||||
textView.textStorage.addAttribute(.backgroundColor, value: UIColor.secondarySystemFill, range: lineRange)
|
||||
}
|
||||
textView.textStorage.endEditing()
|
||||
textView.selectedRange = selectedRange
|
||||
if wasFirstResponder {
|
||||
textView.setContentOffset(priorOffset, animated: false)
|
||||
|
|
@ -3106,15 +3168,19 @@ struct CustomTextEditor: UIViewRepresentable {
|
|||
self.lastHighlightToken = token
|
||||
self.lastSelectionLocation = selectedRange.location
|
||||
self.lastTranslucencyEnabled = self.parent.translucentBackgroundEnabled
|
||||
self.container?.updateLineNumbers(for: text, fontSize: self.parent.fontSize)
|
||||
self.syncLineNumberScroll()
|
||||
if self.shouldRenderLineNumbers() {
|
||||
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)
|
||||
syncBindingText(textView.text)
|
||||
if shouldRenderLineNumbers() {
|
||||
container?.updateLineNumbers(for: textView.text, fontSize: parent.fontSize)
|
||||
}
|
||||
scheduleHighlightIfNeeded(currentText: textView.text)
|
||||
}
|
||||
|
||||
|
|
@ -3203,7 +3269,7 @@ struct CustomTextEditor: UIViewRepresentable {
|
|||
}
|
||||
|
||||
func syncLineNumberScroll() {
|
||||
guard let textView, let lineView = container?.lineNumberView else { return }
|
||||
guard shouldRenderLineNumbers(), let textView, let lineView = container?.lineNumberView else { return }
|
||||
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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue