mirror of
https://github.com/baketnk/frame-yap.git
synced 2026-10-06 02:00:06 +02:00
Finish companion controls and retain panel textures during manipulation
This commit is contained in:
1 parent
b0767d1b36
commit
bcd330d42c
28 files changed
+906
-158
No files matched your search
@@ -200,6 +200,10 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING)
|
||||
target_compile_options(frameyap_battery_test PRIVATE -UNDEBUG)
|
||||
add_test(NAME frameyap.battery COMMAND frameyap_battery_test)
|
||||
endif()
|
||||
add_executable(frameyap_texture_refresh_test tests/texture_refresh_test.cpp)
|
||||
target_include_directories(frameyap_texture_refresh_test PRIVATE src)
|
||||
target_compile_options(frameyap_texture_refresh_test PRIVATE -UNDEBUG)
|
||||
add_test(NAME frameyap.texture_refresh COMMAND frameyap_texture_refresh_test)
|
||||
add_executable(frameyap_panel_drag_test tests/panel_drag_test.cpp)
|
||||
target_include_directories(frameyap_panel_drag_test PRIVATE src)
|
||||
target_compile_options(frameyap_panel_drag_test PRIVATE -UNDEBUG)
|
||||
@@ -268,3 +272,11 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING)
|
||||
endif()
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# Binding isolation is checked without initializing OpenVR.
|
||||
if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING)
|
||||
find_package(Python3 3.10 COMPONENTS Interpreter QUIET)
|
||||
if(Python3_FOUND)
|
||||
add_test(NAME grab_bindings COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_grab_bindings.py")
|
||||
endif()
|
||||
endif()
|
||||
@@ -38,7 +38,7 @@ Open **FrameYap** from the desktop application menu, or in the headset from your
|
||||
| **Y** (right) | Quick phrases; press again to pick the next one. |
|
||||
| Left grip, double-tap | Type + Enter. |
|
||||
|
||||
You can also click the panel's buttons with the laser pointer. Nothing is ever typed or submitted without you pressing a button. Buttons can be remapped with **Bindings** on the panel.
|
||||
You can also click the panel's buttons with the laser pointer. The Review tab shows **Open Plan**, **Open Keyboard**, and **Open Draw** for companion apps installed on the Frame. Tap **Open Keyboard** to show it; hold for 0.8 seconds to recenter it in front of you. Nothing is ever typed or submitted without you pressing a button. Buttons can be remapped with **Bindings** on the panel.
|
||||
|
||||
The header shows battery levels for your controllers and headset. The **Buttons paused** badge means the Steam menu is open: controller buttons go to Steam, so use the pointer instead (or close the menu).
|
||||
|
||||
@@ -61,7 +61,7 @@ Recognition runs locally with the Parakeet Redux model. The installer downloads
|
||||
|
||||
- **Update:** run the install command again and answer **y** to FrameYap. Answer **n** to the model and runtime questions unless the release notes say they changed.
|
||||
- **Check for updates:** Settings → scroll to **Check for updates**. FrameYap contacts GitHub only when you press it. If a newer published release is available, **Install update...** opens a desktop terminal; quit FrameYap and press Enter there to install. Nothing is downloaded by the check itself.
|
||||
- **Settings:** `~/.config/frameyap/config.json` (Quick phrases, theme, placement). Restart FrameYap after editing.
|
||||
- **Settings:** Animated background is off by default; Settings → **Animated background** turns it on and saves your choice. `~/.config/frameyap/config.json` holds Quick phrases, theme and placement; restart FrameYap after manual edits.
|
||||
- **Uninstall:** `sh ~/.local/share/frameyap/current/bin/install.sh --uninstall --unregistered`. Your settings and downloaded model are kept. Delete `~/.local/share/frameyap` and `~/.config/frameyap` to remove everything.
|
||||
|
||||
## More
|
||||
|
||||
+68
-21
@@ -1,29 +1,76 @@
|
||||
{
|
||||
"default_bindings": [
|
||||
{ "controller_type": "frame_controller", "binding_url": "bindings_frame_controller.json" },
|
||||
{ "controller_type": "knuckles", "binding_url": "bindings_knuckles.json" }
|
||||
{
|
||||
"controller_type": "frame_controller",
|
||||
"binding_url": "bindings_frame_controller.json"
|
||||
},
|
||||
{
|
||||
"controller_type": "knuckles",
|
||||
"binding_url": "bindings_knuckles.json"
|
||||
}
|
||||
],
|
||||
"action_sets": [
|
||||
{ "name": "/actions/frameyap", "usage": "single" }
|
||||
{
|
||||
"name": "/actions/frameyap",
|
||||
"usage": "single"
|
||||
},
|
||||
{
|
||||
"name": "/actions/grab",
|
||||
"usage": "single"
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{ "name": "/actions/frameyap/in/left_grip", "type": "boolean" },
|
||||
{ "name": "/actions/frameyap/in/right_grip", "type": "boolean" },
|
||||
{ "name": "/actions/frameyap/in/ptt", "type": "boolean" },
|
||||
{ "name": "/actions/frameyap/in/cancel", "type": "boolean" },
|
||||
{ "name": "/actions/frameyap/in/insert", "type": "boolean" },
|
||||
{ "name": "/actions/frameyap/in/enter", "type": "boolean" },
|
||||
{ "name": "/actions/frameyap/in/quick_chat", "type": "boolean" }
|
||||
{
|
||||
"name": "/actions/frameyap/in/left_grip",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"name": "/actions/frameyap/in/right_grip",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"name": "/actions/frameyap/in/ptt",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"name": "/actions/frameyap/in/cancel",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"name": "/actions/frameyap/in/insert",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"name": "/actions/frameyap/in/enter",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"name": "/actions/frameyap/in/quick_chat",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"name": "/actions/grab/in/left_depth",
|
||||
"type": "vector2"
|
||||
},
|
||||
{
|
||||
"name": "/actions/grab/in/right_depth",
|
||||
"type": "vector2"
|
||||
}
|
||||
],
|
||||
"localization": [{
|
||||
"language_tag": "en_US",
|
||||
"/actions/frameyap": "FrameYap controls",
|
||||
"/actions/frameyap/in/left_grip": "Double tap left grip: Type + Enter (enabled only)",
|
||||
"/actions/frameyap/in/right_grip": "Tap right grip briefly, then squeeze and hold to record; release to finish",
|
||||
"/actions/frameyap/in/ptt": "Hold right X to record (default; remappable)",
|
||||
"/actions/frameyap/in/cancel": "Cancel recording or discard",
|
||||
"/actions/frameyap/in/insert": "Type reviewed text + trailing space",
|
||||
"/actions/frameyap/in/enter": "Type + Enter: pending text + Enter, or Enter alone",
|
||||
"/actions/frameyap/in/quick_chat": "Open quick phrases / cycle selection"
|
||||
}]
|
||||
"localization": [
|
||||
{
|
||||
"language_tag": "en_US",
|
||||
"/actions/frameyap": "FrameYap controls",
|
||||
"/actions/frameyap/in/left_grip": "Double tap left grip: Type + Enter (enabled only)",
|
||||
"/actions/frameyap/in/right_grip": "Tap right grip briefly, then squeeze and hold to record; release to finish",
|
||||
"/actions/frameyap/in/ptt": "Hold right X to record (default; remappable)",
|
||||
"/actions/frameyap/in/cancel": "Cancel recording or discard",
|
||||
"/actions/frameyap/in/insert": "Type reviewed text + trailing space",
|
||||
"/actions/frameyap/in/enter": "Type + Enter: pending text + Enter, or Enter alone",
|
||||
"/actions/frameyap/in/quick_chat": "Open quick phrases / cycle selection",
|
||||
"/actions/grab": "Held panel depth",
|
||||
"/actions/grab/in/left_depth": "Left stick: move held panel forward/back",
|
||||
"/actions/grab/in/right_depth": "Right stick: move held panel forward/back"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -8,32 +8,78 @@
|
||||
{
|
||||
"path": "/user/hand/left/input/grip",
|
||||
"mode": "button",
|
||||
"inputs": { "click": { "output": "/actions/frameyap/in/left_grip" } }
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/frameyap/in/left_grip"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/right/input/grip",
|
||||
"mode": "button",
|
||||
"inputs": { "click": { "output": "/actions/frameyap/in/right_grip" } }
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/frameyap/in/right_grip"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/right/input/x",
|
||||
"mode": "button",
|
||||
"inputs": { "click": { "output": "/actions/frameyap/in/ptt" } }
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/frameyap/in/ptt"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/right/input/b",
|
||||
"mode": "button",
|
||||
"inputs": { "click": { "output": "/actions/frameyap/in/cancel" } }
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/frameyap/in/cancel"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/right/input/a",
|
||||
"mode": "button",
|
||||
"inputs": { "click": { "output": "/actions/frameyap/in/insert" } }
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/frameyap/in/insert"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/right/input/y",
|
||||
"mode": "button",
|
||||
"inputs": { "click": { "output": "/actions/frameyap/in/quick_chat" } }
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/frameyap/in/quick_chat"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"/actions/grab": {
|
||||
"sources": [
|
||||
{
|
||||
"path": "/user/hand/left/input/thumbstick",
|
||||
"mode": "joystick",
|
||||
"inputs": {
|
||||
"position": {
|
||||
"output": "/actions/grab/in/left_depth"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/right/input/thumbstick",
|
||||
"mode": "joystick",
|
||||
"inputs": {
|
||||
"position": {
|
||||
"output": "/actions/grab/in/right_depth"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -8,12 +8,42 @@
|
||||
{
|
||||
"path": "/user/hand/left/input/grip",
|
||||
"mode": "button",
|
||||
"inputs": { "click": { "output": "/actions/frameyap/in/left_grip" } }
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/frameyap/in/left_grip"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/right/input/grip",
|
||||
"mode": "button",
|
||||
"inputs": { "click": { "output": "/actions/frameyap/in/right_grip" } }
|
||||
"inputs": {
|
||||
"click": {
|
||||
"output": "/actions/frameyap/in/right_grip"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"/actions/grab": {
|
||||
"sources": [
|
||||
{
|
||||
"path": "/user/hand/left/input/thumbstick",
|
||||
"mode": "joystick",
|
||||
"inputs": {
|
||||
"position": {
|
||||
"output": "/actions/grab/in/left_depth"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/user/hand/right/input/thumbstick",
|
||||
"mode": "joystick",
|
||||
"inputs": {
|
||||
"position": {
|
||||
"output": "/actions/grab/in/right_depth"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"frame_start": "#1fff91",
|
||||
"frame_end": "#1f70ff"
|
||||
},
|
||||
"gradient": {"enabled": true, "period_seconds": 30, "strength": 0.12},
|
||||
"gradient": {"enabled": false, "period_seconds": 30, "strength": 0.12},
|
||||
"buttons": {
|
||||
"left_grip": "/user/hand/left/input/grip",
|
||||
"right_grip": "/user/hand/right/input/grip",
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Animation opt-in and keyboard hold — 2026-09-29
|
||||
|
||||
Animated background now defaults off in native config, the example config and
|
||||
installer defaults. Settings → Animated background writes the app's nested
|
||||
`gradient.enabled` field without replacing other settings. Static theme colors
|
||||
and the frame remain; existing explicit opt-ins are retained by ordinary upgrades.
|
||||
For this user's requested rollout, the existing Frame config was backed up and
|
||||
explicitly set off before restart.
|
||||
|
||||
Review → Open Keyboard sends `tnkboard --show` on short press. An 800 ms hold
|
||||
sends `--recenter` once, suppressing the short action on release. Leaving the
|
||||
button, resetting pointers, hiding, focus/tracking loss or entering panel drag
|
||||
cancels the hold. The launcher uses fixed exec arguments, not a shell command.
|
||||
The keyboard's matching command cancels placement/releases active input, shows
|
||||
it and uses the current headset pose; saved keyboard size is retained.
|
||||
|
||||
Verification:
|
||||
|
||||
- Full native x86-64 suite: 34/34 passed.
|
||||
- Frame ARM64 suite: 34/34 passed.
|
||||
- Inspected synthetic Settings and keyboard-hold previews.
|
||||
- Managed local artifact `0.1.202609290204` installed and launched on the Frame;
|
||||
prior version retained for rollback. Existing authorized speech runtime/model
|
||||
retained; the restarted process spawned its Redux backend worker using
|
||||
`~/.local/share/frameyap/models/redux`.
|
||||
- Installed native executable SHA-256:
|
||||
`62587bed7f7edfc0c4f2d0ef7f2624eac915f2e9a9839b3de058200e6148ce65`.
|
||||
- Final config check: FrameYap, tnkboard and tnkdraw animation disabled.
|
||||
- No audio recording, key injection or human acceptance result was manufactured.
|
||||
Long-press compositor delivery and subjective headset behavior still need the
|
||||
wearer's check. This is a local development rollout, not a published release.
|
||||
+7
-5
@@ -22,7 +22,8 @@ as explicit fallbacks. No desktop ASR server, network hop, LLM cleanup, scene
|
||||
renderer, avatar, desktop capture or root service is needed in the primary path.
|
||||
|
||||
A first-class product goal is a **one-command GitHub install without a Steam store
|
||||
AppID**. v0.1 is source-only; a prebuilt archive is deferred. Use a normal
|
||||
AppID**. Published v0.1 archives supply the native app, while the model and
|
||||
CPU runtime have separate explicit download steps. Use a normal
|
||||
OpenVR application key for registration, not Steamworks. Installation must remain
|
||||
user-local with opt-in autolaunch. See [installation design](install-design.md).
|
||||
|
||||
@@ -109,12 +110,13 @@ one animation redraw per 100 ms on a monotonic clock. Hidden panels do not
|
||||
repaint for animation. The locally implemented gradient uses a smooth periodic
|
||||
cosine field: one start/end/start cycle across the canvas width, with a shared
|
||||
time phase and global canvas coordinates on the perimeter and grab/scale handles.
|
||||
`gradient.enabled` defaults to true; `period_seconds` defaults to 30 (finite
|
||||
number 5–300), and `strength` to 0.12 (finite number 0–0.3). Its colors derive
|
||||
`gradient.enabled` defaults to false; Settings → Animated background can opt in
|
||||
and persist the choice. `period_seconds` defaults to 30 (finite number 5–300),
|
||||
and `strength` to 0.12 (finite number 0–0.3). Its colors derive
|
||||
only from `theme.frame_start` and `theme.frame_end`; strength blends those colors
|
||||
into `theme.background`. Disabled mode restores the static solid background
|
||||
and existing linear edge/handle gradients. Config is read on restart, with no
|
||||
in-panel gradient switch. This is local implementation, **not** device-validated
|
||||
and existing linear edge/handle gradients. Manual config changes are read on restart; the in-panel switch takes effect
|
||||
immediately. This is local implementation, **not** device-validated
|
||||
appearance, frame pacing, performance or headset acceptance. See
|
||||
[config and rendering](overlay.md).
|
||||
The native implementation uploads the CPU-rasterized panel to
|
||||
|
||||
+46
-17
@@ -26,7 +26,7 @@ persistent Vulkan RGBA8 image and submits it with `SetOverlayTexture`. The image
|
||||
staging allocation and command buffer are reused; tabs do not create extra
|
||||
overlays or render targets. The rounded mint-to-blue perimeter,
|
||||
shallow curved accent, and dark cards form the panel's visual language.
|
||||
With `gradient.enabled` (the default), a low-strength animated field blends
|
||||
With `gradient.enabled` (opt-in), a low-strength animated field blends
|
||||
`theme.frame_start` and `theme.frame_end` into `theme.background`; the perimeter
|
||||
and grab/scale handles share its phase and canvas coordinates. Rounded
|
||||
preview, status and control surfaces use independently rasterized antialiased edges
|
||||
@@ -101,11 +101,16 @@ Settings; no fallback auto-install is attempted.
|
||||
|
||||
The transcript wraps by glyph width and scrolls in its review viewport with
|
||||
the right-stick laser wheel; a new transcript resets the scroll position.
|
||||
The former paging row is retained for **Open Plan** and **Open Keyboard** when
|
||||
the installed `tnkplan` or `tnkboard` executable is found at startup in
|
||||
`~/.local/bin` or an absolute PATH directory. Missing apps have no button.
|
||||
Clicking launches the installed wrapper without a shell; those apps' normal
|
||||
second-launch behavior toggles their existing panels.
|
||||
The former paging row holds **Open Plan**, **Open Keyboard** and **Open Draw**
|
||||
when the installed `tnkplan`, `tnkboard` or `tnkdraw` wrapper is found at startup
|
||||
in `~/.local/bin` or an absolute PATH directory. Missing apps have no button;
|
||||
available buttons share the row. Open Keyboard short press runs the installed
|
||||
wrapper with fixed `--show`; holding it for 800 ms runs fixed `--recenter`
|
||||
once and suppresses the short action, placing the keyboard in front of the
|
||||
wearer. Leaving the button, losing focus/tracking or hiding the overlay cancels
|
||||
a pending hold. Other companion buttons launch their installed wrappers without
|
||||
a shell (their normal second-launch behavior may toggle the existing panel).
|
||||
Restart FrameYap after installing a companion so its button is discovered.
|
||||
Status fits on the single status line; the old bottom detail label is gone.
|
||||
The footer remains available on all tabs: Record (labelled Stop while recording),
|
||||
Cancel, Type (labelled Enter when nothing is pending review), Type + Enter, Hold Quit. Hold Quit needs a 900 ms press and release on
|
||||
@@ -174,7 +179,7 @@ installer creates one with defaults on first install. Copy the shipped
|
||||
"muted": "#97adc1", "accent": "#1ff0a4", "warning": "#ff6e87",
|
||||
"frame_start": "#1fff91", "frame_end": "#1f70ff"
|
||||
},
|
||||
"gradient": {"enabled": true, "period_seconds": 30, "strength": 0.12},
|
||||
"gradient": {"enabled": false, "period_seconds": 30, "strength": 0.12},
|
||||
"buttons": {
|
||||
"ptt": "/user/hand/right/input/x",
|
||||
"cancel": "/user/hand/right/input/b",
|
||||
@@ -194,7 +199,7 @@ A failed save warns and leaves the selection active for this run. Time uses
|
||||
the device's local timezone; these controls do not change system time.
|
||||
|
||||
Each theme color is `#RRGGBB`; omitted colors keep the default. The optional
|
||||
`gradient` object defaults to `{"enabled": true, "period_seconds": 30, "strength": 0.12}`.
|
||||
`gradient` object defaults to `{"enabled": false, "period_seconds": 30, "strength": 0.12}`.
|
||||
`enabled` must be a boolean; `period_seconds` must be a
|
||||
finite number from 5 to 300 (seconds per full cycle), and `strength` a finite
|
||||
number from 0 to 0.3. The animation uses a smooth periodic cosine field with
|
||||
@@ -202,9 +207,11 @@ one start/end/start cycle across the canvas width. Its edge and handle colors
|
||||
use the same global canvas coordinates and time phase; it does not add a
|
||||
separate handle animation. Colors always come from `theme.frame_start` and
|
||||
`theme.frame_end`, while `strength` controls how much of those colors blends
|
||||
into `theme.background`. Set `enabled` to `false` for a static solid background
|
||||
and the existing linear perimeter/handle gradients. Edit the JSON and restart;
|
||||
there is no in-panel gradient switch or hot reload. This is locally implemented,
|
||||
into `theme.background`. The default static mode keeps the solid background
|
||||
and existing linear perimeter/handle gradients. Settings → **Animated background**
|
||||
turns animation on/off immediately and saves only `gradient.enabled` in the JSON
|
||||
config (failed saves leave a session-only choice and a warning). Manual JSON edits
|
||||
take effect on restart; existing enabled configs stay enabled on upgrade. This is locally implemented,
|
||||
not device validated; headset appearance and rendering cost remain unverified.
|
||||
`font` is a TTF/OTF file path (not a family name); a missing file uses the
|
||||
bundled font.
|
||||
@@ -375,6 +382,18 @@ Hold the laser's primary click on the bar to **freely position and rotate** the
|
||||
panel with the controller, including depth, pitch, yaw and roll. Grab captures
|
||||
`inverse(controller_down) * panel_down` and applies that unchanged relative pose
|
||||
to each controller pose, so grabbing does not snap or reset the panel orientation.
|
||||
During a grab, the grabbing hand's thumbstick Y axis also moves the panel along
|
||||
the captured panel's local -Z normal: forward pushes away, backward pulls closer.
|
||||
The axis is velocity with a 0.2 dead zone, 0.6 m/s at full deflection and a
|
||||
±1.5 m offset limit from the pose-only grab. Returning to center holds the
|
||||
current depth; the other hand's stick and scale drags do not change it. A lost
|
||||
axis stops adding depth without resetting placement. Frame controller bindings
|
||||
provide left/right vector2 actions in a separate `/actions/grab` set, including
|
||||
in generated custom button manifests. Only a grab activates this set, restricted
|
||||
to the grabbing hand at overlay priority; it requires SteamVR Experimental
|
||||
overlay input overrides. It does not promote recording or text actions, and does
|
||||
not depend on FrameYap's ordinary `input_priority` preference. Axis delivery during laser drag and dashboard/game focus needs a
|
||||
separate headset check. Laser scroll events are ignored while dragging.
|
||||
Release leaves the last pose in the chosen mount frame; head/wrist mounts continue
|
||||
following that anchor afterward. A completed grab or scale on Head, Left wrist or
|
||||
Right wrist saves the full device-relative canvas position, rotation and scale
|
||||
@@ -470,8 +489,9 @@ coexistence remains unverified.
|
||||
|
||||
### Hardware-free UI checks
|
||||
|
||||
The default build tests mount parsing, laser preference persistence, pose geometry
|
||||
and controller-relative grab/captured-ray scale math without any native dependencies.
|
||||
The default build tests mount parsing, laser preference persistence, pose geometry,
|
||||
controller-relative grab/captured-ray scale math and stick-depth integration without
|
||||
any native dependencies.
|
||||
Drag tests cover XYZ translation, pitch/yaw/roll and lever-arm rotation, re-grabbing
|
||||
a moved panel, stationary stability, relative mounts, out-of-bounds scale hits,
|
||||
invalid poses/rays and no feedback from prior updates. UI/config/installer tests
|
||||
@@ -512,8 +532,8 @@ cmake --build build-native --target frameyap_texture_check
|
||||
|
||||
It does not initialize OpenVR or establish compositor/headset acceptance.
|
||||
|
||||
`assets/actions.json` names seven actions: left/right grip, PTT, cancel,
|
||||
Type, Type + Enter and Quick phrases. `insert`, `enter`, and `quick_chat` remain
|
||||
`assets/actions.json` names nine actions: left/right grip, PTT, cancel,
|
||||
Type, Type + Enter, Quick phrases and the two hand-specific depth axes. `insert`, `enter`, and `quick_chat` remain
|
||||
internal binding keys; visible controls read Type, Type + Enter, Quick phrases.
|
||||
`bindings_frame_controller.json` maps right X click to hold-to-talk PTT;
|
||||
the grip bindings remain for optional remapping/diagnosis. In one dashboard
|
||||
@@ -521,7 +541,8 @@ probe grips were inactive; a later controls-only probe delivered repeated right
|
||||
X PTT BeginRecord/EndRecord callbacks. The wearer reports controller actions
|
||||
are usable with Steam's dashboard closed, not with the dashboard itself open.
|
||||
Neither probe used a microphone or established game-scene pass-through.
|
||||
`bindings_knuckles.json` is an additional **Index/knuckles example only**.
|
||||
`bindings_knuckles.json` is an additional **Index/knuckles example only**;
|
||||
it includes the two thumbstick depth axes alongside grip taps.
|
||||
Collisions with scene actions require
|
||||
separate on-device validation. Left grip double tap
|
||||
(releases <=250 ms, second press within 350 ms) requests explicit Enter only
|
||||
@@ -559,8 +580,16 @@ Registration, repeated registration and removal were tested against the running
|
||||
Frame runtime, with autolaunch verified off. The overlay explicitly identifies
|
||||
its process with the registered app key before setting its action manifest.
|
||||
Cold-runtime behavior and actual SteamVR-menu launch still need validation.
|
||||
See [packaging](packaging.md); no published release is claimed here.
|
||||
See [packaging](packaging.md) for the published native archive and release checks.
|
||||
|
||||
A live headset check must be opt-in and distinguish overlay API discovery from
|
||||
controller delivery, actual transcription, insertion into a disposable target,
|
||||
and human comfort/acceptance.
|
||||
|
||||
### Opt-in rendering measurements
|
||||
|
||||
`FRAMEYAP_PROFILE=1` prints aggregate CPU raster time, upload time, and grab/scale
|
||||
tracking/transform time on exit. Counts and timings contain no speech, typed text
|
||||
or pointer coordinates. The motion measurements exclude sleep and other runtime
|
||||
work; they do not measure motion-to-photon latency. During an initialized grab or
|
||||
scale, neither the renderer nor uploader runs; content updates resume on release.
|
||||
+1
-1
@@ -67,7 +67,7 @@ CONFIG_DEFAULTS = {
|
||||
"theme": {"background": "#0c101b", "card": "#141c2b", "ink": "#e6f0f9",
|
||||
"muted": "#97adc1", "accent": "#1ff0a4", "warning": "#ff6e87",
|
||||
"frame_start": "#1fff91", "frame_end": "#1f70ff"},
|
||||
"gradient": {"enabled": True, "period_seconds": 30, "strength": 0.12},
|
||||
"gradient": {"enabled": False, "period_seconds": 30, "strength": 0.12},
|
||||
"buttons": {"left_grip": "/user/hand/left/input/grip",
|
||||
"right_grip": "/user/hand/right/input/grip",
|
||||
"ptt": "/user/hand/right/input/x", "cancel": "/user/hand/right/input/b",
|
||||
|
||||
@@ -47,7 +47,7 @@ CONFIG_DEFAULTS = {
|
||||
"theme": {"background": "#0c101b", "card": "#141c2b", "ink": "#e6f0f9",
|
||||
"muted": "#97adc1", "accent": "#1ff0a4", "warning": "#ff6e87",
|
||||
"frame_start": "#1fff91", "frame_end": "#1f70ff"},
|
||||
"gradient": {"enabled": True, "period_seconds": 30, "strength": 0.12},
|
||||
"gradient": {"enabled": False, "period_seconds": 30, "strength": 0.12},
|
||||
"buttons": {"left_grip": "/user/hand/left/input/grip",
|
||||
"right_grip": "/user/hand/right/input/grip",
|
||||
"ptt": "/user/hand/right/input/x", "cancel": "/user/hand/right/input/b",
|
||||
|
||||
@@ -16,7 +16,7 @@ bool executable(const std::filesystem::path& path) {
|
||||
}
|
||||
}
|
||||
std::optional<std::filesystem::path> find_companion(std::string_view name) {
|
||||
if (name != "tnkplan" && name != "tnkboard") return {};
|
||||
if (name != "tnkplan" && name != "tnkboard" && name != "tnkdraw") return {};
|
||||
if (const char* home = std::getenv("HOME")) {
|
||||
const auto launcher = std::filesystem::path(home) / ".local/bin" / name;
|
||||
if (executable(launcher)) return launcher;
|
||||
@@ -34,10 +34,12 @@ std::optional<std::filesystem::path> find_companion(std::string_view name) {
|
||||
}
|
||||
return {};
|
||||
}
|
||||
bool launch_companion(const std::filesystem::path& launcher) {
|
||||
bool launch_companion(const std::filesystem::path& launcher, CompanionCommand command) {
|
||||
if (!executable(launcher)) return false;
|
||||
const auto path = launcher.string();
|
||||
char* const args[] = {const_cast<char*>(path.c_str()), nullptr};
|
||||
const char* option = command == CompanionCommand::Show ? "--show" :
|
||||
command == CompanionCommand::Recenter ? "--recenter" : nullptr;
|
||||
char* const args[] = {const_cast<char*>(path.c_str()), const_cast<char*>(option), nullptr};
|
||||
// Double fork so the long-lived companion is adopted rather than becoming
|
||||
// a zombie owned by FrameYap. An O_CLOEXEC pipe reports exec failure only.
|
||||
int pipefd[2];
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
namespace frameyap {
|
||||
// Resolve an installed launcher once at boot; never search the source checkout.
|
||||
std::optional<std::filesystem::path> find_companion(std::string_view name);
|
||||
// Launch the installed wrapper (which toggles an existing overlay) without a shell.
|
||||
// Fixed control arguments only; never pass pointer/UI text to a shell.
|
||||
enum class CompanionCommand { Default, Show, Recenter };
|
||||
// Returns false if fork or exec fails; does not wait for the overlay to exit.
|
||||
bool launch_companion(const std::filesystem::path& launcher);
|
||||
bool launch_companion(const std::filesystem::path& launcher, CompanionCommand command = CompanionCommand::Default);
|
||||
}
|
||||
+43
-9
@@ -307,7 +307,7 @@ Config load_config(const std::filesystem::path& path) {
|
||||
}
|
||||
namespace {
|
||||
bool save_option(const std::filesystem::path& path, std::string_view key,
|
||||
std::string_view value, bool string_value) noexcept {
|
||||
std::string_view value, bool string_value, std::string_view parent_key = {}) noexcept {
|
||||
try {
|
||||
if (path.empty() || !path.is_absolute() || std::filesystem::is_symlink(path)) return false;
|
||||
const bool existing = std::filesystem::exists(path);
|
||||
@@ -318,14 +318,28 @@ bool save_option(const std::filesystem::path& path, std::string_view key,
|
||||
auto root = parser.parse(); parser.ws();
|
||||
if (!root.is_object || parser.pos != bytes.size()) return false;
|
||||
const std::string encoded = string_value ? "\"" + std::string(value) + "\"" : std::string(value);
|
||||
auto it = root.object.find(std::string(key));
|
||||
if (it != root.object.end()) {
|
||||
if (it->second.is_string != string_value || (!string_value && !it->second.is_bool)) return false;
|
||||
if (it->second.value == value) return true;
|
||||
bytes.replace(it->second.start, it->second.end - it->second.start, encoded);
|
||||
} else {
|
||||
bytes.insert(root.end - 1, std::string(root.object.empty() ? "" : ",") +
|
||||
"\"" + std::string(key) + "\":" + encoded);
|
||||
const Json* object = &root;
|
||||
if (!parent_key.empty()) {
|
||||
const auto parent = root.object.find(std::string(parent_key));
|
||||
if (parent == root.object.end()) {
|
||||
bytes.insert(root.end - 1, std::string(root.object.empty() ? "" : ",") +
|
||||
"\"" + std::string(parent_key) + "\":{\"" + std::string(key) + "\":" + encoded + "}");
|
||||
object = nullptr;
|
||||
} else {
|
||||
if (!parent->second.is_object) return false;
|
||||
object = &parent->second;
|
||||
}
|
||||
}
|
||||
if (object) {
|
||||
auto it = object->object.find(std::string(key));
|
||||
if (it != object->object.end()) {
|
||||
if (it->second.is_string != string_value || (!string_value && !it->second.is_bool)) return false;
|
||||
if (it->second.value == value) return true;
|
||||
bytes.replace(it->second.start, it->second.end - it->second.start, encoded);
|
||||
} else {
|
||||
bytes.insert(object->end - 1, std::string(object->object.empty() ? "" : ",") +
|
||||
"\"" + std::string(key) + "\":" + encoded);
|
||||
}
|
||||
}
|
||||
// The native reader rejects files >=4097 bytes, even if the JSON is valid.
|
||||
if (bytes.size() > 4096) return false;
|
||||
@@ -348,6 +362,9 @@ bool save_advanced_debug(const std::filesystem::path& path, bool enabled) noexce
|
||||
bool save_auto_insert(const std::filesystem::path& path, bool enabled) noexcept {
|
||||
return save_bool_option(path, "auto_insert", enabled);
|
||||
}
|
||||
bool save_gradient_enabled(const std::filesystem::path& path, bool enabled) noexcept {
|
||||
return save_option(path, "enabled", enabled ? "true" : "false", false, "gradient");
|
||||
}
|
||||
bool save_close_mic_when_idle(const std::filesystem::path& path, bool enabled) noexcept {
|
||||
return save_bool_option(path, "close_mic_when_idle", enabled);
|
||||
}
|
||||
@@ -405,9 +422,26 @@ std::filesystem::path action_manifest(const std::string& assets, const Config& c
|
||||
for (const auto& [action, path] : buttons) {
|
||||
if (path.empty()) continue;
|
||||
if (!used.emplace(path, action).second) throw std::runtime_error("Two actions share Frame button: " + path);
|
||||
// A remapped thumbstick click must share the joystick source rather
|
||||
// than create a second source for the same physical input path.
|
||||
if (path == "/user/hand/left/input/thumbstick" || path == "/user/hand/right/input/thumbstick") continue;
|
||||
if (binding.back() != '[') binding += ',';
|
||||
binding += "{\"path\":\"" + path + "\",\"mode\":\"button\",\"inputs\":{\"click\":{\"output\":\"/actions/frameyap/in/" + action + "\"}}}";
|
||||
}
|
||||
// Keep configured stick clicks in the ordinary set. The dedicated grab
|
||||
// set contains only position, so its priority cannot promote a text action.
|
||||
for (const auto& hand : {"left", "right"}) {
|
||||
const std::string path = std::string("/user/hand/") + hand + "/input/thumbstick";
|
||||
if (auto it = used.find(path); it != used.end()) {
|
||||
if (binding.back() != '[') binding += ',';
|
||||
binding += "{\"path\":\"" + path + "\",\"mode\":\"joystick\",\"inputs\":{\"click\":{\"output\":\"/actions/frameyap/in/" + it->second + "\"}}}";
|
||||
}
|
||||
}
|
||||
binding += "]},\"/actions/grab\":{\"sources\":[";
|
||||
for (const auto& hand : {"left", "right"}) {
|
||||
if (binding.back() != '[') binding += ',';
|
||||
binding += "{\"path\":\"/user/hand/" + std::string(hand) + "/input/thumbstick\",\"mode\":\"joystick\",\"inputs\":{\"position\":{\"output\":\"/actions/grab/in/" + hand + "_depth\"}}}";
|
||||
}
|
||||
binding += "]}}}";
|
||||
auto manifest = read_file(source);
|
||||
// The OpenVR SDK resolves relative binding URLs beside its manifest.
|
||||
|
||||
+2
-1
@@ -15,7 +15,7 @@ struct Theme {
|
||||
Rgba frame_start{31, 255, 145, 255}, frame_end{31, 112, 255, 255};
|
||||
};
|
||||
struct GradientConfig {
|
||||
bool enabled = true;
|
||||
bool enabled = false; // animated background is opt-in; static frame styling remains
|
||||
float period_seconds = 30.f;
|
||||
float strength = .12f; // muted edge colors blended into theme.background
|
||||
};
|
||||
@@ -45,6 +45,7 @@ Config load_config(const std::filesystem::path& path);
|
||||
// Other user customizations and formatting are retained; creates a minimal config if absent.
|
||||
bool save_advanced_debug(const std::filesystem::path& path, bool enabled) noexcept;
|
||||
bool save_auto_insert(const std::filesystem::path& path, bool enabled) noexcept;
|
||||
bool save_gradient_enabled(const std::filesystem::path& path, bool enabled) noexcept;
|
||||
bool save_close_mic_when_idle(const std::filesystem::path& path, bool enabled) noexcept;
|
||||
bool save_lock_layout(const std::filesystem::path& path, bool enabled) noexcept;
|
||||
bool save_wrist_world_fallback(const std::filesystem::path& path, bool enabled) noexcept;
|
||||
|
||||
+143
-28
@@ -1,5 +1,6 @@
|
||||
#include "overlay.hpp"
|
||||
#include "overlay_texture.hpp"
|
||||
#include "texture_refresh.hpp"
|
||||
#include "angle_fade.hpp"
|
||||
#include "gestures.hpp"
|
||||
#include "laser_setting.hpp"
|
||||
@@ -83,11 +84,14 @@ struct Overlay::Impl {
|
||||
vr::VROverlayHandle_t handle = vr::k_ulOverlayHandleInvalid;
|
||||
std::unique_ptr<OverlayTexture> gpu_texture;
|
||||
vr::VRActionSetHandle_t action_set = vr::k_ulInvalidActionSetHandle;
|
||||
vr::VRActionSetHandle_t grab_action_set = vr::k_ulInvalidActionSetHandle;
|
||||
std::array<vr::VRInputValueHandle_t, 2> hands{};
|
||||
std::array<vr::VRActionHandle_t, 7> actions{};
|
||||
std::array<vr::VRActionHandle_t, 2> depth_actions{};
|
||||
struct Persistence {
|
||||
std::filesystem::path settings_path, laser_settings_path;
|
||||
bool save_failed = false, debug_save_failed = false, auto_save_failed = false;
|
||||
bool layout_save_failed = false, mic_save_failed = false, laser_change_failed = false;
|
||||
bool layout_save_failed = false, mic_save_failed = false, gradient_save_failed = false, laser_change_failed = false;
|
||||
bool placement_save_failed = false;
|
||||
bool persist_mount = true;
|
||||
} persistence;
|
||||
@@ -95,7 +99,7 @@ struct Overlay::Impl {
|
||||
bool lasers_anytime = false;
|
||||
Config config;
|
||||
PanelSurface surface;
|
||||
std::optional<std::filesystem::path> plan_launcher, keyboard_launcher;
|
||||
std::optional<std::filesystem::path> plan_launcher, keyboard_launcher, draw_launcher;
|
||||
UpdateCheck updates;
|
||||
std::filesystem::path install_update_script;
|
||||
std::string update_version;
|
||||
@@ -117,7 +121,7 @@ struct Overlay::Impl {
|
||||
vr::TrackedDeviceIndex_t device = vr::k_unTrackedDeviceIndexInvalid;
|
||||
bool trigger_observed = false;
|
||||
float scale = 1.f;
|
||||
std::chrono::steady_clock::time_point started{};
|
||||
std::chrono::steady_clock::time_point started{}, last_depth_sample{};
|
||||
} dragging;
|
||||
vr::HmdMatrix34_t world_transform{};
|
||||
std::optional<Mount> applied_mount;
|
||||
@@ -137,6 +141,23 @@ struct Overlay::Impl {
|
||||
unsigned overlay_focus_events = 0, global_focus_events = 0, input_focus_captured_events = 0;
|
||||
std::string last_pointer_event = "none";
|
||||
} diagnostics;
|
||||
struct Performance {
|
||||
bool enabled = [] { const char* flag = std::getenv("FRAMEYAP_PROFILE");
|
||||
return flag && std::string_view(flag) == "1"; }();
|
||||
uint64_t renders = 0, uploads = 0, motion_ticks = 0, retained_frames = 0, motion_redraws = 0, motion_uploads = 0;
|
||||
double render_ms = 0, upload_ms = 0, motion_ms = 0, motion_max_ms = 0;
|
||||
static double elapsed(std::chrono::steady_clock::time_point start) {
|
||||
return std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - start).count();
|
||||
}
|
||||
void report() const {
|
||||
if (!enabled) return;
|
||||
std::cout << "FrameYap profile: renders=" << renders << " render_ms=" << render_ms
|
||||
<< " uploads=" << uploads << " upload_ms=" << upload_ms
|
||||
<< " motion_ticks=" << motion_ticks << " motion_ms=" << motion_ms
|
||||
<< " motion_max_ms=" << motion_max_ms << " retained_frames=" << retained_frames
|
||||
<< " motion_redraws=" << motion_redraws << " motion_uploads=" << motion_uploads << std::endl;
|
||||
}
|
||||
} performance;
|
||||
|
||||
Impl(const std::string& assets, const std::string& font, std::optional<Mount> requested, bool persist)
|
||||
: persistence{default_mount_settings_path(), default_laser_settings_path()},
|
||||
@@ -149,7 +170,8 @@ struct Overlay::Impl {
|
||||
persistence.persist_mount = persist;
|
||||
plan_launcher = find_companion("tnkplan");
|
||||
keyboard_launcher = find_companion("tnkboard");
|
||||
surface.set_companions(plan_launcher.has_value(), keyboard_launcher.has_value());
|
||||
draw_launcher = find_companion("tnkdraw");
|
||||
surface.set_companions(plan_launcher.has_value(), keyboard_launcher.has_value(), draw_launcher.has_value());
|
||||
for (auto selected : {Mount::LeftWrist, Mount::RightWrist, Mount::Head})
|
||||
saved_placements[static_cast<size_t>(selected)] =
|
||||
load_relative_placement(default_placement_path(selected), selected);
|
||||
@@ -189,9 +211,18 @@ struct Overlay::Impl {
|
||||
throw std::runtime_error("Could not set OpenVR action manifest path");
|
||||
if (input->GetActionSetHandle("/actions/frameyap", &action_set) != vr::VRInputError_None)
|
||||
throw std::runtime_error("Could not find FrameYap action set");
|
||||
if (input->GetActionSetHandle("/actions/grab", &grab_action_set) != vr::VRInputError_None)
|
||||
throw std::runtime_error("Could not find grab action set");
|
||||
input->GetInputSourceHandle("/user/hand/left", &hands[0]);
|
||||
input->GetInputSourceHandle("/user/hand/right", &hands[1]);
|
||||
for (size_t i = 0; i < action_names.size(); ++i)
|
||||
if (input->GetActionHandle((std::string("/actions/frameyap/in/") + action_names[i]).c_str(), &actions[i]) != vr::VRInputError_None)
|
||||
throw std::runtime_error(std::string("Could not find action ") + action_names[i]);
|
||||
for (size_t i = 0; i < depth_actions.size(); ++i) {
|
||||
const char* name = i == 0 ? "left_depth" : "right_depth";
|
||||
if (input->GetActionHandle((std::string("/actions/grab/in/") + name).c_str(), &depth_actions[i]) != vr::VRInputError_None)
|
||||
throw std::runtime_error(std::string("Could not find action ") + name);
|
||||
}
|
||||
overlay_check(overlay->CreateOverlay("local.frameyap.overlay.panel", "FrameYap", &handle), overlay, "CreateOverlay");
|
||||
overlay_check(overlay->SetOverlayWidthInMeters(handle, 0.85f), overlay, "SetOverlayWidthInMeters");
|
||||
overlay_check(overlay->SetOverlayInputMethod(handle, vr::VROverlayInputMethod_Mouse), overlay, "SetOverlayInputMethod");
|
||||
@@ -209,6 +240,7 @@ struct Overlay::Impl {
|
||||
surface.set_wrist_world_fallback(config.wrist_world_fallback);
|
||||
surface.set_advanced_debug(config.advanced_debug);
|
||||
surface.set_auto_insert(config.auto_insert);
|
||||
surface.set_gradient_enabled(config.gradient.enabled);
|
||||
surface.set_close_mic_when_idle(config.close_mic_when_idle);
|
||||
surface.set_layout_locked(config.lock_layout);
|
||||
surface.set_clock_24h(config.clock_24h);
|
||||
@@ -225,7 +257,7 @@ struct Overlay::Impl {
|
||||
std::cout << input_mode_status() << std::endl;
|
||||
} catch (...) { cleanup(); throw; }
|
||||
}
|
||||
~Impl() { cleanup(); }
|
||||
~Impl() { performance.report(); cleanup(); }
|
||||
void update_intersection_mask() {
|
||||
const auto regions = surface.input_regions();
|
||||
std::vector<vr::VROverlayIntersectionMaskPrimitive_t> mask(regions.size());
|
||||
@@ -292,6 +324,7 @@ struct Overlay::Impl {
|
||||
world_ready = false; // a fresh world fallback near the wearer, not an old room location
|
||||
std::string note = persistence.placement_save_failed ? "Placement not saved; using it only for this session." :
|
||||
persistence.mic_save_failed ? "Mic preference not saved; using it only for this session." :
|
||||
persistence.gradient_save_failed ? "Could not save background setting." :
|
||||
persistence.layout_save_failed ? "Layout lock not saved; using it only for this session." :
|
||||
persistence.auto_save_failed ? "Auto insert preference not saved; using it only for this session." :
|
||||
persistence.debug_save_failed ? "Debug preference not saved; using it only for this session." :
|
||||
@@ -388,7 +421,7 @@ struct Overlay::Impl {
|
||||
return;
|
||||
}
|
||||
dragging.scale = size_scale; dragging.canvas = canvas_pose;
|
||||
dragging.started = std::chrono::steady_clock::now();
|
||||
dragging.started = dragging.last_depth_sample = std::chrono::steady_clock::now();
|
||||
vr::VRControllerState_t state{};
|
||||
dragging.trigger_observed = system->GetControllerState(dragging.device, &state, sizeof(state)) &&
|
||||
(state.ulButtonPressed & vr::ButtonMaskFromId(vr::k_EButton_SteamVR_Trigger));
|
||||
@@ -396,6 +429,11 @@ struct Overlay::Impl {
|
||||
" device=" + std::to_string(dragging.device) + " trigger-watch=" + (dragging.trigger_observed ? "Y" : "N");
|
||||
}
|
||||
void finish_drag(bool released) {
|
||||
if (!released && applied_mount && *applied_mount == mount) {
|
||||
canvas_pose = dragging.canvas;
|
||||
size_scale = dragging.scale;
|
||||
placement_dirty = true;
|
||||
}
|
||||
if (released && applied_mount && *applied_mount == mount && mount != Mount::World &&
|
||||
(size_scale != dragging.scale || canvas_pose != dragging.canvas)) {
|
||||
const RelativePlacement current{canvas_pose, size_scale};
|
||||
@@ -407,6 +445,19 @@ struct Overlay::Impl {
|
||||
}
|
||||
surface.reset_pointers(); dragging.panel.reset();
|
||||
}
|
||||
std::optional<float> depth_axis() const {
|
||||
const auto left = system->GetTrackedDeviceIndexForControllerRole(vr::TrackedControllerRole_LeftHand);
|
||||
const auto right = system->GetTrackedDeviceIndexForControllerRole(vr::TrackedControllerRole_RightHand);
|
||||
const size_t hand = dragging.device == left ? 0 : dragging.device == right ? 1 : depth_actions.size();
|
||||
if (hand == depth_actions.size()) return {};
|
||||
vr::InputAnalogActionData_t data{};
|
||||
if (input->GetAnalogActionData(depth_actions[hand], &data, sizeof(data), hands[hand]) != vr::VRInputError_None ||
|
||||
!data.bActive || data.activeOrigin == vr::k_ulInvalidInputValueHandle || !std::isfinite(data.y)) return {};
|
||||
vr::InputOriginInfo_t origin{};
|
||||
if (input->GetOriginTrackedDeviceInfo(data.activeOrigin, &origin, sizeof(origin)) != vr::VRInputError_None ||
|
||||
origin.trackedDeviceIndex != dragging.device) return {};
|
||||
return data.y;
|
||||
}
|
||||
void update_drag() {
|
||||
if (!dragging.panel.active()) return;
|
||||
const auto source = drag_source();
|
||||
@@ -423,6 +474,11 @@ struct Overlay::Impl {
|
||||
shown && focus && bool(source) && !lost_unwatched_pointer && !timed_out);
|
||||
return;
|
||||
}
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
const double dt = std::chrono::duration<double>(now - dragging.last_depth_sample).count();
|
||||
dragging.last_depth_sample = now;
|
||||
if (dragging.kind == PanelDragKind::Grab && diagnostics.action_update_error == vr::VRInputError_None)
|
||||
if (auto axis = depth_axis()) dragging.panel.step_depth(*axis, dt);
|
||||
const auto change = dragging.panel.update(*source);
|
||||
if (!change) { finish_drag(false); return; }
|
||||
const float scale = dragging.kind == PanelDragKind::Scale ? std::clamp(dragging.scale * change->factor, .5f, 2.f) : size_scale;
|
||||
@@ -461,13 +517,26 @@ struct Overlay::Impl {
|
||||
panel = p;
|
||||
surface.set_clock_time(std::time(nullptr));
|
||||
update_indicators();
|
||||
if (surface.render(p, PanelSurface::Clock::now(), shown)) {
|
||||
// Placement updates reuse the initialized image. Defer cosmetic
|
||||
// rendering/uploads (including gradient ticks) until after the drag;
|
||||
// keep panel and indicator state live for the next render.
|
||||
const bool initialized_motion = dragging.panel.active() && has_texture;
|
||||
if (performance.enabled && initialized_motion) ++performance.retained_frames;
|
||||
refresh_texture(dragging.panel.active(), has_texture,
|
||||
[&] {
|
||||
const auto start = performance.enabled ? std::chrono::steady_clock::now() : std::chrono::steady_clock::time_point{};
|
||||
const bool rendered = surface.render(p, PanelSurface::Clock::now(), shown);
|
||||
if (performance.enabled && rendered) { if (initialized_motion) ++performance.motion_redraws; ++performance.renders; performance.render_ms += Performance::elapsed(start); }
|
||||
return rendered;
|
||||
}, [&] {
|
||||
const auto start = performance.enabled ? std::chrono::steady_clock::now() : std::chrono::steady_clock::time_point{};
|
||||
gpu_texture->upload(surface.pixels());
|
||||
auto texture = gpu_texture->texture();
|
||||
overlay_check(overlay->SetOverlayTexture(handle, &texture), overlay, "SetOverlayTexture (Vulkan)");
|
||||
++diagnostics.texture_uploads;
|
||||
if (performance.enabled) { if (initialized_motion) ++performance.motion_uploads; ++performance.uploads; performance.upload_ms += Performance::elapsed(start); }
|
||||
has_texture = true;
|
||||
}
|
||||
});
|
||||
visibility();
|
||||
}
|
||||
void reset_input(std::vector<UiAction>& result) {
|
||||
@@ -524,6 +593,15 @@ struct Overlay::Impl {
|
||||
}
|
||||
std::vector<UiAction> poll() {
|
||||
std::vector<UiAction> result;
|
||||
const auto open_companion = [&](SurfaceEvent::Companion selected) {
|
||||
const auto& launcher = selected == SurfaceEvent::Companion::Plan ? plan_launcher :
|
||||
selected == SurfaceEvent::Companion::Draw ? draw_launcher : keyboard_launcher;
|
||||
const auto command = selected == SurfaceEvent::Companion::KeyboardRecenter ? CompanionCommand::Recenter :
|
||||
selected == SurfaceEvent::Companion::Keyboard ? CompanionCommand::Show : CompanionCommand::Default;
|
||||
if (launcher && !launch_companion(*launcher, command))
|
||||
surface.set_binding_note("Could not open companion app.");
|
||||
else surface.set_binding_note("");
|
||||
};
|
||||
if (auto update = updates.poll()) {
|
||||
update_version = std::move(update->version);
|
||||
surface.set_update_status(update->state == UpdateResult::State::Available ? UpdateStatus::Available :
|
||||
@@ -568,13 +646,15 @@ struct Overlay::Impl {
|
||||
// a release must still hit the same enabled control.
|
||||
diagnostics.last_pointer_event = "overlay focus changed"; break;
|
||||
case vr::VREvent_MouseMove:
|
||||
// Hover is for scrolling only. Manipulation still uses the captured
|
||||
// controller ray, never mouse coordinates from a changing overlay.
|
||||
if (event.data.mouse.cursorIndex < cursor_positions.size())
|
||||
// Hover drives scrolling and hold cancellation. Manipulation uses
|
||||
// the captured controller ray, not changing overlay coordinates.
|
||||
if (event.data.mouse.cursorIndex < cursor_positions.size()) {
|
||||
cursor_positions[event.data.mouse.cursorIndex] = {event.data.mouse.x, H - event.data.mouse.y};
|
||||
surface.pointer_move(event.data.mouse.cursorIndex, event.data.mouse.x, H - event.data.mouse.y);
|
||||
}
|
||||
break;
|
||||
case vr::VREvent_ScrollDiscrete: case vr::VREvent_ScrollSmooth:
|
||||
if (event.data.scroll.cursorIndex < cursor_positions.size() &&
|
||||
if (!dragging.panel.active() && event.data.scroll.cursorIndex < cursor_positions.size() &&
|
||||
cursor_positions[event.data.scroll.cursorIndex]) {
|
||||
const auto [x, y] = *cursor_positions[event.data.scroll.cursorIndex];
|
||||
surface.scroll(x, y, event.data.scroll.ydelta);
|
||||
@@ -596,7 +676,7 @@ struct Overlay::Impl {
|
||||
diagnostics.last_pointer_event = "up button=" + std::to_string(event.data.mouse.button);
|
||||
if (event.data.mouse.button == vr::VRMouseButton_Left) {
|
||||
auto event_result = surface.pointer_up(event.data.mouse.cursorIndex, event.data.mouse.x, H - event.data.mouse.y);
|
||||
if (event_result.action || event_result.mount || event_result.recenter || event_result.lasers_anytime || event_result.open_bindings || event_result.launch_companion || event_result.check_updates || event_result.install_update || event_result.advanced_debug || event_result.auto_insert || event_result.close_mic_when_idle || event_result.lock_layout || event_result.wrist_world_fallback || event_result.clock_24h || event_result.date_format || event_result.model_action) ++diagnostics.pointer_actions;
|
||||
if (event_result.action || event_result.mount || event_result.recenter || event_result.lasers_anytime || event_result.open_bindings || event_result.launch_companion || event_result.check_updates || event_result.install_update || event_result.advanced_debug || event_result.auto_insert || event_result.gradient_enabled || event_result.close_mic_when_idle || event_result.lock_layout || event_result.wrist_world_fallback || event_result.clock_24h || event_result.date_format || event_result.model_action) ++diagnostics.pointer_actions;
|
||||
if (event_result.action) result.push_back(*event_result.action);
|
||||
if (event_result.check_updates) {
|
||||
update_version.clear();
|
||||
@@ -605,13 +685,7 @@ struct Overlay::Impl {
|
||||
if (event_result.install_update && !update_version.empty() &&
|
||||
!open_update_terminal(install_update_script, update_version))
|
||||
surface.set_update_status(UpdateStatus::TerminalFailed);
|
||||
if (event_result.launch_companion) {
|
||||
const auto& launcher = *event_result.launch_companion == SurfaceEvent::Companion::Plan ?
|
||||
plan_launcher : keyboard_launcher;
|
||||
if (launcher && !launch_companion(*launcher))
|
||||
surface.set_binding_note("Could not open companion app.");
|
||||
else surface.set_binding_note("");
|
||||
}
|
||||
if (event_result.launch_companion) open_companion(*event_result.launch_companion);
|
||||
if (event_result.model_action) {
|
||||
model_actions.push_back(*event_result.model_action);
|
||||
reset_input(result); // revoke held PTT, delivery and stale pointer approval
|
||||
@@ -656,6 +730,14 @@ struct Overlay::Impl {
|
||||
reset_input(result); // a setting change invalidates held actions
|
||||
return result;
|
||||
}
|
||||
if (event_result.gradient_enabled) {
|
||||
config.gradient.enabled = *event_result.gradient_enabled;
|
||||
persistence.gradient_save_failed = persistence.persist_mount &&
|
||||
!save_gradient_enabled(default_config_path(), config.gradient.enabled);
|
||||
surface.set_gradient_enabled(config.gradient.enabled);
|
||||
reset_input(result);
|
||||
return result;
|
||||
}
|
||||
if (event_result.auto_insert) {
|
||||
config.auto_insert = *event_result.auto_insert;
|
||||
persistence.auto_save_failed = persistence.persist_mount && !save_auto_insert(default_config_path(), config.auto_insert);
|
||||
@@ -705,8 +787,49 @@ struct Overlay::Impl {
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
vr::VRActiveActionSet_t set{};
|
||||
set.ulActionSet = action_set;
|
||||
set.ulRestrictedToDevice = vr::k_ulInvalidInputValueHandle;
|
||||
// This is a request, not proof of delivery through the dashboard/game.
|
||||
set.nPriority = action_priority();
|
||||
std::array<vr::VRActiveActionSet_t, 2> sets{set, {}};
|
||||
uint32_t count = 1;
|
||||
if (dragging.panel.active() && dragging.kind == PanelDragKind::Grab && tracked(dragging.device)) {
|
||||
const auto role = system->GetControllerRoleForTrackedDeviceIndex(dragging.device);
|
||||
const auto hand = role == vr::TrackedControllerRole_LeftHand ? hands[0] :
|
||||
role == vr::TrackedControllerRole_RightHand ? hands[1] : vr::k_ulInvalidInputValueHandle;
|
||||
if (hand != vr::k_ulInvalidInputValueHandle) {
|
||||
// Depth priority must never promote recording/typing bindings.
|
||||
sets[1].ulActionSet = grab_action_set;
|
||||
sets[1].ulRestrictedToDevice = hand;
|
||||
sets[1].nPriority = vr::k_nActionSetOverlayGlobalPriorityMin;
|
||||
count = 2;
|
||||
}
|
||||
}
|
||||
diagnostics.action_update_error = input->UpdateActionState(sets.data(), sizeof(sets[0]), count);
|
||||
const bool measuring_motion = performance.enabled && dragging.panel.active();
|
||||
const auto motion_start = measuring_motion ? std::chrono::steady_clock::now() : std::chrono::steady_clock::time_point{};
|
||||
// Event handlers can launch a companion or update a setting. Refresh
|
||||
// tracking immediately before motion submission after that work.
|
||||
if (dragging.panel.active())
|
||||
system->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding, 0, poses.data(), uint32_t(poses.size()));
|
||||
update_drag();
|
||||
place();
|
||||
if (measuring_motion) {
|
||||
++performance.motion_ticks;
|
||||
const auto elapsed = Performance::elapsed(motion_start);
|
||||
performance.motion_ms += elapsed;
|
||||
performance.motion_max_ms = std::max(performance.motion_max_ms, elapsed);
|
||||
}
|
||||
const auto& hmd = poses[vr::k_unTrackedDeviceIndex_Hmd];
|
||||
if (!shown || !focus || !placed || !hmd.bPoseIsValid || !hmd.bDeviceIsConnected ||
|
||||
!overlay->IsHoverTargetOverlay(handle)) {
|
||||
// Tracking/visibility/hover loss revokes the hold even if OpenVR never sends MouseUp.
|
||||
surface.reset_pointers();
|
||||
} else if (auto command = surface.poll_keyboard_hold()) {
|
||||
++diagnostics.pointer_actions;
|
||||
open_companion(*command);
|
||||
}
|
||||
if (dragging.panel.active()) {
|
||||
// A controller used to manipulate the panel must not also authorize
|
||||
// Record/Insert/Enter. Require neutral rearm after the drag ends.
|
||||
@@ -716,14 +839,6 @@ struct Overlay::Impl {
|
||||
for (auto& edge : edges) edge.reset();
|
||||
return result;
|
||||
}
|
||||
vr::VRActiveActionSet_t set{};
|
||||
set.ulActionSet = action_set;
|
||||
set.ulRestrictedToDevice = vr::k_ulInvalidInputValueHandle;
|
||||
// Explicit opt-in affects only sources bound to our existing action set.
|
||||
// Keep requesting it across dashboard/laser states: those are what the
|
||||
// experiment compares. SteamVR's separate permission gate is never changed here.
|
||||
set.nPriority = action_priority();
|
||||
diagnostics.action_update_error = input->UpdateActionState(&set, sizeof(set), 1);
|
||||
if (diagnostics.action_update_error != vr::VRInputError_None) {
|
||||
reset_input(result); return result;
|
||||
}
|
||||
|
||||
+20
-2
@@ -2,6 +2,7 @@
|
||||
|
||||
#include "mount.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <optional>
|
||||
|
||||
@@ -128,11 +129,27 @@ public:
|
||||
return true;
|
||||
}
|
||||
|
||||
// The stick is velocity, not absolute placement. Positive Y moves along
|
||||
// the captured panel's -Z normal in controller coordinates, even for an angled ray.
|
||||
void step_depth(float axis_y, double seconds) {
|
||||
if (!active_ || kind_ != PanelDragKind::Grab || !std::isfinite(axis_y) ||
|
||||
!std::isfinite(seconds) || seconds <= 0) return;
|
||||
const double magnitude = std::abs(std::clamp(double(axis_y), -1., 1.));
|
||||
if (magnitude <= .2) return;
|
||||
const double velocity = std::copysign((magnitude - .2) / .8 * .6, axis_y);
|
||||
depth_ = std::clamp(depth_ + velocity * std::min(seconds, .05), -1.5, 1.5);
|
||||
}
|
||||
|
||||
double depth() const { return depth_; }
|
||||
|
||||
std::optional<PanelDragUpdate> update(const Matrix34& controller_pose) const {
|
||||
using namespace panel_drag_detail;
|
||||
if (!active_ || !rigid(controller_pose)) return std::nullopt;
|
||||
if (kind_ == PanelDragKind::Grab) {
|
||||
const Matrix34 pose = compose_pose(controller_pose, relative_panel_);
|
||||
auto relative = relative_panel_;
|
||||
for (int row = 0; row < 3; ++row)
|
||||
relative[row][3] -= relative_panel_[row][2] * static_cast<float>(depth_);
|
||||
const Matrix34 pose = compose_pose(controller_pose, relative);
|
||||
if (!rigid(pose)) return std::nullopt;
|
||||
return PanelDragUpdate{pose, 1.f};
|
||||
}
|
||||
@@ -150,7 +167,7 @@ public:
|
||||
return PanelDragUpdate{panel_pose_, static_cast<float>(factor)};
|
||||
}
|
||||
|
||||
void reset() { active_ = false; }
|
||||
void reset() { active_ = false; depth_ = 0; }
|
||||
bool active() const { return active_; }
|
||||
|
||||
private:
|
||||
@@ -160,6 +177,7 @@ private:
|
||||
Matrix34 panel_pose_{}, relative_panel_{};
|
||||
Vec3 center_{}, normal_{}, anchor_{}, down_hit_{}, local_ray_{};
|
||||
double baseline_sq_ = 0;
|
||||
double depth_ = 0;
|
||||
};
|
||||
|
||||
} // namespace frameyap
|
||||
+102
-30
@@ -31,10 +31,11 @@ struct Rect {
|
||||
enum class Control { Review, Settings, Bindings, Plan, Keyboard, Record, Cancel, Insert, Enter, Quit,
|
||||
World, Left, Right, Head, Recenter, LasersAnytime, AdvancedDebug, AutoInsert,
|
||||
Clock24h, Date, LockLayout, CloseMicWhenIdle, Models, ModelRow,
|
||||
ModelInstall, ModelPrev, ModelNext, About, WristWorldFallback, UpdateCheck, UpdateInstall };
|
||||
ModelInstall, ModelPrev, ModelNext, About, WristWorldFallback, UpdateCheck, UpdateInstall, Draw,
|
||||
AnimatedBackground };
|
||||
enum class Tab { Review, Settings, Models, About };
|
||||
struct Button { Rect r; Control id; const char* label; };
|
||||
constexpr std::array<Button, 36> buttons{{
|
||||
constexpr std::array<Button, 38> buttons{{
|
||||
{{32, 138, 180, 46}, Control::Review, "Review"},
|
||||
{{226, 138, 180, 46}, Control::Settings, "Settings"},
|
||||
{{420, 138, 180, 46}, Control::Bindings, "Bindings"},
|
||||
@@ -68,15 +69,17 @@ constexpr std::array<Button, 36> buttons{{
|
||||
{{32, 530, 214, 36}, Control::ModelPrev, "Previous"},
|
||||
{{260, 530, 214, 36}, Control::ModelNext, "Next"},
|
||||
{{346, 378, 154, 44}, Control::About, "About"},
|
||||
{{32, 584, 936, 50}, Control::WristWorldFallback, "Wrist world fallback"},
|
||||
{{32, 640, 454, 42}, Control::UpdateCheck, "Check for updates"},
|
||||
{{514, 640, 454, 42}, Control::UpdateInstall, "Install update..."},
|
||||
{{32, 626, 936, 50}, Control::WristWorldFallback, "Wrist world fallback"},
|
||||
{{32, 682, 454, 42}, Control::UpdateCheck, "Check for updates"},
|
||||
{{514, 682, 454, 42}, Control::UpdateInstall, "Install update..."},
|
||||
{{32, 496, 454, 44}, Control::Draw, "Open Draw"},
|
||||
{{32, 574, 936, 36}, Control::AnimatedBackground, "Animated background"},
|
||||
}};
|
||||
constexpr Rect settings_view{32, 232, 936, 316};
|
||||
constexpr Rect review_view{48, 212, 904, 260};
|
||||
constexpr int review_line_height = 42;
|
||||
// Leave a status row after the optional update controls, within the viewport.
|
||||
constexpr int settings_max_scroll = 722 - (settings_view.y + settings_view.h);
|
||||
constexpr int settings_max_scroll = 764 - (settings_view.y + settings_view.h);
|
||||
std::optional<UiAction> action(Control c) {
|
||||
switch (c) {
|
||||
case Control::Record: return UiAction::Record;
|
||||
@@ -100,7 +103,7 @@ bool settings_control(Control c) {
|
||||
return c == Control::Models || c == Control::About || mounting(c) ||
|
||||
c == Control::Recenter || c == Control::LasersAnytime || c == Control::AdvancedDebug ||
|
||||
c == Control::AutoInsert || c == Control::Clock24h || c == Control::Date ||
|
||||
c == Control::CloseMicWhenIdle || c == Control::WristWorldFallback ||
|
||||
c == Control::CloseMicWhenIdle || c == Control::AnimatedBackground || c == Control::WristWorldFallback ||
|
||||
c == Control::UpdateCheck || c == Control::UpdateInstall;
|
||||
}
|
||||
// Invalid bytes become visible replacement glyphs, never control commands.
|
||||
@@ -141,7 +144,7 @@ struct PanelSurface::Impl {
|
||||
bool clock_24h = false, layout_locked = false;
|
||||
float settings_scroll = 0.f;
|
||||
std::optional<Rect> clip_view;
|
||||
bool plan_present = false, keyboard_present = false;
|
||||
bool plan_present = false, keyboard_present = false, draw_present = false;
|
||||
UpdateStatus update_status = UpdateStatus::Idle;
|
||||
std::string update_version;
|
||||
float review_scroll = 0.f;
|
||||
@@ -152,7 +155,8 @@ struct PanelSurface::Impl {
|
||||
StatusIndicators indicators;
|
||||
std::array<int, 2> pressed{{-1, -1}};
|
||||
std::array<PanelSurface::Clock::time_point, 2> press_time{};
|
||||
int hold_progress = 0;
|
||||
std::array<bool, 2> keyboard_fired{};
|
||||
int hold_progress = 0, keyboard_progress = 0;
|
||||
int drag_cursor = -1;
|
||||
std::vector<std::string> lines;
|
||||
size_t model_page = 0;
|
||||
@@ -390,10 +394,11 @@ struct PanelSurface::Impl {
|
||||
if (c == Control::ModelInstall) return tab == Tab::Models;
|
||||
if (c == Control::Plan) return tab == Tab::Review && plan_present;
|
||||
if (c == Control::Keyboard) return tab == Tab::Review && keyboard_present;
|
||||
if (c == Control::Draw) return tab == Tab::Review && draw_present;
|
||||
if (mounting(c) || c == Control::Recenter || c == Control::LasersAnytime ||
|
||||
c == Control::AdvancedDebug || c == Control::AutoInsert ||
|
||||
c == Control::Clock24h || c == Control::Date || c == Control::LockLayout ||
|
||||
c == Control::CloseMicWhenIdle) return tab == Tab::Settings;
|
||||
c == Control::CloseMicWhenIdle || c == Control::AnimatedBackground) return tab == Tab::Settings;
|
||||
return true;
|
||||
}
|
||||
bool enabled(Control c) const {
|
||||
@@ -416,6 +421,19 @@ struct PanelSurface::Impl {
|
||||
}
|
||||
Rect button_rect(const Button& b) const {
|
||||
auto r = b.r;
|
||||
if (b.id == Control::Plan || b.id == Control::Keyboard || b.id == Control::Draw) {
|
||||
// Only installed companions occupy the review row. Keep the full
|
||||
// hit region and label together as the row grows from one to three.
|
||||
const int count = int(plan_present) + int(keyboard_present) + int(draw_present);
|
||||
const int index = (b.id == Control::Keyboard ? int(plan_present) : 0) +
|
||||
(b.id == Control::Draw ? int(plan_present) + int(keyboard_present) : 0);
|
||||
if (count) {
|
||||
constexpr int gap = 12, available_width = 936;
|
||||
const int width = (available_width - gap * (count - 1)) / count;
|
||||
r.x = 32 + index * (width + gap);
|
||||
r.w = index == count - 1 ? 968 - r.x : width;
|
||||
}
|
||||
}
|
||||
if (settings_control(b.id)) r.y -= int(std::lround(settings_scroll));
|
||||
return r;
|
||||
}
|
||||
@@ -429,6 +447,7 @@ struct PanelSurface::Impl {
|
||||
}
|
||||
void reset() {
|
||||
pressed.fill(-1);
|
||||
keyboard_fired.fill(false);
|
||||
drag_cursor = -1;
|
||||
}
|
||||
// Static facts only: nothing here is a live check of the model or device.
|
||||
@@ -476,12 +495,17 @@ struct PanelSurface::Impl {
|
||||
}
|
||||
panel = p;
|
||||
if (panel.quick_open && tab != Tab::Review) { tab = Tab::Review; dirty = true; }
|
||||
int progress = 0;
|
||||
for (size_t cursor = 0; cursor < pressed.size(); ++cursor)
|
||||
int progress = 0, keyboard = 0;
|
||||
for (size_t cursor = 0; cursor < pressed.size(); ++cursor) {
|
||||
const auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(now - press_time[cursor]).count();
|
||||
if (pressed[cursor] == 10)
|
||||
progress = std::max(progress, std::clamp(int(std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
PanelSurface::Clock::now() - press_time[cursor]).count() / 100) + 1, 1, 10));
|
||||
if (progress != hold_progress) { hold_progress = progress; dirty = true; }
|
||||
progress = std::max(progress, std::clamp(int(elapsed / 100) + 1, 1, 10));
|
||||
if (pressed[cursor] >= 0 && buttons[pressed[cursor]].id == Control::Keyboard && !keyboard_fired[cursor])
|
||||
keyboard = std::max(keyboard, std::clamp(int(elapsed / 100) + 1, 1, 8));
|
||||
}
|
||||
if (progress != hold_progress || keyboard != keyboard_progress) {
|
||||
hold_progress = progress; keyboard_progress = keyboard; dirty = true;
|
||||
}
|
||||
auto label = panel_clock(clock_time, clock_24h, date_format);
|
||||
if (label.time != displayed_clock.time || label.date != displayed_clock.date) dirty = true;
|
||||
if (!dirty) return false;
|
||||
@@ -575,8 +599,11 @@ struct PanelSurface::Impl {
|
||||
32, 225, 17, muted, 968);
|
||||
}
|
||||
rect({32, 550, 936, 1}, mix(card, cyan, .17f));
|
||||
if (tab == Tab::Review && !panel.quick_open && max_review_scroll() > 0)
|
||||
text("Right stick: scroll review", 32, 568, 16, muted, 400);
|
||||
if (tab == Tab::Review && !panel.quick_open) {
|
||||
if (keyboard_present) text("Hold Open Keyboard 0.8s: recenter",
|
||||
max_review_scroll() > 0 ? 450 : 32, 568, 19, muted, 968);
|
||||
if (max_review_scroll() > 0) text("Right stick: scroll review", 32, 568, 16, muted, 400);
|
||||
}
|
||||
for (size_t i = 0; i < buttons.size(); ++i) {
|
||||
const auto& b = buttons[i];
|
||||
if (!visible(b.id)) continue;
|
||||
@@ -590,6 +617,7 @@ struct PanelSurface::Impl {
|
||||
(b.id == Control::LasersAnytime && lasers_anytime) ||
|
||||
(b.id == Control::AdvancedDebug && advanced_debug) ||
|
||||
(b.id == Control::AutoInsert && auto_insert) ||
|
||||
(b.id == Control::AnimatedBackground && gradient.enabled) ||
|
||||
(b.id == Control::CloseMicWhenIdle && close_mic_when_idle) ||
|
||||
(b.id == Control::LockLayout && layout_locked);
|
||||
const Color fill = !on ? mix(background, card, .40f) :
|
||||
@@ -600,11 +628,14 @@ struct PanelSurface::Impl {
|
||||
rounded(r, std::min(16, r.h / 3), fill,
|
||||
!on ? mix(card, muted, .13f) : highlighted ? accent : mix(card, muted, .38f),
|
||||
highlighted ? .23f : 0.f, highlighted ? 2 : 1);
|
||||
if (b.id == Control::Quit && (pressed[0] == int(i) || pressed[1] == int(i))) {
|
||||
if ((b.id == Control::Quit || b.id == Control::Keyboard) &&
|
||||
(pressed[0] == int(i) || pressed[1] == int(i))) {
|
||||
const int cursor = pressed[0] == int(i) ? 0 : 1;
|
||||
const auto duration = b.id == Control::Quit ? PanelSurface::quit_hold : PanelSurface::keyboard_hold;
|
||||
const float fraction = std::clamp(float(std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
PanelSurface::Clock::now() - press_time[cursor]).count()) / PanelSurface::quit_hold.count(), 0.f, 1.f);
|
||||
rect({r.x + 7, r.y + r.h - 9, int((r.w - 14) * fraction), 3}, pink);
|
||||
now - press_time[cursor]).count()) / duration.count(), 0.f, 1.f);
|
||||
rect({r.x + 7, r.y + r.h - 9, int((r.w - 14) * fraction), 3},
|
||||
b.id == Control::Quit ? pink : cyan);
|
||||
}
|
||||
const std::string label = b.id == Control::ModelRow ? [&]() {
|
||||
const auto& m = panel.models[model_page * 6 + i - 23];
|
||||
@@ -623,10 +654,12 @@ struct PanelSurface::Impl {
|
||||
on ? ink : mix(background, muted, .48f), r.x + r.w - 8);
|
||||
if (mounting(b.id) && selected) text("ON", r.x + r.w - 56, r.y + 38, 23, cyan, r.x + r.w - 12);
|
||||
if (b.id == Control::LasersAnytime || b.id == Control::AdvancedDebug || b.id == Control::AutoInsert ||
|
||||
b.id == Control::LockLayout || b.id == Control::CloseMicWhenIdle || b.id == Control::WristWorldFallback) {
|
||||
b.id == Control::LockLayout || b.id == Control::CloseMicWhenIdle || b.id == Control::AnimatedBackground ||
|
||||
b.id == Control::WristWorldFallback) {
|
||||
bool active = b.id == Control::LasersAnytime ? lasers_anytime :
|
||||
b.id == Control::AutoInsert ? auto_insert :
|
||||
b.id == Control::LockLayout ? layout_locked :
|
||||
b.id == Control::AnimatedBackground ? gradient.enabled :
|
||||
b.id == Control::CloseMicWhenIdle ? close_mic_when_idle :
|
||||
b.id == Control::WristWorldFallback ? wrist_world_fallback : advanced_debug;
|
||||
text(active ? "ON" : "OFF", r.x + r.w - 66, r.y + r.h / 2 + 9, 22,
|
||||
@@ -649,7 +682,7 @@ struct PanelSurface::Impl {
|
||||
"Could not check for updates.";
|
||||
if (!update_note.empty()) {
|
||||
clip_view = settings_view;
|
||||
text(update_note, 32, 716 - int(std::lround(settings_scroll)), 18,
|
||||
text(update_note, 32, 758 - int(std::lround(settings_scroll)), 18,
|
||||
(update_status == UpdateStatus::Failed || update_status == UpdateStatus::TerminalFailed) ? pink : cyan, 968);
|
||||
clip_view.reset();
|
||||
}
|
||||
@@ -699,7 +732,7 @@ bool PanelSurface::dragging(unsigned cursor) const {
|
||||
return cursor < impl_->pressed.size() && int(cursor) == impl_->drag_cursor;
|
||||
}
|
||||
std::optional<PanelDragKind> PanelSurface::pointer_down(unsigned cursor, float x, float y, Clock::time_point now) {
|
||||
if (cursor >= impl_->pressed.size() || impl_->drag_cursor >= 0) return {};
|
||||
if (cursor >= impl_->pressed.size() || impl_->drag_cursor >= 0 || impl_->pressed[cursor] >= 0) return {};
|
||||
const auto contains = [&](Bounds b) { return Rect{b.x, b.y, b.w, b.h}.contains(x, y); };
|
||||
if (!impl_->layout_locked && (contains(grab) || contains(scale))) {
|
||||
impl_->reset(); // other cursor's prior approval cannot survive relocation
|
||||
@@ -708,7 +741,30 @@ std::optional<PanelDragKind> PanelSurface::pointer_down(unsigned cursor, float x
|
||||
}
|
||||
impl_->pressed[cursor] = impl_->hit(x, y);
|
||||
impl_->press_time[cursor] = now;
|
||||
if (impl_->pressed[cursor] == 10) impl_->dirty = true;
|
||||
impl_->keyboard_fired[cursor] = false;
|
||||
if (impl_->pressed[cursor] == 10 ||
|
||||
(impl_->pressed[cursor] >= 0 && buttons[impl_->pressed[cursor]].id == Control::Keyboard)) impl_->dirty = true;
|
||||
return {};
|
||||
}
|
||||
void PanelSurface::pointer_move(unsigned cursor, float x, float y) {
|
||||
if (cursor >= impl_->pressed.size() || impl_->pressed[cursor] < 0) return;
|
||||
const int index = impl_->pressed[cursor];
|
||||
if (buttons[index].id == Control::Keyboard && impl_->hit(x, y) != index) {
|
||||
impl_->pressed[cursor] = -1; // leaving cancels; returning cannot rearm the same press
|
||||
impl_->keyboard_fired[cursor] = false;
|
||||
impl_->dirty = true;
|
||||
}
|
||||
}
|
||||
std::optional<SurfaceEvent::Companion> PanelSurface::poll_keyboard_hold(Clock::time_point now) {
|
||||
for (size_t cursor = 0; cursor < impl_->pressed.size(); ++cursor) {
|
||||
const int index = impl_->pressed[cursor];
|
||||
if (index >= 0 && buttons[index].id == Control::Keyboard && !impl_->keyboard_fired[cursor] &&
|
||||
now - impl_->press_time[cursor] >= keyboard_hold) {
|
||||
impl_->keyboard_fired[cursor] = true;
|
||||
impl_->dirty = true;
|
||||
return SurfaceEvent::Companion::KeyboardRecenter;
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
SurfaceEvent PanelSurface::pointer_up(unsigned cursor, float x, float y, Clock::time_point now) {
|
||||
@@ -719,9 +775,15 @@ SurfaceEvent PanelSurface::pointer_up(unsigned cursor, float x, float y, Clock::
|
||||
return result;
|
||||
}
|
||||
int index = std::exchange(impl_->pressed[cursor], -1);
|
||||
if (index == 10) impl_->dirty = true;
|
||||
const bool fired = std::exchange(impl_->keyboard_fired[cursor], false);
|
||||
if (index == 10 || (index >= 0 && buttons[index].id == Control::Keyboard)) impl_->dirty = true;
|
||||
if (index < 0 || impl_->hit(x, y) != index) return result;
|
||||
auto c = buttons[index].id;
|
||||
if (c == Control::Keyboard) {
|
||||
if (!fired) result.launch_companion = now - impl_->press_time[cursor] >= keyboard_hold ?
|
||||
SurfaceEvent::Companion::KeyboardRecenter : SurfaceEvent::Companion::Keyboard;
|
||||
return result;
|
||||
}
|
||||
if (c == Control::Quit && now - impl_->press_time[cursor] < quit_hold) return result;
|
||||
if (c == Control::Record) result.action = impl_->panel.recording ? UiAction::EndRecord : UiAction::BeginRecord;
|
||||
else if (auto a = action(c)) result.action = a;
|
||||
@@ -730,6 +792,7 @@ SurfaceEvent PanelSurface::pointer_up(unsigned cursor, float x, float y, Clock::
|
||||
else if (c == Control::LasersAnytime) result.lasers_anytime = !impl_->lasers_anytime;
|
||||
else if (c == Control::AdvancedDebug) result.advanced_debug = !impl_->advanced_debug;
|
||||
else if (c == Control::AutoInsert) result.auto_insert = !impl_->auto_insert;
|
||||
else if (c == Control::AnimatedBackground) result.gradient_enabled = !impl_->gradient.enabled;
|
||||
else if (c == Control::CloseMicWhenIdle) result.close_mic_when_idle = !impl_->close_mic_when_idle;
|
||||
else if (c == Control::LockLayout) result.lock_layout = !impl_->layout_locked;
|
||||
else if (c == Control::WristWorldFallback) result.wrist_world_fallback = !impl_->wrist_world_fallback;
|
||||
@@ -775,8 +838,9 @@ SurfaceEvent PanelSurface::pointer_up(unsigned cursor, float x, float y, Clock::
|
||||
impl_->tab = c == Control::Review ? Tab::Review : Tab::Settings;
|
||||
impl_->install_confirm = false; impl_->consent_snapshot.reset(); impl_->reset(); impl_->dirty = true;
|
||||
}
|
||||
else if (c == Control::Plan || c == Control::Keyboard) {
|
||||
result.launch_companion = c == Control::Plan ? SurfaceEvent::Companion::Plan : SurfaceEvent::Companion::Keyboard;
|
||||
else if (c == Control::Plan || c == Control::Keyboard || c == Control::Draw) {
|
||||
result.launch_companion = c == Control::Plan ? SurfaceEvent::Companion::Plan :
|
||||
c == Control::Keyboard ? SurfaceEvent::Companion::Keyboard : SurfaceEvent::Companion::Draw;
|
||||
impl_->reset();
|
||||
}
|
||||
return result;
|
||||
@@ -800,9 +864,9 @@ bool PanelSurface::scroll(float x, float y, float vertical_delta) {
|
||||
impl_->dirty = true;
|
||||
return true;
|
||||
}
|
||||
void PanelSurface::set_companions(bool plan, bool keyboard) {
|
||||
if (impl_->plan_present != plan || impl_->keyboard_present != keyboard) {
|
||||
impl_->plan_present = plan; impl_->keyboard_present = keyboard;
|
||||
void PanelSurface::set_companions(bool plan, bool keyboard, bool draw) {
|
||||
if (impl_->plan_present != plan || impl_->keyboard_present != keyboard || impl_->draw_present != draw) {
|
||||
impl_->plan_present = plan; impl_->keyboard_present = keyboard; impl_->draw_present = draw;
|
||||
impl_->reset(); impl_->dirty = true;
|
||||
}
|
||||
}
|
||||
@@ -861,6 +925,14 @@ void PanelSurface::set_auto_insert(bool enabled) {
|
||||
impl_->dirty = true;
|
||||
}
|
||||
}
|
||||
void PanelSurface::set_gradient_enabled(bool enabled) {
|
||||
if (impl_->gradient.enabled != enabled) {
|
||||
impl_->gradient.enabled = enabled;
|
||||
impl_->animation_start.reset(); // a new opt-in cycle starts at the next render
|
||||
impl_->reset();
|
||||
impl_->dirty = true;
|
||||
}
|
||||
}
|
||||
void PanelSurface::set_close_mic_when_idle(bool enabled) {
|
||||
if (impl_->close_mic_when_idle != enabled) {
|
||||
impl_->close_mic_when_idle = enabled;
|
||||
|
||||
@@ -12,13 +12,14 @@
|
||||
|
||||
namespace frameyap {
|
||||
struct SurfaceEvent {
|
||||
enum class Companion { Plan, Keyboard };
|
||||
enum class Companion { Plan, Keyboard, KeyboardRecenter, Draw };
|
||||
std::optional<Companion> launch_companion;
|
||||
std::optional<UiAction> action;
|
||||
std::optional<Mount> mount;
|
||||
std::optional<bool> lasers_anytime;
|
||||
std::optional<bool> advanced_debug;
|
||||
std::optional<bool> auto_insert;
|
||||
std::optional<bool> gradient_enabled;
|
||||
std::optional<bool> close_mic_when_idle;
|
||||
std::optional<bool> lock_layout;
|
||||
std::optional<bool> wrist_world_fallback;
|
||||
@@ -57,11 +58,15 @@ public:
|
||||
const std::vector<unsigned char>& pixels() const;
|
||||
// Handles capture one cursor and never authorize a UI action on release.
|
||||
static constexpr auto quit_hold = std::chrono::milliseconds(900);
|
||||
static constexpr auto keyboard_hold = std::chrono::milliseconds(800);
|
||||
std::optional<PanelDragKind> pointer_down(unsigned cursor, float x, float y, Clock::time_point now = Clock::now());
|
||||
void pointer_move(unsigned cursor, float x, float y);
|
||||
// Called while visible and tracked. Fires once at the threshold without waiting for release.
|
||||
std::optional<SurfaceEvent::Companion> poll_keyboard_hold(Clock::time_point now = Clock::now());
|
||||
SurfaceEvent pointer_up(unsigned cursor, float x, float y, Clock::time_point now = Clock::now());
|
||||
// OpenVR laser wheel input at the last hovered panel coordinate.
|
||||
bool scroll(float x, float y, float vertical_delta);
|
||||
void set_companions(bool plan, bool keyboard);
|
||||
void set_companions(bool plan, bool keyboard, bool draw);
|
||||
void set_update_status(UpdateStatus status, std::string version = {});
|
||||
bool dragging(unsigned cursor) const;
|
||||
// OpenVR intersection masks use top-left coordinates, unlike mouse events.
|
||||
@@ -75,6 +80,7 @@ public:
|
||||
void set_lasers_anytime(bool enabled);
|
||||
void set_advanced_debug(bool enabled);
|
||||
void set_auto_insert(bool enabled);
|
||||
void set_gradient_enabled(bool enabled);
|
||||
void set_close_mic_when_idle(bool enabled);
|
||||
void set_clock_24h(bool enabled);
|
||||
void set_date_format(DateFormat format);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
#pragma once
|
||||
#include <utility>
|
||||
|
||||
namespace frameyap {
|
||||
// Follow docs/rendering-performance.md: retain initialized images throughout
|
||||
// manipulation, leaving content invalidation pending until the next refresh.
|
||||
template<class Render, class Upload>
|
||||
bool refresh_texture(bool manipulating, bool initialized, Render&& render, Upload&& upload) {
|
||||
if (manipulating && initialized) return false;
|
||||
if (!std::forward<Render>(render)()) return false;
|
||||
std::forward<Upload>(upload)();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -19,15 +19,20 @@ int main() {
|
||||
::setenv("PATH", path_dir.c_str(), 1);
|
||||
assert(!frameyap::find_companion("tnkplan"));
|
||||
assert(!frameyap::find_companion("tnkboard"));
|
||||
assert(!frameyap::find_companion("tnkdraw"));
|
||||
assert(!frameyap::find_companion("other"));
|
||||
const auto plan = local / "tnkplan", keyboard = path_dir / "tnkboard";
|
||||
const auto plan = local / "tnkplan", keyboard = path_dir / "tnkboard", draw = local / "tnkdraw";
|
||||
std::ofstream(plan) << "#!/bin/sh\nexit 0\n";
|
||||
std::ofstream(keyboard) << "#!/bin/sh\nexit 0\n";
|
||||
std::ofstream(draw) << "#!/bin/sh\nexit 0\n";
|
||||
assert(!frameyap::find_companion("tnkplan")); // a file is not yet executable
|
||||
assert(!frameyap::find_companion("tnkdraw"));
|
||||
::chmod(plan.c_str(), 0700);
|
||||
::chmod(keyboard.c_str(), 0700);
|
||||
::chmod(draw.c_str(), 0700);
|
||||
assert(frameyap::find_companion("tnkplan") == plan);
|
||||
assert(frameyap::find_companion("tnkboard") == keyboard);
|
||||
assert(frameyap::find_companion("tnkdraw") == draw);
|
||||
::setenv("PATH", (":" + path_dir.string()).c_str(), 1);
|
||||
assert(frameyap::find_companion("tnkboard") == keyboard);
|
||||
const auto marker = root / "launched";
|
||||
@@ -36,6 +41,23 @@ int main() {
|
||||
for (int i = 0; i < 100 && !fs::exists(marker); ++i)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||||
assert(fs::exists(marker));
|
||||
const auto argv_marker = root / "argv";
|
||||
std::ofstream(keyboard) << "#!/bin/sh\nprintf '%s\\n' \"$#\" \"$1\" > '" << argv_marker.string() << "'\n";
|
||||
const auto expect_argv = [&](frameyap::CompanionCommand command, const std::string& expected) {
|
||||
fs::remove(argv_marker);
|
||||
assert(frameyap::launch_companion(keyboard, command));
|
||||
std::string count, argument;
|
||||
for (int i = 0; i < 100; ++i) {
|
||||
std::ifstream in(argv_marker);
|
||||
if (std::getline(in, count) && std::getline(in, argument)) break;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||||
}
|
||||
assert(count == (expected.empty() ? "0" : "1"));
|
||||
assert(argument == expected);
|
||||
};
|
||||
expect_argv(frameyap::CompanionCommand::Default, "");
|
||||
expect_argv(frameyap::CompanionCommand::Show, "--show");
|
||||
expect_argv(frameyap::CompanionCommand::Recenter, "--recenter");
|
||||
fs::remove(plan);
|
||||
assert(!frameyap::launch_companion(plan));
|
||||
fs::remove_all(root);
|
||||
|
||||
+26
-3
@@ -50,7 +50,7 @@ int main(int argc, char** argv) {
|
||||
assert(!load_config(path).clock_24h);
|
||||
assert(load_config(path).date_format == DateFormat::MonthDayYear);
|
||||
assert(load_config(path).wrist.width == .30f);
|
||||
assert(load_config(path).gradient.enabled);
|
||||
assert(!load_config(path).gradient.enabled);
|
||||
assert(load_config(path).gradient.period_seconds == 30.f);
|
||||
assert(load_config(path).gradient.strength == .12f);
|
||||
auto example = load_config(std::filesystem::path(argv[1]) / "config.example.json");
|
||||
@@ -67,7 +67,7 @@ int main(int argc, char** argv) {
|
||||
assert(example.wrist_world_fallback);
|
||||
assert(!example.clock_24h && example.date_format == DateFormat::MonthDayYear);
|
||||
assert(example.wrist.y == .18f && example.wrist.z == .089f);
|
||||
assert(example.gradient.enabled && example.gradient.period_seconds == 30.f && example.gradient.strength == .12f);
|
||||
assert(!example.gradient.enabled && example.gradient.period_seconds == 30.f && example.gradient.strength == .12f);
|
||||
std::filesystem::create_directories(path.parent_path());
|
||||
assert(save_backend(path, "fixture_cpu"));
|
||||
assert(load_config(path).backend == "fixture_cpu");
|
||||
@@ -81,6 +81,11 @@ int main(int argc, char** argv) {
|
||||
assert(load_config(path).auto_insert);
|
||||
assert(save_auto_insert(path, false));
|
||||
assert(!load_config(path).auto_insert);
|
||||
assert(save_gradient_enabled(path, true)); // missing gradient creates only the nested setting
|
||||
assert(load_config(path).gradient.enabled);
|
||||
assert(get(path).find("\"gradient\":{\"enabled\":true}") != std::string::npos);
|
||||
assert(save_gradient_enabled(path, false));
|
||||
assert(!load_config(path).gradient.enabled);
|
||||
assert(save_close_mic_when_idle(path, true));
|
||||
assert(load_config(path).close_mic_when_idle);
|
||||
assert(save_close_mic_when_idle(path, false));
|
||||
@@ -229,12 +234,24 @@ int main(int argc, char** argv) {
|
||||
assert(save_auto_insert(path, true)); // unrelated settings retain the effect
|
||||
assert(load_config(path).gradient.period_seconds == 45.5f);
|
||||
put(path, R"({"gradient":{"strength":0}})");
|
||||
assert(load_config(path).gradient.enabled && load_config(path).gradient.period_seconds == 30.f);
|
||||
assert(!load_config(path).gradient.enabled && load_config(path).gradient.period_seconds == 30.f);
|
||||
assert(load_config(path).gradient.strength == 0.f);
|
||||
put(path, R"({"gradient":{"period_seconds":5,"strength":0.3}})");
|
||||
assert(load_config(path).gradient.period_seconds == 5.f && load_config(path).gradient.strength == .3f);
|
||||
put(path, R"({"gradient":{"period_seconds":300}})");
|
||||
assert(load_config(path).gradient.period_seconds == 300.f);
|
||||
put(path, R"({"font":"escaped \u0061","gradient":{"period_seconds":45.5,"strength":0.2},"auto_insert":true})");
|
||||
assert(save_gradient_enabled(path, true));
|
||||
assert(load_config(path).gradient.enabled);
|
||||
assert(load_config(path).gradient.period_seconds == 45.5f && load_config(path).gradient.strength == .2f);
|
||||
assert(get(path).find(R"("font":"escaped \u0061")") != std::string::npos);
|
||||
assert(get(path).find(R"("strength":0.2,"enabled":true},"auto_insert":true)") != std::string::npos);
|
||||
assert(save_gradient_enabled(path, false) && !load_config(path).gradient.enabled);
|
||||
assert(save_gradient_enabled(path, true) && load_config(path).gradient.enabled);
|
||||
put(path, R"({"gradient":{"enabled":false,"strength":0.21},"theme":{"ink":"#123ABC"}})");
|
||||
assert(save_gradient_enabled(path, true));
|
||||
assert(get(path) == R"({"gradient":{"enabled":true,"strength":0.21},"theme":{"ink":"#123ABC"}})");
|
||||
assert(load_config(path).gradient.enabled);
|
||||
for (auto invalid : {R"({"gradient":true})", R"({"gradient":{"enabled":1}})",
|
||||
R"({"gradient":{"enabled":"false"}})", R"({"gradient":{"period_seconds":0}})",
|
||||
R"({"gradient":{"period_seconds":301}})", R"({"gradient":{"period_seconds":1e99}})",
|
||||
@@ -242,6 +259,7 @@ int main(int argc, char** argv) {
|
||||
R"({"gradient":{"strength":0.31}})", R"({"gradient":{"strength":1e99}})",
|
||||
R"({"gradient":{"strength":"0.1"}})", R"({"gradient":{"unknown":1}})"}) {
|
||||
put(path, invalid); fails([&] { load_config(path); });
|
||||
assert(!save_gradient_enabled(path, true) && get(path) == invalid);
|
||||
}
|
||||
put(path, R"({"theme":{"background":"#123ABC","accent":"#abcdef","frame_end":"#010203"},"font":"/nonexistent/face.ttf","buttons":{"ptt":"/user/hand/left/input/y","cancel":"/user/hand/right/input/b","right_grip":""}})");
|
||||
auto config = load_config(path);
|
||||
@@ -259,6 +277,8 @@ int main(int argc, char** argv) {
|
||||
assert(generated.find("/user/hand/right/input/a") != std::string::npos);
|
||||
assert(generated.find("/user/hand/right/input/y") != std::string::npos);
|
||||
assert(generated.find("/actions/frameyap/in/quick_chat") != std::string::npos);
|
||||
assert(generated.find("/actions/grab/in/left_depth") != std::string::npos);
|
||||
assert(generated.find("/actions/grab/in/right_depth") != std::string::npos);
|
||||
config.buttons["cancel"] = ""; config.buttons["insert"] = ""; config.buttons["enter"] = "";
|
||||
auto disabled = action_manifest(argv[1], config);
|
||||
generated = get(disabled.parent_path() / "bindings_frame_controller.json");
|
||||
@@ -266,6 +286,9 @@ int main(int argc, char** argv) {
|
||||
assert(generated.find("/actions/frameyap/in/insert") == std::string::npos);
|
||||
assert(generated.find("/actions/frameyap/in/enter") == std::string::npos);
|
||||
assert(action_manifest(argv[1], {}) == std::filesystem::absolute(std::filesystem::path(argv[1]) / "actions.json"));
|
||||
put(path, R"({"buttons":{"quick_chat":"/user/hand/right/input/thumbstick"}})");
|
||||
generated = get(action_manifest(argv[1], load_config(path)).parent_path() / "bindings_frame_controller.json");
|
||||
assert(generated.find("\"mode\":\"joystick\",\"inputs\":{\"click\":{\"output\":\"/actions/frameyap/in/quick_chat\"}") != std::string::npos);
|
||||
put(path, R"({"buttons":{"enter":"/user/hand/right/input/y"}})");
|
||||
generated = get(action_manifest(argv[1], load_config(path)).parent_path() / "bindings_frame_controller.json");
|
||||
assert(generated.find("/actions/frameyap/in/quick_chat") != std::string::npos);
|
||||
|
||||
@@ -98,6 +98,54 @@ int main() {
|
||||
assert(moved); near_pose(moved->pose, translated(rotated_pose, -.1f, .2f, -.3f));
|
||||
drag.reset(); assert(!drag.update(second_down));
|
||||
|
||||
// Stick depth is independent of controller translation, preserves pose
|
||||
// rotation, and follows the captured panel normal as the controller turns.
|
||||
assert(drag.begin(PanelDragKind::Grab, panel, 1, 1, .5f, .5f, down));
|
||||
drag.step_depth(.1f, 1.); near(float(drag.depth()), 0);
|
||||
drag.step_depth(1.f, .05); near(float(drag.depth()), .03f);
|
||||
auto depth_pose = drag.update(down);
|
||||
assert(depth_pose); near_pose(depth_pose->pose, translated(panel, 0, 0, -.03f));
|
||||
depth_pose = drag.update(translated(down, .1f, .2f, 0));
|
||||
assert(depth_pose); near_pose(depth_pose->pose, translated(panel, .1f, .2f, -.03f));
|
||||
drag.step_depth(0.f, .05); near(float(drag.depth()), .03f);
|
||||
drag.step_depth(-1.f, .05); near(float(drag.depth()), 0);
|
||||
drag.step_depth(1.f, 10.); near(float(drag.depth()), .03f); // bounded time step
|
||||
drag.step_depth(std::numeric_limits<float>::quiet_NaN(), .05);
|
||||
drag.step_depth(1.f, std::numeric_limits<double>::infinity());
|
||||
near(float(drag.depth()), .03f);
|
||||
const auto turned_controller = compose_pose(down, yaw());
|
||||
depth_pose = drag.update(turned_controller);
|
||||
assert(depth_pose);
|
||||
near_pose(depth_pose->pose, translated(compose_pose(turned_controller, relative_pose(down, panel)), -.03f, 0, 0));
|
||||
for (int i = 0; i < 100; ++i) drag.step_depth(1.f, .05);
|
||||
near(float(drag.depth()), 1.5f);
|
||||
for (int i = 0; i < 100; ++i) drag.step_depth(-1.f, .05);
|
||||
near(float(drag.depth()), -1.5f);
|
||||
drag.reset(); near(float(drag.depth()), 0);
|
||||
assert(drag.begin(PanelDragKind::Scale, identity, 1, 1, .5f, .5f, down));
|
||||
drag.step_depth(1.f, .05); near(float(drag.depth()), 0);
|
||||
drag.reset();
|
||||
|
||||
// A controller pointing obliquely at a rotated panel must move depth
|
||||
// perpendicular to the panel rather than along its own pointing axis.
|
||||
const auto angled_panel = translated(yaw(), .25f, .4f, -.3f);
|
||||
assert(drag.begin(PanelDragKind::Grab, angled_panel, 1, 1, .5f, .5f, down));
|
||||
drag.step_depth(1.f, .05);
|
||||
depth_pose = drag.update(down);
|
||||
assert(depth_pose); near_pose(depth_pose->pose, translated(angled_panel, -.03f, 0, 0));
|
||||
const auto angled_moved = translated(compose_pose(down, pitch()), .1f, .2f, -.1f);
|
||||
depth_pose = drag.update(angled_moved);
|
||||
auto expected_relative = relative_pose(down, angled_panel);
|
||||
expected_relative[0][3] -= .03f;
|
||||
assert(depth_pose); near_pose(depth_pose->pose, compose_pose(angled_moved, expected_relative));
|
||||
// The same elapsed movement has the same result at 50 and 100 Hz.
|
||||
for (const double dt : {.01, .02}) {
|
||||
assert(drag.begin(PanelDragKind::Grab, angled_panel, 1, 1, .5f, .5f, down));
|
||||
for (int i = 0; i < int(1. / dt); ++i) drag.step_depth(1.f, dt);
|
||||
near(float(drag.depth()), .6f);
|
||||
}
|
||||
drag.reset();
|
||||
|
||||
grab_rotation_case(pitch(), Matrix34{{{{1.f, 0.f, 0.f, .25f}},
|
||||
{{0.f, 0.f, -1.f, 1.3f}},
|
||||
{{0.f, 1.f, 0.f, 1.4f}}}});
|
||||
|
||||
+133
-18
@@ -1,10 +1,12 @@
|
||||
#include "panel_surface.hpp"
|
||||
#include <cassert>
|
||||
#include <ctime>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <limits>
|
||||
#include <string>
|
||||
#include <unistd.h>
|
||||
|
||||
using namespace frameyap;
|
||||
namespace {
|
||||
@@ -14,7 +16,7 @@ SurfaceEvent click(PanelSurface& surface, float x, float y, unsigned cursor = 0)
|
||||
}
|
||||
void no_action(const SurfaceEvent& event) {
|
||||
assert(!event.action && !event.mount && !event.lasers_anytime && !event.advanced_debug && !event.auto_insert &&
|
||||
!event.close_mic_when_idle && !event.lock_layout && !event.wrist_world_fallback && !event.clock_24h && !event.date_format && !event.recenter && !event.open_bindings && !event.model_action && !event.launch_companion && !event.check_updates && !event.install_update);
|
||||
!event.close_mic_when_idle && !event.gradient_enabled && !event.lock_layout && !event.wrist_world_fallback && !event.clock_24h && !event.date_format && !event.recenter && !event.open_bindings && !event.model_action && !event.launch_companion && !event.check_updates && !event.install_update);
|
||||
}
|
||||
void snapshot(PanelSurface& surface, const std::string& path) {
|
||||
std::ofstream out(path, std::ios::binary);
|
||||
@@ -27,13 +29,14 @@ void snapshot(PanelSurface& surface, const std::string& path) {
|
||||
}
|
||||
int main(int argc, char** argv) {
|
||||
assert(argc >= 2);
|
||||
// Interaction tests use the opt-out path; animation has a deterministic
|
||||
// injected-clock suite below rather than wall-time-sensitive assertions.
|
||||
PanelSurface surface(argv[1], Mount::World, {}, {.enabled = false});
|
||||
// Ordinary UI uses the default static path; animation has an explicitly
|
||||
// opted-in injected-clock suite below.
|
||||
PanelSurface surface(argv[1], Mount::World);
|
||||
Panel p{"Ready to record", "", "Focus your destination before Type. Enter is always separate.", false, false};
|
||||
assert(surface.render(p));
|
||||
assert(surface.pixels().size() == size_t(PanelSurface::width * PanelSurface::height * 4));
|
||||
assert(!surface.render(p));
|
||||
assert(!surface.render(p, PanelSurface::Clock::now() + std::chrono::seconds(60))); // default: no timed redraw
|
||||
surface.set_clock_time(std::time_t{1704211440}); // local clock changes only when rendered minute changes
|
||||
assert(surface.render(p));
|
||||
assert(!surface.render(p));
|
||||
@@ -138,7 +141,7 @@ int main(int argc, char** argv) {
|
||||
// civil clock, and full cycles return byte-identical pixels (including alpha).
|
||||
using namespace std::chrono_literals;
|
||||
const auto epoch = PanelSurface::Clock::time_point{};
|
||||
PanelSurface animated(argv[1], Mount::World, gradient_theme);
|
||||
PanelSurface animated(argv[1], Mount::World, gradient_theme, {.enabled = true});
|
||||
animated.set_clock_time(std::time_t{1704211440});
|
||||
assert(animated.render(p, epoch));
|
||||
const auto first = animated.pixels();
|
||||
@@ -185,14 +188,14 @@ int main(int argc, char** argv) {
|
||||
assert(animated.render(p, epoch + 41s));
|
||||
assert(animated.pointer_up(0, 100, 610, epoch + 41s).action == UiAction::BeginRecord);
|
||||
assert(!gradient_surface.render(p, epoch + 300s)); // disabled has no timed redraws
|
||||
PanelSurface faster(argv[1], Mount::World, gradient_theme, {.period_seconds = 10.f, .strength = 0.f});
|
||||
PanelSurface faster(argv[1], Mount::World, gradient_theme, {.enabled = true, .period_seconds = 10.f, .strength = 0.f});
|
||||
assert(faster.render(p, epoch));
|
||||
const auto fast_first = faster.pixels();
|
||||
assert(rgb(fast_first, 500, 190) == gradient_theme.background);
|
||||
assert(faster.render(p, epoch + 2500ms) && faster.pixels() != fast_first);
|
||||
assert(faster.render(p, epoch + 10s) && faster.pixels() == fast_first);
|
||||
if (argc >= 3) {
|
||||
PanelSurface preview(argv[1], Mount::World);
|
||||
PanelSurface preview(argv[1], Mount::World, {}, {.enabled = true});
|
||||
preview.render(p, epoch);
|
||||
snapshot(preview, std::string(argv[2]) + "-gradient.ppm");
|
||||
}
|
||||
@@ -338,11 +341,43 @@ int main(int argc, char** argv) {
|
||||
assert(surface.scroll(200, 400, -2.f));
|
||||
assert(surface.render(p));
|
||||
no_action(click(surface, 180, 231)); // scrolled settings cannot be clicked above the viewport
|
||||
auto fallback = click(surface, 200, 485);
|
||||
using namespace std::chrono_literals;
|
||||
const auto tick = PanelSurface::Clock::time_point{};
|
||||
const auto static_background = surface.pixels()[pixel];
|
||||
const auto preference = std::filesystem::temp_directory_path() /
|
||||
("frameyap-gradient-ui-" + std::to_string(::getpid()) + ".json");
|
||||
auto animated_background = click(surface, 200, 470); // y=574 minus 120 scroll
|
||||
assert(animated_background.gradient_enabled == true && !animated_background.action);
|
||||
assert(!surface.render(p)); // caller must apply the change
|
||||
assert(save_gradient_enabled(preference, *animated_background.gradient_enabled));
|
||||
assert(load_config(preference).gradient.enabled);
|
||||
PanelSurface restored(argv[1], Mount::World, {}, load_config(preference).gradient);
|
||||
restored.set_clock_time(std::time_t{1704211500});
|
||||
assert(restored.render(p, tick) && restored.pixels()[pixel] != static_background);
|
||||
surface.set_gradient_enabled(true);
|
||||
assert(surface.render(p, tick));
|
||||
assert(surface.pixels()[pixel] != static_background);
|
||||
if (argc >= 3) snapshot(surface, std::string(argv[2]) + "-settings-animated.ppm");
|
||||
assert(!surface.render(p, tick + 99ms));
|
||||
assert(surface.render(p, tick + 100ms));
|
||||
surface.pointer_down(1, 200, 470);
|
||||
surface.set_gradient_enabled(false);
|
||||
assert(surface.render(p, tick + 200ms));
|
||||
no_action(surface.pointer_up(1, 200, 470)); // old press cannot re-enable it
|
||||
assert(surface.pixels()[pixel] == static_background);
|
||||
assert(!surface.render(p, tick + 300s)); // off: no timed redraws
|
||||
animated_background = click(surface, 200, 470);
|
||||
assert(animated_background.gradient_enabled == true);
|
||||
assert(save_gradient_enabled(preference, false));
|
||||
assert(!load_config(preference).gradient.enabled);
|
||||
std::filesystem::remove(preference);
|
||||
assert(surface.scroll(200, 400, -.5f)); // reveal wrist fallback below the new switch
|
||||
assert(surface.render(p));
|
||||
auto fallback = click(surface, 200, 500);
|
||||
assert(fallback.wrist_world_fallback == false && !fallback.action);
|
||||
assert(!surface.render(p));
|
||||
surface.set_wrist_world_fallback(false); assert(surface.render(p));
|
||||
fallback = click(surface, 200, 485);
|
||||
fallback = click(surface, 200, 500);
|
||||
assert(fallback.wrist_world_fallback == true);
|
||||
surface.set_wrist_world_fallback(true); assert(surface.render(p));
|
||||
assert(surface.scroll(200, 400, -2.f)); // reveal bottom update row and status
|
||||
@@ -362,6 +397,7 @@ int main(int argc, char** argv) {
|
||||
no_action(click(surface, 700, 486));
|
||||
assert(click(surface, 280, 610).action == UiAction::Cancel); // footer stays fixed
|
||||
assert(surface.scroll(200, 400, 4.f));
|
||||
assert(surface.scroll(200, 400, 1.f));
|
||||
assert(surface.render(p));
|
||||
assert(!surface.scroll(200, 400, 1.f)); // top clamp
|
||||
assert(click(surface, 180, 260).mount == Mount::World);
|
||||
@@ -382,6 +418,7 @@ int main(int argc, char** argv) {
|
||||
no_action(click(surface, 200, 492)); // clock/date controls only exist on Settings
|
||||
no_action(click(surface, 680, 492));
|
||||
no_action(click(surface, 200, 532)); // mic preference only exists on Settings
|
||||
no_action(click(surface, 200, 470)); // animation toggle only exists on Settings
|
||||
assert(!surface.scroll(200, 400, -1.f)); // short Review needs no scrolling
|
||||
|
||||
// Bindings opens SteamVR directly, from either tab, without replacing review.
|
||||
@@ -405,22 +442,100 @@ int main(int argc, char** argv) {
|
||||
assert(click(surface, 680, 260).mount == Mount::Head); // still in Settings
|
||||
no_action(click(surface, 100, 160));
|
||||
|
||||
// Installed companions alone get launch controls in the old paging row.
|
||||
// Only installed companions occupy the review row, in a stable order.
|
||||
no_action(click(surface, 90, 518)); no_action(click(surface, 900, 518));
|
||||
surface.set_companions(true, false); assert(surface.render(p));
|
||||
surface.set_companions(true, false, false); assert(surface.render(p));
|
||||
assert(click(surface, 90, 518).launch_companion == SurfaceEvent::Companion::Plan);
|
||||
no_action(click(surface, 900, 518));
|
||||
surface.set_companions(false, true); assert(surface.render(p));
|
||||
no_action(click(surface, 90, 518));
|
||||
assert(click(surface, 900, 518).launch_companion == SurfaceEvent::Companion::Keyboard);
|
||||
surface.set_companions(true, true); assert(surface.render(p));
|
||||
surface.set_companions(false, true, false); assert(surface.render(p));
|
||||
assert(click(surface, 90, 518).launch_companion == SurfaceEvent::Companion::Keyboard);
|
||||
surface.set_companions(false, false, true); assert(surface.render(p));
|
||||
assert(click(surface, 90, 518).launch_companion == SurfaceEvent::Companion::Draw);
|
||||
surface.set_companions(true, true, false); assert(surface.render(p));
|
||||
assert(click(surface, 90, 518).launch_companion == SurfaceEvent::Companion::Plan);
|
||||
assert(click(surface, 900, 518).launch_companion == SurfaceEvent::Companion::Keyboard);
|
||||
surface.set_companions(true, true, true); assert(surface.render(p));
|
||||
assert(click(surface, 90, 518).launch_companion == SurfaceEvent::Companion::Plan);
|
||||
assert(click(surface, 480, 518).launch_companion == SurfaceEvent::Companion::Keyboard);
|
||||
assert(click(surface, 900, 518).launch_companion == SurfaceEvent::Companion::Draw);
|
||||
{
|
||||
using namespace std::chrono_literals;
|
||||
const auto start = PanelSurface::Clock::now();
|
||||
// A short release explicitly shows; the boundary and either laser cursor
|
||||
// launch exactly one recenter, never a subsequent show on release.
|
||||
for (unsigned cursor : {0u, 1u}) {
|
||||
surface.pointer_down(cursor, 480, 518, start);
|
||||
assert(surface.render(p, start)); // visible hold cue
|
||||
assert(!surface.render(p, start + 99ms)); // no idle/full-speed redraw
|
||||
assert(surface.render(p, start + 100ms));
|
||||
assert(!surface.poll_keyboard_hold(start + 799ms));
|
||||
assert(surface.pointer_up(cursor, 480, 518, start + 799ms).launch_companion ==
|
||||
SurfaceEvent::Companion::Keyboard);
|
||||
surface.pointer_down(cursor, 480, 518, start);
|
||||
assert(!surface.poll_keyboard_hold(start + 799ms));
|
||||
assert(surface.poll_keyboard_hold(start + 800ms) == SurfaceEvent::Companion::KeyboardRecenter);
|
||||
assert(!surface.poll_keyboard_hold(start + 800ms));
|
||||
assert(!surface.poll_keyboard_hold(start + 5s));
|
||||
no_action(surface.pointer_up(cursor, 480, 518, start + 5s));
|
||||
no_action(surface.pointer_up(cursor, 480, 518, start + 5s));
|
||||
surface.pointer_down(cursor, 480, 518, start);
|
||||
assert(surface.pointer_up(cursor, 480, 518, start + 800ms).launch_companion ==
|
||||
SurfaceEvent::Companion::KeyboardRecenter); // release on threshold before polling
|
||||
}
|
||||
surface.pointer_down(0, 480, 518, start);
|
||||
surface.pointer_down(0, 480, 518, start + 500ms); // duplicate down must not restart the clock
|
||||
assert(surface.poll_keyboard_hold(start + 800ms) == SurfaceEvent::Companion::KeyboardRecenter);
|
||||
no_action(surface.pointer_up(0, 480, 518, start + 800ms));
|
||||
surface.pointer_down(0, 480, 518, start);
|
||||
surface.pointer_down(1, 480, 518, start);
|
||||
surface.pointer_move(0, 300, 518); // one cursor leaving cannot cancel the other
|
||||
assert(surface.poll_keyboard_hold(start + 800ms) == SurfaceEvent::Companion::KeyboardRecenter);
|
||||
assert(!surface.poll_keyboard_hold(start + 800ms));
|
||||
no_action(surface.pointer_up(0, 480, 518, start + 800ms));
|
||||
no_action(surface.pointer_up(1, 480, 518, start + 800ms));
|
||||
surface.pointer_down(0, 480, 518, start);
|
||||
surface.pointer_move(0, 300, 518); // leaving cancels even if pointer comes back
|
||||
surface.pointer_move(0, 480, 518);
|
||||
assert(!surface.poll_keyboard_hold(start + 5s));
|
||||
no_action(surface.pointer_up(0, 480, 518, start + 5s));
|
||||
surface.pointer_down(1, 480, 518, start);
|
||||
no_action(surface.pointer_up(1, 900, 518, start + 5s)); // release outside
|
||||
assert(!surface.poll_keyboard_hold(start + 5s));
|
||||
surface.pointer_down(0, 480, 518, start);
|
||||
surface.reset_pointers(); // tracking/focus loss, hiding, relocation
|
||||
assert(!surface.poll_keyboard_hold(start + 5s));
|
||||
no_action(surface.pointer_up(0, 480, 518, start + 5s));
|
||||
surface.pointer_down(1, 480, 518, start);
|
||||
no_action(click(surface, 290, 160)); // changing tab revokes other cursor
|
||||
assert(!surface.poll_keyboard_hold(start + 5s));
|
||||
no_action(surface.pointer_up(1, 480, 518, start + 5s));
|
||||
no_action(click(surface, 90, 160)); // back to Review
|
||||
surface.pointer_down(0, 480, 518, start);
|
||||
surface.set_companions(true, false, true);
|
||||
assert(!surface.poll_keyboard_hold(start + 5s));
|
||||
no_action(surface.pointer_up(0, 480, 518, start + 5s));
|
||||
surface.set_companions(true, true, true);
|
||||
no_action(surface.pointer_up(1, 480, 518, start + 5s)); // no matching left down (e.g. right mouse)
|
||||
assert(!surface.poll_keyboard_hold(start + 5s));
|
||||
surface.pointer_down(0, 90, 518, start); // other companions never turn into recenter
|
||||
assert(!surface.poll_keyboard_hold(start + 5s));
|
||||
assert(surface.pointer_up(0, 90, 518, start + 5s).launch_companion == SurfaceEvent::Companion::Plan);
|
||||
surface.pointer_down(1, 900, 518, start);
|
||||
assert(!surface.poll_keyboard_hold(start + 5s));
|
||||
assert(surface.pointer_up(1, 900, 518, start + 5s).launch_companion == SurfaceEvent::Companion::Draw);
|
||||
assert(surface.render(p, start + 5s));
|
||||
assert(!surface.render(p, start + 5s)); // no perpetual hold repaint
|
||||
if (argc >= 3) {
|
||||
surface.pointer_down(0, 480, 518, start + 6s);
|
||||
assert(surface.render(p, start + 6s + 400ms));
|
||||
snapshot(surface, std::string(argv[2]) + "-keyboard-hold.ppm");
|
||||
surface.reset_pointers();
|
||||
}
|
||||
}
|
||||
p.quick_open = true; assert(surface.render(p));
|
||||
no_action(click(surface, 90, 518)); no_action(click(surface, 900, 518));
|
||||
no_action(click(surface, 90, 518)); no_action(click(surface, 480, 518)); no_action(click(surface, 900, 518));
|
||||
assert(!surface.scroll(200, 400, -1.f));
|
||||
p.quick_open = false; assert(surface.render(p));
|
||||
surface.set_companions(false, false); assert(surface.render(p));
|
||||
surface.set_companions(false, false, false); assert(surface.render(p));
|
||||
// Long UTF-8, newlines and malformed bytes scroll within the preview.
|
||||
p.transcript.clear();
|
||||
for (int i = 0; i < 60; ++i) p.transcript += "Line " + std::to_string(i) + ": café / 日本語 / naïve / Ω\n";
|
||||
|
||||
@@ -19,9 +19,9 @@ class ActionBindingTests(unittest.TestCase):
|
||||
path = f"/user/hand/right/input/{button}"
|
||||
self.assertIn({"name": name, "type": "boolean"}, manifest["actions"])
|
||||
self.assertEqual(config["buttons"][action], path)
|
||||
self.assertEqual([s["path"] for s in sources if s["inputs"]["click"]["output"] == name], [path])
|
||||
self.assertEqual([s["path"] for s in sources if s["inputs"].get("click", {}).get("output") == name], [path])
|
||||
self.assertEqual(config["buttons"]["enter"], "")
|
||||
self.assertFalse(any(s["inputs"]["click"]["output"] == "/actions/frameyap/in/enter" for s in sources))
|
||||
self.assertFalse(any(s["inputs"].get("click", {}).get("output") == "/actions/frameyap/in/enter" for s in sources))
|
||||
self.assertEqual(config["quick_inputs"], ["/new", "/questions", "/help"])
|
||||
|
||||
def test_right_x_is_hold_to_talk(self):
|
||||
@@ -49,6 +49,18 @@ class ActionBindingTests(unittest.TestCase):
|
||||
), 1)
|
||||
self.assertFalse(any(source["path"].endswith("/input/trigger") for source in sources))
|
||||
|
||||
def test_grab_depth_axes_are_hand_specific(self):
|
||||
manifest = json.loads((ROOT / "assets/actions.json").read_text())
|
||||
for binding_file in ("bindings_frame_controller.json", "bindings_knuckles.json"):
|
||||
sources = json.loads((ROOT / "assets" / binding_file).read_text())["bindings"]["/actions/grab"]["sources"]
|
||||
for hand in ("left", "right"):
|
||||
name = f"/actions/grab/in/{hand}_depth"
|
||||
path = f"/user/hand/{hand}/input/thumbstick"
|
||||
self.assertIn({"name": name, "type": "vector2"}, manifest["actions"])
|
||||
self.assertEqual([s for s in sources if s["path"] == path], [{
|
||||
"path": path, "mode": "joystick", "inputs": {"position": {"output": name}},
|
||||
}])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Offline contract for isolated, stick-only grab bindings."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class GrabBindingTests(unittest.TestCase):
|
||||
def test_grab_set_claims_only_each_hands_stick(self):
|
||||
manifest = json.loads((ROOT / 'assets/actions.json').read_text())
|
||||
self.assertIn({'name': '/actions/grab', 'usage': 'single'}, manifest['action_sets'])
|
||||
actions = {a['name']: a['type'] for a in manifest['actions']}
|
||||
for filename in ('bindings_frame_controller.json', 'bindings_knuckles.json'):
|
||||
bindings = json.loads((ROOT / 'assets' / filename).read_text())['bindings']
|
||||
grab = bindings['/actions/grab']
|
||||
self.assertEqual(set(grab), {'sources'})
|
||||
self.assertEqual({s['path'] for s in grab['sources']}, {
|
||||
'/user/hand/left/input/thumbstick', '/user/hand/right/input/thumbstick'})
|
||||
self.assertEqual(len(grab['sources']), 2)
|
||||
for source in grab['sources']:
|
||||
self.assertEqual(source['mode'], 'joystick')
|
||||
self.assertEqual(set(source['inputs']), {'position'})
|
||||
output = source['inputs']['position']['output']
|
||||
self.assertTrue(output.startswith('/actions/grab/in/'))
|
||||
self.assertEqual(actions[output], 'vector2')
|
||||
for name, binding in bindings.items():
|
||||
if name == '/actions/grab':
|
||||
continue
|
||||
for source in binding.get('sources', []):
|
||||
self.assertNotIn('position', source['inputs'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -87,7 +87,7 @@ class InstallTests(unittest.TestCase):
|
||||
self.assertIs(json.loads(config.read_text())["close_mic_when_idle"], False)
|
||||
self.assertEqual(json.loads(config.read_text())["backend"], "redux")
|
||||
self.assertEqual(json.loads(config.read_text())["gradient"],
|
||||
{"enabled": True, "period_seconds": 30, "strength": 0.12})
|
||||
{"enabled": False, "period_seconds": 30, "strength": 0.12})
|
||||
self.assertIs(json.loads(config.read_text())["lock_layout"], False)
|
||||
self.assertIs(json.loads(config.read_text())["wrist_world_fallback"], True)
|
||||
self.assertEqual(list(config.parent.glob("config.json.backup-*")), [])
|
||||
@@ -255,6 +255,7 @@ class InstallTests(unittest.TestCase):
|
||||
config = self.home / ".config/frameyap/config.json"
|
||||
config.parent.mkdir(parents=True)
|
||||
defaults = installer.CONFIG_DEFAULTS["gradient"]
|
||||
self.assertIs(defaults["enabled"], False)
|
||||
|
||||
# Older configs receive defaults; a non-object section is replaced.
|
||||
for value in (None, [], "animated", 1):
|
||||
@@ -277,6 +278,12 @@ class InstallTests(unittest.TestCase):
|
||||
self.assertEqual(json.loads(config.read_text())["gradient"],
|
||||
{"enabled": False, "period_seconds": 30, "strength": 0.12})
|
||||
self.assertIn(original, [p.read_bytes() for p in config.parent.glob("config.json.backup-*")])
|
||||
original = b'{"gradient":{"enabled":true},"font":"/custom.ttf"}'
|
||||
config.write_bytes(original)
|
||||
self.install("0.1.202609241530", archive, digest)
|
||||
self.assertEqual(json.loads(config.read_text())["gradient"],
|
||||
{"enabled": True, "period_seconds": 30, "strength": 0.12})
|
||||
self.assertIn(original, [p.read_bytes() for p in config.parent.glob("config.json.backup-*")])
|
||||
|
||||
# A complete valid section, including numeric boundaries and explicit
|
||||
# disabled state, retains exact user bytes and gets no new backup.
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
#include "texture_refresh.hpp"
|
||||
#include <cassert>
|
||||
|
||||
int main() {
|
||||
bool initialized = false, dirty = true;
|
||||
int renders = 0, uploads = 0, model = 0, visible = -1;
|
||||
const auto refresh = [&](bool moving) {
|
||||
return frameyap::refresh_texture(moving, initialized, [&] {
|
||||
++renders;
|
||||
if (!dirty) return false;
|
||||
visible = model; dirty = false;
|
||||
return true;
|
||||
}, [&] { ++uploads; initialized = true; });
|
||||
};
|
||||
assert(refresh(true)); // first image is allowed during manipulation
|
||||
assert(renders == 1 && uploads == 1 && visible == 0);
|
||||
for (int i = 1; i <= 200; ++i) {
|
||||
model = i; dirty = true; // model and cosmetic changes stay live
|
||||
assert(!refresh(true));
|
||||
}
|
||||
assert(renders == 1 && uploads == 1 && visible == 0 && dirty);
|
||||
assert(refresh(false)); // display the latest state once, after release
|
||||
assert(renders == 2 && uploads == 2 && visible == 200 && !dirty);
|
||||
assert(!refresh(false));
|
||||
assert(renders == 3 && uploads == 2);
|
||||
}
|
||||
Reference in new issue
Block a user