mirror of
https://github.com/InstanceBrick/nightcap.git
synced 2026-10-27 21:01:29 +01:00
vr: add in-headset virtual keyboard for TextBox input
When Roblox asks for a keyboard (showKeyboard), a world-locked panel opens below eye level as an OpenXR quad layer. Controllers point at keys and the trigger types; text and key presses go through the same platform events as the desktop keyboard, so the focused TextBox edits identically. The layout is US QWERTY without the function row or Ctrl/Alt, with one-shot Shift, Caps Lock, Tab, Backspace auto-repeat, Enter and Close. A trigger aimed at the panel is withheld from Roblox so a key press is not also a click. A text field above the keys mirrors the focused TextBox, whose desktop overlay never reaches the eye images. MOCKTAIL_VR_KEYBOARD=0 turns it off.
This commit is contained in:
16 files changed
+1904
-8
No files matched your search
+34
@@ -27,6 +27,7 @@
|
||||
#include "mocktail/vr/openxr_backend.h"
|
||||
#include "mocktail/vr/openxr_probe.h"
|
||||
#include "mocktail/vr/roblox_vr_device_bridge.h"
|
||||
#include "mocktail/vr/virtual_keyboard.h"
|
||||
#include "runtime/auth_runtime_composition.h"
|
||||
#include "runtime/command_line.h"
|
||||
#include "runtime/crash_report_policy.h"
|
||||
@@ -45,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_editor.h"
|
||||
#include "runtime/runtime_config_bootstrap.h"
|
||||
#include "runtime/runtime_config_file.h"
|
||||
#include "runtime/runtime_paths.h"
|
||||
@@ -1031,6 +1033,38 @@ int main(int argc, char* argv[]) {
|
||||
"native-stereo diagnostic mode without a runtime.\n";
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
// The in-headset keyboard follows Roblox's TextBox keyboard requests
|
||||
// and types through the same events as the desktop keyboard.
|
||||
mocktail::vr::VirtualKeyboardHost keyboard_host;
|
||||
keyboard_host.input_requested = [](void*) {
|
||||
return mocktail::window::TextInputRequested();
|
||||
};
|
||||
keyboard_host.commit_text = [](void*, const char* utf8) {
|
||||
mocktail::window::QueueVirtualTextInput(utf8);
|
||||
};
|
||||
keyboard_host.press_key = [](void*, std::uint32_t scancode) {
|
||||
mocktail::window::QueueVirtualKeyPress(scancode);
|
||||
};
|
||||
mocktail::vr::SetVirtualKeyboardHost(&keyboard_host);
|
||||
// TextBox text is drawn as a desktop window overlay that never reaches
|
||||
// the eye images; mirror it into the keyboard's text field.
|
||||
mocktail::runtime::SetRobloxTextDisplayMirror(
|
||||
{nullptr, [](void*,
|
||||
const mocktail::runtime::RobloxTextDisplayUpdate&
|
||||
update) {
|
||||
if (update.event ==
|
||||
mocktail::runtime::RobloxTextDisplayEvent::kHide) {
|
||||
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);
|
||||
}});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,6 +325,16 @@ Status RobloxTextSurfaceOverlay::UpdateViewport(
|
||||
return Status::Ok();
|
||||
}
|
||||
|
||||
namespace {
|
||||
std::mutex g_display_mirror_mutex;
|
||||
RobloxTextDisplaySink g_display_mirror;
|
||||
} // namespace
|
||||
|
||||
void SetRobloxTextDisplayMirror(RobloxTextDisplaySink sink) {
|
||||
std::lock_guard<std::mutex> lock(g_display_mirror_mutex);
|
||||
g_display_mirror = sink;
|
||||
}
|
||||
|
||||
RobloxTextDisplaySink RobloxTextSurfaceOverlay::sink() {
|
||||
return {this, &RobloxTextSurfaceOverlay::UpdateCallback};
|
||||
}
|
||||
@@ -334,6 +344,10 @@ void RobloxTextSurfaceOverlay::UpdateCallback(
|
||||
if (context != nullptr) {
|
||||
static_cast<RobloxTextSurfaceOverlay*>(context)->ApplyUpdate(update);
|
||||
}
|
||||
std::lock_guard<std::mutex> lock(g_display_mirror_mutex);
|
||||
if (g_display_mirror.update != nullptr) {
|
||||
g_display_mirror.update(g_display_mirror.context, update);
|
||||
}
|
||||
}
|
||||
|
||||
void RobloxTextSurfaceOverlay::ApplyUpdate(
|
||||
|
||||
@@ -38,7 +38,13 @@ namespace {
|
||||
PFNGLDELETERENDERBUFFERSPROC) \
|
||||
X(BlitFramebuffer, \
|
||||
PFNGLBLITFRAMEBUFFERPROC) \
|
||||
X(Flush, PFNGLFLUSHPROC)
|
||||
X(Flush, PFNGLFLUSHPROC) \
|
||||
X(BindTexture, \
|
||||
PFNGLBINDTEXTUREPROC) \
|
||||
X(TexSubImage2D, \
|
||||
PFNGLTEXSUBIMAGE2DPROC)\
|
||||
X(PixelStorei, \
|
||||
PFNGLPIXELSTOREIPROC)
|
||||
struct Procs {
|
||||
#define DECLARE(name, type) type name = nullptr;
|
||||
GL_PROCS(DECLARE)
|
||||
@@ -391,4 +397,28 @@ bool GlesTransport::Copy(const unsigned textures[2], unsigned width,
|
||||
gl.Flush();
|
||||
return copied;
|
||||
}
|
||||
|
||||
bool GlesTransport::Upload(unsigned texture, int width, int height,
|
||||
const void *rgba) {
|
||||
if (!IsCurrent() || texture == 0 || rgba == nullptr)
|
||||
return false;
|
||||
// Restore the guest's bindings and unpack state around the upload.
|
||||
GLint bound = 0, alignment = 4, row_length = 0, unpack_buffer = 0;
|
||||
gl.GetIntegerv(GL_TEXTURE_BINDING_2D, &bound);
|
||||
gl.GetIntegerv(GL_UNPACK_ALIGNMENT, &alignment);
|
||||
gl.GetIntegerv(GL_UNPACK_ROW_LENGTH, &row_length);
|
||||
gl.GetIntegerv(GL_PIXEL_UNPACK_BUFFER_BINDING, &unpack_buffer);
|
||||
if (unpack_buffer != 0)
|
||||
return false; // a bound PBO would reinterpret the pointer as an offset
|
||||
gl.PixelStorei(GL_UNPACK_ALIGNMENT, 4);
|
||||
gl.PixelStorei(GL_UNPACK_ROW_LENGTH, 0);
|
||||
gl.BindTexture(GL_TEXTURE_2D, texture);
|
||||
gl.TexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, width, height, GL_RGBA,
|
||||
GL_UNSIGNED_BYTE, rgba);
|
||||
gl.BindTexture(GL_TEXTURE_2D, static_cast<GLuint>(bound));
|
||||
gl.PixelStorei(GL_UNPACK_ALIGNMENT, alignment);
|
||||
gl.PixelStorei(GL_UNPACK_ROW_LENGTH, row_length);
|
||||
gl.Flush();
|
||||
return true;
|
||||
}
|
||||
} // namespace mocktail::vr
|
||||
@@ -29,6 +29,8 @@ public:
|
||||
static void *Wrap(const char *name, void *raw);
|
||||
void ResetEyes();
|
||||
bool Copy(const unsigned textures[2], unsigned width, unsigned height);
|
||||
// Uploads tightly packed RGBA8 pixels into a runtime swapchain texture.
|
||||
bool Upload(unsigned texture, int width, int height, const void *rgba);
|
||||
const Eye &eye(int index) const { return eyes_[index]; }
|
||||
void *display() const { return display_; }
|
||||
void *config() const { return config_; }
|
||||
|
||||
+334
-2
@@ -174,6 +174,7 @@ struct OpenXrBackend::VkProcs {
|
||||
PFN_vkMapMemory MapMemory = nullptr;
|
||||
PFN_vkUnmapMemory UnmapMemory = nullptr;
|
||||
PFN_vkCmdCopyImageToBuffer CmdCopyImageToBuffer = nullptr;
|
||||
PFN_vkCmdCopyBufferToImage CmdCopyBufferToImage = nullptr;
|
||||
PFN_vkCreateImageView CreateImageView = nullptr;
|
||||
PFN_vkDestroyImageView DestroyImageView = nullptr;
|
||||
PFN_vkCreateCommandPool CreateCommandPool = nullptr;
|
||||
@@ -698,6 +699,7 @@ void OpenXrBackend::SetupDeviceAfterCreationLocked(
|
||||
MOCKTAIL_BACKEND_VK_FN(MapMemory)
|
||||
MOCKTAIL_BACKEND_VK_FN(UnmapMemory)
|
||||
MOCKTAIL_BACKEND_VK_FN(CmdCopyImageToBuffer)
|
||||
MOCKTAIL_BACKEND_VK_FN(CmdCopyBufferToImage)
|
||||
MOCKTAIL_BACKEND_VK_FN(CreateImageView)
|
||||
MOCKTAIL_BACKEND_VK_FN(DestroyImageView)
|
||||
MOCKTAIL_BACKEND_VK_FN(CreateCommandPool)
|
||||
@@ -1238,6 +1240,7 @@ void OpenXrBackend::TeardownSessionLocked(const char* reason, bool preserve_devi
|
||||
mirror_.DestroyStaging(MirrorProcs());
|
||||
}
|
||||
mirror_.Close();
|
||||
DestroyVirtualKeyboardResources();
|
||||
DestroyCopyResources();
|
||||
DestroySwapchains();
|
||||
// Action pose spaces belong to this session; detach before the session and
|
||||
@@ -2115,8 +2118,12 @@ void OpenXrBackend::RunFrameCycle(VkQueue queue, VkDevice device) {
|
||||
XrInfo<XrCompositionLayerProjection>(
|
||||
XR_TYPE_COMPOSITION_LAYER_PROJECTION);
|
||||
std::array<XrCompositionLayerProjectionView, 2> views{};
|
||||
const XrCompositionLayerBaseHeader *layers[1] = {
|
||||
reinterpret_cast<const XrCompositionLayerBaseHeader *>(&projection)};
|
||||
XrCompositionLayerQuad keyboard_layer =
|
||||
XrInfo<XrCompositionLayerQuad>(XR_TYPE_COMPOSITION_LAYER_QUAD);
|
||||
const XrCompositionLayerBaseHeader *layers[2] = {
|
||||
reinterpret_cast<const XrCompositionLayerBaseHeader *>(&projection),
|
||||
reinterpret_cast<const XrCompositionLayerBaseHeader *>(
|
||||
&keyboard_layer)};
|
||||
const bool eyes_usable = eyes_bound_.load(std::memory_order_acquire) &&
|
||||
eyes_[0].valid && eyes_[1].valid;
|
||||
const bool pose_ready = frame_views_valid_ && published_pose_.valid;
|
||||
@@ -2224,6 +2231,24 @@ void OpenXrBackend::RunFrameCycle(VkQueue queue, VkDevice device) {
|
||||
projection.views = views.data();
|
||||
end.layerCount = 1;
|
||||
end.layers = layers;
|
||||
if (PrepareVirtualKeyboardLayerLocked(queue)) {
|
||||
// World-locked panel in LOCAL space, composited over the eyes.
|
||||
const Pose &panel = keyboard_.panel_pose();
|
||||
keyboard_layer.space = static_cast<XrSpace>(xr_local_space_);
|
||||
keyboard_layer.eyeVisibility = XR_EYE_VISIBILITY_BOTH;
|
||||
keyboard_layer.subImage.swapchain =
|
||||
static_cast<XrSwapchain>(keyboard_swapchain_.swapchain);
|
||||
keyboard_layer.subImage.imageRect.extent = {
|
||||
VirtualKeyboard::kTextureWidth, VirtualKeyboard::kTextureHeight};
|
||||
keyboard_layer.pose.position = {panel.position.x, panel.position.y,
|
||||
panel.position.z};
|
||||
keyboard_layer.pose.orientation = {
|
||||
panel.orientation.x, panel.orientation.y, panel.orientation.z,
|
||||
panel.orientation.w};
|
||||
keyboard_layer.size = {VirtualKeyboard::kPanelWidthMetres,
|
||||
VirtualKeyboard::kPanelHeightMetres};
|
||||
end.layerCount = 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
CheckXr(instance, xrEndFrame(session, &end), "xrEndFrame");
|
||||
@@ -2327,6 +2352,12 @@ void OpenXrBackend::RunFrameCycle(VkQueue queue, VkDevice device) {
|
||||
const std::string pose_source =
|
||||
EnvString("MOCKTAIL_VR_POSE_SOURCE", "runtime");
|
||||
Pose head_pose = head_valid ? XrToPose(head.pose) : Pose{};
|
||||
if (head_valid) {
|
||||
// Tracked LOCAL head pose, independent of the scripted test source;
|
||||
// the keyboard panel opens in front of it.
|
||||
keyboard_head_ = head_pose;
|
||||
keyboard_head_valid_ = true;
|
||||
}
|
||||
if (pose_source == "script") {
|
||||
const ScriptedPoseSample scripted =
|
||||
ComputeScriptedPose(++xr_frame_counter_);
|
||||
@@ -2494,9 +2525,310 @@ void OpenXrBackend::SyncControllersLocked(std::uint64_t predicted_display_time)
|
||||
published_pose_.hands[0] = VrHandPose{};
|
||||
published_pose_.hands[1] = VrHandPose{};
|
||||
}
|
||||
UpdateVirtualKeyboardLocked();
|
||||
perf::ProcessCollector().Count(perf::Counter::kControllerSyncs);
|
||||
}
|
||||
|
||||
void OpenXrBackend::UpdateVirtualKeyboardLocked() {
|
||||
static const bool enabled = EnvString("MOCKTAIL_VR_KEYBOARD", "1") != "0";
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
const VirtualKeyboardHost host = CurrentVirtualKeyboardHost();
|
||||
const bool requested =
|
||||
host.input_requested != nullptr && host.input_requested(host.context);
|
||||
if (!requested) {
|
||||
keyboard_.Hide();
|
||||
keyboard_armed_ = true;
|
||||
} else if (keyboard_armed_ && keyboard_head_valid_) {
|
||||
keyboard_.Show(keyboard_head_);
|
||||
keyboard_armed_ = false;
|
||||
Log(" [vr-keyboard] shown for focused TextBox\n");
|
||||
}
|
||||
if (!keyboard_.visible()) {
|
||||
return;
|
||||
}
|
||||
{
|
||||
VirtualKeyboardText text = CurrentVirtualKeyboardText();
|
||||
keyboard_.SetText(text);
|
||||
std::fill(text.utf8.begin(), text.utf8.end(), '\0');
|
||||
}
|
||||
|
||||
VirtualKeyboardPointer pointers[2];
|
||||
const ControllerSnapshot& snapshot = actions_.snapshot();
|
||||
for (int hand = 0; hand < 2; ++hand) {
|
||||
const VrHandPose& pose = actions_.hand(hand);
|
||||
const ControllerHandState& state = snapshot.hands[hand];
|
||||
pointers[hand].valid = state.connected && pose.aim_valid;
|
||||
pointers[hand].aim.position =
|
||||
Vec3{pose.aim_position[0], pose.aim_position[1], pose.aim_position[2]};
|
||||
pointers[hand].aim.orientation =
|
||||
Quat{pose.aim_orientation[0], pose.aim_orientation[1],
|
||||
pose.aim_orientation[2], pose.aim_orientation[3]};
|
||||
pointers[hand].trigger =
|
||||
state.axes[static_cast<std::size_t>(ControllerAxis::kTrigger)];
|
||||
}
|
||||
const VirtualKeyboardOutput output =
|
||||
keyboard_.Update(perf::NowNs(), pointers);
|
||||
for (const std::string& text : output.text) {
|
||||
if (host.commit_text != nullptr) host.commit_text(host.context, text.c_str());
|
||||
}
|
||||
for (const std::uint32_t scancode : output.scancodes) {
|
||||
if (host.press_key != nullptr) host.press_key(host.context, scancode);
|
||||
}
|
||||
// A trigger aimed at the panel types; withhold it from Roblox so it is not
|
||||
// also a click on whatever UI sits behind the keyboard.
|
||||
for (int hand = 0; hand < 2; ++hand) {
|
||||
if (keyboard_.CapturesTrigger(hand)) {
|
||||
published_pose_.controller_channels[hand * 9 + 3] = 0.f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool OpenXrBackend::PrepareVirtualKeyboardLayerLocked(VkQueue queue) {
|
||||
if (!keyboard_.visible() || keyboard_swapchain_failed_) {
|
||||
return false;
|
||||
}
|
||||
const auto instance = static_cast<XrInstance>(xr_instance_);
|
||||
try {
|
||||
EyeSwapchain& slot = keyboard_swapchain_;
|
||||
if (slot.swapchain == nullptr) {
|
||||
auto create_info =
|
||||
XrInfo<XrSwapchainCreateInfo>(XR_TYPE_SWAPCHAIN_CREATE_INFO);
|
||||
create_info.usageFlags = XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT |
|
||||
XR_SWAPCHAIN_USAGE_TRANSFER_DST_BIT;
|
||||
// Same format as the eyes, so the panel's sRGB-encoded bytes are
|
||||
// interpreted exactly like the guest's eye pixels.
|
||||
create_info.format =
|
||||
static_cast<std::int64_t>(eye_swapchains_[0].format);
|
||||
create_info.sampleCount = 1;
|
||||
create_info.width = VirtualKeyboard::kTextureWidth;
|
||||
create_info.height = VirtualKeyboard::kTextureHeight;
|
||||
create_info.faceCount = 1;
|
||||
create_info.arraySize = 1;
|
||||
create_info.mipCount = 1;
|
||||
XrSwapchain swapchain = XR_NULL_HANDLE;
|
||||
CheckXr(instance,
|
||||
xrCreateSwapchain(static_cast<XrSession>(xr_session_),
|
||||
&create_info, &swapchain),
|
||||
"xrCreateSwapchain keyboard");
|
||||
slot.swapchain = swapchain;
|
||||
std::uint32_t count = 0;
|
||||
CheckXr(instance,
|
||||
xrEnumerateSwapchainImages(swapchain, 0, &count, nullptr),
|
||||
"xrEnumerateSwapchainImages keyboard count");
|
||||
Require(count > 0 && count <= 16, "empty keyboard swapchain");
|
||||
if (graphics_api_ == VrGraphicsApi::kVulkan) {
|
||||
std::vector<XrSwapchainImageVulkanKHR> images(count);
|
||||
for (auto& image : images) image.type = XR_TYPE_SWAPCHAIN_IMAGE_VULKAN_KHR;
|
||||
CheckXr(instance,
|
||||
xrEnumerateSwapchainImages(
|
||||
swapchain, count, &count,
|
||||
reinterpret_cast<XrSwapchainImageBaseHeader*>(
|
||||
images.data())),
|
||||
"xrEnumerateSwapchainImages keyboard");
|
||||
for (const auto& image : images) slot.images.push_back(image.image);
|
||||
} else {
|
||||
std::vector<XrSwapchainImageOpenGLESKHR> images(count);
|
||||
for (auto& image : images)
|
||||
image.type = XR_TYPE_SWAPCHAIN_IMAGE_OPENGL_ES_KHR;
|
||||
CheckXr(instance,
|
||||
xrEnumerateSwapchainImages(
|
||||
swapchain, count, &count,
|
||||
reinterpret_cast<XrSwapchainImageBaseHeader*>(
|
||||
images.data())),
|
||||
"xrEnumerateSwapchainImages keyboard GLES");
|
||||
for (const auto& image : images) slot.gl_images.push_back(image.image);
|
||||
}
|
||||
slot.width = VirtualKeyboard::kTextureWidth;
|
||||
slot.height = VirtualKeyboard::kTextureHeight;
|
||||
slot.format = eye_swapchains_[0].format;
|
||||
keyboard_image_ready_ = false;
|
||||
Log(" [vr-keyboard] panel swapchain %ux%u images=%u\n", slot.width,
|
||||
slot.height, count);
|
||||
}
|
||||
if (keyboard_image_ready_ &&
|
||||
keyboard_uploaded_revision_ == keyboard_.revision()) {
|
||||
return true;
|
||||
}
|
||||
if (!keyboard_raster_) {
|
||||
keyboard_raster_ = std::make_unique<VirtualKeyboardRasterizer>();
|
||||
}
|
||||
(void)keyboard_raster_->Draw(keyboard_, &keyboard_pixels_);
|
||||
|
||||
std::uint32_t index = 0;
|
||||
auto acquire = XrInfo<XrSwapchainImageAcquireInfo>(
|
||||
XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO);
|
||||
CheckXr(instance,
|
||||
xrAcquireSwapchainImage(static_cast<XrSwapchain>(slot.swapchain),
|
||||
&acquire, &index),
|
||||
"xrAcquireSwapchainImage keyboard");
|
||||
auto wait =
|
||||
XrInfo<XrSwapchainImageWaitInfo>(XR_TYPE_SWAPCHAIN_IMAGE_WAIT_INFO);
|
||||
wait.timeout = 100'000'000;
|
||||
const XrResult wait_result =
|
||||
xrWaitSwapchainImage(static_cast<XrSwapchain>(slot.swapchain), &wait);
|
||||
bool uploaded = false;
|
||||
if (XR_SUCCEEDED(wait_result) && wait_result != XR_TIMEOUT_EXPIRED) {
|
||||
if (graphics_api_ == VrGraphicsApi::kVulkan) {
|
||||
uploaded = index < slot.images.size() &&
|
||||
UploadVirtualKeyboardVulkan(queue, slot.images[index]);
|
||||
} else {
|
||||
uploaded = gles_ != nullptr && index < slot.gl_images.size() &&
|
||||
gles_->Upload(slot.gl_images[index],
|
||||
VirtualKeyboard::kTextureWidth,
|
||||
VirtualKeyboard::kTextureHeight,
|
||||
keyboard_pixels_.data());
|
||||
}
|
||||
}
|
||||
auto release = XrInfo<XrSwapchainImageReleaseInfo>(
|
||||
XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO);
|
||||
(void)xrReleaseSwapchainImage(static_cast<XrSwapchain>(slot.swapchain),
|
||||
&release);
|
||||
// A released image is what the quad shows, even if this upload failed
|
||||
// after an earlier one succeeded.
|
||||
if (uploaded) {
|
||||
keyboard_image_ready_ = true;
|
||||
keyboard_uploaded_revision_ = keyboard_.revision();
|
||||
}
|
||||
return keyboard_image_ready_;
|
||||
} catch (const std::exception& exception) {
|
||||
Log(" [vr-keyboard] disabled: %s\n", exception.what());
|
||||
keyboard_swapchain_failed_ = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool OpenXrBackend::UploadVirtualKeyboardVulkan(VkQueue queue, VkImage image) {
|
||||
std::string error;
|
||||
if (vk_ == nullptr ||
|
||||
!EnsureCopyResources(vk_device_, vk_queue_family_, &error)) {
|
||||
return false;
|
||||
}
|
||||
const VkDeviceSize size = static_cast<VkDeviceSize>(keyboard_pixels_.size());
|
||||
if (keyboard_staging_ == VK_NULL_HANDLE) {
|
||||
VkBufferCreateInfo info{};
|
||||
info.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
|
||||
info.size = size;
|
||||
info.usage = VK_BUFFER_USAGE_TRANSFER_SRC_BIT;
|
||||
if (vk_->CreateBuffer(vk_device_, &info, nullptr, &keyboard_staging_) !=
|
||||
VK_SUCCESS) {
|
||||
keyboard_staging_ = VK_NULL_HANDLE;
|
||||
return false;
|
||||
}
|
||||
VkMemoryRequirements requirements{};
|
||||
vk_->GetBufferMemoryRequirements(vk_device_, keyboard_staging_,
|
||||
&requirements);
|
||||
VkPhysicalDeviceMemoryProperties properties{};
|
||||
vk_->GetPhysicalDeviceMemoryProperties(vk_physical_device_, &properties);
|
||||
const auto flags = VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT |
|
||||
VK_MEMORY_PROPERTY_HOST_COHERENT_BIT;
|
||||
std::uint32_t type = UINT32_MAX;
|
||||
for (std::uint32_t t = 0; t < properties.memoryTypeCount; ++t) {
|
||||
if ((requirements.memoryTypeBits & (1u << t)) &&
|
||||
(properties.memoryTypes[t].propertyFlags & flags) == flags) {
|
||||
type = t;
|
||||
break;
|
||||
}
|
||||
}
|
||||
VkMemoryAllocateInfo allocation{};
|
||||
allocation.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
|
||||
allocation.allocationSize = requirements.size;
|
||||
allocation.memoryTypeIndex = type;
|
||||
if (type == UINT32_MAX ||
|
||||
vk_->AllocateMemory(vk_device_, &allocation, nullptr,
|
||||
&keyboard_staging_memory_) != VK_SUCCESS ||
|
||||
vk_->BindBufferMemory(vk_device_, keyboard_staging_,
|
||||
keyboard_staging_memory_, 0) != VK_SUCCESS ||
|
||||
vk_->MapMemory(vk_device_, keyboard_staging_memory_, 0, size, 0,
|
||||
&keyboard_staging_mapped_) != VK_SUCCESS) {
|
||||
DestroyVirtualKeyboardResources();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
std::memcpy(keyboard_staging_mapped_, keyboard_pixels_.data(),
|
||||
keyboard_pixels_.size());
|
||||
|
||||
if (vk_->ResetCommandBuffer(copy_command_, 0) != VK_SUCCESS) return false;
|
||||
VkCommandBufferBeginInfo begin{};
|
||||
begin.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
|
||||
begin.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
|
||||
if (vk_->BeginCommandBuffer(copy_command_, &begin) != VK_SUCCESS) {
|
||||
return false;
|
||||
}
|
||||
VkImageMemoryBarrier barrier{};
|
||||
barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER;
|
||||
barrier.srcAccessMask = 0;
|
||||
barrier.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
|
||||
barrier.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED;
|
||||
barrier.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
|
||||
barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
|
||||
barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
|
||||
barrier.image = image;
|
||||
barrier.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
|
||||
vk_->CmdPipelineBarrier(copy_command_, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT,
|
||||
VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0,
|
||||
nullptr, 1, &barrier);
|
||||
VkBufferImageCopy region{};
|
||||
region.imageSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
|
||||
region.imageExtent = {VirtualKeyboard::kTextureWidth,
|
||||
VirtualKeyboard::kTextureHeight, 1};
|
||||
vk_->CmdCopyBufferToImage(copy_command_, keyboard_staging_, image,
|
||||
VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, ®ion);
|
||||
barrier.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
|
||||
barrier.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT;
|
||||
barrier.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
|
||||
barrier.newLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
|
||||
vk_->CmdPipelineBarrier(copy_command_, VK_PIPELINE_STAGE_TRANSFER_BIT,
|
||||
VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, 0, 0, nullptr, 0,
|
||||
nullptr, 1, &barrier);
|
||||
if (vk_->EndCommandBuffer(copy_command_) != VK_SUCCESS ||
|
||||
vk_->ResetFences(vk_device_, 1, ©_fence_) != VK_SUCCESS) {
|
||||
return false;
|
||||
}
|
||||
VkSubmitInfo submit{};
|
||||
submit.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
|
||||
submit.commandBufferCount = 1;
|
||||
submit.pCommandBuffers = ©_command_;
|
||||
if (SubmitHostQueue(vk_->QueueSubmit, vk_device_, queue, 1, &submit,
|
||||
copy_fence_) != VK_SUCCESS) {
|
||||
return false;
|
||||
}
|
||||
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");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void OpenXrBackend::DestroyVirtualKeyboardResources() {
|
||||
if (vk_ != nullptr && vk_device_ != VK_NULL_HANDLE) {
|
||||
if (keyboard_staging_mapped_ != nullptr) {
|
||||
vk_->UnmapMemory(vk_device_, keyboard_staging_memory_);
|
||||
}
|
||||
if (keyboard_staging_ != VK_NULL_HANDLE) {
|
||||
vk_->DestroyBuffer(vk_device_, keyboard_staging_, nullptr);
|
||||
}
|
||||
if (keyboard_staging_memory_ != VK_NULL_HANDLE) {
|
||||
vk_->FreeMemory(vk_device_, keyboard_staging_memory_, nullptr);
|
||||
}
|
||||
}
|
||||
keyboard_staging_mapped_ = nullptr;
|
||||
keyboard_staging_ = VK_NULL_HANDLE;
|
||||
keyboard_staging_memory_ = VK_NULL_HANDLE;
|
||||
EyeSwapchain& slot = keyboard_swapchain_;
|
||||
slot.images.clear();
|
||||
slot.gl_images.clear();
|
||||
if (slot.swapchain != nullptr) {
|
||||
(void)xrDestroySwapchain(static_cast<XrSwapchain>(slot.swapchain));
|
||||
slot.swapchain = nullptr;
|
||||
}
|
||||
keyboard_image_ready_ = false;
|
||||
keyboard_uploaded_revision_ = 0;
|
||||
keyboard_swapchain_failed_ = false;
|
||||
}
|
||||
|
||||
bool OpenXrBackend::EnsureCopyResources(VkDevice device,
|
||||
std::uint32_t queue_family,
|
||||
std::string* error) {
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
#include "mocktail/vr/virtual_keyboard.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cmath>
|
||||
#include <mutex>
|
||||
|
||||
namespace mocktail::vr {
|
||||
namespace {
|
||||
|
||||
using pose::kPi;
|
||||
using pose::Pose;
|
||||
using pose::Quat;
|
||||
using pose::QuatConjugate;
|
||||
using pose::QuatFromAxisAngle;
|
||||
using pose::QuatMultiply;
|
||||
using pose::QuatRotate;
|
||||
using pose::Vec3;
|
||||
|
||||
// SDL scancodes, kept numeric so this pure module needs no SDL headers.
|
||||
constexpr std::uint32_t kScancodeReturn = 40; // SDL_SCANCODE_RETURN
|
||||
constexpr std::uint32_t kScancodeEscape = 41; // SDL_SCANCODE_ESCAPE
|
||||
constexpr std::uint32_t kScancodeBackspace = 42; // SDL_SCANCODE_BACKSPACE
|
||||
constexpr std::uint32_t kScancodeTab = 43; // SDL_SCANCODE_TAB
|
||||
|
||||
// Matches ControllerStateBuilder's analog click hysteresis.
|
||||
constexpr float kTriggerPress = 0.75f;
|
||||
constexpr float kTriggerRelease = 0.6f;
|
||||
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;
|
||||
|
||||
constexpr float kPanelDistanceMetres = 0.45f;
|
||||
constexpr float kPanelDropMetres = 0.28f;
|
||||
constexpr float kPanelTiltRadians = 35.f * kPi / 180.f;
|
||||
|
||||
// US QWERTY without the function row or Ctrl/Alt/Menu, in normal-key units.
|
||||
// Widths follow the desktop shell OSK (Tab 1.6, Shift 2.5, wide keys fill).
|
||||
constexpr float kColumns = 15.f;
|
||||
constexpr int kRows = 5;
|
||||
|
||||
std::mutex g_host_mutex;
|
||||
VirtualKeyboardHost g_host;
|
||||
std::mutex g_text_mutex;
|
||||
VirtualKeyboardText g_text;
|
||||
|
||||
void ScrubText(std::string* text) {
|
||||
std::fill(text->begin(), text->end(), '\0');
|
||||
text->clear();
|
||||
}
|
||||
|
||||
struct KeySpec {
|
||||
const char* label;
|
||||
const char* text;
|
||||
const char* shift_text;
|
||||
VirtualKeyAction action;
|
||||
float width;
|
||||
};
|
||||
|
||||
KeySpec Char(const char* text, const char* shift_text) {
|
||||
return {text, text, shift_text, VirtualKeyAction::kText, 1.f};
|
||||
}
|
||||
KeySpec Special(const char* label, VirtualKeyAction action, float width) {
|
||||
return {label, "", "", action, width};
|
||||
}
|
||||
|
||||
std::vector<std::vector<KeySpec>> Rows() {
|
||||
using A = VirtualKeyAction;
|
||||
return {
|
||||
{Char("`", "~"), Char("1", "!"), Char("2", "@"), Char("3", "#"),
|
||||
Char("4", "$"), Char("5", "%"), Char("6", "^"), Char("7", "&"),
|
||||
Char("8", "*"), Char("9", "("), Char("0", ")"), Char("-", "_"),
|
||||
Char("=", "+"), Special("Backspace", A::kBackspace, 2.f)},
|
||||
{Special("Tab", A::kTab, 1.6f), Char("q", "Q"), Char("w", "W"),
|
||||
Char("e", "E"), Char("r", "R"), Char("t", "T"), Char("y", "Y"),
|
||||
Char("u", "U"), Char("i", "I"), Char("o", "O"), Char("p", "P"),
|
||||
Char("[", "{"), Char("]", "}"), {"\\", "\\", "|", A::kText, 1.4f}},
|
||||
{Special("Caps", A::kCapsLock, 2.f), Char("a", "A"), Char("s", "S"),
|
||||
Char("d", "D"), Char("f", "F"), Char("g", "G"), Char("h", "H"),
|
||||
Char("j", "J"), Char("k", "K"), Char("l", "L"), Char(";", ":"),
|
||||
Char("'", "\""), Special("Enter", A::kEnter, 2.f)},
|
||||
{Special("Shift", A::kShift, 2.5f), Char("z", "Z"), Char("x", "X"),
|
||||
Char("c", "C"), Char("v", "V"), Char("b", "B"), Char("n", "N"),
|
||||
Char("m", "M"), Char(",", "<"), Char(".", ">"), Char("/", "?"),
|
||||
Special("Shift", A::kShift, 2.5f)},
|
||||
{Special("Space", A::kSpace, 12.5f), Special("Close", A::kClose, 2.5f)},
|
||||
};
|
||||
}
|
||||
|
||||
bool IsLetter(const std::string& text) {
|
||||
return text.size() == 1 &&
|
||||
std::isalpha(static_cast<unsigned char>(text[0])) != 0;
|
||||
}
|
||||
|
||||
float Dot(const Vec3& a, const Vec3& b) {
|
||||
return a.x * b.x + a.y * b.y + a.z * b.z;
|
||||
}
|
||||
Vec3 Sub(const Vec3& a, const Vec3& b) {
|
||||
return {a.x - b.x, a.y - b.y, a.z - b.z};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void SetVirtualKeyboardHost(const VirtualKeyboardHost* host) {
|
||||
std::lock_guard<std::mutex> lock(g_host_mutex);
|
||||
g_host = host != nullptr ? *host : VirtualKeyboardHost{};
|
||||
}
|
||||
|
||||
VirtualKeyboardHost CurrentVirtualKeyboardHost() {
|
||||
std::lock_guard<std::mutex> lock(g_host_mutex);
|
||||
return g_host;
|
||||
}
|
||||
|
||||
void SetVirtualKeyboardText(const char* utf8, std::size_t size,
|
||||
int cursor_utf16, bool password) {
|
||||
std::lock_guard<std::mutex> lock(g_text_mutex);
|
||||
ScrubText(&g_text.utf8);
|
||||
if (utf8 != nullptr) g_text.utf8.assign(utf8, size);
|
||||
g_text.cursor_utf16 = std::max(0, cursor_utf16);
|
||||
g_text.password = password;
|
||||
++g_text.revision;
|
||||
}
|
||||
|
||||
void ClearVirtualKeyboardText() {
|
||||
std::lock_guard<std::mutex> lock(g_text_mutex);
|
||||
ScrubText(&g_text.utf8);
|
||||
g_text.cursor_utf16 = 0;
|
||||
g_text.password = false;
|
||||
++g_text.revision;
|
||||
}
|
||||
|
||||
VirtualKeyboardText CurrentVirtualKeyboardText() {
|
||||
std::lock_guard<std::mutex> lock(g_text_mutex);
|
||||
return g_text;
|
||||
}
|
||||
|
||||
bool IntersectVirtualKeyboardPanel(const Pose& quad, float width, float height,
|
||||
const Pose& aim, float* u, float* v,
|
||||
float* distance) {
|
||||
const Vec3 normal = QuatRotate(quad.orientation, Vec3{0.f, 0.f, 1.f});
|
||||
const Vec3 direction = QuatRotate(aim.orientation, Vec3{0.f, 0.f, -1.f});
|
||||
const float denominator = Dot(direction, normal);
|
||||
// Only the front face takes input; a ray from behind passes through.
|
||||
if (denominator > -1e-4f) return false;
|
||||
const float t = Dot(Sub(quad.position, aim.position), normal) / denominator;
|
||||
if (!(t > 0.f)) return false;
|
||||
const Vec3 hit{aim.position.x + direction.x * t,
|
||||
aim.position.y + direction.y * t,
|
||||
aim.position.z + direction.z * t};
|
||||
const Vec3 local =
|
||||
QuatRotate(QuatConjugate(quad.orientation), Sub(hit, quad.position));
|
||||
const float hit_u = local.x / width + 0.5f;
|
||||
const float hit_v = 0.5f - local.y / height;
|
||||
if (hit_u < 0.f || hit_u > 1.f || hit_v < 0.f || hit_v > 1.f) return false;
|
||||
if (u != nullptr) *u = hit_u;
|
||||
if (v != nullptr) *v = hit_v;
|
||||
if (distance != nullptr) *distance = t;
|
||||
return true;
|
||||
}
|
||||
|
||||
VirtualKeyboard::VirtualKeyboard() { RebuildLayout(); }
|
||||
|
||||
void VirtualKeyboard::RebuildLayout() {
|
||||
const auto rows = Rows();
|
||||
keys_.clear();
|
||||
for (int row = 0; row < kRows; ++row) {
|
||||
float column = 0.f;
|
||||
for (const KeySpec& spec : rows[row]) {
|
||||
VirtualKey key;
|
||||
key.action = spec.action;
|
||||
key.text = spec.text;
|
||||
key.shift_text = spec.shift_text;
|
||||
key.label = spec.action == VirtualKeyAction::kText ? Output(key)
|
||||
: spec.label;
|
||||
key.u0 = column / kColumns;
|
||||
key.u1 = std::min(1.f, (column + spec.width) / kColumns);
|
||||
constexpr float kField = VirtualKeyboard::kFieldFraction;
|
||||
key.v0 = kField + (1.f - kField) * static_cast<float>(row) / kRows;
|
||||
key.v1 = kField + (1.f - kField) * static_cast<float>(row + 1) / kRows;
|
||||
keys_.push_back(std::move(key));
|
||||
column += spec.width;
|
||||
}
|
||||
}
|
||||
for (Hand& hand : hands_) {
|
||||
hand.hovered = -1;
|
||||
hand.pressed = -1;
|
||||
}
|
||||
Touch();
|
||||
}
|
||||
|
||||
std::string VirtualKeyboard::Output(const VirtualKey& key) const {
|
||||
// Caps Lock only affects letters; Shift inverts it for letters and selects
|
||||
// the upper symbol on every other key.
|
||||
if (IsLetter(key.text)) {
|
||||
return (shift_ != caps_lock_) ? key.shift_text : key.text;
|
||||
}
|
||||
return shift_ ? key.shift_text : key.text;
|
||||
}
|
||||
|
||||
const std::vector<VirtualKey>& VirtualKeyboard::keys() const { return keys_; }
|
||||
|
||||
void VirtualKeyboard::Show(const Pose& head) {
|
||||
Vec3 forward = QuatRotate(head.orientation, Vec3{0.f, 0.f, -1.f});
|
||||
forward.y = 0.f;
|
||||
const float length = std::sqrt(forward.x * forward.x + forward.z * forward.z);
|
||||
if (length < 1e-3f) {
|
||||
forward = Vec3{0.f, 0.f, -1.f};
|
||||
} else {
|
||||
forward.x /= length;
|
||||
forward.z /= length;
|
||||
}
|
||||
const float yaw = std::atan2(-forward.x, -forward.z);
|
||||
panel_.orientation =
|
||||
QuatMultiply(QuatFromAxisAngle(Vec3{0.f, 1.f, 0.f}, yaw),
|
||||
QuatFromAxisAngle(Vec3{1.f, 0.f, 0.f}, -kPanelTiltRadians));
|
||||
panel_.position =
|
||||
Vec3{head.position.x + forward.x * kPanelDistanceMetres,
|
||||
head.position.y - kPanelDropMetres,
|
||||
head.position.z + forward.z * kPanelDistanceMetres};
|
||||
visible_ = true;
|
||||
shift_ = false;
|
||||
for (Hand& hand : hands_) hand = Hand{};
|
||||
RebuildLayout();
|
||||
}
|
||||
|
||||
void VirtualKeyboard::SetText(const VirtualKeyboardText& text) {
|
||||
if (text.revision == text_.revision) return;
|
||||
const bool changed = text.utf8 != text_.utf8 ||
|
||||
text.cursor_utf16 != text_.cursor_utf16 ||
|
||||
text.password != text_.password;
|
||||
ScrubText(&text_.utf8);
|
||||
text_ = text;
|
||||
if (changed) Touch();
|
||||
}
|
||||
|
||||
void VirtualKeyboard::Hide() {
|
||||
ScrubText(&text_.utf8);
|
||||
text_.revision = UINT64_MAX; // the next SetText always reloads
|
||||
if (!visible_) return;
|
||||
visible_ = false;
|
||||
for (Hand& hand : hands_) hand = Hand{};
|
||||
Touch();
|
||||
}
|
||||
|
||||
int VirtualKeyboard::KeyAt(float u, float v) const {
|
||||
for (std::size_t index = 0; index < keys_.size(); ++index) {
|
||||
const VirtualKey& key = keys_[index];
|
||||
if (u >= key.u0 && u < key.u1 && v >= key.v0 && v < key.v1) {
|
||||
return static_cast<int>(index);
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
bool VirtualKeyboard::CapturesTrigger(int hand) const {
|
||||
const Hand& state = hands_[hand & 1];
|
||||
return visible_ && (state.over_panel || state.captured);
|
||||
}
|
||||
|
||||
bool VirtualKeyboard::cursor(int hand, float* u, float* v) const {
|
||||
const Hand& state = hands_[hand & 1];
|
||||
if (!visible_ || !state.over_panel) return false;
|
||||
*u = state.u;
|
||||
*v = state.v;
|
||||
return true;
|
||||
}
|
||||
|
||||
void VirtualKeyboard::Activate(const VirtualKey& key,
|
||||
VirtualKeyboardOutput* output) {
|
||||
switch (key.action) {
|
||||
case VirtualKeyAction::kText:
|
||||
output->text.push_back(Output(key));
|
||||
if (shift_) {
|
||||
shift_ = false; // one-shot, like a phone keyboard
|
||||
RebuildLayout();
|
||||
}
|
||||
return;
|
||||
case VirtualKeyAction::kSpace:
|
||||
output->text.emplace_back(" ");
|
||||
return;
|
||||
case VirtualKeyAction::kBackspace:
|
||||
output->scancodes.push_back(kScancodeBackspace);
|
||||
return;
|
||||
case VirtualKeyAction::kEnter:
|
||||
output->scancodes.push_back(kScancodeReturn);
|
||||
return;
|
||||
case VirtualKeyAction::kTab:
|
||||
output->scancodes.push_back(kScancodeTab);
|
||||
return;
|
||||
case VirtualKeyAction::kShift:
|
||||
shift_ = !shift_;
|
||||
RebuildLayout();
|
||||
return;
|
||||
case VirtualKeyAction::kCapsLock:
|
||||
caps_lock_ = !caps_lock_;
|
||||
RebuildLayout();
|
||||
return;
|
||||
case VirtualKeyAction::kClose:
|
||||
output->scancodes.push_back(kScancodeEscape);
|
||||
output->closed = true;
|
||||
Hide();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
VirtualKeyboardOutput VirtualKeyboard::Update(
|
||||
std::uint64_t now_ns, const VirtualKeyboardPointer (&pointers)[2]) {
|
||||
VirtualKeyboardOutput output;
|
||||
if (!visible_) return output;
|
||||
for (int index = 0; index < 2 && visible_; ++index) {
|
||||
Hand& hand = hands_[index];
|
||||
const VirtualKeyboardPointer& pointer = pointers[index];
|
||||
float u = 0.f, v = 0.f;
|
||||
const bool over =
|
||||
pointer.valid &&
|
||||
IntersectVirtualKeyboardPanel(panel_, kPanelWidthMetres,
|
||||
kPanelHeightMetres, pointer.aim, &u, &v,
|
||||
nullptr);
|
||||
const int hovered = over ? KeyAt(u, v) : -1;
|
||||
if (over != hand.over_panel || hovered != hand.hovered ||
|
||||
(over && (std::abs(u - hand.u) > kCursorRedrawStep ||
|
||||
std::abs(v - hand.v) > kCursorRedrawStep))) {
|
||||
Touch();
|
||||
hand.u = u;
|
||||
hand.v = v;
|
||||
}
|
||||
hand.over_panel = over;
|
||||
hand.hovered = hovered;
|
||||
|
||||
const bool was_down = hand.trigger_down;
|
||||
if (!pointer.valid) {
|
||||
hand.trigger_down = false;
|
||||
} else if (was_down) {
|
||||
hand.trigger_down = pointer.trigger > kTriggerRelease;
|
||||
} else {
|
||||
hand.trigger_down = pointer.trigger >= kTriggerPress;
|
||||
}
|
||||
|
||||
if (hand.trigger_down && !was_down) {
|
||||
hand.captured = over;
|
||||
if (hovered >= 0) {
|
||||
hand.pressed = hovered;
|
||||
hand.repeat_at_ns = now_ns + kRepeatDelayNs;
|
||||
Touch();
|
||||
// Activate may rebuild the layout and clear pressed/hovered.
|
||||
const VirtualKey key = keys_[hovered];
|
||||
Activate(key, &output);
|
||||
}
|
||||
} else if (hand.trigger_down) {
|
||||
if (hand.pressed >= 0 && hand.pressed != hovered) {
|
||||
hand.pressed = -1;
|
||||
Touch();
|
||||
} else if (hand.pressed >= 0 &&
|
||||
keys_[hand.pressed].action == VirtualKeyAction::kBackspace &&
|
||||
now_ns >= hand.repeat_at_ns) {
|
||||
output.scancodes.push_back(kScancodeBackspace);
|
||||
hand.repeat_at_ns = now_ns + kRepeatIntervalNs;
|
||||
}
|
||||
} else if (was_down) {
|
||||
hand.captured = false;
|
||||
if (hand.pressed >= 0) {
|
||||
hand.pressed = -1;
|
||||
Touch();
|
||||
}
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
} // namespace mocktail::vr
|
||||
@@ -0,0 +1,325 @@
|
||||
#include "mocktail/vr/virtual_keyboard.h"
|
||||
|
||||
#include <SDL3/SDL_pixels.h>
|
||||
#include <SDL3/SDL_surface.h>
|
||||
#include <SDL3_ttf/SDL_ttf.h>
|
||||
#include <fontconfig/fontconfig.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
namespace mocktail::vr {
|
||||
namespace {
|
||||
|
||||
struct Rgb {
|
||||
std::uint8_t r, g, b;
|
||||
};
|
||||
|
||||
constexpr Rgb kBackground{24, 25, 30};
|
||||
constexpr Rgb kKey{58, 60, 70};
|
||||
constexpr Rgb kSpecialKey{42, 44, 52};
|
||||
constexpr Rgb kHover{92, 96, 112};
|
||||
constexpr Rgb kAccent{82, 132, 255};
|
||||
constexpr Rgb kAccentHover{120, 160, 255};
|
||||
constexpr Rgb kLabel{240, 242, 248};
|
||||
constexpr Rgb kField{12, 13, 17};
|
||||
constexpr int kFieldPadding = 24;
|
||||
constexpr int kGap = 8;
|
||||
constexpr int kRadius = 12;
|
||||
constexpr float kLetterPoints = 40.f;
|
||||
constexpr float kSpecialPoints = 28.f;
|
||||
|
||||
// fontconfig's best sans face; MOCKTAIL_TEXT_FONT wins like the TextBox
|
||||
// overlay.
|
||||
std::string ResolveSansFont() {
|
||||
const char* configured = std::getenv("MOCKTAIL_TEXT_FONT");
|
||||
if (configured != nullptr && configured[0] != '\0') return configured;
|
||||
std::string file;
|
||||
if (!FcInit()) return file;
|
||||
FcPattern* pattern =
|
||||
FcNameParse(reinterpret_cast<const FcChar8*>("sans:weight=medium"));
|
||||
if (pattern == nullptr) return file;
|
||||
FcConfigSubstitute(nullptr, pattern, FcMatchPattern);
|
||||
FcDefaultSubstitute(pattern);
|
||||
FcResult result = FcResultNoMatch;
|
||||
FcPattern* match = FcFontMatch(nullptr, pattern, &result);
|
||||
FcPatternDestroy(pattern);
|
||||
if (match == nullptr) return file;
|
||||
FcChar8* path = nullptr;
|
||||
if (FcPatternGetString(match, FC_FILE, 0, &path) == FcResultMatch &&
|
||||
path != nullptr) {
|
||||
file = reinterpret_cast<const char*>(path);
|
||||
}
|
||||
FcPatternDestroy(match);
|
||||
return file;
|
||||
}
|
||||
|
||||
struct AlphaMask {
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
std::vector<std::uint8_t> alpha;
|
||||
};
|
||||
|
||||
// Field text as drawn: password input shows one dot per code point.
|
||||
std::string DisplayText(const VirtualKeyboard& keyboard) {
|
||||
if (!keyboard.text_password()) return keyboard.text();
|
||||
std::string dots;
|
||||
for (const char c : keyboard.text())
|
||||
if ((static_cast<unsigned char>(c) & 0xC0) != 0x80) dots += "\u2022";
|
||||
return dots;
|
||||
}
|
||||
|
||||
// Byte offset in `text` of the UTF-16 cursor. Password dots are one UTF-16
|
||||
// unit each, like the BMP characters they stand for.
|
||||
std::size_t CursorByte(const std::string& text, int cursor_utf16) {
|
||||
std::size_t byte = 0;
|
||||
int units = 0;
|
||||
while (byte < text.size() && units < cursor_utf16) {
|
||||
const auto lead = static_cast<unsigned char>(text[byte]);
|
||||
const std::size_t length = lead < 0x80 ? 1 : lead < 0xE0 ? 2
|
||||
: lead < 0xF0 ? 3 : 4;
|
||||
units += length == 4 ? 2 : 1;
|
||||
byte = std::min(text.size(), byte + length);
|
||||
}
|
||||
return byte;
|
||||
}
|
||||
|
||||
void Scrub(std::vector<std::uint8_t>* bytes) {
|
||||
std::fill(bytes->begin(), bytes->end(), 0);
|
||||
bytes->clear();
|
||||
}
|
||||
|
||||
bool InsideRoundedRect(int x, int y, int left, int top, int right, int bottom,
|
||||
int radius) {
|
||||
const int cx = std::clamp(x, left + radius, right - 1 - radius);
|
||||
const int cy = std::clamp(y, top + radius, bottom - 1 - radius);
|
||||
const int dx = x - cx;
|
||||
const int dy = y - cy;
|
||||
return dx * dx + dy * dy <= radius * radius;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
struct VirtualKeyboardRasterizer::Impl {
|
||||
bool font_attempted = false;
|
||||
bool ttf_initialized = false;
|
||||
TTF_Font* letter_font = nullptr;
|
||||
TTF_Font* special_font = nullptr;
|
||||
std::map<std::pair<std::string, bool>, AlphaMask> masks;
|
||||
// Last rendered field text; not cached by content, since it is sensitive.
|
||||
AlphaMask field;
|
||||
|
||||
~Impl() {
|
||||
Scrub(&field.alpha);
|
||||
if (letter_font != nullptr) TTF_CloseFont(letter_font);
|
||||
if (special_font != nullptr) TTF_CloseFont(special_font);
|
||||
if (ttf_initialized) TTF_Quit();
|
||||
}
|
||||
|
||||
bool EnsureFonts() {
|
||||
if (font_attempted) return letter_font != nullptr;
|
||||
font_attempted = true;
|
||||
if (!TTF_Init()) {
|
||||
std::fprintf(stderr, " [vr-keyboard] SDL3_ttf unavailable: %s\n",
|
||||
SDL_GetError());
|
||||
return false;
|
||||
}
|
||||
ttf_initialized = true;
|
||||
const std::string file = ResolveSansFont();
|
||||
if (!file.empty()) {
|
||||
letter_font = TTF_OpenFont(file.c_str(), kLetterPoints);
|
||||
special_font = TTF_OpenFont(file.c_str(), kSpecialPoints);
|
||||
}
|
||||
if (letter_font == nullptr || special_font == nullptr) {
|
||||
std::fprintf(stderr,
|
||||
" [vr-keyboard] no usable font (%s); keys are unlabelled\n",
|
||||
file.empty() ? "fontconfig found none" : file.c_str());
|
||||
return false;
|
||||
}
|
||||
std::fprintf(stderr, " [vr-keyboard] labels use %s\n", file.c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
static AlphaMask Render(TTF_Font* font, const std::string& text) {
|
||||
AlphaMask mask;
|
||||
SDL_Surface* rendered = TTF_RenderText_Blended(
|
||||
font, text.c_str(), text.size(), SDL_Color{255, 255, 255, 255});
|
||||
if (rendered != nullptr) {
|
||||
SDL_Surface* rgba = SDL_ConvertSurface(rendered, SDL_PIXELFORMAT_RGBA32);
|
||||
if (rgba != nullptr && SDL_LockSurface(rgba)) {
|
||||
mask.width = rgba->w;
|
||||
mask.height = rgba->h;
|
||||
mask.alpha.resize(static_cast<std::size_t>(mask.width) * mask.height);
|
||||
for (int y = 0; y < mask.height; ++y) {
|
||||
const auto* row = static_cast<const std::uint8_t*>(rgba->pixels) +
|
||||
static_cast<std::size_t>(y) * rgba->pitch;
|
||||
for (int x = 0; x < mask.width; ++x) {
|
||||
mask.alpha[static_cast<std::size_t>(y) * mask.width + x] =
|
||||
row[x * 4 + 3];
|
||||
}
|
||||
}
|
||||
SDL_UnlockSurface(rgba);
|
||||
}
|
||||
if (rgba != nullptr) SDL_DestroySurface(rgba);
|
||||
SDL_DestroySurface(rendered);
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
const AlphaMask* Mask(const std::string& label, bool special) {
|
||||
if (label.empty() || !EnsureFonts()) return nullptr;
|
||||
const auto key = std::make_pair(label, special);
|
||||
auto found = masks.find(key);
|
||||
if (found != masks.end()) return &found->second;
|
||||
return &masks
|
||||
.emplace(key, Render(special ? special_font : letter_font,
|
||||
label))
|
||||
.first->second;
|
||||
}
|
||||
};
|
||||
|
||||
VirtualKeyboardRasterizer::VirtualKeyboardRasterizer() : impl_(new Impl) {}
|
||||
VirtualKeyboardRasterizer::~VirtualKeyboardRasterizer() { delete impl_; }
|
||||
|
||||
bool VirtualKeyboardRasterizer::Draw(const VirtualKeyboard& keyboard,
|
||||
std::vector<std::uint8_t>* rgba) {
|
||||
constexpr int width = VirtualKeyboard::kTextureWidth;
|
||||
constexpr int height = VirtualKeyboard::kTextureHeight;
|
||||
rgba->resize(static_cast<std::size_t>(width) * height * 4);
|
||||
std::uint8_t* pixels = rgba->data();
|
||||
auto put = [&](int x, int y, Rgb color) {
|
||||
std::uint8_t* p = pixels + (static_cast<std::size_t>(y) * width + x) * 4;
|
||||
p[0] = color.r;
|
||||
p[1] = color.g;
|
||||
p[2] = color.b;
|
||||
p[3] = 255;
|
||||
};
|
||||
auto blend = [&](int x, int y, Rgb color, std::uint8_t alpha) {
|
||||
if (x < 0 || y < 0 || x >= width || y >= height || alpha == 0) return;
|
||||
std::uint8_t* p = pixels + (static_cast<std::size_t>(y) * width + x) * 4;
|
||||
const int a = alpha;
|
||||
p[0] = static_cast<std::uint8_t>((color.r * a + p[0] * (255 - a)) / 255);
|
||||
p[1] = static_cast<std::uint8_t>((color.g * a + p[1] * (255 - a)) / 255);
|
||||
p[2] = static_cast<std::uint8_t>((color.b * a + p[2] * (255 - a)) / 255);
|
||||
};
|
||||
for (int y = 0; y < height; ++y)
|
||||
for (int x = 0; x < width; ++x) put(x, y, kBackground);
|
||||
|
||||
// Text field: the focused TextBox's contents and caret, scrolled so the
|
||||
// caret stays visible.
|
||||
{
|
||||
const int left = kGap / 2;
|
||||
const int right = width - kGap / 2;
|
||||
const int top = kGap / 2;
|
||||
const int bottom = static_cast<int>(std::lround(
|
||||
VirtualKeyboard::kFieldFraction * height)) -
|
||||
kGap / 2;
|
||||
for (int y = top; y < bottom; ++y)
|
||||
for (int x = left; x < right; ++x)
|
||||
if (InsideRoundedRect(x, y, left, top, right, bottom, kRadius))
|
||||
put(x, y, kField);
|
||||
const int clip_left = left + kFieldPadding;
|
||||
const int clip_right = right - kFieldPadding;
|
||||
const int inner = clip_right - clip_left;
|
||||
const std::string shown = DisplayText(keyboard);
|
||||
Scrub(&impl_->field.alpha);
|
||||
impl_->field = AlphaMask{};
|
||||
int caret_x = 0;
|
||||
int line_height = static_cast<int>(kLetterPoints * 1.2f);
|
||||
if (impl_->EnsureFonts()) {
|
||||
line_height = TTF_GetFontHeight(impl_->letter_font);
|
||||
if (!shown.empty()) {
|
||||
impl_->field = Impl::Render(impl_->letter_font, shown);
|
||||
const std::size_t cursor =
|
||||
CursorByte(shown, keyboard.text_cursor_utf16());
|
||||
int unused = 0;
|
||||
if (cursor > 0)
|
||||
(void)TTF_GetStringSize(impl_->letter_font, shown.c_str(), cursor,
|
||||
&caret_x, &unused);
|
||||
}
|
||||
}
|
||||
const int scroll = std::max(0, caret_x - inner + 4);
|
||||
const int origin_y = (top + bottom - line_height) / 2;
|
||||
const AlphaMask& text = impl_->field;
|
||||
for (int y = 0; y < text.height; ++y)
|
||||
for (int x = 0; x < text.width; ++x) {
|
||||
const int px = clip_left + x - scroll;
|
||||
if (px < clip_left || px >= clip_right) continue;
|
||||
blend(px, origin_y + y, kLabel,
|
||||
text.alpha[static_cast<std::size_t>(y) * text.width + x]);
|
||||
}
|
||||
const int caret = clip_left + caret_x - scroll;
|
||||
const int caret_top = origin_y + line_height / 8;
|
||||
const int caret_bottom = origin_y + line_height - line_height / 8;
|
||||
for (int y = caret_top; y < caret_bottom; ++y)
|
||||
for (int x = caret; x < caret + 3; ++x)
|
||||
if (x >= clip_left - 2 && x < clip_right + 2) put(x, y, kAccent);
|
||||
}
|
||||
|
||||
bool labelled = true;
|
||||
const auto& keys = keyboard.keys();
|
||||
for (std::size_t index = 0; index < keys.size(); ++index) {
|
||||
const VirtualKey& key = keys[index];
|
||||
const int left = static_cast<int>(std::lround(key.u0 * width)) + kGap / 2;
|
||||
const int right = static_cast<int>(std::lround(key.u1 * width)) - kGap / 2;
|
||||
const int top = static_cast<int>(std::lround(key.v0 * height)) + kGap / 2;
|
||||
const int bottom =
|
||||
static_cast<int>(std::lround(key.v1 * height)) - kGap / 2;
|
||||
const int i = static_cast<int>(index);
|
||||
const bool pressed =
|
||||
keyboard.pressed_key(0) == i || keyboard.pressed_key(1) == i;
|
||||
const bool hovered =
|
||||
keyboard.hovered_key(0) == i || keyboard.hovered_key(1) == i;
|
||||
const bool special = key.action != VirtualKeyAction::kText;
|
||||
// Latched modifiers stay lit; hover still shows on top of them.
|
||||
const bool latched =
|
||||
(key.action == VirtualKeyAction::kShift && keyboard.shifted()) ||
|
||||
(key.action == VirtualKeyAction::kCapsLock && keyboard.caps_lock());
|
||||
Rgb fill = special ? kSpecialKey : kKey;
|
||||
if (latched) fill = kAccent;
|
||||
if (hovered) fill = latched ? kAccentHover : kHover;
|
||||
if (pressed) fill = kAccent;
|
||||
for (int y = top; y < bottom; ++y)
|
||||
for (int x = left; x < right; ++x)
|
||||
if (InsideRoundedRect(x, y, left, top, right, bottom, kRadius))
|
||||
put(x, y, fill);
|
||||
|
||||
const AlphaMask* mask = impl_->Mask(key.label, special);
|
||||
if (!key.label.empty() && (mask == nullptr || mask->alpha.empty())) {
|
||||
labelled = false;
|
||||
continue;
|
||||
}
|
||||
if (mask == nullptr) continue;
|
||||
const int origin_x = (left + right - mask->width) / 2;
|
||||
const int origin_y = (top + bottom - mask->height) / 2;
|
||||
for (int y = 0; y < mask->height; ++y)
|
||||
for (int x = 0; x < mask->width; ++x)
|
||||
blend(origin_x + x, origin_y + y, kLabel,
|
||||
mask->alpha[static_cast<std::size_t>(y) * mask->width + x]);
|
||||
}
|
||||
|
||||
// Pointer dots, one per hand over the panel.
|
||||
for (int hand = 0; hand < 2; ++hand) {
|
||||
float u = 0.f, v = 0.f;
|
||||
if (!keyboard.cursor(hand, &u, &v)) continue;
|
||||
const int cx = static_cast<int>(std::lround(u * width));
|
||||
const int cy = static_cast<int>(std::lround(v * height));
|
||||
constexpr int kOuter = 11, kInner = 8;
|
||||
for (int y = -kOuter; y <= kOuter; ++y)
|
||||
for (int x = -kOuter; x <= kOuter; ++x) {
|
||||
const int d2 = x * x + y * y;
|
||||
if (d2 <= kInner * kInner)
|
||||
blend(cx + x, cy + y, Rgb{255, 255, 255}, 255);
|
||||
else if (d2 <= kOuter * kOuter)
|
||||
blend(cx + x, cy + y, Rgb{0, 0, 0}, 200);
|
||||
}
|
||||
}
|
||||
return labelled;
|
||||
}
|
||||
|
||||
} // namespace mocktail::vr
|
||||
+73
-4
@@ -11,8 +11,10 @@
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <deque>
|
||||
#include <filesystem>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
@@ -168,6 +170,15 @@ static WindowFullscreenStateSync g_fullscreen_state_sync;
|
||||
static std::unique_ptr<WindowResizeReadinessGate> g_resize_readiness_gate;
|
||||
static std::unique_ptr<SdlTextInputBackend> g_text_input_backend;
|
||||
static std::unique_ptr<WindowTextInputOwner> g_text_input_owner;
|
||||
// Generation of the showKeyboard request still awaiting its hideKeyboard;
|
||||
// 0 when Roblox wants no keyboard.
|
||||
static std::atomic<uint64_t> g_text_input_requested_generation{0};
|
||||
struct VirtualInput {
|
||||
std::string text; // committed text when non-empty
|
||||
uint32_t scancode = 0; // otherwise one key press
|
||||
};
|
||||
static std::mutex g_virtual_input_mutex;
|
||||
static std::deque<VirtualInput> g_virtual_input;
|
||||
static std::unique_ptr<SdlPointerCaptureBackend> 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
|
||||
@@ -1650,18 +1661,51 @@ bool SetPreTextInputPumpCallback(PreTextInputPumpCallback callback,
|
||||
}
|
||||
void ClearPreTextInputPumpCallback() { g_pre_text_input_pump_gate.Clear(); }
|
||||
void SetWindowTextInputOwnerEnabled(bool enabled) {
|
||||
if (!enabled) {
|
||||
g_text_input_requested_generation.store(0, std::memory_order_release);
|
||||
}
|
||||
if (g_text_input_owner != nullptr) {
|
||||
g_text_input_owner->SetEnabled(enabled);
|
||||
}
|
||||
}
|
||||
bool RequestShowTextInput(uint64_t generation, const TextInputArea& area,
|
||||
const TextInputOptions& options) {
|
||||
return g_text_input_owner != nullptr &&
|
||||
g_text_input_owner->RequestShowTextInput(generation, area, options);
|
||||
const bool accepted =
|
||||
g_text_input_owner != nullptr &&
|
||||
g_text_input_owner->RequestShowTextInput(generation, area, options);
|
||||
if (accepted) {
|
||||
g_text_input_requested_generation.store(generation,
|
||||
std::memory_order_release);
|
||||
}
|
||||
return accepted;
|
||||
}
|
||||
bool RequestHideTextInput(uint64_t generation) {
|
||||
return g_text_input_owner != nullptr &&
|
||||
g_text_input_owner->RequestHideTextInput(generation);
|
||||
const bool accepted = g_text_input_owner != nullptr &&
|
||||
g_text_input_owner->RequestHideTextInput(generation);
|
||||
if (accepted) {
|
||||
uint64_t expected = generation;
|
||||
g_text_input_requested_generation.compare_exchange_strong(
|
||||
expected, 0, std::memory_order_acq_rel);
|
||||
}
|
||||
return accepted;
|
||||
}
|
||||
bool TextInputRequested() {
|
||||
return g_text_input_requested_generation.load(std::memory_order_acquire) !=
|
||||
0;
|
||||
}
|
||||
void QueueVirtualTextInput(const char* utf8) {
|
||||
if (utf8 == nullptr || utf8[0] == '\0') {
|
||||
return;
|
||||
}
|
||||
std::lock_guard<std::mutex> lock(g_virtual_input_mutex);
|
||||
g_virtual_input.push_back(VirtualInput{utf8, 0});
|
||||
}
|
||||
void QueueVirtualKeyPress(uint32_t scancode) {
|
||||
if (scancode == 0) {
|
||||
return;
|
||||
}
|
||||
std::lock_guard<std::mutex> lock(g_virtual_input_mutex);
|
||||
g_virtual_input.push_back(VirtualInput{{}, scancode});
|
||||
}
|
||||
void* GetGLProcAddress(const char* name) {
|
||||
if (!g_state.initialised || name == nullptr || name[0] == '\0') {
|
||||
@@ -2276,6 +2320,31 @@ bool PumpEvents() {
|
||||
g_text_input_owner->active())) {
|
||||
fprintf(stderr, " [input] SDL pointer capture state update failed\n");
|
||||
}
|
||||
{
|
||||
std::deque<VirtualInput> virtual_input;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(g_virtual_input_mutex);
|
||||
virtual_input.swap(g_virtual_input);
|
||||
}
|
||||
for (const VirtualInput& input : virtual_input) {
|
||||
platform::PlatformEvent event{};
|
||||
event.timestamp_ns = SDL_GetTicksNS();
|
||||
if (!input.text.empty()) {
|
||||
event.payload = platform::TextInputEvent{input.text};
|
||||
g_platform_event_observer.Notify(event);
|
||||
continue;
|
||||
}
|
||||
const auto scancode = static_cast<SDL_Scancode>(input.scancode);
|
||||
const auto keycode = static_cast<uint32_t>(
|
||||
SDL_GetKeyFromScancode(scancode, SDL_KMOD_NONE, false));
|
||||
event.payload = platform::KeyEvent{true, false, input.scancode, keycode,
|
||||
0};
|
||||
g_platform_event_observer.Notify(event);
|
||||
event.payload = platform::KeyEvent{false, false, input.scancode, keycode,
|
||||
0};
|
||||
g_platform_event_observer.Notify(event);
|
||||
}
|
||||
}
|
||||
platform::PlatformEvent pending_motion{};
|
||||
bool has_pending_motion = false;
|
||||
|
||||
|
||||
@@ -98,6 +98,15 @@ bool RequestShowTextInput(uint64_t generation, const TextInputArea& area,
|
||||
const TextInputOptions& options);
|
||||
bool RequestHideTextInput(uint64_t generation);
|
||||
|
||||
// In-headset keyboard support. TextInputRequested reports whether Roblox
|
||||
// currently wants an on-screen keyboard (between showKeyboard and the matching
|
||||
// hideKeyboard). The Queue calls are safe from any thread; PumpEvents delivers
|
||||
// them on the main thread as the same platform events a physical keyboard
|
||||
// produces, so the focused TextBox edits them identically.
|
||||
bool TextInputRequested();
|
||||
void QueueVirtualTextInput(const char* utf8);
|
||||
void QueueVirtualKeyPress(uint32_t scancode);
|
||||
|
||||
void* GetEGLDisplay();
|
||||
|
||||
void* GetEGLSurface();
|
||||
|
||||
Reference in new issue
Block a user