fix(appearance): 完善深色模式和阅读页面对比度

- 区分按钮填充与图标颜色,统一书架、书源、搜索和阅读设置的选中态
- 调整编辑器语法高亮、键盘工具栏及统计卡片的浅深色显示
- 适配 RSS 正文和登录网页背景,保留书源自定义样式和页面状态
- 补充外观切换、页面对比度和管理页面的回归测试
This commit is contained in:
Aminark
2026-10-22 10:53:19 +08:00
parent bf72ebc894
commit 45cf5b5482
21 changed files with 611 additions and 46 deletions
+16
View File
@@ -340,6 +340,22 @@ extension View {
}
}
@ViewBuilder
func readingNavigationBarBackground() -> some View {
if #available(iOS 16.0, *), !ReadingCompatibility.usesLegacyUI {
toolbarBackground(ReadingTheme.canvas, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
} else {
background(ReadingUIKitConfiguration { controller in
let appearance = UINavigationBarAppearance()
appearance.configureWithOpaqueBackground()
appearance.backgroundColor = UIColor(ReadingTheme.canvas)
controller.navigationController?.navigationBar.standardAppearance = appearance
controller.navigationController?.navigationBar.scrollEdgeAppearance = appearance
})
}
}
@ViewBuilder
func readingTabBarBackground() -> some View {
if #available(iOS 16.0, *), !ReadingCompatibility.usesLegacyUI {
+29 -2
View File
@@ -77,6 +77,29 @@ private struct ReadingUnavailableLabelStyle: LabelStyle {
}
extension View {
/// Keep filled controls dark in both appearances, independently of icon tint.
func readingProminentButtonStyle() -> some View {
buttonStyle(.borderedProminent)
.tint(ReadingTheme.accentFill)
.foregroundStyle(ReadingTheme.onAccent)
}
func readingSecondaryButtonStyle() -> some View {
buttonStyle(.bordered)
.tint(ReadingTheme.accentFill)
.foregroundStyle(ReadingTheme.accent)
}
@ViewBuilder
func readingChoiceButtonStyle(isSelected: Bool) -> some View {
if isSelected {
readingProminentButtonStyle()
.accessibilityAddTraits(.isSelected)
} else {
readingSecondaryButtonStyle()
}
}
func readingSearchable(text: Binding<String>, isPresented: Binding<Bool>, prompt: String) -> some View {
modifier(ReadingSearchModifier(text: text, isPresented: isPresented, prompt: prompt))
}
@@ -144,7 +167,11 @@ private struct ReadingSearchModifier: ViewModifier {
}
enum ReadingTheme {
static let accent = adaptive(0x171717, dark: 0xF2F2F2)
static let accent = adaptive(0x171717, dark: 0xDADAE0)
static let accentFill = adaptive(0x171717, dark: 0x414148)
static let emphasisSurface = adaptive(0x171717, dark: 0x29292E)
static let selectionBorder = adaptive(0x171717, dark: 0x777780)
static let toggleTint = Color(uiColor: .systemGreen)
static let accentSoft = adaptive(0xEEEEEF, dark: 0x29292C)
static let coral = Color(red: 0.76, green: 0.48, blue: 0.27)
static let paper = Color(red: 0.965, green: 0.945, blue: 0.895)
@@ -153,7 +180,7 @@ enum ReadingTheme {
static let groupedCanvas = adaptive(0xF3F3F4, dark: 0x1C1C1F)
static let surface = adaptive(0xFFFFFF, dark: 0x232326)
static let separator = adaptive(0xE6E6E8, dark: 0x363639)
static let onAccent = adaptive(0xFFFFFF, dark: 0x171717)
static let onAccent = Color.white
private static func adaptive(_ light: UInt32, dark: UInt32) -> Color {
Color(uiColor: UIColor { traits in
@@ -47,7 +47,9 @@ struct ReadingStatisticsView: View {
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(24)
.background(ReadingTheme.accent, in: RoundedRectangle(cornerRadius: 20))
.background(ReadingTheme.emphasisSurface, in: RoundedRectangle(cornerRadius: 20))
.accessibilityElement(children: .contain)
.accessibilityIdentifier("statistics.todayCard")
}
private var overviewGrid: some View {
+2 -2
View File
@@ -194,7 +194,7 @@ struct BookDetailView: View {
.font(.body.weight(.medium))
.frame(maxWidth: .infinity, minHeight: 28)
}
.buttonStyle(.borderedProminent)
.readingProminentButtonStyle()
.disabled(model.chapters.isEmpty)
Button {
if library.contains(model.book) {
@@ -210,7 +210,7 @@ struct BookDetailView: View {
.font(.subheadline)
.frame(maxWidth: dynamicTypeSize.isAccessibilitySize ? .infinity : nil, minHeight: 28)
}
.buttonStyle(.bordered)
.readingSecondaryButtonStyle()
}
@ViewBuilder
+1 -1
View File
@@ -155,7 +155,7 @@ struct ExploreView: View {
Text("添加带有发现分类的书源,即可在这里浏览书籍。")
} actions: {
NavigationLink("管理书源") { SourceManagementView() }
.buttonStyle(.borderedProminent)
.readingProminentButtonStyle()
}
} else {
LazyVStack(spacing: 0) {
+9 -4
View File
@@ -142,7 +142,7 @@ private struct RSSArticleListView: View {
ScrollView(.horizontal, showsIndicators: false) {
HStack { ForEach(Array(sorts.enumerated()), id: \.element.id) { index, sort in
Button(sort.name.isEmpty ? "默认" : sort.name) { selectedSort = index; Task { await refresh() } }
.buttonStyle(.bordered).tint(selectedSort == index ? ReadingTheme.accent : .secondary)
.readingChoiceButtonStyle(isSelected: selectedSort == index)
}}.padding(.horizontal)
}.padding(.vertical, 6)
}
@@ -435,7 +435,7 @@ private struct RSSDownloadView: View {
Image(systemName: "doc.badge.arrow.up").font(.system(size: 48)).foregroundStyle(ReadingTheme.accent)
Text(fileName).font(.headline)
Text(ByteCountFormatter.string(fromByteCount: Int64(data.count), countStyle: .file)).foregroundStyle(.secondary)
Button("选择保存位置", systemImage: "folder") { showsExporter = true }.buttonStyle(.borderedProminent)
Button("选择保存位置", systemImage: "folder") { showsExporter = true }.readingProminentButtonStyle()
}
}.padding().navigationTitle("文件下载").toolbar { ToolbarItem(placement: .cancellationAction) { Button("关闭") { dismiss() } } }
.task(id: url) { await download() }
@@ -464,7 +464,7 @@ private struct RSSBinaryDocument: FileDocument {
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { .init(regularFileWithContents: data) }
}
private struct RSSPhotoView: View {
struct RSSPhotoView: View {
@Environment(\.dismiss) private var dismiss
let value: String
let source: RssSource
@@ -480,9 +480,13 @@ private struct RSSPhotoView: View {
else if loading { ProgressView() }
else { ReadingUnavailableView("图片加载失败", systemImage: "photo.badge.exclamationmark") }
}.frame(minWidth: proxy.size.width, minHeight: proxy.size.height)
}.background(.black)
}
.background(.black)
.environment(\.colorScheme, .dark)
.tint(.white)
}
.navigationTitle(source.sourceName).navigationBarTitleDisplayMode(.inline)
.readingNavigationBarBackground()
.toolbar {
ToolbarItem(placement: .primaryAction) { Button("保存到照片", systemImage: "square.and.arrow.down") { save() }.disabled(image == nil) }
ToolbarItem(placement: .confirmationAction) { Button("完成") { dismiss() } }
@@ -490,6 +494,7 @@ private struct RSSPhotoView: View {
.task(id: value) { await load() }
.alert("图片", isPresented: Binding(get: { message != nil }, set: { if !$0 { message = nil } })) { Button("好") { message = nil } } message: { Text(message ?? "") }
}
.readingTabBarHidden()
}
private func load() async {
+3
View File
@@ -41,6 +41,9 @@ struct RSSWebReaderView: UIViewRepresentable {
controller.addUserScript(.init(source: inject, injectionTime: .atDocumentEnd, forMainFrameOnly: false))
}
let webView = WKWebView(frame: .zero, configuration: configuration)
webView.isOpaque = false
webView.backgroundColor = UIColor(ReadingTheme.canvas)
webView.scrollView.backgroundColor = UIColor(ReadingTheme.canvas)
webView.navigationDelegate = context.coordinator
webView.uiDelegate = context.coordinator
webView.allowsBackForwardNavigationGestures = true
+16 -7
View File
@@ -492,7 +492,7 @@ struct ReaderView: View {
.accessibilityIdentifier("reader.failureChangeSource")
}
}
.buttonStyle(.bordered)
.readingSecondaryButtonStyle()
}
private func openSourceSwitch() {
@@ -1118,7 +1118,7 @@ struct ReaderView: View {
Image(systemName: "textformat.size.smaller")
.frame(width: 38, height: 32)
}
.buttonStyle(.bordered)
.readingSecondaryButtonStyle()
VStack(spacing: 2) {
Text("字号")
@@ -1135,7 +1135,7 @@ struct ReaderView: View {
Image(systemName: "textformat.size.larger")
.frame(width: 38, height: 32)
}
.buttonStyle(.bordered)
.readingSecondaryButtonStyle()
}
HStack(spacing: 8) {
@@ -1285,14 +1285,19 @@ struct ReaderView: View {
Text(choice.title)
.font(.caption2)
}
.foregroundStyle(pageAnimation == choice.value ? Color.white : Color.primary)
.foregroundStyle(pageAnimation == choice.value ? ReadingTheme.onAccent : Color.primary)
.frame(width: 58, height: 54)
.background(
pageAnimation == choice.value ? ReadingTheme.accent : Color.secondary.opacity(0.1),
pageAnimation == choice.value ? ReadingTheme.accentFill : Color.secondary.opacity(0.1),
in: RoundedRectangle(cornerRadius: 12, style: .continuous)
)
.overlay {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(pageAnimation == choice.value ? ReadingTheme.selectionBorder : .clear, lineWidth: 1)
}
}
.buttonStyle(.plain)
.accessibilityAddTraits(pageAnimation == choice.value ? .isSelected : [])
}
}
}
@@ -1392,12 +1397,16 @@ struct ReaderView: View {
Button(action: action) {
Text(title)
.font(.caption.weight(.medium))
.foregroundStyle(selected ? Color.white : Color.primary)
.foregroundStyle(selected ? ReadingTheme.onAccent : Color.primary)
.padding(.horizontal, 12)
.padding(.vertical, 7)
.background(selected ? ReadingTheme.accent : Color.secondary.opacity(0.1), in: Capsule())
.background(selected ? ReadingTheme.accentFill : Color.secondary.opacity(0.1), in: Capsule())
.overlay {
Capsule().strokeBorder(selected ? ReadingTheme.selectionBorder : .clear, lineWidth: 1)
}
}
.buttonStyle(.plain)
.accessibilityAddTraits(selected ? .isSelected : [])
}
private func valueSlider(
@@ -39,7 +39,7 @@ struct QRCodeImportView: View {
} label: {
Label("从照片选择二维码", systemImage: "photo")
.frame(maxWidth: .infinity)
}.buttonStyle(.borderedProminent).disabled(isReading)
}.readingProminentButtonStyle().disabled(isReading)
Text("支持 HTTP(S) 链接、Legado 导入深链和 JSON 内容。识别后进入导入流程。")
.font(.footnote).foregroundStyle(.secondary)
if values.count > 1 {
@@ -3,6 +3,26 @@ import UIKit
struct SyntaxCodeEditor: UIViewRepresentable {
enum Language { case json, rule, javascript, regex }
// Syntax colors are text, so they need more contrast than system icon tints.
private enum Palette {
static let keyword = adaptive(0x7A2BA6, dark: 0xD5A5F5)
static let number = adaptive(0x895900, dark: 0xE6B66A)
static let operation = adaptive(0xAD285F, dark: 0xF49AB8)
static let punctuation = adaptive(0x14666E, dark: 0x83CED8)
static let string = adaptive(0x2D6B39, dark: 0x8CCF96)
static let key = adaptive(0x1E5EB2, dark: 0x93BFFF)
static let comment = adaptive(0x64646B, dark: 0xA4A4AE)
private static func adaptive(_ light: UInt32, dark: UInt32) -> UIColor {
UIColor { traits in
let hex = traits.userInterfaceStyle == .dark ? dark : light
return UIColor(red: CGFloat((hex >> 16) & 0xff) / 255,
green: CGFloat((hex >> 8) & 0xff) / 255,
blue: CGFloat(hex & 0xff) / 255, alpha: 1)
}
}
}
@Binding var text: String
var language: Language = .rule
@@ -13,6 +33,7 @@ struct SyntaxCodeEditor: UIViewRepresentable {
view.delegate = context.coordinator
view.font = .monospacedSystemFont(ofSize: 14, weight: .regular)
view.backgroundColor = .secondarySystemGroupedBackground
view.tintColor = UIColor(ReadingTheme.accent)
view.textContainerInset = UIEdgeInsets(top: 14, left: 12, bottom: 20, right: 12)
view.autocorrectionType = .no
view.autocapitalizationType = .none
@@ -24,6 +45,7 @@ struct SyntaxCodeEditor: UIViewRepresentable {
view.accessibilityIdentifier = "syntaxCodeEditor"
if #available(iOS 16.0, *) { view.isFindInteractionEnabled = true }
let bar = UIToolbar()
bar.tintColor = UIColor(ReadingTheme.accent)
bar.sizeToFit()
bar.items = [
UIBarButtonItem(image: UIImage(systemName: "magnifyingglass"), primaryAction: UIAction(title: "", subtitle: nil) { [weak view, coordinator = context.coordinator] _ in
@@ -126,12 +148,12 @@ struct SyntaxCodeEditor: UIViewRepresentable {
storage.addAttributes([.font: UIFont.monospacedSystemFont(ofSize: 14, weight: .regular), .foregroundColor: UIColor.label], range: all)
if storage.length <= 500_000 {
let tokens: [(String, UIColor)] = [
(#"\b(?:true|false|null|undefined|return|var|let|const|if|else|function|new|for|while|try|catch|throw|await|async)\b"#, .systemPurple),
(#"\b\d+(?:\.\d+)?\b"#, .systemOrange),
(#"@(?:css|xpath|json|js|put|get):?|\$\.|&&|\|\||%%|##|\{\{|\}\}"#, .systemPink),
(#"[{}\[\]()]"#, .systemTeal),
(#""(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`"#, .systemGreen),
(#""(?:\\.|[^"\\])*"(?=\s*:)"#, .systemBlue),
(#"\b(?:true|false|null|undefined|return|var|let|const|if|else|function|new|for|while|try|catch|throw|await|async)\b"#, Palette.keyword),
(#"\b\d+(?:\.\d+)?\b"#, Palette.number),
(#"@(?:css|xpath|json|js|put|get):?|\$\.|&&|\|\||%%|##|\{\{|\}\}"#, Palette.operation),
(#"[{}\[\]()]"#, Palette.punctuation),
(#""(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`"#, Palette.string),
(#""(?:\\.|[^"\\])*"(?=\s*:)"#, Palette.key),
]
for (pattern, color) in tokens {
guard let regex = try? NSRegularExpression(pattern: pattern) else { continue }
@@ -143,7 +165,7 @@ struct SyntaxCodeEditor: UIViewRepresentable {
for pattern in [#"[.*+?^$|]"#, #"\\[dDsSwWbB0-9]|\{[0-9,]+\}"#] {
let regex = try? NSRegularExpression(pattern: pattern)
regex?.enumerateMatches(in: view.text, range: all) { result, _, _ in
if let result { storage.addAttribute(.foregroundColor, value: UIColor.systemPink, range: result.range) }
if let result { storage.addAttribute(.foregroundColor, value: Palette.operation, range: result.range) }
}
}
}
@@ -151,7 +173,7 @@ struct SyntaxCodeEditor: UIViewRepresentable {
let pattern = #"(?m)^\s*//.*$|/\*[\s\S]*?\*/"#
let regex = try? NSRegularExpression(pattern: pattern)
regex?.enumerateMatches(in: view.text, range: all) { result, _, _ in
if let result { storage.addAttribute(.foregroundColor, value: UIColor.secondaryLabel, range: result.range) }
if let result { storage.addAttribute(.foregroundColor, value: Palette.comment, range: result.range) }
}
}
}
+1 -2
View File
@@ -81,8 +81,7 @@ struct SourceDebugView: View {
Label(model.isRunning ? "正在调试…" : "运行完整链路", systemImage: "play.fill")
.font(.headline).frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(ReadingTheme.accent)
.readingProminentButtonStyle()
.disabled(model.isRunning || model.keyword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
+3 -4
View File
@@ -44,11 +44,10 @@ struct SourceEditorView: View {
HStack {
ForEach(BookSourceEditorSchema.sections) { section in
Button(section.title) { sectionID = section.id }
.buttonStyle(.bordered)
.tint(sectionID == section.id ? ReadingTheme.accent : .secondary)
.readingChoiceButtonStyle(isSelected: sectionID == section.id)
}
Button("选项") { sectionID = "options" }.buttonStyle(.bordered)
.tint(sectionID == "options" ? ReadingTheme.accent : .secondary)
Button("选项") { sectionID = "options" }
.readingChoiceButtonStyle(isSelected: sectionID == "options")
}.padding(.horizontal)
}.padding(.vertical, 6)
Form {
@@ -1,5 +1,6 @@
@preconcurrency import WebKit
import SwiftUI
import UIKit
struct SourceLoginView: View {
let source: BookSource
@@ -74,6 +75,9 @@ private struct SourceLoginWebView: UIViewRepresentable {
let configuration = WKWebViewConfiguration()
configuration.websiteDataStore = .default()
let webView = WKWebView(frame: .zero, configuration: configuration)
webView.isOpaque = false
webView.backgroundColor = UIColor(ReadingTheme.canvas)
webView.scrollView.backgroundColor = UIColor(ReadingTheme.canvas)
webView.navigationDelegate = context.coordinator
context.coordinator.installExistingCookies(in: webView) { webView.load(request) }
return webView
@@ -61,7 +61,7 @@ struct SourceManagementView: View {
Text("新建书源,或从文件、链接、JSON、二维码导入。")
} actions: {
Button("导入书源", action: presentFileImporter)
.buttonStyle(.borderedProminent)
.readingProminentButtonStyle()
}
} else {
List {
+14 -9
View File
@@ -50,6 +50,10 @@ struct RootTabView: View {
SearchView(initialKeyword: automationSearchKeyword)
} else if requestedDemoPage == "sources" {
SourceManagementView()
} else if requestedDemoPage == "rss" {
RSSHomeView()
} else if requestedDemoPage == "rss-photo" {
RSSPhotoView(value: "data:image/png;base64,", source: RssSource(sourceUrl: "https://appearance.invalid", sourceName: "图片外观检查"))
} else {
BookshelfView()
}
@@ -99,7 +103,6 @@ struct RootTabView: View {
.tabItem { Label("我的", systemImage: "person.crop.circle") }
.tag(Tab.my)
}
.tint(ReadingTheme.accent)
.readingTabBarBackground()
.preferredColorScheme(themeMode == "2" ? .dark : (themeMode == "1" || themeMode == "3" ? .light : nil))
.transaction { transaction in
@@ -123,6 +126,8 @@ struct RootTabView: View {
dismissButton: .default(Text("好")) { sourceLibrary.notice = nil }
)
}
.tint(ReadingTheme.accent)
.toggleStyle(SwitchToggleStyle(tint: ReadingTheme.toggleTint))
}
private var requestedDemoPage: String? {
@@ -487,7 +492,7 @@ private struct ContinueReadingCard: View {
.foregroundStyle(ReadingTheme.onAccent)
.padding(.horizontal, 18)
.frame(minHeight: 40)
.background(ReadingTheme.accent, in: Capsule())
.background(ReadingTheme.accentFill, in: Capsule())
.padding(.top, 20)
}
.frame(maxWidth: .infinity, alignment: .leading)
@@ -719,7 +724,7 @@ private struct EmptyBookshelfHero: View {
}
VStack(spacing: 12) {
Button("导入本地书", action: onImport)
.buttonStyle(.borderedProminent)
.readingProminentButtonStyle()
.controlSize(.large)
NavigationLink("添加书源") { SourceManagementView() }
.font(.subheadline)
@@ -986,7 +991,7 @@ struct SearchView: View {
Spacer()
if model.isPaused {
Button("继续", systemImage: "play.fill") { model.resume() }
.buttonStyle(.bordered)
.readingSecondaryButtonStyle()
.controlSize(.small)
} else if model.state == .searching {
StatusPill(text: "\(model.completedSourceCount)/\(model.totalSourceCount)", systemImage: "magnifyingglass")
@@ -1037,10 +1042,10 @@ struct SearchView: View {
) {
if model.isPaused {
Button("继续搜索", systemImage: "play.fill") { model.resume() }
.buttonStyle(.borderedProminent).tint(ReadingTheme.accent)
.readingProminentButtonStyle()
}
Button("修改关键词", systemImage: "text.cursor") { searchIsPresented = true }
.buttonStyle(.bordered)
.readingSecondaryButtonStyle()
}
}
@@ -1051,13 +1056,13 @@ struct SearchView: View {
systemImage: "network.slash"
) {
Button("调整范围", systemImage: "scope") { showsScope = true }
.buttonStyle(.borderedProminent).tint(ReadingTheme.accent)
.readingProminentButtonStyle()
NavigationLink {
SourceManagementView()
} label: {
Label("管理书源", systemImage: "network")
}
.buttonStyle(.bordered)
.readingSecondaryButtonStyle()
}
}
@@ -1281,7 +1286,7 @@ private struct SettingsView: View {
.clipped()
.overlay {
if colorScheme == .dark {
Color.black.opacity(0.62)
Color.black.opacity(0.78)
}
}
.overlay(alignment: .bottom) {
+8 -2
View File
@@ -6,6 +6,8 @@
objects = {
/* Begin PBXBuildFile section */
DA1000000000000000000002 /* AppearanceContrastTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2000000000000000000002; };
DA1000000000000000000001 /* DarkModeUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2000000000000000000001; };
B10000000000000000000050 = {isa = PBXBuildFile; fileRef = B20000000000000000000050; };
B10000000000000000000030 /* ReaderScrollGeometryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000030; };
BC1000000000000000000001 /* WebReaderBridgeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC2000000000000000000001; };
@@ -124,6 +126,8 @@
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
DA2000000000000000000002 /* AppearanceContrastTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests/LegadoReaderTests/AppearanceContrastTests.swift; sourceTree = SOURCE_ROOT; };
DA2000000000000000000001 /* DarkModeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests/LegadoReaderUITests/DarkModeUITests.swift; sourceTree = SOURCE_ROOT; };
B20000000000000000000050 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests/LegadoReaderUITests/ReaderScrollGestureTests.swift; sourceTree = SOURCE_ROOT; };
B20000000000000000000051 = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; path = LegadoReaderUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
B20000000000000000000030 /* ReaderScrollGeometryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests/LegadoReaderTests/ReaderScrollGeometryTests.swift; sourceTree = SOURCE_ROOT; };
@@ -272,6 +276,8 @@
AA2200000000000000000010,
B20000000000000000000030 /* ReaderScrollGeometryTests.swift */,
BC2000000000000000000001 /* WebReaderBridgeTests.swift */,
DA2000000000000000000001 /* DarkModeUITests.swift */,
DA2000000000000000000002 /* AppearanceContrastTests.swift */,
A30000000000000000000001 /* App */,
C30000000000000000000001 /* LegadoCore */,
A20000000000000000000024 /* txtTocRule.json */,
@@ -678,8 +684,8 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
B30000000000000000000050 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (B10000000000000000000050, AA1100000000000000000009, AA1100000000000000000010); runOnlyForDeploymentPostprocessing = 0; };
B30000000000000000000030 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (B10000000000000000000030, BC1000000000000000000001); runOnlyForDeploymentPostprocessing = 0; };
B30000000000000000000050 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (B10000000000000000000050, AA1100000000000000000009, AA1100000000000000000010, DA1000000000000000000001); runOnlyForDeploymentPostprocessing = 0; };
B30000000000000000000030 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (B10000000000000000000030, BC1000000000000000000001, DA1000000000000000000002); runOnlyForDeploymentPostprocessing = 0; };
D30000000000000000000002 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@@ -34,6 +34,12 @@ public struct RSSReadingPolicy: Sendable {
public struct RSSHTMLProcessor: Sendable {
public static let defaultStyle = "img{max-width:100% !important; width:auto; height:auto;}video{object-fit:fill; max-width:100% !important; width:auto; height:auto;}body{word-wrap:break-word; height:auto;max-width: 100%; width:auto;}"
// Keep source CSS later in the cascade so intentional source styling wins.
public static let appearanceStyle = """
:root{color-scheme:light dark;}
body{background-color:#fcfcfc;color:#171717;}
@media(prefers-color-scheme:dark){body{background-color:#111113;color:#f2f2f2;}a{color:#9ecbff;}}
"""
public init() {}
public func contentHTML(_ content: String, style: String?) -> String {
@@ -53,7 +59,7 @@ public struct RSSHTMLProcessor: Sendable {
}
private func injectStyle(into content: String, style: String) -> String {
let tag = "<style>\(style)</style>"
let tag = "<style>\(Self.appearanceStyle)</style><style>\(style)</style>"
if let range = content.range(of: "</style>") {
return String(content[..<range.upperBound]) + tag + String(content[range.upperBound...])
}
@@ -440,7 +440,7 @@ struct LegadoDatabaseTests {
@Test func rssHTMLAndResourcePoliciesApplyRulesInOrder() {
let processor = RSSHTMLProcessor()
#expect(processor.contentHTML("<head></head><body>x</body>", style: "body{color:red}").contains("<style>body{color:red}</style></head>"))
#expect(processor.contentHTML("<p>x</p>", style: nil).hasPrefix("<style>\(RSSHTMLProcessor.defaultStyle)</style>"))
#expect(processor.contentHTML("<p>x</p>", style: nil).hasPrefix("<style>\(RSSHTMLProcessor.appearanceStyle)</style><style>\(RSSHTMLProcessor.defaultStyle)</style>"))
var source = RssSource(sourceUrl: "https://rss", sourceName: "源")
source.startJs = "window.ready=true"; source.startStyle = "p{font-size:20px}"
let start = processor.startHTML("<body><p>x</p></body>", source: source)
@@ -0,0 +1,86 @@
import SwiftUI
import UIKit
import WebKit
import XCTest
@testable import Legado
@MainActor
final class AppearanceContrastTests: XCTestCase {
func testLoginWebViewUsesAdaptiveBackgroundBeforeContentArrives() async throws {
var source = BookSource(bookSourceUrl: "https://appearance.invalid", bookSourceName: "外观测试")
source.loginUrl = "about:blank"
let controller = UIHostingController(rootView: SourceLoginView(source: source))
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
window.overrideUserInterfaceStyle = .dark
window.rootViewController = controller
window.makeKeyAndVisible()
defer { window.isHidden = true }
func webView(in view: UIView) -> WKWebView? {
if let view = view as? WKWebView { return view }
return view.subviews.lazy.compactMap { webView(in: $0) }.first
}
window.layoutIfNeeded()
for _ in 0..<20 {
if webView(in: controller.view) != nil { break }
try await Task.sleep(nanoseconds: 50_000_000)
}
let web = try XCTUnwrap(webView(in: controller.view))
XCTAssertFalse(web.isOpaque)
for style in [UIUserInterfaceStyle.dark, .light] {
window.overrideUserInterfaceStyle = style
window.layoutIfNeeded()
let traits = UITraitCollection(userInterfaceStyle: style)
let background = try XCTUnwrap(web.backgroundColor).resolvedColor(with: traits)
let scroll = try XCTUnwrap(web.scrollView.backgroundColor).resolvedColor(with: traits)
if style == .dark {
XCTAssertLessThan(luminance(background), 0.15)
XCTAssertLessThan(luminance(scroll), 0.15)
} else {
XCTAssertGreaterThan(luminance(background), 0.6)
XCTAssertGreaterThan(luminance(scroll), 0.6)
}
}
}
func testSyntaxTokensHaveReadableContrastInBaseAndElevatedAppearances() {
let samples: [(SyntaxCodeEditor.Language, String)] = [
(.json, #"{"title":"正文","count":12,"enabled":true,"empty":null}"#),
(.rule, "@css:div && $.title {{key}} ##"),
(.regex, #"\d+.*(word){2,4}"#),
(.javascript, "// comment\nconst value = 'text'; return 123; /* comment */")
]
for style in [UIUserInterfaceStyle.light, .dark] {
for level in [UIUserInterfaceLevel.base, .elevated] {
let traits = UITraitCollection(traitsFrom: [
UITraitCollection(userInterfaceStyle: style),
UITraitCollection(userInterfaceLevel: level)
])
for (language, text) in samples {
let editor = SyntaxCodeEditor(text: .constant(text), language: language)
let view = UITextView()
view.overrideUserInterfaceStyle = style
view.text = text
editor.makeCoordinator().highlight(view)
let background = UIColor.secondarySystemGroupedBackground.resolvedColor(with: traits)
view.textStorage.enumerateAttribute(.foregroundColor, in: NSRange(location: 0, length: view.textStorage.length)) { color, _, _ in
guard let color = color as? UIColor else { return XCTFail("Missing syntax color") }
let foreground = color.resolvedColor(with: traits)
let values = [luminance(foreground), luminance(background)].sorted()
XCTAssertGreaterThanOrEqual((values[1] + 0.05) / (values[0] + 0.05), 4.5,
"Low contrast for \(language), \(style), \(level)")
}
}
}
}
}
private func luminance(_ color: UIColor) -> Double {
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
XCTAssertTrue(color.getRed(&r, green: &g, blue: &b, alpha: &a))
func linear(_ value: CGFloat) -> Double {
let value = Double(value)
return value <= 0.04045 ? value / 12.92 : pow((value + 0.055) / 1.055, 2.4)
}
return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b)
}
}
@@ -5,6 +5,56 @@ import XCTest
@MainActor
final class WebReaderBridgeTests: XCTestCase {
func testGeneratedRSSContentFollowsAppearanceWithoutReloading() async throws {
let source = RssSource(sourceUrl: "https://appearance-regression.test", sourceName: "测试")
let loaded = expectation(description: "HTML loaded")
let surface = RSSWebReaderView(
mode: .html(content: "<body><p>订阅正文</p><a href='#'>链接</a><button>操作</button></body>", baseURL: nil),
source: source, onTextChange: { _ in loaded.fulfill() }
)
let coordinator = surface.makeCoordinator()
let webView = WKWebView(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
webView.navigationDelegate = coordinator
let window = UIWindow(frame: webView.frame)
let controller = UIViewController()
controller.overrideUserInterfaceStyle = .dark
controller.view.addSubview(webView)
window.rootViewController = controller
window.makeKeyAndVisible()
defer {
RSSWebReaderView.dismantleUIView(webView, coordinator: coordinator)
window.isHidden = true
}
coordinator.installRulesAndLoad(mode: surface.mode, in: webView)
await fulfillment(of: [loaded], timeout: 20)
let appearanceScript = """
[matchMedia('(prefers-color-scheme:dark)').matches,
getComputedStyle(document.body).backgroundColor,
getComputedStyle(document.body).color,
getComputedStyle(document.querySelector('a')).color,
getComputedStyle(document.documentElement).colorScheme].map(String).join('|')
"""
let dark = try await webView.evaluateJavaScript(appearanceScript)
XCTAssertEqual(dark as? String, "true|rgb(17, 17, 19)|rgb(242, 242, 242)|rgb(158, 203, 255)|light dark")
_ = try await webView.evaluateJavaScript("window.appearanceMarker = 'preserved'")
controller.overrideUserInterfaceStyle = .light
window.layoutIfNeeded()
XCTAssertEqual(webView.traitCollection.userInterfaceStyle, .light)
var light = try await webView.evaluateJavaScript(appearanceScript)
for _ in 0..<50 {
if (light as? String)?.hasPrefix("false|") == true { break }
try await Task.sleep(nanoseconds: 100_000_000)
light = try await webView.evaluateJavaScript(appearanceScript)
}
XCTAssertEqual(light as? String, "false|rgb(252, 252, 252)|rgb(23, 23, 23)|rgb(0, 0, 238)|light dark")
let marker = try await webView.evaluateJavaScript("window.appearanceMarker")
XCTAssertEqual(marker as? String, "preserved")
// A source may intentionally override the generated article palette.
_ = try await webView.evaluateJavaScript("document.head.insertAdjacentHTML('beforeend', '<style>body{color:rgb(120,30,40)}</style>')")
let custom = try await webView.evaluateJavaScript("getComputedStyle(document.body).color")
XCTAssertEqual(custom as? String, "rgb(120, 30, 40)")
}
func testPageJavaScriptCanStoreVariablesAndEvaluateContentRules() async throws {
let source = RssSource(sourceUrl: "https://bridge-regression.test/\(UUID().uuidString)", sourceName: "测试")
let loaded = expectation(description: "HTML loaded")
@@ -0,0 +1,326 @@
import UIKit
import XCTest
@MainActor
final class DarkModeUITests: XCTestCase {
func testPrimaryActionsKeepDarkFillInBothAppearances() throws {
continueAfterFailure = false
for theme in ["2", "1"] {
let app = launch(["-uiDemoBookDetail", "-themeMode", theme])
let read = app.buttons.matching(NSPredicate(format: "label IN %@", ["开始阅读", "继续阅读"])).firstMatch
XCTAssertTrue(read.waitForExistence(timeout: 20))
XCTAssertTrue(read.isEnabled)
try assertReadableDarkFill(read, in: app)
attach(app, name: "Book detail theme \(theme)")
app.terminate()
}
}
func testNightReaderSelectionsKeepDarkFillAndReadableLabels() throws {
continueAfterFailure = false
let app = launch([
"-uiDemoReader", "-uiDemoReaderStyle", "-themeMode", "2",
"-readerTheme", "night", "-readerUseBackgroundImage", "NO",
"-lineSpacingExtra", "12", "-pageAnim", "0"
])
XCTAssertTrue(app.navigationBars["阅读样式"].waitForExistence(timeout: 20))
// Expand the sheet so the controls can be inspected without clipping.
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.48))
.press(forDuration: 0.1, thenDragTo: app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.12)))
let standard = app.buttons["标准"]
XCTAssertTrue(standard.waitForExistence(timeout: 5))
try assertReadableDarkFill(standard, in: app)
attach(app, name: "Night reader spacing selection")
let cover = app.buttons["覆盖"]
for _ in 0..<16 {
if cover.exists && cover.isHittable && cover.frame.maxY < app.frame.height - 50 { break }
app.swipeUp()
}
XCTAssertTrue(cover.exists && cover.isHittable)
try assertReadableDarkFill(cover, in: app)
attach(app, name: "Night reader animation selection")
}
func testDarkAppearanceAcrossTabsAndPresentedEditors() throws {
continueAfterFailure = false
let app = launch(["-themeMode", "2"])
XCTAssertTrue(app.tabBars.buttons["书架"].waitForExistence(timeout: 20))
for (tab, heading) in [("书架", "阅读,让时间有了形状"), ("发现", "下一本好书,在这里遇见"),
("统计", "每一页,都算数"), ("我的", "按照你的方式阅读")] {
app.tabBars.buttons[tab].tap()
XCTAssertTrue(app.staticTexts[heading].waitForExistence(timeout: 5))
if tab == "统计" {
let today = app.otherElements["statistics.todayCard"]
XCTAssertTrue(today.waitForExistence(timeout: 5))
try assertReadableDarkFill(today, in: app)
}
attach(app, name: "Dark tab \(tab)")
}
app.buttons["profile.globe"].tap()
XCTAssertTrue(app.navigationBars["书源"].waitForExistence(timeout: 10))
attach(app, name: "Dark source library")
app.buttons["更多"].firstMatch.tap()
app.buttons["新建书源"].tap()
XCTAssertTrue(app.navigationBars["新建书源"].waitForExistence(timeout: 10))
let basic = app.buttons["基础"]
try assertReadableDarkFill(basic, in: app)
let login = app.buttons["登录"]
login.tap()
try assertReadableDarkFill(login, in: app)
attach(app, name: "Dark source category selection")
attach(app, name: "Dark presented source editor")
app.segmentedControls.buttons["JSON"].tap()
XCTAssertTrue(app.textViews["syntaxCodeEditor"].waitForExistence(timeout: 5))
attach(app, name: "Dark JSON editor")
app.textViews["syntaxCodeEditor"].tap()
XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5))
attach(app, name: "Dark editor keyboard and accessory toolbar")
let visibleKeyboard = app.keyboards.firstMatch.frame.intersection(app.frame)
if !visibleKeyboard.isNull && visibleKeyboard.width > 20 && visibleKeyboard.height > 20 {
let keyboard = try luminances(app.keyboards.firstMatch, in: app)
XCTAssertLessThan(keyboard[keyboard.count / 2], 0.15)
}
let accessory = app.buttons["完成"]
let accessoryFrame = accessory.frame.intersection(app.frame)
if accessory.isHittable && !accessoryFrame.isNull && accessoryFrame.width > 20 && accessoryFrame.height > 20 {
try assertReadableDarkFill(accessory, in: app)
}
}
func testDarkSettingsRulesAndManagementPages() throws {
continueAfterFailure = false
let app = launch(["-uiDemoTab", "settings", "-themeMode", "2"])
let routes: [(String, String, String?)] = [
("textformat.size", "阅读设置", nil),
("waveform", "在线朗读引擎", "新建"),
("bookmark", "书签", nil),
("clock", "阅读记录", nil),
("link", "规则订阅", "新建"),
("text.badge.checkmark", "净化规则", "新建规则"),
("list.bullet.rectangle", "TXT目录规则", "新建"),
("folder", "文件管理", nil),
("gearshape", "其他设置", nil),
("info.circle", "关于", nil)
]
for (icon, title, create) in routes {
let entry = app.buttons["profile.\(icon)"]
scrollTo(entry, in: app)
entry.tap()
XCTAssertTrue(app.navigationBars[title].waitForExistence(timeout: 10))
try captureDarkPage(app, name: "Management \(title)")
if let create {
app.buttons[create].firstMatch.tap()
XCTAssertTrue(app.buttons["取消"].waitForExistence(timeout: 5))
try captureDarkPage(app, name: "Editor \(title)")
if title == "净化规则" {
app.segmentedControls.buttons["测试规则"].tap()
try captureDarkPage(app, name: "Rule test form")
}
app.buttons["取消"].tap()
}
if title == "其他设置" {
for (entry, destination) in [("网络与缓存设置", "网络与缓存"), ("章节缓存管理", "缓存管理"), ("日志与诊断", "日志与诊断")] {
let link = app.buttons[entry]
scrollTo(link, in: app)
link.tap()
XCTAssertTrue(app.navigationBars[destination].waitForExistence(timeout: 5))
try captureDarkPage(app, name: "Settings \(destination)")
app.navigationBars.firstMatch.buttons.element(boundBy: 0).tap()
}
}
if title == "关于" {
let license = app.buttons["开源许可"]
scrollTo(license, in: app)
license.tap()
XCTAssertTrue(app.navigationBars["开源许可"].waitForExistence(timeout: 5))
try captureDarkPage(app, name: "Licenses")
app.navigationBars.firstMatch.buttons.element(boundBy: 0).tap()
}
app.navigationBars.firstMatch.buttons.element(boundBy: 0).tap()
XCTAssertTrue(app.scrollViews["profile.scroll"].waitForExistence(timeout: 5))
}
}
func testDarkSearchFiltersAndSourceImportSheets() throws {
continueAfterFailure = false
let app = launch(["-uiDemoTab", "search", "-themeMode", "2", "-uiAutomationSearch", "深色外观检查"])
let cancelSearch = app.buttons.matching(NSPredicate(format: "label IN %@", ["取消", "Cancel"])).firstMatch
if cancelSearch.waitForExistence(timeout: 3) { cancelSearch.tap() }
XCTAssertTrue(app.buttons["搜索选项"].waitForExistence(timeout: 10))
app.buttons["搜索选项"].tap()
app.buttons["分组或书源"].tap()
XCTAssertTrue(app.navigationBars["分组或书源"].waitForExistence(timeout: 5))
try captureDarkPage(app, name: "Search group filter")
app.segmentedControls.buttons["书源"].tap()
try captureDarkPage(app, name: "Search source filter")
app.buttons["取消"].tap()
app.terminate()
app.launchArguments = ["-uiDemo", "-uiDemoTab", "sources", "-themeMode", "2"]
app.launch()
XCTAssertTrue(app.navigationBars["书源"].waitForExistence(timeout: 10))
app.buttons["排序"].tap()
XCTAssertTrue(app.buttons["倒序"].waitForExistence(timeout: 5))
try assertReadableDarkFill(app.buttons["倒序"], in: app)
attach(app, name: "Source sorting menu")
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8)).tap()
app.buttons["更多"].firstMatch.tap()
app.buttons["URL 或粘贴 JSON"].tap()
XCTAssertTrue(app.buttons["取消"].waitForExistence(timeout: 5))
try captureDarkPage(app, name: "Source text import")
}
func testDarkRSSAndBlackPhotoViewerInLightAppearance() throws {
continueAfterFailure = false
let app = launch(["-uiDemoTab", "rss", "-themeMode", "2"])
XCTAssertTrue(app.navigationBars["RSS"].waitForExistence(timeout: 10))
for tab in ["订阅", "收藏", "记录"] {
app.segmentedControls.buttons[tab].tap()
try captureDarkPage(app, name: "RSS \(tab)")
}
app.segmentedControls.buttons["订阅"].tap()
app.buttons["RSS操作"].tap()
app.buttons["新建RSS源"].tap()
XCTAssertTrue(app.navigationBars["新建RSS源"].waitForExistence(timeout: 5))
try captureDarkPage(app, name: "RSS source editor")
app.terminate()
app.launchArguments = ["-uiDemo", "-uiDemoTab", "rss-photo", "-themeMode", "1"]
app.launch()
XCTAssertTrue(app.alerts["图片"].waitForExistence(timeout: 10))
app.alerts["图片"].buttons["好"].tap()
let error = app.staticTexts["图片加载失败"]
XCTAssertTrue(error.waitForExistence(timeout: 5))
try assertReadableDarkFill(error, in: app)
let done = app.navigationBars.buttons["完成"]
XCTAssertTrue(done.exists && done.isEnabled)
attach(app, name: "Photo viewer navigation and content")
let toolbar = try luminances(done, in: app)
XCTAssertGreaterThanOrEqual((toolbar[toolbar.count * 98 / 100] + 0.05) / (toolbar[toolbar.count * 2 / 100] + 0.05), 4.5)
attach(app, name: "Readable photo error on black canvas")
}
func testNightReaderMenusAndSettings() throws {
continueAfterFailure = false
let app = launch(["-uiDemoReader", "-uiDemoReaderMenu", "-readerTheme", "night", "-themeMode", "2", "-readerUseBackgroundImage", "NO"])
XCTAssertTrue(app.buttons["目录"].waitForExistence(timeout: 15))
try captureDarkPage(app, name: "Night reader menu")
app.buttons["目录"].tap()
XCTAssertTrue(app.navigationBars["目录"].waitForExistence(timeout: 5))
try captureDarkPage(app, name: "Night table of contents")
app.buttons["关闭"].tap()
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
app.buttons["设置"].tap()
XCTAssertTrue(app.navigationBars["阅读设置"].waitForExistence(timeout: 5))
try captureDarkPage(app, name: "Night reader settings")
let click = app.buttons["点击区域设置"]
scrollTo(click, in: app)
click.tap()
XCTAssertTrue(app.navigationBars["点击区域设置"].waitForExistence(timeout: 5))
try captureDarkPage(app, name: "Night click action grid")
}
func testAppearanceMenuRestoresSystemAndUpdatesVisiblePage() throws {
continueAfterFailure = false
// Launch arguments override writes to UserDefaults; leave themeMode mutable here.
let app = launch(["-uiDemoTab", "settings"])
let appearance = app.buttons["profile.appearance"]
XCTAssertTrue(appearance.waitForExistence(timeout: 10))
appearance.tap()
let system = app.buttons["跟随系统"]
XCTAssertTrue(system.waitForExistence(timeout: 3))
system.tap()
let followsSystem = expectation(for: NSPredicate(format: "value == %@", "跟随系统"), evaluatedWith: appearance)
wait(for: [followsSystem], timeout: 5)
let initial = try luminances(app, in: app)
let systemIsDark = initial[initial.count / 2] < 0.15
for (choice, expectsDark) in [("深色", true), ("浅色", false), ("跟随系统", systemIsDark)] {
appearance.tap()
let option = app.buttons[choice]
if !option.waitForExistence(timeout: 2), app.buttons["外观"].exists { app.buttons["外观"].tap() }
XCTAssertTrue(option.waitForExistence(timeout: 3))
option.tap()
let changed = expectation(for: NSPredicate(format: "value == %@", choice), evaluatedWith: appearance)
wait(for: [changed], timeout: 5)
let values = try luminances(app, in: app)
let median = values[values.count / 2]
if expectsDark { XCTAssertLessThan(median, 0.15) }
else { XCTAssertGreaterThan(median, 0.6) }
attach(app, name: "Live appearance \(choice)")
}
}
private func scrollTo(_ element: XCUIElement, in app: XCUIApplication) {
for _ in 0..<10 {
if element.exists && element.isHittable { return }
if element.exists && element.frame.maxY < app.frame.height / 2 {
app.swipeDown()
} else {
app.swipeUp()
}
}
XCTAssertTrue(element.exists && element.isHittable)
}
private func captureDarkPage(_ app: XCUIApplication, name: String) throws {
let values = try luminances(app, in: app)
XCTAssertLessThan(values[values.count / 2], 0.15, "Unexpected light page or sheet: \(name)")
attach(app, name: name)
}
// Check both the rendered label contrast and the fill's actual brightness.
// A readable white surface must still fail the dark appearance regression.
private func assertReadableDarkFill(_ element: XCUIElement, in app: XCUIApplication,
file: StaticString = #filePath, line: UInt = #line) throws {
let luminances = try luminances(element, in: app, file: file, line: line)
let low = luminances[luminances.count * 2 / 100]
let high = luminances[luminances.count * 98 / 100]
XCTAssertGreaterThanOrEqual((high + 0.05) / (low + 0.05), 4.5,
"Rendered label has insufficient contrast", file: file, line: line)
XCTAssertLessThan(luminances[luminances.count / 2], 0.15,
"Filled control or card is too bright for the dark palette", file: file, line: line)
}
private func luminances(_ element: XCUIElement, in app: XCUIApplication,
file: StaticString = #filePath, line: UInt = #line) throws -> [Double] {
let image = try XCTUnwrap(app.screenshot().image.cgImage, file: file, line: line)
let scaleX = CGFloat(image.width) / app.frame.width
let scaleY = CGFloat(image.height) / app.frame.height
let bounds = element.frame.intersection(app.frame).insetBy(dx: 8, dy: 8)
let rect = CGRect(x: bounds.minX * scaleX, y: bounds.minY * scaleY,
width: bounds.width * scaleX, height: bounds.height * scaleY).integral
let crop = try XCTUnwrap(image.cropping(to: rect), file: file, line: line)
var bytes = [UInt8](repeating: 0, count: crop.width * crop.height * 4)
try bytes.withUnsafeMutableBytes { buffer in
let context = try XCTUnwrap(CGContext(
data: buffer.baseAddress, width: crop.width, height: crop.height,
bitsPerComponent: 8, bytesPerRow: crop.width * 4,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue
), file: file, line: line)
context.draw(crop, in: CGRect(x: 0, y: 0, width: crop.width, height: crop.height))
}
func linear(_ component: UInt8) -> Double {
let value = Double(component) / 255
return value <= 0.04045 ? value / 12.92 : pow((value + 0.055) / 1.055, 2.4)
}
let sampleStride = crop.width * crop.height > 250_000 ? 64 : 4
return stride(from: 0, to: bytes.count, by: sampleStride).map {
0.2126 * linear(bytes[$0]) + 0.7152 * linear(bytes[$0 + 1]) + 0.0722 * linear(bytes[$0 + 2])
}.sorted()
}
private func launch(_ arguments: [String]) -> XCUIApplication {
let app = XCUIApplication()
app.launchArguments = ["-uiDemo"] + arguments
app.launch()
return app
}
private func attach(_ app: XCUIApplication, name: String) {
let attachment = XCTAttachment(screenshot: app.screenshot())
attachment.name = name
attachment.lifetime = .keepAlways
add(attachment)
}
}