feat(scroll): show which scroll speeds are smooth on this panel (#710)

* feat(scroll): show which scroll speeds are smooth on this panel

The Vegas Scroll Speed slider now says what the panel will do with the
chosen speed and offers the nearest smooth ones to click. Backed by
scroll_config.speed_advice() and GET /api/v3/config/scroll-speed-advice,
which uses the refresh the display measured rather than the cap.

Also stops the default 50 px/s snapping to a stepped 48 px/s (2px every 5
refreshes, 24fps) on a 120Hz panel: the low-fps penalty in solve_crisp()
now loses to 60 or 40 px/s. 100Hz panels are unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix(scroll): hint threw before its timer variables existed; count 25-30fps as stepped

The Vegas speed hint called refreshScrollSpeedHint() before the let
declarations it uses, so it never rendered (found on ledpi). And the
solver's low-fps penalty stopped at 25fps, which let a measured 125.7Hz
panel keep a 25.1fps 2px-every-5-refreshes scroll.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* test: add the scroll-speed-advice route to the /api/v3 URL map snapshot

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-10-01 14:15:22 -04:00
committed by GitHub
co-authored by Claude Sonnet 5.5
parent 4ddc3a3620
commit 16b566e14f
8 changed files with 285 additions and 4 deletions
+66 -2
View File
@@ -157,7 +157,13 @@ def crisp_ladder(
#: when 30 was asked for -- being 11% slow is worth far less than looking bad.
_STEP_PENALTY = 0.05
_SLOW_FPS_PENALTY = 0.25 # below 20fps
_LOWISH_FPS_PENALTY = 0.10 # below 25fps
_LOWISH_FPS_PENALTY = 0.16 # below 30fps, i.e. "slightly stepped"
# Up to 30fps, matching CrispSpeed.steppiness: a measured 125.7Hz panel makes
# 50.3px/s (2px every 5 refreshes) 25.1fps, which a 25fps cutoff let through.
# 0.16, not less: asked for 50px/s on a 120Hz panel, 48px/s (2px every 5
# refreshes, 24fps) costs 0.04 + 0.05 + this, and has to lose to both 60px/s
# and 40px/s (1px, smooth, 20% off = 0.20). At 0.10 it won and shipped a
# visibly stepped scroll to anyone asking for the default.
def _quality_cost(candidate: "CrispSpeed", target: float) -> float:
@@ -173,7 +179,7 @@ def _quality_cost(candidate: "CrispSpeed", target: float) -> float:
fps = candidate.frames_per_second
if fps < 20:
cost += _SLOW_FPS_PENALTY
elif fps < 25:
elif fps < 30:
cost += _LOWISH_FPS_PENALTY
return cost
@@ -474,3 +480,61 @@ def refresh_hz_from_config(global_config: Optional[Dict[str, Any]]) -> float:
if not isinstance(hardware, dict):
return DEFAULT_REFRESH_HZ
return _coerce(hardware.get("limit_refresh_rate_hz")) or DEFAULT_REFRESH_HZ
#: Smooth options offered next to a speed that is not one itself.
_ADVICE_ALTERNATIVES = 2
def speed_advice(
requested_pixels_per_second: float,
refresh_hz: float,
min_pixels_per_second: float = MIN_PIXELS_PER_SECOND,
max_pixels_per_second: float = MAX_PIXELS_PER_SECOND,
) -> Dict[str, Any]:
"""What the panel will do with a requested speed, for showing in a UI.
``applied`` is what :func:`solve_crisp` picks, i.e. what really runs.
``smooth`` is true when that is single-pixel-ish, 30fps-or-better motion.
``alternatives`` are the smooth ladder entries nearest the request inside
the given range, for a click-to-apply suggestion; empty when the request
already is one.
"""
hz = _coerce(refresh_hz) or DEFAULT_REFRESH_HZ
requested = max(MIN_PIXELS_PER_SECOND,
min(MAX_PIXELS_PER_SECOND, _coerce(requested_pixels_per_second) or 0.0))
applied = solve_crisp(requested, hz)
def as_dict(c: CrispSpeed) -> Dict[str, Any]:
return {
"pixels_per_second": round(c.pixels_per_second, 1),
"pixels_per_frame": c.pixels_per_frame,
"frame_hold": c.frame_hold,
"frames_per_second": round(c.frames_per_second, 1),
"steppiness": c.steppiness,
}
smooth_ladder = [
c for c in crisp_ladder(hz)
if c.steppiness == "smooth"
and min_pixels_per_second <= c.pixels_per_second <= max_pixels_per_second
]
# 2%: a UI hands over whole numbers, and 63 asked of a 62.9 px/s panel is
# as good as exact.
exact = abs(applied.pixels_per_second - requested) <= max(0.05, 0.02 * requested)
smooth = applied.steppiness == "smooth"
alternatives: List[CrispSpeed] = []
if not (exact and smooth):
alternatives = sorted(
smooth_ladder,
key=lambda c: abs(c.pixels_per_second - requested),
)[:_ADVICE_ALTERNATIVES]
alternatives.sort(key=lambda c: c.pixels_per_second)
return {
"requested": round(requested, 1),
"refresh_hz": round(hz, 1),
"applied": as_dict(applied),
"exact": exact,
"smooth": smooth,
"alternatives": [as_dict(c) for c in alternatives],
}