import 'dart:async'; import 'dart:math'; import 'dart:ui' as ui; import 'package:bot_toast/bot_toast.dart'; import 'package:desktop_multi_window/desktop_multi_window.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart' hide TabBarTheme; import 'package:flutter_hbb/common.dart'; import 'package:flutter_hbb/consts.dart'; import 'package:flutter_hbb/desktop/pages/remote_page.dart'; import 'package:flutter_hbb/main.dart'; import 'package:flutter_hbb/models/platform_model.dart'; import 'package:flutter_hbb/models/state_model.dart'; import 'package:get/get.dart'; import 'package:get/get_rx/src/rx_workers/utils/debouncer.dart'; import 'package:scroll_pos/scroll_pos.dart'; import 'package:window_manager/window_manager.dart'; import 'package:visibility_detector/visibility_detector.dart'; import '../../utils/multi_window_manager.dart'; const double _kTabBarHeight = kDesktopRemoteTabBarHeight; const double _kIconSize = 18; const double _kDividerIndent = 10; const double _kActionIconSize = 12; class TabInfo { final String key; // Notice: cm use client_id.toString() as key final String label; final IconData? selectedIcon; final IconData? unselectedIcon; final bool closable; final VoidCallback? onTabCloseButton; final VoidCallback? onTap; final Widget page; TabInfo( {required this.key, required this.label, this.selectedIcon, this.unselectedIcon, this.closable = true, this.onTabCloseButton, this.onTap, required this.page}); } enum DesktopTabType { main, cm, remoteScreen, fileTransfer, portForward, install, } class DesktopTabState { final List tabs = []; final ScrollPosController scrollController = ScrollPosController(itemCount: 0); final PageController pageController = PageController(); int selected = 0; TabInfo get selectedTabInfo => tabs[selected]; DesktopTabState() { scrollController.itemCount = tabs.length; } } CancelFunc showRightMenu(ToastBuilder builder, {BuildContext? context, Offset? target}) { return BotToast.showAttachedWidget( target: target, targetContext: context, verticalOffset: 0.0, horizontalOffset: 0.0, duration: Duration(seconds: 300), animationDuration: Duration(milliseconds: 0), animationReverseDuration: Duration(milliseconds: 0), preferDirection: PreferDirection.rightTop, ignoreContentClick: false, onlyOne: true, allowClick: true, enableSafeArea: true, backgroundColor: Color(0x00000000), attachedBuilder: builder, ); } class DesktopTabController { final state = DesktopTabState().obs; final DesktopTabType tabType; /// index, key Function(int, String)? onRemoved; Function(String)? onSelected; DesktopTabController( {required this.tabType, this.onRemoved, this.onSelected}); int get length => state.value.tabs.length; void add(TabInfo tab) { if (!isDesktop) return; final index = state.value.tabs.indexWhere((e) => e.key == tab.key); int toIndex; if (index >= 0) { toIndex = index; } else { state.update((val) { val!.tabs.add(tab); }); state.value.scrollController.itemCount = state.value.tabs.length; toIndex = state.value.tabs.length - 1; assert(toIndex >= 0); } try { // tabPage has not been initialized, call `onSelected` at the end of initState jumpTo(toIndex, callOnSelected: false); } catch (e) { // call before binding controller will throw debugPrint("Failed to jumpTo: $e"); } } void remove(int index) { if (!isDesktop) return; final len = state.value.tabs.length; if (index < 0 || index > len - 1) return; final key = state.value.tabs[index].key; final currentSelected = state.value.selected; int toIndex = 0; if (index == len - 1) { toIndex = max(0, currentSelected - 1); } else if (index < len - 1 && index < currentSelected) { toIndex = max(0, currentSelected - 1); } state.value.tabs.removeAt(index); state.value.scrollController.itemCount = state.value.tabs.length; jumpTo(toIndex); onRemoved?.call(index, key); } /// For addTab, tabPage has not been initialized, set [callOnSelected] to false, /// and call [onSelected] at the end of initState bool jumpTo(int index, {bool callOnSelected = true}) { if (!isDesktop || index < 0) { return false; } state.update((val) { if (val != null) { val.selected = index; Future.delayed(Duration(milliseconds: 100), (() { if (val.pageController.hasClients) { val.pageController.jumpToPage(index); } val.scrollController.itemCount = val.tabs.length; if (val.scrollController.hasClients && val.scrollController.itemCount > index) { val.scrollController .scrollToItem(index, center: false, animate: true); } })); } }); if ((isDesktop && (bind.isIncomingOnly() || bind.isOutgoingOnly())) || callOnSelected) { if (state.value.tabs.length > index) { final key = state.value.tabs[index].key; onSelected?.call(key); } } return true; } bool jumpToByKey(String key, {bool callOnSelected = true}) => jumpTo(state.value.tabs.indexWhere((tab) => tab.key == key), callOnSelected: callOnSelected); bool jumpToByKeyAndDisplay(String key, int display) { for (int i = 0; i < state.value.tabs.length; i++) { final tab = state.value.tabs[i]; if (tab.key == key) { final ffi = (tab.page as RemotePage).ffi; if (ffi.ffiModel.pi.currentDisplay == display) { return jumpTo(i, callOnSelected: true); } } } return false; } void closeBy(String? key) { if (!isDesktop) return; assert(onRemoved != null); if (key == null) { if (state.value.selected < state.value.tabs.length) { remove(state.value.selected); } } else { final index = state.value.tabs.indexWhere((tab) => tab.key == key); remove(index); } } void clear() { state.value.tabs.clear(); state.refresh(); } Widget? widget(String key) { return state.value.tabs.firstWhereOrNull((tab) => tab.key == key)?.page; } } class TabThemeConf { double iconSize; TabThemeConf({required this.iconSize}); } typedef TabBuilder = Widget Function( String key, Widget icon, Widget label, TabThemeConf themeConf); typedef TabMenuBuilder = Widget Function(String key); typedef LabelGetter = Rx Function(String key); /// [_lastClickTime], help to handle double click int _lastClickTime = DateTime.now().millisecondsSinceEpoch - bind.getDoubleClickTime() - 1000; // ignore: must_be_immutable class DesktopTab extends StatelessWidget { final bool showLogo; final bool showTitle; final bool showMinimize; final bool showMaximize; final bool showClose; final Widget Function(Widget pageView)? pageViewBuilder; // Right click tab menu final TabMenuBuilder? tabMenuBuilder; final Widget? tail; final Future Function()? onWindowCloseButton; final TabBuilder? tabBuilder; final LabelGetter? labelGetter; final double? maxLabelWidth; final Color? selectedTabBackgroundColor; final Color? unSelectedTabBackgroundColor; final Color? selectedBorderColor; final RxBool? blockTab; final DesktopTabController controller; Rx get state => controller.state; final _scrollDebounce = Debouncer(delay: Duration(milliseconds: 50)); late final DesktopTabType tabType; late final bool isMainWindow; final RxList invisibleTabKeys = RxList.empty(); DesktopTab({ Key? key, required this.controller, this.showLogo = true, this.showTitle = false, this.showMinimize = true, this.showMaximize = true, this.showClose = true, this.pageViewBuilder, this.tabMenuBuilder, this.tail, this.onWindowCloseButton, this.tabBuilder, this.labelGetter, this.maxLabelWidth, this.selectedTabBackgroundColor, this.unSelectedTabBackgroundColor, this.selectedBorderColor, this.blockTab, }) : super(key: key) { tabType = controller.tabType; isMainWindow = tabType == DesktopTabType.main || tabType == DesktopTabType.cm || tabType == DesktopTabType.install; } static RxString tablabelGetter(String peerId) { final alias = bind.mainGetPeerOptionSync(id: peerId, key: 'alias'); return RxString(getDesktopTabLabel(peerId, alias)); } @override Widget build(BuildContext context) { return Column(children: [ Obx(() { if (stateGlobal.showTabBar.isTrue && !(kUseCompatibleUiMode && isHideSingleItem())) { return SizedBox( height: _kTabBarHeight, child: Column( children: [ SizedBox( height: _kTabBarHeight - 1, child: _buildBar(), ), const Divider( height: 1, ), ], ), ); } else { return Offstage(); } }), Expanded( child: pageViewBuilder != null ? pageViewBuilder!(_buildPageView()) : _buildPageView()) ]); } Widget _buildBlock({required Widget child}) { if (blockTab != null) { return buildRemoteBlock( child: child, block: blockTab!, use: canBeBlocked, mask: tabType == DesktopTabType.main); } else { return child; } } List _tabWidgets = []; Widget _buildPageView() { final child = _buildBlock( child: Obx(() => PageView( controller: state.value.pageController, physics: NeverScrollableScrollPhysics(), children: () { /// to-do refactor, separate connection state and UI state for remote session. /// [workaround] PageView children need an immutable list, after it has been passed into PageView final tabLen = state.value.tabs.length; if (tabLen == _tabWidgets.length) { return _tabWidgets; } else if (_tabWidgets.isNotEmpty && tabLen == _tabWidgets.length + 1) { /// On add. Use the previous list(pointer) to prevent item's state init twice. /// *[_tabWidgets.isNotEmpty] means TabsWindow(remote_tab_page or file_manager_tab_page) opened before, but was hidden. In this case, we have to reload, otherwise the child can't be built. _tabWidgets.add(state.value.tabs.last.page); return _tabWidgets; } else { /// On remove or change. Use new list(pointer) to reload list children so that items loading order is normal. /// the Widgets in list must enable [AutomaticKeepAliveClientMixin] final newList = state.value.tabs.map((v) => v.page).toList(); _tabWidgets = newList; return newList; } }()))); if (tabType == DesktopTabType.remoteScreen) { return Container(color: kColorCanvas, child: child); } else { return child; } } /// Check whether to show ListView /// /// Conditions: /// - hide single item when only has one item (home) on [DesktopTabPage]. bool isHideSingleItem() { return state.value.tabs.length == 1 && (controller.tabType == DesktopTabType.main || controller.tabType == DesktopTabType.install); } Widget _buildBar() { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( child: GestureDetector( // custom double tap handler onTap: !(bind.isIncomingOnly() && isInHomePage()) && showMaximize ? () { final current = DateTime.now().millisecondsSinceEpoch; final elapsed = current - _lastClickTime; _lastClickTime = current; if (elapsed < bind.getDoubleClickTime()) { // onDoubleTap toggleMaximize(isMainWindow) .then((value) => stateGlobal.setMaximized(value)); } } : null, onPanStart: (_) => startDragging(isMainWindow), onPanCancel: () { // We want to disable dragging of the tab area in the tab bar. // Disable dragging is needed because macOS handles dragging by default. if (isMacOS) { setMovable(isMainWindow, false); } }, onPanEnd: (_) { if (isMacOS) { setMovable(isMainWindow, false); } }, child: Row( children: [ Offstage( offstage: !isMacOS, child: const SizedBox( width: 78, )), Offstage( offstage: kUseCompatibleUiMode || isMacOS, child: Row(children: [ Offstage( offstage: !showLogo, child: loadIcon(16), ), Offstage( offstage: !showTitle, child: const Text( "RustDesk", style: TextStyle(fontSize: 13), ).marginOnly(left: 2)) ]).marginOnly( left: 5, right: 10, ), ), Expanded( child: Listener( // handle mouse wheel onPointerSignal: (e) { if (e is PointerScrollEvent) { final sc = controller.state.value.scrollController; if (!sc.canScroll) return; _scrollDebounce.call(() { sc.animateTo(sc.offset + e.scrollDelta.dy, duration: Duration(milliseconds: 200), curve: Curves.ease); }); } }, child: _ListView( controller: controller, invisibleTabKeys: invisibleTabKeys, tabBuilder: tabBuilder, tabMenuBuilder: tabMenuBuilder, labelGetter: labelGetter, maxLabelWidth: maxLabelWidth, selectedTabBackgroundColor: selectedTabBackgroundColor, unSelectedTabBackgroundColor: unSelectedTabBackgroundColor, selectedBorderColor: selectedBorderColor, ))), ], ))), // hide simulated action buttons when we in compatible ui mode, because of reusing system title bar. WindowActionPanel( isMainWindow: isMainWindow, tabType: tabType, state: state, tabController: controller, invisibleTabKeys: invisibleTabKeys, tail: tail, showMinimize: showMinimize, showMaximize: showMaximize, showClose: showClose, onClose: onWindowCloseButton, labelGetter: labelGetter, ).paddingOnly(left: 10) ], ); } } class WindowActionPanel extends StatefulWidget { final bool isMainWindow; final DesktopTabType tabType; final Rx state; final DesktopTabController tabController; final bool showMinimize; final bool showMaximize; final bool showClose; final Widget? tail; final Future Function()? onClose; final RxList invisibleTabKeys; final LabelGetter? labelGetter; const WindowActionPanel( {Key? key, required this.isMainWindow, required this.tabType, required this.state, required this.tabController, required this.invisibleTabKeys, this.tail, this.showMinimize = true, this.showMaximize = true, this.showClose = true, this.onClose, this.labelGetter}) : super(key: key); @override State createState() { return WindowActionPanelState(); } } class WindowActionPanelState extends State with MultiWindowListener, WindowListener { final _saveFrameDebounce = Debouncer(delay: Duration(seconds: 1)); Timer? _macOSCheckRestoreTimer; int _macOSCheckRestoreCounter = 0; @override void initState() { super.initState(); DesktopMultiWindow.addListener(this); windowManager.addListener(this); Future.delayed(Duration(milliseconds: 500), () { if (widget.isMainWindow) { windowManager.isMaximized().then((maximized) { if (stateGlobal.isMaximized.value != maximized) { WidgetsBinding.instance.addPostFrameCallback( (_) => setState(() => stateGlobal.setMaximized(maximized))); } }); } else { final wc = WindowController.fromWindowId(kWindowId!); wc.isMaximized().then((maximized) { debugPrint("isMaximized $maximized"); if (stateGlobal.isMaximized.value != maximized) { WidgetsBinding.instance.addPostFrameCallback( (_) => setState(() => stateGlobal.setMaximized(maximized))); } }); } }); } @override void dispose() { DesktopMultiWindow.removeListener(this); windowManager.removeListener(this); _macOSCheckRestoreTimer?.cancel(); super.dispose(); } void _setMaximized(bool maximize) { stateGlobal.setMaximized(maximize); _saveFrameDebounce.call(_saveFrame); setState(() {}); } @override void onWindowFocus() { stateGlobal.isFocused.value = true; } @override void onWindowBlur() { stateGlobal.isFocused.value = false; } @override void onWindowMinimize() { stateGlobal.setMinimized(true); stateGlobal.setMaximized(false); super.onWindowMinimize(); } @override void onWindowMaximize() { stateGlobal.setMinimized(false); _setMaximized(true); super.onWindowMaximize(); } @override void onWindowUnmaximize() { stateGlobal.setMinimized(false); _setMaximized(false); super.onWindowUnmaximize(); } _saveFrame() async { if (widget.tabType == DesktopTabType.main) { await saveWindowPosition(WindowType.Main); } else if (kWindowType != null && kWindowId != null) { await saveWindowPosition(kWindowType!, windowId: kWindowId); } } @override void onWindowMoved() { _saveFrameDebounce.call(_saveFrame); super.onWindowMoved(); } @override void onWindowResized() { _saveFrameDebounce.call(_saveFrame); super.onWindowMoved(); } @override void onWindowClose() async { mainWindowClose() async => await windowManager.hide(); notMainWindowClose(WindowController controller) async { if (widget.tabController.length != 0) { debugPrint("close not empty multiwindow from taskbar"); if (isWindows) { await controller.show(); await controller.focus(); final res = await widget.onClose?.call() ?? true; if (!res) return; } widget.tabController.clear(); } await controller.hide(); await rustDeskWinManager .call(WindowType.Main, kWindowEventHide, {"id": kWindowId!}); } macOSWindowClose( Future Function() checkFullscreen, Future Function() closeFunc, ) async { _macOSCheckRestoreCounter = 0; _macOSCheckRestoreTimer = Timer.periodic(Duration(milliseconds: 30), (timer) async { _macOSCheckRestoreCounter++; if (!await checkFullscreen() || _macOSCheckRestoreCounter >= 30) { _macOSCheckRestoreTimer?.cancel(); _macOSCheckRestoreTimer = null; Timer(Duration(milliseconds: 700), () async => await closeFunc()); } }); } // hide window on close if (widget.isMainWindow) { if (rustDeskWinManager.getActiveWindows().contains(kMainWindowId)) { await rustDeskWinManager.unregisterActiveWindow(kMainWindowId); } // macOS specific workaround, the window is not hiding when in fullscreen. if (isMacOS && await windowManager.isFullScreen()) { await windowManager.setFullScreen(false); await macOSWindowClose( () async => await windowManager.isFullScreen(), mainWindowClose, ); } else { await mainWindowClose(); } } else { // it's safe to hide the subwindow final controller = WindowController.fromWindowId(kWindowId!); if (isMacOS) { // onWindowClose() maybe called multiple times because of loopCloseWindow() in remote_tab_page.dart. // use ??= to make sure the value is set on first call. if (await widget.onClose?.call() ?? true) { if (await controller.isFullScreen()) { await controller.setFullscreen(false); stateGlobal.setFullscreen(false, procWnd: false); await macOSWindowClose( () async => await controller.isFullScreen(), () async => await notMainWindowClose(controller), ); } else { await notMainWindowClose(controller); } } } else { await notMainWindowClose(controller); } } super.onWindowClose(); } bool showTabDowndown() { return widget.tabController.state.value.tabs.length > 1 && (widget.tabController.tabType == DesktopTabType.remoteScreen || widget.tabController.tabType == DesktopTabType.fileTransfer || widget.tabController.tabType == DesktopTabType.portForward || widget.tabController.tabType == DesktopTabType.cm); } List existingInvisibleTab() { return widget.invisibleTabKeys .where((key) => widget.tabController.state.value.tabs.any((tab) => tab.key == key)) .toList(); } @override Widget build(BuildContext context) { return Row( mainAxisAlignment: MainAxisAlignment.end, children: [ Obx(() { if (showTabDowndown() && existingInvisibleTab().isNotEmpty) { return _TabDropDownButton( controller: widget.tabController, labelGetter: widget.labelGetter, tabkeys: existingInvisibleTab()); } else { return Offstage(); } }), if (widget.tail != null) widget.tail!, if (!kUseCompatibleUiMode) Row( children: [ if (widget.showMinimize && !isMacOS) ActionIcon( message: 'Minimize', icon: IconFont.min, onTap: () { if (widget.isMainWindow) { windowManager.minimize(); } else { WindowController.fromWindowId(kWindowId!).minimize(); } }, isClose: false, ), if (widget.showMaximize && !isMacOS) Obx(() => ActionIcon( message: stateGlobal.isMaximized.isTrue ? 'Restore' : 'Maximize', icon: stateGlobal.isMaximized.isTrue ? IconFont.restore : IconFont.max, onTap: bind.isIncomingOnly() && isInHomePage() ? null : _toggleMaximize, isClose: false, )), if (widget.showClose && !isMacOS) ActionIcon( message: 'Close', icon: IconFont.close, onTap: () async { final res = await widget.onClose?.call() ?? true; if (res) { // hide for all window // note: the main window can be restored by tray icon Future.delayed(Duration.zero, () async { if (widget.isMainWindow) { await windowManager.close(); } else { await WindowController.fromWindowId(kWindowId!) .close(); } }); } }, isClose: true, ) ], ), ], ); } void _toggleMaximize() { toggleMaximize(widget.isMainWindow).then((maximize) { // update state for sub window, wc.unmaximize/maximize() will not invoke onWindowMaximize/Unmaximize stateGlobal.setMaximized(maximize); }); } } void startDragging(bool isMainWindow) { if (isMainWindow) { windowManager.startDragging(); } else { WindowController.fromWindowId(kWindowId!).startDragging(); } } void setMovable(bool isMainWindow, bool movable) { if (isMainWindow) { windowManager.setMovable(movable); } else { WindowController.fromWindowId(kWindowId!).setMovable(movable); } } /// return true -> window will be maximize /// return false -> window will be unmaximize Future toggleMaximize(bool isMainWindow) async { if (isMainWindow) { if (await windowManager.isMaximized()) { windowManager.unmaximize(); return false; } else { windowManager.maximize(); return true; } } else { final wc = WindowController.fromWindowId(kWindowId!); if (await wc.isMaximized()) { wc.unmaximize(); return false; } else { wc.maximize(); return true; } } } Future closeConfirmDialog() async { var confirm = true; final res = await gFFI.dialogManager.show((setState, close, context) { submit() { String value = bool2option(kOptionEnableConfirmClosingTabs, confirm); bind.mainSetLocalOption( key: kOptionEnableConfirmClosingTabs, value: value); close(true); } return CustomAlertDialog( title: Row(children: [ const Icon(Icons.warning_amber_sharp, color: Colors.redAccent, size: 28), const SizedBox(width: 10), Text(translate("Warning")), ]), content: Column( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(translate("Disconnect all devices?")), CheckboxListTile( contentPadding: const EdgeInsets.all(0), dense: true, controlAffinity: ListTileControlAffinity.leading, title: Text( translate("Confirm before closing multiple tabs"), ), value: confirm, onChanged: (v) { if (v == null) return; setState(() => confirm = v); }, ) ]), // confirm checkbox actions: [ dialogButton("Cancel", onPressed: close, isOutline: true), dialogButton("OK", onPressed: submit), ], onSubmit: submit, onCancel: close, ); }); return res == true; } class _ListView extends StatelessWidget { final DesktopTabController controller; final RxList invisibleTabKeys; final TabBuilder? tabBuilder; final TabMenuBuilder? tabMenuBuilder; final LabelGetter? labelGetter; final double? maxLabelWidth; final Color? selectedTabBackgroundColor; final Color? selectedBorderColor; final Color? unSelectedTabBackgroundColor; Rx get state => controller.state; _ListView({ required this.controller, required this.invisibleTabKeys, this.tabBuilder, this.tabMenuBuilder, this.labelGetter, this.maxLabelWidth, this.selectedTabBackgroundColor, this.unSelectedTabBackgroundColor, this.selectedBorderColor, }); /// Check whether to show ListView /// /// Conditions: /// - hide single item when only has one item (home) on [DesktopTabPage]. bool isHideSingleItem() { return state.value.tabs.length == 1 && controller.tabType == DesktopTabType.main || controller.tabType == DesktopTabType.install; } onVisibilityChanged(VisibilityInfo info) { final key = (info.key as ValueKey).value; if (info.visibleFraction < 0.75) { if (!invisibleTabKeys.contains(key)) { invisibleTabKeys.add(key); } invisibleTabKeys.removeWhere((key) => controller.state.value.tabs.where((e) => e.key == key).isEmpty); } else { invisibleTabKeys.remove(key); } } @override Widget build(BuildContext context) { return Obx(() => ListView( controller: state.value.scrollController, scrollDirection: Axis.horizontal, shrinkWrap: true, physics: const BouncingScrollPhysics(), children: isHideSingleItem() ? List.empty() : state.value.tabs.asMap().entries.map((e) { final index = e.key; final tab = e.value; final label = labelGetter == null ? Rx(tab.label) : labelGetter!(tab.label); final child = VisibilityDetector( key: ValueKey(tab.key), onVisibilityChanged: onVisibilityChanged, child: _Tab( key: ValueKey(tab.key), index: index, tabInfoKey: tab.key, label: label, tabType: controller.tabType, selectedIcon: tab.selectedIcon, unselectedIcon: tab.unselectedIcon, closable: tab.closable, selected: state.value.selected, onClose: () { if (tab.onTabCloseButton != null) { tab.onTabCloseButton!(); } else { controller.remove(index); } }, onTap: () { controller.jumpTo(index); tab.onTap?.call(); }, tabBuilder: tabBuilder, tabMenuBuilder: tabMenuBuilder, maxLabelWidth: maxLabelWidth, selectedTabBackgroundColor: selectedTabBackgroundColor ?? MyTheme.tabbar(context).selectedTabBackgroundColor, unSelectedTabBackgroundColor: unSelectedTabBackgroundColor, selectedBorderColor: selectedBorderColor, ), ); return GestureDetector( onPanStart: (e) {}, child: child, ); }).toList())); } } class _Tab extends StatefulWidget { final int index; final String tabInfoKey; final Rx label; final DesktopTabType tabType; final IconData? selectedIcon; final IconData? unselectedIcon; final bool closable; final int selected; final Function() onClose; final Function() onTap; final TabBuilder? tabBuilder; final TabMenuBuilder? tabMenuBuilder; final double? maxLabelWidth; final Color? selectedTabBackgroundColor; final Color? unSelectedTabBackgroundColor; final Color? selectedBorderColor; const _Tab({ Key? key, required this.index, required this.tabInfoKey, required this.label, required this.tabType, this.selectedIcon, this.unselectedIcon, this.tabBuilder, this.tabMenuBuilder, required this.closable, required this.selected, required this.onClose, required this.onTap, this.maxLabelWidth, this.selectedTabBackgroundColor, this.unSelectedTabBackgroundColor, this.selectedBorderColor, }) : super(key: key); @override State<_Tab> createState() => _TabState(); } class _TabState extends State<_Tab> with RestorationMixin { final RestorableBool restoreHover = RestorableBool(false); Widget _buildTabContent() { bool showIcon = widget.selectedIcon != null && widget.unselectedIcon != null; bool isSelected = widget.index == widget.selected; final icon = Offstage( offstage: !showIcon, child: Icon( isSelected ? widget.selectedIcon : widget.unselectedIcon, size: _kIconSize, color: isSelected ? MyTheme.tabbar(context).selectedTabIconColor : MyTheme.tabbar(context).unSelectedTabIconColor, ).paddingOnly(right: 5)); final labelWidget = Obx(() { return ConstrainedBox( constraints: BoxConstraints(maxWidth: widget.maxLabelWidth ?? 200), child: Tooltip( message: widget.tabType == DesktopTabType.main ? '' : translate(widget.label.value), child: Text( translate(widget.label.value), textAlign: TextAlign.center, style: TextStyle( color: isSelected ? MyTheme.tabbar(context).selectedTextColor : MyTheme.tabbar(context).unSelectedTextColor), overflow: TextOverflow.ellipsis, ), )); }); Widget getWidgetWithBuilder() { if (widget.tabBuilder == null) { return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ icon, labelWidget, ], ); } else { return widget.tabBuilder!( widget.tabInfoKey, icon, labelWidget, TabThemeConf(iconSize: _kIconSize), ); } } return Listener( onPointerDown: (e) { if (e.kind != ui.PointerDeviceKind.mouse) { return; } if (e.buttons == 2) { if (widget.tabMenuBuilder != null) { showRightMenu( (cacel) { return widget.tabMenuBuilder!(widget.tabInfoKey); }, target: e.position, ); } } }, child: getWidgetWithBuilder(), ); } @override Widget build(BuildContext context) { bool isSelected = widget.index == widget.selected; bool showDivider = widget.index != widget.selected - 1 && widget.index != widget.selected; RxBool hover = restoreHover.value.obs; return Ink( child: InkWell( onHover: (value) { hover.value = value; restoreHover.value = value; }, onTap: () => widget.onTap(), child: Container( decoration: isSelected && widget.selectedBorderColor != null ? BoxDecoration( border: Border( bottom: BorderSide( color: widget.selectedBorderColor!, width: 1, ), ), ) : null, child: Container( color: isSelected ? widget.selectedTabBackgroundColor : widget.unSelectedTabBackgroundColor, child: Row( children: [ SizedBox( height: _kTabBarHeight, child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ _buildTabContent(), Obx((() => _CloseButton( visible: hover.value && widget.closable, tabSelected: isSelected, onClose: () => widget.onClose(), ))) ])).paddingOnly(left: 10, right: 5), Offstage( offstage: !showDivider, child: VerticalDivider( width: 1, indent: _kDividerIndent, endIndent: _kDividerIndent, color: MyTheme.tabbar(context).dividerColor, ), ) ], ), )), ), ); } @override String? get restorationId => "_Tab${widget.label.value}"; @override void restoreState(RestorationBucket? oldBucket, bool initialRestore) { registerForRestoration(restoreHover, 'restoreHover'); } } class _CloseButton extends StatelessWidget { final bool visible; final bool tabSelected; final Function onClose; const _CloseButton({ Key? key, required this.visible, required this.tabSelected, required this.onClose, }) : super(key: key); @override Widget build(BuildContext context) { return SizedBox( width: _kIconSize, child: () { if (visible) { return InkWell( hoverColor: MyTheme.tabbar(context).closeHoverColor, customBorder: const CircleBorder(), onTap: () => onClose(), child: Icon( Icons.close, size: _kIconSize, color: tabSelected ? MyTheme.tabbar(context).selectedIconColor : MyTheme.tabbar(context).unSelectedIconColor, ), ); } else { return Offstage(); } }()) .paddingOnly(left: 10); } } class ActionIcon extends StatefulWidget { final String? message; final IconData icon; final GestureTapCallback? onTap; final GestureTapDownCallback? onTapDown; final bool isClose; final double iconSize; final double boxSize; const ActionIcon( {Key? key, this.message, required this.icon, this.onTap, this.onTapDown, this.isClose = false, this.iconSize = _kActionIconSize, this.boxSize = _kTabBarHeight - 1}) : super(key: key); @override State createState() => _ActionIconState(); } class _ActionIconState extends State { var hover = false.obs; @override void initState() { super.initState(); hover.value = false; } @override Widget build(BuildContext context) { return Tooltip( message: widget.message != null ? translate(widget.message!) : "", waitDuration: const Duration(seconds: 1), child: InkWell( hoverColor: widget.isClose ? const Color.fromARGB(255, 196, 43, 28) : MyTheme.tabbar(context).hoverColor, onHover: (value) => hover.value = value, onTap: widget.onTap, onTapDown: widget.onTapDown, child: SizedBox( height: widget.boxSize, width: widget.boxSize, child: widget.onTap == null ? Icon( widget.icon, color: Colors.grey, size: widget.iconSize, ) : Obx( () => Icon( widget.icon, color: hover.value && widget.isClose ? Colors.white : MyTheme.tabbar(context).unSelectedIconColor, size: widget.iconSize, ), ), ), ), ); } } class AddButton extends StatelessWidget { const AddButton({ Key? key, }) : super(key: key); @override Widget build(BuildContext context) { return ActionIcon( message: 'New Connection', icon: IconFont.add, onTap: () => rustDeskWinManager.call( WindowType.Main, kWindowMainWindowOnTop, ""), isClose: false); } } class _TabDropDownButton extends StatefulWidget { final DesktopTabController controller; final List tabkeys; final LabelGetter? labelGetter; const _TabDropDownButton( {required this.controller, required this.tabkeys, this.labelGetter}); @override State<_TabDropDownButton> createState() => _TabDropDownButtonState(); } class _TabDropDownButtonState extends State<_TabDropDownButton> { var position = RelativeRect.fromLTRB(0, 0, 0, 0); @override Widget build(BuildContext context) { List sortedKeys = widget.controller.state.value.tabs .where((e) => widget.tabkeys.contains(e.key)) .map((e) => e.key) .toList(); return ActionIcon( onTapDown: (details) { final x = details.globalPosition.dx; final y = details.globalPosition.dy; position = RelativeRect.fromLTRB(x, y, x, y); }, icon: Icons.arrow_drop_down, onTap: () { showMenu( context: context, position: position, items: sortedKeys.map((e) { var label = e; final tabInfo = widget.controller.state.value.tabs .firstWhereOrNull((element) => element.key == e); if (tabInfo != null) { label = tabInfo.label; } if (widget.labelGetter != null) { label = widget.labelGetter!(e).value; } var index = widget.controller.state.value.tabs .indexWhere((t) => t.key == e); label = '${index + 1}. $label'; final menuHover = false.obs; final btnHover = false.obs; return PopupMenuItem( value: e, height: 32, onTap: () { widget.controller.jumpToByKey(e); if (Navigator.of(context).canPop()) { Navigator.of(context).pop(); } }, child: MouseRegion( onHover: (event) => setState(() => menuHover.value = true), onExit: (event) => setState(() => menuHover.value = false), child: Row( children: [ Expanded( child: InkWell(child: Text(label)), ), Obx( () { if (tabInfo?.onTabCloseButton != null && menuHover.value) { return InkWell( onTap: () { tabInfo?.onTabCloseButton?.call(); if (Navigator.of(context).canPop()) { Navigator.of(context).pop(); } }, child: MouseRegion( cursor: SystemMouseCursors.click, onHover: (event) => setState(() => btnHover.value = true), onExit: (event) => setState(() => btnHover.value = false), child: Icon(Icons.close, color: btnHover.value ? Colors.red : null))); } else { return Offstage(); } }, ), ], ), ), ); }).toList(), ); }, ); } } class TabbarTheme extends ThemeExtension { final Color? selectedTabIconColor; final Color? unSelectedTabIconColor; final Color? selectedTextColor; final Color? unSelectedTextColor; final Color? selectedIconColor; final Color? unSelectedIconColor; final Color? dividerColor; final Color? hoverColor; final Color? closeHoverColor; final Color? selectedTabBackgroundColor; const TabbarTheme( {required this.selectedTabIconColor, required this.unSelectedTabIconColor, required this.selectedTextColor, required this.unSelectedTextColor, required this.selectedIconColor, required this.unSelectedIconColor, required this.dividerColor, required this.hoverColor, required this.closeHoverColor, required this.selectedTabBackgroundColor}); static const light = TabbarTheme( selectedTabIconColor: MyTheme.accent, unSelectedTabIconColor: Color.fromARGB(255, 162, 203, 241), selectedTextColor: Colors.black, unSelectedTextColor: Color.fromARGB(255, 112, 112, 112), selectedIconColor: Color.fromARGB(255, 26, 26, 26), unSelectedIconColor: Color.fromARGB(255, 96, 96, 96), dividerColor: Color.fromARGB(255, 238, 238, 238), hoverColor: Colors.white54, closeHoverColor: Colors.white, selectedTabBackgroundColor: Colors.white54); static const dark = TabbarTheme( selectedTabIconColor: MyTheme.accent, unSelectedTabIconColor: Color.fromARGB(255, 30, 65, 98), selectedTextColor: Colors.white, unSelectedTextColor: Color.fromARGB(255, 192, 192, 192), selectedIconColor: Color.fromARGB(255, 192, 192, 192), unSelectedIconColor: Color.fromARGB(255, 255, 255, 255), dividerColor: Color.fromARGB(255, 64, 64, 64), hoverColor: Colors.black26, closeHoverColor: Colors.black, selectedTabBackgroundColor: Colors.black26); @override ThemeExtension copyWith({ Color? selectedTabIconColor, Color? unSelectedTabIconColor, Color? selectedTextColor, Color? unSelectedTextColor, Color? selectedIconColor, Color? unSelectedIconColor, Color? dividerColor, Color? hoverColor, Color? closeHoverColor, Color? selectedTabBackgroundColor, }) { return TabbarTheme( selectedTabIconColor: selectedTabIconColor ?? this.selectedTabIconColor, unSelectedTabIconColor: unSelectedTabIconColor ?? this.unSelectedTabIconColor, selectedTextColor: selectedTextColor ?? this.selectedTextColor, unSelectedTextColor: unSelectedTextColor ?? this.unSelectedTextColor, selectedIconColor: selectedIconColor ?? this.selectedIconColor, unSelectedIconColor: unSelectedIconColor ?? this.unSelectedIconColor, dividerColor: dividerColor ?? this.dividerColor, hoverColor: hoverColor ?? this.hoverColor, closeHoverColor: closeHoverColor ?? this.closeHoverColor, selectedTabBackgroundColor: selectedTabBackgroundColor ?? this.selectedTabBackgroundColor, ); } @override ThemeExtension lerp( ThemeExtension? other, double t) { if (other is! TabbarTheme) { return this; } return TabbarTheme( selectedTabIconColor: Color.lerp(selectedTabIconColor, other.selectedTabIconColor, t), unSelectedTabIconColor: Color.lerp(unSelectedTabIconColor, other.unSelectedTabIconColor, t), selectedTextColor: Color.lerp(selectedTextColor, other.selectedTextColor, t), unSelectedTextColor: Color.lerp(unSelectedTextColor, other.unSelectedTextColor, t), selectedIconColor: Color.lerp(selectedIconColor, other.selectedIconColor, t), unSelectedIconColor: Color.lerp(unSelectedIconColor, other.unSelectedIconColor, t), dividerColor: Color.lerp(dividerColor, other.dividerColor, t), hoverColor: Color.lerp(hoverColor, other.hoverColor, t), closeHoverColor: Color.lerp(closeHoverColor, other.closeHoverColor, t), selectedTabBackgroundColor: Color.lerp( selectedTabBackgroundColor, other.selectedTabBackgroundColor, t), ); } static color(BuildContext context) { return Theme.of(context).extension()!; } }