import 'dart:io'; import 'dart:async'; import 'dart:math'; import 'package:desktop_multi_window/desktop_multi_window.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hbb/common.dart'; import 'package:flutter_hbb/consts.dart'; import 'package:flutter_hbb/main.dart'; import 'package:flutter_hbb/models/platform_model.dart'; import 'package:get/get.dart'; import 'package:scroll_pos/scroll_pos.dart'; import 'package:window_manager/window_manager.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; final String label; final IconData? selectedIcon; final IconData? unselectedIcon; final bool closable; final Widget page; TabInfo( {required this.key, required this.label, this.selectedIcon, this.unselectedIcon, this.closable = true, required this.page}); } enum DesktopTabType { main, cm, remoteScreen, fileTransfer, portForward, rdp, } class DesktopTabState { final List tabs = []; final ScrollPosController scrollController = ScrollPosController(itemCount: 0); final PageController pageController = PageController(); int selected = 0; DesktopTabState() { scrollController.itemCount = tabs.length; } } class DesktopTabController { final state = DesktopTabState().obs; final DesktopTabType tabType; /// index, key Function(int, String)? onRemove; Function(int)? onSelected; DesktopTabController({required this.tabType}); void add(TabInfo tab, {bool authorized = false}) { 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); } if (tabType == DesktopTabType.cm) { Future.delayed(Duration.zero, () async { window_on_top(null); }); if (authorized) { Future.delayed(const Duration(seconds: 3), () { windowManager.minimize(); }); } } try { jumpTo(toIndex); } 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); onRemove?.call(index, key); } void jumpTo(int index) { if (!isDesktop || index < 0) return; state.update((val) { val!.selected = index; Future.delayed(Duration.zero, (() { if (val.pageController.hasClients) { val.pageController.jumpToPage(index); } if (val.scrollController.hasClients && val.scrollController.canScroll && val.scrollController.itemCount > index) { val.scrollController.scrollToItem(index, center: true, animate: true); } })); }); if (state.value.tabs.length > index) { onSelected?.call(index); } } void closeBy(String? key) { if (!isDesktop) return; assert(onRemove != null); if (key == null) { if (state.value.selected < state.value.tabs.length) { remove(state.value.selected); } } else { state.value.tabs.indexWhere((tab) => tab.key == key); remove(state.value.selected); } } void clear() { state.value.tabs.clear(); state.refresh(); } } class TabThemeConf { double iconSize; TarBarTheme theme; TabThemeConf({required this.iconSize, required this.theme}); } typedef TabBuilder = Widget Function( String key, Widget icon, Widget label, TabThemeConf themeConf); typedef LabelGetter = Rx Function(String key); class DesktopTab extends StatelessWidget { final Function(String)? onTabClose; final TarBarTheme theme; final bool showTabBar; final bool showLogo; final bool showTitle; final bool showMinimize; final bool showMaximize; final bool showClose; final Widget Function(Widget pageView)? pageViewBuilder; final Widget? tail; final VoidCallback? onClose; final TabBuilder? tabBuilder; final LabelGetter? labelGetter; final DesktopTabController controller; Rx get state => controller.state; late final DesktopTabType tabType; late final bool isMainWindow; DesktopTab({ Key? key, required this.controller, this.theme = const TarBarTheme.light(), this.onTabClose, this.showTabBar = true, this.showLogo = true, this.showTitle = true, this.showMinimize = true, this.showMaximize = true, this.showClose = true, this.pageViewBuilder, this.tail, this.onClose, this.tabBuilder, this.labelGetter, }) : super(key: key) { tabType = controller.tabType; isMainWindow = tabType == DesktopTabType.main || tabType == DesktopTabType.cm; } @override Widget build(BuildContext context) { return Column(children: [ Offstage( offstage: !showTabBar, child: Container( height: _kTabBarHeight, child: Column( children: [ Container( height: _kTabBarHeight - 1, child: _buildBar(), ), Divider( height: 1, thickness: 1, ), ], ), )), Expanded( child: pageViewBuilder != null ? pageViewBuilder!(_buildPageView()) : _buildPageView()) ]); } Widget _buildBlock({required Widget child}) { if (tabType != DesktopTabType.main) { return child; } var block = false.obs; return Obx(() => MouseRegion( onEnter: (_) async { if (!option2bool( 'allow-remote-config-modification', await bind.mainGetOption( key: 'allow-remote-config-modification'))) { var time0 = DateTime.now().millisecondsSinceEpoch; await bind.mainCheckMouseTime(); Timer(const Duration(milliseconds: 120), () async { var d = time0 - await bind.mainGetMouseTime(); if (d < 120) { block.value = true; } }); } }, onExit: (_) => block.value = false, child: Stack( children: [ child, Offstage( offstage: !block.value, child: Container( color: Colors.black.withOpacity(0.5), )), ], ), )); } Widget _buildPageView() { return _buildBlock( child: Obx(() => PageView( controller: state.value.pageController, children: state.value.tabs .map((tab) => tab.page) .toList(growable: false)))); } Widget _buildBar() { return Row( children: [ Expanded( child: Row( children: [ Offstage( offstage: !Platform.isMacOS, child: const SizedBox( width: 78, )), Row(children: [ Offstage( offstage: !showLogo, child: Image.asset( 'assets/logo.ico', width: 20, height: 20, )), Offstage( offstage: !showTitle, child: Text( "RustDesk", style: TextStyle(fontSize: 13), ).marginOnly(left: 2)) ]).marginOnly( left: 5, right: 10, ), Expanded( child: GestureDetector( onPanStart: (_) { if (isMainWindow) { windowManager.startDragging(); } else { WindowController.fromWindowId(windowId!) .startDragging(); } }, child: _ListView( controller: controller, onTabClose: onTabClose, theme: theme, tabBuilder: tabBuilder, labelGetter: labelGetter, )), ), ], ), ), Offstage(offstage: tail == null, child: tail), WindowActionPanel( mainTab: isMainWindow, tabType: tabType, state: state, theme: theme, showMinimize: showMinimize, showMaximize: showMaximize, showClose: showClose, onClose: onClose, ) ], ); } } class WindowActionPanel extends StatelessWidget { final bool mainTab; final DesktopTabType tabType; final Rx state; final TarBarTheme theme; final bool showMinimize; final bool showMaximize; final bool showClose; final VoidCallback? onClose; const WindowActionPanel( {Key? key, required this.mainTab, required this.tabType, required this.state, required this.theme, this.showMinimize = true, this.showMaximize = true, this.showClose = true, this.onClose}) : super(key: key); @override Widget build(BuildContext context) { return Row( children: [ Offstage( offstage: !showMinimize, child: ActionIcon( message: 'Minimize', icon: IconFont.min, theme: theme, onTap: () { if (mainTab) { windowManager.minimize(); } else { WindowController.fromWindowId(windowId!).minimize(); } }, is_close: false, )), // TODO: drag makes window restore Offstage( offstage: !showMaximize, child: FutureBuilder(builder: (context, snapshot) { RxBool is_maximized = false.obs; if (mainTab) { windowManager.isMaximized().then((maximized) { is_maximized.value = maximized; }); } else { final wc = WindowController.fromWindowId(windowId!); wc.isMaximized().then((maximized) { is_maximized.value = maximized; }); } return Obx( () => ActionIcon( message: is_maximized.value ? "Restore" : "Maximize", icon: is_maximized.value ? IconFont.restore : IconFont.max, theme: theme, onTap: () { if (mainTab) { if (is_maximized.value) { windowManager.unmaximize(); } else { windowManager.maximize(); } } else { // TODO: subwindow is maximized but first query result is not maximized. final wc = WindowController.fromWindowId(windowId!); if (is_maximized.value) { wc.unmaximize(); } else { wc.maximize(); } } is_maximized.value = !is_maximized.value; }, is_close: false, ), ); })), Offstage( offstage: !showClose, child: ActionIcon( message: 'Close', icon: IconFont.close, theme: theme, onTap: () async { action() { if (mainTab) { windowManager.close(); } else { // only hide for multi window, not close Future.delayed(Duration.zero, () { WindowController.fromWindowId(windowId!).hide(); }); } onClose?.call(); } if (tabType != DesktopTabType.main && state.value.tabs.length > 1) { closeConfirmDialog(action); } else { action(); } }, is_close: true, )), ], ); } closeConfirmDialog(Function() callback) async { final res = await gFFI.dialogManager.show((setState, close) { submit() => 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: Text(translate("Disconnect all devices?")), actions: [ TextButton(onPressed: close, child: Text(translate("Cancel"))), ElevatedButton(onPressed: submit, child: Text(translate("OK"))), ], onSubmit: submit, onCancel: close, ); }); if (res == true) { callback(); } } } // ignore: must_be_immutable class _ListView extends StatelessWidget { final DesktopTabController controller; final Function(String key)? onTabClose; final TarBarTheme theme; final TabBuilder? tabBuilder; final LabelGetter? labelGetter; Rx get state => controller.state; _ListView( {required this.controller, required this.onTabClose, required this.theme, this.tabBuilder, this.labelGetter}); @override Widget build(BuildContext context) { return Obx(() => ListView( controller: state.value.scrollController, scrollDirection: Axis.horizontal, shrinkWrap: true, physics: BouncingScrollPhysics(), children: state.value.tabs.asMap().entries.map((e) { final index = e.key; final tab = e.value; return _Tab( index: index, label: labelGetter == null ? Rx(tab.label) : labelGetter!(tab.label), selectedIcon: tab.selectedIcon, unselectedIcon: tab.unselectedIcon, closable: tab.closable, selected: state.value.selected, onClose: () => controller.remove(index), onSelected: () => controller.jumpTo(index), theme: theme, tabBuilder: tabBuilder == null ? null : (Widget icon, Widget labelWidget, TabThemeConf themeConf) { return tabBuilder!( tab.label, icon, labelWidget, themeConf, ); }, ); }).toList())); } } class _Tab extends StatefulWidget { late final int index; late final Rx label; late final IconData? selectedIcon; late final IconData? unselectedIcon; late final bool closable; late final int selected; late final Function() onClose; late final Function() onSelected; late final TarBarTheme theme; final Widget Function(Widget icon, Widget label, TabThemeConf themeConf)? tabBuilder; _Tab( {Key? key, required this.index, required this.label, this.selectedIcon, this.unselectedIcon, this.tabBuilder, required this.closable, required this.selected, required this.onClose, required this.onSelected, required this.theme}) : 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 ? widget.theme.selectedtabIconColor : widget.theme.unSelectedtabIconColor, ).paddingOnly(right: 5)); final labelWidget = Obx(() { return Text( translate(widget.label.value), textAlign: TextAlign.center, style: TextStyle( color: isSelected ? widget.theme.selectedTextColor : widget.theme.unSelectedTextColor), ); }); if (widget.tabBuilder == null) { return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ icon, labelWidget, ], ); } else { return widget.tabBuilder!(icon, labelWidget, TabThemeConf(iconSize: _kIconSize, theme: widget.theme)); } } @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.onSelected(), child: Row( children: [ SizedBox( height: _kTabBarHeight, child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ _buildTabContent(), Obx((() => _CloseButton( visiable: hover.value && widget.closable, tabSelected: isSelected, onClose: () => widget.onClose(), theme: widget.theme, ))) ])).paddingSymmetric(horizontal: 10), Offstage( offstage: !showDivider, child: VerticalDivider( width: 1, indent: _kDividerIndent, endIndent: _kDividerIndent, color: widget.theme.dividerColor, thickness: 1, ), ) ], ), ), ); } @override String? get restorationId => "_Tab${widget.label.value}"; @override void restoreState(RestorationBucket? oldBucket, bool initialRestore) { registerForRestoration(restoreHover, 'restoreHover'); } } class _CloseButton extends StatelessWidget { final bool visiable; final bool tabSelected; final Function onClose; late final TarBarTheme theme; _CloseButton({ Key? key, required this.visiable, required this.tabSelected, required this.onClose, required this.theme, }) : super(key: key); @override Widget build(BuildContext context) { return SizedBox( width: _kIconSize, child: Offstage( offstage: !visiable, child: InkWell( customBorder: const RoundedRectangleBorder(), onTap: () => onClose(), child: Icon( Icons.close, size: _kIconSize, color: tabSelected ? theme.selectedIconColor : theme.unSelectedIconColor, ), ), )).paddingOnly(left: 5); } } class ActionIcon extends StatelessWidget { final String message; final IconData icon; final TarBarTheme theme; final Function() onTap; final bool is_close; const ActionIcon({ Key? key, required this.message, required this.icon, required this.theme, required this.onTap, required this.is_close, }) : super(key: key); @override Widget build(BuildContext context) { RxBool hover = false.obs; return Obx(() => Tooltip( message: translate(message), waitDuration: Duration(seconds: 1), child: InkWell( hoverColor: is_close ? Color.fromARGB(255, 196, 43, 28) : theme.hoverColor, onHover: (value) => hover.value = value, child: Container( height: _kTabBarHeight - 1, width: _kTabBarHeight - 1, child: Icon( icon, color: hover.value && is_close ? Colors.white : theme.unSelectedIconColor, size: _kActionIconSize, ), ), onTap: onTap, ), )); } } class AddButton extends StatelessWidget { late final TarBarTheme theme; AddButton({ Key? key, required this.theme, }) : super(key: key); @override Widget build(BuildContext context) { return ActionIcon( message: 'New Connection', icon: IconFont.add, theme: theme, onTap: () => rustDeskWinManager.call(WindowType.Main, "main_window_on_top", ""), is_close: false); } } class TarBarTheme { final Color unSelectedtabIconColor; final Color selectedtabIconColor; final Color selectedTextColor; final Color unSelectedTextColor; final Color selectedIconColor; final Color unSelectedIconColor; final Color dividerColor; final Color hoverColor; const TarBarTheme.light() : unSelectedtabIconColor = const Color.fromARGB(255, 162, 203, 241), selectedtabIconColor = MyTheme.accent, selectedTextColor = const Color.fromARGB(255, 26, 26, 26), unSelectedTextColor = const Color.fromARGB(255, 96, 96, 96), selectedIconColor = const Color.fromARGB(255, 26, 26, 26), unSelectedIconColor = const Color.fromARGB(255, 96, 96, 96), dividerColor = const Color.fromARGB(255, 238, 238, 238), hoverColor = const Color.fromARGB( 51, 158, 158, 158); // Colors.grey; //0xFF9E9E9E const TarBarTheme.dark() : unSelectedtabIconColor = const Color.fromARGB(255, 30, 65, 98), selectedtabIconColor = MyTheme.accent, selectedTextColor = const Color.fromARGB(255, 255, 255, 255), unSelectedTextColor = const Color.fromARGB(255, 207, 207, 207), selectedIconColor = const Color.fromARGB(255, 215, 215, 215), unSelectedIconColor = const Color.fromARGB(255, 255, 255, 255), dividerColor = const Color.fromARGB(255, 64, 64, 64), hoverColor = Colors.black26; }