Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 2 additions & 8 deletions src/runtime/roblox_input_router.cc
Original file line number Diff line number Diff line change
Expand Up @@ -404,15 +404,9 @@ RobloxInputDispatchResult RobloxInputRouter::HandleEvent(
if (const auto* focus =
std::get_if<platform::WindowFocusEvent>(&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;
Expand Down
6 changes: 5 additions & 1 deletion src/runtime/roblox_text_editor.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
55 changes: 55 additions & 0 deletions src/runtime/roblox_text_surface_overlay.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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<const std::uint8_t*>(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<std::size_t>(y) *
static_cast<std::size_t>(surface->pitch) +
static_cast<std::size_t>(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<std::size_t>(substring.offset) +
static_cast<std::size_t>(substring.length) <=
text.size() &&
!has_ink(substring.rect)) {
SDL_Surface* glyph = TTF_RenderText_Blended(
font, text.data() + substring.offset,
static_cast<std::size_t>(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(); }
Expand Down Expand Up @@ -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();
Expand Down
6 changes: 0 additions & 6 deletions src/window/window.cc
Original file line number Diff line number Diff line change
Expand Up @@ -2470,19 +2470,13 @@ 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,
" [input] failed to release pointer after focus loss\n");
}
}
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())) {
Expand Down
19 changes: 0 additions & 19 deletions src/window/window_text_input_owner.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<std::mutex> 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);
{
Expand Down
3 changes: 0 additions & 3 deletions src/window/window_text_input_owner.h
Original file line number Diff line number Diff line change
Expand Up @@ -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_; }
Expand Down Expand Up @@ -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.
Expand Down
7 changes: 4 additions & 3 deletions tests/roblox_input_router_test.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::string>{"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) {
Expand Down
22 changes: 22 additions & 0 deletions tests/roblox_text_editor_test.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
14 changes: 2 additions & 12 deletions tests/window_text_input_owner_test.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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<char>{'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));
Expand Down