diff --git a/assets/config.example.json b/assets/config.example.json index 18c4421..9d58c0b 100644 --- a/assets/config.example.json +++ b/assets/config.example.json @@ -6,6 +6,7 @@ "close_mic_when_idle": false, "backend": "redux", "lock_layout": false, + "wrist_world_fallback": true, "clock_24h": false, "date_format": "mdy", "quick_inputs": ["/new", "/questions", "/help"], diff --git a/docs/overlay.md b/docs/overlay.md index 484e668..c9eb668 100644 --- a/docs/overlay.md +++ b/docs/overlay.md @@ -74,7 +74,12 @@ release) and Lasers anytime (system-wide lasers may affect games). The header updates when the displayed minute, date, battery or dashboard state changes, not every frame. Settings toggles 12/24-hour time and cycles date Off → MM/DD/YYYY → DD/MM/YYYY → YYYY-MM-DD → Off. These only affect display; -mount choices remain in Settings. +mount choices remain in Settings. The Settings controls form one vertically +scrollable list: hover the laser over the list and use the right stick to scroll. +The tabs, status and action footer remain fixed. Scrolling cancels a pending +pointer press rather than activating a different control. There are no Settings +pages. OpenVR's discrete/smooth laser scroll events are requested; right-stick +behavior with SteamVR's keyboard open still needs a live Frame check. Settings → **About** replaces the settings area with static information: the build version (plus git commit when built from an untagged or modified tree), @@ -142,6 +147,7 @@ installer creates one with defaults on first install. Copy the shipped "close_mic_when_idle": false, "backend": "redux", "lock_layout": false, + "wrist_world_fallback": true, "clock_24h": false, "date_format": "mdy", "quick_inputs": ["/new", "/questions", "/help"], @@ -390,7 +396,7 @@ fade from 60° to 75°, then hidden (including laser interaction). Pitch, yaw an roll contribute together; turning the wrist away or moving the head around it changes the angle. OpenVR's overlay alpha changes without rerendering the panel. World and head mounts do not fade. Missing headset tracking hides a wrist panel; -a lost wrist still uses the existing world-space fallback. Headset readability, +by default a lost wrist uses a world-space fallback. Headset readability, fade feel and interaction at the threshold still need live acceptance. To tune the selected wrist, set `wrist` in `config.json` as in the example above: @@ -401,7 +407,13 @@ are read at startup, and do not alter world/head placement. Invalid values fail direct native startup; the installer backs up and repairs invalid entries. A missing/untracked selected wrist temporarily falls back to world space, with a visible explanation in Settings, then reattaches when tracking returns. -The saved preference is not replaced by the fallback. These offsets and sizes are +Use Settings → **Wrist world fallback** to switch between the default +front-of-you fallback (ON) and hiding the panel when wrist tracking is lost +(OFF). It reappears on the selected wrist when tracking returns. The toggle is +saved to `"wrist_world_fallback"` in `config.json`; manual edits take effect on +restart. This option affects wrist mounts only, not World or Head. While hidden, +pointer controls cannot be used. +The saved mount preference is not replaced by the fallback. These offsets and sizes are initial choices, **not headset-comfort acceptance**. A selection saves only the mount token to `$XDG_CONFIG_HOME/frameyap/mount` diff --git a/install.sh b/install.sh index 073ea25..67ba01e 100755 --- a/install.sh +++ b/install.sh @@ -59,6 +59,7 @@ CONFIG_DEFAULTS = { "close_mic_when_idle": False, "backend": "redux", "lock_layout": False, + "wrist_world_fallback": True, "clock_24h": False, "date_format": "mdy", "quick_inputs": ["/new", "/questions", "/help"], @@ -167,6 +168,8 @@ def normalized_config(data): fixed["backend"] = backend if isinstance(backend, str) and BACKEND_RE.fullmatch(backend) else "redux" layout = data.get("lock_layout", False) fixed["lock_layout"] = layout if type(layout) is bool else False + fallback = data.get("wrist_world_fallback", True) + fixed["wrist_world_fallback"] = fallback if type(fallback) is bool else True clock = data.get("clock_24h", False) fixed["clock_24h"] = clock if type(clock) is bool else False date = data.get("date_format", "mdy") diff --git a/scripts/install_payload.py b/scripts/install_payload.py index ccfdbbc..46cac51 100644 --- a/scripts/install_payload.py +++ b/scripts/install_payload.py @@ -39,6 +39,7 @@ CONFIG_DEFAULTS = { "close_mic_when_idle": False, "backend": "redux", "lock_layout": False, + "wrist_world_fallback": True, "clock_24h": False, "date_format": "mdy", "quick_inputs": ["/new", "/questions", "/help"], @@ -147,6 +148,8 @@ def normalized_config(data): fixed["backend"] = backend if isinstance(backend, str) and BACKEND_RE.fullmatch(backend) else "redux" layout = data.get("lock_layout", False) fixed["lock_layout"] = layout if type(layout) is bool else False + fallback = data.get("wrist_world_fallback", True) + fixed["wrist_world_fallback"] = fallback if type(fallback) is bool else True clock = data.get("clock_24h", False) fixed["clock_24h"] = clock if type(clock) is bool else False date = data.get("date_format", "mdy") diff --git a/src/config.cpp b/src/config.cpp index 7474ba3..eb38469 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -215,6 +215,9 @@ Config load_config(const std::filesystem::path& path) { } else if (key == "lock_layout") { if (!value.is_bool) throw std::runtime_error("Config lock_layout must be a boolean"); config.lock_layout = value.value == "true"; + } else if (key == "wrist_world_fallback") { + if (!value.is_bool) throw std::runtime_error("Config wrist_world_fallback must be a boolean"); + config.wrist_world_fallback = value.value == "true"; } else if (key == "clock_24h") { if (!value.is_bool) throw std::runtime_error("Config clock_24h must be a boolean"); config.clock_24h = value.value == "true"; @@ -351,6 +354,9 @@ bool save_close_mic_when_idle(const std::filesystem::path& path, bool enabled) n bool save_lock_layout(const std::filesystem::path& path, bool enabled) noexcept { return save_bool_option(path, "lock_layout", enabled); } +bool save_wrist_world_fallback(const std::filesystem::path& path, bool enabled) noexcept { + return save_bool_option(path, "wrist_world_fallback", enabled); +} bool save_clock_24h(const std::filesystem::path& path, bool enabled) noexcept { return save_bool_option(path, "clock_24h", enabled); } diff --git a/src/config.hpp b/src/config.hpp index d491183..dc04f10 100644 --- a/src/config.hpp +++ b/src/config.hpp @@ -31,6 +31,7 @@ struct Config { bool auto_insert = false; // opt-in; runtime also requires uninterrupted verified Xwayland focus bool close_mic_when_idle = false; // default keeps the device open, discarding idle audio bool lock_layout = false; // hide the grab and scale handles when enabled + bool wrist_world_fallback = true; // show a world-space panel if a selected wrist loses tracking bool clock_24h = false; DateFormat date_format = DateFormat::MonthDayYear; WristPlacement wrist; @@ -46,6 +47,7 @@ bool save_advanced_debug(const std::filesystem::path& path, bool enabled) noexce bool save_auto_insert(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; bool save_clock_24h(const std::filesystem::path& path, bool enabled) noexcept; bool save_date_format(const std::filesystem::path& path, DateFormat format) noexcept; bool save_backend(const std::filesystem::path& path, const std::string& id) noexcept; diff --git a/src/mount.cpp b/src/mount.cpp index da46f14..a4ea00e 100644 --- a/src/mount.cpp +++ b/src/mount.cpp @@ -38,6 +38,13 @@ std::optional parse_mount(std::string_view name) { return std::nullopt; } +std::optional effective_mount(Mount selected, bool wrist_tracked, bool wrist_world_fallback) { + if (selected != Mount::LeftWrist && selected != Mount::RightWrist) return selected; + if (wrist_tracked) return selected; + if (wrist_world_fallback) return Mount::World; + return std::nullopt; +} + std::optional world_mount_pose(const Matrix34& hmd) { for (const auto& row : hmd) for (float value : row) diff --git a/src/mount.hpp b/src/mount.hpp index e243cac..9f125b9 100644 --- a/src/mount.hpp +++ b/src/mount.hpp @@ -11,6 +11,8 @@ enum class Mount { World, LeftWrist, RightWrist, Head }; std::string_view mount_name(Mount mount); std::optional parse_mount(std::string_view name); +// No mount means hide the panel until the selected wrist is tracked again. +std::optional effective_mount(Mount selected, bool wrist_tracked, bool wrist_world_fallback); // Row-major 3x4 affine transform (OpenVR axes: +Y up, -Z forward). using Matrix34 = std::array, 3>; diff --git a/src/overlay.cpp b/src/overlay.cpp index 2b79784..b343a62 100644 --- a/src/overlay.cpp +++ b/src/overlay.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include @@ -118,6 +119,7 @@ struct Overlay::Impl { std::array poses{}; bool grip_capture = false, ptt_capture = false; bool focus = true; + std::array>, 2> cursor_positions{}; struct Diagnostics { vr::EVRInputError action_update_error = vr::VRInputError_None; unsigned pointer_downs = 0, pointer_ups = 0, pointer_actions = 0, pointer_resets = 0; @@ -182,7 +184,12 @@ struct Overlay::Impl { lasers_anytime = false; persistence.laser_change_failed = true; } + overlay_check(overlay->SetOverlayFlag(handle, vr::VROverlayFlags_SendVRDiscreteScrollEvents, true), overlay, "Laser scroll"); + // Smooth events are optional on some runtimes; discrete wheel events + // keep Settings usable if the compositor declines the smooth mode. + overlay->SetOverlayFlag(handle, vr::VROverlayFlags_SendVRSmoothScrollEvents, true); surface.set_lasers_anytime(lasers_anytime); + 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_close_mic_when_idle(config.close_mic_when_idle); @@ -248,14 +255,22 @@ struct Overlay::Impl { shown = wanted; } void place() { - Mount effective = mount; vr::TrackedDeviceIndex_t target = vr::k_unTrackedDeviceIndex_Hmd; if (mount == Mount::LeftWrist || mount == Mount::RightWrist) { target = system->GetTrackedDeviceIndexForControllerRole(mount == Mount::LeftWrist ? vr::TrackedControllerRole_LeftHand : vr::TrackedControllerRole_RightHand); - if (target >= poses.size() || !poses[target].bPoseIsValid || !system->IsTrackedDeviceConnected(target)) - effective = Mount::World; } + const bool wrist_tracked = target < poses.size() && poses[target].bPoseIsValid && + system->IsTrackedDeviceConnected(target); + const auto selected = effective_mount(mount, wrist_tracked, config.wrist_world_fallback); + if (!selected) { + if (placed) { surface.reset_pointers(); dragging.panel.reset(); } + placed = false; + applied_mount.reset(); + visibility(); + return; + } + const Mount effective = *selected; if (effective == Mount::World && applied_mount && *applied_mount != Mount::World) world_ready = false; // a fresh world fallback near the wearer, not an old room location std::string note = persistence.mic_save_failed ? "Mic preference not saved; using it only for this session." : @@ -488,7 +503,7 @@ struct Overlay::Impl { result.push_back(UiAction::Quit); break; case vr::VREvent_OverlayHidden: ++diagnostics.overlay_hidden_events; - focus = false; reset_input(result); + focus = false; cursor_positions.fill(std::nullopt); reset_input(result); if (panel.recording) result.push_back(UiAction::Cancel); break; case vr::VREvent_OverlayShown: @@ -497,6 +512,7 @@ struct Overlay::Impl { case vr::VREvent_ImageLoaded: ++diagnostics.image_loaded_events; break; case vr::VREvent_ImageFailed: ++diagnostics.image_failed_events; break; case vr::VREvent_OverlayGamepadFocusLost: + cursor_positions.fill(std::nullopt); surface.reset_pointers(); ++diagnostics.pointer_resets; diagnostics.last_pointer_event = "gamepad focus lost"; break; case vr::VREvent_OverlayFocusChanged: @@ -506,10 +522,21 @@ struct Overlay::Impl { // a release must still hit the same enabled control. diagnostics.last_pointer_event = "overlay focus changed"; break; case vr::VREvent_MouseMove: - // Manipulation uses the captured controller ray, not coordinates - // fed back from a changing overlay or a batch of stale mouse hits. + // 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()) + cursor_positions[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() && + cursor_positions[event.data.scroll.cursorIndex]) { + const auto [x, y] = *cursor_positions[event.data.scroll.cursorIndex]; + surface.scroll_settings(x, y, event.data.scroll.ydelta); + } break; case vr::VREvent_MouseButtonDown: + if (event.data.mouse.cursorIndex < cursor_positions.size()) + cursor_positions[event.data.mouse.cursorIndex] = {event.data.mouse.x, H - event.data.mouse.y}; ++diagnostics.pointer_downs; diagnostics.last_pointer_event = "down button=" + std::to_string(event.data.mouse.button); if (event.data.mouse.button == vr::VRMouseButton_Left) @@ -517,11 +544,13 @@ struct Overlay::Impl { begin_drag(*kind, event); break; case vr::VREvent_MouseButtonUp: + if (event.data.mouse.cursorIndex < cursor_positions.size()) + cursor_positions[event.data.mouse.cursorIndex] = {event.data.mouse.x, H - event.data.mouse.y}; ++diagnostics.pointer_ups; 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.advanced_debug || event_result.auto_insert || event_result.close_mic_when_idle || event_result.lock_layout || 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.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) result.push_back(*event_result.action); if (event_result.model_action) { model_actions.push_back(*event_result.model_action); @@ -550,6 +579,15 @@ struct Overlay::Impl { reset_input(result); dragging.panel.reset(); return result; } + if (event_result.wrist_world_fallback) { + config.wrist_world_fallback = *event_result.wrist_world_fallback; + persistence.save_failed = persistence.persist_mount && + !save_wrist_world_fallback(default_config_path(), config.wrist_world_fallback); + surface.set_wrist_world_fallback(config.wrist_world_fallback); + reset_input(result); + place(); // hide or show immediately if the wrist is currently untracked + return result; + } if (event_result.close_mic_when_idle) { config.close_mic_when_idle = *event_result.close_mic_when_idle; persistence.mic_save_failed = persistence.persist_mount && diff --git a/src/panel_surface.cpp b/src/panel_surface.cpp index f6d78ae..b83e803 100644 --- a/src/panel_surface.cpp +++ b/src/panel_surface.cpp @@ -31,10 +31,10 @@ struct Rect { enum class Control { Review, Settings, Bindings, Prev, Next, Record, Cancel, Insert, Enter, Quit, World, Left, Right, Head, Recenter, LasersAnytime, AdvancedDebug, AutoInsert, Clock24h, Date, LockLayout, CloseMicWhenIdle, Models, ModelRow, - ModelInstall, ModelPrev, ModelNext, About }; + ModelInstall, ModelPrev, ModelNext, About, WristWorldFallback }; 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,7 +68,10 @@ 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"}, }}; +constexpr Rect settings_view{32, 232, 936, 316}; +constexpr int settings_max_scroll = 634 - (settings_view.y + settings_view.h); std::optional action(Control c) { switch (c) { case Control::Record: return UiAction::Record; @@ -88,6 +91,12 @@ std::optional mounting(Control c) { default: return {}; } } +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; +} // Invalid bytes become visible replacement glyphs, never control commands. uint32_t next_codepoint(std::string_view s, size_t& i) { const auto a = static_cast(s[i++]); @@ -122,8 +131,10 @@ struct PanelSurface::Impl { Color background, card, ink, muted, cyan, pink; Tab tab = Tab::Review; bool dirty = true, lasers_anytime = false, advanced_debug = false, auto_insert = false; - bool close_mic_when_idle = false; + bool close_mic_when_idle = false, wrist_world_fallback = true; bool clock_24h = false, layout_locked = false; + float settings_scroll = 0.f; + bool clip_settings = false; DateFormat date_format = DateFormat::MonthDayYear; std::time_t clock_time = std::time(nullptr); ClockLabel displayed_clock; @@ -192,11 +203,15 @@ struct PanelSurface::Impl { return result; } void rect(Rect r, Color c) { - for (int y = std::max(r.y, 0); y < std::min(H, r.y + r.h); ++y) - for (int x = std::max(r.x, 0); x < std::min(W, r.x + r.w); ++x) + for (int y = std::max(r.y, clip_settings ? settings_view.y : 0); + y < std::min(clip_settings ? settings_view.y + settings_view.h : H, r.y + r.h); ++y) + for (int x = std::max(r.x, clip_settings ? settings_view.x : 0); + x < std::min(clip_settings ? settings_view.x + settings_view.w : W, r.x + r.w); ++x) std::copy(c.begin(), c.end(), pixels.begin() + (size_t(y) * W + x) * 4); } void blend(int x, int y, Color color, float amount) { + if (clip_settings && (x < settings_view.x || x >= settings_view.x + settings_view.w || + y < settings_view.y || y >= settings_view.y + settings_view.h)) return; auto* dst = pixels.data() + (size_t(y) * W + x) * 4; // Straight-alpha source-over, including strokes in the transparent margin. const float alpha = amount * color[3] / 255.f; @@ -334,7 +349,9 @@ struct PanelSurface::Impl { for (unsigned row = 0; row < b.rows; ++row) for (unsigned col = 0; col < b.width; ++col) { int xx = x + glyph->bitmap_left + int(col), yy = baseline - glyph->bitmap_top + int(row); - if (xx < 0 || xx >= right || xx >= W || yy < 0 || yy >= H) continue; + if (xx < 0 || xx >= right || xx >= W || yy < 0 || yy >= H || + (clip_settings && (xx < settings_view.x || xx >= settings_view.x + settings_view.w || + yy < settings_view.y || yy >= settings_view.y + settings_view.h))) continue; unsigned char alpha = b.buffer[int(row) * b.pitch + int(col)]; auto* dst = pixels.data() + (size_t(yy) * W + xx) * 4; for (int k = 0; k < 3; ++k) @@ -355,7 +372,7 @@ struct PanelSurface::Impl { } bool visible(Control c) const { if (panel.quick_open && c != Control::Cancel && c != Control::Enter && c != Control::Quit) return false; - if (c == Control::Models || c == Control::About) return tab == Tab::Settings; + if (c == Control::Models || c == Control::About || c == Control::WristWorldFallback) return tab == Tab::Settings; if (c == Control::ModelRow) return tab == Tab::Models && !install_confirm; if (c == Control::ModelPrev || c == Control::ModelNext) return tab == Tab::Models; if (c == Control::ModelInstall) return tab == Tab::Models; @@ -385,10 +402,16 @@ struct PanelSurface::Impl { (model_page + 1) * 6 < panel.models.size(); return true; } + Rect button_rect(const Button& b) const { + auto r = b.r; + if (settings_control(b.id)) r.y -= int(std::lround(settings_scroll)); + return r; + } int hit(float x, float y) const { for (size_t i = 0; i < buttons.size(); ++i) { if (buttons[i].id == Control::ModelRow && model_page * 6 + (i - 23) >= panel.models.size()) continue; - if (visible(buttons[i].id) && enabled(buttons[i].id) && buttons[i].r.contains(x, y)) return int(i); + if (settings_control(buttons[i].id) && !settings_view.contains(x, y)) continue; + if (visible(buttons[i].id) && enabled(buttons[i].id) && button_rect(buttons[i]).contains(x, y)) return int(i); } return -1; } @@ -536,8 +559,10 @@ struct PanelSurface::Impl { for (size_t i = 0; i < buttons.size(); ++i) { const auto& b = buttons[i]; if (!visible(b.id)) continue; - bool on = enabled(b.id); if (b.id == Control::ModelRow && model_page * 6 + (i - 23) >= panel.models.size()) continue; + const Rect r = button_rect(b); + clip_settings = settings_control(b.id); + bool on = enabled(b.id); bool selected = (b.id == Control::Review && tab == Tab::Review) || (b.id == Control::Settings && tab == Tab::Settings) || (mounting(b.id) && *mounting(b.id) == mount) || @@ -551,14 +576,14 @@ struct PanelSurface::Impl { const Color accent = b.id == Control::Record && panel.recording ? pink : cyan; const bool highlighted = on && (selected || b.id == Control::Record || (b.id == Control::Insert && panel.transcript.size())); - rounded(b.r, std::min(16, b.r.h / 3), fill, + 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))) { const int cursor = pressed[0] == int(i) ? 0 : 1; 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({b.r.x + 7, b.r.y + b.r.h - 9, int((b.r.w - 14) * fraction), 3}, pink); + rect({r.x + 7, r.y + r.h - 9, int((r.w - 14) * fraction), 3}, pink); } const std::string label = b.id == Control::ModelRow ? [&]() { const auto& m = panel.models[model_page * 6 + i - 23]; @@ -571,23 +596,31 @@ struct PanelSurface::Impl { b.id == Control::Date ? (date_format == DateFormat::Off ? "Date: Off" : date_format == DateFormat::MonthDayYear ? "Date: MM/DD/YYYY" : date_format == DateFormat::DayMonthYear ? "Date: DD/MM/YYYY" : "Date: YYYY-MM-DD") : b.label; - text(label, b.r.x + 16, b.r.y + b.r.h / 2 + 9, + text(label, r.x + 16, r.y + r.h / 2 + 9, b.id == Control::Date ? 23 : b.id == Control::CloseMicWhenIdle ? 22 : b.id == Control::Enter ? 20 : 27, - on ? ink : mix(background, muted, .48f), b.r.x + b.r.w - 8); - if (mounting(b.id) && selected) text("ON", b.r.x + b.r.w - 56, b.r.y + 38, 23, cyan, b.r.x + b.r.w - 12); + 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::LockLayout || b.id == Control::CloseMicWhenIdle || 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::CloseMicWhenIdle ? close_mic_when_idle : advanced_debug; - text(active ? "ON" : "OFF", b.r.x + b.r.w - 66, b.r.y + b.r.h / 2 + 9, 22, - active ? cyan : muted, b.r.x + b.r.w - 12); + 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, + active ? cyan : muted, r.x + r.w - 12); } + clip_settings = false; } - if (tab == Tab::Settings) + if (tab == Tab::Settings) { + rect({976, settings_view.y, 3, settings_view.h}, mix(card, muted, .24f)); + const int thumb = settings_view.h * settings_view.h / (settings_view.h + settings_max_scroll); + rect({975, settings_view.y + int(settings_scroll * (settings_view.h - thumb) / settings_max_scroll), + 5, thumb}, cyan); + text("Right stick: scroll", 32, 565, 16, muted, 320); text("OFF: discard idle audio; ON: spike / start latency.", 390, 565, 16, muted, 887); + } if (tab == Tab::Models && !panel.model_note.empty()) { // The install/navigation controls occupy y=530..566; the status // belongs BELOW the shared footer (574..642), not under buttons. @@ -664,6 +697,7 @@ SurfaceEvent PanelSurface::pointer_up(unsigned cursor, float x, float y, Clock:: else if (c == Control::AutoInsert) result.auto_insert = !impl_->auto_insert; 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; else if (c == Control::Clock24h) result.clock_24h = !impl_->clock_24h; else if (c == Control::Date) result.date_format = static_cast((static_cast(impl_->date_format) + 1) % 4); else if (c == Control::Bindings) { result.open_bindings = true; impl_->reset(); } @@ -708,6 +742,17 @@ SurfaceEvent PanelSurface::pointer_up(unsigned cursor, float x, float y, Clock:: else if (c == Control::Next) { ++impl_->page; impl_->dirty = true; } return result; } +bool PanelSurface::scroll_settings(float x, float y, float vertical_delta) { + if (impl_->tab != Tab::Settings || impl_->drag_cursor >= 0 || + !settings_view.contains(x, y) || !std::isfinite(vertical_delta)) return false; + const float next = std::clamp(impl_->settings_scroll - std::clamp(vertical_delta, -4.f, 4.f) * 48.f, + 0.f, float(settings_max_scroll)); + if (next == impl_->settings_scroll) return false; + impl_->settings_scroll = next; + impl_->reset(); // a press must not turn into a click on a different setting + impl_->dirty = true; + return true; +} void PanelSurface::set_indicators(const StatusIndicators& indicators) { if (impl_->indicators != indicators) { impl_->indicators = indicators; impl_->dirty = true; } } @@ -720,6 +765,11 @@ std::vector PanelSurface::input_regions() const { if (!impl_->layout_locked) { result.push_back(grab); result.push_back(scale); } return result; } +void PanelSurface::set_wrist_world_fallback(bool enabled) { + if (impl_->wrist_world_fallback != enabled) { + impl_->wrist_world_fallback = enabled; impl_->reset(); impl_->dirty = true; + } +} void PanelSurface::set_layout_locked(bool locked) { if (impl_->layout_locked != locked) { impl_->layout_locked = locked; impl_->reset(); impl_->dirty = true; diff --git a/src/panel_surface.hpp b/src/panel_surface.hpp index 57f9f6d..6dd7817 100644 --- a/src/panel_surface.hpp +++ b/src/panel_surface.hpp @@ -19,6 +19,7 @@ struct SurfaceEvent { std::optional auto_insert; std::optional close_mic_when_idle; std::optional lock_layout; + std::optional wrist_world_fallback; std::optional clock_24h; std::optional date_format; bool recenter = false; @@ -53,10 +54,13 @@ public: static constexpr auto quit_hold = std::chrono::milliseconds(900); std::optional pointer_down(unsigned cursor, float x, float y, 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; no paging. + bool scroll_settings(float x, float y, float vertical_delta); bool dragging(unsigned cursor) const; // OpenVR intersection masks use top-left coordinates, unlike mouse events. std::vector input_regions() const; void set_layout_locked(bool locked); + void set_wrist_world_fallback(bool enabled); void reset_pointers(); void set_placement_note(std::string note); // Shown on Settings -> About; empty shows "version unknown". diff --git a/tests/config_test.cpp b/tests/config_test.cpp index ac5c6d6..1436127 100644 --- a/tests/config_test.cpp +++ b/tests/config_test.cpp @@ -46,6 +46,7 @@ int main(int argc, char** argv) { assert(!load_config(path).auto_insert); assert(!load_config(path).close_mic_when_idle); assert(!load_config(path).lock_layout); + assert(load_config(path).wrist_world_fallback); assert(!load_config(path).clock_24h); assert(load_config(path).date_format == DateFormat::MonthDayYear); assert(load_config(path).wrist.width == .30f); @@ -63,6 +64,7 @@ int main(int argc, char** argv) { assert(!example.auto_insert); assert(!example.close_mic_when_idle); assert(!example.lock_layout); + 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); @@ -190,6 +192,20 @@ int main(int argc, char** argv) { assert(save_lock_layout(path, true)); assert(get(path) == R"({"font":"escaped \u0061","theme":{"ink":"#123ABC"},"auto_insert":true,"lock_layout":true})"); assert(load_config(path).lock_layout && load_config(path).auto_insert); + put(path, R"({"wrist_world_fallback":false})"); + assert(!load_config(path).wrist_world_fallback); + assert(save_wrist_world_fallback(path, true)); + assert(load_config(path).wrist_world_fallback); + assert(save_wrist_world_fallback(path, false)); + assert(!load_config(path).wrist_world_fallback); + put(path, R"({"wrist_world_fallback":true})"); + assert(load_config(path).wrist_world_fallback); + for (const auto* invalid : {R"({"wrist_world_fallback":0})", R"({"wrist_world_fallback":"false"})", + R"({"wrist_world_fallback":null})"}) { + put(path, invalid); fails([&] { load_config(path); }); + assert(!save_wrist_world_fallback(path, false)); + assert(get(path) == invalid); + } put(path, R"({"input_priority":"normal"})"); assert(!load_config(path).experimental_input_priority); for (const auto* invalid : {R"({"input_priority":true})", R"({"input_priority":16777216})", diff --git a/tests/mount_test.cpp b/tests/mount_test.cpp index c9bdeeb..b673771 100644 --- a/tests/mount_test.cpp +++ b/tests/mount_test.cpp @@ -58,6 +58,15 @@ int main() { } CHECK(!parse_mount("World")); CHECK(!parse_mount("head\n")); CHECK(!parse_mount("left_wrist")); CHECK(!parse_mount("")); CHECK(!parse_mount("world extra")); + for (auto wrist : {Mount::LeftWrist, Mount::RightWrist}) { + CHECK(effective_mount(wrist, true, true) == wrist); + CHECK(effective_mount(wrist, true, false) == wrist); + CHECK(effective_mount(wrist, false, true) == Mount::World); + CHECK(!effective_mount(wrist, false, false)); + CHECK(effective_mount(wrist, true, false) == wrist); // restored tracking + } + CHECK(effective_mount(Mount::World, false, false) == Mount::World); + CHECK(effective_mount(Mount::Head, false, false) == Mount::Head); CHECK(near(mount_width(Mount::World), .85f)); CHECK(near(mount_width(Mount::Head), .85f)); CHECK(near(mount_width(Mount::LeftWrist), .30f)); CHECK(near(mount_width(Mount::RightWrist), .30f)); diff --git a/tests/panel_test.cpp b/tests/panel_test.cpp index e8f12cf..2ec2050 100644 --- a/tests/panel_test.cpp +++ b/tests/panel_test.cpp @@ -14,7 +14,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.clock_24h && !event.date_format && !event.recenter && !event.open_bindings && !event.model_action); + !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); } void snapshot(PanelSurface& surface, const std::string& path) { std::ofstream out(path, std::ios::binary); @@ -327,6 +327,30 @@ int main(int argc, char** argv) { surface.set_close_mic_when_idle(true); assert(surface.render(p)); assert(click(surface, 200, 532).close_mic_when_idle == false); surface.set_close_mic_when_idle(false); assert(surface.render(p)); + // The Settings viewport scrolls continuously under the laser; it never pages. + assert(!surface.scroll_settings(290, 160, -1.f)); // tab/header, not settings content + assert(!surface.scroll_settings(200, 400, std::numeric_limits::quiet_NaN())); + surface.pointer_down(0, 200, 532); // pending mic press must not activate after movement + assert(surface.scroll_settings(200, 400, -.5f)); + no_action(surface.pointer_up(0, 200, 532)); + assert(surface.render(p)); + assert(!surface.scroll_settings(200, 610, -1.f)); // fixed footer + assert(surface.scroll_settings(200, 400, -2.f)); + assert(surface.render(p)); + assert(!surface.scroll_settings(200, 400, -1.f)); // bottom clamp + no_action(click(surface, 180, 231)); // scrolled settings cannot be clicked above the viewport + auto fallback = click(surface, 200, 520); + 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, 520); + assert(fallback.wrist_world_fallback == true); + surface.set_wrist_world_fallback(true); assert(surface.render(p)); + assert(click(surface, 280, 610).action == UiAction::Cancel); // footer stays fixed + assert(surface.scroll_settings(200, 400, 4.f)); + assert(surface.render(p)); + assert(!surface.scroll_settings(200, 400, 1.f)); // top clamp + assert(click(surface, 180, 260).mount == Mount::World); assert(click(surface, 280, 610).action == UiAction::Cancel); surface.set_placement_note("Wrist not tracked - using world space until it returns."); assert(surface.render(p)); assert(!surface.render(p)); @@ -344,6 +368,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 + assert(!surface.scroll_settings(200, 400, -1.f)); // Review has no Settings scroll // Bindings opens SteamVR directly, from either tab, without replacing review. surface.pointer_down(1, 480, 610); diff --git a/tests/test_installer.py b/tests/test_installer.py index 458bb85..aa1192c 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -89,6 +89,7 @@ class InstallTests(unittest.TestCase): self.assertEqual(json.loads(config.read_text())["gradient"], {"enabled": True, "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-*")), []) self.assertIn("PYTHONDONTWRITEBYTECODE=1", launcher.read_text()) self.assertTrue(os.access(root / "versions/0.1.202609241530/runtime/bin/helper", os.X_OK)) @@ -186,6 +187,7 @@ class InstallTests(unittest.TestCase): fixed["close_mic_when_idle"] = True fixed["backend"] = "custom_v2-1" fixed["lock_layout"] = True + fixed["wrist_world_fallback"] = False fixed["clock_24h"] = True fixed["date_format"] = "iso" fixed["buttons"]["enter"] = "" # intentional disabling survives upgrades @@ -200,6 +202,7 @@ class InstallTests(unittest.TestCase): self.assertIs(json.loads(config.read_text())["close_mic_when_idle"], True) self.assertEqual(json.loads(config.read_text())["backend"], "custom_v2-1") self.assertIs(json.loads(config.read_text())["lock_layout"], True) + self.assertIs(json.loads(config.read_text())["wrist_world_fallback"], False) self.assertIs(json.loads(config.read_text())["clock_24h"], True) self.assertEqual(json.loads(config.read_text())["date_format"], "iso") self.assertEqual(len(list(config.parent.glob("config.json.backup-*"))), 1) @@ -221,6 +224,7 @@ class InstallTests(unittest.TestCase): self.assertIs(fixed["advanced_debug"], False) self.assertIs(fixed["auto_insert"], False) self.assertIs(fixed["lock_layout"], False) + self.assertIs(fixed["wrist_world_fallback"], True) self.assertEqual(fixed["wrist"]["x"], 0.04) self.assertEqual(fixed["wrist"]["y"], 0.18) self.assertEqual(fixed["wrist"]["width"], 0.30) @@ -350,6 +354,12 @@ class InstallTests(unittest.TestCase): self.assertEqual(fixed["font"], "/custom/font.ttf") self.assertIn(original, [p.read_bytes() for p in config.parent.glob("config.json.backup-*")]) + def test_wrist_world_fallback_normalization(self): + self.assertIs(installer.normalized_config({})["wrist_world_fallback"], True) + self.assertIs(installer.normalized_config({"wrist_world_fallback": False})["wrist_world_fallback"], False) + for invalid in ("false", 0, None, [], {}): + self.assertIs(installer.normalized_config({"wrist_world_fallback": invalid})["wrist_world_fallback"], True) + def test_new_config_fields_repair_only_invalid_values(self): archive, digest = self.package("0.1.202609241530") config = self.home / ".config/frameyap/config.json"