From 7fced0d1b8e10cf3b03b7ec9248eb654469369ef Mon Sep 17 00:00:00 2001 From: dim-ghub <59436063+dim-ghub@users.noreply.github.com> Date: Tue, 10 Nov 2026 21:24:21 -0600 Subject: [PATCH 1/3] fix(input): keep the chat TextBox focused when the window loses focus Clipboard managers and emoji pickers take window focus for a moment and then paste or type back into the window. Losing focus used to release the Roblox TextBox and stop SDL text input, so the chatbox closed before the text arrived. Focus loss now only releases held keys, buttons and touches. --- src/runtime/roblox_input_router.cc | 10 ++-------- src/window/window.cc | 6 ------ src/window/window_text_input_owner.cc | 19 ------------------- src/window/window_text_input_owner.h | 3 --- tests/roblox_input_router_test.cc | 7 ++++--- tests/window_text_input_owner_test.cc | 14 ++------------ 6 files changed, 8 insertions(+), 51 deletions(-) diff --git a/src/runtime/roblox_input_router.cc b/src/runtime/roblox_input_router.cc index 93680144..60ce7bea 100644 --- a/src/runtime/roblox_input_router.cc +++ b/src/runtime/roblox_input_router.cc @@ -404,15 +404,9 @@ RobloxInputDispatchResult RobloxInputRouter::HandleEvent( if (const auto* focus = std::get_if(&event.payload)) { if (!focus->focused && snapshot_.focused) { + // Keep any focused TextBox: clipboard managers and emoji pickers take + // window focus briefly and then paste or type back into it. Status status = ReleasePressedInputsLocked(); - const RobloxTextEditorSnapshot text = text_editor_.Snapshot(); - if (text.focused) { - Status text_status = text_editor_.EndFocusSession( - text.textbox_handle, text.generation, true); - if (status.ok()) { - status = std::move(text_status); - } - } snapshot_.focused = false; if (!status.ok()) { ++snapshot_.native_errors; diff --git a/src/window/window.cc b/src/window/window.cc index fd0978ed..6402abfd 100644 --- a/src/window/window.cc +++ b/src/window/window.cc @@ -2470,9 +2470,6 @@ bool PumpEvents() { g_state.quit_requested = true; } if (event.type == SDL_EVENT_WINDOW_FOCUS_LOST) { - if (g_text_input_owner != nullptr) { - g_text_input_owner->OnFocusLost(); - } if (g_pointer_capture_owner != nullptr && !g_pointer_capture_owner->OnFocusLost()) { fprintf(stderr, @@ -2480,9 +2477,6 @@ bool PumpEvents() { } } if (event.type == SDL_EVENT_WINDOW_FOCUS_GAINED) { - if (g_text_input_owner != nullptr) { - g_text_input_owner->OnFocusGained(); - } if (g_pointer_capture_owner != nullptr && !g_pointer_capture_owner->OnFocusGained( g_text_input_owner != nullptr && g_text_input_owner->active())) { diff --git a/src/window/window_text_input_owner.cc b/src/window/window_text_input_owner.cc index b4686a5a..3d81cb46 100644 --- a/src/window/window_text_input_owner.cc +++ b/src/window/window_text_input_owner.cc @@ -115,10 +115,6 @@ bool WindowTextInputOwner::Pump() { has_pending_command_ = false; } - if (!focused_) { - return true; - } - if (command.type == CommandType::kHide) { // 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 @@ -158,21 +154,6 @@ bool WindowTextInputOwner::OnViewportChanged() { return backend_->SetArea(active_area_); } -void WindowTextInputOwner::OnFocusGained() { focused_ = true; } - -void WindowTextInputOwner::OnFocusLost() { - focused_ = false; - { - std::lock_guard lock(mutex_); - has_pending_command_ = false; - } - if (active_) { - StopAndClear(); - } else if (backend_ != nullptr) { - backend_->FlushTextEvents(); - } -} - void WindowTextInputOwner::Shutdown() { enabled_.store(false, std::memory_order_release); { diff --git a/src/window/window_text_input_owner.h b/src/window/window_text_input_owner.h index f1707034..4628604c 100644 --- a/src/window/window_text_input_owner.h +++ b/src/window/window_text_input_owner.h @@ -57,8 +57,6 @@ class WindowTextInputOwner final { // coalesced: SDL only needs the most recent desired text-input state. bool Pump(); bool OnViewportChanged(); - void OnFocusGained(); - void OnFocusLost(); void Shutdown(); bool active() const { return active_; } @@ -87,7 +85,6 @@ class WindowTextInputOwner final { bool active_ = false; uint64_t active_generation_ = 0; TextInputArea active_area_; - bool focused_ = true; }; // SDL3 implementation. The opaque window is an SDL_Window* in production. diff --git a/tests/roblox_input_router_test.cc b/tests/roblox_input_router_test.cc index 2dcf83a9..a9f73f46 100644 --- a/tests/roblox_input_router_test.cc +++ b/tests/roblox_input_router_test.cc @@ -845,15 +845,16 @@ TEST_F(RobloxInputRouterTest, FocusedTextEditingPreeditStreamsToNative) { EXPECT_EQ(snapshot.native_errors, 0u); } -TEST_F(RobloxInputRouterTest, WindowFocusLossReleasesFocusedTextbox) { +TEST_F(RobloxInputRouterTest, WindowFocusLossKeepsFocusedTextbox) { ASSERT_TRUE( router_.BeginTextFocusSession({42, 1, "secret", false, false}).ok()); const auto result = router_.HandleEvent(Event(platform::WindowFocusEvent{false})); EXPECT_EQ(result.state, RobloxInputDispatchState::kStateUpdated); - EXPECT_EQ(probe_.text_operations, (std::vector{"handle"})); + EXPECT_TRUE(probe_.text_operations.empty()); + EXPECT_EQ(router_.Snapshot().text_focus_generation, 1u); const auto text = router_.HandleEvent(Event(platform::TextInputEvent{"x"})); - EXPECT_EQ(text.state, RobloxInputDispatchState::kIgnoredUnsupported); + EXPECT_TRUE(text.dispatched()); } TEST_F(RobloxInputRouterTest, DeactivatePreventsAllLaterNativeCalls) { diff --git a/tests/window_text_input_owner_test.cc b/tests/window_text_input_owner_test.cc index 471912f2..8fc4e133 100644 --- a/tests/window_text_input_owner_test.cc +++ b/tests/window_text_input_owner_test.cc @@ -135,24 +135,14 @@ TEST(WindowTextInputOwnerTest, CoalescedNewerHideStopsThePreviousEditor) { EXPECT_TRUE(owner.active()); } -TEST(WindowTextInputOwnerTest, FocusLossAndShutdownClearInputState) { +TEST(WindowTextInputOwnerTest, ShutdownClearsInputState) { RecordingBackend backend; WindowTextInputOwner owner(&backend); owner.SetEnabled(true); ASSERT_TRUE(owner.RequestShowTextInput(1, kArea, kOptions)); ASSERT_TRUE(owner.Pump()); + EXPECT_TRUE(owner.active()); - owner.OnFocusLost(); - EXPECT_FALSE(owner.active()); - EXPECT_EQ(backend.calls, - (std::vector{'F', 'A', 'S', 'T', 'C', 'F'})); - - EXPECT_TRUE(owner.RequestShowTextInput(1, kArea, kOptions)); - EXPECT_TRUE(owner.Pump()); - EXPECT_FALSE(owner.active()); - owner.OnFocusGained(); - EXPECT_TRUE(owner.RequestShowTextInput(1, kArea, kOptions)); - ASSERT_TRUE(owner.Pump()); owner.Shutdown(); EXPECT_FALSE(owner.active()); EXPECT_FALSE(owner.RequestShowTextInput(1, kArea, kOptions)); From 4b77823732e15853da870cd542955b960c0f4a45 Mon Sep 17 00:00:00 2001 From: dim-ghub <59436063+dim-ghub@users.noreply.github.com> Date: Tue, 10 Nov 2026 21:30:20 -0600 Subject: [PATCH 2/3] fix(input): keep chat command clears from being undone by late echoes TextChatService clears the chat box after "/w name " or "/t ". The editor dropped every echo tombstone on an empty engine update, so the engine's delayed echoes of the typed command were treated as new Lua edits and put the command text back. An empty update now only consumes empty tombstones. --- src/runtime/roblox_text_editor.cc | 6 +++++- tests/roblox_text_editor_test.cc | 22 ++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/runtime/roblox_text_editor.cc b/src/runtime/roblox_text_editor.cc index 118c404c..5025c788 100644 --- a/src/runtime/roblox_text_editor.cc +++ b/src/runtime/roblox_text_editor.cc @@ -233,8 +233,12 @@ Status RobloxTextEditor::ReplaceFocusedTextFromEngine( // Games can clear a TextBox from Lua after consuming its value. An empty // engine update must win even when it matches an older host echo; otherwise // the stale host buffer remains visible and the next word starts with it. + // Non-empty tombstones stay: chat commands such as "/w name " or "/t " clear + // the box while echoes of the typed command are still in flight, and those + // echoes must not restore it. if (authoritative_utf8.empty()) { - ClearPendingNativeEchoesLocked(); + while (ConsumePendingNativeEchoLocked(authoritative_utf8)) { + } SecureClear(&text_); cursor_byte_ = 0; selection_anchor_byte_ = 0; diff --git a/tests/roblox_text_editor_test.cc b/tests/roblox_text_editor_test.cc index a3e96947..b6e0a782 100644 --- a/tests/roblox_text_editor_test.cc +++ b/tests/roblox_text_editor_test.cc @@ -317,6 +317,28 @@ TEST(RobloxTextEditorTest, EmptyEngineUpdateOverridesOlderEmptyHostEcho) { EXPECT_TRUE(display_probe.calls.back().text.empty()); } +TEST(RobloxTextEditorTest, LateHostEchoCannotRestoreChatCommandClear) { + Probe probe; + DisplayProbe display_probe; + RobloxTextEditor editor(Sink(&probe), DisplaySink(&display_probe)); + ASSERT_TRUE(editor.BeginFocusSession(Session(42, 7, "")).ok()); + ASSERT_TRUE(editor.HandleTextInput({"/"}).status.ok()); + ASSERT_TRUE(editor.HandleTextInput({"t"}).status.ok()); + ASSERT_TRUE(editor.HandleTextInput({" "}).status.ok()); + + // The chat script switches to team chat and clears the box before the + // engine has echoed the typed command back. + ASSERT_TRUE(editor.ReplaceFocusedTextFromEngine(7, {}).ok()); + ASSERT_TRUE(editor.ReplaceFocusedTextFromEngine(7, "/t").ok()); + ASSERT_TRUE(editor.ReplaceFocusedTextFromEngine(7, "/t ").ok()); + + EXPECT_EQ(editor.Snapshot().text_bytes, 0u); + EXPECT_TRUE(display_probe.calls.back().text.empty()); + + ASSERT_TRUE(editor.HandleTextInput({"hi"}).status.ok()); + EXPECT_EQ(display_probe.calls.back().text, "hi"); +} + TEST(RobloxTextEditorTest, UnknownEngineUpdateCancelsActiveComposition) { Probe probe; DisplayProbe display_probe; From 69a2842ac4bfb8da5cc49401cf681ad09bad5d15 Mon Sep 17 00:00:00 2001 From: dim-ghub <59436063+dim-ghub@users.noreply.github.com> Date: Tue, 10 Nov 2026 21:39:49 -0600 Subject: [PATCH 3/3] fix(input): draw color emoji in the chatbox overlay SDL_ttf 3.2's surface text engine sizes each glyph from its outline. Roblox's TwemojiMozilla is a COLR font whose base outlines are empty, so emoji kept their advance but drew nothing and showed up as spaces. After drawing the text, fill any multi-byte cluster that is still blank with TTF_RenderText_Blended, which renders the color layers. --- src/runtime/roblox_text_surface_overlay.cc | 55 ++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/src/runtime/roblox_text_surface_overlay.cc b/src/runtime/roblox_text_surface_overlay.cc index 1c3f577a..3faaa229 100644 --- a/src/runtime/roblox_text_surface_overlay.cc +++ b/src/runtime/roblox_text_surface_overlay.cc @@ -295,6 +295,56 @@ TextBytePosition TextPosition(TTF_Text* layout, std::size_t byte_offset, return position; } +// SDL_ttf 3.2's surface text engine sizes each glyph from its outline. COLR +// emoji such as Roblox's TwemojiMozilla have an empty base outline, so they +// keep their advance but draw nothing. Fill those clusters from the font's own +// renderer, which uses the color layers. +void DrawBlankColorClusters(TTF_Text* layout, TTF_Font* font, + const std::string& text, SDL_Color color, + SDL_Surface* surface) { + if (layout == nullptr || font == nullptr || surface == nullptr || + surface->pixels == nullptr) { + return; + } + const auto* pixels = static_cast(surface->pixels); + const auto has_ink = [surface, pixels](const SDL_Rect& rect) { + const int right = std::min(surface->w, rect.x + rect.w); + const int bottom = std::min(surface->h, rect.y + rect.h); + for (int y = std::max(0, rect.y); y < bottom; ++y) { + for (int x = std::max(0, rect.x); x < right; ++x) { + if (pixels[static_cast(y) * + static_cast(surface->pitch) + + static_cast(x) * 4 + 3] != 0) { + return true; + } + } + } + return false; + }; + TTF_SubString substring{}; + bool found = TTF_GetTextSubString(layout, 0, &substring); + while (found && (substring.flags & TTF_SUBSTRING_TEXT_END) == 0) { + // Single-byte clusters are ASCII, which never comes from a color font. + if (substring.length > 1 && substring.offset >= 0 && + static_cast(substring.offset) + + static_cast(substring.length) <= + text.size() && + !has_ink(substring.rect)) { + SDL_Surface* glyph = TTF_RenderText_Blended( + font, text.data() + substring.offset, + static_cast(substring.length), color); + if (glyph != nullptr) { + SDL_Rect destination{substring.rect.x, substring.rect.y, 0, 0}; + (void)SDL_BlitSurface(glyph, nullptr, surface, &destination); + SDL_DestroySurface(glyph); + } + } + TTF_SubString next{}; + found = TTF_GetNextTextSubString(layout, &substring, &next); + substring = next; + } +} + } // namespace RobloxTextSurfaceOverlay::~RobloxTextSurfaceOverlay() { (void)Shutdown(); } @@ -539,6 +589,11 @@ Status RobloxTextSurfaceOverlay::RasterizeLocked() { TTF_SetTextColor(layout, text_color.r, text_color.g, text_color.b, 255) && TTF_DrawSurfaceText(layout, 0, 0, rendered); + if (success) { + DrawBlankColorClusters( + layout, fonts.front(), text.value, + {text_color.r, text_color.g, text_color.b, 255}, rendered); + } } Status status = Status::Ok();