@@ -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
0 commit comments