diff --git a/README.md b/README.md index ccdedaa..ec86081 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,7 @@ the two files below). windowDistance.world.max = 10.0; steamCloseButton.enable = true; windowCurvature.enable = true; + frameControls.enable = true; }; firefox.enable = true; # Only relevant with Steam Developer Mode on (see hidden-apps below). @@ -168,7 +169,7 @@ home-manager switch --flake .#steamos ``` Individual modules are available as -`homeManagerModules.{session,portal,keyboard-layout,steam-keyboard-patch,hidden-apps,steam-ui-patches,launcher-menu,steamvr-debugger,dashboard-windows,steam-close-button,window-curvature,clipboard-sync,firefox}`; +`homeManagerModules.{session,portal,keyboard-layout,steam-keyboard-patch,hidden-apps,steam-ui-patches,launcher-menu,steamvr-debugger,dashboard-windows,steam-close-button,window-curvature,frame-controls,clipboard-sync,firefox}`; `default` imports all of them. **Steam Developer Mode** (a Steam setting, not managed here) makes the "+" @@ -214,6 +215,11 @@ menu list every desktop entry, including terminals such as Konsole. | `steamFrame.dashboard.windowCurvature.barDragPixelsPerUnit` | number (px) | `30` | Drag distance per 1.0 of curvature on the bottom-bar button. | | `steamFrame.dashboard.windowCurvature.barDragRoom` | unsigned int (px) | `160` | Transparent room added above and below a window's bottom bar while its curvature button is dragged, so the laser stays on the bar. `0`: none. | | `steamFrame.dashboard.windowCurvature.haptics` | bool | `true` | Controller haptics while dragging: snap at snap points, edge at 0 and `max`, a tick per other step. | +| `steamFrame.dashboard.frameControls.enable` | bool | `false` | Move the control icons under dashboard windows between the bottom bar and the More Options (three-dot) menu: long press an icon or menu row, then "Show in bar". Per control type, for all windows, kept across SteamVR restarts. See [Window control bar](#window-control-bar-dashboardframecontrols). | +| `steamFrame.dashboard.frameControls.longPressMs` | int, 300-10000 (ms) | `1500` | Hold time of the long press. A ring shows the progress from half of it (at most after 1 s). | +| `steamFrame.dashboard.frameControls.inBar` | list of control names | `[ ]` | Controls that start in the bar: `keyboard`, `float`, `dashboard`, `theater`, `dockLeft`, `dockRight`, `close`, `curvature`, or `"icon:"`. A popup choice wins until the entry changes. | +| `steamFrame.dashboard.frameControls.inMenu` | list of control names | `[ ]` | Controls that start in the three-dot menu (same names). | +| `steamFrame.dashboard.frameControls.floatInTheater` | bool | `false` | Give theater windows the "Float" control (stock only shows it for dashboard-docked windows). | | `steamFrame.steamvrDebugger.enable` | bool | `true` if a UI patch uses port 8087, else `false` | SteamVR dashboard DevTools on `127.0.0.1:8087` (`VRWebHelper/DebuggerEnabled`), needed by dashboard patches. See [SteamVR debugger](#steamvr-debugger-steamvrdebuggerenable). | | `steamFrame.hiddenApps` | list of str | `[ ]` | Desktop entry ids (without `.desktop`) to hide from the "+" and KDE menus. | | `steamFrame.clipboardSync.enable` | bool | `true` | Clipboard bridge between the Steam session and the nested desktop. | @@ -835,6 +841,83 @@ offline (`window-curvature` in `modules/lib/signatures.json`, `scripts/check-signatures.mjs`); if a lookup fails, the patch reports it and leaves the dashboard stock. Tested with SteamVR build 11008059. +### Window control bar (`dashboard.frameControls.*`) + +**Problem:** the controls under a SteamVR dashboard window are fixed: some +sit in the window's bottom bar (keyboard, float or back to the dashboard, +theater, close), others only in its More Options (three-dot) menu +(curvature, dock to a controller), and in the theater the "Float" button is +gone. + +**Fix:** a UI patch (see above) of SteamVR's dashboard page (`vrwebhelper`, +`127.0.0.1:8087`, title `systemui`; it turns on the +[SteamVR debugger](#steamvr-debugger-steamvrdebuggerenable)): + +- **long press** (`longPressMs`) a bar icon or a three-dot menu row: after + half the time (at most after 1 s) a ring around the icon shows the + progress, so ordinary clicks show nothing; moving the laser or leaving + the icon doesn't cancel it, releasing early does. Then a small popup + opens above the icon (or above the menu) with **Show in bar**; toggling + it moves that control between the bar and the menu, **for all windows**. + The release after the long press doesn't trigger the control. The + popup closes on a press elsewhere, Escape, 1 s after the laser left it, + and after toggling. +- a control moved into the bar joins the group left of the three-dot + button; the three-dot button itself can't be moved and, as in SteamVR, + shows only while its menu has entries. +- `inBar` / `inMenu` set where controls start; a choice made in the popup + wins until that control's entry changes. `floatInTheater` gives theater + windows the "Float" button back (same call as the stock one: the window + floats in the world). + +```nix +steamFrame.dashboard.frameControls = { + enable = true; + # longPressMs = 1500; + # inBar = [ "curvature" ]; inMenu = [ "theater" ]; + # floatInTheater = true; +}; +``` + +How it works: each window hands its control lists to +`Frame.prototype.SetControlsItems(bottom, tabHover, additional)`; the patch +wraps it and stores a re-partitioned copy (stock order kept, gaps merged). +Controls are identified by their action icon (`icon:`), the same in every +window and language. Placements are kept in the `systemui` page +(`window.__sfuiFrameControlsState`) and in its `localStorage`, so they +survive restarts of the patch service and of SteamVR. The popup is a +scene-graph panel of its own, attached to the pressed button's anchor like +SteamVR's tooltips and three-dot menu, so the bar and the menu don't change +size or move. SteamVR closes the three-dot menu as soon as the laser's focus +leaves the bar and the menu; while its popup is open that close is held +back and applied (unless the laser is on the bar or menu again) when the +popup closes. `window.__sfuiFrameControls.dump()` lists every window's +controls, `.placement()` the current choices, `.reset()` forgets them, +`.log` recent events. + +With [Window curvature](#window-curvature-dashboardwindowcurvature), the +curvature control keeps its drag: a drag that starts before the ring shows +cancels the long press; once the ring shows, the long press takes the press +over (its `sfui-curv-*` contract), so a still hold on the curvature control +opens the popup. + +**Limitations:** + +- SteamVR delivers no right-click or thumbstick click to the dashboard, so + there is no context menu on a secondary button. +- Only the laser: with controller (gamepad) navigation the controls are + stock. +- Placements are per control type, not per window. + +**Caveats:** depends on SteamVR UI internals: MobX, the `Frame` class, the +action store, the dock-location enum, the input-focus store and the +localization function are found by signature, and the rest (the control +list layout, the icon numbers, the frame-controls panel and anchor ids, the +scene-graph DOM walk and `SGApp`'s embedded-UV table, the menu's auto-close) +is checked offline (`frame-controls` in `modules/lib/signatures.json`, +`scripts/check-signatures.mjs`); if a lookup fails, the patch reports it and +leaves the dashboard stock. Tested with SteamVR build 11008059. + ### SteamVR debugger (`steamvrDebugger.enable`) Patches of the SteamVR dashboard need its DevTools port, which SteamVR only @@ -843,7 +926,8 @@ opens with the setting `VRWebHelper/DebuggerEnabled` (port (`mkDefault`) as soon as a patch in `steamFrame.uiPatches.patches` uses port 8087 (e.g. [Dashboard windows](#dashboard-windows-dashboard), [Steam close button](#steam-close-button-dashboardsteamclosebuttonenable), -[Window curvature](#window-curvature-dashboardwindowcurvature)). +[Window curvature](#window-curvature-dashboardwindowcurvature), +[Window control bar](#window-control-bar-dashboardframecontrols)). SteamVR rewrites `~/.config/openvr/config/steamvr.vrsettings` while it runs and on exit, so the file can't be managed by Home Manager, and edits while it diff --git a/flake.nix b/flake.nix index 6b8886c..0c6c20b 100644 --- a/flake.nix +++ b/flake.nix @@ -23,6 +23,7 @@ dashboard-windows = ./modules/dashboard-windows.nix; steam-close-button = ./modules/steam-close-button.nix; window-curvature = ./modules/window-curvature.nix; + frame-controls = ./modules/frame-controls.nix; # Built with the consumer's pkgs; only the source comes from our input. # The key lets the module system deduplicate it when it is imported both # directly and through `default`. diff --git a/modules/frame-controls.nix b/modules/frame-controls.nix new file mode 100644 index 0000000..a32cfda --- /dev/null +++ b/modules/frame-controls.nix @@ -0,0 +1,89 @@ +# Window control bar (steamFrame.dashboard.frameControls): move the control +# icons under SteamVR dashboard windows between the bottom bar and the More +# Options (three-dot) menu with a long press on the icon or menu row (a popup +# with "Show in bar"). Placement is per control type, for all windows, kept +# across SteamVR restarts; inBar / inMenu set defaults. Optionally gives +# theater windows the "Float" control back. Runtime patch of SteamVR's +# dashboard page (vrwebhelper "systemui", DevTools 127.0.0.1:8087; see +# frame-controls/patch.js for how it works), registered with +# steam-ui-patches.nix only when enabled; it turns on the SteamVR web helper +# debugger (steamvr-debugger.nix), which needs one SteamVR restart the first +# time. Depends on SteamVR UI internals, found by signature +# (lib/signatures.json, "frame-controls"; scripts/check-signatures.mjs checks +# them offline). +{ config, lib, pkgs, ... }: +let + cfg = config.steamFrame.dashboard.frameControls; + inherit (lib) mkOption types; + inherit (import ./lib { inherit pkgs; }) mkPatch; + + # Control names -> SteamVR action icon enums (the patch keys controls by icon). + names = [ "keyboard" "float" "dashboard" "theater" "dockLeft" "dockRight" "close" "curvature" ]; + controlType = types.either (types.enum names) (types.strMatching "icon:[0-9]+"); + controlList = description: mkOption { + type = types.listOf controlType; + default = [ ]; + example = [ "curvature" ]; + inherit description; + }; +in { + imports = [ ./steam-ui-patches.nix ./steamvr-debugger.nix ]; + + options.steamFrame.dashboard.frameControls = { + enable = lib.mkEnableOption '' + moving the control icons under SteamVR dashboard windows between the + window's bottom bar and its More Options (three-dot) menu: a long press + on a bar icon or a menu row opens a popup with "Show in bar"; the choice + applies to that control in all windows and is kept across SteamVR + restarts. Short presses work as usual. Runtime patch of the SteamVR + dashboard (steamFrame.uiPatches), applied immediately; turning it off + restores the stock controls (next switch)''; + longPressMs = mkOption { + type = types.ints.between 300 10000; + default = 1500; + description = '' + Hold time (ms) of the long press that opens the popup. A ring around + the icon shows the progress from half the time (at most after 1 s), + so ordinary clicks show nothing. + ''; + }; + inBar = controlList '' + Controls that start in the bar (by default: SteamVR's placement). + Names: ${lib.concatStringsSep ", " names}, or "icon:" (the action's + icon number, see window.__sfuiFrameControls.dump() in the dashboard's + DevTools). A choice made in the popup wins until the control's entry + here changes. + ''; + inMenu = controlList '' + Controls that start in the three-dot menu (same names as inBar). + ''; + floatInTheater = mkOption { + type = types.bool; + default = false; + description = '' + Give windows in the theater the "Float" control (SteamVR only shows it + for windows docked in the dashboard): floats the window in the world, + like the stock button. Follows Float's placement. + ''; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = [ { + assertion = lib.intersectLists cfg.inBar cfg.inMenu == [ ]; + message = "steamFrame.dashboard.frameControls: ${builtins.toJSON (lib.intersectLists cfg.inBar cfg.inMenu)} in both inBar and inMenu."; + } ]; + + steamFrame.uiPatches.patches = [ { + name = "frame-controls"; + endpoint = "http://127.0.0.1:8087"; + target.title = "systemui"; + patch = mkPatch { + name = "frame-controls"; + src = ./frame-controls/patch.js; + opts = removeAttrs cfg [ "enable" ]; + }; + unpatch = ./frame-controls/unpatch.js; + } ]; + }; +} diff --git a/modules/frame-controls/patch.js b/modules/frame-controls/patch.js new file mode 100644 index 0000000..85a9ba5 --- /dev/null +++ b/modules/frame-controls/patch.js @@ -0,0 +1,635 @@ +// frame-controls: move the control icons under SteamVR dashboard windows +// between the window's bottom bar and its More Options (three-dot) menu. +// A long press on a bar icon or a menu row (opts.longPressMs; a ring around +// the icon fills up once half a second .. a second has passed) opens a small +// popup with a "Show in bar" checkbox; toggling it moves that control for all +// windows. Short presses stay stock. The three-dot button itself can't be +// moved (stock shows it while its menu has entries). opts.floatInTheater +// gives theater windows the "Float" control back. +// +// Target: SteamVR's dashboard page (vrwebhelper, DevTools 127.0.0.1:8087, +// title "systemui"). This file is a function expression, called by the file +// lib/default.nix (mkPatch) generates: ()(find, sigs, opts), with +// find the finder library (lib/finders.js), sigs this patch's module +// signatures (lib/signatures.json, "frame-controls") and opts the options +// from frame-controls.nix. +// +// Stock frame controls (systemui): each window (Frame) renders its controls +// as (type 2 = action button, +// 1 = spacer); the items register while rendering and the frame's +// hands the lists to Frame.prototype.SetControlsItems(bottom, +// tabHover, additional) (a MobX action). Items inside onlyVisibleIn= +// "additional-options" (curvature, dock to a controller) go to `additional`. +// systemui draws the controls of every window itself (also the Steam window): +// panel vsg-node#legacy-frame-controls- from `bottom`, and, while +// the menu is open, #legacy-frame-controls-additional-options- from +// `additional`. Buttons are div.ButtonControl whose React `control` prop is +// the item; stock actions run on click (primary button only). +// +// Placement: SetControlsItems is wrapped; the stock lists are remembered per +// frame and a re-partitioned copy is stored (stock order kept; a control +// moved into the bar joins the group left of the three-dot button, which +// keeps a group of its own; adjacent spacer runs are merged so gaps don't +// widen). Without a placement differing from stock the stock arrays pass +// through unchanged. A change re-applies the remembered lists of every frame +// (no React re-render needed). The tab-hover list is untouched. +// Placement is global per control type: a control's key is its action icon, +// "icon:" (the same in every window and language; action ids are per +// frame instance and app window keys change with every app start). Effective +// placement: the popup's choice, else opts.inBar / opts.inMenu, else stock; a +// popup choice is dropped when the option for that control changes. Kept in +// window.__sfuiFrameControlsState (survives patch restarts and upgrades; +// unpatch keeps it) and mirrored to the page's localStorage, so it also +// survives SteamVR restarts. +// +// Long press: SteamVR delivers only primary-button laser input to the +// dashboard (no right-click; the thumbstick click arrives as nothing), so a +// long press is the trigger. Nothing is stopped or restyled while holding; +// the timer keeps running when the laser moves or leaves the control and is +// cancelled only by an early release. When it completes, the popup opens and +// the one click that follows the release on that control is swallowed, so +// its stock action doesn't run. +// window-curvature (its controls own press-and-drag) contract: its elements +// carry the class sfui-curv-ctl; a drag there dispatches a bubbling +// CustomEvent 'sfui-curv-dragstart' (cancels the long press before the ring +// shows); once the ring shows, the long press takes the press over with +// window.__sfuiWindowCurvature.cancelPress(), so drifting doesn't start a +// drag. Neither side reads the other's thresholds. +// +// Popup: in a scene-graph panel of its own, attached like the stock menu, so +// neither the bar nor the menu panel changes. The dashboard builds its scene +// graph by walking the DOM (vsg-transform elements from their attributes, +// other nodes through an element's buildNode()); panel textures are regions +// of the page, published per panel through SGApp's embedded-UV table. So: +// translation=...> +// buildNode: a panel with the bar/menu panel's own properties +// (key, meters-per-pixel, curvature origin, laser visibility), +// origin bottom centre, own UVs and embedded-UV slot +// popup +// inserted after the bar's transform. Bar controls: the pressed button's +// anchor (the tooltips' anchor), 0.15 up (the tooltips' offset): above the +// icon. Menu rows: the three-dot button's popout anchor (from which the stock +// menu is placed), centred on the menu and GAP_PX above it. The stock menu +// closes when the compositor's input focus leaves the bar and menu panels, +// which pointing at the popup does, so while a menu popup is open a close of +// that menu is deferred (Frame.prototype.SetControlAdditionalOptionsOpen +// wrapped) and settled when the popup closes. The popup closes on a press +// outside it, Escape, 1 s after the laser left it and its bar/menu, and after +// toggling. Without SGApp/anchors the popup goes into the bar/menu panel. +// +// Debugging: window.__sfuiFrameControls: log, dump(), placement(), +// setPlacement(name or "icon:N", 'bar' | 'menu' | null), reset(). +((find, sigs, opts) => { + const NAME = 'frame-controls'; + const VERSION = 1; + const T_SPACER = 1, T_ACTION = 2; + const LS_KEY = 'sfui.frameControls.v1'; + // Action icon enums (sigs.controls anchors them): names for the options. + const NAMES = { keyboard: 22, float: 26, dashboard: 27, theater: 28, dockLeft: 29, dockRight: 30, close: 31, curvature: 40 }; + const ICON = { more: 38, float: 26, dashboard: 27 }; + const NAME_OF = { ...Object.fromEntries(Object.entries(NAMES).map(([k, v]) => [`icon:${v}`, k])), [`icon:${ICON.more}`]: 'more' }; + const GAP_PX = 10, MENU_LEFT_PX = 34; // stock menu: x -34 px from its anchor + const TOOLTIP_OFFSET = '0 0.15 0.06'; // stock tooltip / menu offset (y 0.15, z 0.05), in front + + const o = { inBar: [], inMenu: [], longPressMs: 1500, floatInTheater: false, ...opts }; + if (!(o.longPressMs >= 200)) return `invalid options ${JSON.stringify(opts)}`; + const toKey = (x) => (typeof x === 'number' ? `icon:${x}` : NAMES[x] != null ? `icon:${NAMES[x]}` : /^icon:\d+$/.test(x) ? x : null); + const optMap = {}; // key -> 'bar' | 'menu' from the options + for (const [list, where] of [[o.inBar, 'bar'], [o.inMenu, 'menu']]) { + for (const x of list ?? []) { + const k = toKey(x); + if (!k) return `invalid control ${JSON.stringify(x)} (use ${Object.keys(NAMES).join(', ')} or "icon:N")`; + if (optMap[k] && optMap[k] !== where) return `control ${JSON.stringify(x)} is in both inBar and inMenu`; + optMap[k] = where; + } + } + const id = JSON.stringify([VERSION, optMap, o.longPressMs, !!o.floatInTheater]); + + const prev = window.__sfuiFrameControls; + if (prev?.version === VERSION && prev.id === id) return prev.check(); + + const FS = window.FrameStore; + if (!FS) return 'not patched: window.FrameStore missing'; + let mods; + try { + mods = find.resolveAll(find.getWebpackRequire('webpackChunkvrwebui'), sigs); + } catch (e) { + return `signature not found, dashboard left unpatched: ${e.message}`; + } + const mx = mods.mobx.module; + const P = mods.frame.exports.Frame.prototype; + const actions = mods.actions.exports.store; + const DOCK = mods.dock.exports.EDockLocation; + const focus = mods.inputFocus.exports.store; + const localize = mods.localize.exports.localize; + if (typeof mx.runInAction !== 'function') return 'signature not found, dashboard left unpatched: mobx.runInAction'; + + prev?.teardown?.(); // other version or options: back to stock first + + // ---- state + log ------------------------------------------------------------------- + let S = window.__sfuiFrameControlsState; + if (S?.schema !== 1) { + let saved = null; + try { saved = JSON.parse(localStorage.getItem(LS_KEY) ?? 'null'); } catch { /* none */ } + const ok = saved?.schema === 1; + S = window.__sfuiFrameControlsState = { schema: 1, placement: ok ? saved.placement ?? {} : {}, nixSeen: ok ? saved.nixSeen ?? {} : {}, log: [] }; + } + const logBuf = S.log ??= []; + const log = (msg, data) => { + logBuf.push({ t: new Date().toISOString().slice(11, 23), msg, ...(data !== undefined ? { data } : {}) }); + if (logBuf.length > 200) logBuf.splice(0, logBuf.length - 200); + }; + const save = () => { + try { localStorage.setItem(LS_KEY, JSON.stringify({ schema: 1, placement: S.placement, nixSeen: S.nixSeen })); } catch { /* in-page only */ } + }; + for (const k of new Set([...Object.keys(optMap), ...Object.keys(S.nixSeen)])) { + if (S.nixSeen[k] !== optMap[k] && k in S.placement) { + log('option changed, popup choice dropped', { key: k, was: S.placement[k], option: optMap[k] ?? 'stock' }); + delete S.placement[k]; + } + } + S.nixSeen = { ...optMap }; + save(); + + // Popup text in the dashboard's language (its own rule: the first of + // navigator.languages it has a translation for, else English). + const LANGS = ['en', 'de', 'fr', 'it', 'ko', 'es-419', 'es', 'zh-CN', 'zh-TW', 'ru', 'th', 'ja', 'pt', 'pl', 'da', 'nl', + 'fi', 'no', 'sv', 'hu', 'cs', 'ro', 'tr', 'pt-BR', 'bg', 'el', 'uk', 'vi']; + const lang = (() => { + for (const t of navigator.languages ?? []) { + if (LANGS.includes(t)) return t; + if (LANGS.includes(t.split('-')[0])) return t.split('-')[0]; + } + return 'en'; + })(); + const TEXT_SHOW = { de: 'In der Leiste anzeigen' }[lang] ?? 'Show in bar'; + + // ---- controls ---------------------------------------------------------------------- + const protoOf = (aid) => { try { return actions.GetAction(aid)?.protoForSteam; } catch { return undefined; } }; + const iconOf = (aid) => { const p = protoOf(aid); return p?.icon?.enum ?? p?.icon_active?.enum; }; + const keyOf = (aid) => { const e = iconOf(aid); return e ? `icon:${e}` : null; }; + const isMore = (frame, it) => it?.action_id != null && + (it.action_id === frame.m_unControlAdditionalOptionsActionID || iconOf(it.action_id) === ICON.more); + const movable = (frame, it) => it?.type === T_ACTION && it.action_id != null && !isMore(frame, it); + const wanted = (key) => (key ? S.placement[key] ?? optMap[key] : undefined); // 'bar' | 'menu' | undefined (stock) + + // Float in theater: stock shows "Float" (SetDockLocation(World)) only for + // windows docked in the dashboard; a theater window gets an action of its + // own (frame.CreateAction, destroyed with the frame) with the stock label, + // icon and call, after "back to dashboard". Same key, so it follows Float's + // placement. + const floatActions = new Map(); // frame -> action + const withFloat = (frame, bottom) => { + if (!o.floatInTheater || !Array.isArray(bottom) || frame.docking?.dockLocation !== DOCK.Theater) return bottom; + if (bottom.some((it) => iconOf(it?.action_id) === ICON.float)) return bottom; + let a = floatActions.get(frame); + if (!a?.isAlive) { + a = frame.CreateAction({ display_name: localize('#FloatInWorld'), icon: { enum: ICON.float }, invocation: 1 }, + () => frame.docking.SetDockLocation(DOCK.World)); + floatActions.set(frame, a); + } + const out = [...bottom]; + let at = out.findIndex((it) => iconOf(it?.action_id) === ICON.dashboard) + 1; + if (at === 0) { + at = Math.max(0, out.findIndex((it) => isMore(frame, it))); + while (at > 0 && out[at - 1]?.type === T_SPACER) at--; + } + out.splice(at, 0, { type: T_ACTION, action_id: a.actionID }); + return out; + }; + + // Stock lists -> [bottom, additional] with the placements applied, or null + // when nothing moves. + const partition = (frame, bottom, add) => { + if (!Array.isArray(bottom) || !Array.isArray(add)) return null; + const toMenu = bottom.filter((it) => movable(frame, it) && wanted(keyOf(it.action_id)) === 'menu'); + const toBar = add.filter((it) => movable(frame, it) && wanted(keyOf(it.action_id)) === 'bar'); + if (!toMenu.length && !toBar.length) return null; + const tokens = [], runLen = []; // bar items; spacers tagged with their run + let run = -1, inRun = false; + for (const it of bottom) { + if (it?.type === T_SPACER) { + if (!inRun) { run++; runLen[run] = 0; inRun = true; } + runLen[run]++; + tokens.push({ it, run }); + } else { + inRun = false; + if (!toMenu.includes(it)) tokens.push({ it }); + } + } + if (toBar.length) { + const ins = toBar.map((it) => ({ it })); + const more = tokens.findIndex((t) => t.run == null && isMore(frame, t.it)); + let at = more; + if (at >= 0) { // end of the group left of the three-dot button + while (at > 0 && tokens[at - 1].run != null) at--; + if (at === more) { runLen.push(1); ins.push({ it: { type: T_SPACER }, run: runLen.length - 1 }); } + } else { // no three-dot button: before the first wide gap + at = tokens.findIndex((t) => t.run != null && runLen[t.run] >= 2); + if (at < 0) at = tokens.length; + } + tokens.splice(at, 0, ...ins); + } + const outBar = []; + for (let i = 0; i < tokens.length;) { + if (tokens[i].run == null) { outBar.push(tokens[i++].it); continue; } + const group = []; + while (i < tokens.length && tokens[i].run != null) group.push(tokens[i++]); + const runs = [...new Set(group.map((t) => t.run))]; + const keep = runs.reduce((a, b) => (runLen[b] > runLen[a] ? b : a)); + for (const t of group) if (runs.length === 1 || t.run === keep) outBar.push(t.it); + } + return [outBar, [...toMenu, ...add.filter((it) => !toBar.includes(it))]]; + }; + + // SetControlsItems wrapper: one per page, version independent (looks the + // current state up per call, inert once it is gone; found anywhere in the + // chain, so a wrapper on top of it doesn't make us re-wrap). + const stock = new WeakMap(); // frame -> [bottom, tab, additional] as passed by React + const onSet = (frame, args) => { + stock.set(frame, [args[0], args[1], args[2]]); + const bottom = withFloat(frame, args[0]); + const r = partition(frame, bottom, args[2]); + if (r) [args[0], args[2]] = r; + else args[0] = bottom; + }; + const findOurs = () => { + for (let f = P.SetControlsItems, i = 0; f && i < 20; f = f.__sfuiOrig, i++) if (f.__sfuiPatch === NAME) return f; + return null; + }; + const install = () => { + if (findOurs()) return; + const orig = P.SetControlsItems; + P.SetControlsItems = Object.assign(function SetControlsItems(...args) { + const st = window.__sfuiFrameControls; + if (st?.onSet) { + try { st.onSet(this, args); } catch (e) { st.addLog?.('partition error', String(e)); } + } + return orig.apply(this, args); + }, { __sfuiPatch: NAME, __sfuiOrig: orig, isMobxAction: orig.isMobxAction }); + }; + // Frames that existed before the patch: their current lists are stock. + const captureExisting = () => { + for (const f of FS.frames ?? []) { + if (stock.has(f)) continue; + const pl = (a) => (a ? Array.from(a) : []); + stock.set(f, [pl(f.m_rgControlsItems_BottomFrameControls), pl(f.m_rgControlsItems_TabHoverControls), pl(f.m_rgControlsItems_AdditionalOptions)]); + } + }; + // Re-applies every frame's stock lists through the wrapper (or, useStock, + // straight to the stock setter). + const applyAll = (useStock = false) => { + const set = useStock ? findOurs()?.__sfuiOrig ?? P.SetControlsItems : null; + mx.runInAction(() => { + for (const f of FS.frames ?? []) { + const s = stock.get(f); + if (!s) continue; + try { + if (useStock) set.call(f, s[0], s[1], s[2]); + else f.SetControlsItems(s[0], s[1], s[2]); + // A menu emptied by a move would pop open again once it gets an entry back. + if (!f.m_rgControlsItems_AdditionalOptions?.length && f.m_bControlAdditionalOptionsOpen) f.SetControlAdditionalOptionsOpen(false); + } catch (e) { log('apply error', { frame: f.frameID, e: String(e) }); } + } + }); + }; + const placeIn = (lists, aid) => (lists?.[0]?.some((it) => it?.action_id === aid) ? 'bar' + : lists?.[1]?.some((it) => it?.action_id === aid) ? 'menu' : undefined); + const stockPlace = (f, aid) => { const s = stock.get(f); return placeIn(s && [s[0], s[2]], aid); }; + const currentPlace = (f, aid) => placeIn([f.m_rgControlsItems_BottomFrameControls, f.m_rgControlsItems_AdditionalOptions], aid); + const setPlacement = (key, where, why = 'api') => { // where: 'bar' | 'menu' | null (option / stock) + if (!key) return 'unknown control'; + if (where == null) delete S.placement[key]; + else S.placement[key] = where; + save(); + log('placement', { key, name: NAME_OF[key], where: where ?? 'default', why }); + applyAll(); + return S.placement[key] ?? optMap[key] ?? 'stock'; + }; + + // ---- popup ------------------------------------------------------------------------- + const STYLE_ID = 'sfui-frame-controls-style'; + const CSS = ` +.sfui-fc-pop { display: block; width: max-content; box-sizing: border-box; background: var(--gamepadui-darkish-grey, #2d3239); + border-radius: 10px; overflow: hidden; color: #fff; font-size: 24px; line-height: 1.2; text-align: left; + box-shadow: 0 4px 18px rgba(0, 0, 0, 0.55); } +.sfui-fc-own { display: inline-block; vertical-align: top; } +.sfui-fc-in-bar { margin: 10px 0 0 0; } +.sfui-fc-in-menu { margin: 0 0 10px 0; } +.sfui-fc-row { display: flex; flex-direction: row; align-items: center; gap: 16px; padding: 14px 24px 14px 20px; + white-space: nowrap; cursor: pointer; } +.sfui-fc-row:hover { background: var(--gamepadui-dark-grey, #3d4450); } +.sfui-fc-row:active { background: var(--gamepadui-grey, #67707b); } +.sfui-fc-box { flex: none; position: relative; width: 1em; height: 1em; box-sizing: border-box; + border: 2px solid currentColor; border-radius: 4px; } +.sfui-fc-box.sfui-on { background: #1a9fff; border-color: #1a9fff; } +.sfui-fc-box.sfui-on::after { content: ''; position: absolute; left: 30%; top: 6%; width: 28%; height: 56%; + border: solid #fff; border-width: 0 3px 3px 0; transform: rotate(45deg); } +.sfui-fc-pressing { position: relative; } +`; + const ensureStyle = () => { + if (document.getElementById(STYLE_ID)) return; + const s = document.createElement('style'); + s.id = STYLE_ID; s.textContent = CSS; + document.head.appendChild(s); + }; + + const PANEL_RE = /^legacy-frame-controls-(additional-options-)?(\d+)$/; + // The control under an event target: { frame, actionId, where, button, panel } or null. + const controlAt = (target) => { + const button = target?.closest?.('.ButtonControl'); + const panel = button?.closest('vsg-node[id^="legacy-frame-controls-"]'); + const m = panel && PANEL_RE.exec(panel.id); + if (!m) return null; + const actionId = find.findFiberUp(button, (f) => f.memoizedProps?.control != null, 12)?.memoizedProps.control.action_id; + const frame = FS.GetFrame(+m[2]); + return actionId != null && frame ? { frame, actionId, where: m[1] ? 'menu' : 'bar', button, panel } : null; + }; + + let pop = null; // { el, frame, actionId, panel, button, where, own, leaveTimer, off, menuCloseDeferred } + const closePopup = (why) => { + const p = pop; + if (!p) return; + pop = null; + clearTimeout(p.leaveTimer); + p.off(); + p.el.remove(); + try { p.own?.dispose(); } catch (e) { log('popup panel dispose failed', String(e)); } + if (p.menuCloseDeferred) setTimeout(() => settleMenu(p.frame), 250); + log('popup closed', { why }); + }; + + // Keep the three-dot menu open while its popup is pointed at (see header). + const GUARD = 'frame-controls-menu-guard'; + const origOpen = P.SetControlAdditionalOptionsOpen; + const guardOpen = Object.assign(function SetControlAdditionalOptionsOpen(v, ...rest) { + if (window.__sfuiFrameControls?.deferMenuClose?.(this, v)) return undefined; + return guardOpen.__sfuiOrig.call(this, v, ...rest); + }, { __sfuiPatch: GUARD, __sfuiOrig: origOpen, isMobxAction: origOpen.isMobxAction }); + const deferMenuClose = (frame, v) => { + if (v !== false || !pop?.own || pop.where !== 'menu' || pop.frame !== frame) return false; + if (!pop.menuCloseDeferred) log('menu close deferred while its popup is open', { frame: frame.frameID }); + pop.menuCloseDeferred = true; + return true; + }; + // After the popup closed: close the menu unless the laser is on the bar or menu. + const settleMenu = (frame) => { + if (pop?.frame === frame && pop.where === 'menu') return; + let keep = false; + try { keep = !!frame.inputFocus?.frameControlsHaveFocus || focus.BPanelHasFocus(frame.panels?.additionalOptionsPanelSGID); } catch { /* close */ } + if (!keep) { try { origOpen.call(frame, false); } catch (e) { log('menu close failed', String(e)); } } + }; + + // The popup's own scene-graph panel (see header); null if SGApp or the + // anchor isn't there. + const mountOwnPanel = (hit, content) => { + const { panel: base, button, frame } = hit; // base: the bar or menu panel, whose look is copied + const app = window.SGApp; + const menu = hit.where === 'menu'; + const host = document.getElementById(`legacy-frame-controls-${frame.frameID}`)?.parentElement; + const anchor = menu ? document.getElementById(`legacy-frame-controls-additional-options-anchor-${frame.frameID}`) + : button.querySelector('vsg-node[vsg-type="panel-anchor"] vsg-transform[id]'); + if (typeof app?.addEmbeddedPanelUVs !== 'function' || typeof base.buildNode !== 'function' || + typeof VRHTML?.NextSGID !== 'function' || !anchor || !host) return null; + const baseProps = (ctx) => base.buildNode(ctx, base)?.[1]?.properties ?? {}; + let translation = TOOLTIP_OFFSET; + if (menu) { // centred on the menu, GAP_PX above it + const mpp = baseProps({ currentPanel: null, bInsideReparentedPanel: false, bShouldAbort: false })['meters-per-pixel']; + const r = base.getBoundingClientRect(); + if (!(mpp > 0 && r.width > 0 && r.height > 0)) return null; + const x = -MENU_LEFT_PX * mpp * (app.m_fCurrentScale ?? 1) + (r.width / 2) * mpp, y = 0.15 + (r.height + GAP_PX) * mpp; + translation = `${x.toFixed(5)} ${y.toFixed(5)} 0.06`; + } + const outer = document.createElement('vsg-transform'); + for (const [k, v] of Object.entries({ 'parent-id': anchor.id, translation, rotation: '1 0 0 0', scale: '1 1 1', sgid: VRHTML.NextSGID() })) + outer.setAttribute(k, String(v)); + const node = document.createElement('vsg-node'); + node.id = `sfui-fc-popup-${frame.frameID}`; + node.className = 'sfui-fc-own'; + node.setAttribute('vsg-type', 'panel'); + const sgid = VRHTML.NextSGID(); + node.setAttribute('sgid', String(sgid)); + node.appendChild(content); + outer.appendChild(node); + const fp = { // what SGApp's embedded-UV table reads of a panel + props: { debug_name: node.id }, isExternal: false, m_Rect: { x: 0, y: 0, width: 0, height: 0 }, idx: undefined, + getSGID: () => sgid, getEmbeddedIndex: () => fp.idx, getCurrentRootElement: () => node, + updateLayoutValues() { const r = node.getBoundingClientRect(); fp.m_Rect = { x: r.x, y: r.y, width: r.width, height: r.height }; }, + }; + fp.idx = app.addEmbeddedPanelUVs(fp); + if (fp.idx == null) return null; + const KEEP = ['key', 'overlay_handle', 'meters-per-pixel', 'curvature-origin-id', 'reflect', 'lasermouse-filtering', + 'focus-outline', 'only-visible-with-laser', 'steam-input-appid', 'scale-index']; + node.buildNode = (ctx) => { + if (!base.isConnected || !anchor.isConnected) { // bar / menu gone: drop the popup + queueMicrotask(() => { if (pop?.own?.node === node) closePopup('panel gone'); }); + return [ctx, null]; + } + const bp = baseProps(ctx), r = node.getBoundingClientRect(), W = window.innerWidth, H = window.innerHeight; + const props = {}; + for (const k of KEEP) if (bp[k] !== undefined) props[k] = bp[k]; + Object.assign(props, { + id: typeof bp.id === 'string' ? bp.id.replace(/[^:]*$/, node.id) : node.id, sgid, debug_name: node.id, + origin: [0, -1], interactive: true, visibility: 0, 'sort-depth-bias': -1, 'can-take-keyboard-focus': false, + 'embedded-uv-index': fp.idx, uv_min: [r.x / W, r.y / H], uv_max: [(r.x + r.width) / W, (r.y + r.height) / H], + }); + return [{ ...ctx, currentPanel: fp, bInsideReparentedPanel: false }, { type: 'panel', properties: props }]; + }; + host.after(outer); + window.forceLayoutUpdate?.(); + return { + node, + dispose: () => { outer.remove(); app.removeEmbeddedPanelUVs(fp); window.forceLayoutUpdate?.(); }, + }; + }; + + const openPopup = (hit) => { + closePopup('reopen'); + const { frame, actionId, panel, button, where } = hit; + const key = keyOf(actionId); + if (!key) return; + ensureStyle(); + const el = document.createElement('div'); + el.className = 'sfui-fc-pop'; + const row = document.createElement('div'); + row.className = 'sfui-fc-row'; + const box = document.createElement('span'); + box.className = `sfui-fc-box${currentPlace(frame, actionId) === 'bar' ? ' sfui-on' : ''}`; + const label = document.createElement('span'); + label.textContent = TEXT_SHOW; + row.append(box, label); + el.append(row); + for (const t of ['mousedown', 'mouseup', 'click', 'dblclick', 'pointerdown', 'pointerup', 'contextmenu']) + el.addEventListener(t, (e) => e.stopPropagation()); // nothing reaches the page's handlers + row.addEventListener('click', () => { + const now = currentPlace(frame, actionId), to = now === 'bar' ? 'menu' : 'bar'; + const dflt = optMap[key] ?? stockPlace(frame, actionId); + closePopup('toggled'); + setPlacement(key, to === dflt ? null : to, `popup (${now} -> ${to})`); + }); + const own = mountOwnPanel(hit, el); + if (!own) { // fallback: inside the bar (below) / menu (on top) + log('no own popup panel', { where }); + el.classList.add(where === 'bar' ? 'sfui-fc-in-bar' : 'sfui-fc-in-menu'); + if (where === 'bar') { + panel.appendChild(el); + const pr = panel.getBoundingClientRect(), br = button.getBoundingClientRect(), w = el.getBoundingClientRect().width; + el.style.marginLeft = `${Math.round(Math.max(0, Math.min(pr.width - w, br.left + br.width / 2 - pr.left - w / 2)))}px`; + } else panel.insertBefore(el, panel.firstChild); + } + const p = pop = { el, frame, actionId, panel, button, where, own, leaveTimer: null, off: null, menuCloseDeferred: false }; + // Leaving the popup and its bar/menu for 1 s closes it. + const onLeave = () => { clearTimeout(p.leaveTimer); p.leaveTimer = setTimeout(() => { if (pop === p) closePopup('laser left'); }, 1000); }; + const onEnter = () => clearTimeout(p.leaveTimer); + const watched = own ? [panel, own.node] : [panel]; + for (const w of watched) { w.addEventListener('mouseleave', onLeave); w.addEventListener('mouseenter', onEnter); } + p.off = () => { for (const w of watched) { w.removeEventListener('mouseleave', onLeave); w.removeEventListener('mouseenter', onEnter); } }; + log('popup opened', { frame: frame.frameID, key, name: NAME_OF[key], where, ownPanel: !!own }); + }; + + // ---- long press -------------------------------------------------------------------- + const SVGNS = 'http://www.w3.org/2000/svg'; + const RING_DELAY = Math.min(1000, o.longPressMs / 2); + const curvApi = () => window.__sfuiWindowCurvature; + let press = null; // { hit, t0, timer, ringTimer, ring, done, curv } + let swallow = null; // { until, button }: the click after a completed long press + + // Ring around the pressed control's icon (svg.Icon, or img.Icon for the + // dock-to-controller rows), ~icon size, starting at the elapsed fraction; + // essential styling inline, so it can't show unstyled. + const showRing = (p) => { + const btn = p.hit.button; + if (!btn.isConnected) return null; + const icon = btn.querySelector('.Icon'); + const br = btn.getBoundingClientRect(), ir = icon?.getBoundingClientRect(); + const ok = ir?.width > 0 && ir.height > 0; + const size = Math.round(Math.min(48, Math.max(20, ok ? Math.max(ir.width, ir.height) : Math.min(br.height, 38))) + 20); + const cx = ok ? ir.left - br.left + ir.width / 2 : br.height / 2 + 12; + const cy = ok ? ir.top - br.top + ir.height / 2 : br.height / 2; + const ring = document.createElementNS(SVGNS, 'svg'); + for (const [k, v] of Object.entries({ class: 'sfui-fc-ring', viewBox: '0 0 40 40', width: size, height: size })) ring.setAttribute(k, String(v)); + const circle = (stroke, extra = {}) => { + const c = document.createElementNS(SVGNS, 'circle'); + for (const [k, v] of Object.entries({ cx: 20, cy: 20, r: 17, fill: 'none', stroke, 'stroke-width': 3.5, ...extra })) c.setAttribute(k, String(v)); + return c; + }; + const fg = circle('#1a9fff', { pathLength: 100, 'stroke-dasharray': 100, 'stroke-linecap': 'round' }); + ring.append(circle('rgba(255,255,255,0.18)'), fg); + const elapsed = performance.now() - p.t0; + Object.assign(ring.style, { + position: 'absolute', pointerEvents: 'none', overflow: 'visible', margin: '0', zIndex: '1', transform: 'rotate(-90deg)', + width: `${size}px`, height: `${size}px`, left: `${Math.round(cx - size / 2)}px`, top: `${Math.round(cy - size / 2)}px`, + }); + fg.style.strokeDashoffset = String(100 * (1 - Math.min(1, elapsed / o.longPressMs))); + btn.appendChild(ring); + ring.getBoundingClientRect(); // start value applied, then animate the rest + fg.style.transition = `stroke-dashoffset ${Math.round(Math.max(0, o.longPressMs - elapsed))}ms linear`; + fg.style.strokeDashoffset = '0'; + return ring; + }; + const endPress = (why) => { + const p = press; + if (!p) return; + press = null; + clearTimeout(p.timer); clearTimeout(p.ringTimer); + p.ring?.remove(); + p.hit.button.classList.remove('sfui-fc-pressing'); + if (!p.done) log('long press cancelled', { why, ms: Math.round(performance.now() - p.t0) }); + }; + const completePress = () => { + const p = press; + if (!p?.hit.button.isConnected) { endPress('button gone'); return; } + p.done = true; + p.ring?.remove(); p.ring = null; + if (p.curv) curvApi()?.cancelPress?.(); + openPopup(p.hit); + }; + const onCurvDrag = () => { if (press && !press.done) endPress('curvature drag'); }; + + const onEvent = (e) => { + const t = e.type; + if (t === 'click') { + const w = swallow; + if (w && e.button === 0 && performance.now() < w.until && w.button.contains(e.target)) { + swallow = null; + e.stopImmediatePropagation(); e.preventDefault(); + } + return; + } + if (t === 'mousedown' || t === 'pointerdown') { + if (pop && (!pop.el.contains(e.target) || !pop.button.isConnected)) closePopup('press outside'); + if (t !== 'mousedown' || e.button !== 0) return; + swallow = null; + endPress('new press'); + const hit = controlAt(e.target); + if (!hit || !movable(hit.frame, { type: T_ACTION, action_id: hit.actionId })) return; // three-dot: stock + ensureStyle(); + const p = press = { + hit, t0: performance.now(), done: false, ring: null, curv: hit.button.classList.contains('sfui-curv-ctl'), + timer: setTimeout(completePress, o.longPressMs), ringTimer: null, + }; + hit.button.classList.add('sfui-fc-pressing'); // positioning context for the ring + p.ringTimer = setTimeout(() => { + if (press !== p || p.done) return; + if (p.curv) curvApi()?.cancelPress?.(); // the long press takes the press over + p.ring = showRing(p); + }, RING_DELAY); + return; + } + if (t === 'mouseup' && e.button === 0 && press) { + const p = press; + endPress(p.done ? 'released after long press' : 'released'); + if (p.done) swallow = { until: performance.now() + 1000, button: p.hit.button }; + } + }; + const onKey = (e) => { if (pop && e.key === 'Escape') { e.stopPropagation(); closePopup('escape'); } }; + const EVENTS = ['mousedown', 'pointerdown', 'mouseup', 'click']; + + // ---- setup / teardown -------------------------------------------------------------- + const teardown = () => { + endPress('teardown'); + closePopup('teardown'); + for (const t of EVENTS) window.removeEventListener(t, onEvent, true); + window.removeEventListener('sfui-curv-dragstart', onCurvDrag); + window.removeEventListener('keydown', onKey, true); + if (P.SetControlAdditionalOptionsOpen === guardOpen) P.SetControlAdditionalOptionsOpen = origOpen; + if (window.__sfuiFrameControls === state) delete window.__sfuiFrameControls; // wrappers now inert + applyAll(true); // stock lists back + for (const a of floatActions.values()) { try { a.Destroy(); } catch { /* gone */ } } + floatActions.clear(); + if (P.SetControlsItems?.__sfuiPatch === NAME) P.SetControlsItems = P.SetControlsItems.__sfuiOrig; + document.getElementById(STYLE_ID)?.remove(); + }; + const check = () => { + if (findOurs()) return 'unchanged'; + install(); captureExisting(); applyAll(); + return 'patched (wrapper reinstalled)'; + }; + const effective = () => Object.fromEntries([...new Set([...Object.keys(optMap), ...Object.keys(S.placement)])] + .map((k) => [NAME_OF[k] ?? k, S.placement[k] ? `${S.placement[k]} (popup)` : `${optMap[k]} (option)`])); + const names = (list) => (list ?? []).map((it) => (it?.type === T_SPACER ? '|' : NAME_OF[keyOf(it?.action_id)] ?? keyOf(it?.action_id) ?? '?')).join(' '); + const dump = () => (FS.frames ?? []).map((f) => ({ + frame: f.frameID, title: f.title, bar: names(f.m_rgControlsItems_BottomFrameControls), menu: names(f.m_rgControlsItems_AdditionalOptions), + stockBar: names(stock.get(f)?.[0]), stockMenu: names(stock.get(f)?.[2]), menuOpen: f.isControlAdditionalOptionsOpen, + })); + const state = { + version: VERSION, id, opts: o, log: logBuf, addLog: log, onSet, deferMenuClose, teardown, check, dump, + placement: effective, + setPlacement: (name, where) => setPlacement(toKey(name), where), + reset: () => { S.placement = {}; save(); log('reset'); applyAll(); return 'reset'; }, + }; + + window.__sfuiFrameControls = state; + if (P.SetControlAdditionalOptionsOpen?.__sfuiPatch !== GUARD) P.SetControlAdditionalOptionsOpen = guardOpen; + for (const t of EVENTS) window.addEventListener(t, onEvent, true); + window.addEventListener('sfui-curv-dragstart', onCurvDrag); + window.addEventListener('keydown', onKey, true); + ensureStyle(); + install(); + captureExisting(); + applyAll(); + log('patched', { version: VERSION, options: optMap, popup: S.placement }); + const eff = Object.entries(effective()); + return `patched (long press ${o.longPressMs} ms${o.floatInTheater ? ', float in theater' : ''}` + + `${eff.length ? `; ${eff.map(([k, v]) => `${k}: ${v}`).join(', ')}` : ''})`; +}) diff --git a/modules/frame-controls/unpatch.js b/modules/frame-controls/unpatch.js new file mode 100644 index 0000000..2c53338 --- /dev/null +++ b/modules/frame-controls/unpatch.js @@ -0,0 +1,14 @@ +// Reverts frame-controls/patch.js: ends a running long press, closes the +// popup (and removes its scene-graph panel), gives every window its stock +// control lists back and removes its wrappers (Frame.prototype +// SetControlsItems / SetControlAdditionalOptionsOpen; a wrapper something +// else wrapped since stays in the chain but is inert), listeners, style and +// the Float actions it added to theater windows. +// window.__sfuiFrameControlsState (placements, log) and its localStorage +// mirror are kept for a re-injection. Safe when not patched. +(() => { + const s = window.__sfuiFrameControls; + if (!s) return 'not patched'; + s.teardown(); + return 'unpatched'; +})() diff --git a/modules/lib/signatures.json b/modules/lib/signatures.json index 273a68d..ae514cb 100644 --- a/modules/lib/signatures.json +++ b/modules/lib/signatures.json @@ -349,6 +349,96 @@ "module": { "includes": ["case\"VSG-TRANSFORM\":", ".properties.translation="] } } } + }, + "frame-controls": { + "bundle": "vrwebui-systemui", + "modules": { + "mobx": { + "module": { "includes": ["runInAction:()=>", "makeObservable:()=>", "reaction:()=>"] } + }, + "frame": { + "module": { "includes": ["m_rgControlsItems_BottomFrameControls", "get protoForSteam()", "SetControlsItems("] }, + "exports": { + "Frame": { "type": "class", "protoMethods": ["SetControlsItems", "SetControlAdditionalOptionsOpen", "CreateAction"] } + }, + "expects": ["SetControlsItems(e,t,o){this.m_rgControlsItems_BottomFrameControls=e,this.m_rgControlsItems_TabHoverControls=t,this.m_rgControlsItems_AdditionalOptions=o}", + "get isControlAdditionalOptionsOpen(){return this.m_bControlAdditionalOptionsOpen&&this.m_rgControlsItems_AdditionalOptions.length>0}", + "BPanelHasFocus(this.panels.additionalOptionsPanelSGID)", "this.SetControlAdditionalOptionsOpen(!1)", "m_unControlAdditionalOptionsActionID"] + }, + "actions": { + "module": { "includes": ["window.dashboardActionStore="] }, + "exports": { + "store": { "type": "object", "methods": ["GetAction", "BIsVRLinkRemoteAction"] } + }, + "expects": ["get protoForSteam()", "get isAlive()"] + }, + "dock": { + "module": { "includes": ["e[e.Dashboard=1]=\"Dashboard\",e[e.LeftHand=2]=\"LeftHand\""] }, + "exports": { + "EDockLocation": { "type": "object", "props": { "Dashboard": 1, "World": 4, "Theater": 5 } } + } + }, + "inputFocus": { + "module": { "includes": ["BPanelHasFocus(e,t=!0)", "\"defocus_panel\""] }, + "exports": { + "store": { "type": "object", "methods": ["BPanelHasFocus", "BPanelSubviewHasFocus", "RemoveInputFocus"] } + }, + "expects": ["m_entryAtTopOfStack.m_unPanelSGID"] + }, + "localize": { + "module": { "includes": ["LocalizeStringFromFallback(", "m_mapFallbackTokens"] }, + "exports": { + "localize": { "type": "function", "includes": [".LocalizeString(e)", "$s/g"] } + }, + "expects": ["[\"en\",\"english\"],[\"de\",\"german\"],[\"fr\",\"french\"]", "for(let t of navigator.languages)"] + }, + "controls": { + "checkOnly": true, + "module": { "includes": ["\"#AdditionalOptionsTooltip\"", "\"#FloatInWorld\""] }, + "expects": ["icon:{enum:38},invocation:2", "\"#FloatInWorld\"),icon:{enum:26},invocation:1", "\"#ReturnToDashboard\"),icon:{enum:27}", + "\"#ViewInTheater\"),icon:{enum:28}", "icon:{enum:\"left\"==t?29:30}", "icon:{enum:22},icon_active:{enum:24}", + "icon:{enum:40},icon_active:{enum:39}", "onlyVisibleIn:\"additional-options\"", "controlAdditionalOptionsItems.length>0", + "SetDockLocation(l.yWq.World)"] + }, + "closeIcon": { + "checkOnly": true, + "module": { "includes": ["icon_close_black.png"] }, + "expects": ["case 31:"] + }, + "frameControls": { + "checkOnly": true, + "module": { "includes": ["legacy-frame-controls-additional-options-anchor-", "controlAdditionalOptionsItems.filter("] }, + "expects": ["id:`legacy-frame-controls-${", "id:`legacy-frame-controls-additional-options-${", "parent_id:p,translation:{x:-34*", + "\"ButtonControl\"", "onClick:()=>{", "control:e,key:e.action_id", "const v={x:0,y:.15,z:.05}"] + }, + "panel": { + "checkOnly": true, + "module": { "includes": ["getNodeType(){return\"panel\"}"] }, + "expects": ["addEmbeddedPanelUVs(this)", "\"embedded-uv-index\"", "uv_min", "\"curvature-origin-id\"", "\"only-visible-with-laser\"", + "\"meters-per-pixel\"", "\"sort-depth-bias\""] + }, + "panelAnchor": { + "checkOnly": true, + "module": { "includes": ["getNodeType(){return\"panel-anchor\"}"] }, + "expects": ["\"anchor-u\"", "\"anchor-v\""] + }, + "sgApp": { + "checkOnly": true, + "module": { "includes": ["window.SGApp=this", "addEmbeddedPanelUVs("] }, + "expects": ["removeEmbeddedPanelUVs(", "updateAllPanelBounds(){", "m_fCurrentScale", "t.forceLayoutUpdate=this.forceLayoutUpdate", + "e.updateLayoutValues()", "getEmbeddedIndex()"] + }, + "sceneGraphDom": { + "checkOnly": true, + "module": { "includes": ["\"update_scene_graph\"", "case\"VSG-TRANSFORM\""] }, + "expects": [".buildNode;if(", "\"parent-id\"", "\"translation\""] + }, + "docking": { + "checkOnly": true, + "module": { "includes": ["SetInTheater(e=!0){", "GetTrackedDeviceIndexForControllerRole"] }, + "expects": ["SetDockLocation(e){", "SetJustFloatedFromDashboard("] + } + } } } } diff --git a/template/home.nix b/template/home.nix index 1cddf2a..caf1069 100644 --- a/template/home.nix +++ b/template/home.nix @@ -46,6 +46,9 @@ # # window's More Options menu (or its bar button) to toggle, drag # # up/down to adjust (with controller haptics): # windowCurvature.enable = true; + # # Long press a control under a window (or a three-dot menu row) to + # # move it between the bar and the three-dot menu: + # frameControls.enable = true; # }; # firefox.enable = true; # launcher for the Firefox Flatpak # # Hidden from the "+" menu (with Steam Developer Mode on):