diff --git a/ApplicationLibrary/Views/Log/LogTextView.swift b/ApplicationLibrary/Views/Log/LogTextView.swift index fb910bf..cce141d 100644 --- a/ApplicationLibrary/Views/Log/LogTextView.swift +++ b/ApplicationLibrary/Views/Log/LogTextView.swift @@ -23,11 +23,17 @@ struct LogTextView: View { } } +@MainActor class LogCoordinator { var lastLogsCount: Int = 0 var lastLog: LogEntry? var lastSearchText: String = "" - var cachedAttributedString: NSAttributedString? + var buildVersion: Int = 0 + var currentBuildTask: Task? + + deinit { + currentBuildTask?.cancel() + } func shouldUpdate(logs: [LogEntry], searchText: String) -> UpdateStrategy { let currentCount = logs.count @@ -47,14 +53,12 @@ class LogCoordinator { if currentCount == 0 || searchChanged || lastLogsCount > currentCount { // Full rebuild needed strategy = .fullRebuild - cachedAttributedString = nil } else if currentCount > lastLogsCount { // Incremental update possible strategy = .incremental(from: lastLogsCount) } else { // Same count but different last log (shouldn't happen normally) strategy = .fullRebuild - cachedAttributedString = nil } lastLogsCount = currentCount @@ -63,6 +67,38 @@ class LogCoordinator { return strategy } + #if os(iOS) || os(macOS) + fileprivate func scheduleBuildTask( + logs: [LogEntry], + searchText: String, + monoFont: PlatformFont, + defaultColor: PlatformColor, + startIndex: Int?, + isViewValid: @escaping @MainActor () -> Bool, + applyUpdate: @escaping @MainActor (NSAttributedString, Bool) -> Void + ) { + currentBuildTask?.cancel() + buildVersion += 1 + let version = buildVersion + + currentBuildTask = Task.detached(priority: .userInitiated) { [weak self] in + guard let attributedString = try? await buildAttributedString( + logs: logs, + monoFont: monoFont, + defaultColor: defaultColor, + searchText: searchText, + startIndex: startIndex ?? 0 + ) else { return } + await MainActor.run { + guard let self, isViewValid() else { return } + guard self.buildVersion == version else { return } + applyUpdate(attributedString, startIndex != nil) + self.currentBuildTask = nil + } + } + } + #endif + enum UpdateStrategy { case noUpdate case fullRebuild @@ -71,26 +107,18 @@ class LogCoordinator { } #if os(iOS) || os(macOS) - private func buildAttributedString(logs: [LogEntry], monoFont: PlatformFont, defaultColor: PlatformColor, searchText: String, baseAttributedString: NSAttributedString? = nil, startIndex: Int = 0) -> NSAttributedString { - let result: NSMutableAttributedString + private func buildAttributedString(logs: [LogEntry], monoFont: PlatformFont, defaultColor: PlatformColor, searchText: String, startIndex: Int = 0) async throws -> NSAttributedString { + let result = NSMutableAttributedString() let highlightColor: PlatformColor = .systemYellow - - if let base = baseAttributedString { - result = NSMutableAttributedString(attributedString: base) - // Add newline separator if appending to existing content - if result.length > 0 { - result.append(NSAttributedString(string: "\n", attributes: [ - .foregroundColor: defaultColor, - .font: monoFont, - ])) - } - } else { - result = NSMutableAttributedString() - } + let cancellationCheckInterval = 50 let logsToProcess = logs[startIndex...] for (offset, log) in logsToProcess.enumerated() { + if offset % cancellationCheckInterval == 0 { + try Task.checkCancellation() + } + let attributedString = ANSIColors.parseAnsiString(log.message) let nsAttributedString = NSMutableAttributedString(string: String(attributedString.characters)) @@ -136,46 +164,12 @@ class LogCoordinator { #endif #if os(iOS) - struct LogTextViewIOS: View { + struct LogTextViewIOS: UIViewRepresentable { let logs: [LogEntry] let font: Font let shouldAutoScroll: Bool let searchText: String - var body: some View { - ScrollViewReader { proxy in - ScrollView { - LogUITextView(logs: logs, searchText: searchText) - .font(font) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .topLeading) - .padding() - .id("logContent") - } - .onAppear { - if shouldAutoScroll { - scrollToBottom(proxy: proxy) - } - } - .onChangeCompat(of: logs.count) { _ in - if shouldAutoScroll { - scrollToBottom(proxy: proxy) - } - } - } - } - - private func scrollToBottom(proxy: ScrollViewProxy) { - DispatchQueue.main.async { - proxy.scrollTo("logContent", anchor: .bottom) - } - } - } - - struct LogUITextView: UIViewRepresentable { - let logs: [LogEntry] - let searchText: String - private static let monoFont = UIFont.monospacedSystemFont(ofSize: 11, weight: .regular) private static let defaultColor = UIColor.label @@ -183,58 +177,64 @@ class LogCoordinator { let textView = UITextView() textView.isEditable = false textView.isSelectable = true - textView.isScrollEnabled = false + textView.isScrollEnabled = true textView.backgroundColor = .clear - textView.textContainerInset = .zero + textView.textContainerInset = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) textView.textContainer.lineFragmentPadding = 0 textView.font = Self.monoFont textView.textColor = Self.defaultColor - textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - // Low hugging priority allows expansion to fill available space for proper ScrollView integration - textView.setContentHuggingPriority(.defaultLow, for: .vertical) return textView } func updateUIView(_ textView: UITextView, context: Context) { let updateStrategy = context.coordinator.shouldUpdate(logs: logs, searchText: searchText) + let startIndex: Int? switch updateStrategy { case .noUpdate: return case .fullRebuild: - let attributedString = buildAttributedString(logs: logs, monoFont: Self.monoFont, defaultColor: Self.defaultColor, searchText: searchText) - context.coordinator.cachedAttributedString = attributedString - textView.attributedText = attributedString - case let .incremental(from: startIndex): - // Build only the new logs, not the entire attributed string - let newLogsAttributedString = buildAttributedString(logs: logs, monoFont: Self.monoFont, defaultColor: Self.defaultColor, searchText: searchText, baseAttributedString: nil, startIndex: startIndex) - - // Append to existing text storage instead of replacing - let textStorage = textView.textStorage - - // Add newline separator before appending if there's existing content - if textStorage.length > 0 { - let newline = NSAttributedString(string: "\n", attributes: [ - .foregroundColor: Self.defaultColor, - .font: Self.monoFont, - ]) - textStorage.append(newline) - } - - textStorage.append(newLogsAttributedString) - - // Update cache to full content - context.coordinator.cachedAttributedString = NSAttributedString(attributedString: textStorage) + startIndex = nil + case let .incremental(from: index): + startIndex = index } - textView.invalidateIntrinsicContentSize() + let shouldAutoScroll = shouldAutoScroll + context.coordinator.scheduleBuildTask( + logs: logs, + searchText: searchText, + monoFont: Self.monoFont, + defaultColor: Self.defaultColor, + startIndex: startIndex, + isViewValid: { [weak textView] in textView?.window != nil }, + applyUpdate: { [weak textView] attributedString, isIncremental in + guard let textView else { return } + if isIncremental { + let textStorage = textView.textStorage + if textStorage.length > 0 { + textStorage.append(NSAttributedString(string: "\n", attributes: [ + .foregroundColor: Self.defaultColor, + .font: Self.monoFont, + ])) + } + textStorage.append(attributedString) + } else { + textView.attributedText = attributedString + } + if shouldAutoScroll { + Self.scrollToBottom(textView) + } + } + ) } - @available(iOS 16.0, *) - func sizeThatFits(_ proposal: ProposedViewSize, uiView: UITextView, context _: Context) -> CGSize? { - guard let width = proposal.width, width > 0 else { return nil } - let size = uiView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude)) - return CGSize(width: width, height: size.height) + private static func scrollToBottom(_ textView: UITextView) { + textView.layoutManager.ensureLayout(for: textView.textContainer) + textView.layoutIfNeeded() + let bottom = textView.contentSize.height - textView.bounds.height + textView.adjustedContentInset.bottom + if bottom > 0 { + textView.setContentOffset(CGPoint(x: 0, y: bottom), animated: false) + } } func makeCoordinator() -> LogCoordinator { @@ -271,6 +271,7 @@ class LogCoordinator { if let textContainer = textView.textContainer { textContainer.widthTracksTextView = true textContainer.containerSize = NSSize(width: scrollView.contentSize.width, height: .greatestFiniteMagnitude) + textContainer.lineFragmentPadding = 0 } scrollView.documentView = textView @@ -281,46 +282,47 @@ class LogCoordinator { guard let textView = scrollView.documentView as? NSTextView else { return } guard let textStorage = textView.textStorage else { return } - let lastCount = context.coordinator.lastLogsCount let updateStrategy = context.coordinator.shouldUpdate(logs: logs, searchText: searchText) + let startIndex: Int? switch updateStrategy { case .noUpdate: return case .fullRebuild: - let attributedText = buildAttributedString(logs: logs, monoFont: Self.monoFont, defaultColor: Self.defaultColor, searchText: searchText) - context.coordinator.cachedAttributedString = attributedText - textStorage.setAttributedString(attributedText) - case let .incremental(from: startIndex): - // Build only the new logs, not the entire attributed string - let newLogsAttributedString = buildAttributedString(logs: logs, monoFont: Self.monoFont, defaultColor: Self.defaultColor, searchText: searchText, baseAttributedString: nil, startIndex: startIndex) + startIndex = nil + case let .incremental(from: index): + startIndex = index + } - // Add newline separator before appending if there's existing content - if textStorage.length > 0 { - let newline = NSAttributedString(string: "\n", attributes: [ - .foregroundColor: Self.defaultColor, - .font: Self.monoFont, - ]) - textStorage.append(newline) + let shouldAutoScroll = shouldAutoScroll + context.coordinator.scheduleBuildTask( + logs: logs, + searchText: searchText, + monoFont: Self.monoFont, + defaultColor: Self.defaultColor, + startIndex: startIndex, + isViewValid: { [weak textView] in textView?.window != nil }, + applyUpdate: { [weak textView, weak textStorage] attributedString, isIncremental in + guard let textView, let textStorage else { return } + if isIncremental { + if textStorage.length > 0 { + textStorage.append(NSAttributedString(string: "\n", attributes: [ + .foregroundColor: Self.defaultColor, + .font: Self.monoFont, + ])) + } + textStorage.append(attributedString) + } else { + textStorage.setAttributedString(attributedString) + } + if let textContainer = textView.textContainer { + textView.layoutManager?.ensureLayout(for: textContainer) + } + if shouldAutoScroll { + textView.scrollToEndOfDocument(nil) + } } - - // Append to existing text storage instead of replacing - textStorage.append(newLogsAttributedString) - - // Update cache to full content - context.coordinator.cachedAttributedString = NSAttributedString(attributedString: textStorage) - } - - if let textContainer = textView.textContainer { - textView.layoutManager?.ensureLayout(for: textContainer) - } - - let shouldScroll = shouldAutoScroll && logs.count != lastCount - if shouldScroll { - DispatchQueue.main.async { - textView.scrollToEndOfDocument(nil) - } - } + ) } func makeCoordinator() -> LogCoordinator { diff --git a/ApplicationLibrary/Views/Log/LogViewModel.swift b/ApplicationLibrary/Views/Log/LogViewModel.swift index 380eeb8..2cb7aa9 100644 --- a/ApplicationLibrary/Views/Log/LogViewModel.swift +++ b/ApplicationLibrary/Views/Log/LogViewModel.swift @@ -12,6 +12,7 @@ import SwiftUI @MainActor public class LogDataModel: ObservableObject { @Published public var filteredLogs: [LogEntry] = [] + @Published public private(set) var visibleLogs: [LogEntry] = [] @Published public var showFileExporter = false @Published public var logFileURL: URL? @@ -27,11 +28,11 @@ public class LogDataModel: ObservableObject { public var isEmpty: Bool { commandClient.logList.isEmpty } public var isConnected: Bool { commandClient.isConnected } - public var visibleLogs: [LogEntry] { + private func updateVisibleLogs() { if filteredLogs.count <= Self.maxVisibleLogs { - return filteredLogs + visibleLogs = filteredLogs } else { - return Array(filteredLogs.suffix(Self.maxVisibleLogs)) + visibleLogs = Array(filteredLogs.suffix(Self.maxVisibleLogs)) } } @@ -72,6 +73,7 @@ public class LogDataModel: ObservableObject { } } + self.updateVisibleLogs() self.lastProcessedLogCount = logList.count self.lastEffectiveLevel = effectiveLevel self.lastSearchText = searchText