feat(input): trigger selects in the browser, grip toggles the laser, downloads window replaces the browser

- Restore trigger-to-select in the file browser (it is the intuitive gesture);
  A remains an alternative for thumbstick/d-pad navigation. Both skip when the
  laser is resting on a control-bar button, which owns that click.
- Grip now toggles that hand's laser pointer, SteamVR-style: turn one off to
  drive a panel with the stick without the beam fighting it, and to pick the
  pointing hand. The raycast and the beam both honour it per hand.
- Recenter moves from Grip to Bumper (saved grip bindings migrate), since Grip
  is now the laser toggle.
- The downloads window replaces the browser panel instead of stacking over it,
  and browser input is suspended while it (or the confirm dialog) is open.
This commit is contained in:
Pixelplanet committed 2026-10-15 20:52:38 +02:00
1 parent e39fd5e77c
commit ee95d79efe
8 files changed
+84 -28

No files matched your search

+11 -9
View File
@@ -47,17 +47,19 @@ stays usable while a panel is open.
| Input | Action |
| --- | --- |
| **Trigger** | Point and click: open the entry under the laser, or press a control-bar button |
| **A** | Open the highlighted entry — folder navigates, video plays / streams. Outside the browser it is play / pause |
| **X** / **Y** | Download the highlighted entry (folders confirm first, showing their size) |
| **B** | Open or close the file browser; also closes a panel and cancels the download dialog |
| **Grip** | Toggle **that hand's** laser pointer on/off. Turn one off to navigate with the thumbstick or d-pad without the beam interfering, and to choose which controller points |
| Thumbstick | Move the highlight in a panel; left/right seeks while playing |
| **A** | Open the highlighted browser entry — folder navigates, video plays / streams. Outside the browser it is play / pause |
| **B** | Open or close the file browser; also closes the settings panel and cancels the download dialog |
| **Trigger** | Laser pointer: click a panel row, or a control-bar button |
| **Grip** | Recenter the view in front of you |
| **Bumper** | Recenter the view in front of you |
The control bar has a dedicated **Download** button (arrow) that downloads the
entry highlighted in the browser — folders ask for confirmation and show their
size first. The **Downloads** button (list icon) opens the downloads manager,
where you can pause, resume, stop and clear jobs, then play a finished file or
open a finished folder.
The control bar has a **Download** button (arrow) that downloads the entry
highlighted in the browser, and a **Downloads** button (list icon) that opens the
downloads manager — where you can pause, resume, stop and clear jobs, then play a
finished file or open a finished folder. Opening the downloads manager replaces
the file browser rather than stacking on top of it.
All button bindings can be changed in **Settings ▸ Controls** (press-to-bind).
+28 -14
View File
@@ -435,6 +435,20 @@ int App::run() {
!settings_.isListening() && !settings_.isOpen() &&
(in.buttonDown[XrInputState::BtnX] ||
in.buttonDown[XrInputState::BtnY]);
// Grip toggles that hand's laser pointer (SteamVR behaviour), so the
// stick / d-pad can drive a panel without the beam fighting it, and so
// the user picks which controller is the pointing hand.
if (!settings_.isListening()) {
for (int h = 0; h < 2; ++h) {
if (in.gripDown[h]) {
const bool on = !renderer_.laserOn(h);
renderer_.setLaserOn(h, on);
PIXELVR_LOG_INFO("laser [%s]: %s",
h == 0 ? "left" : "right",
on ? "on" : "off");
}
}
}
bool interacted = false;
bool browserOpen = false;
bool settingsOpen = false;
@@ -679,7 +693,9 @@ int App::run() {
renderer_.setDownloadsModal(false);
}
// Publish the browser panel first so the pointer can raycast it.
if (browser_.isOpen()) {
// The downloads window replaces the browser view instead of stacking
// on top of it, so nothing behind it can be selected by mistake.
if (browser_.isOpen() && !dlWin) {
std::vector<std::string> rows;
rows.reserve(browser_.entries().size());
for (const auto& e : browser_.entries()) {
@@ -827,8 +843,10 @@ int App::run() {
#ifdef PIXELVR_HAVE_FREETYPE
// Browsing stays interactive during downloads: they run in a separate
// process now, so they no longer block directory access. (Only guard
// against an in-flight SMB directory scan.)
if (browser_.isOpen() && !browser_.isScanning()) {
// against an in-flight SMB directory scan.) Interaction is suspended
// while an overlay (downloads window / confirm dialog) owns the view.
if (browser_.isOpen() && !browser_.isScanning() && !downloadsWindow_ &&
pendingDl_.empty()) {
if (const int d = navStep(in.thumbstickY, nowT)) {
browser_.move(d);
renderer_.scrollBrowser(d);
@@ -837,19 +855,15 @@ int App::run() {
const int hovered = renderer_.browserHovered();
if (hovered >= 0) {
renderer_.setBrowserSelected(hovered);
// The pointer drives the selection, so A acts on the entry
// actually being pointed at (not just the last trigger press).
browser_.setSelected(hovered);
}
// Trigger activates the selection whether it was made with the
// laser (hovered row) or moved with the thumbstick. Skipped when
// the pointer is on the control bar (that click is handled below).
// A opens / activates the highlighted entry: folders navigate, video
// files play. Downloading has its own button (X / Y), and when the
// laser is resting on a control-bar button that button takes the
// press instead (handled below).
if (in.togglePlay && renderer_.pointerButton() < 0 &&
pendingDl_.empty()) {
// Open the highlighted entry. The trigger does it whenever the
// laser is not resting on a control-bar button (that click belongs
// to the bar, handled below); A does it too, which is the natural
// choice when moving the highlight with the thumbstick / d-pad.
// Downloading has its own buttons (X / Y).
if ((in.togglePlay || in.select) &&
renderer_.pointerButton() < 0) {
std::string playPath;
if (browser_.activate(playPath) == Browser::Result::Play &&
!playPath.empty()) {
+4 -3
View File
@@ -1015,9 +1015,10 @@ void VulkanRenderer::updatePointer() {
}
const Mat4 inv = rigidInverse(panelBase);
// Prefer the right hand; fall back to the left.
// Prefer the right hand; fall back to the left. A hand whose laser is off
// takes no part in the raycast.
for (int h : {1, 0}) {
if (!controllers_[h].active) {
if (!controllers_[h].active || !laserOn_[h]) {
continue;
}
const Quat q{controllers_[h].orientation[0], controllers_[h].orientation[1],
@@ -2151,7 +2152,7 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex,
const float rayCol[4] = {0.25f, 0.60f, 1.0f, 0.85f};
const float cursorCol[4] = {1.0f, 1.0f, 1.0f, 0.95f};
for (int h = 0; h < 2; ++h) {
if (!controllers_[h].active) {
if (!controllers_[h].active || !laserOn_[h]) {
continue;
}
const Quat q{controllers_[h].orientation[0],
+12
View File
@@ -202,6 +202,16 @@ public:
};
void setControlBar(bool visible) { controlBarVisible_ = visible; }
// Per-hand laser pointer toggle (grip press). A hand with the laser off
// neither casts a beam nor takes part in the UI raycast, so the other hand
// can own the pointer while this one drives a panel with its stick/d-pad.
void setLaserOn(int hand, bool on) {
if (hand >= 0 && hand < 2) laserOn_[hand] = on;
}
bool laserOn(int hand) const {
return (hand >= 0 && hand < 2) ? laserOn_[hand] : false;
}
// The control-bar button currently under the pointer, or -1 if none.
int pointerButton() const { return pointerButton_; }
// True when the pointer is over the draggable timeline; pointerScrub() is
@@ -365,6 +375,8 @@ private:
bool pointerActive_ = false;
int pointerRow_ = -1;
int pointerHand_ = -1;
// Whether each hand's laser pointer is on (toggled with the grip button).
bool laserOn_[2] = {true, true};
float pointerHit_[3] = {0.0f, 0.0f, 0.0f};
bool controlBarVisible_ = false;
int pointerButton_ = -1;
+6 -1
View File
@@ -343,7 +343,12 @@ void SettingsMenu::loadConfig() {
else if (key == "distance") screenDistance_ = std::clamp(std::stof(val), 1.0f, 10.0f);
else if (key == "bind_play_btn") bindPlayBtn_ = std::clamp(std::stoi(val), 0, kButtonCount - 1);
else if (key == "bind_menu_btn") bindMenuBtn_ = std::clamp(std::stoi(val), 0, kButtonCount - 1);
else if (key == "bind_recenter_btn") bindRecenterBtn_ = std::clamp(std::stoi(val), 0, kButtonCount - 1);
else if (key == "bind_recenter_btn") {
const int b = std::clamp(std::stoi(val), 0, kButtonCount - 1);
// Grip used to default to recenter; it now toggles the laser pointer,
// so a saved grip binding is migrated to the new default (Bumper).
bindRecenterBtn_ = (b == 8) ? 6 : b;
}
else if (key == "download_dir") downloadDirIdx_ = std::clamp(std::stoi(val), 0, kDownloadDirCount - 1);
else if (key == "audio_lang") audioLangIdx_ = std::clamp(std::stoi(val), 0, kLangCount - 1);
}
+1 -1
View File
@@ -109,7 +109,7 @@ private:
// Function -> physical button (XrInputState::Button index); press-to-bind.
int bindPlayBtn_ = 0; // default A
int bindMenuBtn_ = 1; // default B
int bindRecenterBtn_ = 8; // default Grip
int bindRecenterBtn_ = 6; // default Bumper (grip toggles the laser)
int listeningFunc_ = -1; // 0=play 1=menu 2=recenter while binding, else -1
int downloadDirIdx_ = 0; // index into the download-destination presets
+17
View File
@@ -543,6 +543,8 @@ void XrContext::syncActions() {
input_.thumbstickRX = 0.0f;
input_.thumbstickRY = 0.0f;
for (int i = 0; i < XrInputState::BtnCount; ++i) input_.buttonDown[i] = false;
input_.gripDown[0] = false;
input_.gripDown[1] = false;
if (!actionsReady_) {
return;
}
@@ -628,6 +630,21 @@ void XrContext::syncActions() {
st.isActive && st.currentState && st.changedSinceLastSync;
}
}
// Per-hand grip press: `buttonDown` above aggregates both hands, but the laser
// pointer is toggled per hand, so query the squeeze action for each subaction.
if (buttonActions_[XrInputState::BtnGrip] != XR_NULL_HANDLE) {
for (int h = 0; h < 2; ++h) {
XrActionStateGetInfo gi{XR_TYPE_ACTION_STATE_GET_INFO};
gi.action = buttonActions_[XrInputState::BtnGrip];
gi.subactionPath = handPaths_[h];
XrActionStateBoolean st{XR_TYPE_ACTION_STATE_BOOLEAN};
if (XR_SUCCEEDED(xrGetActionStateBoolean(session_, &gi, &st))) {
input_.gripDown[h] =
st.isActive && st.currentState && st.changedSinceLastSync;
}
}
}
}
void XrContext::handleSessionStateChange(const XrEventDataSessionStateChanged& ev,
+5
View File
@@ -56,6 +56,11 @@ struct XrInputState {
BtnDpadUp, BtnDpadDown, BtnDpadLeft, BtnDpadRight, BtnCount
};
bool buttonDown[BtnCount] = {};
// Per-hand grip (squeeze) rising edge: toggles that hand's laser pointer, so
// one controller can drive a panel with the stick while the other points.
// Index 0 = left, 1 = right.
bool gripDown[2] = {false, false};
};
// Owns the OpenXR instance, system, session, reference space, and swapchains,