diff --git a/example/lib/pages/dialog_page.dart b/example/lib/pages/dialog_page.dart index 7b6ddb1be..599f6de46 100644 --- a/example/lib/pages/dialog_page.dart +++ b/example/lib/pages/dialog_page.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:yaru_widgets/yaru_widgets.dart'; @@ -49,7 +51,17 @@ class _DialogPageState extends State { ), ), title: const Text('The Title'), - isClosable: isCloseable, + windowControlLayout: isCloseable + ? ((Platform.isMacOS) + ? const YaruWindowControlLayout( + [YaruWindowControlType.close], + [], + ) + : const YaruWindowControlLayout( + [], + [YaruWindowControlType.close], + )) + : const YaruWindowControlLayout([], []), ), content: SizedBox( height: 100, diff --git a/lib/src/widgets/yaru_title_bar.dart b/lib/src/widgets/yaru_title_bar.dart index fa4446f5a..c716695e1 100644 --- a/lib/src/widgets/yaru_title_bar.dart +++ b/lib/src/widgets/yaru_title_bar.dart @@ -5,11 +5,13 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:yaru/yaru.dart'; import 'package:yaru_widgets/constants.dart'; +import 'package:yaru_widgets/src/widgets/yaru_window_control_layout.dart'; import 'package:yaru_window/yaru_window.dart'; import 'yaru_title_bar_gesture_detector.dart'; import 'yaru_title_bar_theme.dart'; import 'yaru_window_control.dart'; +import 'yaru_window_control_row.dart'; const _kYaruTitleBarHeroTag = ''; @@ -32,12 +34,10 @@ class YaruTitleBar extends StatelessWidget implements PreferredSizeWidget { this.shape, this.border, this.style, + this.windowControlLayout, this.isActive, - this.isClosable, + this.isMaximized, this.isDraggable, - this.isMaximizable, - this.isMinimizable, - this.isRestorable, this.onClose, this.onDrag, this.onMaximize, @@ -80,30 +80,21 @@ class YaruTitleBar extends StatelessWidget implements PreferredSizeWidget { /// The style. final YaruTitleBarStyle? style; + /// The order and position of window controls + final YaruWindowControlLayout? windowControlLayout; + /// Whether the title bar visualized as active. final bool? isActive; - /// Whether the title bar shows a close button. - final bool? isClosable; + /// Whether to show the restore rather than the maximize button. + final bool? isMaximized; /// Whether the title bar can be dragged. final bool? isDraggable; - /// Whether the title bar shows a maximize button. - final bool? isMaximizable; - - /// Whether the title bar shows a minimize button. - final bool? isMinimizable; - - /// Whether the title bar shows a restore button. - final bool? isRestorable; - /// Called when the close button is pressed. final FutureOr Function(BuildContext)? onClose; - /// Called when the title bar is dragged to move the window. - final FutureOr Function(BuildContext)? onDrag; - /// Called when the maximize button is pressed or the title bar is /// double-clicked while the window is not maximized. final FutureOr Function(BuildContext)? onMaximize; @@ -115,6 +106,9 @@ class YaruTitleBar extends StatelessWidget implements PreferredSizeWidget { /// double-clicked while the window is maximized. final FutureOr Function(BuildContext)? onRestore; + /// Called when the title bar is dragged to move the window. + final FutureOr Function(BuildContext)? onDrag; + /// Called when the secondary mouse button is pressed. final FutureOr Function(BuildContext)? onShowMenu; @@ -230,11 +224,9 @@ class YaruTitleBar extends StatelessWidget implements PreferredSizeWidget { return TextFieldTapRegion( child: YaruTitleBarGestureDetector( onDrag: isDraggable == true ? (_) => onDrag?.call(context) : null, - onDoubleTap: () => isMaximizable == true - ? onMaximize?.call(context) - : isRestorable == true - ? onRestore?.call(context) - : null, + onDoubleTap: () => isMaximized == true + ? onRestore?.call(context) + : onMaximize?.call(context), onSecondaryTap: onShowMenu != null ? () => onShowMenu!(context) : null, child: AppBar( elevation: titleBarTheme.elevation, @@ -254,54 +246,32 @@ class YaruTitleBar extends StatelessWidget implements PreferredSizeWidget { Row( mainAxisSize: MainAxisSize.min, children: [ + if (style == YaruTitleBarStyle.normal && + windowControlLayout?.leftItems != null && + windowControlLayout!.leftItems.isNotEmpty) + YaruWindowControlRow( + windowControls: windowControlLayout!.leftItems, + isMaximized: isMaximized ?? false, + buttonPadding: bPadding, + buttonSpacing: bSpacing, + onClose: onClose, + onMaximize: onMaximize, + onRestore: onRestore, + onMinimize: onMinimize, + ), ...?actions, if (style == YaruTitleBarStyle.normal && - (isMinimizable == true || - isRestorable == true || - isMaximizable == true || - isClosable == true)) - Padding( - padding: bPadding, - child: Row( - children: [ - if (isMinimizable == true) - YaruWindowControl( - platform: windowControlPlatform, - foregroundColor: foregroundColor, - type: YaruWindowControlType.minimize, - onTap: onMinimize != null - ? () => onMinimize!(context) - : null, - ), - if (isRestorable == true) - YaruWindowControl( - platform: windowControlPlatform, - foregroundColor: foregroundColor, - type: YaruWindowControlType.restore, - onTap: onRestore != null - ? () => onRestore!(context) - : null, - ), - if (isMaximizable == true) - YaruWindowControl( - platform: windowControlPlatform, - foregroundColor: foregroundColor, - type: YaruWindowControlType.maximize, - onTap: onMaximize != null - ? () => onMaximize!(context) - : null, - ), - if (isClosable == true) - YaruWindowControl( - platform: windowControlPlatform, - foregroundColor: foregroundColor, - type: YaruWindowControlType.close, - onTap: onClose != null - ? () => onClose!(context) - : null, - ), - ].withSpacing(bSpacing), - ), + windowControlLayout?.rightItems != null && + windowControlLayout!.rightItems.isNotEmpty) + YaruWindowControlRow( + windowControls: windowControlLayout!.rightItems, + isMaximized: isMaximized ?? false, + buttonPadding: bPadding, + buttonSpacing: bSpacing, + onClose: onClose, + onMaximize: onMaximize, + onRestore: onRestore, + onMinimize: onMinimize, ), ], ), @@ -314,15 +284,6 @@ class YaruTitleBar extends StatelessWidget implements PreferredSizeWidget { } } -extension _ListSpacing on List { - List withSpacing(double spacing) { - return expand((item) sync* { - yield SizedBox(width: spacing); - yield item; - }).skip(1).toList(); - } -} - /// A window title bar. /// /// `YaruWindowTitleBar` is a replacement for the native window title bar, that @@ -425,12 +386,10 @@ class YaruWindowTitleBar extends StatelessWidget this.shape, this.border, this.style, + this.windowControlLayout, this.isActive, - this.isClosable, this.isDraggable, - this.isMaximizable, - this.isMinimizable, - this.isRestorable, + this.isMaximized, this.onClose = YaruWindow.close, this.onDrag = YaruWindow.drag, this.onMaximize = YaruWindow.maximize, @@ -476,20 +435,14 @@ class YaruWindowTitleBar extends StatelessWidget /// Whether the title bar visualized as active. final bool? isActive; - /// Whether the title bar shows a close button. - final bool? isClosable; - /// Whether the title bar can be dragged to move the window. final bool? isDraggable; - /// Whether the title bar shows a maximize button. - final bool? isMaximizable; - - /// Whether the title bar shows a minimize button. - final bool? isMinimizable; + /// Whether to show the restore rather than the maximize button. + final bool? isMaximized; - /// Whether the title bar shows a restore button. - final bool? isRestorable; + /// The order and position of window controls + final YaruWindowControlLayout? windowControlLayout; /// Called when the close button is pressed. final FutureOr Function(BuildContext)? onClose; @@ -566,14 +519,16 @@ class YaruWindowTitleBar extends StatelessWidget border: border, style: style, isActive: isActive ?? state?.isActive, - isClosable: isClosable ?? state?.isClosable?.exceptMacOS(context), isDraggable: isDraggable ?? state?.isMovable, - isMaximizable: - isMaximizable ?? state?.isMaximizable?.exceptMacOS(context), - isMinimizable: - isMinimizable ?? state?.isMinimizable?.exceptMacOS(context), - isRestorable: - isRestorable ?? state?.isRestorable?.exceptMacOS(context), + isMaximized: isMaximized ?? state?.isMaximized, + windowControlLayout: windowControlLayout ?? + (_onMacOS(context) + ? const YaruWindowControlLayout([], []) + : const YaruWindowControlLayout([], [ + YaruWindowControlType.minimize, + YaruWindowControlType.maximize, + YaruWindowControlType.close + ])), onClose: onClose, onDrag: onDrag, onMaximize: onMaximize, @@ -585,6 +540,11 @@ class YaruWindowTitleBar extends StatelessWidget }, ); } + + bool _onMacOS(BuildContext context) { + final platform = Theme.of(context).platform; + return !kIsWeb && platform == TargetPlatform.macOS; + } } /// A dialog title bar. @@ -607,11 +567,10 @@ class YaruDialogTitleBar extends YaruWindowTitleBar { super.border, super.style = YaruTitleBarStyle.normal, super.isActive, - super.isClosable = true, super.isDraggable, - super.isMaximizable = false, - super.isMinimizable = false, - super.isRestorable = false, + super.windowControlLayout = + const YaruWindowControlLayout([], [YaruWindowControlType.close]), + super.isMaximized = false, super.onClose = _maybePop, super.onDrag = YaruWindow.drag, super.onMaximize = null, @@ -634,10 +593,3 @@ class YaruDialogTitleBar extends YaruWindowTitleBar { return Navigator.maybePop(context); } } - -extension on bool? { - bool? exceptMacOS(BuildContext context) { - final platform = Theme.of(context).platform; - return !kIsWeb && platform == TargetPlatform.macOS ? false : this; - } -} diff --git a/lib/src/widgets/yaru_window_control_layout.dart b/lib/src/widgets/yaru_window_control_layout.dart new file mode 100644 index 000000000..370e00bbc --- /dev/null +++ b/lib/src/widgets/yaru_window_control_layout.dart @@ -0,0 +1,56 @@ +import 'yaru_window_control.dart'; + +/// Defines the order and position in which [YaruWindowControl] items are presented. +/// +/// YaruWindowControlType.maximize and YaruWindowControlType.restore are treated as the same button. +/// Only put one of these types in your YaruWindowControlLayout, otherwise the button will appear twice. + +class YaruWindowControlLayout { + const YaruWindowControlLayout(this.leftItems, this.rightItems); + + final List leftItems; + final List rightItems; + + /// Parses the [gtk-decoration-layout](https://docs.gtk.org/gtk4/property.Settings.gtk-decoration-layout.html) + /// string, as provided by GTK settings. + /// + /// It is up to the developer how to retrieve the string. For example, they can + /// use the [gtk](https://pub.dev/packages/gtk) package. + static YaruWindowControlLayout parseGTKSetting( + String gtkDecorationLayoutString, + ) { + final splitSideStrings = gtkDecorationLayoutString.split(':'); + final leftItemStrings = splitSideStrings.first.split(','); + final rightItemStrings = (splitSideStrings.length > 1) + ? splitSideStrings.last.split(',') + : []; + + return YaruWindowControlLayout( + _getControlTypesFromStrings(leftItemStrings), + _getControlTypesFromStrings(rightItemStrings), + ); + } + + static List _getControlTypesFromStrings( + List gtkControls, + ) { + final decorations = List.empty(growable: true); + for (final gtkControl in gtkControls) { + if (gtkControl.isNotEmpty) { + switch (gtkControl) { + case 'close': + decorations.add(YaruWindowControlType.close); + break; + case 'minimize': + decorations.add(YaruWindowControlType.minimize); + break; + case 'maximize': + decorations.add(YaruWindowControlType.maximize); + break; + default: // anything else is not supported, including "icon" and "menu" + } + } + } + return decorations; + } +} diff --git a/lib/src/widgets/yaru_window_control_row.dart b/lib/src/widgets/yaru_window_control_row.dart new file mode 100644 index 000000000..48b9f7734 --- /dev/null +++ b/lib/src/widgets/yaru_window_control_row.dart @@ -0,0 +1,106 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; + +import '../../yaru_widgets.dart'; + +class YaruWindowControlRow extends StatelessWidget { + const YaruWindowControlRow({ + super.key, + required this.windowControls, + required this.buttonPadding, + required this.buttonSpacing, + required this.isMaximized, + required this.onClose, + required this.onMaximize, + required this.onMinimize, + required this.onRestore, + this.foregroundColor, + this.backgroundColor, + }); + + final List windowControls; + final EdgeInsetsGeometry buttonPadding; + final double buttonSpacing; + final bool isMaximized; + + /// Called when the close button is pressed. + final FutureOr Function(BuildContext)? onClose; + + /// Called when the maximize button is pressed or the title bar is + /// double-clicked while the window is not maximized. + final FutureOr Function(BuildContext)? onMaximize; + + /// Called when the minimize button is pressed. + final FutureOr Function(BuildContext)? onMinimize; + + /// Called when the restore button is pressed or the title bar is + /// double-clicked while the window is maximized. + final FutureOr Function(BuildContext)? onRestore; + + /// The foreground color. + final Color? foregroundColor; + + /// The background color. + final Color? backgroundColor; + + @override + Widget build(BuildContext context) { + return Padding( + padding: buttonPadding, + child: Row( + children: windowControls + .map((type) { + switch (type) { + case YaruWindowControlType.close: + return YaruWindowControl( + foregroundColor: foregroundColor, + backgroundColor: backgroundColor, + type: YaruWindowControlType.close, + onTap: onClose != null ? () => onClose!(context) : null, + ); + case YaruWindowControlType.maximize: + case YaruWindowControlType.restore: + if (isMaximized == true) { + return YaruWindowControl( + foregroundColor: foregroundColor, + backgroundColor: backgroundColor, + type: YaruWindowControlType.restore, + onTap: + onRestore != null ? () => onRestore!(context) : null, + ); + } else { + return YaruWindowControl( + foregroundColor: foregroundColor, + backgroundColor: backgroundColor, + type: YaruWindowControlType.maximize, + onTap: onMaximize != null + ? () => onMaximize!(context) + : null, + ); + } + case YaruWindowControlType.minimize: + return YaruWindowControl( + foregroundColor: foregroundColor, + backgroundColor: backgroundColor, + type: YaruWindowControlType.minimize, + onTap: + onMinimize != null ? () => onMinimize!(context) : null, + ); + } + }) + .toList() + .withSpacing(buttonSpacing), + ), + ); + } +} + +extension _ListSpacing on List { + List withSpacing(double spacing) { + return expand((item) sync* { + yield SizedBox(width: spacing); + yield item; + }).skip(1).toList(); + } +} diff --git a/lib/widgets.dart b/lib/widgets.dart index eb104944f..7972f2166 100644 --- a/lib/widgets.dart +++ b/lib/widgets.dart @@ -52,3 +52,4 @@ export 'src/widgets/yaru_toggle_button.dart'; export 'src/widgets/yaru_toggle_button_theme.dart'; export 'src/widgets/yaru_watermark.dart'; export 'src/widgets/yaru_window_control.dart'; +export 'src/widgets/yaru_window_control_layout.dart'; diff --git a/test/widgets/yaru_title_bar_test.dart b/test/widgets/yaru_title_bar_test.dart index 5379ccdd8..4df6c3c75 100644 --- a/test/widgets/yaru_title_bar_test.dart +++ b/test/widgets/yaru_title_bar_test.dart @@ -44,15 +44,30 @@ void main() { ); if (defaultTargetPlatform == TargetPlatform.macOS) { - expect(titleBar.isClosable, isFalse); - expect(titleBar.isMaximizable, isFalse); - expect(titleBar.isMinimizable, isFalse); - expect(titleBar.isRestorable, isFalse); + expect(titleBar.windowControlLayout?.leftItems ?? [], isEmpty); + expect(titleBar.windowControlLayout?.rightItems ?? [], isEmpty); } else { - expect(titleBar.isClosable, isTrue); - expect(titleBar.isMaximizable, isTrue); - expect(titleBar.isMinimizable, isTrue); - expect(titleBar.isRestorable, isTrue); + expect( + titleBar.windowControlLayout?.rightItems + .contains(YaruWindowControlType.close) ?? + false, + isTrue, + ); + expect( + (titleBar.windowControlLayout?.rightItems + .contains(YaruWindowControlType.maximize) ?? + false) || + (titleBar.windowControlLayout?.rightItems + .contains(YaruWindowControlType.restore) ?? + false), + isTrue, + ); + expect( + titleBar.windowControlLayout?.rightItems + .contains(YaruWindowControlType.minimize) ?? + false, + isTrue, + ); } }, variant: TargetPlatformVariant.desktop(), @@ -77,10 +92,7 @@ void main() { const MaterialApp( home: Scaffold( appBar: YaruWindowTitleBar( - isClosable: false, - isMinimizable: false, - isMaximizable: false, - isRestorable: false, + windowControlLayout: YaruWindowControlLayout([], []), ), ), ), @@ -93,10 +105,8 @@ void main() { find.byType(YaruTitleBar), ); - expect(titleBar.isClosable, isFalse); - expect(titleBar.isMaximizable, isFalse); - expect(titleBar.isMinimizable, isFalse); - expect(titleBar.isRestorable, isFalse); + expect(titleBar.windowControlLayout?.leftItems, isEmpty); + expect(titleBar.windowControlLayout?.rightItems, isEmpty); }, variant: TargetPlatformVariant.desktop(), ); @@ -114,7 +124,9 @@ void main() { platform = YaruWindowControlPlatform.yaru; } - final state = variant.value!; + final state = variant.value!.first as YaruWindowState; + final windowControlLayout = + variant.value!.last as YaruWindowControlLayout; final builder = variant.label.contains('dialog') ? YaruDialogTitleBar.new : YaruTitleBar.new; @@ -122,11 +134,9 @@ void main() { await tester.pumpScaffold( builder( isActive: state.isActive, - isClosable: state.isClosable, + isMaximized: state.isMaximized, + windowControlLayout: windowControlLayout, isDraggable: false, - isMaximizable: state.isMaximizable, - isMinimizable: state.isMinimizable, - isRestorable: state.isRestorable, title: Text(state.title!), onClose: (_) {}, onMaximize: (_) {},