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/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/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(); 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/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; 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));