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:
@@ -28,6 +28,7 @@ class LogCoordinator {
|
|||||||
var lastLogsCount: Int = 0
|
var lastLogsCount: Int = 0
|
||||||
var lastLog: LogEntry?
|
var lastLog: LogEntry?
|
||||||
var lastSearchText: String = ""
|
var lastSearchText: String = ""
|
||||||
|
var lastBackgroundColorHash: Int?
|
||||||
var buildVersion: Int = 0
|
var buildVersion: Int = 0
|
||||||
var currentBuildTask: Task<Void, Never>?
|
var currentBuildTask: Task<Void, Never>?
|
||||||
|
|
||||||
@@ -35,11 +36,20 @@ class LogCoordinator {
|
|||||||
currentBuildTask?.cancel()
|
currentBuildTask?.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
func shouldUpdate(logs: [LogEntry], searchText: String) -> UpdateStrategy {
|
func shouldUpdate(logs: [LogEntry], searchText: String, backgroundColorHash: Int) -> UpdateStrategy {
|
||||||
let currentCount = logs.count
|
let currentCount = logs.count
|
||||||
let searchChanged = searchText != lastSearchText
|
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 currentCount == lastLogsCount, currentCount > 0, !searchChanged {
|
||||||
if let lastLog = logs.last, let previousLastLog = self.lastLog {
|
if let lastLog = logs.last, let previousLastLog = self.lastLog {
|
||||||
if lastLog.id == previousLastLog.id {
|
if lastLog.id == previousLastLog.id {
|
||||||
@@ -48,16 +58,12 @@ class LogCoordinator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine update strategy
|
|
||||||
let strategy: UpdateStrategy
|
let strategy: UpdateStrategy
|
||||||
if currentCount == 0 || searchChanged || lastLogsCount > currentCount {
|
if currentCount == 0 || searchChanged || lastLogsCount > currentCount {
|
||||||
// Full rebuild needed
|
|
||||||
strategy = .fullRebuild
|
strategy = .fullRebuild
|
||||||
} else if currentCount > lastLogsCount {
|
} else if currentCount > lastLogsCount {
|
||||||
// Incremental update possible
|
|
||||||
strategy = .incremental(from: lastLogsCount)
|
strategy = .incremental(from: lastLogsCount)
|
||||||
} else {
|
} else {
|
||||||
// Same count but different last log (shouldn't happen normally)
|
|
||||||
strategy = .fullRebuild
|
strategy = .fullRebuild
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +79,7 @@ class LogCoordinator {
|
|||||||
searchText: String,
|
searchText: String,
|
||||||
monoFont: PlatformFont,
|
monoFont: PlatformFont,
|
||||||
defaultColor: PlatformColor,
|
defaultColor: PlatformColor,
|
||||||
|
backgroundColor: PlatformColor,
|
||||||
startIndex: Int?,
|
startIndex: Int?,
|
||||||
isViewValid: @escaping @MainActor () -> Bool,
|
isViewValid: @escaping @MainActor () -> Bool,
|
||||||
applyUpdate: @escaping @MainActor (NSAttributedString, Bool) -> Void
|
applyUpdate: @escaping @MainActor (NSAttributedString, Bool) -> Void
|
||||||
@@ -86,6 +93,7 @@ class LogCoordinator {
|
|||||||
logs: logs,
|
logs: logs,
|
||||||
monoFont: monoFont,
|
monoFont: monoFont,
|
||||||
defaultColor: defaultColor,
|
defaultColor: defaultColor,
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
searchText: searchText,
|
searchText: searchText,
|
||||||
startIndex: startIndex ?? 0
|
startIndex: startIndex ?? 0
|
||||||
) else { return }
|
) else { return }
|
||||||
@@ -107,7 +115,7 @@ class LogCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS) || os(macOS)
|
#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 result = NSMutableAttributedString()
|
||||||
let highlightColor: PlatformColor = .systemYellow
|
let highlightColor: PlatformColor = .systemYellow
|
||||||
let cancellationCheckInterval = 50
|
let cancellationCheckInterval = 50
|
||||||
@@ -124,7 +132,8 @@ class LogCoordinator {
|
|||||||
|
|
||||||
for run in attributedString.runs {
|
for run in attributedString.runs {
|
||||||
let range = NSRange(run.range, in: attributedString)
|
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(.foregroundColor, value: color, range: range)
|
||||||
nsAttributedString.addAttribute(.font, value: monoFont, range: range)
|
nsAttributedString.addAttribute(.font, value: monoFont, range: range)
|
||||||
@@ -187,7 +196,10 @@ class LogCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func updateUIView(_ textView: UITextView, context: Context) {
|
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?
|
let startIndex: Int?
|
||||||
switch updateStrategy {
|
switch updateStrategy {
|
||||||
@@ -205,6 +217,7 @@ class LogCoordinator {
|
|||||||
searchText: searchText,
|
searchText: searchText,
|
||||||
monoFont: Self.monoFont,
|
monoFont: Self.monoFont,
|
||||||
defaultColor: Self.defaultColor,
|
defaultColor: Self.defaultColor,
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
startIndex: startIndex,
|
startIndex: startIndex,
|
||||||
isViewValid: { [weak textView] in textView?.window != nil },
|
isViewValid: { [weak textView] in textView?.window != nil },
|
||||||
applyUpdate: { [weak textView] attributedString, isIncremental in
|
applyUpdate: { [weak textView] attributedString, isIncremental in
|
||||||
@@ -282,7 +295,10 @@ class LogCoordinator {
|
|||||||
guard let textView = scrollView.documentView as? NSTextView else { return }
|
guard let textView = scrollView.documentView as? NSTextView else { return }
|
||||||
guard let textStorage = textView.textStorage 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?
|
let startIndex: Int?
|
||||||
switch updateStrategy {
|
switch updateStrategy {
|
||||||
@@ -300,6 +316,7 @@ class LogCoordinator {
|
|||||||
searchText: searchText,
|
searchText: searchText,
|
||||||
monoFont: Self.monoFont,
|
monoFont: Self.monoFont,
|
||||||
defaultColor: Self.defaultColor,
|
defaultColor: Self.defaultColor,
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
startIndex: startIndex,
|
startIndex: startIndex,
|
||||||
isViewValid: { [weak textView] in textView?.window != nil },
|
isViewValid: { [weak textView] in textView?.window != nil },
|
||||||
applyUpdate: { [weak textView, weak textStorage] attributedString, isIncremental in
|
applyUpdate: { [weak textView, weak textStorage] attributedString, isIncremental in
|
||||||
|
|||||||
@@ -221,6 +221,7 @@ private struct LogContentInnerView: View {
|
|||||||
@EnvironmentObject private var environments: ExtensionEnvironments
|
@EnvironmentObject private var environments: ExtensionEnvironments
|
||||||
@ObservedObject var dataModel: LogDataModel
|
@ObservedObject var dataModel: LogDataModel
|
||||||
@ObservedObject var viewModel: LogViewModel
|
@ObservedObject var viewModel: LogViewModel
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
private let logFont = Font.system(.caption2, design: .monospaced)
|
private let logFont = Font.system(.caption2, design: .monospaced)
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -248,7 +249,7 @@ private struct LogContentInnerView: View {
|
|||||||
return ScrollView {
|
return ScrollView {
|
||||||
LazyVStack(alignment: .leading, spacing: 8) {
|
LazyVStack(alignment: .leading, spacing: 8) {
|
||||||
ForEach(logList.indices, id: \.self) { index in
|
ForEach(logList.indices, id: \.self) { index in
|
||||||
Text(ANSIColors.parseAnsiString(logList[index]))
|
Text(contrastAdjustedText(for: logList[index]))
|
||||||
.font(logFont)
|
.font(logFont)
|
||||||
.focusable()
|
.focusable()
|
||||||
}
|
}
|
||||||
@@ -321,8 +322,23 @@ private struct LogContentInnerView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
private func highlightedText(for message: String) -> AttributedString {
|
private func contrastAdjustedText(for message: String) -> AttributedString {
|
||||||
var attributedString = ANSIColors.parseAnsiString(message)
|
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 {
|
if !viewModel.searchText.isEmpty {
|
||||||
let searchText = viewModel.searchText
|
let searchText = viewModel.searchText
|
||||||
|
|||||||
@@ -23,3 +23,151 @@ public extension Color {
|
|||||||
#endif
|
#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
|
||||||
|
|||||||
Reference in New Issue
Block a user