mirror of
https://github.com/InstanceBrick/nightcap.git
synced 2026-10-17 23:00:46 +02:00
Fix chat text render and chat shiftlock issues
Fixes 106, Fixes #127, Refs #116
This commit is contained in:
1 parent
c523048518
commit
0548ebd253
13 files changed
+206
-16
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user