diff --git a/FrameworkTests/ColorTagPickerTests.swift b/FrameworkTests/ColorTagPickerTests.swift new file mode 100644 index 00000000..fcb56f4d --- /dev/null +++ b/FrameworkTests/ColorTagPickerTests.swift @@ -0,0 +1,166 @@ +import CoreGraphics +import Foundation +import ImageIO +import SwiftUI +import TermindDomain +import Testing +import UniformTypeIdentifiers + +@testable import TermindPresentation + +@Suite @MainActor +struct ColorTagPickerTests { + @Test(arguments: [ColorTagPicker.Layout.compact, .touch], [ColorScheme.light, .dark]) + func selectionChangesOnlyTheInsideOfEachSwatch(layout: ColorTagPicker.Layout, scheme: ColorScheme) throws { + let size = layout == .compact ? 20 : 26 + for tag in TagColor.allCases { + let plain = try swatch(tag, selected: false, layout: layout, scheme: scheme) + let selected = try swatch(tag, selected: true, layout: layout, scheme: scheme) + #expect(plain.width == (size + 12) * 2) + #expect(plain.width == selected.width && plain.height == selected.height) + let center = Double(plain.width) / 2 + var changedCenter = false + var changedBorder = false + let expectsLightMark = tag != .none || scheme == .dark + for y in 0.. 220 : selected.data[offset + $0] < 60 + } + if distance < Double(size) / 2 - 4 && hasMarkColor { + changedCenter = true + } else if distance >= Double(size) / 2 - 4 && hasMarkColor { + changedBorder = true + } + } + } + #expect(changedCenter, "A checkmark must appear in white, or adaptive primary for no color.") + #expect(changedBorder, "An inset border must appear in white, or adaptive primary for no color.") + } + } + + @Test(arguments: [ColorTagPicker.Layout.compact, .touch]) + func theSamePickerUpdatesSelectionAndAppearanceWithoutResizing(layout: ColorTagPicker.Layout) throws { + let renderer = ImageRenderer(content: Fixture(selection: .red, scheme: .light, layout: layout)) + renderer.scale = 2 + let red = try bitmap(renderer) + renderer.content.selection = .yellow + let yellow = try bitmap(renderer) + #expect(red.width == yellow.width && red.height == yellow.height) + #expect(!red.matches(yellow)) + renderer.content.selection = .none + let none = try bitmap(renderer) + #expect(none.width == red.width && none.height == red.height) + #expect(!none.matches(yellow)) + renderer.content.selection = .red + #expect(try bitmap(renderer).matches(red)) + renderer.content.scheme = .dark + let dark = try bitmap(renderer) + #expect(dark.width == red.width && dark.height == red.height) + #expect(!dark.matches(red)) + renderer.content.scheme = .light + #expect(try bitmap(renderer).matches(red)) + } + + @Test + func exportPaletteReviewWhenRequested() throws { + guard let directory = ProcessInfo.processInfo.environment["TERMIND_COLOR_PICKER_SCREENSHOTS"] else { return } + try FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true) + #if os(macOS) + let platform = "macos" + let layout = ColorTagPicker.Layout.compact + #else + let platform = "ios" + let layout = ColorTagPicker.Layout.touch + #endif + for language in ["en", "zh-Hans", "zh-Hant"] { + for scheme in [ColorScheme.light, .dark] { + let renderer = ImageRenderer( + content: VStack(alignment: .leading, spacing: 16) { + ForEach(TagColor.allCases) { tag in + VStack(alignment: .leading, spacing: 8) { + Text(tag.displayName.swiftUIKey).font(.caption) + ColorTagPicker(selection: .constant(tag), layout: layout) + } + } + } + .frame(width: 300) + .padding(24) + .background(.background) + .environment(\.locale, Locale(identifier: language)) + .environment(\.colorScheme, scheme) + ) + renderer.scale = 2 + let image = try #require(renderer.cgImage) + let name = "color-picker-\(platform)-\(language)-\(scheme).png" + let url = URL(filePath: directory).appending(path: name) + let destination = try #require( + CGImageDestinationCreateWithURL(url as CFURL, UTType.png.identifier as CFString, 1, nil)) + CGImageDestinationAddImage(destination, image, nil) + #expect(CGImageDestinationFinalize(destination)) + Attachment.record(try Data(contentsOf: url), named: name) + } + } + } + + private func swatch( + _ tag: TagColor, selected: Bool, layout: ColorTagPicker.Layout, scheme: ColorScheme + ) throws -> Bitmap { + let renderer = ImageRenderer( + content: ColorTagPicker.Swatch(color: tag, isSelected: selected, layout: layout) + .padding(6) + .background(.background) + .environment(\.colorScheme, scheme)) + renderer.scale = 2 + return try bitmap(renderer) + } + + private func bitmap(_ renderer: ImageRenderer) throws -> Bitmap { + let image = try #require(renderer.cgImage) + let space = try #require(CGColorSpace(name: CGColorSpace.sRGB)) + let context = try #require( + CGContext( + data: nil, width: image.width, height: image.height, bitsPerComponent: 8, + bytesPerRow: image.width * 4, space: space, + bitmapInfo: CGBitmapInfo.byteOrder32Big.rawValue | CGImageAlphaInfo.premultipliedLast.rawValue)) + context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height)) + return Bitmap( + width: image.width, height: image.height, + data: Data(bytes: try #require(context.data), count: context.bytesPerRow * context.height)) + } + + private struct Bitmap { + let width: Int + let height: Int + let data: Data + + func matches(_ other: Bitmap) -> Bool { + width == other.width && height == other.height + // Allow one channel step of offscreen antialiasing roundoff. + && zip(data, other.data).allSatisfy { abs(Int($0) - Int($1)) <= 1 } + } + + func differs(from other: Bitmap, x: Int, y: Int) -> Bool { + let offset = (y * width + x) * 4 + return (0..<4).contains { abs(Int(data[offset + $0]) - Int(other.data[offset + $0])) > 1 } + } + } + + private struct Fixture: View { + var selection: TagColor + var scheme: ColorScheme + let layout: ColorTagPicker.Layout + + var body: some View { + ColorTagPicker(selection: .constant(selection), layout: layout) + .frame(width: 300) + .padding(8) + .background(.background) + .environment(\.colorScheme, scheme) + } + } +} diff --git a/Frameworks/TermindPresentation/ColorTagPicker.swift b/Frameworks/TermindPresentation/ColorTagPicker.swift index 328765ac..98210e44 100644 --- a/Frameworks/TermindPresentation/ColorTagPicker.swift +++ b/Frameworks/TermindPresentation/ColorTagPicker.swift @@ -31,24 +31,9 @@ public struct ColorTagPicker: View { Button { selection = color } label: { - ZStack { - if let fill = color.color { - Circle().fill(fill) - } else { - Image(systemName: "circle.dashed").resizable().fontWeight(.light).foregroundStyle(.secondary) - } - if layout == .touch && selection == color { - Image(systemName: "checkmark").font(.system(size: 12, weight: .bold)) - .foregroundStyle(.primary).padding(3).background(.background, in: .circle) - } - } - .frame(width: layout == .compact ? 20 : 26, height: layout == .compact ? 20 : 26) - .overlay { - Circle().strokeBorder(Color.accentColor, lineWidth: 2).padding(-3) - .opacity(selection == color ? 1 : 0) - } - .frame(minWidth: layout == .touch ? 44 : nil, minHeight: layout == .touch ? 44 : nil) - .contentShape(.rect) + Swatch(color: color, isSelected: selection == color, layout: layout) + .frame(minWidth: layout == .touch ? 44 : nil, minHeight: layout == .touch ? 44 : nil) + .contentShape(.rect) } .buttonStyle(.plain) .help(Text(color.displayName)) @@ -56,4 +41,38 @@ public struct ColorTagPicker: View { .accessibilityAddTraits(selection == color ? [.isButton, .isSelected] : .isButton) } } + + struct Swatch: View { + let color: TagColor + let isSelected: Bool + let layout: Layout + + var body: some View { + ZStack { + if let fill = color.color { + Circle().fill(fill) + } else { + Image(systemName: "circle.dashed").resizable().fontWeight(.light).foregroundStyle(.secondary) + } + } + .frame(width: layout == .compact ? 20 : 26, height: layout == .compact ? 20 : 26) + .overlay { + if isSelected { + let foreground = selectionForeground + ZStack { + Circle().strokeBorder(foreground, lineWidth: 1.5).padding(2) + Image(systemName: "checkmark") + .font(.system(size: layout == .compact ? 9 : 12, weight: .bold)) + .foregroundStyle(foreground) + } + .accessibilityHidden(true) + .allowsHitTesting(false) + } + } + } + + private var selectionForeground: Color { + color == .none ? .primary : .white + } + } } diff --git a/TFMTests/ColorTagPickerInteractionTests.swift b/TFMTests/ColorTagPickerInteractionTests.swift new file mode 100644 index 00000000..687b376c --- /dev/null +++ b/TFMTests/ColorTagPickerInteractionTests.swift @@ -0,0 +1,95 @@ +import AppKit +import Observation +import SwiftUI +import TermindDomain +import TermindPresentation +import Testing + +@Suite(.requiresVisibleWindow, .serialized) @MainActor +struct ColorTagPickerInteractionTests { + @Test(arguments: [ColorTagPicker.Layout.compact, .touch]) + func clickingSwatchesSelectsRepeatsAndClearsColors(layout: ColorTagPicker.Layout) async throws { + let state = Selection() + var pickerFrame = CGRect.zero + let controller = NSHostingController( + rootView: ColorTagPicker( + selection: Binding( + get: { state.color }, + set: { + state.color = $0 + state.writes.append($0) + }), + layout: layout + ) + .onGeometryChange(for: CGRect.self) { + $0.frame(in: .named("palette.test")) + } action: { + pickerFrame = $0 + } + .environment(\.locale, Locale(identifier: "en")) + .frame(width: 300) + .padding(24) + .frame(width: 348, height: 180, alignment: .top) + .coordinateSpace(name: "palette.test")) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 348, height: 180), + styleMask: [.titled], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + controller.sizingOptions = [] + window.contentViewController = controller + controller.view.frame = NSRect(x: 0, y: 0, width: 348, height: 180) + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + defer { + window.orderOut(nil) + window.contentViewController = nil + window.close() + } + try await Task.sleep(for: .milliseconds(150)) + controller.view.layoutSubtreeIfNeeded() + + #expect(pickerFrame.width > 0 && pickerFrame.height > 0) + for tag in [TagColor.yellow, .yellow, .purple, .none] { + let index = try #require(TagColor.allCases.firstIndex(of: tag)) + let point: CGPoint + if layout == .compact { + point = CGPoint(x: pickerFrame.minX + 10 + CGFloat(index) * 28, y: pickerFrame.midY) + } else { + let columns = Int((pickerFrame.width + 4) / 48) + let cellWidth = (pickerFrame.width - CGFloat(columns - 1) * 4) / CGFloat(columns) + point = CGPoint( + x: pickerFrame.minX + cellWidth / 2 + CGFloat(index % columns) * (cellWidth + 4), + y: pickerFrame.minY + 22 + CGFloat(index / columns) * 52) + } + try click(point, in: controller.view, window: window) + try await Task.sleep(for: .milliseconds(100)) + #expect(state.color == tag) + } + #expect(state.writes == [.yellow, .yellow, .purple, .none]) + } + + private func click(_ point: CGPoint, in view: NSView, window: NSWindow) throws { + let location = view.convert(point, to: nil) + for type in [NSEvent.EventType.leftMouseDown, .leftMouseUp] { + let event = try #require( + NSEvent.mouseEvent( + with: type, location: location, modifierFlags: [], timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, context: nil, eventNumber: 0, clickCount: 1, pressure: 1)) + NSApp.postEvent(event, atStart: false) + } + let deadline = Date(timeIntervalSinceNow: 0.2) + while Date() < deadline { + if let event = NSApp.nextEvent( + matching: .any, until: Date(timeIntervalSinceNow: 0.02), inMode: .default, dequeue: true + ) { + NSApp.sendEvent(event) + } + } + } + + @Observable @MainActor + fileprivate final class Selection { + var color = TagColor.red + var writes: [TagColor] = [] + } +}