Add ANSI color contrast adjustment for log view

Automatically adjust log text colors when contrast against background
is below WCAG AA standard (4.5:1). Light mode darkens colors, dark mode
lightens them. Supports iOS, macOS, and tvOS.
This commit is contained in:
世界
2026-01-08 22:12:45 +08:00
parent 1707a3412a
commit b0f1eed827
3 changed files with 193 additions and 12 deletions
+27 -10
View File
@@ -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<Void, Never>?
@@ -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
+18 -2
View File
@@ -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