mirror of
https://github.com/rustdesk/rustdesk.git
synced 2024-12-15 12:09:08 +08:00
6740587d8a
Signed-off-by: fufesou <shuanglongchen@yeah.net>
2112 lines
62 KiB
Dart
2112 lines
62 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:ui' as ui;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.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_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 '../../mobile/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 MenubarState {
|
|
final kStoreKey = 'remoteMenubarState';
|
|
late RxBool show;
|
|
late RxBool _pin;
|
|
RxString viewStyle = RxString(kRemoteViewStyleOriginal);
|
|
|
|
MenubarState() {
|
|
final s = bind.getLocalFlutterConfig(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 menubar state ${e.toString()}');
|
|
_initSet(false, false);
|
|
}
|
|
}
|
|
|
|
_initSet(bool s, bool p) {
|
|
// Show remubar when connection is established.
|
|
show = RxBool(true);
|
|
_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.setLocalFlutterConfig(
|
|
k: kStoreKey, v: jsonEncode({'pin': _pin.value}));
|
|
}
|
|
|
|
save() async {
|
|
await _savePin();
|
|
}
|
|
}
|
|
|
|
class _MenubarTheme {
|
|
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;
|
|
}
|
|
|
|
typedef DismissFunc = void Function();
|
|
|
|
class RemoteMenuEntry {
|
|
static MenuEntryRadios<String> viewStyle(
|
|
String remoteId,
|
|
FFI ffi,
|
|
EdgeInsets padding, {
|
|
DismissFunc? dismissFunc,
|
|
DismissCallback? dismissCallback,
|
|
RxString? rxViewStyle,
|
|
}) {
|
|
return MenuEntryRadios<String>(
|
|
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(id: remoteId) ?? '';
|
|
if (rxViewStyle != null) {
|
|
rxViewStyle.value = viewStyle;
|
|
}
|
|
return viewStyle;
|
|
},
|
|
optionSetter: (String oldValue, String newValue) async {
|
|
await bind.sessionSetViewStyle(id: remoteId, value: newValue);
|
|
if (rxViewStyle != null) {
|
|
rxViewStyle.value = newValue;
|
|
}
|
|
ffi.canvasModel.updateViewStyle();
|
|
if (dismissFunc != null) {
|
|
dismissFunc();
|
|
}
|
|
},
|
|
padding: padding,
|
|
dismissOnClicked: true,
|
|
dismissCallback: dismissCallback,
|
|
);
|
|
}
|
|
|
|
static MenuEntrySwitch2<String> showRemoteCursor(
|
|
String remoteId,
|
|
EdgeInsets padding, {
|
|
DismissFunc? dismissFunc,
|
|
DismissCallback? dismissCallback,
|
|
}) {
|
|
final state = ShowRemoteCursorState.find(remoteId);
|
|
final optKey = 'show-remote-cursor';
|
|
return MenuEntrySwitch2<String>(
|
|
switchType: SwitchType.scheckbox,
|
|
text: translate('Show remote cursor'),
|
|
getter: () {
|
|
return state;
|
|
},
|
|
setter: (bool v) async {
|
|
await bind.sessionToggleOption(id: remoteId, value: optKey);
|
|
state.value =
|
|
bind.sessionGetToggleOptionSync(id: remoteId, arg: optKey);
|
|
if (dismissFunc != null) {
|
|
dismissFunc();
|
|
}
|
|
},
|
|
padding: padding,
|
|
dismissOnClicked: true,
|
|
dismissCallback: dismissCallback,
|
|
);
|
|
}
|
|
|
|
static MenuEntrySwitch<String> disableClipboard(
|
|
String remoteId,
|
|
EdgeInsets? padding, {
|
|
DismissFunc? dismissFunc,
|
|
DismissCallback? dismissCallback,
|
|
}) {
|
|
return createSwitchMenuEntry(
|
|
remoteId,
|
|
'Disable clipboard',
|
|
'disable-clipboard',
|
|
padding,
|
|
true,
|
|
dismissCallback: dismissCallback,
|
|
);
|
|
}
|
|
|
|
static MenuEntrySwitch<String> createSwitchMenuEntry(
|
|
String remoteId,
|
|
String text,
|
|
String option,
|
|
EdgeInsets? padding,
|
|
bool dismissOnClicked, {
|
|
DismissFunc? dismissFunc,
|
|
DismissCallback? dismissCallback,
|
|
}) {
|
|
return MenuEntrySwitch<String>(
|
|
switchType: SwitchType.scheckbox,
|
|
text: translate(text),
|
|
getter: () async {
|
|
return bind.sessionGetToggleOptionSync(id: remoteId, arg: option);
|
|
},
|
|
setter: (bool v) async {
|
|
await bind.sessionToggleOption(id: remoteId, value: option);
|
|
if (dismissFunc != null) {
|
|
dismissFunc();
|
|
}
|
|
},
|
|
padding: padding,
|
|
dismissOnClicked: dismissOnClicked,
|
|
dismissCallback: dismissCallback,
|
|
);
|
|
}
|
|
|
|
static MenuEntryButton<String> insertLock(
|
|
String remoteId,
|
|
EdgeInsets? padding, {
|
|
DismissFunc? dismissFunc,
|
|
DismissCallback? dismissCallback,
|
|
}) {
|
|
return MenuEntryButton<String>(
|
|
childBuilder: (TextStyle? style) => Text(
|
|
translate('Insert Lock'),
|
|
style: style,
|
|
),
|
|
proc: () {
|
|
bind.sessionLockScreen(id: remoteId);
|
|
if (dismissFunc != null) {
|
|
dismissFunc();
|
|
}
|
|
},
|
|
padding: padding,
|
|
dismissOnClicked: true,
|
|
dismissCallback: dismissCallback,
|
|
);
|
|
}
|
|
|
|
static insertCtrlAltDel(
|
|
String remoteId,
|
|
EdgeInsets? padding, {
|
|
DismissFunc? dismissFunc,
|
|
DismissCallback? dismissCallback,
|
|
}) {
|
|
return MenuEntryButton<String>(
|
|
childBuilder: (TextStyle? style) => Text(
|
|
'${translate("Insert")} Ctrl + Alt + Del',
|
|
style: style,
|
|
),
|
|
proc: () {
|
|
bind.sessionCtrlAltDel(id: remoteId);
|
|
if (dismissFunc != null) {
|
|
dismissFunc();
|
|
}
|
|
},
|
|
padding: padding,
|
|
dismissOnClicked: true,
|
|
dismissCallback: dismissCallback,
|
|
);
|
|
}
|
|
}
|
|
|
|
class RemoteMenubar extends StatefulWidget {
|
|
final String id;
|
|
final FFI ffi;
|
|
final MenubarState state;
|
|
final Function(Function(bool)) onEnterOrLeaveImageSetter;
|
|
final Function() onEnterOrLeaveImageCleaner;
|
|
|
|
RemoteMenubar({
|
|
Key? key,
|
|
required this.id,
|
|
required this.ffi,
|
|
required this.state,
|
|
required this.onEnterOrLeaveImageSetter,
|
|
required this.onEnterOrLeaveImageCleaner,
|
|
}) : super(key: key);
|
|
|
|
@override
|
|
State<RemoteMenubar> createState() => _RemoteMenubarState();
|
|
}
|
|
|
|
class _RemoteMenubarState extends State<RemoteMenubar> {
|
|
late Debouncer<int> _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;
|
|
|
|
@override
|
|
initState() {
|
|
super.initState();
|
|
|
|
_debouncerHide = Debouncer<int>(
|
|
Duration(milliseconds: 5000),
|
|
onChanged: _debouncerHideProc,
|
|
initialValue: 0,
|
|
);
|
|
|
|
widget.onEnterOrLeaveImageSetter((enter) {
|
|
if (enter) {
|
|
_debouncerHide.value = 0;
|
|
_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
|
|
? _buildMenubar(context)
|
|
: _buildDraggableShowHide(context)),
|
|
);
|
|
}
|
|
|
|
Widget _buildDraggableShowHide(BuildContext context) {
|
|
return Obx(() {
|
|
if (show.isTrue && _dragging.isFalse) {
|
|
_debouncerHide.value = 1;
|
|
}
|
|
return Align(
|
|
alignment: FractionalOffset(_fractionX.value, 0),
|
|
child: Offstage(
|
|
offstage: _dragging.isTrue,
|
|
child: _DraggableShowHide(
|
|
dragging: _dragging,
|
|
fractionX: _fractionX,
|
|
show: show,
|
|
),
|
|
),
|
|
);
|
|
});
|
|
}
|
|
|
|
Widget _buildMenubar(BuildContext context) {
|
|
final List<Widget> menubarItems = [];
|
|
if (!isWebDesktop) {
|
|
menubarItems.add(_PinMenu(state: widget.state));
|
|
menubarItems.add(
|
|
_FullscreenMenu(state: widget.state, setFullscreen: _setFullscreen));
|
|
menubarItems.add(_MobileActionMenu(ffi: widget.ffi));
|
|
}
|
|
menubarItems.add(_MonitorMenu(id: widget.id, ffi: widget.ffi));
|
|
menubarItems
|
|
.add(_ControlMenu(id: widget.id, ffi: widget.ffi, state: widget.state));
|
|
menubarItems.add(_DisplayMenu(
|
|
id: widget.id,
|
|
ffi: widget.ffi,
|
|
state: widget.state,
|
|
setFullscreen: _setFullscreen,
|
|
));
|
|
menubarItems.add(_KeyboardMenu(id: widget.id, ffi: widget.ffi));
|
|
if (!isWeb) {
|
|
menubarItems.add(_ChatMenu(id: widget.id, ffi: widget.ffi));
|
|
menubarItems.add(_VoiceCallMenu(id: widget.id, ffi: widget.ffi));
|
|
}
|
|
menubarItems.add(_RecordMenu());
|
|
menubarItems.add(_CloseMenu(id: widget.id, ffi: widget.ffi));
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.all(Radius.circular(10)),
|
|
),
|
|
child: SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
child: Theme(
|
|
data: themeData(),
|
|
child: MenuBar(
|
|
children: [
|
|
SizedBox(width: _MenubarTheme.buttonHMargin),
|
|
...menubarItems,
|
|
SizedBox(width: _MenubarTheme.buttonHMargin)
|
|
],
|
|
),
|
|
)),
|
|
),
|
|
_buildDraggableShowHide(context),
|
|
],
|
|
);
|
|
}
|
|
|
|
ThemeData themeData() {
|
|
return Theme.of(context).copyWith(
|
|
menuButtonTheme: MenuButtonThemeData(
|
|
style: ButtonStyle(
|
|
minimumSize: MaterialStatePropertyAll(Size(64, 36)),
|
|
textStyle: MaterialStatePropertyAll(
|
|
TextStyle(fontWeight: FontWeight.normal)))),
|
|
dividerTheme: DividerThemeData(space: 4),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PinMenu extends StatelessWidget {
|
|
final MenubarState 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 menubar' : 'Pin menubar',
|
|
onPressed: state.switchPin,
|
|
color: state.pin ? _MenubarTheme.blueColor : Colors.grey[800]!,
|
|
hoverColor:
|
|
state.pin ? _MenubarTheme.hoverBlueColor : Colors.grey[850]!,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _FullscreenMenu extends StatelessWidget {
|
|
final MenubarState state;
|
|
final Function(bool) setFullscreen;
|
|
bool get isFullscreen => stateGlobal.fullscreen;
|
|
const _FullscreenMenu(
|
|
{Key? key, required this.state, required this.setFullscreen})
|
|
: super(key: key);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return _IconMenuButton(
|
|
assetName:
|
|
isFullscreen ? "assets/fullscreen_exit.svg" : "assets/fullscreen.svg",
|
|
tooltip: isFullscreen ? 'Exit Fullscreen' : 'Fullscreen',
|
|
onPressed: () => setFullscreen(!isFullscreen),
|
|
color: _MenubarTheme.blueColor,
|
|
hoverColor: _MenubarTheme.hoverBlueColor,
|
|
);
|
|
}
|
|
}
|
|
|
|
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: _MenubarTheme.blueColor,
|
|
hoverColor: _MenubarTheme.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) {
|
|
if (stateGlobal.displaysCount.value < 2) return Offstage();
|
|
return _IconSubmenuButton(
|
|
icon: icon(),
|
|
ffi: ffi,
|
|
color: _MenubarTheme.blueColor,
|
|
hoverColor: _MenubarTheme.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/display.svg",
|
|
color: Colors.white,
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 3.9),
|
|
child: Obx(() {
|
|
RxInt display = CurrentDisplayState.find(id);
|
|
return Text(
|
|
'${display.value + 1}/${pi.displays.length}',
|
|
style: const TextStyle(color: Colors.white, fontSize: 8),
|
|
);
|
|
}),
|
|
)
|
|
],
|
|
);
|
|
}
|
|
|
|
List<Widget> displays(BuildContext context) {
|
|
final List<Widget> rowChildren = [];
|
|
final pi = ffi.ffiModel.pi;
|
|
for (int i = 0; i < pi.displays.length; i++) {
|
|
rowChildren.add(_IconMenuButton(
|
|
color: _MenubarTheme.blueColor,
|
|
hoverColor: _MenubarTheme.hoverBlueColor,
|
|
tooltip: "",
|
|
hMargin: 6,
|
|
vMargin: 12,
|
|
icon: Container(
|
|
alignment: AlignmentDirectional.center,
|
|
constraints: const BoxConstraints(minHeight: _MenubarTheme.height),
|
|
child: Stack(
|
|
alignment: Alignment.center,
|
|
children: [
|
|
SvgPicture.asset(
|
|
"assets/display.svg",
|
|
color: Colors.white,
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 3.5 /*2.5*/),
|
|
child: Text(
|
|
(i + 1).toString(),
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
)
|
|
],
|
|
),
|
|
),
|
|
onPressed: () {
|
|
_menuDismissCallback(ffi);
|
|
RxInt display = CurrentDisplayState.find(id);
|
|
if (display.value != i) {
|
|
bind.sessionSwitchDisplay(id: id, value: i);
|
|
}
|
|
},
|
|
));
|
|
}
|
|
return rowChildren;
|
|
}
|
|
}
|
|
|
|
class _ControlMenu extends StatelessWidget {
|
|
final String id;
|
|
final FFI ffi;
|
|
final MenubarState state;
|
|
_ControlMenu(
|
|
{Key? key, required this.id, required this.ffi, required this.state})
|
|
: super(key: key);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return _IconSubmenuButton(
|
|
svg: "assets/actions.svg",
|
|
color: _MenubarTheme.blueColor,
|
|
hoverColor: _MenubarTheme.hoverBlueColor,
|
|
ffi: ffi,
|
|
menuChildren: [
|
|
requestElevation(),
|
|
osPassword(),
|
|
transferFile(context),
|
|
tcpTunneling(context),
|
|
note(),
|
|
Divider(),
|
|
ctrlAltDel(),
|
|
restart(),
|
|
insertLock(),
|
|
blockUserInput(),
|
|
switchSides(),
|
|
refresh(),
|
|
]);
|
|
}
|
|
|
|
requestElevation() {
|
|
final visible = ffi.elevationModel.showRequestMenu;
|
|
if (!visible) return Offstage();
|
|
return _MenuItemButton(
|
|
child: Text(translate('Request Elevation')),
|
|
ffi: ffi,
|
|
onPressed: () => showRequestElevationDialog(id, ffi.dialogManager));
|
|
}
|
|
|
|
osPassword() {
|
|
return _MenuItemButton(
|
|
child: Text(translate('OS Password')),
|
|
trailingIcon: Transform.scale(scale: 0.8, child: Icon(Icons.edit)),
|
|
ffi: ffi,
|
|
onPressed: () => _showSetOSPassword(id, false, ffi.dialogManager));
|
|
}
|
|
|
|
_showSetOSPassword(
|
|
String id, bool login, OverlayDialogManager dialogManager) async {
|
|
final controller = TextEditingController();
|
|
var password =
|
|
await bind.sessionGetOption(id: id, arg: 'os-password') ?? '';
|
|
var autoLogin =
|
|
await bind.sessionGetOption(id: id, arg: 'auto-login') != '';
|
|
controller.text = password;
|
|
dialogManager.show((setState, close) {
|
|
submit() {
|
|
var text = controller.text.trim();
|
|
bind.sessionPeerOption(id: id, name: 'os-password', value: text);
|
|
bind.sessionPeerOption(
|
|
id: id, name: 'auto-login', value: autoLogin ? 'Y' : '');
|
|
if (text != '' && login) {
|
|
bind.sessionInputOsPassword(id: id, value: text);
|
|
}
|
|
close();
|
|
}
|
|
|
|
return CustomAlertDialog(
|
|
title: Text(translate('OS Password')),
|
|
content: Column(mainAxisSize: MainAxisSize.min, children: [
|
|
PasswordWidget(controller: controller),
|
|
CheckboxListTile(
|
|
contentPadding: const EdgeInsets.all(0),
|
|
dense: true,
|
|
controlAffinity: ListTileControlAffinity.leading,
|
|
title: Text(
|
|
translate('Auto Login'),
|
|
),
|
|
value: autoLogin,
|
|
onChanged: (v) {
|
|
if (v == null) return;
|
|
setState(() => autoLogin = v);
|
|
},
|
|
),
|
|
]),
|
|
actions: [
|
|
dialogButton('Cancel', onPressed: close, isOutline: true),
|
|
dialogButton('OK', onPressed: submit),
|
|
],
|
|
onSubmit: submit,
|
|
onCancel: close,
|
|
);
|
|
});
|
|
}
|
|
|
|
transferFile(BuildContext context) {
|
|
return _MenuItemButton(
|
|
child: Text(translate('Transfer File')),
|
|
ffi: ffi,
|
|
onPressed: () => connect(context, id, isFileTransfer: true));
|
|
}
|
|
|
|
tcpTunneling(BuildContext context) {
|
|
return _MenuItemButton(
|
|
child: Text(translate('TCP Tunneling')),
|
|
ffi: ffi,
|
|
onPressed: () => connect(context, id, isTcpTunneling: true));
|
|
}
|
|
|
|
note() {
|
|
final auditServer = bind.sessionGetAuditServerSync(id: id, typ: "conn");
|
|
final visible = auditServer.isNotEmpty;
|
|
if (!visible) return Offstage();
|
|
return _MenuItemButton(
|
|
child: Text(translate('Note')),
|
|
ffi: ffi,
|
|
onPressed: () => _showAuditDialog(id, ffi.dialogManager),
|
|
);
|
|
}
|
|
|
|
_showAuditDialog(String id, dialogManager) async {
|
|
final controller = TextEditingController();
|
|
dialogManager.show((setState, close) {
|
|
submit() {
|
|
var text = controller.text.trim();
|
|
if (text != '') {
|
|
bind.sessionSendNote(id: id, note: text);
|
|
}
|
|
close();
|
|
}
|
|
|
|
late final focusNode = FocusNode(
|
|
onKey: (FocusNode node, RawKeyEvent evt) {
|
|
if (evt.logicalKey.keyLabel == 'Enter') {
|
|
if (evt is RawKeyDownEvent) {
|
|
int pos = controller.selection.base.offset;
|
|
controller.text =
|
|
'${controller.text.substring(0, pos)}\n${controller.text.substring(pos)}';
|
|
controller.selection =
|
|
TextSelection.fromPosition(TextPosition(offset: pos + 1));
|
|
}
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (evt.logicalKey.keyLabel == 'Esc') {
|
|
if (evt is RawKeyDownEvent) {
|
|
close();
|
|
}
|
|
return KeyEventResult.handled;
|
|
} else {
|
|
return KeyEventResult.ignored;
|
|
}
|
|
},
|
|
);
|
|
|
|
return CustomAlertDialog(
|
|
title: Text(translate('Note')),
|
|
content: SizedBox(
|
|
width: 250,
|
|
height: 120,
|
|
child: TextField(
|
|
autofocus: true,
|
|
keyboardType: TextInputType.multiline,
|
|
textInputAction: TextInputAction.newline,
|
|
decoration: const InputDecoration.collapsed(
|
|
hintText: 'input note here',
|
|
),
|
|
maxLines: null,
|
|
maxLength: 256,
|
|
controller: controller,
|
|
focusNode: focusNode,
|
|
)),
|
|
actions: [
|
|
dialogButton('Cancel', onPressed: close, isOutline: true),
|
|
dialogButton('OK', onPressed: submit)
|
|
],
|
|
onSubmit: submit,
|
|
onCancel: close,
|
|
);
|
|
});
|
|
}
|
|
|
|
ctrlAltDel() {
|
|
final perms = ffi.ffiModel.permissions;
|
|
final pi = ffi.ffiModel.pi;
|
|
final visible = perms['keyboard'] != false &&
|
|
(pi.platform == kPeerPlatformLinux || pi.sasEnabled);
|
|
if (!visible) return Offstage();
|
|
return _MenuItemButton(
|
|
child: Text('${translate("Insert")} Ctrl + Alt + Del'),
|
|
ffi: ffi,
|
|
onPressed: () => bind.sessionCtrlAltDel(id: id));
|
|
}
|
|
|
|
restart() {
|
|
final perms = ffi.ffiModel.permissions;
|
|
final pi = ffi.ffiModel.pi;
|
|
final visible = perms['restart'] != false &&
|
|
(pi.platform == kPeerPlatformLinux ||
|
|
pi.platform == kPeerPlatformWindows ||
|
|
pi.platform == kPeerPlatformMacOS);
|
|
if (!visible) return Offstage();
|
|
return _MenuItemButton(
|
|
child: Text(translate('Restart Remote Device')),
|
|
ffi: ffi,
|
|
onPressed: () => showRestartRemoteDevice(pi, id, ffi.dialogManager));
|
|
}
|
|
|
|
insertLock() {
|
|
final perms = ffi.ffiModel.permissions;
|
|
final visible = perms['keyboard'] != false;
|
|
if (!visible) return Offstage();
|
|
return _MenuItemButton(
|
|
child: Text(translate('Insert Lock')),
|
|
ffi: ffi,
|
|
onPressed: () => bind.sessionLockScreen(id: id));
|
|
}
|
|
|
|
blockUserInput() {
|
|
final perms = ffi.ffiModel.permissions;
|
|
final pi = ffi.ffiModel.pi;
|
|
final visible =
|
|
perms['keyboard'] != false && pi.platform == kPeerPlatformWindows;
|
|
if (!visible) return Offstage();
|
|
return _MenuItemButton(
|
|
child: Obx(() => Text(translate(
|
|
'${BlockInputState.find(id).value ? 'Unb' : 'B'}lock user input'))),
|
|
ffi: ffi,
|
|
onPressed: () {
|
|
RxBool blockInput = BlockInputState.find(id);
|
|
bind.sessionToggleOption(
|
|
id: id, value: '${blockInput.value ? 'un' : ''}block-input');
|
|
blockInput.value = !blockInput.value;
|
|
});
|
|
}
|
|
|
|
switchSides() {
|
|
final perms = ffi.ffiModel.permissions;
|
|
final pi = ffi.ffiModel.pi;
|
|
final visible = perms['keyboard'] != false &&
|
|
pi.platform != kPeerPlatformAndroid &&
|
|
pi.platform != kPeerPlatformMacOS &&
|
|
version_cmp(pi.version, '1.2.0') >= 0;
|
|
if (!visible) return Offstage();
|
|
return _MenuItemButton(
|
|
child: Text(translate('Switch Sides')),
|
|
ffi: ffi,
|
|
onPressed: () => _showConfirmSwitchSidesDialog(id, ffi.dialogManager));
|
|
}
|
|
|
|
void _showConfirmSwitchSidesDialog(
|
|
String id, OverlayDialogManager dialogManager) async {
|
|
dialogManager.show((setState, close) {
|
|
submit() async {
|
|
await bind.sessionSwitchSides(id: id);
|
|
closeConnection(id: id);
|
|
}
|
|
|
|
return CustomAlertDialog(
|
|
content: msgboxContent('info', 'Switch Sides',
|
|
'Please confirm if you want to share your desktop?'),
|
|
actions: [
|
|
dialogButton('Cancel', onPressed: close, isOutline: true),
|
|
dialogButton('OK', onPressed: submit),
|
|
],
|
|
onSubmit: submit,
|
|
onCancel: close,
|
|
);
|
|
});
|
|
}
|
|
|
|
refresh() {
|
|
final pi = ffi.ffiModel.pi;
|
|
final visible = pi.version.isNotEmpty;
|
|
if (!visible) return Offstage();
|
|
return _MenuItemButton(
|
|
child: Text(translate('Refresh')),
|
|
ffi: ffi,
|
|
onPressed: () => bind.sessionRefresh(id: id));
|
|
}
|
|
}
|
|
|
|
class _DisplayMenu extends StatefulWidget {
|
|
final String id;
|
|
final FFI ffi;
|
|
final MenubarState state;
|
|
final Function(bool) setFullscreen;
|
|
_DisplayMenu(
|
|
{Key? key,
|
|
required this.id,
|
|
required this.ffi,
|
|
required this.state,
|
|
required this.setFullscreen})
|
|
: super(key: key);
|
|
|
|
@override
|
|
State<_DisplayMenu> createState() => _DisplayMenuState();
|
|
}
|
|
|
|
class _DisplayMenuState extends State<_DisplayMenu> {
|
|
window_size.Screen? _screen;
|
|
|
|
bool get isFullscreen => stateGlobal.fullscreen;
|
|
|
|
int get windowId => stateGlobal.windowId;
|
|
|
|
Map<String, bool> get perms => widget.ffi.ffiModel.permissions;
|
|
|
|
PeerInfo get pi => widget.ffi.ffiModel.pi;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
_updateScreen();
|
|
return _IconSubmenuButton(
|
|
svg: "assets/display.svg",
|
|
ffi: widget.ffi,
|
|
color: _MenubarTheme.blueColor,
|
|
hoverColor: _MenubarTheme.hoverBlueColor,
|
|
menuChildren: [
|
|
adjustWindow(),
|
|
viewStyle(),
|
|
scrollStyle(),
|
|
imageQuality(),
|
|
codec(),
|
|
resolutions(),
|
|
Divider(),
|
|
showRemoteCursor(),
|
|
zoomCursor(),
|
|
showQualityMonitor(),
|
|
mute(),
|
|
fileCopyAndPaste(),
|
|
disableClipboard(),
|
|
lockAfterSessionEnd(),
|
|
privacyMode(),
|
|
]);
|
|
}
|
|
|
|
adjustWindow() {
|
|
final visible = _isWindowCanBeAdjusted();
|
|
if (!visible) return Offstage();
|
|
return Column(
|
|
children: [
|
|
_MenuItemButton(
|
|
child: Text(translate('Adjust Window')),
|
|
onPressed: _doAdjustWindow,
|
|
ffi: widget.ffi),
|
|
Divider(),
|
|
],
|
|
);
|
|
}
|
|
|
|
_doAdjustWindow() async {
|
|
await _updateScreen();
|
|
if (_screen != null) {
|
|
widget.setFullscreen(false);
|
|
double scale = _screen!.scaleFactor;
|
|
final wndRect = await WindowController.fromWindowId(windowId).getFrame();
|
|
final mediaSize = MediaQueryData.fromWindow(ui.window).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 = widget.ffi.canvasModel;
|
|
final width = (canvasModel.getDisplayWidth() * canvasModel.scale +
|
|
canvasModel.windowBorderWidth * 2) *
|
|
scale +
|
|
magicWidth;
|
|
final height = (canvasModel.getDisplayHeight() * canvasModel.scale +
|
|
canvasModel.tabBarHeight +
|
|
canvasModel.windowBorderWidth * 2) *
|
|
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']);
|
|
}
|
|
}
|
|
|
|
_isWindowCanBeAdjusted() {
|
|
if (widget.state.viewStyle.value != 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 = widget.ffi.canvasModel;
|
|
final displayWidth = canvasModel.getDisplayWidth();
|
|
final displayHeight = canvasModel.getDisplayHeight();
|
|
final requiredWidth = displayWidth +
|
|
(canvasModel.tabBarHeight + canvasModel.windowBorderWidth * 2);
|
|
final requiredHeight = displayHeight +
|
|
(canvasModel.tabBarHeight + canvasModel.windowBorderWidth * 2);
|
|
return selfWidth > (requiredWidth * scale) &&
|
|
selfHeight > (requiredHeight * scale);
|
|
}
|
|
|
|
viewStyle() {
|
|
return futureBuilder(future: () async {
|
|
final viewStyle = await bind.sessionGetViewStyle(id: widget.id) ?? '';
|
|
widget.state.viewStyle.value = viewStyle;
|
|
return viewStyle;
|
|
}(), hasData: (data) {
|
|
final groupValue = data as String;
|
|
onChanged(String? value) async {
|
|
if (value == null) return;
|
|
await bind.sessionSetViewStyle(id: widget.id, value: value);
|
|
widget.state.viewStyle.value = value;
|
|
widget.ffi.canvasModel.updateViewStyle();
|
|
}
|
|
|
|
return Column(children: [
|
|
_RadioMenuButton<String>(
|
|
child: Text(translate('Scale original')),
|
|
value: kRemoteViewStyleOriginal,
|
|
groupValue: groupValue,
|
|
onChanged: onChanged,
|
|
ffi: widget.ffi,
|
|
),
|
|
_RadioMenuButton<String>(
|
|
child: Text(translate('Scale adaptive')),
|
|
value: kRemoteViewStyleAdaptive,
|
|
groupValue: groupValue,
|
|
onChanged: onChanged,
|
|
ffi: widget.ffi,
|
|
),
|
|
Divider(),
|
|
]);
|
|
});
|
|
}
|
|
|
|
scrollStyle() {
|
|
final visible = widget.state.viewStyle.value == kRemoteViewStyleOriginal;
|
|
if (!visible) return Offstage();
|
|
return futureBuilder(future: () async {
|
|
final scrollStyle = await bind.sessionGetScrollStyle(id: widget.id) ?? '';
|
|
return scrollStyle;
|
|
}(), hasData: (data) {
|
|
final groupValue = data as String;
|
|
onChange(String? value) async {
|
|
if (value == null) return;
|
|
await bind.sessionSetScrollStyle(id: widget.id, value: value);
|
|
widget.ffi.canvasModel.updateScrollStyle();
|
|
}
|
|
|
|
final enabled = widget.ffi.canvasModel.imageOverflow.value;
|
|
return Column(children: [
|
|
_RadioMenuButton<String>(
|
|
child: Text(translate('ScrollAuto')),
|
|
value: kRemoteScrollStyleAuto,
|
|
groupValue: groupValue,
|
|
onChanged: enabled ? (value) => onChange(value) : null,
|
|
ffi: widget.ffi,
|
|
),
|
|
_RadioMenuButton<String>(
|
|
child: Text(translate('Scrollbar')),
|
|
value: kRemoteScrollStyleBar,
|
|
groupValue: groupValue,
|
|
onChanged: enabled ? (value) => onChange(value) : null,
|
|
ffi: widget.ffi,
|
|
),
|
|
Divider(),
|
|
]);
|
|
});
|
|
}
|
|
|
|
imageQuality() {
|
|
return futureBuilder(future: () async {
|
|
final imageQuality =
|
|
await bind.sessionGetImageQuality(id: widget.id) ?? '';
|
|
return imageQuality;
|
|
}(), hasData: (data) {
|
|
final groupValue = data as String;
|
|
onChanged(String? value) async {
|
|
if (value == null) return;
|
|
await bind.sessionSetImageQuality(id: widget.id, value: value);
|
|
}
|
|
|
|
return _SubmenuButton(
|
|
ffi: widget.ffi,
|
|
child: Text(translate('Image Quality')),
|
|
menuChildren: [
|
|
_RadioMenuButton<String>(
|
|
child: Text(translate('Good image quality')),
|
|
value: kRemoteImageQualityBest,
|
|
groupValue: groupValue,
|
|
onChanged: onChanged,
|
|
ffi: widget.ffi,
|
|
),
|
|
_RadioMenuButton<String>(
|
|
child: Text(translate('Balanced')),
|
|
value: kRemoteImageQualityBalanced,
|
|
groupValue: groupValue,
|
|
onChanged: onChanged,
|
|
ffi: widget.ffi,
|
|
),
|
|
_RadioMenuButton<String>(
|
|
child: Text(translate('Optimize reaction time')),
|
|
value: kRemoteImageQualityLow,
|
|
groupValue: groupValue,
|
|
onChanged: onChanged,
|
|
ffi: widget.ffi,
|
|
),
|
|
_RadioMenuButton<String>(
|
|
child: Text(translate('Custom')),
|
|
value: kRemoteImageQualityCustom,
|
|
groupValue: groupValue,
|
|
onChanged: (value) {
|
|
onChanged(value);
|
|
_customImageQualityDialog();
|
|
},
|
|
ffi: widget.ffi,
|
|
),
|
|
],
|
|
);
|
|
});
|
|
}
|
|
|
|
_customImageQualityDialog() async {
|
|
double qualityInitValue = 50;
|
|
double fpsInitValue = 30;
|
|
bool qualitySet = false;
|
|
bool fpsSet = false;
|
|
setCustomValues({double? quality, double? fps}) async {
|
|
if (quality != null) {
|
|
qualitySet = true;
|
|
await bind.sessionSetCustomImageQuality(
|
|
id: widget.id, value: quality.toInt());
|
|
}
|
|
if (fps != null) {
|
|
fpsSet = true;
|
|
await bind.sessionSetCustomFps(id: widget.id, fps: fps.toInt());
|
|
}
|
|
if (!qualitySet) {
|
|
qualitySet = true;
|
|
await bind.sessionSetCustomImageQuality(
|
|
id: widget.id, value: qualityInitValue.toInt());
|
|
}
|
|
if (!fpsSet) {
|
|
fpsSet = true;
|
|
await bind.sessionSetCustomFps(
|
|
id: widget.id, fps: fpsInitValue.toInt());
|
|
}
|
|
}
|
|
|
|
final btnClose = dialogButton('Close', onPressed: () async {
|
|
await setCustomValues();
|
|
widget.ffi.dialogManager.dismissAll();
|
|
});
|
|
|
|
// quality
|
|
final quality = await bind.sessionGetCustomImageQuality(id: widget.id);
|
|
qualityInitValue =
|
|
quality != null && quality.isNotEmpty ? quality[0].toDouble() : 50.0;
|
|
const qualityMinValue = 10.0;
|
|
const qualityMaxValue = 100.0;
|
|
if (qualityInitValue < qualityMinValue) {
|
|
qualityInitValue = qualityMinValue;
|
|
}
|
|
if (qualityInitValue > qualityMaxValue) {
|
|
qualityInitValue = qualityMaxValue;
|
|
}
|
|
final RxDouble qualitySliderValue = RxDouble(qualityInitValue);
|
|
final debouncerQuality = Debouncer<double>(
|
|
Duration(milliseconds: 1000),
|
|
onChanged: (double v) {
|
|
setCustomValues(quality: v);
|
|
},
|
|
initialValue: qualityInitValue,
|
|
);
|
|
final qualitySlider = Obx(() => Row(
|
|
children: [
|
|
Slider(
|
|
value: qualitySliderValue.value,
|
|
min: qualityMinValue,
|
|
max: qualityMaxValue,
|
|
divisions: 18,
|
|
onChanged: (double value) {
|
|
qualitySliderValue.value = value;
|
|
debouncerQuality.value = value;
|
|
},
|
|
),
|
|
SizedBox(
|
|
width: 40,
|
|
child: Text(
|
|
'${qualitySliderValue.value.round()}%',
|
|
style: const TextStyle(fontSize: 15),
|
|
)),
|
|
SizedBox(
|
|
width: 50,
|
|
child: Text(
|
|
translate('Bitrate'),
|
|
style: const TextStyle(fontSize: 15),
|
|
))
|
|
],
|
|
));
|
|
// fps
|
|
final fpsOption =
|
|
await bind.sessionGetOption(id: widget.id, arg: 'custom-fps');
|
|
fpsInitValue = fpsOption == null ? 30 : double.tryParse(fpsOption) ?? 30;
|
|
if (fpsInitValue < 10 || fpsInitValue > 120) {
|
|
fpsInitValue = 30;
|
|
}
|
|
final RxDouble fpsSliderValue = RxDouble(fpsInitValue);
|
|
final debouncerFps = Debouncer<double>(
|
|
Duration(milliseconds: 1000),
|
|
onChanged: (double v) {
|
|
setCustomValues(fps: v);
|
|
},
|
|
initialValue: qualityInitValue,
|
|
);
|
|
bool? direct;
|
|
try {
|
|
direct = ConnectionTypeState.find(widget.id).direct.value ==
|
|
ConnectionType.strDirect;
|
|
} catch (_) {}
|
|
final fpsSlider = Offstage(
|
|
offstage: (await bind.mainIsUsingPublicServer() && direct != true) ||
|
|
version_cmp(pi.version, '1.2.0') < 0,
|
|
child: Row(
|
|
children: [
|
|
Obx((() => Slider(
|
|
value: fpsSliderValue.value,
|
|
min: 10,
|
|
max: 120,
|
|
divisions: 22,
|
|
onChanged: (double value) {
|
|
fpsSliderValue.value = value;
|
|
debouncerFps.value = value;
|
|
},
|
|
))),
|
|
SizedBox(
|
|
width: 40,
|
|
child: Obx(() => Text(
|
|
'${fpsSliderValue.value.round()}',
|
|
style: const TextStyle(fontSize: 15),
|
|
))),
|
|
SizedBox(
|
|
width: 50,
|
|
child: Text(
|
|
translate('FPS'),
|
|
style: const TextStyle(fontSize: 15),
|
|
))
|
|
],
|
|
),
|
|
);
|
|
|
|
final content = Column(
|
|
children: [qualitySlider, fpsSlider],
|
|
);
|
|
msgBoxCommon(
|
|
widget.ffi.dialogManager, 'Custom Image Quality', content, [btnClose]);
|
|
}
|
|
|
|
codec() {
|
|
return futureBuilder(future: () async {
|
|
final supportedHwcodec =
|
|
await bind.sessionSupportedHwcodec(id: widget.id);
|
|
final codecPreference =
|
|
await bind.sessionGetOption(id: widget.id, arg: 'codec-preference') ??
|
|
'';
|
|
return {
|
|
'supportedHwcodec': supportedHwcodec,
|
|
'codecPreference': codecPreference
|
|
};
|
|
}(), hasData: (data) {
|
|
final List<bool> codecs = [];
|
|
try {
|
|
final Map codecsJson = jsonDecode(data['supportedHwcodec']);
|
|
final h264 = codecsJson['h264'] ?? false;
|
|
final h265 = codecsJson['h265'] ?? false;
|
|
codecs.add(h264);
|
|
codecs.add(h265);
|
|
} catch (e) {
|
|
debugPrint("Show Codec Preference err=$e");
|
|
}
|
|
final visible = bind.mainHasHwcodec() &&
|
|
codecs.length == 2 &&
|
|
(codecs[0] || codecs[1]);
|
|
if (!visible) return Offstage();
|
|
final groupValue = data['codecPreference'] as String;
|
|
onChanged(String? value) async {
|
|
if (value == null) return;
|
|
await bind.sessionPeerOption(
|
|
id: widget.id, name: 'codec-preference', value: value);
|
|
bind.sessionChangePreferCodec(id: widget.id);
|
|
}
|
|
|
|
return _SubmenuButton(
|
|
ffi: widget.ffi,
|
|
child: Text(translate('Codec')),
|
|
menuChildren: [
|
|
_RadioMenuButton<String>(
|
|
child: Text(translate('Auto')),
|
|
value: 'auto',
|
|
groupValue: groupValue,
|
|
onChanged: onChanged,
|
|
ffi: widget.ffi,
|
|
),
|
|
_RadioMenuButton<String>(
|
|
child: Text(translate('VP9')),
|
|
value: 'vp9',
|
|
groupValue: groupValue,
|
|
onChanged: onChanged,
|
|
ffi: widget.ffi,
|
|
),
|
|
_RadioMenuButton<String>(
|
|
child: Text(translate('H264')),
|
|
value: 'h264',
|
|
groupValue: groupValue,
|
|
onChanged: onChanged,
|
|
ffi: widget.ffi,
|
|
),
|
|
_RadioMenuButton<String>(
|
|
child: Text(translate('H265')),
|
|
value: 'h265',
|
|
groupValue: groupValue,
|
|
onChanged: onChanged,
|
|
ffi: widget.ffi,
|
|
),
|
|
]);
|
|
});
|
|
}
|
|
|
|
resolutions() {
|
|
final resolutions = widget.ffi.ffiModel.pi.resolutions;
|
|
final visible = widget.ffi.ffiModel.permissions["keyboard"] != false &&
|
|
resolutions.length > 1;
|
|
if (!visible) return Offstage();
|
|
final display = widget.ffi.ffiModel.display;
|
|
final groupValue = "${display.width}x${display.height}";
|
|
onChanged(String? value) async {
|
|
if (value == null) return;
|
|
final list = value.split('x');
|
|
if (list.length == 2) {
|
|
final w = int.tryParse(list[0]);
|
|
final h = int.tryParse(list[1]);
|
|
if (w != null && h != null) {
|
|
await bind.sessionChangeResolution(
|
|
id: widget.id, width: w, height: h);
|
|
Future.delayed(Duration(seconds: 3), () async {
|
|
final display = widget.ffi.ffiModel.display;
|
|
if (w == display.width && h == display.height) {
|
|
if (_isWindowCanBeAdjusted()) {
|
|
_doAdjustWindow();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return _SubmenuButton(
|
|
ffi: widget.ffi,
|
|
menuChildren: resolutions
|
|
.map((e) => _RadioMenuButton(
|
|
value: '${e.width}x${e.height}',
|
|
groupValue: groupValue,
|
|
onChanged: onChanged,
|
|
ffi: widget.ffi,
|
|
child: Text('${e.width}x${e.height}')))
|
|
.toList(),
|
|
child: Text(translate("Resolution")));
|
|
}
|
|
|
|
showRemoteCursor() {
|
|
final visible = !widget.ffi.canvasModel.cursorEmbedded;
|
|
if (!visible) return Offstage();
|
|
final state = ShowRemoteCursorState.find(widget.id);
|
|
final option = 'show-remote-cursor';
|
|
return _CheckboxMenuButton(
|
|
value: state.value,
|
|
onChanged: (value) async {
|
|
if (value == null) return;
|
|
await bind.sessionToggleOption(id: widget.id, value: option);
|
|
state.value =
|
|
bind.sessionGetToggleOptionSync(id: widget.id, arg: option);
|
|
},
|
|
ffi: widget.ffi,
|
|
child: Text(translate('Show remote cursor')));
|
|
}
|
|
|
|
zoomCursor() {
|
|
final visible = widget.state.viewStyle.value != kRemoteViewStyleOriginal;
|
|
if (!visible) return Offstage();
|
|
final option = 'zoom-cursor';
|
|
final peerState = PeerBoolOption.find(widget.id, option);
|
|
return _CheckboxMenuButton(
|
|
value: peerState.value,
|
|
onChanged: (value) async {
|
|
if (value == null) return;
|
|
await bind.sessionToggleOption(id: widget.id, value: option);
|
|
peerState.value =
|
|
bind.sessionGetToggleOptionSync(id: widget.id, arg: option);
|
|
},
|
|
ffi: widget.ffi,
|
|
child: Text(translate('Zoom cursor')));
|
|
}
|
|
|
|
showQualityMonitor() {
|
|
final option = 'show-quality-monitor';
|
|
final value = bind.sessionGetToggleOptionSync(id: widget.id, arg: option);
|
|
return _CheckboxMenuButton(
|
|
value: value,
|
|
onChanged: (value) async {
|
|
if (value == null) return;
|
|
await bind.sessionToggleOption(id: widget.id, value: option);
|
|
widget.ffi.qualityMonitorModel.checkShowQualityMonitor(widget.id);
|
|
},
|
|
ffi: widget.ffi,
|
|
child: Text(translate('Show quality monitor')));
|
|
}
|
|
|
|
mute() {
|
|
final visible = perms['audio'] != false;
|
|
if (!visible) return Offstage();
|
|
final option = 'disable-audio';
|
|
final value = bind.sessionGetToggleOptionSync(id: widget.id, arg: option);
|
|
return _CheckboxMenuButton(
|
|
value: value,
|
|
onChanged: (value) {
|
|
if (value == null) return;
|
|
bind.sessionToggleOption(id: widget.id, value: option);
|
|
},
|
|
ffi: widget.ffi,
|
|
child: Text(translate('Mute')));
|
|
}
|
|
|
|
fileCopyAndPaste() {
|
|
final visible = Platform.isWindows &&
|
|
pi.platform == kPeerPlatformWindows &&
|
|
perms['file'] != false;
|
|
if (!visible) return Offstage();
|
|
final option = 'enable-file-transfer';
|
|
final value = bind.sessionGetToggleOptionSync(id: widget.id, arg: option);
|
|
return _CheckboxMenuButton(
|
|
value: value,
|
|
onChanged: (value) {
|
|
if (value == null) return;
|
|
bind.sessionToggleOption(id: widget.id, value: option);
|
|
},
|
|
ffi: widget.ffi,
|
|
child: Text(translate('Allow file copy and paste')));
|
|
}
|
|
|
|
disableClipboard() {
|
|
final visible = perms['keyboard'] != false && perms['clipboard'] != false;
|
|
if (!visible) return Offstage();
|
|
final option = 'disable-clipboard';
|
|
final value = bind.sessionGetToggleOptionSync(id: widget.id, arg: option);
|
|
return _CheckboxMenuButton(
|
|
value: value,
|
|
onChanged: (value) {
|
|
if (value == null) return;
|
|
bind.sessionToggleOption(id: widget.id, value: option);
|
|
},
|
|
ffi: widget.ffi,
|
|
child: Text(translate('Disable clipboard')));
|
|
}
|
|
|
|
lockAfterSessionEnd() {
|
|
final visible = perms['keyboard'] != false;
|
|
if (!visible) return Offstage();
|
|
final option = 'lock-after-session-end';
|
|
final value = bind.sessionGetToggleOptionSync(id: widget.id, arg: option);
|
|
return _CheckboxMenuButton(
|
|
value: value,
|
|
onChanged: (value) {
|
|
if (value == null) return;
|
|
bind.sessionToggleOption(id: widget.id, value: option);
|
|
},
|
|
ffi: widget.ffi,
|
|
child: Text(translate('Lock after session end')));
|
|
}
|
|
|
|
privacyMode() {
|
|
bool visible = perms['keyboard'] != false && pi.features.privacyMode;
|
|
if (!visible) return Offstage();
|
|
final option = 'privacy-mode';
|
|
final rxValue = PrivacyModeState.find(widget.id);
|
|
return _CheckboxMenuButton(
|
|
value: rxValue.value,
|
|
onChanged: (value) {
|
|
if (value == null) return;
|
|
bind.sessionToggleOption(id: widget.id, value: option);
|
|
},
|
|
ffi: widget.ffi,
|
|
child: Text(translate('Privacy mode')));
|
|
}
|
|
}
|
|
|
|
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) {
|
|
// Do not check permission here?
|
|
// var ffiModel = Provider.of<FfiModel>(context);
|
|
// if (ffiModel.permissions['keyboard'] == false) return Offstage();
|
|
if (stateGlobal.grabKeyboard) {
|
|
if (bind.sessionIsKeyboardModeSupported(id: id, mode: _kKeyMapMode)) {
|
|
bind.sessionSetKeyboardMode(id: id, value: _kKeyMapMode);
|
|
} else if (bind.sessionIsKeyboardModeSupported(
|
|
id: id, mode: _kKeyLegacyMode)) {
|
|
bind.sessionSetKeyboardMode(id: id, value: _kKeyLegacyMode);
|
|
}
|
|
return Offstage();
|
|
}
|
|
return _IconSubmenuButton(
|
|
svg: "assets/keyboard.svg",
|
|
ffi: ffi,
|
|
color: _MenubarTheme.blueColor,
|
|
hoverColor: _MenubarTheme.hoverBlueColor,
|
|
menuChildren: [mode(), localKeyboardType()]);
|
|
}
|
|
|
|
mode() {
|
|
return futureBuilder(future: () async {
|
|
return await bind.sessionGetKeyboardMode(id: id) ?? _kKeyLegacyMode;
|
|
}(), hasData: (data) {
|
|
final groupValue = data as String;
|
|
List<KeyboardModeMenu> modes = [
|
|
KeyboardModeMenu(key: _kKeyLegacyMode, menu: 'Legacy mode'),
|
|
KeyboardModeMenu(key: _kKeyMapMode, menu: 'Map mode'),
|
|
KeyboardModeMenu(key: _kKeyTranslateMode, menu: 'Translate mode'),
|
|
];
|
|
List<_RadioMenuButton> list = [];
|
|
onChanged(String? value) async {
|
|
if (value == null) return;
|
|
await bind.sessionSetKeyboardMode(id: id, value: value);
|
|
}
|
|
|
|
for (KeyboardModeMenu mode in modes) {
|
|
if (bind.sessionIsKeyboardModeSupported(id: id, mode: mode.key)) {
|
|
if (mode.key == _kKeyTranslateMode) {
|
|
if (Platform.isLinux || pi.platform == kPeerPlatformLinux) {
|
|
continue;
|
|
}
|
|
}
|
|
var text = translate(mode.menu);
|
|
if (mode.key == _kKeyTranslateMode) {
|
|
text = '$text beta';
|
|
}
|
|
list.add(_RadioMenuButton<String>(
|
|
child: Text(text),
|
|
value: mode.key,
|
|
groupValue: groupValue,
|
|
onChanged: onChanged,
|
|
ffi: ffi,
|
|
));
|
|
}
|
|
}
|
|
return Column(children: list);
|
|
});
|
|
}
|
|
|
|
localKeyboardType() {
|
|
final localPlatform = getLocalPlatformForKBLayoutType(pi.platform);
|
|
final visible = localPlatform != '';
|
|
if (!visible) return Offstage();
|
|
return Column(
|
|
children: [
|
|
Divider(),
|
|
_MenuItemButton(
|
|
child: Text(
|
|
'${translate('Local keyboard type')}: ${KBLayoutType.value}'),
|
|
trailingIcon: const Icon(Icons.settings),
|
|
ffi: ffi,
|
|
onPressed: () =>
|
|
showKBLayoutTypeChooser(localPlatform, ffi.dialogManager),
|
|
)
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
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(
|
|
key: chatButtonKey,
|
|
svg: 'assets/chat.svg',
|
|
ffi: widget.ffi,
|
|
color: _MenubarTheme.blueColor,
|
|
hoverColor: _MenubarTheme.hoverBlueColor,
|
|
menuChildren: [textChat(), voiceCall()]);
|
|
}
|
|
|
|
textChat() {
|
|
return _MenuItemButton(
|
|
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 + _MenubarTheme.dividerHeight);
|
|
}
|
|
|
|
widget.ffi.chatModel.changeCurrentID(ChatModel.clientModeID);
|
|
widget.ffi.chatModel.toggleChatOverlay(chatInitPos: initPos);
|
|
});
|
|
}
|
|
|
|
voiceCall() {
|
|
return _MenuItemButton(
|
|
child: Text(translate('Voice call')),
|
|
ffi: widget.ffi,
|
|
onPressed: () => bind.sessionRequestVoiceCall(id: widget.id),
|
|
);
|
|
}
|
|
}
|
|
|
|
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(id: id),
|
|
color: _MenubarTheme.redColor,
|
|
hoverColor: _MenubarTheme.hoverRedColor);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RecordMenu extends StatelessWidget {
|
|
const _RecordMenu({Key? key}) : super(key: key);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
var ffi = Provider.of<FfiModel>(context);
|
|
final visible = ffi.permissions['recording'] != false;
|
|
if (!visible) return Offstage();
|
|
return Consumer<RecordingModel>(
|
|
builder: (context, value, child) => _IconMenuButton(
|
|
assetName: 'assets/rec.svg',
|
|
tooltip:
|
|
value.start ? 'Stop session recording' : 'Start session recording',
|
|
onPressed: () => value.toggle(),
|
|
color: value.start ? _MenubarTheme.redColor : _MenubarTheme.blueColor,
|
|
hoverColor: value.start
|
|
? _MenubarTheme.hoverRedColor
|
|
: _MenubarTheme.hoverBlueColor,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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(id, ffi.dialogManager),
|
|
color: _MenubarTheme.redColor,
|
|
hoverColor: _MenubarTheme.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;
|
|
const _IconMenuButton({
|
|
Key? key,
|
|
this.assetName,
|
|
this.icon,
|
|
required this.tooltip,
|
|
required this.color,
|
|
required this.hoverColor,
|
|
required this.onPressed,
|
|
this.hMargin,
|
|
this.vMargin,
|
|
}) : 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!,
|
|
color: Colors.white,
|
|
width: _MenubarTheme.buttonSize,
|
|
height: _MenubarTheme.buttonSize,
|
|
);
|
|
return SizedBox(
|
|
width: _MenubarTheme.buttonSize,
|
|
height: _MenubarTheme.buttonSize,
|
|
child: MenuItemButton(
|
|
style: ButtonStyle(
|
|
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(_MenubarTheme.iconRadius),
|
|
color: hover ? widget.hoverColor : widget.color,
|
|
),
|
|
child: icon))),
|
|
),
|
|
).marginSymmetric(
|
|
horizontal: widget.hMargin ?? _MenubarTheme.buttonHMargin,
|
|
vertical: widget.vMargin ?? _MenubarTheme.buttonVMargin);
|
|
}
|
|
}
|
|
|
|
class _IconSubmenuButton extends StatefulWidget {
|
|
final String? svg;
|
|
final Widget? icon;
|
|
final Color color;
|
|
final Color hoverColor;
|
|
final List<Widget> menuChildren;
|
|
final MenuStyle? menuStyle;
|
|
final FFI ffi;
|
|
|
|
_IconSubmenuButton(
|
|
{Key? key,
|
|
this.svg,
|
|
this.icon,
|
|
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!,
|
|
color: Colors.white,
|
|
width: _MenubarTheme.buttonSize,
|
|
height: _MenubarTheme.buttonSize,
|
|
);
|
|
return SizedBox(
|
|
width: _MenubarTheme.buttonSize,
|
|
height: _MenubarTheme.buttonSize,
|
|
child: SubmenuButton(
|
|
menuStyle: widget.menuStyle,
|
|
style: ButtonStyle(
|
|
padding: MaterialStatePropertyAll(EdgeInsets.zero),
|
|
overlayColor: MaterialStatePropertyAll(Colors.transparent)),
|
|
onHover: (value) => setState(() {
|
|
hover = value;
|
|
}),
|
|
child: Material(
|
|
type: MaterialType.transparency,
|
|
child: Ink(
|
|
decoration: BoxDecoration(
|
|
borderRadius:
|
|
BorderRadius.circular(_MenubarTheme.iconRadius),
|
|
color: hover ? widget.hoverColor : widget.color,
|
|
),
|
|
child: icon)),
|
|
menuChildren: widget.menuChildren
|
|
.map((e) => _buildPointerTrackWidget(e, widget.ffi))
|
|
.toList()))
|
|
.marginSymmetric(
|
|
horizontal: _MenubarTheme.buttonHMargin,
|
|
vertical: _MenubarTheme.buttonVMargin);
|
|
}
|
|
}
|
|
|
|
class _SubmenuButton extends StatelessWidget {
|
|
final List<Widget> 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(),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MenuItemButton extends StatelessWidget {
|
|
final VoidCallback? onPressed;
|
|
final Widget? trailingIcon;
|
|
final Widget? child;
|
|
final FFI ffi;
|
|
_MenuItemButton(
|
|
{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 _CheckboxMenuButton extends StatelessWidget {
|
|
final bool? value;
|
|
final ValueChanged<bool?>? onChanged;
|
|
final Widget? child;
|
|
final FFI ffi;
|
|
const _CheckboxMenuButton(
|
|
{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 _RadioMenuButton<T> extends StatelessWidget {
|
|
final T value;
|
|
final T? groupValue;
|
|
final ValueChanged<T?>? onChanged;
|
|
final Widget? child;
|
|
final FFI ffi;
|
|
const _RadioMenuButton(
|
|
{Key? key,
|
|
required this.value,
|
|
required this.groupValue,
|
|
required this.onChanged,
|
|
required this.child,
|
|
required this.ffi})
|
|
: 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 RxDouble fractionX;
|
|
final RxBool dragging;
|
|
final RxBool show;
|
|
const _DraggableShowHide({
|
|
Key? key,
|
|
required this.fractionX,
|
|
required this.dragging,
|
|
required this.show,
|
|
}) : super(key: key);
|
|
|
|
@override
|
|
State<_DraggableShowHide> createState() => _DraggableShowHideState();
|
|
}
|
|
|
|
class _DraggableShowHideState extends State<_DraggableShowHide> {
|
|
Offset position = Offset.zero;
|
|
Size size = Size.zero;
|
|
|
|
Widget _buildDraggable(BuildContext context) {
|
|
return Draggable(
|
|
axis: Axis.horizontal,
|
|
child: Icon(
|
|
Icons.drag_indicator,
|
|
size: 20,
|
|
color: Colors.grey[800],
|
|
),
|
|
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.fromWindow(ui.window).size;
|
|
widget.fractionX.value +=
|
|
(details.offset.dx - position.dx) / (mediaSize.width - size.width);
|
|
if (widget.fractionX.value < 0.35) {
|
|
widget.fractionX.value = 0.35;
|
|
}
|
|
if (widget.fractionX.value > 0.65) {
|
|
widget.fractionX.value = 0.65;
|
|
}
|
|
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 child = Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_buildDraggable(context),
|
|
TextButton(
|
|
onPressed: () => setState(() {
|
|
widget.show.value = !widget.show.value;
|
|
}),
|
|
child: Obx((() => Icon(
|
|
widget.show.isTrue ? Icons.expand_less : Icons.expand_more,
|
|
size: 20,
|
|
))),
|
|
),
|
|
],
|
|
);
|
|
return TextButtonTheme(
|
|
data: TextButtonThemeData(style: buttonStyle),
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
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,
|
|
),
|
|
);
|
|
}
|