@@ -123,6 +123,10 @@ struct EditableColorValue: View {
123123 /// as the dragged one did. Showing the complete value here keeps the readout honest without
124124 /// anything in the row itself changing size.
125125 @State private var rowScrubPreview : String ?
126+ /// True for the duration of a click-drag/scroll scrub. A scrub deliberately resigns first
127+ /// responder (so no caret or selection shows over a value you're dragging), and that blur
128+ /// must not be mistaken for a tab-out that should commit and close the session.
129+ @State private var isScrubbing = false
126130
127131 private var decomposed : DecomposedColor {
128132 format. decompose ( eyedropper. color, style: style, in: colorSpace)
@@ -170,7 +174,9 @@ struct EditableColorValue: View {
170174 onCancel: revertEditing,
171175 onDragBegin: { beginDragSession ( index: index, layout: layout) } ,
172176 onDragEnd: { finishDragOrScrollSession ( index: index) } ,
173- onLiveValue: { value in previewLiveScrub ( index: index, layout: layout, value: value) }
177+ onLiveValue: { value, places in
178+ previewLiveScrub ( index: index, layout: layout, value: value, decimals: places)
179+ }
174180 )
175181 if index < layout. separators. count {
176182 affix ( layout. separators [ index] , size: size)
@@ -183,29 +189,27 @@ struct EditableColorValue: View {
183189 // Decorative overlay: it doesn't feed into the row's reported size, so it can appear and
184190 // change width without perturbing `FlowLayout`. Anchored to the row rather than to the
185191 // dragged field, so it's always in bounds and doesn't jump between components.
186- . overlay ( alignment: . top ) {
192+ . overlay ( alignment: . topLeading ) {
187193 if let rowScrubPreview {
188194 Text ( rowScrubPreview)
189- . font ( . system( size: 11 , weight: . semibold, design: . rounded) )
190- . monospacedDigit ( )
195+ // Monospaced so the digits hold their columns: at a proportional width the
196+ // numbers jitter sideways on every frame of a drag, which is exactly the
197+ // distraction the pill exists to avoid.
198+ . font ( . system( size: 11 , weight: . semibold, design: . monospaced) )
191199 . lineLimit ( 1 )
192200 . minimumScaleFactor ( 0.6 )
193201 . foregroundStyle ( Color ( uiColor == . white ? . black : . white) )
194202 . padding ( . horizontal, 8 )
195203 . padding ( . vertical, 3 )
196204 . 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)
205+ . frame ( maxWidth: effectiveWidth, alignment: . leading)
200206 // The swatch's content carries a text shadow for legibility on any colour;
201207 // inherited by the pill it just reads as blur, so cancel it here.
202208 . shadow ( color: . clear, radius: 0 , x: 0 , y: 0 )
203209 . offset ( y: - 24 )
204- . transition ( . opacity. combined ( with: . scale( scale: 0.9 ) ) )
205210 . allowsHitTesting ( false )
206211 }
207212 }
208- . animation ( . easeOut( duration: 0.1 ) , value: rowScrubPreview)
209213 // An explicit width, not `.frame(maxWidth: .infinity)`: a plain flexible frame was found
210214 // to sometimes only ever be queried for its *ideal* size in this view's position in the
211215 // hierarchy, never its true constrained size, so `FlowLayout` never wrapped and the row
@@ -294,6 +298,7 @@ struct EditableColorValue: View {
294298 /// with a changing `text` binding just renders like a label — no editor involved.
295299 private func beginDragSession( index: Int , layout: DecomposedColor ) {
296300 sessionOwner = index
301+ isScrubbing = true
297302 if isEditing {
298303 focusedIndex = index
299304 return
@@ -309,8 +314,9 @@ struct EditableColorValue: View {
309314 if !isEditing {
310315 startSession ( layout: layout)
311316 }
312- } else if isEditing {
317+ } else if isEditing, !isScrubbing {
313318 // Focus left every field (blur / tab-out) — commit if valid, otherwise revert.
319+ // Not during a scrub: that blur is one we asked for, not the user leaving the field.
314320 finishEditing ( )
315321 }
316322 }
@@ -324,6 +330,7 @@ struct EditableColorValue: View {
324330 private func finishDragOrScrollSession( index: Int ) {
325331 guard isEditing, sessionOwner == index else { return }
326332 rowScrubPreview = nil
333+ isScrubbing = false
327334 finishEditing ( )
328335 // A scrub's committed colour is the clamped, displayable one, which may not decompose
329336 // back to exactly the values that produced it. Resync the whole readout from the real
@@ -351,58 +358,17 @@ struct EditableColorValue: View {
351358 private func startSession( layout: DecomposedColor ) {
352359 isEditing = true
353360 sessionStartValues = layout. values
354- // Sized to the *widest possible* value for this format, not the current one: keeping
355- // `frozenSize` in step with the live value (as it started out) only froze the font size,
356- // not the wrap decision — a component can still change digit count as it's scrubbed
357- // (e.g. "0.25" → "0.3"), which shifts where FlowLayout breaks the line even at a fixed
358- // font size. Sizing conservatively for the worst case up front means no value this
359- // format can ever produce needs more room than what's already budgeted, so the number of
360- // lines genuinely can't change for the rest of the session, however the digits move.
361- frozenSize = fontSize ( for: worstCaseJoined ( layout) )
361+ // The size the value is *already* being shown at. Sizing to the format's theoretical
362+ // widest value instead made the whole readout collapse to `minSize` the instant you
363+ // clicked it in a narrow window — a jarring shrink, and the reason it's not done here.
364+ // Nothing in the row changes width mid-scrub any more (every field's text is frozen and
365+ // the live value goes to the pill), so there's no drift left to size defensively against.
366+ frozenSize = fontSize ( for: layout. joined ( ) )
362367 preEditColor = eyedropper. color
363368 values = layout. values
364369 valuesKey = FormatStyleKey ( format: format, style: style, colorSpace: colorSpace)
365370 }
366371
367- /// The longest string this format's layout could ever produce: same scaffolding (leading/
368- /// separators/trailing) as `layout.joined()`, but each component replaced with its own
369- /// worst-case placeholder — see `worstCaseComponentString`.
370- private func worstCaseJoined( _ layout: DecomposedColor ) -> String {
371- var result = layout. leading
372- for (index, component) in layout. components. enumerated ( ) {
373- result += worstCaseComponentString ( component)
374- if index < layout. separators. count { result += layout. separators [ index] }
375- }
376- return result + layout. trailing
377- }
378-
379- /// The widest value a component could ever display. Integers use the range's most digits;
380- /// decimals use the range's most integer-part digits plus 4 decimal places (the original
381- /// stripped format's max — still the true worst case even though scrubbing now defaults to
382- /// coarser 2-place rounding, since finer starting precision is preserved up to 4). A leading
383- /// "-" is budgeted for any component whose range allows (or has no range, e.g. Lab a/b) a
384- /// negative value. Unranged decimals (Lab a/b) have no clamp and are genuinely unbounded, so
385- /// there's no true worst case to size to; 3 int digits is a practical bound that covers real
386- /// sRGB-gamut a*/b* extremes (b* reaches roughly -107) without reserving excessive width.
387- /// Hex is already fixed-length, so it's left as-is.
388- private func worstCaseComponentString( _ component: ColorComponent ) -> String {
389- let sign = ( component. range? . lowerBound ?? - 1 ) < 0 ? " - " : " "
390- switch component. kind {
391- case . hex:
392- return component. value
393- case . integer:
394- let digits = component. range. map {
395- max ( String ( abs ( Int ( $0. upperBound. rounded ( ) ) ) ) . count, String ( abs ( Int ( $0. lowerBound. rounded ( ) ) ) ) . count)
396- } ?? 3
397- return sign + String( repeating: " 9 " , count: max ( digits, 1 ) )
398- case . decimal:
399- let intDigits = component. range. map {
400- max ( String ( abs ( Int ( $0. upperBound) ) ) . count, String ( abs ( Int ( $0. lowerBound) ) ) . count)
401- } ?? 3
402- return sign + String( repeating: " 9 " , count: max ( intDigits, 1 ) ) + " . " + String( repeating: " 9 " , count: 4 )
403- }
404- }
405-
406372 /// Recompose the working values and preview them live; flag invalid input for the pill.
407373 private func previewIfValid( layout: DecomposedColor ) {
408374 let allValid = zip ( layout. components, values) . allSatisfy { $0. isValid ( $1) }
@@ -412,10 +378,6 @@ struct EditableColorValue: View {
412378 lastPreviewedColor = eyedropper. color
413379 }
414380
415- /// Live-previews the eyedropper colour for a single component's in-progress drag/scroll
416- /// value, mirroring `previewIfValid`'s recompose-and-set against a substituted value —
417- /// without touching `values`/`text`, which stay frozen for the whole gesture so `FlowLayout`
418- /// never reflows mid-scrub (see `rowScrubPreview`).
419381 /// Returns the value actually achieved — which is not always the one requested. Lab/OKLCH can
420382 /// express colours outside sRGB, and `recompose` clamps those to the nearest displayable
421383 /// channel (see `NSColor.encodeSRGB`), so e.g. `oklch(30% 0.2 230)` really lands on chroma
@@ -424,7 +386,7 @@ struct EditableColorValue: View {
424386 /// stops there instead of displaying a number that silently disagrees with the swatch (and
425387 /// then appearing to "jump" when a later interaction resynced from the real colour).
426388 @discardableResult
427- private func previewLiveScrub( index: Int , layout: DecomposedColor , value: Double ) -> Double {
389+ private func previewLiveScrub( index: Int , layout: DecomposedColor , value: Double , decimals : Int ) -> Double {
428390 guard index < layout. components. count else { return value }
429391 // From the session's starting values, never the live (clamped) ones — see
430392 // `sessionStartValues`. This is what makes a scrub reversible: drag chroma up into the
@@ -450,7 +412,17 @@ struct EditableColorValue: View {
450412 else {
451413 return value
452414 }
453- rowScrubPreview = achieved. joined ( )
415+ // Just the numbers — the `oklch(`/`)` scaffolding is already right there in the row
416+ // beneath, so repeating it in the pill is noise. The dragged component renders at the
417+ // scrub's own precision (what vertical travel is adjusting); the rest show as decomposed.
418+ var preview = " "
419+ for (position, component) in achieved. components. enumerated ( ) {
420+ preview += position == index
421+ ? ColorComponentField . formattedDragValue ( effective, kind: component. kind, stableDecimalPlaces: decimals)
422+ : component. value
423+ if position < achieved. separators. count { preview += achieved. separators [ position] }
424+ }
425+ rowScrubPreview = preview
454426 return effective
455427 }
456428
@@ -517,6 +489,8 @@ struct EditableColorValue: View {
517489
518490 private func endSession( resync: Bool ) {
519491 isEditing = false
492+ isScrubbing = false
493+ rowScrubPreview = nil
520494 frozenSize = nil
521495 isInvalid = false
522496 preEditColor = nil
@@ -560,7 +534,7 @@ struct ColorComponentField: View {
560534 /// Fired with the raw live value on every drag/scroll step, so the parent can preview the
561535 /// eyedropper colour without touching `text` (which stays frozen for the gesture — see
562536 /// `rowScrubPreview`).
563- let onLiveValue : ( Double ) -> Double
537+ let onLiveValue : ( Double , Int ) -> Double
564538
565539 @State private var isHovering = false
566540 /// Non-nil while a two-finger scroll-to-scrub gesture owns this field; holds the value at
@@ -713,7 +687,7 @@ struct ColorComponentField: View {
713687 if let range = component. range {
714688 newValue = min ( max ( newValue, range. lowerBound) , range. upperBound)
715689 }
716- let achieved = onLiveValue ( newValue)
690+ let achieved = onLiveValue ( newValue, scrollDecimalPlaces )
717691 scrollLastValue = achieved
718692 }
719693
0 commit comments