Skip to content

Commit 64ecd39

Browse files
Show the readout boundary across both swatches at one height
The hairline was per-swatch: its own hover state, drawn at the top of its own block. So it appeared on one side only, and sat at a different height whenever one value wrapped onto a second line and the other didn't — the boundary looked like two unrelated marks rather than one edge across the row. Lift both to `ColorPickers`. Hovering either swatch now shows both, since the boundary describes where the pair stops being a pick target, which is one fact about the row. And each block reports its natural height through a `max`-reducing preference, with both then held at the taller one — so the shield and the line that marks its edge agree across the pair. Height is measured before the shared value is imposed, so a block can't feed back into its own answer, and applied bottom-aligned so the extra height opens upward and the readout itself stays put. The preference write is deferred off the layout pass that produced it, the same reentrancy that silently dropped the swatch-width measurement earlier.
1 parent 9375f79 commit 64ecd39

3 files changed

Lines changed: 113 additions & 64 deletions

File tree

Pika/Views/ColorPickers.swift

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
import SwiftUI
22

3+
/// Natural height of a swatch's readout block, reduced across both swatches with `max`.
4+
/// The taller of the two wins so the boundary hairline sits at one height across the pair
5+
/// rather than stepping where a value happens to wrap onto a second line.
6+
struct ReadoutHeightKey: PreferenceKey {
7+
static var defaultValue: CGFloat = 0
8+
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
9+
value = Swift.max(value, nextValue())
10+
}
11+
}
12+
313
struct ColorPickers: View {
414
@EnvironmentObject var eyedroppers: Eyedroppers
515

@@ -9,6 +19,11 @@ struct ColorPickers: View {
919
/// first-responder check in `EyedropperButton.PickTarget` can't tell them apart on its own).
1020
/// Broadcasting the bump to both is harmless — only the swatch with an active session reacts.
1121
@State private var dismissEditingTrigger: Int = 0
22+
/// Tallest readout block of the two, applied to both — see `ReadoutHeightKey`.
23+
@State private var readoutHeight: CGFloat = 0
24+
/// Hovering either swatch shows the boundary on both: it marks where the pair stops being
25+
/// a pick target, which is one fact about the whole row, not a per-swatch one.
26+
@State private var pickersHovered = false
1227

1328
var body: some View {
1429
let eyedropperArray: [Eyedropper] = [eyedroppers.foreground, eyedroppers.background]
@@ -17,9 +32,21 @@ struct ColorPickers: View {
1732
ForEach(Array(eyedropperArray.enumerated()), id: \.element.type) { _, eyedropper in
1833
// No divider between the two swatches — the colours meet directly, and the
1934
// horizontal section dividers do the framing.
20-
EyedropperItem(eyedropper: eyedropper, dismissEditingTrigger: $dismissEditingTrigger)
35+
EyedropperItem(
36+
eyedropper: eyedropper,
37+
dismissEditingTrigger: $dismissEditingTrigger,
38+
readoutHeight: readoutHeight,
39+
showsReadoutBoundary: pickersHovered
40+
)
2141
}
2242
}
43+
.onPreferenceChange(ReadoutHeightKey.self) { height in
44+
// Deferred: this fires from within the layout pass that measured it, and writing
45+
// state straight back re-enters that pass (the same reentrancy that silently dropped
46+
// the swatch-width measurement in `EditableColorValue`).
47+
DispatchQueue.main.async { readoutHeight = height }
48+
}
49+
.onHover { pickersHovered = $0 }
2350
}
2451
}
2552

Pika/Views/EyedropperButton.swift

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,11 @@ struct EyedropperButton: View {
9090
/// scoped to this button. Bumping a trigger only this button's own `EditableColorValue`
9191
/// hears would silently drop that edit instead of committing or reverting it.
9292
@Binding var dismissEditingTrigger: Int
93+
/// Height to hold the readout block at — the taller of the two swatches', resolved by
94+
/// `ColorPickers`, so the boundary sits at one height across the pair. 0 until measured.
95+
var readoutHeight: CGFloat = 0
96+
/// Driven by hovering either swatch, so both boundaries show together.
97+
var showsReadoutBoundary: Bool = false
9398
@Default(.colorFormat) var colorFormat
9499
@Default(.copyFormat) var copyFormat
95100
@Default(.hideColorNames) var hideColorNames
@@ -103,9 +108,6 @@ struct EyedropperButton: View {
103108
@State private var valueInvalid: Bool = false
104109
@State private var isPressed: Bool = false
105110
@State private var flashOpacity: Double = 0
106-
/// Drives the hairline that shows where the non-picking readout block begins.
107-
@State private var readoutHovered = false
108-
109111
/// Mirrors `wantsColorName`, but only ever changed inside `withAnimation`. Animating the
110112
/// environment value directly doesn't work: it changes as part of the geometry pass that
111113
/// re-evaluates the whole tree, and `.animation(_:value:)` doesn't catch that — the row just
@@ -219,6 +221,18 @@ struct EyedropperButton: View {
219221
.padding(.bottom, 10.0)
220222
// Roomier above than below, so the hairline doesn't crowd the type label.
221223
.padding(.top, 16.0)
224+
// Measured *before* the shared height is imposed below, so this reports what the
225+
// block naturally wants and can't feed back into its own answer.
226+
.background(
227+
GeometryReader { geo in
228+
Color.clear.preference(key: ReadoutHeightKey.self, value: geo.size.height)
229+
}
230+
)
231+
// Both swatches take the taller one's height, so the shield and the hairline that
232+
// marks its edge line up across the pair even when one value wraps and the other
233+
// doesn't. Bottom-aligned, so the extra height opens upward and the readout itself
234+
// stays put.
235+
.frame(height: readoutHeight > 0 ? readoutHeight : nil, alignment: .bottom)
222236
.modify {
223237
let shadowColor: Color = eyedropper.color.getUIColor() == .white ? .black : .white
224238
$0
@@ -231,12 +245,11 @@ struct EyedropperButton: View {
231245
// target — a full box around the text read as a control it isn't.
232246
.overlay(alignment: .top) {
233247
Rectangle()
234-
.fill(Color(eyedropper.color.getUIColor()).opacity(readoutHovered ? 0.15 : 0))
248+
.fill(Color(eyedropper.color.getUIColor()).opacity(showsReadoutBoundary ? 0.15 : 0))
235249
.frame(height: 1)
236250
.allowsHitTesting(false)
237251
}
238-
.onHover { readoutHovered = $0 }
239-
.animation(.easeInOut(duration: 0.15), value: readoutHovered)
252+
.animation(.easeInOut(duration: 0.15), value: showsReadoutBoundary)
240253
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
241254

242255
VStack(spacing: 4.0) {

Pika/Views/EyedropperItem.swift

Lines changed: 66 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -12,77 +12,86 @@ struct EyedropperItem: View {
1212
@Environment(\.colorScheme) var colorScheme: ColorScheme
1313
@ObservedObject var eyedropper: Eyedropper
1414
@Binding var dismissEditingTrigger: Int
15+
/// Height to hold the readout block at, shared across both swatches by `ColorPickers`.
16+
var readoutHeight: CGFloat = 0
17+
/// Whether the readout boundary hairline is showing — driven by hovering either swatch.
18+
var showsReadoutBoundary: Bool = false
1519
@State private var showToast: Bool = false
1620
@Default(.colorFormat) var colorFormat
1721
@Default(.copyFormat) var copyFormat
1822
let pasteboard = NSPasteboard.general
1923
var body: some View {
2024
ZStack {
21-
EyedropperButton(eyedropper: eyedropper, dismissEditingTrigger: $dismissEditingTrigger)
22-
.frame(maxWidth: .infinity, maxHeight: .infinity)
23-
.onReceive(NotificationCenter.default.publisher(for: eyedropper.type.pickNotification)) { note in
24-
let requestedChain = note.userInfo?["chain"] as? Bool == true
25-
let chain = eyedropper.type == .foreground
26-
&& (Defaults[.pickContrastingColor] || requestedChain)
27-
eyedropper.start(chainContrasting: chain)
25+
EyedropperButton(
26+
eyedropper: eyedropper,
27+
dismissEditingTrigger: $dismissEditingTrigger,
28+
readoutHeight: readoutHeight,
29+
showsReadoutBoundary: showsReadoutBoundary
30+
)
31+
.frame(maxWidth: .infinity, maxHeight: .infinity)
32+
.onReceive(NotificationCenter.default.publisher(for: eyedropper.type.pickNotification)) { note in
33+
let requestedChain = note.userInfo?["chain"] as? Bool == true
34+
let chain = eyedropper.type == .foreground
35+
&& (Defaults[.pickContrastingColor] || requestedChain)
36+
eyedropper.start(chainContrasting: chain)
37+
}
38+
.onReceive(NotificationCenter.default.publisher(for: eyedropper.type.copyNotification)) { _ in
39+
showToast = true
40+
pasteboard.clearContents()
41+
let contents = "\(eyedropper.color.toFormat(format: colorFormat, style: Defaults[.copyFormat]))"
42+
pasteboard.setString(contents, forType: .string)
43+
}
44+
.onReceive(NotificationCenter.default.publisher(for: eyedropper.type.systemPickerNotification)) { _ in
45+
let panel = NSColorPanel.shared
46+
if panel.isVisible, panel.title == "\(eyedropper.type.rawValue.capitalized)" {
47+
panel.close()
48+
} else {
49+
eyedropper.picker()
2850
}
29-
.onReceive(NotificationCenter.default.publisher(for: eyedropper.type.copyNotification)) { _ in
30-
showToast = true
31-
pasteboard.clearContents()
32-
let contents = "\(eyedropper.color.toFormat(format: colorFormat, style: Defaults[.copyFormat]))"
33-
pasteboard.setString(contents, forType: .string)
51+
}
52+
.onReceive(NotificationCenter.default.publisher(for: .triggerFormatHex)) { _ in
53+
if copyFormat != .swiftUI {
54+
colorFormat = ColorFormat.hex
3455
}
35-
.onReceive(NotificationCenter.default.publisher(for: eyedropper.type.systemPickerNotification)) { _ in
36-
let panel = NSColorPanel.shared
37-
if panel.isVisible, panel.title == "\(eyedropper.type.rawValue.capitalized)" {
38-
panel.close()
39-
} else {
40-
eyedropper.picker()
41-
}
42-
}
43-
.onReceive(NotificationCenter.default.publisher(for: .triggerFormatHex)) { _ in
44-
if copyFormat != .swiftUI {
45-
colorFormat = ColorFormat.hex
46-
}
47-
}
48-
.onReceive(NotificationCenter.default.publisher(for: .triggerFormatRGB)) { _ in
49-
colorFormat = ColorFormat.rgb
50-
}
51-
.onReceive(NotificationCenter.default.publisher(for: .triggerFormatHSB)) { _ in
52-
colorFormat = ColorFormat.hsb
56+
}
57+
.onReceive(NotificationCenter.default.publisher(for: .triggerFormatRGB)) { _ in
58+
colorFormat = ColorFormat.rgb
59+
}
60+
.onReceive(NotificationCenter.default.publisher(for: .triggerFormatHSB)) { _ in
61+
colorFormat = ColorFormat.hsb
62+
}
63+
.onReceive(NotificationCenter.default.publisher(for: .triggerFormatHSL)) { _ in
64+
if copyFormat != .swiftUI {
65+
colorFormat = ColorFormat.hsl
5366
}
54-
.onReceive(NotificationCenter.default.publisher(for: .triggerFormatHSL)) { _ in
55-
if copyFormat != .swiftUI {
56-
colorFormat = ColorFormat.hsl
57-
}
67+
}
68+
.onReceive(NotificationCenter.default.publisher(for: .triggerFormatOpenGL)) { _ in
69+
if copyFormat != .swiftUI {
70+
colorFormat = ColorFormat.opengl
5871
}
59-
.onReceive(NotificationCenter.default.publisher(for: .triggerFormatOpenGL)) { _ in
60-
if copyFormat != .swiftUI {
61-
colorFormat = ColorFormat.opengl
72+
}
73+
.onChange(of: copyFormat) {
74+
if copyFormat == .swiftUI {
75+
if PikaConstants.disabledFormats.contains(colorFormat) {
76+
colorFormat = .rgb
6277
}
6378
}
64-
.onChange(of: copyFormat) {
65-
if copyFormat == .swiftUI {
66-
if PikaConstants.disabledFormats.contains(colorFormat) {
67-
colorFormat = .rgb
68-
}
69-
}
79+
}
80+
.onReceive(NotificationCenter.default.publisher(for: .triggerFormatLAB)) { _ in
81+
if copyFormat != .swiftUI {
82+
colorFormat = .lab
7083
}
71-
.onReceive(NotificationCenter.default.publisher(for: .triggerFormatLAB)) { _ in
72-
if copyFormat != .swiftUI {
73-
colorFormat = .lab
74-
}
75-
}
76-
.onReceive(NotificationCenter.default.publisher(for: .triggerFormatOKLCH)) { _ in
77-
if copyFormat != .swiftUI {
78-
colorFormat = .oklch
79-
}
84+
}
85+
.onReceive(NotificationCenter.default.publisher(for: .triggerFormatOKLCH)) { _ in
86+
if copyFormat != .swiftUI {
87+
colorFormat = .oklch
8088
}
81-
.toast(
82-
isShowing: $showToast,
83-
color: eyedropper.color.getUIColor(),
84-
text: Text(String(PikaText.textColorCopied))
85-
)
89+
}
90+
.toast(
91+
isShowing: $showToast,
92+
color: eyedropper.color.getUIColor(),
93+
text: Text(String(PikaText.textColorCopied))
94+
)
8695
}
8796
}
8897
}

0 commit comments

Comments
 (0)