import 'dart:convert'; import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hbb/common/widgets/toolbar.dart'; import 'package:flutter_hbb/models/chat_model.dart'; import 'package:flutter_hbb/models/state_model.dart'; import 'package:flutter_hbb/consts.dart'; import 'package:flutter_hbb/utils/multi_window_manager.dart'; import 'package:flutter_hbb/plugin/widgets/desc_ui.dart'; import 'package:flutter_hbb/plugin/common.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:get/get.dart'; import 'package:provider/provider.dart'; import 'package:debounce_throttle/debounce_throttle.dart'; import 'package:desktop_multi_window/desktop_multi_window.dart'; import 'package:window_size/window_size.dart' as window_size; import '../../common.dart'; import '../../common/widgets/dialog.dart'; import '../../models/model.dart'; import '../../models/platform_model.dart'; import '../../common/shared_state.dart'; import './popup_menu.dart'; import './kb_layout_type_chooser.dart'; const _kKeyLegacyMode = 'legacy'; const _kKeyMapMode = 'map'; const _kKeyTranslateMode = 'translate'; class ToolbarState { final kStoreKey = 'remoteMenubarState'; late RxBool show; late RxBool _pin; ToolbarState() { final s = bind.getLocalFlutterOption(k: kStoreKey); if (s.isEmpty) { _initSet(false, false); return; } try { final m = jsonDecode(s); if (m == null) { _initSet(false, false); } else { _initSet(m['pin'] ?? false, m['pin'] ?? false); } } catch (e) { debugPrint('Failed to decode toolbar state ${e.toString()}'); _initSet(false, false); } } _initSet(bool s, bool p) { // Show remubar when connection is established. show = RxBool(bind.mainGetUserDefaultOption(key: 'collapse_toolbar') != 'Y'); _pin = RxBool(p); } bool get pin => _pin.value; switchShow() async { show.value = !show.value; } setShow(bool v) async { if (show.value != v) { show.value = v; } } switchPin() async { _pin.value = !_pin.value; // Save everytime changed, as this func will not be called frequently await _savePin(); } setPin(bool v) async { if (_pin.value != v) { _pin.value = v; // Save everytime changed, as this func will not be called frequently await _savePin(); } } _savePin() async { bind.setLocalFlutterOption( k: kStoreKey, v: jsonEncode({'pin': _pin.value})); } save() async { await _savePin(); } } class _ToolbarTheme { static const Color blueColor = MyTheme.button; static const Color hoverBlueColor = MyTheme.accent; static const Color redColor = Colors.redAccent; static const Color hoverRedColor = Colors.red; // kMinInteractiveDimension static const double height = 20.0; static const double dividerHeight = 12.0; static const double buttonSize = 32; static const double buttonHMargin = 3; static const double buttonVMargin = 6; static const double iconRadius = 8; static const double elevation = 3; static const Color bordDark = MyTheme.bordDark; static const Color bordLight = MyTheme.bordLight; static const Color dividerDark = MyTheme.dividerDark; static const Color dividerLight = MyTheme.dividerLight; static double dividerSpaceToAction = Platform.isWindows ? 8 : 14; static double menuBorderRadius = Platform.isWindows ? 5.0 : 7.0; static EdgeInsets menuPadding = Platform.isWindows ? EdgeInsets.fromLTRB(4, 12, 4, 12) : EdgeInsets.fromLTRB(6, 14, 6, 14); static const double menuButtonBorderRadius = 3.0; static final defaultMenuStyle = MenuStyle( side: MaterialStateProperty.all(BorderSide( width: 1, color: MyTheme.currentThemeMode() == ThemeMode.light ? _ToolbarTheme.bordLight : _ToolbarTheme.bordDark, )), shape: MaterialStatePropertyAll(RoundedRectangleBorder( borderRadius: BorderRadius.circular(_ToolbarTheme.menuBorderRadius))), padding: MaterialStateProperty.all(_ToolbarTheme.menuPadding), ); static final defaultMenuButtonStyle = ButtonStyle( backgroundColor: MaterialStatePropertyAll(Colors.transparent), padding: MaterialStatePropertyAll(EdgeInsets.zero), overlayColor: MaterialStatePropertyAll(Colors.transparent), ); } typedef DismissFunc = void Function(); class RemoteMenuEntry { static MenuEntryRadios viewStyle( String remoteId, FFI ffi, EdgeInsets padding, { DismissFunc? dismissFunc, DismissCallback? dismissCallback, RxString? rxViewStyle, }) { return MenuEntryRadios( text: translate('Ratio'), optionsGetter: () => [ MenuEntryRadioOption( text: translate('Scale original'), value: kRemoteViewStyleOriginal, dismissOnClicked: true, dismissCallback: dismissCallback, ), MenuEntryRadioOption( text: translate('Scale adaptive'), value: kRemoteViewStyleAdaptive, dismissOnClicked: true, dismissCallback: dismissCallback, ), ], curOptionGetter: () async { // null means peer id is not found, which there's no need to care about final viewStyle = await bind.sessionGetViewStyle(sessionId: ffi.sessionId) ?? ''; if (rxViewStyle != null) { rxViewStyle.value = viewStyle; } return viewStyle; }, optionSetter: (String oldValue, String newValue) async { await bind.sessionSetViewStyle( sessionId: ffi.sessionId, value: newValue); if (rxViewStyle != null) { rxViewStyle.value = newValue; } ffi.canvasModel.updateViewStyle(); if (dismissFunc != null) { dismissFunc(); } }, padding: padding, dismissOnClicked: true, dismissCallback: dismissCallback, ); } static MenuEntrySwitch2 showRemoteCursor( String remoteId, SessionID sessionId, EdgeInsets padding, { DismissFunc? dismissFunc, DismissCallback? dismissCallback, }) { final state = ShowRemoteCursorState.find(remoteId); final optKey = 'show-remote-cursor'; return MenuEntrySwitch2( switchType: SwitchType.scheckbox, text: translate('Show remote cursor'), getter: () { return state; }, setter: (bool v) async { await bind.sessionToggleOption(sessionId: sessionId, value: optKey); state.value = bind.sessionGetToggleOptionSync(sessionId: sessionId, arg: optKey); if (dismissFunc != null) { dismissFunc(); } }, padding: padding, dismissOnClicked: true, dismissCallback: dismissCallback, ); } static MenuEntrySwitch disableClipboard( SessionID sessionId, EdgeInsets? padding, { DismissFunc? dismissFunc, DismissCallback? dismissCallback, }) { return createSwitchMenuEntry( sessionId, 'Disable clipboard', 'disable-clipboard', padding, true, dismissCallback: dismissCallback, ); } static MenuEntrySwitch createSwitchMenuEntry( SessionID sessionId, String text, String option, EdgeInsets? padding, bool dismissOnClicked, { DismissFunc? dismissFunc, DismissCallback? dismissCallback, }) { return MenuEntrySwitch( switchType: SwitchType.scheckbox, text: translate(text), getter: () async { return bind.sessionGetToggleOptionSync( sessionId: sessionId, arg: option); }, setter: (bool v) async { await bind.sessionToggleOption(sessionId: sessionId, value: option); if (dismissFunc != null) { dismissFunc(); } }, padding: padding, dismissOnClicked: dismissOnClicked, dismissCallback: dismissCallback, ); } static MenuEntryButton insertLock( SessionID sessionId, EdgeInsets? padding, { DismissFunc? dismissFunc, DismissCallback? dismissCallback, }) { return MenuEntryButton( childBuilder: (TextStyle? style) => Text( translate('Insert Lock'), style: style, ), proc: () { bind.sessionLockScreen(sessionId: sessionId); if (dismissFunc != null) { dismissFunc(); } }, padding: padding, dismissOnClicked: true, dismissCallback: dismissCallback, ); } static insertCtrlAltDel( SessionID sessionId, EdgeInsets? padding, { DismissFunc? dismissFunc, DismissCallback? dismissCallback, }) { return MenuEntryButton( childBuilder: (TextStyle? style) => Text( '${translate("Insert")} Ctrl + Alt + Del', style: style, ), proc: () { bind.sessionCtrlAltDel(sessionId: sessionId); if (dismissFunc != null) { dismissFunc(); } }, padding: padding, dismissOnClicked: true, dismissCallback: dismissCallback, ); } } class RemoteToolbar extends StatefulWidget { final String id; final FFI ffi; final ToolbarState state; final Function(Function(bool)) onEnterOrLeaveImageSetter; final Function() onEnterOrLeaveImageCleaner; RemoteToolbar({ Key? key, required this.id, required this.ffi, required this.state, required this.onEnterOrLeaveImageSetter, required this.onEnterOrLeaveImageCleaner, }) : super(key: key); @override State createState() => _RemoteToolbarState(); } class _RemoteToolbarState extends State { late Debouncer _debouncerHide; bool _isCursorOverImage = false; final _fractionX = 0.5.obs; final _dragging = false.obs; int get windowId => stateGlobal.windowId; bool get isFullscreen => stateGlobal.fullscreen; void _setFullscreen(bool v) { stateGlobal.setFullscreen(v); setState(() {}); } RxBool get show => widget.state.show; bool get pin => widget.state.pin; PeerInfo get pi => widget.ffi.ffiModel.pi; FfiModel get ffiModel => widget.ffi.ffiModel; triggerAutoHide() => _debouncerHide.value = _debouncerHide.value + 1; void _minimize() async => await WindowController.fromWindowId(windowId).minimize(); @override initState() { super.initState(); Future.delayed(Duration.zero, () async { _fractionX.value = double.tryParse(await bind.sessionGetOption( sessionId: widget.ffi.sessionId, arg: 'remote-menubar-drag-x') ?? '0.5') ?? 0.5; }); _debouncerHide = Debouncer( Duration(milliseconds: 5000), onChanged: _debouncerHideProc, initialValue: 0, ); widget.onEnterOrLeaveImageSetter((enter) { if (enter) { triggerAutoHide(); _isCursorOverImage = true; } else { _isCursorOverImage = false; } }); } _debouncerHideProc(int v) { if (!pin && show.isTrue && _isCursorOverImage && _dragging.isFalse) { show.value = false; } } @override dispose() { super.dispose(); widget.onEnterOrLeaveImageCleaner(); } @override Widget build(BuildContext context) { // No need to use future builder here. return Align( alignment: Alignment.topCenter, child: Obx(() => show.value ? _buildToolbar(context) : _buildDraggableShowHide(context)), ); } Widget _buildDraggableShowHide(BuildContext context) { return Obx(() { if (show.isTrue && _dragging.isFalse) { triggerAutoHide(); } return Align( alignment: FractionalOffset(_fractionX.value, 0), child: Offstage( offstage: _dragging.isTrue, child: Material( elevation: _ToolbarTheme.elevation, shadowColor: MyTheme.color(context).shadow, child: _DraggableShowHide( sessionId: widget.ffi.sessionId, dragging: _dragging, fractionX: _fractionX, show: show, setFullscreen: _setFullscreen, setMinimize: _minimize, ), ), ), ); }); } Widget _buildToolbar(BuildContext context) { final List toolbarItems = []; if (!isWebDesktop) { toolbarItems.add(_PinMenu(state: widget.state)); toolbarItems.add(_MobileActionMenu(ffi: widget.ffi)); } if (PrivacyModeState.find(widget.id).isFalse && pi.displays.length > 1) { toolbarItems.add( bind.mainGetUserDefaultOption(key: 'show_monitors_toolbar') == 'Y' ? _MultiMonitorMenu(id: widget.id, ffi: widget.ffi) : _MonitorMenu(id: widget.id, ffi: widget.ffi), ); } toolbarItems .add(_ControlMenu(id: widget.id, ffi: widget.ffi, state: widget.state)); toolbarItems.add(_DisplayMenu( id: widget.id, ffi: widget.ffi, state: widget.state, setFullscreen: _setFullscreen, )); toolbarItems.add(_KeyboardMenu(id: widget.id, ffi: widget.ffi)); if (!isWeb) { toolbarItems.add(_ChatMenu(id: widget.id, ffi: widget.ffi)); toolbarItems.add(_VoiceCallMenu(id: widget.id, ffi: widget.ffi)); } toolbarItems.add(_RecordMenu(ffi: widget.ffi)); toolbarItems.add(_CloseMenu(id: widget.id, ffi: widget.ffi)); return Column( mainAxisSize: MainAxisSize.min, children: [ Material( elevation: _ToolbarTheme.elevation, shadowColor: MyTheme.color(context).shadow, borderRadius: BorderRadius.all(Radius.circular(4.0)), color: Theme.of(context) .menuBarTheme .style ?.backgroundColor ?.resolve(MaterialState.values.toSet()), child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: Theme( data: themeData(), child: Row( children: [ SizedBox(width: _ToolbarTheme.buttonHMargin * 2), ...toolbarItems, SizedBox(width: _ToolbarTheme.buttonHMargin * 2) ], ), ), ), ), _buildDraggableShowHide(context), ], ); } ThemeData themeData() { return Theme.of(context).copyWith( menuButtonTheme: MenuButtonThemeData( style: ButtonStyle( minimumSize: MaterialStatePropertyAll(Size(64, 32)), textStyle: MaterialStatePropertyAll( TextStyle(fontWeight: FontWeight.normal), ), shape: MaterialStatePropertyAll(RoundedRectangleBorder( borderRadius: BorderRadius.circular(_ToolbarTheme.menuButtonBorderRadius))), ), ), dividerTheme: DividerThemeData( space: _ToolbarTheme.dividerSpaceToAction, color: MyTheme.currentThemeMode() == ThemeMode.light ? _ToolbarTheme.dividerLight : _ToolbarTheme.dividerDark, ), menuBarTheme: MenuBarThemeData( style: MenuStyle( padding: MaterialStatePropertyAll(EdgeInsets.zero), elevation: MaterialStatePropertyAll(0), shape: MaterialStatePropertyAll(BeveledRectangleBorder()), ).copyWith( backgroundColor: Theme.of(context).menuBarTheme.style?.backgroundColor)), ); } } class _PinMenu extends StatelessWidget { final ToolbarState state; const _PinMenu({Key? key, required this.state}) : super(key: key); @override Widget build(BuildContext context) { return Obx( () => _IconMenuButton( assetName: state.pin ? "assets/pinned.svg" : "assets/unpinned.svg", tooltip: state.pin ? 'Unpin Toolbar' : 'Pin Toolbar', onPressed: state.switchPin, color: state.pin ? _ToolbarTheme.blueColor : Colors.grey[800]!, hoverColor: state.pin ? _ToolbarTheme.hoverBlueColor : Colors.grey[850]!, ), ); } } class _MobileActionMenu extends StatelessWidget { final FFI ffi; const _MobileActionMenu({Key? key, required this.ffi}) : super(key: key); @override Widget build(BuildContext context) { if (!ffi.ffiModel.isPeerAndroid) return Offstage(); return _IconMenuButton( assetName: 'assets/actions_mobile.svg', tooltip: 'Mobile Actions', onPressed: () => ffi.dialogManager.toggleMobileActionsOverlay(ffi: ffi), color: _ToolbarTheme.blueColor, hoverColor: _ToolbarTheme.hoverBlueColor, ); } } class _MonitorMenu extends StatelessWidget { final String id; final FFI ffi; const _MonitorMenu({Key? key, required this.id, required this.ffi}) : super(key: key); @override Widget build(BuildContext context) { return _IconSubmenuButton( tooltip: 'Select Monitor', icon: icon(), ffi: ffi, color: _ToolbarTheme.blueColor, hoverColor: _ToolbarTheme.hoverBlueColor, menuStyle: MenuStyle( padding: MaterialStatePropertyAll(EdgeInsets.symmetric(horizontal: 6))), menuChildren: [Row(children: displays(context))]); } icon() { final pi = ffi.ffiModel.pi; return Stack( alignment: Alignment.center, children: [ SvgPicture.asset( "assets/screen.svg", colorFilter: ColorFilter.mode(Colors.white, BlendMode.srcIn), ), Obx(() { RxInt display = CurrentDisplayState.find(id); return Text( '${display.value + 1}/${pi.displays.length}', style: const TextStyle( color: _ToolbarTheme.blueColor, fontSize: 8, fontWeight: FontWeight.bold, ), ); }), ], ); } List displays(BuildContext context) { final List rowChildren = []; final pi = ffi.ffiModel.pi; for (int i = 0; i < pi.displays.length; i++) { rowChildren.add(_IconMenuButton( topLevel: false, color: _ToolbarTheme.blueColor, hoverColor: _ToolbarTheme.hoverBlueColor, tooltip: "#${i + 1} monitor", hMargin: 6, vMargin: 12, icon: Container( alignment: AlignmentDirectional.center, constraints: const BoxConstraints(minHeight: _ToolbarTheme.height), child: Stack( alignment: Alignment.center, children: [ SvgPicture.asset( "assets/screen.svg", colorFilter: ColorFilter.mode(Colors.white, BlendMode.srcIn), ), Text( (i + 1).toString(), style: TextStyle( color: _ToolbarTheme.blueColor, fontSize: 12, fontWeight: FontWeight.bold, ), ), ], ), ), onPressed: () { _menuDismissCallback(ffi); RxInt display = CurrentDisplayState.find(id); if (display.value != i) { bind.sessionSwitchDisplay(sessionId: ffi.sessionId, value: i); } }, )); } return rowChildren; } } class _ControlMenu extends StatelessWidget { final String id; final FFI ffi; final ToolbarState state; _ControlMenu( {Key? key, required this.id, required this.ffi, required this.state}) : super(key: key); @override Widget build(BuildContext context) { return _IconSubmenuButton( tooltip: 'Control Actions', svg: "assets/actions.svg", color: _ToolbarTheme.blueColor, hoverColor: _ToolbarTheme.hoverBlueColor, ffi: ffi, menuChildren: toolbarControls(context, id, ffi).map((e) { if (e.divider) { return Divider(); } else { return MenuButton( child: e.child, onPressed: e.onPressed, ffi: ffi, trailingIcon: e.trailingIcon); } }).toList()); } } class ScreenAdjustor { final String id; final FFI ffi; final VoidCallback cbExitFullscreen; window_size.Screen? _screen; ScreenAdjustor({ required this.id, required this.ffi, required this.cbExitFullscreen, }); bool get isFullscreen => stateGlobal.fullscreen; int get windowId => stateGlobal.windowId; adjustWindow(BuildContext context) { return futureBuilder( future: isWindowCanBeAdjusted(), hasData: (data) { final visible = data as bool; if (!visible) return Offstage(); return Column( children: [ MenuButton( child: Text(translate('Adjust Window')), onPressed: () => doAdjustWindow(context), ffi: ffi), Divider(), ], ); }); } doAdjustWindow(BuildContext context) async { await updateScreen(); if (_screen != null) { cbExitFullscreen(); double scale = _screen!.scaleFactor; final wndRect = await WindowController.fromWindowId(windowId).getFrame(); final mediaSize = MediaQueryData.fromView(View.of(context)).size; // On windows, wndRect is equal to GetWindowRect and mediaSize is equal to GetClientRect. // https://stackoverflow.com/a/7561083 double magicWidth = wndRect.right - wndRect.left - mediaSize.width * scale; double magicHeight = wndRect.bottom - wndRect.top - mediaSize.height * scale; final canvasModel = ffi.canvasModel; final width = (canvasModel.getDisplayWidth() * canvasModel.scale + CanvasModel.leftToEdge + CanvasModel.rightToEdge) * scale + magicWidth; final height = (canvasModel.getDisplayHeight() * canvasModel.scale + CanvasModel.topToEdge + CanvasModel.bottomToEdge) * scale + magicHeight; double left = wndRect.left + (wndRect.width - width) / 2; double top = wndRect.top + (wndRect.height - height) / 2; Rect frameRect = _screen!.frame; if (!isFullscreen) { frameRect = _screen!.visibleFrame; } if (left < frameRect.left) { left = frameRect.left; } if (top < frameRect.top) { top = frameRect.top; } if ((left + width) > frameRect.right) { left = frameRect.right - width; } if ((top + height) > frameRect.bottom) { top = frameRect.bottom - height; } await WindowController.fromWindowId(windowId) .setFrame(Rect.fromLTWH(left, top, width, height)); } } updateScreen() async { final v = await rustDeskWinManager.call( WindowType.Main, kWindowGetWindowInfo, ''); final String valueStr = v; if (valueStr.isEmpty) { _screen = null; } else { final screenMap = jsonDecode(valueStr); _screen = window_size.Screen( Rect.fromLTRB(screenMap['frame']['l'], screenMap['frame']['t'], screenMap['frame']['r'], screenMap['frame']['b']), Rect.fromLTRB( screenMap['visibleFrame']['l'], screenMap['visibleFrame']['t'], screenMap['visibleFrame']['r'], screenMap['visibleFrame']['b']), screenMap['scaleFactor']); } } Future isWindowCanBeAdjusted() async { final viewStyle = await bind.sessionGetViewStyle(sessionId: ffi.sessionId) ?? ''; if (viewStyle != kRemoteViewStyleOriginal) { return false; } final remoteCount = RemoteCountState.find().value; if (remoteCount != 1) { return false; } if (_screen == null) { return false; } final scale = kIgnoreDpi ? 1.0 : _screen!.scaleFactor; double selfWidth = _screen!.visibleFrame.width; double selfHeight = _screen!.visibleFrame.height; if (isFullscreen) { selfWidth = _screen!.frame.width; selfHeight = _screen!.frame.height; } final canvasModel = ffi.canvasModel; final displayWidth = canvasModel.getDisplayWidth(); final displayHeight = canvasModel.getDisplayHeight(); final requiredWidth = CanvasModel.leftToEdge + displayWidth + CanvasModel.rightToEdge; final requiredHeight = CanvasModel.topToEdge + displayHeight + CanvasModel.bottomToEdge; return selfWidth > (requiredWidth * scale) && selfHeight > (requiredHeight * scale); } } class _DisplayMenu extends StatefulWidget { final String id; final FFI ffi; final ToolbarState state; final Function(bool) setFullscreen; final Widget pluginItem; _DisplayMenu( {Key? key, required this.id, required this.ffi, required this.state, required this.setFullscreen}) : pluginItem = LocationItem.createLocationItem( id, ffi, kLocationClientRemoteToolbarDisplay, true, ), super(key: key); @override State<_DisplayMenu> createState() => _DisplayMenuState(); } class _DisplayMenuState extends State<_DisplayMenu> { late final ScreenAdjustor _screenAdjustor = ScreenAdjustor( id: widget.id, ffi: widget.ffi, cbExitFullscreen: () => widget.setFullscreen(false), ); bool get isFullscreen => stateGlobal.fullscreen; int get windowId => stateGlobal.windowId; Map get perms => widget.ffi.ffiModel.permissions; PeerInfo get pi => widget.ffi.ffiModel.pi; FfiModel get ffiModel => widget.ffi.ffiModel; FFI get ffi => widget.ffi; String get id => widget.id; @override void initState() { super.initState(); } @override Widget build(BuildContext context) { _screenAdjustor.updateScreen(); return _IconSubmenuButton( tooltip: 'Display Settings', svg: "assets/display.svg", ffi: widget.ffi, color: _ToolbarTheme.blueColor, hoverColor: _ToolbarTheme.hoverBlueColor, menuChildren: [ _screenAdjustor.adjustWindow(context), viewStyle(), scrollStyle(), imageQuality(), codec(), _ResolutionsMenu( id: widget.id, ffi: widget.ffi, screenAdjustor: _screenAdjustor, ), Divider(), toggles(), widget.pluginItem, ]); } viewStyle() { return futureBuilder( future: toolbarViewStyle(context, widget.id, widget.ffi), hasData: (data) { final v = data as List>; return Column(children: [ ...v .map((e) => RdoMenuButton( value: e.value, groupValue: e.groupValue, onChanged: e.onChanged, child: e.child, ffi: ffi)) .toList(), Divider(), ]); }); } scrollStyle() { return futureBuilder(future: () async { final viewStyle = await bind.sessionGetViewStyle(sessionId: ffi.sessionId) ?? ''; final visible = viewStyle == kRemoteViewStyleOriginal; final scrollStyle = await bind.sessionGetScrollStyle(sessionId: ffi.sessionId) ?? ''; return {'visible': visible, 'scrollStyle': scrollStyle}; }(), hasData: (data) { final visible = data['visible'] as bool; if (!visible) return Offstage(); final groupValue = data['scrollStyle'] as String; onChange(String? value) async { if (value == null) return; await bind.sessionSetScrollStyle( sessionId: ffi.sessionId, value: value); widget.ffi.canvasModel.updateScrollStyle(); } final enabled = widget.ffi.canvasModel.imageOverflow.value; return Column(children: [ RdoMenuButton( child: Text(translate('ScrollAuto')), value: kRemoteScrollStyleAuto, groupValue: groupValue, onChanged: enabled ? (value) => onChange(value) : null, ffi: widget.ffi, ), RdoMenuButton( child: Text(translate('Scrollbar')), value: kRemoteScrollStyleBar, groupValue: groupValue, onChanged: enabled ? (value) => onChange(value) : null, ffi: widget.ffi, ), Divider(), ]); }); } imageQuality() { return futureBuilder( future: toolbarImageQuality(context, widget.id, widget.ffi), hasData: (data) { final v = data as List>; return _SubmenuButton( ffi: widget.ffi, child: Text(translate('Image Quality')), menuChildren: v .map((e) => RdoMenuButton( value: e.value, groupValue: e.groupValue, onChanged: e.onChanged, child: e.child, ffi: ffi)) .toList(), ); }); } codec() { return futureBuilder( future: toolbarCodec(context, id, ffi), hasData: (data) { final v = data as List>; if (v.isEmpty) return Offstage(); return _SubmenuButton( ffi: widget.ffi, child: Text(translate('Codec')), menuChildren: v .map((e) => RdoMenuButton( value: e.value, groupValue: e.groupValue, onChanged: e.onChanged, child: e.child, ffi: ffi)) .toList()); }); } toggles() { return futureBuilder( future: toolbarDisplayToggle(context, id, ffi), hasData: (data) { final v = data as List; if (v.isEmpty) return Offstage(); return Column( children: v .map((e) => CkbMenuButton( value: e.value, onChanged: e.onChanged, child: e.child, ffi: ffi)) .toList()); }); } } class _ResolutionsMenu extends StatefulWidget { final String id; final FFI ffi; final ScreenAdjustor screenAdjustor; _ResolutionsMenu({ Key? key, required this.id, required this.ffi, required this.screenAdjustor, }) : super(key: key); @override State<_ResolutionsMenu> createState() => _ResolutionsMenuState(); } const double _kCustomResolutionEditingWidth = 42; const _kCustomResolutionValue = 'custom'; class _ResolutionsMenuState extends State<_ResolutionsMenu> { String _groupValue = ''; Resolution? _localResolution; late final TextEditingController _customWidth = TextEditingController(text: display.width.toString()); late final TextEditingController _customHeight = TextEditingController(text: display.height.toString()); FFI get ffi => widget.ffi; PeerInfo get pi => widget.ffi.ffiModel.pi; FfiModel get ffiModel => widget.ffi.ffiModel; Display get display => ffiModel.display; List get resolutions => pi.resolutions; @override void initState() { super.initState(); } @override Widget build(BuildContext context) { final isVirtualDisplay = display.isVirtualDisplayResolution; final visible = ffiModel.keyboard && (isVirtualDisplay || resolutions.length > 1); if (!visible) return Offstage(); _getLocalResolution(); final showOriginalBtn = display.isOriginalResolutionSet && !display.isOriginalResolution; final showFitLocalBtn = !_isRemoteResolutionFitLocal(); _setGroupValue(); return _SubmenuButton( ffi: widget.ffi, menuChildren: [ _OriginalResolutionMenuButton(context, showOriginalBtn), _FitLocalResolutionMenuButton(context, showFitLocalBtn), _customResolutionMenuButton(context, isVirtualDisplay), _menuDivider(showOriginalBtn, showFitLocalBtn, isVirtualDisplay), ] + _supportedResolutionMenuButtons(), child: Text(translate("Resolution")), ); } _setGroupValue() { final lastGroupValue = stateGlobal.getLastResolutionGroupValue(widget.id, pi.currentDisplay); if (lastGroupValue == _kCustomResolutionValue) { _groupValue = _kCustomResolutionValue; } else { _groupValue = '${display.width}x${display.height}'; } } _menuDivider( bool showOriginalBtn, bool showFitLocalBtn, bool isVirtualDisplay) { return Offstage( offstage: !(showOriginalBtn || showFitLocalBtn || isVirtualDisplay), child: Divider(), ); } _getLocalResolution() { _localResolution = null; final String currentDisplay = bind.mainGetCurrentDisplay(); if (currentDisplay.isNotEmpty) { try { final display = json.decode(currentDisplay); if (display['w'] != null && display['h'] != null) { _localResolution = Resolution(display['w'], display['h']); } } catch (e) { debugPrint('Failed to decode $currentDisplay, $e'); } } } _onChanged(BuildContext context, String? value) async { stateGlobal.setLastResolutionGroupValue( widget.id, pi.currentDisplay, value); if (value == null) return; int? w; int? h; if (value == _kCustomResolutionValue) { w = int.tryParse(_customWidth.text); h = int.tryParse(_customHeight.text); } else { final list = value.split('x'); if (list.length == 2) { w = int.tryParse(list[0]); h = int.tryParse(list[1]); } } if (w != null && h != null) { if (w != display.width || h != display.height) { await _changeResolution(context, w, h); } } } _changeResolution(BuildContext context, int w, int h) async { await bind.sessionChangeResolution( sessionId: ffi.sessionId, display: pi.currentDisplay, width: w, height: h, ); Future.delayed(Duration(seconds: 3), () async { final display = ffiModel.display; if (w == display.width && h == display.height) { if (await widget.screenAdjustor.isWindowCanBeAdjusted()) { widget.screenAdjustor.doAdjustWindow(context); } } }); } Widget _OriginalResolutionMenuButton( BuildContext context, bool showOriginalBtn) { return Offstage( offstage: !showOriginalBtn, child: MenuButton( onPressed: () => _changeResolution( context, display.originalWidth, display.originalHeight), ffi: widget.ffi, child: Text( '${translate('resolution_original_tip')} ${display.originalWidth}x${display.originalHeight}'), ), ); } Widget _FitLocalResolutionMenuButton( BuildContext context, bool showFitLocalBtn) { return Offstage( offstage: !showFitLocalBtn, child: MenuButton( onPressed: () { final resolution = _getBestFitResolution(); if (resolution != null) { _changeResolution(context, resolution.width, resolution.height); } }, ffi: widget.ffi, child: Text( '${translate('resolution_fit_local_tip')} ${_localResolution?.width ?? 0}x${_localResolution?.height ?? 0}'), ), ); } Widget _customResolutionMenuButton(BuildContext context, isVirtualDisplay) { return Offstage( offstage: !isVirtualDisplay, child: RdoMenuButton( value: _kCustomResolutionValue, groupValue: _groupValue, onChanged: (String? value) => _onChanged(context, value), ffi: widget.ffi, child: Row( children: [ Text('${translate('resolution_custom_tip')} '), SizedBox( width: _kCustomResolutionEditingWidth, child: _resolutionInput(_customWidth), ), Text(' x '), SizedBox( width: _kCustomResolutionEditingWidth, child: _resolutionInput(_customHeight), ), ], ), ), ); } TextField _resolutionInput(TextEditingController controller) { return TextField( decoration: InputDecoration( border: InputBorder.none, isDense: true, contentPadding: EdgeInsets.fromLTRB(3, 3, 3, 3), ), keyboardType: TextInputType.number, inputFormatters: [ FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(4), FilteringTextInputFormatter.allow(RegExp(r'[0-9]')), ], controller: controller, ); } List _supportedResolutionMenuButtons() => resolutions .map((e) => RdoMenuButton( value: '${e.width}x${e.height}', groupValue: _groupValue, onChanged: (String? value) => _onChanged(context, value), ffi: widget.ffi, child: Text('${e.width}x${e.height}'))) .toList(); Resolution? _getBestFitResolution() { if (_localResolution == null) { return null; } if (display.isVirtualDisplayResolution) { return _localResolution!; } for (final r in resolutions) { if (r.width == _localResolution!.width && r.height == _localResolution!.height) { return r; } } return null; } bool _isRemoteResolutionFitLocal() { if (_localResolution == null) { return true; } final bestFitResolution = _getBestFitResolution(); if (bestFitResolution == null) { return true; } return bestFitResolution.width == display.width && bestFitResolution.height == display.height; } } class _KeyboardMenu extends StatelessWidget { final String id; final FFI ffi; _KeyboardMenu({ Key? key, required this.id, required this.ffi, }) : super(key: key); PeerInfo get pi => ffi.ffiModel.pi; @override Widget build(BuildContext context) { var ffiModel = Provider.of(context); if (!ffiModel.keyboard) return Offstage(); String? modeOnly; if (stateGlobal.grabKeyboard) { if (bind.sessionIsKeyboardModeSupported( sessionId: ffi.sessionId, mode: _kKeyMapMode)) { bind.sessionSetKeyboardMode( sessionId: ffi.sessionId, value: _kKeyMapMode); modeOnly = _kKeyMapMode; } else if (bind.sessionIsKeyboardModeSupported( sessionId: ffi.sessionId, mode: _kKeyLegacyMode)) { bind.sessionSetKeyboardMode( sessionId: ffi.sessionId, value: _kKeyLegacyMode); modeOnly = _kKeyLegacyMode; } } return _IconSubmenuButton( tooltip: 'Keyboard Settings', svg: "assets/keyboard.svg", ffi: ffi, color: _ToolbarTheme.blueColor, hoverColor: _ToolbarTheme.hoverBlueColor, menuChildren: [ mode(modeOnly), localKeyboardType(), Divider(), view_mode(), ]); } mode(String? modeOnly) { return futureBuilder(future: () async { return await bind.sessionGetKeyboardMode(sessionId: ffi.sessionId) ?? _kKeyLegacyMode; }(), hasData: (data) { final groupValue = data as String; List modes = [ KeyboardModeMenu(key: _kKeyLegacyMode, menu: 'Legacy mode'), KeyboardModeMenu(key: _kKeyMapMode, menu: 'Map mode'), KeyboardModeMenu(key: _kKeyTranslateMode, menu: 'Translate mode'), ]; List list = []; final enabled = !ffi.ffiModel.viewOnly; onChanged(String? value) async { if (value == null) return; await bind.sessionSetKeyboardMode( sessionId: ffi.sessionId, value: value); } for (KeyboardModeMenu mode in modes) { if (modeOnly != null && mode.key != modeOnly) { continue; } else if (!bind.sessionIsKeyboardModeSupported( sessionId: ffi.sessionId, mode: mode.key)) { continue; } if (pi.is_wayland && mode.key != _kKeyMapMode) { continue; } var text = translate(mode.menu); if (mode.key == _kKeyTranslateMode) { text = '$text beta'; } list.add(RdoMenuButton( child: Text(text), value: mode.key, groupValue: groupValue, onChanged: enabled ? onChanged : null, ffi: ffi, )); } return Column(children: list); }); } localKeyboardType() { final localPlatform = getLocalPlatformForKBLayoutType(pi.platform); final visible = localPlatform != ''; if (!visible) return Offstage(); final enabled = !ffi.ffiModel.viewOnly; return Column( children: [ Divider(), MenuButton( child: Text( '${translate('Local keyboard type')}: ${KBLayoutType.value}'), trailingIcon: const Icon(Icons.settings), ffi: ffi, onPressed: enabled ? () => showKBLayoutTypeChooser(localPlatform, ffi.dialogManager) : null, ) ], ); } view_mode() { final ffiModel = ffi.ffiModel; final enabled = version_cmp(pi.version, '1.2.0') >= 0 && ffiModel.keyboard; return CkbMenuButton( value: ffiModel.viewOnly, onChanged: enabled ? (value) async { if (value == null) return; await bind.sessionToggleOption( sessionId: ffi.sessionId, value: 'view-only'); ffiModel.setViewOnly(id, value); } : null, ffi: ffi, child: Text(translate('View Mode'))); } } class _ChatMenu extends StatefulWidget { final String id; final FFI ffi; _ChatMenu({ Key? key, required this.id, required this.ffi, }) : super(key: key); @override State<_ChatMenu> createState() => _ChatMenuState(); } class _ChatMenuState extends State<_ChatMenu> { // Using in StatelessWidget got `Looking up a deactivated widget's ancestor is unsafe`. final chatButtonKey = GlobalKey(); @override Widget build(BuildContext context) { return _IconSubmenuButton( tooltip: 'Chat', key: chatButtonKey, svg: 'assets/chat.svg', ffi: widget.ffi, color: _ToolbarTheme.blueColor, hoverColor: _ToolbarTheme.hoverBlueColor, menuChildren: [textChat(), voiceCall()]); } textChat() { return MenuButton( child: Text(translate('Text chat')), ffi: widget.ffi, onPressed: () { RenderBox? renderBox = chatButtonKey.currentContext?.findRenderObject() as RenderBox?; Offset? initPos; if (renderBox != null) { final pos = renderBox.localToGlobal(Offset.zero); initPos = Offset(pos.dx, pos.dy + _ToolbarTheme.dividerHeight); } widget.ffi.chatModel.changeCurrentKey( MessageKey(widget.ffi.id, ChatModel.clientModeID)); widget.ffi.chatModel.toggleChatOverlay(chatInitPos: initPos); }); } voiceCall() { return MenuButton( child: Text(translate('Voice call')), ffi: widget.ffi, onPressed: () => bind.sessionRequestVoiceCall(sessionId: widget.ffi.sessionId), ); } } class _VoiceCallMenu extends StatelessWidget { final String id; final FFI ffi; _VoiceCallMenu({ Key? key, required this.id, required this.ffi, }) : super(key: key); @override Widget build(BuildContext context) { return Obx( () { final String tooltip; final String icon; switch (ffi.chatModel.voiceCallStatus.value) { case VoiceCallStatus.waitingForResponse: tooltip = "Waiting"; icon = "assets/call_wait.svg"; break; case VoiceCallStatus.connected: tooltip = "Disconnect"; icon = "assets/call_end.svg"; break; default: return Offstage(); } return _IconMenuButton( assetName: icon, tooltip: tooltip, onPressed: () => bind.sessionCloseVoiceCall(sessionId: ffi.sessionId), color: _ToolbarTheme.redColor, hoverColor: _ToolbarTheme.hoverRedColor); }, ); } } class _RecordMenu extends StatelessWidget { final FFI ffi; const _RecordMenu({Key? key, required this.ffi}) : super(key: key); @override Widget build(BuildContext context) { var ffiModel = Provider.of(context); var recordingModel = Provider.of(context); final visible = recordingModel.start || ffiModel.permissions['recording'] != false; if (!visible) return Offstage(); final menuButton = _IconMenuButton( assetName: 'assets/rec.svg', tooltip: recordingModel.start ? 'Stop session recording' : 'Start session recording', onPressed: () => recordingModel.toggle(), color: recordingModel.start ? _ToolbarTheme.redColor : _ToolbarTheme.blueColor, hoverColor: recordingModel.start ? _ToolbarTheme.hoverRedColor : _ToolbarTheme.hoverBlueColor, ); return ChangeNotifierProvider.value( value: ffi.qualityMonitorModel, child: Consumer( builder: (context, model, child) => Offstage( // If already started, AV1->Hidden/Stop, Other->Start, same as actual offstage: model.data.codecFormat == 'AV1', child: menuButton, ))); } } class _CloseMenu extends StatelessWidget { final String id; final FFI ffi; const _CloseMenu({Key? key, required this.id, required this.ffi}) : super(key: key); @override Widget build(BuildContext context) { return _IconMenuButton( assetName: 'assets/close.svg', tooltip: 'Close', onPressed: () => clientClose(ffi.sessionId, ffi.dialogManager), color: _ToolbarTheme.redColor, hoverColor: _ToolbarTheme.hoverRedColor, ); } } class _IconMenuButton extends StatefulWidget { final String? assetName; final Widget? icon; final String tooltip; final Color color; final Color hoverColor; final VoidCallback? onPressed; final double? hMargin; final double? vMargin; final bool topLevel; const _IconMenuButton({ Key? key, this.assetName, this.icon, required this.tooltip, required this.color, required this.hoverColor, required this.onPressed, this.hMargin, this.vMargin, this.topLevel = true, }) : super(key: key); @override State<_IconMenuButton> createState() => _IconMenuButtonState(); } class _IconMenuButtonState extends State<_IconMenuButton> { bool hover = false; @override Widget build(BuildContext context) { assert(widget.assetName != null || widget.icon != null); final icon = widget.icon ?? SvgPicture.asset( widget.assetName!, colorFilter: ColorFilter.mode(Colors.white, BlendMode.srcIn), width: _ToolbarTheme.buttonSize, height: _ToolbarTheme.buttonSize, ); var button = SizedBox( width: _ToolbarTheme.buttonSize, height: _ToolbarTheme.buttonSize, child: MenuItemButton( style: ButtonStyle( backgroundColor: MaterialStatePropertyAll(Colors.transparent), padding: MaterialStatePropertyAll(EdgeInsets.zero), overlayColor: MaterialStatePropertyAll(Colors.transparent)), onHover: (value) => setState(() { hover = value; }), onPressed: widget.onPressed, child: Tooltip( message: translate(widget.tooltip), child: Material( type: MaterialType.transparency, child: Ink( decoration: BoxDecoration( borderRadius: BorderRadius.circular(_ToolbarTheme.iconRadius), color: hover ? widget.hoverColor : widget.color, ), child: icon)), ) ), ).marginSymmetric( horizontal: widget.hMargin ?? _ToolbarTheme.buttonHMargin, vertical: widget.vMargin ?? _ToolbarTheme.buttonVMargin); button = Tooltip( message: widget.tooltip, child: button, ); if (widget.topLevel) { return MenuBar(children: [button]); } else { return button; } } } class _IconSubmenuButton extends StatefulWidget { final String tooltip; final String? svg; final Widget? icon; final Color color; final Color hoverColor; final List menuChildren; final MenuStyle? menuStyle; final FFI ffi; _IconSubmenuButton( {Key? key, this.svg, this.icon, required this.tooltip, required this.color, required this.hoverColor, required this.menuChildren, required this.ffi, this.menuStyle}) : super(key: key); @override State<_IconSubmenuButton> createState() => _IconSubmenuButtonState(); } class _IconSubmenuButtonState extends State<_IconSubmenuButton> { bool hover = false; @override Widget build(BuildContext context) { assert(widget.svg != null || widget.icon != null); final icon = widget.icon ?? SvgPicture.asset( widget.svg!, colorFilter: ColorFilter.mode(Colors.white, BlendMode.srcIn), width: _ToolbarTheme.buttonSize, height: _ToolbarTheme.buttonSize, ); final button = SizedBox( width: _ToolbarTheme.buttonSize, height: _ToolbarTheme.buttonSize, child: SubmenuButton( menuStyle: widget.menuStyle ?? _ToolbarTheme.defaultMenuStyle, style: _ToolbarTheme.defaultMenuButtonStyle, onHover: (value) => setState(() { hover = value; }), child: Tooltip( message: translate(widget.tooltip), child: Material( type: MaterialType.transparency, child: Ink( decoration: BoxDecoration( borderRadius: BorderRadius.circular(_ToolbarTheme.iconRadius), color: hover ? widget.hoverColor : widget.color, ), child: icon)) ), menuChildren: widget.menuChildren .map((e) => _buildPointerTrackWidget(e, widget.ffi)) .toList())); return MenuBar(children: [ button.marginSymmetric( horizontal: _ToolbarTheme.buttonHMargin, vertical: _ToolbarTheme.buttonVMargin) ]); } } class _SubmenuButton extends StatelessWidget { final List menuChildren; final Widget? child; final FFI ffi; const _SubmenuButton({ Key? key, required this.menuChildren, required this.child, required this.ffi, }) : super(key: key); @override Widget build(BuildContext context) { return SubmenuButton( key: key, child: child, menuChildren: menuChildren.map((e) => _buildPointerTrackWidget(e, ffi)).toList(), menuStyle: _ToolbarTheme.defaultMenuStyle, ); } } class MenuButton extends StatelessWidget { final VoidCallback? onPressed; final Widget? trailingIcon; final Widget? child; final FFI ffi; MenuButton( {Key? key, this.onPressed, this.trailingIcon, required this.child, required this.ffi}) : super(key: key); @override Widget build(BuildContext context) { return MenuItemButton( key: key, onPressed: onPressed != null ? () { _menuDismissCallback(ffi); onPressed?.call(); } : null, trailingIcon: trailingIcon, child: child); } } class CkbMenuButton extends StatelessWidget { final bool? value; final ValueChanged? onChanged; final Widget? child; final FFI ffi; const CkbMenuButton( {Key? key, required this.value, required this.onChanged, required this.child, required this.ffi}) : super(key: key); @override Widget build(BuildContext context) { return CheckboxMenuButton( key: key, value: value, child: child, onChanged: onChanged != null ? (bool? value) { _menuDismissCallback(ffi); onChanged?.call(value); } : null, ); } } class RdoMenuButton extends StatelessWidget { final T value; final T? groupValue; final ValueChanged? onChanged; final Widget? child; final FFI ffi; const RdoMenuButton({ Key? key, required this.value, required this.groupValue, required this.child, required this.ffi, this.onChanged, }) : super(key: key); @override Widget build(BuildContext context) { return RadioMenuButton( value: value, groupValue: groupValue, child: child, onChanged: onChanged != null ? (T? value) { _menuDismissCallback(ffi); onChanged?.call(value); } : null, ); } } class _DraggableShowHide extends StatefulWidget { final SessionID sessionId; final RxDouble fractionX; final RxBool dragging; final RxBool show; final Function(bool) setFullscreen; final Function() setMinimize; const _DraggableShowHide({ Key? key, required this.sessionId, required this.fractionX, required this.dragging, required this.show, required this.setFullscreen, required this.setMinimize, }) : super(key: key); @override State<_DraggableShowHide> createState() => _DraggableShowHideState(); } class _DraggableShowHideState extends State<_DraggableShowHide> { Offset position = Offset.zero; Size size = Size.zero; double left = 0.0; double right = 1.0; @override initState() { super.initState(); final confLeft = double.tryParse( bind.mainGetLocalOption(key: 'remote-menubar-drag-left')); if (confLeft == null) { bind.mainSetLocalOption( key: 'remote-menubar-drag-left', value: left.toString()); } else { left = confLeft; } final confRight = double.tryParse( bind.mainGetLocalOption(key: 'remote-menubar-drag-right')); if (confRight == null) { bind.mainSetLocalOption( key: 'remote-menubar-drag-right', value: right.toString()); } else { right = confRight; } } Widget _buildDraggable(BuildContext context) { return Draggable( axis: Axis.horizontal, child: Icon( Icons.drag_indicator, size: 20, color: MyTheme.color(context).drag_indicator, ), feedback: widget, onDragStarted: (() { final RenderObject? renderObj = context.findRenderObject(); if (renderObj != null) { final RenderBox renderBox = renderObj as RenderBox; size = renderBox.size; position = renderBox.localToGlobal(Offset.zero); } widget.dragging.value = true; }), onDragEnd: (details) { final mediaSize = MediaQueryData.fromView(View.of(context)).size; widget.fractionX.value += (details.offset.dx - position.dx) / (mediaSize.width - size.width); if (widget.fractionX.value < left) { widget.fractionX.value = left; } if (widget.fractionX.value > right) { widget.fractionX.value = right; } bind.sessionPeerOption( sessionId: widget.sessionId, name: 'remote-menubar-drag-x', value: widget.fractionX.value.toString(), ); widget.dragging.value = false; }, ); } @override Widget build(BuildContext context) { final ButtonStyle buttonStyle = ButtonStyle( minimumSize: MaterialStateProperty.all(const Size(0, 0)), padding: MaterialStateProperty.all(EdgeInsets.zero), ); final isFullscreen = stateGlobal.fullscreen; const double iconSize = 20; final child = Row( mainAxisSize: MainAxisSize.min, children: [ _buildDraggable(context), TextButton( onPressed: () { widget.setFullscreen(!isFullscreen); setState(() {}); }, child: Tooltip( message: translate(isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'), child: Icon( isFullscreen ? Icons.fullscreen_exit : Icons.fullscreen, size: iconSize, ), ), ), Offstage( offstage: !isFullscreen, child: TextButton( onPressed: () => widget.setMinimize(), child: Tooltip( message: translate('Minimize'), child: Icon( Icons.remove, size: iconSize, ), ), ), ), TextButton( onPressed: () => setState(() { widget.show.value = !widget.show.value; }), child: Obx((() => Tooltip( message: translate( widget.show.isTrue ? 'Hide Toolbar' : 'Show Toolbar'), child: Icon( widget.show.isTrue ? Icons.expand_less : Icons.expand_more, size: iconSize, ), ))), ), ], ); return TextButtonTheme( data: TextButtonThemeData(style: buttonStyle), child: Container( decoration: BoxDecoration( color: Theme.of(context) .menuBarTheme .style ?.backgroundColor ?.resolve(MaterialState.values.toSet()), borderRadius: BorderRadius.vertical( bottom: Radius.circular(5), ), ), child: SizedBox( height: 20, child: child, ), ), ); } } class KeyboardModeMenu { final String key; final String menu; KeyboardModeMenu({required this.key, required this.menu}); } _menuDismissCallback(FFI ffi) => ffi.inputModel.refreshMousePos(); Widget _buildPointerTrackWidget(Widget child, FFI ffi) { return Listener( onPointerHover: (PointerHoverEvent e) => ffi.inputModel.lastMousePos = e.position, child: MouseRegion( child: child, ), ); } class _MultiMonitorMenu extends StatelessWidget { final String id; final FFI ffi; const _MultiMonitorMenu({ Key? key, required this.id, required this.ffi, }) : super(key: key); @override Widget build(BuildContext context) { final List rowChildren = []; final pi = ffi.ffiModel.pi; for (int i = 0; i < pi.displays.length; i++) { rowChildren.add( Obx(() { RxInt display = CurrentDisplayState.find(id); return _IconMenuButton( tooltip: "", topLevel: false, color: i == display.value ? _ToolbarTheme.blueColor : Colors.grey[800]!, hoverColor: i == display.value ? _ToolbarTheme.hoverBlueColor : Colors.grey[850]!, icon: Container( alignment: AlignmentDirectional.center, constraints: const BoxConstraints(minHeight: _ToolbarTheme.height), child: Stack( alignment: Alignment.center, children: [ SvgPicture.asset( "assets/screen.svg", colorFilter: ColorFilter.mode(Colors.white, BlendMode.srcIn), ), Obx( () => Text( (i + 1).toString(), style: TextStyle( color: i == display.value ? _ToolbarTheme.blueColor : Colors.grey[800]!, fontSize: 12, fontWeight: FontWeight.bold, ), ), ), ], ), ), onPressed: () { if (display.value != i) { bind.sessionSwitchDisplay(sessionId: ffi.sessionId, value: i); } }, ); }), ); } return Row(children: rowChildren); } }