mirror of
https://github.com/Pixelplanet/Pixel-VR-Player.git
synced 2026-10-29 04:02:28 +01:00
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:
1 parent
766e67bffd
commit
41e8d09b74
14 files changed
+1439
-486
No files matched your search
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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_;
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user