From bcd330d42ca7d104346bd98e1ded881aec6eed83 Mon Sep 17 00:00:00 2001 From: baketnk Date: Sun, 4 Oct 2026 21:43:55 -0400 Subject: [PATCH] Finish companion controls and retain panel textures during manipulation --- CMakeLists.txt | 12 ++ README.md | 4 +- assets/actions.json | 89 ++++++++++---- assets/bindings_frame_controller.json | 58 ++++++++- assets/bindings_knuckles.json | 34 ++++- assets/config.example.json | 2 +- docs/animation-and-keyboard-hold.md | 31 +++++ docs/design.md | 12 +- docs/overlay.md | 63 +++++++--- install.sh | 2 +- scripts/install_payload.py | 2 +- src/companion_apps.cpp | 8 +- src/companion_apps.hpp | 5 +- src/config.cpp | 52 ++++++-- src/config.hpp | 3 +- src/overlay.cpp | 171 +++++++++++++++++++++----- src/panel_drag.hpp | 22 +++- src/panel_surface.cpp | 132 +++++++++++++++----- src/panel_surface.hpp | 10 +- src/texture_refresh.hpp | 14 +++ tests/companion_apps_test.cpp | 24 +++- tests/config_test.cpp | 29 ++++- tests/panel_drag_test.cpp | 48 ++++++++ tests/panel_test.cpp | 151 ++++++++++++++++++++--- tests/test_action_bindings.py | 16 ++- tests/test_grab_bindings.py | 35 ++++++ tests/test_installer.py | 9 +- tests/texture_refresh_test.cpp | 26 ++++ 28 files changed, 906 insertions(+), 158 deletions(-) create mode 100644 docs/animation-and-keyboard-hold.md create mode 100644 src/texture_refresh.hpp create mode 100644 tests/test_grab_bindings.py create mode 100644 tests/texture_refresh_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 1ddbdca..dff5ad4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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() diff --git a/README.md b/README.md index c5baa75..588093a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/assets/actions.json b/assets/actions.json index ed1e68b..82d39e8 100644 --- a/assets/actions.json +++ b/assets/actions.json @@ -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" + } + ] } diff --git a/assets/bindings_frame_controller.json b/assets/bindings_frame_controller.json index f620578..b327c8b 100644 --- a/assets/bindings_frame_controller.json +++ b/assets/bindings_frame_controller.json @@ -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" + } + } } ] } diff --git a/assets/bindings_knuckles.json b/assets/bindings_knuckles.json index b370f8b..c1d668f 100644 --- a/assets/bindings_knuckles.json +++ b/assets/bindings_knuckles.json @@ -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" + } + } } ] } diff --git a/assets/config.example.json b/assets/config.example.json index 9d58c0b..c1c110d 100644 --- a/assets/config.example.json +++ b/assets/config.example.json @@ -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", diff --git a/docs/animation-and-keyboard-hold.md b/docs/animation-and-keyboard-hold.md new file mode 100644 index 0000000..4f564b1 --- /dev/null +++ b/docs/animation-and-keyboard-hold.md @@ -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. diff --git a/docs/design.md b/docs/design.md index 6a1324a..0d30965 100644 --- a/docs/design.md +++ b/docs/design.md @@ -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 diff --git a/docs/overlay.md b/docs/overlay.md index 20cc357..7d67537 100644 --- a/docs/overlay.md +++ b/docs/overlay.md @@ -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. diff --git a/install.sh b/install.sh index 67ba01e..d27f712 100755 --- a/install.sh +++ b/install.sh @@ -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", diff --git a/scripts/install_payload.py b/scripts/install_payload.py index 46cac51..4e9458e 100644 --- a/scripts/install_payload.py +++ b/scripts/install_payload.py @@ -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", diff --git a/src/companion_apps.cpp b/src/companion_apps.cpp index 8b736fa..9086410 100644 --- a/src/companion_apps.cpp +++ b/src/companion_apps.cpp @@ -16,7 +16,7 @@ bool executable(const std::filesystem::path& path) { } } std::optional 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 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(path.c_str()), nullptr}; + const char* option = command == CompanionCommand::Show ? "--show" : + command == CompanionCommand::Recenter ? "--recenter" : nullptr; + char* const args[] = {const_cast(path.c_str()), const_cast(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]; diff --git a/src/companion_apps.hpp b/src/companion_apps.hpp index e6feb1b..5d61d3a 100644 --- a/src/companion_apps.hpp +++ b/src/companion_apps.hpp @@ -6,7 +6,8 @@ namespace frameyap { // Resolve an installed launcher once at boot; never search the source checkout. std::optional 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); } diff --git a/src/config.cpp b/src/config.cpp index eb38469..1ca52d4 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -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. diff --git a/src/config.hpp b/src/config.hpp index dc04f10..85c1155 100644 --- a/src/config.hpp +++ b/src/config.hpp @@ -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; diff --git a/src/overlay.cpp b/src/overlay.cpp index c38dc56..1e0433a 100644 --- a/src/overlay.cpp +++ b/src/overlay.cpp @@ -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 gpu_texture; vr::VRActionSetHandle_t action_set = vr::k_ulInvalidActionSetHandle; + vr::VRActionSetHandle_t grab_action_set = vr::k_ulInvalidActionSetHandle; + std::array hands{}; std::array actions{}; + std::array 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 plan_launcher, keyboard_launcher; + std::optional 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 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(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 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(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 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 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(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& result) { @@ -524,6 +593,15 @@ struct Overlay::Impl { } std::vector poll() { std::vector 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 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; } diff --git a/src/panel_drag.hpp b/src/panel_drag.hpp index 57d0375..1a1f390 100644 --- a/src/panel_drag.hpp +++ b/src/panel_drag.hpp @@ -2,6 +2,7 @@ #include "mount.hpp" +#include #include #include @@ -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 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(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(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 diff --git a/src/panel_surface.cpp b/src/panel_surface.cpp index c11090f..2112324 100644 --- a/src/panel_surface.cpp +++ b/src/panel_surface.cpp @@ -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 buttons{{ +constexpr std::array 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 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 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 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 pressed{{-1, -1}}; std::array press_time{}; - int hold_progress = 0; + std::array keyboard_fired{}; + int hold_progress = 0, keyboard_progress = 0; int drag_cursor = -1; std::vector 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(now - press_time[cursor]).count(); if (pressed[cursor] == 10) - progress = std::max(progress, std::clamp(int(std::chrono::duration_cast( - 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( - 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 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 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 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; diff --git a/src/panel_surface.hpp b/src/panel_surface.hpp index d4121d0..e5f712b 100644 --- a/src/panel_surface.hpp +++ b/src/panel_surface.hpp @@ -12,13 +12,14 @@ namespace frameyap { struct SurfaceEvent { - enum class Companion { Plan, Keyboard }; + enum class Companion { Plan, Keyboard, KeyboardRecenter, Draw }; std::optional launch_companion; std::optional action; std::optional mount; std::optional lasers_anytime; std::optional advanced_debug; std::optional auto_insert; + std::optional gradient_enabled; std::optional close_mic_when_idle; std::optional lock_layout; std::optional wrist_world_fallback; @@ -57,11 +58,15 @@ public: const std::vector& 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 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 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); diff --git a/src/texture_refresh.hpp b/src/texture_refresh.hpp new file mode 100644 index 0000000..c10ec74 --- /dev/null +++ b/src/texture_refresh.hpp @@ -0,0 +1,14 @@ +#pragma once +#include + +namespace frameyap { +// Follow docs/rendering-performance.md: retain initialized images throughout +// manipulation, leaving content invalidation pending until the next refresh. +template +bool refresh_texture(bool manipulating, bool initialized, Render&& render, Upload&& upload) { + if (manipulating && initialized) return false; + if (!std::forward(render)()) return false; + std::forward(upload)(); + return true; +} +} diff --git a/tests/companion_apps_test.cpp b/tests/companion_apps_test.cpp index 42adc62..730740a 100644 --- a/tests/companion_apps_test.cpp +++ b/tests/companion_apps_test.cpp @@ -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); diff --git a/tests/config_test.cpp b/tests/config_test.cpp index 1436127..c09f03d 100644 --- a/tests/config_test.cpp +++ b/tests/config_test.cpp @@ -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); diff --git a/tests/panel_drag_test.cpp b/tests/panel_drag_test.cpp index d04ce91..9ac4280 100644 --- a/tests/panel_drag_test.cpp +++ b/tests/panel_drag_test.cpp @@ -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::quiet_NaN(), .05); + drag.step_depth(1.f, std::numeric_limits::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}}}}); diff --git a/tests/panel_test.cpp b/tests/panel_test.cpp index 259df54..50c97dd 100644 --- a/tests/panel_test.cpp +++ b/tests/panel_test.cpp @@ -1,10 +1,12 @@ #include "panel_surface.hpp" #include #include +#include #include #include #include #include +#include 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"; diff --git a/tests/test_action_bindings.py b/tests/test_action_bindings.py index 6c56d7e..e2116c4 100644 --- a/tests/test_action_bindings.py +++ b/tests/test_action_bindings.py @@ -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() diff --git a/tests/test_grab_bindings.py b/tests/test_grab_bindings.py new file mode 100644 index 0000000..cd3574b --- /dev/null +++ b/tests/test_grab_bindings.py @@ -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() diff --git a/tests/test_installer.py b/tests/test_installer.py index ba69192..fef9a4f 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -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. diff --git a/tests/texture_refresh_test.cpp b/tests/texture_refresh_test.cpp new file mode 100644 index 0000000..57383a7 --- /dev/null +++ b/tests/texture_refresh_test.cpp @@ -0,0 +1,26 @@ +#include "texture_refresh.hpp" +#include + +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); +}