diff --git a/ApplicationLibrary/Views/Log/LogTextView.swift b/ApplicationLibrary/Views/Log/LogTextView.swift index cce141d..54bd2c9 100644 --- a/ApplicationLibrary/Views/Log/LogTextView.swift +++ b/ApplicationLibrary/Views/Log/LogTextView.swift @@ -28,6 +28,7 @@ class LogCoordinator { var lastLogsCount: Int = 0 var lastLog: LogEntry? var lastSearchText: String = "" + var lastBackgroundColorHash: Int? var buildVersion: Int = 0 var currentBuildTask: Task? @@ -35,11 +36,20 @@ class LogCoordinator { currentBuildTask?.cancel() } - func shouldUpdate(logs: [LogEntry], searchText: String) -> UpdateStrategy { + func shouldUpdate(logs: [LogEntry], searchText: String, backgroundColorHash: Int) -> UpdateStrategy { let currentCount = logs.count let searchChanged = searchText != lastSearchText + let colorSchemeChanged = lastBackgroundColorHash != nil && lastBackgroundColorHash != backgroundColorHash + + lastBackgroundColorHash = backgroundColorHash + + if colorSchemeChanged { + lastLogsCount = currentCount + lastLog = logs.last + lastSearchText = searchText + return .fullRebuild + } - // Check if nothing changed if currentCount == lastLogsCount, currentCount > 0, !searchChanged { if let lastLog = logs.last, let previousLastLog = self.lastLog { if lastLog.id == previousLastLog.id { @@ -48,16 +58,12 @@ class LogCoordinator { } } - // Determine update strategy let strategy: UpdateStrategy if currentCount == 0 || searchChanged || lastLogsCount > currentCount { - // Full rebuild needed strategy = .fullRebuild } else if currentCount > lastLogsCount { - // Incremental update possible strategy = .incremental(from: lastLogsCount) } else { - // Same count but different last log (shouldn't happen normally) strategy = .fullRebuild } @@ -73,6 +79,7 @@ class LogCoordinator { searchText: String, monoFont: PlatformFont, defaultColor: PlatformColor, + backgroundColor: PlatformColor, startIndex: Int?, isViewValid: @escaping @MainActor () -> Bool, applyUpdate: @escaping @MainActor (NSAttributedString, Bool) -> Void @@ -86,6 +93,7 @@ class LogCoordinator { logs: logs, monoFont: monoFont, defaultColor: defaultColor, + backgroundColor: backgroundColor, searchText: searchText, startIndex: startIndex ?? 0 ) else { return } @@ -107,7 +115,7 @@ class LogCoordinator { } #if os(iOS) || os(macOS) - private func buildAttributedString(logs: [LogEntry], monoFont: PlatformFont, defaultColor: PlatformColor, searchText: String, startIndex: Int = 0) async throws -> NSAttributedString { + private func buildAttributedString(logs: [LogEntry], monoFont: PlatformFont, defaultColor: PlatformColor, backgroundColor: PlatformColor, searchText: String, startIndex: Int = 0) async throws -> NSAttributedString { let result = NSMutableAttributedString() let highlightColor: PlatformColor = .systemYellow let cancellationCheckInterval = 50 @@ -124,7 +132,8 @@ class LogCoordinator { for run in attributedString.runs { let range = NSRange(run.range, in: attributedString) - let color = run.foregroundColor.map { PlatformColor($0) } ?? defaultColor + var color = run.foregroundColor.map { PlatformColor($0) } ?? defaultColor + color = color.adjustedForContrast(against: backgroundColor) nsAttributedString.addAttribute(.foregroundColor, value: color, range: range) nsAttributedString.addAttribute(.font, value: monoFont, range: range) @@ -187,7 +196,10 @@ class LogCoordinator { } func updateUIView(_ textView: UITextView, context: Context) { - let updateStrategy = context.coordinator.shouldUpdate(logs: logs, searchText: searchText) + let backgroundColor = UIColor.systemBackground.resolvedColor(with: textView.traitCollection) + let backgroundColorHash = backgroundColor.hash + + let updateStrategy = context.coordinator.shouldUpdate(logs: logs, searchText: searchText, backgroundColorHash: backgroundColorHash) let startIndex: Int? switch updateStrategy { @@ -205,6 +217,7 @@ class LogCoordinator { searchText: searchText, monoFont: Self.monoFont, defaultColor: Self.defaultColor, + backgroundColor: backgroundColor, startIndex: startIndex, isViewValid: { [weak textView] in textView?.window != nil }, applyUpdate: { [weak textView] attributedString, isIncremental in @@ -282,7 +295,10 @@ class LogCoordinator { guard let textView = scrollView.documentView as? NSTextView else { return } guard let textStorage = textView.textStorage else { return } - let updateStrategy = context.coordinator.shouldUpdate(logs: logs, searchText: searchText) + let backgroundColor = NSColor.textBackgroundColor + let backgroundColorHash = backgroundColor.hash + + let updateStrategy = context.coordinator.shouldUpdate(logs: logs, searchText: searchText, backgroundColorHash: backgroundColorHash) let startIndex: Int? switch updateStrategy { @@ -300,6 +316,7 @@ class LogCoordinator { searchText: searchText, monoFont: Self.monoFont, defaultColor: Self.defaultColor, + backgroundColor: backgroundColor, startIndex: startIndex, isViewValid: { [weak textView] in textView?.window != nil }, applyUpdate: { [weak textView, weak textStorage] attributedString, isIncremental in diff --git a/ApplicationLibrary/Views/Log/LogView.swift b/ApplicationLibrary/Views/Log/LogView.swift index 7f68609..c7c5017 100644 --- a/ApplicationLibrary/Views/Log/LogView.swift +++ b/ApplicationLibrary/Views/Log/LogView.swift @@ -221,6 +221,7 @@ private struct LogContentInnerView: View { @EnvironmentObject private var environments: ExtensionEnvironments @ObservedObject var dataModel: LogDataModel @ObservedObject var viewModel: LogViewModel + @Environment(\.colorScheme) private var colorScheme private let logFont = Font.system(.caption2, design: .monospaced) var body: some View { @@ -248,7 +249,7 @@ private struct LogContentInnerView: View { return ScrollView { LazyVStack(alignment: .leading, spacing: 8) { ForEach(logList.indices, id: \.self) { index in - Text(ANSIColors.parseAnsiString(logList[index])) + Text(contrastAdjustedText(for: logList[index])) .font(logFont) .focusable() } @@ -321,8 +322,23 @@ private struct LogContentInnerView: View { } #if os(tvOS) - private func highlightedText(for message: String) -> AttributedString { + private func contrastAdjustedText(for message: String) -> AttributedString { var attributedString = ANSIColors.parseAnsiString(message) + let backgroundColor: UIColor = colorScheme == .dark ? .black : .white + + for run in attributedString.runs { + if let fgColor = run.foregroundColor { + let uiColor = UIColor(fgColor) + let adjusted = uiColor.adjustedForContrast(against: backgroundColor) + attributedString[run.range].foregroundColor = Color(adjusted) + } + } + + return attributedString + } + + private func highlightedText(for message: String) -> AttributedString { + var attributedString = contrastAdjustedText(for: message) if !viewModel.searchText.isEmpty { let searchText = viewModel.searchText diff --git a/Library/Shared/Color+Extension.swift b/Library/Shared/Color+Extension.swift index 3efa58c..68857da 100644 --- a/Library/Shared/Color+Extension.swift +++ b/Library/Shared/Color+Extension.swift @@ -23,3 +23,151 @@ public extension Color { #endif } } + +#if canImport(UIKit) + public extension UIColor { + var relativeLuminance: CGFloat { + var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0 + getRed(&red, green: &green, blue: &blue, alpha: &alpha) + + func linearize(_ c: CGFloat) -> CGFloat { + c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4) + } + + return 0.2126 * linearize(red) + 0.7152 * linearize(green) + 0.0722 * linearize(blue) + } + + static func contrastRatio(_ color1: UIColor, _ color2: UIColor) -> CGFloat { + let l1 = color1.relativeLuminance + let l2 = color2.relativeLuminance + let lighter = max(l1, l2) + let darker = min(l1, l2) + return (lighter + 0.05) / (darker + 0.05) + } + + func adjustedForContrast(against background: UIColor, minRatio: CGFloat = 4.5) -> UIColor { + let currentRatio = Self.contrastRatio(self, background) + if currentRatio >= minRatio { + return self + } + + var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0 + getRed(&red, green: &green, blue: &blue, alpha: &alpha) + + let bgLuminance = background.relativeLuminance + let shouldDarken = bgLuminance > 0.5 + + var low: CGFloat = 0 + var high: CGFloat = 1 + var bestColor = self + + for _ in 0 ..< 10 { + let mid = (low + high) / 2 + let adjusted: UIColor + + if shouldDarken { + adjusted = UIColor( + red: red * (1 - mid), + green: green * (1 - mid), + blue: blue * (1 - mid), + alpha: alpha + ) + } else { + adjusted = UIColor( + red: red + (1 - red) * mid, + green: green + (1 - green) * mid, + blue: blue + (1 - blue) * mid, + alpha: alpha + ) + } + + let ratio = Self.contrastRatio(adjusted, background) + if ratio >= minRatio { + bestColor = adjusted + high = mid + } else { + low = mid + } + } + + return bestColor + } + } +#elseif canImport(AppKit) + public extension NSColor { + var relativeLuminance: CGFloat { + guard let rgbColor = usingColorSpace(.sRGB) else { + return 0.5 + } + + var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0 + rgbColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + + func linearize(_ c: CGFloat) -> CGFloat { + c <= 0.04045 ? c / 12.92 : pow((c + 0.055) / 1.055, 2.4) + } + + return 0.2126 * linearize(red) + 0.7152 * linearize(green) + 0.0722 * linearize(blue) + } + + static func contrastRatio(_ color1: NSColor, _ color2: NSColor) -> CGFloat { + let l1 = color1.relativeLuminance + let l2 = color2.relativeLuminance + let lighter = max(l1, l2) + let darker = min(l1, l2) + return (lighter + 0.05) / (darker + 0.05) + } + + func adjustedForContrast(against background: NSColor, minRatio: CGFloat = 4.5) -> NSColor { + let currentRatio = Self.contrastRatio(self, background) + if currentRatio >= minRatio { + return self + } + + guard let rgbSelf = usingColorSpace(.sRGB) else { + return self + } + + var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0 + rgbSelf.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + + let bgLuminance = background.relativeLuminance + let shouldDarken = bgLuminance > 0.5 + + var low: CGFloat = 0 + var high: CGFloat = 1 + var bestColor = self + + for _ in 0 ..< 10 { + let mid = (low + high) / 2 + let adjusted: NSColor + + if shouldDarken { + adjusted = NSColor( + red: red * (1 - mid), + green: green * (1 - mid), + blue: blue * (1 - mid), + alpha: alpha + ) + } else { + adjusted = NSColor( + red: red + (1 - red) * mid, + green: green + (1 - green) * mid, + blue: blue + (1 - blue) * mid, + alpha: alpha + ) + } + + let ratio = Self.contrastRatio(adjusted, background) + if ratio >= minRatio { + bestColor = adjusted + high = mid + } else { + low = mid + } + } + + return bestColor + } + } +#endif