From bff47e2b81c73072ab11606593a618ae1f7aa4cd Mon Sep 17 00:00:00 2001
From: StealUrKill <35749471+StealUrKill@users.noreply.github.com>
Date: Fri, 19 Jun 2026 12:22:20 -0500
Subject: [PATCH] Feature: Add monitor-switch buttons to remote toolbars
(#15342)
* Feature: add monitor-switch buttons to remote toolbars
Add buttons to cycle through the remote displays from the toolbars:
- A main-toolbar button and a minimized-handle button, both using a shared SVG icon with the current monitor number overlaid.
- Two opt-in settings under Settings/Other. The minimized-toolbar option is nested under the main-toolbar option.
- The minimized button only appears once the toolbar is collapsed.
- Cycling does not move the remote cursor, matching the existing in-toolbar monitor buttons.
Signed-off-by: StealUrKill <35749471+StealUrKill@users.noreply.github.com>
* fix: respect individual-window display mode when cycling
In "Show displays as individual windows" mode, route the cycle button through openMonitorInNewTabOrWindow like the monitor selector, so each display keeps its own window instead of repurposing the current one.
Signed-off-by: StealUrKill <35749471+StealUrKill@users.noreply.github.com>
---------
Signed-off-by: StealUrKill <35749471+StealUrKill@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 | 169 ++++++++++++++++++
4 files changed, 220 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..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,
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ });
+ }
+}