Skip to content

Commit e3baaf4

Browse files
Preview the whole colour in one pill; freeze every field while scrubbing
Syncing the untouched components live fixed the readout contradicting the swatch, but reintroduced the original problem from the other side: changing their text changes *their* widths, which moves FlowLayout's wrap point just as surely as the dragged component did. Show the complete colour in a single pill above the row instead, and leave every field's text frozen for the whole gesture. Nothing in the row changes size, so it cannot reflow, and the readout is still honest about what the colour actually is — including the lightness and hue that clamping moves when chroma is pushed out of gamut. The pill now belongs to the row rather than to a field: it can't be clipped by a field sitting against the swatch edge, and it doesn't jump between components. It's bounded by the row's width and scales down rather than overflowing, since a long format (rgba at five decimals a channel) is wider than the swatch.
1 parent 7e5b1d5 commit e3baaf4

2 files changed

Lines changed: 37 additions & 47 deletions

File tree

Pika/Views/EditableColorValue.swift

Lines changed: 35 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,12 @@ struct EditableColorValue: View {
117117
/// path-dependent, ratcheting the untouched components a little further every frame so
118118
/// dragging back where you came from no longer returns the colour you started with.
119119
@State private var sessionStartValues: [String] = []
120+
/// The whole colour, formatted, while a scrub is in flight — shown in one pill above the row.
121+
/// Every field's text stays frozen for the gesture: syncing the untouched components live
122+
/// would change *their* widths instead, which moves `FlowLayout`'s wrap point just as surely
123+
/// as the dragged one did. Showing the complete value here keeps the readout honest without
124+
/// anything in the row itself changing size.
125+
@State private var rowScrubPreview: String?
120126

121127
private var decomposed: DecomposedColor {
122128
format.decompose(eyedropper.color, style: style, in: colorSpace)
@@ -174,6 +180,29 @@ struct EditableColorValue: View {
174180
}
175181
}
176182
}
183+
// Decorative overlay: it doesn't feed into the row's reported size, so it can appear and
184+
// change width without perturbing `FlowLayout`. Anchored to the row rather than to the
185+
// dragged field, so it's always in bounds and doesn't jump between components.
186+
.overlay(alignment: .topLeading) {
187+
if let rowScrubPreview {
188+
Text(rowScrubPreview)
189+
.font(.system(size: 11, weight: .semibold, design: .rounded))
190+
.monospacedDigit()
191+
.lineLimit(1)
192+
.minimumScaleFactor(0.6)
193+
.foregroundStyle(Color(uiColor == .white ? .black : .white))
194+
.padding(.horizontal, 8)
195+
.padding(.vertical, 3)
196+
.background(Capsule().fill(Color(uiColor).opacity(0.92)))
197+
// Bounded by the row, and allowed to shrink rather than run past its edge:
198+
// a long format (rgba with five decimals a channel) is wider than the swatch.
199+
.frame(maxWidth: effectiveWidth, alignment: .leading)
200+
.offset(y: -24)
201+
.transition(.opacity.combined(with: .scale(scale: 0.9)))
202+
.allowsHitTesting(false)
203+
}
204+
}
205+
.animation(.easeOut(duration: 0.1), value: rowScrubPreview)
177206
// An explicit width, not `.frame(maxWidth: .infinity)`: a plain flexible frame was found
178207
// to sometimes only ever be queried for its *ideal* size in this view's position in the
179208
// hierarchy, never its true constrained size, so `FlowLayout` never wrapped and the row
@@ -291,6 +320,7 @@ struct EditableColorValue: View {
291320
/// one started elsewhere. Only finish if `index` is still the session's current owner.
292321
private func finishDragOrScrollSession(index: Int) {
293322
guard isEditing, sessionOwner == index else { return }
323+
rowScrubPreview = nil
294324
finishEditing()
295325
// A scrub's committed colour is the clamped, displayable one, which may not decompose
296326
// back to exactly the values that produced it. Resync the whole readout from the real
@@ -382,7 +412,7 @@ struct EditableColorValue: View {
382412
/// Live-previews the eyedropper colour for a single component's in-progress drag/scroll
383413
/// value, mirroring `previewIfValid`'s recompose-and-set against a substituted value —
384414
/// without touching `values`/`text`, which stay frozen for the whole gesture so `FlowLayout`
385-
/// never reflows mid-scrub (see `scrubPreviewText`).
415+
/// never reflows mid-scrub (see `rowScrubPreview`).
386416
/// Returns the value actually achieved — which is not always the one requested. Lab/OKLCH can
387417
/// express colours outside sRGB, and `recompose` clamps those to the nearest displayable
388418
/// channel (see `NSColor.encodeSRGB`), so e.g. `oklch(30% 0.2 230)` really lands on chroma
@@ -417,11 +447,7 @@ struct EditableColorValue: View {
417447
else {
418448
return value
419449
}
420-
if achieved.values.count == values.count {
421-
for other in achieved.values.indices where other != index {
422-
values[other] = achieved.values[other]
423-
}
424-
}
450+
rowScrubPreview = achieved.joined()
425451
return effective
426452
}
427453

@@ -529,7 +555,7 @@ struct ColorComponentField: View {
529555
let onDragEnd: () -> Void
530556
/// Fired with the raw live value on every drag/scroll step, so the parent can preview the
531557
/// eyedropper colour without touching `text` (which stays frozen for the gesture — see
532-
/// `scrubPreviewText`).
558+
/// `rowScrubPreview`).
533559
let onLiveValue: (Double) -> Double
534560

535561
@State private var isHovering = false
@@ -539,7 +565,7 @@ struct ColorComponentField: View {
539565
@State private var scrollAccumulated: CGFloat = 0
540566
/// The last value computed during an active scroll — nil once no scroll is in progress.
541567
/// Committed to `text` in `handleScrollEnded`, since the field's own text stays frozen
542-
/// (see `scrubPreviewText`) for the live-updating part of the gesture.
568+
/// (see `EditableColorValue.rowScrubPreview`) for the live-updating part of the gesture.
543569
@State private var scrollLastValue: Double?
544570
/// Decimal places to hold this scroll session's live display at — captured once at scroll
545571
/// start (see `stableDecimalPlaces(for:)`) and held fixed for the session, same reasoning as
@@ -552,7 +578,6 @@ struct ColorComponentField: View {
552578
/// a fixed decimal-place count (e.g. "0.0000" vs "0.1111" in a proportional font), so a
553579
/// preview that doesn't participate in `FlowLayout`'s sizing at all is the only way to fully
554580
/// rule out wrap flicker while scrubbing.
555-
@State private var scrubPreviewText: String?
556581
/// Bumped on every focus event (begin or end) this field reports; see the deferred-blur
557582
/// comment at its use in `onFocusChange` below.
558583
@State private var focusVersion = 0
@@ -599,11 +624,7 @@ struct ColorComponentField: View {
599624
onCancel: onCancel,
600625
onDragBegin: onDragBegin,
601626
onDragEnd: onDragEnd,
602-
onScrubPreview: { scrubPreviewText = $0 },
603-
onDragCancel: {
604-
scrubPreviewText = nil
605-
onCancel()
606-
},
627+
onDragCancel: onCancel,
607628
onLiveValue: onLiveValue,
608629
onStep: isDraggable ? stepValue : nil
609630
)
@@ -621,27 +642,6 @@ struct ColorComponentField: View {
621642
style: StrokeStyle(lineWidth: 1, dash: isInvalid ? [2, 2] : [])
622643
)
623644
)
624-
// Purely decorative: an `.overlay` doesn't feed back into this view's own reported size,
625-
// so the pill can appear, change text, and disappear without ever perturbing `FlowLayout`.
626-
// Leading, not centred: centring a wide pill over a field near the swatch's left edge
627-
// pushes it past that edge, where it's clipped (the value row starts hard against it).
628-
// Growing rightward instead keeps it inside — the row reserves a trailing gutter anyway.
629-
.overlay(alignment: .topLeading) {
630-
if let scrubPreviewText {
631-
Text(scrubPreviewText)
632-
.font(.system(size: 12, weight: .semibold, design: .rounded))
633-
.monospacedDigit()
634-
.foregroundStyle(.white)
635-
.padding(.horizontal, 8)
636-
.padding(.vertical, 3)
637-
.background(Capsule().fill(.black.opacity(0.85)))
638-
.fixedSize()
639-
.offset(y: -26)
640-
.transition(.opacity.combined(with: .scale(scale: 0.9)))
641-
.allowsHitTesting(false)
642-
}
643-
}
644-
.animation(.easeOut(duration: 0.1), value: scrubPreviewText)
645645
.contentShape(Rectangle())
646646
.onHover { hovering in
647647
isHovering = hovering
@@ -711,7 +711,6 @@ struct ColorComponentField: View {
711711
}
712712
let achieved = onLiveValue(newValue)
713713
scrollLastValue = achieved
714-
scrubPreviewText = Self.formattedDragValue(achieved, kind: component.kind, stableDecimalPlaces: scrollDecimalPlaces)
715714
}
716715

717716
private func handleScrollEnded() {
@@ -722,7 +721,6 @@ struct ColorComponentField: View {
722721
scrollOrigin = nil
723722
scrollAccumulated = 0
724723
scrollLastValue = nil
725-
scrubPreviewText = nil
726724
onDragEnd()
727725
}
728726

Pika/Views/ScrubTextField.swift

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -105,14 +105,10 @@ struct ScrubbableColorField: NSViewRepresentable {
105105
/// same live-preview/commit session used for typed edits.
106106
let onDragBegin: () -> Void
107107
let onDragEnd: () -> Void
108-
/// Live value as the drag moves, or `nil` once it ends — drives a floating preview pill
109-
/// instead of the field's own text, which stays frozen for the whole drag (see
110-
/// `EditableColorValue.scrubPreviewText`).
111-
let onScrubPreview: (String?) -> Void
112108
/// Escape pressed mid-drag — abandon the scrub and put the colour back how it was.
113109
let onDragCancel: () -> Void
114110
/// Fired with the raw live value on every drag step, so the parent can preview the eyedropper
115-
/// colour without touching `text` (which stays frozen — see `onScrubPreview` above).
111+
/// colour without touching `text`, which stays frozen for the whole gesture.
116112
let onLiveValue: (Double) -> Double
117113
/// Fired with +1/-1 for Up/Down arrow keys, `nil` for non-draggable (hex) fields.
118114
let onStep: ((CGFloat) -> Void)?
@@ -167,24 +163,20 @@ struct ScrubbableColorField: NSViewRepresentable {
167163
}
168164
// The field's own `stringValue` is deliberately never touched here — it stays frozen at
169165
// whatever it showed when the drag began, for `FlowLayout`'s benefit (see
170-
// `EditableColorValue.scrubPreviewText`). Only the floating pill sees the live value.
166+
// `EditableColorValue.rowScrubPreview`). Only the floating pill sees the live value.
171167
nsView.onDragChanged = { [weak nsView] newValue in
172168
guard let nsView else { return }
173169
// The achieved value, not the requested one: a drag past the sRGB gamut boundary
174170
// clamps, and the pill must show what the swatch actually is.
175171
let achieved = onLiveValue(newValue)
176172
nsView.lastAchievedValue = achieved
177-
onScrubPreview(ColorComponentField.formattedDragValue(
178-
achieved, kind: kind, stableDecimalPlaces: nsView.dragDecimalPlaces
179-
))
180173
}
181174
nsView.onDragEnd = { [weak nsView] finalValue in
182175
guard let nsView else { return }
183176
text = ColorComponentField.formattedDragValue(
184177
nsView.lastAchievedValue ?? finalValue, kind: kind, stableDecimalPlaces: nsView.dragDecimalPlaces
185178
)
186179
nsView.lastAchievedValue = nil
187-
onScrubPreview(nil)
188180
onDragEnd()
189181
}
190182

0 commit comments

Comments
 (0)