diff --git a/UI_HANDOFF_ROADMAP.md b/UI_HANDOFF_ROADMAP.md new file mode 100644 index 0000000..3e37958 --- /dev/null +++ b/UI_HANDOFF_ROADMAP.md @@ -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 queue_` to `std::deque 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 ==='" + ``` diff --git a/src/app/app.cpp b/src/app/app.cpp index 9eca793..622b182 100644 --- a/src/app/app.cpp +++ b/src/app/app.cpp @@ -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(smbModal_.host.front())) || + smbModal_.host.front() == '/' || smbModal_.host.front() == '\\')) { + smbModal_.host.erase(smbModal_.host.begin()); + } + while (!smbModal_.host.empty() && + (std::isspace(static_cast(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 onDone, + std::function 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(clean.front())) || clean.front() == '/' || clean.front() == '\\')) { + clean.erase(clean.begin()); + } + while (!clean.empty() && (std::isspace(static_cast(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(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(h.front())) || h.front() == '/' || h.front() == '\\')) h.erase(h.begin()); + while (!h.empty() && (std::isspace(static_cast(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((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) { diff --git a/src/app/app.hpp b/src/app/app.hpp index 5d80078..2e0bc16 100644 --- a/src/app/app.hpp +++ b/src/app/app.hpp @@ -16,6 +16,7 @@ #include "ui/settings_menu.hpp" #include +#include #include namespace pixelvr { @@ -122,6 +123,15 @@ private: void saveSmbModal(); void deleteSmbModal(); + std::function kbOnDone_; + std::function kbOnCancel_; + void openKeyboard(const std::string& prompt, + const std::string& initialText, + bool isPassword, + const XrInputState& in, + std::function onDone, + std::function onCancel = nullptr); + #ifdef PIXELVR_HAVE_FREETYPE Browser browser_; ThumbnailCache thumbnailCache_; diff --git a/src/gfx/text_atlas.cpp b/src/gfx/text_atlas.cpp index 6770c08..771b22a 100644 --- a/src/gfx/text_atlas.cpp +++ b/src/gfx/text_atlas.cpp @@ -36,7 +36,7 @@ FontAtlas buildFontAtlas(const char* ttfPath, int pixelHeight) { FT_Set_Pixel_Sizes(face, 0, static_cast(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(ch); + if (ch >= 137 && ch < 160) { + continue; + } + FT_ULong codepoint = static_cast(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; } } diff --git a/src/gfx/text_atlas.hpp b/src/gfx/text_atlas.hpp index a6a462e..d5524e6 100644 --- a/src/gfx/text_atlas.hpp +++ b/src/gfx/text_atlas.hpp @@ -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 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) diff --git a/src/gfx/vulkan_renderer.cpp b/src/gfx/vulkan_renderer.cpp index 6678ec1..86ebc93 100644 --- a/src/gfx/vulkan_renderer.cpp +++ b/src/gfx/vulkan_renderer.cpp @@ -1369,11 +1369,13 @@ void VulkanRenderer::updatePointer() { if (actionMenu_.page == 0 || actionMenu_.page == 1) { const int optCount = static_cast(actionMenu_.options.size()); - const float startY = (optCount > 4) ? 0.12f : 0.08f; + const float stepY = 0.068f; + const float spanY = static_cast(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(i) * 0.072f; + const float cy = startY - static_cast(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(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(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(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(r) * 0.24f; + cardHalfH = 0.098f; + } else if (total <= 8) { + ry = 0.26f - static_cast(r) * 0.185f; + cardHalfH = 0.078f; + } else { + ry = 0.28f - static_cast(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(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(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(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(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(s[idx + 1]); + if ((b1 & 0xC0) == 0x80) { + cp = ((static_cast(b0 & 0x1F) << 6) | + static_cast(b1 & 0x3F)); + idx += 2; + } + } else if ((b0 & 0xF0) == 0xE0 && idx + 2 < len) { + const unsigned char b1 = static_cast(s[idx + 1]); + const unsigned char b2 = static_cast(s[idx + 2]); + if ((b1 & 0xC0) == 0x80 && (b2 & 0xC0) == 0x80) { + cp = ((static_cast(b0 & 0x0F) << 12) | + (static_cast(b1 & 0x3F) << 6) | + static_cast(b2 & 0x3F)); + idx += 3; + } + } else if ((b0 & 0xF8) == 0xF0 && idx + 3 < len) { + const unsigned char b1 = static_cast(s[idx + 1]); + const unsigned char b2 = static_cast(s[idx + 2]); + const unsigned char b3 = static_cast(s[idx + 3]); + if ((b1 & 0xC0) == 0x80 && (b2 & 0xC0) == 0x80 && (b3 & 0xC0) == 0x80) { + cp = ((static_cast(b0 & 0x07) << 18) | + (static_cast(b1 & 0x3F) << 12) | + (static_cast(b2 & 0x3F) << 6) | + static_cast(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(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(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(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(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(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(r) * 0.24f; cardHalfH = 0.098f; + pillY = ry - 0.028f; + pillHalfH = 0.036f; } else if (total <= 8) { - ry = 0.25f - static_cast(r) * 0.17f; - cardHalfH = 0.072f; + ry = 0.26f - static_cast(r) * 0.185f; + cardHalfH = 0.078f; + pillY = ry - 0.022f; + pillHalfH = 0.029f; } else { - ry = 0.27f - static_cast(r) * 0.142f; - cardHalfH = 0.058f; + ry = 0.28f - static_cast(r) * 0.152f; + cardHalfH = 0.066f; + pillY = ry - 0.019f; + pillHalfH = 0.025f; } const bool en = settingsEnabled_.empty() || (i < static_cast(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(r) * 0.24f; + labelY = ry + 0.044f; + pillY = ry - 0.028f; } else if (total <= 8) { - ry = 0.25f - static_cast(r) * 0.17f; + ry = 0.26f - static_cast(r) * 0.185f; + labelY = ry + 0.034f; + pillY = ry - 0.022f; + textSc = scSub * 0.95f; } else { - ry = 0.27f - static_cast(r) * 0.142f; + ry = 0.28f - static_cast(r) * 0.152f; + labelY = ry + 0.028f; + pillY = ry - 0.019f; + textSc = scSub * 0.90f; } const bool en = settingsEnabled_.empty() || (i < static_cast(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(actionMenu_.options.size()); + const float menuStepY = 0.068f; + const float menuSpanY = static_cast(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(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(i) * 0.072f; + for (int i = 0; i < menuOptCount; ++i) { + const float cy = menuStartY - static_cast(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(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(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(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(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(i) * 0.072f; + for (int i = 0; i < menuOptCount; ++i) { + const float cy = menuStartY - static_cast(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(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(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(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(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(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::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. diff --git a/src/gfx/vulkan_renderer.hpp b/src/gfx/vulkan_renderer.hpp index 55f9d4e..4b894de 100644 --- a/src/gfx/vulkan_renderer.hpp +++ b/src/gfx/vulkan_renderer.hpp @@ -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(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 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 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; diff --git a/src/media/media_engine.cpp b/src/media/media_engine.cpp index 296e397..43109c7 100644 --- a/src/media/media_engine.cpp +++ b/src/media/media_engine.cpp @@ -34,6 +34,7 @@ extern "C" { #ifdef PIXELVR_HAVE_PULSE #include #include +#include #endif #ifdef PIXELVR_HAVE_SMB @@ -333,6 +334,15 @@ struct MediaEngine::Impl { std::atomic masterClock{0.0}; #ifdef PIXELVR_HAVE_PULSE pa_simple* pa = nullptr; + struct AudioChunk { + std::vector pcm; + double pts = -1.0; + double duration = 0.0; + }; + std::mutex audioMutex; + std::condition_variable audioCv; + std::deque audioQueue; + std::thread audioThread; #endif std::thread thread; @@ -363,6 +373,14 @@ struct MediaEngine::Impl { void teardown() { #ifdef PIXELVR_HAVE_PULSE + { + std::lock_guard 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 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 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 audioBuf; auto process_audio = [&]() { const int maxOut = swr_get_out_samples(swr, frame->nb_samples); if (maxOut <= 0) { return; } - audioBuf.resize(static_cast(maxOut) * kOutChannels * 2); - uint8_t* out[1] = {audioBuf.data()}; + std::vector pcm(static_cast(maxOut) * kOutChannels * 2); + uint8_t* out[1] = {pcm.data()}; const int got = swr_convert(swr, out, maxOut, const_cast(frame->extended_data), @@ -660,21 +671,32 @@ void MediaEngine::Impl::decodeLoop() { return; } const std::size_t bytes = static_cast(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(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(got) / static_cast(kOutSampleRate); + + { + std::lock_guard 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 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 lk(audioMutex); + audioCv.wait_for(lk, std::chrono::milliseconds(10)); + continue; + } + + AudioChunk chunk; + bool haveChunk = false; + double waitSec = 0.0; + { + std::unique_lock 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(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()) { 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(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(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(pa_usec_to_bytes(60000, &spec)); + ba.minreq = static_cast(pa_usec_to_bytes(20000, &spec)); + ba.prebuf = static_cast(-1); + ba.maxlength = static_cast(-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 diff --git a/src/ui/browser.cpp b/src/ui/browser.cpp index bfa7689..f40dd46 100644 --- a/src/ui/browser.cpp +++ b/src/ui/browser.cpp @@ -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(entries_.size())) { + selPath = entries_[static_cast(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(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 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(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(i)].path == st.selectedPath) { + foundIdx = i; + break; + } + } + } + if (foundIdx < 0 && !restoreChildPath_.empty()) { + for (int i = 0; i < n; ++i) { + if (entries_[static_cast(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(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()) { diff --git a/src/ui/browser.hpp b/src/ui/browser.hpp index 1ff5833..b1eab76 100644 --- a/src/ui/browser.hpp +++ b/src/ui/browser.hpp @@ -6,6 +6,7 @@ #include #include #include +#include #include namespace pixelvr { @@ -90,6 +91,14 @@ public: const int n = static_cast(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 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_; diff --git a/src/ui/settings_menu.cpp b/src/ui/settings_menu.cpp index 8960d2f..2289bda 100644 --- a/src/ui/settings_menu.cpp +++ b/src/ui/settings_menu.cpp @@ -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(i); + break; + } + } + if (open_) refresh(); +} + std::string SettingsMenu::audioLanguage() const { return kLangCodes[audioLangIdx_ % kLangCount]; } diff --git a/src/ui/settings_menu.hpp b/src/ui/settings_menu.hpp index aed4939..1583c44 100644 --- a/src/ui/settings_menu.hpp +++ b/src/ui/settings_menu.hpp @@ -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(); diff --git a/src/xr/vr_keyboard.cpp b/src/xr/vr_keyboard.cpp index e59dc80..359bafb 100644 --- a/src/xr/vr_keyboard.cpp +++ b/src/xr/vr_keyboard.cpp @@ -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(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( + g_VR_GetGenericInterface(vr::IVRSettings_Version, &err)); + + vr::EVRInitError propErr = vr::VRInitError_None; + void* props = reinterpret_cast( + g_VR_GetGenericInterface("IVRProperties_001", &propErr)); + + vr::EVRInitError focusErr = vr::VRInitError_None; + void* inputFocus = reinterpret_cast( + 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(sys), reinterpret_cast(ov)); + PIXELVR_LOG_INFO("VrKeyboard: Successfully initialized SteamVR overlay interface (sys=%p, ov=%p, settings=%p, props=%p, focus=%p)", + reinterpret_cast(sys), reinterpret_cast(ov), + reinterpret_cast(settings), props, inputFocus); return true; } +void VrKeyboard::setLaserPointerActive(bool active) { + // 1. Update dashboard.preferGamepadMode setting + if (auto* settings = reinterpret_cast(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(vrProperties_); + auto writeBatch = reinterpret_cast(vtable[1]); + auto devToContainer = reinterpret_cast(vtable[3]); + auto* sys = reinterpret_cast(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(vrInputFocus_); + auto setMode = reinterpret_cast(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(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(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(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(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(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(currentText_[cursorPos_]) & 0xC0) == 0x80) { + ++cursorPos_; + } + --steps; + } + } +} + void VrKeyboard::poll(std::function onDone, std::function onCancel) { if (!available_ || !isOpen_) return; @@ -454,64 +620,179 @@ void VrKeyboard::poll(std::function onDone, auto* ov = reinterpret_cast(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(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(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(event.data.keyboard.uUserValue), + static_cast(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(event.data.keyboard.uUserValue), - static_cast(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(inStr[0]); - if (firstByte == '\b' || firstByte == 0x7F || firstByte == 8) { - // Backspace: pop last UTF-8 character - while (!currentText_.empty() && (static_cast(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(key[0]) == 0x7f) && key.size() == 1 && escState_ == 0) { + deleteBeforeCursor(); + } else if (!key.empty()) { + for (char c : key) { + const unsigned char uc = static_cast(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(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(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 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 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(); } } diff --git a/src/xr/vr_keyboard.hpp b/src/xr/vr_keyboard.hpp index c26760b..c26b62a 100644 --- a/src/xr/vr_keyboard.hpp +++ b/src/xr/vr_keyboard.hpp @@ -37,6 +37,8 @@ public: // Retrieves current entered buffer std::string currentText() const; + int cursorPos() const { return static_cast(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