Files
plezy/windows/runner/flutter_window.cpp
T
edde746 41c92b6324 fix(windows): persist the on-screen rect of a snapped window
Snapping the window with Aero Snap (edge drag, Win+Arrow, Snap Layouts)
and quitting restored it where it sat before the snap. Windows keeps a
snapped window in the "arranged" state: GetWindowPlacement still reports
SW_SHOWNORMAL with the pre-snap rect in rcNormalPosition, so the saved
placement never reflected what was on screen.

Resolve IsWindowArranged from user32 at runtime (exported since Windows
10 1903, no header declaration) and, when the window is arranged, save
GetWindowRect converted to workspace coordinates instead. The same
correction covers the pre-fullscreen placement captured by the native
fullscreen toggle. Relaunch lands a normal window on the snapped rect;
there is no API to re-enter the snapped state.

close #1895
2026-09-05 07:14:59 +02:00

345 lines
14 KiB
C++

#include "flutter_window.h"
#include <optional>
#include "flutter/generated_plugin_registrant.h"
#include "mpv/display_mode_manager.h"
#include "mpv/mpv_plugin.h"
static constexpr wchar_t kWindowPlacementKey[] = L"Software\\Plezy";
static constexpr wchar_t kWindowPlacementValue[] = L"WindowPlacement";
static UINT_PTR g_saveTimerId = 0;
static HWND g_mainHwnd = nullptr;
// When true, WM_WINDOWPOSCHANGED should not persist placement. Used while a
// native fullscreen toggle is in flux so we don't record the fullscreen rect
// as the user's last "normal" placement.
static bool g_suppressPlacementSave = false;
static void SaveWindowPlacement(HWND hwnd);
static void CALLBACK SaveTimerProc(HWND, UINT, UINT_PTR, DWORD) {
if (g_mainHwnd) SaveWindowPlacement(g_mainHwnd);
KillTimer(nullptr, g_saveTimerId);
g_saveTimerId = 0;
}
static void WriteWindowPlacement(const WINDOWPLACEMENT& wp) {
HKEY hKey;
if (RegCreateKeyExW(
HKEY_CURRENT_USER, kWindowPlacementKey, 0, nullptr, REG_OPTION_NON_VOLATILE, KEY_WRITE, nullptr, &hKey,
nullptr) == ERROR_SUCCESS) {
RegSetValueExW(hKey, kWindowPlacementValue, 0, REG_BINARY, reinterpret_cast<const BYTE*>(&wp), sizeof(wp));
RegCloseKey(hKey);
}
}
// GetWindowPlacement with Aero Snap folded in. A snapped window is "arranged":
// Windows keeps the pre-snap rect in rcNormalPosition and still reports
// SW_SHOWNORMAL, so persisting the raw placement restores the window where it
// was *before* the snap. Substitute the on-screen rect instead; there is no
// API to re-enter the snapped state, so relaunch lands a normal window on the
// same rect. IsWindowArranged is exported by user32 since Windows 10 1903 but
// has no header/import-lib declaration; older builds fall through unchanged.
static bool QueryWindowPlacement(HWND hwnd, WINDOWPLACEMENT* wp) {
wp->length = sizeof(*wp);
if (!GetWindowPlacement(hwnd, wp)) return false;
using IsWindowArrangedFn = BOOL(WINAPI*)(HWND);
static const IsWindowArrangedFn is_window_arranged =
reinterpret_cast<IsWindowArrangedFn>(GetProcAddress(GetModuleHandleW(L"user32.dll"), "IsWindowArranged"));
if (!is_window_arranged || IsIconic(hwnd) || IsZoomed(hwnd) || !is_window_arranged(hwnd)) return true;
// rcNormalPosition is in workspace coordinates (offset by the primary work
// area) while GetWindowRect is in screen coordinates; LoadWindowPlacement
// applies the inverse offset.
RECT rect{};
if (!GetWindowRect(hwnd, &rect)) return true;
RECT workArea{};
SystemParametersInfoW(SPI_GETWORKAREA, 0, &workArea, 0);
OffsetRect(&rect, -workArea.left, -workArea.top);
wp->rcNormalPosition = rect;
return true;
}
static void SaveWindowPlacement(HWND hwnd) {
// Never persist a hidden window: the exit path hides the window before its
// multi-second teardown, and a save landing in that gap would record
// SW_HIDE over the user's real show state.
if (!IsWindowVisible(hwnd)) return;
WINDOWPLACEMENT wp{};
if (!QueryWindowPlacement(hwnd, &wp)) return;
WriteWindowPlacement(wp);
}
// Loads the saved WINDOWPLACEMENT and applies it while keeping the window
// hidden; the first-frame callback in OnCreate performs the single show.
// Returns whether the window should be shown maximized.
static bool LoadWindowPlacement(HWND hwnd) {
HKEY hKey;
if (RegOpenKeyExW(HKEY_CURRENT_USER, kWindowPlacementKey, 0, KEY_READ, &hKey) != ERROR_SUCCESS) return false;
WINDOWPLACEMENT wp{};
wp.length = sizeof(wp);
DWORD size = sizeof(wp);
bool wasMaximized = false;
if (RegQueryValueExW(hKey, kWindowPlacementValue, nullptr, nullptr, reinterpret_cast<BYTE*>(&wp), &size) ==
ERROR_SUCCESS &&
size == sizeof(wp)) {
// A window minimized away from the maximized state stores SW_SHOWMINIMIZED
// plus WPF_RESTORETOMAXIMIZED; both spellings mean "maximized" on relaunch.
wasMaximized = wp.showCmd == SW_SHOWMAXIMIZED || (wp.flags & WPF_RESTORETOMAXIMIZED) != 0;
// The saved monitor may be gone (undocked laptop, powered-off TV).
// rcNormalPosition is in workspace coordinates — screen coordinates offset
// by the primary work area — so convert before testing against monitors.
// On a miss, keep the size but fall back to the default creation position
// so the window never restores invisible.
RECT workArea{};
SystemParametersInfoW(SPI_GETWORKAREA, 0, &workArea, 0);
RECT screenRect = wp.rcNormalPosition;
OffsetRect(&screenRect, workArea.left, workArea.top);
if (MonitorFromRect(&screenRect, MONITOR_DEFAULTTONULL) == nullptr) {
RECT current{};
GetWindowRect(hwnd, &current);
const LONG width = wp.rcNormalPosition.right - wp.rcNormalPosition.left;
const LONG height = wp.rcNormalPosition.bottom - wp.rcNormalPosition.top;
wp.rcNormalPosition.left = current.left - workArea.left;
wp.rcNormalPosition.top = current.top - workArea.top;
wp.rcNormalPosition.right = wp.rcNormalPosition.left + width;
wp.rcNormalPosition.bottom = wp.rcNormalPosition.top + height;
}
// Apply hidden. A visible showCmd here would display a blank window at
// the restored spot before Flutter has rendered anything.
wp.showCmd = SW_HIDE;
SetWindowPlacement(hwnd, &wp);
}
RegCloseKey(hKey);
return wasMaximized;
}
static void DebounceSaveWindowPlacement(HWND hwnd) {
g_mainHwnd = hwnd;
if (g_saveTimerId) KillTimer(nullptr, g_saveTimerId);
g_saveTimerId = SetTimer(nullptr, 0, 500, SaveTimerProc);
}
FlutterWindow::FlutterWindow(const flutter::DartProject& project) : project_(project) {}
FlutterWindow::~FlutterWindow() {}
bool FlutterWindow::OnCreate() {
if (!Win32Window::OnCreate()) {
return false;
}
RECT frame = GetClientArea();
// The size here must match the window dimensions to avoid unnecessary surface
// creation / destruction in the startup path.
flutter_controller_ =
std::make_unique<flutter::FlutterViewController>(frame.right - frame.left, frame.bottom - frame.top, project_);
if (!flutter_controller_->engine() || !flutter_controller_->view()) {
return false;
}
RegisterPlugins(flutter_controller_->engine());
OutputDebugStringA("FlutterWindow: About to register MpvPlayerPlugin\n");
MpvPlayerPluginRegisterWithRegistrar(flutter_controller_->engine()->GetRegistrarForPlugin("MpvPlayerPlugin"));
MpvAudioPlayerPluginRegisterWithRegistrar(
flutter_controller_->engine()->GetRegistrarForPlugin("MpvAudioPlayerPlugin"));
OutputDebugStringA("FlutterWindow: MpvPlayerPlugin registered\n");
RegisterWindowChannel();
SetChildContent(flutter_controller_->view()->GetNativeWindow());
HWND hwnd = GetHandle();
bool maximized = LoadWindowPlacement(hwnd);
flutter_controller_->engine()->SetNextFrameCallback(
[this, maximized]() { ::ShowWindow(this->GetHandle(), maximized ? SW_SHOWMAXIMIZED : SW_SHOWNORMAL); });
// Flutter can complete the first frame before the "show window" callback is
// registered. The following call ensures a frame is pending to ensure the
// window is shown. It is a no-op if the first frame hasn't completed yet.
flutter_controller_->ForceRedraw();
return true;
}
void FlutterWindow::OnDestroy() {
// Cancel any pending save timer and save immediately
if (g_saveTimerId) {
KillTimer(nullptr, g_saveTimerId);
g_saveTimerId = 0;
}
// If still fullscreen at shutdown, persist the pre-fullscreen placement
// rather than the fullscreen rect so the next launch restores correctly.
if (is_fullscreen_ && placement_before_fullscreen_.length != 0) {
WriteWindowPlacement(placement_before_fullscreen_);
} else {
SaveWindowPlacement(GetHandle());
}
window_channel_ = nullptr;
if (flutter_controller_) {
flutter_controller_ = nullptr;
}
Win32Window::OnDestroy();
}
LRESULT
FlutterWindow::MessageHandler(HWND hwnd, UINT const message, WPARAM const wparam, LPARAM const lparam) noexcept {
// Give Flutter, including plugins, an opportunity to handle window messages.
if (flutter_controller_) {
std::optional<LRESULT> result = flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, lparam);
if (result) {
return *result;
}
}
switch (message) {
case WM_DISPLAYCHANGE:
// One bounded, serialized retry for a display that may have reconnected.
mpv::DisplayModeManager::RecoverIfNeeded();
break;
case WM_FONTCHANGE:
flutter_controller_->engine()->ReloadSystemFonts();
break;
case WM_WINDOWPOSCHANGED:
// Don't persist placement while fullscreen, mid-toggle, or hidden — the
// rect would overwrite the user's real window position or show state.
if (!is_fullscreen_ && !g_suppressPlacementSave && IsWindowVisible(hwnd)) {
DebounceSaveWindowPlacement(hwnd);
}
break;
}
return Win32Window::MessageHandler(hwnd, message, wparam, lparam);
}
// ---------------------------------------------------------------------------
// Native fullscreen (plezy/window method channel)
//
// Works around window_manager 0.5.1's multi-monitor fullscreen bug where the
// window ends up on the wrong monitor and renders only a fragment of the
// Flutter surface. We:
// 1) Resolve the target monitor by the current window's center point
// (robust across DPI and restore transitions).
// 2) Save WINDOWPLACEMENT + styles once.
// 3) Strip WS_OVERLAPPEDWINDOW and move+size in a single SetWindowPos so
// Flutter only relayouts once, at the final DPI/size.
// 4) On exit, restore styles and WINDOWPLACEMENT, re-maximizing if needed.
// ---------------------------------------------------------------------------
void FlutterWindow::RegisterWindowChannel() {
auto messenger = flutter_controller_->engine()->messenger();
window_channel_ = std::make_unique<flutter::MethodChannel<flutter::EncodableValue>>(
messenger, "plezy/window", &flutter::StandardMethodCodec::GetInstance());
window_channel_->SetMethodCallHandler([this](
const flutter::MethodCall<flutter::EncodableValue>& call,
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result) {
const std::string& name = call.method_name();
if (name == "setFullScreen") {
bool value = false;
if (const auto* args = std::get_if<flutter::EncodableMap>(call.arguments())) {
auto it = args->find(flutter::EncodableValue("isFullScreen"));
if (it != args->end()) {
if (const bool* b = std::get_if<bool>(&it->second)) value = *b;
}
}
SetNativeFullScreen(value);
result->Success();
} else if (name == "isFullScreen") {
result->Success(flutter::EncodableValue(is_fullscreen_));
} else {
result->NotImplemented();
}
});
}
void FlutterWindow::NotifyFullScreenChanged() {
if (!window_channel_) return;
window_channel_->InvokeMethod("onFullScreenChanged", std::make_unique<flutter::EncodableValue>(is_fullscreen_));
}
void FlutterWindow::SetNativeFullScreen(bool fullscreen) {
HWND hwnd = GetHandle();
if (!hwnd) return;
if (fullscreen == is_fullscreen_) return;
g_suppressPlacementSave = true;
// Also cancel any pending save so a WM_WINDOWPOSCHANGED just before the
// toggle doesn't fire SaveTimerProc mid-transition.
if (g_saveTimerId) {
KillTimer(nullptr, g_saveTimerId);
g_saveTimerId = 0;
}
if (fullscreen) {
// Resolve target monitor first — if this fails we bail before mutating
// any saved state, so a subsequent exit call has nothing stale to act on.
RECT wr{};
::GetWindowRect(hwnd, &wr);
// Center point is stable across maximize/straddle cases where
// MonitorFromWindow would resolve to the neighbor monitor.
POINT center{(wr.left + wr.right) / 2, (wr.top + wr.bottom) / 2};
MONITORINFO mi{};
mi.cbSize = sizeof(mi);
if (!::GetMonitorInfoW(::MonitorFromPoint(center, MONITOR_DEFAULTTONEAREST), &mi)) {
g_suppressPlacementSave = false;
return;
}
// Save pre-fullscreen state (showCmd inside the placement carries the
// maximize bit, so no separate flag is needed).
QueryWindowPlacement(hwnd, &placement_before_fullscreen_);
style_before_fullscreen_ = ::GetWindowLongPtr(hwnd, GWL_STYLE);
ex_style_before_fullscreen_ = ::GetWindowLongPtr(hwnd, GWL_EXSTYLE);
// Strip frame/caption. Stripping WS_OVERLAPPEDWINDOW alone is enough to
// make the following SetWindowPos use the given rect exactly — no need
// to ShowWindow(SW_SHOWNORMAL) first (would cause a second relayout).
::SetWindowLongPtr(hwnd, GWL_STYLE, style_before_fullscreen_ & ~WS_OVERLAPPEDWINDOW);
::SetWindowLongPtr(
hwnd, GWL_EXSTYLE,
ex_style_before_fullscreen_ & ~(WS_EX_DLGMODALFRAME | WS_EX_WINDOWEDGE | WS_EX_CLIENTEDGE | WS_EX_STATICEDGE));
const RECT& r = mi.rcMonitor;
::SetWindowPos(
hwnd, HWND_TOP, r.left, r.top, r.right - r.left, r.bottom - r.top,
SWP_FRAMECHANGED | SWP_NOZORDER | SWP_NOACTIVATE);
is_fullscreen_ = true;
} else {
if (style_before_fullscreen_ != 0) {
::SetWindowLongPtr(hwnd, GWL_STYLE, style_before_fullscreen_);
::SetWindowLongPtr(hwnd, GWL_EXSTYLE, ex_style_before_fullscreen_);
}
if (placement_before_fullscreen_.length == sizeof(WINDOWPLACEMENT)) {
WINDOWPLACEMENT wp = placement_before_fullscreen_;
if (wp.showCmd == SW_SHOWMINIMIZED) wp.showCmd = SW_SHOWNORMAL;
::SetWindowPlacement(hwnd, &wp);
}
// Force a frame refresh so restored chrome paints.
::SetWindowPos(
hwnd, nullptr, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED);
is_fullscreen_ = false;
placement_before_fullscreen_ = {};
style_before_fullscreen_ = 0;
ex_style_before_fullscreen_ = 0;
}
g_suppressPlacementSave = false;
NotifyFullScreenChanged();
}