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-