diff --git a/.github/workflows/dart_code_metrics.yaml b/.github/workflows/dart_code_metrics.yaml index 964933df..a22ecac7 100644 --- a/.github/workflows/dart_code_metrics.yaml +++ b/.github/workflows/dart_code_metrics.yaml @@ -9,7 +9,7 @@ jobs: - uses: actions/checkout@v3 - name: Run Dart Code Metrics - uses: dart-code-checker/dart-code-metrics-action@v2 + uses: dart-code-checker/dart-code-metrics-action@v3 with: github_token: ${{ secrets.GITHUB_TOKEN }} pull_request_comment: true diff --git a/.gitignore b/.gitignore index 2baf544e..70efb322 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,8 @@ .pub-cache/ .pub/ build/ +.fvm/ +metrics # Android related **/android/**/gradle-wrapper.jar diff --git a/CHANGELOG.md b/CHANGELOG.md index 343ebb3d..994154c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## [1.10.0] +🚨 Breaking Changes 🚨 +* `MacosScrollbar` has been completely overhauled and now resembles the native macOS scrollbar in appearance and + behavior. Previously, it wrapped the material scrollbar, and now creates a custom scrollbar that extends + `RawScrollbar`. This resulted in the removal of several material-based properties for the scrollbar, and + `ContentArea.builder` is once again a `ScrollableWidgetBuilder`! 🎉 +* Removed material-based scrollbar properties from `MacosScrollbarThemeData` + +Other changes: +* Added implementation of `MacosDisclosureButton` +* Fixed a bug where `CapacityIndicator` only worked correctly for splits = 10 + ## [1.9.1] * Adds optional `initialDate` to `MacosDatePicker` @@ -7,14 +19,14 @@ ## [1.8.0] 🚨 Breaking Changes 🚨 * `ContentArea.builder` has been changed from a `ScrollableWidgetBuilder` to a `WidgetBuilder` due to -changes in Flutter 3.7. The `MacosScrollBar` widget needs to undergo radical changes in order to achieve the +changes in Flutter 3.7. The `MacosScrollbar` widget needs to undergo radical changes in order to achieve the native macOS scrollbar look and feel in the future, so this will be revisited at that time. -Other changes +Other changes: * Per Flutter 3.7.0: Replace deprecated `MacosTextField.toolbarOptions` with `MacosTextField.contextMenuBuilder` * Ensure the color panel releases when it is closed * Avoid render overflows in the `Sidebar` when the window height is resized below a certain threshold ([#325](https://github.com/GroovinChip/macos_ui/issues/325)) -* Update `MacosScrollBar.thumbVisibility` with the latest change introduced in Flutter 3.7 +* Update `MacosScrollbar.thumbVisibility` with the latest change introduced in Flutter 3.7 * Update `README.md` to address issues [#325](https://github.com/GroovinChip/macos_ui/issues/325) & [#332](https://github.com/GroovinChip/macos_ui/issues/332) ## [1.7.6] @@ -238,7 +250,7 @@ leading widget, and the font size of the item's label widget according to the gi * Added `==` and `hashCode` to various classes ## [0.7.1] -* Add generics support to `MacosRadioButton` - Thank you [Sacha Arbonel](https://github.com/sachaarbonel)! +* Add generics support to `MacosRadioButton` - Thank you, [Sacha Arbonel](https://github.com/sachaarbonel)! ## [0.7.0+2] * Add note in docs that a `Builder` is required for manual sidebar toggling to work. @@ -295,7 +307,7 @@ leading widget, and the font size of the item's label widget according to the gi * `Switch` -> `MacosSwitch` ## [0.2.4] -* Fix textfield prefix icon alignment +* Fix text field prefix icon alignment ## [0.2.3] * Add `canvasColor` to `MacosThemeData`. `Scaffold` now uses this as its default background color. @@ -327,7 +339,7 @@ leading widget, and the font size of the item's label widget according to the gi * Updated the theme api * Properties in `MacosThemeData` and in `Typography` can't be null * Renamed `DynamicColorX` to `MacosDynamicColor` - * Added the method `lerp` on all theme datas. + * Added the method `lerp` on all theme data classes. ## [0.1.1] * Implemented `Label` ([#61](https://github.com/GroovinChip/macos_ui/issues/61)) diff --git a/README.md b/README.md index a697ca0c..663d9d65 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Guides, codelabs, and other documentation can be found at https://macosui.dev [![pub package](https://img.shields.io/pub/v/macos_ui.svg)](https://pub.dev/packages/macos_ui) [![pub package](https://img.shields.io/pub/publisher/macos_ui.svg)](https://pub.dev/packages/macos_ui) -[![Flutter Analysis](https://github.com/GroovinChip/macos_ui/actions/workflows/flutter_analysis.yml/badge.svg)](https://github.com/GroovinChip/macos_ui/actions/workflows/flutter_analysis.yml) +[![Flutter Analysis](https://github.com/GroovinChip/macos_ui/actions/workflows/flutter_analysis.yml/badge.svg?branch=stable)](https://github.com/GroovinChip/macos_ui/actions/workflows/flutter_analysis.yml) [![Pana Analysis](https://github.com/GroovinChip/macos_ui/actions/workflows/pana_analysis.yml/badge.svg)](https://github.com/GroovinChip/macos_ui/actions/workflows/pana_analysis.yml) [![codecov](https://github.com/GroovinChip/macos_ui/actions/workflows/codecov.yaml/badge.svg)](https://github.com/GroovinChip/macos_ui/actions/workflows/codecov.yaml) [![codecov](https://codecov.io/gh/GroovinChip/macos_ui/branch/dev/graph/badge.svg?token=1SZGEVVMCH)](https://codecov.io/gh/GroovinChip/macos_ui) diff --git a/analysis_options.yaml b/analysis_options.yaml index e71274dd..d4a906dd 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -8,7 +8,8 @@ analyzer: plugins: - dart_code_metrics exclude: - - starter_app/** + - test/mock_canvas.dart + - test/recording_canvas.dart dart_code_metrics: metrics: @@ -25,3 +26,12 @@ dart_code_metrics: order: - constructors - public_fields + - private-fields + widgets-order: + - const fields + - init-state-method + - did-change-dependencies-method + - did-update-widget-method + - private-methods + - dispose-method + - build-method diff --git a/example/lib/main.dart b/example/lib/main.dart index 0b33c9af..447f1fcd 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -171,11 +171,11 @@ class _WidgetGalleryState extends State { ], ), minWidth: 200, - builder: (context, _) { + builder: (context, scrollController) { return SidebarItems( currentIndex: pageIndex, onChanged: (i) => setState(() => pageIndex = i), - scrollController: ScrollController(), + scrollController: scrollController, itemSize: SidebarItemSize.large, items: [ const SidebarItem( diff --git a/example/lib/pages/buttons_page.dart b/example/lib/pages/buttons_page.dart index 29722f3b..dafc5ea2 100644 --- a/example/lib/pages/buttons_page.dart +++ b/example/lib/pages/buttons_page.dart @@ -16,6 +16,7 @@ class _ButtonsPageState extends State { String popupValue = 'One'; String languagePopupValue = 'English'; bool switchValue = false; + bool isDisclosureButtonPressed = false; final _tabController = MacosTabController(initialIndex: 0, length: 3); @override @@ -70,9 +71,9 @@ class _ButtonsPageState extends State { }, ), ContentArea( - builder: (context) { + builder: (context, scrollController) { return SingleChildScrollView( - // controller: _, + controller: scrollController, padding: const EdgeInsets.all(20), child: Column( children: [ @@ -92,6 +93,23 @@ class _ButtonsPageState extends State { ], ), const SizedBox(height: 20), + const Text('MacosDisclosureButton'), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + MacosDisclosureButton( + isPressed: isDisclosureButtonPressed, + onPressed: () { + debugPrint('click'); + setState(() { + isDisclosureButtonPressed = + !isDisclosureButtonPressed; + }); + }), + ], + ), + const SizedBox(height: 20), const Text('MacosIconButton'), const SizedBox(height: 8), Row( @@ -150,7 +168,7 @@ class _ButtonsPageState extends State { ), children: [ ContentArea( - builder: (context) { + builder: (context, _) { return Center( child: PushButton( buttonSize: ButtonSize.large, diff --git a/example/lib/pages/colors_page.dart b/example/lib/pages/colors_page.dart index 37bc59f2..7a9df4fe 100644 --- a/example/lib/pages/colors_page.dart +++ b/example/lib/pages/colors_page.dart @@ -28,7 +28,7 @@ class _ColorsPageState extends State { ), children: [ ContentArea( - builder: (context) { + builder: (context, scrollController) { return SingleChildScrollView( padding: const EdgeInsets.all(20), child: Column( diff --git a/example/lib/pages/dialogs_page.dart b/example/lib/pages/dialogs_page.dart index 53db5028..b7645978 100644 --- a/example/lib/pages/dialogs_page.dart +++ b/example/lib/pages/dialogs_page.dart @@ -29,7 +29,7 @@ class _DialogsPageState extends State { ), children: [ ContentArea( - builder: (context) { + builder: (context, scrollController) { return SingleChildScrollView( padding: const EdgeInsets.all(20), child: Center( diff --git a/example/lib/pages/fields_page.dart b/example/lib/pages/fields_page.dart index e7865f98..906cb13f 100644 --- a/example/lib/pages/fields_page.dart +++ b/example/lib/pages/fields_page.dart @@ -28,7 +28,7 @@ class _FieldsPageState extends State { ), children: [ ContentArea( - builder: (context) { + builder: (context, scrollController) { return SingleChildScrollView( padding: const EdgeInsets.all(20), child: Column( diff --git a/example/lib/pages/indicators_page.dart b/example/lib/pages/indicators_page.dart index 58a6d391..1dd4caf5 100644 --- a/example/lib/pages/indicators_page.dart +++ b/example/lib/pages/indicators_page.dart @@ -33,20 +33,21 @@ class _IndicatorsPageState extends State { ), children: [ ContentArea( - builder: (context) { + builder: (context, scrollController) { return SingleChildScrollView( padding: const EdgeInsets.all(20), child: Column( children: [ CapacityIndicator( - value: sliderValue, - onChanged: (v) => setState(() => sliderValue = v), + value: capacitorValue, + onChanged: (v) => setState(() => capacitorValue = v), + splits: 20, discrete: true, ), const SizedBox(height: 20), CapacityIndicator( - value: sliderValue, - onChanged: (v) => setState(() => sliderValue = v), + value: capacitorValue, + onChanged: (v) => setState(() => capacitorValue = v), ), const SizedBox(height: 20), MacosSlider( diff --git a/example/lib/pages/selectors_page.dart b/example/lib/pages/selectors_page.dart index a8c1634a..8256a33e 100644 --- a/example/lib/pages/selectors_page.dart +++ b/example/lib/pages/selectors_page.dart @@ -28,9 +28,9 @@ class _SelectorsPageState extends State { ), children: [ ContentArea( - builder: (context) { + builder: (context, scrollController) { return SingleChildScrollView( - // controller: scrollController, + controller: scrollController, padding: const EdgeInsets.all(20), child: Column( children: [ diff --git a/example/lib/pages/tabview_page.dart b/example/lib/pages/tabview_page.dart index 2497c2ea..64d4d8ff 100644 --- a/example/lib/pages/tabview_page.dart +++ b/example/lib/pages/tabview_page.dart @@ -22,7 +22,7 @@ class _TabViewPageState extends State { ), children: [ ContentArea( - builder: (context) { + builder: (context, _) { return Padding( padding: const EdgeInsets.all(24.0), child: MacosTabView( diff --git a/example/lib/pages/toolbar_page.dart b/example/lib/pages/toolbar_page.dart index 4e77fc86..af01f229 100644 --- a/example/lib/pages/toolbar_page.dart +++ b/example/lib/pages/toolbar_page.dart @@ -156,26 +156,28 @@ class _ToolbarPageState extends State { ], ), children: [ - ContentArea(builder: (context) { - return SingleChildScrollView( - padding: const EdgeInsets.all(30), - child: Center( - child: Column( - children: const [ - Text( - "The toolbar appears below the title bar of the macOS app or integrates with it.", - textAlign: TextAlign.center, - ), - SizedBox(height: 20.0), - Text( - "It provides convenient access to frequently used commands and features.", - textAlign: TextAlign.center, - ), - ], + ContentArea( + builder: (context, scrollController) { + return SingleChildScrollView( + padding: const EdgeInsets.all(30), + child: Center( + child: Column( + children: const [ + Text( + "The toolbar appears below the title bar of the macOS app or integrates with it.", + textAlign: TextAlign.center, + ), + SizedBox(height: 20.0), + Text( + "It provides convenient access to frequently used commands and features.", + textAlign: TextAlign.center, + ), + ], + ), ), - ), - ); - }), + ); + }, + ), ], ); } diff --git a/example/pubspec.lock b/example/pubspec.lock index b0517c99..45cafbfa 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -97,7 +97,7 @@ packages: path: ".." relative: true source: path - version: "1.9.1" + version: "1.10.0" matcher: dependency: transitive description: diff --git a/lib/macos_ui.dart b/lib/macos_ui.dart index 619f0956..4bd80c30 100644 --- a/lib/macos_ui.dart +++ b/lib/macos_ui.dart @@ -16,6 +16,7 @@ library macos_ui; export 'src/buttons/back_button.dart'; export 'src/buttons/checkbox.dart'; +export 'src/buttons/disclosure_button.dart'; export 'src/buttons/help_button.dart'; export 'src/buttons/icon_button.dart'; export 'src/buttons/popup_button.dart'; @@ -36,7 +37,7 @@ export 'src/indicators/capacity_indicators.dart'; export 'src/indicators/progress_indicators.dart'; export 'src/indicators/rating_indicator.dart'; export 'src/indicators/relevance_indicator.dart'; -export 'src/indicators/scrollbar.dart'; +export 'src/layout/scrollbar.dart'; export 'src/indicators/slider.dart'; export 'src/labels/label.dart'; export 'src/labels/tooltip.dart'; diff --git a/lib/src/buttons/back_button.dart b/lib/src/buttons/back_button.dart index 088b382c..5bce067b 100644 --- a/lib/src/buttons/back_button.dart +++ b/lib/src/buttons/back_button.dart @@ -88,12 +88,6 @@ class MacosBackButtonState extends State _opacityTween.end = 1.0; } - @override - void dispose() { - _animationController.dispose(); - super.dispose(); - } - @visibleForTesting bool buttonHeldDown = false; @@ -129,6 +123,12 @@ class MacosBackButtonState extends State }); } + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final bool enabled = widget.enabled; diff --git a/lib/src/buttons/disclosure_button.dart b/lib/src/buttons/disclosure_button.dart new file mode 100644 index 00000000..3a8a3d6b --- /dev/null +++ b/lib/src/buttons/disclosure_button.dart @@ -0,0 +1,199 @@ +import 'package:flutter/foundation.dart'; +import 'package:macos_ui/macos_ui.dart'; +import 'package:macos_ui/src/library.dart'; + +/// A macOS style disclosure button. +class MacosDisclosureButton extends StatefulWidget { + /// Creates a `DisclosureButton` with the appropriate icon/background colors based + /// on light/dark themes. + const MacosDisclosureButton({ + super.key, + this.fillColor, + this.semanticLabel, + this.isPressed = false, + this.mouseCursor = SystemMouseCursors.basic, + this.onPressed, + }); + + /// The callback that is called when the button is tapped. + /// + /// If this is set to null, the button will be disabled. + final VoidCallback? onPressed; + + /// The color to fill the space around the icon with. + final Color? fillColor; + + /// The semantic label used by screen readers. + final String? semanticLabel; + + /// The mouse cursor to use when hovering over this widget. + final MouseCursor? mouseCursor; + + /// Whether the button is in the active state (chevron pointing up) + /// or inactive state (chevron pointing down). + final bool isPressed; + + /// Whether the button is enabled or disabled. Buttons are disabled by default. + /// + /// To enable a button, set its [onPressed] property to a non-null value. + bool get enabled => onPressed != null; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(ColorProperty('fillColor', fillColor)); + properties.add(ColorProperty('hoverColor', fillColor)); + properties.add(StringProperty('semanticLabel', semanticLabel)); + properties.add(FlagProperty( + 'enabled', + value: enabled, + ifFalse: 'disabled', + )); + } + + @override + MacosDisclosureButtonState createState() => MacosDisclosureButtonState(); +} + +class MacosDisclosureButtonState extends State + with SingleTickerProviderStateMixin { + static const Duration kFadeOutDuration = Duration(milliseconds: 10); + static const Duration kFadeInDuration = Duration(milliseconds: 100); + final Tween _opacityTween = Tween(begin: 1.0); + + late AnimationController _animationController; + late Animation _opacityAnimation; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: const Duration(milliseconds: 200), + value: 0.0, + vsync: this, + ); + _opacityAnimation = _animationController + .drive(CurveTween(curve: Curves.decelerate)) + .drive(_opacityTween); + _setTween(); + } + + @override + void didUpdateWidget(MacosDisclosureButton oldWidget) { + super.didUpdateWidget(oldWidget); + _setTween(); + } + + void _setTween() { + _opacityTween.end = 1.0; + } + + @visibleForTesting + bool buttonHeldDown = false; + + void _handleTapDown(TapDownDetails event) { + if (!buttonHeldDown) { + buttonHeldDown = true; + _animate(); + } + } + + void _handleTapUp(TapUpDetails event) { + if (buttonHeldDown) { + buttonHeldDown = false; + _animate(); + } + } + + void _handleTapCancel() { + if (buttonHeldDown) { + buttonHeldDown = false; + _animate(); + } + } + + void _animate() { + if (_animationController.isAnimating) return; + final bool wasHeldDown = buttonHeldDown; + final TickerFuture ticker = buttonHeldDown + ? _animationController.animateTo(1.0, duration: kFadeOutDuration) + : _animationController.animateTo(0.0, duration: kFadeInDuration); + ticker.then((void value) { + if (mounted && wasHeldDown != buttonHeldDown) _animate(); + }); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final bool enabled = widget.enabled; + final brightness = MacosTheme.of(context).brightness; + final iconColor = brightness == Brightness.dark + ? CupertinoColors.white + : CupertinoColors.black; + + Color? fillColor; + if (widget.fillColor != null) { + fillColor = widget.fillColor; + } else { + fillColor = brightness == Brightness.dark + ? const Color(0xff323232) + : const Color(0xffF4F5F5); + } + + return MouseRegion( + cursor: widget.mouseCursor!, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: enabled ? _handleTapDown : null, + onTapUp: enabled ? _handleTapUp : null, + onTapCancel: enabled ? _handleTapCancel : null, + onTap: () { + if (enabled) { + widget.onPressed!(); + } + }, + child: Semantics( + button: true, + child: ConstrainedBox( + constraints: const BoxConstraints( + minWidth: 20, + minHeight: 20, + ), + child: FadeTransition( + opacity: _opacityAnimation, + child: AnimatedBuilder( + animation: _opacityAnimation, + builder: (context, widget1) { + return DecoratedBox( + decoration: BoxDecoration( + color: buttonHeldDown + ? brightness == Brightness.dark + ? const MacosColor(0xff3C383C) + : const MacosColor(0xffE5E5E5) + : fillColor, + borderRadius: BorderRadius.circular(7), + ), + child: RotatedBox( + quarterTurns: widget.isPressed ? 1 : 3, + child: Icon( + CupertinoIcons.back, + size: 14, + color: iconColor, + ), + ), + ); + }, + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/src/buttons/help_button.dart b/lib/src/buttons/help_button.dart index 70ae07f7..0c5d5ece 100644 --- a/lib/src/buttons/help_button.dart +++ b/lib/src/buttons/help_button.dart @@ -114,12 +114,6 @@ class HelpButtonState extends State _opacityTween.end = widget.pressedOpacity ?? 1.0; } - @override - void dispose() { - _animationController.dispose(); - super.dispose(); - } - @visibleForTesting bool buttonHeldDown = false; @@ -155,6 +149,12 @@ class HelpButtonState extends State }); } + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final bool enabled = widget.enabled; diff --git a/lib/src/buttons/icon_button.dart b/lib/src/buttons/icon_button.dart index e8e6e9f5..9b339aa7 100644 --- a/lib/src/buttons/icon_button.dart +++ b/lib/src/buttons/icon_button.dart @@ -161,12 +161,6 @@ class MacosIconButtonState extends State _opacityTween.end = widget.pressedOpacity ?? 1.0; } - @override - void dispose() { - _animationController.dispose(); - super.dispose(); - } - @visibleForTesting bool buttonHeldDown = false; @@ -202,6 +196,12 @@ class MacosIconButtonState extends State }); } + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final bool enabled = widget.enabled; diff --git a/lib/src/buttons/popup_button.dart b/lib/src/buttons/popup_button.dart index 481a25fe..a08cb113 100644 --- a/lib/src/buttons/popup_button.dart +++ b/lib/src/buttons/popup_button.dart @@ -1007,11 +1007,6 @@ class _MacosPopupButtonState extends State> late Map> _actionMap; late FocusHighlightMode _focusHighlightMode; - // Only used if needed to create _internalNode. - FocusNode _createFocusNode() { - return FocusNode(debugLabel: '${widget.runtimeType}'); - } - @override void initState() { super.initState(); @@ -1034,14 +1029,22 @@ class _MacosPopupButtonState extends State> } @override - void dispose() { - WidgetsBinding.instance.removeObserver(this); - _removeMacosPopupRoute(); - WidgetsBinding.instance.focusManager - .removeHighlightModeListener(_handleFocusHighlightModeChange); - focusNode!.removeListener(_handleFocusChanged); - _internalNode?.dispose(); - super.dispose(); + void didUpdateWidget(MacosPopupButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.focusNode != oldWidget.focusNode) { + oldWidget.focusNode?.removeListener(_handleFocusChanged); + if (widget.focusNode == null) { + _internalNode ??= _createFocusNode(); + } + _hasPrimaryFocus = focusNode!.hasPrimaryFocus; + focusNode!.addListener(_handleFocusChanged); + } + _updateSelectedIndex(); + } + + // Only used if needed to create _internalNode. + FocusNode _createFocusNode() { + return FocusNode(debugLabel: '${widget.runtimeType}'); } void _removeMacosPopupRoute() { @@ -1062,20 +1065,6 @@ class _MacosPopupButtonState extends State> setState(() => _focusHighlightMode = mode); } - @override - void didUpdateWidget(MacosPopupButton oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.focusNode != oldWidget.focusNode) { - oldWidget.focusNode?.removeListener(_handleFocusChanged); - if (widget.focusNode == null) { - _internalNode ??= _createFocusNode(); - } - _hasPrimaryFocus = focusNode!.hasPrimaryFocus; - focusNode!.addListener(_handleFocusChanged); - } - _updateSelectedIndex(); - } - void _updateSelectedIndex() { if (widget.items == null || widget.items!.isEmpty || @@ -1100,9 +1089,6 @@ class _MacosPopupButtonState extends State> } } - TextStyle? get _textStyle => - widget.style ?? MacosTheme.of(context).typography.body; - void _handleTap() { final TextDirection? textDirection = Directionality.maybeOf(context); const EdgeInsetsGeometry menuMargin = @@ -1161,6 +1147,20 @@ class _MacosPopupButtonState extends State> widget.onTap?.call(); } + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _removeMacosPopupRoute(); + WidgetsBinding.instance.focusManager + .removeHighlightModeListener(_handleFocusHighlightModeChange); + focusNode!.removeListener(_handleFocusChanged); + _internalNode?.dispose(); + super.dispose(); + } + + TextStyle? get _textStyle => + widget.style ?? MacosTheme.of(context).typography.body; + bool get _enabled => widget.items != null && widget.items!.isNotEmpty && diff --git a/lib/src/buttons/pulldown_button.dart b/lib/src/buttons/pulldown_button.dart index 9d218364..dbe7e0d1 100644 --- a/lib/src/buttons/pulldown_button.dart +++ b/lib/src/buttons/pulldown_button.dart @@ -746,11 +746,6 @@ class _MacosPulldownButtonState extends State late FocusHighlightMode _focusHighlightMode; PulldownButtonState _pullDownButtonState = PulldownButtonState.enabled; - // Only used if needed to create _internalNode. - FocusNode _createFocusNode() { - return FocusNode(debugLabel: '${widget.runtimeType}'); - } - @override void initState() { super.initState(); @@ -772,14 +767,21 @@ class _MacosPulldownButtonState extends State } @override - void dispose() { - WidgetsBinding.instance.removeObserver(this); - _removeMacosPulldownRoute(); - WidgetsBinding.instance.focusManager - .removeHighlightModeListener(_handleFocusHighlightModeChange); - focusNode!.removeListener(_handleFocusChanged); - _internalNode?.dispose(); - super.dispose(); + void didUpdateWidget(MacosPulldownButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.focusNode != oldWidget.focusNode) { + oldWidget.focusNode?.removeListener(_handleFocusChanged); + if (widget.focusNode == null) { + _internalNode ??= _createFocusNode(); + } + _hasPrimaryFocus = focusNode!.hasPrimaryFocus; + focusNode!.addListener(_handleFocusChanged); + } + } + + // Only used if needed to create _internalNode. + FocusNode _createFocusNode() { + return FocusNode(debugLabel: '${widget.runtimeType}'); } void _removeMacosPulldownRoute() { @@ -800,19 +802,6 @@ class _MacosPulldownButtonState extends State setState(() => _focusHighlightMode = mode); } - @override - void didUpdateWidget(MacosPulldownButton oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.focusNode != oldWidget.focusNode) { - oldWidget.focusNode?.removeListener(_handleFocusChanged); - if (widget.focusNode == null) { - _internalNode ??= _createFocusNode(); - } - _hasPrimaryFocus = focusNode!.hasPrimaryFocus; - focusNode!.addListener(_handleFocusChanged); - } - } - TextStyle? get _textStyle => widget.style ?? MacosTheme.of(context).typography.body; @@ -885,6 +874,17 @@ class _MacosPulldownButtonState extends State } } + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _removeMacosPulldownRoute(); + WidgetsBinding.instance.focusManager + .removeHighlightModeListener(_handleFocusHighlightModeChange); + focusNode!.removeListener(_handleFocusChanged); + _internalNode?.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final buttonHeight = _hasIcon ? 28.0 : 20.0; diff --git a/lib/src/buttons/push_button.dart b/lib/src/buttons/push_button.dart index 27759e1c..6f759c1d 100644 --- a/lib/src/buttons/push_button.dart +++ b/lib/src/buttons/push_button.dart @@ -179,15 +179,6 @@ class PushButtonState extends State _opacityTween.end = widget.pressedOpacity ?? 1.0; } - @override - void dispose() { - _animationController.dispose(); - super.dispose(); - } - - @visibleForTesting - bool buttonHeldDown = false; - void _handleTapDown(TapDownDetails event) { if (!buttonHeldDown) { buttonHeldDown = true; @@ -220,6 +211,15 @@ class PushButtonState extends State }); } + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + @visibleForTesting + bool buttonHeldDown = false; + @override Widget build(BuildContext context) { assert(debugCheckHasMacosTheme(context)); diff --git a/lib/src/fields/search_field.dart b/lib/src/fields/search_field.dart index e550dde4..d7eddb63 100644 --- a/lib/src/fields/search_field.dart +++ b/lib/src/fields/search_field.dart @@ -252,82 +252,6 @@ class _MacosSearchFieldState extends State> { }); } - @override - void dispose() { - suggestionStream.close(); - if (widget.controller == null) { - searchController!.dispose(); - } - if (widget.focusNode == null) { - _focus!.dispose(); - } - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - CompositedTransformTarget( - link: _layerLink, - child: MacosTextField( - placeholder: widget.placeholder, - prefix: const Padding( - padding: EdgeInsets.symmetric(), - child: MacosIcon(CupertinoIcons.search), - ), - clearButtonMode: OverlayVisibilityMode.editing, - onTap: () { - suggestionStream.sink.add(widget.results); - if (mounted) { - setState(() { - isResultExpanded = true; - }); - } - widget.onTap?.call(); - }, - controller: widget.controller ?? searchController, - focusNode: _focus, - style: widget.style, - onChanged: (query) { - final searchResult = []; - if (query.isEmpty) { - suggestionStream.sink.add(widget.results); - return; - } - if (widget.results != null) { - for (final suggestion in widget.results!) { - if (suggestion.searchKey - .toLowerCase() - .contains(query.toLowerCase())) { - searchResult.add(suggestion); - } - } - } - suggestionStream.sink.add(searchResult); - widget.onChanged?.call(query); - }, - decoration: widget.decoration, - focusedDecoration: widget.focusedDecoration, - padding: widget.padding, - placeholderStyle: widget.placeholderStyle, - textAlign: widget.textAlign, - autocorrect: widget.autocorrect, - autofocus: widget.autofocus, - maxLines: widget.maxLines, - minLines: widget.minLines, - expands: widget.expands, - maxLength: widget.maxLength, - maxLengthEnforcement: widget.maxLengthEnforcement, - inputFormatters: widget.inputFormatters, - enabled: widget.enabled, - ), - ), - ], - ); - } - OverlayEntry _createOverlay() { final renderBox = context.findRenderObject() as RenderBox; final size = renderBox.size; @@ -442,6 +366,82 @@ class _MacosSearchFieldState extends State> { }, ); } + + @override + void dispose() { + suggestionStream.close(); + if (widget.controller == null) { + searchController!.dispose(); + } + if (widget.focusNode == null) { + _focus!.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CompositedTransformTarget( + link: _layerLink, + child: MacosTextField( + placeholder: widget.placeholder, + prefix: const Padding( + padding: EdgeInsets.symmetric(), + child: MacosIcon(CupertinoIcons.search), + ), + clearButtonMode: OverlayVisibilityMode.editing, + onTap: () { + suggestionStream.sink.add(widget.results); + if (mounted) { + setState(() { + isResultExpanded = true; + }); + } + widget.onTap?.call(); + }, + controller: widget.controller ?? searchController, + focusNode: _focus, + style: widget.style, + onChanged: (query) { + final searchResult = []; + if (query.isEmpty) { + suggestionStream.sink.add(widget.results); + return; + } + if (widget.results != null) { + for (final suggestion in widget.results!) { + if (suggestion.searchKey + .toLowerCase() + .contains(query.toLowerCase())) { + searchResult.add(suggestion); + } + } + } + suggestionStream.sink.add(searchResult); + widget.onChanged?.call(query); + }, + decoration: widget.decoration, + focusedDecoration: widget.focusedDecoration, + padding: widget.padding, + placeholderStyle: widget.placeholderStyle, + textAlign: widget.textAlign, + autocorrect: widget.autocorrect, + autofocus: widget.autofocus, + maxLines: widget.maxLines, + minLines: widget.minLines, + expands: widget.expands, + maxLength: widget.maxLength, + maxLengthEnforcement: widget.maxLengthEnforcement, + inputFormatters: widget.inputFormatters, + enabled: widget.enabled, + ), + ), + ], + ); + } } /// An item to show in the search results of a search field. diff --git a/lib/src/fields/text_field.dart b/lib/src/fields/text_field.dart index 99f390cc..9387b029 100644 --- a/lib/src/fields/text_field.dart +++ b/lib/src/fields/text_field.dart @@ -435,6 +435,15 @@ class MacosTextField extends StatefulWidget { keyboardType = keyboardType ?? (maxLines == 1 ? TextInputType.text : TextInputType.multiline); + static Widget _defaultContextMenuBuilder( + BuildContext context, + EditableTextState editableTextState, + ) { + return CupertinoAdaptiveTextSelectionToolbar.editableText( + editableTextState: editableTextState, + ); + } + /// Controls the text being edited. /// /// If null, this widget will create its own [TextEditingController]. @@ -548,15 +557,6 @@ class MacosTextField extends StatefulWidget { /// * [CupertinoAdaptiveTextSelectionToolbar], which is built by default. final EditableTextContextMenuBuilder? contextMenuBuilder; - static Widget _defaultContextMenuBuilder( - BuildContext context, - EditableTextState editableTextState, - ) { - return CupertinoAdaptiveTextSelectionToolbar.editableText( - editableTextState: editableTextState, - ); - } - /// {@macro flutter.material.InputDecorator.textAlignVertical} final TextAlignVertical? textAlignVertical; @@ -920,10 +920,6 @@ class _MacosTextFieldState extends State _effectiveFocusNode.addListener(_handleFocusChanged); } - void _handleFocusChanged() { - setState(() {}); - } - @override void didUpdateWidget(MacosTextField oldWidget) { super.didUpdateWidget(oldWidget); @@ -937,11 +933,8 @@ class _MacosTextFieldState extends State _effectiveFocusNode.canRequestFocus = widget.enabled ?? true; } - @override - void restoreState(RestorationBucket? oldBucket, bool initialRestore) { - if (_controller != null) { - _registerController(); - } + void _handleFocusChanged() { + setState(() {}); } void _registerController() { @@ -960,18 +953,6 @@ class _MacosTextFieldState extends State } } - @override - String? get restorationId => widget.restorationId; - - @override - void dispose() { - _focusNode?.dispose(); - _controller?.dispose(); - super.dispose(); - } - - EditableTextState get _editableText => editableTextKey.currentState!; - void _requestKeyboard() { _editableText.requestKeyboard(); } @@ -1008,9 +989,6 @@ class _MacosTextFieldState extends State } } - @override - bool get wantKeepAlive => _controller?.value.text.isNotEmpty == true; - bool _shouldShowAttachment({ required OverlayVisibilityMode attachment, required bool hasText, @@ -1050,26 +1028,6 @@ class _MacosTextFieldState extends State ); } - // True if any surrounding decoration widgets will be shown. - bool get _hasDecoration { - return widget.placeholder != null || - widget.clearButtonMode != OverlayVisibilityMode.never || - widget.prefix != null || - widget.suffix != null; - } - - // Provide default behavior if widget.textAlignVertical is not set. - // TextField has top alignment by default, unless it has decoration - // like a prefix or suffix, in which case it's aligned to the center. - TextAlignVertical get _textAlignVertical { - if (widget.textAlignVertical != null) { - return widget.textAlignVertical!; - } - return widget.maxLines == null || widget.maxLines! > 1 - ? TextAlignVertical.center - : TextAlignVertical.top; - } - Widget _addTextDependentAttachments( Widget editableText, TextStyle textStyle, @@ -1185,6 +1143,48 @@ class _MacosTextFieldState extends State ); } + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + if (_controller != null) { + _registerController(); + } + } + + @override + String? get restorationId => widget.restorationId; + + @override + void dispose() { + _focusNode?.dispose(); + _controller?.dispose(); + super.dispose(); + } + + EditableTextState get _editableText => editableTextKey.currentState!; + + @override + bool get wantKeepAlive => _controller?.value.text.isNotEmpty == true; + + // True if any surrounding decoration widgets will be shown. + bool get _hasDecoration { + return widget.placeholder != null || + widget.clearButtonMode != OverlayVisibilityMode.never || + widget.prefix != null || + widget.suffix != null; + } + + // Provide default behavior if widget.textAlignVertical is not set. + // TextField has top alignment by default, unless it has decoration + // like a prefix or suffix, in which case it's aligned to the center. + TextAlignVertical get _textAlignVertical { + if (widget.textAlignVertical != null) { + return widget.textAlignVertical!; + } + return widget.maxLines == null || widget.maxLines! > 1 + ? TextAlignVertical.center + : TextAlignVertical.top; + } + @override // ignore: code-metrics Widget build(BuildContext context) { diff --git a/lib/src/indicators/capacity_indicators.dart b/lib/src/indicators/capacity_indicators.dart index 461f3055..ed26f90f 100644 --- a/lib/src/indicators/capacity_indicators.dart +++ b/lib/src/indicators/capacity_indicators.dart @@ -90,7 +90,7 @@ class CapacityIndicator extends StatelessWidget { } void _handleUpdate(Offset lp, double width) { - double value = (lp.dx / width) * splits; + double value = (lp.dx / width) * 100 / splits; onChanged?.call(value.clamp(0.0, 100.0)); } @@ -108,7 +108,7 @@ class CapacityIndicator extends StatelessWidget { if (width.isInfinite) width = 100; final splitWidth = width / splits; if (discrete) { - final fillToIndex = value / splits - 1; + final fillToIndex = (value / 100) * splits - 1; return SizedBox( width: width, child: GestureDetector( diff --git a/lib/src/indicators/scrollbar.dart b/lib/src/indicators/scrollbar.dart deleted file mode 100644 index 98c60b33..00000000 --- a/lib/src/indicators/scrollbar.dart +++ /dev/null @@ -1,148 +0,0 @@ -import 'package:flutter/material.dart' as m; -import 'package:macos_ui/macos_ui.dart'; -import 'package:macos_ui/src/library.dart'; - -/// A Macos Design scrollbar. -/// -/// To add a scrollbar to a [ScrollView], wrap the scroll view -/// widget in a [MacosScrollbar] widget. -/// -/// {@macro flutter.widgets.Scrollbar} -/// -/// The color of the Scrollbar will change when dragged. A hover animation is -/// also triggered when used on web and desktop platforms. A scrollbar track -/// can also been drawn when triggered by a hover event, which is controlled by -/// [trackVisibility]. The thickness of the track and scrollbar thumb will -/// become larger when hovering, unless overridden by [hoverThickness]. -/// -/// See also: -/// -/// * [RawScrollbar], a basic scrollbar that fades in and out, extended -/// by this class to add more animations and behaviors. -/// * [MacosScrollbarTheme], which configures the Scrollbar's appearance. -/// * [m.Scrollbar], a Material style scrollbar. -/// * [CupertinoScrollbar], an iOS style scrollbar. -/// * [ListView], which displays a linear, scrollable list of children. -/// * [GridView], which displays a 2 dimensional, scrollable array of children. -class MacosScrollbar extends StatelessWidget { - /// Creates a macos design scrollbar that by default will connect to the - /// closest Scrollable descendent of [child]. - /// - /// The [child] should be a source of [ScrollNotification] notifications, - /// typically a [Scrollable] widget. - /// - /// If the [controller] is null, the default behavior is to - /// enable scrollbar dragging using the [PrimaryScrollController]. - /// - /// When null, [thickness] defaults to 8.0 pixels on desktop and web, and 4.0 - /// pixels when on mobile platforms. A null [radius] will result in a default - /// of an 8.0 pixel circular radius about the corners of the scrollbar thumb, - /// except for when executing on [TargetPlatform.android], which will render the - /// thumb without a radius. - const MacosScrollbar({ - super.key, - required this.child, - this.controller, - this.isAlwaysShown, - this.trackVisibility, - this.thickness, - this.radius, - this.notificationPredicate, - this.interactive, - }); - - /// {@macro flutter.widgets.Scrollbar.child} - final Widget child; - - /// {@macro flutter.widgets.Scrollbar.controller} - final ScrollController? controller; - - /// {@macro flutter.widgets.Scrollbar.isAlwaysShown} - final bool? isAlwaysShown; - - /// Controls if the track will always be visible or not. - /// - /// If this property is null, then [MacosScrollbarThemeData.showTrackOnHover] of - /// [MacosThemeData.scrollbarTheme] is used. If that is also null, the default value - /// is false. - final bool? trackVisibility; - - /// The thickness of the scrollbar in the cross axis of the scrollable. - /// - /// If null, the default value is platform dependent. On [TargetPlatform.android], - /// the default thickness is 4.0 pixels. On [TargetPlatform.iOS], - /// [CupertinoScrollbar.defaultThickness] is used. The remaining platforms have a - /// default thickness of 8.0 pixels. - final double? thickness; - - /// The [Radius] of the scrollbar thumb's rounded rectangle corners. - /// - /// If null, the default value is platform dependent. On [TargetPlatform.android], - /// no radius is applied to the scrollbar thumb. On [TargetPlatform.iOS], - /// [CupertinoScrollbar.defaultRadius] is used. The remaining platforms have a - /// default [Radius.circular] of 8.0 pixels. - final Radius? radius; - - /// {@macro flutter.widgets.Scrollbar.interactive} - final bool? interactive; - - /// {@macro flutter.widgets.Scrollbar.notificationPredicate} - final ScrollNotificationPredicate? notificationPredicate; - - @override - Widget build(BuildContext context) { - assert(debugCheckHasMacosTheme(context)); - final theme = MacosScrollbarTheme.of(context); - return m.ScrollbarTheme( - data: m.ScrollbarThemeData( - crossAxisMargin: theme.crossAxisMargin, - mainAxisMargin: theme.mainAxisMargin, - interactive: theme.interactive, - thumbVisibility: m.MaterialStateProperty.resolveWith((states) { - return isAlwaysShown; - }), - trackVisibility: m.MaterialStateProperty.resolveWith((states) { - return trackVisibility; - }), - minThumbLength: theme.minThumbLength, - radius: theme.radius, - thickness: m.MaterialStateProperty.resolveWith((states) { - if (states.contains(m.MaterialState.hovered)) { - return theme.hoveringThickness ?? theme.thickness; - } - return theme.thickness; - }), - thumbColor: m.MaterialStateProperty.resolveWith((states) { - if (states.contains(m.MaterialState.hovered)) { - return theme.hoveringThumbColor ?? theme.thumbColor; - } else if (states.contains(m.MaterialState.dragged)) { - return theme.draggingThumbColor ?? theme.thumbColor; - } - return theme.thumbColor; - }), - trackBorderColor: m.MaterialStateProperty.resolveWith((states) { - if (states.contains(m.MaterialState.hovered)) { - return theme.hoveringTrackBorderColor ?? theme.trackBorderColor; - } - return theme.trackBorderColor; - }), - trackColor: m.MaterialStateProperty.resolveWith((states) { - if (states.contains(m.MaterialState.hovered)) { - return theme.hoveringTrackColor ?? theme.trackColor; - } - return theme.trackColor; - }), - ), - child: m.Scrollbar( - controller: controller, - thumbVisibility: isAlwaysShown, - trackVisibility: trackVisibility, - thickness: thickness, - radius: radius, - interactive: interactive, - notificationPredicate: notificationPredicate, - child: child, - ), - ); - } -} diff --git a/lib/src/labels/tooltip.dart b/lib/src/labels/tooltip.dart index 0c42f005..35022953 100644 --- a/lib/src/labels/tooltip.dart +++ b/lib/src/labels/tooltip.dart @@ -236,6 +236,12 @@ class _MacosTooltipState extends State super.deactivate(); } + void _handleLongPress() { + _longPressActivated = true; + final bool tooltipCreated = ensureTooltipVisible(); + if (tooltipCreated) Feedback.forLongPress(context); + } + @override void dispose() { GestureBinding.instance.pointerRouter @@ -247,12 +253,6 @@ class _MacosTooltipState extends State super.dispose(); } - void _handleLongPress() { - _longPressActivated = true; - final bool tooltipCreated = ensureTooltipVisible(); - if (tooltipCreated) Feedback.forLongPress(context); - } - @override Widget build(BuildContext context) { assert(debugCheckHasMacosTheme(context)); diff --git a/lib/src/layout/content_area.dart b/lib/src/layout/content_area.dart index 0012292b..5026e260 100644 --- a/lib/src/layout/content_area.dart +++ b/lib/src/layout/content_area.dart @@ -16,7 +16,7 @@ class ContentArea extends StatelessWidget { }) : super(key: const Key('macos_scaffold_content_area')); /// The builder that creates a child to display in this widget. - final WidgetBuilder? builder; + final ScrollableWidgetBuilder? builder; /// Specifies the minimum width that this [ContentArea] can have. final double minWidth; @@ -30,7 +30,7 @@ class ContentArea extends StatelessWidget { child: SafeArea( left: false, right: false, - child: builder!(context), + child: builder!(context, ScrollController()), ), ); } diff --git a/lib/src/layout/resizable_pane.dart b/lib/src/layout/resizable_pane.dart index 4c6c78f2..c71ff8c2 100644 --- a/lib/src/layout/resizable_pane.dart +++ b/lib/src/layout/resizable_pane.dart @@ -1,7 +1,7 @@ import 'dart:math' as math show max, min; import 'package:flutter/services.dart' show SystemMouseCursor; -import 'package:macos_ui/src/indicators/scrollbar.dart'; +import 'package:macos_ui/src/layout/scrollbar.dart'; import 'package:macos_ui/src/library.dart'; import 'package:macos_ui/src/theme/macos_theme.dart'; @@ -160,12 +160,6 @@ class _ResizablePaneState extends State { _scrollController.addListener(() => setState(() {})); } - @override - void dispose() { - _scrollController.dispose(); - super.dispose(); - } - @override void didUpdateWidget(covariant ResizablePane oldWidget) { super.didUpdateWidget(oldWidget); @@ -180,6 +174,12 @@ class _ResizablePaneState extends State { } } + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final media = MediaQuery.of(context); diff --git a/lib/src/layout/scrollbar.dart b/lib/src/layout/scrollbar.dart new file mode 100644 index 00000000..334aced5 --- /dev/null +++ b/lib/src/layout/scrollbar.dart @@ -0,0 +1,213 @@ +import 'package:flutter/services.dart'; +import 'package:macos_ui/macos_ui.dart'; +import 'package:macos_ui/src/library.dart'; + +const double _kScrollbarMinLength = 36.0; +const double _kScrollbarMinOverscrollLength = 8.0; +const Duration _kScrollbarTimeToFade = Duration(milliseconds: 1200); +const Duration _kScrollbarFadeDuration = Duration(milliseconds: 250); +const Duration _kScrollbarResizeDuration = Duration(milliseconds: 100); +const double _kScrollbarMainAxisMargin = 3.0; +const double _kScrollbarCrossAxisMargin = 2.0; + +/// A macOS-style scrollbar. +/// +/// Applications built with `macos_ui` will automatically apply this widget +/// to [ScrollView]s as the default scrollbar. +/// +/// To explicitly add a scrollbar to a [ScrollView], wrap the scroll view +/// widget in a [MacosScrollbar] widget. +/// +/// {@macro flutter.widgets.Scrollbar} +/// +/// See also: +/// +/// * [RawScrollbar], a basic scrollbar that fades in and out, extended +/// by this class internally to add animations and behaviors that aim to +/// match the native macOS scrollbar. +/// * [MacosScrollbarTheme], which configures this widget's appearance. +/// * [Scrollbar], a Material style scrollbar. +/// * [CupertinoScrollbar], an iOS style scrollbar. +class MacosScrollbar extends StatelessWidget { + /// Creates a macOS-style scrollbar that by default will connect to the + /// closest Scrollable descendant of [child]. + /// + /// The [child] should be a source of [ScrollNotification] notifications, + /// typically a [Scrollable] widget. + /// + /// If the [controller] is null, the default behavior is to + /// enable scrollbar dragging using the [PrimaryScrollController]. + const MacosScrollbar({ + super.key, + required this.child, + this.controller, + this.thumbVisibility, + this.thickness, + this.thicknessWhileHovering, + this.radius, + this.notificationPredicate, + this.scrollbarOrientation, + }); + + /// {@macro flutter.widgets.Scrollbar.child} + final Widget child; + + /// {@macro flutter.widgets.Scrollbar.controller} + final ScrollController? controller; + + /// {@macro flutter.widgets.Scrollbar.thumbVisibility} + final bool? thumbVisibility; + + /// The thickness of the scrollbar in the cross axis of the scrollable. + /// + /// Defaults to 6.0. + final double? thickness; + + /// The thickness of the scrollbar in the cross axis of the scrollable while + /// the mouse cursor is hovering over the scrollbar. + /// + /// Defaults to 9.0. + final double? thicknessWhileHovering; + + /// The [Radius] of the scrollbar thumb's rounded rectangle corners. + /// + /// Defaults to `const Radius.circular(25)`. + final Radius? radius; + + /// {@macro flutter.widgets.Scrollbar.notificationPredicate} + final ScrollNotificationPredicate? notificationPredicate; + + /// {@macro flutter.widgets.Scrollbar.scrollbarOrientation} + final ScrollbarOrientation? scrollbarOrientation; + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMacosTheme(context)); + final scrollbarTheme = MacosScrollbarTheme.of(context); + assert(scrollbarTheme.thickness != null); + assert(scrollbarTheme.thicknessWhileHovering != null); + + return _RawMacosScrollBar( + controller: controller, + thumbVisibility: thumbVisibility ?? scrollbarTheme.thumbVisibility, + thickness: thickness ?? scrollbarTheme.thickness, + thicknessWhileHovering: + thicknessWhileHovering ?? scrollbarTheme.thicknessWhileHovering!, + notificationPredicate: notificationPredicate, + scrollbarOrientation: scrollbarOrientation, + effectiveThumbColor: scrollbarTheme.thumbColor!, + radius: radius ?? scrollbarTheme.radius, + child: child, + ); + } +} + +class _RawMacosScrollBar extends RawScrollbar { + const _RawMacosScrollBar({ + required super.child, + super.controller, + bool? thumbVisibility, + super.thickness, + required this.thicknessWhileHovering, + ScrollNotificationPredicate? notificationPredicate, + super.scrollbarOrientation, + required this.effectiveThumbColor, + super.radius, + }) : assert(thickness != null && thickness < double.infinity), + assert(thicknessWhileHovering < double.infinity), + super( + thumbVisibility: thumbVisibility ?? false, + fadeDuration: _kScrollbarFadeDuration, + timeToFade: _kScrollbarTimeToFade, + notificationPredicate: + notificationPredicate ?? defaultScrollNotificationPredicate, + ); + + final double thicknessWhileHovering; + final Color effectiveThumbColor; + + @override + RawScrollbarState<_RawMacosScrollBar> createState() => + _RawMacosScrollBarState(); +} + +class _RawMacosScrollBarState extends RawScrollbarState<_RawMacosScrollBar> { + late AnimationController _thumbThicknessAnimationController; + late AnimationController _trackColorAnimationController; + late Animation _trackColorTween; + bool _hoverIsActive = false; + + double get _thickness { + return widget.thickness! + + _thumbThicknessAnimationController.value * + (widget.thicknessWhileHovering - widget.thickness!); + } + + @override + void initState() { + super.initState(); + _thumbThicknessAnimationController = AnimationController( + vsync: this, + duration: _kScrollbarResizeDuration, + ); + _trackColorAnimationController = AnimationController( + vsync: this, + duration: _kScrollbarResizeDuration, + ); + _trackColorTween = ColorTween( + begin: MacosColors.transparent, + end: widget.effectiveThumbColor.withOpacity(.15), + ).animate(_trackColorAnimationController); + _thumbThicknessAnimationController.addListener(() { + updateScrollbarPainter(); + }); + _trackColorAnimationController.addListener(() { + updateScrollbarPainter(); + }); + } + + @override + void updateScrollbarPainter() { + scrollbarPainter + ..color = widget.effectiveThumbColor + ..trackColor = _trackColorTween.value + ..textDirection = Directionality.of(context) + ..thickness = _thickness + ..mainAxisMargin = _kScrollbarMainAxisMargin + ..crossAxisMargin = _kScrollbarCrossAxisMargin + ..radius = widget.radius + ..padding = MediaQuery.of(context).padding + ..minLength = _kScrollbarMinLength + ..minOverscrollLength = _kScrollbarMinOverscrollLength + ..scrollbarOrientation = widget.scrollbarOrientation; + } + + @override + void handleHover(PointerHoverEvent event) { + super.handleHover(event); + if (isPointerOverScrollbar(event.position, event.kind, forHover: true)) { + setState(() => _hoverIsActive = true); + _thumbThicknessAnimationController.forward(); + _trackColorAnimationController.forward(); + } else if (_hoverIsActive) { + setState(() => _hoverIsActive = false); + _thumbThicknessAnimationController.reverse(); + _trackColorAnimationController.reverse(); + } + } + + @override + void handleHoverExit(PointerExitEvent event) { + super.handleHoverExit(event); + setState(() => _hoverIsActive = false); + _thumbThicknessAnimationController.reverse(); + _trackColorAnimationController.reverse(); + } + + @override + void dispose() { + _thumbThicknessAnimationController.dispose(); + _trackColorAnimationController.dispose(); + super.dispose(); + } +} diff --git a/lib/src/layout/sidebar/sidebar_items.dart b/lib/src/layout/sidebar/sidebar_items.dart index f13779d8..aada626f 100644 --- a/lib/src/layout/sidebar/sidebar_items.dart +++ b/lib/src/layout/sidebar/sidebar_items.dart @@ -364,12 +364,6 @@ class __DisclosureSidebarItemState extends State<_DisclosureSidebarItem> _iconTurns = _controller.drive(_halfTween.chain(_easeInTween)); } - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - void _handleTap() { setState(() { _isExpanded = !_isExpanded; @@ -461,6 +455,12 @@ class __DisclosureSidebarItemState extends State<_DisclosureSidebarItem> ); } + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { assert(debugCheckHasMacosTheme(context)); diff --git a/lib/src/layout/tab_view/tab_view.dart b/lib/src/layout/tab_view/tab_view.dart index 03d9b91b..fa32c734 100644 --- a/lib/src/layout/tab_view/tab_view.dart +++ b/lib/src/layout/tab_view/tab_view.dart @@ -74,6 +74,31 @@ class _MacosTabViewState extends State { late List _childrenWithKey; int? _currentIndex; + @override + void initState() { + super.initState(); + _updateChildren(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _updateTabController(); + _currentIndex = widget.controller.index; + } + + @override + void didUpdateWidget(MacosTabView oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.controller != oldWidget.controller) { + _updateTabController(); + _currentIndex = widget.controller.index; + } + if (widget.children != oldWidget.children) { + _updateChildren(); + } + } + int get _tabRotation { switch (widget.position) { case MacosTabPosition.left: @@ -101,29 +126,8 @@ class _MacosTabViewState extends State { }); } - @override - void initState() { - super.initState(); - _updateChildren(); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _updateTabController(); - _currentIndex = widget.controller.index; - } - - @override - void didUpdateWidget(MacosTabView oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.controller != oldWidget.controller) { - _updateTabController(); - _currentIndex = widget.controller.index; - } - if (widget.children != oldWidget.children) { - _updateChildren(); - } + void _updateChildren() { + _childrenWithKey = KeyedSubtree.ensureUniqueKeysForList(widget.children); } @override @@ -132,10 +136,6 @@ class _MacosTabViewState extends State { super.dispose(); } - void _updateChildren() { - _childrenWithKey = KeyedSubtree.ensureUniqueKeysForList(widget.children); - } - @override Widget build(BuildContext context) { assert(() { diff --git a/lib/src/layout/window.dart b/lib/src/layout/window.dart index d8cb563c..9ce38eb0 100644 --- a/lib/src/layout/window.dart +++ b/lib/src/layout/window.dart @@ -2,7 +2,7 @@ import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; -import 'package:macos_ui/src/indicators/scrollbar.dart'; +import 'package:macos_ui/src/layout/scrollbar.dart'; import 'package:macos_ui/src/layout/content_area.dart'; import 'package:macos_ui/src/layout/resizable_pane.dart'; import 'package:macos_ui/src/layout/scaffold.dart'; @@ -82,13 +82,6 @@ class _MacosWindowState extends State { } } - @override - void dispose() { - _sidebarScrollController.dispose(); - _endSidebarScrollController.dispose(); - super.dispose(); - } - @override void didUpdateWidget(covariant MacosWindow old) { super.didUpdateWidget(old); @@ -118,6 +111,13 @@ class _MacosWindowState extends State { }); } + @override + void dispose() { + _sidebarScrollController.dispose(); + _endSidebarScrollController.dispose(); + super.dispose(); + } + @override // ignore: code-metrics Widget build(BuildContext context) { @@ -221,7 +221,7 @@ class _MacosWindowState extends State { _sidebarScrollController.offset > 0.0) Divider(thickness: 1, height: 1, color: dividerColor), if (widget.sidebar!.top != null && - constraints.maxHeight < 81) + constraints.maxHeight > 81) Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: widget.sidebar!.top!, @@ -237,7 +237,7 @@ class _MacosWindowState extends State { ), ), if (widget.sidebar?.bottom != null && - constraints.maxHeight < 141) + constraints.maxHeight > 141) Padding( padding: const EdgeInsets.all(16.0), child: widget.sidebar!.bottom!, diff --git a/lib/src/library.dart b/lib/src/library.dart index b473c28f..9d05ec7e 100644 --- a/lib/src/library.dart +++ b/lib/src/library.dart @@ -27,7 +27,8 @@ export 'package:flutter/material.dart' kElevationToShadow, DateUtils, TimeOfDay, - DayPeriod; + DayPeriod, + MaterialState; export 'package:flutter/widgets.dart'; export 'utils.dart'; diff --git a/lib/src/macos_app.dart b/lib/src/macos_app.dart index 1aa3fb52..c4068f30 100644 --- a/lib/src/macos_app.dart +++ b/lib/src/macos_app.dart @@ -35,7 +35,7 @@ class MacosApp extends StatefulWidget { /// application is launched with an intent that specifies an otherwise /// unsupported route. /// - /// This class creates an instance of [WidgetsApp]. + /// This class creates an instance of [CupertinoApp]. /// /// The boolean arguments, [routes], and [navigatorObservers], must not be null. const MacosApp({ @@ -302,22 +302,6 @@ class MacosApp extends StatefulWidget { class _MacosAppState extends State { bool get _usesRouter => widget.routerDelegate != null; - @override - Widget build(BuildContext context) { - // leaves room for assertions, etc - Widget result = _buildMacosApp(context); - return result; - } - - Iterable> get _localizationsDelegates sync* { - if (widget.localizationsDelegates != null) { - yield* widget.localizationsDelegates!; - } - yield DefaultMaterialLocalizations.delegate; - yield DefaultCupertinoLocalizations.delegate; - yield DefaultWidgetsLocalizations.delegate; - } - Widget _macosBuilder(BuildContext context, Widget? child) { final mode = widget.themeMode ?? ThemeMode.system; final platformBrightness = MediaQuery.platformBrightnessOf(context); @@ -381,6 +365,7 @@ class _MacosAppState extends State { debugShowCheckedModeBanner: widget.debugShowCheckedModeBanner, shortcuts: widget.shortcuts, actions: widget.actions, + scrollBehavior: widget.scrollBehavior, ); } return c.CupertinoApp( @@ -409,8 +394,25 @@ class _MacosAppState extends State { debugShowCheckedModeBanner: widget.debugShowCheckedModeBanner, shortcuts: widget.shortcuts, actions: widget.actions, + scrollBehavior: widget.scrollBehavior, ); } + + @override + Widget build(BuildContext context) { + // leaves room for assertions, etc + Widget result = _buildMacosApp(context); + return result; + } + + Iterable> get _localizationsDelegates sync* { + if (widget.localizationsDelegates != null) { + yield* widget.localizationsDelegates!; + } + yield DefaultMaterialLocalizations.delegate; + yield DefaultCupertinoLocalizations.delegate; + yield DefaultWidgetsLocalizations.delegate; + } } /// Describes how [Scrollable] widgets behave for [MacosApp]s. @@ -428,6 +430,11 @@ class MacosScrollBehavior extends ScrollBehavior { /// [MacosScrollbar]s based on the current platform and provided [ScrollableDetails]. const MacosScrollBehavior(); + /*@override + Set get dragDevices => { + PointerDeviceKind.mouse, + };*/ + @override Widget buildScrollbar(context, child, details) { // When modifying this function, consider modifying the implementation in diff --git a/lib/src/selectors/date_picker.dart b/lib/src/selectors/date_picker.dart index d11d3d4b..4fec86a3 100644 --- a/lib/src/selectors/date_picker.dart +++ b/lib/src/selectors/date_picker.dart @@ -64,6 +64,18 @@ class MacosDatePicker extends StatefulWidget { } class _MacosDatePickerState extends State { + // Use this to get the weekday abbreviations instead of + // localizations.narrowWeekdays() in order to match Apple's spec + static const List _narrowWeekdays = [ + 'Su', + 'Mo', + 'Tu', + 'We', + 'Th', + 'Fr', + 'Sa', + ]; + final _today = DateTime.now(); late final _initialDate = widget.initialDate ?? _today; @@ -140,18 +152,6 @@ class _MacosDatePickerState extends State { widget.onDateChanged.call(_formatAsDateTime()); } - // Use this to get the weekday abbreviations instead of - // localizations.narrowWeekdays() in order to match Apple's spec - static const List _narrowWeekdays = [ - 'Su', - 'Mo', - 'Tu', - 'We', - 'Th', - 'Fr', - 'Sa', - ]; - // Creates the day headers - Su, Mo, Tu, We, Th, Fr, Sa List _dayHeaders( TextStyle? headerStyle, diff --git a/lib/src/theme/macos_theme.dart b/lib/src/theme/macos_theme.dart index e2c084d8..ac03ea2d 100644 --- a/lib/src/theme/macos_theme.dart +++ b/lib/src/theme/macos_theme.dart @@ -2,6 +2,11 @@ import 'package:flutter/foundation.dart'; import 'package:macos_ui/macos_ui.dart'; import 'package:macos_ui/src/library.dart'; +CupertinoDynamicColor _kScrollbarColor = CupertinoDynamicColor.withBrightness( + color: MacosColors.systemGrayColor.color.withOpacity(.8), + darkColor: MacosColors.systemGrayColor.darkColor.withOpacity(.8), +); + /// Applies a macOS-style theme to descendant macOS widgets. /// /// Affects the color and text styles of macOS widgets whose styling @@ -238,7 +243,13 @@ class MacosThemeData with Diagnosticable { brightness: _brightness, textStyle: typography.callout, ); - scrollbarTheme ??= const MacosScrollbarThemeData(); + scrollbarTheme ??= MacosScrollbarThemeData( + thickness: 6.0, + thicknessWhileHovering: 9.0, + thumbColor: isDark ? _kScrollbarColor.darkColor : _kScrollbarColor.color, + radius: const Radius.circular(25), + thumbVisibility: false, + ); macosIconButtonTheme ??= MacosIconButtonThemeData( backgroundColor: MacosColors.transparent, disabledColor: isDark diff --git a/lib/src/theme/scrollbar_theme.dart b/lib/src/theme/scrollbar_theme.dart index a62378a5..2655e4de 100644 --- a/lib/src/theme/scrollbar_theme.dart +++ b/lib/src/theme/scrollbar_theme.dart @@ -70,21 +70,10 @@ class MacosScrollbarThemeData with Diagnosticable { /// Creates a theme that can be used for [MacosThemeData.scrollbarTheme]. const MacosScrollbarThemeData({ this.thickness, - this.hoveringThickness, - this.showTrackOnHover, - this.isAlwaysShown, + this.thicknessWhileHovering, + this.thumbVisibility, this.radius, this.thumbColor, - this.hoveringThumbColor, - this.draggingThumbColor, - this.trackColor, - this.hoveringTrackColor, - this.trackBorderColor, - this.hoveringTrackBorderColor, - this.crossAxisMargin, - this.mainAxisMargin, - this.minThumbLength, - this.interactive, }); /// Overrides the default value of [MacosScrollbar.thickness] in all @@ -93,19 +82,11 @@ class MacosScrollbarThemeData with Diagnosticable { /// Overrides the default value of [MacosScrollbar.hoverThickness] in all /// descendant [MacosScrollbar] widgets when hovering is active. - final double? hoveringThickness; + final double? thicknessWhileHovering; - /// Overrides the default value of [MacosScrollbar.trackVisibility] in all + /// Overrides the default value of [MacosScrollbar.thumbVisibility] in all /// descendant [MacosScrollbar] widgets. - final bool? showTrackOnHover; - - /// Overrides the default value of [MacosScrollbar.isAlwaysShown] in all - /// descendant [MacosScrollbar] widgets. - final bool? isAlwaysShown; - - /// Overrides the default value of [MacosScrollbar.interactive] in all - /// descendant [MacosScrollbar] widgets. - final bool? interactive; + final bool? thumbVisibility; /// Overrides the default value of [MacosScrollbar.radius] in all /// descendant widgets. @@ -115,99 +96,23 @@ class MacosScrollbarThemeData with Diagnosticable { /// descendant [MacosScrollbar] widgets. final Color? thumbColor; - /// Overrides the default [Color] of the [MacosScrollbar] thumb in all - /// descendant [MacosScrollbar] widgets when hovering is active. - final Color? hoveringThumbColor; - - /// Overrides the default [Color] of the [MacosScrollbar] thumb in all - /// descendant [MacosScrollbar] widgets when dragging is active. - final Color? draggingThumbColor; - - /// Overrides the default [Color] of the [MacosScrollbar] track when - /// [showTrackOnHover] is true in all descendant [MacosScrollbar] widgets. - final Color? trackColor; - - /// Overrides the default [Color] of the [MacosScrollbar] track when - /// [showTrackOnHover] is true in all descendant [MacosScrollbar] widgets - /// when hovering is active. - final Color? hoveringTrackColor; - - /// Overrides the default [Color] of the [MacosScrollbar] track border when - /// [showTrackOnHover] is true in all descendant [MacosScrollbar] widgets. - final Color? trackBorderColor; - - /// Overrides the default [Color] of the [MacosScrollbar] track border when - /// [showTrackOnHover] is true in all descendant [MacosScrollbar] widgets - /// when hovering is active. - final Color? hoveringTrackBorderColor; - - /// Overrides the default value of the [ScrollbarPainter.crossAxisMargin] - /// property in all descendant [MacosScrollbar] widgets. - /// - /// See also: - /// - /// * [ScrollbarPainter.crossAxisMargin], which sets the distance from the - /// scrollbar's side to the nearest edge in logical pixels. - final double? crossAxisMargin; - - /// Overrides the default value of the [ScrollbarPainter.mainAxisMargin] - /// property in all descendant [MacosScrollbar] widgets. - /// - /// See also: - /// - /// * [ScrollbarPainter.mainAxisMargin], which sets the distance from the - /// scrollbar's start and end to the edge of the viewport in logical pixels. - final double? mainAxisMargin; - - /// Overrides the default value of the [ScrollbarPainter.minLength] - /// property in all descendant [MacosScrollbar] widgets. - /// - /// See also: - /// - /// * [ScrollbarPainter.minLength], which sets the preferred smallest size - /// the scrollbar can shrink to when the total scrollable extent is large, - /// the current visible viewport is small, and the viewport is not - /// overscrolled. - final double? minThumbLength; - /// Creates a copy of this object with the given fields replaced with the /// new values. MacosScrollbarThemeData copyWith({ double? thickness, - double? hoveringThickness, + double? thicknessWhileHovering, bool? showTrackOnHover, - bool? isAlwaysShown, - bool? interactive, + bool? thumbVisibility, Radius? radius, Color? thumbColor, - Color? hoveringThumbColor, - Color? draggingThumbColor, - Color? trackColor, - Color? hoveringTrackColor, - Color? trackBorderColor, - Color? hoveringTrackBorderColor, - double? crossAxisMargin, - double? mainAxisMargin, - double? minThumbLength, }) { return MacosScrollbarThemeData( thickness: thickness ?? this.thickness, - hoveringThickness: hoveringThickness ?? this.hoveringThickness, - showTrackOnHover: showTrackOnHover ?? this.showTrackOnHover, - isAlwaysShown: isAlwaysShown ?? this.isAlwaysShown, - interactive: interactive ?? this.interactive, + thicknessWhileHovering: + thicknessWhileHovering ?? this.thicknessWhileHovering, + thumbVisibility: thumbVisibility ?? this.thumbVisibility, radius: radius ?? this.radius, thumbColor: thumbColor ?? this.thumbColor, - hoveringThumbColor: hoveringThumbColor ?? this.hoveringThumbColor, - draggingThumbColor: draggingThumbColor ?? this.draggingThumbColor, - trackColor: trackColor ?? this.trackColor, - hoveringTrackColor: hoveringTrackColor ?? this.hoveringTrackColor, - trackBorderColor: trackBorderColor ?? this.trackBorderColor, - hoveringTrackBorderColor: - hoveringTrackBorderColor ?? this.hoveringTrackBorderColor, - crossAxisMargin: crossAxisMargin ?? this.crossAxisMargin, - mainAxisMargin: mainAxisMargin ?? this.mainAxisMargin, - minThumbLength: minThumbLength ?? this.minThumbLength, ); } @@ -224,29 +129,25 @@ class MacosScrollbarThemeData with Diagnosticable { ) { return MacosScrollbarThemeData( thickness: lerpDouble(a?.thickness, b?.thickness, t), - hoveringThickness: - lerpDouble(a?.hoveringThickness, b?.hoveringThickness, t), - showTrackOnHover: t < 0.5 ? a?.showTrackOnHover : b?.showTrackOnHover, - isAlwaysShown: t < 0.5 ? a?.isAlwaysShown : b?.isAlwaysShown, - interactive: t < 0.5 ? a?.interactive : b?.interactive, - radius: Radius.lerp(a?.radius, b?.radius, t), - thumbColor: Color.lerp(a?.thumbColor, b?.thumbColor, t), - hoveringThumbColor: - Color.lerp(a?.hoveringThumbColor, b?.hoveringThumbColor, t), - draggingThumbColor: - Color.lerp(a?.draggingThumbColor, b?.draggingThumbColor, t), - trackColor: Color.lerp(a?.trackColor, b?.trackColor, t), - hoveringTrackColor: - Color.lerp(a?.hoveringThumbColor, b?.hoveringThumbColor, t), - trackBorderColor: Color.lerp(a?.trackBorderColor, b?.trackBorderColor, t), - hoveringTrackBorderColor: Color.lerp( - a?.hoveringTrackBorderColor, - b?.hoveringTrackBorderColor, + thicknessWhileHovering: lerpDouble( + a?.thicknessWhileHovering, + b?.thicknessWhileHovering, t, ), - crossAxisMargin: lerpDouble(a?.crossAxisMargin, b?.crossAxisMargin, t), - mainAxisMargin: lerpDouble(a?.mainAxisMargin, b?.mainAxisMargin, t), - minThumbLength: lerpDouble(a?.minThumbLength, b?.minThumbLength, t), + thumbVisibility: t < 0.5 ? a?.thumbVisibility : b?.thumbVisibility, + radius: Radius.lerp(a?.radius, b?.radius, t), + thumbColor: Color.lerp(a?.thumbColor, b?.thumbColor, t), + ); + } + + /// Merges this [MacosScrollbarThemeData] with another. + MacosScrollbarThemeData merge(MacosScrollbarThemeData? other) { + if (other == null) return this; + return copyWith( + thickness: other.thickness, + thumbVisibility: other.thumbVisibility, + radius: other.radius, + thumbColor: other.thumbColor, ); } @@ -254,21 +155,10 @@ class MacosScrollbarThemeData with Diagnosticable { int get hashCode { return Object.hash( thickness, - hoveringThickness, - showTrackOnHover, - isAlwaysShown, - interactive, + thicknessWhileHovering, + thumbVisibility, radius, thumbColor, - hoveringThumbColor, - draggingThumbColor, - trackColor, - hoveringTrackColor, - trackBorderColor, - hoveringTrackBorderColor, - crossAxisMargin, - mainAxisMargin, - minThumbLength, ); } @@ -278,21 +168,10 @@ class MacosScrollbarThemeData with Diagnosticable { if (other.runtimeType != runtimeType) return false; return other is MacosScrollbarThemeData && other.thickness == thickness && - other.hoveringThickness == hoveringThickness && - other.showTrackOnHover == showTrackOnHover && - other.isAlwaysShown == isAlwaysShown && - other.interactive == interactive && + other.thicknessWhileHovering == thicknessWhileHovering && + other.thumbVisibility == thumbVisibility && other.radius == radius && - other.thumbColor == thumbColor && - other.hoveringThumbColor == hoveringThumbColor && - other.draggingThumbColor == draggingThumbColor && - other.trackColor == trackColor && - other.hoveringTrackColor == hoveringTrackColor && - other.trackBorderColor == trackBorderColor && - other.hoveringTrackBorderColor == hoveringTrackBorderColor && - other.crossAxisMargin == crossAxisMargin && - other.mainAxisMargin == mainAxisMargin && - other.minThumbLength == minThumbLength; + other.thumbColor == thumbColor; } @override @@ -302,90 +181,18 @@ class MacosScrollbarThemeData with Diagnosticable { DiagnosticsProperty('thickness', thickness, defaultValue: null), ); properties.add(DiagnosticsProperty( - 'hoveringThickness', - hoveringThickness, - defaultValue: null, - )); - properties.add(DiagnosticsProperty( - 'showTrackOnHover', - showTrackOnHover, + 'thicknessWhileHovering', + thicknessWhileHovering, defaultValue: null, )); properties.add(DiagnosticsProperty( - 'isAlwaysShown', - isAlwaysShown, + 'thumbVisibility', + thumbVisibility, defaultValue: null, )); - properties.add( - DiagnosticsProperty('interactive', interactive, defaultValue: null), - ); properties.add( DiagnosticsProperty('radius', radius, defaultValue: null), ); properties.add(ColorProperty('thumbColor', thumbColor, defaultValue: null)); - properties.add(ColorProperty( - 'hoveringThumbColor', - hoveringThumbColor, - defaultValue: null, - )); - properties.add(ColorProperty( - 'draggingThumbColor', - draggingThumbColor, - defaultValue: null, - )); - properties.add(ColorProperty('trackColor', trackColor, defaultValue: null)); - properties.add( - ColorProperty( - 'hoveringTrackColor', - hoveringTrackColor, - defaultValue: null, - ), - ); - properties.add( - ColorProperty('trackBorderColor', trackBorderColor, defaultValue: null), - ); - properties.add(ColorProperty( - 'hoveringTrackBorderColor', - hoveringTrackBorderColor, - defaultValue: null, - )); - properties.add(DiagnosticsProperty( - 'crossAxisMargin', - crossAxisMargin, - defaultValue: null, - )); - properties.add(DiagnosticsProperty( - 'mainAxisMargin', - mainAxisMargin, - defaultValue: null, - )); - properties.add(DiagnosticsProperty( - 'minThumbLength', - minThumbLength, - defaultValue: null, - )); - } - - /// Merges this [MacosScrollbarThemeData] with another. - MacosScrollbarThemeData merge(MacosScrollbarThemeData? other) { - if (other == null) return this; - return copyWith( - thickness: other.thickness, - hoveringThickness: other.hoveringThickness, - showTrackOnHover: other.showTrackOnHover, - isAlwaysShown: other.isAlwaysShown, - interactive: other.interactive, - radius: other.radius, - thumbColor: other.thumbColor, - hoveringThumbColor: other.hoveringThumbColor, - draggingThumbColor: other.draggingThumbColor, - trackColor: other.trackColor, - hoveringTrackColor: other.hoveringTrackColor, - trackBorderColor: other.trackBorderColor, - hoveringTrackBorderColor: other.hoveringTrackBorderColor, - crossAxisMargin: other.crossAxisMargin, - mainAxisMargin: other.mainAxisMargin, - minThumbLength: other.minThumbLength, - ); } } diff --git a/pubspec.lock b/pubspec.lock index eb16ba9a..75773850 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,18 +5,18 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: d976d24314f193899a3079b14fe336215a63a3b1e1c3743eabba8f83e049e9a9 + sha256: "0c80aeab9bc807ab10022cd3b2f4cf2ecdf231949dc1ddd9442406a003f19201" url: "https://pub.dev" source: hosted - version: "49.0.0" + version: "52.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: "40ba2c6d2ab41a66476f8f1f099da6be0795c1b47221f5e2c5f8ad6048cdffae" + sha256: cd8ee83568a77f3ae6b913a36093a1c9b1264e7cb7f834d9ddd2311dade9c1f4 url: "https://pub.dev" source: hosted - version: "5.1.0" + version: "5.4.0" analyzer_plugin: dependency: transitive description: @@ -37,10 +37,10 @@ packages: dependency: transitive description: name: args - sha256: "139d809800a412ebb26a3892da228b2d0ba36f0ef5d9a82166e5e52ec8d61611" + sha256: "4cab82a83ffef80b262ddedf47a0a8e56ee6fbf7fe21e6e768b02792034dd440" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.4.0" async: dependency: transitive description: @@ -93,10 +93,10 @@ packages: dependency: transitive description: name: coverage - sha256: "961c4aebd27917269b1896382c7cb1b1ba81629ba669ba09c27a7e5710ec9040" + sha256: "2fb815080e44a09b85e0f2ca8a820b15053982b2e714b59267719e8a9ff17097" url: "https://pub.dev" source: hosted - version: "1.6.2" + version: "1.6.3" crypto: dependency: transitive description: @@ -117,10 +117,18 @@ packages: dependency: "direct dev" description: name: dart_code_metrics - sha256: "219607f5abbf4c0d254ca39ee009f9ff28df91c40aef26718fde15af6b7a6c24" + sha256: "026e28da197a03caeccccc0b174ec98ef03da3c81c4543314d7add121aab4375" + url: "https://pub.dev" + source: hosted + version: "5.6.0" + dart_code_metrics_presets: + dependency: transitive + description: + name: dart_code_metrics_presets + sha256: "9c51724f836aebc4465228954cb5757e5a99737af26a452b5dec0a2d5d0b4d66" url: "https://pub.dev" source: hosted - version: "4.21.3" + version: "1.2.0" dart_style: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4ffe3e99..7b6b263b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: macos_ui description: Flutter widgets and themes implementing the current macOS design language. -version: 1.9.1 +version: 1.10.0 homepage: "https://macosui.dev" repository: "https://github.com/GroovinChip/macos_ui" @@ -15,7 +15,7 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter - dart_code_metrics: ^4.17.1 + dart_code_metrics: ^5.6.0 flutter_lints: ^2.0.1 mocktail: ^0.3.0 diff --git a/test/buttons/checkbox_test.dart b/test/buttons/checkbox_test.dart index 323c3425..09881007 100644 --- a/test/buttons/checkbox_test.dart +++ b/test/buttons/checkbox_test.dart @@ -12,7 +12,7 @@ void main() { child: MacosScaffold( children: [ ContentArea( - builder: (context) { + builder: (context, _) { return StatefulBuilder( builder: (context, setState) { return MacosCheckbox( diff --git a/test/buttons/disclosure_button_test.dart b/test/buttons/disclosure_button_test.dart new file mode 100644 index 00000000..b00087a3 --- /dev/null +++ b/test/buttons/disclosure_button_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:macos_ui/macos_ui.dart'; + +import '../mocks.dart'; + +void main() { + late MockOnPressedFunction mockOnPressedFunction; + + setUp(() { + mockOnPressedFunction = MockOnPressedFunction(); + }); + + testWidgets('MacosDisclosureButton onPressed works', (tester) async { + await tester.pumpWidget( + MacosApp( + home: MacosWindow( + child: MacosScaffold( + children: [ + ContentArea( + builder: (context, scrollController) { + return MacosDisclosureButton( + onPressed: mockOnPressedFunction.handler, + ); + }, + ), + ], + ), + ), + ), + ); + + final disclosureButton = find.byType(MacosDisclosureButton); + await tester.tap(disclosureButton); + await tester.pumpAndSettle(); + + expect(mockOnPressedFunction.called, 2); + }); + + testWidgets('debugFillProperties', (tester) async { + final builder = DiagnosticPropertiesBuilder(); + const MacosDisclosureButton().debugFillProperties(builder); + + final description = builder.properties + .where((node) => !node.isFiltered(DiagnosticLevel.info)) + .map((node) => node.toString()) + .toList(); + + expect( + description, + [ + 'fillColor: null', + 'hoverColor: null', + 'semanticLabel: null', + 'disabled', + ], + ); + }); +} diff --git a/test/buttons/help_button_test.dart b/test/buttons/help_button_test.dart index 095acbc6..2f11f430 100644 --- a/test/buttons/help_button_test.dart +++ b/test/buttons/help_button_test.dart @@ -24,7 +24,7 @@ void main() { child: MacosScaffold( children: [ ContentArea( - builder: (context) { + builder: (context, _) { return HelpButton( onPressed: mockOnPressedFunction.handler, ); @@ -55,7 +55,7 @@ void main() { child: MacosScaffold( children: [ ContentArea( - builder: (context) { + builder: (context, _) { return HelpButton( key: helpButtonKey, onPressed: mockOnTapCancelFunction.handler, diff --git a/test/buttons/icon_button_test.dart b/test/buttons/icon_button_test.dart index a6d893de..057f6a3b 100644 --- a/test/buttons/icon_button_test.dart +++ b/test/buttons/icon_button_test.dart @@ -19,7 +19,7 @@ void main() { child: MacosScaffold( children: [ ContentArea( - builder: (context) { + builder: (context, _) { return MacosIconButton( icon: const Icon(CupertinoIcons.add), onPressed: mockOnPressedFunction.handler, @@ -48,7 +48,7 @@ void main() { child: MacosScaffold( children: [ ContentArea( - builder: (context) { + builder: (context, _) { return MacosIconButton( key: iconButtonKey, icon: const Icon(CupertinoIcons.add), diff --git a/test/buttons/popup_button_test.dart b/test/buttons/popup_button_test.dart index a57d1516..704501ef 100644 --- a/test/buttons/popup_button_test.dart +++ b/test/buttons/popup_button_test.dart @@ -17,7 +17,7 @@ void main() { child: MacosScaffold( children: [ ContentArea( - builder: (context) { + builder: (context, _) { return StatefulBuilder( builder: (context, setState) { return MacosPopupButton( diff --git a/test/buttons/pulldown_button_test.dart b/test/buttons/pulldown_button_test.dart index fd319577..e22a355e 100644 --- a/test/buttons/pulldown_button_test.dart +++ b/test/buttons/pulldown_button_test.dart @@ -22,7 +22,7 @@ void main() { child: MacosScaffold( children: [ ContentArea( - builder: (context) { + builder: (context, _) { return Center( child: MacosPulldownButton( title: "test", @@ -67,7 +67,7 @@ void main() { child: MacosScaffold( children: [ ContentArea( - builder: (context) { + builder: (context, _) { return Center( child: MacosPulldownButton( title: "test", diff --git a/test/buttons/push_button_test.dart b/test/buttons/push_button_test.dart index 3cd6c5e5..96dc571e 100644 --- a/test/buttons/push_button_test.dart +++ b/test/buttons/push_button_test.dart @@ -25,7 +25,7 @@ void main() { child: MacosScaffold( children: [ ContentArea( - builder: (context) { + builder: (context, _) { return PushButton( buttonSize: ButtonSize.small, onPressed: mockOnPressedFunction.handler, @@ -58,7 +58,7 @@ void main() { child: MacosScaffold( children: [ ContentArea( - builder: (context) { + builder: (context, _) { return PushButton( buttonSize: ButtonSize.small, key: pushButtonKey, diff --git a/test/buttons/radio_button_test.dart b/test/buttons/radio_button_test.dart index f900a867..d561b643 100644 --- a/test/buttons/radio_button_test.dart +++ b/test/buttons/radio_button_test.dart @@ -20,7 +20,7 @@ void main() { child: MacosScaffold( children: [ ContentArea( - builder: (context) { + builder: (context, _) { return Center( child: MacosRadioButton( value: TestOptions.first, @@ -53,7 +53,7 @@ void main() { child: MacosScaffold( children: [ ContentArea( - builder: (context) { + builder: (context, _) { return Center( child: MacosRadioButton( value: TestOptions.second, diff --git a/test/buttons/segmented_control_test.dart b/test/buttons/segmented_control_test.dart index f85c9d89..62d19796 100644 --- a/test/buttons/segmented_control_test.dart +++ b/test/buttons/segmented_control_test.dart @@ -14,7 +14,7 @@ void main() { child: MacosScaffold( children: [ ContentArea( - builder: (context) { + builder: (context, _) { return Center( child: MacosSegmentedControl( controller: controller, @@ -59,7 +59,7 @@ void main() { child: MacosScaffold( children: [ ContentArea( - builder: (context) { + builder: (context, _) { return Center( child: MacosSegmentedControl( controller: controller, diff --git a/test/buttons/switch_test.dart b/test/buttons/switch_test.dart index 9c32d1b4..642f0f11 100644 --- a/test/buttons/switch_test.dart +++ b/test/buttons/switch_test.dart @@ -12,7 +12,7 @@ void main() { child: MacosScaffold( children: [ ContentArea( - builder: (context) { + builder: (context, _) { return Center( child: MacosSwitch( value: selected, diff --git a/test/fields/search_field_test.dart b/test/fields/search_field_test.dart index 507b5648..124f9fb1 100644 --- a/test/fields/search_field_test.dart +++ b/test/fields/search_field_test.dart @@ -29,7 +29,7 @@ void main() { child: MacosScaffold( children: [ ContentArea( - builder: (context) { + builder: (context, _) { return Center( child: SizedBox( width: 300.0, diff --git a/test/fields/text_field_test.dart b/test/fields/text_field_test.dart index eff83bc2..71b12568 100644 --- a/test/fields/text_field_test.dart +++ b/test/fields/text_field_test.dart @@ -12,7 +12,7 @@ void main() { child: MacosScaffold( children: [ ContentArea( - builder: (context) { + builder: (context, _) { return Center( child: MacosTextField( controller: controller, diff --git a/test/indicators/capacity_indicators_test.dart b/test/indicators/capacity_indicators_test.dart index 25b274cf..5f5eb33a 100644 --- a/test/indicators/capacity_indicators_test.dart +++ b/test/indicators/capacity_indicators_test.dart @@ -1,7 +1,12 @@ import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + import 'package:flutter_test/flutter_test.dart'; + import 'package:macos_ui/macos_ui.dart'; +import '../mock_canvas.dart'; + void main() { testWidgets('debugFillProperties', (tester) async { final builder = DiagnosticPropertiesBuilder(); @@ -27,4 +32,163 @@ void main() { ], ); }); + + testWidgets('debugFillProperties with discrete splits = 20', (tester) async { + final builder = DiagnosticPropertiesBuilder(); + const CapacityIndicator( + value: 50, + splits: 20, + discrete: true, + ).debugFillProperties(builder); + + final description = builder.properties + .where((node) => !node.isFiltered(DiagnosticLevel.info)) + .map((node) => node.toString()) + .toList(); + + expect( + description, + [ + 'value: 50.0', + 'splits: 20', + 'color: systemGreen(*color = Color(0xff34c759)*, darkColor = Color(0xff30d158), highContrastColor = Color(0xff248a3d), darkHighContrastColor = Color(0xff30db5b), resolved by: UNRESOLVED)', + 'backgroundColor: tertiarySystemGroupedBackground(*color = Color(0xfff2f2f7)*, darkColor = Color(0xff2c2c2e), highContrastColor = Color(0xffebebf0), darkHighContrastColor = Color(0xff363638), *elevatedColor = Color(0xfff2f2f7)*, darkElevatedColor = Color(0xff3a3a3c), highContrastElevatedColor = Color(0xffebebf0), darkHighContrastElevatedColor = Color(0xff444446), resolved by: UNRESOLVED)', + 'borderColor: tertiaryLabel(*color = Color(0x4c3c3c43)*, darkColor = Color(0x4cebebf5), highContrastColor = Color(0x603c3c43), darkHighContrastColor = Color(0x60ebebf5), resolved by: UNRESOLVED)', + 'semanticLabel: null', + ], + ); + }); + + testWidgets( + 'CapacityIndicator paints the correct number of segments', + (WidgetTester tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: 200.0, + child: CapacityIndicator( + value: 50, + splits: 20, + discrete: true, + ), + ), + ), + ), + ); + + expect( + find.byType(CapacityIndicator), + // each discrete segment is drawn 3 times, two times with fill, last time with stroke + paintsExactlyCountTimes(#drawRRect, 20 * 3), + ); + }, + ); + + testWidgets( + 'CapacityIndicator paints two filled segments for value=10 and 20 segments', + (WidgetTester tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: 200.0, + child: CapacityIndicator( + value: 10, + splits: 20, + discrete: true, + ), + ), + ), + ), + ); + + expect( + find.byType(CapacityIndicator), + // each discrete segment is drawn 3 times, background - fill - stroke + // a filled segment is drawn by fromLTRBR with LTRB=0,0,8,16 + // an empty segment is drawnby fromLTRBAndCorners with LTRB=0,0,0,16 + paints + ..rrect( + rrect: RRect.fromLTRBR( + 0.0, + 0.0, + 8.0, + 16.0, + const Radius.circular(2.0), + ), + ) + ..rrect( + rrect: RRect.fromLTRBR( + 0.0, + 0.0, + 8.0, + 16.0, + const Radius.circular(2.0), + ), + ) + ..rrect( + rrect: RRect.fromLTRBR( + 0.0, + 0.0, + 8.0, + 16.0, + const Radius.circular(2.0), + ), + ) + ..translate(x: 10.0, y: 0.0) + ..rrect( + rrect: RRect.fromLTRBR( + 0.0, + 0.0, + 8.0, + 16.0, + const Radius.circular(2.0), + ), + ) + ..rrect( + rrect: RRect.fromLTRBR( + 0.0, + 0.0, + 8.0, + 16.0, + const Radius.circular(2.0), + ), + ) + ..rrect( + rrect: RRect.fromLTRBR( + 0.0, + 0.0, + 8.0, + 16.0, + const Radius.circular(2.0), + ), + ) + ..translate(x: 20.0, y: 0.0) + ..rrect( + rrect: RRect.fromLTRBR( + 0.0, + 0.0, + 8.0, + 16.0, + const Radius.circular(2.0), + ), + ) + ..rrect( + rrect: RRect.fromLTRBAndCorners( + 0.0, + 0.0, + 0.0, + 16.0, + topLeft: const Radius.circular(2.0), + topRight: const Radius.circular(0.0), + bottomRight: const Radius.circular(0.0), + bottomLeft: const Radius.circular(2.0), + ), + ), + ); + }, + ); } diff --git a/test/indicators/scrollbar_test.dart b/test/indicators/scrollbar_test.dart new file mode 100644 index 00000000..f5ffe2c2 --- /dev/null +++ b/test/indicators/scrollbar_test.dart @@ -0,0 +1,66 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:macos_ui/macos_ui.dart'; + +// TODO: Test trackpad scroll +// TODO: Test trackpad fling +// TODO: Test scrollbar track UI when hovering over the scrollbar and dragging to scroll + +void main() { + const double thickness = 6; + const double thicknessWhenDragging = 9; + const double scaleFactor = 2; + final ScrollController scrollController = ScrollController(); + + testWidgets( + 'Scrollbar changes position when scrolled with the mouse wheel', + (tester) async { + final Size screenSize = tester.binding.window.physicalSize / + tester.binding.window.devicePixelRatio; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: PrimaryScrollController( + controller: scrollController, + child: MacosTheme( + data: MacosThemeData.light(), + child: MacosScrollbar( + thickness: thickness, + thicknessWhileHovering: thicknessWhenDragging, + child: SingleChildScrollView( + child: SizedBox( + width: screenSize.width * scaleFactor, + height: screenSize.height * scaleFactor, + ), + ), + ), + ), + ), + ), + ), + ); + + const Offset scrollAmount = Offset(0.0, 5.0); + const Offset reverseScrollAmount = Offset(0.0, -5.0); + Offset finalPosition = Offset.zero; + final Offset scrollEventLocation = + tester.getCenter(find.byType(SingleChildScrollView)); + final TestPointer testPointer = TestPointer(1, PointerDeviceKind.mouse); + + testPointer.hover(scrollEventLocation); + + // Scroll down + await tester.sendEventToBinding(testPointer.scroll(scrollAmount)); + await tester.pumpAndSettle(); + expect(scrollController.offset, scrollAmount.dy); + // Scroll back up + await tester.sendEventToBinding(testPointer.scroll(reverseScrollAmount)); + await tester.pumpAndSettle(); + expect(scrollController.offset, finalPosition.dy); + }, + ); +} diff --git a/test/layout/macos_list_tile_test.dart b/test/layout/macos_list_tile_test.dart index e021b3b5..e019a007 100644 --- a/test/layout/macos_list_tile_test.dart +++ b/test/layout/macos_list_tile_test.dart @@ -23,7 +23,7 @@ void main() { child: MacosScaffold( children: [ ContentArea( - builder: (context) { + builder: (context, _) { return MacosListTile( title: const Text('List Tile'), onClick: mockOnPressedFunction.handler, @@ -52,7 +52,7 @@ void main() { child: MacosScaffold( children: [ ContentArea( - builder: (context) { + builder: (context, _) { return MacosListTile( title: const Text('List Tile'), subtitle: const Text('Subtitle'), diff --git a/test/layout/resizeable_pane_test.dart b/test/layout/resizeable_pane_test.dart index 26016e84..bae5ac4d 100644 --- a/test/layout/resizeable_pane_test.dart +++ b/test/layout/resizeable_pane_test.dart @@ -26,7 +26,9 @@ void main() { children: [ resizablePane, ContentArea( - builder: (context) => const Text('Hello there'), + builder: (context, scrollController) { + return const Text('Hello there'); + }, ), ], ), diff --git a/test/layout/tab_view_test.dart b/test/layout/tab_view_test.dart index 89a0bad0..2fb09c07 100644 --- a/test/layout/tab_view_test.dart +++ b/test/layout/tab_view_test.dart @@ -14,7 +14,7 @@ void main() { child: MacosScaffold( children: [ ContentArea( - builder: (context) { + builder: (context, _) { return Padding( padding: const EdgeInsets.all(24.0), child: MacosTabView( diff --git a/test/mock_canvas.dart b/test/mock_canvas.dart new file mode 100644 index 00000000..c86c7482 --- /dev/null +++ b/test/mock_canvas.dart @@ -0,0 +1,1912 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:ui' as ui show Paragraph, Image; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'recording_canvas.dart'; + +/// Matches objects or functions that paint a display list that matches the +/// canvas calls described by the pattern. +/// +/// Specifically, this can be applied to [RenderObject]s, [Finder]s that +/// correspond to a single [RenderObject], and functions that have either of the +/// following signatures: +/// +/// ```dart +/// void function(PaintingContext context, Offset offset); +/// void function(Canvas canvas); +/// ``` +/// +/// In the case of functions that take a [PaintingContext] and an [Offset], the +/// [paints] matcher will always pass a zero offset. +/// +/// To specify the pattern, call the methods on the returned object. For example: +/// +/// ```dart +/// expect(myRenderObject, paints..circle(radius: 10.0)..circle(radius: 20.0)); +/// ``` +/// +/// This particular pattern would verify that the render object `myRenderObject` +/// paints, among other things, two circles of radius 10.0 and 20.0 (in that +/// order). +/// +/// See [PaintPattern] for a discussion of the semantics of paint patterns. +/// +/// To match something which paints nothing, see [paintsNothing]. +/// +/// To match something which asserts instead of painting, see [paintsAssertion]. +PaintPattern get paints => _TestRecordingCanvasPatternMatcher(); + +/// Matches objects or functions that does not paint anything on the canvas. +Matcher get paintsNothing => _TestRecordingCanvasPaintsNothingMatcher(); + +/// Matches objects or functions that assert when they try to paint. +Matcher get paintsAssertion => _TestRecordingCanvasPaintsAssertionMatcher(); + +/// Matches objects or functions that draw `methodName` exactly `count` number of times. +Matcher paintsExactlyCountTimes(Symbol methodName, int count) { + return _TestRecordingCanvasPaintsCountMatcher(methodName, count); +} + +/// Signature for the [PaintPattern.something] and [PaintPattern.everything] +/// predicate argument. +/// +/// Used by the [paints] matcher. +/// +/// The `methodName` argument is a [Symbol], and can be compared with the symbol +/// literal syntax, for example: +/// +/// ```dart +/// if (methodName == #drawCircle) { ... } +/// ``` +typedef PaintPatternPredicate = bool Function( + Symbol methodName, List arguments); + +/// The signature of [RenderObject.paint] functions. +typedef _ContextPainterFunction = void Function( + PaintingContext context, Offset offset); + +/// The signature of functions that paint directly on a canvas. +typedef _CanvasPainterFunction = void Function(Canvas canvas); + +/// Builder interface for patterns used to match display lists (canvas calls). +/// +/// The [paints] matcher returns a [PaintPattern] so that you can build the +/// pattern in the [expect] call. +/// +/// Patterns are subset matches, meaning that any calls not described by the +/// pattern are ignored. This allows, for instance, transforms to be skipped. +abstract class PaintPattern { + /// Indicates that a transform is expected next. + /// + /// Calls are skipped until a call to [Canvas.transform] is found. The call's + /// arguments are compared to those provided here. If any fail to match, or if + /// no call to [Canvas.transform] is found, then the matcher fails. + /// + /// Dynamic so matchers can be more easily passed in. + /// + /// The `matrix4` argument is dynamic so it can be either a [Matcher], or a + /// [Float64List] of [double]s. If it is a [Float64List] of [double]s then + /// each value in the matrix must match in the expected matrix. A deep + /// matching [Matcher] such as [equals] can be used to test each value in the + /// matrix with utilities such as [moreOrLessEquals]. + void transform({dynamic matrix4}); + + /// Indicates that a translation transform is expected next. + /// + /// Calls are skipped until a call to [Canvas.translate] is found. The call's + /// arguments are compared to those provided here. If any fail to match, or if + /// no call to [Canvas.translate] is found, then the matcher fails. + void translate({double? x, double? y}); + + /// Indicates that a scale transform is expected next. + /// + /// Calls are skipped until a call to [Canvas.scale] is found. The call's + /// arguments are compared to those provided here. If any fail to match, or if + /// no call to [Canvas.scale] is found, then the matcher fails. + void scale({double? x, double? y}); + + /// Indicates that a rotate transform is expected next. + /// + /// Calls are skipped until a call to [Canvas.rotate] is found. If the `angle` + /// argument is provided here, the call's argument is compared to it. If that + /// fails to match, or if no call to [Canvas.rotate] is found, then the + /// matcher fails. + void rotate({double? angle}); + + /// Indicates that a save is expected next. + /// + /// Calls are skipped until a call to [Canvas.save] is found. If none is + /// found, the matcher fails. + /// + /// See also: + /// + /// * [restore], which indicates that a restore is expected next. + /// * [saveRestore], which indicates that a matching pair of save/restore + /// calls is expected next. + void save(); + + /// Indicates that a restore is expected next. + /// + /// Calls are skipped until a call to [Canvas.restore] is found. If none is + /// found, the matcher fails. + /// + /// See also: + /// + /// * [save], which indicates that a save is expected next. + /// * [saveRestore], which indicates that a matching pair of save/restore + /// calls is expected next. + void restore(); + + /// Indicates that a matching pair of save/restore calls is expected next. + /// + /// Calls are skipped until a call to [Canvas.save] is found, then, calls are + /// skipped until the matching [Canvas.restore] call is found. If no matching + /// pair of calls could be found, the matcher fails. + /// + /// See also: + /// + /// * [save], which indicates that a save is expected next. + /// * [restore], which indicates that a restore is expected next. + void saveRestore(); + + /// Indicates that a rectangular clip is expected next. + /// + /// The next rectangular clip is examined. Any arguments that are passed to + /// this method are compared to the actual [Canvas.clipRect] call's argument + /// and any mismatches result in failure. + /// + /// If no call to [Canvas.clipRect] was made, then this results in failure. + /// + /// Any calls made between the last matched call (if any) and the + /// [Canvas.clipRect] call are ignored. + void clipRect({Rect? rect}); + + /// Indicates that a path clip is expected next. + /// + /// The next path clip is examined. + /// The path that is passed to the actual [Canvas.clipPath] call is matched + /// using [pathMatcher]. + /// + /// If no call to [Canvas.clipPath] was made, then this results in failure. + /// + /// Any calls made between the last matched call (if any) and the + /// [Canvas.clipPath] call are ignored. + void clipPath({Matcher? pathMatcher}); + + /// Indicates that a rectangle is expected next. + /// + /// The next rectangle is examined. Any arguments that are passed to this + /// method are compared to the actual [Canvas.drawRect] call's arguments + /// and any mismatches result in failure. + /// + /// If no call to [Canvas.drawRect] was made, then this results in failure. + /// + /// Any calls made between the last matched call (if any) and the + /// [Canvas.drawRect] call are ignored. + /// + /// The [Paint]-related arguments (`color`, `strokeWidth`, `hasMaskFilter`, + /// `style`) are compared against the state of the [Paint] object after the + /// painting has completed, not at the time of the call. If the same [Paint] + /// object is reused multiple times, then this may not match the actual + /// arguments as they were seen by the method. + void rect( + {Rect? rect, + Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}); + + /// Indicates that a rounded rectangle clip is expected next. + /// + /// The next rounded rectangle clip is examined. Any arguments that are passed + /// to this method are compared to the actual [Canvas.clipRRect] call's + /// argument and any mismatches result in failure. + /// + /// If no call to [Canvas.clipRRect] was made, then this results in failure. + /// + /// Any calls made between the last matched call (if any) and the + /// [Canvas.clipRRect] call are ignored. + void clipRRect({RRect? rrect}); + + /// Indicates that a rounded rectangle is expected next. + /// + /// The next rounded rectangle is examined. Any arguments that are passed to + /// this method are compared to the actual [Canvas.drawRRect] call's arguments + /// and any mismatches result in failure. + /// + /// If no call to [Canvas.drawRRect] was made, then this results in failure. + /// + /// Any calls made between the last matched call (if any) and the + /// [Canvas.drawRRect] call are ignored. + /// + /// The [Paint]-related arguments (`color`, `strokeWidth`, `hasMaskFilter`, + /// `style`) are compared against the state of the [Paint] object after the + /// painting has completed, not at the time of the call. If the same [Paint] + /// object is reused multiple times, then this may not match the actual + /// arguments as they were seen by the method. + void rrect( + {RRect? rrect, + Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}); + + /// Indicates that a rounded rectangle outline is expected next. + /// + /// The next call to [Canvas.drawRRect] is examined. Any arguments that are + /// passed to this method are compared to the actual [Canvas.drawRRect] call's + /// arguments and any mismatches result in failure. + /// + /// If no call to [Canvas.drawRRect] was made, then this results in failure. + /// + /// Any calls made between the last matched call (if any) and the + /// [Canvas.drawRRect] call are ignored. + /// + /// The [Paint]-related arguments (`color`, `strokeWidth`, `hasMaskFilter`, + /// `style`) are compared against the state of the [Paint] object after the + /// painting has completed, not at the time of the call. If the same [Paint] + /// object is reused multiple times, then this may not match the actual + /// arguments as they were seen by the method. + void drrect( + {RRect? outer, + RRect? inner, + Color? color, + double strokeWidth, + bool hasMaskFilter, + PaintingStyle style}); + + /// Indicates that a circle is expected next. + /// + /// The next circle is examined. Any arguments that are passed to this method + /// are compared to the actual [Canvas.drawCircle] call's arguments and any + /// mismatches result in failure. + /// + /// If no call to [Canvas.drawCircle] was made, then this results in failure. + /// + /// Any calls made between the last matched call (if any) and the + /// [Canvas.drawCircle] call are ignored. + /// + /// The [Paint]-related arguments (`color`, `strokeWidth`, `hasMaskFilter`, + /// `style`) are compared against the state of the [Paint] object after the + /// painting has completed, not at the time of the call. If the same [Paint] + /// object is reused multiple times, then this may not match the actual + /// arguments as they were seen by the method. + void circle( + {double? x, + double? y, + double? radius, + Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}); + + /// Indicates that a path is expected next. + /// + /// The next path is examined. Any arguments that are passed to this method + /// are compared to the actual [Canvas.drawPath] call's `paint` argument, and + /// any mismatches result in failure. + /// + /// To introspect the Path object (as it stands after the painting has + /// completed), the `includes` and `excludes` arguments can be provided to + /// specify points that should be considered inside or outside the path + /// (respectively). + /// + /// If no call to [Canvas.drawPath] was made, then this results in failure. + /// + /// Any calls made between the last matched call (if any) and the + /// [Canvas.drawPath] call are ignored. + /// + /// The [Paint]-related arguments (`color`, `strokeWidth`, `hasMaskFilter`, + /// `style`) are compared against the state of the [Paint] object after the + /// painting has completed, not at the time of the call. If the same [Paint] + /// object is reused multiple times, then this may not match the actual + /// arguments as they were seen by the method. + void path( + {Iterable? includes, + Iterable? excludes, + Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}); + + /// Indicates that a line is expected next. + /// + /// The next line is examined. Any arguments that are passed to this method + /// are compared to the actual [Canvas.drawLine] call's `p1`, `p2`, and + /// `paint` arguments, and any mismatches result in failure. + /// + /// If no call to [Canvas.drawLine] was made, then this results in failure. + /// + /// Any calls made between the last matched call (if any) and the + /// [Canvas.drawLine] call are ignored. + /// + /// The [Paint]-related arguments (`color`, `strokeWidth`, `hasMaskFilter`, + /// `style`) are compared against the state of the [Paint] object after the + /// painting has completed, not at the time of the call. If the same [Paint] + /// object is reused multiple times, then this may not match the actual + /// arguments as they were seen by the method. + void line( + {Offset? p1, + Offset? p2, + Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}); + + /// Indicates that an arc is expected next. + /// + /// The next arc is examined. Any arguments that are passed to this method + /// are compared to the actual [Canvas.drawArc] call's `paint` argument, and + /// any mismatches result in failure. + /// + /// If no call to [Canvas.drawArc] was made, then this results in failure. + /// + /// Any calls made between the last matched call (if any) and the + /// [Canvas.drawArc] call are ignored. + /// + /// The [Paint]-related arguments (`color`, `strokeWidth`, `hasMaskFilter`, + /// `style`) are compared against the state of the [Paint] object after the + /// painting has completed, not at the time of the call. If the same [Paint] + /// object is reused multiple times, then this may not match the actual + /// arguments as they were seen by the method. + void arc( + {Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}); + + /// Indicates that a paragraph is expected next. + /// + /// Calls are skipped until a call to [Canvas.drawParagraph] is found. Any + /// arguments that are passed to this method are compared to the actual + /// [Canvas.drawParagraph] call's argument, and any mismatches result in failure. + /// + /// The `offset` argument can be either an [Offset] or a [Matcher]. If it is + /// an [Offset] then the actual value must match the expected offset + /// precisely. If it is a [Matcher] then the comparison is made according to + /// the semantics of the [Matcher]. For example, [within] can be used to + /// assert that the actual offset is within a given distance from the expected + /// offset. + /// + /// If no call to [Canvas.drawParagraph] was made, then this results in failure. + void paragraph({ui.Paragraph? paragraph, dynamic offset}); + + /// Indicates that a shadow is expected next. + /// + /// The next shadow is examined. Any arguments that are passed to this method + /// are compared to the actual [Canvas.drawShadow] call's `paint` argument, + /// and any mismatches result in failure. + /// + /// In tests, shadows from framework features such as [BoxShadow] or + /// [Material] are disabled by default, and thus this predicate would not + /// match. The [debugDisableShadows] flag controls this. + /// + /// To introspect the Path object (as it stands after the painting has + /// completed), the `includes` and `excludes` arguments can be provided to + /// specify points that should be considered inside or outside the path + /// (respectively). + /// + /// If no call to [Canvas.drawShadow] was made, then this results in failure. + /// + /// Any calls made between the last matched call (if any) and the + /// [Canvas.drawShadow] call are ignored. + void shadow( + {Iterable? includes, + Iterable? excludes, + Color? color, + double? elevation, + bool? transparentOccluder}); + + /// Indicates that an image is expected next. + /// + /// The next call to [Canvas.drawImage] is examined, and its arguments + /// compared to those passed to _this_ method. + /// + /// If no call to [Canvas.drawImage] was made, then this results in + /// failure. + /// + /// Any calls made between the last matched call (if any) and the + /// [Canvas.drawImage] call are ignored. + /// + /// The [Paint]-related arguments (`color`, `strokeWidth`, `hasMaskFilter`, + /// `style`) are compared against the state of the [Paint] object after the + /// painting has completed, not at the time of the call. If the same [Paint] + /// object is reused multiple times, then this may not match the actual + /// arguments as they were seen by the method. + void image( + {ui.Image? image, + double? x, + double? y, + Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}); + + /// Indicates that an image subsection is expected next. + /// + /// The next call to [Canvas.drawImageRect] is examined, and its arguments + /// compared to those passed to _this_ method. + /// + /// If no call to [Canvas.drawImageRect] was made, then this results in + /// failure. + /// + /// Any calls made between the last matched call (if any) and the + /// [Canvas.drawImageRect] call are ignored. + /// + /// The [Paint]-related arguments (`color`, `strokeWidth`, `hasMaskFilter`, + /// `style`) are compared against the state of the [Paint] object after the + /// painting has completed, not at the time of the call. If the same [Paint] + /// object is reused multiple times, then this may not match the actual + /// arguments as they were seen by the method. + void drawImageRect( + {ui.Image? image, + Rect? source, + Rect? destination, + Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}); + + /// Provides a custom matcher. + /// + /// Each method call after the last matched call (if any) will be passed to + /// the given predicate, along with the values of its (positional) arguments. + /// + /// For each one, the predicate must either return a boolean or throw a [String]. + /// + /// If the predicate returns true, the call is considered a successful match + /// and the next step in the pattern is examined. If this was the last step, + /// then any calls that were not yet matched are ignored and the [paints] + /// [Matcher] is considered a success. + /// + /// If the predicate returns false, then the call is considered uninteresting + /// and the predicate will be called again for the next [Canvas] call that was + /// made by the [RenderObject] under test. If this was the last call, then the + /// [paints] [Matcher] is considered to have failed. + /// + /// If the predicate throws a [String], then the [paints] [Matcher] is + /// considered to have failed. The thrown string is used in the message + /// displayed from the test framework and should be complete sentence + /// describing the problem. + void something(PaintPatternPredicate predicate); + + /// Provides a custom matcher. + /// + /// Each method call after the last matched call (if any) will be passed to + /// the given predicate, along with the values of its (positional) arguments. + /// + /// For each one, the predicate must either return a boolean or throw a [String]. + /// + /// The predicate will be applied to each [Canvas] call until it returns false + /// or all of the method calls have been tested. + /// + /// If the predicate returns false, then the [paints] [Matcher] is considered + /// to have failed. If all calls are tested without failing, then the [paints] + /// [Matcher] is considered a success. + /// + /// If the predicate throws a [String], then the [paints] [Matcher] is + /// considered to have failed. The thrown string is used in the message + /// displayed from the test framework and should be complete sentence + /// describing the problem. + void everything(PaintPatternPredicate predicate); +} + +/// Matches a [Path] that contains (as defined by [Path.contains]) the given +/// `includes` points and does not contain the given `excludes` points. +Matcher isPathThat({ + Iterable includes = const [], + Iterable excludes = const [], +}) { + return _PathMatcher(includes.toList(), excludes.toList()); +} + +class _PathMatcher extends Matcher { + _PathMatcher(this.includes, this.excludes); + + List includes; + List excludes; + + @override + bool matches(Object? object, Map matchState) { + if (object is! Path) { + matchState[this] = 'The given object ($object) was not a Path.'; + return false; + } + final Path path = object; + final List errors = [ + for (final Offset offset in includes) + if (!path.contains(offset)) + 'Offset $offset should be inside the path, but is not.', + for (final Offset offset in excludes) + if (path.contains(offset)) + 'Offset $offset should be outside the path, but is not.', + ]; + if (errors.isEmpty) { + return true; + } + matchState[this] = + 'Not all the given points were inside or outside the path as expected:\n ${errors.join("\n ")}'; + return false; + } + + @override + Description describe(Description description) { + String points(List list) { + final int count = list.length; + if (count == 1) { + return 'one particular point'; + } + return '$count particular points'; + } + + return description.add( + 'A Path that contains ${points(includes)} but does not contain ${points(excludes)}.'); + } + + @override + Description describeMismatch( + dynamic item, + Description description, + Map matchState, + bool verbose, + ) { + return description.add(matchState[this] as String); + } +} + +class _MismatchedCall { + const _MismatchedCall(this.message, this.callIntroduction, this.call); + final String message; + final String callIntroduction; + final RecordedInvocation call; +} + +bool _evaluatePainter(Object? object, Canvas canvas, PaintingContext context) { + if (object is _ContextPainterFunction) { + final _ContextPainterFunction function = object; + function(context, Offset.zero); + } else if (object is _CanvasPainterFunction) { + final _CanvasPainterFunction function = object; + function(canvas); + } else { + if (object is Finder) { + TestAsyncUtils.guardSync(); + final Finder finder = object; + object = finder.evaluate().single.renderObject; + } + if (object is RenderObject) { + final RenderObject renderObject = object; + renderObject.paint(context, Offset.zero); + } else { + return false; + } + } + return true; +} + +abstract class _TestRecordingCanvasMatcher extends Matcher { + @override + bool matches(Object? object, Map matchState) { + final TestRecordingCanvas canvas = TestRecordingCanvas(); + final TestRecordingPaintingContext context = + TestRecordingPaintingContext(canvas); + final StringBuffer description = StringBuffer(); + String prefixMessage = 'unexpectedly failed.'; + bool result = false; + try { + if (!_evaluatePainter(object, canvas, context)) { + matchState[this] = + 'was not one of the supported objects for the "paints" matcher.'; + return false; + } + result = _evaluatePredicates(canvas.invocations, description); + if (!result) { + prefixMessage = 'did not match the pattern.'; + } + } catch (error, stack) { + prefixMessage = 'threw the following exception:'; + description.writeln(error.toString()); + description.write(stack.toString()); + result = false; + } + if (!result) { + if (canvas.invocations.isNotEmpty) { + description.write('The complete display list was:'); + for (final RecordedInvocation call in canvas.invocations) { + description.write('\n * $call'); + } + } + matchState[this] = '$prefixMessage\n$description'; + } + return result; + } + + bool _evaluatePredicates( + Iterable calls, StringBuffer description); + + @override + Description describeMismatch( + dynamic item, + Description description, + Map matchState, + bool verbose, + ) { + return description.add(matchState[this] as String); + } +} + +class _TestRecordingCanvasPaintsCountMatcher + extends _TestRecordingCanvasMatcher { + _TestRecordingCanvasPaintsCountMatcher(Symbol methodName, int count) + : _methodName = methodName, + _count = count; + + final Symbol _methodName; + final int _count; + + @override + Description describe(Description description) { + return description + .add('Object or closure painting $_methodName exactly $_count times'); + } + + @override + bool _evaluatePredicates( + Iterable calls, StringBuffer description) { + int count = 0; + for (final RecordedInvocation call in calls) { + if (call.invocation.isMethod && + call.invocation.memberName == _methodName) { + count++; + } + } + if (count != _count) { + description.write( + 'It painted $_methodName $count times instead of $_count times.'); + } + return count == _count; + } +} + +class _TestRecordingCanvasPaintsNothingMatcher + extends _TestRecordingCanvasMatcher { + @override + Description describe(Description description) { + return description.add('An object or closure that paints nothing.'); + } + + @override + bool _evaluatePredicates( + Iterable calls, StringBuffer description) { + final Iterable paintingCalls = + _filterCanvasCalls(calls); + if (paintingCalls.isEmpty) { + return true; + } + description.write( + 'painted something, the first call having the following stack:\n' + '${paintingCalls.first.stackToString(indent: " ")}\n', + ); + return false; + } + + static const List _nonPaintingOperations = [ + #save, + #restore, + ]; + + // Filters out canvas calls that are not painting anything. + static Iterable _filterCanvasCalls( + Iterable canvasCalls) { + return canvasCalls.where( + (RecordedInvocation canvasCall) => + !_nonPaintingOperations.contains(canvasCall.invocation.memberName), + ); + } +} + +class _TestRecordingCanvasPaintsAssertionMatcher extends Matcher { + @override + bool matches(Object? object, Map matchState) { + final TestRecordingCanvas canvas = TestRecordingCanvas(); + final TestRecordingPaintingContext context = + TestRecordingPaintingContext(canvas); + final StringBuffer description = StringBuffer(); + String prefixMessage = 'unexpectedly failed.'; + bool result = false; + try { + if (!_evaluatePainter(object, canvas, context)) { + matchState[this] = + 'was not one of the supported objects for the "paints" matcher.'; + return false; + } + prefixMessage = 'did not assert.'; + } on AssertionError { + result = true; + } catch (error, stack) { + prefixMessage = 'threw the following exception:'; + description.writeln(error.toString()); + description.write(stack.toString()); + result = false; + } + if (!result) { + if (canvas.invocations.isNotEmpty) { + description.write('The complete display list was:'); + for (final RecordedInvocation call in canvas.invocations) { + description.write('\n * $call'); + } + } + matchState[this] = '$prefixMessage\n$description'; + } + return result; + } + + @override + Description describe(Description description) { + return description + .add('An object or closure that asserts when it tries to paint.'); + } + + @override + Description describeMismatch( + dynamic item, + Description description, + Map matchState, + bool verbose, + ) { + return description.add(matchState[this] as String); + } +} + +class _TestRecordingCanvasPatternMatcher extends _TestRecordingCanvasMatcher + implements PaintPattern { + final List<_PaintPredicate> _predicates = <_PaintPredicate>[]; + + @override + void transform({dynamic matrix4}) { + _predicates.add(_FunctionPaintPredicate(#transform, [matrix4])); + } + + @override + void translate({double? x, double? y}) { + _predicates.add(_FunctionPaintPredicate(#translate, [x, y])); + } + + @override + void scale({double? x, double? y}) { + _predicates.add(_FunctionPaintPredicate(#scale, [x, y])); + } + + @override + void rotate({double? angle}) { + _predicates.add(_FunctionPaintPredicate(#rotate, [angle])); + } + + @override + void save() { + _predicates.add(_FunctionPaintPredicate(#save, [])); + } + + @override + void restore() { + _predicates.add(_FunctionPaintPredicate(#restore, [])); + } + + @override + void saveRestore() { + _predicates.add(_SaveRestorePairPaintPredicate()); + } + + @override + void clipRect({Rect? rect}) { + _predicates.add(_FunctionPaintPredicate(#clipRect, [rect])); + } + + @override + void clipPath({Matcher? pathMatcher}) { + _predicates.add(_FunctionPaintPredicate(#clipPath, [pathMatcher])); + } + + @override + void rect( + {Rect? rect, + Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}) { + _predicates.add(_RectPaintPredicate( + rect: rect, + color: color, + strokeWidth: strokeWidth, + hasMaskFilter: hasMaskFilter, + style: style)); + } + + @override + void clipRRect({RRect? rrect}) { + _predicates.add(_FunctionPaintPredicate(#clipRRect, [rrect])); + } + + @override + void rrect( + {RRect? rrect, + Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}) { + _predicates.add(_RRectPaintPredicate( + rrect: rrect, + color: color, + strokeWidth: strokeWidth, + hasMaskFilter: hasMaskFilter, + style: style)); + } + + @override + void drrect( + {RRect? outer, + RRect? inner, + Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}) { + _predicates.add(_DRRectPaintPredicate( + outer: outer, + inner: inner, + color: color, + strokeWidth: strokeWidth, + hasMaskFilter: hasMaskFilter, + style: style)); + } + + @override + void circle( + {double? x, + double? y, + double? radius, + Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}) { + _predicates.add(_CirclePaintPredicate( + x: x, + y: y, + radius: radius, + color: color, + strokeWidth: strokeWidth, + hasMaskFilter: hasMaskFilter, + style: style)); + } + + @override + void path( + {Iterable? includes, + Iterable? excludes, + Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}) { + _predicates.add(_PathPaintPredicate( + includes: includes, + excludes: excludes, + color: color, + strokeWidth: strokeWidth, + hasMaskFilter: hasMaskFilter, + style: style)); + } + + @override + void line( + {Offset? p1, + Offset? p2, + Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}) { + _predicates.add(_LinePaintPredicate( + p1: p1, + p2: p2, + color: color, + strokeWidth: strokeWidth, + hasMaskFilter: hasMaskFilter, + style: style)); + } + + @override + void arc( + {Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}) { + _predicates.add(_ArcPaintPredicate( + color: color, + strokeWidth: strokeWidth, + hasMaskFilter: hasMaskFilter, + style: style)); + } + + @override + void paragraph({ui.Paragraph? paragraph, dynamic offset}) { + _predicates.add( + _FunctionPaintPredicate(#drawParagraph, [paragraph, offset])); + } + + @override + void shadow( + {Iterable? includes, + Iterable? excludes, + Color? color, + double? elevation, + bool? transparentOccluder}) { + _predicates.add(_ShadowPredicate( + includes: includes, + excludes: excludes, + color: color, + elevation: elevation, + transparentOccluder: transparentOccluder)); + } + + @override + void image( + {ui.Image? image, + double? x, + double? y, + Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}) { + _predicates.add(_DrawImagePaintPredicate( + image: image, + x: x, + y: y, + color: color, + strokeWidth: strokeWidth, + hasMaskFilter: hasMaskFilter, + style: style)); + } + + @override + void drawImageRect( + {ui.Image? image, + Rect? source, + Rect? destination, + Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}) { + _predicates.add(_DrawImageRectPaintPredicate( + image: image, + source: source, + destination: destination, + color: color, + strokeWidth: strokeWidth, + hasMaskFilter: hasMaskFilter, + style: style)); + } + + @override + void something(PaintPatternPredicate predicate) { + _predicates.add(_SomethingPaintPredicate(predicate)); + } + + @override + void everything(PaintPatternPredicate predicate) { + _predicates.add(_EverythingPaintPredicate(predicate)); + } + + @override + Description describe(Description description) { + if (_predicates.isEmpty) { + return description.add('An object or closure and a paint pattern.'); + } + description.add('Object or closure painting:\n'); + return description.addAll( + '', + '\n', + '', + _predicates + .map((_PaintPredicate predicate) => predicate.toString()), + ); + } + + @override + bool _evaluatePredicates( + Iterable calls, StringBuffer description) { + if (calls.isEmpty) { + description.writeln('It painted nothing.'); + return false; + } + if (_predicates.isEmpty) { + description.writeln( + 'It painted something, but you must now add a pattern to the paints matcher ' + 'in the test to verify that it matches the important parts of the following.', + ); + return false; + } + final Iterator<_PaintPredicate> predicate = _predicates.iterator; + final Iterator call = calls.iterator..moveNext(); + try { + while (predicate.moveNext()) { + predicate.current.match(call); + } + // We allow painting more than expected. + } on _MismatchedCall catch (data) { + description.writeln(data.message); + description.writeln(data.callIntroduction); + description.writeln(data.call.stackToString(indent: ' ')); + return false; + } on String catch (s) { + description.writeln(s); + try { + description.write( + 'The stack of the offending call was:\n${call.current.stackToString(indent: " ")}\n'); + } on TypeError catch (_) { + // All calls have been evaluated + } + return false; + } + return true; + } +} + +abstract class _PaintPredicate { + void match(Iterator call); + + @protected + void checkMethod(Iterator call, Symbol symbol) { + int others = 0; + final RecordedInvocation firstCall = call.current; + while (!call.current.invocation.isMethod || + call.current.invocation.memberName != symbol) { + others += 1; + if (!call.moveNext()) { + throw _MismatchedCall( + 'It called $others other method${others == 1 ? "" : "s"} on the canvas, ' + 'the first of which was $firstCall, but did not ' + 'call ${_symbolName(symbol)}() at the time where $this was expected.', + 'The first method that was called when the call to ${_symbolName(symbol)}() ' + 'was expected, $firstCall, was called with the following stack:', + firstCall, + ); + } + } + } + + @override + String toString() { + throw FlutterError('$runtimeType does not implement toString.'); + } +} + +abstract class _DrawCommandPaintPredicate extends _PaintPredicate { + _DrawCommandPaintPredicate( + this.symbol, + this.name, + this.argumentCount, + this.paintArgumentIndex, { + this.color, + this.strokeWidth, + this.hasMaskFilter, + this.style, + }); + + final Symbol symbol; + final String name; + final int argumentCount; + final int paintArgumentIndex; + final Color? color; + final double? strokeWidth; + final bool? hasMaskFilter; + final PaintingStyle? style; + + String get methodName => _symbolName(symbol); + + @override + void match(Iterator call) { + checkMethod(call, symbol); + final int actualArgumentCount = + call.current.invocation.positionalArguments.length; + if (actualArgumentCount != argumentCount) { + throw 'It called $methodName with $actualArgumentCount argument${actualArgumentCount == 1 ? "" : "s"}; expected $argumentCount.'; + } + verifyArguments(call.current.invocation.positionalArguments); + call.moveNext(); + } + + @protected + @mustCallSuper + void verifyArguments(List arguments) { + final Paint paintArgument = arguments[paintArgumentIndex] as Paint; + if (color != null && paintArgument.color != color) { + throw 'It called $methodName with a paint whose color, ${paintArgument.color}, was not exactly the expected color ($color).'; + } + if (strokeWidth != null && paintArgument.strokeWidth != strokeWidth) { + throw 'It called $methodName with a paint whose strokeWidth, ${paintArgument.strokeWidth}, was not exactly the expected strokeWidth ($strokeWidth).'; + } + if (hasMaskFilter != null && + (paintArgument.maskFilter != null) != hasMaskFilter) { + if (hasMaskFilter!) { + throw 'It called $methodName with a paint that did not have a mask filter, despite expecting one.'; + } else { + throw 'It called $methodName with a paint that did have a mask filter, despite not expecting one.'; + } + } + if (style != null && paintArgument.style != style) { + throw 'It called $methodName with a paint whose style, ${paintArgument.style}, was not exactly the expected style ($style).'; + } + } + + @override + String toString() { + final List description = []; + debugFillDescription(description); + String result = name; + if (description.isNotEmpty) { + result += ' with ${description.join(", ")}'; + } + return result; + } + + @protected + @mustCallSuper + void debugFillDescription(List description) { + if (color != null) { + description.add('$color'); + } + if (strokeWidth != null) { + description.add('strokeWidth: $strokeWidth'); + } + if (hasMaskFilter != null) { + description.add(hasMaskFilter! ? 'a mask filter' : 'no mask filter'); + } + if (style != null) { + description.add('$style'); + } + } +} + +class _OneParameterPaintPredicate extends _DrawCommandPaintPredicate { + _OneParameterPaintPredicate( + Symbol symbol, + String name, { + required this.expected, + required Color? color, + required double? strokeWidth, + required bool? hasMaskFilter, + required PaintingStyle? style, + }) : super( + symbol, + name, + 2, + 1, + color: color, + strokeWidth: strokeWidth, + hasMaskFilter: hasMaskFilter, + style: style, + ); + + final T? expected; + + @override + void verifyArguments(List arguments) { + super.verifyArguments(arguments); + final T actual = arguments[0] as T; + if (expected != null && actual != expected) { + throw 'It called $methodName with $T, $actual, which was not exactly the expected $T ($expected).'; + } + } + + @override + void debugFillDescription(List description) { + super.debugFillDescription(description); + if (expected != null) { + if (expected.toString().contains(T.toString())) { + description.add('$expected'); + } else { + description.add('$T: $expected'); + } + } + } +} + +class _TwoParameterPaintPredicate extends _DrawCommandPaintPredicate { + _TwoParameterPaintPredicate( + Symbol symbol, + String name, { + required this.expected1, + required this.expected2, + required Color? color, + required double? strokeWidth, + required bool? hasMaskFilter, + required PaintingStyle? style, + }) : super( + symbol, + name, + 3, + 2, + color: color, + strokeWidth: strokeWidth, + hasMaskFilter: hasMaskFilter, + style: style, + ); + + final T1? expected1; + + final T2? expected2; + + @override + void verifyArguments(List arguments) { + super.verifyArguments(arguments); + final T1 actual1 = arguments[0] as T1; + if (expected1 != null && actual1 != expected1) { + throw 'It called $methodName with its first argument (a $T1), $actual1, which was not exactly the expected $T1 ($expected1).'; + } + final T2 actual2 = arguments[1] as T2; + if (expected2 != null && actual2 != expected2) { + throw 'It called $methodName with its second argument (a $T2), $actual2, which was not exactly the expected $T2 ($expected2).'; + } + } + + @override + void debugFillDescription(List description) { + super.debugFillDescription(description); + if (expected1 != null) { + if (expected1.toString().contains(T1.toString())) { + description.add('$expected1'); + } else { + description.add('$T1: $expected1'); + } + } + if (expected2 != null) { + if (expected2.toString().contains(T2.toString())) { + description.add('$expected2'); + } else { + description.add('$T2: $expected2'); + } + } + } +} + +class _RectPaintPredicate extends _OneParameterPaintPredicate { + _RectPaintPredicate( + {Rect? rect, + Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}) + : super( + #drawRect, + 'a rectangle', + expected: rect, + color: color, + strokeWidth: strokeWidth, + hasMaskFilter: hasMaskFilter, + style: style, + ); +} + +class _RRectPaintPredicate extends _DrawCommandPaintPredicate { + _RRectPaintPredicate( + {this.rrect, + Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}) + : super( + #drawRRect, + 'a rounded rectangle', + 2, + 1, + color: color, + strokeWidth: strokeWidth, + hasMaskFilter: hasMaskFilter, + style: style, + ); + + final RRect? rrect; + + @override + void verifyArguments(List arguments) { + super.verifyArguments(arguments); + const double eps = .0001; + final RRect actual = arguments[0] as RRect; + if (rrect != null && + ((actual.left - rrect!.left).abs() > eps || + (actual.right - rrect!.right).abs() > eps || + (actual.top - rrect!.top).abs() > eps || + (actual.bottom - rrect!.bottom).abs() > eps || + (actual.blRadiusX - rrect!.blRadiusX).abs() > eps || + (actual.blRadiusY - rrect!.blRadiusY).abs() > eps || + (actual.brRadiusX - rrect!.brRadiusX).abs() > eps || + (actual.brRadiusY - rrect!.brRadiusY).abs() > eps || + (actual.tlRadiusX - rrect!.tlRadiusX).abs() > eps || + (actual.tlRadiusY - rrect!.tlRadiusY).abs() > eps || + (actual.trRadiusX - rrect!.trRadiusX).abs() > eps || + (actual.trRadiusY - rrect!.trRadiusY).abs() > eps)) { + throw 'It called $methodName with RRect, $actual, which was not exactly the expected RRect ($rrect).'; + } + } + + @override + void debugFillDescription(List description) { + super.debugFillDescription(description); + if (rrect != null) { + description.add('RRect: $rrect'); + } + } +} + +class _DRRectPaintPredicate extends _TwoParameterPaintPredicate { + _DRRectPaintPredicate( + {RRect? inner, + RRect? outer, + Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}) + : super( + #drawDRRect, + 'a rounded rectangle outline', + expected1: outer, + expected2: inner, + color: color, + strokeWidth: strokeWidth, + hasMaskFilter: hasMaskFilter, + style: style, + ); +} + +class _CirclePaintPredicate extends _DrawCommandPaintPredicate { + _CirclePaintPredicate( + {this.x, + this.y, + this.radius, + Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}) + : super( + #drawCircle, + 'a circle', + 3, + 2, + color: color, + strokeWidth: strokeWidth, + hasMaskFilter: hasMaskFilter, + style: style, + ); + + final double? x; + final double? y; + final double? radius; + + @override + void verifyArguments(List arguments) { + super.verifyArguments(arguments); + final Offset pointArgument = arguments[0] as Offset; + if (x != null && y != null) { + final Offset point = Offset(x!, y!); + if (point != pointArgument) { + throw 'It called $methodName with a center coordinate, $pointArgument, which was not exactly the expected coordinate ($point).'; + } + } else { + if (x != null && pointArgument.dx != x) { + throw 'It called $methodName with a center coordinate, $pointArgument, whose x-coordinate not exactly the expected coordinate (${x!.toStringAsFixed(1)}).'; + } + if (y != null && pointArgument.dy != y) { + throw 'It called $methodName with a center coordinate, $pointArgument, whose y-coordinate not exactly the expected coordinate (${y!.toStringAsFixed(1)}).'; + } + } + final double radiusArgument = arguments[1] as double; + if (radius != null && radiusArgument != radius) { + throw 'It called $methodName with radius, ${radiusArgument.toStringAsFixed(1)}, which was not exactly the expected radius (${radius!.toStringAsFixed(1)}).'; + } + } + + @override + void debugFillDescription(List description) { + super.debugFillDescription(description); + if (x != null && y != null) { + description.add('point ${Offset(x!, y!)}'); + } else { + if (x != null) { + description.add('x-coordinate ${x!.toStringAsFixed(1)}'); + } + if (y != null) { + description.add('y-coordinate ${y!.toStringAsFixed(1)}'); + } + } + if (radius != null) { + description.add('radius ${radius!.toStringAsFixed(1)}'); + } + } +} + +class _PathPaintPredicate extends _DrawCommandPaintPredicate { + _PathPaintPredicate( + {this.includes, + this.excludes, + Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}) + : super( + #drawPath, + 'a path', + 2, + 1, + color: color, + strokeWidth: strokeWidth, + hasMaskFilter: hasMaskFilter, + style: style, + ); + + final Iterable? includes; + final Iterable? excludes; + + @override + void verifyArguments(List arguments) { + super.verifyArguments(arguments); + final Path pathArgument = arguments[0] as Path; + if (includes != null) { + for (final Offset offset in includes!) { + if (!pathArgument.contains(offset)) { + throw 'It called $methodName with a path that unexpectedly did not contain $offset.'; + } + } + } + if (excludes != null) { + for (final Offset offset in excludes!) { + if (pathArgument.contains(offset)) { + throw 'It called $methodName with a path that unexpectedly contained $offset.'; + } + } + } + } + + @override + void debugFillDescription(List description) { + super.debugFillDescription(description); + if (includes != null && excludes != null) { + description.add('that contains $includes and does not contain $excludes'); + } else if (includes != null) { + description.add('that contains $includes'); + } else if (excludes != null) { + description.add('that does not contain $excludes'); + } + } +} + +// TODO(ianh): add arguments to test the length, angle, that kind of thing +class _LinePaintPredicate extends _DrawCommandPaintPredicate { + _LinePaintPredicate( + {this.p1, + this.p2, + Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}) + : super( + #drawLine, + 'a line', + 3, + 2, + color: color, + strokeWidth: strokeWidth, + hasMaskFilter: hasMaskFilter, + style: style, + ); + + final Offset? p1; + final Offset? p2; + + @override + void verifyArguments(List arguments) { + super.verifyArguments(arguments); // Checks the 3rd argument, a Paint + if (arguments.length != 3) { + throw 'It called $methodName with ${arguments.length} arguments; expected 3.'; + } + final Offset p1Argument = arguments[0] as Offset; + final Offset p2Argument = arguments[1] as Offset; + if (p1 != null && p1Argument != p1) { + throw 'It called $methodName with p1 endpoint, $p1Argument, which was not exactly the expected endpoint ($p1).'; + } + if (p2 != null && p2Argument != p2) { + throw 'It called $methodName with p2 endpoint, $p2Argument, which was not exactly the expected endpoint ($p2).'; + } + } + + @override + void debugFillDescription(List description) { + super.debugFillDescription(description); + if (p1 != null) { + description.add('end point p1: $p1'); + } + if (p2 != null) { + description.add('end point p2: $p2'); + } + } +} + +class _ArcPaintPredicate extends _DrawCommandPaintPredicate { + _ArcPaintPredicate( + {Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}) + : super( + #drawArc, + 'an arc', + 5, + 4, + color: color, + strokeWidth: strokeWidth, + hasMaskFilter: hasMaskFilter, + style: style, + ); +} + +class _ShadowPredicate extends _PaintPredicate { + _ShadowPredicate( + {this.includes, + this.excludes, + this.color, + this.elevation, + this.transparentOccluder}); + + final Iterable? includes; + final Iterable? excludes; + final Color? color; + final double? elevation; + final bool? transparentOccluder; + + static const Symbol symbol = #drawShadow; + String get methodName => _symbolName(symbol); + + @protected + void verifyArguments(List arguments) { + if (arguments.length != 4) { + throw 'It called $methodName with ${arguments.length} arguments; expected 4.'; + } + final Path pathArgument = arguments[0] as Path; + if (includes != null) { + for (final Offset offset in includes!) { + if (!pathArgument.contains(offset)) { + throw 'It called $methodName with a path that unexpectedly did not contain $offset.'; + } + } + } + if (excludes != null) { + for (final Offset offset in excludes!) { + if (pathArgument.contains(offset)) { + throw 'It called $methodName with a path that unexpectedly contained $offset.'; + } + } + } + final Color actualColor = arguments[1] as Color; + if (color != null && actualColor != color) { + throw 'It called $methodName with a color, $actualColor, which was not exactly the expected color ($color).'; + } + final double actualElevation = arguments[2] as double; + if (elevation != null && actualElevation != elevation) { + throw 'It called $methodName with an elevation, $actualElevation, which was not exactly the expected value ($elevation).'; + } + final bool actualTransparentOccluder = arguments[3] as bool; + if (transparentOccluder != null && + actualTransparentOccluder != transparentOccluder) { + throw 'It called $methodName with a transparentOccluder value, $actualTransparentOccluder, which was not exactly the expected value ($transparentOccluder).'; + } + } + + @override + void match(Iterator call) { + checkMethod(call, symbol); + verifyArguments(call.current.invocation.positionalArguments); + call.moveNext(); + } + + @protected + void debugFillDescription(List description) { + if (includes != null && excludes != null) { + description.add('that contains $includes and does not contain $excludes'); + } else if (includes != null) { + description.add('that contains $includes'); + } else if (excludes != null) { + description.add('that does not contain $excludes'); + } + if (color != null) { + description.add('$color'); + } + if (elevation != null) { + description.add('elevation: $elevation'); + } + if (transparentOccluder != null) { + description.add('transparentOccluder: $transparentOccluder'); + } + } + + @override + String toString() { + final List description = []; + debugFillDescription(description); + String result = methodName; + if (description.isNotEmpty) { + result += ' with ${description.join(", ")}'; + } + return result; + } +} + +class _DrawImagePaintPredicate extends _DrawCommandPaintPredicate { + _DrawImagePaintPredicate( + {this.image, + this.x, + this.y, + Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}) + : super( + #drawImage, + 'an image', + 3, + 2, + color: color, + strokeWidth: strokeWidth, + hasMaskFilter: hasMaskFilter, + style: style, + ); + + final ui.Image? image; + final double? x; + final double? y; + + @override + void verifyArguments(List arguments) { + super.verifyArguments(arguments); + final ui.Image imageArgument = arguments[0] as ui.Image; + if (image != null && !image!.isCloneOf(imageArgument)) { + throw 'It called $methodName with an image, $imageArgument, which was not exactly the expected image ($image).'; + } + final Offset pointArgument = arguments[0] as Offset; + if (x != null && y != null) { + final Offset point = Offset(x!, y!); + if (point != pointArgument) { + throw 'It called $methodName with an offset coordinate, $pointArgument, which was not exactly the expected coordinate ($point).'; + } + } else { + if (x != null && pointArgument.dx != x) { + throw 'It called $methodName with an offset coordinate, $pointArgument, whose x-coordinate not exactly the expected coordinate (${x!.toStringAsFixed(1)}).'; + } + if (y != null && pointArgument.dy != y) { + throw 'It called $methodName with an offset coordinate, $pointArgument, whose y-coordinate not exactly the expected coordinate (${y!.toStringAsFixed(1)}).'; + } + } + } + + @override + void debugFillDescription(List description) { + super.debugFillDescription(description); + if (image != null) { + description.add('image $image'); + } + if (x != null && y != null) { + description.add('point ${Offset(x!, y!)}'); + } else { + if (x != null) { + description.add('x-coordinate ${x!.toStringAsFixed(1)}'); + } + if (y != null) { + description.add('y-coordinate ${y!.toStringAsFixed(1)}'); + } + } + } +} + +class _DrawImageRectPaintPredicate extends _DrawCommandPaintPredicate { + _DrawImageRectPaintPredicate( + {this.image, + this.source, + this.destination, + Color? color, + double? strokeWidth, + bool? hasMaskFilter, + PaintingStyle? style}) + : super( + #drawImageRect, + 'an image', + 4, + 3, + color: color, + strokeWidth: strokeWidth, + hasMaskFilter: hasMaskFilter, + style: style, + ); + + final ui.Image? image; + final Rect? source; + final Rect? destination; + + @override + void verifyArguments(List arguments) { + super.verifyArguments(arguments); + final ui.Image imageArgument = arguments[0] as ui.Image; + if (image != null && !image!.isCloneOf(imageArgument)) { + throw 'It called $methodName with an image, $imageArgument, which was not exactly the expected image ($image).'; + } + final Rect sourceArgument = arguments[1] as Rect; + if (source != null && sourceArgument != source) { + throw 'It called $methodName with a source rectangle, $sourceArgument, which was not exactly the expected rectangle ($source).'; + } + final Rect destinationArgument = arguments[2] as Rect; + if (destination != null && destinationArgument != destination) { + throw 'It called $methodName with a destination rectangle, $destinationArgument, which was not exactly the expected rectangle ($destination).'; + } + } + + @override + void debugFillDescription(List description) { + super.debugFillDescription(description); + if (image != null) { + description.add('image $image'); + } + if (source != null) { + description.add('source $source'); + } + if (destination != null) { + description.add('destination $destination'); + } + } +} + +class _SomethingPaintPredicate extends _PaintPredicate { + _SomethingPaintPredicate(this.predicate); + + final PaintPatternPredicate predicate; + + @override + void match(Iterator call) { + RecordedInvocation currentCall; + bool testedAllCalls = false; + do { + if (testedAllCalls) { + throw 'It painted methods that the predicate passed to a "something" step, ' + 'in the paint pattern, none of which were considered correct.'; + } + currentCall = call.current; + if (!currentCall.invocation.isMethod) { + throw 'It called $currentCall, which was not a method, when the paint pattern expected a method call'; + } + testedAllCalls = !call.moveNext(); + } while (!_runPredicate(currentCall.invocation.memberName, + currentCall.invocation.positionalArguments)); + } + + bool _runPredicate(Symbol methodName, List arguments) { + try { + return predicate(methodName, arguments); + } on String catch (s) { + throw 'It painted something that the predicate passed to a "something" step ' + 'in the paint pattern considered incorrect:\n $s\n '; + } + } + + @override + String toString() => 'a "something" step'; +} + +class _EverythingPaintPredicate extends _PaintPredicate { + _EverythingPaintPredicate(this.predicate); + + final PaintPatternPredicate predicate; + + @override + void match(Iterator call) { + do { + final RecordedInvocation currentCall = call.current; + if (!currentCall.invocation.isMethod) { + throw 'It called $currentCall, which was not a method, when the paint pattern expected a method call'; + } + if (!_runPredicate(currentCall.invocation.memberName, + currentCall.invocation.positionalArguments)) { + throw 'It painted something that the predicate passed to an "everything" step ' + 'in the paint pattern considered incorrect.\n'; + } + } while (call.moveNext()); + } + + bool _runPredicate(Symbol methodName, List arguments) { + try { + return predicate(methodName, arguments); + } on String catch (s) { + throw 'It painted something that the predicate passed to an "everything" step ' + 'in the paint pattern considered incorrect:\n $s\n '; + } + } + + @override + String toString() => 'an "everything" step'; +} + +class _FunctionPaintPredicate extends _PaintPredicate { + _FunctionPaintPredicate(this.symbol, this.arguments); + + final Symbol symbol; + + final List arguments; + + @override + void match(Iterator call) { + checkMethod(call, symbol); + if (call.current.invocation.positionalArguments.length != + arguments.length) { + throw 'It called ${_symbolName(symbol)} with ${call.current.invocation.positionalArguments.length} arguments; expected ${arguments.length}.'; + } + for (int index = 0; index < arguments.length; index += 1) { + final dynamic actualArgument = + call.current.invocation.positionalArguments[index]; + final dynamic desiredArgument = arguments[index]; + + if (desiredArgument is Matcher) { + expect(actualArgument, desiredArgument); + } else if (desiredArgument != null && desiredArgument != actualArgument) { + throw 'It called ${_symbolName(symbol)} with argument $index having value ${_valueName(actualArgument)} when ${_valueName(desiredArgument)} was expected.'; + } + } + call.moveNext(); + } + + @override + String toString() { + final List adjectives = [ + for (int index = 0; index < arguments.length; index += 1) + arguments[index] != null ? _valueName(arguments[index]) : '...', + ]; + return '${_symbolName(symbol)}(${adjectives.join(", ")})'; + } +} + +class _SaveRestorePairPaintPredicate extends _PaintPredicate { + @override + void match(Iterator call) { + checkMethod(call, #save); + int depth = 1; + while (depth > 0) { + if (!call.moveNext()) { + throw 'It did not have a matching restore() for the save() that was found where $this was expected.'; + } + if (call.current.invocation.isMethod) { + if (call.current.invocation.memberName == #save) { + depth += 1; + } else if (call.current.invocation.memberName == #restore) { + depth -= 1; + } + } + } + call.moveNext(); + } + + @override + String toString() => 'a matching save/restore pair'; +} + +String _valueName(Object? value) { + if (value is double) { + return value.toStringAsFixed(1); + } + return value.toString(); +} + +// Workaround for https://github.com/dart-lang/sdk/issues/28372 +String _symbolName(Symbol symbol) { + // WARNING: Assumes a fixed format for Symbol.toString which is *not* + // guaranteed anywhere. + final String s = '$symbol'; + return s.substring(8, s.length - 2); +} diff --git a/test/recording_canvas.dart b/test/recording_canvas.dart new file mode 100644 index 00000000..d2bd2f33 --- /dev/null +++ b/test/recording_canvas.dart @@ -0,0 +1,252 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/rendering.dart'; + +/// An [Invocation] and the [stack] trace that led to it. +/// +/// Used by [TestRecordingCanvas] to trace canvas calls. +class RecordedInvocation { + /// Create a record for an invocation list. + const RecordedInvocation(this.invocation, {required this.stack}); + + /// The method that was called and its arguments. + /// + /// The arguments preserve identity, but not value. Thus, if two invocations + /// were made with the same [Paint] object, but with that object configured + /// differently each time, then they will both have the same object as their + /// argument, and inspecting that object will return the object's current + /// values (mostly likely those passed to the second call). + final Invocation invocation; + + /// The stack trace at the time of the method call. + final StackTrace stack; + + @override + String toString() => _describeInvocation(invocation); + + /// Converts [stack] to a string using the [FlutterError.defaultStackFilter] logic. + String stackToString({String indent = ''}) { + return indent + + FlutterError.defaultStackFilter( + stack.toString().trimRight().split('\n'), + ).join('\n$indent'); + } +} + +/// A [Canvas] for tests that records its method calls. +/// +/// This class can be used in conjunction with [TestRecordingPaintingContext] +/// to record the [Canvas] method calls made by a renderer. For example: +/// +/// ```dart +/// RenderBox box = tester.renderObject(find.text('ABC')); +/// TestRecordingCanvas canvas = TestRecordingCanvas(); +/// TestRecordingPaintingContext context = TestRecordingPaintingContext(canvas); +/// box.paint(context, Offset.zero); +/// // Now test the expected canvas.invocations. +/// ``` +/// +/// In some cases it may be useful to define a subclass that overrides the +/// [Canvas] methods the test is checking and squirrels away the parameters +/// that the test requires. +/// +/// For simple tests, consider using the [paints] matcher, which overlays a +/// pattern matching API over [TestRecordingCanvas]. +class TestRecordingCanvas implements Canvas { + /// All of the method calls on this canvas. + final List invocations = []; + + int _saveCount = 0; + + @override + int getSaveCount() => _saveCount; + + @override + void save() { + _saveCount += 1; + invocations + .add(RecordedInvocation(_MethodCall(#save), stack: StackTrace.current)); + } + + @override + void saveLayer(Rect? bounds, Paint paint) { + _saveCount += 1; + invocations.add(RecordedInvocation( + _MethodCall(#saveLayer, [bounds, paint]), + stack: StackTrace.current)); + } + + @override + void restore() { + _saveCount -= 1; + assert(_saveCount >= 0); + invocations.add( + RecordedInvocation(_MethodCall(#restore), stack: StackTrace.current)); + } + + @override + void noSuchMethod(Invocation invocation) { + invocations.add(RecordedInvocation(invocation, stack: StackTrace.current)); + } +} + +/// A [PaintingContext] for tests that use [TestRecordingCanvas]. +class TestRecordingPaintingContext extends ClipContext + implements PaintingContext { + /// Creates a [PaintingContext] for tests that use [TestRecordingCanvas]. + TestRecordingPaintingContext(this.canvas); + + @override + final Canvas canvas; + + @override + void paintChild(RenderObject child, Offset offset) { + child.paint(this, offset); + } + + @override + ClipRectLayer? pushClipRect( + bool needsCompositing, + Offset offset, + Rect clipRect, + PaintingContextCallback painter, { + Clip clipBehavior = Clip.hardEdge, + ClipRectLayer? oldLayer, + }) { + clipRectAndPaint(clipRect.shift(offset), clipBehavior, + clipRect.shift(offset), () => painter(this, offset)); + return null; + } + + @override + ClipRRectLayer? pushClipRRect( + bool needsCompositing, + Offset offset, + Rect bounds, + RRect clipRRect, + PaintingContextCallback painter, { + Clip clipBehavior = Clip.antiAlias, + ClipRRectLayer? oldLayer, + }) { + clipRRectAndPaint(clipRRect.shift(offset), clipBehavior, + bounds.shift(offset), () => painter(this, offset)); + return null; + } + + @override + ClipPathLayer? pushClipPath( + bool needsCompositing, + Offset offset, + Rect bounds, + Path clipPath, + PaintingContextCallback painter, { + Clip clipBehavior = Clip.antiAlias, + ClipPathLayer? oldLayer, + }) { + clipPathAndPaint(clipPath.shift(offset), clipBehavior, bounds.shift(offset), + () => painter(this, offset)); + return null; + } + + @override + TransformLayer? pushTransform( + bool needsCompositing, + Offset offset, + Matrix4 transform, + PaintingContextCallback painter, { + TransformLayer? oldLayer, + }) { + canvas.save(); + canvas.transform(transform.storage); + painter(this, offset); + canvas.restore(); + return null; + } + + @override + OpacityLayer pushOpacity( + Offset offset, + int alpha, + PaintingContextCallback painter, { + OpacityLayer? oldLayer, + }) { + canvas.saveLayer(null, Paint()); // TODO(ianh): Expose the alpha somewhere. + painter(this, offset); + canvas.restore(); + return OpacityLayer(); + } + + @override + void pushLayer( + Layer childLayer, + PaintingContextCallback painter, + Offset offset, { + Rect? childPaintBounds, + }) { + painter(this, offset); + } + + @override + void noSuchMethod(Invocation invocation) {} +} + +class _MethodCall implements Invocation { + _MethodCall(this._name, [this._arguments = const []]); + final Symbol _name; + final List _arguments; + @override + bool get isAccessor => false; + @override + bool get isGetter => false; + @override + bool get isMethod => true; + @override + bool get isSetter => false; + @override + Symbol get memberName => _name; + @override + Map get namedArguments => {}; + @override + List get positionalArguments => _arguments; + @override + List get typeArguments => const []; +} + +String _valueName(Object? value) { + if (value is double) { + return value.toStringAsFixed(1); + } + return value.toString(); +} + +// Workaround for https://github.com/dart-lang/sdk/issues/28372 +String _symbolName(Symbol symbol) { + // WARNING: Assumes a fixed format for Symbol.toString which is *not* + // guaranteed anywhere. + final String s = '$symbol'; + return s.substring(8, s.length - 2); +} + +// Workaround for https://github.com/dart-lang/sdk/issues/28373 +String _describeInvocation(Invocation call) { + final StringBuffer buffer = StringBuffer(); + buffer.write(_symbolName(call.memberName)); + if (call.isSetter) { + buffer.write(call.positionalArguments[0].toString()); + } else if (call.isMethod) { + buffer.write('('); + buffer.writeAll(call.positionalArguments.map(_valueName), ', '); + String separator = call.positionalArguments.isEmpty ? '' : ', '; + call.namedArguments.forEach((Symbol name, Object? value) { + buffer.write(separator); + buffer.write(_symbolName(name)); + buffer.write(': '); + buffer.write(_valueName(value)); + separator = ', '; + }); + buffer.write(')'); + } + return buffer.toString(); +} diff --git a/test/selectors/date_picker_test.dart b/test/selectors/date_picker_test.dart index 80e00bf8..f0c5afbc 100644 --- a/test/selectors/date_picker_test.dart +++ b/test/selectors/date_picker_test.dart @@ -15,7 +15,7 @@ void main() { child: MacosScaffold( children: [ ContentArea( - builder: (context) { + builder: (context, _) { return Center( child: MacosDatePicker( onDateChanged: (date) {}, @@ -53,7 +53,7 @@ void main() { child: MacosScaffold( children: [ ContentArea( - builder: (context) { + builder: (context, _) { return Center( child: MacosDatePicker( onDateChanged: (date) {}, @@ -90,7 +90,7 @@ void main() { child: MacosScaffold( children: [ ContentArea( - builder: (context) { + builder: (context, _) { return Center( child: MacosDatePicker( onDateChanged: (date) {}, @@ -135,7 +135,7 @@ void main() { child: MacosScaffold( children: [ ContentArea( - builder: (context) { + builder: (context, _) { return Center( child: MacosDatePicker( onDateChanged: (date) {}, @@ -180,7 +180,7 @@ void main() { child: MacosScaffold( children: [ ContentArea( - builder: (context) { + builder: (context, _) { return Center( child: MacosDatePicker( onDateChanged: (date) {}, @@ -226,7 +226,7 @@ void main() { child: MacosScaffold( children: [ ContentArea( - builder: (context) { + builder: (context, _) { return Center( child: MacosDatePicker( onDateChanged: (date) { @@ -262,7 +262,7 @@ void main() { child: MacosScaffold( children: [ ContentArea( - builder: (context) { + builder: (context, _) { return Center( child: MacosDatePicker( onDateChanged: (date) { diff --git a/test/theme/help_button_theme_test.dart b/test/theme/help_button_theme_test.dart index aa73c2c4..702a85ed 100644 --- a/test/theme/help_button_theme_test.dart +++ b/test/theme/help_button_theme_test.dart @@ -59,7 +59,7 @@ void main() { child: MacosScaffold( children: [ ContentArea( - builder: (context) { + builder: (context, _) { capturedContext = context; return const HelpButton(); }, diff --git a/test/theme/icon_button_theme_test.dart b/test/theme/icon_button_theme_test.dart index bc7ff339..af7a40e6 100644 --- a/test/theme/icon_button_theme_test.dart +++ b/test/theme/icon_button_theme_test.dart @@ -66,7 +66,7 @@ void main() { child: MacosScaffold( children: [ ContentArea( - builder: (context) { + builder: (context, _) { capturedContext = context; return MacosIconButton( icon: const Icon(CupertinoIcons.add), diff --git a/test/theme/icon_theme_test.dart b/test/theme/icon_theme_test.dart index 5e37d2cd..8e98f80a 100644 --- a/test/theme/icon_theme_test.dart +++ b/test/theme/icon_theme_test.dart @@ -66,7 +66,7 @@ void main() { child: MacosScaffold( children: [ ContentArea( - builder: (context) { + builder: (context, _) { capturedContext = context; return const MacosIcon( CupertinoIcons.add, diff --git a/test/theme/popup_button_theme_test.dart b/test/theme/popup_button_theme_test.dart index 96f40400..daa039de 100644 --- a/test/theme/popup_button_theme_test.dart +++ b/test/theme/popup_button_theme_test.dart @@ -70,9 +70,7 @@ void main() { child: MacosScaffold( children: [ ContentArea( - builder: ( - context, - ) { + builder: (context, _) { capturedContext = context; return MacosPopupButton( value: popupValue, diff --git a/test/theme/pulldown_button_theme_test.dart b/test/theme/pulldown_button_theme_test.dart index 9342c46d..373ac0c4 100644 --- a/test/theme/pulldown_button_theme_test.dart +++ b/test/theme/pulldown_button_theme_test.dart @@ -69,7 +69,7 @@ void main() { child: MacosScaffold( children: [ ContentArea( - builder: (context) { + builder: (context, _) { capturedContext = context; return const Center( child: MacosPulldownButton( diff --git a/test/theme/push_button_theme_test.dart b/test/theme/push_button_theme_test.dart index b163be44..5bcc4ef1 100644 --- a/test/theme/push_button_theme_test.dart +++ b/test/theme/push_button_theme_test.dart @@ -61,7 +61,7 @@ void main() { child: MacosScaffold( children: [ ContentArea( - builder: (context) { + builder: (context, _) { capturedContext = context; return const PushButton( buttonSize: ButtonSize.small, diff --git a/test/theme/scrollbar_theme_test.dart b/test/theme/scrollbar_theme_test.dart index c89d0d02..39e3c156 100644 --- a/test/theme/scrollbar_theme_test.dart +++ b/test/theme/scrollbar_theme_test.dart @@ -53,21 +53,9 @@ void main() { } final _scrollbarThemeData = MacosScrollbarThemeData( - draggingThumbColor: Colors.grey.shade600, - hoveringThumbColor: Colors.grey.shade600, - hoveringTrackBorderColor: Colors.grey.shade600, - hoveringTrackColor: Colors.grey.shade600, thumbColor: Colors.grey.shade600, - trackBorderColor: Colors.grey.shade600, - trackColor: Colors.grey.shade600, ); final _scrollbarThemeDataDark = MacosScrollbarThemeData( - draggingThumbColor: Colors.grey.shade300, - hoveringThumbColor: Colors.grey.shade300, - hoveringTrackBorderColor: Colors.grey.shade300, - hoveringTrackColor: Colors.grey.shade300, thumbColor: Colors.grey.shade300, - trackBorderColor: Colors.grey.shade300, - trackColor: Colors.grey.shade300, ); diff --git a/test/theme/search_field_theme_test.dart b/test/theme/search_field_theme_test.dart index cf740bfe..da838f37 100644 --- a/test/theme/search_field_theme_test.dart +++ b/test/theme/search_field_theme_test.dart @@ -65,7 +65,7 @@ void main() { child: MacosScaffold( children: [ ContentArea( - builder: (context) { + builder: (context, _) { capturedContext = context; return const Center( child: MacosSearchField(),