feat(ui): unified VR keyboard, UTF-8 Latin-1 glyphs, folder scroll history & A/V sync fixes

- Unified VR keyboard across app with default laser focus and cursor navigation for arrow keys
- Expanded font atlas to 256 glyphs with UTF-8 decoding for German umlauts and eszett
- Added folder navigation history preserving scroll offset and selection when exiting subfolders
- Fixed A/V desync and 3D SBS video stuttering
- Dynamic context menu sizing and vertical text centering in list view
- Added UI handoff roadmap documentation
This commit is contained in:
Pixelplanet committed 2026-10-25 09:35:35 +01:00
1 parent 766e67bffd
commit 41e8d09b74
14 files changed
+1439 -486

No files matched your search

+148
View File
@@ -0,0 +1,148 @@
# PixelVRPlayer — Spatial UI Visual Polish, UX & Technical Handoff Roadmap
This document is a complete, implementation-ready handoff guide for **PixelVRPlayer** so any new agent or developer can pick up immediately.
---
## 1. Visual Concept Mockups (Spatial VR UI Gallery)
Generated 16:9 high-resolution concept mockups (stored in the artifact directory `C:\Users\tomwi\.gemini\antigravity\brain\0772bbab-53a0-49f5-b094-22a628d153cf\`):
- **Mockup 1 — File Browser:** `mockup_ui_file_browser_1792865434894.jpg` (Glassmorphic cards, header Up button, breadcrumb pills, folder stack lip & right scrollbar)
- **Mockup 2 — Settings View:** `mockup_ui_settings_1792865444847.jpg` (Visual toggle switches, stepped progress bars & color swatch dots)
- **Mockup 3 — Cinema Transport Dock:** `mockup_ui_transport_dock_1792865461315.jpg` (Timeline hover timestamp bubble, icon pills & hero glow ring)
- **Mockup 4 — Cinema Context & Options:** `pixelvrplayer_spatial_ui_mockup_1792865752605.jpg` (3D projection format pills, audio/subtitle drawer & theater ambilight)
---
## 2. Key Codebase Map
| Subsystem | Files | Key Symbols / Line Ranges |
| :--- | :--- | :--- |
| **UI & Text Renderer** | `src/gfx/vulkan_renderer.hpp`, `src/gfx/vulkan_renderer.cpp` | `VulkanRenderer::renderView`, `VulkanRenderer::updatePointer` |
| **Procedural SDF UI Shader** | `shaders/ui.frag`, `shaders/ui.vert` | `sdRoundedBox`, `Push` constants (`params.x` = shape, `params.y` = radius, `params.z` = borderWidth) |
| **Thumbnail Shader** | `shaders/thumb.frag` | Rounded-corner SDF mask + sRGB-to-linear conversion |
| **SDF Font Atlas (UTF-8 / Latin-1)** | `src/gfx/text_atlas.hpp`, `src/gfx/text_atlas.cpp` | `buildFontAtlas` (`glyphs[256]`) |
| **File Browser & SMB** | `src/ui/browser.hpp`, `src/ui/browser.cpp` | `Browser::scanLocalInto`, `Browser::scanSmbInto`, `folderHistory_` |
| **Thumbnail & Metadata Cache** | `src/ui/thumbnail_cache.hpp`, `src/ui/thumbnail_cache.cpp` | `ThumbnailCache::extractThumbnailAndMeta` |
| **SteamVR Virtual Keyboard** | `src/xr/vr_keyboard.hpp`, `src/xr/vr_keyboard.cpp` | `VrKeyboard::show`, `VrKeyboard::poll` |
| **Main App Loop & Input** | `src/app/app.hpp`, `src/app/app.cpp` | `App::run` |
---
## 3. Visual UI Improvements (Step-by-Step Implementation Specs)
### 3.1. Frosted Glass Gradient & Top Specular Rim in `shaders/ui.frag`
Right now, `Shape 0` (`UiShape::RoundedBox`) in `shaders/ui.frag` outputs a flat solid color (`pc.color`) and uniform border.
- **Implementation in `shaders/ui.frag`:**
```glsl
vec4 fillLin = vec4(pow(max(pc.color.rgb, vec3(0.0)), vec3(2.2)), pc.color.a);
if (int(pc.params.x) == 0) {
// Subtle vertical luminance gradient across panels/pills (lit from top)
float topLight = mix(0.92, 1.10, clamp(0.5 * (vCoord.y + 1.0), 0.0, 1.0));
fillLin.rgb *= topLight;
}
vec4 finalColor = fillLin;
if (hasBorder) {
float borderWidth = pc.params.z;
float innerD = d + borderWidth;
float borderAlpha = 1.0 - smoothstep(-aa, aa, innerD);
vec4 bordLin = vec4(pow(max(pc.borderColor.rgb, vec3(0.0)), vec3(2.2)), pc.borderColor.a);
if (int(pc.params.x) == 0) {
// Specular catchlight on upper chamfer/rim
float rimLight = mix(0.85, 1.38, smoothstep(-0.2, 0.95, vCoord.y));
bordLin.rgb *= rimLight;
}
finalColor = mix(bordLin, fillLin, borderAlpha);
}
```
### 3.2. Thumbnail Vignette Scrims (`shaders/thumb.frag`) & Card Depth
In `shaders/thumb.frag`, thumbnails are drawn without scrims, causing white duration and badge text to wash out against bright scenes.
- **Implementation in `shaders/thumb.frag`:**
```glsl
vec3 linRgb = pow(max(col.rgb, vec3(0.0)), vec3(2.2));
// Top scrim (for format badges) and bottom scrim (for title / duration pills)
float topScrim = mix(0.68, 1.0, smoothstep(0.0, 0.26, vUV.y));
float botScrim = mix(0.58, 1.0, smoothstep(1.0, 0.70, vUV.y));
linRgb *= (topScrim * botScrim);
outColor = vec4(linRgb * pc.color.rgb, col.a * pc.color.a * alpha);
```
- **Folder Stack Visual Cue (`src/gfx/vulkan_renderer.cpp`):**
- When `item.isDir` is `true` in `BigCards` or `MiniCards`, draw a secondary rounded box `0.008m` above `lay.thumbCy + lay.thumbHalfH` (`width = lay.thumbHalfW * 0.88f, height = 0.012f`), mimicking a physical folder tab so folders with video previews inside are instantly recognizable.
- **Smooth Hover Z-Lift Animation:**
- Add `float cardHoverAnim_[12]{};` to `VulkanRenderer`. Each frame, lerp `cardHoverAnim_[i]` toward `1.0f` if `entryIdx == browserHovered_` else `0.0f` (`speed ~ 14.0f * dt`).
- Offset the card's local Z by `-0.012f * cardHoverAnim_[i]` (towards user) so hovered cards float forward.
### 3.3. Header Navigation & Breadcrumbs (`src/gfx/vulkan_renderer.cpp`)
- **Dedicated `[ ↑ Up ]` Button in Header:**
- Add `browserPageBtnHovered_ = 4` at `cx = -0.43f, cy = 0.455f, halfW = 0.042f, halfH = 0.028f` labeled `↑ Up`.
- In `src/app/app.cpp`, clicking it navigates up if `entries()[0].isBack` is present, allowing one-click parent folder return from any scroll page.
- **Segmented Breadcrumb Styling:**
- Replace raw path strings (`/home/steamos/Videos/Sub`) with pill segments: `Home › Videos › Subfolder` or `SMB › MediaServer › 3D Movies`.
- **Right-Edge Scrollbar Track & Thumb:**
- When `total > perPage`, draw a slim vertical track at `cx = 0.875f, cy = -0.02f, halfW = 0.004f, halfH = 0.34f` with an accent thumb sized to `perPage / total`.
### 3.4. Settings View Visual Controls (`src/gfx/vulkan_renderer.cpp`)
- **Toggle Switches:** Draw pill switches (`halfW = 0.036f, halfH = 0.018f`) with sliding round knobs for `ON` / `OFF` and `Visible` / `Hidden` settings instead of plain text.
- **Color Swatches:** In `Settings > UI & Appearance`, render circular color dots for `Text Color` and `Accent Theme` with a white halo ring on the active selection.
- **Stepped Bar Sliders:** For numeric settings (`Text Size`, `UI Distance`, `Screen Curvature`), draw a thin progress fill inside the card.
### 3.5. Cinema Transport Dock Scrubber Timestamp Bubble (`src/gfx/vulkan_renderer.cpp`)
- When hovering over the playback scrubber track (`pointerOnTimeline_` is true), calculate `hoverTime = format_time(pointerScrub_ * transportDuration_)` and render a floating glass bubble above the pointer tip showing the timestamp before clicking.
---
## 4. Architectural & Performance Improvements
### 4.1. High-Priority Visible Thumbnail Loading (`ThumbnailCache`)
- **Problem:** In large folders (50+ videos), `thumbnailCache_.request()` queues items in FIFO order. Visible cards on page 3 or 4 must wait behind 30 unseen videos.
- **Solution:**
1. In `src/ui/thumbnail_cache.hpp`, change `std::queue<std::string> queue_` to `std::deque<std::string> queue_`.
2. In `ThumbnailCache::request(const std::string& videoPath, bool highPriority = false)`:
```cpp
if (highPriority) {
queue_.erase(std::remove(queue_.begin(), queue_.end(), videoPath), queue_.end());
queue_.push_front(videoPath);
} else {
queue_.push_back(videoPath);
}
```
3. In `src/app/app.cpp`, query visible slot indices `curFirst .. curFirst + numSlots - 1` and pass `highPriority = true`.
### 4.2. Top-Bottom (Over-Under) 3D Thumbnail Un-Squishing
- In `ThumbnailCache::extractThumbnailAndMeta` (`src/ui/thumbnail_cache.cpp`):
```cpp
const bool isTb = (lowerPath.find("tb") != std::string::npos ||
lowerPath.find("ou") != std::string::npos ||
lowerPath.find("overunder") != std::string::npos ||
lowerPath.find("htab") != std::string::npos);
if (isSbs && srcW >= 2 * kThumbW) {
srcW = frame->width / 2;
} else if (isTb && srcH >= 2 * kThumbH) {
srcH = frame->height / 2;
}
```
### 4.3. Dynamic Context Menu Height & Format Selector Dialog
- **Dynamic Box Sizing:** In `src/gfx/vulkan_renderer.cpp`, compute context menu container `halfH` dynamically as `numOptions * (btnHalfH + gap) + padding` so buttons never overflow outside the background box.
- **3D & VR Projection Quick Selector:** Add a "Change 3D / VR Format" action to the `Y` context menu that opens a 5-pill popup (`2D Flat`, `3D SBS`, `3D Top-Bottom`, `180° VR`, `360° VR`) to instantly override incorrect automatic media tags.
### 4.4. Spatial Haptic Feedback Integration
- OpenXR haptic pulses make laser interactions feel responsive and physical:
- **Button Hover Enter:** Trigger short tick: `xr_.triggerHaptic(hand, 0.008f, 0.0f, 0.12f)`.
- **Button Click / Toggle:** Trigger crisp confirmation click: `xr_.triggerHaptic(hand, 0.025f, 0.0f, 0.45f)`.
- **Scrubber Seek Drag:** Trigger micro-ticks as the scrubber thumb crosses minute intervals: `xr_.triggerHaptic(hand, 0.005f, 0.0f, 0.08f)`.
### 4.5. Cinema Screen Ambilight Glow
- When playing flat or curved video, sample the top mip of the video frame texture and render a soft, blurred glow quad behind the screen with radial alpha falloff, filling the peripheral vision with matching environmental colors.
---
## 5. Build, Test & Headset Deployment Reference
- **Device IP:** `192.168.178.61`
- **SSH Key:** `$HOME\AppData\Local\steamos-devkit\steamos-devkit\devkit_rsa`
- **One-Line Remote Build & Deploy Command:**
```powershell
ssh -i "$HOME\AppData\Local\steamos-devkit\steamos-devkit\devkit_rsa" -o StrictHostKeyChecking=no steamos@192.168.178.61 "cmake --build ~/pixelvr-src/build --parallel && cp ~/pixelvr-src/build/bin/pixelvr ~/devkit-game/PixelVRPlayer/pixelvr.real && cp ~/pixelvr-src/build/bin/pixelvr ~/PixelVRPlayer/pixelvr && cp ~/pixelvr-src/build/shaders/*.spv ~/devkit-game/PixelVRPlayer/shaders/ && cp ~/pixelvr-src/build/shaders/*.spv ~/PixelVRPlayer/shaders/ && echo '=== BUILD & DEPLOY COMPLETE ==='"
```
+208 -97
View File
@@ -373,10 +373,14 @@ void App::loadMedia(const std::string& path) {
void App::switchTracks(bool byLanguage) {
if (currentMediaPath_.empty() || !mediaOpen_) return;
const double pos = media_.position();
media_.setPreferredAudioLanguage(settings_.audioLanguage());
if (byLanguage) {
media_.setPreferredAudioLanguage(settings_.audioLanguage());
media_.setPreferredAudioStream(-1);
} else {
media_.setPreferredAudioLanguage("");
media_.setPreferredAudioStream(settings_.selectedAudioStream());
}
media_.setPreferredVideoStream(settings_.selectedVideoStream());
media_.setPreferredAudioStream(byLanguage ? -1
: settings_.selectedAudioStream());
loadMedia(currentMediaPath_); // same file: keeps the preferences set above
if (pos > 0.5 && media_.duration() > 0.0) media_.seek(pos);
}
@@ -627,14 +631,30 @@ void App::updateSmbModalMenu() {
actionMenu_.title = smbModal_.isNew ? "Add SMB Network Share" : "Edit SMB Network Share";
actionMenu_.subtitle = "Point & click a field to enter text via keyboard";
actionMenu_.options.clear();
actionMenu_.editingRow = -1;
actionMenu_.cursorPos = 0;
actionMenu_.options.push_back("Server IP: " + (smbModal_.host.empty() ? "[ Enter IPv4 Address ]" : smbModal_.host));
actionMenu_.options.push_back("Share Name: " + (smbModal_.share.empty() ? "[ All Shares ]" : smbModal_.share));
const bool kbActive = VrKeyboard::instance().isOpen();
const std::string liveText = VrKeyboard::instance().currentText();
const int liveCur = VrKeyboard::instance().cursorPos();
auto fieldVal = [&](SmbModalState::Field f, int rowIdx, const std::string& saved, const std::string& placeholder, bool mask) {
if (kbActive && smbModal_.editingField == f) {
actionMenu_.editingRow = rowIdx;
actionMenu_.cursorPos = liveCur;
return mask ? std::string(liveText.length(), '*') : liveText;
}
if (saved.empty()) return placeholder;
return mask ? std::string(saved.length(), '*') : saved;
};
actionMenu_.options.push_back("Server / Host: " + fieldVal(SmbModalState::Field::Host, 0, smbModal_.host, "[ Enter IP or Hostname ]", false));
actionMenu_.options.push_back("Share Name: " + fieldVal(SmbModalState::Field::Share, 1, smbModal_.share, "[ All Shares ]", false));
actionMenu_.options.push_back("Authentication: " + std::string(smbModal_.anon ? "Guest / Anonymous" : "Username & Password"));
actionMenu_.options.push_back("Username: " + (smbModal_.user.empty() ? "[ Enter Username ]" : smbModal_.user));
actionMenu_.options.push_back("Password: " + (smbModal_.pass.empty() ? "[ Enter Password ]" : std::string(smbModal_.pass.length(), '*')));
actionMenu_.options.push_back("Workgroup: " + smbModal_.workgroup);
actionMenu_.options.push_back("Nickname: " + (smbModal_.label.empty() ? "[ Optional Nickname ]" : smbModal_.label));
actionMenu_.options.push_back("Username: " + fieldVal(SmbModalState::Field::User, 3, smbModal_.user, "[ Enter Username ]", false));
actionMenu_.options.push_back("Password: " + fieldVal(SmbModalState::Field::Pass, 4, smbModal_.pass, "[ Enter Password ]", true));
actionMenu_.options.push_back("Workgroup: " + fieldVal(SmbModalState::Field::Workgroup, 5, smbModal_.workgroup, "WORKGROUP", false));
actionMenu_.options.push_back("Nickname: " + fieldVal(SmbModalState::Field::Label, 6, smbModal_.label, "[ Optional Nickname ]", false));
actionMenu_.options.push_back("[ Save SMB Share ]");
if (!smbModal_.isNew) {
@@ -646,16 +666,20 @@ void App::updateSmbModalMenu() {
void App::saveSmbModal() {
#ifdef PIXELVR_HAVE_FREETYPE
while (!smbModal_.host.empty() &&
(std::isspace(static_cast<unsigned char>(smbModal_.host.front())) ||
smbModal_.host.front() == '/' || smbModal_.host.front() == '\\')) {
smbModal_.host.erase(smbModal_.host.begin());
}
while (!smbModal_.host.empty() &&
(std::isspace(static_cast<unsigned char>(smbModal_.host.back())) ||
smbModal_.host.back() == '/' || smbModal_.host.back() == '\\')) {
smbModal_.host.pop_back();
}
if (smbModal_.host.empty()) {
actionMenu_.subtitle = "Error: Server IPv4 address is required!";
actionMenu_.subtitle = "Error: Server IP or Hostname is required!";
return;
}
std::string cleanHost = sanitizeIpv4(smbModal_.host, true);
if (!isValidIpv4(cleanHost)) {
actionMenu_.subtitle = "Error: Invalid IPv4 address (e.g. 192.168.1.100)";
return;
}
smbModal_.host = cleanHost;
if (!smbModal_.isNew && (smbModal_.host != smbModal_.origHost || smbModal_.share != smbModal_.origShare)) {
SmbCredentialsManager::instance().deleteShare(smbModal_.origHost, smbModal_.origShare);
}
@@ -692,6 +716,22 @@ void App::deleteSmbModal() {
#endif
}
void App::openKeyboard(const std::string& prompt,
const std::string& initialText,
bool isPassword,
const XrInputState& in,
std::function<void(const std::string&)> onDone,
std::function<void()> onCancel) {
kbOnDone_ = std::move(onDone);
kbOnCancel_ = std::move(onCancel);
float stagePos[3]{0.0f, 1.65f, 0.0f};
float stageFwd[3]{0.0f, 0.0f, -1.0f};
const float* fwd = renderer_.screenForward();
xr_.transformToStage(in.headPos, fwd, stagePos, stageFwd);
VrKeyboard::instance().show(prompt, initialText, isPassword, 256,
in.headPos, fwd, stagePos, stageFwd);
}
void App::openActionMenu() {
#ifdef PIXELVR_HAVE_FREETYPE
const Browser::Entry* fe = browser_.focused();
@@ -722,7 +762,6 @@ void App::openActionMenu() {
} else {
actionMenu_.options.push_back("Add to Bookmarks");
}
actionMenu_.options.push_back("Go to Bookmarks");
actionMenu_.options.push_back("Cancel");
return;
}
@@ -749,7 +788,6 @@ void App::openActionMenu() {
} else {
actionMenu_.options.push_back(fe->isDir ? "Add to Bookmarks" : "Add Folder to Bookmarks");
}
actionMenu_.options.push_back("Go to Bookmarks");
if (!browser_.onNetwork()) {
if (VaultManager::instance().isUnlocked() && !browser_.inVault()) {
actionMenu_.options.push_back("Move to Vault");
@@ -811,54 +849,54 @@ int App::run() {
// Poll native SteamVR virtual keyboard events if active
VrKeyboard::instance().poll(
[this](const std::string& text) {
if (smbModal_.active) {
if (smbModal_.editingField == SmbModalState::Field::Host) {
smbModal_.host = sanitizeIpv4(text, true);
} else if (smbModal_.editingField == SmbModalState::Field::Share) {
std::string s = text;
while (!s.empty() && (s.front() == '/' || s.front() == '\\')) s.erase(s.begin());
while (!s.empty() && (s.back() == '/' || s.back() == '\\')) s.pop_back();
smbModal_.share = s;
} else if (smbModal_.editingField == SmbModalState::Field::User) {
smbModal_.user = text;
} else if (smbModal_.editingField == SmbModalState::Field::Pass) {
smbModal_.pass = text;
} else if (smbModal_.editingField == SmbModalState::Field::Workgroup) {
smbModal_.workgroup = text.empty() ? "WORKGROUP" : text;
} else if (smbModal_.editingField == SmbModalState::Field::Label) {
smbModal_.label = text;
}
smbModal_.editingField = SmbModalState::Field::None;
updateSmbModalMenu();
if (kbOnDone_) {
auto cb = std::move(kbOnDone_);
kbOnDone_ = nullptr;
kbOnCancel_ = nullptr;
cb(text);
} else if (settings_.isOpen()) {
settings_.onKeyboardSubmit(text);
}
},
[this]() {
if (smbModal_.active) {
smbModal_.editingField = SmbModalState::Field::None;
updateSmbModalMenu();
} else if (settings_.isOpen()) {
settings_.onKeyboardCancel();
if (kbOnCancel_) {
auto cb = std::move(kbOnCancel_);
kbOnDone_ = nullptr;
kbOnCancel_ = nullptr;
cb();
} else {
kbOnDone_ = nullptr;
if (settings_.isOpen()) {
settings_.onKeyboardCancel();
}
}
});
const bool kbOpen = VrKeyboard::instance().isOpen();
std::string kbDisplayText = VrKeyboard::instance().currentText();
if (smbModal_.active && smbModal_.editingField == SmbModalState::Field::Host) {
kbDisplayText = sanitizeIpv4(kbDisplayText, false);
}
renderer_.setKeyboardModal(kbOpen,
VrKeyboard::instance().description(),
kbDisplayText,
VrKeyboard::instance().isPassword(),
kbOpen ? VrKeyboard::instance().transform() : nullptr);
if (kbOpen) {
const int kbDx = navStepX(in.thumbstickX, nowT);
if (kbDx != 0) {
VrKeyboard::instance().moveCursor(kbDx);
xr_.triggerHaptic(0, 0.012f, 0.0f, 0.35f);
xr_.triggerHaptic(1, 0.012f, 0.0f, 0.35f);
}
in.thumbstickX = 0.0f;
in.thumbstickY = 0.0f;
in.select = false;
in.selectHeld = false;
in.togglePlay = false;
in.browserToggle = false;
if (smbModal_.active && smbModal_.editingField != SmbModalState::Field::None) {
updateSmbModalMenu();
}
}
std::string kbDisplayText = VrKeyboard::instance().currentText();
renderer_.setKeyboardModal(kbOpen,
VrKeyboard::instance().description(),
kbDisplayText,
VrKeyboard::instance().isPassword(),
kbOpen ? VrKeyboard::instance().transform() : nullptr,
VrKeyboard::instance().cursorPos());
// Custom combo recording listener (active while recording new 6-button combo in Vault & Privacy)
if (VaultManager::instance().isRecordingCombo()) {
@@ -1476,6 +1514,7 @@ int App::run() {
}
renderer_.setBrowserNetwork(browser_.onNetwork());
renderer_.setBrowserQuickFilter(browser_.activeFilter());
std::string status = browser_.status();
if (browser_.onNetwork() && !browser_.isBusy()) {
if (status.empty()) {
@@ -1492,8 +1531,15 @@ int App::run() {
settings_.listShowSize(),
settings_.listShowDuration()
);
int restoredFirst = 0;
if (browser_.consumeRestoredFirst(restoredFirst)) {
renderer_.setBrowserFirst(restoredFirst);
}
renderer_.setBrowserItems(true, browser_.title(), items,
browser_.selected(), status);
if (!browser_.isScanning()) {
browser_.setFirst(renderer_.browserFirst());
}
// Feed ready thumbnails to the visible slots (6 for BigCards, 12 for MiniCards, 8 for List)
const int curFirst = renderer_.browserFirst();
@@ -1574,7 +1620,36 @@ int App::run() {
// Keep laser active when UI panels, activity, or virtual keyboard are active!
const bool kbActive = VrKeyboard::instance().isOpen();
if (kbActive) {
if (!renderer_.laserOn(0) && !renderer_.laserOn(1)) {
// Auto-focus the laser on whichever hand is actually aiming at the
// keyboard, so the user never has to grip to "activate" a pointer.
// The keyboard's local transform and the controller aim poses live in
// the same space the renderer raycasts in, so we can score each hand by
// how directly it points at the keyboard centre.
const float* kb = VrKeyboard::instance().transform();
const float kbCenter[3] = { kb[3], kb[7], kb[11] };
float score[2] = { -2.0f, -2.0f };
for (int h = 0; h < 2; ++h) {
if (!in.controllers[h].active) continue;
const float fwdLocal[3] = { 0.0f, 0.0f, -1.0f };
float fwd[3];
quatRotate(in.controllers[h].orientation, fwdLocal, fwd);
float d[3] = { kbCenter[0] - in.controllers[h].position[0],
kbCenter[1] - in.controllers[h].position[1],
kbCenter[2] - in.controllers[h].position[2] };
const float dl = std::sqrt(d[0]*d[0] + d[1]*d[1] + d[2]*d[2]);
if (dl > 1e-4f) { d[0] /= dl; d[1] /= dl; d[2] /= dl; }
score[h] = fwd[0]*d[0] + fwd[1]*d[1] + fwd[2]*d[2];
}
const int cur = renderer_.laserOn(1) ? 1 : (renderer_.laserOn(0) ? 0 : -1);
const int best = (score[1] >= score[0]) ? 1 : 0;
if (score[best] > 0.55f && in.controllers[best].active) {
// A hand is pointing at the keyboard: give it the laser, but only
// switch away from the current hand when it is clearly better (hysteresis).
if (cur < 0 || best == cur || score[best] > score[cur] + 0.12f) {
if (!renderer_.laserOn(best)) renderer_.setLaserOn(best, true);
}
} else if (!renderer_.laserOn(0) && !renderer_.laserOn(1)) {
// Neither hand aims at the keyboard yet: make sure some laser is on.
if (in.controllers[1].active) renderer_.setLaserOn(1, true);
else if (in.controllers[0].active) renderer_.setLaserOn(0, true);
} else if (renderer_.laserOn(1) && !in.controllers[1].active && in.controllers[0].active) {
@@ -1804,11 +1879,34 @@ int App::run() {
actionMenu_.options.push_back("Cancel");
actionMenu_.selectedOption = 0;
} else if (opt == "Rename") {
actionMenu_.page = 3;
actionMenu_.title = (fe && fe->isDir) ? "Rename Folder" : "Rename File";
actionMenu_.subtitle = fe ? fe->label : "";
actionMenu_.renameBuffer = fe ? fe->label : "";
actionMenu_.selectedOption = -1;
actionMenu_.visible = false;
if (fe) {
std::string curName = fe->label;
while (!curName.empty() && (curName.back() == '/' || curName.back() == '\\')) {
curName.pop_back();
}
const bool isDir = fe->isDir;
openKeyboard(
isDir ? "Rename Folder" : "Rename File",
curName,
false,
in,
[this](const std::string& newText) {
std::string clean = newText;
while (!clean.empty() && (std::isspace(static_cast<unsigned char>(clean.front())) || clean.front() == '/' || clean.front() == '\\')) {
clean.erase(clean.begin());
}
while (!clean.empty() && (std::isspace(static_cast<unsigned char>(clean.back())) || clean.back() == '/' || clean.back() == '\\')) {
clean.pop_back();
}
if (!clean.empty()) {
if (browser_.renameFocused(clean)) {
xr_.triggerHaptic(0, 0.050f, 0.0f, 0.7f);
xr_.triggerHaptic(1, 0.050f, 0.0f, 0.7f);
}
}
});
}
} else if (opt == "Delete") {
actionMenu_.page = 2;
actionMenu_.title = (fe && fe->isDir) ? "Delete Folder?" : "Delete File?";
@@ -1867,41 +1965,6 @@ int App::run() {
}
interacted = true;
}
} else if (actionMenu_.page == 3) {
const int btnHov = renderer_.actionMenuBtnHovered();
const int keyHov = renderer_.actionMenuKeyHovered();
if (in.select || in.togglePlay) {
if (btnHov == 0) {
actionMenu_.renameBuffer.clear();
xr_.triggerHaptic(0, 0.020f, 0.0f, 0.5f);
xr_.triggerHaptic(1, 0.020f, 0.0f, 0.5f);
} else if (btnHov == 1) {
if (!actionMenu_.renameBuffer.empty()) {
actionMenu_.renameBuffer.pop_back();
xr_.triggerHaptic(0, 0.020f, 0.0f, 0.5f);
xr_.triggerHaptic(1, 0.020f, 0.0f, 0.5f);
}
} else if (btnHov == 2) {
if (!actionMenu_.renameBuffer.empty()) {
if (browser_.renameFocused(actionMenu_.renameBuffer)) {
xr_.triggerHaptic(0, 0.050f, 0.0f, 0.7f);
xr_.triggerHaptic(1, 0.050f, 0.0f, 0.7f);
}
actionMenu_.visible = false;
}
} else if (btnHov == 3) {
actionMenu_.visible = false;
} else if (keyHov >= 0) {
static const char* kKeys = "1234567890-_QWERTYUIOPASDFGHJKLZXCVBNM. ";
if (keyHov < static_cast<int>(std::strlen(kKeys))) {
actionMenu_.renameBuffer.push_back(kKeys[keyHov]);
xr_.triggerHaptic(0, 0.015f, 0.0f, 0.4f);
xr_.triggerHaptic(1, 0.015f, 0.0f, 0.4f);
}
}
interacted = true;
}
} else if (actionMenu_.page == 4) {
// SMB Share Editor Modal
const int dy = navStep(in.thumbstickY, nowT);
@@ -1922,11 +1985,42 @@ int App::run() {
auto openKeyboardForField = [&](SmbModalState::Field field, const std::string& desc, const std::string& currentVal, bool isPass) {
smbModal_.editingField = field;
float stagePos[3]{0.0f, 1.65f, 0.0f};
float stageFwd[3]{0.0f, 0.0f, -1.0f};
const float* fwd = renderer_.screenForward();
xr_.transformToStage(in.headPos, fwd, stagePos, stageFwd);
VrKeyboard::instance().show(desc, currentVal, isPass, 256, in.headPos, fwd, stagePos, stageFwd);
openKeyboard(
desc,
currentVal,
isPass,
in,
[this, field](const std::string& text) {
if (!smbModal_.active) return;
if (field == SmbModalState::Field::Host) {
std::string h = text;
if (h.rfind("smb://", 0) == 0) h = h.substr(6);
while (!h.empty() && (std::isspace(static_cast<unsigned char>(h.front())) || h.front() == '/' || h.front() == '\\')) h.erase(h.begin());
while (!h.empty() && (std::isspace(static_cast<unsigned char>(h.back())) || h.back() == '/' || h.back() == '\\')) h.pop_back();
smbModal_.host = h;
} else if (field == SmbModalState::Field::Share) {
std::string s = text;
while (!s.empty() && (s.front() == '/' || s.front() == '\\')) s.erase(s.begin());
while (!s.empty() && (s.back() == '/' || s.back() == '\\')) s.pop_back();
smbModal_.share = s;
} else if (field == SmbModalState::Field::User) {
smbModal_.user = text;
} else if (field == SmbModalState::Field::Pass) {
smbModal_.pass = text;
} else if (field == SmbModalState::Field::Workgroup) {
smbModal_.workgroup = text.empty() ? "WORKGROUP" : text;
} else if (field == SmbModalState::Field::Label) {
smbModal_.label = text;
}
smbModal_.editingField = SmbModalState::Field::None;
updateSmbModalMenu();
},
[this]() {
if (smbModal_.active) {
smbModal_.editingField = SmbModalState::Field::None;
updateSmbModalMenu();
}
});
};
if (chosen == 0) {
@@ -1994,9 +2088,11 @@ int App::run() {
(renderer_.browserViewMode() == VulkanRenderer::BrowserViewMode::MiniCards ? 3 : 8);
if (pbHover == 0) {
renderer_.scrollBrowser(-pageStep);
browser_.setFirst(renderer_.browserFirst());
browser_.setSelected(renderer_.browserFirst());
} else if (pbHover == 1) {
renderer_.scrollBrowser(pageStep);
browser_.setFirst(renderer_.browserFirst());
browser_.setSelected(renderer_.browserFirst());
} else if (pbHover == 2) {
// Cycle View Mode: BigCards (0) -> MiniCards (1) -> CompactList (2)
@@ -2216,7 +2312,22 @@ int App::run() {
}
} else if (cb == VulkanRenderer::CtrlButton::AudioToggle) {
if (mediaOpen_ && media_.audioStreams().size() > 1) {
const auto& streams = media_.audioStreams();
int curStream = media_.currentAudioStream();
int nextIdx = 0;
for (size_t i = 0; i < streams.size(); ++i) {
if (streams[i].index == curStream) {
nextIdx = static_cast<int>((i + 1) % streams.size());
break;
}
}
int nextStreamIndex = streams[nextIdx].index;
settings_.setSelectedAudioStream(nextStreamIndex);
media_.setPreferredAudioStream(nextStreamIndex);
media_.setPreferredAudioLanguage("");
switchTracks(false);
xr_.triggerHaptic(0, 0.02f, 0.0f, 0.5f);
xr_.triggerHaptic(1, 0.02f, 0.0f, 0.5f);
}
} else if (cb == VulkanRenderer::CtrlButton::ScreenToggle) {
if (settings_.projectionMode() == 0) {
+10
View File
@@ -16,6 +16,7 @@
#include "ui/settings_menu.hpp"
#include <chrono>
#include <functional>
#include <string>
namespace pixelvr {
@@ -122,6 +123,15 @@ private:
void saveSmbModal();
void deleteSmbModal();
std::function<void(const std::string&)> kbOnDone_;
std::function<void()> kbOnCancel_;
void openKeyboard(const std::string& prompt,
const std::string& initialText,
bool isPassword,
const XrInputState& in,
std::function<void(const std::string&)> onDone,
std::function<void()> onCancel = nullptr);
#ifdef PIXELVR_HAVE_FREETYPE
Browser browser_;
ThumbnailCache thumbnailCache_;
+21 -5
View File
@@ -36,7 +36,7 @@ FontAtlas buildFontAtlas(const char* ttfPath, int pixelHeight) {
FT_Set_Pixel_Sizes(face, 0, static_cast<FT_UInt>(pixelHeight));
constexpr int kFirst = 32;
constexpr int kLast = 127;
constexpr int kLast = 255;
constexpr int kCols = 16;
const int count = kLast - kFirst + 1;
const int rows = (count + kCols - 1) / kCols;
@@ -49,11 +49,27 @@ FontAtlas buildFontAtlas(const char* ttfPath, int pixelHeight) {
atlas.ascent = face->size->metrics.ascender / 64.0f;
for (int ch = kFirst; ch <= kLast; ++ch) {
FT_ULong codepoint = (ch == 127) ? 0x2022UL : static_cast<FT_ULong>(ch);
if (ch >= 137 && ch < 160) {
continue;
}
FT_ULong codepoint = static_cast<FT_ULong>(ch);
if (ch == 127) codepoint = 0x2022UL; // bullet (•)
else if (ch == 128) codepoint = 0x20ACUL; // Euro (€)
else if (ch == 129) codepoint = 0x2013UL; // en-dash (–)
else if (ch == 130) codepoint = 0x2014UL; // em-dash (—)
else if (ch == 131) codepoint = 0x2018UL; // left single quote (‘)
else if (ch == 132) codepoint = 0x2019UL; // right single quote (’)
else if (ch == 133) codepoint = 0x201CUL; // left double quote (“)
else if (ch == 134) codepoint = 0x201DUL; // right double quote (”)
else if (ch == 135) codepoint = 0x2026UL; // ellipsis (…)
else if (ch == 136) codepoint = 0x2122UL; // trademark (™)
if (FT_Load_Char(face, codepoint, FT_LOAD_DEFAULT) != 0) {
if (ch == 127 && FT_Load_Char(face, 0x00B7UL, FT_LOAD_DEFAULT) != 0) {
continue;
} else if (ch != 127) {
if (ch == 127 && FT_Load_Char(face, 0x00B7UL, FT_LOAD_DEFAULT) == 0) {
// fallback to middle dot
} else if (ch == 160 && FT_Load_Char(face, 0x0020UL, FT_LOAD_DEFAULT) == 0) {
// fallback to regular space for NBSP
} else {
continue;
}
}
+3 -2
View File
@@ -13,12 +13,13 @@ struct Glyph {
float advance = 0.0f;
};
// An R8 coverage atlas of ASCII 32..126 plus per-glyph metrics.
// An R8 coverage atlas of ASCII 32..126 + Latin-1 Supplement (160..255, e.g. ß/ä/ö/ü)
// plus per-glyph metrics.
struct FontAtlas {
int width = 0;
int height = 0;
std::vector<uint8_t> pixels; // single channel (SDF, or coverage fallback)
Glyph glyphs[128]; // indexed by ASCII code
Glyph glyphs[256]; // indexed by codepoint / slot (0..255)
float lineHeight = 0.0f;
float ascent = 0.0f;
bool sdf = false; // true = signed-distance field (crisp at scale)
+319 -248
View File
@@ -1369,11 +1369,13 @@ void VulkanRenderer::updatePointer() {
if (actionMenu_.page == 0 || actionMenu_.page == 1) {
const int optCount = static_cast<int>(actionMenu_.options.size());
const float startY = (optCount > 4) ? 0.12f : 0.08f;
const float stepY = 0.068f;
const float spanY = static_cast<float>(std::max(0, optCount - 1)) * stepY;
const float startY = -0.040f + 0.5f * spanY;
const float btnHalfW = 0.38f;
const float btnHalfH = 0.030f;
for (int i = 0; i < optCount; ++i) {
const float cy = startY - static_cast<float>(i) * 0.072f;
const float cy = startY - static_cast<float>(i) * stepY;
if (std::fabs(hx) <= btnHalfW && std::fabs(hyy - cy) <= btnHalfH) {
actionMenuBtnHovered_ = i;
break;
@@ -1388,37 +1390,6 @@ void VulkanRenderer::updatePointer() {
if (std::fabs(hx - (-0.15f)) <= btnHalfW) actionMenuBtnHovered_ = 0;
else if (std::fabs(hx - 0.15f) <= btnHalfW) actionMenuBtnHovered_ = 1;
}
} else if (actionMenu_.page == 3) {
// Rename page: Top action buttons at cy = 0.08f
if (std::fabs(hyy - 0.08f) <= 0.028f) {
if (std::fabs(hx - (-0.27f)) <= 0.07f) actionMenuBtnHovered_ = 0; // Clear
else if (std::fabs(hx - (-0.09f)) <= 0.09f) actionMenuBtnHovered_ = 1; // Backspace
else if (std::fabs(hx - 0.11f) <= 0.09f) actionMenuBtnHovered_ = 2; // Save
else if (std::fabs(hx - 0.28f) <= 0.07f) actionMenuBtnHovered_ = 3; // Cancel
}
// On-screen keyboard keys: 4 rows
static const char* kRow0 = "1234567890-_";
static const char* kRow1 = "QWERTYUIOP";
static const char* kRow2 = "ASDFGHJKL";
static const char* kRow3 = "ZXCVBNM. ";
auto checkRow = [&](const char* rowKeys, float rowY, int keyOffset, float keyW, float keySpacing) {
const int count = static_cast<int>(std::strlen(rowKeys));
const float totalW = count * keyW + (count - 1) * keySpacing;
const float startX = -0.5f * totalW + 0.5f * keyW;
if (std::fabs(hyy - rowY) <= 0.026f) {
for (int k = 0; k < count; ++k) {
const float kx = startX + k * (keyW + keySpacing);
if (std::fabs(hx - kx) <= keyW * 0.5f) {
actionMenuKeyHovered_ = keyOffset + k;
break;
}
}
}
};
checkRow(kRow0, 0.005f, 0, 0.055f, 0.010f);
checkRow(kRow1, -0.055f, 12, 0.065f, 0.012f);
checkRow(kRow2, -0.115f, 22, 0.070f, 0.012f);
checkRow(kRow3, -0.175f, 31, 0.070f, 0.012f);
} else if (actionMenu_.page == 4) {
// SMB Share Editor Modal: Top fields 0..6: cy = 0.19f - i * 0.054f
const int optCount = static_cast<int>(actionMenu_.options.size());
@@ -1633,46 +1604,30 @@ void VulkanRenderer::updatePointer() {
}
}
} else if (settingsVisible_) {
// Settings cards in 2-column grid (or list when >6 items)
// Settings cards in 2-column grid
const int total = static_cast<int>(settingsRows_.size());
if (total > 0 && total <= 6) {
const float sColX[2] = {-0.135f, 0.535f};
const float sRowY[3] = {0.22f, -0.02f, -0.26f};
for (int i = 0; i < total; ++i) {
const int c = i % 2;
const int r = i / 2;
if (std::fabs(hx - sColX[c]) <= 0.315f &&
std::fabs(hyy - sRowY[r]) <= 0.098f) {
settingsRow_ = i;
settingsHovered_ = i;
break;
}
const int maxVis = (total > 8) ? 10 : 8;
for (int i = 0; i < total && i < maxVis; ++i) {
const int c = i % 2;
const int r = i / 2;
const float cx = (c == 0) ? -0.135f : 0.535f;
float ry = 0.0f;
float cardHalfH = 0.0f;
if (total <= 6) {
ry = 0.22f - static_cast<float>(r) * 0.24f;
cardHalfH = 0.098f;
} else if (total <= 8) {
ry = 0.26f - static_cast<float>(r) * 0.185f;
cardHalfH = 0.078f;
} else {
ry = 0.28f - static_cast<float>(r) * 0.152f;
cardHalfH = 0.066f;
}
} else if (total > 6 && total <= 8) {
for (int i = 0; i < total && i < 8; ++i) {
const int c = i % 2;
const int r = i / 2;
const float cx = (c == 0) ? -0.135f : 0.535f;
const float ry = 0.25f - static_cast<float>(r) * 0.17f;
if (std::fabs(hx - cx) <= 0.315f &&
std::fabs(hyy - ry) <= 0.072f) {
settingsRow_ = i;
settingsHovered_ = i;
break;
}
}
} else if (total > 8) {
for (int i = 0; i < total && i < 10; ++i) {
const int c = i % 2;
const int r = i / 2;
const float cx = (c == 0) ? -0.135f : 0.535f;
const float ry = 0.27f - static_cast<float>(r) * 0.142f;
if (std::fabs(hx - cx) <= 0.315f &&
std::fabs(hyy - ry) <= 0.058f) {
settingsRow_ = i;
settingsHovered_ = i;
break;
}
if (std::fabs(hx - cx) <= 0.315f &&
std::fabs(hyy - ry) <= cardHalfH) {
settingsRow_ = i;
settingsHovered_ = i;
break;
}
}
}
@@ -2192,17 +2147,73 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex,
// Standardized typography helpers (never shrink font dynamically across buttons;
// truncate cleanly with ellipsis if text exceeds maxW).
auto mapChar = [](char rawc) -> unsigned char {
unsigned char c = static_cast<unsigned char>(rawc);
if (c == 0xB7) return 127; // middle dot / bullet slot in atlas
if (c < 32 || c > 127) return '?';
return c;
auto nextGlyphIndex = [](const std::string& s, std::size_t& idx) -> unsigned char {
const std::size_t len = s.size();
if (idx >= len) return '?';
const unsigned char b0 = static_cast<unsigned char>(s[idx]);
if (b0 < 0x80) {
++idx;
if (b0 < 32 || b0 == 127) return '?';
return b0;
}
// Legacy standalone 0xB7 middle-dot separator used in UI strings
if (b0 == 0xB7) {
++idx;
return 127;
}
uint32_t cp = 0;
if ((b0 & 0xE0) == 0xC0 && idx + 1 < len) {
const unsigned char b1 = static_cast<unsigned char>(s[idx + 1]);
if ((b1 & 0xC0) == 0x80) {
cp = ((static_cast<uint32_t>(b0 & 0x1F) << 6) |
static_cast<uint32_t>(b1 & 0x3F));
idx += 2;
}
} else if ((b0 & 0xF0) == 0xE0 && idx + 2 < len) {
const unsigned char b1 = static_cast<unsigned char>(s[idx + 1]);
const unsigned char b2 = static_cast<unsigned char>(s[idx + 2]);
if ((b1 & 0xC0) == 0x80 && (b2 & 0xC0) == 0x80) {
cp = ((static_cast<uint32_t>(b0 & 0x0F) << 12) |
(static_cast<uint32_t>(b1 & 0x3F) << 6) |
static_cast<uint32_t>(b2 & 0x3F));
idx += 3;
}
} else if ((b0 & 0xF8) == 0xF0 && idx + 3 < len) {
const unsigned char b1 = static_cast<unsigned char>(s[idx + 1]);
const unsigned char b2 = static_cast<unsigned char>(s[idx + 2]);
const unsigned char b3 = static_cast<unsigned char>(s[idx + 3]);
if ((b1 & 0xC0) == 0x80 && (b2 & 0xC0) == 0x80 && (b3 & 0xC0) == 0x80) {
cp = ((static_cast<uint32_t>(b0 & 0x07) << 18) |
(static_cast<uint32_t>(b1 & 0x3F) << 12) |
(static_cast<uint32_t>(b2 & 0x3F) << 6) |
static_cast<uint32_t>(b3 & 0x3F));
idx += 4;
}
}
if (cp == 0) {
++idx;
if (b0 >= 160) return b0;
return '?';
}
if (cp == 0x00B7 || cp == 0x2022) return 127;
if (cp >= 160 && cp <= 255) return static_cast<unsigned char>(cp);
if (cp == 0x20AC) return 128; // €
if (cp == 0x2013) return 129; // –
if (cp == 0x2014) return 130; // —
if (cp == 0x2018) return 131; // ‘
if (cp == 0x2019) return 132; // ’
if (cp == 0x201C) return 133; // “
if (cp == 0x201D) return 134; // ”
if (cp == 0x2026) return 135; // …
if (cp == 0x2122) return 136; // ™
return '?';
};
auto drawString = [&](float x, float baseline, float scale, const float col[4],
const std::string& s) {
float penX = x;
for (char rawc : s) {
unsigned char c = mapChar(rawc);
std::size_t idx = 0;
while (idx < s.size()) {
unsigned char c = nextGlyphIndex(s, idx);
const Glyph& g = fontAtlas_.glyphs[c];
if (g.width > 0.0f && g.height > 0.0f) {
TextPush push{};
@@ -2227,8 +2238,9 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex,
};
auto measureString = [&](float scale, const std::string& s) -> float {
float tw = 0.0f;
for (char rawc : s) {
unsigned char c = mapChar(rawc);
std::size_t idx = 0;
while (idx < s.size()) {
unsigned char c = nextGlyphIndex(s, idx);
tw += fontAtlas_.glyphs[c].advance * scale;
}
return tw;
@@ -2238,7 +2250,13 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex,
if (measureString(scale, str) <= maxW) return str;
std::string trunc = str;
while (trunc.size() > 1 && measureString(scale, trunc + "...") > maxW) {
trunc.pop_back();
while (!trunc.empty() &&
(static_cast<unsigned char>(trunc.back()) & 0xC0) == 0x80) {
trunc.pop_back();
}
if (!trunc.empty()) {
trunc.pop_back();
}
}
return trunc + "...";
};
@@ -2258,6 +2276,92 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex,
drawString(cx - finalW * 0.5f, baseline, fixedScale, col, out);
};
struct TextFieldLayout {
std::string visibleStr;
float startX = 0.0f;
float cursorX = 0.0f;
};
auto layoutTextField = [&](float fieldLeft, float fieldRight, float scale,
const std::string& rawText, int cursorPos,
bool isPassword, bool centerIfFits) -> TextFieldLayout {
const float maxW = std::max(0.01f, fieldRight - fieldLeft);
const std::string dispText = isPassword
? std::string(rawText.length(), '*')
: rawText;
const int n = static_cast<int>(dispText.size());
const int c = (cursorPos >= 0) ? std::clamp(cursorPos, 0, n) : n;
auto nextCharPos = [&](int pos) -> int {
if (pos >= n) return n;
int p = pos + 1;
while (p < n && (static_cast<unsigned char>(dispText[p]) & 0xC0) == 0x80) {
++p;
}
return p;
};
auto prevCharPos = [&](int pos) -> int {
if (pos <= 0) return 0;
int p = pos - 1;
while (p > 0 && (static_cast<unsigned char>(dispText[p]) & 0xC0) == 0x80) {
--p;
}
return p;
};
int visStart = 0;
int visEnd = n;
if (measureString(scale, dispText) > maxW) {
visStart = c;
visEnd = c;
auto windowW = [&](int s, int e) {
std::string pre = (s > 0) ? "..." : "";
std::string suf = (e < n) ? "..." : "";
return measureString(scale, pre + dispText.substr(s, e - s) + suf);
};
// Keep up to 3 lookahead characters after the cursor when editing inside text
int lookahead = 0;
while (visEnd < n && lookahead < 3) {
const int nxt = nextCharPos(visEnd);
if (windowW(visStart, nxt) <= maxW) {
visEnd = nxt;
++lookahead;
} else {
break;
}
}
// Expand left from the cursor so the text scrolls to keep the cursor in view
while (visStart > 0) {
const int prv = prevCharPos(visStart);
if (windowW(prv, visEnd) <= maxW) {
visStart = prv;
} else {
break;
}
}
// Fill any remaining space to the right
while (visEnd < n) {
const int nxt = nextCharPos(visEnd);
if (windowW(visStart, nxt) <= maxW) {
visEnd = nxt;
} else {
break;
}
}
}
const std::string prefix = (visStart > 0) ? "..." : "";
const std::string suffix = (visEnd < n) ? "..." : "";
const std::string visibleStr =
prefix + dispText.substr(visStart, visEnd - visStart) + suffix;
const float totalVisW = measureString(scale, visibleStr);
const float startX = (centerIfFits && visStart == 0 && visEnd == n)
? (0.5f * (fieldLeft + fieldRight) - totalVisW * 0.5f)
: fieldLeft;
const std::string beforeCursor =
prefix + dispText.substr(visStart, std::clamp(c, visStart, visEnd) - visStart);
const float cursorX = startX + measureString(scale, beforeCursor);
return {visibleStr, startX, cursorX};
};
const bool hubOpen = browserVisible_ || settingsVisible_ ||
(downloadsVisible_ && !downloadsModal_);
const bool modalOpen = downloadsVisible_ && downloadsModal_;
@@ -2522,12 +2626,8 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex,
themeAccentCol[2] * 0.58f, 0.95f};
const float tabActiveBord[4] = {
themeAccentCol[0], themeAccentCol[1], themeAccentCol[2], 1.0f};
const float tabHovBg[4] = {
themeAccentCol[0] * 0.32f, themeAccentCol[1] * 0.32f,
themeAccentCol[2] * 0.42f, 0.90f};
const float tabHovBord[4] = {
themeAccentCol[0] * 0.9f, themeAccentCol[1] * 0.9f,
themeAccentCol[2] * 0.9f, 0.95f};
const float tabHovBg[4] = {0.10f, 0.15f, 0.24f, 0.78f};
const float tabHovBord[4] = {0.35f, 0.48f, 0.65f, 0.72f};
const float tabNormBg[4] = {0.05f, 0.08f, 0.14f, 0.70f};
const float tabNormBord[4] = {0.20f, 0.28f, 0.42f, 0.55f};
@@ -2555,31 +2655,24 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex,
const bool isSet = (activeMainTab == 2);
const bool isDl = (activeMainTab == 1);
const int subCount = isDl ? 3 : (isSet ? (vaultUnlocked_ ? 5 : 4) : (vaultUnlocked_ ? 6 : 5));
int activeSubTab = browserQuickFilter_;
if (activeMainTab == 1) {
activeSubTab = downloadsFilter_;
} else if (activeMainTab == 2) {
activeSubTab = (settingsSection_ >= 10 && settingsSection_ <= 12) ? 2 : (settingsSection_ == 21 ? 1 : settingsSection_);
}
const float subActBg[4] = {
themeAccentCol[0] * 0.28f, themeAccentCol[1] * 0.28f,
themeAccentCol[2] * 0.38f, 0.92f};
for (int sub = 0; sub < subCount; ++sub) {
const float sy = isSet ? setSubY[sub] : (isDl ? dlSubY[sub] : fileSubY[sub]);
const float sH = isSet ? 0.030f : 0.032f;
const bool hov = (browserSidebarHovered_ == (3 + sub));
bool subAct = false;
if (activeMainTab == 1) {
subAct = (downloadsFilter_ == sub);
} else if (activeMainTab == 2) {
const int effSec = (settingsSection_ >= 10 && settingsSection_ <= 12) ? 2 : (settingsSection_ == 21 ? 1 : settingsSection_);
subAct = (effSec == sub);
} else if (activeMainTab == 0) {
if (sub == 0) subAct = (browserTitle_ != "Bookmarks" && browserTitle_ != "bookmarks://" && browserTitle_.find("Videos") == std::string::npos && browserTitle_.rfind("smb://", 0) != 0 && browserTitle_.find("Downloads") == std::string::npos && browserTitle_.find("Vault") == std::string::npos);
else if (sub == 1) subAct = (browserTitle_.find("Videos") != std::string::npos);
else if (sub == 2) subAct = (browserTitle_.rfind("smb://", 0) == 0 || browserTitle_ == "smb://");
else if (sub == 3) subAct = (browserTitle_.find("Downloads") != std::string::npos);
else if (sub == 4) subAct = (browserTitle_ == "Bookmarks" || browserTitle_ == "bookmarks://");
else if (sub == 5) subAct = (browserTitle_.find("Vault") != std::string::npos);
}
const float subActBg[4] = {
themeAccentCol[0] * 0.25f, themeAccentCol[1] * 0.25f,
themeAccentCol[2] * 0.35f, 0.90f};
const bool subAct = (sub == activeSubTab);
if (subAct || hov) {
drawUiElement(vpPanel, -0.695f, sy, 0.172f, sH,
UiShape::RoundedBox, hov ? tabHovBg : subActBg,
0.40f, 0.025f, hov ? tabHovBord : tabActiveBord);
UiShape::RoundedBox, subAct ? subActBg : tabHovBg,
0.40f, 0.025f, subAct ? tabActiveBord : tabHovBord);
}
if (activeMainTab == 0) {
const UiShape qIcons[6] = {UiShape::Home, UiShape::CurvedDisplay, UiShape::List, UiShape::Download, UiShape::RoundedBox, UiShape::RoundedBox};
@@ -2860,7 +2953,7 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex,
const char* qLabels[6] = {"Local Storage", "Videos", "SMB Shares", "Downloads", "Bookmarks", "Vault (Private)"};
const int qCount = vaultUnlocked_ ? 6 : 5;
for (int sub = 0; sub < qCount; ++sub) {
const float* col = (sub == 4) ? themeAccentCol : ((sub == 5) ? themeAccentCol : slateCol);
const float* col = (browserQuickFilter_ == sub) ? themeTextCol : slateCol;
drawFit(-0.790f, fileSubY[sub], 0.28f, scSub, col, qLabels[sub]);
}
if (vaultUnlocked_) {
@@ -2937,21 +3030,21 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex,
}
} else { // CompactList
const float textLeft = -0.315f;
drawFit(textLeft, lay.cy - 0.005f, listCols.nameMaxW, scSub * 0.95f, themeTextCol, item.name);
drawFit(textLeft, lay.cy, listCols.nameMaxW, scSub * 0.95f, themeTextCol, item.name);
if (listShowFormat_) {
drawFitCentered(listCols.fmtCx, lay.cy - 0.005f, 0.11f, scBadge * 0.90f, themeAccentCol, bTag);
drawFitCentered(listCols.fmtCx, lay.cy, 0.11f, scBadge * 0.90f, themeAccentCol, bTag);
}
if (listShowRes_) {
const std::string resStr = item.resolutionTag.empty() ? (item.isDir || item.isBack ? "" : "--") : item.resolutionTag;
drawFitCentered(listCols.resCx, lay.cy - 0.005f, 0.15f, scDetail * 0.95f, slateCol, resStr);
drawFitCentered(listCols.resCx, lay.cy, 0.15f, scDetail * 0.95f, slateCol, resStr);
}
if (listShowSize_) {
const std::string sizeStr = item.sizeTag.empty() ? (item.isDir || item.isBack ? "" : "--") : item.sizeTag;
drawFitCentered(listCols.sizeCx, lay.cy - 0.005f, 0.11f, scDetail * 0.95f, slateCol, sizeStr);
drawFitCentered(listCols.sizeCx, lay.cy, 0.11f, scDetail * 0.95f, slateCol, sizeStr);
}
if (listShowDuration_) {
const std::string durStr = item.durationTag.empty() ? (item.isDir || item.isBack ? "" : "--") : item.durationTag;
drawFitCentered(listCols.durCx, lay.cy - 0.005f, 0.11f, scDetail * 0.95f, slateCol, durStr);
drawFitCentered(listCols.durCx, lay.cy, 0.11f, scDetail * 0.95f, slateCol, durStr);
}
}
}
@@ -3133,15 +3226,23 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex,
const float cx = (c == 0) ? -0.135f : 0.535f;
float ry = 0.0f;
float cardHalfH = 0.0f;
float pillY = 0.0f;
float pillHalfH = 0.0f;
if (total <= 6) {
ry = 0.22f - static_cast<float>(r) * 0.24f;
cardHalfH = 0.098f;
pillY = ry - 0.028f;
pillHalfH = 0.036f;
} else if (total <= 8) {
ry = 0.25f - static_cast<float>(r) * 0.17f;
cardHalfH = 0.072f;
ry = 0.26f - static_cast<float>(r) * 0.185f;
cardHalfH = 0.078f;
pillY = ry - 0.022f;
pillHalfH = 0.029f;
} else {
ry = 0.27f - static_cast<float>(r) * 0.142f;
cardHalfH = 0.058f;
ry = 0.28f - static_cast<float>(r) * 0.152f;
cardHalfH = 0.066f;
pillY = ry - 0.019f;
pillHalfH = 0.025f;
}
const bool en = settingsEnabled_.empty() ||
(i < static_cast<int>(settingsEnabled_.size()) &&
@@ -3154,12 +3255,10 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex,
0.10f, (hov || sel) ? 0.03f : 0.018f,
(hov || sel) ? tabActiveBord : cardBord);
// Value pill inside card
if (total <= 6) {
drawUiElement(vpPanel, cx, ry - 0.028f, 0.275f, 0.036f,
UiShape::RoundedBox, en ? tabActiveBg : tabNormBg,
0.45f, 0.03f, en ? tabActiveBord : tabNormBord);
}
// Consistent value pill inside every settings card
drawUiElement(vpPanel, cx, pillY, 0.275f, pillHalfH,
UiShape::RoundedBox, en ? tabActiveBg : tabNormBg,
0.45f, 0.03f, en ? tabActiveBord : tabNormBord);
}
// Text pass for Hub rail + Settings cards
@@ -3198,12 +3297,23 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex,
const int r = i / 2;
const float cx = (c == 0) ? -0.135f : 0.535f;
float ry = 0.0f;
float labelY = 0.0f;
float pillY = 0.0f;
float textSc = scSub;
if (total <= 6) {
ry = 0.22f - static_cast<float>(r) * 0.24f;
labelY = ry + 0.044f;
pillY = ry - 0.028f;
} else if (total <= 8) {
ry = 0.25f - static_cast<float>(r) * 0.17f;
ry = 0.26f - static_cast<float>(r) * 0.185f;
labelY = ry + 0.034f;
pillY = ry - 0.022f;
textSc = scSub * 0.95f;
} else {
ry = 0.27f - static_cast<float>(r) * 0.142f;
ry = 0.28f - static_cast<float>(r) * 0.152f;
labelY = ry + 0.028f;
pillY = ry - 0.019f;
textSc = scSub * 0.90f;
}
const bool en = settingsEnabled_.empty() ||
(i < static_cast<int>(settingsEnabled_.size()) &&
@@ -3218,24 +3328,10 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex,
val = fullRow.substr(sep + 3);
}
if (total <= 6) {
drawFit(cx - 0.275f, ry + 0.044f, 0.55f, scSub,
en ? themeTextCol : dimCol, label);
drawFitCentered(cx, ry - 0.028f, 0.51f, scSub,
en ? themeTextCol : dimCol, val.empty() ? label : val);
} else if (total <= 8) {
drawFit(cx - 0.275f, ry + 0.016f, 0.55f, scSub,
en ? themeTextCol : dimCol, label);
if (!val.empty()) {
drawFit(cx - 0.275f, ry - 0.026f, 0.55f, scDetail, themeAccentCol, val);
}
} else {
drawFit(cx - 0.275f, ry + 0.014f, 0.55f, scSub * 0.90f,
en ? themeTextCol : dimCol, label);
if (!val.empty()) {
drawFit(cx - 0.275f, ry - 0.020f, 0.55f, scDetail * 0.95f, themeAccentCol, val);
}
}
drawFit(cx - 0.275f, labelY, 0.55f, textSc,
en ? themeTextCol : dimCol, label);
drawFitCentered(cx, pillY, 0.51f, textSc,
en ? themeTextCol : dimCol, val.empty() ? label : val);
}
if (!settingsStatus_.empty()) {
@@ -3305,8 +3401,14 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex,
drawUiElement(vpPanel, 0.0f, 0.0f, kPanelW, kPanelTop,
UiShape::RoundedBox, backdrop, 0.045f);
const float mHalfW = (actionMenu_.page == 4) ? 0.48f : ((actionMenu_.page == 3) ? 0.48f : 0.44f);
const float mHalfH = (actionMenu_.page == 4) ? 0.38f : ((actionMenu_.page == 3) ? 0.32f : 0.28f);
const int menuOptCount = static_cast<int>(actionMenu_.options.size());
const float menuStepY = 0.068f;
const float menuSpanY = static_cast<float>(std::max(0, menuOptCount - 1)) * menuStepY;
const float menuStartY = -0.040f + 0.5f * menuSpanY;
const float menuDynHalfH = std::max(0.22f, 0.5f * menuSpanY + 0.125f);
const float mHalfW = (actionMenu_.page == 4) ? 0.48f : 0.44f;
const float mHalfH = (actionMenu_.page == 4) ? 0.38f : ((actionMenu_.page == 2) ? 0.24f : menuDynHalfH);
const float modalBg[4] = {0.032f, 0.045f, 0.075f, 0.98f};
drawUiElement(vpPanel, 0.0f, 0.0f, mHalfW, mHalfH,
UiShape::RoundedBox, modalBg, 0.07f, 0.02f, themeAccentCol);
@@ -3321,11 +3423,6 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex,
const float slateBgHov[4] = {0.20f, 0.28f, 0.42f, 1.0f};
const float slateBorder[4] = {0.30f, 0.42f, 0.62f, 0.85f};
// Green button palette
const float greenBg[4] = {0.12f, 0.58f, 0.34f, 0.95f};
const float greenBgHov[4] = {0.18f, 0.74f, 0.44f, 1.0f};
const float greenBorder[4] = {0.35f, 0.88f, 0.58f, 0.95f};
// Vault gold/cyan palette
const float goldBg[4] = {0.28f, 0.22f, 0.08f, 0.95f};
const float goldBgHov[4] = {0.45f, 0.36f, 0.12f, 1.0f};
@@ -3342,12 +3439,10 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex,
const float slateCol[4] = {0.60f, 0.70f, 0.84f, 1.0f};
if (actionMenu_.page == 0 || actionMenu_.page == 1) {
const int optCount = static_cast<int>(actionMenu_.options.size());
const float startY = (optCount > 4) ? 0.12f : 0.08f;
const float btnHalfW = 0.38f;
const float btnHalfH = 0.028f;
for (int i = 0; i < optCount; ++i) {
const float cy = startY - static_cast<float>(i) * 0.072f;
for (int i = 0; i < menuOptCount; ++i) {
const float cy = menuStartY - static_cast<float>(i) * menuStepY;
const bool hov = (actionMenuBtnHovered_ == i) || (actionMenu_.selectedOption == i);
const std::string& optStr = actionMenu_.options[i];
const bool isDelete = (optStr.find("Delete") != std::string::npos);
@@ -3371,56 +3466,36 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex,
UiShape::RoundedBox, delHov ? redBgHov : redBg, 0.42f, 0.03f, redBorder);
drawUiElement(vpPanel, 0.15f, cy, btnHalfW, btnHalfH,
UiShape::RoundedBox, canHov ? slateBgHov : slateBg, 0.42f, 0.03f, slateBorder);
} else if (actionMenu_.page == 3) {
// Rename: text input box
const float innerBg[4] = {0.018f, 0.026f, 0.046f, 0.90f};
drawUiElement(vpPanel, 0.0f, 0.16f, mHalfW - 0.04f, 0.035f,
UiShape::RoundedBox, innerBg, 0.25f, 0.02f, themeAccentCol);
// 4 action buttons
const float btnY = 0.08f;
const float btnH = 0.025f;
auto drawBtn = [&](float cx, float hw, int idx, const float* bg, const float* bgHov, const float* bord) {
const bool hov = (actionMenuBtnHovered_ == idx);
drawUiElement(vpPanel, cx, btnY, hw, btnH,
UiShape::RoundedBox, hov ? bgHov : bg, 0.35f, 0.02f, bord);
};
drawBtn(-0.27f, 0.07f, 0, slateBg, slateBgHov, slateBorder);
drawBtn(-0.09f, 0.09f, 1, slateBg, slateBgHov, slateBorder);
drawBtn(0.11f, 0.09f, 2, greenBg, greenBgHov, greenBorder);
drawBtn(0.28f, 0.07f, 3, slateBg, slateBgHov, slateBorder);
// On-screen keyboard keys
static const char* kRow0 = "1234567890-_";
static const char* kRow1 = "QWERTYUIOP";
static const char* kRow2 = "ASDFGHJKL";
static const char* kRow3 = "ZXCVBNM. ";
auto drawRowKeys = [&](const char* rowKeys, float rowY, int keyOffset, float keyW, float keySpacing) {
const int count = static_cast<int>(std::strlen(rowKeys));
const float totalW = count * keyW + (count - 1) * keySpacing;
const float startX = -0.5f * totalW + 0.5f * keyW;
for (int k = 0; k < count; ++k) {
const float kx = startX + k * (keyW + keySpacing);
const bool hov = (actionMenuKeyHovered_ == keyOffset + k);
drawUiElement(vpPanel, kx, rowY, keyW * 0.5f, 0.022f,
UiShape::RoundedBox, hov ? slateBgHov : slateBg, 0.35f, 0.015f,
hov ? tabActiveBord : slateBorder);
}
};
drawRowKeys(kRow0, 0.005f, 0, 0.055f, 0.010f);
drawRowKeys(kRow1, -0.055f, 12, 0.065f, 0.012f);
drawRowKeys(kRow2, -0.115f, 22, 0.070f, 0.012f);
drawRowKeys(kRow3, -0.175f, 31, 0.070f, 0.012f);
} else if (actionMenu_.page == 4) {
// SMB Share Editor Modal
const int optCount = static_cast<int>(actionMenu_.options.size());
const int fieldCount = std::min(optCount, 7);
const float scField = ((0.032f * tScale) / fontAtlas_.lineHeight) * 0.92f;
const float innerFieldBg[4] = {0.010f, 0.015f, 0.026f, 0.95f};
const float innerFieldBord[4] = {0.16f, 0.26f, 0.42f, 0.75f};
for (int i = 0; i < fieldCount; ++i) {
const float cy = 0.19f - static_cast<float>(i) * 0.054f;
const bool hov = (actionMenuBtnHovered_ == i) || (actionMenu_.selectedOption == i);
const bool editing = (actionMenu_.editingRow == i);
drawUiElement(vpPanel, 0.0f, cy, 0.42f, 0.024f,
UiShape::RoundedBox, hov ? tabHovBg : tabNormBg, 0.35f, 0.018f,
hov ? tabActiveBord : tabNormBord);
(hov || editing) ? tabActiveBord : tabNormBord);
if (i != 2) {
drawUiElement(vpPanel, 0.13f, cy, 0.275f, 0.018f,
UiShape::RoundedBox, innerFieldBg, 0.30f, 0.015f,
editing ? tabActiveBord : innerFieldBord);
}
if (editing) {
const std::string& opt = actionMenu_.options[i];
const auto colonPos = opt.find(':');
std::string val = (colonPos != std::string::npos) ? opt.substr(colonPos + 1) : opt;
while (!val.empty() && val.front() == ' ') val.erase(val.begin());
const TextFieldLayout rowLay =
layoutTextField(-0.13f, 0.39f, scField, val, actionMenu_.cursorPos, false, false);
const float caretCol[4] = {1.0f, 1.0f, 1.0f, 0.98f};
drawUiElement(vpPanel, rowLay.cursorX + 0.002f, cy, 0.0022f, 0.013f,
UiShape::RoundedBox, caretCol, 0.45f, 0.001f, tabActiveBord);
}
}
if (optCount == 8) {
// Save (7) and Cancel (8)
@@ -3456,10 +3531,8 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex,
}
if (actionMenu_.page == 0 || actionMenu_.page == 1) {
const int optCount = static_cast<int>(actionMenu_.options.size());
const float startY = (optCount > 4) ? 0.12f : 0.08f;
for (int i = 0; i < optCount; ++i) {
const float cy = startY - static_cast<float>(i) * 0.072f;
for (int i = 0; i < menuOptCount; ++i) {
const float cy = menuStartY - static_cast<float>(i) * menuStepY;
const bool hov = (actionMenuBtnHovered_ == i) || (actionMenu_.selectedOption == i);
drawFitCentered(0.0f, cy, 0.38f * 1.85f, sc, hov ? themeTextCol : slateCol, actionMenu_.options[i]);
}
@@ -3467,52 +3540,28 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex,
drawFitCentered(0.0f, 0.035f, (mHalfW - 0.06f) * 2.0f, sc, themeTextCol, actionMenu_.subtitle);
drawFitCentered(-0.15f, -0.12f, 0.13f * 1.8f, sc, themeTextCol, "Delete");
drawFitCentered(0.15f, -0.12f, 0.13f * 1.8f, sc, themeTextCol, "Cancel");
} else if (actionMenu_.page == 3) {
std::string disp = actionMenu_.renameBuffer.empty() ? "|" : (actionMenu_.renameBuffer + "|");
drawFitCentered(0.0f, 0.16f, (mHalfW - 0.06f) * 1.85f, sc, themeTextCol, disp);
const float btnY = 0.08f;
drawFitCentered(-0.27f, btnY, 0.12f, sc * 0.90f, themeTextCol, "Clear");
drawFitCentered(-0.09f, btnY, 0.16f, sc * 0.90f, themeTextCol, "Backspace");
drawFitCentered(0.11f, btnY, 0.16f, sc * 0.90f, themeTextCol, "Save");
drawFitCentered(0.28f, btnY, 0.12f, sc * 0.90f, themeTextCol, "Cancel");
static const char* kRow0 = "1234567890-_";
static const char* kRow1 = "QWERTYUIOP";
static const char* kRow2 = "ASDFGHJKL";
static const char* kRow3 = "ZXCVBNM. ";
auto drawRowText = [&](const char* rowKeys, float rowY, int keyOffset, float keyW, float keySpacing) {
const int count = static_cast<int>(std::strlen(rowKeys));
const float totalW = count * keyW + (count - 1) * keySpacing;
const float startX = -0.5f * totalW + 0.5f * keyW;
for (int k = 0; k < count; ++k) {
const float kx = startX + k * (keyW + keySpacing);
char label[8];
if (rowKeys[k] == ' ') std::snprintf(label, sizeof(label), "Space");
else std::snprintf(label, sizeof(label), "%c", rowKeys[k]);
drawFitCentered(kx, rowY, keyW * 0.85f, sc * 0.85f, themeTextCol, label);
}
};
drawRowText(kRow0, 0.005f, 0, 0.055f, 0.010f);
drawRowText(kRow1, -0.055f, 12, 0.065f, 0.012f);
drawRowText(kRow2, -0.115f, 22, 0.070f, 0.012f);
drawRowText(kRow3, -0.175f, 31, 0.070f, 0.012f);
} else if (actionMenu_.page == 4) {
const int optCount = static_cast<int>(actionMenu_.options.size());
const int fieldCount = std::min(optCount, 7);
const float scField = sc * 0.92f;
for (int i = 0; i < fieldCount; ++i) {
const float cy = 0.19f - static_cast<float>(i) * 0.054f;
const bool hov = (actionMenuBtnHovered_ == i) || (actionMenu_.selectedOption == i);
const bool editing = (actionMenu_.editingRow == i);
const std::string& opt = actionMenu_.options[i];
const auto colonPos = opt.find(':');
if (colonPos != std::string::npos) {
std::string label = opt.substr(0, colonPos + 1);
std::string val = opt.substr(colonPos + 1);
while (!val.empty() && val.front() == ' ') val.erase(val.begin());
drawFit(-0.39f, cy - 0.003f, 0.24f, sc * 0.90f, hov ? themeTextCol : slateCol, label);
drawFit(-0.13f, cy - 0.003f, 0.52f, sc * 0.92f, themeAccentCol, val);
drawFit(-0.39f, cy, 0.24f, sc * 0.90f, hov ? themeTextCol : slateCol, label);
const int curP = editing ? actionMenu_.cursorPos : static_cast<int>(val.size());
const TextFieldLayout rowLay =
layoutTextField(-0.13f, 0.39f, scField, val, curP, false, false);
const float baseline = cy - 0.35f * fontAtlas_.ascent * scField;
drawString(rowLay.startX, baseline, scField, themeAccentCol, rowLay.visibleStr);
} else {
drawFitCentered(0.0f, cy - 0.003f, 0.78f, sc * 0.90f, hov ? themeTextCol : slateCol, opt);
drawFitCentered(0.0f, cy, 0.78f, sc * 0.90f, hov ? themeTextCol : slateCol, opt);
}
}
if (optCount == 8) {
@@ -3551,35 +3600,57 @@ void VulkanRenderer::renderView(uint32_t viewIndex, uint32_t imageIndex,
const float fieldBord[4] = {0.20f, 0.32f, 0.50f, 0.85f};
drawUiElement(vpPanel, 0.0f, 0.15f, 0.40f, 0.038f, UiShape::RoundedBox, fieldBg, 0.035f, 0.015f, fieldBord);
const float scHeader = (0.038f * tScale) / fontAtlas_.lineHeight;
const float scStd = (0.030f * tScale) / fontAtlas_.lineHeight;
const float scDetail = (0.022f * tScale) / fontAtlas_.lineHeight;
const float fieldSc = scStd * 1.15f;
const float kbSlateCol[4] = {themeTextCol[0] * 0.78f, themeTextCol[1] * 0.84f, themeTextCol[2] * 0.94f, 0.95f};
// Compute scrolled text window and exact cursor position inside [-0.37f, +0.37f]
const TextFieldLayout kbLay = layoutTextField(
-0.370f, 0.370f, fieldSc, keyboardModal_.text,
keyboardModal_.cursorPos, keyboardModal_.isPassword, false);
// Keep cursor solid while typing/moving, and blink smoothly when idle
static std::string sLastText;
static int sLastCursor = -1;
static auto sLastEditT = std::chrono::steady_clock::now();
const auto nowT = std::chrono::steady_clock::now();
if (keyboardModal_.text != sLastText || keyboardModal_.cursorPos != sLastCursor) {
sLastText = keyboardModal_.text;
sLastCursor = keyboardModal_.cursorPos;
sLastEditT = nowT;
}
const auto idleMs = std::chrono::duration_cast<std::chrono::milliseconds>(nowT - sLastEditT).count();
const bool cursorVisible = (idleMs < 450) || (((idleMs / 480) % 2) == 0);
if (cursorVisible) {
const float caretCol[4] = {1.0f, 1.0f, 1.0f, 0.98f};
drawUiElement(vpPanel, kbLay.cursorX + 0.0025f, 0.15f, 0.0028f, 0.022f,
UiShape::RoundedBox, caretCol, 0.45f, 0.001f, mBorder);
}
// Text pass
vkCmdBindPipeline(commandBuffer_, VK_PIPELINE_BIND_POINT_GRAPHICS, textPipeline_);
vkCmdBindDescriptorSets(commandBuffer_, VK_PIPELINE_BIND_POINT_GRAPHICS,
textPipelineLayout_, 0, 1, &textSet_, 0, nullptr);
const float scHeader = (0.038f * tScale) / fontAtlas_.lineHeight;
const float scStd = (0.030f * tScale) / fontAtlas_.lineHeight;
const float scDetail = (0.022f * tScale) / fontAtlas_.lineHeight;
const float kbSlateCol[4] = {themeTextCol[0] * 0.78f, themeTextCol[1] * 0.84f, themeTextCol[2] * 0.94f, 0.95f};
// Prompt Title
drawFitCentered(0.0f, 0.235f, 0.85f, scHeader * 0.85f, themeTextCol,
keyboardModal_.prompt.empty() ? "Virtual Keyboard Input" : keyboardModal_.prompt);
// Live Typed Text Buffer (with password masking and blinking cursor)
std::string dispText = keyboardModal_.isPassword
? std::string(keyboardModal_.text.length(), '*')
: keyboardModal_.text;
static auto sStartT = std::chrono::steady_clock::now();
const auto nowMs = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now() - sStartT).count();
const bool cursorBlink = ((nowMs / 500) % 2) == 0;
dispText += (cursorBlink ? "_" : " ");
drawFitCentered(0.0f, 0.15f, 0.76f, scStd * 1.15f, themeAccentCol, dispText);
// Live Scrolled Text Buffer
if (!kbLay.visibleStr.empty()) {
const float baseline = 0.15f - 0.35f * fontAtlas_.ascent * fieldSc;
drawString(kbLay.startX, baseline, fieldSc, themeAccentCol, kbLay.visibleStr);
} else {
const float placeCol[4] = {kbSlateCol[0], kbSlateCol[1], kbSlateCol[2], 0.38f};
const float baseline = 0.15f - 0.35f * fontAtlas_.ascent * fieldSc;
drawString(kbLay.startX + 0.010f, baseline, fieldSc * 0.90f, placeCol, "Type here...");
}
// Instruction Subtitle
drawFitCentered(0.0f, 0.075f, 0.85f, scDetail * 0.95f, kbSlateCol,
"Type on the SteamVR virtual keyboard below \xB7 Press Enter/Done to submit");
"Type on keyboard below \xB7 Stick L/R: Move Cursor \xB7 Enter/Done: Submit");
}
// Tracked controller 3D models (lit capsule), drawn before the laser/cursor.
+10 -1
View File
@@ -153,8 +153,11 @@ public:
void updateCardThumbnail(int slot, const std::string& path, const uint8_t* rgba, int width, int height);
void clearCardThumbnails();
int browserFirst() const { return browserFirst_; }
void setBrowserFirst(int first) { browserFirst_ = first < 0 ? 0 : first; }
int browserSidebarHovered() const { return browserSidebarHovered_; }
int browserPageBtnHovered() const { return browserPageBtnHovered_; }
void setBrowserQuickFilter(int f) { browserQuickFilter_ = f; }
int browserQuickFilter() const { return browserQuickFilter_; }
void setDownloadsFilter(int f) { downloadsFilter_ = f; }
int downloadsFilter() const { return downloadsFilter_; }
void setActiveDownloadCount(int c) { activeDownloadCount_ = c; }
@@ -192,16 +195,19 @@ public:
std::string prompt;
std::string text;
bool isPassword = false;
int cursorPos = 0;
bool hasTransform = false;
float transform[3][4]{};
};
void setKeyboardModal(bool visible, const std::string& prompt,
const std::string& text, bool isPassword = false,
const float* transform = nullptr) {
const float* transform = nullptr,
int cursorPos = -1) {
keyboardModal_.visible = visible;
keyboardModal_.prompt = prompt;
keyboardModal_.text = text;
keyboardModal_.isPassword = isPassword;
keyboardModal_.cursorPos = (cursorPos >= 0) ? cursorPos : static_cast<int>(text.size());
if (transform && visible) {
std::memcpy(keyboardModal_.transform, transform, sizeof(keyboardModal_.transform));
keyboardModal_.hasTransform = true;
@@ -243,6 +249,8 @@ public:
std::string subtitle;
std::vector<std::string> options;
int selectedOption = -1;
int editingRow = -1;
int cursorPos = 0;
std::string renameBuffer;
};
void setActionMenu(const ActionMenuData& data) { actionMenu_ = data; }
@@ -543,6 +551,7 @@ private:
std::vector<BrowserItem> browserItems_;
int browserSidebarHovered_ = -1;
int browserPageBtnHovered_ = -1;
int browserQuickFilter_ = 0; // 0=Local, 1=Videos, 2=SMB, 3=Downloads, 4=Bookmarks, 5=Vault
int downloadsFilter_ = 0; // 0=All, 1=Active, 2=Completed
int activeDownloadCount_ = 0;
int settingsSection_ = 0;
+179 -32
View File
@@ -34,6 +34,7 @@ extern "C" {
#ifdef PIXELVR_HAVE_PULSE
#include <pulse/error.h>
#include <pulse/simple.h>
#include <pulse/sample.h>
#endif
#ifdef PIXELVR_HAVE_SMB
@@ -333,6 +334,15 @@ struct MediaEngine::Impl {
std::atomic<double> masterClock{0.0};
#ifdef PIXELVR_HAVE_PULSE
pa_simple* pa = nullptr;
struct AudioChunk {
std::vector<uint8_t> pcm;
double pts = -1.0;
double duration = 0.0;
};
std::mutex audioMutex;
std::condition_variable audioCv;
std::deque<AudioChunk> audioQueue;
std::thread audioThread;
#endif
std::thread thread;
@@ -363,6 +373,14 @@ struct MediaEngine::Impl {
void teardown() {
#ifdef PIXELVR_HAVE_PULSE
{
std::lock_guard<std::mutex> lk(audioMutex);
audioQueue.clear();
}
audioCv.notify_all();
if (audioThread.joinable()) {
audioThread.join();
}
if (pa) {
pa_simple_free(pa);
pa = nullptr;
@@ -421,19 +439,14 @@ struct MediaEngine::Impl {
std::unique_lock<std::mutex> lock(mutex);
// Back-pressure: wait for the consumer to free a slot rather than dropping
// frames, so the decoder is paced by presentation and cannot race ahead of
// the playback clock. An unpaced decode burst (post-seek audio mute, or the
// initial burst on a fresh video) otherwise outran the clock and froze the
// picture permanently.
queueCv.wait_for(lock, std::chrono::milliseconds(100), [&] {
return swQueue.size() < maxQueue || !running.load() ||
seekTarget.load() >= 0.0;
});
// the playback clock.
while (swQueue.size() >= maxQueue && running.load() &&
seekTarget.load() < 0.0) {
queueCv.wait_for(lock, std::chrono::milliseconds(50));
}
if (!running.load() || seekTarget.load() >= 0.0) {
return; // a stop/seek is pending: drop this stale frame
}
while (swQueue.size() >= maxQueue) {
swQueue.pop_front(); // consumer stalled past the timeout: stay live
}
VideoFrame f;
f.width = w;
f.height = h;
@@ -449,19 +462,16 @@ struct MediaEngine::Impl {
void pushDrm(const DrmVideoFrame& f) {
std::unique_lock<std::mutex> lock(mutex);
// Back-pressure (see pushFrame): pace the decoder to the consumer so a
// post-seek or startup decode burst can't outrun the playback clock.
queueCv.wait_for(lock, std::chrono::milliseconds(100), [&] {
return drmQueue.size() < maxQueue || !running.load() ||
seekTarget.load() >= 0.0;
});
// post-seek or startup decode burst can't outrun the playback clock and
// we never drop decoded hardware frames from the queue.
while (drmQueue.size() >= maxQueue && running.load() &&
seekTarget.load() < 0.0) {
queueCv.wait_for(lock, std::chrono::milliseconds(50));
}
if (!running.load() || seekTarget.load() >= 0.0) {
if (f.fd >= 0) ::close(f.fd); // stop/seek pending: drop; we own the fd
return;
}
while (drmQueue.size() >= maxQueue) {
if (drmQueue.front().fd >= 0) ::close(drmQueue.front().fd);
drmQueue.pop_front(); // consumer stalled past the timeout: stay live
}
DrmVideoFrame queued = f;
queued.serial = ++serial;
drmQueue.push_back(std::move(queued));
@@ -496,6 +506,7 @@ struct MediaEngine::Impl {
}
void decodeLoop();
void audioThreadLoop();
};
void MediaEngine::Impl::decodeLoop() {
@@ -513,6 +524,7 @@ void MediaEngine::Impl::decodeLoop() {
double startPts = 0.0;
bool haveClock = false;
bool eofFlushed = false;
bool wasPaused = false;
auto lastSeekWall = clock::now() - std::chrono::seconds(1);
auto process_video = [&]() {
@@ -644,14 +656,13 @@ void MediaEngine::Impl::decodeLoop() {
};
#ifdef PIXELVR_HAVE_PULSE
std::vector<uint8_t> audioBuf;
auto process_audio = [&]() {
const int maxOut = swr_get_out_samples(swr, frame->nb_samples);
if (maxOut <= 0) {
return;
}
audioBuf.resize(static_cast<std::size_t>(maxOut) * kOutChannels * 2);
uint8_t* out[1] = {audioBuf.data()};
std::vector<uint8_t> pcm(static_cast<std::size_t>(maxOut) * kOutChannels * 2);
uint8_t* out[1] = {pcm.data()};
const int got =
swr_convert(swr, out, maxOut,
const_cast<const uint8_t**>(frame->extended_data),
@@ -660,21 +671,32 @@ void MediaEngine::Impl::decodeLoop() {
return;
}
const std::size_t bytes = static_cast<std::size_t>(got) * kOutChannels * 2;
pcm.resize(bytes);
// Mute while actively scrubbing so audio writes don't block video previews
// and the sound doesn't stutter across seek positions.
if (std::chrono::duration<double>(clock::now() - lastSeekWall).count() < 0.25) {
return;
}
int err = 0;
pa_simple_write(pa, audioBuf.data(), bytes, &err);
if (!pa) {
return;
}
int64_t ts = frame->best_effort_timestamp;
if (ts == AV_NOPTS_VALUE) {
ts = frame->pts;
}
if (ts != AV_NOPTS_VALUE) {
masterClock.store(ts * av_q2d(audioTimeBase));
const double pts = (ts != AV_NOPTS_VALUE) ? (ts * av_q2d(audioTimeBase)) : -1.0;
const double dur = static_cast<double>(got) / static_cast<double>(kOutSampleRate);
{
std::lock_guard<std::mutex> lk(audioMutex);
// Keep up to ~5s of decoded audio in RAM; never block the video decode loop.
while (audioQueue.size() >= 250) {
audioQueue.pop_front();
}
audioQueue.push_back(AudioChunk{std::move(pcm), pts, dur});
}
audioCv.notify_one();
};
#endif
@@ -732,6 +754,17 @@ void MediaEngine::Impl::decodeLoop() {
if (audioCodec != nullptr) {
avcodec_flush_buffers(audioCodec);
}
#ifdef PIXELVR_HAVE_PULSE
{
std::lock_guard<std::mutex> lk(audioMutex);
audioQueue.clear();
}
if (pa) {
int pErr = 0;
pa_simple_flush(pa, &pErr);
}
audioCv.notify_all();
#endif
haveClock = false;
masterClock.store(seekTo);
playClock.reset(); // the next presented frame re-anchors the clock
@@ -739,11 +772,27 @@ void MediaEngine::Impl::decodeLoop() {
eof.store(false);
}
if (paused.load()) {
if (!wasPaused) {
#ifdef PIXELVR_HAVE_PULSE
if (pa) {
int pErr = 0;
pa_simple_flush(pa, &pErr);
}
audioCv.notify_all();
#endif
wasPaused = true;
}
haveClock = false; // re-establish the wall clock on resume
playClock.freeze(); // hold the clock so video doesn't advance
std::this_thread::sleep_for(std::chrono::milliseconds(10));
continue;
}
if (wasPaused) {
wasPaused = false;
#ifdef PIXELVR_HAVE_PULSE
audioCv.notify_all();
#endif
}
playClock.resume(); // no-op unless resuming from pause
const int readResult = av_read_frame(format, packet);
if (readResult < 0) {
@@ -783,6 +832,66 @@ void MediaEngine::Impl::decodeLoop() {
av_frame_free(&frame);
}
void MediaEngine::Impl::audioThreadLoop() {
#ifdef PIXELVR_HAVE_PULSE
// Pre-write lead time (~45ms) matching the PulseAudio buffer so samples
// written now emerge from the speakers when playClock reaches chunk.pts.
constexpr double kAudioLeadSec = 0.045;
while (running.load()) {
if (paused.load() || !playClock.running.load(std::memory_order_relaxed)) {
std::unique_lock<std::mutex> lk(audioMutex);
audioCv.wait_for(lk, std::chrono::milliseconds(10));
continue;
}
AudioChunk chunk;
bool haveChunk = false;
double waitSec = 0.0;
{
std::unique_lock<std::mutex> lk(audioMutex);
if (audioQueue.empty()) {
audioCv.wait_for(lk, std::chrono::milliseconds(10));
continue;
}
const double vClock = playClock.now();
// Drop any chunks that are already late (> 80ms behind current video clock)
// so post-startup or post-seek audio snaps into sync with video immediately.
while (!audioQueue.empty() && audioQueue.front().pts >= 0.0 &&
(audioQueue.front().pts + audioQueue.front().duration) < (vClock - 0.080)) {
audioQueue.pop_front();
}
if (audioQueue.empty()) {
continue;
}
const double targetPlayTime = vClock + kAudioLeadSec;
if (audioQueue.front().pts >= 0.0 &&
audioQueue.front().pts > targetPlayTime + 0.015) {
// Audio is ahead of the video presentation clock (because video frames
// are buffered in the V4L2 decoder + drmQueue). Hold until due.
waitSec = std::min(0.015, audioQueue.front().pts - targetPlayTime);
} else {
chunk = std::move(audioQueue.front());
audioQueue.pop_front();
haveChunk = true;
}
}
if (waitSec > 0.0) {
std::this_thread::sleep_for(std::chrono::duration<double>(waitSec));
continue;
}
if (haveChunk && pa != nullptr && !chunk.pcm.empty()) {
int err = 0;
pa_simple_write(pa, chunk.pcm.data(), chunk.pcm.size(), &err);
}
}
#endif
}
MediaEngine::MediaEngine() : impl_(std::make_unique<Impl>()) {
avformat_network_init();
}
@@ -890,8 +999,13 @@ bool MediaEngine::open(const std::string& path) {
const int wantVideo =
(impl_->preferredVideoStream >= 0) ? impl_->preferredVideoStream : -1;
impl_->videoStream = av_find_best_stream(impl_->format, AVMEDIA_TYPE_VIDEO,
wantVideo, -1, nullptr, 0);
if (wantVideo >= 0 && wantVideo < static_cast<int>(impl_->format->nb_streams) &&
impl_->format->streams[wantVideo]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) {
impl_->videoStream = wantVideo;
} else {
impl_->videoStream = av_find_best_stream(impl_->format, AVMEDIA_TYPE_VIDEO,
wantVideo, -1, nullptr, 0);
}
if (impl_->videoStream < 0) {
PIXELVR_LOG_ERROR("MediaEngine: no video stream in %s", path.c_str());
close();
@@ -1019,8 +1133,14 @@ bool MediaEngine::open(const std::string& path) {
}
}
}
impl_->audioStream = av_find_best_stream(impl_->format, AVMEDIA_TYPE_AUDIO,
wantAudio, -1, &adec, 0);
if (wantAudio >= 0 && wantAudio < static_cast<int>(impl_->format->nb_streams) &&
impl_->format->streams[wantAudio]->codecpar->codec_type == AVMEDIA_TYPE_AUDIO) {
impl_->audioStream = wantAudio;
adec = avcodec_find_decoder(impl_->format->streams[wantAudio]->codecpar->codec_id);
} else {
impl_->audioStream = av_find_best_stream(impl_->format, AVMEDIA_TYPE_AUDIO,
wantAudio, -1, &adec, 0);
}
if (impl_->audioStream >= 0 && adec != nullptr) {
AVStream* as = impl_->format->streams[impl_->audioStream];
impl_->audioTimeBase = as->time_base;
@@ -1040,9 +1160,15 @@ bool MediaEngine::open(const std::string& path) {
spec.format = PA_SAMPLE_S16LE;
spec.rate = MediaEngine::Impl::kOutSampleRate;
spec.channels = MediaEngine::Impl::kOutChannels;
pa_buffer_attr ba;
std::memset(&ba, 0xff, sizeof(ba));
ba.tlength = static_cast<uint32_t>(pa_usec_to_bytes(60000, &spec));
ba.minreq = static_cast<uint32_t>(pa_usec_to_bytes(20000, &spec));
ba.prebuf = static_cast<uint32_t>(-1);
ba.maxlength = static_cast<uint32_t>(-1);
impl_->pa =
pa_simple_new(nullptr, "PixelVRPlayer", PA_STREAM_PLAYBACK,
nullptr, "video", &spec, nullptr, nullptr, nullptr);
nullptr, "video", &spec, nullptr, &ba, nullptr);
impl_->audioEnabled = (impl_->pa != nullptr);
}
av_channel_layout_uninit(&outLayout);
@@ -1059,6 +1185,11 @@ bool MediaEngine::open(const std::string& path) {
impl_->eof.store(false);
impl_->running.store(true);
impl_->thread = std::thread([this] { impl_->decodeLoop(); });
#ifdef PIXELVR_HAVE_PULSE
if (impl_->audioEnabled) {
impl_->audioThread = std::thread([this] { impl_->audioThreadLoop(); });
}
#endif
return true;
}
@@ -1073,6 +1204,12 @@ void MediaEngine::close() {
impl_->releaseCv.notify_all();
impl_->queueCv.notify_all(); // wake a back-pressured decoder to exit
}
#ifdef PIXELVR_HAVE_PULSE
impl_->audioCv.notify_all();
if (impl_->audioThread.joinable()) {
impl_->audioThread.join();
}
#endif
#ifdef PIXELVR_HAVE_SMB
impl_->smbReader.abort(); // unblock a decode thread stalled in smb_read
#endif
@@ -1217,7 +1354,13 @@ bool MediaEngine::latestFrame(VideoFrame& out) {
out = std::move(q.front());
q.pop_front();
impl_->primed = true;
if (firstFrame) impl_->playClock.start(out.pts); // anchor to the first frame
impl_->masterClock.store(out.pts, std::memory_order_relaxed);
if (firstFrame) {
impl_->playClock.start(out.pts); // anchor to the first frame
#ifdef PIXELVR_HAVE_PULSE
impl_->audioCv.notify_all();
#endif
}
impl_->queueCv.notify_one(); // slot freed: wake a back-pressured decoder
return true;
}
@@ -1258,8 +1401,12 @@ bool MediaEngine::latestDrmFrame(DrmVideoFrame& out) {
out = std::move(q.front()); // transfers fd + keepAlive ownership to caller
q.pop_front();
impl_->primed = true;
impl_->masterClock.store(out.pts, std::memory_order_relaxed);
if (firstFrame) {
impl_->playClock.start(out.pts); // anchor to the first frame
#ifdef PIXELVR_HAVE_PULSE
impl_->audioCv.notify_all();
#endif
PIXELVR_LOG_INFO("MediaEngine: prime pts=%.2f clock=%.2f", out.pts, clock);
}
impl_->queueCv.notify_one(); // slot freed: wake a back-pressured decoder
+143 -29
View File
@@ -127,9 +127,10 @@ void Browser::open() {
open_ = true;
browseOpen_.store(true);
status_.clear();
saveCurrentFolderState();
// Resume where the user last was this session (local folder or SMB share).
if (lastInSmb_ && !lastLocation_.empty()) {
beginScan(lastLocation_, true);
beginScan(lastLocation_, true, true);
return;
}
std::string start = homeDir_;
@@ -139,7 +140,7 @@ void Browser::open() {
start = lastLocation_;
}
}
beginScan(start, false);
beginScan(start, false, !lastLocation_.empty() && start == lastLocation_);
}
void Browser::move(int delta) {
@@ -374,11 +375,57 @@ void Browser::scanSmbInto(const std::string& uri, ScanResult& out) {
#endif
}
void Browser::updateActiveFilterFor(const std::string& loc, bool isSmb) {
if (loc == "bookmarks://") {
activeFilter_ = 4;
} else if (isSmb || loc.rfind("smb://", 0) == 0) {
activeFilter_ = 2;
} else if (VaultManager::instance().isVaultPath(loc)) {
activeFilter_ = 5;
} else if (!downloadDir_.empty() && loc.rfind(downloadDir_, 0) == 0) {
activeFilter_ = 3;
} else if (loc.rfind(homeDir_ + "/Downloads", 0) == 0) {
activeFilter_ = 3;
} else if (loc.rfind(homeDir_ + "/Videos", 0) == 0) {
activeFilter_ = 1;
} else {
activeFilter_ = 0;
}
}
void Browser::saveCurrentFolderState() {
if (location_.empty() || scanning_.load()) return;
if (entries_.size() == 1 && entries_[0].label == "Scanning..." && entries_[0].path.empty()) {
return;
}
std::string selPath;
if (selected_ >= 0 && selected_ < static_cast<int>(entries_.size())) {
selPath = entries_[static_cast<std::size_t>(selected_)].path;
}
folderHistory_[location_] = {selected_, first_, selPath};
}
// Starts a background scan so SMB discovery (NetBIOS + mDNS, seconds) and large
// directories never freeze the render loop. poll() publishes the result.
void Browser::beginScan(const std::string& location, bool isSmb) {
PIXELVR_LOG_INFO("beginScan '%s' smb=%d scanning=%d", location.c_str(),
isSmb ? 1 : 0, scanning_.load() ? 1 : 0);
void Browser::beginScan(const std::string& location, bool isSmb,
bool restoreView, const std::string& childPath) {
PIXELVR_LOG_INFO("beginScan '%s' smb=%d scanning=%d restore=%d", location.c_str(),
isSmb ? 1 : 0, scanning_.load() ? 1 : 0, restoreView ? 1 : 0);
updateActiveFilterFor(location, isSmb);
location_ = location;
inSmb_ = isSmb;
if (VaultManager::instance().isVaultPath(location)) {
title_ = "Vault (Private)";
} else if (location == "bookmarks://") {
title_ = "Bookmarks";
} else {
title_ = location;
}
status_ = "Scanning...";
entries_.clear();
entries_.push_back({"Scanning...", "", false, false, false, false});
selected_ = 0;
if (scanning_.load() && scanThread_.joinable()) {
PIXELVR_LOG_INFO("beginScan DEFERRED (scan in progress)");
// A scan is already running (possibly waiting on the SMB lock held by a
@@ -386,17 +433,16 @@ void Browser::beginScan(const std::string& location, bool isSmb) {
// which would freeze the UI until the download's read returns.
pendingScanLoc_ = location;
pendingScanSmb_ = isSmb;
pendingScanRestore_ = restoreView;
pendingScanChildPath_ = childPath;
hasPendingScan_ = true;
return;
}
if (scanThread_.joinable()) scanThread_.join(); // previous scan has finished
restoreViewOnScan_ = restoreView;
restoreChildPath_ = childPath;
scanReady_.store(false);
scanning_.store(true);
title_ = location;
status_ = "Scanning...";
entries_.clear();
entries_.push_back({"Scanning...", "", false, false, false, false});
selected_ = 0;
scanThread_ =
std::thread([this, location, isSmb] { runScan(location, isSmb); });
}
@@ -413,10 +459,12 @@ void Browser::openNetwork() {
browseOpen_.store(true);
status_.clear();
beginScan("smb://", true);
activeFilter_ = 2;
}
void Browser::openHome() {
openPath(homeDir_);
activeFilter_ = 0;
}
void Browser::openVideos() {
@@ -427,6 +475,7 @@ void Browser::openVideos() {
} else {
openPath(homeDir_);
}
activeFilter_ = 1;
}
void Browser::openDownloads() {
@@ -435,6 +484,7 @@ void Browser::openDownloads() {
fs::create_directories(downloadDir_, ec);
}
openPath(downloadDir_);
activeFilter_ = 3;
}
void Browser::openDrives() {
@@ -445,12 +495,14 @@ void Browser::openDrives() {
} else {
openPath("/");
}
activeFilter_ = 0;
}
void Browser::openVault() {
if (!VaultManager::instance().isUnlocked()) return;
VaultManager::instance().ensureDirectories();
openPath(VaultManager::instance().vaultDir());
activeFilter_ = 5;
}
void Browser::openBookmarks() {
@@ -458,6 +510,7 @@ void Browser::openBookmarks() {
browseOpen_.store(true);
status_.clear();
beginScan("bookmarks://", false);
activeFilter_ = 4;
}
void Browser::addBookmark(const std::string& path, const std::string& label) {
@@ -619,7 +672,8 @@ bool Browser::moveFocusedToVault(std::string& outNewPath) {
return false;
}
status_ = "Moved " + e->label + " to Vault";
beginScan(location_, inSmb_);
saveCurrentFolderState();
beginScan(location_, inSmb_, true);
return true;
}
@@ -666,7 +720,8 @@ bool Browser::moveFocusedTo(const std::string& destDir, std::string& outNewPath)
}
outNewPath = dest.string();
status_ = "Moved " + e->label + " to " + fs::path(destDir).filename().string();
beginScan(location_, inSmb_);
saveCurrentFolderState();
beginScan(location_, inSmb_, true);
return true;
}
@@ -687,7 +742,8 @@ bool Browser::deleteFocused(std::string& deletedName) {
return false;
}
status_ = "Deleted " + deletedName;
beginScan(location_, inSmb_);
saveCurrentFolderState();
beginScan(location_, inSmb_, true);
return true;
}
@@ -708,7 +764,9 @@ bool Browser::renameFocused(const std::string& newName) {
return false;
}
status_ = "Renamed to " + newName;
beginScan(location_, inSmb_);
saveCurrentFolderState();
folderHistory_[location_].selectedPath = dest.string();
beginScan(location_, inSmb_, true);
return true;
}
@@ -762,28 +820,29 @@ Browser::Result Browser::activate(std::string& playPath) {
const Entry e = entries_[static_cast<std::size_t>(selected_)];
if (e.isBack) {
const std::string prevChild = location_;
if (location_ == "bookmarks://") {
beginScan(homeDir_, false);
beginScan(homeDir_, false, true, prevChild);
return Result::Navigated;
}
if (inSmb_) {
const std::string parent = smb_parent(location_);
if (parent.empty()) {
beginScan(homeDir_, false);
beginScan(homeDir_, false, true, prevChild);
} else {
beginScan(parent, true);
beginScan(parent, true, true, prevChild);
}
} else {
if (VaultManager::instance().isVaultPath(location_)) {
if (location_ == VaultManager::instance().vaultDir()) {
beginScan(homeDir_, false);
beginScan(homeDir_, false, true, prevChild);
} else {
const fs::path p(location_);
beginScan(p.has_parent_path() ? p.parent_path().string() : homeDir_, false);
beginScan(p.has_parent_path() ? p.parent_path().string() : homeDir_, false, true, prevChild);
}
} else {
const fs::path p(location_);
beginScan(p.has_parent_path() ? p.parent_path().string() : "/", false);
beginScan(p.has_parent_path() ? p.parent_path().string() : "/", false, true, prevChild);
}
}
return Result::Navigated;
@@ -792,15 +851,18 @@ Browser::Result Browser::activate(std::string& playPath) {
return Result::AddSmbShare;
}
if (e.isNetwork) {
beginScan("smb://", true);
saveCurrentFolderState();
beginScan("smb://", true, false);
return Result::Navigated;
}
if (e.isDir) {
saveCurrentFolderState();
const bool isSmb = e.isNetwork || (e.path.rfind("smb://", 0) == 0);
beginScan(e.path, isSmb);
beginScan(e.path, isSmb, false);
return Result::Navigated;
}
if (e.isVideo) {
saveCurrentFolderState();
// Stream by default: local files and SMB shares both play in place. The
// media engine opens smb:// URLs directly, so there is no need to copy
// the file to the device first. Downloading is an explicit action
@@ -1367,21 +1429,73 @@ Browser::Result Browser::poll(std::string& playPath) {
// Publish a finished async directory scan (SMB discovery / large dirs).
if (scanReady_.exchange(false)) {
if (scanThread_.joinable()) scanThread_.join();
{
// If the user switched to another directory while this scan was busy,
// discard the stale result and immediately start the pending scan.
if (hasPendingScan_) {
hasPendingScan_ = false;
const int savedFilter = activeFilter_;
beginScan(pendingScanLoc_, pendingScanSmb_,
pendingScanRestore_, pendingScanChildPath_);
activeFilter_ = savedFilter;
} else {
std::lock_guard<std::mutex> lk(scanMutex_);
entries_ = std::move(scanResult_.entries);
title_ = std::move(scanResult_.title);
status_ = std::move(scanResult_.status);
location_ = scanResult_.location;
inSmb_ = scanResult_.inSmb;
selected_ = 0;
lastLocation_ = location_; // remember for the next open()
lastInSmb_ = inSmb_;
}
// Run a navigation the user requested while this scan was busy.
if (hasPendingScan_) {
hasPendingScan_ = false;
beginScan(pendingScanLoc_, pendingScanSmb_);
const int n = static_cast<int>(entries_.size());
if (restoreViewOnScan_ && n > 0) {
auto it = folderHistory_.find(location_);
if (it != folderHistory_.end()) {
const FolderViewState& st = it->second;
int foundIdx = -1;
if (!st.selectedPath.empty()) {
for (int i = 0; i < n; ++i) {
if (entries_[static_cast<std::size_t>(i)].path == st.selectedPath) {
foundIdx = i;
break;
}
}
}
if (foundIdx < 0 && !restoreChildPath_.empty()) {
for (int i = 0; i < n; ++i) {
if (entries_[static_cast<std::size_t>(i)].path == restoreChildPath_) {
foundIdx = i;
break;
}
}
}
selected_ = (foundIdx >= 0) ? foundIdx : std::clamp(st.selected, 0, n - 1);
first_ = std::clamp(st.first, 0, n - 1);
pendingRestoreFirst_ = first_;
hasPendingRestoreFirst_ = true;
} else {
int foundIdx = 0;
if (!restoreChildPath_.empty()) {
for (int i = 0; i < n; ++i) {
if (entries_[static_cast<std::size_t>(i)].path == restoreChildPath_) {
foundIdx = i;
break;
}
}
}
selected_ = foundIdx;
first_ = 0;
pendingRestoreFirst_ = 0;
hasPendingRestoreFirst_ = true;
}
} else {
selected_ = 0;
first_ = 0;
pendingRestoreFirst_ = 0;
hasPendingRestoreFirst_ = true;
}
restoreViewOnScan_ = false;
restoreChildPath_.clear();
}
}
if (dlActive_.load()) {
+30 -1
View File
@@ -6,6 +6,7 @@
#include <mutex>
#include <string>
#include <thread>
#include <unordered_map>
#include <vector>
namespace pixelvr {
@@ -90,6 +91,14 @@ public:
const int n = static_cast<int>(entries_.size());
selected_ = (n <= 0) ? 0 : (index < 0 ? 0 : (index >= n ? n - 1 : index));
}
void setFirst(int first) { first_ = first < 0 ? 0 : first; }
int first() const { return first_; }
bool consumeRestoredFirst(int& outFirst) {
if (!hasPendingRestoreFirst_) return false;
outFirst = pendingRestoreFirst_;
hasPendingRestoreFirst_ = false;
return true;
}
// Activates the current selection. On Result::Play, `playPath` receives the
// path or smb:// URI for the media engine (SMB files stream in place).
@@ -109,6 +118,7 @@ public:
// True while browsing an SMB share (as opposed to local storage).
bool onNetwork() const { return inSmb_; }
int activeFilter() const { return activeFilter_; }
// Advances any in-progress SMB download. Returns Result::Play with a LOCAL
// playPath when a download finishes; otherwise Result::None.
@@ -174,12 +184,19 @@ private:
std::string location;
bool inSmb = false;
};
void beginScan(const std::string& location, bool isSmb); // async navigate
void beginScan(const std::string& location, bool isSmb,
bool restoreView = false,
const std::string& childPath = ""); // async navigate
void runScan(std::string location, bool isSmb); // worker body
void saveCurrentFolderState();
void updateActiveFilterFor(const std::string& loc, bool isSmb);
int activeFilter_ = 0;
// Navigation requested while a scan was already running (deferred so the
// main thread never blocks joining a scan stuck on the SMB lock).
std::string pendingScanLoc_;
bool pendingScanSmb_ = false;
bool pendingScanRestore_ = false;
std::string pendingScanChildPath_;
bool hasPendingScan_ = false;
void scanLocalInto(const std::string& dir, ScanResult& out);
void scanSmbInto(const std::string& uri, ScanResult& out);
@@ -188,6 +205,18 @@ private:
void startDownloadFolder(const std::string& uri, const std::string& name);
void startNextQueued();
struct FolderViewState {
int selected = 0;
int first = 0;
std::string selectedPath;
};
std::unordered_map<std::string, FolderViewState> folderHistory_;
int first_ = 0;
bool restoreViewOnScan_ = false;
std::string restoreChildPath_;
int pendingRestoreFirst_ = 0;
bool hasPendingRestoreFirst_ = false;
bool open_ = false;
bool inSmb_ = false;
std::string title_;
+13 -3
View File
@@ -27,10 +27,10 @@ constexpr int kDownloadDirCount = 3;
const char* kLangNames[] = {"Auto", "English", "German", "French",
"Spanish", "Japanese", "Italian", "Russian",
"Chinese", "Korean"};
"Chinese"};
const char* kLangCodes[] = {"", "eng", "deu", "fra", "spa",
"jpn", "ita", "rus", "chi", "kor"};
constexpr int kLangCount = 10;
"jpn", "ita", "rus", "chi"};
constexpr int kLangCount = 9;
const char* kCurvatureNames[] = {"Flat (0%)", "Gentle (25%)", "Cinema (50%)",
"Deep Curve (75%)", "IMAX Wrap (100%)"};
@@ -505,6 +505,16 @@ int SettingsMenu::selectedAudioStream() const {
: -1;
}
void SettingsMenu::setSelectedAudioStream(int containerIndex) {
for (std::size_t i = 0; i < audioStreams_.size(); ++i) {
if (audioStreams_[i].first == containerIndex) {
audioSel_ = static_cast<int>(i);
break;
}
}
if (open_) refresh();
}
std::string SettingsMenu::audioLanguage() const {
return kLangCodes[audioLangIdx_ % kLangCount];
}
+1
View File
@@ -126,6 +126,7 @@ public:
int currentVideo, int currentAudio);
int selectedVideoStream() const;
int selectedAudioStream() const;
void setSelectedAudioStream(int containerIndex);
std::string audioLanguage() const; // ISO code of the preferred language, ""
void loadConfig();
+345 -68
View File
@@ -70,8 +70,49 @@ static void clearOrSetShmKeyboardBuffer(const std::string& text) {
}
closedir(dir);
}
// Reads the live keyboard buffer the GamepadUI keyboard maintains in shared memory
// (same segment/offset written by clearOrSetShmKeyboardBuffer). Returns true if a valid
// keyboard shm segment was found, writing the current full text (possibly empty) to out.
// This is the reliable source of the complete typed string: GetKeyboardText() on this
// keyboard only returns the latest single keystroke, not the accumulated buffer.
static bool readShmKeyboardBuffer(std::string& out) {
out.clear();
DIR* dir = opendir("/dev/shm");
if (!dir) return false;
bool found = false;
struct dirent* entry;
while ((entry = readdir(dir)) != nullptr) {
if (std::strstr(entry->d_name, "Shm_") != nullptr) {
std::string path = std::string("/dev/shm/") + entry->d_name;
struct stat st{};
if (stat(path.c_str(), &st) == 0 && st.st_size == 0x40cc0) {
int fd = open(path.c_str(), O_RDONLY);
if (fd >= 0) {
void* ptr = mmap(nullptr, 0x40cc0, PROT_READ, MAP_SHARED, fd, 0);
close(fd);
if (ptr != MAP_FAILED) {
const char* kbBuf = reinterpret_cast<const char*>(ptr) + 0x3fe60;
std::size_t len = strnlen(kbBuf, 1023);
found = true;
// Multiple segments can exist; prefer the one that actually holds
// text (the active keyboard's buffer may not be the first found).
if (len > 0 && out.empty()) {
out.assign(kbBuf, len);
}
munmap(ptr, 0x40cc0);
}
}
}
}
}
closedir(dir);
return found;
}
#else
static void clearOrSetShmKeyboardBuffer(const std::string&) {}
static bool readShmKeyboardBuffer(std::string& out) { out.clear(); return false; }
#endif
} // namespace
@@ -154,24 +195,101 @@ bool VrKeyboard::init() {
return false;
}
vr::IVRSettings* settings = reinterpret_cast<vr::IVRSettings*>(
g_VR_GetGenericInterface(vr::IVRSettings_Version, &err));
vr::EVRInitError propErr = vr::VRInitError_None;
void* props = reinterpret_cast<void*>(
g_VR_GetGenericInterface("IVRProperties_001", &propErr));
vr::EVRInitError focusErr = vr::VRInitError_None;
void* inputFocus = reinterpret_cast<void*>(
g_VR_GetGenericInterface("IVRInputFocusInternal_XXX", &focusErr));
vrSystem_ = sys;
vrOverlay_ = ov;
vrSettings_ = settings;
vrProperties_ = props;
vrInputFocus_ = inputFocus;
available_ = true;
// Clear any stale shared memory keyboard buffer left by previous sessions
clearOrSetShmKeyboardBuffer("");
PIXELVR_LOG_INFO("VrKeyboard: Successfully initialized SteamVR overlay interface (sys=%p, ov=%p)",
reinterpret_cast<void*>(sys), reinterpret_cast<void*>(ov));
PIXELVR_LOG_INFO("VrKeyboard: Successfully initialized SteamVR overlay interface (sys=%p, ov=%p, settings=%p, props=%p, focus=%p)",
reinterpret_cast<void*>(sys), reinterpret_cast<void*>(ov),
reinterpret_cast<void*>(settings), props, inputFocus);
return true;
}
void VrKeyboard::setLaserPointerActive(bool active) {
// 1. Update dashboard.preferGamepadMode setting
if (auto* settings = reinterpret_cast<vr::IVRSettings*>(vrSettings_)) {
vr::EVRSettingsError sErr = vr::VRSettingsError_None;
settings->SetBool("dashboard", "preferGamepadMode", !active, &sErr);
}
// 2. Clear or restore controller property 0x845 (2117, k_unBoolPropertyTag = 4) via IVRProperties_001.
// On Steam Frame standalone, vrcompositor checks property 0x845 on each controller every frame;
// when true, it zeroes m_ulActiveLaserHand and switches SystemPanelInteractionMode back to Gamepad (2)
// unless the user manually presses the Grip laser-toggle button. Setting property 0x845 to false (0)
// while the keyboard is open allows vrcompositor to activate system.pointer automatically.
if (vrProperties_) {
struct PropertyWrite {
uint32_t prop;
uint32_t writeType;
uint32_t eSetError;
void* pvBuffer;
uint32_t unBufferSize;
uint32_t unTag;
uint32_t eError;
};
using FnWritePropertyBatch = uint32_t (*)(void*, uint64_t, PropertyWrite*, uint32_t);
using FnTrackedDeviceToPropertyContainer = uint64_t (*)(void*, uint32_t);
void** vtable = *reinterpret_cast<void***>(vrProperties_);
auto writeBatch = reinterpret_cast<FnWritePropertyBatch>(vtable[1]);
auto devToContainer = reinterpret_cast<FnTrackedDeviceToPropertyContainer>(vtable[3]);
auto* sys = reinterpret_cast<vr::IVRSystem*>(vrSystem_);
uint8_t val = active ? 0 : 1;
for (uint32_t dev = 1; dev < 16; ++dev) {
if (!active && sys && sys->GetTrackedDeviceClass(dev) != vr::TrackedDeviceClass_Controller) {
continue;
}
uint64_t container = devToContainer(vrProperties_, dev);
if (container != 0) {
PropertyWrite pw{};
pw.prop = 0x845; // 2117: controller prefer-gamepad-over-laser property
pw.writeType = 0; // PropertyWrite_Set
pw.eSetError = 0;
pw.pvBuffer = &val;
pw.unBufferSize = sizeof(val);
pw.unTag = 4; // k_unBoolPropertyTag
pw.eError = 0;
writeBatch(vrProperties_, container, &pw, 1);
}
}
}
// 3. Switch FocusState SystemPanelInteractionMode to LaserMouse (1) via IVRInputFocusInternal_XXX
// vtable[12] (SetSystemPanelInteractionMode) so the laser pointer is immediately active even if
// more than 10 seconds have elapsed since the dashboard last had focus.
if (active && vrInputFocus_) {
using FnSetSystemPanelInteractionMode = void (*)(void*, int);
void** vtable = *reinterpret_cast<void***>(vrInputFocus_);
auto setMode = reinterpret_cast<FnSetSystemPanelInteractionMode>(vtable[12]);
setMode(vrInputFocus_, 1); // 1 = LaserMouse, 2 = Gamepad
}
}
void VrKeyboard::shutdown() {
if (!available_) return;
available_ = false;
if (isOpen_) {
hide();
}
setLaserPointerActive(false);
if (g_VR_ShutdownInternal) {
auto fn = g_VR_ShutdownInternal;
g_VR_ShutdownInternal = nullptr;
@@ -179,6 +297,9 @@ void VrKeyboard::shutdown() {
}
vrSystem_ = nullptr;
vrOverlay_ = nullptr;
vrSettings_ = nullptr;
vrProperties_ = nullptr;
vrInputFocus_ = nullptr;
if (libHandle_) {
#if defined(__linux__) || defined(__APPLE__)
dlclose(libHandle_);
@@ -215,17 +336,19 @@ bool VrKeyboard::configureKeyboardOverlay() {
ov->SetOverlayFlag(h, vr::VROverlayFlags_SendVRDiscreteScrollEvents, true);
ov->ShowOverlay(h);
foundAny = true;
PIXELVR_LOG_INFO("VrKeyboard: Configured keyboard overlay %llu ('%s') with interactive laser mouse at standing pos (%.2f, %.2f, %.2f)",
static_cast<unsigned long long>(h), name ? name : "unknown",
mat.m[0][3], mat.m[1][3], mat.m[2][3]);
if (!kbConfigured_) {
PIXELVR_LOG_INFO("VrKeyboard: Configured keyboard overlay %llu ('%s') with interactive laser mouse at standing pos (%.2f, %.2f, %.2f)",
static_cast<unsigned long long>(h), name ? name : "unknown",
mat.m[0][3], mat.m[1][3], mat.m[2][3]);
}
};
// 1. Check known specific overlay keys
// 1. Check known specific VR keyboard overlay keys (exclude valve.steam.gamepadui.keyboard
// which is the 2D gamescope overlay and would collide with system.keyboard in 3D space)
const char* kbOverlayNames[] = {
"VRKeyboard_uid0",
"VRKeyboard_uid1",
"VRKeyboard",
"valve.steam.gamepadui.keyboard",
"system.keyboard"
};
for (const char* name : kbOverlayNames) {
@@ -241,6 +364,9 @@ bool VrKeyboard::configureKeyboardOverlay() {
vr::EVROverlayError kErr = vr::VROverlayError_None;
uint32_t len = ov->GetOverlayKey(h, keyBuf, sizeof(keyBuf), &kErr);
if (kErr == vr::VROverlayError_None && len > 0) {
if (std::strcmp(keyBuf, "valve.steam.gamepadui.keyboard") == 0) {
continue;
}
if (std::strstr(keyBuf, "keyboard") != nullptr ||
std::strstr(keyBuf, "Keyboard") != nullptr ||
std::strstr(keyBuf, "VRKeyboard") != nullptr) {
@@ -252,6 +378,7 @@ bool VrKeyboard::configureKeyboardOverlay() {
if (foundAny) {
kbConfigured_ = true;
}
setLaserPointerActive(true);
return foundAny;
}
@@ -282,6 +409,9 @@ bool VrKeyboard::show(const std::string& description,
description_ = description;
existingText_ = existingText;
currentText_ = existingText;
cursorPos_ = currentText_.size();
escState_ = 0;
escParams_.clear();
isPassword_ = isPassword;
maxChars_ = maxChars;
doneSubmitted_ = false;
@@ -395,6 +525,10 @@ bool VrKeyboard::show(const std::string& description,
}
kbConfigured_ = false;
// 0. Activate SteamVR's laser pointer (system.pointer) on the keyboard overlay immediately
// without requiring the user to press the Grip button first.
setLaserPointerActive(true);
// 1. Set keyboard transform in standing universe BEFORE ShowKeyboard
ov->SetKeyboardTransformAbsolute(vr::TrackingUniverseStanding, &kbMatStage);
@@ -402,7 +536,7 @@ bool VrKeyboard::show(const std::string& description,
vr::EVROverlayError err = ov->ShowKeyboard(
mode,
vr::k_EGamepadTextInputLineModeSingleLine,
vr::KeyboardFlag_Modal | vr::KeyboardFlag_Minimal,
vr::KeyboardFlag_Modal,
description.c_str(),
static_cast<uint32_t>(maxChars),
existingText.c_str(),
@@ -410,6 +544,7 @@ bool VrKeyboard::show(const std::string& description,
if (err != vr::VROverlayError_None) {
PIXELVR_LOG_WARN("VrKeyboard: ShowKeyboard failed with error %d", static_cast<int>(err));
setLaserPointerActive(false);
return false;
}
@@ -436,6 +571,7 @@ void VrKeyboard::hide() {
if (ov) {
ov->HideKeyboard();
}
setLaserPointerActive(false);
clearOrSetShmKeyboardBuffer("");
isOpen_ = false;
kbConfigured_ = false;
@@ -444,9 +580,39 @@ void VrKeyboard::hide() {
currentNonce_ = 0;
currentText_.clear();
existingText_.clear();
cursorPos_ = 0;
escState_ = 0;
escParams_.clear();
PIXELVR_LOG_INFO("VrKeyboard: Hidden virtual keyboard");
}
void VrKeyboard::moveCursor(int delta) {
if (!isOpen_) return;
if (cursorPos_ > currentText_.size()) {
cursorPos_ = currentText_.size();
}
if (delta < 0) {
int steps = -delta;
while (steps > 0 && cursorPos_ > 0) {
--cursorPos_;
while (cursorPos_ > 0 && (static_cast<unsigned char>(currentText_[cursorPos_]) & 0xC0) == 0x80) {
--cursorPos_;
}
--steps;
}
} else if (delta > 0) {
int steps = delta;
while (steps > 0 && cursorPos_ < currentText_.size()) {
++cursorPos_;
while (cursorPos_ < currentText_.size() &&
(static_cast<unsigned char>(currentText_[cursorPos_]) & 0xC0) == 0x80) {
++cursorPos_;
}
--steps;
}
}
}
void VrKeyboard::poll(std::function<void(const std::string&)> onDone,
std::function<void()> onCancel) {
if (!available_ || !isOpen_) return;
@@ -454,64 +620,179 @@ void VrKeyboard::poll(std::function<void(const std::string&)> onDone,
auto* ov = reinterpret_cast<vr::IVROverlay*>(vrOverlay_);
if (!sys || !ov) return;
// Tears down the current session state and clears any lingering draft buffer so that
// text can never persist into the next field or session.
auto resetSession = [&]() {
isOpen_ = false;
kbConfigured_ = false;
ov->HideKeyboard();
setLaserPointerActive(false);
clearOrSetShmKeyboardBuffer("");
currentText_.clear();
existingText_.clear();
cursorPos_ = 0;
escState_ = 0;
escParams_.clear();
userTyped_ = false;
currentNonce_ = 0;
};
auto deleteBeforeCursor = [&]() {
if (cursorPos_ > currentText_.size()) cursorPos_ = currentText_.size();
if (cursorPos_ == 0 || currentText_.empty()) return;
std::size_t p = cursorPos_;
while (p > 0) {
--p;
if ((static_cast<unsigned char>(currentText_[p]) & 0xC0) != 0x80) {
break;
}
}
currentText_.erase(p, cursorPos_ - p);
cursorPos_ = p;
};
auto deleteAtCursor = [&]() {
if (cursorPos_ > currentText_.size()) cursorPos_ = currentText_.size();
if (cursorPos_ >= currentText_.size() || currentText_.empty()) return;
std::size_t p = cursorPos_ + 1;
while (p < currentText_.size() &&
(static_cast<unsigned char>(currentText_[p]) & 0xC0) == 0x80) {
++p;
}
currentText_.erase(cursorPos_, p - cursorPos_);
};
auto handleCsiCommand = [&](char finalChar, const std::string& params) {
if (finalChar == 'D') {
moveCursor(-1);
} else if (finalChar == 'C') {
moveCursor(1);
} else if (finalChar == 'A' || finalChar == 'H') {
cursorPos_ = 0;
} else if (finalChar == 'B' || finalChar == 'F') {
cursorPos_ = currentText_.size();
} else if (finalChar == '~') {
if (params == "3") {
deleteAtCursor();
} else if (params == "1" || params == "7") {
cursorPos_ = 0;
} else if (params == "4" || params == "8") {
cursorPos_ = currentText_.size();
}
}
};
vr::VREvent_t event{};
while (sys->PollNextEvent(&event, sizeof(event))) {
const bool isKeyboardEvent =
event.eventType == vr::VREvent_KeyboardCharInput ||
event.eventType == vr::VREvent_KeyboardDone ||
event.eventType == vr::VREvent_KeyboardClosed ||
event.eventType == vr::VREvent_KeyboardClosed_Global;
// Discard events belonging to an old or stale keyboard session. This guard must cover
// every keyboard event (not just CharInput): a stale Done/Closed would otherwise leak
// the previous field's text into the field currently being edited.
if (isKeyboardEvent &&
event.data.keyboard.uUserValue != 0 &&
event.data.keyboard.uUserValue != currentNonce_) {
PIXELVR_LOG_INFO("VrKeyboard: Ignored stale keyboard event (type %u, nonce %llu != current %llu)",
event.eventType,
static_cast<unsigned long long>(event.data.keyboard.uUserValue),
static_cast<unsigned long long>(currentNonce_));
continue;
}
if (event.eventType == vr::VREvent_KeyboardCharInput) {
// Discard any events from old or stale keyboard sessions
if (event.data.keyboard.uUserValue != 0 && event.data.keyboard.uUserValue != currentNonce_) {
PIXELVR_LOG_INFO("VrKeyboard: Ignored stale CharInput (nonce %llu != current %llu)",
static_cast<unsigned long long>(event.data.keyboard.uUserValue),
static_cast<unsigned long long>(currentNonce_));
continue;
const char* inStr = event.data.keyboard.cNewInput;
char apiBuf[1024] = {0};
uint32_t apiLen = ov->GetKeyboardText(apiBuf, sizeof(apiBuf));
// Determine the keystroke payload: SteamVR's system.keyboard sends each
// key press as a single-character delta via cNewInput (and mirrors that
// single character in GetKeyboardText / shm 0x3fe60).
std::string key;
std::string shmKey;
if (inStr && inStr[0] != '\0') {
key.assign(inStr, strnlen(inStr, sizeof(event.data.keyboard.cNewInput)));
} else if (apiLen > 0 && apiBuf[0] != '\0') {
key.assign(apiBuf, strnlen(apiBuf, sizeof(apiBuf) - 1));
} else if (readShmKeyboardBuffer(shmKey) && !shmKey.empty()) {
key = shmKey;
}
const char* inStr = event.data.keyboard.cNewInput;
if (inStr && inStr[0] != 0) {
userTyped_ = true;
const unsigned char firstByte = static_cast<unsigned char>(inStr[0]);
if (firstByte == '\b' || firstByte == 0x7F || firstByte == 8) {
// Backspace: pop last UTF-8 character
while (!currentText_.empty() && (static_cast<unsigned char>(currentText_.back()) & 0xC0) == 0x80) {
currentText_.pop_back();
if (!key.empty() && (key[0] == '\n' || key[0] == '\r')) {
// Enter key submits the accumulated buffer.
const std::string text = currentText_;
doneSubmitted_ = true;
resetSession();
PIXELVR_LOG_INFO("VrKeyboard: User submitted text via Return key: '%s'", text.c_str());
if (onDone) {
onDone(text);
}
return;
}
// SteamVR's virtual keyboard sends ANSI escape sequences (either "\x1b[D"/"\x1b[C"
// or bare "[D"/"[C") when clicking its Left / Right cursor buttons.
if (escState_ == 0 && (key == "[D" || key == "[C" || key == "[A" || key == "[B" ||
key == "[H" || key == "[F" || key == "[3~")) {
if (key == "[3~") {
deleteAtCursor();
} else {
handleCsiCommand(key[1], "");
}
} else if (!key.empty() && (key[0] == '\b' || static_cast<unsigned char>(key[0]) == 0x7f) && key.size() == 1 && escState_ == 0) {
deleteBeforeCursor();
} else if (!key.empty()) {
for (char c : key) {
const unsigned char uc = static_cast<unsigned char>(c);
if (escState_ == 1) {
if (c == '[' || c == 'O') {
escState_ = 2;
escParams_.clear();
continue;
}
escState_ = 0;
escParams_.clear();
} else if (escState_ == 2) {
if (uc >= 0x30 && uc <= 0x3F) {
escParams_.push_back(c);
continue;
}
if (uc >= 0x20 && uc <= 0x2F) {
continue;
}
if (uc >= 0x40 && uc <= 0x7E) {
handleCsiCommand(c, escParams_);
}
escState_ = 0;
escParams_.clear();
continue;
}
if (!currentText_.empty()) {
currentText_.pop_back();
if (c == '\x1b') {
escState_ = 1;
escParams_.clear();
} else if (c == '\b' || uc == 0x7f) {
deleteBeforeCursor();
} else if (uc >= 32) {
if (currentText_.length() < static_cast<std::size_t>(maxChars_)) {
if (cursorPos_ > currentText_.size()) cursorPos_ = currentText_.size();
currentText_.insert(cursorPos_, 1, c);
++cursorPos_;
}
}
PIXELVR_LOG_INFO("VrKeyboard: Backspace pressed, text now: '%s'", currentText_.c_str());
} else if (firstByte == '\n' || firstByte == '\r') {
isOpen_ = false;
kbConfigured_ = false;
doneSubmitted_ = true;
std::string text = currentText_;
ov->HideKeyboard();
clearOrSetShmKeyboardBuffer("");
currentText_.clear();
existingText_.clear();
userTyped_ = false;
currentNonce_ = 0;
PIXELVR_LOG_INFO("VrKeyboard: User submitted text via Return key: '%s'", text.c_str());
if (onDone) {
onDone(text);
}
return;
} else if (firstByte >= 32) {
if (currentText_.length() + std::strlen(inStr) <= static_cast<size_t>(maxChars_)) {
currentText_ += inStr;
}
PIXELVR_LOG_INFO("VrKeyboard: Input '%s', text now: '%s'", inStr, currentText_.c_str());
}
}
userTyped_ = true;
PIXELVR_LOG_INFO("VrKeyboard: CharInput key='%s' api(len=%u)='%s' -> currentText='%s' cursor=%zu",
key.c_str(), apiLen, apiBuf, currentText_.c_str(), cursorPos_);
} else if (event.eventType == vr::VREvent_KeyboardDone) {
isOpen_ = false;
kbConfigured_ = false;
const std::string text = currentText_;
doneSubmitted_ = true;
std::string text = currentText_;
ov->HideKeyboard();
clearOrSetShmKeyboardBuffer("");
currentText_.clear();
existingText_.clear();
userTyped_ = false;
currentNonce_ = 0;
resetSession();
PIXELVR_LOG_INFO("VrKeyboard: User submitted text via KeyboardDone: '%s'", text.c_str());
if (onDone) {
onDone(text);
@@ -519,16 +800,11 @@ void VrKeyboard::poll(std::function<void(const std::string&)> onDone,
return;
} else if (event.eventType == vr::VREvent_KeyboardClosed ||
event.eventType == vr::VREvent_KeyboardClosed_Global) {
isOpen_ = false;
kbConfigured_ = false;
ov->HideKeyboard();
clearOrSetShmKeyboardBuffer("");
PIXELVR_LOG_INFO("VrKeyboard: Keyboard closed (doneSubmitted=%d)", doneSubmitted_ ? 1 : 0);
if (!doneSubmitted_) {
currentText_.clear();
existingText_.clear();
userTyped_ = false;
currentNonce_ = 0;
const bool wasSubmitted = doneSubmitted_;
resetSession();
doneSubmitted_ = false;
PIXELVR_LOG_INFO("VrKeyboard: Keyboard closed (doneSubmitted=%d)", wasSubmitted ? 1 : 0);
if (!wasSubmitted) {
PIXELVR_LOG_INFO("VrKeyboard: Discarded input on KeyboardClosed");
if (onCancel) {
onCancel();
@@ -540,9 +816,10 @@ void VrKeyboard::poll(std::function<void(const std::string&)> onDone,
if (isOpen_) {
pollFramesSinceOpen_++;
// Maintain and reinforce keyboard overlay configuration across the first 90 frames (~1 sec)
// until successfully configured
if (!kbConfigured_ && pollFramesSinceOpen_ < 90) {
// Maintain and reinforce keyboard overlay configuration across the first 90 frames (~1.2 sec)
// so that even if vrwebhelper spawns system.keyboard ~100ms after ShowKeyboard(), it gets
// positioned and configured for laser mouse interaction.
if (!kbConfigured_ || pollFramesSinceOpen_ < 90) {
configureKeyboardOverlay();
}
}
+9
View File
@@ -37,6 +37,8 @@ public:
// Retrieves current entered buffer
std::string currentText() const;
int cursorPos() const { return static_cast<int>(cursorPos_); }
void moveCursor(int delta);
const std::string& description() const { return description_; }
bool isPassword() const { return isPassword_; }
const float* transform() const { return &kbMatLocal_[0][0]; }
@@ -46,6 +48,7 @@ private:
~VrKeyboard();
bool configureKeyboardOverlay();
void setLaserPointerActive(bool active);
bool available_ = false;
bool isOpen_ = false;
@@ -60,10 +63,16 @@ private:
std::string description_;
std::string existingText_;
std::string currentText_;
std::size_t cursorPos_ = 0;
int escState_ = 0;
std::string escParams_;
bool isPassword_ = false;
void* libHandle_ = nullptr;
void* vrSystem_ = nullptr;
void* vrOverlay_ = nullptr;
void* vrSettings_ = nullptr;
void* vrProperties_ = nullptr;
void* vrInputFocus_ = nullptr;
};
} // namespace pixelvr