diff --git a/flutter/assets/display_switcher.svg b/flutter/assets/display_switcher.svg new file mode 100644 index 000000000..4f5b4bb58 --- /dev/null +++ b/flutter/assets/display_switcher.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/flutter/lib/consts.dart b/flutter/lib/consts.dart index 8caf6ee11..69f4be59e 100644 --- a/flutter/lib/consts.dart +++ b/flutter/lib/consts.dart @@ -174,6 +174,8 @@ const String kOptionShowVirtualMouse = "show-virtual-mouse"; const String kOptionVirtualMouseScale = "virtual-mouse-scale"; const String kOptionShowVirtualJoystick = "show-virtual-joystick"; const String kOptionAllowAskForNoteAtEndOfConnection = "allow-ask-for-note"; +const String kOptionAllowMonitorSwitchMainToolbar = "allow-monitor-switch-main-toolbar"; +const String kOptionAllowMonitorSwitchMinToolbar = "allow-monitor-switch-min-toolbar"; const String kOptionEnableShowTerminalExtraKeys = "enable-show-terminal-extra-keys"; // network options diff --git a/flutter/lib/desktop/pages/desktop_setting_page.dart b/flutter/lib/desktop/pages/desktop_setting_page.dart index d1d620014..ef5b75d36 100644 --- a/flutter/lib/desktop/pages/desktop_setting_page.dart +++ b/flutter/lib/desktop/pages/desktop_setting_page.dart @@ -407,6 +407,7 @@ class _GeneralState extends State<_General> { final RxBool serviceStop = isWeb ? RxBool(false) : Get.find(tag: 'stop-service'); RxBool serviceBtnEnabled = true.obs; + final GlobalKey _minToolbarOptionKey = GlobalKey(); @override Widget build(BuildContext context) { @@ -605,6 +606,47 @@ class _GeneralState extends State<_General> { }, )); } + children.add(_OptionCheckBox( + context, + 'Show monitor switch button on the main toolbar', + kOptionAllowMonitorSwitchMainToolbar, + isServer: false, + update: (enabled) async { + if (!enabled) { + await mainSetLocalBoolOption( + kOptionAllowMonitorSwitchMinToolbar, false); + } + if (mounted) setState(() {}); + reloadAllWindows(); + if (enabled) { + WidgetsBinding.instance.addPostFrameCallback((_) { + final ctx = _minToolbarOptionKey.currentContext; + if (ctx != null) { + Scrollable.ensureVisible( + ctx, + alignment: 0.5, + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + ); + } + }); + } + }, + )); + if (mainGetLocalBoolOptionSync(kOptionAllowMonitorSwitchMainToolbar)) { + children.add(KeyedSubtree( + key: _minToolbarOptionKey, + child: _OptionCheckBox( + context, + 'Show on the minimized toolbar', + kOptionAllowMonitorSwitchMinToolbar, + isServer: false, + update: (_) { + reloadAllWindows(); + }, + ).marginOnly(left: _kCheckBoxLeftMargin * 3), + )); + } return _Card(title: 'Other', children: children); } diff --git a/flutter/lib/desktop/widgets/remote_toolbar.dart b/flutter/lib/desktop/widgets/remote_toolbar.dart index 686120be5..34879e5cb 100644 --- a/flutter/lib/desktop/widgets/remote_toolbar.dart +++ b/flutter/lib/desktop/widgets/remote_toolbar.dart @@ -779,6 +779,7 @@ class _RemoteToolbarState extends State { borderRadius: borderRadius, child: _DraggableShowHide( id: widget.id, + ffi: widget.ffi, sessionId: widget.ffi.sessionId, dragging: _dragging, fraction: _fraction, @@ -805,6 +806,17 @@ class _RemoteToolbarState extends State { BuildContext context, _ToolbarEdge edge, bool isHorizontal) { final List toolbarItems = []; toolbarItems.add(_PinMenu(state: widget.state)); + toolbarItems.add(Obx(() { + final privacyModeState = PrivacyModeState.find(widget.id); + if ((privacyModeState.isEmpty || + allowDisplaySwitchInPrivacyMode(pi, privacyModeState.value)) && + pi.displaysCount.value > 1 && + mainGetLocalBoolOptionSync(kOptionAllowMonitorSwitchMainToolbar)) { + return _MainMonitorSwitchButton(id: widget.id, ffi: widget.ffi); + } else { + return const Offstage(); + } + })); if (!isWebDesktop) { toolbarItems.add(_MobileActionMenu(ffi: widget.ffi)); } @@ -965,6 +977,88 @@ class _MobileActionMenu extends StatelessWidget { } } +class _MonitorCycle { + final String id; + final FFI ffi; + const _MonitorCycle(this.id, this.ffi); + + PeerInfo get _pi => ffi.ffiModel.pi; + int get total => _pi.displays.length; + int get _current => CurrentDisplayState.find(id).value; + bool get _inRange => _current >= 0 && _current < total; + + String get label => _inRange ? '${_current + 1}' : '*'; + String get tooltip => '${translate('Switch display')} ($label/$total)'; + + void next() { + final t = total; + if (t < 2) return; + final from = _inRange ? _current : -1; + final target = (from + 1) % t; + final isChooseDisplayToOpenInNewWindow = _pi.isSupportMultiDisplay && + bind.sessionGetDisplaysAsIndividualWindows(sessionId: ffi.sessionId) == + 'Y'; + if (isChooseDisplayToOpenInNewWindow) { + openMonitorInNewTabOrWindow(target, ffi.id, _pi); + } else { + openMonitorInTheSameTab(target, ffi, _pi, updateCursorPos: false); + } + } +} + +class _MainMonitorSwitchButton extends StatelessWidget { + final String id; + final FFI ffi; + + const _MainMonitorSwitchButton({ + Key? key, + required this.id, + required this.ffi, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final cycle = _MonitorCycle(id, ffi); + return Obx(() { + if (cycle.total < 2) return const Offstage(); + final label = cycle.label; + + return _IconMenuButton( + tooltip: cycle.tooltip, + color: _ToolbarTheme.blueColor, + hoverColor: _ToolbarTheme.hoverBlueColor, + onPressed: cycle.next, + icon: SizedBox( + width: _ToolbarTheme.buttonSize, + height: _ToolbarTheme.buttonSize, + child: Stack( + alignment: const Alignment(0, -0.125), + children: [ + SvgPicture.asset( + 'assets/display_switcher.svg', + colorFilter: + const ColorFilter.mode(Colors.white, BlendMode.srcIn), + width: _ToolbarTheme.buttonSize, + height: _ToolbarTheme.buttonSize, + ), + Text( + label, + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.black, + fontSize: 11, + height: 1, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ); + }); + } +} + class _MonitorMenu extends StatelessWidget { final String id; final FFI ffi; @@ -2971,6 +3065,7 @@ class RdoMenuButton extends StatelessWidget { class _DraggableShowHide extends StatefulWidget { final String id; + final FFI ffi; final SessionID sessionId; final RxDouble fraction; final Rx<_ToolbarEdge> edge; @@ -2994,6 +3089,7 @@ class _DraggableShowHide extends StatefulWidget { const _DraggableShowHide({ Key? key, required this.id, + required this.ffi, required this.sessionId, required this.fraction, required this.edge, @@ -3250,6 +3346,9 @@ class _DraggableShowHideState extends State<_DraggableShowHide> { mainAxisSize: MainAxisSize.min, children: [ _buildDraggable(context), + Obx(() => collapse.isTrue + ? _MinimizedMonitorSwitchButton(id: widget.id, ffi: widget.ffi) + : const Offstage()), Obx(() => buttonWrapper( () { widget.setFullscreen(!isFullscreen.value); @@ -3410,3 +3509,73 @@ class EdgeThicknessControl extends StatelessWidget { return slider; } } + +class _MinimizedMonitorSwitchButton extends StatelessWidget { + final String id; + final FFI ffi; + + const _MinimizedMonitorSwitchButton({ + Key? key, + required this.id, + required this.ffi, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + const double iconSize = 20; + final cycle = _MonitorCycle(id, ffi); + + return Obx(() { + final label = cycle.label; + if (!mainGetLocalBoolOptionSync(kOptionAllowMonitorSwitchMainToolbar) || + !mainGetLocalBoolOptionSync(kOptionAllowMonitorSwitchMinToolbar)) { + return const Offstage(); + } + if (cycle.total < 2) return const Offstage(); + final privacyModeState = PrivacyModeState.find(id); + if (privacyModeState.isNotEmpty && + !allowDisplaySwitchInPrivacyMode( + ffi.ffiModel.pi, privacyModeState.value)) { + return const Offstage(); + } + + return Tooltip( + message: cycle.tooltip, + child: TextButton( + onPressed: cycle.next, + style: ButtonStyle( + minimumSize: MaterialStateProperty.all(const Size(0, 0)), + padding: MaterialStateProperty.all(EdgeInsets.zero), + backgroundColor: MaterialStateProperty.resolveWith((states) { + if (states.contains(MaterialState.hovered)) { + return _ToolbarTheme.blueColor.withOpacity(0.15); + } + return null; + }), + ), + child: Stack( + alignment: const Alignment(0, -0.125), + children: [ + SvgPicture.asset( + 'assets/display_switcher.svg', + colorFilter: + ColorFilter.mode(_ToolbarTheme.blueColor, BlendMode.srcIn), + width: iconSize, + height: iconSize, + ), + Text( + label, + style: const TextStyle( + color: Colors.white, + fontSize: 9, + height: 1, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ); + }); + } +}