mirror of
https://github.com/SteamFramelyHomebrew/framely.git
synced 2026-10-29 04:02:47 +01:00
feat(input): add configurable Steam button radial controls
This commit is contained in:
1 parent
109160ac5b
commit
b3182c46ec
12 files changed
+340
-4
No files matched your search
@@ -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)
|
||||
@@ -0,0 +1,68 @@
|
||||
#pragma once
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <optional>
|
||||
|
||||
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<float>(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<SystemRadialDirection> 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<deadzone*deadzone)return SystemRadialDirection::Center;
|
||||
constexpr float sector=.78539816339f;
|
||||
int index=(int)std::lround(std::atan2(right,up)/sector);
|
||||
if(index<0)index+=8;
|
||||
switch(index%8) {
|
||||
case 0:return SystemRadialDirection::Up;
|
||||
case 1:return SystemRadialDirection::UpRight;
|
||||
case 2:return SystemRadialDirection::Right;
|
||||
case 3:return SystemRadialDirection::DownRight;
|
||||
case 4:return SystemRadialDirection::Down;
|
||||
case 5:return SystemRadialDirection::DownLeft;
|
||||
case 6:return SystemRadialDirection::Left;
|
||||
default:return SystemRadialDirection::UpLeft;
|
||||
}
|
||||
}
|
||||
// Match the four visible 124x82 slots and 112px center in the 720px overlay.
|
||||
// A ray inside the painted button always selects it, including its border.
|
||||
static std::optional<SystemRadialDirection> 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;
|
||||
}
|
||||
};
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
#include "system_radial.h"
|
||||
#include <cassert>
|
||||
#include <iostream>
|
||||
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";
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
+3
-3
@@ -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<LauncherConfig>){setBusy(true);setError('');try{await api('launcher.settings.save',{...config,...p});await refresh();}catch(e){setError(String(e));}finally{setBusy(false);}}
|
||||
return <><section className="settings-card launcher-settings"><h2>{t('启动方式与菜单')}</h2><SwitchRow label={t('启动台资源优先')} description={t('启动台显示时提高界面 CPU 调度优先级,关闭后恢复。')} checked={config.prioritizeUi??true} disabled={busy} onChange={prioritizeUi=>void save({prioritizeUi})}/><p className="sub">{t('点击入口图标打开启动台;长按 600 毫秒打开快捷面板。')}</p><SwitchRow label={t('长按菜单自动关闭')} description={t('指针离开后开始计时,移入后取消。')} checked={config.menuAutoClose} disabled={busy} onChange={menuAutoClose=>void save({menuAutoClose})}/><label><span>{t('自动关闭时间(秒)')}</span><input type="number" min={1} max={300} value={seconds} disabled={busy||!config.menuAutoClose} onChange={e=>setSeconds(+e.target.value)} onBlur={()=>{if(seconds!==config.menuTimeoutSeconds&&seconds>=1&&seconds<=300)void save({menuTimeoutSeconds:seconds});}}/></label>{error&&<p role="alert">{error}</p>}</section><section className="settings-card launcher-settings"><h2>{t('全部包含的分类')}</h2><p className="sub">{t('只影响全部分类,其他分类和收藏仍可访问。')}</p>{categories.filter(c=>!['all','favorites'].includes(c.key)).map(c=><SwitchRow key={c.key} label={t(c.label)} description={c.key==='plugin'?t('包含 Framely'):undefined} checked={(config.allCategories??defaults.allCategories!).includes(c.key)} disabled={busy} onChange={checked=>{const selected=new Set(config.allCategories??defaults.allCategories);if(checked)selected.add(c.key);else selected.delete(c.key);void save({allCategories:[...selected]});}}/>)}</section><section className="settings-card launcher-settings"><h2>{t('眼追校准')}</h2><p className="sub">{t('在 Frame 内打开模拟启动台,依次注视高亮目标。')}</p><div className="actions"><button disabled={busy} onClick={()=>void api('host.launcher.calibrate',{active:true}).catch(e=>setError(String(e)))}>{t('开始校准')}</button><button disabled={busy} onClick={()=>{setBusy(true);void api('launcher.gaze.calibration.save',null).then(()=>{window.dispatchEvent(new Event('framely.gaze.calibration.changed'));return refresh();}).catch(e=>setError(String(e))).finally(()=>setBusy(false));}}>{t('清除校准')}</button></div></section></>;
|
||||
return <><section className="settings-card launcher-settings"><h2>{t('启动方式与菜单')}</h2><SwitchRow label={t('启动台资源优先')} description={t('启动台显示时提高界面 CPU 调度优先级,关闭后恢复。')} checked={config.prioritizeUi??true} disabled={busy} onChange={prioritizeUi=>void save({prioritizeUi})}/><SwitchRow label={t('长按 Steam 键打开 Framely 菜单')} description={t('关闭后恢复 Steam 原生长按行为。切换时 SteamVR 会重新启动。')} checked={config.systemRadialMenu??true} disabled={busy} onChange={systemRadialMenu=>void save({systemRadialMenu})}/><p className="sub">{t('点击入口图标打开启动台;长按 600 毫秒打开快捷面板。')}</p><SwitchRow label={t('长按菜单自动关闭')} description={t('指针离开后开始计时,移入后取消。')} checked={config.menuAutoClose} disabled={busy} onChange={menuAutoClose=>void save({menuAutoClose})}/><label><span>{t('自动关闭时间(秒)')}</span><input type="number" min={1} max={300} value={seconds} disabled={busy||!config.menuAutoClose} onChange={e=>setSeconds(+e.target.value)} onBlur={()=>{if(seconds!==config.menuTimeoutSeconds&&seconds>=1&&seconds<=300)void save({menuTimeoutSeconds:seconds});}}/></label>{error&&<p role="alert">{error}</p>}</section><section className="settings-card launcher-settings"><h2>{t('全部包含的分类')}</h2><p className="sub">{t('只影响全部分类,其他分类和收藏仍可访问。')}</p>{categories.filter(c=>!['all','favorites'].includes(c.key)).map(c=><SwitchRow key={c.key} label={t(c.label)} description={c.key==='plugin'?t('包含 Framely'):undefined} checked={(config.allCategories??defaults.allCategories!).includes(c.key)} disabled={busy} onChange={checked=>{const selected=new Set(config.allCategories??defaults.allCategories);if(checked)selected.add(c.key);else selected.delete(c.key);void save({allCategories:[...selected]});}}/>)}</section><section className="settings-card launcher-settings"><h2>{t('眼追校准')}</h2><p className="sub">{t('在 Frame 内打开模拟启动台,依次注视高亮目标。')}</p><div className="actions"><button disabled={busy} onClick={()=>void api('host.launcher.calibrate',{active:true}).catch(e=>setError(String(e)))}>{t('开始校准')}</button><button disabled={busy} onClick={()=>{setBusy(true);void api('launcher.gaze.calibration.save',null).then(()=>{window.dispatchEvent(new Event('framely.gaze.calibration.changed'));return refresh();}).catch(e=>setError(String(e))).finally(()=>setBusy(false));}}>{t('清除校准')}</button></div></section></>;
|
||||
}
|
||||
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};
|
||||
|
||||
@@ -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}}
|
||||
@@ -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<State>(initial);
|
||||
useEffect(()=>{
|
||||
const update=(event:Event)=>setState((event as CustomEvent<State>).detail);
|
||||
window.addEventListener('framely.systemMenu',update);
|
||||
return()=>window.removeEventListener('framely.systemMenu',update);
|
||||
},[]);
|
||||
return <main className={'system-radial '+(progressOnly?'is-charging':'is-open')} aria-label="Framely 快捷菜单">
|
||||
{progressOnly?<div className="system-radial-charge">
|
||||
<svg className="system-radial-charge-ring" viewBox="0 0 86 86" aria-hidden="true">
|
||||
<circle className="system-radial-charge-track" cx="43" cy="43" r="31"/>
|
||||
<circle className="system-radial-charge-progress" cx="43" cy="43" r="31" pathLength="100" strokeDasharray={`${Math.max(0,Math.min(1,state.progress))*100} 100`}/>
|
||||
</svg>
|
||||
</div>:<div className="system-radial-face">
|
||||
{slots.map((slot,index)=>{
|
||||
const Icon=slot.icon;
|
||||
if(!Icon)return null;
|
||||
return <div key={slot.direction} className={'system-radial-slot'+(state.direction===slot.direction?' is-selected':'')} style={{'--angle':`${index*45}deg`} as React.CSSProperties}>
|
||||
<Icon size={29} stroke={1.7}/><span>{slot.direction==='left'&&state.recording?'结束录屏':slot.direction==='left'?'开始录屏':slot.label}</span>
|
||||
</div>;
|
||||
})}
|
||||
<div className={'system-radial-center'+(state.direction==='center'?' is-selected':'')}>
|
||||
<IconRefresh size={24} stroke={1.6}/><span>重置视角</span>
|
||||
</div>
|
||||
</div>}
|
||||
</main>;
|
||||
}
|
||||
Reference in new issue
Block a user