diff --git a/lib/app_widget.dart b/lib/app_widget.dart index 78ad8aeca..1fdf97470 100644 --- a/lib/app_widget.dart +++ b/lib/app_widget.dart @@ -12,6 +12,7 @@ import 'package:kazumi/utils/logger.dart'; import 'package:window_manager/window_manager.dart'; import 'package:kazumi/bean/dialog/dialog_helper.dart'; import 'package:kazumi/bean/settings/theme_provider.dart'; +import 'package:kazumi/bean/appbar/window_control_overlay.dart'; import 'package:provider/provider.dart'; import 'package:kazumi/utils/constants.dart'; @@ -278,6 +279,12 @@ class _AppWidgetState extends State ], locale: const Locale.fromSubtags( languageCode: 'zh', scriptCode: 'Hans', countryCode: "CN"), + builder: (context, child) { + if (child == null) { + return const SizedBox.shrink(); + } + return WindowControlOverlay(child: child); + }, theme: themeProvider.light, darkTheme: themeProvider.dark, themeMode: themeProvider.themeMode, diff --git a/lib/bean/appbar/sys_app_bar.dart b/lib/bean/appbar/sys_app_bar.dart index 43b1f87e3..2f0a7591c 100644 --- a/lib/bean/appbar/sys_app_bar.dart +++ b/lib/bean/appbar/sys_app_bar.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:kazumi/bean/appbar/window_control_overlay.dart'; import 'package:kazumi/bean/widget/embedded_native_control_area.dart'; import 'package:kazumi/utils/storage.dart'; import 'package:kazumi/utils/utils.dart'; @@ -52,11 +53,11 @@ class SysAppBar extends StatelessWidget implements PreferredSizeWidget { acs.addAll(actions!); } if (Utils.isDesktop()) { - // acs.add(IconButton(onPressed: () => windowManager.minimize(), icon: const Icon(Icons.minimize))); - if (!showWindowButton()) { - acs.add(CloseButton(onPressed: () => windowManager.close())); - } - acs.add(const SizedBox(width: 8)); + acs.add( + SizedBox( + width: showWindowButton() ? 8 : windowControlOverlayReservedWidth, + ), + ); } return GestureDetector( onPanStart: (_) => diff --git a/lib/bean/appbar/window_control_overlay.dart b/lib/bean/appbar/window_control_overlay.dart new file mode 100644 index 000000000..c52386f30 --- /dev/null +++ b/lib/bean/appbar/window_control_overlay.dart @@ -0,0 +1,691 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:kazumi/bean/appbar/windows_interface.dart'; +import 'package:kazumi/utils/storage.dart'; +import 'package:kazumi/utils/utils.dart'; +import 'package:window_manager/window_manager.dart'; + +const double windowControlOverlayReservedWidth = 132.0; + +class WindowControlOverlayVisibilityController { + static final ValueNotifier _visibleOverride = + ValueNotifier(null); + static final ValueNotifier _topShiftOverride = + ValueNotifier(null); + static final ValueNotifier _lightAppearanceOverride = + ValueNotifier(null); + + static bool? _pendingValue; + static double? _pendingTopShift; + static bool? _pendingLightAppearance; + static bool _flushScheduled = false; + + static ValueNotifier get visibleOverride => _visibleOverride; + static ValueNotifier get topShiftOverride => _topShiftOverride; + static ValueNotifier get lightAppearanceOverride => + _lightAppearanceOverride; + + static void setVisible(bool visible) { + _requestValue(visible: visible, updateVisible: true); + } + + static void clear() { + _requestValue(visible: null, updateVisible: true); + } + + static void setTopShift(double shift) { + _requestValue(topShift: shift, updateTopShift: true); + } + + static void clearTopShift() { + _requestValue(topShift: null, updateTopShift: true); + } + + static void setLightAppearance(bool enabled) { + _requestValue( + lightAppearance: enabled, + updateLightAppearance: true, + ); + } + + static void clearLightAppearance() { + _requestValue( + lightAppearance: null, + updateLightAppearance: true, + ); + } + + static void _requestValue({ + bool? visible, + bool updateVisible = false, + double? topShift, + bool updateTopShift = false, + bool? lightAppearance, + bool updateLightAppearance = false, + }) { + if (updateVisible) { + _pendingValue = visible; + } + if (updateTopShift) { + _pendingTopShift = topShift; + } + if (updateLightAppearance) { + _pendingLightAppearance = lightAppearance; + } + if (_flushScheduled) { + return; + } + _flushScheduled = true; + SchedulerBinding.instance.addPostFrameCallback((_) { + _flushScheduled = false; + final bool? next = _pendingValue; + final double? nextTopShift = _pendingTopShift; + final bool? nextLightAppearance = _pendingLightAppearance; + if (_visibleOverride.value != next) { + _visibleOverride.value = next; + } + if (_topShiftOverride.value != nextTopShift) { + _topShiftOverride.value = nextTopShift; + } + if (_lightAppearanceOverride.value != nextLightAppearance) { + _lightAppearanceOverride.value = nextLightAppearance; + } + // If state changed again while this frame callback was running, schedule once more. + if (_pendingValue != _visibleOverride.value || + _pendingTopShift != _topShiftOverride.value || + _pendingLightAppearance != _lightAppearanceOverride.value) { + _requestValue( + visible: _pendingValue, + updateVisible: true, + topShift: _pendingTopShift, + updateTopShift: true, + lightAppearance: _pendingLightAppearance, + updateLightAppearance: true, + ); + } + }); + SchedulerBinding.instance.ensureVisualUpdate(); + } +} + +class WindowControlOverlay extends StatefulWidget { + const WindowControlOverlay({ + super.key, + required this.child, + }); + + final Widget child; + + @override + State createState() => _WindowControlOverlayState(); +} + +class _WindowControlOverlayState extends State + with WindowListener { + static const double _titleButtonWidth = 44; + static const double _titleButtonHeight = 32; + static const double _titleButtonTopPadding = 3; + + bool _isMaximized = false; + StreamSubscription? _exitBehaviorSubscription; + StreamSubscription? _windowsEventSubscription; + bool _windowsInterfaceReady = false; + bool? _lastWindowsTitleBarEnabled; + int? _lastWindowsTitleTopInset; + int? _lastWindowsTitleButtonHeight; + int? _lastWindowsTitleButtonWidth; + CustomTitleBarHoveredButton _nativeDownButton = + CustomTitleBarHoveredButton.none; + + bool get _showOverlay { + if (!Utils.isDesktop()) { + return false; + } + return !GStorage.setting + .get(SettingBoxKey.showWindowButton, defaultValue: false); + } + + bool get _closeMeansMinimizeToTray { + final int exitBehavior = + GStorage.setting.get(SettingBoxKey.exitBehavior, defaultValue: 2); + return exitBehavior == 1; + } + + @override + void initState() { + super.initState(); + _exitBehaviorSubscription = + GStorage.setting.watch(key: SettingBoxKey.exitBehavior).listen((_) { + if (mounted) { + setState(() {}); + } + }); + if (Utils.isDesktop()) { + windowManager.addListener(this); + _syncWindowState(); + } + if (Platform.isWindows) { + unawaited(_initializeWindowsInterface()); + } + } + + @override + void dispose() { + _windowsEventSubscription?.cancel(); + if (Platform.isWindows && _windowsInterfaceReady) { + unawaited(WindowsInterface.instance.setWindowsTitleBarEnabled(false)); + } + _exitBehaviorSubscription?.cancel(); + if (Utils.isDesktop()) { + windowManager.removeListener(this); + } + super.dispose(); + } + + Future _initializeWindowsInterface() async { + await WindowsInterface.instance.ensureInitialized(); + if (!mounted) { + return; + } + _windowsInterfaceReady = true; + _windowsEventSubscription = + WindowsInterface.instance.events.listen(_handleWindowsButtonEvent); + } + + void _handleWindowsButtonEvent(WindowsTitleButtonEvent event) { + if (!mounted) { + return; + } + switch (event.type) { + case WindowsTitleButtonEventType.hover: + if (_nativeDownButton != CustomTitleBarHoveredButton.none && + event.button != _nativeDownButton) { + setState(() { + _nativeDownButton = CustomTitleBarHoveredButton.none; + }); + } + break; + case WindowsTitleButtonEventType.down: + setState(() { + _nativeDownButton = event.button; + }); + break; + case WindowsTitleButtonEventType.up: + if (_nativeDownButton != CustomTitleBarHoveredButton.none) { + setState(() { + _nativeDownButton = CustomTitleBarHoveredButton.none; + }); + } + break; + case WindowsTitleButtonEventType.click: + if (_nativeDownButton != CustomTitleBarHoveredButton.none) { + setState(() { + _nativeDownButton = CustomTitleBarHoveredButton.none; + }); + } + unawaited(_handleWindowsButtonClick(event.button)); + break; + } + } + + Future _handleWindowsButtonClick( + CustomTitleBarHoveredButton button) async { + switch (button) { + case CustomTitleBarHoveredButton.minimize: + await windowManager.minimize(); + return; + case CustomTitleBarHoveredButton.maximize: + await _toggleMaximize(); + return; + case CustomTitleBarHoveredButton.close: + await windowManager.close(); + return; + case CustomTitleBarHoveredButton.none: + return; + } + } + + Future _syncWindowsTitleBarState({ + required bool enabled, + required int topInsetPhysical, + required int titleButtonHeightPhysical, + required int titleButtonWidthPhysical, + }) async { + if (!Platform.isWindows || !_windowsInterfaceReady) { + return; + } + if (_lastWindowsTitleButtonHeight != titleButtonHeightPhysical) { + _lastWindowsTitleButtonHeight = titleButtonHeightPhysical; + await WindowsInterface.instance + .setWindowsTitleHeight(titleButtonHeightPhysical); + } + if (_lastWindowsTitleButtonWidth != titleButtonWidthPhysical) { + _lastWindowsTitleButtonWidth = titleButtonWidthPhysical; + await WindowsInterface.instance + .setWindowsTitleButtonWidth(titleButtonWidthPhysical); + } + + if (_lastWindowsTitleBarEnabled == enabled && + (!enabled || _lastWindowsTitleTopInset == topInsetPhysical)) { + return; + } + _lastWindowsTitleBarEnabled = enabled; + _lastWindowsTitleTopInset = topInsetPhysical; + + await WindowsInterface.instance.setWindowsTitleBarEnabled(enabled); + if (enabled) { + await WindowsInterface.instance.setWindowsTitleTopInset(topInsetPhysical); + } + } + + @override + void onWindowMaximize() { + _setMaximized(true); + } + + @override + void onWindowUnmaximize() { + _setMaximized(false); + } + + @override + void onWindowRestore() { + _syncWindowState(); + } + + void _setMaximized(bool value) { + if (!mounted) { + return; + } + setState(() { + _isMaximized = value; + }); + } + + Future _syncWindowState() async { + final bool isMaximized = await windowManager.isMaximized(); + _setMaximized(isMaximized); + } + + Future _toggleMaximize() async { + final bool isMaximized = await windowManager.isMaximized(); + if (isMaximized) { + await windowManager.unmaximize(); + } else { + await windowManager.maximize(); + } + await _syncWindowState(); + } + + @override + Widget build(BuildContext context) { + final double pixelRatio = MediaQuery.devicePixelRatioOf(context); + final int titleButtonHeightPhysical = + (_titleButtonHeight * pixelRatio).round(); + final int titleButtonWidthPhysical = + (_titleButtonWidth * pixelRatio).round(); + + if (!_showOverlay) { + if (Platform.isWindows) { + unawaited(_syncWindowsTitleBarState( + enabled: false, + topInsetPhysical: 0, + titleButtonHeightPhysical: titleButtonHeightPhysical, + titleButtonWidthPhysical: titleButtonWidthPhysical, + )); + } + return widget.child; + } + final ColorScheme colorScheme = Theme.of(context).colorScheme; + return AnimatedBuilder( + animation: Listenable.merge([ + WindowControlOverlayVisibilityController.visibleOverride, + WindowControlOverlayVisibilityController.topShiftOverride, + WindowControlOverlayVisibilityController.lightAppearanceOverride, + ]), + builder: (context, _) { + final bool visible = + WindowControlOverlayVisibilityController.visibleOverride.value ?? + true; + final double topShift = + WindowControlOverlayVisibilityController.topShiftOverride.value ?? + 0; + final bool lightAppearance = WindowControlOverlayVisibilityController + .lightAppearanceOverride.value ?? + false; + final bool useNativeWindowsEvents = Platform.isWindows; + final double overlayTopPadding = + 8 + MediaQuery.paddingOf(context).top + topShift; + if (useNativeWindowsEvents) { + final int topInsetPhysical = + ((overlayTopPadding + _titleButtonTopPadding) * pixelRatio) + .round(); + unawaited(_syncWindowsTitleBarState( + enabled: visible, + topInsetPhysical: topInsetPhysical, + titleButtonHeightPhysical: titleButtonHeightPhysical, + titleButtonWidthPhysical: titleButtonWidthPhysical, + )); + } + final Color iconColor = lightAppearance + ? Colors.white.withValues(alpha: 0.92) + : colorScheme.onSurface; + final bool closeMeansMinimizeToTray = _closeMeansMinimizeToTray; + return Stack( + children: [ + widget.child, + Positioned( + top: 0, + right: -3, + child: IgnorePointer( + ignoring: !visible || useNativeWindowsEvents, + child: AnimatedSlide( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + offset: visible ? Offset.zero : const Offset(0, -1), + child: Padding( + padding: EdgeInsets.only( + top: overlayTopPadding, + ), + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onPanStart: (_) => windowManager.startDragging(), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 4, vertical: 3), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _WindowControlButton( + lightAppearance: lightAppearance, + pressed: useNativeWindowsEvents + ? _nativeDownButton == + CustomTitleBarHoveredButton.minimize + : false, + onPressed: useNativeWindowsEvents + ? null + : () => windowManager.minimize(), + icon: _GlyphIcon( + painter: _MinimizeGlyphPainter( + color: iconColor, + ), + ), + ), + _WindowControlButton( + lightAppearance: lightAppearance, + pressed: useNativeWindowsEvents + ? _nativeDownButton == + CustomTitleBarHoveredButton.maximize + : false, + onPressed: useNativeWindowsEvents + ? null + : _toggleMaximize, + icon: _GlyphIcon( + painter: _MaximizeGlyphPainter( + color: iconColor, + showRestore: _isMaximized, + ), + ), + ), + _WindowControlButton( + lightAppearance: lightAppearance, + pressed: useNativeWindowsEvents + ? _nativeDownButton == + CustomTitleBarHoveredButton.close + : false, + danger: !closeMeansMinimizeToTray, + onPressed: useNativeWindowsEvents + ? null + : () => windowManager.close(), + icon: _GlyphIcon( + painter: closeMeansMinimizeToTray + ? _TrayMinimizeGlyphPainter( + color: iconColor) + : _CloseGlyphPainter( + color: iconColor, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ], + ); + }, + ); + } +} + +class _WindowControlButton extends StatelessWidget { + const _WindowControlButton({ + required this.onPressed, + required this.icon, + this.danger = false, + this.lightAppearance = false, + this.pressed = false, + }); + + final Future Function()? onPressed; + final Widget icon; + final bool danger; + final bool lightAppearance; + final bool pressed; + + Color _pressedColor(ColorScheme colorScheme) { + return danger + ? (lightAppearance + ? const Color(0xFFC42B1C) + : colorScheme.errorContainer.withValues(alpha: 0.72)) + : (lightAppearance + ? Colors.white.withValues(alpha: 0.24) + : colorScheme.primaryContainer.withValues(alpha: 0.72)); + } + + @override + Widget build(BuildContext context) { + final ColorScheme colorScheme = Theme.of(context).colorScheme; + final Color nativeStateColor = + pressed ? _pressedColor(colorScheme) : Colors.transparent; + + if (onPressed == null) { + return AnimatedContainer( + duration: const Duration(milliseconds: 80), + curve: Curves.easeOut, + width: 44, + height: 32, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: nativeStateColor, + ), + child: Center(child: icon), + ); + } + + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(10), + hoverColor: Colors.transparent, + onTap: onPressed, + child: SizedBox( + width: 44, + height: 32, + child: Center(child: icon), + ), + ), + ); + } +} + +class _GlyphIcon extends StatelessWidget { + const _GlyphIcon({ + required this.painter, + }); + + final CustomPainter painter; + + @override + Widget build(BuildContext context) { + return CustomPaint( + size: const Size(16, 16), + painter: painter, + ); + } +} + +class _MinimizeGlyphPainter extends CustomPainter { + const _MinimizeGlyphPainter({ + required this.color, + }); + + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..strokeWidth = 1.7 + ..strokeCap = StrokeCap.round; + final double y = size.height / 2; + canvas.drawLine(Offset(2, y), Offset(size.width - 2, y), paint); + } + + @override + bool shouldRepaint(covariant _MinimizeGlyphPainter oldDelegate) { + return oldDelegate.color != color; + } +} + +class _MaximizeGlyphPainter extends CustomPainter { + const _MaximizeGlyphPainter({ + required this.color, + required this.showRestore, + }); + + final Color color; + final bool showRestore; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..strokeWidth = 1.6 + ..style = PaintingStyle.stroke; + const Radius radius = Radius.circular(1.8); + if (!showRestore) { + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(2.4, 2.4, size.width - 4.8, size.height - 4.8), + radius, + ), + paint, + ); + return; + } + + final RRect frontRect = RRect.fromRectAndRadius( + Rect.fromLTWH(2.4, 4.8, size.width - 7.2, size.height - 7.2), + radius, + ); + final RRect backRect = RRect.fromRectAndRadius( + Rect.fromLTWH(4.8, 2.4, size.width - 7.2, size.height - 7.2), + radius, + ); + + final Path visibleBackArea = Path() + ..fillType = PathFillType.evenOdd + ..addRect(Offset.zero & size) + ..addRRect(frontRect); + + canvas.save(); + canvas.clipPath(visibleBackArea); + canvas.drawRRect( + backRect, + paint, + ); + canvas.restore(); + + canvas.drawRRect(frontRect, paint); + } + + @override + bool shouldRepaint(covariant _MaximizeGlyphPainter oldDelegate) { + return oldDelegate.color != color || oldDelegate.showRestore != showRestore; + } +} + +class _CloseGlyphPainter extends CustomPainter { + const _CloseGlyphPainter({ + required this.color, + }); + + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..strokeWidth = 1.8 + ..strokeCap = StrokeCap.round; + canvas.drawLine( + const Offset(2.4, 2.4), + Offset(size.width - 2.4, size.height - 2.4), + paint, + ); + canvas.drawLine( + Offset(size.width - 2.4, 2.4), + Offset(2.4, size.height - 2.4), + paint, + ); + } + + @override + bool shouldRepaint(covariant _CloseGlyphPainter oldDelegate) { + return oldDelegate.color != color; + } +} + +class _TrayMinimizeGlyphPainter extends CustomPainter { + const _TrayMinimizeGlyphPainter({ + required this.color, + }); + + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..strokeWidth = 1.6 + ..strokeCap = StrokeCap.round; + // Match maximize icon visual box and draw double downward chevrons. + final double leftX = 2.4; + final double rightX = size.width - 2.4; + final double centerX = size.width / 2; + + final Offset topLeft1 = Offset(leftX, 2.4); + final Offset topRight1 = Offset(rightX, 2.4); + final Offset bottom1 = Offset(centerX, size.height * 0.50); + canvas.drawLine(topLeft1, bottom1, paint); + canvas.drawLine(bottom1, topRight1, paint); + + final Offset topLeft2 = Offset(leftX, size.height * 0.44); + final Offset topRight2 = Offset(rightX, size.height * 0.44); + final Offset bottom2 = Offset(centerX, size.height - 2.4); + canvas.drawLine(topLeft2, bottom2, paint); + canvas.drawLine(bottom2, topRight2, paint); + } + + @override + bool shouldRepaint(covariant _TrayMinimizeGlyphPainter oldDelegate) { + return oldDelegate.color != color; + } +} diff --git a/lib/bean/appbar/windows_interface.dart b/lib/bean/appbar/windows_interface.dart new file mode 100644 index 000000000..fb61745ee --- /dev/null +++ b/lib/bean/appbar/windows_interface.dart @@ -0,0 +1,124 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/services.dart'; + +enum CustomTitleBarHoveredButton { + none(0), + minimize(1), + maximize(2), + close(3); + + const CustomTitleBarHoveredButton(this.value); + final int value; + + static CustomTitleBarHoveredButton fromValue(dynamic value) { + final int v = value is int ? value : 0; + for (final button in CustomTitleBarHoveredButton.values) { + if (button.value == v) { + return button; + } + } + return CustomTitleBarHoveredButton.none; + } +} + +enum WindowsTitleButtonEventType { + hover, + down, + up, + click, +} + +class WindowsTitleButtonEvent { + const WindowsTitleButtonEvent({ + required this.type, + required this.button, + }); + + final WindowsTitleButtonEventType type; + final CustomTitleBarHoveredButton button; +} + +class WindowsInterface { + WindowsInterface._(); + + static final WindowsInterface instance = WindowsInterface._(); + static const MethodChannel _channel = + MethodChannel('com.predidit.kazumi/windows_interface'); + + final StreamController _eventController = + StreamController.broadcast(); + bool _initialized = false; + + Stream get events => _eventController.stream; + + Future ensureInitialized() async { + if (!Platform.isWindows || _initialized) { + return; + } + _initialized = true; + _channel.setMethodCallHandler(_handleMethodCall); + } + + Future setWindowsTitleHeight(int height) async { + if (!Platform.isWindows) { + return; + } + await _channel.invokeMethod('setWindowsTitleHeight', height); + } + + Future setWindowsTitleButtonWidth(int width) async { + if (!Platform.isWindows) { + return; + } + await _channel.invokeMethod('setWindowsTitleButtonWidth', width); + } + + Future setWindowsTitleTopInset(int inset) async { + if (!Platform.isWindows) { + return; + } + await _channel.invokeMethod('setWindowsTitleTopInset', inset); + } + + Future setWindowsTitleBarEnabled(bool enabled) async { + if (!Platform.isWindows) { + return; + } + await _channel.invokeMethod('setWindowsTitleBarEnabled', enabled); + } + + Future _handleMethodCall(MethodCall call) async { + final CustomTitleBarHoveredButton button = + CustomTitleBarHoveredButton.fromValue(call.arguments); + switch (call.method) { + case 'onTitleButtonHover': + _eventController.add(WindowsTitleButtonEvent( + type: WindowsTitleButtonEventType.hover, + button: button, + )); + break; + case 'onTitleButtonDown': + _eventController.add(WindowsTitleButtonEvent( + type: WindowsTitleButtonEventType.down, + button: button, + )); + break; + case 'onTitleButtonUp': + _eventController.add(WindowsTitleButtonEvent( + type: WindowsTitleButtonEventType.up, + button: button, + )); + break; + case 'onTitleButtonClick': + _eventController.add(WindowsTitleButtonEvent( + type: WindowsTitleButtonEventType.click, + button: button, + )); + break; + default: + break; + } + } +} diff --git a/lib/pages/info/info_page.dart b/lib/pages/info/info_page.dart index 82d52f54d..7a46c2eb5 100644 --- a/lib/pages/info/info_page.dart +++ b/lib/pages/info/info_page.dart @@ -4,6 +4,7 @@ import 'package:kazumi/utils/utils.dart'; import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:kazumi/bean/widget/collect_button.dart'; +import 'package:kazumi/bean/appbar/window_control_overlay.dart'; import 'package:kazumi/bean/widget/embedded_native_control_area.dart'; import 'package:kazumi/utils/constants.dart'; import 'package:kazumi/utils/storage.dart'; @@ -16,7 +17,6 @@ import 'package:kazumi/bean/card/network_img_layer.dart'; import 'package:kazumi/utils/logger.dart'; import 'package:kazumi/pages/info/info_tabview.dart'; import 'package:url_launcher/url_launcher.dart'; -import 'package:window_manager/window_manager.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:kazumi/modules/bangumi/bangumi_item.dart'; import 'package:kazumi/bean/appbar/drag_to_move_bar.dart' as dtb; @@ -239,7 +239,9 @@ class _InfoPageState extends State with TickerProviderStateMixin { ), ), if (!showWindowButton && Utils.isDesktop()) - CloseButton(onPressed: () => windowManager.close()), + const SizedBox( + width: windowControlOverlayReservedWidth, + ), SizedBox(width: 8), ], toolbarHeight: (Platform.isMacOS && showWindowButton) @@ -376,7 +378,9 @@ class _InfoPageState extends State with TickerProviderStateMixin { showDragHandle: true, context: context, builder: (context) { - return SourceSheet(tabController: sourceTabController, infoController: infoController); + return SourceSheet( + tabController: sourceTabController, + infoController: infoController); }, ); }, diff --git a/lib/pages/player/player_item_panel.dart b/lib/pages/player/player_item_panel.dart index a40a915a6..1d58ea322 100644 --- a/lib/pages/player/player_item_panel.dart +++ b/lib/pages/player/player_item_panel.dart @@ -18,6 +18,7 @@ import 'package:kazumi/utils/storage.dart'; import 'package:audio_video_progress_bar/audio_video_progress_bar.dart'; import 'package:kazumi/utils/timed_shutdown_service.dart'; import 'package:kazumi/pages/download/download_controller.dart'; +import 'package:kazumi/bean/appbar/window_control_overlay.dart'; class PlayerItemPanel extends StatefulWidget { const PlayerItemPanel({ @@ -97,6 +98,37 @@ class _PlayerItemPanelState extends State { static const double _danmakuIconSize = 24.0; static const double _loadingIndicatorStrokeWidth = 2.0; + bool? _lastWindowOverlayVisibility; + + bool get _useCustomWindowControlOverlay { + return Utils.isDesktop() && + !setting.get(SettingBoxKey.showWindowButton, defaultValue: false); + } + + bool get _shouldShowWindowControlOverlay { + return _useCustomWindowControlOverlay && + !videoPageController.isFullscreen && + !playerController.lockPanel && + playerController.showVideoController; + } + + void _syncWindowControlOverlayVisibility() { + final bool? targetVisibility = + _useCustomWindowControlOverlay ? _shouldShowWindowControlOverlay : null; + if (_lastWindowOverlayVisibility == targetVisibility) { + return; + } + _lastWindowOverlayVisibility = targetVisibility; + if (targetVisibility == null) { + WindowControlOverlayVisibilityController.clear(); + WindowControlOverlayVisibilityController.clearTopShift(); + WindowControlOverlayVisibilityController.clearLightAppearance(); + } else { + WindowControlOverlayVisibilityController.setVisible(targetVisibility); + WindowControlOverlayVisibilityController.setTopShift(-8); + WindowControlOverlayVisibilityController.setLightAppearance(true); + } + } Widget get danmakuTextField { return Container( @@ -303,7 +335,17 @@ class _PlayerItemPanelState extends State { haEnable = setting.get(SettingBoxKey.hAenable, defaultValue: true); cacheSvgIcons(); } - + + @override + void dispose() { + WindowControlOverlayVisibilityController.clear(); + WindowControlOverlayVisibilityController.clearTopShift(); + WindowControlOverlayVisibilityController.clearLightAppearance(); + textController.dispose(); + textFieldFocus.dispose(); + super.dispose(); + } + void cacheSvgIcons() { cachedDanmakuOffIcon = RepaintBoundary( child: SvgPicture.asset( @@ -391,6 +433,7 @@ class _PlayerItemPanelState extends State { @override Widget build(BuildContext context) { return Observer(builder: (context) { + _syncWindowControlOverlayVisibility(); return Stack( alignment: Alignment.center, children: [ @@ -1385,6 +1428,8 @@ class _PlayerItemPanelState extends State { ), ], ), + if (_shouldShowWindowControlOverlay) + const SizedBox(width: windowControlOverlayReservedWidth), ], ), ), diff --git a/lib/pages/player/smallest_player_item_panel.dart b/lib/pages/player/smallest_player_item_panel.dart index f73bc1b5d..701c6cdfd 100644 --- a/lib/pages/player/smallest_player_item_panel.dart +++ b/lib/pages/player/smallest_player_item_panel.dart @@ -16,6 +16,7 @@ import 'package:kazumi/utils/storage.dart'; import 'package:kazumi/bean/appbar/drag_to_move_bar.dart' as dtb; import 'package:audio_video_progress_bar/audio_video_progress_bar.dart'; import 'package:kazumi/bean/widget/embedded_native_control_area.dart'; +import 'package:kazumi/bean/appbar/window_control_overlay.dart'; import 'package:kazumi/utils/timed_shutdown_service.dart'; class SmallestPlayerItemPanel extends StatefulWidget { @@ -85,6 +86,36 @@ class _SmallestPlayerItemPanelState extends State { static const double _danmakuIconSize = 24.0; static const double _loadingIndicatorStrokeWidth = 2.0; + bool? _lastWindowOverlayVisibility; + + bool get _useCustomWindowControlOverlay { + return Utils.isDesktop() && + !setting.get(SettingBoxKey.showWindowButton, defaultValue: false); + } + + bool get _shouldShowWindowControlOverlay { + return _useCustomWindowControlOverlay && + !playerController.lockPanel && + playerController.showVideoController; + } + + void _syncWindowControlOverlayVisibility() { + final bool? targetVisibility = + _useCustomWindowControlOverlay ? _shouldShowWindowControlOverlay : null; + if (_lastWindowOverlayVisibility == targetVisibility) { + return; + } + _lastWindowOverlayVisibility = targetVisibility; + if (targetVisibility == null) { + WindowControlOverlayVisibilityController.clear(); + WindowControlOverlayVisibilityController.clearTopShift(); + WindowControlOverlayVisibilityController.clearLightAppearance(); + } else { + WindowControlOverlayVisibilityController.setVisible(targetVisibility); + WindowControlOverlayVisibilityController.setTopShift(-8); + WindowControlOverlayVisibilityController.setLightAppearance(true); + } + } void showForwardChange() { KazumiDialog.show(builder: (context) { @@ -158,7 +189,16 @@ class _SmallestPlayerItemPanelState extends State { haEnable = setting.get(SettingBoxKey.hAenable, defaultValue: true); cacheSvgIcons(); } - + + @override + void dispose() { + WindowControlOverlayVisibilityController.clear(); + WindowControlOverlayVisibilityController.clearTopShift(); + WindowControlOverlayVisibilityController.clearLightAppearance(); + textController.dispose(); + super.dispose(); + } + void cacheSvgIcons() { cachedDanmakuOffIcon = RepaintBoundary( child: SvgPicture.asset( @@ -239,6 +279,7 @@ class _SmallestPlayerItemPanelState extends State { @override Widget build(BuildContext context) { return Observer(builder: (context) { + _syncWindowControlOverlayVisibility(); return Stack( alignment: Alignment.center, children: [ @@ -970,6 +1011,8 @@ class _SmallestPlayerItemPanelState extends State { ), ], ), + if (_useCustomWindowControlOverlay) + const SizedBox(width: windowControlOverlayReservedWidth), ], ), ); diff --git a/lib/pages/popular/popular_page.dart b/lib/pages/popular/popular_page.dart index c17687b09..012265611 100644 --- a/lib/pages/popular/popular_page.dart +++ b/lib/pages/popular/popular_page.dart @@ -9,7 +9,7 @@ import 'package:kazumi/bean/card/bangumi_card.dart'; import 'package:kazumi/utils/constants.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:flutter/services.dart'; -import 'package:window_manager/window_manager.dart'; +import 'package:kazumi/bean/appbar/window_control_overlay.dart'; import 'package:kazumi/utils/utils.dart'; import 'package:kazumi/utils/logger.dart'; import 'package:kazumi/pages/menu/menu.dart'; @@ -163,7 +163,7 @@ class _PopularPageState extends State ); } - Widget contentGrid(bangumiList) { + Widget contentGrid(List bangumiList) { int crossCount = 3; if (MediaQuery.sizeOf(context).width > LayoutBreakpoint.compact['width']!) { crossCount = 5; @@ -187,11 +187,11 @@ class _PopularPageState extends State ), delegate: SliverChildBuilderDelegate( (BuildContext context, int index) { - return bangumiList!.isNotEmpty + return bangumiList.isNotEmpty ? BangumiCardV(bangumiItem: bangumiList[index]) : null; }, - childCount: bangumiList!.isNotEmpty ? bangumiList!.length : 10, + childCount: bangumiList.isNotEmpty ? bangumiList.length : 10, ), ), ); @@ -279,16 +279,8 @@ class _PopularPageState extends State icon: const Icon(Icons.history), ), ); - if (Utils.isDesktop()) { - if (!showWindowButton()) { - actions.add( - IconButton( - tooltip: '退出', - onPressed: () => windowManager.close(), - icon: const Icon(Icons.close), - ), - ); - } + if (Utils.isDesktop() && !showWindowButton()) { + actions.add(const SizedBox(width: windowControlOverlayReservedWidth)); } return actions; } diff --git a/lib/pages/video/video_page.dart b/lib/pages/video/video_page.dart index 73e8e2988..f6d2953e0 100644 --- a/lib/pages/video/video_page.dart +++ b/lib/pages/video/video_page.dart @@ -22,6 +22,7 @@ import 'package:kazumi/pages/download/download_controller.dart'; import 'package:kazumi/pages/download/download_episode_sheet.dart'; import 'package:kazumi/modules/download/download_module.dart'; import 'package:kazumi/utils/timed_shutdown_service.dart'; +import 'package:kazumi/bean/appbar/window_control_overlay.dart'; class VideoPage extends StatefulWidget { const VideoPage({super.key}); @@ -58,6 +59,36 @@ class _VideoPageState extends State // disable animation. late final bool disableAnimations; + bool get _useCustomWindowControlOverlay { + return Utils.isDesktop() && + !setting.get(SettingBoxKey.showWindowButton, defaultValue: false); + } + + bool get _isPreloadOverlayVisible { + return videoPageController.loading || + playerController.loading || + videoPageController.errorMessage != null; + } + + void _syncWindowControlOverlayStyle() { + if (!_useCustomWindowControlOverlay) { + WindowControlOverlayVisibilityController.clear(); + WindowControlOverlayVisibilityController.clearTopShift(); + WindowControlOverlayVisibilityController.clearLightAppearance(); + return; + } + if (videoPageController.isFullscreen) { + WindowControlOverlayVisibilityController.setVisible(false); + return; + } + WindowControlOverlayVisibilityController.setTopShift(-8); + WindowControlOverlayVisibilityController.setLightAppearance(true); + if (_isPreloadOverlayVisible) { + WindowControlOverlayVisibilityController.setVisible(true); + return; + } + } + // SyncPlayChatMessage late final StreamSubscription _syncChatSubscription; @@ -197,6 +228,9 @@ class _VideoPageState extends State @override void dispose() { + WindowControlOverlayVisibilityController.clear(); + WindowControlOverlayVisibilityController.clearTopShift(); + WindowControlOverlayVisibilityController.clearLightAppearance(); try { windowManager.removeListener(this); } catch (_) {} @@ -531,6 +565,7 @@ class _VideoPageState extends State } } return Observer(builder: (context) { + _syncWindowControlOverlayStyle(); return Scaffold( appBar: null, body: SafeArea( @@ -759,6 +794,11 @@ class _VideoPageState extends State switchDebugConsole(); }, ), + if (_useCustomWindowControlOverlay && + !videoPageController.isFullscreen) + const SizedBox( + width: windowControlOverlayReservedWidth, + ), ], ), ), diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt index 50de5963b..1ab551ee0 100644 --- a/windows/runner/CMakeLists.txt +++ b/windows/runner/CMakeLists.txt @@ -18,6 +18,9 @@ add_executable(${BINARY_NAME} WIN32 "runner.exe.manifest" ) +# add windows_interface +add_subdirectory("windows_interface") + # Apply the standard set of build settings. This can be removed for applications # that need different build settings. apply_standard_settings(${BINARY_NAME}) @@ -36,6 +39,7 @@ target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") # dependencies here. target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_link_libraries(${BINARY_NAME} PRIVATE windows_interface) target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") # Run the Flutter tool portions of the build. This must not be removed. diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp index c2ef7ef99..6f91cdb0f 100644 --- a/windows/runner/flutter_window.cpp +++ b/windows/runner/flutter_window.cpp @@ -1,6 +1,7 @@ #include "flutter_window.h" #include "fullscreen_utils.h" #include "external_player_utils.h" +#include "windows_interface.h" #include #include @@ -32,6 +33,9 @@ bool FlutterWindow::OnCreate() { } RegisterPlugins(flutter_controller_->engine()); SetChildContent(flutter_controller_->view()->GetNativeWindow()); + WindowsInterface::RegisterPlugin( + flutter_controller_->engine(), + flutter_controller_->view()->GetNativeWindow()); // Removed automatic window show to let window_manager plugin control visibility // This prevents window flashing during startup diff --git a/windows/runner/windows_interface/CMakeLists.txt b/windows/runner/windows_interface/CMakeLists.txt new file mode 100644 index 000000000..3e3ff9c2f --- /dev/null +++ b/windows/runner/windows_interface/CMakeLists.txt @@ -0,0 +1,14 @@ +cmake_minimum_required(VERSION 3.14) +project(windows_interface LANGUAGES CXX) + +add_library(windows_interface STATIC + "windows_interface.cpp" +) + +apply_standard_settings(windows_interface) + +target_compile_definitions(windows_interface PRIVATE "NOMINMAX") + +target_link_libraries(windows_interface PRIVATE flutter flutter_wrapper_plugin) + +target_include_directories(windows_interface INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}") diff --git a/windows/runner/windows_interface/windows_interface.cpp b/windows/runner/windows_interface/windows_interface.cpp new file mode 100644 index 000000000..964a11153 --- /dev/null +++ b/windows/runner/windows_interface/windows_interface.cpp @@ -0,0 +1,296 @@ +#include "windows_interface.h" + +#include +#include +#include +#include + +namespace { + +std::optional ToInt64(const flutter::EncodableValue& value) { + if (const auto* int32_value = std::get_if(&value)) { + return static_cast(*int32_value); + } + if (const auto* int64_value = std::get_if(&value)) { + return *int64_value; + } + if (const auto* double_value = std::get_if(&value)) { + return static_cast(*double_value); + } + return std::nullopt; +} + +std::optional ToBool(const flutter::EncodableValue& value) { + if (const auto* bool_value = std::get_if(&value)) { + return *bool_value; + } + return std::nullopt; +} + +} // namespace + +std::unique_ptr WindowsInterface::instance_ = nullptr; + +void WindowsInterface::RegisterPlugin(flutter::FlutterEngine* engine, + HWND flutter_hwnd) { + if (instance_ != nullptr) { + return; + } + instance_ = std::unique_ptr( + new WindowsInterface(engine, flutter_hwnd)); +} + +WindowsInterface::WindowsInterface(flutter::FlutterEngine* engine, + HWND flutter_hwnd) + : flutter_hwnd_(flutter_hwnd) { + RegisterChannel(engine); + old_wnd_proc_ = reinterpret_cast(SetWindowLongPtr( + flutter_hwnd_, GWLP_WNDPROC, reinterpret_cast(WndProc))); +} + +WindowsInterface::~WindowsInterface() { + if (flutter_hwnd_ != nullptr && old_wnd_proc_ != nullptr) { + SetWindowLongPtr(flutter_hwnd_, GWLP_WNDPROC, + reinterpret_cast(old_wnd_proc_)); + old_wnd_proc_ = nullptr; + } +} + +void WindowsInterface::RegisterChannel(flutter::FlutterEngine* engine) { + channel_ = std::make_unique>( + engine->messenger(), "com.predidit.kazumi/windows_interface", + &flutter::StandardMethodCodec::GetInstance()); + + channel_->SetMethodCallHandler([this](const auto& call, auto result) { + if (call.method_name().compare("setWindowsTitleHeight") == 0) { + if (!call.arguments()) { + result->Error("InvalidArguments", "Missing title height"); + return; + } + std::optional height = ToInt64(*call.arguments()); + if (!height.has_value()) { + result->Error("InvalidArguments", "Title height must be a number"); + return; + } + SetWindowsTitleHeight(height.value()); + result->Success(); + return; + } + + if (call.method_name().compare("setWindowsTitleButtonWidth") == 0) { + if (!call.arguments()) { + result->Error("InvalidArguments", "Missing title button width"); + return; + } + std::optional width = ToInt64(*call.arguments()); + if (!width.has_value()) { + result->Error("InvalidArguments", + "Title button width must be a number"); + return; + } + SetWindowsTitleButtonWidth(width.value()); + result->Success(); + return; + } + + if (call.method_name().compare("setWindowsTitleTopInset") == 0) { + if (!call.arguments()) { + result->Error("InvalidArguments", "Missing title top inset"); + return; + } + std::optional inset = ToInt64(*call.arguments()); + if (!inset.has_value()) { + result->Error("InvalidArguments", "Title top inset must be a number"); + return; + } + SetWindowsTitleTopInset(inset.value()); + result->Success(); + return; + } + + if (call.method_name().compare("setWindowsTitleBarEnabled") == 0) { + if (!call.arguments()) { + result->Error("InvalidArguments", "Missing enabled flag"); + return; + } + std::optional enabled = ToBool(*call.arguments()); + if (!enabled.has_value()) { + result->Error("InvalidArguments", "Enabled flag must be bool"); + return; + } + SetEnabled(enabled.value()); + result->Success(); + return; + } + + result->NotImplemented(); + }); +} + +LRESULT CALLBACK WindowsInterface::WndProc(HWND hWnd, UINT message, + WPARAM wParam, LPARAM lParam) { + if (instance_ == nullptr || hWnd != instance_->flutter_hwnd_) { + return DefWindowProc(hWnd, message, wParam, lParam); + } + return instance_->HandleMessage(hWnd, message, wParam, lParam); +} + +LRESULT WindowsInterface::HandleMessage(HWND hWnd, UINT message, WPARAM wParam, + LPARAM lParam) { + if (!enabled_) { + return CallWindowProc(old_wnd_proc_, hWnd, message, wParam, lParam); + } + + switch (message) { + case WM_NCHITTEST: { + UpdateTitleButtonStatus(hWnd, lParam); + if (title_bar_hovered_button_ == CustomTitleBarHoveredButton_Maximize) { + return HTMAXBUTTON; + } else if (title_bar_hovered_button_ == + CustomTitleBarHoveredButton_Minimize) { + return HTMINBUTTON; + } else if (title_bar_hovered_button_ == CustomTitleBarHoveredButton_Close) { + return HTCLOSE; + } + return HTCLIENT; + } + case WM_NCMOUSEMOVE: + case WM_MOUSEMOVE: { + UpdateTitleButtonStatus(hWnd, lParam); + TRACKMOUSEEVENT track_event{ + sizeof(TRACKMOUSEEVENT), TME_LEAVE, hWnd, HOVER_DEFAULT}; + TrackMouseEvent(&track_event); + break; + } + case WM_MOUSELEAVE: + case WM_NCMOUSELEAVE: { + if (title_bar_hovered_button_ != CustomTitleBarHoveredButton_None) { + title_bar_hovered_button_ = CustomTitleBarHoveredButton_None; + OnTitleButtonHover(); + } + break; + } + case WM_NCLBUTTONDOWN: { + if (title_bar_hovered_button_ != CustomTitleBarHoveredButton_None) { + OnTitleButtonDown(); + title_bar_down_button_ = title_bar_hovered_button_; + return HTNOWHERE; + } + break; + } + case WM_NCLBUTTONUP: { + if (title_bar_hovered_button_ != CustomTitleBarHoveredButton_None) { + OnTitleButtonUp(); + if (title_bar_hovered_button_ == title_bar_down_button_) { + OnTitleButtonClick(); + } + title_bar_down_button_ = CustomTitleBarHoveredButton_None; + return HTNOWHERE; + } + title_bar_down_button_ = CustomTitleBarHoveredButton_None; + break; + } + default: + break; + } + + return CallWindowProc(old_wnd_proc_, hWnd, message, wParam, lParam); +} + +void WindowsInterface::UpdateTitleButtonStatus(HWND hWnd, LPARAM lParam) { + CustomTitleBarHoveredButton button = HitTestTitleBarButton(hWnd, lParam); + if (button == title_bar_hovered_button_) { + return; + } + title_bar_hovered_button_ = button; + OnTitleButtonHover(); +} + +CustomTitleBarHoveredButton WindowsInterface::HitTestTitleBarButton( + HWND hWnd, LPARAM lParam) const { + if (windows_title_height_ <= 0 || windows_title_button_width_ <= 0) { + return CustomTitleBarHoveredButton_None; + } + + POINT cursor = {GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)}; + if (!ScreenToClient(hWnd, &cursor)) { + return CustomTitleBarHoveredButton_None; + } + + RECT client_rect{}; + if (!GetClientRect(hWnd, &client_rect)) { + return CustomTitleBarHoveredButton_None; + } + + const int top = windows_title_top_inset_; + const int bottom = top + windows_title_height_; + if (cursor.y < top || cursor.y >= bottom) { + return CustomTitleBarHoveredButton_None; + } + + const int right_distance = client_rect.right - cursor.x; + if (right_distance <= 0) { + return CustomTitleBarHoveredButton_None; + } + + if (right_distance <= windows_title_button_width_) { + return CustomTitleBarHoveredButton_Close; + } + if (right_distance <= windows_title_button_width_ * 2) { + return CustomTitleBarHoveredButton_Maximize; + } + if (right_distance <= windows_title_button_width_ * 3) { + return CustomTitleBarHoveredButton_Minimize; + } + return CustomTitleBarHoveredButton_None; +} + +void WindowsInterface::OnTitleButtonHover() { + channel_->InvokeMethod( + "onTitleButtonHover", + std::make_unique( + static_cast(title_bar_hovered_button_))); +} + +void WindowsInterface::OnTitleButtonDown() { + channel_->InvokeMethod( + "onTitleButtonDown", + std::make_unique( + static_cast(title_bar_hovered_button_))); +} + +void WindowsInterface::OnTitleButtonUp() { + channel_->InvokeMethod( + "onTitleButtonUp", + std::make_unique( + static_cast(title_bar_hovered_button_))); +} + +void WindowsInterface::OnTitleButtonClick() { + channel_->InvokeMethod( + "onTitleButtonClick", + std::make_unique( + static_cast(title_bar_hovered_button_))); +} + +void WindowsInterface::SetWindowsTitleHeight(int64_t height) { + windows_title_height_ = static_cast(std::max(0, height)); +} + +void WindowsInterface::SetWindowsTitleButtonWidth(int64_t width) { + windows_title_button_width_ = + static_cast(std::max(0, width)); +} + +void WindowsInterface::SetWindowsTitleTopInset(int64_t inset) { + windows_title_top_inset_ = static_cast(std::max(0, inset)); +} + +void WindowsInterface::SetEnabled(bool enabled) { + enabled_ = enabled; + if (!enabled_) { + title_bar_hovered_button_ = CustomTitleBarHoveredButton_None; + title_bar_down_button_ = CustomTitleBarHoveredButton_None; + OnTitleButtonHover(); + } +} diff --git a/windows/runner/windows_interface/windows_interface.h b/windows/runner/windows_interface/windows_interface.h new file mode 100644 index 000000000..fb443d481 --- /dev/null +++ b/windows/runner/windows_interface/windows_interface.h @@ -0,0 +1,65 @@ +#ifndef RUNNER_WINDOWS_INTERFACE_WINDOWS_INTERFACE_H_ +#define RUNNER_WINDOWS_INTERFACE_WINDOWS_INTERFACE_H_ + +#include +#include +#include +#include + +#include +#include +#include + +enum CustomTitleBarHoveredButton { + CustomTitleBarHoveredButton_None = 0, + CustomTitleBarHoveredButton_Minimize = 1, + CustomTitleBarHoveredButton_Maximize = 2, + CustomTitleBarHoveredButton_Close = 3, +}; + +class WindowsInterface { + public: + static void RegisterPlugin(flutter::FlutterEngine* engine, HWND flutter_hwnd); + + ~WindowsInterface(); + + private: + WindowsInterface(flutter::FlutterEngine* engine, HWND flutter_hwnd); + + static LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, + LPARAM lParam); + + LRESULT HandleMessage(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam); + void RegisterChannel(flutter::FlutterEngine* engine); + void UpdateTitleButtonStatus(HWND hWnd, LPARAM lParam); + CustomTitleBarHoveredButton HitTestTitleBarButton(HWND hWnd, + LPARAM lParam) const; + + void OnTitleButtonHover(); + void OnTitleButtonDown(); + void OnTitleButtonUp(); + void OnTitleButtonClick(); + + void SetWindowsTitleHeight(int64_t height); + void SetWindowsTitleButtonWidth(int64_t width); + void SetWindowsTitleTopInset(int64_t inset); + void SetEnabled(bool enabled); + + static std::unique_ptr instance_; + + HWND flutter_hwnd_ = nullptr; + WNDPROC old_wnd_proc_ = nullptr; + std::unique_ptr> channel_; + + CustomTitleBarHoveredButton title_bar_hovered_button_ = + CustomTitleBarHoveredButton_None; + CustomTitleBarHoveredButton title_bar_down_button_ = + CustomTitleBarHoveredButton_None; + + int32_t windows_title_height_ = 32; + int32_t windows_title_button_width_ = 44; + int32_t windows_title_top_inset_ = 0; + bool enabled_ = true; +}; + +#endif // RUNNER_WINDOWS_INTERFACE_WINDOWS_INTERFACE_H_