Skip to content

Commit f3cca6b

Browse files
Refine the scrub readout: pill styling, hairline, and two interaction bugs
Pill: - Left-align it again, drop the `oklch(…)` scaffolding (it's already in the row underneath), and set it in a monospaced face so the digits hold their columns instead of jittering sideways every frame. - Remove the crossfade between values — at scrub rate it read as a smear, and a hard snap is easier to follow. - Render the dragged component at the scrub's own precision again. Showing `decompose`'s formatting had quietly made vertical travel look like it no longer did anything, since the precision it picks never reached the screen. Readout block: - Hairline is now a single top edge at 50%, square rather than a rounded box around the text, which read as a control it isn't. - More padding above than below so the line doesn't crowd the label. Two bugs: - Clicking a value collapsed the whole readout to `minSize` in a narrow window. `frozenSize` was computed from the format's theoretical widest value, which in a narrow column is tiny. That defence is obsolete now that every field's text is frozen mid-scrub and the live value goes to the pill, so freeze at the size it's already displayed at. - A scrub left the field select-all highlighted with a caret parked at the end, because AppKit focuses the field while routing the mouseDown that turns out to be a drag. Hand first responder back once it's resolved as a drag; `isScrubbing` keeps `EditableColorValue` from mistaking that blur for a tab-out that should commit and close.
1 parent 3d2c10a commit f3cca6b

3 files changed

Lines changed: 60 additions & 83 deletions

File tree

Pika/Views/EditableColorValue.swift

Lines changed: 39 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Pika/Views/EyedropperButton.swift

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,10 @@ struct EyedropperButton: View {
181181
}
182182
}
183183
}
184-
.padding(.all, 10.0)
184+
.padding(.horizontal, 10.0)
185+
.padding(.bottom, 10.0)
186+
// Roomier above than below, so the hairline doesn't crowd the type label.
187+
.padding(.top, 16.0)
185188
.modify {
186189
let shadowColor: Color = eyedropper.color.getUIColor() == .white ? .black : .white
187190
$0
@@ -190,14 +193,14 @@ struct EyedropperButton: View {
190193
}
191194
// Both of these sit outside the shadow above, so the hairline stays crisp.
192195
.background(ClickShield())
193-
.overlay(
194-
RoundedRectangle(cornerRadius: 6.0, style: .continuous)
195-
.strokeBorder(
196-
eyedropper.color.getUIColor().opacity(readoutHovered ? 0.35 : 0),
197-
lineWidth: 1
198-
)
196+
// A single hairline along the top edge, marking where the block stops being a pick
197+
// target — a full box around the text read as a control it isn't.
198+
.overlay(alignment: .top) {
199+
Rectangle()
200+
.fill(Color(eyedropper.color.getUIColor()).opacity(readoutHovered ? 0.5 : 0))
201+
.frame(height: 1)
199202
.allowsHitTesting(false)
200-
)
203+
}
201204
.onHover { readoutHovered = $0 }
202205
.animation(.easeInOut(duration: 0.15), value: readoutHovered)
203206
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)

Pika/Views/ScrubTextField.swift

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ struct ScrubbableColorField: NSViewRepresentable {
109109
let onDragCancel: () -> Void
110110
/// Fired with the raw live value on every drag step, so the parent can preview the eyedropper
111111
/// colour without touching `text`, which stays frozen for the whole gesture.
112-
let onLiveValue: (Double) -> Double
112+
let onLiveValue: (Double, Int) -> Double
113113
/// Fired with +1/-1 for Up/Down arrow keys, `nil` for non-draggable (hex) fields.
114114
let onStep: ((CGFloat) -> Void)?
115115

@@ -168,7 +168,7 @@ struct ScrubbableColorField: NSViewRepresentable {
168168
guard let nsView else { return }
169169
// The achieved value, not the requested one: a drag past the sRGB gamut boundary
170170
// clamps, and the pill must show what the swatch actually is.
171-
let achieved = onLiveValue(newValue)
171+
let achieved = onLiveValue(newValue, nsView.dragDecimalPlaces)
172172
nsView.lastAchievedValue = achieved
173173
}
174174
nsView.onDragEnd = { [weak nsView] finalValue in
@@ -461,14 +461,14 @@ final class ScrubTextField: NSTextField {
461461
NSCursor.resizeLeftRight.set()
462462
// AppKit's own event routing (`_handleMouseDownEvent:` → `NSTextFieldCell
463463
// _selectOrEdit:`) already focused and select-all'd this field as part of routing the
464-
// mouseDown that's turning out to be this drag — before this override's loop could
465-
// tell click from drag apart. Collapse that selection now that we know it's a drag, so
466-
// releasing the mouse doesn't leave the dragged-to value shown text-selected. Just the
467-
// selection, not the focus itself — resigning first responder here would race the
468-
// deferred focus-loss handling in `EditableColorValue` and could end the drag's edit
469-
// session (commit/revert) while the user is still mid-drag.
470-
if let editor = currentEditor() {
471-
editor.selectedRange = NSRange(location: (editor.string as NSString).length, length: 0)
464+
// mouseDown that's turning out to be this drag — before this override's loop could tell
465+
// click from drag apart. Hand first responder back now that we know it's a drag: a scrub
466+
// should read as dragging a value, not as editing text, so it shouldn't leave the field
467+
// select-all highlighted with a caret parked at the end of it. `EditableColorValue`
468+
// knows a scrub is in flight (`isScrubbing`) and won't mistake this blur for a tab-out
469+
// that should commit and close the session.
470+
if currentEditor() != nil {
471+
window?.makeFirstResponder(nil)
472472
}
473473
onDragBegin?()
474474
}

0 commit comments

Comments
 (0)