From e8c269bf0e4e34e98324044aa8b5938449e608f1 Mon Sep 17 00:00:00 2001 From: Dylan Deverill Date: Mon, 7 Sep 2026 15:49:20 -0700 Subject: [PATCH] Add a per-pad scroll speed setting (#95) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Normalising the wheel setting removes the difference between machines; it does not settle what the shared feel should be. Steam has a scroll speed slider and we had a compile-time constant, so a pad that scrolled too fast for someone left them nothing to do about it but stop using scroll mode. Per pad rather than global, because the two pads are already configured independently and either can be the one scrolling. Shown only in scroll mode, alongside the scroll direction it belongs with. Applied after the wheel correction so the percentage means the same thing everywhere: 100% is the calibrated feel, not "whatever this machine happens to do". Bounded 25-400%. Zero is the value the registry yields for every profile written before this existed, so it has to read as "unset" and land on the default rather than as "do not scroll". ScrollSpeedFromDword owns that on the C++ side and clampSpeed owns it in the page, each the single place its side bounds the value — the page previously clamped only on render, which let a stored out-of-range value reach savedProfile unbounded and show up as an unsaved change the user never made. Co-Authored-By: Claude Opus 5 --- src/app/ControllerManager.cpp | 2 + src/app/GameProfiles.cpp | 3 + src/app/RemapWindow.cpp | 82 +++++++++++++++++++++++++++- src/app/TrayApp.cpp | 2 + src/probe/TrackpadDirectionProbe.cpp | 28 ++++++++++ 5 files changed, 114 insertions(+), 3 deletions(-) diff --git a/src/app/ControllerManager.cpp b/src/app/ControllerManager.cpp index c14c86a..718583e 100644 --- a/src/app/ControllerManager.cpp +++ b/src/app/ControllerManager.cpp @@ -479,6 +479,8 @@ void ControllerManager::ApplyPadSettings(Slot& slot) { slot.rightPad.SetMode(m_profile.rightPad.mode); slot.leftPad.SetScrollDirection(m_profile.leftPad.scrollDir); slot.rightPad.SetScrollDirection(m_profile.rightPad.scrollDir); + slot.leftPad.SetScrollSpeed(m_profile.leftPad.scrollSpeed); + slot.rightPad.SetScrollSpeed(m_profile.rightPad.scrollSpeed); slot.leftPad.SetDiagonals(m_profile.leftPad.diagonals); slot.rightPad.SetDiagonals(m_profile.rightPad.diagonals); // The virtual controller reads pad modes straight off the profile it is diff --git a/src/app/GameProfiles.cpp b/src/app/GameProfiles.cpp index efc368f..5828022 100644 --- a/src/app/GameProfiles.cpp +++ b/src/app/GameProfiles.cpp @@ -51,6 +51,8 @@ void ReadPad(HKEY key, const wchar_t* prefix, TrackpadSettings& pad) { pad.mode = TrackpadModeFromDword(ReadDw(key, name(L"Mode").c_str(), 0)); pad.click = binding(L"Click", BackButtonAction::None); pad.scrollDir = ScrollDirectionFromDword(ReadDw(key, name(L"ScrollDir").c_str(), 0)); + // Zero means absent, which ScrollSpeedFromDword reads as the default. + pad.scrollSpeed = ScrollSpeedFromDword(ReadDw(key, name(L"ScrollSpeed").c_str(), 0)); // Absent from every profile written before the directional modes existed. // The defaults match TrackpadSettings' own, so those profiles read back as // an unconfigured directional pad rather than a broken one. @@ -68,6 +70,7 @@ void WritePad(HKEY key, const wchar_t* prefix, const TrackpadSettings& pad) { WriteDw(key, name(L"Mode").c_str(), static_cast(pad.mode)); WriteDw(key, name(L"Click").c_str(), pad.click.Pack()); WriteDw(key, name(L"ScrollDir").c_str(), static_cast(pad.scrollDir)); + WriteDw(key, name(L"ScrollSpeed").c_str(), pad.scrollSpeed); WriteDw(key, name(L"Touch").c_str(), pad.touch.Pack()); WriteDw(key, name(L"Up").c_str(), pad.up.Pack()); WriteDw(key, name(L"Down").c_str(), pad.down.Pack()); diff --git a/src/app/RemapWindow.cpp b/src/app/RemapWindow.cpp index 39b8082..5948839 100644 --- a/src/app/RemapWindow.cpp +++ b/src/app/RemapWindow.cpp @@ -13,6 +13,7 @@ #include #include #include +#include RemapWindow* RemapWindow::s_instance = nullptr; @@ -126,6 +127,12 @@ button{font-family:'Barlow',system-ui,sans-serif;cursor:pointer;border:none;back .mode-select{background:#101925;border:1px solid rgba(255,255,255,.12);border-radius:6px;color:#fff;font-size:13px;font-family:'Barlow',system-ui,sans-serif;padding:7px 10px;font-weight:600;cursor:pointer;flex:none;min-width:215px;} .mode-select:hover{border-color:#66c0f4;} .mode-select:focus{outline:none;border-color:#66c0f4;} +.speed-wrap{display:flex;align-items:center;gap:10px;flex:none;min-width:215px;} +.speed-slider{-webkit-appearance:none;appearance:none;flex:1;height:4px;border-radius:2px;background:rgba(255,255,255,.16);outline:none;cursor:pointer;} +.speed-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:15px;height:15px;border-radius:50%;background:#66c0f4;border:none;cursor:pointer;} +.speed-slider:hover::-webkit-slider-thumb{background:#8fd3ff;} +.speed-slider:focus{outline:none;} +.speed-val{font-family:'JetBrains Mono',monospace;font-size:12px;color:#8f98a0;font-weight:600;min-width:44px;text-align:right;} .picker{margin-top:12px;background:#101925;border:1px solid rgba(102,192,244,.2);border-radius:8px;padding:12px 13px;} .picker-label{font-family:'JetBrains Mono',monospace;font-size:10.5px;letter-spacing:1px;color:#5c6b78;font-weight:600;} .picker-rows{display:flex;flex-direction:column;gap:7px;margin-top:10px;} @@ -242,6 +249,16 @@ R"HTML( +
+
+ Scroll Speed +
+
+ + 100% +
+
+
Diagonals @@ -273,6 +290,16 @@ R"HTML(
+
+
+ Scroll Speed +
+
+ + 100% +
+
+
Diagonals @@ -351,6 +378,11 @@ var DEFAULTS = {L4:'leftMouse',L5:'none',R4:'leftMouse',R5:'none',LPAD:'none',RP var DEFAULT_MODES = {LPAD:'scroll',RPAD:'pointer'}; var DEFAULT_DIRS = {LPAD:'natural',RPAD:'natural'}; var DEFAULT_DIAGS = {LPAD:'eight',RPAD:'eight'}; +// Percent of the calibrated scroll scale. Keep the bounds in step with +// kScrollSpeedMin/Max in TrackpadConfig.h — the C++ side clamps to them, so a +// slider allowed to travel further would just snap back on apply. +var DEFAULT_SPEEDS = {LPAD:100,RPAD:100}; +var SPEED_MIN = 25, SPEED_MAX = 400, SPEED_STEP = 5; var DEFAULT_PLATFORM = 'xbox'; // ---- State ---- @@ -363,6 +395,9 @@ var dirs = {LPAD:'natural',RPAD:'natural'}; // Whether a directional pad's diagonals press two directions or round to one. // Kept for every pad for the same reason as the scroll direction. var diags = {LPAD:'eight',RPAD:'eight'}; +// Scroll speed per pad, kept for every pad for the same reason as the two +// above: switching modes back and forth must not lose the choice. +var speeds = {LPAD:100,RPAD:100}; var platform = 'xbox'; // This game follows the default profile's controls instead of carrying its // own. Only ever true for a game — the default has nothing to follow. @@ -458,7 +493,7 @@ var runningOpen = false; // Each profile is a flat object: one entry per bindable row id, plus // "mode" for each trackpad's movement mode. Flat because the JSON // reader on the C++ side matches "key":"value" pairs without nesting. -var PROFILES = {'':{platform:'xbox',L4:'leftMouse',L5:'none',R4:'leftMouse',R5:'none',LPAD:'none',RPAD:'leftMouse',LPADmode:'scroll',RPADmode:'pointer',LPADdir:'natural',RPADdir:'natural',LPADdiag:'eight',RPADdiag:'eight',LPADtouch:'none',RPADtouch:'none',LPADup:'Up',LPADdown:'Down',LPADleft:'Left',LPADright:'Right',RPADup:'Up',RPADdown:'Down',RPADleft:'Left',RPADright:'Right'}}; +var PROFILES = {'':{platform:'xbox',L4:'leftMouse',L5:'none',R4:'leftMouse',R5:'none',LPAD:'none',RPAD:'leftMouse',LPADmode:'scroll',RPADmode:'pointer',LPADdir:'natural',RPADdir:'natural',LPADspeed:'100',RPADspeed:'100',LPADdiag:'eight',RPADdiag:'eight',LPADtouch:'none',RPADtouch:'none',LPADup:'Up',LPADdown:'Down',LPADleft:'Left',LPADright:'Right',RPADup:'Up',RPADdown:'Down',RPADleft:'Left',RPADright:'Right'}}; var currentGame = ''; var comboOpen = false; var comboQuery = ''; @@ -652,6 +687,23 @@ function setMode(padId,modeId){ function setDir(padId,dirId){ dirs[padId]=dirId; } +// The one place a scroll speed is bounded, so a value arriving from storage +// and one arriving from the slider cannot disagree. Anything unparseable or +// zero is "unset", which is what the registry writes for a profile saved +// before this setting existed — see ScrollSpeedFromDword. +function clampSpeed(padId,value){ + var v=parseInt(value,10); + if(isNaN(v)||v===0) return DEFAULT_SPEEDS[padId]; + return Math.min(SPEED_MAX,Math.max(SPEED_MIN,v)); +} +// Live while dragging, so the readout tracks the thumb. Stored as a number; +// currentProfile stringifies it on the way out, matching every other value on +// the wire. +function setSpeed(padId,value){ + speeds[padId]=clampSpeed(padId,value); + var out=document.getElementById('speed-val-'+padId); + if(out) out.textContent=speeds[padId]+'%'; +} function setDiag(padId,diagId){ diags[padId]=diagId; } @@ -660,11 +712,12 @@ function resetDefaults(){ platform=DEFAULT_PLATFORM; bindings={}; ROWS.forEach(function(r){bindings[r.id]=DEFAULTS[r.id];}); - modes={}; dirs={}; diags={}; + modes={}; dirs={}; diags={}; speeds={}; PADS.forEach(function(p){ modes[p]=DEFAULT_MODES[p]; dirs[p]=DEFAULT_DIRS[p]; diags[p]=DEFAULT_DIAGS[p]; + speeds[p]=DEFAULT_SPEEDS[p]; }); flash=null; clearTimeout(flashTimer); @@ -687,6 +740,7 @@ function currentProfile(){ p[x+'mode']=modes[x]; p[x+'dir']=dirs[x]; p[x+'diag']=diags[x]; + p[x+'speed']=String(speeds[x]); }); return p; } @@ -708,11 +762,14 @@ function loadProfileInto(p){ ROWS.forEach(function(r){ bindings[r.id]=p.hasOwnProperty(r.id)?p[r.id]:DEFAULTS[r.id]; }); - modes={}; dirs={}; diags={}; + modes={}; dirs={}; diags={}; speeds={}; PADS.forEach(function(x){ modes[x]=p.hasOwnProperty(x+'mode')?p[x+'mode']:DEFAULT_MODES[x]; dirs[x] =p.hasOwnProperty(x+'dir') ?p[x+'dir'] :DEFAULT_DIRS[x]; diags[x]=p.hasOwnProperty(x+'diag')?p[x+'diag']:DEFAULT_DIAGS[x]; + // clampSpeed turns a missing key into the default, so this needs no + // hasOwnProperty guard of its own. + speeds[x]=clampSpeed(x,p[x+'speed']); }); savedProfile=currentProfile(); } @@ -995,6 +1052,14 @@ function renderModeSelects(){ fillSelect('mode-'+padId,opts,modes[padId]); fillSelect('dir-'+padId,DIR_OPTIONS,dirs[padId]); fillSelect('diag-'+padId,DIAG_OPTIONS,diags[padId]); + var speedSel=document.getElementById('speed-'+padId); + if(speedSel){ + speedSel.min=SPEED_MIN; speedSel.max=SPEED_MAX; speedSel.step=SPEED_STEP; + speedSel.value=speeds[padId]; + } + // Goes through setSpeed so the readout beside the slider is written by the + // one function that owns it, rather than by two that can drift apart. + setSpeed(padId,speeds[padId]); }); renderPadRows(); } @@ -1005,6 +1070,8 @@ function renderPadRows(){ var shown=PAD_ROWS_BY_MODE[mode]||[]; var dirRow=document.getElementById('dir-row-'+padId); if(dirRow) dirRow.style.display=(mode==='scroll')?'':'none'; + var speedRow=document.getElementById('speed-row-'+padId); + if(speedRow) speedRow.style.display=(mode==='scroll')?'':'none'; var diagRow=document.getElementById('diag-row-'+padId); if(diagRow) diagRow.style.display=(mode==='dpad')?'':'none'; // Only a directional pad splits its surface, so only it needs explaining. @@ -1117,6 +1184,10 @@ PADS.forEach(function(padId){ if(dirSel) dirSel.addEventListener('change',function(e){setDir(padId,e.target.value);}); var diagSel=document.getElementById('diag-'+padId); if(diagSel) diagSel.addEventListener('change',function(e){setDiag(padId,e.target.value);}); + var speedSel=document.getElementById('speed-'+padId); + // 'input' rather than 'change': the readout has to follow the thumb while it + // is being dragged, not only when it is let go. + if(speedSel) speedSel.addEventListener('input',function(e){setSpeed(padId,e.target.value);}); }); // mousedown as well as click: the document-level handler below closes the @@ -1992,6 +2063,10 @@ void RemapWindow::OnWebMessage(const std::wstring& raw) { s.click = BackButtonBinding::FromId(JsonStr(msg, id)); s.mode = TrackpadModeFromId(JsonStr(msg, id + "mode")); s.scrollDir = ScrollDirectionFromId(JsonStr(msg, id + "dir")); + // strtoul yields 0 for anything unparseable, which is the same + // "unset" the registry uses and lands on the default. + s.scrollSpeed = ScrollSpeedFromDword(static_cast( + std::strtoul(JsonStr(msg, id + "speed").c_str(), nullptr, 10))); s.diagonals = DiagonalModeFromId(JsonStr(msg, id + "diag")); s.touch = BackButtonBinding::FromId(JsonStr(msg, id + "touch")); s.up = BackButtonBinding::FromId(JsonStr(msg, id + "up")); @@ -2083,6 +2158,7 @@ std::wstring RemapWindow::ProfileJson(const ControllerProfile& p) { return L"\"" + k + L"\":\"" + wid(s.click) + L"\"," L"\"" + k + L"mode\":\"" + narrow(TrackpadModeId(s.mode)) + L"\"," L"\"" + k + L"dir\":\"" + narrow(ScrollDirectionId(s.scrollDir)) + L"\"," + L"\"" + k + L"speed\":\"" + std::to_wstring(s.scrollSpeed) + L"\"," L"\"" + k + L"diag\":\"" + narrow(DiagonalModeId(s.diagonals)) + L"\"," L"\"" + k + L"touch\":\"" + wid(s.touch) + L"\"," L"\"" + k + L"up\":\"" + wid(s.up) + L"\"," diff --git a/src/app/TrayApp.cpp b/src/app/TrayApp.cpp index 2c1dda9..f8e094a 100644 --- a/src/app/TrayApp.cpp +++ b/src/app/TrayApp.cpp @@ -1872,6 +1872,7 @@ void TrayApp::LoadSettings() { pad.mode = TrackpadModeFromDword(readDw(name(L"Mode").c_str(), 0)); pad.click = binding(L"Click", BackButtonAction::None); pad.scrollDir = ScrollDirectionFromDword(readDw(name(L"ScrollDir").c_str(), 0)); + pad.scrollSpeed = ScrollSpeedFromDword(readDw(name(L"ScrollSpeed").c_str(), 0)); pad.touch = binding(L"Touch", BackButtonAction::None); pad.up = binding(L"Up", BackButtonAction::DPadUp); pad.down = binding(L"Down", BackButtonAction::DPadDown); @@ -1948,6 +1949,7 @@ void TrayApp::SaveSettings() { writeDw(name(L"Mode").c_str(), static_cast(pad.mode)); writeDw(name(L"Click").c_str(), pad.click.Pack()); writeDw(name(L"ScrollDir").c_str(), static_cast(pad.scrollDir)); + writeDw(name(L"ScrollSpeed").c_str(), pad.scrollSpeed); writeDw(name(L"Touch").c_str(), pad.touch.Pack()); writeDw(name(L"Up").c_str(), pad.up.Pack()); writeDw(name(L"Down").c_str(), pad.down.Pack()); diff --git a/src/probe/TrackpadDirectionProbe.cpp b/src/probe/TrackpadDirectionProbe.cpp index dbafb87..23604c1 100644 --- a/src/probe/TrackpadDirectionProbe.cpp +++ b/src/probe/TrackpadDirectionProbe.cpp @@ -772,6 +772,34 @@ int RunChecks() { } + // Scroll speed bounds (#95). The page has its own copy of these rules in + // clampSpeed, so the two have to agree — a value the page allows and this + // side rewrites shows up as a setting that will not stay put. + printf("\nScroll speed clamping\n"); + { + // Zero is what the registry yields for every profile written before + // the setting existed. It has to read as "unset", not as "do not + // scroll" — a pad that silently stopped scrolling on upgrade would be + // a far worse bug than one that scrolls at the wrong speed. + Check(ScrollSpeedFromDword(0) == kScrollSpeedDefault, + "an absent value is the default, not zero"); + Check(ScrollSpeedFromDword(100) == 100, "a normal value is kept"); + Check(ScrollSpeedFromDword(kScrollSpeedMin) == kScrollSpeedMin, + "the minimum is kept"); + Check(ScrollSpeedFromDword(kScrollSpeedMax) == kScrollSpeedMax, + "the maximum is kept"); + Check(ScrollSpeedFromDword(1) == kScrollSpeedMin, + "below the minimum clamps up"); + Check(ScrollSpeedFromDword(99999) == kScrollSpeedMax, + "above the maximum clamps down"); + // A DWORD read from the registry can be anything at all. + Check(ScrollSpeedFromDword(0xFFFFFFFFu) == kScrollSpeedMax, + "a nonsense DWORD clamps rather than wrapping"); + Check(kScrollSpeedMin <= kScrollSpeedDefault + && kScrollSpeedDefault <= kScrollSpeedMax, + "the default sits inside the range"); + } + // Scroll normalisation (#95). A machine's lines-per-notch setting // multiplies every wheel event we send, so identical injection scrolls a // different distance on two machines — which is how a scroll-is-far-too-