From 7c8b0adc1ed49038380903f06518f4548c4d0f02 Mon Sep 17 00:00:00 2001 From: StealUrKill <35749471+StealUrKill@users.noreply.github.com> Date: Thu, 18 Jun 2026 22:19:08 -0500 Subject: [PATCH] Feature: Add monitor-switch buttons to remote toolbars (#15314) * Feature: add monitor-switch buttons to remote toolbars Add a one-click "switch to next monitor" control to both desktop toolbars: - Main toolbar: always shown when the remote has more than one monitor, styled to match the existing blue icon buttons (white screen, black number). - Minimized (draggable show/hide) toolbar: off by default, toggled via a new "Show monitor switch on minimized toolbar" checkbox in the Display menu and persisted as a local option. Signed-off-by: StealUrKill <35749471+StealUrKill@users.noreply.github.com> * Update remote_toolbar.dart * refact: unify monitor-switch button icons, share tooltip Addressing the review feedback on the monitor-switch toolbar buttons: - Add assets/display_switcher.svg and use it for both the main and minimized buttons. This replaces the hand-drawn glyph (Containers + magic numbers) on the main toolbar. The icon scales with DPI/theme and the two toolbars stay visually consistent. - Flip the minimized button's label to white for contrast, since the new icon has a solid screen. - Move the tooltip string into a shared _MonitorCycle.tooltip getter so both buttons use one source of truth. - Use const Offstage() for consistency with the surrounding returns. Signed-off-by: StealUrKill <35749471+StealUrKill@users.noreply.github.com> * Improve monitor-switch settings and toolbar behavior - Nest the minimized-toolbar option under the main one in settings only show when the main option is enabled. - Only show the minimized switch button on the collapsed toolbar handle, so it no longer duplicates the main switch while the toolbar is expanded. Signed-off-by: StealUrKill <35749471+StealUrKill@users.noreply.github.com> --------- Signed-off-by: StealUrKill <35749471+StealUrKill@users.noreply.github.com> Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com> --- flutter/assets/display_switcher.svg | 7 + flutter/lib/consts.dart | 2 + .../desktop/pages/desktop_setting_page.dart | 42 +++++ .../lib/desktop/widgets/remote_toolbar.dart | 157 ++++++++++++++++++ 4 files changed, 208 insertions(+) create mode 100644 flutter/assets/display_switcher.svg 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..da604e424 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,16 @@ class _RemoteToolbarState extends State { BuildContext context, _ToolbarEdge edge, bool isHorizontal) { final List toolbarItems = []; toolbarItems.add(_PinMenu(state: widget.state)); + toolbarItems.add(Obx(() { + if ((PrivacyModeState.find(widget.id).isEmpty || + allowDisplaySwitchInPrivacyMode(pi)) && + 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 +976,80 @@ 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; + openMonitorInTheSameTab((from + 1) % t, 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 +3056,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 +3080,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 +3337,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 +3500,70 @@ 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(kOptionAllowMonitorSwitchMinToolbar)) { + return const Offstage(); + } + if (cycle.total < 2) return const Offstage(); + if (PrivacyModeState.find(id).isNotEmpty && + !allowDisplaySwitchInPrivacyMode(ffi.ffiModel.pi)) { + 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, + ), + ), + ], + ), + ), + ); + }); + } +}