Skip to content

Commit 87c3ce5

Browse files
Scrub vertically to change precision, horizontally to change value
Two related fixes for the readout looking frozen mid-drag. First, the default precision is now derived from the component's own per-pixel step rather than fixed at 2 places. The step is range-scaled, so at 2 places a fine-ranged component like OKLCH chroma advanced its last digit only every ~4px — the number appeared stuck while the colour plainly changed. Deriving it from the step means the last digit always moves about once per pixel. Precision already on display still wins if it's finer, so starting a scrub never truncates what's shown. Second, vertical travel during a drag now trades precision: dragging down reveals more decimals (with a proportionally finer step), up rounds them off (coarser step). The drag re-anchors whenever precision changes, so rescaling the axis alters sensitivity without making the value jump. Only for decimal components — integers have no decimals to trade — and this is only safe to do mid-drag because the field's text stays frozen and the live value renders in the overlay pill, which can't reflow the row.
1 parent 7614a92 commit 87c3ce5

2 files changed

Lines changed: 66 additions & 8 deletions

File tree

Pika/Views/EditableColorValue.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -712,6 +712,17 @@ struct ColorComponentField: View {
712712
/// the value already displays with more precision than that, in which case keep it. Otherwise
713713
/// starting a scrub would itself immediately truncate the value and reflow the row, before
714714
/// any actual dragging has happened.
715+
/// Decimal places at which this component's per-pixel drag step is actually visible: the
716+
/// step is range-scaled (`dragUnitsPerPixel`), so a fixed 2 places leaves a fine-ranged
717+
/// component like OKLCH chroma advancing its last digit only every ~4px — which reads as the
718+
/// number being stuck while the colour plainly changes. Derived from the step so the last
719+
/// digit always moves about once per pixel.
720+
static func naturalDecimalPlaces(forRange range: ClosedRange<Double>?) -> Int {
721+
let step = dragUnitsPerPixel(for: range)
722+
guard step > 0 else { return 2 }
723+
return max(0, Int(ceil(-log10(step))))
724+
}
725+
715726
static func stableDecimalPlaces(for text: String) -> Int {
716727
guard let dotIndex = text.firstIndex(of: ".") else { return 2 }
717728
let decimals = text.distance(from: text.index(after: dotIndex), to: text.endIndex)

Pika/Views/ScrubTextField.swift

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,15 @@ final class ScrubTextField: NSTextField {
289289
/// run, since `updateDrag` fires at least once (immediately after `beginDrag`) before a
290290
/// `mouseUp` can be reached. Read once, then cleared, to hand `onDragEnd` its final value.
291291
private var lastDragValue: Double?
292+
/// Value/x-position the current drag measures its horizontal offset from. Re-anchored
293+
/// whenever vertical movement changes precision, so rescaling the axis mid-drag doesn't make
294+
/// the value jump — it just changes how far a pixel moves it from wherever it already is.
295+
private var dragAnchorValue: Double?
296+
private var dragAnchorX: CGFloat = 0
297+
/// Decimal places the drag started at; vertical movement offsets from this, and the
298+
/// per-pixel step scales inversely so the last shown digit always advances about one per
299+
/// pixel (otherwise a coarse readout looks frozen while the colour visibly changes).
300+
private var dragBaseDecimalPlaces = 2
292301
/// The gamut-clamped value the last drag step actually achieved (see
293302
/// `EditableColorValue.previewLiveScrub`), so the commit uses reality rather than the raw
294303
/// requested value. Cleared once the drag ends.
@@ -401,9 +410,9 @@ final class ScrubTextField: NSTextField {
401410
if !didBeginDrag {
402411
guard abs(translationX) >= threshold else { continue }
403412
didBeginDrag = true
404-
beginDrag()
413+
beginDrag(at: next.locationInWindow)
405414
}
406-
updateDrag(translationX: translationX)
415+
updateDrag(location: next.locationInWindow, startPoint: startPoint)
407416
case .leftMouseUp:
408417
if didBeginDrag {
409418
finishDrag()
@@ -431,9 +440,20 @@ final class ScrubTextField: NSTextField {
431440
}
432441
}
433442

434-
private func beginDrag() {
443+
private func beginDrag(at location: NSPoint) {
435444
dragOrigin = Double(stringValue.trimmingCharacters(in: .whitespaces)) ?? 0
436-
dragDecimalPlaces = ColorComponentField.stableDecimalPlaces(for: stringValue)
445+
dragAnchorValue = dragOrigin
446+
dragAnchorX = location.x
447+
// Whichever is finer: the precision this component's drag step can actually resolve, or
448+
// the precision already on display (so starting a scrub never truncates what's shown).
449+
dragDecimalPlaces = min(
450+
Self.precisionRange.upperBound,
451+
max(
452+
ColorComponentField.naturalDecimalPlaces(forRange: range),
453+
ColorComponentField.stableDecimalPlaces(for: stringValue)
454+
)
455+
)
456+
dragBaseDecimalPlaces = dragDecimalPlaces
437457
NSCursor.resizeLeftRight.set()
438458
// AppKit's own event routing (`_handleMouseDownEvent:` → `NSTextFieldCell
439459
// _selectOrEdit:`) already focused and select-all'd this field as part of routing the
@@ -449,11 +469,37 @@ final class ScrubTextField: NSTextField {
449469
onDragBegin?()
450470
}
451471

452-
private func updateDrag(translationX: CGFloat) {
453-
guard let origin = dragOrigin else { return }
472+
/// Points of vertical travel per decimal place gained or lost.
473+
private static let pointsPerPrecisionStep: CGFloat = 40
474+
/// Bounds on scrub precision. Never 0: a 0...1 component (OKLCH chroma) would read a constant
475+
/// "0" and look broken. 4 matches the widest the normal stripped display ever shows.
476+
private static let precisionRange = 1 ... 4
477+
478+
private func updateDrag(location: NSPoint, startPoint: NSPoint) {
479+
guard dragAnchorValue != nil else { return }
480+
481+
// Vertical travel picks the precision — dragging down (which decreases y in AppKit's
482+
// bottom-left window coordinates) reveals more decimals, up rounds them off. Only for
483+
// `.decimal`; integers have no decimals to trade.
484+
if kind == .decimal {
485+
let steps = Int(((startPoint.y - location.y) / Self.pointsPerPrecisionStep).rounded())
486+
let wanted = min(max(dragBaseDecimalPlaces + steps, Self.precisionRange.lowerBound),
487+
Self.precisionRange.upperBound)
488+
if wanted != dragDecimalPlaces {
489+
// Re-anchor before rescaling, so only the sensitivity changes, not the value.
490+
dragAnchorValue = lastDragValue ?? dragAnchorValue
491+
dragAnchorX = location.x
492+
dragDecimalPlaces = wanted
493+
}
494+
}
495+
496+
guard let anchorValue = dragAnchorValue else { return }
454497
let fine = NSEvent.modifierFlags.contains(.option)
455-
let unitsPerStep = dragUnitsPerPixel(for: range)
456-
var newValue = origin + Double(translationX) * unitsPerStep * (fine ? 0.1 : 1.0)
498+
// Scale the per-pixel step against the precision on show, so one pixel moves roughly one
499+
// unit of the last visible digit at every precision.
500+
let scale = pow(10.0, Double(dragBaseDecimalPlaces - dragDecimalPlaces))
501+
let unitsPerStep = dragUnitsPerPixel(for: range) * scale
502+
var newValue = anchorValue + Double(location.x - dragAnchorX) * unitsPerStep * (fine ? 0.1 : 1.0)
457503
if let range {
458504
newValue = min(max(newValue, range.lowerBound), range.upperBound)
459505
}
@@ -463,6 +509,7 @@ final class ScrubTextField: NSTextField {
463509

464510
private func finishDrag() {
465511
dragOrigin = nil
512+
dragAnchorValue = nil
466513
NSCursor.arrow.set()
467514
if let lastDragValue {
468515
onDragEnd?(lastDragValue)

0 commit comments

Comments
 (0)