fix(ui): refine color picker selection indicators
This commit is contained in:
1 parent
9738605dd3
commit
428181b611
3 files changed
+298
-18
No files matched your search
@@ -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] = []
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user