diff --git a/CHANGELOG.md b/CHANGELOG.md index bacfa4a9..59054b49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## [2.2.4] +### 🛠 Fixed 🛠 +- A hidden `Sidebar` now stays out of sight and out of input. A hidden start sidebar (below `windowBreakpoint`, toggled off, or drag-closed) slides off-screen like the end sidebar; previously it stayed at the window's leading edge beneath the content, visible wherever the content paints no background. While hidden, neither sidebar takes pointer, focus or semantics; previously a hidden end sidebar could still receive keyboard focus. Addresses #593. + ## [2.2.3] ### 🛠 Fixed 🛠 - Fixed sidebar background painting when wallpaper tinting is disabled: the macOS sidebar now stays transparent unless a decoration color is provided (previously showed a black strip). Addresses #587. diff --git a/lib/src/layout/window.dart b/lib/src/layout/window.dart index 30a84026..f5f157bc 100644 --- a/lib/src/layout/window.dart +++ b/lib/src/layout/window.dart @@ -257,132 +257,148 @@ class _MacosWindowState extends State { curve: curve, duration: duration, height: height, + // A hidden sidebar slides off the leading edge, as the end + // sidebar slides off the trailing one, and takes no pointer, + // focus or semantics. Left at x = 0 it stayed visible + // wherever the content paints nothing, and reachable. + left: visibleSidebarWidth - _sidebarWidth, width: _sidebarWidth, - child: AnimatedContainer( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - // The sidebar background is painted exactly once by the inner - // ColoredBox (web) / DecoratedBox (macOS) below. Painting it - // here too would blend a semi-transparent - // Sidebar.decoration.color twice, making it darker than - // requested. - constraints: BoxConstraints( - minWidth: sidebar.minWidth, - maxWidth: sidebar.maxWidth!, - minHeight: height, - maxHeight: height, - ).normalize(), - child: kIsWeb - ? ColoredBox( - color: sidebarBackgroundColor ?? theme.canvasColor, - child: Column( - children: [ - // If an app is running on macOS, apply - // sidebar.topOffset as needed in order to avoid - // the traffic lights. Otherwise, position the - // sidebar by the top of the application's bounds - // based on the presence of sidebar.top. - if (!kIsWeb && sidebar.topOffset > 0) ...[ - SizedBox(height: sidebar.topOffset), - ] else if (sidebar.top != null) ...[ - const SizedBox(height: 12), - ] else - const SizedBox.shrink(), - if (_sidebarScrollController.hasClients && - _sidebarScrollController.offset > 0.0) - Divider( - thickness: 1, - height: 1, - color: dividerColor, - ), - if (sidebar.top != null && - constraints.maxHeight > 81) - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8.0, - ), - child: sidebar.top!, - ), - Expanded( - child: MacosScrollbar( - controller: _sidebarScrollController, - child: Padding( - padding: sidebar.padding, - child: sidebar.builder( - context, - _sidebarScrollController, + child: IgnorePointer( + ignoring: !canShowSidebar, + child: ExcludeFocus( + excluding: !canShowSidebar, + child: ExcludeSemantics( + excluding: !canShowSidebar, + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + // The sidebar background is painted exactly once by the inner + // ColoredBox (web) / DecoratedBox (macOS) below. Painting it + // here too would blend a semi-transparent + // Sidebar.decoration.color twice, making it darker than + // requested. + constraints: BoxConstraints( + minWidth: sidebar.minWidth, + maxWidth: sidebar.maxWidth!, + minHeight: height, + maxHeight: height, + ).normalize(), + child: kIsWeb + ? ColoredBox( + color: + sidebarBackgroundColor ?? theme.canvasColor, + child: Column( + children: [ + // If an app is running on macOS, apply + // sidebar.topOffset as needed in order to avoid + // the traffic lights. Otherwise, position the + // sidebar by the top of the application's bounds + // based on the presence of sidebar.top. + if (!kIsWeb && sidebar.topOffset > 0) ...[ + SizedBox(height: sidebar.topOffset), + ] else if (sidebar.top != null) ...[ + const SizedBox(height: 12), + ] else + const SizedBox.shrink(), + if (_sidebarScrollController.hasClients && + _sidebarScrollController.offset > 0.0) + Divider( + thickness: 1, + height: 1, + color: dividerColor, + ), + if (sidebar.top != null && + constraints.maxHeight > 81) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8.0, + ), + child: sidebar.top!, + ), + Expanded( + child: MacosScrollbar( + controller: _sidebarScrollController, + child: Padding( + padding: sidebar.padding, + child: sidebar.builder( + context, + _sidebarScrollController, + ), + ), + ), ), - ), - ), - ), - if (sidebar.bottom != null && - constraints.maxHeight > 141) - Padding( - padding: const EdgeInsets.all(16.0), - child: sidebar.bottom!, + if (sidebar.bottom != null && + constraints.maxHeight > 141) + Padding( + padding: const EdgeInsets.all(16.0), + child: sidebar.bottom!, + ), + ], ), - ], - ), - ) - : TransparentMacOSSidebar( - state: sidebarState, - child: DecoratedBox( - decoration: - (sidebar.decoration ?? const BoxDecoration()) - .copyWith( - // Only paint if the caller set a color; null preserves native transparency. - color: sidebarBackgroundColor, - ), - child: Column( - children: [ - // If an app is running on macOS, apply - // sidebar.topOffset as needed in order to avoid - // the traffic lights. Otherwise, position the - // sidebar by the top of the application's bounds - // based on the presence of sidebar.top. - if (!kIsWeb && sidebar.topOffset > 0) ...[ - SizedBox(height: sidebar.topOffset), - ] else if (sidebar.top != null) ...[ - const SizedBox(height: 12), - ] else - const SizedBox.shrink(), - if (_sidebarScrollController.hasClients && - _sidebarScrollController.offset > 0.0) - Divider( - thickness: 1, - height: 1, - color: dividerColor, - ), - if (sidebar.top != null && - constraints.maxHeight > 81) - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8.0, - ), - child: sidebar.top!, - ), - Expanded( - child: MacosScrollbar( - controller: _sidebarScrollController, - child: Padding( - padding: sidebar.padding, - child: sidebar.builder( - context, - _sidebarScrollController, + ) + : TransparentMacOSSidebar( + state: sidebarState, + child: DecoratedBox( + decoration: + (sidebar.decoration ?? + const BoxDecoration()) + .copyWith( + // Only paint if the caller set a color; null preserves native transparency. + color: sidebarBackgroundColor, + ), + child: Column( + children: [ + // If an app is running on macOS, apply + // sidebar.topOffset as needed in order to avoid + // the traffic lights. Otherwise, position the + // sidebar by the top of the application's bounds + // based on the presence of sidebar.top. + if (!kIsWeb && sidebar.topOffset > 0) ...[ + SizedBox(height: sidebar.topOffset), + ] else if (sidebar.top != null) ...[ + const SizedBox(height: 12), + ] else + const SizedBox.shrink(), + if (_sidebarScrollController.hasClients && + _sidebarScrollController.offset > 0.0) + Divider( + thickness: 1, + height: 1, + color: dividerColor, + ), + if (sidebar.top != null && + constraints.maxHeight > 81) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8.0, + ), + child: sidebar.top!, + ), + Expanded( + child: MacosScrollbar( + controller: _sidebarScrollController, + child: Padding( + padding: sidebar.padding, + child: sidebar.builder( + context, + _sidebarScrollController, + ), + ), + ), ), - ), + if (sidebar.bottom != null && + constraints.maxHeight > 141) + Padding( + padding: const EdgeInsets.all(16.0), + child: sidebar.bottom!, + ), + ], ), ), - if (sidebar.bottom != null && - constraints.maxHeight > 141) - Padding( - padding: const EdgeInsets.all(16.0), - child: sidebar.bottom!, - ), - ], - ), - ), - ), + ), + ), + ), + ), ), ), @@ -484,51 +500,66 @@ class _MacosWindowState extends State { duration: duration, height: height, width: _endSidebarWidth, - child: AnimatedContainer( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - color: endSidebarBackgroundColor, - constraints: BoxConstraints( - minWidth: endSidebar.minWidth, - maxWidth: endSidebar.maxWidth!, - minHeight: height, - maxHeight: height, - ).normalize(), - child: WallpaperTintedArea( - backgroundColor: endSidebarBackgroundColor, - insertRepaintBoundary: true, - child: Column( - children: [ - if (endSidebar.topOffset > 0) - SizedBox(height: endSidebar.topOffset), - if (_endSidebarScrollController.hasClients && - _endSidebarScrollController.offset > 0.0) - Divider(thickness: 1, height: 1, color: dividerColor), - if (endSidebar.top != null) - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8.0, - ), - child: endSidebar.top!, - ), - Expanded( - child: MacosScrollbar( - controller: _endSidebarScrollController, - child: Padding( - padding: endSidebar.padding, - child: endSidebar.builder( - context, - _endSidebarScrollController, + // Off-screen alone does not keep a hidden sidebar out of + // focus traversal. + child: IgnorePointer( + ignoring: !canShowEndSidebar, + child: ExcludeFocus( + excluding: !canShowEndSidebar, + child: ExcludeSemantics( + excluding: !canShowEndSidebar, + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + color: endSidebarBackgroundColor, + constraints: BoxConstraints( + minWidth: endSidebar.minWidth, + maxWidth: endSidebar.maxWidth!, + minHeight: height, + maxHeight: height, + ).normalize(), + child: WallpaperTintedArea( + backgroundColor: endSidebarBackgroundColor, + insertRepaintBoundary: true, + child: Column( + children: [ + if (endSidebar.topOffset > 0) + SizedBox(height: endSidebar.topOffset), + if (_endSidebarScrollController.hasClients && + _endSidebarScrollController.offset > 0.0) + Divider( + thickness: 1, + height: 1, + color: dividerColor, + ), + if (endSidebar.top != null) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8.0, + ), + child: endSidebar.top!, + ), + Expanded( + child: MacosScrollbar( + controller: _endSidebarScrollController, + child: Padding( + padding: endSidebar.padding, + child: endSidebar.builder( + context, + _endSidebarScrollController, + ), + ), + ), ), - ), + if (endSidebar.bottom != null) + Padding( + padding: const EdgeInsets.all(16.0), + child: endSidebar.bottom!, + ), + ], ), ), - if (endSidebar.bottom != null) - Padding( - padding: const EdgeInsets.all(16.0), - child: endSidebar.bottom!, - ), - ], + ), ), ), ), diff --git a/pubspec.yaml b/pubspec.yaml index c6355f3f..04a55ca7 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: 2.2.3 +version: 2.2.4 homepage: "https://macosui.dev" repository: "https://github.com/GroovinChip/macos_ui" diff --git a/test/layout/window_test.dart b/test/layout/window_test.dart index eb9fc42c..c4df42f4 100644 --- a/test/layout/window_test.dart +++ b/test/layout/window_test.dart @@ -343,5 +343,308 @@ void main() { ); }); }); + + group('a hidden start sidebar is off-screen and out of input', () { + const probeKey = Key('sidebar probe'); + const probeLabel = 'sidebar probe'; + late FocusNode probeFocus; + late BuildContext contentContext; + + setUp(() => probeFocus = FocusNode(debugLabel: probeLabel)); + tearDown(() => probeFocus.dispose()); + + final sidebarFinder = find.byType(AnimatedPositioned).at(1); + final resizerFinder = find.byType(AnimatedPositioned).at(3); + + // The content paints nothing, as a ContentArea does, so anything the + // sidebar leaves beneath it shows through. + pumpWindow(WidgetTester tester, {double width = 1000}) async { + tester.view.physicalSize = Size(width, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget( + MacosApp( + home: MacosWindow( + disableWallpaperTinting: true, + sidebar: Sidebar( + minWidth: 100, + startWidth: 150, + maxWidth: 300, + windowBreakpoint: 700, + builder: (context, scrollController) => Focus( + focusNode: probeFocus, + child: Semantics( + label: probeLabel, + // A ColoredBox takes hits; a childless SizedBox never + // does, so it could not show whether the sidebar does. + child: const ColoredBox( + key: probeKey, + color: MacosColors.systemRedColor, + child: SizedBox.expand(), + ), + ), + ), + ), + child: ContentArea( + builder: (context, scrollController) { + contentContext = context; + return const SizedBox.expand(); + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + // find.semantics searches the live tree; find.bySemanticsLabel can + // still report a node after it has been excluded. + expectHidden(WidgetTester tester) { + expect( + tester.getRect(find.byKey(probeKey)).right, + lessThanOrEqualTo(0), + ); + expect(find.byKey(probeKey).hitTestable(), findsNothing); + expect(probeFocus.canRequestFocus, isFalse); + expect(find.semantics.byLabel(probeLabel), findsNothing); + } + + expectShown(WidgetTester tester) { + expect( + tester.getRect(sidebarFinder), + const Rect.fromLTWH(0, 0, 150, 600), + ); + expect(find.byKey(probeKey).hitTestable(), findsOneWidget); + expect(probeFocus.canRequestFocus, isTrue); + expect(find.semantics.byLabel(probeLabel), findsOne); + } + + testWidgets('a shown sidebar is on-screen and takes input', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + await pumpWindow(tester); + + expectShown(tester); + + semantics.dispose(); + await tester.pump(Duration.zero); + }); + + testWidgets('below windowBreakpoint', (tester) async { + final semantics = tester.ensureSemantics(); + await pumpWindow(tester, width: 600); + + expectHidden(tester); + + semantics.dispose(); + await tester.pump(Duration.zero); + }); + + testWidgets('toggled off, and shown again when toggled back on', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + await pumpWindow(tester); + + MacosWindowScope.of(contentContext).toggleSidebar(); + await tester.pumpAndSettle(); + + expectHidden(tester); + + MacosWindowScope.of(contentContext).toggleSidebar(); + await tester.pumpAndSettle(); + + expectShown(tester); + + semantics.dispose(); + await tester.pump(Duration.zero); + }); + + testWidgets('takes no input while it slides away', (tester) async { + final semantics = tester.ensureSemantics(); + await pumpWindow(tester); + + MacosWindowScope.of(contentContext).toggleSidebar(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + final rect = tester.getRect(find.byKey(probeKey)); + expect(rect.left, lessThan(0)); + expect(rect.left + rect.width * 0.95, greaterThan(0)); + expect( + find.byKey(probeKey).hitTestable(at: const Alignment(0.9, 0)), + findsNothing, + ); + expect(find.semantics.byLabel(probeLabel), findsNothing); + + await tester.pumpAndSettle(); + semantics.dispose(); + await tester.pump(Duration.zero); + }); + + testWidgets('dragged closed', (tester) async { + final semantics = tester.ensureSemantics(); + await pumpWindow(tester); + + await tester.drag(resizerFinder, const Offset(-500, 0)); + await tester.pumpAndSettle(); + + expectHidden(tester); + + semantics.dispose(); + await tester.pump(Duration.zero); + }); + }); + + group('a hidden end sidebar is out of input', () { + const endSidebarKey = Key('end sidebar'); + const probeKey = Key('end sidebar probe'); + const probeLabel = 'end sidebar probe'; + late FocusNode probeFocus; + late BuildContext contentContext; + + setUp(() => probeFocus = FocusNode(debugLabel: probeLabel)); + tearDown(() => probeFocus.dispose()); + + final endSidebarFinder = find.byKey(endSidebarKey); + final resizerFinder = find.byType(AnimatedPositioned).last; + + pumpWindow(WidgetTester tester, {double width = 1000}) async { + tester.view.physicalSize = Size(width, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget( + MacosApp( + home: MacosWindow( + disableWallpaperTinting: true, + endSidebar: Sidebar( + key: endSidebarKey, + minWidth: 100, + startWidth: 150, + maxWidth: 300, + windowBreakpoint: 700, + shownByDefault: true, + builder: (context, scrollController) => Focus( + focusNode: probeFocus, + child: Semantics( + label: probeLabel, + child: const ColoredBox( + key: probeKey, + color: MacosColors.systemRedColor, + child: SizedBox.expand(), + ), + ), + ), + ), + child: ContentArea( + builder: (context, scrollController) { + contentContext = context; + return const SizedBox.expand(); + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + expectHidden(WidgetTester tester, {double windowWidth = 1000}) { + expect( + tester.getRect(find.byKey(probeKey)).left, + greaterThanOrEqualTo(windowWidth), + ); + expect(find.byKey(probeKey).hitTestable(), findsNothing); + expect(probeFocus.canRequestFocus, isFalse); + expect(find.semantics.byLabel(probeLabel), findsNothing); + } + + expectShown(WidgetTester tester) { + expect( + tester.getRect(endSidebarFinder), + const Rect.fromLTWH(850, 0, 150, 600), + ); + expect(find.byKey(probeKey).hitTestable(), findsOneWidget); + expect(probeFocus.canRequestFocus, isTrue); + expect(find.semantics.byLabel(probeLabel), findsOne); + } + + testWidgets('a shown end sidebar is on-screen and takes input', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + await pumpWindow(tester); + + expectShown(tester); + + semantics.dispose(); + await tester.pump(Duration.zero); + }); + + testWidgets('below windowBreakpoint', (tester) async { + final semantics = tester.ensureSemantics(); + await pumpWindow(tester, width: 600); + + expectHidden(tester, windowWidth: 600); + + semantics.dispose(); + await tester.pump(Duration.zero); + }); + + testWidgets('toggled off, and shown again when toggled back on', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + await pumpWindow(tester); + + MacosWindowScope.of(contentContext).toggleEndSidebar(); + await tester.pumpAndSettle(); + + expectHidden(tester); + + MacosWindowScope.of(contentContext).toggleEndSidebar(); + await tester.pumpAndSettle(); + + expectShown(tester); + + semantics.dispose(); + await tester.pump(Duration.zero); + }); + + testWidgets('takes no input while it slides away', (tester) async { + final semantics = tester.ensureSemantics(); + await pumpWindow(tester); + + MacosWindowScope.of(contentContext).toggleEndSidebar(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + final rect = tester.getRect(find.byKey(probeKey)); + expect(rect.right, greaterThan(1000)); + expect(rect.left + rect.width * 0.05, lessThan(1000)); + expect( + find.byKey(probeKey).hitTestable(at: const Alignment(-0.9, 0)), + findsNothing, + ); + expect(find.semantics.byLabel(probeLabel), findsNothing); + + await tester.pumpAndSettle(); + semantics.dispose(); + await tester.pump(Duration.zero); + }); + + testWidgets('dragged closed', (tester) async { + final semantics = tester.ensureSemantics(); + await pumpWindow(tester); + + await tester.drag(resizerFinder, const Offset(500, 0)); + await tester.pumpAndSettle(); + + expectHidden(tester); + + semantics.dispose(); + await tester.pump(Duration.zero); + }); + }); }); }