From 0548ebd2533fa946ec00f8108bfe7ded075fa254 Mon Sep 17 00:00:00 2001 From: komaruworld Date: Sat, 12 Sep 2026 14:45:29 +0300 Subject: [PATCH] Fix chat text render and chat shiftlock issues Fixes 106, Fixes #127, Refs #116 --- include/runtime/roblox_input_router.h | 2 + src/runtime/roblox_input_router.cc | 5 +- src/runtime/roblox_text_display_state.cc | 6 +-- src/runtime/roblox_text_input_jni_bridge.cc | 41 ++++++++++++++++ src/runtime/roblox_text_input_jni_bridge.h | 1 + src/runtime/roblox_text_surface_overlay.cc | 9 ++-- src/window/window_pointer_capture_owner.cc | 10 +++- src/window/window_text_input_owner.cc | 5 +- tests/roblox_input_router_test.cc | 14 ++++++ tests/roblox_text_display_state_test.cc | 26 +++++++++++ tests/roblox_text_input_jni_bridge_test.cc | 52 +++++++++++++++++++++ tests/window_pointer_capture_owner_test.cc | 23 +++++++-- tests/window_text_input_owner_test.cc | 28 +++++++++++ 13 files changed, 206 insertions(+), 16 deletions(-) diff --git a/include/runtime/roblox_input_router.h b/include/runtime/roblox_input_router.h index ae52ef4..5b14649 100644 --- a/include/runtime/roblox_input_router.h +++ b/include/runtime/roblox_input_router.h @@ -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; diff --git a/src/runtime/roblox_input_router.cc b/src/runtime/roblox_input_router.cc index cdb7b96..33c1551 100644 --- a/src/runtime/roblox_input_router.cc +++ b/src/runtime/roblox_input_router.cc @@ -526,7 +526,10 @@ RobloxInputDispatchResult RobloxInputRouter::HandleEvent( RobloxInputSnapshot RobloxInputRouter::Snapshot() const { std::lock_guard 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( diff --git a/src/runtime/roblox_text_display_state.cc b/src/runtime/roblox_text_display_state.cc index a870d53..1fdf966 100644 --- a/src/runtime/roblox_text_display_state.cc +++ b/src/runtime/roblox_text_display_state.cc @@ -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(update.area_x) + diff --git a/src/runtime/roblox_text_input_jni_bridge.cc b/src/runtime/roblox_text_input_jni_bridge.cc index 23e75b8..4acf4d4 100644 --- a/src/runtime/roblox_text_input_jni_bridge.cc +++ b/src/runtime/roblox_text_input_jni_bridge.cc @@ -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 lock(mutex); + EnterTerminalLocked("SDL text-input completion hide was rejected"); + } + return CompleteTerminalOnMainThread("host-focus-end"); + } + { + std::lock_guard 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; diff --git a/src/runtime/roblox_text_input_jni_bridge.h b/src/runtime/roblox_text_input_jni_bridge.h index 59cc7e2..237faa9 100644 --- a/src/runtime/roblox_text_input_jni_bridge.h +++ b/src/runtime/roblox_text_input_jni_bridge.h @@ -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( diff --git a/src/runtime/roblox_text_surface_overlay.cc b/src/runtime/roblox_text_surface_overlay.cc index 034bdec..958611b 100644 --- a/src/runtime/roblox_text_surface_overlay.cc +++ b/src/runtime/roblox_text_surface_overlay.cc @@ -431,13 +431,12 @@ Status RobloxTextSurfaceOverlay::RasterizeLocked() { static_cast(width * height * 4), 0); const int frame_width = static_cast(width); const int frame_height = static_cast(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; diff --git a/src/window/window_pointer_capture_owner.cc b/src/window/window_pointer_capture_owner.cc index 616cced..58a5d14 100644 --- a/src/window/window_pointer_capture_owner.cc +++ b/src/window/window_pointer_capture_owner.cc @@ -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); diff --git a/src/window/window_text_input_owner.cc b/src/window/window_text_input_owner.cc index 89ef4c6..b4686a5 100644 --- a/src/window/window_text_input_owner.cc +++ b/src/window/window_text_input_owner.cc @@ -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(); diff --git a/tests/roblox_input_router_test.cc b/tests/roblox_input_router_test.cc index 419d8c7..fa38409 100644 --- a/tests/roblox_input_router_test.cc +++ b/tests/roblox_input_router_test.cc @@ -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(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; diff --git a/tests/roblox_text_display_state_test.cc b/tests/roblox_text_display_state_test.cc index 52bb676..5931957 100644 --- a/tests/roblox_text_display_state_test.cc +++ b/tests/roblox_text_display_state_test.cc @@ -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 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"; diff --git a/tests/roblox_text_input_jni_bridge_test.cc b/tests/roblox_text_input_jni_bridge_test.cc index c5f0c61..83f1c62 100644 --- a/tests/roblox_text_input_jni_bridge_test.cc +++ b/tests/roblox_text_input_jni_bridge_test.cc @@ -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(); + std::unique_ptr 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{"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(); + std::unique_ptr 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{"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; diff --git a/tests/window_pointer_capture_owner_test.cc b/tests/window_pointer_capture_owner_test.cc index c75b9a2..1fa40e6 100644 --- a/tests/window_pointer_capture_owner_test.cc +++ b/tests/window_pointer_capture_owner_test.cc @@ -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()); } diff --git a/tests/window_text_input_owner_test.cc b/tests/window_text_input_owner_test.cc index 7ff1ecb..471912f 100644 --- a/tests/window_text_input_owner_test.cc +++ b/tests/window_text_input_owner_test.cc @@ -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{'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);