diff --git a/lib/src/widgets/window_caption_button.dart b/lib/src/widgets/window_caption_button.dart index 5e1eecff..f1ff3a3a 100644 --- a/lib/src/widgets/window_caption_button.dart +++ b/lib/src/widgets/window_caption_button.dart @@ -44,7 +44,8 @@ class _IconChromeMinimizePainter extends CustomPainter { @override bool shouldRepaint(covariant CustomPainter oldDelegate) { - return false; + return oldDelegate is! _IconChromeMinimizePainter || + oldDelegate.color != color; } } @@ -109,7 +110,8 @@ class _IconChromeMaximizePainter extends CustomPainter { @override bool shouldRepaint(covariant CustomPainter oldDelegate) { - return false; + return oldDelegate is! _IconChromeMaximizePainter || + oldDelegate.color != color; } } @@ -197,7 +199,8 @@ class _IconChromeUnmaximizePainter extends CustomPainter { @override bool shouldRepaint(covariant CustomPainter oldDelegate) { - return false; + return oldDelegate is! _IconChromeUnmaximizePainter || + oldDelegate.color != color; } } @@ -251,7 +254,8 @@ class _IconChromeClosePainter extends CustomPainter { @override bool shouldRepaint(covariant CustomPainter oldDelegate) { - return false; + return oldDelegate is! _IconChromeClosePainter || + oldDelegate.color != color; } } @@ -328,7 +332,7 @@ class WindowCaptionButton extends StatefulWidget { normal: Colors.white, hovered: Colors.white, pressed: Colors.white.withValues(alpha: 0.786), - disabled: Colors.black.withValues(alpha: 0.3628), + disabled: Colors.white.withValues(alpha: 0.3628), ); final Brightness? brightness; @@ -356,7 +360,7 @@ class WindowCaptionButton extends StatefulWidget { normal: Colors.white, hovered: Colors.white, pressed: Colors.white.withValues(alpha: 0.786), - disabled: Colors.black.withValues(alpha: 0.3628), + disabled: Colors.white.withValues(alpha: 0.3628), ); _ButtonBgColorScheme get buttonBgColorScheme => brightness != Brightness.dark @@ -373,61 +377,94 @@ class WindowCaptionButton extends StatefulWidget { } class _WindowCaptionButtonState extends State { + // Whether a mouse pointer is over the button. Tracked even while the button + // is disabled so that re-enabling it under a resting pointer shows the hover + // state immediately. bool _isHovering = false; bool _isPressed = false; - void _onEntered({required bool hovered}) { - setState(() => _isHovering = hovered); + bool get _enabled => widget.onPressed != null; + + void _setHovering(bool hovering) { + if (_isHovering == hovering) return; + setState(() => _isHovering = hovering); } - void _onActive({required bool pressed}) { + void _setPressed(bool pressed) { + if (_isPressed == pressed) return; setState(() => _isPressed = pressed); } @override - Widget build(BuildContext context) { - Color bgColor = widget.buttonBgColorScheme.normal; - Color iconColor = widget.buttonIconColorScheme.normal; + void didUpdateWidget(covariant WindowCaptionButton oldWidget) { + super.didUpdateWidget(oldWidget); + // A press in progress cannot complete once the button is disabled. + if (!_enabled) _isPressed = false; + } - if (_isHovering) { - bgColor = widget.buttonBgColorScheme.hovered; - iconColor = widget.buttonIconColorScheme.hovered; + Widget _buildIcon(Color iconColor) { + final customIcon = widget.icon; + if (customIcon != null) { + // Let Icon-based custom icons follow the button's state colors. + return IconTheme.merge( + data: IconThemeData(color: iconColor, size: 16), + child: customIcon, + ); } - if (_isPressed) { - bgColor = widget.buttonBgColorScheme.pressed; - iconColor = widget.buttonIconColorScheme.pressed; + return WindowCaptionButtonIcon( + color: iconColor, + createPainter: (color) { + switch (widget.iconName) { + case _kIconChromeMinimize: + return _IconChromeMinimizePainter(color!); + case _kIconChromeMaximize: + return _IconChromeMaximizePainter(color!); + case _kIconChromeUnmaximize: + return _IconChromeUnmaximizePainter(color!); + case _kIconChromeClose: + default: + return _IconChromeClosePainter(color!); + } + }, + ); + } + + @override + Widget build(BuildContext context) { + final enabled = _enabled; + final bgScheme = widget.buttonBgColorScheme; + final iconScheme = widget.buttonIconColorScheme; + + Color bgColor = bgScheme.normal; + Color iconColor = iconScheme.normal; + if (!enabled) { + iconColor = iconScheme.disabled; + } else if (_isPressed) { + bgColor = bgScheme.pressed; + iconColor = iconScheme.pressed; + } else if (_isHovering) { + bgColor = bgScheme.hovered; + iconColor = iconScheme.hovered; } - return MouseRegion( - onExit: (value) => _onEntered(hovered: false), - onHover: (value) => _onEntered(hovered: true), - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTapDown: (_) => _onActive(pressed: true), - onTapCancel: () => _onActive(pressed: false), - onTapUp: (_) => _onActive(pressed: false), - onTap: widget.onPressed, - child: Container( - constraints: const BoxConstraints(minWidth: 46, minHeight: 32), - decoration: BoxDecoration(color: bgColor), - child: Center( - child: WindowCaptionButtonIcon( - color: iconColor, - createPainter: (color) { - switch (widget.iconName) { - case _kIconChromeMinimize: - return _IconChromeMinimizePainter(color!); - case _kIconChromeMaximize: - return _IconChromeMaximizePainter(color!); - case _kIconChromeUnmaximize: - return _IconChromeUnmaximizePainter(color!); - case _kIconChromeClose: - return _IconChromeClosePainter(color!); - default: - return _IconChromeClosePainter(color!); - } - }, - ), + return Semantics( + button: true, + enabled: enabled, + child: MouseRegion( + // onEnter/onExit fire only on boundary crossings, unlike onHover which + // fires for every pointer movement inside the button. + onEnter: (_) => _setHovering(true), + onExit: (_) => _setHovering(false), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: enabled ? (_) => _setPressed(true) : null, + onTapCancel: enabled ? () => _setPressed(false) : null, + onTapUp: enabled ? (_) => _setPressed(false) : null, + onTap: widget.onPressed, + child: Container( + constraints: const BoxConstraints(minWidth: 46, minHeight: 32), + decoration: BoxDecoration(color: bgColor), + child: Center(child: _buildIcon(iconColor)), ), ), ), diff --git a/test/window_caption_button_test.dart b/test/window_caption_button_test.dart new file mode 100644 index 00000000..16be60b5 --- /dev/null +++ b/test/window_caption_button_test.dart @@ -0,0 +1,218 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:window_manager/window_manager.dart'; + +// Caption buttons are laid out in a Row; mirror that sizing here. +Widget _host(Widget button) => MaterialApp( + home: Center(child: UnconstrainedBox(child: button)), +); + +Color? _background(WidgetTester tester) { + final container = tester.widget( + find.descendant( + of: find.byType(WindowCaptionButton), + matching: find.byType(Container), + ), + ); + return (container.decoration! as BoxDecoration).color; +} + +CustomPainter _painter(WidgetTester tester) => tester + .widget( + find.descendant( + of: find.byType(WindowCaptionButtonIcon), + matching: find.byType(CustomPaint), + ), + ) + .painter!; + +Color? _iconColor(WidgetTester tester) => tester + .widget(find.byType(WindowCaptionButtonIcon)) + .color; + +Future _mouse(WidgetTester tester) async { + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + await tester.pump(); + return gesture; +} + +void main() { + testWidgets('chrome icon painters repaint when the icon color changes', ( + tester, + ) async { + await tester.pumpWidget( + _host( + WindowCaptionButton.close( + brightness: Brightness.light, + onPressed: () {}, + ), + ), + ); + final light = _painter(tester); + await tester.pumpWidget( + _host( + WindowCaptionButton.close( + brightness: Brightness.dark, + onPressed: () {}, + ), + ), + ); + final dark = _painter(tester); + + expect(dark.shouldRepaint(light), isTrue); + expect(dark.shouldRepaint(dark), isFalse); + + for (final button in [ + WindowCaptionButton.minimize(onPressed: () {}), + WindowCaptionButton.maximize(onPressed: () {}), + WindowCaptionButton.unmaximize(onPressed: () {}), + ]) { + await tester.pumpWidget(_host(button)); + final normal = _painter(tester); + final down = await tester.startGesture( + tester.getCenter(find.byType(WindowCaptionButton)), + ); + await tester.pump(); + final pressed = _painter(tester); + expect(pressed.shouldRepaint(normal), isTrue, reason: '$button'); + await down.up(); + await tester.pump(); + } + }); + + testWidgets('hover is shown on enter, cleared on exit, without per-move ' + 'rebuilds', (tester) async { + await tester.pumpWidget(_host(WindowCaptionButton.close(onPressed: () {}))); + final center = tester.getCenter(find.byType(WindowCaptionButton)); + final mouse = await _mouse(tester); + expect(_background(tester), Colors.transparent); + + await mouse.moveTo(center); + expect(tester.binding.hasScheduledFrame, isTrue); + await tester.pump(); + expect(_background(tester), const Color(0xffC42B1C)); + + // Moving within the button must not schedule another frame. + await mouse.moveTo(center + const Offset(3, 2)); + expect(tester.binding.hasScheduledFrame, isFalse); + + await mouse.moveTo(Offset.zero); + await tester.pump(); + expect(_background(tester), Colors.transparent); + }); + + testWidgets('a custom icon is rendered and follows the state color', ( + tester, + ) async { + await tester.pumpWidget( + _host( + WindowCaptionButton( + brightness: Brightness.light, + icon: const Icon(Icons.push_pin), + onPressed: () {}, + ), + ), + ); + expect(find.byIcon(Icons.push_pin), findsOneWidget); + expect(find.byType(WindowCaptionButtonIcon), findsNothing); + + IconThemeData theme() => + IconTheme.of(tester.element(find.byIcon(Icons.push_pin))); + expect(theme().color, Colors.black.withValues(alpha: 0.8956)); + expect(theme().size, 16); + + await tester.pumpWidget( + _host( + WindowCaptionButton( + brightness: Brightness.light, + icon: const Icon(Icons.push_pin), + onPressed: null, + ), + ), + ); + expect(theme().color, Colors.black.withValues(alpha: 0.3614)); + }); + + testWidgets('a disabled button shows the disabled color and ignores input', ( + tester, + ) async { + await tester.pumpWidget( + _host( + WindowCaptionButton.close(brightness: Brightness.dark, onPressed: null), + ), + ); + // Light-on-dark: the disabled glyph must stay visible on a dark caption. + expect(_iconColor(tester), Colors.white.withValues(alpha: 0.3628)); + + final center = tester.getCenter(find.byType(WindowCaptionButton)); + final mouse = await _mouse(tester); + await mouse.moveTo(center); + await tester.pump(); + final down = await tester.startGesture(center); + await tester.pump(); + expect(_background(tester), Colors.transparent); + expect(_iconColor(tester), Colors.white.withValues(alpha: 0.3628)); + await down.up(); + }); + + testWidgets('disabling during a press and re-enabling under the pointer', ( + tester, + ) async { + var taps = 0; + Future show({required bool enabled}) => tester.pumpWidget( + _host( + WindowCaptionButton.minimize( + brightness: Brightness.light, + onPressed: enabled ? () => taps++ : null, + ), + ), + ); + + await show(enabled: true); + final center = tester.getCenter(find.byType(WindowCaptionButton)); + final mouse = await _mouse(tester); + await mouse.moveTo(center); + final down = await tester.startGesture(center); + await tester.pump(); + final pressedColor = Colors.black.withValues(alpha: 0.0241); + expect(_background(tester), pressedColor); + + await show(enabled: false); + expect(_background(tester), Colors.transparent); + await down.up(); + expect(taps, 0); + + // The pointer never left, so re-enabling shows the hover state at once. + await show(enabled: true); + expect(_background(tester), Colors.black.withValues(alpha: 0.0373)); + + await tester.tap(find.byType(WindowCaptionButton)); + expect(taps, 1); + }); + + testWidgets('exposes button semantics with the enabled state', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + await tester.pumpWidget(_host(WindowCaptionButton.close(onPressed: () {}))); + expect( + tester.getSemantics(find.byType(WindowCaptionButton)), + matchesSemantics( + isButton: true, + hasEnabledState: true, + isEnabled: true, + hasTapAction: true, + ), + ); + + await tester.pumpWidget(_host(WindowCaptionButton.close(onPressed: null))); + expect( + tester.getSemantics(find.byType(WindowCaptionButton)), + matchesSemantics(isButton: true, hasEnabledState: true), + ); + handle.dispose(); + }); +}