From b3182c46ec0260764e025f59e7235cc07c98d793 Mon Sep 17 00:00:00 2001 From: toorux Date: Mon, 19 Oct 2026 22:51:58 +0800 Subject: [PATCH] feat(input): add configurable Steam button radial controls --- native/input/steam-system-menu.py | 115 ++++++++++++++++++++++++++++++ native/system_radial.h | 68 ++++++++++++++++++ packaging/rollback.sh | 3 + packaging/system-menu-restore.sh | 18 +++++ packaging/uninstall.sh | 3 + src/model.rs | 3 + tests/system_radial.cpp | 31 ++++++++ tests/test_steam_system_menu.py | 37 ++++++++++ tools/test-native.sh | 2 +- ui/src/launcher.tsx | 6 +- ui/src/system-radial-menu.css | 14 ++++ ui/src/system-radial-menu.tsx | 44 ++++++++++++ 12 files changed, 340 insertions(+), 4 deletions(-) create mode 100644 native/input/steam-system-menu.py create mode 100644 native/system_radial.h create mode 100644 packaging/system-menu-restore.sh create mode 100644 tests/system_radial.cpp create mode 100644 tests/test_steam_system_menu.py create mode 100644 ui/src/system-radial-menu.css create mode 100644 ui/src/system-radial-menu.tsx diff --git a/native/input/steam-system-menu.py b/native/input/steam-system-menu.py new file mode 100644 index 0000000..1ad3afe --- /dev/null +++ b/native/input/steam-system-menu.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Keep SteamVR's click and double-click actions while Framely handles hold.""" + +import hashlib +import json +import os +from pathlib import Path +import sys +from urllib.parse import urlencode +from urllib.request import Request, urlopen + +APP = "openvr.component.vrcompositor" +CONTROLLER = "frame_controller" +BASE = "http://127.0.0.1:27062" +HEADERS = { + "Origin": BASE, + "Referer": BASE + "/dashboard/controllerbinding.html", +} +DIRECTORY = Path.home() / ".local/share/framely/input" +BINDING = DIRECTORY / "steam-system-menu.json" +META = DIRECTORY / "steam-system-menu-source.json" + + +def request(path, params=None, body=None): + if params: + path += "?" + urlencode(params) + data = None if body is None else json.dumps(body).encode() + headers = dict(HEADERS) + if data is not None: + headers["Content-Type"] = "application/json" + with urlopen(Request(BASE + path, data=data, headers=headers), timeout=3) as response: + return json.load(response) + + +def current_url(): + data = request("/input/getactions.json", {"app_key": APP}) + return data["current_binding_url"][CONTROLLER] + + +def select(url): + result = request( + "/input/selectconfig.action", + body={"app_key": APP, "controller_type": CONTROLLER, "url": url}, + ) + if not result.get("success"): + raise RuntimeError(f"SteamVR rejected controller binding: {result}") + + +def source_binding(url): + result = request( + "/input/loadbindingfromurl.json", + {"binding_url": url, "controller_type": CONTROLLER, "app_key": APP}, + ) + if "binding_config" not in result: + raise RuntimeError(f"SteamVR could not load controller binding: {result}") + return result["binding_config"] + + +def without_recenter(binding): + # Preserve every other compositor action, including click, double and + # screenshot chords. A user's unrelated quick-recenter sources survive. + result = json.loads(json.dumps(binding)) + sources = result.get("bindings", {}).get("/actions/quickrecenter", {}).get("sources", []) + kept = [ + source for source in sources + if source.get("path") not in ( + "/user/hand/left/input/system", + "/user/hand/right/input/system", + ) or "long" not in source.get("inputs", {}) + ] + result["bindings"]["/actions/quickrecenter"]["sources"] = kept + return result, len(kept) != len(sources) + + +def atomic_json(path, value): + DIRECTORY.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix + ".new") + with temporary.open("w") as out: + json.dump(value, out, ensure_ascii=False, separators=(",", ":")) + out.flush() + os.fsync(out.fileno()) + os.replace(temporary, path) + + +def main(): + active = current_url() + ours = BINDING.resolve().as_uri() + metadata = json.loads(META.read_text()) if META.exists() else {} + if len(sys.argv) > 1 and sys.argv[1] == "--restore": + if active == ours and metadata.get("source_url"): + select(metadata["source_url"]) + return 10 + return 0 + source_url = metadata.get("source_url") if active == ours else active + if not source_url: + raise RuntimeError("SteamVR did not report a Frame controller binding") + source = source_binding(source_url) + patched, changed = without_recenter(source) + if not changed: + return 0 + digest = hashlib.sha256(json.dumps(source, sort_keys=True).encode()).hexdigest() + if active == ours and metadata.get("source_hash") == digest and BINDING.exists(): + return 0 + atomic_json(BINDING, patched) + atomic_json(META, {"source_url": source_url, "source_hash": digest}) + select(ours) + return 10 # SteamVR caches the old long-press action until it restarts. + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception as error: + print(f"SteamVR system-menu binding: {error}", file=sys.stderr) + sys.exit(1) diff --git a/native/system_radial.h b/native/system_radial.h new file mode 100644 index 0000000..1219572 --- /dev/null +++ b/native/system_radial.h @@ -0,0 +1,68 @@ +#pragma once +#include +#include +#include +#include + +enum class SystemRadialDirection {Center,Up,UpRight,Right,DownRight,Down,DownLeft,Left,UpLeft}; +enum class SystemRadialEvent {Idle,Begin,Open,Change,Cancel,Commit}; + +struct SystemRadial { + using Clock=std::chrono::steady_clock; + bool active=false,open=false; + int hand=-1; + Clock::time_point began{}; + SystemRadialDirection direction=SystemRadialDirection::Center; + static constexpr auto hold=std::chrono::seconds(1); + static constexpr float deadzone=.09f; + float progress(Clock::time_point now) const { + return active?std::clamp(std::chrono::duration(now-began).count(),0.f,1.f):0.f; + } + SystemRadialEvent update(int input_hand,bool valid,bool down,float right,float up,Clock::time_point now,std::optional hover=std::nullopt) { + if(!active) { + if(!valid||!down)return SystemRadialEvent::Idle; + active=true;open=false;hand=input_hand;began=now;direction=SystemRadialDirection::Center; + return SystemRadialEvent::Begin; + } + if(input_hand!=hand)return SystemRadialEvent::Idle; + if(!valid) {active=false;open=false;hand=-1;return SystemRadialEvent::Cancel;} + if(!down) { + const bool commit=open||now-began>=hold; + active=false;open=false;hand=-1; + return commit?SystemRadialEvent::Commit:SystemRadialEvent::Cancel; + } + if(!open&&now-began>=hold){open=true;return SystemRadialEvent::Open;} + if(open) { + auto next=hover.value_or(select(right,up)); + if(next!=direction){direction=next;return SystemRadialEvent::Change;} + } + return SystemRadialEvent::Idle; + } + static SystemRadialDirection select(float right,float up) { + if(right*right+up*up hit(float x,float y) { + const float dx=x-360.f,dy=y-360.f; + if(dx*dx+dy*dy<=56.f*56.f)return SystemRadialDirection::Center; + if(std::abs(dx)<=62.f&&std::abs(dy+177.f)<=41.f)return SystemRadialDirection::Up; + if(std::abs(dx-177.f)<=62.f&&std::abs(dy)<=41.f)return SystemRadialDirection::Right; + if(std::abs(dx)<=62.f&&std::abs(dy-177.f)<=41.f)return SystemRadialDirection::Down; + if(std::abs(dx+177.f)<=62.f&&std::abs(dy)<=41.f)return SystemRadialDirection::Left; + return std::nullopt; + } +}; diff --git a/packaging/rollback.sh b/packaging/rollback.sh index 9388c44..60e3237 100755 --- a/packaging/rollback.sh +++ b/packaging/rollback.sh @@ -5,6 +5,9 @@ root=/var/lib/framely previous=$(cat "$root/previous-release") [[ $previous == releases/* && $previous != *..* && -d $root/$previous ]] || { echo 'No previous release.' >&2; exit 1; } current=$(readlink "$root/current") +if [[ ! -f $root/$previous/share/input/steam-system-menu.py && -f $root/current/system-menu-restore.sh ]]; then + bash "$root/current/system-menu-restore.sh" +fi systemctl stop framely-session.service framely.service ln -s "$previous" "$root/current.rollback" mv -Tf "$root/current.rollback" "$root/current" diff --git a/packaging/system-menu-restore.sh b/packaging/system-menu-restore.sh new file mode 100644 index 0000000..a86f07b --- /dev/null +++ b/packaging/system-menu-restore.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail +root=/var/lib/framely +helper=$root/current/share/input/steam-system-menu.py +[[ -f $helper && -f $root/steam-user ]] || exit 0 +steam_user=$(cat "$root/steam-user") +steam_uid=$(id -u "$steam_user") +steam_home=$(getent passwd "$steam_user" | cut -d: -f6) +set +e +runuser -u "$steam_user" -- env HOME="$steam_home" XDG_RUNTIME_DIR="/run/user/$steam_uid" DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$steam_uid/bus" python3 "$helper" --restore +result=$? +set -e +if [[ $result == 10 ]]; then + systemctl --user --machine="$steam_user@" restart steamvr.service +elif [[ $result != 0 ]]; then + echo 'Could not restore the original SteamVR system-button binding; start SteamVR and retry.' >&2 + exit "$result" +fi diff --git a/packaging/uninstall.sh b/packaging/uninstall.sh index 06e33b6..7b7d118 100755 --- a/packaging/uninstall.sh +++ b/packaging/uninstall.sh @@ -13,6 +13,9 @@ restore_session() { exit "$code" } trap restore_session ERR +if [[ -f $root/current/system-menu-restore.sh ]]; then + bash "$root/current/system-menu-restore.sh" +fi systemctl start framely.service ready=false for attempt in {1..30}; do diff --git a/src/model.rs b/src/model.rs index f8feb40..09bbee3 100644 --- a/src/model.rs +++ b/src/model.rs @@ -199,6 +199,8 @@ pub struct LauncherSettings { #[serde(default = "yes")] pub prioritize_ui: bool, #[serde(default = "yes")] + pub system_radial_menu: bool, + #[serde(default = "yes")] pub primary_trigger: bool, #[serde(default = "yes")] pub menu_auto_close: bool, @@ -264,6 +266,7 @@ impl Default for LauncherSettings { fn default() -> Self { Self { prioritize_ui: true, + system_radial_menu: true, primary_trigger: true, menu_auto_close: true, menu_timeout_seconds: 10, diff --git a/tests/system_radial.cpp b/tests/system_radial.cpp new file mode 100644 index 0000000..f69f7c8 --- /dev/null +++ b/tests/system_radial.cpp @@ -0,0 +1,31 @@ +#include "system_radial.h" +#include +#include +int main(){ + using Clock=SystemRadial::Clock; + auto start=Clock::now(); + SystemRadial menu; + assert(menu.update(0,true,true,0,0,start)==SystemRadialEvent::Begin); + assert(menu.update(0,true,false,0,0,start+std::chrono::milliseconds(120))==SystemRadialEvent::Cancel); + assert(menu.update(1,true,true,0,0,start+std::chrono::seconds(2))==SystemRadialEvent::Begin); + assert(menu.update(1,true,true,0,0,start+std::chrono::seconds(3))==SystemRadialEvent::Open); + assert(menu.update(1,true,true,0,.15f,start+std::chrono::milliseconds(3100))==SystemRadialEvent::Change); + assert(menu.direction==SystemRadialDirection::Up); + assert(menu.update(1,true,false,0,.15f,start+std::chrono::milliseconds(3200))==SystemRadialEvent::Commit); + assert(SystemRadial::select(.2f,0)==SystemRadialDirection::Right); + assert(SystemRadial::select(-.2f,0)==SystemRadialDirection::Left); + assert(SystemRadial::select(0,-.2f)==SystemRadialDirection::Down); + assert(SystemRadial::select(.03f,.01f)==SystemRadialDirection::Center); + assert(SystemRadial::hit(475,319)==SystemRadialDirection::Right); + assert(SystemRadial::hit(599,401)==SystemRadialDirection::Right); + assert(SystemRadial::hit(360,183)==SystemRadialDirection::Up); + assert(SystemRadial::hit(183,360)==SystemRadialDirection::Left); + assert(SystemRadial::hit(360,537)==SystemRadialDirection::Down); + assert(!SystemRadial::hit(470,315)); + SystemRadial pointer; + assert(pointer.update(0,true,true,0,0,start)==SystemRadialEvent::Begin); + assert(pointer.update(0,true,true,0,0,start+std::chrono::seconds(1))==SystemRadialEvent::Open); + assert(pointer.update(0,true,true,0,0,start+std::chrono::milliseconds(1050),SystemRadial::hit(475,319))==SystemRadialEvent::Change); + assert(pointer.direction==SystemRadialDirection::Right); + std::cout<<"system radial: passed\n"; +} diff --git a/tests/test_steam_system_menu.py b/tests/test_steam_system_menu.py new file mode 100644 index 0000000..003cd7f --- /dev/null +++ b/tests/test_steam_system_menu.py @@ -0,0 +1,37 @@ +import importlib.util +from pathlib import Path +import unittest + + +spec = importlib.util.spec_from_file_location( + "steam_system_menu", + Path(__file__).parents[1] / "native/input/steam-system-menu.py", +) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + + +class BindingTests(unittest.TestCase): + def test_removes_only_system_long_recenter(self): + binding = { + "bindings": { + "/actions/system": {"sources": [{ + "path": "/user/hand/left/input/system", + "inputs": {"click": {"output": "dashboard"}, "double": {"output": "room"}}, + }]}, + "/actions/quickrecenter": {"sources": [ + {"path": "/user/hand/left/input/system", "inputs": {"long": {"output": "recenter"}}}, + {"path": "/user/hand/right/input/system", "inputs": {"long": {"output": "recenter"}}}, + {"path": "/user/hand/left/input/grip", "inputs": {"long": {"output": "recenter"}}}, + ]}, + } + } + patched, changed = module.without_recenter(binding) + self.assertTrue(changed) + self.assertEqual(len(patched["bindings"]["/actions/quickrecenter"]["sources"]), 1) + self.assertEqual(patched["bindings"]["/actions/system"], binding["bindings"]["/actions/system"]) + self.assertEqual(len(binding["bindings"]["/actions/quickrecenter"]["sources"]), 3) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/test-native.sh b/tools/test-native.sh index c3f7cef..699cca1 100755 --- a/tools/test-native.sh +++ b/tools/test-native.sh @@ -3,7 +3,7 @@ set -euo pipefail base=$(cd -- "$(dirname -- "$0")/.." && pwd) cd "$base" mkdir -p target/tests -for test in gamepad_mapping gamepad_steam gamepad_direct entry_hold menu_geometry keyboard_state notification_geometry notification_badge ui_visibility plugin_navigation launcher_input launcher_gaze gaze_geometry launcher_fade launcher_pointer page_scroll paint_buffer render_timing launcher_priority recording_indicator recording_region framing_lifecycle recording_region_depth; do +for test in gamepad_mapping gamepad_steam gamepad_direct entry_hold menu_geometry keyboard_state notification_geometry notification_badge ui_visibility plugin_navigation launcher_input launcher_gaze gaze_geometry launcher_fade launcher_pointer page_scroll paint_buffer render_timing launcher_priority recording_indicator recording_region framing_lifecycle recording_region_depth system_radial; do "${CXX:-g++}" -std=c++17 -O2 -pthread -Inative -Inative/vendor/openvr "tests/$test.cpp" -ldl -o "target/tests/$test" "target/tests/$test" done diff --git a/ui/src/launcher.tsx b/ui/src/launcher.tsx index 9ed67f0..a1bfc35 100644 --- a/ui/src/launcher.tsx +++ b/ui/src/launcher.tsx @@ -12,8 +12,8 @@ import './launcher.css'; import {installLauncherGaze} from './launcher-gaze'; import {LauncherCalibration} from './launcher-calibration'; import {matchesSearch,type SearchIndex} from './launcher-search'; -export type LauncherConfig={prioritizeUi?:boolean;primaryTrigger:boolean;menuAutoClose:boolean;menuTimeoutSeconds:number;allCategories?:string[]}; -const defaults:LauncherConfig={prioritizeUi:true,primaryTrigger:true,menuAutoClose:true,menuTimeoutSeconds:10,allCategories:['plugin','steam','lepton','desktop']}; +export type LauncherConfig={prioritizeUi?:boolean;systemRadialMenu?:boolean;primaryTrigger:boolean;menuAutoClose:boolean;menuTimeoutSeconds:number;allCategories?:string[]}; +const defaults:LauncherConfig={prioritizeUi:true,systemRadialMenu:true,primaryTrigger:true,menuAutoClose:true,menuTimeoutSeconds:10,allCategories:['plugin','steam','lepton','desktop']}; type SteamApp={remote?:boolean;remoteClient?:string;remoteTargets?:{client:string;name:string}[];id:number;name:string;icon?:string;iconFit?:string;kind?:string}; function launcherCells(total:number){ const counts=[Math.min(Math.max(total-5,0),4),Math.min(total,5),Math.max(total-9,0)]; @@ -30,7 +30,7 @@ export function LauncherSettings({config=defaults,refresh}:{config?:LauncherConf const[busy,setBusy]=useState(false),[error,setError]=useState(''),[seconds,setSeconds]=useState(config.menuTimeoutSeconds); useEffect(()=>setSeconds(config.menuTimeoutSeconds),[config.menuTimeoutSeconds]); async function save(p:Partial){setBusy(true);setError('');try{await api('launcher.settings.save',{...config,...p});await refresh();}catch(e){setError(String(e));}finally{setBusy(false);}} - return <>

{t('启动方式与菜单')}

void save({prioritizeUi})}/>

{t('点击入口图标打开启动台;长按 600 毫秒打开快捷面板。')}

void save({menuAutoClose})}/>{error&&

{error}

}

{t('全部包含的分类')}

{t('只影响全部分类,其他分类和收藏仍可访问。')}

{categories.filter(c=>!['all','favorites'].includes(c.key)).map(c=>{const selected=new Set(config.allCategories??defaults.allCategories);if(checked)selected.add(c.key);else selected.delete(c.key);void save({allCategories:[...selected]});}}/>)}

{t('眼追校准')}

{t('在 Frame 内打开模拟启动台,依次注视高亮目标。')}

; + return <>

{t('启动方式与菜单')}

void save({prioritizeUi})}/>void save({systemRadialMenu})}/>

{t('点击入口图标打开启动台;长按 600 毫秒打开快捷面板。')}

void save({menuAutoClose})}/>{error&&

{error}

}

{t('全部包含的分类')}

{t('只影响全部分类,其他分类和收藏仍可访问。')}

{categories.filter(c=>!['all','favorites'].includes(c.key)).map(c=>{const selected=new Set(config.allCategories??defaults.allCategories);if(checked)selected.add(c.key);else selected.delete(c.key);void save({allCategories:[...selected]});}}/>)}

{t('眼追校准')}

{t('在 Frame 内打开模拟启动台,依次注视高亮目标。')}

; } type LocalApp={steamAppId?:number|null;id:string;name:string;kind:'desktop'|'lepton';icon?:string;launchUnavailable?:string|null}; type LaunchItem={remote?:boolean;remoteClient?:string;remoteTargets?:{client:string;name:string}[];key:string;kind:string;id:string;name:string;icon?:string|null;fit?:string;favorite:boolean;launchUnavailable?:string|null}; diff --git a/ui/src/system-radial-menu.css b/ui/src/system-radial-menu.css new file mode 100644 index 0000000..1faaec0 --- /dev/null +++ b/ui/src/system-radial-menu.css @@ -0,0 +1,14 @@ +html:has(.system-radial),body:has(.system-radial),#root:has(.system-radial){width:100%;height:100%;margin:0;background:transparent!important;overflow:hidden} +.system-radial{position:relative;display:grid;place-items:center;width:100%;height:100%;color:#fff;font-family:system-ui,"Noto Sans CJK SC",sans-serif;user-select:none;pointer-events:none} +.system-radial-charge{position:relative;width:86px;height:86px;border-radius:50%;background:rgba(0,0,0,.58)} +.system-radial-charge-ring{position:absolute;inset:0;width:86px;height:86px;fill:none;stroke-width:3;transform:rotate(-90deg)} +.system-radial-charge-track{stroke:rgba(255,255,255,.24)} +.system-radial-charge-progress{stroke:#fff;stroke-linecap:round} +.system-radial-face{position:relative;width:500px;height:500px} +.system-radial-slot{--radius:177px;position:absolute;left:50%;top:50%;width:124px;height:82px;transform:translate(-50%,-50%) rotate(var(--angle)) translateY(calc(-1 * var(--radius))) rotate(calc(-1 * var(--angle)));display:flex;align-items:center;justify-content:center;flex-direction:column;gap:6px;border:1px solid transparent;border-radius:18px;background:transparent;box-sizing:border-box;opacity:.7;text-shadow:0 2px 8px rgba(0,0,0,.9);transition:opacity .12s,background .12s,border-color .12s} +.system-radial-slot span{font-size:14px;font-weight:590;white-space:nowrap} +.system-radial-slot.is-selected{opacity:1;background:rgba(0,0,0,.62);border-color:rgba(255,255,255,.65)} +.system-radial-center{position:absolute;left:50%;top:50%;width:112px;height:112px;transform:translate(-50%,-50%);display:flex;align-items:center;justify-content:center;flex-direction:column;gap:7px;border-radius:50%;border:1px solid rgba(255,255,255,.22);background:rgba(0,0,0,.5);color:#fff;box-sizing:border-box;text-shadow:0 2px 8px rgba(0,0,0,.75);opacity:.68} +.system-radial-center.is-selected{border-color:rgba(255,255,255,.8);background:rgba(0,0,0,.7);opacity:1} +.system-radial-center>span{font-size:13px;font-weight:580;white-space:nowrap} +@media (prefers-reduced-motion:reduce){.system-radial-slot{transition:none}} diff --git a/ui/src/system-radial-menu.tsx b/ui/src/system-radial-menu.tsx new file mode 100644 index 0000000..1dfa624 --- /dev/null +++ b/ui/src/system-radial-menu.tsx @@ -0,0 +1,44 @@ +import {useEffect,useState} from 'react'; +import {IconApps,IconCamera,IconDeviceDesktop,IconPlayerRecord,IconRefresh} from '@tabler/icons-react'; +import './system-radial-menu.css'; + +type State={progress:number;open:boolean;direction:string;recording:boolean}; +const initial:State={progress:0,open:false,direction:'center',recording:false}; +const slots=[ + {direction:'up',label:'启动台',icon:IconApps}, + {direction:'upRight',label:'',icon:null}, + {direction:'right',label:'截图',icon:IconCamera}, + {direction:'downRight',label:'',icon:null}, + {direction:'down',label:'管理面板',icon:IconDeviceDesktop}, + {direction:'downLeft',label:'',icon:null}, + {direction:'left',label:'录屏',icon:IconPlayerRecord}, + {direction:'upLeft',label:'',icon:null}, +]; + +export function SystemRadialMenu({progressOnly=false}:{progressOnly?:boolean}){ + const[state,setState]=useState(initial); + useEffect(()=>{ + const update=(event:Event)=>setState((event as CustomEvent).detail); + window.addEventListener('framely.systemMenu',update); + return()=>window.removeEventListener('framely.systemMenu',update); + },[]); + return
+ {progressOnly?
+ +
:
+ {slots.map((slot,index)=>{ + const Icon=slot.icon; + if(!Icon)return null; + return
+ {slot.direction==='left'&&state.recording?'结束录屏':slot.direction==='left'?'开始录屏':slot.label} +
; + })} +
+ 重置视角 +
+
} +
; +}