diff --git a/lib/pages/clock_page.dart b/lib/pages/clock_page.dart index 633c498..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; @@ -19,6 +20,10 @@ class ClockPage extends ConsumerStatefulWidget { class _ClockPageState extends ConsumerState { bool _isMiniMode = false; bool _isTogglingMiniMode = false; + bool _isLocked = false; + bool _isLocking = false; + bool _hudVisible = true; + Timer? _hudHideTimer; @override void didChangeDependencies() { @@ -30,6 +35,7 @@ class _ClockPageState extends ConsumerState { void dispose() { // 离开页面时恢复默认白色 tint(macOS/Windows 都支持更新 tint 颜色) GlassTintController.instance.resetTintColor(); + _hudHideTimer?.cancel(); super.dispose(); } @@ -62,6 +68,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 +92,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(); @@ -87,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; @@ -133,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, - ), - onPressed: _isTogglingMiniMode ? null : _toggleMiniMode, - icon: Icon( - _isMiniMode - ? Icons.crop_square - : Icons.crop_square_outlined, - color: theme.colorScheme.onPrimary, + 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 ? '退出迷你模式' : '进入迷你模式', ), - tooltip: _isMiniMode ? '退出迷你模式' : '进入迷你模式', ), ], ), @@ -175,7 +265,33 @@ 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: 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 ? '解锁窗口' : '锁定窗口', + ), + ), + ), + ), + ], + ), ), ), ), 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/flutter_window.cpp b/windows/runner/flutter_window.cpp index c68fca7..71a59d0 100644 --- a/windows/runner/flutter_window.cpp +++ b/windows/runner/flutter_window.cpp @@ -1,5 +1,6 @@ #include "flutter_window.h" +#include #include #include @@ -7,9 +8,30 @@ // 来自 foreground_tracker_win.cpp:查询当前是否处于 pinned 模式。 extern "C" int rt_is_pinned(); +// 查询当前是否处于锁定状态(lock 模式)。 +extern "C" int rt_is_locked(); 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; @@ -22,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)}; @@ -34,15 +56,31 @@ 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; + 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 - 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; + 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; diff --git a/windows/runner/foreground_tracker_win.cpp b/windows/runner/foreground_tracker_win.cpp index fe3687b..c363e74 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; @@ -438,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() { @@ -490,7 +497,51 @@ __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; + } + + // 目前只需标记窗口处于锁定状态,拖拽行为由 hit-test 层检测。 + 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; + } + + 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 d8750c5..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 { @@ -45,6 +47,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) { @@ -190,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); @@ -208,15 +229,34 @@ 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 按钮点击也拦截成拖动。 + 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) { // 告诉系统这是标题栏,系统会自动处理拖动 return HTCAPTION; }