Make wrist fallback optional and scroll Settings with laser

This commit is contained in:
baketnk committed 2026-09-26 23:17:13 -04:00
1 parent 0f36a45358
commit 8f4c1877f7
15 files changed
+218 -30

No files matched your search

+1
View File
@@ -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"],
+15 -3
View File
@@ -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`
+3
View File
@@ -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")
+3
View File
@@ -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")
+6
View File
@@ -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);
}
+2
View File
@@ -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;
+7
View File
@@ -38,6 +38,13 @@ std::optional<Mount> parse_mount(std::string_view name) {
return std::nullopt;
}
std::optional<Mount> 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<Matrix34> world_mount_pose(const Matrix34& hmd) {
for (const auto& row : hmd)
for (float value : row)
+2
View File
@@ -11,6 +11,8 @@ enum class Mount { World, LeftWrist, RightWrist, Head };
std::string_view mount_name(Mount mount);
std::optional<Mount> parse_mount(std::string_view name);
// No mount means hide the panel until the selected wrist is tracked again.
std::optional<Mount> 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<std::array<float, 4>, 3>;
+45 -7
View File
@@ -19,6 +19,7 @@
#include <stdexcept>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
#include <unistd.h>
@@ -118,6 +119,7 @@ struct Overlay::Impl {
std::array<vr::TrackedDevicePose_t, vr::k_unMaxTrackedDeviceCount> poses{};
bool grip_capture = false, ptt_capture = false;
bool focus = true;
std::array<std::optional<std::pair<float, float>>, 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 &&
+69 -19
View File
@@ -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<Button, 33> buttons{{
constexpr std::array<Button, 34> 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<Button, 33> 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<UiAction> action(Control c) {
switch (c) {
case Control::Record: return UiAction::Record;
@@ -88,6 +91,12 @@ std::optional<Mount> 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<unsigned char>(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<std::chrono::milliseconds>(
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<DateFormat>((static_cast<int>(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::Bounds> 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;
+4
View File
@@ -19,6 +19,7 @@ struct SurfaceEvent {
std::optional<bool> auto_insert;
std::optional<bool> close_mic_when_idle;
std::optional<bool> lock_layout;
std::optional<bool> wrist_world_fallback;
std::optional<bool> clock_24h;
std::optional<DateFormat> date_format;
bool recenter = false;
@@ -53,10 +54,13 @@ public:
static constexpr auto quit_hold = std::chrono::milliseconds(900);
std::optional<PanelDragKind> 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<Bounds> 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".
+16
View File
@@ -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})",
+9
View File
@@ -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));
+26 -1
View File
@@ -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<float>::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);
+10
View File
@@ -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"