feature: make the text input plugin use the correct view on the Windows platform (#163847)

## What's new?
- Updates the `TextInput.setClient` method to expect a view ID, which is
already being sent up by clients. This makes it so that the IME info
shows on the text input correctly across views 🎉. Also - text input
on windows works properly across views (although it was working before
too)
- Using the view ID in `TextInputPlugin::HandleMethodCall` to resolve
the view
- Update tests to no longer assume that we are using the implicit view
id
- Add two tests to ensure that the view id is set

## What's fixed?
- Partially fixes: https://github.com/flutter/flutter/issues/142845

## Pre-launch Checklist

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel
on [Discord].

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
This commit is contained in:
Matthew Kosarek 2025-03-04 10:22:20 -05:00 committed by GitHub
parent 249e954600
commit 9246b02af1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 202 additions and 23 deletions

View File

@ -2203,7 +2203,7 @@ TEST_F(KeyboardTest, TextInputSubmit) {
tester.InjectPlatformMessage( tester.InjectPlatformMessage(
"flutter/textinput", "TextInput.setClient", "flutter/textinput", "TextInput.setClient",
R"|([108, {"inputAction": "TextInputAction.none"}])|"); R"|([108, {"inputAction": "TextInputAction.none", "viewId": 0}])|");
// Press Enter // Press Enter
tester.InjectKeyboardChanges(std::vector<KeyboardChange>{ tester.InjectKeyboardChanges(std::vector<KeyboardChange>{

View File

@ -43,6 +43,11 @@ class EngineModifier {
engine_->views_[kImplicitViewId] = view; engine_->views_[kImplicitViewId] = view;
} }
/// Associate a view with a view id.
void SetViewById(FlutterWindowsView* view, FlutterViewId viewId) {
engine_->views_[viewId] = view;
}
/// Reset the start_time field that is used to align vsync events. /// Reset the start_time field that is used to align vsync events.
void SetStartTime(uint64_t start_time_nanos) { void SetStartTime(uint64_t start_time_nanos) {
engine_->start_time_ = std::chrono::nanoseconds(start_time_nanos); engine_->start_time_ = std::chrono::nanoseconds(start_time_nanos);

View File

@ -38,6 +38,7 @@ static constexpr char kDeltaEndKey[] = "deltaEnd";
static constexpr char kDeltasKey[] = "deltas"; static constexpr char kDeltasKey[] = "deltas";
static constexpr char kEnableDeltaModel[] = "enableDeltaModel"; static constexpr char kEnableDeltaModel[] = "enableDeltaModel";
static constexpr char kTextInputAction[] = "inputAction"; static constexpr char kTextInputAction[] = "inputAction";
static constexpr char kViewId[] = "viewId";
static constexpr char kTextInputType[] = "inputType"; static constexpr char kTextInputType[] = "inputType";
static constexpr char kTextInputTypeName[] = "name"; static constexpr char kTextInputTypeName[] = "name";
static constexpr char kComposingBaseKey[] = "composingBase"; static constexpr char kComposingBaseKey[] = "composingBase";
@ -216,12 +217,12 @@ void TextInputPlugin::HandleMethodCall(
if (method.compare(kShowMethod) == 0 || method.compare(kHideMethod) == 0) { if (method.compare(kShowMethod) == 0 || method.compare(kHideMethod) == 0) {
// These methods are no-ops. // These methods are no-ops.
} else if (method.compare(kClearClientMethod) == 0) { } else if (method.compare(kClearClientMethod) == 0) {
// TODO(loicsharma): Remove implicit view assumption. FlutterWindowsView* view = engine_->view(view_id_);
// https://github.com/flutter/flutter/issues/142845
FlutterWindowsView* view = engine_->view(kImplicitViewId);
if (view == nullptr) { if (view == nullptr) {
result->Error(kInternalConsistencyError, std::stringstream ss;
"Text input is not available in Windows headless mode"); ss << "Text input is not available because view with view_id=" << view_id_
<< " cannot be found";
result->Error(kInternalConsistencyError, ss.str());
return; return;
} }
if (active_model_ != nullptr && active_model_->composing()) { if (active_model_ != nullptr && active_model_->composing()) {
@ -255,6 +256,15 @@ void TextInputPlugin::HandleMethodCall(
enable_delta_model_json->value.IsBool()) { enable_delta_model_json->value.IsBool()) {
enable_delta_model = enable_delta_model_json->value.GetBool(); enable_delta_model = enable_delta_model_json->value.GetBool();
} }
auto view_id_json = client_config.FindMember(kViewId);
if (view_id_json != client_config.MemberEnd() &&
view_id_json->value.IsInt()) {
view_id_ = view_id_json->value.GetInt();
} else {
result->Error(kBadArgumentError,
"Could not set client, view ID is null.");
return;
}
input_action_ = ""; input_action_ = "";
auto input_action_json = client_config.FindMember(kTextInputAction); auto input_action_json = client_config.FindMember(kTextInputAction);
if (input_action_json != client_config.MemberEnd() && if (input_action_json != client_config.MemberEnd() &&
@ -328,12 +338,12 @@ void TextInputPlugin::HandleMethodCall(
TextRange(composing_base, composing_extent), cursor_offset); TextRange(composing_base, composing_extent), cursor_offset);
} }
} else if (method.compare(kSetMarkedTextRect) == 0) { } else if (method.compare(kSetMarkedTextRect) == 0) {
// TODO(loicsharma): Remove implicit view assumption. FlutterWindowsView* view = engine_->view(view_id_);
// https://github.com/flutter/flutter/issues/142845
FlutterWindowsView* view = engine_->view(kImplicitViewId);
if (view == nullptr) { if (view == nullptr) {
result->Error(kInternalConsistencyError, std::stringstream ss;
"Text input is not available in Windows headless mode"); ss << "Text input is not available because view with view_id=" << view_id_
<< " cannot be found";
result->Error(kInternalConsistencyError, ss.str());
return; return;
} }
if (!method_call.arguments() || method_call.arguments()->IsNull()) { if (!method_call.arguments() || method_call.arguments()->IsNull()) {
@ -359,12 +369,12 @@ void TextInputPlugin::HandleMethodCall(
Rect transformed_rect = GetCursorRect(); Rect transformed_rect = GetCursorRect();
view->OnCursorRectUpdated(transformed_rect); view->OnCursorRectUpdated(transformed_rect);
} else if (method.compare(kSetEditableSizeAndTransform) == 0) { } else if (method.compare(kSetEditableSizeAndTransform) == 0) {
// TODO(loicsharma): Remove implicit view assumption. FlutterWindowsView* view = engine_->view(view_id_);
// https://github.com/flutter/flutter/issues/142845
FlutterWindowsView* view = engine_->view(kImplicitViewId);
if (view == nullptr) { if (view == nullptr) {
result->Error(kInternalConsistencyError, std::stringstream ss;
"Text input is not available in Windows headless mode"); ss << "Text input is not available because view with view_id=" << view_id_
<< " cannot be found";
result->Error(kInternalConsistencyError, ss.str());
return; return;
} }
if (!method_call.arguments() || method_call.arguments()->IsNull()) { if (!method_call.arguments() || method_call.arguments()->IsNull()) {

View File

@ -16,6 +16,7 @@
#include "flutter/shell/platform/common/json_method_codec.h" #include "flutter/shell/platform/common/json_method_codec.h"
#include "flutter/shell/platform/common/text_editing_delta.h" #include "flutter/shell/platform/common/text_editing_delta.h"
#include "flutter/shell/platform/common/text_input_model.h" #include "flutter/shell/platform/common/text_input_model.h"
#include "flutter/shell/platform/embedder/embedder.h"
#include "flutter/shell/platform/windows/keyboard_handler_base.h" #include "flutter/shell/platform/windows/keyboard_handler_base.h"
namespace flutter { namespace flutter {
@ -70,6 +71,9 @@ class TextInputPlugin {
virtual void ComposeChangeHook(const std::u16string& text, int cursor_pos); virtual void ComposeChangeHook(const std::u16string& text, int cursor_pos);
private: private:
// Allows modifying the TextInputPlugin in tests.
friend class TextInputPluginModifier;
// Sends the current state of the given model to the Flutter engine. // Sends the current state of the given model to the Flutter engine.
void SendStateUpdate(const TextInputModel& model); void SendStateUpdate(const TextInputModel& model);
@ -98,6 +102,9 @@ class TextInputPlugin {
// The active client id. // The active client id.
int client_id_; int client_id_;
// The active view id.
FlutterViewId view_id_ = 0;
// The active model. nullptr if not set. // The active model. nullptr if not set.
std::unique_ptr<TextInputModel> active_model_; std::unique_ptr<TextInputModel> active_model_;

View File

@ -20,6 +20,22 @@
#include "gtest/gtest.h" #include "gtest/gtest.h"
namespace flutter { namespace flutter {
class TextInputPluginModifier {
public:
explicit TextInputPluginModifier(TextInputPlugin* text_input_plugin)
: text_input_plugin(text_input_plugin) {}
void SetViewId(FlutterViewId view_id) {
text_input_plugin->view_id_ = view_id;
}
private:
TextInputPlugin* text_input_plugin;
FML_DISALLOW_COPY_AND_ASSIGN(TextInputPluginModifier);
};
namespace testing { namespace testing {
namespace { namespace {
@ -33,6 +49,7 @@ static constexpr int kDefaultClientId = 42;
// Should be identical to constants in text_input_plugin.cc. // Should be identical to constants in text_input_plugin.cc.
static constexpr char kChannelName[] = "flutter/textinput"; static constexpr char kChannelName[] = "flutter/textinput";
static constexpr char kEnableDeltaModel[] = "enableDeltaModel"; static constexpr char kEnableDeltaModel[] = "enableDeltaModel";
static constexpr char kViewId[] = "viewId";
static constexpr char kSetClientMethod[] = "TextInput.setClient"; static constexpr char kSetClientMethod[] = "TextInput.setClient";
static constexpr char kAffinityDownstream[] = "TextAffinity.downstream"; static constexpr char kAffinityDownstream[] = "TextAffinity.downstream";
static constexpr char kTextKey[] = "text"; static constexpr char kTextKey[] = "text";
@ -63,6 +80,7 @@ static std::unique_ptr<rapidjson::Document> EncodedClientConfig(
rapidjson::Value config(rapidjson::kObjectType); rapidjson::Value config(rapidjson::kObjectType);
config.AddMember("inputAction", input_action, allocator); config.AddMember("inputAction", input_action, allocator);
config.AddMember(kEnableDeltaModel, false, allocator); config.AddMember(kEnableDeltaModel, false, allocator);
config.AddMember(kViewId, 456, allocator);
rapidjson::Value type_info(rapidjson::kObjectType); rapidjson::Value type_info(rapidjson::kObjectType);
type_info.AddMember("name", type_name, allocator); type_info.AddMember("name", type_name, allocator);
config.AddMember("inputType", type_info, allocator); config.AddMember("inputType", type_info, allocator);
@ -149,7 +167,20 @@ class TextInputPluginTest : public WindowsTest {
std::move(window)); std::move(window));
EngineModifier modifier{engine_.get()}; EngineModifier modifier{engine_.get()};
modifier.SetImplicitView(view_.get()); modifier.SetViewById(view_.get(), 456);
}
std::unique_ptr<MockFlutterWindowsView> AddViewWithId(int view_id) {
EXPECT_NE(engine_, nullptr);
auto window = std::make_unique<MockWindowBindingHandler>();
EXPECT_CALL(*window, SetView).Times(1);
EXPECT_CALL(*window, GetWindowHandle).WillRepeatedly(Return(nullptr));
auto view = std::make_unique<MockFlutterWindowsView>(engine_.get(),
std::move(window));
EngineModifier modifier{engine_.get()};
modifier.SetViewById(view_.get(), view_id);
return view;
} }
private: private:
@ -194,6 +225,8 @@ TEST_F(TextInputPluginTest, ClearClientResetsComposing) {
BinaryReply reply_handler = [](const uint8_t* reply, size_t reply_size) {}; BinaryReply reply_handler = [](const uint8_t* reply, size_t reply_size) {};
TextInputPlugin handler(&messenger, engine()); TextInputPlugin handler(&messenger, engine());
TextInputPluginModifier modifier(&handler);
modifier.SetViewId(456);
EXPECT_CALL(*view(), OnResetImeComposing()); EXPECT_CALL(*view(), OnResetImeComposing());
@ -224,9 +257,10 @@ TEST_F(TextInputPluginTest, ClearClientRequiresView) {
messenger.SimulateEngineMessage(kChannelName, message->data(), messenger.SimulateEngineMessage(kChannelName, message->data(),
message->size(), reply_handler); message->size(), reply_handler);
EXPECT_EQ(reply, EXPECT_EQ(
"[\"Internal Consistency Error\",\"Text input is not available in " reply,
"Windows headless mode\",null]"); "[\"Internal Consistency Error\",\"Text input is not available because "
"view with view_id=0 cannot be found\",null]");
} }
// Verify that the embedder sends state update messages to the framework during // Verify that the embedder sends state update messages to the framework during
@ -253,6 +287,7 @@ TEST_F(TextInputPluginTest, VerifyComposingSendStateUpdate) {
config.AddMember("inputAction", "done", allocator); config.AddMember("inputAction", "done", allocator);
config.AddMember("inputType", "text", allocator); config.AddMember("inputType", "text", allocator);
config.AddMember(kEnableDeltaModel, false, allocator); config.AddMember(kEnableDeltaModel, false, allocator);
config.AddMember(kViewId, 456, allocator);
arguments->PushBack(config, allocator); arguments->PushBack(config, allocator);
auto message = auto message =
codec.EncodeMethodCall({"TextInput.setClient", std::move(arguments)}); codec.EncodeMethodCall({"TextInput.setClient", std::move(arguments)});
@ -392,6 +427,71 @@ TEST_F(TextInputPluginTest, VerifyInputActionSendDoesNotInsertNewLine) {
messages.front().begin())); messages.front().begin()));
} }
TEST_F(TextInputPluginTest, SetClientRequiresViewId) {
UseEngineWithView();
TestBinaryMessenger messenger([](const std::string& channel,
const uint8_t* message, size_t message_size,
BinaryReply reply) {});
TextInputPlugin handler(&messenger, engine());
auto args = std::make_unique<rapidjson::Document>(rapidjson::kArrayType);
auto& allocator = args->GetAllocator();
args->PushBack(123, allocator); // client_id
rapidjson::Value client_config(rapidjson::kObjectType);
args->PushBack(client_config, allocator);
auto encoded = JsonMethodCodec::GetInstance().EncodeMethodCall(
MethodCall<rapidjson::Document>(kSetClientMethod, std::move(args)));
std::string reply;
BinaryReply reply_handler = [&reply](const uint8_t* reply_bytes,
size_t reply_size) {
reply = std::string(reinterpret_cast<const char*>(reply_bytes), reply_size);
};
EXPECT_TRUE(messenger.SimulateEngineMessage(kChannelName, encoded->data(),
encoded->size(), reply_handler));
EXPECT_EQ(
reply,
"[\"Bad Arguments\",\"Could not set client, view ID is null.\",null]");
}
TEST_F(TextInputPluginTest, SetClientRequiresViewIdToBeInteger) {
UseEngineWithView();
TestBinaryMessenger messenger([](const std::string& channel,
const uint8_t* message, size_t message_size,
BinaryReply reply) {});
TextInputPlugin handler(&messenger, engine());
auto args = std::make_unique<rapidjson::Document>(rapidjson::kArrayType);
auto& allocator = args->GetAllocator();
args->PushBack(123, allocator); // client_id
rapidjson::Value client_config(rapidjson::kObjectType);
client_config.AddMember(kViewId, "Not an integer", allocator); // view_id
args->PushBack(client_config, allocator);
auto encoded = JsonMethodCodec::GetInstance().EncodeMethodCall(
MethodCall<rapidjson::Document>(kSetClientMethod, std::move(args)));
std::string reply;
BinaryReply reply_handler = [&reply](const uint8_t* reply_bytes,
size_t reply_size) {
reply = std::string(reinterpret_cast<const char*>(reply_bytes), reply_size);
};
EXPECT_TRUE(messenger.SimulateEngineMessage(kChannelName, encoded->data(),
encoded->size(), reply_handler));
EXPECT_EQ(
reply,
"[\"Bad Arguments\",\"Could not set client, view ID is null.\",null]");
}
TEST_F(TextInputPluginTest, TextEditingWorksWithDeltaModel) { TEST_F(TextInputPluginTest, TextEditingWorksWithDeltaModel) {
UseEngineWithView(); UseEngineWithView();
@ -413,6 +513,7 @@ TEST_F(TextInputPluginTest, TextEditingWorksWithDeltaModel) {
rapidjson::Value client_config(rapidjson::kObjectType); rapidjson::Value client_config(rapidjson::kObjectType);
client_config.AddMember(kEnableDeltaModel, true, allocator); client_config.AddMember(kEnableDeltaModel, true, allocator);
client_config.AddMember(kViewId, 456, allocator);
args->PushBack(client_config, allocator); args->PushBack(client_config, allocator);
auto encoded = JsonMethodCodec::GetInstance().EncodeMethodCall( auto encoded = JsonMethodCodec::GetInstance().EncodeMethodCall(
@ -479,6 +580,7 @@ TEST_F(TextInputPluginTest, CompositionCursorPos) {
auto& allocator = args->GetAllocator(); auto& allocator = args->GetAllocator();
args->PushBack(123, allocator); // client_id args->PushBack(123, allocator); // client_id
rapidjson::Value client_config(rapidjson::kObjectType); rapidjson::Value client_config(rapidjson::kObjectType);
client_config.AddMember(kViewId, 456, allocator);
args->PushBack(client_config, allocator); args->PushBack(client_config, allocator);
auto encoded = JsonMethodCodec::GetInstance().EncodeMethodCall( auto encoded = JsonMethodCodec::GetInstance().EncodeMethodCall(
MethodCall<rapidjson::Document>(kSetClientMethod, std::move(args))); MethodCall<rapidjson::Document>(kSetClientMethod, std::move(args)));
@ -535,6 +637,8 @@ TEST_F(TextInputPluginTest, TransformCursorRect) {
BinaryReply reply_handler = [](const uint8_t* reply, size_t reply_size) {}; BinaryReply reply_handler = [](const uint8_t* reply, size_t reply_size) {};
TextInputPlugin handler(&messenger, engine()); TextInputPlugin handler(&messenger, engine());
TextInputPluginModifier modifier(&handler);
modifier.SetViewId(456);
auto& codec = JsonMethodCodec::GetInstance(); auto& codec = JsonMethodCodec::GetInstance();
@ -611,9 +715,62 @@ TEST_F(TextInputPluginTest, SetMarkedTextRectRequiresView) {
messenger.SimulateEngineMessage(kChannelName, message->data(), messenger.SimulateEngineMessage(kChannelName, message->data(),
message->size(), reply_handler); message->size(), reply_handler);
EXPECT_EQ(reply, EXPECT_EQ(
"[\"Internal Consistency Error\",\"Text input is not available in " reply,
"Windows headless mode\",null]"); "[\"Internal Consistency Error\",\"Text input is not available because "
"view with view_id=0 cannot be found\",null]");
}
TEST_F(TextInputPluginTest, SetAndUseMultipleClients) {
UseEngineWithView(); // Creates the default view
AddViewWithId(789); // Creates the next view
bool sent_message = false;
TestBinaryMessenger messenger(
[&sent_message](const std::string& channel, const uint8_t* message,
size_t message_size,
BinaryReply reply) { sent_message = true; });
TextInputPlugin handler(&messenger, engine());
auto const set_client_and_send_message = [&](int client_id, int view_id) {
auto args = std::make_unique<rapidjson::Document>(rapidjson::kArrayType);
auto& allocator = args->GetAllocator();
args->PushBack(client_id, allocator); // client_id
rapidjson::Value client_config(rapidjson::kObjectType);
client_config.AddMember(kViewId, view_id, allocator); // view_id
args->PushBack(client_config, allocator);
auto encoded = JsonMethodCodec::GetInstance().EncodeMethodCall(
MethodCall<rapidjson::Document>(kSetClientMethod, std::move(args)));
std::string reply;
BinaryReply reply_handler = [&reply](const uint8_t* reply_bytes,
size_t reply_size) {
reply =
std::string(reinterpret_cast<const char*>(reply_bytes), reply_size);
};
EXPECT_TRUE(messenger.SimulateEngineMessage(
kChannelName, encoded->data(), encoded->size(), reply_handler));
sent_message = false;
handler.ComposeBeginHook();
EXPECT_TRUE(sent_message);
sent_message = false;
handler.ComposeChangeHook(u"4", 1);
EXPECT_TRUE(sent_message);
sent_message = false;
handler.ComposeCommitHook();
EXPECT_FALSE(sent_message);
sent_message = false;
handler.ComposeEndHook();
EXPECT_TRUE(sent_message);
};
set_client_and_send_message(123, 456); // Set and send for the first view
set_client_and_send_message(123, 789); // Set and send for the next view
} }
} // namespace testing } // namespace testing