diff --git a/docs/frame-plugin-author.md b/docs/frame-plugin-author.md index ab65086..fbca363 100644 --- a/docs/frame-plugin-author.md +++ b/docs/frame-plugin-author.md @@ -63,6 +63,7 @@ none of these frontend controls has passed a worn-headset check yet. | Official `@decky/rollup` JSX (`SP_JSX.jsx`, `jsxs`, `Fragment`) | Creates React elements in the isolated panel | Sample bundle smoke test; host unit test | | `PanelSection`, rows, buttons, toggles, dropdowns | Local touch-sized controls; disabled controls stay inert during a backend call | Local browser and host unit test | | `TextField`, `SliderField`, grouped `Dropdown` options | Native text, range and select controls in the panel; disabled state, numeric slider callback and basic input types | Host unit test; worn VR use unverified | +| Scrollable panel content | Visible Scroll plugin up/down buttons move the last region reached by a trusted pointer, or the document if that region disappears. Plugins may still use ordinary CSS overflow containers. | Parent/iframe message tests; physical controller scroll remains unverified | | `Tabs`, `SidebarNavigation`, spinners, dialog/menu wrappers, progress bars | Local tab navigation and ordinary HTML controls; no Steam navigation or controller focus tree | Host tests and catalog frontend smoke checks in local Chromium | | `SteamUIStore` / `DFL.Router.RunningApps`, `MainRunningApp` | Bounded detached Steam CEF reads while the panel is open; refresh about every two seconds; no MobX observation. Failed reads clear stale context. | Locked real Frame empty-list reads; nonempty local Chromium fixtures. Real running-game selection unverified. | | `GameSessions.RegisterForAppLifetimeNotifications` | Actual Steam callbacks, bounded queue and disposable `.unregister()` handle; no events inferred from snapshot changes; lease cleanup after lost panels | Real Frame registration/poll/close; launch/exit, retry, expiry and duplicate tests use explicit fixtures. Real game transitions unverified. | diff --git a/docs/framey-loader.md b/docs/framey-loader.md index e98344b..233dd1d 100644 --- a/docs/framey-loader.md +++ b/docs/framey-loader.md @@ -16,6 +16,10 @@ plugins → Open official store**. Search the catalogue, confirm an install, and choose **Open panel** from the installed plugin list. The panel contains the plugin's own controls and settings. Update and Remove affect only plugin folders that Frame Control's store manager installed. +Use **Scroll plugin up** and **Scroll plugin down** above an open panel when +wheel scrolling is unavailable. Point at a scrollable part of the plugin first +to move that section; otherwise the buttons move the panel document. Each +press moves about three quarters of the visible section and stops at its edge. Removing a plugin keeps its Decky settings. Removing the Frame Control headset panel or Decky backend keeps plugin folders until each is removed separately. @@ -82,7 +86,11 @@ record the resulting TabMaster documentation support and remaining blockers. The host currently supplies sections, rows, buttons, toggles, text fields, sliders, grouped dropdowns, scroll containers, a modal, locale information, local panel routes and toasts, -and backend calls for the plugin that owns the panel. Steam library/store page +and backend calls for the plugin that owns the panel. The headset's scroll +buttons send only a direction to the isolated iframe. They target the last +scrollable region reached by a trusted pointer move and fall back to the iframe +document when that region disappears. A result message reports whether content +moved; this bridge has no backend or Steam privilege. Steam library/store page patches, global Steam UI components, file pickers, and privileged root methods have no VR target. A plugin that depends on them can render only part of its UI or show an incompatibility message. A Decky plugin's Python backend still runs as diff --git a/docs/framey-vr-check.md b/docs/framey-vr-check.md index 60c429c..330087b 100644 --- a/docs/framey-vr-check.md +++ b/docs/framey-vr-check.md @@ -59,6 +59,10 @@ pointer over it, then attempts OpenVR smooth and discrete scroll events. It reports region geometry, trusted pointer events, wheel events and `scrollTop`. No wheel event or offset change means this synthetic injection path remains unverified; it does not establish whether a physical controller can scroll. +The exercise then clicks the visible **Scroll plugin down/up** buttons through +the same native OpenVR pointer transport and requires the test region to move +down and return to the top. This proves the button path only; it does not +identify a physical controller or repair native wheel delivery. Temporary controls exercise the shipped `DFL.SliderField` and `DFL.TextField`; their values remain UI-only. Text uses CDP `Input.insertText` after OpenVR pointer focus. This is synthetic input, diff --git a/scripts/framey_vr_input.py b/scripts/framey_vr_input.py index d10742a..bcbf7b3 100644 --- a/scripts/framey_vr_input.py +++ b/scripts/framey_vr_input.py @@ -71,10 +71,26 @@ def exercise(profile, plugin_id, folder): if not hit: raise RuntimeError('Native click hit another element: ' + str(panel.eval('({tag:__vrLastTarget?.tagName,text:__vrLastTarget?.textContent?.slice(0,100)})'))) + def click_parent(selector): + parent.eval(f'document.querySelector({json.dumps(selector)}).scrollIntoView({{block:"center"}})') + time.sleep(.3) + pos = parent.eval(f'''(()=>{{let r=document.querySelector({json.dumps(selector)}).getBoundingClientRect(); + return {{x:r.x+r.width/2,y:r.y+r.height/2,w:innerWidth,h:innerHeight}}}})()''') + sx, sy = vr.snapshot()['input']['scale'] + x, y = pos['x'] * sx / pos['w'], sy - pos['y'] * sy / pos['h'] + count = parent.eval('window.__vrParentClickCount') + vr.post_mouse(300, x, y) + vr.post_mouse(301, x, y, 1) + vr.post_mouse(302, x, y, 1) + wait(lambda:parent.eval('window.__vrParentClickCount'), lambda n:n > count, seconds=3) + if not parent.eval(f'!!window.__vrParentLastTarget?.closest({json.dumps(selector)})'): + raise RuntimeError('Native click missed parent control ' + selector) + try: parent.eval(f'(async()=>{{await refreshDecky();openFrameyPanel({plugin_id},"Framey VR Check");return true}})()') wait(lambda:parent.eval('byId("frameyPanelStatus").innerText'), lambda s:s == 'Plugin panel ready') panel = connect_panel() + parent.eval('window.__vrParentClickCount=0;addEventListener("pointerup",e=>{window.__vrParentClickCount++;window.__vrParentLastTarget=e.target;});true') initial = wait(text, lambda s:'Saved setting: off' in s or 'Could not read setting:' in s) if 'Could not read setting:' in initial: raise RuntimeError(initial) result['initialText'] = initial @@ -149,6 +165,13 @@ def exercise(profile, plugin_id, folder): result['scrollAfter'] = result['scrollAttempts'][-1]['offset'] result['scrollEvents'] = panel.eval('__vrWheelEvents') result['nativeScrollMoved'] = result['scrollAfter'] != result['scrollBefore'] + click_parent('#frameyScrollDown') + result['buttonScrollAfterDown'] = wait( + lambda:panel.eval('document.getElementById("vr-scroll-probe").scrollTop'), lambda n:n > 0) + click_parent('#frameyScrollUp') + result['buttonScrollAfterUp'] = wait( + lambda:panel.eval('document.getElementById("vr-scroll-probe").scrollTop'), lambda n:n == 0) + result['buttonScrollMoved'] = result['buttonScrollAfterDown'] > 0 and result['buttonScrollAfterUp'] == 0 panel.eval('document.getElementById("vr-scroll-probe").remove()') panel.eval('document.getElementById("vr-controls").remove()') # Scroll saved status into the visible iframe for compositor evidence. @@ -156,7 +179,7 @@ def exercise(profile, plugin_id, folder): result['finalOverlay'] = vr.snapshot() result['passed'] = (result['trustedOverlayPointer'] and result['diskSetting'] == {'enabled':True} and result['reopenedChecked'] and result['sliderValue'] != '10' - and result['textValue'] == 'framey-ci') + and result['textValue'] == 'framey-ci' and result['buttonScrollMoved']) return result except Exception as exc: result['error'] = str(exc) diff --git a/tests/page/framey_actions.mjs b/tests/page/framey_actions.mjs index 4ed994f..4cd6cb7 100644 --- a/tests/page/framey_actions.mjs +++ b/tests/page/framey_actions.mjs @@ -21,7 +21,8 @@ const window={React,ReactDOM:{createRoot:node=>{ const listeners=[];const messages=[];const parent={postMessage:message=>messages.push(message)}; const context=vm.createContext({window,document:{getElementById:id=>{ if(!elements.has(id))elements.set(id,new Element());return elements.get(id);},createElement:()=>new Element()}, - navigator:{language:'en'}, parent,DOMException,addEventListener:(type,handler)=>listeners.push({type,handler}), + navigator:{language:'en'}, parent,DOMException,getComputedStyle:node=>({overflowY:node.overflowY||'visible'}), + addEventListener:(type,handler)=>listeners.push({type,handler}), setTimeout:()=>1,clearTimeout(){}}); vm.runInContext(testSource,context); // Official @decky/rollup bundles use the automatic JSX runtime, including keyed lists. @@ -371,6 +372,35 @@ assert.equal(nodes(window.__surface({plugin}),GameRoute).length,0); // Event subscription is acknowledged before a method that immediately emits. const onMessage=listeners.find(x=>x.type==='message').handler; +// The headset's two scroll buttons target the last trusted pointer's nested +// scroll region, with a document fallback after that region is removed. +const documentScroll={scrollTop:0,clientHeight:500,scrollHeight:900,isConnected:true,overflowY:'auto'}; +const nestedScroll={scrollTop:0,clientHeight:160,scrollHeight:716,isConnected:true, + overflowY:'scroll',parentElement:documentScroll}; +const child={scrollTop:0,clientHeight:0,scrollHeight:0,parentElement:nestedScroll}; +context.document.scrollingElement=documentScroll; +const pointerMove=listeners.find(x=>x.type==='pointermove').handler; +pointerMove({isTrusted:true,target:child}); +onMessage({source:parent,data:{type:'framey:scroll',session:'',direction:'down'}}); +assert.equal(nestedScroll.scrollTop,120); +assert.equal(messages.at(-1).moved,true); +pointerMove({isTrusted:false,target:documentScroll}); +onMessage({source:parent,data:{type:'framey:scroll',session:'',direction:'up'}}); +assert.equal(nestedScroll.scrollTop,0,'Untrusted movement cannot retarget scroll'); +nestedScroll.scrollTop=550; +onMessage({source:parent,data:{type:'framey:scroll',session:'',direction:'down'}}); +assert.equal(nestedScroll.scrollTop,556,'Scroll stops at the content end'); +assert.equal(messages.at(-1).moved,true); +onMessage({source:parent,data:{type:'framey:scroll',session:'',direction:'down'}}); +assert.equal(messages.at(-1).moved,false); +const scrollReplies=messages.length; +onMessage({source:parent,data:{type:'framey:scroll',session:'stale',direction:'up'}}); +onMessage({source:{},data:{type:'framey:scroll',session:'',direction:'up'}}); +onMessage({source:parent,data:{type:'framey:scroll',session:'',direction:'sideways'}}); +assert.equal(messages.length,scrollReplies); +nestedScroll.isConnected=false; +onMessage({source:parent,data:{type:'framey:scroll',session:'',direction:'down'}}); +assert.equal(documentScroll.scrollTop,375); const eventValues=[]; const callback=(...args)=>eventValues.push(args); assert.equal(api.addEventListener('timer:tick',callback),callback); diff --git a/tests/page/framey_event_parent.mjs b/tests/page/framey_event_parent.mjs index ddc6701..8d68865 100644 --- a/tests/page/framey_event_parent.mjs +++ b/tests/page/framey_event_parent.mjs @@ -69,7 +69,6 @@ vm.runInContext('openFrameyPanel(96,"Timer")',context); const priorReplies=replies.length; requests.at(-1).resolve({ok:true,json:async()=>({result:'AU'})}); await staleRead;assert.equal(replies.length,priorReplies); - // Running reads are tied to the current iframe and session, with no method path. session=vm.runInContext('frameySession',context); const running={type:'framey:running',id:20,session,args:[0]}; @@ -96,3 +95,19 @@ await finalRunning; await send({type:'framey:running:close',session}); assert.equal(requests.at(-1).body.receipt,'a'.repeat(64)); assert.equal(requests.at(-1).body.active,false); + +const scrollSession=vm.runInContext('frameySession',context); +el('frameyScrollDown').onclick(); +assert.equal(replies.at(-1).type,'framey:scroll'); +assert.equal(replies.at(-1).session,scrollSession); +assert.equal(replies.at(-1).direction,'down'); +await send({type:'framey:scroll:result',session:'old',direction:'down',moved:true}); +assert.equal(el('frameyPanelStatus').textContent,'Loading plugin panel…'); +await send({type:'framey:scroll:result',session:scrollSession,direction:'down',moved:true}); +assert.equal(el('frameyPanelStatus').textContent,'Scrolled plugin down'); +await send({type:'framey:scroll:result',session:scrollSession,direction:'down',moved:false}); +assert.equal(el('frameyPanelStatus').textContent,'No more plugin content in that direction'); +el('frameyClose').onclick(); +const replyCount=replies.length; +el('frameyScrollUp').onclick(); +assert.equal(replies.length,replyCount); diff --git a/ui/framey-host.js b/ui/framey-host.js index 810d2c1..57fdcdf 100644 --- a/ui/framey-host.js +++ b/ui/framey-host.js @@ -34,6 +34,25 @@ const root = document.getElementById('framey-root'); const error = document.getElementById('framey-error'); const toastRoot = document.getElementById('framey-toasts'); + let lastScrollTarget = null; + function scrollTarget(element) { + for (let node = element; node && node !== document; node = node.parentElement) { + if (node.scrollHeight <= node.clientHeight + 1) continue; + if (node === document.scrollingElement || /^(auto|scroll|overlay)$/.test(getComputedStyle(node).overflowY)) + return node; + } + return document.scrollingElement; + } + function scrollPlugin(direction) { + if (direction !== 'up' && direction !== 'down') return false; + const target = lastScrollTarget?.isConnected ? lastScrollTarget : document.scrollingElement; + if (!target) return false; + const before = target.scrollTop; + const maximum = Math.max(0, target.scrollHeight - target.clientHeight); + const step = Math.max(120, Math.round(target.clientHeight * 0.75)); + target.scrollTop = Math.max(0, Math.min(maximum, before + (direction === 'down' ? step : -step))); + return target.scrollTop !== before; + } const pending = new Map(); const routes = new Map(); const routeListeners = new Set(); @@ -139,6 +158,14 @@ return request(method, args, legacy); } addEventListener('message', event => { + if (event.source === parent && event.data?.type === 'framey:scroll' && + event.data.session === cacheSession) { + if (event.data.direction !== 'up' && event.data.direction !== 'down') return; + const moved = scrollPlugin(event.data.direction); + parent.postMessage({type:'framey:scroll:result', session:cacheSession, + direction:event.data.direction, moved}, '*'); + return; + } if (event.source === parent && event.data?.type === 'framey:cache:seed' && event.data.session === cacheSession && acceptSeed) { const entries = event.data.entries; @@ -165,6 +192,9 @@ addEventListener('pointerup', event => { if (event.isTrusted) parent.postMessage({type:'framey:input'}, '*'); }, {passive:true}); + addEventListener('pointermove', event => { + if (event.isTrusted) lastScrollTarget = scrollTarget(event.target); + }, {passive:true}); const passthrough = tag => ({children, ...props}) => R.createElement(tag, props, children); const Field = ({label, description, children}) => R.createElement('label', null, diff --git a/ui/headset.html b/ui/headset.html index c76b690..856a9e8 100644 --- a/ui/headset.html +++ b/ui/headset.html @@ -53,7 +53,8 @@ iframe{display:block;width:100%;height:560px;background:#0d1925;border:1px solid
Loading plugin panel…