diff --git a/cmake/MocktailVR.cmake b/cmake/MocktailVR.cmake index 4e1e813..7c76c85 100644 --- a/cmake/MocktailVR.cmake +++ b/cmake/MocktailVR.cmake @@ -32,17 +32,11 @@ add_library(mocktail_vr STATIC src/vr/vr_mirror.cc src/vr/xr_controller.cc src/vr/roblox_vr_device_bridge.cc - src/vr/virtual_keyboard.cc - src/vr/virtual_keyboard_raster.cc) + src/vr/virtual_keyboard.cc) add_library(Mocktail::VR ALIAS mocktail_vr) target_include_directories(mocktail_vr PUBLIC "${CMAKE_SOURCE_DIR}/include") target_include_directories(mocktail_vr PRIVATE "${CMAKE_SOURCE_DIR}/src") target_link_libraries(mocktail_vr PUBLIC Vulkan::Headers PRIVATE nlohmann_json::nlohmann_json) -# The in-headset keyboard draws its labels with the same SDL3_ttf/fontconfig -# stack as the TextBox overlay. MocktailInput/MocktailAudio find these -# packages later in the same directory scope; links resolve at generate time. -target_link_libraries(mocktail_vr PRIVATE SDL3_ttf::SDL3_ttf SDL3::SDL3 - PkgConfig::FONTCONFIG) # shm_open for the desktop eye mirror lives in librt on glibc. find_library(MOCKTAIL_RT_LIBRARY rt) if(MOCKTAIL_RT_LIBRARY) @@ -75,7 +69,16 @@ if(MOCKTAIL_ENABLE_VR) endfunction() mocktail_add_openxr_sdk() target_sources(mocktail_vr PRIVATE src/vr/openxr_probe.cc src/vr/openxr_preview.cc - src/vr/openxr_backend.cc src/vr/gles_transport.cc src/vr/xr_actions.cc) + src/vr/openxr_backend.cc src/vr/gles_transport.cc src/vr/xr_actions.cc + src/vr/virtual_keyboard_raster.cc) + # The in-headset keyboard draws its labels with the same SDL3_ttf and + # fontconfig stack as the TextBox overlay. + find_package(SDL3 3.4 REQUIRED CONFIG) + find_package(SDL3_ttf REQUIRED CONFIG) + find_package(PkgConfig REQUIRED) + pkg_check_modules(FONTCONFIG REQUIRED IMPORTED_TARGET fontconfig) + target_link_libraries(mocktail_vr PRIVATE SDL3_ttf::SDL3_ttf SDL3::SDL3 + PkgConfig::FONTCONFIG) # Error unwinding is confined to these native session implementations; their # public entry points catch errors before returning to the guest runtime. set_source_files_properties(src/vr/openxr_preview.cc src/vr/openxr_backend.cc diff --git a/include/mocktail/vr/openxr_backend.h b/include/mocktail/vr/openxr_backend.h index 6a1dc0d..2b94a87 100644 --- a/include/mocktail/vr/openxr_backend.h +++ b/include/mocktail/vr/openxr_backend.h @@ -336,6 +336,7 @@ class OpenXrBackend final { // keyboard swapchain holds a released image ready for a quad layer. bool PrepareVirtualKeyboardLayerLocked(VkQueue queue); bool UploadVirtualKeyboardVulkan(VkQueue queue, VkImage image); + void DestroyVirtualKeyboardStaging(); void DestroyVirtualKeyboardResources(); bool CaptureEyeEvidence(VkQueue queue, int eye); ScriptedPoseSample ComputeScriptedPose(std::uint64_t frame) const; diff --git a/include/mocktail/vr/virtual_keyboard.h b/include/mocktail/vr/virtual_keyboard.h index a9cb230..ece5ffa 100644 --- a/include/mocktail/vr/virtual_keyboard.h +++ b/include/mocktail/vr/virtual_keyboard.h @@ -74,7 +74,6 @@ struct VirtualKeyboardPointer { struct VirtualKeyboardOutput { std::vector text; // committed in order std::vector scancodes; // pressed in order, after text - bool closed = false; // user pressed Close }; // Ray/quad intersection. The quad is centred on `quad` with its face along @@ -118,8 +117,9 @@ class VirtualKeyboard { const VirtualKeyboardPointer (&pointers)[2]); // True when this hand's trigger belongs to the keyboard this frame (aimed - // at the panel, or a press that began on it is still held). The backend - // withholds that trigger from Roblox so a key press is not also a click. + // at the panel, or a press that began on it is still held, including after + // the panel hid mid-press). The backend withholds that trigger from Roblox + // so a key press is not also a click. bool CapturesTrigger(int hand) const; // Current layout, with Shift/Caps Lock applied to the labels. @@ -159,6 +159,9 @@ class VirtualKeyboard { pose::Pose panel_{}; VirtualKeyboardText text_; Hand hands_[2]; + // A press the keyboard owned when it hid; held until that trigger releases. + bool release_pending_[2] = {false, false}; + std::uint64_t cursor_drawn_ns_ = 0; std::vector keys_; std::uint64_t revision_ = 1; }; diff --git a/src/main.cc b/src/main.cc index 2d89487..46cc1bb 100644 --- a/src/main.cc +++ b/src/main.cc @@ -46,6 +46,7 @@ #include "runtime/roblox_desktop_app_policy.h" #include "runtime/roblox_experience_launch_bridge.h" #include "runtime/roblox_fullscreen_runtime_bridge.h" +#include "runtime/roblox_text_display_state.h" #include "runtime/roblox_text_editor.h" #include "runtime/runtime_config_bootstrap.h" #include "runtime/runtime_config_file.h" @@ -1082,13 +1083,10 @@ int main(int argc, char* argv[]) { mocktail::vr::ClearVirtualKeyboardText(); return; } - // RbxKeyboard password input types (see the SDL mapping). - const bool password = update.text_input_type == 5 || - update.text_input_type == 9 || - update.text_input_type == 10; mocktail::vr::SetVirtualKeyboardText( update.utf8, update.utf8_size, update.cursor_utf16, - password); + mocktail::runtime::IsRobloxPasswordTextInputType( + update.text_input_type)); }}); } } diff --git a/src/runtime/roblox_text_surface_overlay.cc b/src/runtime/roblox_text_surface_overlay.cc index c62530d..2465ee4 100644 --- a/src/runtime/roblox_text_surface_overlay.cc +++ b/src/runtime/roblox_text_surface_overlay.cc @@ -328,11 +328,15 @@ Status RobloxTextSurfaceOverlay::UpdateViewport( namespace { std::mutex g_display_mirror_mutex; RobloxTextDisplaySink g_display_mirror; +// Lets a text update skip the lock while no mirror is installed. +std::atomic g_display_mirror_set{false}; } // namespace void SetRobloxTextDisplayMirror(RobloxTextDisplaySink sink) { std::lock_guard lock(g_display_mirror_mutex); g_display_mirror = sink; + g_display_mirror_set.store(sink.update != nullptr, + std::memory_order_release); } RobloxTextDisplaySink RobloxTextSurfaceOverlay::sink() { @@ -344,6 +348,9 @@ void RobloxTextSurfaceOverlay::UpdateCallback( if (context != nullptr) { static_cast(context)->ApplyUpdate(update); } + if (!g_display_mirror_set.load(std::memory_order_acquire)) { + return; + } std::lock_guard lock(g_display_mirror_mutex); if (g_display_mirror.update != nullptr) { g_display_mirror.update(g_display_mirror.context, update); diff --git a/src/vr/openxr_backend.cc b/src/vr/openxr_backend.cc index 537a508..5dec0cd 100644 --- a/src/vr/openxr_backend.cc +++ b/src/vr/openxr_backend.cc @@ -2725,8 +2725,10 @@ void OpenXrBackend::UpdateVirtualKeyboardLocked() { return; } const VirtualKeyboardHost host = CurrentVirtualKeyboardHost(); + // A panel that cannot be drawn must not take triggers either. const bool requested = - host.input_requested != nullptr && host.input_requested(host.context); + !keyboard_swapchain_failed_ && host.input_requested != nullptr && + host.input_requested(host.context); if (!requested) { keyboard_.Hide(); keyboard_armed_ = true; @@ -2735,10 +2737,9 @@ void OpenXrBackend::UpdateVirtualKeyboardLocked() { keyboard_armed_ = false; Log(" [vr-keyboard] shown for focused TextBox\n"); } - if (!keyboard_.visible()) { - return; - } - { + // A hidden keyboard still runs below: it keeps a trigger that was pressed + // on the panel away from Roblox until that trigger is released. + if (keyboard_.visible()) { VirtualKeyboardText text = CurrentVirtualKeyboardText(); keyboard_.SetText(text); std::fill(text.utf8.begin(), text.utf8.end(), '\0'); @@ -2904,6 +2905,8 @@ bool OpenXrBackend::UploadVirtualKeyboardVulkan(VkQueue queue, VkImage image) { if (vk_->CreateBuffer(vk_device_, &info, nullptr, &keyboard_staging_) != VK_SUCCESS) { keyboard_staging_ = VK_NULL_HANDLE; + keyboard_swapchain_failed_ = true; + Log(" [vr-keyboard] disabled: no staging buffer\n"); return false; } VkMemoryRequirements requirements{}; @@ -2932,7 +2935,11 @@ bool OpenXrBackend::UploadVirtualKeyboardVulkan(VkQueue queue, VkImage image) { keyboard_staging_memory_, 0) != VK_SUCCESS || vk_->MapMemory(vk_device_, keyboard_staging_memory_, 0, size, 0, &keyboard_staging_mapped_) != VK_SUCCESS) { - DestroyVirtualKeyboardResources(); + // The caller still holds an acquired swapchain image, so only the + // staging objects go; the latch stops a retry on every frame. + DestroyVirtualKeyboardStaging(); + keyboard_swapchain_failed_ = true; + Log(" [vr-keyboard] disabled: no staging memory\n"); return false; } } @@ -2987,12 +2994,15 @@ bool OpenXrBackend::UploadVirtualKeyboardVulkan(VkQueue queue, VkImage image) { if (vk_->WaitForFences(vk_device_, 1, ©_fence_, VK_TRUE, 2'000'000'000ULL) != VK_SUCCESS) { (void)IdleHostDevice(vk_->DeviceWaitIdle, vk_device_); - throw std::runtime_error("keyboard upload fence wait failed"); + // Returning lets the caller release the acquired swapchain image. + keyboard_swapchain_failed_ = true; + Log(" [vr-keyboard] disabled: upload fence wait failed\n"); + return false; } return true; } -void OpenXrBackend::DestroyVirtualKeyboardResources() { +void OpenXrBackend::DestroyVirtualKeyboardStaging() { if (vk_ != nullptr && vk_device_ != VK_NULL_HANDLE) { if (keyboard_staging_mapped_ != nullptr) { vk_->UnmapMemory(vk_device_, keyboard_staging_memory_); @@ -3007,6 +3017,10 @@ void OpenXrBackend::DestroyVirtualKeyboardResources() { keyboard_staging_mapped_ = nullptr; keyboard_staging_ = VK_NULL_HANDLE; keyboard_staging_memory_ = VK_NULL_HANDLE; +} + +void OpenXrBackend::DestroyVirtualKeyboardResources() { + DestroyVirtualKeyboardStaging(); EyeSwapchain& slot = keyboard_swapchain_; slot.images.clear(); slot.gl_images.clear(); diff --git a/src/vr/openxr_disabled.cc b/src/vr/openxr_disabled.cc index b9f7446..0345fec 100644 --- a/src/vr/openxr_disabled.cc +++ b/src/vr/openxr_disabled.cc @@ -4,6 +4,9 @@ namespace mocktail::vr { OpenXrBackend::~OpenXrBackend() = default; +// The backend's keyboard_raster_ member needs this destructor. No rasterizer +// is ever created here, so SDL3_ttf and fontconfig stay out of this build. +VirtualKeyboardRasterizer::~VirtualKeyboardRasterizer() = default; Status OpenXrBackend::Arm(VrGraphicsApi) { return Status::Error(StatusCode::kUnavailable, "OpenXR support is disabled in this build"); } diff --git a/src/vr/virtual_keyboard.cc b/src/vr/virtual_keyboard.cc index 83333c8..c737a87 100644 --- a/src/vr/virtual_keyboard.cc +++ b/src/vr/virtual_keyboard.cc @@ -30,6 +30,9 @@ constexpr std::uint64_t kRepeatDelayNs = 450'000'000ULL; constexpr std::uint64_t kRepeatIntervalNs = 60'000'000ULL; // Cursor moves smaller than this (in UV) do not force a redraw. constexpr float kCursorRedrawStep = 0.004f; +// A moving cursor redraws the panel at most this often; each redraw is a +// full texture upload on the frame thread. +constexpr std::uint64_t kCursorRedrawIntervalNs = 33'000'000ULL; constexpr float kPanelDistanceMetres = 0.45f; constexpr float kPanelDropMetres = 0.28f; @@ -239,7 +242,13 @@ void VirtualKeyboard::Hide() { text_.revision = UINT64_MAX; // the next SetText always reloads if (!visible_) return; visible_ = false; - for (Hand& hand : hands_) hand = Hand{}; + for (int index = 0; index < 2; ++index) { + // Keeps a press already pending from an earlier hide of this same press. + release_pending_[index] = + release_pending_[index] || + (hands_[index].trigger_down && hands_[index].captured); + hands_[index] = Hand{}; + } Touch(); } @@ -255,7 +264,8 @@ int VirtualKeyboard::KeyAt(float u, float v) const { bool VirtualKeyboard::CapturesTrigger(int hand) const { const Hand& state = hands_[hand & 1]; - return visible_ && (state.over_panel || state.captured); + return release_pending_[hand & 1] || + (visible_ && (state.over_panel || state.captured)); } bool VirtualKeyboard::cursor(int hand, float* u, float* v) const { @@ -298,7 +308,6 @@ void VirtualKeyboard::Activate(const VirtualKey& key, return; case VirtualKeyAction::kClose: output->scancodes.push_back(kScancodeEscape); - output->closed = true; Hide(); return; } @@ -307,8 +316,18 @@ void VirtualKeyboard::Activate(const VirtualKey& key, VirtualKeyboardOutput VirtualKeyboard::Update( std::uint64_t now_ns, const VirtualKeyboardPointer (&pointers)[2]) { VirtualKeyboardOutput output; - if (!visible_) return output; + bool held_over[2] = {false, false}; + for (int index = 0; index < 2; ++index) { + if (!release_pending_[index]) continue; + // Losing the aim pose is not a release: the trigger is read regardless. + held_over[index] = pointers[index].trigger > kTriggerRelease; + release_pending_[index] = held_over[index]; + } + // Decided once per frame, or one moving hand starves the other's cursor. + const bool cursor_redraw_due = + now_ns - cursor_drawn_ns_ >= kCursorRedrawIntervalNs; for (int index = 0; index < 2 && visible_; ++index) { + if (held_over[index]) continue; Hand& hand = hands_[index]; const VirtualKeyboardPointer& pointer = pointers[index]; float u = 0.f, v = 0.f; @@ -318,12 +337,15 @@ VirtualKeyboardOutput VirtualKeyboard::Update( kPanelHeightMetres, pointer.aim, &u, &v, nullptr); const int hovered = over ? KeyAt(u, v) : -1; + const bool cursor_moved = + over && (std::abs(u - hand.u) > kCursorRedrawStep || + std::abs(v - hand.v) > kCursorRedrawStep); if (over != hand.over_panel || hovered != hand.hovered || - (over && (std::abs(u - hand.u) > kCursorRedrawStep || - std::abs(v - hand.v) > kCursorRedrawStep))) { + (cursor_moved && cursor_redraw_due)) { Touch(); hand.u = u; hand.v = v; + cursor_drawn_ns_ = now_ns; } hand.over_panel = over; hand.hovered = hovered; diff --git a/src/window/window.cc b/src/window/window.cc index c2b4e52..877d206 100644 --- a/src/window/window.cc +++ b/src/window/window.cc @@ -179,6 +179,8 @@ struct VirtualInput { }; static std::mutex g_virtual_input_mutex; static std::deque g_virtual_input; +// Set while the queue may hold input, so a pump with none takes no lock. +static std::atomic g_virtual_input_pending{false}; static std::unique_ptr g_pointer_capture_backend; // The owner is created and destroyed on the SDL thread, but the experience // composition reaches it from its own thread. The mutex guards the pointer @@ -1699,6 +1701,7 @@ void QueueVirtualTextInput(const char* utf8) { } std::lock_guard lock(g_virtual_input_mutex); g_virtual_input.push_back(VirtualInput{utf8, 0}); + g_virtual_input_pending.store(true, std::memory_order_release); } void QueueVirtualKeyPress(uint32_t scancode) { if (scancode == 0) { @@ -1706,6 +1709,7 @@ void QueueVirtualKeyPress(uint32_t scancode) { } std::lock_guard lock(g_virtual_input_mutex); g_virtual_input.push_back(VirtualInput{{}, scancode}); + g_virtual_input_pending.store(true, std::memory_order_release); } void* GetGLProcAddress(const char* name) { if (!g_state.initialised || name == nullptr || name[0] == '\0') { @@ -2320,7 +2324,7 @@ bool PumpEvents() { g_text_input_owner->active())) { fprintf(stderr, " [input] SDL pointer capture state update failed\n"); } - { + if (g_virtual_input_pending.exchange(false, std::memory_order_acq_rel)) { std::deque virtual_input; { std::lock_guard lock(g_virtual_input_mutex); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 26ba95a..895d7c0 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -64,6 +64,12 @@ if(MOCKTAIL_ENABLE_VR) target_include_directories(openxr_backend_test PRIVATE "${PROJECT_SOURCE_DIR}/third_party/OpenXR-SDK/include") target_link_libraries(openxr_backend_test PRIVATE Mocktail::VR GTest::gtest_main) gtest_discover_tests(openxr_backend_test) + add_executable(virtual_keyboard_raster_test + vr/virtual_keyboard_raster_test.cc) + target_link_libraries(virtual_keyboard_raster_test PRIVATE + Mocktail::VR GTest::gtest_main) + mocktail_apply_compile_options(virtual_keyboard_raster_test) + gtest_discover_tests(virtual_keyboard_raster_test) add_executable(vr_queue_submission_test vr/queue_submission_test.cc) target_link_libraries(vr_queue_submission_test PRIVATE Mocktail::VR stub_vulkan GTest::gtest_main) diff --git a/tests/vr/virtual_keyboard_raster_test.cc b/tests/vr/virtual_keyboard_raster_test.cc new file mode 100644 index 0000000..7c5a78b --- /dev/null +++ b/tests/vr/virtual_keyboard_raster_test.cc @@ -0,0 +1,96 @@ +#include + +#include +#include +#include + +#include "mocktail/vr/virtual_keyboard.h" + +namespace mocktail::vr { +namespace { + +using pose::Pose; +using pose::Vec3; + +Pose Head() { + Pose head; + head.position = Vec3{0.f, 1.6f, 0.f}; + return head; +} + +int FindKey(const VirtualKeyboard& keyboard, const std::string& label) { + const auto& keys = keyboard.keys(); + for (std::size_t i = 0; i < keys.size(); ++i) + if (keys[i].label == label) return static_cast(i); + return -1; +} + +void SetText(VirtualKeyboard* keyboard, const std::string& utf8, bool password, + std::uint64_t revision) { + VirtualKeyboardText text; + text.utf8 = utf8; + text.cursor_utf16 = static_cast(utf8.size()); + text.password = password; + text.revision = revision; + keyboard->SetText(text); +} + +TEST(VirtualKeyboardRasterTest, DrawsFieldTextAndCaret) { + VirtualKeyboard keyboard; + keyboard.Show(Head()); + VirtualKeyboardRasterizer rasterizer; + auto field_light_pixels = [&](const std::vector& rgba) { + const int bottom = static_cast(VirtualKeyboard::kFieldFraction * + VirtualKeyboard::kTextureHeight); + int count = 0; + for (int y = 8; y < bottom - 8; ++y) + for (int x = 30; x < VirtualKeyboard::kTextureWidth - 30; ++x) + if (rgba[(static_cast(y) * + VirtualKeyboard::kTextureWidth + x) * 4 + 1] > 120) + ++count; + return count; + }; + std::vector empty, typed, masked; + if (!rasterizer.Draw(keyboard, &empty)) GTEST_SKIP() << "no host font"; + SetText(&keyboard, "Hello world", false, 1); + ASSERT_TRUE(rasterizer.Draw(keyboard, &typed)); + EXPECT_GT(field_light_pixels(typed), field_light_pixels(empty) + 200); + SetText(&keyboard, "Hello world", true, 2); + ASSERT_TRUE(rasterizer.Draw(keyboard, &masked)); + EXPECT_NE(masked, typed); // dots, not the letters + EXPECT_GT(field_light_pixels(masked), field_light_pixels(empty)); +} + +TEST(VirtualKeyboardRasterTest, PasswordPictureDoesNotDependOnItsLetters) { + VirtualKeyboard keyboard; + keyboard.Show(Head()); + VirtualKeyboardRasterizer rasterizer; + std::vector first, second; + SetText(&keyboard, "Hello world", true, 1); + if (!rasterizer.Draw(keyboard, &first)) GTEST_SKIP() << "no host font"; + SetText(&keyboard, "Jumpy vixen", true, 2); + ASSERT_TRUE(rasterizer.Draw(keyboard, &second)); + EXPECT_EQ(first, second); +} + +TEST(VirtualKeyboardRasterTest, FillsAnOpaqueTexture) { + VirtualKeyboard keyboard; + keyboard.Show(Head()); + VirtualKeyboardRasterizer rasterizer; + std::vector rgba; + (void)rasterizer.Draw(keyboard, &rgba); // labels depend on host fonts + ASSERT_EQ(rgba.size(), static_cast( + VirtualKeyboard::kTextureWidth) * + VirtualKeyboard::kTextureHeight * 4); + for (std::size_t i = 3; i < rgba.size(); i += 4) ASSERT_EQ(rgba[i], 255); + // A key is lighter than the background behind it. + const auto& q = keyboard.keys()[FindKey(keyboard, "q")]; + const int x = static_cast(q.u0 * VirtualKeyboard::kTextureWidth) + 20; + const int y = static_cast(q.v0 * VirtualKeyboard::kTextureHeight) + 20; + const std::size_t key_px = + (static_cast(y) * VirtualKeyboard::kTextureWidth + x) * 4; + EXPECT_GT(rgba[key_px], rgba[0]); +} + +} // namespace +} // namespace mocktail::vr diff --git a/tests/vr/virtual_keyboard_test.cc b/tests/vr/virtual_keyboard_test.cc index 1dfb6da..448e594 100644 --- a/tests/vr/virtual_keyboard_test.cc +++ b/tests/vr/virtual_keyboard_test.cc @@ -346,13 +346,141 @@ TEST(VirtualKeyboardTest, LayoutRowsFillThePanelAndCloseKeyHides) { Driver d; d.keyboard.Show(HeadAt(0.f, 1.6f, 0.f)); const auto closed = d.Frame(AimAtKey(d.keyboard, "Close"), 1.f); - EXPECT_TRUE(closed.closed); EXPECT_EQ(closed.scancodes, std::vector{kEscape}); EXPECT_FALSE(d.keyboard.visible()); - EXPECT_FALSE(d.keyboard.CapturesTrigger(1)); EXPECT_TRUE(d.Frame(AimAtKey(d.keyboard, "q"), 0.f).text.empty()); } +TEST(VirtualKeyboardTest, TriggerStaysWithheldAfterCloseUntilReleased) { + Driver d; + d.keyboard.Show(HeadAt(0.f, 1.6f, 0.f)); + const Pose close = AimAtKey(d.keyboard, "Close"); + d.Frame(close, 1.f); + ASSERT_FALSE(d.keyboard.visible()); + // The trigger that pressed Close is still down; Roblox must not see it as + // a click on whatever is behind the panel. + EXPECT_TRUE(d.keyboard.CapturesTrigger(1)); + EXPECT_FALSE(d.keyboard.CapturesTrigger(0)); + d.Frame(close, 1.f); + EXPECT_TRUE(d.keyboard.CapturesTrigger(1)); + d.Frame(close, 0.f); + EXPECT_FALSE(d.keyboard.CapturesTrigger(1)); + d.Frame(close, 1.f); // a new press belongs to Roblox + EXPECT_FALSE(d.keyboard.CapturesTrigger(1)); +} + +TEST(VirtualKeyboardTest, TriggerStaysWithheldWhenThePanelHidesMidPress) { + Driver d; + d.keyboard.Show(HeadAt(0.f, 1.6f, 0.f)); + const Pose enter = AimAtKey(d.keyboard, "Enter"); + d.Frame(enter, 1.f); + d.keyboard.Hide(); // Enter made the TextBox release focus + EXPECT_TRUE(d.keyboard.CapturesTrigger(1)); + d.Frame(enter, 0.f); + EXPECT_FALSE(d.keyboard.CapturesTrigger(1)); + // A hand that was only hovering is free at once. + d.keyboard.Show(HeadAt(0.f, 1.6f, 0.f)); + d.Frame(enter, 0.f); + d.keyboard.Hide(); + EXPECT_FALSE(d.keyboard.CapturesTrigger(1)); +} + +TEST(VirtualKeyboardTest, HeldTriggerTypesNothingWhenThePanelReopens) { + Driver d; + d.keyboard.Show(HeadAt(0.f, 1.6f, 0.f)); + d.Frame(AimAtKey(d.keyboard, "Close"), 1.f); + d.keyboard.Show(HeadAt(0.f, 1.6f, 0.f)); + const Pose q = AimAtKey(d.keyboard, "q"); + EXPECT_TRUE(d.Frame(q, 1.f).text.empty()); + d.Frame(q, 0.f); + EXPECT_EQ(d.Frame(q, 1.f).text, std::vector{"q"}); +} + +TEST(VirtualKeyboardTest, CursorMovesRedrawAtMostThirtyTimesASecond) { + Driver d; + d.keyboard.Show(HeadAt(0.f, 1.6f, 0.f)); + const VirtualKey space = d.keyboard.keys()[FindKey(d.keyboard, "Space")]; + const float v = (space.v0 + space.v1) / 2; + float u = 0.10f; + d.Frame(AimAt(d.keyboard, u, v), 0.f); + const std::uint64_t before = d.keyboard.revision(); + // 30 frames at 90 Hz sliding along one key: 330 ms of pointer motion. + for (int frame = 0; frame < 30; ++frame) { + u += 0.01f; + d.Frame(AimAt(d.keyboard, u, v), 0.f); + } + const std::uint64_t redraws = d.keyboard.revision() - before; + EXPECT_LE(redraws, 11u); + EXPECT_GE(redraws, 5u); + // Once the pointer rests, the drawn cursor catches up with it. + d.Frame(AimAt(d.keyboard, u, v), 0.f, 1, 40 * kMs); + float drawn_u = 0.f, drawn_v = 0.f; + ASSERT_TRUE(d.keyboard.cursor(1, &drawn_u, &drawn_v)); + EXPECT_NEAR(drawn_u, u, 1e-3f); +} + +TEST(VirtualKeyboardTest, BothCursorsKeepUpWhileBothHandsMove) { + Driver d; + d.keyboard.Show(HeadAt(0.f, 1.6f, 0.f)); + const VirtualKey space = d.keyboard.keys()[FindKey(d.keyboard, "Space")]; + const float v = (space.v0 + space.v1) / 2; + float left_u = 0.10f, right_u = 0.50f; + auto frame = [&] { + VirtualKeyboardPointer pointers[2]; + pointers[0].valid = pointers[1].valid = true; + pointers[0].aim = AimAt(d.keyboard, left_u, v); + pointers[1].aim = AimAt(d.keyboard, right_u, v); + d.now += 11 * kMs; + d.keyboard.Update(d.now, pointers); + }; + frame(); + // 30 frames at 90 Hz, both hands sliding along one key. + for (int step = 0; step < 30; ++step) { + left_u += 0.005f; + right_u += 0.005f; + frame(); + } + // Neither drawn cursor trails its hand by more than one redraw interval. + float drawn_u = 0.f, drawn_v = 0.f; + ASSERT_TRUE(d.keyboard.cursor(0, &drawn_u, &drawn_v)); + EXPECT_NEAR(drawn_u, left_u, 0.02f); + ASSERT_TRUE(d.keyboard.cursor(1, &drawn_u, &drawn_v)); + EXPECT_NEAR(drawn_u, right_u, 0.02f); +} + +TEST(VirtualKeyboardTest, HeldTriggerStaysWithheldWhenAReopenedPanelHides) { + Driver d; + d.keyboard.Show(HeadAt(0.f, 1.6f, 0.f)); + const Pose close = AimAtKey(d.keyboard, "Close"); + d.Frame(close, 1.f); + // The panel reopens and hides again during the same trigger press. + d.keyboard.Show(HeadAt(0.f, 1.6f, 0.f)); + d.Frame(close, 1.f); + d.keyboard.Hide(); + EXPECT_TRUE(d.keyboard.CapturesTrigger(1)); + d.Frame(close, 1.f); + EXPECT_TRUE(d.keyboard.CapturesTrigger(1)); + d.Frame(close, 0.f); + EXPECT_FALSE(d.keyboard.CapturesTrigger(1)); +} + +TEST(VirtualKeyboardTest, HeldTriggerStaysWithheldWhileAimTrackingDrops) { + Driver d; + d.keyboard.Show(HeadAt(0.f, 1.6f, 0.f)); + d.Frame(AimAtKey(d.keyboard, "Close"), 1.f); + ASSERT_TRUE(d.keyboard.CapturesTrigger(1)); + // The aim pose is lost for a frame; the trigger is still down. + VirtualKeyboardPointer lost[2]; + lost[1].trigger = 1.f; + d.now += 11 * kMs; + d.keyboard.Update(d.now, lost); + EXPECT_TRUE(d.keyboard.CapturesTrigger(1)); + lost[1].trigger = 0.f; + d.now += 11 * kMs; + d.keyboard.Update(d.now, lost); + EXPECT_FALSE(d.keyboard.CapturesTrigger(1)); +} + TEST(VirtualKeyboardTest, RevisionMovesOnlyWhenTheImageChanges) { Driver d; d.keyboard.Show(HeadAt(0.f, 1.6f, 0.f)); @@ -413,57 +541,5 @@ TEST(VirtualKeyboardTest, TextMirrorFollowsTheFocusedTextBox) { EXPECT_TRUE(CurrentVirtualKeyboardText().utf8.empty()); } -TEST(VirtualKeyboardTest, RasterizerDrawsFieldTextAndCaret) { - VirtualKeyboard keyboard; - keyboard.Show(HeadAt(0.f, 1.6f, 0.f)); - VirtualKeyboardRasterizer rasterizer; - auto field_light_pixels = [&](const std::vector& rgba) { - const int bottom = static_cast(VirtualKeyboard::kFieldFraction * - VirtualKeyboard::kTextureHeight); - int count = 0; - for (int y = 8; y < bottom - 8; ++y) - for (int x = 30; x < VirtualKeyboard::kTextureWidth - 30; ++x) - if (rgba[(static_cast(y) * - VirtualKeyboard::kTextureWidth + x) * 4 + 1] > 120) - ++count; - return count; - }; - std::vector empty, typed, masked; - if (!rasterizer.Draw(keyboard, &empty)) GTEST_SKIP() << "no host font"; - VirtualKeyboardText text; - text.utf8 = "Hello world"; - text.cursor_utf16 = 11; - text.revision = 1; - keyboard.SetText(text); - ASSERT_TRUE(rasterizer.Draw(keyboard, &typed)); - // Text adds light glyph pixels; the empty field only has its caret. - EXPECT_GT(field_light_pixels(typed), field_light_pixels(empty) + 200); - text.password = true; - text.revision = 2; - keyboard.SetText(text); - ASSERT_TRUE(rasterizer.Draw(keyboard, &masked)); - EXPECT_NE(masked, typed); // dots, not the letters - EXPECT_GT(field_light_pixels(masked), field_light_pixels(empty)); -} - -TEST(VirtualKeyboardTest, RasterizerFillsAnOpaqueTexture) { - VirtualKeyboard keyboard; - keyboard.Show(HeadAt(0.f, 1.6f, 0.f)); - VirtualKeyboardRasterizer rasterizer; - std::vector rgba; - (void)rasterizer.Draw(keyboard, &rgba); // labels depend on host fonts - ASSERT_EQ(rgba.size(), static_cast( - VirtualKeyboard::kTextureWidth) * - VirtualKeyboard::kTextureHeight * 4); - for (std::size_t i = 3; i < rgba.size(); i += 4) ASSERT_EQ(rgba[i], 255); - // A key is lighter than the background behind it. - const auto& q = keyboard.keys()[FindKey(keyboard, "q")]; - const int x = static_cast(q.u0 * VirtualKeyboard::kTextureWidth) + 20; - const int y = static_cast(q.v0 * VirtualKeyboard::kTextureHeight) + 20; - const std::size_t key_px = - (static_cast(y) * VirtualKeyboard::kTextureWidth + x) * 4; - EXPECT_GT(rgba[key_px], rgba[0]); -} - } // namespace } // namespace mocktail::vr