mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-02-17 22:11:30 +08:00
* Repurposed the MacOS-specific platform channel mechanism for all platforms: - Renamed the channel from "org.rustdesk.rustdesk/macos" to "org.rustdesk.rustdesk/host". - Renamed _osxMethodChannel in platform_channel.dart to _hostMethodChannel. - Updated linux/my_application.cc to use the fl_* API to set up a Method Channel and to dispose it during my_application_dispose. - Updated windows/runner/flutter_window.cpp to use the C++ API to set up a Method Channel. - Updated the channel name in macos/Runner/MainFlutterWindow.swift. Signed-off-by: Jonathan Gilbert <logic@deltaq.org> * Added a method "bumpMouse" to the Platform Channel. Added a thunk to call the method through the channel to platform_channel.dart. Added implementation bump_mouse() in linux/my_application.cc using Gdk API calls. Updated host_channel_call_handler to process "bumpMouse" method call messages by calling bump_mouse. Added implementation Win32Desktop::BumpMouse in windows/runner/win32_desktop.cpp/.h. Updated the inline method call handler in flutter_window.cpp to handle "bumpMouse" method calls by calling Win32Desktop::BumpMouse. Updated the method call handler in macos/Runner/MainFlutterWindow.swift to handle "bumpMouse" method call messages. Updated MainFlutterWindow to use a subclass of FlutterViewController exposing access to mouseLocationOutsideOfEventStream. Signed-off-by: Jonathan Gilbert <logic@deltaq.org> * Added message type kWindowBumpMouse to the multiwindow window event model: - Added constant kWindowBumpMouse to consts.dart. - Updated the method handler attached to rustDeskWinManager by DesktopHomePageState to recognize kWindowBumpMouse and translate it to a call to RdPlatformChannel.bumpMouse. Signed-off-by: Jonathan Gilbert <logic@deltaq.org> * Centralized serialization of ScrollStyle values, moving JSON and string conversions into methods toString/fromString and toJson/fromJson within the type. Signed-off-by: Jonathan Gilbert <logic@deltaq.org> * Added new scroll style for edge scrolling: - Added ScrollStyle enum member "scrolledge". Added corresponding constant kRemoteScrollStyleEdge to consts.dart for the string serialized form. - Updated sites checking specifically for ScrollStyle.scrollbar to instead check for NOT ScrollStyle.scrollauto. - Added radio buttons for the new "ScrollEdge" style to desktop_setting_page.dart and remote_toolbar.dart. Added new string "ScrollEdge" to lang/template.rs. Signed-off-by: Jonathan Gilbert <logic@deltaq.org> * Implemented edge scrolling: - Added methods edgeScrollMouse and pushScrollPositionToUI to class CanvasModel in model.dart. - Added boolean parameter edgeScroll to handleMouse, handlePointerDevicePos and processEventToPeer in input_model.dart. - Updated handlePointerDevicePos in input_model.dart to call edgeScrollMouse on move events when the edgeScroll parameter is true. - Added convenience accessor useEdgeScroll to the InputModel class. Updated call sites to handleMouse to use it to supply the value for the edgeScroll parameter. Signed-off-by: Jonathan Gilbert <logic@deltaq.org> * Updated CanvasModel.edgeScrollMouse to be resilient to receiving events when _horizontal/_vertical aren't wired up to any UI. * Updated CanvasModel to take notifications of resizes via method notifyResize and to suppress edge scrolling briefly after a resize. Updated the onWindowResized handler in tabbar_widget.dart to call notifyResize on the canvasModel of any RemotePage tabs. * Half a go at fixing MainFlutterWindow.swift. * Copilot feedback. * Applied fix suggested by Copilot in its explanation of the build error. * Fixed a couple of silly errors in windows/runner/flutter_window.cpp. * Fixed MainFlutterWindow.swift build errors. Co-Authored-By: fufesou <linlong1266@gmail.com> Signed-off-by: Jonathan Gilbert <logic@deltaq.org> * Moved new translation to the end of template.rs. Reran res/lang.py. Signed-off-by: Jonathan Gilbert <logic@deltaq.org> * Switched MainFlutterWindow.swift to use NSEvent.mouseLocation. * Updated MainFlutterWindow.swift code based on build error. * Fixed silly typo. * Reintroduced the coordinate system translation in MainFlutterWindow.swift. * Updated edgeScrollMouse in model.dart to add a "safe zone" around the window frame that doesn't trigger edge scrolling. * Updated the bumpMouse handler in MainFlutterWindow.swift to call CGAssociateMouseAndMouseCursorPosition to cancel event suppression. * Added debug annotation to the onWindowResized event in tabbar_widget.dart. * Fix parameter type for CGAssociateMouseAndMouseCursorPosition in MainFlutterWindow.swift. * tabbar_widget.dart: onWindowResized -> onWindowResize * Removed temporary diagnostic debugPrint from tabbar_widget.dart. * Updated MainFlutterWindow.swift to obtain the mouse position by creating a dummy CGEvent. The old NSEvent.mouseLocation code is left as a fallback. * The documentation said to be sure to call CFRelease, but apparently it's a build error to do so. :-P * Replaced CGEvent calls in MainFlutterWindow.swift with uses of the CGEvent wrapper struct. * Added argument label to call to CGEvent.init. * Changed mouseLoc from piecewise assignment to assignment of the whole structure, as it is not yet initialized at that point. * Linux platform channel: Refactored bump_mouse, setting the stage for a future Wayland implementation. - Made a new top-level bump_mouse method in bump_mouse.cc/.h. - Moved the X11-specific implementation to bump_mouse_x11 in bump_mouse_x11.cc/h. Reworked the bumpMouse operation to have a boolean return value: - Updated bumpMouse in platform_channel.dart to return a Future<bool> instead of a Future<void>. - Windows platform channel: Updated BumpMouse in win32_desktop.cpp to return a bool value. Updated the method call handler "bumpMouse" branch in flutter_window.cpp to propagate the BumpMouse return value back to the originating MethodCall. - MacOS platform channel: Updated the "bumpMouse" branch in the method call handler in MainFlutterWindow.swift to pass true or false into the 'result()' call. - Linux platform channel: Updated the bump_mouse top-level method and its underlying implementation bump_mouse_x11 to return bool values. Updated the "bumpMouse" branch of host_channel_call_handler in my_application.cc to propagate the result value back up the method channel. - Updated the kWindowBumpMouse branch of the method handler registered in desktop_home_page.dart to propagate a return value from RdplatformChannel.bumpMouse. * Reworked the edge scrolling computations in model.dart to use Vector2 from the vector_math package. Updated pubspec.yaml to declare a dependency on vector_math. * Added an alternative edge scrolling mechanism for when "Bump Mouse" functionality is unavailable: - Added methods setEdgeScrollTimer and cancelEdgeScrollTimer to model.dart, along with a few state fields. - Updated edgeScrollMouse to latch the (x, y) coordinate of the last edge scroll event, in case it will be autorepeating. - Updated edgeScrollMouse to check whether the call to the kWindowBumpMouse method of rustDeskWinManager (and thus the underlying bump_mouse method) succeeded, and to switch to timer-based autorepeat if it fails. Made edgeScrollMouse async to allow awaiting the result of the kWindowBumpMouse method call. - Updated input_model.dart to call cancelEdgeScrollTimer when a new move event is being processed. - Updated remote_page.dart to call cancelEdgeScrollTimer when the pointer exits the area represented by the view. * Fixed scroll percentage math in edgeScrollMouse in model.dart. * Fixed declared return value for Win32Desktop::BumpMouse in win32_desktop.h. * Fixed vector_math dependency version in pubspec.yaml to be compatible with the codebase standard Flutter version. * Added class EdgeScrollFallbackState to model.dart for tracking the state of the edge scroll fallback strategy. Factored out the actual edge scrolling action from CanvasModel.edgeScrollMouse to new method performEdgeScroll so that EdgeScrollFallbackState can call it. Updated edgeScrollMouse to not call performEdgeScroll when it's enabling the fallback strategy. Updated CanvasModel to use EdgeScrollFallbackState instead of directly tracking the state. Removed method setEdgeScrollTimer. Added method initializeEdgeScrollFallback to CanvasModel that takes a TickerProvider. Updated _RemotePageState to include the mixin TickerProviderStateMixin. Updated _RemotePageState.initState to call canvasModel.initializeEdgeScrollFallback. Updated handlePointerDevicePos in input_model.dart to not call cancelEdgeScrollTimer before edgeScrollMouse. Renamed CanvasModel.cancelEdgeScrollTimer to CanvasModel.cancelEdgeScroll. Updated the calculations in CanvasModel.edgeScrollMouse to only factor in the safe zone if BumpMouse is working. (Otherwise the problem with resizing can't possibly occur.) * Updated CanvasModel.edgeScrollMouse in model.dart to handle the situation where only one of the scrollbars is active. Factored extraction of scrollbar data into new function getScrollInfo. * Updated onWindowResize in tabbar_widget.dart to be resilient to RemotePage instances that don't yet have an ffi reference. Added property hasFFI to remote_page.dart. * Removed debug output from model.dart. * PR feedback: - Added filtering to diagnostic output in the method handler in desktop_home_page.dart to exclude the very chatty kWindowBumpMouse-related output. - Removed the diagnostic output from bumpMouse in platform_channel.dart for the same reason. - Updated setScrollPercent to coalesce NaN values for x and y to 0. - Initialized the GError pointer variable passed into fl_method_call_respond_success in linux/my_application.cc to NULL. - Added bounds checking of the argument values in the EncodableList branch of the "bumpMouse" method call handler in windows/runner/flutter_window.cpp. * Added a latch mechanism that keeps edge scrolling disabled until the cursor is observed to be in the inner area bounded by the edge scroll areas: - Added tristate enumerated type EdgeScrollState to model.dart. In addition to inactive and active states, there is state armed which behaves like inactive but can transition to active when conditions are met. - Added a field to CanvasModel of type EdgeScrollState. Added methods disableEdgeScroll and rearmEdgeScroll. - Updated enterView to call canvasModel.rearmEdgeScroll and leaveView to call canvasModel.disableEdgeScroll in remote_page.dart. - Updated edgeScrollMouse to check the state, disabling edge scrolling when the state is not active and transitioning from armed to active when the mouse is in the interior space. - Removed the notifyResize/_suppressEdgeScroll mechanism from CanvasModel in model.dart as it is no longer necessary. - Removed the "safe zone" mechanism from CanvasModel.edgeScrollMouse in model.dart as it is no longer necessary. - Switched the onWindowResize handler in DesktopTabState in tabbar_widget.dart back to onWindowResized, now that it is no longer delivering canvasModel.notifyResize to all RemotePage tabs. * Fixed memory leak: Added call to free GError object returned by Flutter API in the event of an error. * PR feedback: - Copilot: Use type annotations. - Copilot: Condition to stop edge scrolling when fallback strategy is in use and the mouse is moved back to the centre. - Copilot: Check FLValue type before calling fl_value_get_int. - Copilot: Support list-style method channel dispatch in "bumpMouse" handler for macos as the linux and windows implementations already do. - Naming convention for constants. - Left-over variable from previous strategy: _suppressEdgeScroll. - Unnecessary extra parentheses in edge scroll area conditions. * Removed property suppressEdgeScroll referencing now-removed field _suppressEdgeScroll in model.dart. Removed accidental extra blank line in MainFlutterWindow.swift. * Switched CanvasModel.setScrollPercent to use double.isFinite instead of double.isNaN to test for proper numerical values. * PR feedback: - Copilot: Use Vector2.length2 instead of Vector2.length to avoid an unnecessary sqrt in comparison with zero. - Copilot: Baleet unnecessary semicolons from Swift code. * PR feedback: - Copilot: Check argList.count before indexing it * Oops with the semicolons again. * Edge scroll, active local cursor Signed-off-by: fufesou <linlong1266@gmail.com> * Remove duplicated condition checks Signed-off-by: fufesou <linlong1266@gmail.com> * Chore Signed-off-by: fufesou <linlong1266@gmail.com> * PR feedback: - Copilot: Removed unused property hasFFI from remote_page.dart. - Copilot: Updated updateScrollStyle in model.dart to be resilient to the possibility of bind.sessionGetScrollStyle returning null. * Factored local cursor updates out of CanvasModel.moveDesktopMouse in model.dart, adding new methods activateLocalCursor and updateLocalCursor. Updated handlePointerDevicePos in input_model.dart to call canvasModel.updateLocalCursor on every mouse event. Updated initState in remote_page.dart to schedule a call to canvasModel.activateLocalCursor as a first-image callback. * Updated the explanation for rounding away from 0 in edgeScrollMouse in model.dart. --------- Signed-off-by: Jonathan Gilbert <logic@deltaq.org> Signed-off-by: fufesou <linlong1266@gmail.com> Co-authored-by: fufesou <linlong1266@gmail.com>
2765 lines
84 KiB
Dart
2765 lines
84 KiB
Dart
import 'dart:convert';
|
|
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_hbb/common/widgets/audio_input.dart';
|
|
import 'package:flutter_hbb/common/widgets/dialog.dart';
|
|
import 'package:flutter_hbb/common/widgets/toolbar.dart';
|
|
import 'package:flutter_hbb/models/chat_model.dart';
|
|
import 'package:flutter_hbb/models/state_model.dart';
|
|
import 'package:flutter_hbb/consts.dart';
|
|
import 'package:flutter_hbb/utils/multi_window_manager.dart';
|
|
import 'package:flutter_hbb/plugin/widgets/desc_ui.dart';
|
|
import 'package:flutter_hbb/plugin/common.dart';
|
|
import 'package:flutter_svg/flutter_svg.dart';
|
|
import 'package:get/get.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:debounce_throttle/debounce_throttle.dart';
|
|
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
|
import 'package:window_size/window_size.dart' as window_size;
|
|
|
|
import '../../common.dart';
|
|
import '../../models/model.dart';
|
|
import '../../models/platform_model.dart';
|
|
import '../../common/shared_state.dart';
|
|
import './popup_menu.dart';
|
|
import './kb_layout_type_chooser.dart';
|
|
import 'package:flutter_hbb/utils/scale.dart';
|
|
|
|
class ToolbarState {
|
|
late RxBool _pin;
|
|
|
|
bool isShowInited = false;
|
|
RxBool show = false.obs;
|
|
|
|
ToolbarState() {
|
|
_pin = RxBool(false);
|
|
final s = bind.getLocalFlutterOption(k: kOptionRemoteMenubarState);
|
|
if (s.isEmpty) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
final m = jsonDecode(s);
|
|
if (m != null) {
|
|
_pin = RxBool(m['pin'] ?? false);
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Failed to decode toolbar state ${e.toString()}');
|
|
}
|
|
}
|
|
|
|
bool get pin => _pin.value;
|
|
|
|
switchShow(SessionID sessionId) async {
|
|
bind.sessionToggleOption(
|
|
sessionId: sessionId, value: kOptionCollapseToolbar);
|
|
show.value = !show.value;
|
|
}
|
|
|
|
initShow(SessionID sessionId) async {
|
|
if (!isShowInited) {
|
|
show.value = !(await bind.sessionGetToggleOption(
|
|
sessionId: sessionId, arg: kOptionCollapseToolbar) ??
|
|
false);
|
|
isShowInited = true;
|
|
}
|
|
}
|
|
|
|
switchPin() async {
|
|
_pin.value = !_pin.value;
|
|
// Save everytime changed, as this func will not be called frequently
|
|
await _savePin();
|
|
}
|
|
|
|
setPin(bool v) async {
|
|
if (_pin.value != v) {
|
|
_pin.value = v;
|
|
// Save everytime changed, as this func will not be called frequently
|
|
await _savePin();
|
|
}
|
|
}
|
|
|
|
_savePin() async {
|
|
bind.setLocalFlutterOption(
|
|
k: kOptionRemoteMenubarState, v: jsonEncode({'pin': _pin.value}));
|
|
}
|
|
}
|
|
|
|
class _ToolbarTheme {
|
|
static const Color blueColor = MyTheme.button;
|
|
static const Color hoverBlueColor = MyTheme.accent;
|
|
static Color inactiveColor = Colors.grey[800]!;
|
|
static Color hoverInactiveColor = Colors.grey[850]!;
|
|
|
|
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 = 2;
|
|
static const double buttonVMargin = 6;
|
|
static const double iconRadius = 8;
|
|
static const double elevation = 3;
|
|
|
|
static double dividerSpaceToAction = isWindows ? 8 : 14;
|
|
|
|
static double menuBorderRadius = isWindows ? 5.0 : 7.0;
|
|
static EdgeInsets menuPadding = isWindows
|
|
? EdgeInsets.fromLTRB(4, 12, 4, 12)
|
|
: EdgeInsets.fromLTRB(6, 14, 6, 14);
|
|
static const double menuButtonBorderRadius = 3.0;
|
|
|
|
static Color borderColor(BuildContext context) =>
|
|
MyTheme.color(context).border3 ?? MyTheme.border;
|
|
|
|
static Color? dividerColor(BuildContext context) =>
|
|
MyTheme.color(context).divider;
|
|
|
|
static MenuStyle defaultMenuStyle(BuildContext context) => MenuStyle(
|
|
side: MaterialStateProperty.all(BorderSide(
|
|
width: 1,
|
|
color: borderColor(context),
|
|
)),
|
|
shape: MaterialStatePropertyAll(RoundedRectangleBorder(
|
|
borderRadius:
|
|
BorderRadius.circular(_ToolbarTheme.menuBorderRadius))),
|
|
padding: MaterialStateProperty.all(_ToolbarTheme.menuPadding),
|
|
);
|
|
static final defaultMenuButtonStyle = ButtonStyle(
|
|
backgroundColor: MaterialStatePropertyAll(Colors.transparent),
|
|
padding: MaterialStatePropertyAll(EdgeInsets.zero),
|
|
overlayColor: MaterialStatePropertyAll(Colors.transparent),
|
|
);
|
|
|
|
static Widget borderWrapper(
|
|
BuildContext context, Widget child, BorderRadius borderRadius) {
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
border: Border.all(
|
|
color: borderColor(context),
|
|
width: 1,
|
|
),
|
|
borderRadius: borderRadius,
|
|
),
|
|
child: child,
|
|
);
|
|
}
|
|
}
|
|
|
|
typedef DismissFunc = void Function();
|
|
|
|
class RemoteMenuEntry {
|
|
static MenuEntryButton<String> insertLock(
|
|
SessionID sessionId,
|
|
EdgeInsets? padding, {
|
|
DismissFunc? dismissFunc,
|
|
DismissCallback? dismissCallback,
|
|
}) {
|
|
return MenuEntryButton<String>(
|
|
childBuilder: (TextStyle? style) => Text(
|
|
translate('Insert Lock'),
|
|
style: style,
|
|
),
|
|
proc: () {
|
|
bind.sessionLockScreen(sessionId: sessionId);
|
|
if (dismissFunc != null) {
|
|
dismissFunc();
|
|
}
|
|
},
|
|
padding: padding,
|
|
dismissOnClicked: true,
|
|
dismissCallback: dismissCallback,
|
|
);
|
|
}
|
|
|
|
static insertCtrlAltDel(
|
|
SessionID sessionId,
|
|
EdgeInsets? padding, {
|
|
DismissFunc? dismissFunc,
|
|
DismissCallback? dismissCallback,
|
|
}) {
|
|
return MenuEntryButton<String>(
|
|
childBuilder: (TextStyle? style) => Text(
|
|
translate("Insert Ctrl + Alt + Del"),
|
|
style: style,
|
|
),
|
|
proc: () {
|
|
bind.sessionCtrlAltDel(sessionId: sessionId);
|
|
if (dismissFunc != null) {
|
|
dismissFunc();
|
|
}
|
|
},
|
|
padding: padding,
|
|
dismissOnClicked: true,
|
|
dismissCallback: dismissCallback,
|
|
);
|
|
}
|
|
}
|
|
|
|
class RemoteToolbar extends StatefulWidget {
|
|
final String id;
|
|
final FFI ffi;
|
|
final ToolbarState state;
|
|
final Function(int, Function(bool)) onEnterOrLeaveImageSetter;
|
|
final Function(int) onEnterOrLeaveImageCleaner;
|
|
final Function(VoidCallback) setRemoteState;
|
|
|
|
RemoteToolbar({
|
|
Key? key,
|
|
required this.id,
|
|
required this.ffi,
|
|
required this.state,
|
|
required this.onEnterOrLeaveImageSetter,
|
|
required this.onEnterOrLeaveImageCleaner,
|
|
required this.setRemoteState,
|
|
}) : super(key: key);
|
|
|
|
@override
|
|
State<RemoteToolbar> createState() => _RemoteToolbarState();
|
|
}
|
|
|
|
class _RemoteToolbarState extends State<RemoteToolbar> {
|
|
late Debouncer<int> _debouncerHide;
|
|
bool _isCursorOverImage = false;
|
|
final _fractionX = 0.5.obs;
|
|
final _dragging = false.obs;
|
|
|
|
int get windowId => stateGlobal.windowId;
|
|
|
|
void _setFullscreen(bool v) {
|
|
stateGlobal.setFullscreen(v);
|
|
// stateGlobal.fullscreen is RxBool now, no need to call setState.
|
|
// setState(() {});
|
|
}
|
|
|
|
RxBool get show => widget.state.show;
|
|
bool get pin => widget.state.pin;
|
|
|
|
PeerInfo get pi => widget.ffi.ffiModel.pi;
|
|
FfiModel get ffiModel => widget.ffi.ffiModel;
|
|
|
|
triggerAutoHide() => _debouncerHide.value = _debouncerHide.value + 1;
|
|
|
|
void _minimize() async =>
|
|
await WindowController.fromWindowId(windowId).minimize();
|
|
|
|
@override
|
|
initState() {
|
|
super.initState();
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
|
_fractionX.value = double.tryParse(await bind.sessionGetOption(
|
|
sessionId: widget.ffi.sessionId,
|
|
arg: 'remote-menubar-drag-x') ??
|
|
'0.5') ??
|
|
0.5;
|
|
});
|
|
|
|
_debouncerHide = Debouncer<int>(
|
|
Duration(milliseconds: 5000),
|
|
onChanged: _debouncerHideProc,
|
|
initialValue: 0,
|
|
);
|
|
|
|
widget.onEnterOrLeaveImageSetter(identityHashCode(this), (enter) {
|
|
if (enter) {
|
|
triggerAutoHide();
|
|
_isCursorOverImage = true;
|
|
} else {
|
|
_isCursorOverImage = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
_debouncerHideProc(int v) {
|
|
if (!pin && show.isTrue && _isCursorOverImage && _dragging.isFalse) {
|
|
show.value = false;
|
|
}
|
|
}
|
|
|
|
@override
|
|
dispose() {
|
|
super.dispose();
|
|
|
|
widget.onEnterOrLeaveImageCleaner(identityHashCode(this));
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Align(
|
|
alignment: Alignment.topCenter,
|
|
child: Obx(() => show.value
|
|
? _buildToolbar(context)
|
|
: _buildDraggableShowHide(context)),
|
|
);
|
|
}
|
|
|
|
Widget _buildDraggableShowHide(BuildContext context) {
|
|
return Obx(() {
|
|
if (show.isTrue && _dragging.isFalse) {
|
|
triggerAutoHide();
|
|
}
|
|
final borderRadius = BorderRadius.vertical(
|
|
bottom: Radius.circular(5),
|
|
);
|
|
return Align(
|
|
alignment: FractionalOffset(_fractionX.value, 0),
|
|
child: Offstage(
|
|
offstage: _dragging.isTrue,
|
|
child: Material(
|
|
elevation: _ToolbarTheme.elevation,
|
|
shadowColor: MyTheme.color(context).shadow,
|
|
borderRadius: borderRadius,
|
|
child: _DraggableShowHide(
|
|
id: widget.id,
|
|
sessionId: widget.ffi.sessionId,
|
|
dragging: _dragging,
|
|
fractionX: _fractionX,
|
|
toolbarState: widget.state,
|
|
setFullscreen: _setFullscreen,
|
|
setMinimize: _minimize,
|
|
borderRadius: borderRadius,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
});
|
|
}
|
|
|
|
Widget _buildToolbar(BuildContext context) {
|
|
final List<Widget> toolbarItems = [];
|
|
toolbarItems.add(_PinMenu(state: widget.state));
|
|
if (!isWebDesktop) {
|
|
toolbarItems.add(_MobileActionMenu(ffi: widget.ffi));
|
|
}
|
|
|
|
toolbarItems.add(Obx(() {
|
|
if (PrivacyModeState.find(widget.id).isEmpty &&
|
|
pi.displaysCount.value > 1) {
|
|
return _MonitorMenu(
|
|
id: widget.id,
|
|
ffi: widget.ffi,
|
|
setRemoteState: widget.setRemoteState);
|
|
} else {
|
|
return Offstage();
|
|
}
|
|
}));
|
|
|
|
toolbarItems
|
|
.add(_ControlMenu(id: widget.id, ffi: widget.ffi, state: widget.state));
|
|
toolbarItems.add(_DisplayMenu(
|
|
id: widget.id,
|
|
ffi: widget.ffi,
|
|
state: widget.state,
|
|
setFullscreen: _setFullscreen,
|
|
));
|
|
// Do not show keyboard for camera connection type.
|
|
if (widget.ffi.connType == ConnType.defaultConn) {
|
|
toolbarItems.add(_KeyboardMenu(id: widget.id, ffi: widget.ffi));
|
|
}
|
|
toolbarItems.add(_ChatMenu(id: widget.id, ffi: widget.ffi));
|
|
if (!isWeb) {
|
|
toolbarItems.add(_VoiceCallMenu(id: widget.id, ffi: widget.ffi));
|
|
}
|
|
if (!isWeb) toolbarItems.add(_RecordMenu());
|
|
toolbarItems.add(_CloseMenu(id: widget.id, ffi: widget.ffi));
|
|
final toolbarBorderRadius = BorderRadius.all(Radius.circular(4.0));
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Material(
|
|
elevation: _ToolbarTheme.elevation,
|
|
shadowColor: MyTheme.color(context).shadow,
|
|
borderRadius: toolbarBorderRadius,
|
|
color: Theme.of(context)
|
|
.menuBarTheme
|
|
.style
|
|
?.backgroundColor
|
|
?.resolve(MaterialState.values.toSet()),
|
|
child: SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
child: Theme(
|
|
data: themeData(),
|
|
child: _ToolbarTheme.borderWrapper(
|
|
context,
|
|
Row(
|
|
children: [
|
|
SizedBox(width: _ToolbarTheme.buttonHMargin * 2),
|
|
...toolbarItems,
|
|
SizedBox(width: _ToolbarTheme.buttonHMargin * 2)
|
|
],
|
|
),
|
|
toolbarBorderRadius),
|
|
),
|
|
),
|
|
),
|
|
_buildDraggableShowHide(context),
|
|
],
|
|
);
|
|
}
|
|
|
|
ThemeData themeData() {
|
|
return Theme.of(context).copyWith(
|
|
menuButtonTheme: MenuButtonThemeData(
|
|
style: ButtonStyle(
|
|
minimumSize: MaterialStatePropertyAll(Size(64, 32)),
|
|
textStyle: MaterialStatePropertyAll(
|
|
TextStyle(fontWeight: FontWeight.normal),
|
|
),
|
|
shape: MaterialStatePropertyAll(RoundedRectangleBorder(
|
|
borderRadius:
|
|
BorderRadius.circular(_ToolbarTheme.menuButtonBorderRadius))),
|
|
),
|
|
),
|
|
dividerTheme: DividerThemeData(
|
|
space: _ToolbarTheme.dividerSpaceToAction,
|
|
color: _ToolbarTheme.dividerColor(context),
|
|
),
|
|
menuBarTheme: MenuBarThemeData(
|
|
style: MenuStyle(
|
|
padding: MaterialStatePropertyAll(EdgeInsets.zero),
|
|
elevation: MaterialStatePropertyAll(0),
|
|
shape: MaterialStatePropertyAll(BeveledRectangleBorder()),
|
|
).copyWith(
|
|
backgroundColor:
|
|
Theme.of(context).menuBarTheme.style?.backgroundColor)),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PinMenu extends StatelessWidget {
|
|
final ToolbarState state;
|
|
const _PinMenu({Key? key, required this.state}) : super(key: key);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Obx(
|
|
() => _IconMenuButton(
|
|
assetName: state.pin ? "assets/pinned.svg" : "assets/unpinned.svg",
|
|
tooltip: state.pin ? 'Unpin Toolbar' : 'Pin Toolbar',
|
|
onPressed: state.switchPin,
|
|
color:
|
|
state.pin ? _ToolbarTheme.blueColor : _ToolbarTheme.inactiveColor,
|
|
hoverColor: state.pin
|
|
? _ToolbarTheme.hoverBlueColor
|
|
: _ToolbarTheme.hoverInactiveColor,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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 Obx(() => _IconMenuButton(
|
|
assetName: 'assets/actions_mobile.svg',
|
|
tooltip: 'Mobile Actions',
|
|
onPressed: () => ffi.dialogManager.setMobileActionsOverlayVisible(
|
|
!ffi.dialogManager.mobileActionsOverlayVisible.value),
|
|
color: ffi.dialogManager.mobileActionsOverlayVisible.isTrue
|
|
? _ToolbarTheme.blueColor
|
|
: _ToolbarTheme.inactiveColor,
|
|
hoverColor: ffi.dialogManager.mobileActionsOverlayVisible.isTrue
|
|
? _ToolbarTheme.hoverBlueColor
|
|
: _ToolbarTheme.hoverInactiveColor,
|
|
));
|
|
}
|
|
}
|
|
|
|
class _MonitorMenu extends StatelessWidget {
|
|
final String id;
|
|
final FFI ffi;
|
|
final Function(VoidCallback) setRemoteState;
|
|
const _MonitorMenu({
|
|
Key? key,
|
|
required this.id,
|
|
required this.ffi,
|
|
required this.setRemoteState,
|
|
}) : super(key: key);
|
|
|
|
bool get showMonitorsToolbar =>
|
|
bind.mainGetUserDefaultOption(key: kKeyShowMonitorsToolbar) == 'Y';
|
|
|
|
bool get supportIndividualWindows =>
|
|
!isWeb && ffi.ffiModel.pi.isSupportMultiDisplay;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => showMonitorsToolbar
|
|
? buildMultiMonitorMenu(context)
|
|
: Obx(() => buildMonitorMenu(context));
|
|
|
|
Widget buildMonitorMenu(BuildContext context) {
|
|
final width = SimpleWrapper<double>(0);
|
|
final monitorsIcon =
|
|
globalMonitorsWidget(width, Colors.white, Colors.black38);
|
|
return _IconSubmenuButton(
|
|
tooltip: 'Select Monitor',
|
|
icon: monitorsIcon,
|
|
ffi: ffi,
|
|
width: width.value,
|
|
color: _ToolbarTheme.blueColor,
|
|
hoverColor: _ToolbarTheme.hoverBlueColor,
|
|
menuStyle: MenuStyle(
|
|
padding:
|
|
MaterialStatePropertyAll(EdgeInsets.symmetric(horizontal: 6))),
|
|
menuChildrenGetter: () => [buildMonitorSubmenuWidget(context)]);
|
|
}
|
|
|
|
Widget buildMultiMonitorMenu(BuildContext context) {
|
|
return Row(children: buildMonitorList(context, true));
|
|
}
|
|
|
|
Widget buildMonitorSubmenuWidget(BuildContext context) {
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Row(children: buildMonitorList(context, false)),
|
|
supportIndividualWindows ? Divider() : Offstage(),
|
|
supportIndividualWindows ? chooseDisplayBehavior() : Offstage(),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget chooseDisplayBehavior() {
|
|
final value =
|
|
bind.sessionGetDisplaysAsIndividualWindows(sessionId: ffi.sessionId) ==
|
|
'Y';
|
|
return CkbMenuButton(
|
|
value: value,
|
|
onChanged: (value) async {
|
|
if (value == null) return;
|
|
await bind.sessionSetDisplaysAsIndividualWindows(
|
|
sessionId: ffi.sessionId, value: value ? 'Y' : 'N');
|
|
},
|
|
ffi: ffi,
|
|
child: Text(translate('Show displays as individual windows')));
|
|
}
|
|
|
|
buildOneMonitorButton(i, curDisplay) => Text(
|
|
'${i + 1}',
|
|
style: TextStyle(
|
|
color: i == curDisplay
|
|
? _ToolbarTheme.blueColor
|
|
: _ToolbarTheme.inactiveColor,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
);
|
|
|
|
List<Widget> buildMonitorList(BuildContext context, bool isMulti) {
|
|
final List<Widget> monitorList = [];
|
|
final pi = ffi.ffiModel.pi;
|
|
|
|
buildMonitorButton(int i) => Obx(() {
|
|
RxInt display = CurrentDisplayState.find(id);
|
|
|
|
final isAllMonitors = i == kAllDisplayValue;
|
|
final width = SimpleWrapper<double>(0);
|
|
Widget? monitorsIcon;
|
|
if (isAllMonitors) {
|
|
monitorsIcon = globalMonitorsWidget(
|
|
width, Colors.white, _ToolbarTheme.blueColor);
|
|
}
|
|
return _IconMenuButton(
|
|
tooltip: isMulti
|
|
? ''
|
|
: isAllMonitors
|
|
? 'all monitors'
|
|
: '#${i + 1} monitor',
|
|
hMargin: isMulti ? null : 6,
|
|
vMargin: isMulti ? null : 12,
|
|
topLevel: false,
|
|
color: i == display.value
|
|
? _ToolbarTheme.blueColor
|
|
: _ToolbarTheme.inactiveColor,
|
|
hoverColor: i == display.value
|
|
? _ToolbarTheme.hoverBlueColor
|
|
: _ToolbarTheme.hoverInactiveColor,
|
|
width: isAllMonitors ? width.value : null,
|
|
icon: isAllMonitors
|
|
? monitorsIcon
|
|
: Container(
|
|
alignment: AlignmentDirectional.center,
|
|
constraints:
|
|
const BoxConstraints(minHeight: _ToolbarTheme.height),
|
|
child: Stack(
|
|
alignment: Alignment.center,
|
|
children: [
|
|
SvgPicture.asset(
|
|
"assets/screen.svg",
|
|
colorFilter:
|
|
ColorFilter.mode(Colors.white, BlendMode.srcIn),
|
|
),
|
|
Obx(() => buildOneMonitorButton(i, display.value)),
|
|
],
|
|
),
|
|
),
|
|
onPressed: () => onPressed(i, pi, isMulti),
|
|
);
|
|
});
|
|
|
|
for (int i = 0; i < pi.displays.length; i++) {
|
|
monitorList.add(buildMonitorButton(i));
|
|
}
|
|
if (supportIndividualWindows && pi.displays.length > 1) {
|
|
monitorList.add(buildMonitorButton(kAllDisplayValue));
|
|
}
|
|
return monitorList;
|
|
}
|
|
|
|
globalMonitorsWidget(
|
|
SimpleWrapper<double> width, Color activeTextColor, Color activeBgColor) {
|
|
getMonitors() {
|
|
final pi = ffi.ffiModel.pi;
|
|
RxInt display = CurrentDisplayState.find(id);
|
|
final rect = ffi.ffiModel.globalDisplaysRect();
|
|
if (rect == null) {
|
|
return Offstage();
|
|
}
|
|
|
|
final scale = _ToolbarTheme.buttonSize / rect.height * 0.75;
|
|
final startY = (_ToolbarTheme.buttonSize - rect.height * scale) * 0.5;
|
|
final startX = startY;
|
|
|
|
final children = <Widget>[];
|
|
for (var i = 0; i < pi.displays.length; i++) {
|
|
final d = pi.displays[i];
|
|
double s = d.scale;
|
|
int dWidth = d.width.toDouble() ~/ s;
|
|
int dHeight = d.height.toDouble() ~/ s;
|
|
final fontSize = (dWidth * scale < dHeight * scale
|
|
? dWidth * scale
|
|
: dHeight * scale) *
|
|
0.65;
|
|
children.add(Positioned(
|
|
left: (d.x - rect.left) * scale + startX,
|
|
top: (d.y - rect.top) * scale + startY,
|
|
width: dWidth * scale,
|
|
height: dHeight * scale,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
border: Border.all(
|
|
color: Colors.grey,
|
|
width: 1.0,
|
|
),
|
|
color: display.value == i ? activeBgColor : Colors.white,
|
|
),
|
|
child: Center(
|
|
child: Text(
|
|
'${i + 1}',
|
|
style: TextStyle(
|
|
color: display.value == i
|
|
? activeTextColor
|
|
: _ToolbarTheme.inactiveColor,
|
|
fontSize: fontSize,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
)),
|
|
),
|
|
));
|
|
}
|
|
width.value = rect.width * scale + startX * 2;
|
|
return SizedBox(
|
|
width: width.value,
|
|
height: rect.height * scale + startY * 2,
|
|
child: Stack(
|
|
children: children,
|
|
),
|
|
);
|
|
}
|
|
|
|
return Stack(
|
|
alignment: Alignment.center,
|
|
children: [
|
|
SizedBox(height: _ToolbarTheme.buttonSize),
|
|
getMonitors(),
|
|
],
|
|
);
|
|
}
|
|
|
|
onPressed(int i, PeerInfo pi, bool isMulti) {
|
|
if (!isMulti) {
|
|
// If show monitors in toolbar(`buildMultiMonitorMenu()`), then the menu will dismiss automatically.
|
|
_menuDismissCallback(ffi);
|
|
}
|
|
RxInt display = CurrentDisplayState.find(id);
|
|
if (display.value != i) {
|
|
final isChooseDisplayToOpenInNewWindow = pi.isSupportMultiDisplay &&
|
|
bind.sessionGetDisplaysAsIndividualWindows(
|
|
sessionId: ffi.sessionId) ==
|
|
'Y';
|
|
if (isChooseDisplayToOpenInNewWindow) {
|
|
openMonitorInNewTabOrWindow(i, ffi.id, pi);
|
|
} else {
|
|
openMonitorInTheSameTab(i, ffi, pi, updateCursorPos: !isMulti);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
class _ControlMenu extends StatelessWidget {
|
|
final String id;
|
|
final FFI ffi;
|
|
final ToolbarState state;
|
|
_ControlMenu(
|
|
{Key? key, required this.id, required this.ffi, required this.state})
|
|
: super(key: key);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return _IconSubmenuButton(
|
|
tooltip: 'Control Actions',
|
|
svg: "assets/actions.svg",
|
|
color: _ToolbarTheme.blueColor,
|
|
hoverColor: _ToolbarTheme.hoverBlueColor,
|
|
ffi: ffi,
|
|
menuChildrenGetter: () => toolbarControls(context, id, ffi).map((e) {
|
|
if (e.divider) {
|
|
return Divider();
|
|
} else {
|
|
return MenuButton(
|
|
child: e.child,
|
|
onPressed: e.onPressed,
|
|
ffi: ffi,
|
|
trailingIcon: e.trailingIcon);
|
|
}
|
|
}).toList());
|
|
}
|
|
}
|
|
|
|
class ScreenAdjustor {
|
|
final String id;
|
|
final FFI ffi;
|
|
final VoidCallback cbExitFullscreen;
|
|
window_size.Screen? _screen;
|
|
|
|
ScreenAdjustor({
|
|
required this.id,
|
|
required this.ffi,
|
|
required this.cbExitFullscreen,
|
|
});
|
|
|
|
bool get isFullscreen => stateGlobal.fullscreen.isTrue;
|
|
int get windowId => stateGlobal.windowId;
|
|
|
|
adjustWindow(BuildContext context) {
|
|
return futureBuilder(
|
|
future: isWindowCanBeAdjusted(),
|
|
hasData: (data) {
|
|
final visible = data as bool;
|
|
if (!visible) return Offstage();
|
|
return Column(
|
|
children: [
|
|
MenuButton(
|
|
child: Text(translate('Adjust Window')),
|
|
onPressed: () => doAdjustWindow(context),
|
|
ffi: ffi),
|
|
Divider(),
|
|
],
|
|
);
|
|
});
|
|
}
|
|
|
|
doAdjustWindow(BuildContext context) async {
|
|
await updateScreen();
|
|
if (_screen != null) {
|
|
cbExitFullscreen();
|
|
double scale = _screen!.scaleFactor;
|
|
final wndRect = await WindowController.fromWindowId(windowId).getFrame();
|
|
final mediaSize = MediaQueryData.fromView(View.of(context)).size;
|
|
// On windows, wndRect is equal to GetWindowRect and mediaSize is equal to GetClientRect.
|
|
// https://stackoverflow.com/a/7561083
|
|
double magicWidth =
|
|
wndRect.right - wndRect.left - mediaSize.width * scale;
|
|
double magicHeight =
|
|
wndRect.bottom - wndRect.top - mediaSize.height * scale;
|
|
final canvasModel = ffi.canvasModel;
|
|
final width = (canvasModel.getDisplayWidth() * canvasModel.scale +
|
|
CanvasModel.leftToEdge +
|
|
CanvasModel.rightToEdge) *
|
|
scale +
|
|
magicWidth;
|
|
final height = (canvasModel.getDisplayHeight() * canvasModel.scale +
|
|
CanvasModel.topToEdge +
|
|
CanvasModel.bottomToEdge) *
|
|
scale +
|
|
magicHeight;
|
|
double left = wndRect.left + (wndRect.width - width) / 2;
|
|
double top = wndRect.top + (wndRect.height - height) / 2;
|
|
|
|
Rect frameRect = _screen!.frame;
|
|
if (!isFullscreen) {
|
|
frameRect = _screen!.visibleFrame;
|
|
}
|
|
if (left < frameRect.left) {
|
|
left = frameRect.left;
|
|
}
|
|
if (top < frameRect.top) {
|
|
top = frameRect.top;
|
|
}
|
|
if ((left + width) > frameRect.right) {
|
|
left = frameRect.right - width;
|
|
}
|
|
if ((top + height) > frameRect.bottom) {
|
|
top = frameRect.bottom - height;
|
|
}
|
|
await WindowController.fromWindowId(windowId)
|
|
.setFrame(Rect.fromLTWH(left, top, width, height));
|
|
stateGlobal.setMaximized(false);
|
|
}
|
|
}
|
|
|
|
updateScreen() async {
|
|
final String info =
|
|
isWeb ? screenInfo : await _getScreenInfoDesktop() ?? '';
|
|
if (info.isEmpty) {
|
|
_screen = null;
|
|
} else {
|
|
final screenMap = jsonDecode(info);
|
|
_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']);
|
|
}
|
|
}
|
|
|
|
_getScreenInfoDesktop() async {
|
|
final v = await rustDeskWinManager.call(
|
|
WindowType.Main, kWindowGetWindowInfo, '');
|
|
return v.result;
|
|
}
|
|
|
|
Future<bool> isWindowCanBeAdjusted() async {
|
|
final viewStyle =
|
|
await bind.sessionGetViewStyle(sessionId: ffi.sessionId) ?? '';
|
|
if (viewStyle != kRemoteViewStyleOriginal) {
|
|
return false;
|
|
}
|
|
if (!isWeb) {
|
|
final remoteCount = RemoteCountState.find().value;
|
|
if (remoteCount != 1) {
|
|
return false;
|
|
}
|
|
}
|
|
if (_screen == null) {
|
|
return false;
|
|
}
|
|
final scale = kIgnoreDpi ? 1.0 : _screen!.scaleFactor;
|
|
double selfWidth = _screen!.visibleFrame.width;
|
|
double selfHeight = _screen!.visibleFrame.height;
|
|
if (isFullscreen) {
|
|
selfWidth = _screen!.frame.width;
|
|
selfHeight = _screen!.frame.height;
|
|
}
|
|
|
|
final canvasModel = ffi.canvasModel;
|
|
final displayWidth = canvasModel.getDisplayWidth();
|
|
final displayHeight = canvasModel.getDisplayHeight();
|
|
final requiredWidth =
|
|
CanvasModel.leftToEdge + displayWidth + CanvasModel.rightToEdge;
|
|
final requiredHeight =
|
|
CanvasModel.topToEdge + displayHeight + CanvasModel.bottomToEdge;
|
|
return selfWidth > (requiredWidth * scale) &&
|
|
selfHeight > (requiredHeight * scale);
|
|
}
|
|
}
|
|
|
|
class _DisplayMenu extends StatefulWidget {
|
|
final String id;
|
|
final FFI ffi;
|
|
final ToolbarState state;
|
|
final Function(bool) setFullscreen;
|
|
final Widget pluginItem;
|
|
_DisplayMenu(
|
|
{Key? key,
|
|
required this.id,
|
|
required this.ffi,
|
|
required this.state,
|
|
required this.setFullscreen})
|
|
: pluginItem = LocationItem.createLocationItem(
|
|
id,
|
|
ffi,
|
|
kLocationClientRemoteToolbarDisplay,
|
|
true,
|
|
),
|
|
super(key: key);
|
|
|
|
@override
|
|
State<_DisplayMenu> createState() => _DisplayMenuState();
|
|
}
|
|
|
|
class _DisplayMenuState extends State<_DisplayMenu> {
|
|
final RxInt _customPercent = 100.obs;
|
|
late final ScreenAdjustor _screenAdjustor = ScreenAdjustor(
|
|
id: widget.id,
|
|
ffi: widget.ffi,
|
|
cbExitFullscreen: () => widget.setFullscreen(false),
|
|
);
|
|
|
|
int get windowId => stateGlobal.windowId;
|
|
Map<String, bool> get perms => widget.ffi.ffiModel.permissions;
|
|
PeerInfo get pi => widget.ffi.ffiModel.pi;
|
|
FfiModel get ffiModel => widget.ffi.ffiModel;
|
|
FFI get ffi => widget.ffi;
|
|
String get id => widget.id;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
// Initialize custom percent from stored option once
|
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
|
try {
|
|
final v = await getSessionCustomScalePercent(widget.ffi.sessionId);
|
|
if (_customPercent.value != v) {
|
|
_customPercent.value = v;
|
|
}
|
|
} catch (_) {}
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
_screenAdjustor.updateScreen();
|
|
menuChildrenGetter() {
|
|
final menuChildren = <Widget>[
|
|
_screenAdjustor.adjustWindow(context),
|
|
viewStyle(customPercent: _customPercent),
|
|
scrollStyle(),
|
|
imageQuality(),
|
|
codec(),
|
|
if (ffi.connType == ConnType.defaultConn)
|
|
_ResolutionsMenu(
|
|
id: widget.id,
|
|
ffi: widget.ffi,
|
|
screenAdjustor: _screenAdjustor,
|
|
),
|
|
if (showVirtualDisplayMenu(ffi) && ffi.connType == ConnType.defaultConn)
|
|
_SubmenuButton(
|
|
ffi: widget.ffi,
|
|
menuChildren: getVirtualDisplayMenuChildren(ffi, id, null),
|
|
child: Text(translate("Virtual display")),
|
|
),
|
|
if (ffi.connType == ConnType.defaultConn) cursorToggles(),
|
|
Divider(),
|
|
toggles(),
|
|
];
|
|
// privacy mode
|
|
if (ffi.connType == ConnType.defaultConn &&
|
|
ffiModel.keyboard &&
|
|
pi.features.privacyMode) {
|
|
final privacyModeState = PrivacyModeState.find(id);
|
|
final privacyModeList =
|
|
toolbarPrivacyMode(privacyModeState, context, id, ffi);
|
|
if (privacyModeList.length == 1) {
|
|
menuChildren.add(CkbMenuButton(
|
|
value: privacyModeList[0].value,
|
|
onChanged: privacyModeList[0].onChanged,
|
|
child: privacyModeList[0].child,
|
|
ffi: ffi));
|
|
} else if (privacyModeList.length > 1) {
|
|
menuChildren.addAll([
|
|
Divider(),
|
|
_SubmenuButton(
|
|
ffi: widget.ffi,
|
|
child: Text(translate('Privacy mode')),
|
|
menuChildren: privacyModeList
|
|
.map((e) => CkbMenuButton(
|
|
value: e.value,
|
|
onChanged: e.onChanged,
|
|
child: e.child,
|
|
ffi: ffi))
|
|
.toList()),
|
|
]);
|
|
}
|
|
}
|
|
if (ffi.connType == ConnType.defaultConn) {
|
|
menuChildren.add(widget.pluginItem);
|
|
}
|
|
return menuChildren;
|
|
}
|
|
|
|
return _IconSubmenuButton(
|
|
tooltip: 'Display Settings',
|
|
svg: "assets/display.svg",
|
|
ffi: widget.ffi,
|
|
color: _ToolbarTheme.blueColor,
|
|
hoverColor: _ToolbarTheme.hoverBlueColor,
|
|
menuChildrenGetter: menuChildrenGetter,
|
|
);
|
|
}
|
|
|
|
viewStyle({required RxInt customPercent}) {
|
|
return futureBuilder(
|
|
future: toolbarViewStyle(context, widget.id, widget.ffi),
|
|
hasData: (data) {
|
|
final v = data as List<TRadioMenu<String>>;
|
|
final bool isCustomSelected = v.isNotEmpty
|
|
? v.first.groupValue == kRemoteViewStyleCustom
|
|
: false;
|
|
return Column(children: [
|
|
...v.map((e) {
|
|
final isCustom = e.value == kRemoteViewStyleCustom;
|
|
final child = isCustom
|
|
? Text(translate('Scale custom'))
|
|
: e.child;
|
|
// Whether the current selection is already custom
|
|
final bool isGroupCustomSelected =
|
|
e.groupValue == kRemoteViewStyleCustom;
|
|
// Keep menu open when switching INTO custom so the slider is visible immediately
|
|
final bool keepOpenForThisItem = isCustom && !isGroupCustomSelected;
|
|
return RdoMenuButton<String>(
|
|
value: e.value,
|
|
groupValue: e.groupValue,
|
|
onChanged: (value) {
|
|
// Perform the original change
|
|
e.onChanged?.call(value);
|
|
// Only force a rebuild when we keep the menu open to reveal the slider
|
|
if (keepOpenForThisItem) {
|
|
setState(() {});
|
|
}
|
|
},
|
|
child: child,
|
|
ffi: ffi,
|
|
// When entering custom, keep submenu open to show the slider controls
|
|
closeOnActivate: !keepOpenForThisItem);
|
|
}).toList(),
|
|
// Only show a divider when custom is NOT selected
|
|
if (!isCustomSelected) Divider(),
|
|
_customControlsIfCustomSelected(onChanged: (v) => customPercent.value = v),
|
|
]);
|
|
});
|
|
}
|
|
|
|
Widget _customControlsIfCustomSelected({ValueChanged<int>? onChanged}) {
|
|
return futureBuilder(future: () async {
|
|
final current = await bind.sessionGetViewStyle(sessionId: ffi.sessionId);
|
|
return current == kRemoteViewStyleCustom;
|
|
}(), hasData: (data) {
|
|
final isCustom = data as bool;
|
|
return AnimatedSwitcher(
|
|
duration: Duration(milliseconds: 220),
|
|
switchInCurve: Curves.easeOut,
|
|
switchOutCurve: Curves.easeIn,
|
|
child: isCustom ? _CustomScaleMenuControls(ffi: ffi, onChanged: onChanged) : SizedBox.shrink(),
|
|
);
|
|
});
|
|
}
|
|
|
|
scrollStyle() {
|
|
return futureBuilder(future: () async {
|
|
final viewStyle =
|
|
await bind.sessionGetViewStyle(sessionId: ffi.sessionId) ?? '';
|
|
final visible = viewStyle == kRemoteViewStyleOriginal ||
|
|
viewStyle == kRemoteViewStyleCustom;
|
|
final scrollStyle =
|
|
await bind.sessionGetScrollStyle(sessionId: ffi.sessionId) ?? '';
|
|
return {'visible': visible, 'scrollStyle': scrollStyle};
|
|
}(), hasData: (data) {
|
|
final visible = data['visible'] as bool;
|
|
if (!visible) return Offstage();
|
|
final groupValue = data['scrollStyle'] as String;
|
|
onChange(String? value) async {
|
|
if (value == null) return;
|
|
await bind.sessionSetScrollStyle(
|
|
sessionId: ffi.sessionId, value: value);
|
|
widget.ffi.canvasModel.updateScrollStyle();
|
|
}
|
|
|
|
return Obx(() => Column(children: [
|
|
RdoMenuButton<String>(
|
|
child: Text(translate('ScrollAuto')),
|
|
value: kRemoteScrollStyleAuto,
|
|
groupValue: groupValue,
|
|
onChanged: widget.ffi.canvasModel.imageOverflow.value
|
|
? (value) => onChange(value)
|
|
: null,
|
|
ffi: widget.ffi,
|
|
),
|
|
RdoMenuButton<String>(
|
|
child: Text(translate('ScrollEdge')),
|
|
value: kRemoteScrollStyleEdge,
|
|
groupValue: groupValue,
|
|
onChanged: widget.ffi.canvasModel.imageOverflow.value
|
|
? (value) => onChange(value)
|
|
: null,
|
|
ffi: widget.ffi,
|
|
),
|
|
RdoMenuButton<String>(
|
|
child: Text(translate('Scrollbar')),
|
|
value: kRemoteScrollStyleBar,
|
|
groupValue: groupValue,
|
|
onChanged: widget.ffi.canvasModel.imageOverflow.value
|
|
? (value) => onChange(value)
|
|
: null,
|
|
ffi: widget.ffi,
|
|
),
|
|
Divider(),
|
|
]));
|
|
});
|
|
}
|
|
|
|
imageQuality() {
|
|
return futureBuilder(
|
|
future: toolbarImageQuality(context, widget.id, widget.ffi),
|
|
hasData: (data) {
|
|
final v = data as List<TRadioMenu<String>>;
|
|
return _SubmenuButton(
|
|
ffi: widget.ffi,
|
|
child: Text(translate('Image Quality')),
|
|
menuChildren: v
|
|
.map((e) => RdoMenuButton<String>(
|
|
value: e.value,
|
|
groupValue: e.groupValue,
|
|
onChanged: e.onChanged,
|
|
child: e.child,
|
|
ffi: ffi))
|
|
.toList(),
|
|
);
|
|
});
|
|
}
|
|
|
|
codec() {
|
|
return futureBuilder(
|
|
future: toolbarCodec(context, id, ffi),
|
|
hasData: (data) {
|
|
final v = data as List<TRadioMenu<String>>;
|
|
if (v.isEmpty) return Offstage();
|
|
|
|
return _SubmenuButton(
|
|
ffi: widget.ffi,
|
|
child: Text(translate('Codec')),
|
|
menuChildren: v
|
|
.map((e) => RdoMenuButton(
|
|
value: e.value,
|
|
groupValue: e.groupValue,
|
|
onChanged: e.onChanged,
|
|
child: e.child,
|
|
ffi: ffi))
|
|
.toList());
|
|
});
|
|
}
|
|
|
|
cursorToggles() {
|
|
return futureBuilder(
|
|
future: toolbarCursor(context, id, ffi),
|
|
hasData: (data) {
|
|
final v = data as List<TToggleMenu>;
|
|
if (v.isEmpty) return Offstage();
|
|
return Column(children: [
|
|
Divider(),
|
|
...v
|
|
.map((e) => CkbMenuButton(
|
|
value: e.value,
|
|
onChanged: e.onChanged,
|
|
child: e.child,
|
|
ffi: ffi))
|
|
.toList(),
|
|
]);
|
|
});
|
|
}
|
|
|
|
toggles() {
|
|
return futureBuilder(
|
|
future: toolbarDisplayToggle(context, id, ffi),
|
|
hasData: (data) {
|
|
final v = data as List<TToggleMenu>;
|
|
if (v.isEmpty) return Offstage();
|
|
return Column(
|
|
children: v
|
|
.map((e) => CkbMenuButton(
|
|
value: e.value,
|
|
onChanged: e.onChanged,
|
|
child: e.child,
|
|
ffi: ffi))
|
|
.toList());
|
|
});
|
|
}
|
|
}
|
|
|
|
class _CustomScaleMenuControls extends StatefulWidget {
|
|
final FFI ffi;
|
|
final ValueChanged<int>? onChanged;
|
|
const _CustomScaleMenuControls({Key? key, required this.ffi, this.onChanged}) : super(key: key);
|
|
|
|
@override
|
|
State<_CustomScaleMenuControls> createState() => _CustomScaleMenuControlsState();
|
|
}
|
|
|
|
class _CustomScaleMenuControlsState extends State<_CustomScaleMenuControls> {
|
|
late int _value;
|
|
late final Debouncer<int> _debouncerScale;
|
|
// Normalized slider position in [0, 1]. We map it nonlinearly to percent.
|
|
double _pos = 0.0;
|
|
|
|
// Piecewise mapping constants (moved to consts.dart)
|
|
static const int _minPercent = kScaleCustomMinPercent;
|
|
static const int _pivotPercent = kScaleCustomPivotPercent; // 100% should be at 1/3 of track
|
|
static const int _maxPercent = kScaleCustomMaxPercent;
|
|
static const double _pivotPos = kScaleCustomPivotPos; // first 1/3 → up to 100%
|
|
static const double _detentEpsilon = kScaleCustomDetentEpsilon; // snap range around pivot (~0.6%)
|
|
|
|
// Clamp helper for local use
|
|
int _clamp(int v) => clampCustomScalePercent(v);
|
|
|
|
// Map normalized position [0,1] → percent [5,1000] with 100 at 1/3 width.
|
|
int _mapPosToPercent(double p) {
|
|
if (p <= 0.0) return _minPercent;
|
|
if (p >= 1.0) return _maxPercent;
|
|
if (p <= _pivotPos) {
|
|
final q = p / _pivotPos; // 0..1
|
|
final v = _minPercent + q * (_pivotPercent - _minPercent);
|
|
return _clamp(v.round());
|
|
} else {
|
|
final q = (p - _pivotPos) / (1.0 - _pivotPos); // 0..1
|
|
final v = _pivotPercent + q * (_maxPercent - _pivotPercent);
|
|
return _clamp(v.round());
|
|
}
|
|
}
|
|
|
|
// Map percent [5,1000] → normalized position [0,1]
|
|
double _mapPercentToPos(int percent) {
|
|
final p = _clamp(percent);
|
|
if (p <= _pivotPercent) {
|
|
final q = (p - _minPercent) / (_pivotPercent - _minPercent);
|
|
return q * _pivotPos;
|
|
} else {
|
|
final q = (p - _pivotPercent) / (_maxPercent - _pivotPercent);
|
|
return _pivotPos + q * (1.0 - _pivotPos);
|
|
}
|
|
}
|
|
|
|
// Snap normalized position to the pivot when close to it
|
|
double _snapNormalizedPos(double p) {
|
|
if ((p - _pivotPos).abs() <= _detentEpsilon) return _pivotPos;
|
|
if (p < 0.0) return 0.0;
|
|
if (p > 1.0) return 1.0;
|
|
return p;
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_value = 100;
|
|
_debouncerScale = Debouncer<int>(
|
|
kDebounceCustomScaleDuration,
|
|
onChanged: (v) async {
|
|
await _apply(v);
|
|
},
|
|
initialValue: _value,
|
|
);
|
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
|
try {
|
|
final v = await getSessionCustomScalePercent(widget.ffi.sessionId);
|
|
if (mounted) {
|
|
setState(() {
|
|
_value = v;
|
|
_pos = _mapPercentToPos(v);
|
|
});
|
|
}
|
|
} catch (e, st) {
|
|
debugPrint('[CustomScale] Failed to get initial value: $e');
|
|
debugPrintStack(stackTrace: st);
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
Future<void> _apply(int v) async {
|
|
v = clampCustomScalePercent(v);
|
|
setState(() {
|
|
_value = v;
|
|
});
|
|
try {
|
|
await bind.sessionSetFlutterOption(
|
|
sessionId: widget.ffi.sessionId,
|
|
k: kCustomScalePercentKey,
|
|
v: v.toString());
|
|
final curStyle = await bind.sessionGetViewStyle(sessionId: widget.ffi.sessionId);
|
|
if (curStyle != kRemoteViewStyleCustom) {
|
|
await bind.sessionSetViewStyle(
|
|
sessionId: widget.ffi.sessionId, value: kRemoteViewStyleCustom);
|
|
}
|
|
await widget.ffi.canvasModel.updateViewStyle();
|
|
if (isMobile) {
|
|
HapticFeedback.selectionClick();
|
|
}
|
|
widget.onChanged?.call(v);
|
|
} catch (e, st) {
|
|
debugPrint('[CustomScale] Apply failed: $e');
|
|
debugPrintStack(stackTrace: st);
|
|
}
|
|
}
|
|
|
|
void _nudge(int delta) {
|
|
final next = _clamp(_value + delta);
|
|
setState(() {
|
|
_value = next;
|
|
_pos = _mapPercentToPos(next);
|
|
});
|
|
widget.onChanged?.call(next);
|
|
_debouncerScale.value = next;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_debouncerScale.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
const smallBtnConstraints = BoxConstraints(minWidth: 28, minHeight: 28);
|
|
|
|
final sliderControl = Semantics(
|
|
label: translate('Custom scale slider'),
|
|
value: '$_value%',
|
|
child: SliderTheme(
|
|
data: SliderTheme.of(context).copyWith(
|
|
activeTrackColor: colorScheme.primary,
|
|
thumbColor: colorScheme.primary,
|
|
overlayColor: colorScheme.primary.withOpacity(0.1),
|
|
showValueIndicator: ShowValueIndicator.never,
|
|
thumbShape: _RectValueThumbShape(
|
|
min: _minPercent.toDouble(),
|
|
max: _maxPercent.toDouble(),
|
|
width: 52,
|
|
height: 24,
|
|
radius: 4,
|
|
// Display the mapped percent for the current normalized value
|
|
displayValueForNormalized: (t) => _mapPosToPercent(t),
|
|
),
|
|
),
|
|
child: Slider(
|
|
value: _pos,
|
|
min: 0.0,
|
|
max: 1.0,
|
|
// Use a wide range of divisions (calculated as (_maxPercent - _minPercent)) to provide ~1% precision increments.
|
|
// This allows users to set precise scale values. Lower values would require more fine-tuning via the +/- buttons, which is undesirable for big ranges.
|
|
divisions: (_maxPercent - _minPercent).round(),
|
|
onChanged: (v) {
|
|
final snapped = _snapNormalizedPos(v);
|
|
final next = _mapPosToPercent(snapped);
|
|
if (next != _value || snapped != _pos) {
|
|
setState(() {
|
|
_pos = snapped;
|
|
_value = next;
|
|
});
|
|
widget.onChanged?.call(next);
|
|
_debouncerScale.value = next;
|
|
}
|
|
},
|
|
),
|
|
),
|
|
);
|
|
|
|
return Column(children: [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
|
child: Row(children: [
|
|
Tooltip(
|
|
message: translate('Decrease'),
|
|
child: IconButton(
|
|
iconSize: 16,
|
|
padding: EdgeInsets.all(1),
|
|
constraints: smallBtnConstraints,
|
|
icon: const Icon(Icons.remove),
|
|
onPressed: () => _nudge(-1),
|
|
),
|
|
),
|
|
Expanded(child: sliderControl),
|
|
Tooltip(
|
|
message: translate('Increase'),
|
|
child: IconButton(
|
|
iconSize: 16,
|
|
padding: EdgeInsets.all(1),
|
|
constraints: smallBtnConstraints,
|
|
icon: const Icon(Icons.add),
|
|
onPressed: () => _nudge(1),
|
|
),
|
|
),
|
|
]),
|
|
),
|
|
Divider(),
|
|
]);
|
|
}
|
|
}
|
|
|
|
// Lightweight rectangular thumb that paints the current percentage.
|
|
// Stateless and uses only SliderTheme colors; avoids allocations beyond a TextPainter per frame.
|
|
class _RectValueThumbShape extends SliderComponentShape {
|
|
final double min;
|
|
final double max;
|
|
final double width;
|
|
final double height;
|
|
final double radius;
|
|
// Optional mapper to compute display value from normalized position [0,1]
|
|
// If null, falls back to linear interpolation between min and max.
|
|
final int Function(double normalized)? displayValueForNormalized;
|
|
|
|
const _RectValueThumbShape({
|
|
required this.min,
|
|
required this.max,
|
|
required this.width,
|
|
required this.height,
|
|
required this.radius,
|
|
this.displayValueForNormalized,
|
|
});
|
|
|
|
@override
|
|
Size getPreferredSize(bool isEnabled, bool isDiscrete) {
|
|
return Size(width, height);
|
|
}
|
|
|
|
@override
|
|
void paint(
|
|
PaintingContext context,
|
|
Offset center, {
|
|
required Animation<double> activationAnimation,
|
|
required Animation<double> enableAnimation,
|
|
required bool isDiscrete,
|
|
required TextPainter labelPainter,
|
|
required RenderBox parentBox,
|
|
required SliderThemeData sliderTheme,
|
|
required TextDirection textDirection,
|
|
required double value,
|
|
required double textScaleFactor,
|
|
required Size sizeWithOverflow,
|
|
}) {
|
|
final Canvas canvas = context.canvas;
|
|
|
|
// Resolve color based on enabled/disabled animation, with safe fallbacks.
|
|
final ColorTween colorTween = ColorTween(
|
|
begin: sliderTheme.disabledThumbColor,
|
|
end: sliderTheme.thumbColor,
|
|
);
|
|
final Color? evaluatedColor = colorTween.evaluate(enableAnimation);
|
|
final Color? thumbColor = sliderTheme.thumbColor;
|
|
final Color fillColor = evaluatedColor ?? thumbColor ?? Colors.blueAccent;
|
|
|
|
final RRect rrect = RRect.fromRectAndRadius(
|
|
Rect.fromCenter(center: center, width: width, height: height),
|
|
Radius.circular(radius),
|
|
);
|
|
final Paint paint = Paint()..color = fillColor;
|
|
canvas.drawRRect(rrect, paint);
|
|
|
|
// Compute displayed percent from normalized slider value.
|
|
final int percent = displayValueForNormalized != null
|
|
? displayValueForNormalized!(value)
|
|
: (min + value * (max - min)).round();
|
|
final TextSpan span = TextSpan(
|
|
text: '$percent%',
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
);
|
|
final TextPainter tp = TextPainter(
|
|
text: span,
|
|
textAlign: TextAlign.center,
|
|
textDirection: textDirection,
|
|
);
|
|
tp.layout(maxWidth: width - 4);
|
|
tp.paint(canvas, Offset(center.dx - tp.width / 2, center.dy - tp.height / 2));
|
|
}
|
|
}
|
|
|
|
class _ResolutionsMenu extends StatefulWidget {
|
|
final String id;
|
|
final FFI ffi;
|
|
final ScreenAdjustor screenAdjustor;
|
|
|
|
_ResolutionsMenu({
|
|
Key? key,
|
|
required this.id,
|
|
required this.ffi,
|
|
required this.screenAdjustor,
|
|
}) : super(key: key);
|
|
|
|
@override
|
|
State<_ResolutionsMenu> createState() => _ResolutionsMenuState();
|
|
}
|
|
|
|
const double _kCustomResolutionEditingWidth = 42;
|
|
const _kCustomResolutionValue = 'custom';
|
|
|
|
class _ResolutionsMenuState extends State<_ResolutionsMenu> {
|
|
String _groupValue = '';
|
|
Resolution? _localResolution;
|
|
|
|
late final TextEditingController _customWidth =
|
|
TextEditingController(text: rect?.width.toInt().toString() ?? '');
|
|
late final TextEditingController _customHeight =
|
|
TextEditingController(text: rect?.height.toInt().toString() ?? '');
|
|
|
|
FFI get ffi => widget.ffi;
|
|
PeerInfo get pi => widget.ffi.ffiModel.pi;
|
|
FfiModel get ffiModel => widget.ffi.ffiModel;
|
|
Rect? get rect => scaledRect();
|
|
List<Resolution> get resolutions => pi.resolutions;
|
|
bool get isWayland => bind.mainCurrentIsWayland();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_getLocalResolutionWayland();
|
|
});
|
|
}
|
|
|
|
Rect? scaledRect() {
|
|
final scale = pi.scaleOfDisplay(pi.currentDisplay);
|
|
final rect = ffiModel.rect;
|
|
if (rect == null) {
|
|
return null;
|
|
}
|
|
return Rect.fromLTWH(
|
|
rect.left,
|
|
rect.top,
|
|
rect.width / scale,
|
|
rect.height / scale,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final isVirtualDisplay = ffiModel.isVirtualDisplayResolution;
|
|
final visible = ffiModel.keyboard &&
|
|
(isVirtualDisplay || resolutions.length > 1) &&
|
|
pi.currentDisplay != kAllDisplayValue;
|
|
if (!visible) return Offstage();
|
|
final showOriginalBtn =
|
|
ffiModel.isOriginalResolutionSet && !ffiModel.isOriginalResolution;
|
|
final showFitLocalBtn = !_isRemoteResolutionFitLocal();
|
|
_setGroupValue();
|
|
return _SubmenuButton(
|
|
ffi: widget.ffi,
|
|
menuChildren: <Widget>[
|
|
_OriginalResolutionMenuButton(context, showOriginalBtn),
|
|
_FitLocalResolutionMenuButton(context, showFitLocalBtn),
|
|
_customResolutionMenuButton(context, isVirtualDisplay),
|
|
_menuDivider(showOriginalBtn, showFitLocalBtn, isVirtualDisplay),
|
|
] +
|
|
_supportedResolutionMenuButtons(),
|
|
child: Text(translate("Resolution")),
|
|
);
|
|
}
|
|
|
|
_setGroupValue() {
|
|
if (pi.currentDisplay == kAllDisplayValue) {
|
|
return;
|
|
}
|
|
final lastGroupValue =
|
|
stateGlobal.getLastResolutionGroupValue(widget.id, pi.currentDisplay);
|
|
if (lastGroupValue == _kCustomResolutionValue) {
|
|
_groupValue = _kCustomResolutionValue;
|
|
} else {
|
|
_groupValue =
|
|
'${(rect?.width ?? 0).toInt()}x${(rect?.height ?? 0).toInt()}';
|
|
}
|
|
}
|
|
|
|
_menuDivider(
|
|
bool showOriginalBtn, bool showFitLocalBtn, bool isVirtualDisplay) {
|
|
return Offstage(
|
|
offstage: !(showOriginalBtn || showFitLocalBtn || isVirtualDisplay),
|
|
child: Divider(),
|
|
);
|
|
}
|
|
|
|
Future<void> _getLocalResolutionWayland() async {
|
|
if (!isWayland) return _getLocalResolution();
|
|
final window = await window_size.getWindowInfo();
|
|
final screen = window.screen;
|
|
if (screen != null) {
|
|
setState(() {
|
|
_localResolution = Resolution(
|
|
screen.frame.width.toInt(),
|
|
screen.frame.height.toInt(),
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
_getLocalResolution() {
|
|
_localResolution = null;
|
|
final String mainDisplay = bind.mainGetMainDisplay();
|
|
if (mainDisplay.isNotEmpty) {
|
|
try {
|
|
final display = json.decode(mainDisplay);
|
|
if (display['w'] != null && display['h'] != null) {
|
|
_localResolution = Resolution(display['w'], display['h']);
|
|
if (isWeb) {
|
|
if (display['scaleFactor'] != null) {
|
|
_localResolution = Resolution(
|
|
(display['w'] / display['scaleFactor']).toInt(),
|
|
(display['h'] / display['scaleFactor']).toInt(),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Failed to decode $mainDisplay, $e');
|
|
}
|
|
}
|
|
}
|
|
|
|
// This widget has been unmounted, so the State no longer has a context
|
|
_onChanged(String? value) async {
|
|
if (pi.currentDisplay == kAllDisplayValue) {
|
|
return;
|
|
}
|
|
stateGlobal.setLastResolutionGroupValue(
|
|
widget.id, pi.currentDisplay, value);
|
|
if (value == null) return;
|
|
|
|
int? w;
|
|
int? h;
|
|
if (value == _kCustomResolutionValue) {
|
|
w = int.tryParse(_customWidth.text);
|
|
h = int.tryParse(_customHeight.text);
|
|
} else {
|
|
final list = value.split('x');
|
|
if (list.length == 2) {
|
|
w = int.tryParse(list[0]);
|
|
h = int.tryParse(list[1]);
|
|
}
|
|
}
|
|
|
|
if (w != null && h != null) {
|
|
if (w != rect?.width.toInt() || h != rect?.height.toInt()) {
|
|
await _changeResolution(w, h);
|
|
}
|
|
}
|
|
}
|
|
|
|
_changeResolution(int w, int h) async {
|
|
if (pi.currentDisplay == kAllDisplayValue) {
|
|
return;
|
|
}
|
|
await bind.sessionChangeResolution(
|
|
sessionId: ffi.sessionId,
|
|
display: pi.currentDisplay,
|
|
width: w,
|
|
height: h,
|
|
);
|
|
Future.delayed(Duration(seconds: 3), () async {
|
|
final rect = ffiModel.rect;
|
|
if (rect == null) {
|
|
return;
|
|
}
|
|
if (w == rect.width.toInt() && h == rect.height.toInt()) {
|
|
if (await widget.screenAdjustor.isWindowCanBeAdjusted()) {
|
|
widget.screenAdjustor.doAdjustWindow(context);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
Widget _OriginalResolutionMenuButton(
|
|
BuildContext context, bool showOriginalBtn) {
|
|
final display = pi.tryGetDisplayIfNotAllDisplay();
|
|
if (display == null) {
|
|
return Offstage();
|
|
}
|
|
if (!resolutions.any((e) =>
|
|
e.width == display.originalWidth &&
|
|
e.height == display.originalHeight)) {
|
|
return Offstage();
|
|
}
|
|
return Offstage(
|
|
offstage: !showOriginalBtn,
|
|
child: MenuButton(
|
|
onPressed: () =>
|
|
_changeResolution(display.originalWidth, display.originalHeight),
|
|
ffi: widget.ffi,
|
|
child: Text(
|
|
'${translate('resolution_original_tip')} ${display.originalWidth}x${display.originalHeight}'),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _FitLocalResolutionMenuButton(
|
|
BuildContext context, bool showFitLocalBtn) {
|
|
return Offstage(
|
|
offstage: !showFitLocalBtn,
|
|
child: MenuButton(
|
|
onPressed: () {
|
|
final resolution = _getBestFitResolution();
|
|
if (resolution != null) {
|
|
_changeResolution(resolution.width, resolution.height);
|
|
}
|
|
},
|
|
ffi: widget.ffi,
|
|
child: Text(
|
|
'${translate('resolution_fit_local_tip')} ${_localResolution?.width ?? 0}x${_localResolution?.height ?? 0}'),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _customResolutionMenuButton(BuildContext context, isVirtualDisplay) {
|
|
return Offstage(
|
|
offstage: !isVirtualDisplay,
|
|
child: RdoMenuButton(
|
|
value: _kCustomResolutionValue,
|
|
groupValue: _groupValue,
|
|
onChanged: (String? value) => _onChanged(value),
|
|
ffi: widget.ffi,
|
|
child: Row(
|
|
children: [
|
|
Text('${translate('resolution_custom_tip')} '),
|
|
SizedBox(
|
|
width: _kCustomResolutionEditingWidth,
|
|
child: _resolutionInput(_customWidth),
|
|
),
|
|
Text(' x '),
|
|
SizedBox(
|
|
width: _kCustomResolutionEditingWidth,
|
|
child: _resolutionInput(_customHeight),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _resolutionInput(TextEditingController controller) {
|
|
return TextField(
|
|
decoration: InputDecoration(
|
|
border: InputBorder.none,
|
|
isDense: true,
|
|
contentPadding: EdgeInsets.fromLTRB(3, 3, 3, 3),
|
|
),
|
|
keyboardType: TextInputType.number,
|
|
inputFormatters: <TextInputFormatter>[
|
|
FilteringTextInputFormatter.digitsOnly,
|
|
LengthLimitingTextInputFormatter(4),
|
|
FilteringTextInputFormatter.allow(RegExp(r'[0-9]')),
|
|
],
|
|
controller: controller,
|
|
).workaroundFreezeLinuxMint();
|
|
}
|
|
|
|
List<Widget> _supportedResolutionMenuButtons() => resolutions
|
|
.map((e) => RdoMenuButton(
|
|
value: '${e.width}x${e.height}',
|
|
groupValue: _groupValue,
|
|
onChanged: (String? value) => _onChanged(value),
|
|
ffi: widget.ffi,
|
|
child: Text('${e.width}x${e.height}')))
|
|
.toList();
|
|
|
|
Resolution? _getBestFitResolution() {
|
|
if (_localResolution == null) {
|
|
return null;
|
|
}
|
|
|
|
if (ffiModel.isVirtualDisplayResolution) {
|
|
return _localResolution!;
|
|
}
|
|
|
|
for (final r in resolutions) {
|
|
if (r.width == _localResolution!.width &&
|
|
r.height == _localResolution!.height) {
|
|
return r;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
bool _isRemoteResolutionFitLocal() {
|
|
if (_localResolution == null) {
|
|
return true;
|
|
}
|
|
final bestFitResolution = _getBestFitResolution();
|
|
if (bestFitResolution == null) {
|
|
return true;
|
|
}
|
|
return bestFitResolution.width == rect?.width.toInt() &&
|
|
bestFitResolution.height == rect?.height.toInt();
|
|
}
|
|
}
|
|
|
|
class _KeyboardMenu extends StatelessWidget {
|
|
final String id;
|
|
final FFI ffi;
|
|
_KeyboardMenu({
|
|
Key? key,
|
|
required this.id,
|
|
required this.ffi,
|
|
}) : super(key: key);
|
|
|
|
PeerInfo get pi => ffi.ffiModel.pi;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
var ffiModel = Provider.of<FfiModel>(context);
|
|
if (!ffiModel.keyboard) return Offstage();
|
|
toolbarToggles() => toolbarKeyboardToggles(ffi)
|
|
.map((e) => CkbMenuButton(
|
|
value: e.value, onChanged: e.onChanged, child: e.child, ffi: ffi))
|
|
.toList();
|
|
return _IconSubmenuButton(
|
|
tooltip: 'Keyboard Settings',
|
|
svg: "assets/keyboard.svg",
|
|
ffi: ffi,
|
|
color: _ToolbarTheme.blueColor,
|
|
hoverColor: _ToolbarTheme.hoverBlueColor,
|
|
menuChildrenGetter: () => [
|
|
keyboardMode(),
|
|
localKeyboardType(),
|
|
inputSource(),
|
|
Divider(),
|
|
viewMode(),
|
|
if ([kPeerPlatformWindows, kPeerPlatformMacOS, kPeerPlatformLinux]
|
|
.contains(pi.platform))
|
|
showMyCursor(),
|
|
Divider(),
|
|
...toolbarToggles(),
|
|
...mouseSpeed(),
|
|
...mobileActions(),
|
|
]);
|
|
}
|
|
|
|
mouseSpeed() {
|
|
final speedWidgets = [];
|
|
final sessionId = ffi.sessionId;
|
|
if (isDesktop) {
|
|
if (ffi.ffiModel.keyboard) {
|
|
final enabled = !ffi.ffiModel.viewOnly;
|
|
final trackpad = MenuButton(
|
|
child: Text(translate('Trackpad speed')).paddingOnly(left: 26.0),
|
|
onPressed: enabled ? () => trackpadSpeedDialog(sessionId, ffi) : null,
|
|
ffi: ffi,
|
|
);
|
|
speedWidgets.add(trackpad);
|
|
}
|
|
}
|
|
return speedWidgets;
|
|
}
|
|
|
|
keyboardMode() {
|
|
return futureBuilder(future: () async {
|
|
return await bind.sessionGetKeyboardMode(sessionId: ffi.sessionId) ??
|
|
kKeyLegacyMode;
|
|
}(), hasData: (data) {
|
|
final groupValue = data as String;
|
|
List<InputModeMenu> modes = [
|
|
InputModeMenu(key: kKeyLegacyMode, menu: 'Legacy mode'),
|
|
InputModeMenu(key: kKeyMapMode, menu: 'Map mode'),
|
|
InputModeMenu(key: kKeyTranslateMode, menu: 'Translate mode'),
|
|
];
|
|
List<RdoMenuButton> list = [];
|
|
final enabled = !ffi.ffiModel.viewOnly;
|
|
onChanged(String? value) async {
|
|
if (value == null) return;
|
|
await bind.sessionSetKeyboardMode(
|
|
sessionId: ffi.sessionId, value: value);
|
|
await ffi.inputModel.updateKeyboardMode();
|
|
}
|
|
|
|
// If use flutter to grab keys, we can only use one mode.
|
|
// Map mode and Legacy mode, at least one of them is supported.
|
|
String? modeOnly;
|
|
// Keep both map and legacy mode on web at the moment.
|
|
// TODO: Remove legacy mode after web supports translate mode on web.
|
|
if (isInputSourceFlutter && isDesktop) {
|
|
if (bind.sessionIsKeyboardModeSupported(
|
|
sessionId: ffi.sessionId, mode: kKeyMapMode)) {
|
|
modeOnly = kKeyMapMode;
|
|
} else if (bind.sessionIsKeyboardModeSupported(
|
|
sessionId: ffi.sessionId, mode: kKeyLegacyMode)) {
|
|
modeOnly = kKeyLegacyMode;
|
|
}
|
|
}
|
|
|
|
for (InputModeMenu mode in modes) {
|
|
if (modeOnly != null && mode.key != modeOnly) {
|
|
continue;
|
|
} else if (!bind.sessionIsKeyboardModeSupported(
|
|
sessionId: ffi.sessionId, mode: mode.key)) {
|
|
continue;
|
|
}
|
|
|
|
if (pi.isWayland && mode.key != kKeyMapMode) {
|
|
continue;
|
|
}
|
|
|
|
var text = translate(mode.menu);
|
|
if (mode.key == kKeyTranslateMode) {
|
|
text = '$text beta';
|
|
}
|
|
list.add(RdoMenuButton<String>(
|
|
child: Text(text),
|
|
value: mode.key,
|
|
groupValue: groupValue,
|
|
onChanged: enabled ? onChanged : null,
|
|
ffi: ffi,
|
|
));
|
|
}
|
|
return Column(children: list);
|
|
});
|
|
}
|
|
|
|
localKeyboardType() {
|
|
final localPlatform = getLocalPlatformForKBLayoutType(pi.platform);
|
|
final visible = localPlatform != '';
|
|
if (!visible) return Offstage();
|
|
final enabled = !ffi.ffiModel.viewOnly;
|
|
return Column(
|
|
children: [
|
|
Divider(),
|
|
MenuButton(
|
|
child: Text(
|
|
'${translate('Local keyboard type')}: ${KBLayoutType.value}'),
|
|
trailingIcon: const Icon(Icons.settings),
|
|
ffi: ffi,
|
|
onPressed: enabled
|
|
? () => showKBLayoutTypeChooser(localPlatform, ffi.dialogManager)
|
|
: null,
|
|
)
|
|
],
|
|
);
|
|
}
|
|
|
|
inputSource() {
|
|
final supportedInputSource = bind.mainSupportedInputSource();
|
|
if (supportedInputSource.isEmpty) return Offstage();
|
|
late final List<dynamic> supportedInputSourceList;
|
|
try {
|
|
supportedInputSourceList = jsonDecode(supportedInputSource);
|
|
} catch (e) {
|
|
debugPrint('Failed to decode $supportedInputSource, $e');
|
|
return;
|
|
}
|
|
if (supportedInputSourceList.length < 2) return Offstage();
|
|
final inputSource = stateGlobal.getInputSource();
|
|
final enabled = !ffi.ffiModel.viewOnly;
|
|
final children = <Widget>[Divider()];
|
|
children.addAll(supportedInputSourceList.map((e) {
|
|
final d = e as List<dynamic>;
|
|
return RdoMenuButton<String>(
|
|
child: Text(translate(d[1] as String)),
|
|
value: d[0] as String,
|
|
groupValue: inputSource,
|
|
onChanged: enabled
|
|
? (v) async {
|
|
if (v != null) {
|
|
await stateGlobal.setInputSource(ffi.sessionId, v);
|
|
await ffi.ffiModel.checkDesktopKeyboardMode();
|
|
await ffi.inputModel.updateKeyboardMode();
|
|
}
|
|
}
|
|
: null,
|
|
ffi: ffi,
|
|
);
|
|
}));
|
|
return Column(children: children);
|
|
}
|
|
|
|
viewMode() {
|
|
final ffiModel = ffi.ffiModel;
|
|
final enabled = versionCmp(pi.version, '1.2.0') >= 0 && ffiModel.keyboard;
|
|
return CkbMenuButton(
|
|
value: ffiModel.viewOnly,
|
|
onChanged: enabled
|
|
? (value) async {
|
|
if (value == null) return;
|
|
await bind.sessionToggleOption(
|
|
sessionId: ffi.sessionId, value: kOptionToggleViewOnly);
|
|
final viewOnly = await bind.sessionGetToggleOption(
|
|
sessionId: ffi.sessionId, arg: kOptionToggleViewOnly);
|
|
ffiModel.setViewOnly(id, viewOnly ?? value);
|
|
final showMyCursor = await bind.sessionGetToggleOption(
|
|
sessionId: ffi.sessionId, arg: kOptionToggleShowMyCursor);
|
|
ffiModel.setShowMyCursor(showMyCursor ?? value);
|
|
}
|
|
: null,
|
|
ffi: ffi,
|
|
child: Text(translate('View Mode')));
|
|
}
|
|
|
|
showMyCursor() {
|
|
final ffiModel = ffi.ffiModel;
|
|
return CkbMenuButton(
|
|
value: ffiModel.showMyCursor,
|
|
onChanged: (value) async {
|
|
if (value == null) return;
|
|
await bind.sessionToggleOption(
|
|
sessionId: ffi.sessionId, value: kOptionToggleShowMyCursor);
|
|
final showMyCursor = await bind.sessionGetToggleOption(
|
|
sessionId: ffi.sessionId,
|
|
arg: kOptionToggleShowMyCursor) ??
|
|
value;
|
|
ffiModel.setShowMyCursor(showMyCursor);
|
|
|
|
// Also set view only if showMyCursor is enabled and viewOnly is not enabled.
|
|
if (showMyCursor && !ffiModel.viewOnly) {
|
|
await bind.sessionToggleOption(
|
|
sessionId: ffi.sessionId, value: kOptionToggleViewOnly);
|
|
final viewOnly = await bind.sessionGetToggleOption(
|
|
sessionId: ffi.sessionId, arg: kOptionToggleViewOnly);
|
|
ffiModel.setViewOnly(id, viewOnly ?? value);
|
|
}
|
|
},
|
|
ffi: ffi,
|
|
child: Text(translate('Show my cursor')))
|
|
.paddingOnly(left: 26.0);
|
|
}
|
|
|
|
mobileActions() {
|
|
if (pi.platform != kPeerPlatformAndroid) return [];
|
|
final enabled = versionCmp(pi.version, '1.2.7') >= 0;
|
|
if (!enabled) return [];
|
|
return [
|
|
Divider(),
|
|
MenuButton(
|
|
child: Text(translate('Back')),
|
|
onPressed: () => ffi.inputModel.onMobileBack(),
|
|
ffi: ffi),
|
|
MenuButton(
|
|
child: Text(translate('Home')),
|
|
onPressed: () => ffi.inputModel.onMobileHome(),
|
|
ffi: ffi),
|
|
MenuButton(
|
|
child: Text(translate('Apps')),
|
|
onPressed: () => ffi.inputModel.onMobileApps(),
|
|
ffi: ffi),
|
|
MenuButton(
|
|
child: Text(translate('Volume up')),
|
|
onPressed: () => ffi.inputModel.onMobileVolumeUp(),
|
|
ffi: ffi),
|
|
MenuButton(
|
|
child: Text(translate('Volume down')),
|
|
onPressed: () => ffi.inputModel.onMobileVolumeDown(),
|
|
ffi: ffi),
|
|
MenuButton(
|
|
child: Text(translate('Power')),
|
|
onPressed: () => ffi.inputModel.onMobilePower(),
|
|
ffi: ffi),
|
|
];
|
|
}
|
|
}
|
|
|
|
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) {
|
|
if (isWeb) {
|
|
return buildTextChatButton();
|
|
} else {
|
|
return _IconSubmenuButton(
|
|
tooltip: 'Chat',
|
|
key: chatButtonKey,
|
|
svg: 'assets/chat.svg',
|
|
ffi: widget.ffi,
|
|
color: _ToolbarTheme.blueColor,
|
|
hoverColor: _ToolbarTheme.hoverBlueColor,
|
|
menuChildrenGetter: () => [textChat(), voiceCall()]);
|
|
}
|
|
}
|
|
|
|
buildTextChatButton() {
|
|
return _IconMenuButton(
|
|
assetName: 'assets/message_24dp_5F6368.svg',
|
|
tooltip: 'Text chat',
|
|
key: chatButtonKey,
|
|
onPressed: _textChatOnPressed,
|
|
color: _ToolbarTheme.blueColor,
|
|
hoverColor: _ToolbarTheme.hoverBlueColor,
|
|
);
|
|
}
|
|
|
|
textChat() {
|
|
return MenuButton(
|
|
child: Text(translate('Text chat')),
|
|
ffi: widget.ffi,
|
|
onPressed: _textChatOnPressed);
|
|
}
|
|
|
|
_textChatOnPressed() {
|
|
RenderBox? renderBox =
|
|
chatButtonKey.currentContext?.findRenderObject() as RenderBox?;
|
|
Offset? initPos;
|
|
if (renderBox != null) {
|
|
final pos = renderBox.localToGlobal(Offset.zero);
|
|
initPos = Offset(pos.dx, pos.dy + _ToolbarTheme.dividerHeight);
|
|
}
|
|
widget.ffi.chatModel
|
|
.changeCurrentKey(MessageKey(widget.ffi.id, ChatModel.clientModeID));
|
|
widget.ffi.chatModel.toggleChatOverlay(chatInitPos: initPos);
|
|
}
|
|
|
|
voiceCall() {
|
|
return MenuButton(
|
|
child: Text(translate('Voice call')),
|
|
ffi: widget.ffi,
|
|
onPressed: () =>
|
|
bind.sessionRequestVoiceCall(sessionId: widget.ffi.sessionId),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _VoiceCallMenu extends StatelessWidget {
|
|
final String id;
|
|
final FFI ffi;
|
|
_VoiceCallMenu({
|
|
Key? key,
|
|
required this.id,
|
|
required this.ffi,
|
|
}) : super(key: key);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
menuChildrenGetter() {
|
|
final audioInput = AudioInput(
|
|
builder: (devices, currentDevice, setDevice) {
|
|
return Column(
|
|
children: devices
|
|
.map((d) => RdoMenuButton<String>(
|
|
child: Container(
|
|
child: Text(
|
|
d,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
constraints: BoxConstraints(maxWidth: 250),
|
|
),
|
|
value: d,
|
|
groupValue: currentDevice,
|
|
onChanged: (v) {
|
|
if (v != null) setDevice(v);
|
|
},
|
|
ffi: ffi,
|
|
))
|
|
.toList(),
|
|
);
|
|
},
|
|
isCm: false,
|
|
isVoiceCall: true,
|
|
);
|
|
return [
|
|
audioInput,
|
|
Divider(),
|
|
MenuButton(
|
|
child: Text(translate('End call')),
|
|
onPressed: () => bind.sessionCloseVoiceCall(sessionId: ffi.sessionId),
|
|
ffi: ffi,
|
|
),
|
|
];
|
|
}
|
|
|
|
return Obx(
|
|
() {
|
|
switch (ffi.chatModel.voiceCallStatus.value) {
|
|
case VoiceCallStatus.waitingForResponse:
|
|
return buildCallWaiting(context);
|
|
case VoiceCallStatus.connected:
|
|
return _IconSubmenuButton(
|
|
tooltip: 'Voice call',
|
|
svg: 'assets/voice_call.svg',
|
|
color: _ToolbarTheme.blueColor,
|
|
hoverColor: _ToolbarTheme.hoverBlueColor,
|
|
menuChildrenGetter: menuChildrenGetter,
|
|
ffi: ffi,
|
|
);
|
|
default:
|
|
return Offstage();
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget buildCallWaiting(BuildContext context) {
|
|
return _IconMenuButton(
|
|
assetName: "assets/call_wait.svg",
|
|
tooltip: "Waiting",
|
|
onPressed: () => bind.sessionCloseVoiceCall(sessionId: ffi.sessionId),
|
|
color: _ToolbarTheme.redColor,
|
|
hoverColor: _ToolbarTheme.hoverRedColor,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RecordMenu extends StatelessWidget {
|
|
const _RecordMenu({Key? key}) : super(key: key);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
var ffi = Provider.of<FfiModel>(context);
|
|
var recordingModel = Provider.of<RecordingModel>(context);
|
|
final visible =
|
|
(recordingModel.start || ffi.permissions['recording'] != false);
|
|
if (!visible) return Offstage();
|
|
return _IconMenuButton(
|
|
assetName: 'assets/rec.svg',
|
|
tooltip: recordingModel.start
|
|
? 'Stop session recording'
|
|
: 'Start session recording',
|
|
onPressed: () => recordingModel.toggle(),
|
|
color: recordingModel.start
|
|
? _ToolbarTheme.redColor
|
|
: _ToolbarTheme.blueColor,
|
|
hoverColor: recordingModel.start
|
|
? _ToolbarTheme.hoverRedColor
|
|
: _ToolbarTheme.hoverBlueColor,
|
|
);
|
|
}
|
|
}
|
|
|
|
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: () => closeConnection(id: id),
|
|
color: _ToolbarTheme.redColor,
|
|
hoverColor: _ToolbarTheme.hoverRedColor,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _IconMenuButton extends StatefulWidget {
|
|
final String? assetName;
|
|
final Widget? icon;
|
|
final String tooltip;
|
|
final Color color;
|
|
final Color hoverColor;
|
|
final VoidCallback? onPressed;
|
|
final double? hMargin;
|
|
final double? vMargin;
|
|
final bool topLevel;
|
|
final double? width;
|
|
const _IconMenuButton({
|
|
Key? key,
|
|
this.assetName,
|
|
this.icon,
|
|
required this.tooltip,
|
|
required this.color,
|
|
required this.hoverColor,
|
|
required this.onPressed,
|
|
this.hMargin,
|
|
this.vMargin,
|
|
this.topLevel = true,
|
|
this.width,
|
|
}) : super(key: key);
|
|
|
|
@override
|
|
State<_IconMenuButton> createState() => _IconMenuButtonState();
|
|
}
|
|
|
|
class _IconMenuButtonState extends State<_IconMenuButton> {
|
|
bool hover = false;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
assert(widget.assetName != null || widget.icon != null);
|
|
final icon = widget.icon ??
|
|
SvgPicture.asset(
|
|
widget.assetName!,
|
|
colorFilter: ColorFilter.mode(Colors.white, BlendMode.srcIn),
|
|
width: _ToolbarTheme.buttonSize,
|
|
height: _ToolbarTheme.buttonSize,
|
|
);
|
|
var button = SizedBox(
|
|
width: widget.width ?? _ToolbarTheme.buttonSize,
|
|
height: _ToolbarTheme.buttonSize,
|
|
child: MenuItemButton(
|
|
style: ButtonStyle(
|
|
backgroundColor: MaterialStatePropertyAll(Colors.transparent),
|
|
padding: MaterialStatePropertyAll(EdgeInsets.zero),
|
|
overlayColor: MaterialStatePropertyAll(Colors.transparent)),
|
|
onHover: (value) => setState(() {
|
|
hover = value;
|
|
}),
|
|
onPressed: widget.onPressed,
|
|
child: Tooltip(
|
|
message: translate(widget.tooltip),
|
|
child: Material(
|
|
type: MaterialType.transparency,
|
|
child: Ink(
|
|
decoration: BoxDecoration(
|
|
borderRadius:
|
|
BorderRadius.circular(_ToolbarTheme.iconRadius),
|
|
color: hover ? widget.hoverColor : widget.color,
|
|
),
|
|
child: icon)),
|
|
)),
|
|
).marginSymmetric(
|
|
horizontal: widget.hMargin ?? _ToolbarTheme.buttonHMargin,
|
|
vertical: widget.vMargin ?? _ToolbarTheme.buttonVMargin);
|
|
button = Tooltip(
|
|
message: widget.tooltip,
|
|
child: button,
|
|
);
|
|
if (widget.topLevel) {
|
|
return MenuBar(children: [button]);
|
|
} else {
|
|
return button;
|
|
}
|
|
}
|
|
}
|
|
|
|
class _IconSubmenuButton extends StatefulWidget {
|
|
final String tooltip;
|
|
final String? svg;
|
|
final Widget? icon;
|
|
final Color color;
|
|
final Color hoverColor;
|
|
final List<Widget> Function() menuChildrenGetter;
|
|
final MenuStyle? menuStyle;
|
|
final FFI? ffi;
|
|
final double? width;
|
|
|
|
_IconSubmenuButton({
|
|
Key? key,
|
|
this.svg,
|
|
this.icon,
|
|
required this.tooltip,
|
|
required this.color,
|
|
required this.hoverColor,
|
|
required this.menuChildrenGetter,
|
|
this.ffi,
|
|
this.menuStyle,
|
|
this.width,
|
|
}) : super(key: key);
|
|
|
|
@override
|
|
State<_IconSubmenuButton> createState() => _IconSubmenuButtonState();
|
|
}
|
|
|
|
class _IconSubmenuButtonState extends State<_IconSubmenuButton> {
|
|
bool hover = false;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
assert(widget.svg != null || widget.icon != null);
|
|
final icon = widget.icon ??
|
|
SvgPicture.asset(
|
|
widget.svg!,
|
|
colorFilter: ColorFilter.mode(Colors.white, BlendMode.srcIn),
|
|
width: _ToolbarTheme.buttonSize,
|
|
height: _ToolbarTheme.buttonSize,
|
|
);
|
|
final button = SizedBox(
|
|
width: widget.width ?? _ToolbarTheme.buttonSize,
|
|
height: _ToolbarTheme.buttonSize,
|
|
child: SubmenuButton(
|
|
menuStyle:
|
|
widget.menuStyle ?? _ToolbarTheme.defaultMenuStyle(context),
|
|
style: _ToolbarTheme.defaultMenuButtonStyle,
|
|
onHover: (value) => setState(() {
|
|
hover = value;
|
|
}),
|
|
child: Tooltip(
|
|
message: translate(widget.tooltip),
|
|
child: Material(
|
|
type: MaterialType.transparency,
|
|
child: Ink(
|
|
decoration: BoxDecoration(
|
|
borderRadius:
|
|
BorderRadius.circular(_ToolbarTheme.iconRadius),
|
|
color: hover ? widget.hoverColor : widget.color,
|
|
),
|
|
child: icon))),
|
|
menuChildren: widget
|
|
.menuChildrenGetter()
|
|
.map((e) => _buildPointerTrackWidget(e, widget.ffi))
|
|
.toList()));
|
|
return MenuBar(children: [
|
|
button.marginSymmetric(
|
|
horizontal: _ToolbarTheme.buttonHMargin,
|
|
vertical: _ToolbarTheme.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(),
|
|
menuStyle: _ToolbarTheme.defaultMenuStyle(context),
|
|
);
|
|
}
|
|
}
|
|
|
|
class MenuButton extends StatelessWidget {
|
|
final VoidCallback? onPressed;
|
|
final Widget? trailingIcon;
|
|
final Widget? child;
|
|
final FFI? ffi;
|
|
MenuButton(
|
|
{Key? key,
|
|
this.onPressed,
|
|
this.trailingIcon,
|
|
required this.child,
|
|
this.ffi})
|
|
: super(key: key);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return MenuItemButton(
|
|
key: key,
|
|
onPressed: onPressed != null
|
|
? () {
|
|
if (ffi != null) {
|
|
_menuDismissCallback(ffi!);
|
|
}
|
|
onPressed?.call();
|
|
}
|
|
: null,
|
|
trailingIcon: trailingIcon,
|
|
child: child);
|
|
}
|
|
}
|
|
|
|
class CkbMenuButton extends StatelessWidget {
|
|
final bool? value;
|
|
final ValueChanged<bool?>? onChanged;
|
|
final Widget? child;
|
|
final FFI? ffi;
|
|
const CkbMenuButton(
|
|
{Key? key,
|
|
required this.value,
|
|
required this.onChanged,
|
|
required this.child,
|
|
this.ffi})
|
|
: super(key: key);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return CheckboxMenuButton(
|
|
key: key,
|
|
value: value,
|
|
child: child,
|
|
onChanged: onChanged != null
|
|
? (bool? value) {
|
|
if (ffi != null) {
|
|
_menuDismissCallback(ffi!);
|
|
}
|
|
onChanged?.call(value);
|
|
}
|
|
: null,
|
|
);
|
|
}
|
|
}
|
|
|
|
class RdoMenuButton<T> extends StatelessWidget {
|
|
final T value;
|
|
final T? groupValue;
|
|
final ValueChanged<T?>? onChanged;
|
|
final Widget? child;
|
|
final FFI? ffi;
|
|
// When true, submenu will be dismissed on activate; when false, it stays open.
|
|
final bool closeOnActivate;
|
|
const RdoMenuButton({
|
|
Key? key,
|
|
required this.value,
|
|
required this.groupValue,
|
|
required this.child,
|
|
this.ffi,
|
|
this.onChanged,
|
|
this.closeOnActivate = true,
|
|
}) : super(key: key);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return RadioMenuButton(
|
|
value: value,
|
|
groupValue: groupValue,
|
|
child: child,
|
|
closeOnActivate: closeOnActivate,
|
|
onChanged: onChanged != null
|
|
? (T? value) {
|
|
if (ffi != null && closeOnActivate) {
|
|
_menuDismissCallback(ffi!);
|
|
}
|
|
onChanged?.call(value);
|
|
}
|
|
: null,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DraggableShowHide extends StatefulWidget {
|
|
final String id;
|
|
final SessionID sessionId;
|
|
final RxDouble fractionX;
|
|
final RxBool dragging;
|
|
final ToolbarState toolbarState;
|
|
final BorderRadius borderRadius;
|
|
|
|
final Function(bool) setFullscreen;
|
|
final Function() setMinimize;
|
|
|
|
const _DraggableShowHide({
|
|
Key? key,
|
|
required this.id,
|
|
required this.sessionId,
|
|
required this.fractionX,
|
|
required this.dragging,
|
|
required this.toolbarState,
|
|
required this.setFullscreen,
|
|
required this.setMinimize,
|
|
required this.borderRadius,
|
|
}) : super(key: key);
|
|
|
|
@override
|
|
State<_DraggableShowHide> createState() => _DraggableShowHideState();
|
|
}
|
|
|
|
class _DraggableShowHideState extends State<_DraggableShowHide> {
|
|
Offset position = Offset.zero;
|
|
Size size = Size.zero;
|
|
double left = 0.0;
|
|
double right = 1.0;
|
|
|
|
RxBool get show => widget.toolbarState.show;
|
|
|
|
@override
|
|
initState() {
|
|
super.initState();
|
|
|
|
final confLeft = double.tryParse(
|
|
bind.mainGetLocalOption(key: kOptionRemoteMenubarDragLeft));
|
|
if (confLeft == null) {
|
|
bind.mainSetLocalOption(
|
|
key: kOptionRemoteMenubarDragLeft, value: left.toString());
|
|
} else {
|
|
left = confLeft;
|
|
}
|
|
final confRight = double.tryParse(
|
|
bind.mainGetLocalOption(key: kOptionRemoteMenubarDragRight));
|
|
if (confRight == null) {
|
|
bind.mainSetLocalOption(
|
|
key: kOptionRemoteMenubarDragRight, value: right.toString());
|
|
} else {
|
|
right = confRight;
|
|
}
|
|
}
|
|
|
|
Widget _buildDraggable(BuildContext context) {
|
|
return Draggable(
|
|
axis: Axis.horizontal,
|
|
child: Icon(
|
|
Icons.drag_indicator,
|
|
size: 20,
|
|
color: MyTheme.color(context).drag_indicator,
|
|
),
|
|
feedback: widget,
|
|
onDragStarted: (() {
|
|
final RenderObject? renderObj = context.findRenderObject();
|
|
if (renderObj != null) {
|
|
final RenderBox renderBox = renderObj as RenderBox;
|
|
size = renderBox.size;
|
|
position = renderBox.localToGlobal(Offset.zero);
|
|
}
|
|
widget.dragging.value = true;
|
|
}),
|
|
onDragEnd: (details) {
|
|
final mediaSize = MediaQueryData.fromView(View.of(context)).size;
|
|
widget.fractionX.value +=
|
|
(details.offset.dx - position.dx) / (mediaSize.width - size.width);
|
|
if (widget.fractionX.value < left) {
|
|
widget.fractionX.value = left;
|
|
}
|
|
if (widget.fractionX.value > right) {
|
|
widget.fractionX.value = right;
|
|
}
|
|
bind.sessionPeerOption(
|
|
sessionId: widget.sessionId,
|
|
name: 'remote-menubar-drag-x',
|
|
value: widget.fractionX.value.toString(),
|
|
);
|
|
widget.dragging.value = false;
|
|
},
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final ButtonStyle buttonStyle = ButtonStyle(
|
|
minimumSize: MaterialStateProperty.all(const Size(0, 0)),
|
|
padding: MaterialStateProperty.all(EdgeInsets.zero),
|
|
);
|
|
final isFullscreen = stateGlobal.fullscreen;
|
|
const double iconSize = 20;
|
|
|
|
buttonWrapper(VoidCallback? onPressed, Widget child,
|
|
{Color hoverColor = _ToolbarTheme.blueColor}) {
|
|
final bgColor = buttonStyle.backgroundColor?.resolve({});
|
|
return TextButton(
|
|
onPressed: onPressed,
|
|
child: child,
|
|
style: buttonStyle.copyWith(
|
|
backgroundColor: MaterialStateProperty.resolveWith((states) {
|
|
if (states.contains(MaterialState.hovered)) {
|
|
return (bgColor ?? hoverColor).withOpacity(0.15);
|
|
}
|
|
return bgColor;
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
final child = Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_buildDraggable(context),
|
|
Obx(() => buttonWrapper(
|
|
() {
|
|
widget.setFullscreen(!isFullscreen.value);
|
|
},
|
|
Tooltip(
|
|
message: translate(
|
|
isFullscreen.isTrue ? 'Exit Fullscreen' : 'Fullscreen'),
|
|
child: Icon(
|
|
isFullscreen.isTrue
|
|
? Icons.fullscreen_exit
|
|
: Icons.fullscreen,
|
|
size: iconSize,
|
|
),
|
|
),
|
|
)),
|
|
if (!isMacOS && !isWebDesktop)
|
|
Obx(() => Offstage(
|
|
offstage: isFullscreen.isFalse,
|
|
child: buttonWrapper(
|
|
widget.setMinimize,
|
|
Tooltip(
|
|
message: translate('Minimize'),
|
|
child: Icon(
|
|
Icons.remove,
|
|
size: iconSize,
|
|
),
|
|
),
|
|
),
|
|
)),
|
|
buttonWrapper(
|
|
() => setState(() {
|
|
widget.toolbarState.switchShow(widget.sessionId);
|
|
}),
|
|
Obx((() => Tooltip(
|
|
message:
|
|
translate(show.isTrue ? 'Hide Toolbar' : 'Show Toolbar'),
|
|
child: Icon(
|
|
show.isTrue ? Icons.expand_less : Icons.expand_more,
|
|
size: iconSize,
|
|
),
|
|
))),
|
|
),
|
|
if (isWebDesktop)
|
|
Obx(() {
|
|
if (show.isTrue) {
|
|
return Offstage();
|
|
} else {
|
|
return buttonWrapper(
|
|
() => closeConnection(id: widget.id),
|
|
Tooltip(
|
|
message: translate('Close'),
|
|
child: Icon(
|
|
Icons.close,
|
|
size: iconSize,
|
|
color: _ToolbarTheme.redColor,
|
|
),
|
|
),
|
|
hoverColor: _ToolbarTheme.redColor,
|
|
).paddingOnly(left: iconSize / 2);
|
|
}
|
|
})
|
|
],
|
|
);
|
|
return TextButtonTheme(
|
|
data: TextButtonThemeData(style: buttonStyle),
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context)
|
|
.menuBarTheme
|
|
.style
|
|
?.backgroundColor
|
|
?.resolve(MaterialState.values.toSet()),
|
|
border: Border.all(
|
|
color: _ToolbarTheme.borderColor(context),
|
|
width: 1,
|
|
),
|
|
borderRadius: widget.borderRadius,
|
|
),
|
|
child: SizedBox(
|
|
height: 20,
|
|
child: child,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class InputModeMenu {
|
|
final String key;
|
|
final String menu;
|
|
|
|
InputModeMenu({required this.key, required this.menu});
|
|
}
|
|
|
|
_menuDismissCallback(FFI ffi) => ffi.inputModel.refreshMousePos();
|
|
|
|
Widget _buildPointerTrackWidget(Widget child, FFI? ffi) {
|
|
return Listener(
|
|
onPointerHover: (PointerHoverEvent e) => {
|
|
if (ffi != null) {ffi.inputModel.lastMousePos = e.position}
|
|
},
|
|
child: MouseRegion(
|
|
child: child,
|
|
),
|
|
);
|
|
}
|