diff --git a/ApplicationLibrary/Views/Log/LogTextView.swift b/ApplicationLibrary/Views/Log/LogTextView.swift new file mode 100644 index 0000000..c472bfa --- /dev/null +++ b/ApplicationLibrary/Views/Log/LogTextView.swift @@ -0,0 +1,218 @@ +import Foundation +import Library +import SwiftUI + +#if canImport(UIKit) + import UIKit +#elseif canImport(AppKit) + import AppKit +#endif + +struct LogTextView: View { + let logs: [LogEntry] + let font: Font + let shouldAutoScroll: Bool + let searchText: String + + var body: some View { + #if os(iOS) + LogTextViewIOS(logs: logs, font: font, shouldAutoScroll: shouldAutoScroll, searchText: searchText) + #elseif os(macOS) + LogTextViewMacOS(logs: logs, font: font, shouldAutoScroll: shouldAutoScroll, searchText: searchText) + #endif + } +} + +class LogCoordinator { + var lastLogsCount: Int = 0 + var lastLog: LogEntry? + + func shouldUpdate(logs: [LogEntry]) -> Bool { + let currentCount = logs.count + if currentCount == lastLogsCount, currentCount > 0 { + if let lastLog = logs.last, let previousLastLog = self.lastLog { + if lastLog.id == previousLastLog.id { + return false + } + } + } + lastLogsCount = currentCount + lastLog = logs.last + return true + } +} + +#if os(iOS) || os(macOS) + private func buildAttributedString(logs: [LogEntry], monoFont: PlatformFont, defaultColor: PlatformColor, searchText: String) -> NSAttributedString { + let result = NSMutableAttributedString() + let highlightColor: PlatformColor = .systemYellow + + for (index, log) in logs.enumerated() { + let attributedString = ANSIColors.parseAnsiString(log.message) + let nsAttributedString = NSMutableAttributedString(string: String(attributedString.characters)) + + for run in attributedString.runs { + let range = NSRange(run.range, in: attributedString) + let color = run.foregroundColor.map { PlatformColor($0) } ?? defaultColor + + nsAttributedString.addAttribute(.foregroundColor, value: color, range: range) + nsAttributedString.addAttribute(.font, value: monoFont, range: range) + } + + if !searchText.isEmpty { + let fullString = nsAttributedString.string + var searchRange = fullString.startIndex ..< fullString.endIndex + + while let range = fullString.range(of: searchText, range: searchRange) { + let nsRange = NSRange(range, in: fullString) + nsAttributedString.addAttribute(.backgroundColor, value: highlightColor, range: nsRange) + searchRange = range.upperBound ..< fullString.endIndex + } + } + + result.append(nsAttributedString) + + if index < logs.count - 1 { + result.append(NSAttributedString(string: "\n", attributes: [ + .foregroundColor: defaultColor, + .font: monoFont, + ])) + } + } + return result + } + + #if os(iOS) + private typealias PlatformFont = UIFont + private typealias PlatformColor = UIColor + #elseif os(macOS) + private typealias PlatformFont = NSFont + private typealias PlatformColor = NSColor + #endif +#endif + +#if os(iOS) + struct LogTextViewIOS: View { + 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) + .frame(maxWidth: .infinity, alignment: .leading) + .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 + + func makeUIView(context _: Context) -> UITextView { + let textView = UITextView() + textView.isEditable = false + textView.isSelectable = true + textView.isScrollEnabled = false + textView.backgroundColor = .clear + textView.textContainerInset = .zero + textView.textContainer.lineFragmentPadding = 0 + textView.font = Self.monoFont + textView.textColor = Self.defaultColor + textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + return textView + } + + func updateUIView(_ textView: UITextView, context: Context) { + guard context.coordinator.shouldUpdate(logs: logs) else { return } + textView.attributedText = buildAttributedString(logs: logs, monoFont: Self.monoFont, defaultColor: Self.defaultColor, searchText: searchText) + } + + func makeCoordinator() -> LogCoordinator { + LogCoordinator() + } + } +#endif + +#if os(macOS) + struct LogTextViewMacOS: NSViewRepresentable { + let logs: [LogEntry] + let font: Font + let shouldAutoScroll: Bool + let searchText: String + + private static let monoFont = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular) + private static let defaultColor = NSColor.labelColor + + func makeNSView(context _: Context) -> NSScrollView { + let scrollView = NSScrollView() + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = true + + let textView = NSTextView() + textView.isEditable = false + textView.isSelectable = true + textView.drawsBackground = false + textView.textContainerInset = NSSize(width: 16, height: 16) + textView.font = Self.monoFont + textView.textColor = Self.defaultColor + textView.autoresizingMask = [.width] + + if let textContainer = textView.textContainer { + textContainer.widthTracksTextView = true + textContainer.containerSize = NSSize(width: scrollView.contentSize.width, height: .greatestFiniteMagnitude) + } + + scrollView.documentView = textView + return scrollView + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + guard let textView = scrollView.documentView as? NSTextView else { return } + + let lastCount = context.coordinator.lastLogsCount + guard context.coordinator.shouldUpdate(logs: logs) else { return } + + let attributedText = buildAttributedString(logs: logs, monoFont: Self.monoFont, defaultColor: Self.defaultColor, searchText: searchText) + let shouldScroll = shouldAutoScroll && logs.count != lastCount + + textView.textStorage?.setAttributedString(attributedText) + + if shouldScroll { + DispatchQueue.main.async { + textView.scrollToEndOfDocument(nil) + } + } + } + + func makeCoordinator() -> LogCoordinator { + LogCoordinator() + } + } +#endif diff --git a/ApplicationLibrary/Views/Log/LogView.swift b/ApplicationLibrary/Views/Log/LogView.swift index 416e95c..fdcf44a 100644 --- a/ApplicationLibrary/Views/Log/LogView.swift +++ b/ApplicationLibrary/Views/Log/LogView.swift @@ -25,14 +25,15 @@ private struct LogViewContent: View { if ApplicationLibrary.inPreview { previewContent } else if viewModel.isEmpty { - emptyStateContent + emptyContent } else if viewModel.filteredLogs.isEmpty { - emptyLogsContent + emptyContent } else { logScrollView } } #if !os(tvOS) + .applySearchable(text: $viewModel.searchText, isSearching: $viewModel.isSearching, shouldShow: viewModel.isSearching) .toolbar { ToolbarItemGroup { if !viewModel.isEmpty { @@ -52,80 +53,116 @@ private struct LogViewContent: View { "inbound/tun[0]: started at utun3", "sing-box started (1.666s)", ] - return ScrollView { - LazyVStack(alignment: .leading, spacing: 8) { - ForEach(logList.indices, id: \.self) { index in - Text(ANSIColors.parseAnsiString(logList[index])) - .font(logFont) - #if os(tvOS) - .focusable() - #endif - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .padding() - } #if os(tvOS) - .focusEffectDisabled() - .focusSection() - #endif - } - - private var emptyStateContent: some View { - VStack { - if viewModel.isConnected { - Text("Empty logs") - } else { - Text("Service not started").onAppear { - environments.connect() - } - } - } - } - - private var emptyLogsContent: some View { - Text("Empty logs") - } - - private var logScrollView: some View { - ScrollViewReader { reader in - ScrollView { + return ScrollView { LazyVStack(alignment: .leading, spacing: 8) { - ForEach(viewModel.filteredLogs) { logEntry in - Text(ANSIColors.parseAnsiString(logEntry.message)) + ForEach(logList.indices, id: \.self) { index in + Text(ANSIColors.parseAnsiString(logList[index])) .font(logFont) - #if os(tvOS) .focusable() - #endif } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .padding() } - #if os(tvOS) .focusEffectDisabled() .focusSection() - #endif - .onAppear { - scrollToLastEntry(reader: reader) + #else + let previewLogs = logList.enumerated().map { _, message in + LogEntry(level: 4, message: message) } - .onChangeCompat(of: viewModel.filteredLogs.count) { _ in - if !viewModel.isPaused { - scrollToLastEntry(reader: reader) - } - } - } - } - - private func scrollToLastEntry(reader: ScrollViewProxy) { - guard let lastEntry = viewModel.filteredLogs.last else { return } - withAnimation { - reader.scrollTo(lastEntry.id, anchor: .bottom) - } + return LogTextView( + logs: previewLogs, + font: logFont, + shouldAutoScroll: false, + searchText: "" + ) + #endif } + @ViewBuilder + private var emptyContent: some View { + if viewModel.isConnected { + Text("Empty logs") + } else { + Text("Service not started").onAppear { + environments.connect() + } + } + } + + private var logScrollView: some View { + #if os(tvOS) + ScrollViewReader { reader in + ScrollView { + LazyVStack(alignment: .leading, spacing: 8) { + ForEach(viewModel.filteredLogs) { logEntry in + Text(highlightedText(for: logEntry.message)) + .font(logFont) + .focusable() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding() + } + .focusEffectDisabled() + .focusSection() + .onAppear { + scrollToLastEntry(reader) + } + .onChangeCompat(of: viewModel.filteredLogs.count) { _ in + if !viewModel.isPaused { + scrollToLastEntry(reader) + } + } + } + #else + LogTextView( + logs: viewModel.filteredLogs, + font: logFont, + shouldAutoScroll: !viewModel.isPaused, + searchText: viewModel.searchText + ) + #endif + } + + #if os(tvOS) + private func highlightedText(for message: String) -> AttributedString { + var attributedString = ANSIColors.parseAnsiString(message) + + if !viewModel.searchText.isEmpty { + let searchText = viewModel.searchText + let messageString = String(attributedString.characters) + var searchRange = messageString.startIndex ..< messageString.endIndex + + while let range = messageString.range(of: searchText, range: searchRange) { + if let attributedRange = Range(range, in: attributedString) { + attributedString[attributedRange].backgroundColor = .yellow + } + searchRange = range.upperBound ..< messageString.endIndex + } + } + + return attributedString + } + #endif + + #if os(tvOS) + private func scrollToLastEntry(_ reader: ScrollViewProxy) { + guard let lastEntry = viewModel.filteredLogs.last else { return } + withAnimation { + reader.scrollTo(lastEntry.id, anchor: .bottom) + } + } + #endif + @ViewBuilder private var toolbarButtons: some View { + if #available(iOS 17.0, macOS 14.0, *) { + Button(action: viewModel.toggleSearch) { + Label("Search", systemImage: "magnifyingglass") + } + } Button(action: viewModel.togglePause) { Label( viewModel.isPaused ? NSLocalizedString("Resume", comment: "Resume log auto-scroll") : NSLocalizedString("Pause", comment: "Pause log auto-scroll"), @@ -152,3 +189,19 @@ private struct LogViewContent: View { } } } + +#if !os(tvOS) + private extension View { + func applySearchable(text: Binding, isSearching: Binding, shouldShow: Bool) -> some View { + if #available(iOS 17.0, macOS 14.0, *) { + if shouldShow { + return AnyView(searchable(text: text, isPresented: isSearching)) + } else { + return AnyView(self) + } + } else { + return AnyView(searchable(text: text)) + } + } + } +#endif diff --git a/ApplicationLibrary/Views/Log/LogViewModel.swift b/ApplicationLibrary/Views/Log/LogViewModel.swift index cac536f..7708de5 100644 --- a/ApplicationLibrary/Views/Log/LogViewModel.swift +++ b/ApplicationLibrary/Views/Log/LogViewModel.swift @@ -6,6 +6,8 @@ import Library public class LogViewModel: ObservableObject { @Published public var selectedLogLevel: Int? @Published public var isPaused = false + @Published public var searchText = "" + @Published public var isSearching = false @Published public var filteredLogs: [LogEntry] = [] private let commandClient: CommandClient @@ -16,14 +18,21 @@ public class LogViewModel: ObservableObject { public init(commandClient: CommandClient) { self.commandClient = commandClient - Publishers.CombineLatest3( + let debouncedSearchText = $searchText + .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) + + Publishers.CombineLatest4( commandClient.$logList, commandClient.$defaultLogLevel, - $selectedLogLevel + $selectedLogLevel, + debouncedSearchText ) - .map { logList, defaultLogLevel, selectedLogLevel in + .map { logList, defaultLogLevel, selectedLogLevel, searchText in let effectiveLevel = selectedLogLevel ?? defaultLogLevel - return logList.filter { $0.level <= effectiveLevel } + return logList.filter { log in + log.level <= effectiveLevel && + (searchText.isEmpty || log.message.contains(searchText)) + } } .receive(on: DispatchQueue.main) .assign(to: &$filteredLogs) @@ -33,6 +42,13 @@ public class LogViewModel: ObservableObject { isPaused.toggle() } + public func toggleSearch() { + isSearching.toggle() + if !isSearching { + searchText = "" + } + } + public func clearLogs() { commandClient.logList.removeAll() isPaused = false diff --git a/Library/Network/CommandClient.swift b/Library/Network/CommandClient.swift index 1f14541..97f41c7 100644 --- a/Library/Network/CommandClient.swift +++ b/Library/Network/CommandClient.swift @@ -5,6 +5,11 @@ public struct LogEntry: Identifiable { public let id = UUID() public let level: Int public let message: String + + public init(level: Int, message: String) { + self.level = level + self.message = message + } } public enum LogLevel: Int, CaseIterable, Identifiable { diff --git a/Localizable.xcstrings b/Localizable.xcstrings index f6297f5..614f6d6 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -1350,6 +1350,9 @@ } } } + }, + "Search" : { + }, "Select Device" : { "localizations" : {