[windows] wire the focus request and the focus events through the Windows platform (#164296)

## What's changed?
- When a view is focused or unfocused, an event is now sent to the
engine. This makes it so the proper view has focus when the
corresponding window is focused. Thus, global shortcuts now work across
views 🎉
- Applications can request for a particular view to be focused, which
causes the corresponding window to be focused
- Wrote unit tests for all of this

## How To Test
1. Merge `windows/view_focus_event` into
[canonical/foundation](https://github.com/canonical/flutter/tree/foundation)
2. Create a new app:
```dart
import 'dart:ui';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

void main() {
  final RegularWindowController controller1 = RegularWindowController(size: const Size(640, 480));
  final RegularWindowController controller2 = RegularWindowController(size: const Size(640, 480));
  runWidget(ViewCollection(
    views: [
      RegularWindow(controller: controller1, child: MyApp(otherController: controller2)),
      RegularWindow(controller: controller2, child: MyApp(otherController: controller1)),
    ]
  ));
}

class IncrementIntent extends Intent {
  const IncrementIntent();
}

class MyApp extends StatelessWidget {
  const MyApp({super.key, required this.otherController});

  final RegularWindowController otherController;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Shortcut Counter',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: CounterPage(otherController: otherController),
    );
  }
}

class CounterPage extends StatefulWidget {
  const CounterPage({super.key, required this.otherController});

  final RegularWindowController otherController;

  @override
  _CounterPageState createState() => _CounterPageState();
}

class _CounterPageState extends State<CounterPage> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Shortcuts(
      shortcuts: {
        LogicalKeySet(LogicalKeyboardKey.space): const IncrementIntent(),
      },
      child: Actions(
        actions: {
          IncrementIntent: CallbackAction<IncrementIntent>(onInvoke: (intent) {
            _incrementCounter();
            return null;
          }),
        },
        child: Focus(
          autofocus: true,
          child: Scaffold(
            appBar: AppBar(title: const Text('Shortcut Counter')),
            body: Center(
              child: Column(children: [Text(
                'Counter: $_counter',
                style: const TextStyle(fontSize: 24),
              ),
              OutlinedButton(onPressed: () {
                WidgetsBinding.instance.platformDispatcher.requestViewFocusChange(
                  direction: ViewFocusDirection.forward,
                  state: ViewFocusState.focused,
                  viewId: widget.otherController.rootView.viewId,
                );
              }, child: const Text('Focus other window'))]),
            ),
          ),
        ),
      ),
    );
  }
}
```
3. Run with:
```
flutter run --debug --local-engine-src-path C:/dev/flutter/engine/src/ --local-engine host_debug_unopt --local-engine-host host_debug_unopt lib/main.dart  --enable-multi-window
```
4. Pressing spacebar while either window is focused should make the
corresponding counter go up
5. Clicking the button on either window should make the other window
become focused

## 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-06 08:42:52 -05:00 committed by GitHub
parent 9a8b308dcd
commit 099e6d39fe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 172 additions and 1 deletions

View File

@ -185,6 +185,20 @@ void FlutterWindow::SetFlutterCursor(HCURSOR cursor) {
::SetCursor(current_cursor_);
}
bool FlutterWindow::Focus() {
auto hwnd = GetWindowHandle();
if (hwnd == nullptr) {
return false;
}
HWND prevFocus = ::SetFocus(hwnd);
if (prevFocus == nullptr) {
return false;
}
return true;
}
void FlutterWindow::OnDpiScale(unsigned int dpi) {};
// When DesktopWindow notifies that a WM_Size message has come in
@ -377,9 +391,19 @@ void FlutterWindow::OnWindowStateEvent(WindowStateEvent event) {
break;
case WindowStateEvent::kFocus:
focused_ = true;
if (binding_handler_delegate_) {
binding_handler_delegate_->OnFocus(
FlutterViewFocusState::kFocused,
FlutterViewFocusDirection::kUndefined);
}
break;
case WindowStateEvent::kUnfocus:
focused_ = false;
if (binding_handler_delegate_) {
binding_handler_delegate_->OnFocus(
FlutterViewFocusState::kUnfocused,
FlutterViewFocusDirection::kUndefined);
}
break;
}
HWND hwnd = GetWindowHandle();

View File

@ -187,6 +187,9 @@ class FlutterWindow : public KeyboardManager::WindowDelegate,
// |WindowBindingHandler|
virtual ui::AXPlatformNodeWin* GetAlert() override;
// [WindowBindingHandler]
virtual bool Focus() override;
// Called to obtain a pointer to the fragment root delegate.
virtual ui::AXFragmentRootDelegateWin* GetAxFragmentRootDelegate();

View File

@ -18,6 +18,7 @@ namespace testing {
using ::testing::_;
using ::testing::AnyNumber;
using ::testing::Eq;
using ::testing::Invoke;
using ::testing::Return;
@ -372,9 +373,15 @@ TEST_F(FlutterWindowTest, LifecycleFocusMessages) {
win32window.InjectWindowMessage(WM_SIZE, 0, MAKEWORD(1, 1));
EXPECT_EQ(last_event, WindowStateEvent::kShow);
EXPECT_CALL(delegate, OnFocus(Eq(FlutterViewFocusState::kFocused),
Eq(FlutterViewFocusDirection::kUndefined)))
.Times(1);
win32window.InjectWindowMessage(WM_SETFOCUS, 0, 0);
EXPECT_EQ(last_event, WindowStateEvent::kFocus);
EXPECT_CALL(delegate, OnFocus(Eq(FlutterViewFocusState::kUnfocused),
Eq(FlutterViewFocusDirection::kUndefined)))
.Times(1);
win32window.InjectWindowMessage(WM_KILLFOCUS, 0, 0);
EXPECT_EQ(last_event, WindowStateEvent::kUnfocus);
}
@ -407,6 +414,9 @@ TEST_F(FlutterWindowTest, CachedLifecycleMessage) {
}
});
EXPECT_CALL(delegate, OnFocus(Eq(FlutterViewFocusState::kFocused),
Eq(FlutterViewFocusDirection::kUndefined)))
.Times(1);
win32window.SetView(&delegate);
EXPECT_TRUE(focused);
EXPECT_TRUE(restored);

View File

@ -393,6 +393,11 @@ bool FlutterWindowsEngine::Run(std::string_view entrypoint) {
SAFE_ACCESS(update, listening, false));
}
};
args.view_focus_change_request_callback =
[](const FlutterViewFocusChangeRequest* request, void* user_data) {
auto host = static_cast<FlutterWindowsEngine*>(user_data);
host->OnViewFocusChangeRequest(request);
};
args.custom_task_runners = &custom_task_runners;
@ -711,6 +716,13 @@ void FlutterWindowsEngine::SendKeyEvent(const FlutterKeyEvent& event,
}
}
void FlutterWindowsEngine::SendViewFocusEvent(
const FlutterViewFocusEvent& event) {
if (engine_) {
embedder_api_.SendViewFocusEvent(engine_, &event);
}
}
bool FlutterWindowsEngine::SendPlatformMessage(
const char* channel,
const uint8_t* message,
@ -998,6 +1010,19 @@ void FlutterWindowsEngine::OnChannelUpdate(std::string name, bool listening) {
}
}
void FlutterWindowsEngine::OnViewFocusChangeRequest(
const FlutterViewFocusChangeRequest* request) {
std::shared_lock read_lock(views_mutex_);
auto iterator = views_.find(request->view_id);
if (iterator == views_.end()) {
return;
}
FlutterWindowsView* view = iterator->second;
view->Focus();
}
bool FlutterWindowsEngine::Present(const FlutterPresentViewInfo* info) {
// This runs on the raster thread. Lock the views map for the entirety of the
// present operation to block the platform thread from destroying the

View File

@ -185,6 +185,9 @@ class FlutterWindowsEngine {
FlutterKeyEventCallback callback,
void* user_data);
// Informs the engine of an incoming focus event.
void SendViewFocusEvent(const FlutterViewFocusEvent& event);
KeyboardHandlerBase* keyboard_key_handler() {
return keyboard_key_handler_.get();
}
@ -331,6 +334,9 @@ class FlutterWindowsEngine {
// channel.
virtual void OnChannelUpdate(std::string name, bool listening);
virtual void OnViewFocusChangeRequest(
const FlutterViewFocusChangeRequest* request);
private:
// Allows swapping out embedder_api_ calls in tests.
friend class EngineModifier;

View File

@ -99,6 +99,7 @@ TEST_F(FlutterWindowsEngineTest, RunDoesExpectedInitialization) {
EXPECT_NE(args->update_semantics_callback2, nullptr);
EXPECT_EQ(args->update_semantics_node_callback, nullptr);
EXPECT_EQ(args->update_semantics_custom_action_callback, nullptr);
EXPECT_NE(args->view_focus_change_request_callback, nullptr);
args->custom_task_runners->thread_priority_setter(
FlutterThreadPriority::kRaster);
@ -661,6 +662,7 @@ class MockFlutterWindowsView : public FlutterWindowsView {
(ui::AXPlatformNodeWin*, ax::mojom::Event),
(override));
MOCK_METHOD(HWND, GetWindowHandle, (), (const, override));
MOCK_METHOD(bool, Focus, (), (override));
private:
FML_DISALLOW_COPY_AND_ASSIGN(MockFlutterWindowsView);
@ -1346,5 +1348,22 @@ TEST_F(FlutterWindowsEngineTest, MergedUIThread) {
ASSERT_EQ(*ui_thread_id, std::this_thread::get_id());
}
TEST_F(FlutterWindowsEngineTest, OnViewFocusChangeRequest) {
FlutterWindowsEngineBuilder builder{GetContext()};
std::unique_ptr<FlutterWindowsEngine> engine = builder.Build();
auto window_binding_handler =
std::make_unique<::testing::NiceMock<MockWindowBindingHandler>>();
MockFlutterWindowsView view(engine.get(), std::move(window_binding_handler));
EngineModifier modifier(engine.get());
modifier.SetImplicitView(&view);
FlutterViewFocusChangeRequest request;
request.view_id = kImplicitViewId;
EXPECT_CALL(view, Focus()).WillOnce(Return(true));
modifier.OnViewFocusChangeRequest(&request);
}
} // namespace testing
} // namespace flutter

View File

@ -315,6 +315,11 @@ void FlutterWindowsView::OnKey(int key,
SendKey(key, scancode, action, character, extended, was_down, callback);
}
void FlutterWindowsView::OnFocus(FlutterViewFocusState focus_state,
FlutterViewFocusDirection direction) {
SendFocus(focus_state, direction);
}
void FlutterWindowsView::OnComposeBegin() {
SendComposeBegin();
}
@ -368,7 +373,7 @@ void FlutterWindowsView::OnResetImeComposing() {
binding_handler_->OnResetImeComposing();
}
// Sends new size information to FlutterEngine.
// Sends new size information to FlutterEngine.
void FlutterWindowsView::SendWindowMetrics(size_t width,
size_t height,
double pixel_ratio) const {
@ -557,6 +562,16 @@ void FlutterWindowsView::SendKey(int key,
});
}
void FlutterWindowsView::SendFocus(FlutterViewFocusState focus_state,
FlutterViewFocusDirection direction) {
FlutterViewFocusEvent event = {};
event.struct_size = sizeof(event);
event.view_id = view_id_;
event.state = focus_state;
event.direction = direction;
engine_->SendViewFocusEvent(event);
}
void FlutterWindowsView::SendComposeBegin() {
engine_->text_input_plugin()->ComposeBeginHook();
}
@ -826,6 +841,10 @@ void FlutterWindowsView::OnWindowStateEvent(HWND hwnd, WindowStateEvent event) {
engine_->OnWindowStateEvent(hwnd, event);
}
bool FlutterWindowsView::Focus() {
return binding_handler_->Focus();
}
bool FlutterWindowsView::NeedsVsync() const {
// If the Desktop Window Manager composition is enabled,
// the system itself synchronizes with vsync.

View File

@ -190,6 +190,10 @@ class FlutterWindowsView : public WindowBindingHandlerDelegate {
bool was_down,
KeyEventCallback callback) override;
// |WindowBindingHandlerDelegate|
void OnFocus(FlutterViewFocusState focus_state,
FlutterViewFocusDirection direction) override;
// |WindowBindingHandlerDelegate|
void OnComposeBegin() override;
@ -246,6 +250,10 @@ class FlutterWindowsView : public WindowBindingHandlerDelegate {
// |WindowBindingHandlerDelegate|
void OnWindowStateEvent(HWND hwnd, WindowStateEvent event) override;
// Focus the view.
// Returns true if the view was focused.
virtual bool Focus();
protected:
virtual void NotifyWinEventWrapper(ui::AXPlatformNodeWin* node,
ax::mojom::Event event);
@ -352,6 +360,10 @@ class FlutterWindowsView : public WindowBindingHandlerDelegate {
bool was_down,
KeyEventCallback callback);
// Reports a focus event to Flutter engine.
void SendFocus(FlutterViewFocusState focus_state,
FlutterViewFocusDirection direction);
// Reports an IME compose begin event.
//
// Triggered when the user begins editing composing text using a multi-step

View File

@ -1668,5 +1668,38 @@ TEST(FlutterWindowsViewTest, UpdatesVSyncOnDwmUpdates) {
}
}
TEST(FlutterWindowsViewTest, FocusTriggersWindowFocus) {
std::unique_ptr<FlutterWindowsEngine> engine = GetTestEngine();
auto window_binding_handler =
std::make_unique<NiceMock<MockWindowBindingHandler>>();
EXPECT_CALL(*window_binding_handler, Focus()).WillOnce(Return(true));
std::unique_ptr<FlutterWindowsView> view =
engine->CreateView(std::move(window_binding_handler));
EXPECT_TRUE(view->Focus());
}
TEST(FlutterWindowsViewTest, OnFocusTriggersSendFocusViewEvent) {
std::unique_ptr<FlutterWindowsEngine> engine = GetTestEngine();
auto window_binding_handler =
std::make_unique<NiceMock<MockWindowBindingHandler>>();
std::unique_ptr<FlutterWindowsView> view =
engine->CreateView(std::move(window_binding_handler));
EngineModifier modifier(engine.get());
bool received_focus_event = false;
modifier.embedder_api().SendViewFocusEvent = MOCK_ENGINE_PROC(
SendViewFocusEvent, [&](FLUTTER_API_SYMBOL(FlutterEngine) raw_engine,
FlutterViewFocusEvent const* event) {
EXPECT_EQ(event->state, FlutterViewFocusState::kFocused);
EXPECT_EQ(event->direction, FlutterViewFocusDirection::kUndefined);
EXPECT_EQ(event->view_id, view->view_id());
EXPECT_EQ(event->struct_size, sizeof(FlutterViewFocusEvent));
received_focus_event = true;
return kSuccess;
});
view->OnFocus(FlutterViewFocusState::kFocused,
FlutterViewFocusDirection::kUndefined);
EXPECT_TRUE(received_focus_event);
}
} // namespace testing
} // namespace flutter

View File

@ -87,6 +87,10 @@ class EngineModifier {
engine_->platform_view_plugin_ = std::move(manager);
}
void OnViewFocusChangeRequest(const FlutterViewFocusChangeRequest* request) {
engine_->OnViewFocusChangeRequest(request);
}
private:
FlutterWindowsEngine* engine_;

View File

@ -38,6 +38,7 @@ class MockWindowBindingHandler : public WindowBindingHandler {
MOCK_METHOD(PointerLocation, GetPrimaryPointerLocation, (), (override));
MOCK_METHOD(AlertPlatformNodeDelegate*, GetAlertDelegate, (), (override));
MOCK_METHOD(ui::AXPlatformNodeWin*, GetAlert, (), (override));
MOCK_METHOD(bool, Focus, (), (override));
private:
FML_DISALLOW_COPY_AND_ASSIGN(MockWindowBindingHandler);

View File

@ -53,6 +53,10 @@ class MockWindowBindingHandlerDelegate : public WindowBindingHandlerDelegate {
OnKey,
(int, int, int, char32_t, bool, bool, KeyEventCallback),
(override));
MOCK_METHOD(void,
OnFocus,
(FlutterViewFocusState, FlutterViewFocusDirection),
(override));
MOCK_METHOD(void, OnComposeBegin, (), (override));
MOCK_METHOD(void, OnComposeCommit, (), (override));
MOCK_METHOD(void, OnComposeEnd, (), (override));

View File

@ -90,6 +90,10 @@ class WindowBindingHandler {
// Retrieve the alert node.
virtual ui::AXPlatformNodeWin* GetAlert() = 0;
// Focuses the current window.
// Returns true if the window was successfully focused.
virtual bool Focus() = 0;
};
} // namespace flutter

View File

@ -93,6 +93,13 @@ class WindowBindingHandlerDelegate {
bool was_down,
KeyEventCallback callback) = 0;
/// Notifies the delegate that the backing window has received or
/// lost focus.
///
/// Typically called by currently configured WindowBindingHandler.
virtual void OnFocus(FlutterViewFocusState focus_state,
FlutterViewFocusDirection direction) = 0;
// Notifies the delegate that IME composing mode has begun.
//
// Triggered when the user begins editing composing text using a multi-step