From 0135c0a04711e444ad654b2501da867a8b33af5c Mon Sep 17 00:00:00 2001 From: ringotypowriter Date: Sat, 6 Dec 2025 18:09:08 +0800 Subject: [PATCH 1/5] feat: lock window --- lib/pages/clock_page.dart | 64 +++++++++ lib/platform/window_pin_controller.dart | 154 +++++++++++++++++++++- macos/Runner/MainFlutterWindow.swift | 34 +++++ windows/runner/foreground_tracker_win.cpp | 82 ++++++++++++ windows/runner/win32_window.cpp | 12 +- 5 files changed, 339 insertions(+), 7 deletions(-) diff --git a/lib/pages/clock_page.dart b/lib/pages/clock_page.dart index 633c498..234b314 100644 --- a/lib/pages/clock_page.dart +++ b/lib/pages/clock_page.dart @@ -19,6 +19,8 @@ class ClockPage extends ConsumerStatefulWidget { class _ClockPageState extends ConsumerState { bool _isMiniMode = false; bool _isTogglingMiniMode = false; + bool _isLocked = false; + bool _isLocking = false; @override void didChangeDependencies() { @@ -62,6 +64,13 @@ class _ClockPageState extends ConsumerState { final bool success; if (_isMiniMode) { + // 退出mini mode时自动解锁 + if (_isLocked) { + await controller.unlockWindow(); + setState(() { + _isLocked = false; + }); + } success = await controller.exitPinnedMode(); } else { success = await controller.enterPinnedMode(); @@ -79,6 +88,39 @@ class _ClockPageState extends ConsumerState { }); } + Future _toggleLock() async { + if (_isLocking || !_isMiniMode) { + return; + } + + final controller = WindowPinController.instance; + if (!controller.isSupported) { + return; + } + + setState(() { + _isLocking = true; + }); + + final bool success; + if (_isLocked) { + success = await controller.unlockWindow(); + } else { + success = await controller.lockWindow(); + } + + if (!mounted) { + return; + } + + setState(() { + _isLocking = false; + if (success) { + _isLocked = !_isLocked; + } + }); + } + void _handleBack(BuildContext context) { if (Navigator.canPop(context)) { context.pop(); @@ -175,6 +217,28 @@ class _ClockPageState extends ConsumerState { Expanded( child: _buildTimeContent(context, ref, theme, timeTextStyle), ), + // 在底部显示锁定按钮(仅在mini mode下) + if (pinSupported && _isMiniMode) + Align( + alignment: Alignment.bottomRight, + child: Padding( + padding: EdgeInsets.only(top: 8.h, right: 8.w), + child: IconButton( + iconSize: 16, + padding: EdgeInsets.all(8.w), + constraints: BoxConstraints( + minWidth: 44.w, + minHeight: 44.w, + ), + onPressed: _isLocking ? null : _toggleLock, + icon: Icon( + _isLocked ? Icons.lock_outline : Icons.lock_open, + color: theme.colorScheme.onPrimary, + ), + tooltip: _isLocked ? '解锁窗口' : '锁定窗口', + ), + ), + ), ], ), ), diff --git a/lib/platform/window_pin_controller.dart b/lib/platform/window_pin_controller.dart index acfeac8..7fd27f0 100644 --- a/lib/platform/window_pin_controller.dart +++ b/lib/platform/window_pin_controller.dart @@ -12,6 +12,12 @@ typedef _RtEnterPinnedModeDart = int Function(); typedef _RtExitPinnedModeNative = ffi.Int32 Function(); typedef _RtExitPinnedModeDart = int Function(); +typedef _RtLockWindowNative = ffi.Int32 Function(); +typedef _RtLockWindowDart = int Function(); + +typedef _RtUnlockWindowNative = ffi.Int32 Function(); +typedef _RtUnlockWindowDart = int Function(); + /// 控制窗口在 Windows 上的置顶 / 取消置顶行为。 /// /// 通过 FFI 调用 runner 进程中导出的 Win32 函数,实现: @@ -21,6 +27,8 @@ final class WindowPinController { WindowPinController._( this._enterPinnedMode, this._exitPinnedMode, + this._lockWindow, + this._unlockWindow, this._methodChannel, ); @@ -31,6 +39,8 @@ final class WindowPinController { final _RtEnterPinnedModeDart? _enterPinnedMode; final _RtExitPinnedModeDart? _exitPinnedMode; + final _RtLockWindowDart? _lockWindow; + final _RtUnlockWindowDart? _unlockWindow; final MethodChannel? _methodChannel; static WindowPinController _create() { @@ -39,7 +49,7 @@ final class WindowPinController { if (kDebugMode) { debugPrint('[WindowPinController] web platform, using noop'); } - return WindowPinController._(null, null, null); + return WindowPinController._(null, null, null, null, null); } // Windows: 通过 FFI 调用 runner 导出的 C 接口。 @@ -59,7 +69,20 @@ final class WindowPinController { debugPrint('[WindowPinController] Windows FFI functions resolved'); } - return WindowPinController._(enterFn, exitFn, null); + final lockFn = lib + .lookupFunction<_RtLockWindowNative, _RtLockWindowDart>( + 'rt_lock_window', + ); + final unlockFn = lib + .lookupFunction<_RtUnlockWindowNative, _RtUnlockWindowDart>( + 'rt_unlock_window', + ); + + if (kDebugMode) { + debugPrint('[WindowPinController] Windows FFI functions resolved'); + } + + return WindowPinController._(enterFn, exitFn, lockFn, unlockFn, null); } catch (e, st) { AppLogService.instance.logError( _logTag, @@ -68,7 +91,7 @@ final class WindowPinController { if (kDebugMode) { debugPrint('[WindowPinController] lookup failed: $e'); } - return WindowPinController._(null, null, null); + return WindowPinController._(null, null, null, null, null); } } @@ -78,14 +101,14 @@ final class WindowPinController { if (kDebugMode) { debugPrint('[WindowPinController] macOS MethodChannel created'); } - return WindowPinController._(null, null, channel); + return WindowPinController._(null, null, null, null, channel); } // 其他平台暂不支持。 if (kDebugMode) { debugPrint('[WindowPinController] unsupported platform, using noop'); } - return WindowPinController._(null, null, null); + return WindowPinController._(null, null, null, null, null); } bool get isSupported { @@ -93,7 +116,10 @@ final class WindowPinController { return false; } if (Platform.isWindows) { - return _enterPinnedMode != null && _exitPinnedMode != null; + return _enterPinnedMode != null && + _exitPinnedMode != null && + _lockWindow != null && + _unlockWindow != null; } if (Platform.isMacOS) { return _methodChannel != null; @@ -216,4 +242,120 @@ final class WindowPinController { return false; } + + Future lockWindow() async { + if (!isSupported) { + return false; + } + + // Windows FFI 路径。 + final lockFn = _lockWindow; + if (Platform.isWindows && lockFn != null) { + try { + final result = lockFn(); + final ok = result != 0; + if (!ok) { + AppLogService.instance.logError( + _logTag, + 'lockWindow failed with code: $result', + ); + } + return ok; + } catch (e, st) { + AppLogService.instance.logError( + _logTag, + 'lockWindow threw: $e\n$st', + ); + if (kDebugMode) { + debugPrint('[WindowPinController] lockWindow error: $e'); + } + return false; + } + } + + // macOS MethodChannel 路径。 + final channel = _methodChannel; + if (Platform.isMacOS && channel != null) { + try { + final result = + await channel.invokeMethod('lockWindow') ?? false; + if (!result) { + AppLogService.instance.logError( + _logTag, + 'lockWindow(macOS) returned false', + ); + } + return result; + } catch (e, st) { + AppLogService.instance.logError( + _logTag, + 'lockWindow(macOS) threw: $e\n$st', + ); + if (kDebugMode) { + debugPrint('[WindowPinController] lockWindow macOS error: $e'); + } + return false; + } + } + + return false; + } + + Future unlockWindow() async { + if (!isSupported) { + return false; + } + + // Windows FFI 路径。 + final unlockFn = _unlockWindow; + if (Platform.isWindows && unlockFn != null) { + try { + final result = unlockFn(); + final ok = result != 0; + if (!ok) { + AppLogService.instance.logError( + _logTag, + 'unlockWindow failed with code: $result', + ); + } + return ok; + } catch (e, st) { + AppLogService.instance.logError( + _logTag, + 'unlockWindow threw: $e\n$st', + ); + if (kDebugMode) { + debugPrint('[WindowPinController] unlockWindow error: $e'); + } + return false; + } + } + + // macOS MethodChannel 路径。 + final channel = _methodChannel; + if (Platform.isMacOS && channel != null) { + try { + final result = + await channel.invokeMethod('unlockWindow') ?? false; + if (!result) { + AppLogService.instance.logError( + _logTag, + 'unlockWindow(macOS) returned false', + ); + } + return result; + } catch (e, st) { + AppLogService.instance.logError( + _logTag, + 'unlockWindow(macOS) threw: $e\n$st', + ); + if (kDebugMode) { + debugPrint('[WindowPinController] unlockWindow macOS error: $e'); + } + return false; + } + } + + return false; + } } diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift index 41571dd..3b5a168 100644 --- a/macos/Runner/MainFlutterWindow.swift +++ b/macos/Runner/MainFlutterWindow.swift @@ -5,6 +5,7 @@ class MainFlutterWindow: NSWindow { private var foregroundAppStreamHandler: ForegroundAppStreamHandler? private var strokeEventStreamHandler: StrokeEventStreamHandler? private var isPinnedWindow: Bool = false + private var isLockedWindow: Bool = false private var previousFrame: NSRect? private var previousLevel: NSWindow.Level? private var previousCollectionBehavior: NSWindow.CollectionBehavior? @@ -81,6 +82,12 @@ class MainFlutterWindow: NSWindow { case "exitPinnedMode": let ok = self.exitPinnedMode() result(ok) + case "lockWindow": + let ok = self.lockWindow() + result(ok) + case "unlockWindow": + let ok = self.unlockWindow() + result(ok) default: result(FlutterMethodNotImplemented) } @@ -284,6 +291,32 @@ class MainFlutterWindow: NSWindow { return true } + private func lockWindow() -> Bool { + if !isPinnedWindow || isLockedWindow { + return true + } + + self.isMovable = false + self.isMovableByWindowBackground = false + + isLockedWindow = true + NSLog("[RingoTrack] MainFlutterWindow lockWindow") + return true + } + + private func unlockWindow() -> Bool { + if !isPinnedWindow || !isLockedWindow { + return true + } + + self.isMovable = true + self.isMovableByWindowBackground = true + + isLockedWindow = false + NSLog("[RingoTrack] MainFlutterWindow unlockWindow") + return true + } + private func exitPinnedMode() -> Bool { if !isPinnedWindow { return true @@ -321,6 +354,7 @@ class MainFlutterWindow: NSWindow { } isPinnedWindow = false + isLockedWindow = false previousFrame = nil previousLevel = nil previousCollectionBehavior = nil diff --git a/windows/runner/foreground_tracker_win.cpp b/windows/runner/foreground_tracker_win.cpp index fe3687b..ca078d6 100644 --- a/windows/runner/foreground_tracker_win.cpp +++ b/windows/runner/foreground_tracker_win.cpp @@ -163,6 +163,7 @@ __declspec(dllexport) void rt_shutdown_stroke_hook() { UninstallMouseHook(); } namespace { std::atomic g_is_pinned{false}; +std::atomic g_is_locked{false}; HWND g_pinned_hwnd = nullptr; WINDOWPLACEMENT g_prev_placement{}; LONG g_prev_style = 0; @@ -490,10 +491,91 @@ __declspec(dllexport) std::int32_t rt_exit_pinned_mode() { ::ZeroMemory(&g_prev_placement, sizeof(g_prev_placement)); g_prev_style = 0; g_prev_ex_style = 0; + g_is_locked.store(false, std::memory_order_release); return 1; } +// 锁定窗口(禁止移动) +// 返回值:非 0 表示成功,0 表示失败 +__declspec(dllexport) std::int32_t rt_lock_window() { + if (!g_is_pinned.load(std::memory_order_acquire)) { + return 0; // 仅在pinned模式下允许锁定 + } + + if (g_is_locked.load(std::memory_order_acquire)) { + return 1; // 已经锁定 + } + + if (g_pinned_hwnd == nullptr) { + return 0; + } + + HWND hwnd = g_pinned_hwnd; + + // 获取当前窗口样式 + LONG current_style = static_cast(::GetWindowLongPtrW(hwnd, GWL_STYLE)); + + // 移除WS_THICKFRAME和WS_CAPTION以防止用户通过标题栏移动窗口 + // 保留其他样式以确保窗口正常显示 + LONG new_style = current_style; + new_style &= ~WS_THICKFRAME; // 移除可调整大小的边框 + new_style &= ~WS_CAPTION; // 移除标题栏 + + ::SetWindowLongPtrW(hwnd, GWL_STYLE, new_style); + + // 重新应用边框更改 + ::SetWindowPos(hwnd, + nullptr, + 0, + 0, + 0, + 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED); + + g_is_locked.store(true, std::memory_order_release); + return 1; +} + +// 解锁窗口(允许移动) +// 返回值:非 0 表示成功,0 表示失败 +__declspec(dllexport) std::int32_t rt_unlock_window() { + if (!g_is_pinned.load(std::memory_order_acquire)) { + return 0; // 仅在pinned模式下允许解锁 + } + + if (!g_is_locked.load(std::memory_order_acquire)) { + return 1; // 已经解锁 + } + + if (g_pinned_hwnd == nullptr) { + return 0; + } + + HWND hwnd = g_pinned_hwnd; + + // 恢复原始的窗口样式(在pinned模式下) + LONG new_style = g_prev_style; + new_style &= ~WS_CAPTION; // pinned模式下仍然隐藏标题栏 + new_style &= ~WS_THICKFRAME; // pinned模式下仍然隐藏可调整大小的边框 + new_style &= ~WS_MINIMIZEBOX; // pinned模式下隐藏最小化按钮 + new_style &= ~WS_MAXIMIZEBOX; // pinned模式下隐藏最大化按钮 + + ::SetWindowLongPtrW(hwnd, GWL_STYLE, new_style); + + // 重新应用边框更改 + ::SetWindowPos(hwnd, + nullptr, + 0, + 0, + 0, + 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED); + + g_is_locked.store(false, std::memory_order_release); + return 1; +} + // ------------------- 毛玻璃 tint 控制导出(Windows FFI) ------------------- // 根据给定的 RGB 值,为主窗口应用带毛玻璃的背景颜色。 diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp index d8750c5..0c977af 100644 --- a/windows/runner/win32_window.cpp +++ b/windows/runner/win32_window.cpp @@ -216,7 +216,17 @@ Win32Window::MessageHandler(HWND hwnd, client_pos.y >= client_rect.top && client_pos.y <= client_rect.top + kPinSafeHeight; - if (!in_pin_safe_region) { + // 在右下角预留一块区域给 Flutter 内部的 lock 按钮点击, + // 避免把 lock 按钮点击也拦截成拖动。 + constexpr int kLockSafeWidth = 80; + constexpr int kLockSafeHeight = 80; + const bool in_lock_safe_region = + client_pos.x >= client_rect.right - kLockSafeWidth && + client_pos.x <= client_rect.right && + client_pos.y >= client_rect.bottom - kLockSafeHeight && + client_pos.y <= client_rect.bottom; + + if (!in_pin_safe_region && !in_lock_safe_region) { // 告诉系统这是标题栏,系统会自动处理拖动 return HTCAPTION; } From c54005716e0200de1f2c9a340535fad2e21b2828 Mon Sep 17 00:00:00 2001 From: ringotypowriter Date: Sat, 6 Dec 2025 18:18:03 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat=EF=BC=9A=20hud=20auto=20hide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/clock_page.dart | 124 +++++++++++++++++++++++++++----------- 1 file changed, 88 insertions(+), 36 deletions(-) diff --git a/lib/pages/clock_page.dart b/lib/pages/clock_page.dart index 234b314..0ce8eee 100644 --- a/lib/pages/clock_page.dart +++ b/lib/pages/clock_page.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io' show Platform; import 'dart:math' as math; @@ -21,6 +22,8 @@ class _ClockPageState extends ConsumerState { bool _isTogglingMiniMode = false; bool _isLocked = false; bool _isLocking = false; + bool _hudVisible = true; + Timer? _hudHideTimer; @override void didChangeDependencies() { @@ -32,6 +35,7 @@ class _ClockPageState extends ConsumerState { void dispose() { // 离开页面时恢复默认白色 tint(macOS/Windows 都支持更新 tint 颜色) GlassTintController.instance.resetTintColor(); + _hudHideTimer?.cancel(); super.dispose(); } @@ -129,6 +133,39 @@ class _ClockPageState extends ConsumerState { } } + void _resetHudHideTimer() { + _hudHideTimer?.cancel(); + _hudHideTimer = Timer(const Duration(seconds: 5), () { + if (mounted) { + setState(() { + _hudVisible = false; + }); + } + }); + } + + void _handleMouseActivity() { + if (!_hudVisible && mounted) { + setState(() { + _hudVisible = true; + }); + } + _resetHudHideTimer(); + } + + void _handleMouseEnter(PointerEvent event) { + _handleMouseActivity(); + } + + void _handleMouseHover(PointerEvent event) { + _handleMouseActivity(); + } + + void _handleMouseExit(PointerEvent event) { + // 鼠标离开窗口时开始计时隐藏 + _resetHudHideTimer(); + } + @override Widget build(BuildContext context) { final ref = this.ref; @@ -175,41 +212,52 @@ class _ClockPageState extends ConsumerState { return Scaffold( backgroundColor: useGlass ? Colors.transparent : clockBgColor, - body: SafeArea( - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 32.w, vertical: 32.h), + body: MouseRegion( + onEnter: _handleMouseEnter, + onHover: _handleMouseHover, + onExit: _handleMouseExit, + opaque: false, + child: SafeArea( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 32.w, vertical: 32.h), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ if (!_isMiniMode) - IconButton( - onPressed: () => _handleBack(context), - icon: Icon( - Icons.arrow_back_rounded, - color: theme.colorScheme.onPrimary, + Visibility( + visible: _hudVisible, + child: IconButton( + onPressed: () => _handleBack(context), + icon: Icon( + Icons.arrow_back_rounded, + color: theme.colorScheme.onPrimary, + ), + tooltip: '返回仪表盘', ), - tooltip: '返回仪表盘', ), if (_isMiniMode) SizedBox(width: 48.w, height: 48.w), const Spacer(), if (pinSupported) - IconButton( - iconSize: _isMiniMode ? 16 : 24, - padding: EdgeInsets.all(8.w), - constraints: BoxConstraints( - minWidth: 44.w, - minHeight: 44.w, + Visibility( + visible: _hudVisible, + child: IconButton( + iconSize: _isMiniMode ? 16 : 24, + padding: EdgeInsets.all(8.w), + constraints: BoxConstraints( + minWidth: 44.w, + minHeight: 44.w, + ), + onPressed: _isTogglingMiniMode ? null : _toggleMiniMode, + icon: Icon( + _isMiniMode + ? Icons.crop_square + : Icons.crop_square_outlined, + color: theme.colorScheme.onPrimary, + ), + tooltip: _isMiniMode ? '退出迷你模式' : '进入迷你模式', ), - onPressed: _isTogglingMiniMode ? null : _toggleMiniMode, - icon: Icon( - _isMiniMode - ? Icons.crop_square - : Icons.crop_square_outlined, - color: theme.colorScheme.onPrimary, - ), - tooltip: _isMiniMode ? '退出迷你模式' : '进入迷你模式', ), ], ), @@ -223,23 +271,27 @@ class _ClockPageState extends ConsumerState { alignment: Alignment.bottomRight, child: Padding( padding: EdgeInsets.only(top: 8.h, right: 8.w), - child: IconButton( - iconSize: 16, - padding: EdgeInsets.all(8.w), - constraints: BoxConstraints( - minWidth: 44.w, - minHeight: 44.w, - ), - onPressed: _isLocking ? null : _toggleLock, - icon: Icon( - _isLocked ? Icons.lock_outline : Icons.lock_open, - color: theme.colorScheme.onPrimary, + child: Visibility( + visible: _hudVisible, + child: IconButton( + iconSize: 16, + padding: EdgeInsets.all(8.w), + constraints: BoxConstraints( + minWidth: 44.w, + minHeight: 44.w, + ), + onPressed: _isLocking ? null : _toggleLock, + icon: Icon( + _isLocked ? Icons.lock_outline : Icons.lock_open, + color: theme.colorScheme.onPrimary, + ), + tooltip: _isLocked ? '解锁窗口' : '锁定窗口', ), - tooltip: _isLocked ? '解锁窗口' : '锁定窗口', ), ), ), - ], + ], + ), ), ), ), From a6a7f3f821ca822b1515475b6f729b71e71ec641 Mon Sep 17 00:00:00 2001 From: ringotypowriter Date: Sat, 6 Dec 2025 18:38:59 +0800 Subject: [PATCH 3/5] fix: dpi click check --- windows/runner/flutter_window.cpp | 33 ++++++++++++++++++++--- windows/runner/win32_window.cpp | 44 +++++++++++++++++++++++++------ 2 files changed, 65 insertions(+), 12 deletions(-) diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp index c68fca7..3944d25 100644 --- a/windows/runner/flutter_window.cpp +++ b/windows/runner/flutter_window.cpp @@ -1,5 +1,6 @@ #include "flutter_window.h" +#include #include #include @@ -10,6 +11,25 @@ extern "C" int rt_is_pinned(); namespace { +double GetFlutterWindowScaleFactor(HWND hwnd) { + HMONITOR monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST); + if (monitor == nullptr) { + return 1.0; + } + + const UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + if (dpi == 0) { + return 1.0; + } + + return static_cast(dpi) / 96.0; +} + +int ScaleToDpiValue(int source, double scale_factor) { + const int scaled = static_cast(source * scale_factor); + return scaled > 0 ? scaled : 1; +} + // 原始 Flutter View 窗口过程,用于在自定义处理后转发消息。 WNDPROC g_flutter_view_wndproc = nullptr; @@ -34,13 +54,18 @@ LRESULT CALLBACK FlutterViewWindowProc(HWND hwnd, GetClientRect(hwnd, &client_rect); // 在右上角预留一块区域给 Flutter 内部的 pin 按钮点击 - constexpr int kPinSafeWidth = 80; - constexpr int kPinSafeHeight = 80; + constexpr int kPinSafeWidthDip = 80; + constexpr int kPinSafeHeightDip = 80; + const double scale_factor = GetFlutterWindowScaleFactor(hwnd); + const int kPinSafeWidthScaled = + ScaleToDpiValue(kPinSafeWidthDip, scale_factor); + const int kPinSafeHeightScaled = + ScaleToDpiValue(kPinSafeHeightDip, scale_factor); const bool in_pin_safe_region = - client_pos.x >= client_rect.right - kPinSafeWidth && + client_pos.x >= client_rect.right - kPinSafeWidthScaled && client_pos.x <= client_rect.right && client_pos.y >= client_rect.top && - client_pos.y <= client_rect.top + kPinSafeHeight; + client_pos.y <= client_rect.top + kPinSafeHeightScaled; if (!in_pin_safe_region) { // 返回 HTTRANSPARENT,让系统将命中测试传递给父窗口, diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp index 0c977af..e4aa7ff 100644 --- a/windows/runner/win32_window.cpp +++ b/windows/runner/win32_window.cpp @@ -45,6 +45,25 @@ int Scale(int source, double scale_factor) { return static_cast(source * scale_factor); } +double GetWindowScaleFactor(HWND hwnd) { + HMONITOR monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST); + if (monitor == nullptr) { + return 1.0; + } + + const UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + if (dpi == 0) { + return 1.0; + } + + return static_cast(dpi) / 96.0; +} + +int ScaleToDpi(int source, double scale_factor) { + const int scaled = Scale(source, scale_factor); + return scaled > 0 ? scaled : 1; +} + // Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. // This API is only needed for PerMonitor V1 awareness mode. void EnableFullDpiSupportIfAvailable(HWND hwnd) { @@ -208,22 +227,31 @@ Win32Window::MessageHandler(HWND hwnd, // 在右上角预留一块区域给 Flutter 内部的 pin 按钮点击, // 避免把 pin 按钮点击也拦截成拖动。 - constexpr int kPinSafeWidth = 80; - constexpr int kPinSafeHeight = 80; + constexpr int kPinSafeWidthDip = 80; + constexpr int kPinSafeHeightDip = 80; + constexpr int kLockSafeWidthDip = 80; + constexpr int kLockSafeHeightDip = 80; + const double scale_factor = GetWindowScaleFactor(hwnd); + const int kPinSafeWidthScaled = + ScaleToDpi(kPinSafeWidthDip, scale_factor); + const int kPinSafeHeightScaled = + ScaleToDpi(kPinSafeHeightDip, scale_factor); + const int kLockSafeWidthScaled = + ScaleToDpi(kLockSafeWidthDip, scale_factor); + const int kLockSafeHeightScaled = + ScaleToDpi(kLockSafeHeightDip, scale_factor); const bool in_pin_safe_region = - client_pos.x >= client_rect.right - kPinSafeWidth && + client_pos.x >= client_rect.right - kPinSafeWidthScaled && client_pos.x <= client_rect.right && client_pos.y >= client_rect.top && - client_pos.y <= client_rect.top + kPinSafeHeight; + client_pos.y <= client_rect.top + kPinSafeHeightScaled; // 在右下角预留一块区域给 Flutter 内部的 lock 按钮点击, // 避免把 lock 按钮点击也拦截成拖动。 - constexpr int kLockSafeWidth = 80; - constexpr int kLockSafeHeight = 80; const bool in_lock_safe_region = - client_pos.x >= client_rect.right - kLockSafeWidth && + client_pos.x >= client_rect.right - kLockSafeWidthScaled && client_pos.x <= client_rect.right && - client_pos.y >= client_rect.bottom - kLockSafeHeight && + client_pos.y >= client_rect.bottom - kLockSafeHeightScaled && client_pos.y <= client_rect.bottom; if (!in_pin_safe_region && !in_lock_safe_region) { From b5da4240af3f181bf9567527e07e04952322e5fe Mon Sep 17 00:00:00 2001 From: ringotypowriter Date: Sat, 6 Dec 2025 18:49:48 +0800 Subject: [PATCH 4/5] fix: flutter window passthrough --- windows/runner/flutter_window.cpp | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp index 3944d25..761bd80 100644 --- a/windows/runner/flutter_window.cpp +++ b/windows/runner/flutter_window.cpp @@ -56,18 +56,29 @@ LRESULT CALLBACK FlutterViewWindowProc(HWND hwnd, // 在右上角预留一块区域给 Flutter 内部的 pin 按钮点击 constexpr int kPinSafeWidthDip = 80; constexpr int kPinSafeHeightDip = 80; + constexpr int kLockSafeWidthDip = 80; + constexpr int kLockSafeHeightDip = 80; const double scale_factor = GetFlutterWindowScaleFactor(hwnd); const int kPinSafeWidthScaled = ScaleToDpiValue(kPinSafeWidthDip, scale_factor); const int kPinSafeHeightScaled = ScaleToDpiValue(kPinSafeHeightDip, scale_factor); + const int kLockSafeWidthScaled = + ScaleToDpiValue(kLockSafeWidthDip, scale_factor); + const int kLockSafeHeightScaled = + ScaleToDpiValue(kLockSafeHeightDip, scale_factor); const bool in_pin_safe_region = client_pos.x >= client_rect.right - kPinSafeWidthScaled && client_pos.x <= client_rect.right && client_pos.y >= client_rect.top && client_pos.y <= client_rect.top + kPinSafeHeightScaled; + const bool in_lock_safe_region = + client_pos.x >= client_rect.right - kLockSafeWidthScaled && + client_pos.x <= client_rect.right && + client_pos.y >= client_rect.bottom - kLockSafeHeightScaled && + client_pos.y <= client_rect.bottom; - if (!in_pin_safe_region) { + if (!in_pin_safe_region && !in_lock_safe_region) { // 返回 HTTRANSPARENT,让系统将命中测试传递给父窗口, // 父窗口会返回 HTCAPTION,从而触发系统原生拖动。 return HTTRANSPARENT; From c65107a92bb27775e1dd35cccc0f74c8763bf508 Mon Sep 17 00:00:00 2001 From: ringotypowriter Date: Sat, 6 Dec 2025 19:02:19 +0800 Subject: [PATCH 5/5] fix: windows lock hittest --- windows/runner/flutter_window.cpp | 4 +- windows/runner/foreground_tracker_win.cpp | 55 +++++------------------ windows/runner/win32_window.cpp | 4 +- 3 files changed, 18 insertions(+), 45 deletions(-) diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp index 761bd80..71a59d0 100644 --- a/windows/runner/flutter_window.cpp +++ b/windows/runner/flutter_window.cpp @@ -8,6 +8,8 @@ // 来自 foreground_tracker_win.cpp:查询当前是否处于 pinned 模式。 extern "C" int rt_is_pinned(); +// 查询当前是否处于锁定状态(lock 模式)。 +extern "C" int rt_is_locked(); namespace { @@ -42,7 +44,7 @@ LRESULT CALLBACK FlutterViewWindowProc(HWND hwnd, LPARAM const lparam) { switch (message) { case WM_NCHITTEST: { - if (rt_is_pinned()) { + if (rt_is_pinned() && !rt_is_locked()) { // 获取鼠标屏幕坐标 POINT screen_pos{GET_X_LPARAM(lparam), GET_Y_LPARAM(lparam)}; diff --git a/windows/runner/foreground_tracker_win.cpp b/windows/runner/foreground_tracker_win.cpp index ca078d6..c363e74 100644 --- a/windows/runner/foreground_tracker_win.cpp +++ b/windows/runner/foreground_tracker_win.cpp @@ -439,6 +439,12 @@ __declspec(dllexport) std::int32_t rt_is_pinned() { return g_is_pinned.load(std::memory_order_acquire) ? 1 : 0; } +// 查询当前是否处于锁定状态。 +// 返回值:1 表示窗口已锁定,0 表示未锁定。 +__declspec(dllexport) std::int32_t rt_is_locked() { + return g_is_locked.load(std::memory_order_acquire) ? 1 : 0; +} + // 退出置顶小窗模式,恢复窗口原有位置和大小。 // 返回值:非 0 表示成功,0 表示失败。 __declspec(dllexport) std::int32_t rt_exit_pinned_mode() { @@ -502,7 +508,7 @@ __declspec(dllexport) std::int32_t rt_lock_window() { if (!g_is_pinned.load(std::memory_order_acquire)) { return 0; // 仅在pinned模式下允许锁定 } - + if (g_is_locked.load(std::memory_order_acquire)) { return 1; // 已经锁定 } @@ -511,28 +517,7 @@ __declspec(dllexport) std::int32_t rt_lock_window() { return 0; } - HWND hwnd = g_pinned_hwnd; - - // 获取当前窗口样式 - LONG current_style = static_cast(::GetWindowLongPtrW(hwnd, GWL_STYLE)); - - // 移除WS_THICKFRAME和WS_CAPTION以防止用户通过标题栏移动窗口 - // 保留其他样式以确保窗口正常显示 - LONG new_style = current_style; - new_style &= ~WS_THICKFRAME; // 移除可调整大小的边框 - new_style &= ~WS_CAPTION; // 移除标题栏 - - ::SetWindowLongPtrW(hwnd, GWL_STYLE, new_style); - - // 重新应用边框更改 - ::SetWindowPos(hwnd, - nullptr, - 0, - 0, - 0, - 0, - SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED); - + // 目前只需标记窗口处于锁定状态,拖拽行为由 hit-test 层检测。 g_is_locked.store(true, std::memory_order_release); return 1; } @@ -543,7 +528,7 @@ __declspec(dllexport) std::int32_t rt_unlock_window() { if (!g_is_pinned.load(std::memory_order_acquire)) { return 0; // 仅在pinned模式下允许解锁 } - + if (!g_is_locked.load(std::memory_order_acquire)) { return 1; // 已经解锁 } @@ -552,25 +537,9 @@ __declspec(dllexport) std::int32_t rt_unlock_window() { return 0; } - HWND hwnd = g_pinned_hwnd; - - // 恢复原始的窗口样式(在pinned模式下) - LONG new_style = g_prev_style; - new_style &= ~WS_CAPTION; // pinned模式下仍然隐藏标题栏 - new_style &= ~WS_THICKFRAME; // pinned模式下仍然隐藏可调整大小的边框 - new_style &= ~WS_MINIMIZEBOX; // pinned模式下隐藏最小化按钮 - new_style &= ~WS_MAXIMIZEBOX; // pinned模式下隐藏最大化按钮 - - ::SetWindowLongPtrW(hwnd, GWL_STYLE, new_style); - - // 重新应用边框更改 - ::SetWindowPos(hwnd, - nullptr, - 0, - 0, - 0, - 0, - SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED); + if (g_pinned_hwnd == nullptr) { + return 0; + } g_is_locked.store(false, std::memory_order_release); return 1; diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp index e4aa7ff..e483b00 100644 --- a/windows/runner/win32_window.cpp +++ b/windows/runner/win32_window.cpp @@ -12,6 +12,8 @@ extern "C" int rt_exit_pinned_mode(); // 来自 foreground_tracker_win.cpp:查询当前是否处于 pinned 模式。 extern "C" int rt_is_pinned(); +// 查询当前是否锁定窗口。 +extern "C" int rt_is_locked(); namespace { @@ -209,7 +211,7 @@ Win32Window::MessageHandler(HWND hwnd, case WM_NCHITTEST: { // 在 pinned 小窗模式下(无标题栏),让整个窗口(除右上角安全区域) // 都被系统识别为「标题栏」,这样系统会自动处理窗口拖动,非常流畅跟手。 - if (rt_is_pinned()) { + if (rt_is_pinned() && !rt_is_locked()) { // 先调用默认处理获取标准结果 LRESULT default_result = DefWindowProc(hwnd, message, wparam, lparam);