Files
InstanceBrick--nightcap/src/vr/virtual_keyboard.cc
T
dim-ghub 4cc0e41ef3 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.
2026-10-12 23:45:51 -05:00

372 lines
12 KiB
C++

#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