diff --git a/img/Main window.png b/img/Main window.png index 84b6b118e..7c4a997f8 100644 Binary files a/img/Main window.png and b/img/Main window.png differ diff --git "a/img/\344\270\273\347\252\227\345\217\243.png" "b/img/\344\270\273\347\252\227\345\217\243.png" index f97dcc2c0..a26c96306 100644 Binary files "a/img/\344\270\273\347\252\227\345\217\243.png" and "b/img/\344\270\273\347\252\227\345\217\243.png" differ diff --git a/src/Magpie.App/App.idl b/src/Magpie.App/App.idl index f5c2f28bc..0fc83cb99 100644 --- a/src/Magpie.App/App.idl +++ b/src/Magpie.App/App.idl @@ -26,6 +26,8 @@ #include "ScalingConfigurationPage.idl" #include "ProfilePage.idl" #include "SettingsPage.idl" +#include "CaptionButtonsControl.idl" +#include "TitleBarControl.idl" namespace Magpie.App { enum ShortcutAction { diff --git a/src/Magpie.App/App.xaml b/src/Magpie.App/App.xaml index 3138f8bc0..b8c040b81 100644 --- a/src/Magpie.App/App.xaml +++ b/src/Magpie.App/App.xaml @@ -17,6 +17,628 @@ + + 12 + + + + + + + diff --git a/src/Magpie.App/IconHelper.cpp b/src/Magpie.App/IconHelper.cpp index 7700d782b..6292f91db 100644 --- a/src/Magpie.App/IconHelper.cpp +++ b/src/Magpie.App/IconHelper.cpp @@ -185,7 +185,6 @@ SoftwareBitmap IconHelper::ExtractIconFormWnd(HWND hWnd, uint32_t preferredSize, } SoftwareBitmap IconHelper::ExtractIconFromExe(const wchar_t* fileName, uint32_t preferredSize, uint32_t dpi) { - preferredSize = (preferredSize + 15) / 16 * 16; preferredSize = (uint32_t)std::lround(preferredSize * dpi / double(USER_DEFAULT_SCREEN_DPI)); { diff --git a/src/Magpie.App/Magpie.App.vcxproj b/src/Magpie.App/Magpie.App.vcxproj index b64cac855..aaa27625c 100644 --- a/src/Magpie.App/Magpie.App.vcxproj +++ b/src/Magpie.App/Magpie.App.vcxproj @@ -128,6 +128,10 @@ CandidateWindowItem.idl Code + + CaptionButtonsControl.xaml + Code + @@ -235,6 +239,10 @@ TextBlockHelper.idl Code + + TitleBarControl.xaml + Code + WrapPanel.idl @@ -275,6 +283,10 @@ CandidateWindowItem.idl Code + + CaptionButtonsControl.xaml + Code + EffectParametersViewModel.idl @@ -381,6 +393,10 @@ TextBlockHelper.idl Code + + TitleBarControl.xaml + Code + WrapPanel.idl @@ -388,6 +404,14 @@ + + CaptionButtonsControl.xaml + Code + + + TitleBarControl.xaml + Code + Designer @@ -500,6 +524,9 @@ Designer + + Designer + Designer @@ -533,7 +560,7 @@ Designer - + Designer diff --git a/src/Magpie.App/Magpie.App.vcxproj.filters b/src/Magpie.App/Magpie.App.vcxproj.filters index 0bbbf1677..02d401c0a 100644 --- a/src/Magpie.App/Magpie.App.vcxproj.filters +++ b/src/Magpie.App/Magpie.App.vcxproj.filters @@ -224,9 +224,6 @@ Controls - - Styles - Controls @@ -245,6 +242,12 @@ Styles + + Controls + + + Controls + diff --git a/src/Magpie.App/MainPage.cpp b/src/Magpie.App/MainPage.cpp index 5e30cf6fb..9664e9fd6 100644 --- a/src/Magpie.App/MainPage.cpp +++ b/src/Magpie.App/MainPage.cpp @@ -76,7 +76,7 @@ void MainPage::InitializeComponent() { MUXC::BackdropMaterial::SetApplyToRootOrPageBackground(*this, true); } - IVector navMenuItems = __super::RootNavigationView().MenuItems(); + IVector navMenuItems = RootNavigationView().MenuItems(); for (const Profile& profile : AppSettings::Get().Profiles()) { MUXC::NavigationViewItem item; item.Content(box_value(profile.name)); @@ -86,28 +86,14 @@ void MainPage::InitializeComponent() { navMenuItems.InsertAt(navMenuItems.Size() - 1, item); } - - // Win10 里启动时有一个 ToggleSwitch 的动画 bug,这里展示页面切换动画掩盖 - if (!osVersion.IsWin11()) { - ContentFrame().Navigate(winrt::xaml_typename()); - } } void MainPage::Loaded(IInspectable const&, RoutedEventArgs const&) { - MUXC::NavigationView nv = __super::RootNavigationView(); - - if (nv.DisplayMode() == MUXC::NavigationViewDisplayMode::Minimal) { - nv.IsPaneOpen(true); - } + MUXC::NavigationView nv = RootNavigationView(); // 修复 WinUI 的汉堡菜单的尺寸 bug nv.PaneDisplayMode(MUXC::NavigationViewPaneDisplayMode::Auto); - // 消除焦点框 - IsTabStop(true); - Focus(FocusState::Programmatic); - IsTabStop(false); - // 设置 NavigationView 内的 Tooltip 的主题 XamlUtils::UpdateThemeOfTooltips(*this, ActualTheme()); } @@ -119,7 +105,7 @@ void MainPage::NavigationView_SelectionChanged( auto contentFrame = ContentFrame(); if (args.IsSettingsSelected()) { - contentFrame.Navigate(winrt::xaml_typename()); + contentFrame.Navigate(xaml_typename()); } else { IInspectable selectedItem = args.SelectedItem(); if (!selectedItem) { @@ -132,22 +118,22 @@ void MainPage::NavigationView_SelectionChanged( hstring tagStr = unbox_value(tag); Interop::TypeName typeName; if (tagStr == L"Home") { - typeName = winrt::xaml_typename(); + typeName = xaml_typename(); } else if (tagStr == L"ScalingConfiguration") { - typeName = winrt::xaml_typename(); + typeName = xaml_typename(); } else if (tagStr == L"About") { - typeName = winrt::xaml_typename(); + typeName = xaml_typename(); } else { - typeName = winrt::xaml_typename(); + typeName = xaml_typename(); } contentFrame.Navigate(typeName); } else { // 缩放配置页面 - MUXC::NavigationView nv = __super::RootNavigationView(); + MUXC::NavigationView nv = RootNavigationView(); uint32_t index; if (nv.MenuItems().IndexOf(nv.SelectedItem(), index)) { - contentFrame.Navigate(winrt::xaml_typename(), box_value((int)index - 4)); + contentFrame.Navigate(xaml_typename(), box_value((int)index - 4)); } } } @@ -163,7 +149,7 @@ void MainPage::NavigationView_PaneOpening(MUXC::NavigationView const&, IInspecta // UpdateThemeOfTooltips 中使用的 hack 会使 NavigationViewItem 在展开时不会自动删除 Tooltip // 因此这里手动删除 - const MUXC::NavigationView& nv = __super::RootNavigationView(); + const MUXC::NavigationView& nv = RootNavigationView(); for (const IInspectable& item : nv.MenuItems()) { ToolTipService::SetToolTip(item.as(), nullptr); } @@ -176,7 +162,18 @@ void MainPage::NavigationView_PaneClosing(MUXC::NavigationView const&, MUXC::Nav XamlUtils::UpdateThemeOfTooltips(*this, ActualTheme()); } -void MainPage::NavigationView_DisplayModeChanged(MUXC::NavigationView const&, MUXC::NavigationViewDisplayModeChangedEventArgs const&) { +void MainPage::NavigationView_DisplayModeChanged(MUXC::NavigationView const& nv, MUXC::NavigationViewDisplayModeChangedEventArgs const&) { + bool isExpanded = nv.DisplayMode() == MUXC::NavigationViewDisplayMode::Expanded; + nv.IsPaneToggleButtonVisible(!isExpanded); + if (isExpanded) { + nv.IsPaneOpen(true); + } + + // HACK! + // 使导航栏的可滚动区域不会覆盖标题栏 + FrameworkElement menuItemsScrollViewer = nv.GetTemplateChild(L"MenuItemsScrollViewer").as(); + menuItemsScrollViewer.Margin({ 0,isExpanded ? TitleBar().ActualHeight() : 0.0,0,0}); + XamlUtils::UpdateThemeOfTooltips(*this, ActualTheme()); } @@ -188,8 +185,6 @@ fire_and_forget MainPage::NavigationView_ItemInvoked(MUXC::NavigationView const& // 同步调用 ShowAt 有时会失败 co_await Dispatcher().TryRunAsync(CoreDispatcherPriority::Normal, [this]() { - // 仅限 Win10:导航栏处于 Minimal 状态时会导致 Flyout 不在正确位置弹出 - // 有一个修复方法,但会导致性能损失 NewProfileFlyout().ShowAt(NewProfileNavigationViewItem()); }); } @@ -354,7 +349,7 @@ void MainPage::_ProfileService_ProfileAdded(Profile& profile) { item.Icon(FontIcon()); _LoadIcon(item, profile); - IVector navMenuItems = __super::RootNavigationView().MenuItems(); + IVector navMenuItems = RootNavigationView().MenuItems(); navMenuItems.InsertAt(navMenuItems.Size() - 1, item); RootNavigationView().SelectedItem(item); } diff --git a/src/Magpie.App/MainPage.h b/src/Magpie.App/MainPage.h index fc2b803d2..cc3c3ee53 100644 --- a/src/Magpie.App/MainPage.h +++ b/src/Magpie.App/MainPage.h @@ -22,7 +22,7 @@ struct MainPage : MainPageT { void NavigationView_PaneClosing(MUXC::NavigationView const&, MUXC::NavigationViewPaneClosingEventArgs const&); - void NavigationView_DisplayModeChanged(MUXC::NavigationView const&, MUXC::NavigationViewDisplayModeChangedEventArgs const&); + void NavigationView_DisplayModeChanged(MUXC::NavigationView const& nv, MUXC::NavigationViewDisplayModeChangedEventArgs const&); fire_and_forget NavigationView_ItemInvoked(MUXC::NavigationView const&, MUXC::NavigationViewItemInvokedEventArgs const& args); diff --git a/src/Magpie.App/MainPage.idl b/src/Magpie.App/MainPage.idl index 2fa3f00f9..a252bc721 100644 --- a/src/Magpie.App/MainPage.idl +++ b/src/Magpie.App/MainPage.idl @@ -3,6 +3,8 @@ namespace Magpie.App { MainPage(); Microsoft.UI.Xaml.Controls.NavigationView RootNavigationView { get; }; + TitleBarControl TitleBar { get; }; + NewProfileViewModel NewProfileViewModel { get; }; void NavigateToAboutPage(); diff --git a/src/Magpie.App/MainPage.xaml b/src/Magpie.App/MainPage.xaml index 0f85f2a58..73d057332 100644 --- a/src/Magpie.App/MainPage.xaml +++ b/src/Magpie.App/MainPage.xaml @@ -7,151 +7,155 @@ xmlns:muxc="using:Microsoft.UI.Xaml.Controls" Loaded="Loaded" mc:Ignorable="d"> - - - - - + + + + + 0 + 1,0,0,0 + - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + ItemsSource="{x:Bind NewProfileViewModel.CandidateWindows, Mode=OneWay}" + SelectedIndex="{x:Bind NewProfileViewModel.CandidateWindowIndex, Mode=TwoWay}"> - - + + + + + + + + + + + + + + + + + + + + + + + + - - + + + diff --git a/src/Magpie.App/ShortcutDialog.idl b/src/Magpie.App/ShortcutDialog.idl index bd94b6468..a4f25c1fa 100644 --- a/src/Magpie.App/ShortcutDialog.idl +++ b/src/Magpie.App/ShortcutDialog.idl @@ -1,5 +1,5 @@ namespace Magpie.App { - runtimeclass ShortcutDialog : Windows.UI.Xaml.Controls.UserControl { + runtimeclass ShortcutDialog : Windows.UI.Xaml.Controls.Grid { ShortcutDialog(); ShortcutError Error; diff --git a/src/Magpie.App/ShortcutDialog.xaml b/src/Magpie.App/ShortcutDialog.xaml index 9321b490b..be25efd8b 100644 --- a/src/Magpie.App/ShortcutDialog.xaml +++ b/src/Magpie.App/ShortcutDialog.xaml @@ -1,82 +1,82 @@ - - - - - - - + - + + + + + - - - - - - - - - - - - + - - - - - - - - + + + + + + + + + + + + - + + + + + + + + - - - - - - - - + + + + + + + + + diff --git a/src/Magpie.App/TitlebarControl.cpp b/src/Magpie.App/TitlebarControl.cpp new file mode 100644 index 000000000..dfdeb4e32 --- /dev/null +++ b/src/Magpie.App/TitlebarControl.cpp @@ -0,0 +1,41 @@ +#include "pch.h" +#include "TitleBarControl.h" +#if __has_include("TitleBarControl.g.cpp") +#include "TitleBarControl.g.cpp" +#endif +#include "IconHelper.h" + +using namespace winrt; +using namespace Windows::UI::Xaml::Media::Imaging; + +namespace winrt::Magpie::App::implementation { + +TitleBarControl::TitleBarControl() { + // 异步加载 Logo + [](TitleBarControl* that)->fire_and_forget { + wchar_t exePath[MAX_PATH]; + GetModuleFileName(NULL, exePath, MAX_PATH); + + auto weakThis = that->get_weak(); + + SoftwareBitmapSource bitmap; + co_await bitmap.SetBitmapAsync(IconHelper::ExtractIconFromExe(exePath, 40, USER_DEFAULT_SCREEN_DPI)); + + if (!weakThis.get()) { + co_return; + } + + that->_logo = std::move(bitmap); + that->_propertyChangedEvent(*that, PropertyChangedEventArgs(L"Logo")); + }(this); +} + +void TitleBarControl::Loading(FrameworkElement const&, IInspectable const&) { + MUXC::NavigationView rootNavigationView = Application::Current().as().MainPage().RootNavigationView(); + rootNavigationView.DisplayModeChanged([this](const auto&, const auto& args) { + bool expanded = args.DisplayMode() == MUXC::NavigationViewDisplayMode::Expanded; + VisualStateManager::GoToState(*this, expanded ? L"Expanded" : L"Compact", true); + }); +} + +} diff --git a/src/Magpie.App/TitlebarControl.h b/src/Magpie.App/TitlebarControl.h new file mode 100644 index 000000000..8504a8f39 --- /dev/null +++ b/src/Magpie.App/TitlebarControl.h @@ -0,0 +1,33 @@ +#pragma once +#include "TitleBarControl.g.h" + +namespace winrt::Magpie::App::implementation { +struct TitleBarControl : TitleBarControlT { + TitleBarControl(); + + void Loading(FrameworkElement const&, IInspectable const&); + + Imaging::SoftwareBitmapSource Logo() const noexcept { + return _logo; + } + + event_token PropertyChanged(PropertyChangedEventHandler const& value) { + return _propertyChangedEvent.add(value); + } + + void PropertyChanged(event_token const& token) { + _propertyChangedEvent.remove(token); + } + +private: + Imaging::SoftwareBitmapSource _logo{ nullptr }; + event _propertyChangedEvent; +}; +} + +namespace winrt::Magpie::App::factory_implementation { + +struct TitleBarControl : TitleBarControlT { +}; + +} diff --git a/src/Magpie.App/TitlebarControl.idl b/src/Magpie.App/TitlebarControl.idl new file mode 100644 index 000000000..5e0f00c73 --- /dev/null +++ b/src/Magpie.App/TitlebarControl.idl @@ -0,0 +1,8 @@ +namespace Magpie.App { + runtimeclass TitleBarControl : Windows.UI.Xaml.Controls.UserControl, Windows.UI.Xaml.Data.INotifyPropertyChanged { + TitleBarControl(); + + Windows.UI.Xaml.Media.Imaging.SoftwareBitmapSource Logo { get; }; + CaptionButtonsControl CaptionButtons { get; }; + } +} diff --git a/src/Magpie.App/TitlebarControl.xaml b/src/Magpie.App/TitlebarControl.xaml new file mode 100644 index 000000000..21e2e7c96 --- /dev/null +++ b/src/Magpie.App/TitlebarControl.xaml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Magpie.App/ToggleSwitch.xaml b/src/Magpie.App/ToggleSwitch.xaml deleted file mode 100644 index 6d765bbc9..000000000 --- a/src/Magpie.App/ToggleSwitch.xaml +++ /dev/null @@ -1,654 +0,0 @@ - - - - - - - diff --git a/src/Magpie.Core/MagApp.cpp b/src/Magpie.Core/MagApp.cpp index 696d6457b..6e3c4aa41 100644 --- a/src/Magpie.Core/MagApp.cpp +++ b/src/Magpie.Core/MagApp.cpp @@ -425,8 +425,10 @@ bool MagApp::_CreateHostWnd() { } } + // WS_EX_NOREDIRECTIONBITMAP 可以避免 WS_EX_LAYERED 导致的额外内存开销 _hwndHost = CreateWindowEx( - (_options.IsDebugMode() ? 0 : WS_EX_TOPMOST) | WS_EX_NOACTIVATE | WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_TOOLWINDOW, + (_options.IsDebugMode() ? 0 : WS_EX_TOPMOST) | WS_EX_NOACTIVATE + | WS_EX_LAYERED | WS_EX_NOREDIRECTIONBITMAP | WS_EX_TRANSPARENT | WS_EX_TOOLWINDOW, HOST_WINDOW_CLASS_NAME, NULL, // 标题为空,否则会被添加新配置页面列为候选窗口 WS_POPUP, diff --git a/src/Magpie/MainWindow.cpp b/src/Magpie/MainWindow.cpp index 851acc216..e52087c3a 100644 --- a/src/Magpie/MainWindow.cpp +++ b/src/Magpie/MainWindow.cpp @@ -8,15 +8,24 @@ namespace Magpie { bool MainWindow::Create(HINSTANCE hInstance, const RECT& windowRect, bool isMaximized) noexcept { - WNDCLASSEXW wcex{}; - wcex.cbSize = sizeof(wcex); - wcex.lpfnWndProc = _WndProc; - wcex.hInstance = hInstance; - wcex.hIcon = LoadIcon(hInstance, MAKEINTRESOURCE(CommonSharedConstants::IDI_APP)); - wcex.hCursor = LoadCursor(nullptr, IDC_ARROW); - wcex.lpszClassName = CommonSharedConstants::MAIN_WINDOW_CLASS_NAME; + static const int _ = [](HINSTANCE hInstance) { + WNDCLASSEXW wcex{}; + wcex.cbSize = sizeof(wcex); + wcex.lpfnWndProc = _WndProc; + wcex.hInstance = hInstance; + wcex.hIcon = LoadIcon(hInstance, MAKEINTRESOURCE(CommonSharedConstants::IDI_APP)); + wcex.hCursor = LoadCursor(nullptr, IDC_ARROW); + wcex.lpszClassName = CommonSharedConstants::MAIN_WINDOW_CLASS_NAME; + RegisterClassEx(&wcex); - RegisterClassEx(&wcex); + wcex.style = CS_DBLCLKS; + wcex.lpfnWndProc = _TitleBarWndProc; + wcex.hIcon = NULL; + wcex.lpszClassName = CommonSharedConstants::TITLE_BAR_WINDOW_CLASS_NAME; + RegisterClassEx(&wcex); + + return 0; + }(hInstance); // Win11 22H2 中为了使用 Mica 背景需指定 WS_EX_NOREDIRECTIONBITMAP CreateWindowEx( @@ -25,8 +34,8 @@ bool MainWindow::Create(HINSTANCE hInstance, const RECT& windowRect, bool isMaxi L"Magpie", WS_OVERLAPPEDWINDOW, windowRect.left, windowRect.top, windowRect.right, windowRect.bottom, - nullptr, - nullptr, + NULL, + NULL, hInstance, this ); @@ -37,22 +46,87 @@ bool MainWindow::Create(HINSTANCE hInstance, const RECT& windowRect, bool isMaxi _SetContent(winrt::Magpie::App::MainPage()); - // Xaml 控件加载完成后显示主窗口 - _content.Loaded([this, isMaximized](winrt::IInspectable const&, winrt::RoutedEventArgs const&) -> winrt::IAsyncAction { - co_await _content.Dispatcher().RunAsync(winrt::CoreDispatcherPriority::Normal, [hWnd(_hWnd), isMaximized]() { - // 防止窗口显示时背景闪烁 - // https://stackoverflow.com/questions/69715610/how-to-initialize-the-background-color-of-win32-app-to-something-other-than-whit - SetWindowPos(hWnd, NULL, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE); - ShowWindow(hWnd, isMaximized ? SW_SHOWMAXIMIZED : SW_SHOWNORMAL); - Win32Utils::SetForegroundWindow(hWnd); - }); - }); - _content.ActualThemeChanged([this](winrt::FrameworkElement const&, winrt::IInspectable const&) { _UpdateTheme(); }); _UpdateTheme(); + // 窗口尚未显示无法最大化,所以我们设置 _isMaximized 使 XamlWindow 估计 XAML Islands 窗口尺寸。 + // 否则在显示窗口时可能会看到 NavigationView 的导航栏的展开动画。 + _isMaximized = isMaximized; + + // 1. 设置初始 XAML Islands 窗口的尺寸 + // 2. 刷新窗口边框 + // 3. 防止窗口显示时背景闪烁: https://stackoverflow.com/questions/69715610/how-to-initialize-the-background-color-of-win32-app-to-something-other-than-whit + SetWindowPos(_hWnd, NULL, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_FRAMECHANGED); + + // Xaml 控件加载完成后显示主窗口 + _content.Loaded([this, isMaximized](winrt::IInspectable const&, winrt::RoutedEventArgs const&) { + if (isMaximized) { + // ShowWindow(_hWnd, SW_SHOWMAXIMIZED) 会显示错误的动画。因此我们以窗口化显示, + // 但位置和大小都和最大化相同,显示完毕后将状态设为最大化。 + // + // 在此过程中,_isMaximized 始终是 true。 + + // 保存原始窗口化位置 + WINDOWPLACEMENT wp{}; + wp.length = sizeof(wp); + GetWindowPlacement(_hWnd, &wp); + + // 查询最大化窗口位置 + if (HMONITOR hMon = MonitorFromWindow(_hWnd, MONITOR_DEFAULTTONEAREST)) { + MONITORINFO mi{}; + mi.cbSize = sizeof(mi); + GetMonitorInfo(hMon, &mi); + + // 播放窗口显示动画 + SetWindowPos( + _hWnd, + NULL, + mi.rcWork.left, + mi.rcWork.top, + mi.rcMonitor.right - mi.rcMonitor.left, + mi.rcMonitor.bottom - mi.rcMonitor.top, + SWP_NOACTIVATE | SWP_NOZORDER | SWP_SHOWWINDOW + ); + } + + // 将状态设为最大化,也还原了原始的窗口化位置 + wp.showCmd = SW_SHOWMAXIMIZED; + SetWindowPlacement(_hWnd, &wp); + } else { + ShowWindow(_hWnd, SW_SHOWNORMAL); + } + + Win32Utils::SetForegroundWindow(_hWnd); + + _isWindowShown = true; + }); + + // 创建标题栏窗口,它是主窗口的子窗口。我们将它置于 XAML Islands 窗口之上以防止鼠标事件被吞掉 + // + // 出于未知的原因,必须添加 WS_EX_LAYERED 样式才能发挥作用,见 + // https://github.com/microsoft/terminal/blob/0ee2c74cd432eda153f3f3e77588164cde95044f/src/cascadia/WindowsTerminal/NonClientIslandWindow.cpp#L79 + // WS_EX_NOREDIRECTIONBITMAP 可以避免 WS_EX_LAYERED 导致的额外内存开销 + // + // WS_MINIMIZEBOX 和 WS_MAXIMIZEBOX 使得鼠标悬停时显示文字提示,Win11 的贴靠布局不依赖它们 + CreateWindowEx( + WS_EX_LAYERED | WS_EX_NOPARENTNOTIFY | WS_EX_NOREDIRECTIONBITMAP | WS_EX_NOACTIVATE, + CommonSharedConstants::TITLE_BAR_WINDOW_CLASS_NAME, + L"", + WS_CHILD | WS_MINIMIZEBOX | WS_MAXIMIZEBOX, + 0, 0, 0, 0, + _hWnd, + nullptr, + hInstance, + this + ); + SetLayeredWindowAttributes(_hwndTitleBar, 0, 255, LWA_ALPHA); + + _content.TitleBar().SizeChanged([this](winrt::IInspectable const&, winrt::SizeChangedEventArgs const&) { + _ResizeTitleBarWindow(); + }); + return true; } @@ -66,16 +140,59 @@ void MainWindow::Show() const noexcept { LRESULT MainWindow::_MessageHandler(UINT msg, WPARAM wParam, LPARAM lParam) noexcept { switch (msg) { + case WM_SIZE: + { + LRESULT ret = base_type::_MessageHandler(WM_SIZE, wParam, lParam); + _ResizeTitleBarWindow(); + _content.TitleBar().CaptionButtons().IsWindowMaximized(_isMaximized); + return ret; + } case WM_GETMINMAXINFO: { // 设置窗口最小尺寸 MINMAXINFO* mmi = (MINMAXINFO*)lParam; - mmi->ptMinTrackSize = { 500,300 }; + mmi->ptMinTrackSize = { + std::lround(550 * _currentDpi / double(USER_DEFAULT_SCREEN_DPI)), + std::lround(300 * _currentDpi / double(USER_DEFAULT_SCREEN_DPI)) + }; return 0; } + case WM_NCRBUTTONUP: + { + // 我们自己处理标题栏右键,不知为何 DefWindowProc 没有作用 + if (wParam == HTCAPTION) { + HMENU systemMenu = GetSystemMenu(_hWnd, FALSE); + + // 根据窗口状态更新选项 + MENUITEMINFO mii{}; + mii.cbSize = sizeof(MENUITEMINFO); + mii.fMask = MIIM_STATE; + mii.fType = MFT_STRING; + auto setState = [&](UINT item, bool enabled) { + mii.fState = enabled ? MF_ENABLED : MF_DISABLED; + SetMenuItemInfo(systemMenu, item, FALSE, &mii); + }; + setState(SC_RESTORE, _isMaximized); + setState(SC_MOVE, !_isMaximized); + setState(SC_SIZE, !_isMaximized); + setState(SC_MINIMIZE, true); + setState(SC_MAXIMIZE, !_isMaximized); + setState(SC_CLOSE, true); + SetMenuDefaultItem(systemMenu, UINT_MAX, FALSE); + + BOOL cmd = TrackPopupMenu(systemMenu, TPM_RETURNCMD, + GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam), 0, _hWnd, nullptr); + if (cmd != 0) { + PostMessage(_hWnd, WM_SYSCOMMAND, cmd, 0); + } + } + break; + } case WM_DESTROY: { XamlApp::Get().SaveSettings(); + _hwndTitleBar = NULL; + _trackingMouse = false; break; } case CommonSharedConstants::WM_QUIT_MAGPIE: @@ -93,27 +210,227 @@ LRESULT MainWindow::_MessageHandler(UINT msg, WPARAM wParam, LPARAM lParam) noex } void MainWindow::_UpdateTheme() { - const bool isDarkTheme = _content.ActualTheme() == winrt::ElementTheme::Dark; - - if (Win32Utils::GetOSVersion().Is22H2OrNewer()) { - // 设置 Mica 背景 - DWM_SYSTEMBACKDROP_TYPE value = DWMSBT_MAINWINDOW; - DwmSetWindowAttribute(_hWnd, DWMWA_SYSTEMBACKDROP_TYPE, &value, sizeof(value)); - } else { - // 更改背景色以配合主题 - // 背景色在更改窗口大小时会短暂可见 - HBRUSH hbrOld = (HBRUSH)SetClassLongPtr( - _hWnd, - GCLP_HBRBACKGROUND, - (INT_PTR)CreateSolidBrush(isDarkTheme ? - CommonSharedConstants::DARK_TINT_COLOR : CommonSharedConstants::LIGHT_TINT_COLOR)); - if (hbrOld) { - DeleteObject(hbrOld); + XamlWindowT::_SetTheme(_content.ActualTheme() == winrt::ElementTheme::Dark); +} + +LRESULT MainWindow::_TitleBarWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) noexcept { + if (msg == WM_NCCREATE) { + MainWindow* that = (MainWindow*)(((CREATESTRUCT*)lParam)->lpCreateParams); + assert(that && !that->_hwndTitleBar); + that->_hwndTitleBar = hWnd; + SetWindowLongPtr(hWnd, GWLP_USERDATA, (LONG_PTR)that); + } else if (MainWindow* that = (MainWindow*)GetWindowLongPtr(hWnd, GWLP_USERDATA)) { + return that->_TitleBarMessageHandler(msg, wParam, lParam); + } + + return DefWindowProc(hWnd, msg, wParam, lParam); +} + +LRESULT MainWindow::_TitleBarMessageHandler(UINT msg, WPARAM wParam, LPARAM lParam) noexcept { + switch (msg) { + case WM_NCHITTEST: + { + POINT cursorPos{ GET_X_LPARAM(lParam),GET_Y_LPARAM(lParam) }; + ScreenToClient(_hwndTitleBar, &cursorPos); + + RECT titleBarClientRect; + GetClientRect(_hwndTitleBar, &titleBarClientRect); + if (!PtInRect(&titleBarClientRect, cursorPos)) { + // 先检查鼠标是否在窗口内。在标题栏按钮上按下鼠标时我们会捕获光标,从而收到 WM_MOUSEMOVE 和 WM_LBUTTONUP 消息。 + // 它们使用 WM_NCHITTEST 测试鼠标位于哪个区域 + return HTNOWHERE; } - InvalidateRect(_hWnd, nullptr, TRUE); + + if (!_isMaximized && cursorPos.y + (int)_GetTopBorderHeight() < _GetResizeHandleHeight()) { + // 鼠标位于上边框 + return HTTOP; + } + + static const winrt::Size buttonSizeInDips = [this]() { + return _content.TitleBar().CaptionButtons().CaptionButtonSize(); + }(); + + const float buttonWidthInPixels = buttonSizeInDips.Width * _currentDpi / USER_DEFAULT_SCREEN_DPI; + const float buttonHeightInPixels = buttonSizeInDips.Height * _currentDpi / USER_DEFAULT_SCREEN_DPI; + + if (cursorPos.y >= buttonHeightInPixels) { + // 鼠标位于标题按钮下方,如果标题栏很宽,这里也可以拖动 + return HTCAPTION; + } + + // 从右向左检查鼠标是否位于某个标题栏按钮上 + const LONG cursorToRight = titleBarClientRect.right - cursorPos.x; + if (cursorToRight < buttonWidthInPixels) { + return HTCLOSE; + } else if (cursorToRight < buttonWidthInPixels * 2) { + // 支持 Win11 的贴靠布局 + // FIXME: 最大化时贴靠布局的位置不对,目前没有找到解决方案。似乎只适配了系统原生框架和 UWP + return HTMAXBUTTON; + } else if (cursorToRight < buttonWidthInPixels * 3) { + return HTMINBUTTON; + } else { + // 不在任何标题栏按钮上则在可拖拽区域 + return HTCAPTION; + } + } + // 在捕获光标时会收到 + case WM_MOUSEMOVE: + { + POINT cursorPos{ GET_X_LPARAM(lParam),GET_Y_LPARAM(lParam) }; + ClientToScreen(_hwndTitleBar, &cursorPos); + wParam = SendMessage(_hwndTitleBar, WM_NCHITTEST, 0, MAKELPARAM(cursorPos.x, cursorPos.y)); + } + [[fallthrough]]; + case WM_NCMOUSEMOVE: + { + auto captionButtons = _content.TitleBar().CaptionButtons(); + + // 将 hover 状态通知 CaptionButtons。标题栏窗口拦截了 XAML Islands 中的标题栏 + // 控件的鼠标消息,标题栏按钮的状态由我们手动控制。 + switch (wParam) { + case HTTOP: + case HTCAPTION: + { + captionButtons.LeaveButtons(); + + // 将 HTTOP 传给主窗口才能通过上边框调整窗口高度 + return SendMessage(_hWnd, msg, wParam, lParam); + } + case HTMINBUTTON: + case HTMAXBUTTON: + case HTCLOSE: + captionButtons.HoverButton((winrt::Magpie::App::CaptionButton)wParam); + + // 追踪鼠标以确保鼠标离开标题栏时我们能收到 WM_NCMOUSELEAVE 消息,否则无法 + // 可靠的收到这个消息,尤其是在用户快速移动鼠标的时候。 + if (!_trackingMouse && msg == WM_NCMOUSEMOVE) { + TRACKMOUSEEVENT ev{}; + ev.cbSize = sizeof(TRACKMOUSEEVENT); + ev.dwFlags = TME_LEAVE | TME_NONCLIENT; + ev.hwndTrack = _hwndTitleBar; + ev.dwHoverTime = HOVER_DEFAULT; // 不关心 HOVER 消息 + TrackMouseEvent(&ev); + _trackingMouse = true; + } + + break; + default: + captionButtons.LeaveButtons(); + } + break; + } + case WM_NCMOUSELEAVE: + case WM_MOUSELEAVE: + { + // 我们需要检查鼠标是否**真的**离开了标题栏按钮,因为在某些情况下 OS 会错误汇报。 + // 比如:鼠标在关闭按钮上停留了一段时间,系统会显示文字提示,这时按下左键,便会收 + // 到 WM_NCMOUSELEAVE,但此时鼠标并没有离开标题栏按钮 + POINT cursorPos; + GetCursorPos(&cursorPos); + // 先检查鼠标是否在主窗口上,如果正在显示文字提示,会返回 _hwndTitleBar + HWND hwndUnderCursor = WindowFromPoint(cursorPos); + if (hwndUnderCursor != _hWnd && hwndUnderCursor != _hwndTitleBar) { + _content.TitleBar().CaptionButtons().LeaveButtons(); + } else { + // 然后检查鼠标在标题栏上的位置 + LRESULT hit = SendMessage(_hwndTitleBar, WM_NCHITTEST, 0, MAKELPARAM(cursorPos.x, cursorPos.y)); + if (hit != HTMINBUTTON && hit != HTMAXBUTTON && hit != HTCLOSE) { + _content.TitleBar().CaptionButtons().LeaveButtons(); + } + } + + _trackingMouse = false; + break; + } + case WM_NCLBUTTONDOWN: + case WM_NCLBUTTONDBLCLK: + { + // 手动处理标题栏上的点击。如果在标题栏按钮上,则通知 CaptionButtons,否则将消息传递 + // 给主窗口。 + switch (wParam) { + case HTTOP: + case HTCAPTION: + { + // 将 HTTOP 传给主窗口才能通过上边框调整窗口高度 + return SendMessage(_hWnd, msg, wParam, lParam); + } + case HTMINBUTTON: + case HTMAXBUTTON: + case HTCLOSE: + _content.TitleBar().CaptionButtons().PressButton((winrt::Magpie::App::CaptionButton)wParam); + // 在标题栏按钮上按下左键后我们便捕获光标,这样才能在释放时得到通知。注意捕获光标后 + // 便不会再收到 NC 族消息,这就是为什么我们要处理 WM_MOUSEMOVE 和 WM_LBUTTONUP + SetCapture(_hwndTitleBar); + break; + } + return 0; + } + // 在捕获光标时会收到 + case WM_LBUTTONUP: + { + ReleaseCapture(); + + POINT cursorPos{ GET_X_LPARAM(lParam),GET_Y_LPARAM(lParam) }; + ClientToScreen(_hwndTitleBar, &cursorPos); + wParam = SendMessage(_hwndTitleBar, WM_NCHITTEST, 0, MAKELPARAM(cursorPos.x, cursorPos.y)); + } + [[fallthrough]]; + case WM_NCLBUTTONUP: + { + // 处理鼠标在标题栏上释放。如果位于标题栏按钮上,则传递给 CaptionButtons,不在则将消息传递给主窗口 + switch (wParam) { + case HTTOP: + case HTCAPTION: + { + // 在可拖拽区域或上边框释放左键,将此消息传递给主窗口 + _content.TitleBar().CaptionButtons().ReleaseButtons(); + return SendMessage(_hWnd, msg, wParam, lParam); + } + case HTMINBUTTON: + case HTMAXBUTTON: + case HTCLOSE: + // 在标题栏按钮上释放左键 + _content.TitleBar().CaptionButtons().ReleaseButton((winrt::Magpie::App::CaptionButton)wParam); + break; + default: + _content.TitleBar().CaptionButtons().ReleaseButtons(); + } + + return 0; + } + case WM_NCRBUTTONDOWN: + case WM_NCRBUTTONDBLCLK: + case WM_NCRBUTTONUP: + // 不关心右键,将它们传递给主窗口 + return SendMessage(_hWnd, msg, wParam, lParam); } - ThemeHelper::SetWindowTheme(_hWnd, isDarkTheme); + return DefWindowProc(_hwndTitleBar, msg, wParam, lParam); +} + +void MainWindow::_ResizeTitleBarWindow() noexcept { + if (!_hwndTitleBar) { + return; + } + + auto titleBar = _content.TitleBar(); + + // 获取标题栏的边框矩形 + winrt::Rect rect{0.0f, 0.0f, (float)titleBar.ActualWidth(), (float)titleBar.ActualHeight()}; + rect = titleBar.TransformToVisual(_content).TransformBounds(rect); + + const float dpiScale = _currentDpi / float(USER_DEFAULT_SCREEN_DPI); + + // 将标题栏窗口置于 XAML Islands 窗口上方 + SetWindowPos( + _hwndTitleBar, + HWND_TOP, + (int)std::floorf(rect.X * dpiScale), + (int)std::floorf(rect.Y * dpiScale) + _GetTopBorderHeight(), + (int)std::ceilf(rect.Width * dpiScale), + (int)std::floorf(rect.Height * dpiScale + 1), // 不知为何,直接向上取整有时无法遮盖 TitleBarControl + SWP_SHOWWINDOW + ); } } diff --git a/src/Magpie/MainWindow.h b/src/Magpie/MainWindow.h index 81b96cd25..54dd10eba 100644 --- a/src/Magpie/MainWindow.h +++ b/src/Magpie/MainWindow.h @@ -17,7 +17,14 @@ class MainWindow : public XamlWindowT private: void _UpdateTheme(); - bool _isMainWndMaximized = false; + static LRESULT CALLBACK _TitleBarWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) noexcept; + + LRESULT _TitleBarMessageHandler(UINT msg, WPARAM wParam, LPARAM lParam) noexcept; + + void _ResizeTitleBarWindow() noexcept; + + HWND _hwndTitleBar = NULL; + bool _trackingMouse = false; }; } diff --git a/src/Magpie/ThemeHelper.cpp b/src/Magpie/ThemeHelper.cpp index d09bd6818..0ba5eebc7 100644 --- a/src/Magpie/ThemeHelper.cpp +++ b/src/Magpie/ThemeHelper.cpp @@ -45,17 +45,17 @@ void ThemeHelper::Initialize() noexcept { RefreshImmersiveColorPolicyState(); } -void ThemeHelper::SetWindowTheme(HWND hWnd, bool isDark) noexcept { +void ThemeHelper::SetWindowTheme(HWND hWnd, bool darkBorder, bool darkMenu) noexcept { InitApis(); - SetPreferredAppMode(isDark ? PreferredAppMode::ForceDark : PreferredAppMode::ForceLight); - AllowDarkModeForWindow(hWnd, isDark); + SetPreferredAppMode(darkMenu ? PreferredAppMode::ForceDark : PreferredAppMode::ForceLight); + AllowDarkModeForWindow(hWnd, darkMenu); // 使标题栏适应黑暗模式 // build 18985 之前 DWMWA_USE_IMMERSIVE_DARK_MODE 的值不同 // https://github.com/MicrosoftDocs/sdk-api/pull/966/files constexpr const DWORD DWMWA_USE_IMMERSIVE_DARK_MODE_BEFORE_20H1 = 19; - BOOL value = isDark; + BOOL value = darkBorder; DwmSetWindowAttribute( hWnd, Win32Utils::GetOSVersion().Is20H1OrNewer() ? DWMWA_USE_IMMERSIVE_DARK_MODE : DWMWA_USE_IMMERSIVE_DARK_MODE_BEFORE_20H1, @@ -65,19 +65,6 @@ void ThemeHelper::SetWindowTheme(HWND hWnd, bool isDark) noexcept { RefreshImmersiveColorPolicyState(); FlushMenuThemes(); - - const Win32Utils::OSVersion& osVersion = Win32Utils::GetOSVersion(); - if (osVersion.Is22H2OrNewer()) { - return; - } - - LONG_PTR style = GetWindowLongPtr(hWnd, GWL_EXSTYLE); - if (!osVersion.IsWin11()) { - // 在 Win10 上需要更多 hack - SetWindowLongPtr(hWnd, GWL_EXSTYLE, style | WS_EX_LAYERED); - SetLayeredWindowAttributes(hWnd, 0, 254, LWA_ALPHA); - } - SetWindowLongPtr(hWnd, GWL_EXSTYLE, style); } } diff --git a/src/Magpie/ThemeHelper.h b/src/Magpie/ThemeHelper.h index 19ac75671..a31f5fc6e 100644 --- a/src/Magpie/ThemeHelper.h +++ b/src/Magpie/ThemeHelper.h @@ -5,7 +5,7 @@ namespace Magpie { struct ThemeHelper { // 应用程序启动时调用一次 static void Initialize() noexcept; - static void SetWindowTheme(HWND hWnd, bool isDark) noexcept; + static void SetWindowTheme(HWND hWnd, bool darkBorder, bool darkMenu) noexcept; }; } diff --git a/src/Magpie/XamlWindow.h b/src/Magpie/XamlWindow.h index d465a8fe4..604a2f330 100644 --- a/src/Magpie/XamlWindow.h +++ b/src/Magpie/XamlWindow.h @@ -2,6 +2,11 @@ #include #include #include "XamlUtils.h" +#include "Win32Utils.h" +#include "ThemeHelper.h" +#include "CommonSharedConstants.h" + +#pragma comment(lib, "uxtheme.lib") namespace Magpie { @@ -92,13 +97,212 @@ class XamlWindowT { sender.NavigateFocus(args.Request()); } }); + } + + void _SetTheme(bool isDarkTheme) noexcept { + _isDarkTheme = isDarkTheme; - // 防止第一次收到 WM_SIZE 消息时 MainPage 尺寸为 0 - _OnResize(); + // Win10 中即使在亮色主题下我们也使用暗色边框,这也是 UWP 窗口的行为 + ThemeHelper::SetWindowTheme( + _hWnd, + Win32Utils::GetOSVersion().IsWin11() ? isDarkTheme : true, + isDarkTheme + ); + + if (Win32Utils::GetOSVersion().Is22H2OrNewer()) { + // 设置 Mica 背景 + DWM_SYSTEMBACKDROP_TYPE value = DWMSBT_MAINWINDOW; + DwmSetWindowAttribute(_hWnd, DWMWA_SYSTEMBACKDROP_TYPE, &value, sizeof(value)); + return; + } + + if (Win32Utils::GetOSVersion().IsWin11()) { + // Win11 21H1/21H2 对 Mica 的支持不完善,改为使用纯色背景。Win10 在 WM_PAINT 中 + // 绘制背景。背景色在更改窗口大小时会短暂可见。 + HBRUSH hbrOld = (HBRUSH)SetClassLongPtr( + _hWnd, + GCLP_HBRBACKGROUND, + (INT_PTR)CreateSolidBrush(isDarkTheme ? + CommonSharedConstants::DARK_TINT_COLOR : CommonSharedConstants::LIGHT_TINT_COLOR)); + if (hbrOld) { + DeleteObject(hbrOld); + } + } + + // 立即重新绘制 + InvalidateRect(_hWnd, nullptr, FALSE); + UpdateWindow(_hWnd); } LRESULT _MessageHandler(UINT msg, WPARAM wParam, LPARAM lParam) noexcept { switch (msg) { + case WM_CREATE: + { + _currentDpi = GetDpiForWindow(_hWnd); + + _UpdateFrameMargins(); + + if (!Win32Utils::GetOSVersion().IsWin11()) { + // 初始化双缓冲绘图 + static const int _ = []() { + BufferedPaintInit(); + return 0; + }(); + } + + break; + } + case WM_NCCALCSIZE: + { + // 移除标题栏的逻辑基本来自 Windows Terminal + // https://github.com/microsoft/terminal/blob/0ee2c74cd432eda153f3f3e77588164cde95044f/src/cascadia/WindowsTerminal/NonClientIslandWindow.cpp + + if (!wParam) { + return 0; + } + + NCCALCSIZE_PARAMS* params = (NCCALCSIZE_PARAMS*)lParam; + RECT& clientRect = params->rgrc[0]; + + // 保存原始上边框位置 + const LONG originalTop = clientRect.top; + + // 应用默认边框 + LRESULT ret = DefWindowProc(_hWnd, WM_NCCALCSIZE, wParam, lParam); + if (ret != 0) { + return ret; + } + + // 重新应用原始上边框,因此我们完全移除了默认边框中的上边框和标题栏,但保留了其他方向的边框 + clientRect.top = originalTop; + + // WM_NCCALCSIZE 在 WM_SIZE 前 + _UpdateMaximizedState(); + + if (_isMaximized) { + // 最大化的窗口的实际尺寸比屏幕的工作区更大一点,这是为了将可调整窗口大小的区域隐藏在屏幕外面 + clientRect.top += _GetResizeHandleHeight(); + + // 如果有自动隐藏的任务栏,我们在它的方向稍微减小客户区,这样用户就可以用鼠标呼出任务栏 + if (HMONITOR hMon = MonitorFromWindow(_hWnd, MONITOR_DEFAULTTONEAREST)) { + MONITORINFO monInfo{}; + monInfo.cbSize = sizeof(MONITORINFO); + GetMonitorInfo(hMon, &monInfo); + + // 检查是否有自动隐藏的任务栏 + APPBARDATA appBarData{}; + appBarData.cbSize = sizeof(appBarData); + if (SHAppBarMessage(ABM_GETSTATE, &appBarData) & ABS_AUTOHIDE) { + // 检查显示器的一条边 + auto hasAutohideTaskbar = [&monInfo](UINT edge) -> bool { + APPBARDATA data{}; + data.cbSize = sizeof(data); + data.uEdge = edge; + data.rc = monInfo.rcMonitor; + HWND hTaskbar = (HWND)SHAppBarMessage(ABM_GETAUTOHIDEBAREX, &data); + return hTaskbar != nullptr; + }; + + static constexpr int AUTO_HIDE_TASKBAR_HEIGHT = 2; + + if (hasAutohideTaskbar(ABE_TOP)) { + clientRect.top += AUTO_HIDE_TASKBAR_HEIGHT; + } + if (hasAutohideTaskbar(ABE_BOTTOM)) { + clientRect.bottom -= AUTO_HIDE_TASKBAR_HEIGHT; + } + if (hasAutohideTaskbar(ABE_LEFT)) { + clientRect.left += AUTO_HIDE_TASKBAR_HEIGHT; + } + if (hasAutohideTaskbar(ABE_RIGHT)) { + clientRect.right -= AUTO_HIDE_TASKBAR_HEIGHT; + } + } + } + } + + return 0; + } + case WM_NCHITTEST: + { + // 让 OS 处理左右下三边,由于我们移除了标题栏,上边框会被视为客户区 + LRESULT originalRet = DefWindowProc(_hWnd, WM_NCHITTEST, 0, lParam); + if (originalRet != HTCLIENT) { + return originalRet; + } + + // XAML Islands 和它上面的标题栏窗口都会吞掉鼠标事件,因此能到达这里的唯一机会 + // 是上边框。保险起见做一些额外检查。 + + if (!_isMaximized) { + RECT rcWindow; + GetWindowRect(_hWnd, &rcWindow); + + if (GET_Y_LPARAM(lParam) < rcWindow.top + _GetResizeHandleHeight()) { + return HTTOP; + } + } + + return HTCAPTION; + } + case WM_PAINT: + { + if (Win32Utils::GetOSVersion().IsWin11()) { + break; + } + + PAINTSTRUCT ps{ 0 }; + HDC hdc = BeginPaint(_hWnd, &ps); + if (!hdc) { + return 0; + } + + const int topBorderHeight = (int)_GetTopBorderHeight(); + + // 在顶部绘制黑色实线以显示系统原始边框,见 _UpdateFrameMargins + if (ps.rcPaint.top < topBorderHeight) { + RECT rcTopBorder = ps.rcPaint; + rcTopBorder.bottom = topBorderHeight; + + static HBRUSH hBrush = GetStockBrush(BLACK_BRUSH); + FillRect(hdc, &rcTopBorder, hBrush); + } + + // 绘制客户区,它会在调整窗口尺寸时短暂可见 + if (ps.rcPaint.bottom > topBorderHeight) { + RECT rcRest = ps.rcPaint; + rcRest.top = topBorderHeight; + + static bool isDarkBrush = _isDarkTheme; + static HBRUSH backgroundBrush = CreateSolidBrush(isDarkBrush ? + CommonSharedConstants::DARK_TINT_COLOR : CommonSharedConstants::LIGHT_TINT_COLOR); + + if (isDarkBrush != _isDarkTheme) { + isDarkBrush = _isDarkTheme; + DeleteBrush(backgroundBrush); + backgroundBrush = CreateSolidBrush(isDarkBrush ? + CommonSharedConstants::DARK_TINT_COLOR : CommonSharedConstants::LIGHT_TINT_COLOR); + } + + if (isDarkBrush) { + // 这里我们想要黑色背景而不是原始边框 + // hack 来自 https://github.com/microsoft/terminal/blob/0ee2c74cd432eda153f3f3e77588164cde95044f/src/cascadia/WindowsTerminal/NonClientIslandWindow.cpp#L1030-L1047 + HDC opaqueDc; + BP_PAINTPARAMS params = { sizeof(params), BPPF_NOCLIP | BPPF_ERASE }; + HPAINTBUFFER buf = BeginBufferedPaint(hdc, &rcRest, BPBF_TOPDOWNDIB, ¶ms, &opaqueDc); + if (buf && opaqueDc) { + FillRect(opaqueDc, &rcRest, backgroundBrush); + BufferedPaintSetAlpha(buf, nullptr, 255); + EndBufferedPaint(buf, TRUE); + } + } else { + FillRect(hdc, &rcRest, backgroundBrush); + } + } + + EndPaint(_hWnd, &ps); + return 0; + } case WM_SHOWWINDOW: { if (wParam == TRUE) { @@ -123,6 +327,8 @@ class XamlWindowT { } case WM_DPICHANGED: { + _currentDpi = HIWORD(wParam); + RECT* newRect = (RECT*)lParam; SetWindowPos(_hWnd, NULL, @@ -172,8 +378,10 @@ class XamlWindowT { } case WM_SIZE: { + _UpdateMaximizedState(); + if (wParam != SIZE_MINIMIZED) { - _OnResize(); + _UpdateIslandPosition(LOWORD(lParam), HIWORD(lParam)); if (_hwndXamlIsland) { // 使 ContentDialog 跟随窗口尺寸调整 @@ -192,6 +400,8 @@ class XamlWindowT { } } + _UpdateFrameMargins(); + return 0; } case WM_DESTROY: @@ -205,6 +415,10 @@ class XamlWindowT { _xamlSource = nullptr; _hwndXamlIsland = NULL; + _isMaximized = false; + _isWindowShown = false; + _isDarkTheme = false; + _content = nullptr; _destroyedEvent(); @@ -216,14 +430,82 @@ class XamlWindowT { return DefWindowProc(_hWnd, msg, wParam, lParam); } + uint32_t _GetTopBorderHeight() const noexcept { + static constexpr uint32_t TOP_BORDER_HEIGHT = 1; + + // Win11 或最大化时没有上边框 + return (Win32Utils::GetOSVersion().IsWin11() || _isMaximized) ? 0 : TOP_BORDER_HEIGHT; + } + + int _GetResizeHandleHeight() noexcept { + // 没有 SM_CYPADDEDBORDER + return GetSystemMetricsForDpi(SM_CXPADDEDBORDER, _currentDpi) + + GetSystemMetricsForDpi(SM_CYSIZEFRAME, _currentDpi); + } + HWND _hWnd = NULL; C _content{ nullptr }; + uint32_t _currentDpi = USER_DEFAULT_SCREEN_DPI; + bool _isMaximized = false; + bool _isWindowShown = false; + bool _isDarkTheme = false; + private: - void _OnResize() noexcept { - RECT clientRect; - GetClientRect(_hWnd, &clientRect); - SetWindowPos(_hwndXamlIsland, NULL, 0, 0, clientRect.right - clientRect.left, clientRect.bottom - clientRect.top, SWP_SHOWWINDOW | SWP_NOACTIVATE); + void _UpdateIslandPosition(int width, int height) const noexcept { + if (!IsWindowVisible(_hWnd) && _isMaximized) { + // 初始化过程中此函数会被调用两次。如果窗口以最大化显示,则两次传入的尺寸不一致。第一次 + // 调用此函数时主窗口尚未显示,因此无法最大化,我们必须估算最大化窗口的尺寸。不执行这个 + // 操作可能导致窗口显示时展示 NavigationView 导航展开的动画。 + if (HMONITOR hMon = MonitorFromWindow(_hWnd, MONITOR_DEFAULTTONEAREST)) { + MONITORINFO monInfo{}; + monInfo.cbSize = sizeof(MONITORINFO); + GetMonitorInfo(hMon, &monInfo); + + // 最大化窗口的尺寸为当前屏幕工作区的尺寸 + width = monInfo.rcWork.right - monInfo.rcMonitor.left; + height = monInfo.rcWork.bottom - monInfo.rcMonitor.top; + } + } + + int topBorderHeight = _GetTopBorderHeight(); + + // SWP_NOZORDER 确保 XAML Islands 窗口始终在标题栏窗口下方,否则主窗口在调整大小时会闪烁 + SetWindowPos( + _hwndXamlIsland, + NULL, + 0, + topBorderHeight, + width, + height - topBorderHeight, + SWP_NOACTIVATE | SWP_NOZORDER | SWP_SHOWWINDOW + ); + } + + void _UpdateMaximizedState() noexcept { + // 如果窗口尚未显示,不碰 _isMaximized + if (_isWindowShown) { + _isMaximized = IsMaximized(_hWnd); + } + } + + void _UpdateFrameMargins() const noexcept { + if (Win32Utils::GetOSVersion().IsWin11()) { + return; + } + + MARGINS margins{}; + if (_GetTopBorderHeight() > 0) { + // 在 Win10 中,移除标题栏时上边框也被没了。我们的解决方案是:使用 DwmExtendFrameIntoClientArea + // 将边框扩展到客户区,然后在顶部绘制了一个黑色实线来显示系统原始边框(这种情况下操作系统将黑色视 + // 为透明)。因此我们有**完美**的上边框! + // 见 https://docs.microsoft.com/en-us/windows/win32/dwm/customframe#extending-the-client-frame + // + // 有的软件自己绘制了假的上边框,如 Chromium 系、WinUI 3 等,但窗口失去焦点时边框是半透明的,无法 + // 完美模拟。 + margins.cxLeftWidth = -1; + } + DwmExtendFrameIntoClientArea(_hWnd, &margins); } winrt::event> _destroyedEvent; diff --git a/src/Shared/CommonSharedConstants.h b/src/Shared/CommonSharedConstants.h index 7b521f5ee..cc6696d10 100644 --- a/src/Shared/CommonSharedConstants.h +++ b/src/Shared/CommonSharedConstants.h @@ -2,6 +2,7 @@ struct CommonSharedConstants { static constexpr const wchar_t* MAIN_WINDOW_CLASS_NAME = L"Magpie_Main"; + static constexpr const wchar_t* TITLE_BAR_WINDOW_CLASS_NAME = L"Magpie_TitleBar"; static constexpr const wchar_t* NOTIFY_ICON_WINDOW_CLASS_NAME = L"Magpie_NotifyIcon"; static constexpr const wchar_t* HOTKEY_WINDOW_CLASS_NAME = L"Magpie_Hotkey";