Fix chat text render and chat shiftlock issues

Fixes 106, Fixes #127, Refs #116
This commit is contained in:
komaruworld committed 2026-09-12 14:45:29 +03:00
1 parent c523048518
commit 0548ebd253
13 files changed
+206 -16

No files matched your search

+2
View File
@@ -138,6 +138,8 @@ struct RobloxInputSnapshot {
uint64_t keyboard_events = 0;
uint64_t ignored_text_events = 0;
uint64_t text_events = 0;
// Zero after the host editor finishes, even if Roblox sends no hide callback.
uint64_t text_focus_generation = 0;
uint64_t native_errors = 0;
uint32_t active_mouse_buttons = 0;
uint32_t active_touches = 0;
+4 -1
View File
@@ -526,7 +526,10 @@ RobloxInputDispatchResult RobloxInputRouter::HandleEvent(
RobloxInputSnapshot RobloxInputRouter::Snapshot() const {
std::lock_guard<std::mutex> lock(mutex_);
return snapshot_;
RobloxInputSnapshot snapshot = snapshot_;
const RobloxTextEditorSnapshot text = text_editor_.Snapshot();
snapshot.text_focus_generation = text.focused ? text.generation : 0;
return snapshot;
}
RobloxInputDispatchResult RobloxInputRouter::HandleMouseMotionLocked(
+2 -4
View File
@@ -14,8 +14,6 @@ namespace mocktail {
namespace runtime {
namespace {
constexpr int kMinimumNativeWidth = 32;
constexpr int kMinimumNativeHeight = 18;
constexpr char kPasswordBullet[] = "\xE2\x80\xA2";
void SecureClear(std::string* value) {
@@ -83,8 +81,8 @@ bool ValidateAndCountUtf8(const char* text, std::size_t size,
bool NativeGeometryIntersectsViewport(
const RobloxTextDisplayUpdate& update,
const RobloxTextOverlayViewport& viewport) {
if (!viewport.valid() || update.area_width < kMinimumNativeWidth ||
update.area_height < kMinimumNativeHeight) {
if (!viewport.valid() || update.area_width <= 0 ||
update.area_height <= 0) {
return false;
}
const int64_t right = static_cast<int64_t>(update.area_x) +
@@ -142,6 +142,9 @@ class ProductionTextInputBackend final
return runtime_->EndTextFocusSession(textbox_handle, generation,
notify_native);
}
bool IsTextFocusSessionActive(uint64_t generation) override {
return runtime_->Snapshot().text_focus_generation == generation;
}
Status ReplaceFocusedTextFromEngine(uint64_t generation,
std::string authoritative_utf8) override {
return runtime_->ReplaceFocusedTextFromEngine(
@@ -559,6 +562,44 @@ struct RobloxTextInputJniBridge::State {
return CompleteTerminalOnMainThread("queue");
}
// RbxKeyboard hides its own editor after Enter/Escape. The native side
// need not echo hideKeyboard, so reconcile the host editor before polling
// properties or replaying delayed text updates from that focus session.
if (applied_active &&
!backend->IsTextFocusSessionActive(applied_generation)) {
const uint64_t ended_generation = applied_generation;
if (!backend->RequestHideTextInput(ended_generation)) {
for (Command& command : pending) command.ClearSensitiveData();
{
std::lock_guard<std::mutex> lock(mutex);
EnterTerminalLocked("SDL text-input completion hide was rejected");
}
return CompleteTerminalOnMainThread("host-focus-end");
}
{
std::lock_guard<std::mutex> lock(mutex);
if (active_generation == ended_generation) {
desired_active = false;
active_handle = 0;
active_info = {};
active_geometry = {};
has_active_geometry = false;
geometry_refresh_pumps = 0;
}
}
for (auto it = pending.begin(); it != pending.end();) {
if (it->generation == ended_generation) {
it->ClearSensitiveData();
it = pending.erase(it);
} else {
++it;
}
}
applied_active = false;
applied_generation = 0;
applied_handle = 0;
}
AppendGeometryRefresh(&pending);
bool success = true;
@@ -27,6 +27,7 @@ class RobloxTextInputJniBridgeBackend {
virtual Status EndTextFocusSession(int64_t textbox_handle,
uint64_t generation,
bool notify_native) = 0;
virtual bool IsTextFocusSessionActive(uint64_t generation) = 0;
virtual Status ReplaceFocusedTextFromEngine(
uint64_t generation, std::string authoritative_utf8) = 0;
virtual Status QueryCurrentTextBoxInfo(
+4 -5
View File
@@ -431,13 +431,12 @@ Status RobloxTextSurfaceOverlay::RasterizeLocked() {
static_cast<std::size_t>(width * height * 4), 0);
const int frame_width = static_cast<int>(width);
const int frame_height = static_cast<int>(height);
// Use Roblox's horizontal text bounds directly. Inventing padding from
// the field's width or position shifts text away from its native origin
// and makes long lines scroll before they reach the field's actual edge.
// Use Roblox's text bounds directly. Extra padding shifts the native text
// origin, scrolls long lines too early and clips compact chat fields.
const int clip_left = 0;
const int clip_right = frame_width;
const int clip_top = 3;
const int clip_bottom = std::max(clip_top, frame_height - 3);
const int clip_top = 0;
const int clip_bottom = frame_height;
const int available_width = std::max(1, clip_right - clip_left);
const bool wrapped_layout =
presentation.multiline || presentation.text_wrapped;
+8 -2
View File
@@ -52,6 +52,9 @@ void WindowPointerCaptureOwner::ClearQuery() {
}
bool WindowPointerCaptureOwner::Pump(bool text_input_active) {
if (!focused_) {
return Apply(false, true);
}
MouseLockQueryCallback callback = nullptr;
void* context = nullptr;
{
@@ -93,9 +96,12 @@ bool WindowPointerCaptureOwner::Pump(bool text_input_active) {
const bool client_active = query_ok;
const bool native_capture_active = client_active && native_lock_active;
const bool right_drag_active = right_button_held_;
// Roblox can keep Shift Lock active while chat has text focus. Honor that
// native lock so camera motion stays relative and cannot leave the window.
// Text editing only suppresses the host's fallback RMB camera capture.
const bool capture_requested =
(right_drag_active || native_capture_active) && !text_input_active;
// Text input releases capture, but Roblox still draws its own cursor.
native_capture_active || (right_drag_active && !text_input_active);
// When capture is released, Roblox still draws its own cursor.
// Show the system pointer only when the native client cannot provide one.
const bool cursor_visible = !client_active && !capture_requested;
return Apply(capture_requested, cursor_visible);
+4 -1
View File
@@ -120,7 +120,10 @@ bool WindowTextInputOwner::Pump() {
}
if (command.type == CommandType::kHide) {
if (!active_ || active_generation_ != command.generation) {
// A hide can replace both a pending show and the hide of the previous
// editor. It must also stop that older editor if the new show was never
// pumped (Roblox briefly focuses an empty TextBox when closing chat).
if (!active_ || active_generation_ > command.generation) {
return true;
}
return StopAndClear();
+14
View File
@@ -434,6 +434,20 @@ TEST_F(RobloxInputRouterTest, FocusedTextSuppressesDuplicateHardwareEditKeys) {
EXPECT_EQ(router_.Snapshot().text_events, 2u);
}
TEST_F(RobloxInputRouterTest, ReportsHostTextFocusCompletion) {
for (const auto scancode : {SDL_SCANCODE_RETURN, SDL_SCANCODE_ESCAPE}) {
ASSERT_TRUE(router_.BeginTextFocusSession({42, 3, "", false, false}).ok());
EXPECT_EQ(router_.Snapshot().text_focus_generation, 3U);
ASSERT_TRUE(router_.HandleEvent(Event(platform::KeyEvent{
true, false, static_cast<uint32_t>(scancode), 0, 0})).status.ok());
EXPECT_EQ(router_.Snapshot().text_focus_generation, 0U);
}
ASSERT_TRUE(router_.BeginTextFocusSession({43, 4, "", true, false}).ok());
ASSERT_TRUE(router_.HandleEvent(Event(platform::KeyEvent{
true, false, SDL_SCANCODE_RETURN, SDLK_RETURN, 0})).status.ok());
EXPECT_EQ(router_.Snapshot().text_focus_generation, 4U);
}
TEST(RobloxInputRouterClipboardTest,
CtrlVPastesWithoutForwardingTheLetterKeyToRoblox) {
Probe probe;
+26
View File
@@ -85,6 +85,32 @@ TEST(RobloxTextDisplayStateTest, RejectsDetachedFallbackForMissingGeometry) {
EXPECT_FALSE(geometry.used_fallback);
}
TEST(RobloxTextDisplayStateTest, RendersCompactChatFields) {
RobloxTextSurfaceOverlay overlay;
ASSERT_TRUE(overlay.Initialize({1630, 985, 1.145833F}).ok());
const std::string text = "chat test";
auto show = Show(1, text, 9);
// Actual NativeTextBoxInfo from MIC UP; 17px is a valid chat line.
show.area_x = 38;
show.area_y = 328;
show.area_width = 387;
show.area_height = 17;
show.font_size = 14.0F;
overlay.sink().update(overlay.sink().context, show);
EXPECT_TRUE(mocktail_text_overlay_may_present());
MocktailTextOverlayFrameInfo frame{};
ASSERT_TRUE(overlay.QueryFrame(&frame));
ASSERT_NE(frame.visible, 0U);
EXPECT_EQ(frame.width, 387U);
EXPECT_EQ(frame.height, 17U);
std::vector<std::uint8_t> rgba(frame.rgba_bytes);
ASSERT_TRUE(overlay.CopyFrame(frame.revision, rgba.data(), rgba.size()));
const auto bounds = FindAlphaBounds(frame, rgba);
ASSERT_TRUE(bounds.valid());
EXPECT_GT(bounds.maximum_x - bounds.minimum_x, 10);
EXPECT_TRUE(overlay.Shutdown().ok());
}
TEST(RobloxTextDisplayStateTest,
PreservesNativeExtentForCompositorSourceClipping) {
const std::string text = "edge";
@@ -57,6 +57,10 @@ class FakeBackend final : public RobloxTextInputJniBridgeBackend {
return replace_status;
}
bool IsTextFocusSessionActive(uint64_t generation) override {
return active && active_generation == generation;
}
Status QueryCurrentTextBoxInfo(
RobloxNativeTextBoxInfoQueryResult* result) override {
calls.push_back("query:" + std::to_string(active_generation));
@@ -229,6 +233,54 @@ TEST(RobloxTextInputJniBridgeTest, CommandsDrainInGenerationOrder) {
EXPECT_FALSE(backend->owner_transitions.back());
}
TEST(RobloxTextInputJniBridgeTest,
HostCompletionHidesInputWithoutNativeHideAndDropsLateUpdates) {
jnivm::VM vm;
auto backend = std::make_shared<FakeBackend>();
std::unique_ptr<RobloxTextInputJniBridge> bridge;
ASSERT_TRUE(
RobloxTextInputJniBridge::CreateForTesting(&vm, backend, &bridge).ok());
ASSERT_TRUE(vm.DispatchRobloxTextInputShow(ShowRequest(42, "draft")));
ASSERT_TRUE(backend->Pump());
backend->calls.clear();
// Enter/Escape closes the host editor; Android hides RbxKeyboard locally
// and does not guarantee a native hideKeyboard callback in response.
backend->active = false;
ASSERT_TRUE(vm.DispatchRobloxTextInputReplaceText("late echo"));
ASSERT_TRUE(backend->Pump());
EXPECT_EQ(backend->calls, (std::vector<std::string>{"hide:1"}));
for (int i = 0; i < 16; ++i) ASSERT_TRUE(backend->Pump());
EXPECT_EQ(backend->calls.size(), 1U);
ASSERT_TRUE(vm.DispatchRobloxTextInputShow(ShowRequest(43, "next")));
ASSERT_TRUE(backend->Pump());
EXPECT_TRUE(backend->active);
EXPECT_EQ(backend->active_generation, 2U);
EXPECT_TRUE(bridge->Shutdown().ok());
}
TEST(RobloxTextInputJniBridgeTest,
HostCompletionPreservesAnAlreadyQueuedNewFocus) {
jnivm::VM vm;
auto backend = std::make_shared<FakeBackend>();
std::unique_ptr<RobloxTextInputJniBridge> bridge;
ASSERT_TRUE(
RobloxTextInputJniBridge::CreateForTesting(&vm, backend, &bridge).ok());
ASSERT_TRUE(vm.DispatchRobloxTextInputShow(ShowRequest(42, "draft")));
ASSERT_TRUE(backend->Pump());
backend->calls.clear();
backend->active = false;
ASSERT_TRUE(vm.DispatchRobloxTextInputReplaceText("late echo"));
ASSERT_TRUE(vm.DispatchRobloxTextInputShow(ShowRequest(43, "next")));
ASSERT_TRUE(backend->Pump());
EXPECT_EQ(backend->calls,
(std::vector<std::string>{"hide:1", "begin:2", "show:2"}));
EXPECT_TRUE(backend->active);
EXPECT_EQ(backend->last_initial_text, "next");
EXPECT_TRUE(bridge->Shutdown().ok());
}
TEST(RobloxTextInputJniBridgeTest,
PendingReplaceCallbacksCollapseToLatestTextInCurrentGeneration) {
jnivm::VM vm;
+20 -3
View File
@@ -71,21 +71,38 @@ TEST(WindowPointerCaptureOwnerTest, FollowsNativeMouseLockState) {
EXPECT_FALSE(owner.cursor_visible());
}
TEST(WindowPointerCaptureOwnerTest, TextAndFocusAlwaysReleaseCapture) {
TEST(WindowPointerCaptureOwnerTest, TextInputPreservesNativeShiftLock) {
FakeBackend backend;
QueryState query{true, true};
WindowPointerCaptureOwner owner(&backend);
ASSERT_TRUE(owner.RegisterQuery(Query, &query));
ASSERT_TRUE(owner.Pump(false));
// Opening chat must not turn relative motion into absolute motion while
// Roblox still requests center lock: the pointer would reach the window edge
// and camera rotation would stop during typing.
EXPECT_TRUE(owner.Pump(true));
EXPECT_TRUE(owner.captured());
EXPECT_FALSE(owner.cursor_visible());
// Roblox can release its lock to let the user interact with a text field.
query.locked_center = false;
EXPECT_TRUE(owner.Pump(true));
EXPECT_FALSE(owner.captured());
EXPECT_FALSE(owner.cursor_visible());
query.locked_center = true;
EXPECT_TRUE(owner.Pump(true));
EXPECT_TRUE(owner.captured());
EXPECT_TRUE(owner.Pump(false));
EXPECT_TRUE(owner.captured());
EXPECT_TRUE(owner.OnFocusLost());
EXPECT_FALSE(owner.captured());
EXPECT_TRUE(owner.OnFocusGained(false));
// Regular pumps continue while the window is unfocused. A stale native
// lock must not recapture the pointer before focus actually returns.
EXPECT_TRUE(owner.Pump(false));
EXPECT_FALSE(owner.captured());
EXPECT_TRUE(owner.cursor_visible());
EXPECT_TRUE(owner.OnFocusGained(true));
EXPECT_TRUE(owner.captured());
}
+28
View File
@@ -107,6 +107,34 @@ TEST(WindowTextInputOwnerTest, ReappliesAreaAfterResizeWithoutRestart) {
EXPECT_EQ(backend.areas.size(), 2u);
}
TEST(WindowTextInputOwnerTest, CoalescedNewerHideStopsThePreviousEditor) {
RecordingBackend backend;
WindowTextInputOwner owner(&backend);
owner.SetEnabled(true);
ASSERT_TRUE(owner.RequestShowTextInput(3, kArea, kOptions));
ASSERT_TRUE(owner.Pump());
ASSERT_TRUE(owner.active());
// Closing Roblox chat hides the current editor, then briefly focuses and
// hides another TextBox before the next SDL pump.
ASSERT_TRUE(owner.RequestHideTextInput(3));
ASSERT_TRUE(owner.RequestShowTextInput(4, {}, kOptions));
ASSERT_TRUE(owner.RequestHideTextInput(4));
ASSERT_TRUE(owner.Pump());
EXPECT_FALSE(owner.active());
EXPECT_EQ(owner.active_generation(), 0u);
EXPECT_EQ(backend.calls,
(std::vector<char>{'F', 'A', 'S', 'T', 'C', 'F'}));
ASSERT_TRUE(owner.RequestShowTextInput(5, kArea, kOptions));
ASSERT_TRUE(owner.Pump());
EXPECT_TRUE(owner.active());
EXPECT_EQ(owner.active_generation(), 5u);
EXPECT_FALSE(owner.RequestHideTextInput(4));
EXPECT_TRUE(owner.Pump());
EXPECT_TRUE(owner.active());
}
TEST(WindowTextInputOwnerTest, FocusLossAndShutdownClearInputState) {
RecordingBackend backend;
WindowTextInputOwner owner(&backend);