From db74e3be96d18d835a4e5149ba3d57fd75ad6f59 Mon Sep 17 00:00:00 2001 From: SAPHID Date: Sun, 25 Oct 2026 10:09:56 +0000 Subject: [PATCH] Support more Decky panel controls in Framey --- docs/frame-plugin-author.md | 3 ++- docs/framey-loader.md | 3 ++- tests/page/framey_actions.mjs | 28 +++++++++++++++++++++++- ui/framey-host.js | 40 ++++++++++++++++++++++++++++------- 4 files changed, 63 insertions(+), 11 deletions(-) diff --git a/docs/frame-plugin-author.md b/docs/frame-plugin-author.md index 1d00243..af3e5e2 100644 --- a/docs/frame-plugin-author.md +++ b/docs/frame-plugin-author.md @@ -51,7 +51,8 @@ none of these frontend controls has passed a worn-headset check yet. | --- | --- | --- | | `definePlugin` with `content`, `titleView` | Renders a React panel and optional title | Local browser with ProtonDB Badges; host unit test | | 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 | Local browser and host unit test | +| `PanelSection`, rows, buttons, toggles, dropdowns | Local touch-sized controls; disabled state works for buttons, toggles and dropdowns | Local browser and host unit test | +| `TextField`, `SliderField`, grouped `Dropdown` options | Native text, range and select controls in the panel; numeric slider callback and basic input types | Host unit test; worn VR use unverified | | Optional `settings`, `frameyQuickActions` | Separate settings tab; up to eight actions with result text | Host unit test; these fields are Framey additions | | `serverAPI.call` / `callable` | Calls only that plugin's Decky Python backend via the headset service | Fake Frame e2e for setting read/write across CEF calls, update, and reinstall; host unit test. Real Decky persistence remains unverified. | | `toaster.toast` | Visible toast inside the plugin panel; `dismiss` and optional `onClick` | Host unit test; no Steam system notification | diff --git a/docs/framey-loader.md b/docs/framey-loader.md index e6dded6..7f5d90c 100644 --- a/docs/framey-loader.md +++ b/docs/framey-loader.md @@ -37,7 +37,8 @@ Decky websocket, because Decky allows only one frontend connection. The iframe does not receive Frame Control's UI key. React and ReactDOM are bundled for offline use under their MIT licenses in `ui/vendor`. -The host currently supplies sections, rows, buttons, toggles, dropdowns, +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 patches, global Steam UI components, file pickers, and privileged root methods diff --git a/tests/page/framey_actions.mjs b/tests/page/framey_actions.mjs index 74fe926..9f27681 100644 --- a/tests/page/framey_actions.mjs +++ b/tests/page/framey_actions.mjs @@ -28,7 +28,33 @@ assert.deepEqual(jsx.children,['Saved']); const fragments=window.SP_JSX.jsxs(window.SP_JSX.Fragment,{children:['one','two']}); assert.equal(fragments.type,React.Fragment); assert.deepEqual(fragments.children,[['one','two']]); -const nodes=(tree,type)=>[...(tree?.type===type?[tree]:[]),...(tree?.children||[]).flatMap(child=>nodes(child,type))]; +const nodes=(tree,type)=>Array.isArray(tree)?tree.flatMap(child=>nodes(child,type)): + [...(tree?.type===type?[tree]:[]),...(tree?.children||[]).flatMap(child=>nodes(child,type))]; +// Common Decky controls must honor disabled and provide numeric/text inputs. +const toggle=nodes(window.DFL.ToggleField({label:'Enabled',checked:true,disabled:true}),'input')[0]; +assert.equal(toggle.props.disabled,true); +const button=nodes(window.DFL.ButtonItem({children:'Refresh',disabled:true}),'button')[0]; +assert.equal(button.props.disabled,true); +let entered=''; +const input=nodes(window.DFL.TextField({label:'Alias',value:'Old',placeholder:'New',disabled:true, + onChange:event=>{entered=event.target.value;}}),'input')[0]; +assert.equal(input.props.value,'Old'); +assert.equal(input.props.disabled,true); +input.props.onChange({target:{value:'New'}});assert.equal(entered,'New'); +let speed=0; +const slider=nodes(window.DFL.SliderField({label:'Speed',value:2,min:0,max:10,step:2,disabled:true, + onChange:value=>{speed=value;}}),'input')[0]; +assert.equal(slider.props.type,'range'); +assert.equal(slider.props.disabled,true); +slider.props.onChange({target:{value:'4'}});assert.equal(speed,4); +let selected; +const options=[{label:'Modes',options:[{label:'Slow',data:'slow'},{label:'Fast',data:'fast'}]}]; +const dropdown=nodes(window.DFL.Dropdown({rgOptions:options,selectedOption:'fast',disabled:true, + onChange:option=>{selected=option;}}),'select')[0]; +assert.equal(dropdown.props.disabled,true); +assert.equal(dropdown.props.value,'1'); +assert.equal(nodes(dropdown,'optgroup').length,1); +dropdown.props.onChange({target:{value:'0'}});assert.equal(selected.data,'slow'); let called=0; const plugin={content:{type:'content'},settings:{type:'settings'},titleView:{type:'title'}, frameyQuickActions:[{label:'Refresh data',onClick:async()=>{called++;return 'Fresh';}}, diff --git a/ui/framey-host.js b/ui/framey-host.js index be2da53..94050aa 100644 --- a/ui/framey-host.js +++ b/ui/framey-host.js @@ -46,13 +46,36 @@ const passthrough = tag => ({children, ...props}) => R.createElement(tag, props, children); const Field = ({label, description, children}) => R.createElement('label', null, R.createElement('strong', null, label), description && R.createElement('small', null, description), children); - const ToggleField = ({label, description, checked, onChange}) => R.createElement(Field, {label, description}, - R.createElement('input', {type:'checkbox', checked:!!checked, onChange:e=>onChange?.(e.target.checked)})); - const DropdownItem = ({label, description, rgOptions=[], selectedOption, onChange}) => R.createElement(Field, {label, description}, - R.createElement('select', {value:String(selectedOption ?? ''), onChange:e=>onChange?.(rgOptions.find(x=>String(x.data)===e.target.value))}, - rgOptions.map(x=>R.createElement('option', {key:String(x.data), value:String(x.data)}, x.label)))); - const ButtonItem = ({label, children, onClick}) => R.createElement(Field, {label}, - R.createElement('button', {type:'button', onClick}, children || label)); + const ToggleField = ({label, description, checked, disabled, onChange}) => R.createElement(Field, {label, description}, + R.createElement('input', {type:'checkbox', checked:!!checked, disabled:!!disabled, + onChange:e=>onChange?.(e.target.checked)})); + const DropdownItem = ({label, description, rgOptions=[], selectedOption, disabled, strDefaultLabel, onChange}) => { + const choices=[]; + const option = entry => { + const index=choices.push(entry)-1; + return R.createElement('option', {key:index, value:String(index)}, entry.label); + }; + const entries=(Array.isArray(rgOptions) ? rgOptions : []).map(entry => + Array.isArray(entry.options) ? R.createElement('optgroup', {key:String(entry.label), label:String(entry.label)}, + entry.options.map(option)) : option(entry)); + const selected=choices.findIndex(entry=>Object.is(entry.data, selectedOption)); + return R.createElement(Field, {label, description}, R.createElement('select', { + value:selected < 0 ? '' : String(selected), disabled:!!disabled, + onChange:e=>{const entry=choices[Number(e.target.value)];if(entry)onChange?.(entry);}}, + selected < 0 && R.createElement('option', {value:'', disabled:true}, strDefaultLabel || 'Choose an option'), + entries)); + }; + const ButtonItem = ({label, children, disabled, onClick}) => R.createElement(Field, {label}, + R.createElement('button', {type:'button', disabled:!!disabled, onClick}, children || label)); + const TextField = ({label, description, value, placeholder, disabled, bIsPassword, + mustBeNumeric, mustBeURL, mustBeEmail, onChange}) => R.createElement(Field, {label, description}, + R.createElement('input', {type:bIsPassword?'password':mustBeNumeric?'number':mustBeURL?'url':mustBeEmail?'email':'text', + value, placeholder, disabled:!!disabled, onChange})); + const SliderField = ({label, description, value, min=0, max=100, step=1, disabled, showValue, + valueSuffix='', onChange}) => R.createElement(Field, {label, description}, + R.createElement('input', {type:'range', value, min, max, step, disabled:!!disabled, + 'aria-label':typeof label==='string'?label:undefined, onChange:e=>onChange?.(Number(e.target.value))}), + showValue && R.createElement('span', null, String(value) + valueSuffix)); const PanelSection = ({title, children}) => R.createElement('section', null, title && R.createElement('h2', null, title), children); const ConfirmModal = ({strTitle, strOKButtonText, onOK, children}) => R.createElement('section', null, @@ -80,7 +103,8 @@ }; const DFL = window.DFL = { definePlugin: init => init, - PanelSection, PanelSectionRow: passthrough('div'), Field, ToggleField, DropdownItem, ButtonItem, + PanelSection, PanelSectionRow: passthrough('div'), Field, ToggleField, DropdownItem, + Dropdown: DropdownItem, ButtonItem, TextField, SliderField, Focusable: passthrough('div'), ScrollPanel: passthrough('div'), ScrollPanelGroup: passthrough('div'), DialogButton: passthrough('button'), ConfirmModal, staticClasses: {Title:'framey-title'},