Improve large-file responsiveness and staged loading

This commit is contained in:
h3p 2026-02-20 20:32:03 +01:00
parent 2f5bae7ba6
commit 474ec53cab
5 changed files with 283 additions and 50 deletions

View file

@ -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;

View file

@ -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,

View file

@ -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

View file

@ -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

View file

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