fix(ui): refine color picker selection indicators

This commit is contained in:
Kaede Akino committed 2026-10-16 08:50:03 +08:00
1 parent 9738605dd3
commit 428181b611
3 files changed
+298 -18

No files matched your search

+166
View File
@@ -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..<plain.height {
for x in 0..<plain.width {
guard plain.differs(from: selected, x: x, y: y) else { continue }
let distance = hypot(Double(x) + 0.5 - center, Double(y) + 0.5 - center) / 2
#expect(distance < Double(size) / 2 - 1, "Selection must not alter the outer edge or background.")
let offset = (y * plain.width + x) * 4
let hasMarkColor = (0..<3).allSatisfy {
expectsLightMark ? selected.data[offset + $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<some View>) 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)
}
}
}
@@ -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
}
}
}
@@ -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] = []
}
}