mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-08-28 17:17:02 +00:00
* fix(windows): restore keyboard focus when the cursor re-enters the remote image On Windows the raw key focus node is unfocused on window blur and nothing requests it back, so returning to an already connected session left the keyboard dead until the remote image was clicked. Request focus from enterView(), gated on the window being active, the tab being selected and no blocking overlay, so a background window cannot grab system keys. enterOrLeave(true) is still driven by RawKeyFocusScope's onFocusChange, so it is not called twice. * fix(windows): refocus on window focus when the cursor already hovers the image Alt+Tab or a taskbar click returns focus without a PointerEnter, so enterView() cannot restore the keyboard. Reuse _cursorOverImage, gated on the selected tab and no blocking overlay. * refactor(windows): share one focus predicate for every requestFocus path The relative-mouse-mode restore on window focus could hand remote input to this page while a blocking dialog was up or the tab was not selected.
1427 lines
49 KiB
Dart
1427 lines
49 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:desktop_multi_window/desktop_multi_window.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter/scheduler.dart';
|
|
import 'package:get/get.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:flutter_hbb/models/state_model.dart';
|
|
|
|
import '../../consts.dart';
|
|
import '../../common/widgets/overlay.dart';
|
|
import '../../common/widgets/remote_input.dart';
|
|
import '../../common.dart';
|
|
import '../../common/widgets/dialog.dart';
|
|
import '../../common/widgets/toolbar.dart';
|
|
import '../../models/model.dart';
|
|
import '../../models/input_model.dart';
|
|
import '../../models/platform_model.dart';
|
|
import '../../common/shared_state.dart';
|
|
import '../../utils/image.dart';
|
|
import '../widgets/remote_toolbar.dart';
|
|
import '../widgets/kb_layout_type_chooser.dart';
|
|
import '../widgets/tabbar_widget.dart';
|
|
import 'macos_full_screen_focus_recovery.dart';
|
|
|
|
import 'package:flutter_hbb/native/custom_cursor.dart'
|
|
if (dart.library.html) 'package:flutter_hbb/web/custom_cursor.dart';
|
|
|
|
final SimpleWrapper<bool> _firstEnterImage = SimpleWrapper(false);
|
|
|
|
// Used to skip session close if "move to new window" is clicked.
|
|
final Map<String, bool> closeSessionOnDispose = {};
|
|
|
|
class RemotePage extends StatefulWidget {
|
|
RemotePage({
|
|
Key? key,
|
|
required this.id,
|
|
required this.toolbarState,
|
|
this.sessionId,
|
|
this.tabWindowId,
|
|
this.password,
|
|
this.display,
|
|
this.displays,
|
|
this.tabController,
|
|
this.switchUuid,
|
|
this.forceRelay,
|
|
this.isSharedPassword,
|
|
}) : super(key: key) {
|
|
initSharedStates(id);
|
|
}
|
|
|
|
final String id;
|
|
final SessionID? sessionId;
|
|
final int? tabWindowId;
|
|
final int? display;
|
|
final List<int>? displays;
|
|
final String? password;
|
|
final ToolbarState toolbarState;
|
|
final String? switchUuid;
|
|
final bool? forceRelay;
|
|
final bool? isSharedPassword;
|
|
final SimpleWrapper<State<RemotePage>?> _lastState = SimpleWrapper(null);
|
|
final DesktopTabController? tabController;
|
|
|
|
FFI get ffi => (_lastState.value! as _RemotePageState)._ffi;
|
|
|
|
void releaseMacOSInputForTabTransfer() {
|
|
if (!isMacOS) return;
|
|
// Release before removing the source tab. Its delayed disposal must not
|
|
// disable a native keyboard hook already acquired by the destination page.
|
|
(_lastState.value! as _RemotePageState)._releaseMacOSRemoteInput();
|
|
}
|
|
|
|
@override
|
|
State<RemotePage> createState() {
|
|
final state = _RemotePageState(id);
|
|
_lastState.value = state;
|
|
return state;
|
|
}
|
|
}
|
|
|
|
class _RemotePageState extends State<RemotePage>
|
|
with
|
|
AutomaticKeepAliveClientMixin,
|
|
MultiWindowListener,
|
|
WidgetsBindingObserver,
|
|
TickerProviderStateMixin {
|
|
Timer? _timer;
|
|
String keyboardMode = "legacy";
|
|
bool _isWindowBlur = false;
|
|
// Known macOS remote-input trade-offs (kept simple intentionally):
|
|
// 1. Dialogs rely on FocusNode loss plus middleBlocked, not mirrored dialog
|
|
// state. Reproduce: activate remote input, open a dialog, then type.
|
|
// 2. Delayed fullscreen recovery can race a local-control focus change; no
|
|
// owner state is added. Reproduce: focus the toolbar during a Space switch.
|
|
// 3. Input-source switching releases native input without updating this
|
|
// page's cache. Reproduce: switch sources, then type before and after
|
|
// clicking the remote image; the click reasserts input.
|
|
// These latches compensate for out-of-order macOS focus events. Treat them
|
|
// as coupled when changing a transition or _syncMacOSKeyboardGrab().
|
|
AppLifecycleState? _macOSLifecycleState;
|
|
bool _macOSLocalFocusLost = false;
|
|
bool _macOSInputActive = false;
|
|
bool _macOSInputSuppressed = false;
|
|
final _macOSFullScreenFocusRecovery = MacOSFullScreenFocusRecovery();
|
|
bool _macOSExplicitFocusRequestPending = false;
|
|
StreamSubscription<DesktopTabState>? _tabStateSubscription;
|
|
final _cursorOverImage = false.obs;
|
|
late RxBool _showRemoteCursor;
|
|
late RxBool _zoomCursor;
|
|
late RxBool _remoteCursorMoved;
|
|
late RxBool _keyboardEnabled;
|
|
final _uniqueKey = UniqueKey();
|
|
|
|
var _blockableOverlayState = BlockableOverlayState();
|
|
|
|
final FocusNode _rawKeyFocusNode = FocusNode(debugLabel: "rawkeyFocusNode");
|
|
|
|
// Debounce timer for pointer lock center updates during window events.
|
|
// Uses kDefaultPointerLockCenterThrottleMs from consts.dart for the duration.
|
|
Timer? _pointerLockCenterDebounceTimer;
|
|
|
|
// We need `_instanceIdOnEnterOrLeaveImage4Toolbar` together with `_onEnterOrLeaveImage4Toolbar`
|
|
// to identify the toolbar instance and its callback function.
|
|
int? _instanceIdOnEnterOrLeaveImage4Toolbar;
|
|
Function(bool)? _onEnterOrLeaveImage4Toolbar;
|
|
|
|
late FFI _ffi;
|
|
Worker? _waylandKeyboardModeWorker;
|
|
bool _waylandKeyboardModeNormalized = false;
|
|
bool _waylandKeyboardModeNormalizing = false;
|
|
|
|
SessionID get sessionId => _ffi.sessionId;
|
|
|
|
_RemotePageState(String id) {
|
|
_initStates(id);
|
|
}
|
|
|
|
void _initStates(String id) {
|
|
_zoomCursor = PeerBoolOption.find(id, kOptionZoomCursor);
|
|
_showRemoteCursor = ShowRemoteCursorState.find(id);
|
|
_keyboardEnabled = KeyboardEnabledState.find(id);
|
|
_remoteCursorMoved = RemoteCursorMovedState.find(id);
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_ffi = FFI(widget.sessionId);
|
|
if (isMacOS) {
|
|
// SchedulerBinding.instance.lifecycleState is null in the first connection in a new window.
|
|
_macOSLifecycleState = SchedulerBinding.instance.lifecycleState;
|
|
WidgetsBinding.instance.addObserver(this);
|
|
_tabStateSubscription =
|
|
widget.tabController?.state.listen(_onMacOSTabStateChanged);
|
|
}
|
|
Get.put<FFI>(_ffi, tag: widget.id);
|
|
_ffi.imageModel.addCallbackOnFirstImage((String peerId) {
|
|
_ffi.canvasModel.activateLocalCursor();
|
|
showKBLayoutTypeChooserIfNeeded(
|
|
_ffi.ffiModel.pi.platform, _ffi.dialogManager);
|
|
_ffi.recordingModel
|
|
.updateStatus(bind.sessionGetIsRecording(sessionId: _ffi.sessionId));
|
|
});
|
|
_ffi.canvasModel.initializeEdgeScrollFallback(this);
|
|
_ffi.start(
|
|
widget.id,
|
|
password: widget.password,
|
|
isSharedPassword: widget.isSharedPassword,
|
|
switchUuid: widget.switchUuid,
|
|
forceRelay: widget.forceRelay,
|
|
tabWindowId: widget.tabWindowId,
|
|
display: widget.display,
|
|
displays: widget.displays,
|
|
);
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: []);
|
|
_ffi.dialogManager
|
|
.showLoading(translate('Connecting...'), onCancel: closeConnection);
|
|
});
|
|
WakelockManager.enable(_uniqueKey);
|
|
|
|
_ffi.ffiModel.updateEventListener(sessionId, widget.id);
|
|
_ffi.qualityMonitorModel.checkShowQualityMonitor(sessionId);
|
|
_ffi.dialogManager.loadMobileActionsOverlayVisible();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
// Session option should be set after models.dart/FFI.start
|
|
_showRemoteCursor.value = bind.sessionGetToggleOptionSync(
|
|
sessionId: sessionId, arg: 'show-remote-cursor');
|
|
_zoomCursor.value = bind.sessionGetToggleOptionSync(
|
|
sessionId: sessionId, arg: kOptionZoomCursor);
|
|
});
|
|
DesktopMultiWindow.addListener(this);
|
|
// if (!_isCustomCursorInited) {
|
|
// customCursorController.registerNeedUpdateCursorCallback(
|
|
// (String? lastKey, String? currentKey) async {
|
|
// if (_firstEnterImage.value) {
|
|
// _firstEnterImage.value = false;
|
|
// return true;
|
|
// }
|
|
// return lastKey == null || lastKey != currentKey;
|
|
// });
|
|
// _isCustomCursorInited = true;
|
|
// }
|
|
|
|
_blockableOverlayState.applyFfi(_ffi);
|
|
// Call onSelected in post frame callback, since we cannot guarantee that the callback will not call setState.
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
widget.tabController?.onSelected?.call(widget.id);
|
|
});
|
|
|
|
// Register callback to cancel debounce timer when relative mouse mode is disabled
|
|
_ffi.inputModel.onRelativeMouseModeDisabled =
|
|
_cancelPointerLockCenterDebounceTimer;
|
|
|
|
_waylandKeyboardModeWorker = ever(_ffi.ffiModel.pi.isSet, (bool isSet) {
|
|
if (isSet) {
|
|
unawaited(_normalizeWaylandKeyboardModeIfNeeded());
|
|
}
|
|
});
|
|
if (_ffi.ffiModel.pi.isSet.value) {
|
|
unawaited(_normalizeWaylandKeyboardModeIfNeeded());
|
|
}
|
|
}
|
|
|
|
Future<void> _normalizeWaylandKeyboardModeIfNeeded() async {
|
|
if (!mounted ||
|
|
_waylandKeyboardModeNormalized ||
|
|
_waylandKeyboardModeNormalizing) {
|
|
return;
|
|
}
|
|
_waylandKeyboardModeNormalizing = true;
|
|
try {
|
|
final pi = _ffi.ffiModel.pi;
|
|
if (pi.platform != kPeerPlatformLinux || !pi.isWayland) return;
|
|
final mapSupported = bind.sessionIsKeyboardModeSupported(
|
|
sessionId: sessionId, mode: kKeyMapMode);
|
|
if (!mapSupported) return;
|
|
final current = await bind.sessionGetKeyboardMode(sessionId: sessionId);
|
|
if (!mounted) return;
|
|
if (current == kKeyMapMode) {
|
|
_waylandKeyboardModeNormalized = true;
|
|
return;
|
|
}
|
|
await bind.sessionSetKeyboardMode(
|
|
sessionId: sessionId, value: kKeyMapMode);
|
|
if (!mounted) return;
|
|
await _ffi.inputModel.updateKeyboardMode();
|
|
if (!mounted) return;
|
|
_waylandKeyboardModeNormalized = true;
|
|
} catch (e, st) {
|
|
debugPrint('Failed to normalize Wayland keyboard mode: $e');
|
|
debugPrintStack(stackTrace: st);
|
|
} finally {
|
|
_waylandKeyboardModeNormalizing = false;
|
|
}
|
|
}
|
|
|
|
/// Cancel the pointer lock center debounce timer
|
|
void _cancelPointerLockCenterDebounceTimer() {
|
|
_pointerLockCenterDebounceTimer?.cancel();
|
|
_pointerLockCenterDebounceTimer = null;
|
|
}
|
|
|
|
bool get _isSelectedTab {
|
|
final controller = widget.tabController;
|
|
if (controller == null) return true;
|
|
final tabState = controller.state.value;
|
|
final selected = tabState.selected;
|
|
return selected >= 0 &&
|
|
selected < tabState.tabs.length &&
|
|
tabState.tabs[selected].key == widget.id;
|
|
}
|
|
|
|
// Every Windows requestFocus() must pass this, or a blocking dialog or an
|
|
// inactive tab could hand remote input to this page.
|
|
bool get _windowsCanFocusRemoteInput =>
|
|
_isSelectedTab && _blockableOverlayState.middleBlocked.isFalse;
|
|
|
|
bool get _isMacOSKeyboardContextActive {
|
|
return stateGlobal.isFocused.value && !_isWindowBlur && _isSelectedTab;
|
|
}
|
|
|
|
void _onMacOSTabStateChanged(DesktopTabState _) {
|
|
if (!_isSelectedTab) {
|
|
_macOSFullScreenFocusRecovery.cancel();
|
|
_syncMacOSKeyboardGrab();
|
|
return;
|
|
}
|
|
// Tab listeners run synchronously. Defer the selected page so the previous
|
|
// page releases first; a late leave from it can disable the new session.
|
|
scheduleMicrotask(() {
|
|
if (mounted) {
|
|
_syncMacOSKeyboardGrab(reassert: true);
|
|
}
|
|
});
|
|
}
|
|
|
|
void _releaseMacOSRemoteInput() {
|
|
_macOSFullScreenFocusRecovery.cancel();
|
|
_macOSExplicitFocusRequestPending = false;
|
|
_macOSInputSuppressed = true;
|
|
_macOSLocalFocusLost = true;
|
|
_ffi.inputModel.enterOrLeave(false);
|
|
_macOSInputActive = false;
|
|
_rawKeyFocusNode.unfocus();
|
|
}
|
|
|
|
void _onMacOSFocusChange() {
|
|
// requestFocus() notifies later; only a recorded explicit request may clear
|
|
// the local-focus-loss latch.
|
|
if (_rawKeyFocusNode.hasPrimaryFocus) {
|
|
final explicitRequest = _macOSExplicitFocusRequestPending;
|
|
_macOSExplicitFocusRequestPending = false;
|
|
if (explicitRequest && _isMacOSKeyboardContextActive) {
|
|
_macOSLocalFocusLost = false;
|
|
}
|
|
_syncMacOSKeyboardGrab(allowInactiveLifecycle: explicitRequest);
|
|
} else {
|
|
if (_macOSInputActive) {
|
|
_ffi.inputModel.enterOrLeave(false);
|
|
_macOSInputActive = false;
|
|
}
|
|
if (_isMacOSKeyboardContextActive) {
|
|
_macOSLocalFocusLost = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 1. Sync the keyboard grab state with the current context.
|
|
// 2. Call enterOrLeave() to update the input state in the FFI layer.
|
|
// 3. Request or unfocus the raw key focus node based on the current context.
|
|
// Flutter focus and native input are separate; native input activates only
|
|
// after the FocusNode has primary focus.
|
|
void _syncMacOSKeyboardGrab({
|
|
bool reassert = false,
|
|
bool allowInactiveLifecycle = false,
|
|
}) {
|
|
if (!isMacOS) return;
|
|
// A secondary engine may stay hidden while its window is visible, so
|
|
// explicit pointer/fullscreen recovery must bypass the global lifecycle.
|
|
final lifecycleAllowsInput = allowInactiveLifecycle ||
|
|
_macOSLifecycleState == null ||
|
|
_macOSLifecycleState == AppLifecycleState.resumed;
|
|
// Input stays pointer-gated except for focused fullscreen recovery, which
|
|
// compensates when macOS omits PointerEnter during a Space switch.
|
|
final shouldFocus = lifecycleAllowsInput &&
|
|
_isMacOSKeyboardContextActive &&
|
|
!_macOSInputSuppressed &&
|
|
_blockableOverlayState.middleBlocked.isFalse &&
|
|
_cursorOverImage.value &&
|
|
!_macOSLocalFocusLost;
|
|
final hasFocus = _rawKeyFocusNode.hasPrimaryFocus;
|
|
final shouldActivateInput = shouldFocus && hasFocus;
|
|
|
|
if (shouldActivateInput != _macOSInputActive ||
|
|
(shouldActivateInput && reassert)) {
|
|
_ffi.inputModel.enterOrLeave(shouldActivateInput);
|
|
}
|
|
_macOSInputActive = shouldActivateInput;
|
|
|
|
if (!shouldFocus) {
|
|
_macOSExplicitFocusRequestPending = false;
|
|
if (hasFocus) _rawKeyFocusNode.unfocus();
|
|
} else if (!hasFocus) {
|
|
_macOSExplicitFocusRequestPending = allowInactiveLifecycle;
|
|
_rawKeyFocusNode.requestFocus();
|
|
} else {
|
|
_macOSExplicitFocusRequestPending = false;
|
|
}
|
|
}
|
|
|
|
void _restoreMacOSKeyboardAfterFullScreen({
|
|
required int generation,
|
|
bool allowHiddenLifecycle = false,
|
|
}) {
|
|
// Fullscreen callbacks preserve recovery while hidden. Native window focus
|
|
// may bypass a stale hidden lifecycle for the newly visible Space.
|
|
if (!_macOSFullScreenFocusRecovery.isCurrent(generation) ||
|
|
(!allowHiddenLifecycle &&
|
|
_macOSLifecycleState == AppLifecycleState.hidden)) {
|
|
return;
|
|
}
|
|
final contextActive =
|
|
stateGlobal.isFocused.value && !_isWindowBlur && _isSelectedTab;
|
|
// macOS can focus a fullscreen Space without sending PointerEnter. Native
|
|
// window focus is authoritative here; a later blur cancels this generation
|
|
// before an off-screen window can restore input.
|
|
final shouldInferPointerInside = !_cursorOverImage.value &&
|
|
allowHiddenLifecycle &&
|
|
stateGlobal.fullscreen.isTrue &&
|
|
contextActive;
|
|
final canRestore = contextActive &&
|
|
_blockableOverlayState.middleBlocked.isFalse &&
|
|
(_cursorOverImage.value || shouldInferPointerInside);
|
|
if (!_macOSFullScreenFocusRecovery.consume(generation)) return;
|
|
if (!canRestore) {
|
|
// Consuming recovery here requires a later pointer/window/tab event.
|
|
return;
|
|
}
|
|
if (shouldInferPointerInside) {
|
|
_cursorOverImage.value = true;
|
|
}
|
|
_macOSLocalFocusLost = false;
|
|
stateGlobal.getInputSource(force: true);
|
|
_syncMacOSKeyboardGrab(reassert: true, allowInactiveLifecycle: true);
|
|
}
|
|
|
|
void _scheduleMacOSKeyboardAfterFullScreen({
|
|
required int generation,
|
|
bool allowHiddenLifecycle = false,
|
|
}) {
|
|
// Fullscreen can deliver FocusNode loss after its callback; wait for frame
|
|
// completion and then advance one event-loop turn before restoring.
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
Timer.run(() {
|
|
if (mounted) {
|
|
_restoreMacOSKeyboardAfterFullScreen(
|
|
generation: generation,
|
|
allowHiddenLifecycle: allowHiddenLifecycle,
|
|
);
|
|
}
|
|
});
|
|
});
|
|
WidgetsBinding.instance.ensureVisualUpdate();
|
|
}
|
|
|
|
void _queueMacOSKeyboardAfterFullScreen({
|
|
bool allowHiddenLifecycle = false,
|
|
}) {
|
|
final generation = _macOSFullScreenFocusRecovery.queue();
|
|
if (_macOSLifecycleState == AppLifecycleState.paused ||
|
|
_macOSLifecycleState == AppLifecycleState.detached) {
|
|
_macOSFullScreenFocusRecovery.cancel();
|
|
return;
|
|
}
|
|
_scheduleMacOSKeyboardAfterFullScreen(
|
|
generation: generation,
|
|
allowHiddenLifecycle: allowHiddenLifecycle,
|
|
);
|
|
}
|
|
|
|
@override
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
super.didChangeAppLifecycleState(state);
|
|
if (!isMacOS || _macOSLifecycleState == state) return;
|
|
_macOSLifecycleState = state;
|
|
if (state == AppLifecycleState.resumed) {
|
|
_syncMacOSKeyboardGrab(reassert: true);
|
|
} else if (_macOSInputActive) {
|
|
_ffi.inputModel.enterOrLeave(false);
|
|
_macOSInputActive = false;
|
|
}
|
|
|
|
final generation = _macOSFullScreenFocusRecovery.pendingGeneration;
|
|
if (generation == null) return;
|
|
if (state == AppLifecycleState.inactive ||
|
|
state == AppLifecycleState.resumed) {
|
|
_scheduleMacOSKeyboardAfterFullScreen(generation: generation);
|
|
} else if (state == AppLifecycleState.paused ||
|
|
state == AppLifecycleState.detached) {
|
|
_macOSFullScreenFocusRecovery.cancel();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void onWindowBlur() {
|
|
super.onWindowBlur();
|
|
// On windows, we use `focus` way to handle keyboard better.
|
|
// Now on Linux, there's some rdev issues which will break the input.
|
|
// We disable the `focus` way for Linux temporarily.
|
|
if (isWindows || isMacOS) {
|
|
_isWindowBlur = true;
|
|
}
|
|
if (isMacOS) {
|
|
_macOSFullScreenFocusRecovery.cancel();
|
|
// A blur or Space switch may not emit PointerExit, so cursor state alone
|
|
// cannot prevent the old remote surface from reclaiming the keyboard.
|
|
_macOSLocalFocusLost = true;
|
|
}
|
|
if (isWindows) {
|
|
// unfocus the primary-focus when the whole window is lost focus,
|
|
// and let OS to handle events instead.
|
|
_rawKeyFocusNode.unfocus();
|
|
}
|
|
stateGlobal.isFocused.value = false;
|
|
_syncMacOSKeyboardGrab();
|
|
|
|
// When window loses focus, temporarily release relative mouse mode constraints
|
|
// to allow user to interact with other applications normally.
|
|
// The cursor will be re-hidden and re-centered when window regains focus.
|
|
if (_ffi.inputModel.relativeMouseMode.value) {
|
|
_ffi.inputModel.onWindowBlur();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void onWindowFocus() {
|
|
super.onWindowFocus();
|
|
// See [onWindowBlur].
|
|
if (isWindows || isMacOS) {
|
|
_isWindowBlur = false;
|
|
}
|
|
if (isMacOS) stateGlobal.getInputSource(force: true);
|
|
stateGlobal.isFocused.value = true;
|
|
|
|
// Normal macOS windows wait for PointerEnter or PointerDown. A focused
|
|
// fullscreen Space queues delayed recovery; if this window blurs again, the
|
|
// pending recovery is cancelled before native input can reactivate.
|
|
// Regression: switch directly between fullscreen remote Spaces without
|
|
// moving or clicking; only the newly focused session may receive input.
|
|
if (isMacOS &&
|
|
stateGlobal.fullscreen.isTrue &&
|
|
!_ffi.inputModel.relativeMouseMode.value) {
|
|
// Native window focus is authoritative when a secondary engine retains a
|
|
// stale hidden lifecycle state after its fullscreen Space becomes visible.
|
|
_queueMacOSKeyboardAfterFullScreen(allowHiddenLifecycle: true);
|
|
}
|
|
|
|
// Refocus without PointerEnter: the cursor already hovers the image when
|
|
// focus returns (Alt+Tab, taskbar), so enterView() never fires again.
|
|
if (isWindows &&
|
|
_cursorOverImage.value &&
|
|
_windowsCanFocusRemoteInput &&
|
|
!_rawKeyFocusNode.hasFocus) {
|
|
_rawKeyFocusNode.requestFocus();
|
|
}
|
|
|
|
// Restore relative mouse mode constraints when window regains focus.
|
|
if (_ffi.inputModel.relativeMouseMode.value) {
|
|
if (isMacOS) {
|
|
// Native relative mode retains pointer capture and does not emit
|
|
// PointerEnter after window focus returns. Restore both latches unless
|
|
// a local overlay still owns input.
|
|
if (_blockableOverlayState.middleBlocked.isFalse) {
|
|
_cursorOverImage.value = true;
|
|
_macOSLocalFocusLost = false;
|
|
}
|
|
} else if (!isWindows || _windowsCanFocusRemoteInput) {
|
|
_rawKeyFocusNode.requestFocus();
|
|
}
|
|
_ffi.inputModel.onWindowFocus();
|
|
}
|
|
_syncMacOSKeyboardGrab(reassert: true, allowInactiveLifecycle: true);
|
|
}
|
|
|
|
@override
|
|
void onWindowRestore() {
|
|
super.onWindowRestore();
|
|
// On windows, we use `onWindowRestore` way to handle window restore from
|
|
// a minimized state.
|
|
if (isWindows) {
|
|
_isWindowBlur = false;
|
|
}
|
|
WakelockManager.enable(_uniqueKey);
|
|
// Update pointer lock center when window is restored
|
|
_updatePointerLockCenterIfNeeded();
|
|
}
|
|
|
|
// When the window is unminimized, onWindowMaximize or onWindowRestore can be called when the old state was maximized or not.
|
|
@override
|
|
void onWindowMaximize() {
|
|
super.onWindowMaximize();
|
|
WakelockManager.enable(_uniqueKey);
|
|
// Update pointer lock center when window is maximized
|
|
_updatePointerLockCenterIfNeeded();
|
|
}
|
|
|
|
@override
|
|
void onWindowResize() {
|
|
super.onWindowResize();
|
|
// Update pointer lock center when window is resized
|
|
_updatePointerLockCenterIfNeeded();
|
|
}
|
|
|
|
@override
|
|
void onWindowMove() {
|
|
super.onWindowMove();
|
|
// Update pointer lock center when window is moved
|
|
_updatePointerLockCenterIfNeeded();
|
|
}
|
|
|
|
/// Update pointer lock center with debouncing to avoid excessive updates
|
|
/// during rapid window move/resize events.
|
|
void _updatePointerLockCenterIfNeeded() {
|
|
if (!_ffi.inputModel.relativeMouseMode.value) return;
|
|
|
|
// Cancel any pending update and schedule a new one (debounce pattern)
|
|
_pointerLockCenterDebounceTimer?.cancel();
|
|
_pointerLockCenterDebounceTimer = Timer(
|
|
const Duration(milliseconds: kDefaultPointerLockCenterThrottleMs),
|
|
() {
|
|
if (!mounted) return;
|
|
if (_ffi.inputModel.relativeMouseMode.value) {
|
|
_ffi.inputModel.updatePointerLockCenter();
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
@override
|
|
void onWindowMinimize() {
|
|
super.onWindowMinimize();
|
|
WakelockManager.disable(_uniqueKey);
|
|
if (isMacOS) {
|
|
_macOSFullScreenFocusRecovery.cancel();
|
|
_isWindowBlur = true;
|
|
_cursorOverImage.value = false;
|
|
stateGlobal.isFocused.value = false;
|
|
_syncMacOSKeyboardGrab();
|
|
}
|
|
// Release cursor constraints when minimized
|
|
if (_ffi.inputModel.relativeMouseMode.value) {
|
|
_ffi.inputModel.onWindowBlur();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void onWindowEnterFullScreen() {
|
|
super.onWindowEnterFullScreen();
|
|
if (isMacOS) {
|
|
stateGlobal.setFullscreen(true);
|
|
_queueMacOSKeyboardAfterFullScreen();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void onWindowLeaveFullScreen() {
|
|
super.onWindowLeaveFullScreen();
|
|
if (isMacOS) {
|
|
stateGlobal.setFullscreen(false);
|
|
_queueMacOSKeyboardAfterFullScreen();
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> dispose() async {
|
|
final closeSession = closeSessionOnDispose.remove(widget.id) ?? true;
|
|
|
|
// https://github.com/flutter/flutter/issues/64935
|
|
if (isMacOS) {
|
|
// Tab moves release before transfer to avoid a late retained-session leave.
|
|
if (closeSession) {
|
|
_releaseMacOSRemoteInput();
|
|
}
|
|
_tabStateSubscription?.cancel();
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
}
|
|
super.dispose();
|
|
debugPrint("REMOTE PAGE dispose session $sessionId ${widget.id}");
|
|
|
|
// Defensive cleanup: ensure host system-key propagation is reset even if
|
|
// MouseRegion.onExit never fired (e.g., tab closed while cursor inside).
|
|
if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: true);
|
|
|
|
_pointerLockCenterDebounceTimer?.cancel();
|
|
_pointerLockCenterDebounceTimer = null;
|
|
_waylandKeyboardModeWorker?.dispose();
|
|
// Clear callback reference to prevent memory leaks and stale references
|
|
_ffi.inputModel.onRelativeMouseModeDisabled = null;
|
|
// Relative mouse mode cleanup is centralized in FFI.close(closeSession: ...).
|
|
_ffi.textureModel.onRemotePageDispose(closeSession);
|
|
if (closeSession && !isMacOS) {
|
|
// ensure we leave this session, this is a double check
|
|
// enterOrLeave() is already called previously in _releaseMacOSRemoteInput() for macOS.
|
|
_ffi.inputModel.enterOrLeave(false);
|
|
}
|
|
DesktopMultiWindow.removeListener(this);
|
|
_ffi.dialogManager.hideMobileActionsOverlay();
|
|
_ffi.imageModel.disposeImage();
|
|
_ffi.cursorModel.disposeImages();
|
|
_rawKeyFocusNode.dispose();
|
|
if (closeSession) {
|
|
clearWaylandKeyboardPromptSuppressedForConnection(sessionId.toString());
|
|
}
|
|
await _ffi.close(closeSession: closeSession);
|
|
_timer?.cancel();
|
|
_ffi.dialogManager.dismissAll();
|
|
if (closeSession) {
|
|
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
|
|
overlays: SystemUiOverlay.values);
|
|
}
|
|
WakelockManager.disable(_uniqueKey);
|
|
await Get.delete<FFI>(tag: widget.id);
|
|
removeSharedStates(widget.id);
|
|
}
|
|
|
|
Widget emptyOverlay() => BlockableOverlay(
|
|
/// the Overlay key will be set with _blockableOverlayState in BlockableOverlay
|
|
/// see override build() in [BlockableOverlay]
|
|
state: _blockableOverlayState,
|
|
underlying: Container(
|
|
color: Colors.transparent,
|
|
),
|
|
);
|
|
|
|
Widget buildBody(BuildContext context) {
|
|
remoteToolbar(BuildContext context) => RemoteToolbar(
|
|
id: widget.id,
|
|
ffi: _ffi,
|
|
state: widget.toolbarState,
|
|
onEnterOrLeaveImageSetter: (id, func) {
|
|
_instanceIdOnEnterOrLeaveImage4Toolbar = id;
|
|
_onEnterOrLeaveImage4Toolbar = func;
|
|
},
|
|
onEnterOrLeaveImageCleaner: (id) {
|
|
// If _instanceIdOnEnterOrLeaveImage4Toolbar != id
|
|
// it means `_onEnterOrLeaveImage4Toolbar` is not set or it has been changed to another toolbar.
|
|
if (_instanceIdOnEnterOrLeaveImage4Toolbar == id) {
|
|
_instanceIdOnEnterOrLeaveImage4Toolbar = null;
|
|
_onEnterOrLeaveImage4Toolbar = null;
|
|
}
|
|
},
|
|
setRemoteState: setState,
|
|
);
|
|
|
|
bodyWidget() {
|
|
return Stack(
|
|
children: [
|
|
Container(
|
|
color: kColorCanvas,
|
|
child: RawKeyFocusScope(
|
|
focusNode: _rawKeyFocusNode,
|
|
onFocusChange: (bool imageFocused) {
|
|
debugPrint(
|
|
"onFocusChange(window active:${!_isWindowBlur}) $imageFocused");
|
|
// See [onWindowBlur].
|
|
if (isWindows) {
|
|
if (_isWindowBlur) {
|
|
imageFocused = false;
|
|
Future.delayed(Duration.zero, () {
|
|
_rawKeyFocusNode.unfocus();
|
|
});
|
|
}
|
|
if (imageFocused) {
|
|
_ffi.inputModel.enterOrLeave(true);
|
|
} else {
|
|
_ffi.inputModel.enterOrLeave(false);
|
|
}
|
|
} else if (isMacOS) {
|
|
_onMacOSFocusChange();
|
|
}
|
|
},
|
|
inputModel: _ffi.inputModel,
|
|
child: getBodyForDesktop(context))),
|
|
Stack(
|
|
children: [
|
|
_ffi.ffiModel.pi.isSet.isTrue &&
|
|
_ffi.ffiModel.waitForFirstImage.isTrue
|
|
? emptyOverlay()
|
|
: () {
|
|
if (!_ffi.ffiModel.isPeerAndroid) {
|
|
return Offstage();
|
|
} else {
|
|
return Obx(() => Offstage(
|
|
offstage: _ffi.dialogManager
|
|
.mobileActionsOverlayVisible.isFalse,
|
|
child: Overlay(initialEntries: [
|
|
makeMobileActionsOverlayEntry(
|
|
() => _ffi.dialogManager
|
|
.setMobileActionsOverlayVisible(false),
|
|
ffi: _ffi,
|
|
)
|
|
]),
|
|
));
|
|
}
|
|
}(),
|
|
// Use Overlay to enable rebuild every time on menu button click.
|
|
// Hide toolbar when relative mouse mode is active to prevent
|
|
// cursor from escaping to toolbar area.
|
|
Obx(() => _ffi.inputModel.relativeMouseMode.value
|
|
? const Offstage()
|
|
: _ffi.ffiModel.pi.isSet.isTrue
|
|
? Overlay(initialEntries: [
|
|
OverlayEntry(builder: remoteToolbar)
|
|
])
|
|
: remoteToolbar(context)),
|
|
_ffi.ffiModel.pi.isSet.isFalse ? emptyOverlay() : Offstage(),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
return Scaffold(
|
|
backgroundColor: Theme.of(context).colorScheme.background,
|
|
body: Obx(() {
|
|
final imageReady = _ffi.ffiModel.pi.isSet.isTrue &&
|
|
_ffi.ffiModel.waitForFirstImage.isFalse;
|
|
if (imageReady) {
|
|
// If the privacy mode(disable physical displays) is switched,
|
|
// we should not dismiss the dialog immediately.
|
|
if (DateTime.now().difference(togglePrivacyModeTime) >
|
|
const Duration(milliseconds: 3000)) {
|
|
// `dismissAll()` is to ensure that the state is clean.
|
|
// It's ok to call dismissAll() here.
|
|
_ffi.dialogManager.dismissAll();
|
|
// Recreate the block state to refresh the state.
|
|
_blockableOverlayState = BlockableOverlayState();
|
|
_blockableOverlayState.applyFfi(_ffi);
|
|
}
|
|
// Block the whole `bodyWidget()` when dialog shows.
|
|
return BlockableOverlay(
|
|
underlying: bodyWidget(),
|
|
state: _blockableOverlayState,
|
|
);
|
|
} else {
|
|
// `_blockableOverlayState` is not recreated here.
|
|
// The toolbar's block state won't work properly when reconnecting, but that's okay.
|
|
return bodyWidget();
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
super.build(context);
|
|
return WillPopScope(
|
|
onWillPop: () async {
|
|
clientClose(sessionId, _ffi);
|
|
return false;
|
|
},
|
|
child: MultiProvider(providers: [
|
|
ChangeNotifierProvider.value(value: _ffi.ffiModel),
|
|
ChangeNotifierProvider.value(value: _ffi.imageModel),
|
|
ChangeNotifierProvider.value(value: _ffi.cursorModel),
|
|
ChangeNotifierProvider.value(value: _ffi.canvasModel),
|
|
ChangeNotifierProvider.value(value: _ffi.recordingModel),
|
|
], child: buildBody(context)));
|
|
}
|
|
|
|
void enterView(PointerEnterEvent evt) {
|
|
_ffi.canvasModel.rearmEdgeScroll();
|
|
|
|
_cursorOverImage.value = true;
|
|
_firstEnterImage.value = true;
|
|
if (_onEnterOrLeaveImage4Toolbar != null) {
|
|
try {
|
|
_onEnterOrLeaveImage4Toolbar!(true);
|
|
} catch (e) {
|
|
//
|
|
}
|
|
}
|
|
|
|
// See [onWindowBlur].
|
|
if (isMacOS) {
|
|
_macOSLocalFocusLost = false;
|
|
stateGlobal.getInputSource(force: true);
|
|
_syncMacOSKeyboardGrab(reassert: true, allowInactiveLifecycle: true);
|
|
} else if (isWindows) {
|
|
// Blur unfocuses this node and nothing restores it, so the keyboard stayed
|
|
// dead until a click. Focus only while the window is really active, or a
|
|
// background window would grab system keys. onFocusChange does enterOrLeave.
|
|
if (!_isWindowBlur &&
|
|
_windowsCanFocusRemoteInput &&
|
|
!_rawKeyFocusNode.hasFocus) {
|
|
_rawKeyFocusNode.requestFocus();
|
|
}
|
|
} else {
|
|
if (!_rawKeyFocusNode.hasFocus) {
|
|
_rawKeyFocusNode.requestFocus();
|
|
}
|
|
_ffi.inputModel.enterOrLeave(true);
|
|
}
|
|
}
|
|
|
|
void leaveView(PointerExitEvent evt) {
|
|
_ffi.canvasModel.disableEdgeScroll();
|
|
|
|
if (_ffi.ffiModel.keyboard) {
|
|
_ffi.inputModel.tryMoveEdgeOnExit(evt.position);
|
|
}
|
|
|
|
_cursorOverImage.value = false;
|
|
_firstEnterImage.value = false;
|
|
if (_onEnterOrLeaveImage4Toolbar != null) {
|
|
try {
|
|
_onEnterOrLeaveImage4Toolbar!(false);
|
|
} catch (e) {
|
|
//
|
|
}
|
|
}
|
|
|
|
// See [onWindowBlur].
|
|
if (isMacOS) {
|
|
_syncMacOSKeyboardGrab();
|
|
} else if (!isWindows) {
|
|
_ffi.inputModel.enterOrLeave(false);
|
|
}
|
|
}
|
|
|
|
Widget _buildRawTouchAndPointerRegion(
|
|
Widget child,
|
|
PointerEnterEventListener? onEnter,
|
|
PointerExitEventListener? onExit,
|
|
) {
|
|
return RawTouchGestureDetectorRegion(
|
|
child: _buildRawPointerMouseRegion(child, onEnter, onExit),
|
|
ffi: _ffi,
|
|
);
|
|
}
|
|
|
|
Widget _buildRawPointerMouseRegion(
|
|
Widget child,
|
|
PointerEnterEventListener? onEnter,
|
|
PointerExitEventListener? onExit,
|
|
) {
|
|
return RawPointerMouseRegion(
|
|
onEnter: onEnter,
|
|
onExit: onExit,
|
|
onPointerDown: (event) {
|
|
// A double check for blur status on Windows and macOS.
|
|
// Note: If there's an `onPointerDown` event is triggered, `_isWindowBlur` is expected being false.
|
|
// Sometimes the system does not send the necessary focus event to flutter. We should manually
|
|
// handle this inconsistent status by setting `_isWindowBlur` to false. So we can
|
|
// ensure the grab-key thread is running when our users are clicking the remote canvas.
|
|
if ((isWindows || isMacOS) && _isWindowBlur) {
|
|
debugPrint(
|
|
"Unexpected status: onPointerDown is triggered while the remote window is in blur status");
|
|
_isWindowBlur = false;
|
|
}
|
|
if (isMacOS) {
|
|
// Regions without matching enter/exit callbacks cannot safely own
|
|
// keyboard state.
|
|
if (onEnter == null || onExit == null) return;
|
|
if (!stateGlobal.isFocused.value) {
|
|
stateGlobal.isFocused.value = true;
|
|
}
|
|
_cursorOverImage.value = true;
|
|
_macOSLocalFocusLost = false;
|
|
stateGlobal.getInputSource(force: true);
|
|
_syncMacOSKeyboardGrab(
|
|
reassert: !isInputSourceFlutter, allowInactiveLifecycle: true);
|
|
} else if (!_rawKeyFocusNode.hasFocus) {
|
|
_rawKeyFocusNode.requestFocus();
|
|
}
|
|
},
|
|
inputModel: _ffi.inputModel,
|
|
child: child,
|
|
);
|
|
}
|
|
|
|
Widget getBodyForDesktop(BuildContext context) {
|
|
var paints = <Widget>[
|
|
MouseRegion(
|
|
onEnter: (evt) {
|
|
if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: false);
|
|
},
|
|
onExit: (evt) {
|
|
if (!isWeb) bind.hostStopSystemKeyPropagate(stopped: true);
|
|
},
|
|
child: _ViewStyleUpdater(
|
|
canvasModel: _ffi.canvasModel,
|
|
inputModel: _ffi.inputModel,
|
|
child: Builder(builder: (context) {
|
|
final peerDisplay = CurrentDisplayState.find(widget.id);
|
|
return Obx(
|
|
() => _ffi.ffiModel.pi.isSet.isFalse
|
|
? Container(color: Colors.transparent)
|
|
: Obx(() {
|
|
_ffi.textureModel.updateCurrentDisplay(peerDisplay.value);
|
|
return ImagePaint(
|
|
id: widget.id,
|
|
zoomCursor: _zoomCursor,
|
|
cursorOverImage: _cursorOverImage,
|
|
keyboardEnabled: _keyboardEnabled,
|
|
remoteCursorMoved: _remoteCursorMoved,
|
|
listenerBuilder: (child) =>
|
|
_buildRawTouchAndPointerRegion(
|
|
child, enterView, leaveView),
|
|
ffi: _ffi,
|
|
);
|
|
}),
|
|
);
|
|
}),
|
|
),
|
|
)
|
|
];
|
|
|
|
if (!_ffi.canvasModel.cursorEmbedded) {
|
|
paints
|
|
.add(Obx(() => _showRemoteCursor.isFalse || _remoteCursorMoved.isFalse
|
|
? Offstage()
|
|
: CursorPaint(
|
|
id: widget.id,
|
|
zoomCursor: _zoomCursor,
|
|
)));
|
|
}
|
|
paints.add(
|
|
Positioned(
|
|
top: 10,
|
|
right: 10,
|
|
child: _buildRawTouchAndPointerRegion(
|
|
QualityMonitor(_ffi.qualityMonitorModel), null, null),
|
|
),
|
|
);
|
|
return Stack(
|
|
children: paints,
|
|
);
|
|
}
|
|
|
|
@override
|
|
bool get wantKeepAlive => true;
|
|
}
|
|
|
|
/// A widget that tracks the view size and updates CanvasModel.updateViewStyle()
|
|
/// and InputModel.updateImageWidgetSize() only when size actually changes.
|
|
/// This avoids scheduling post-frame callbacks on every LayoutBuilder rebuild.
|
|
class _ViewStyleUpdater extends StatefulWidget {
|
|
final CanvasModel canvasModel;
|
|
final InputModel inputModel;
|
|
final Widget child;
|
|
|
|
const _ViewStyleUpdater({
|
|
Key? key,
|
|
required this.canvasModel,
|
|
required this.inputModel,
|
|
required this.child,
|
|
}) : super(key: key);
|
|
|
|
@override
|
|
State<_ViewStyleUpdater> createState() => _ViewStyleUpdaterState();
|
|
}
|
|
|
|
class _ViewStyleUpdaterState extends State<_ViewStyleUpdater> {
|
|
Size? _lastSize;
|
|
bool _callbackScheduled = false;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final maxWidth = constraints.maxWidth;
|
|
final maxHeight = constraints.maxHeight;
|
|
// Guard against infinite constraints (e.g., unconstrained ancestor).
|
|
if (!maxWidth.isFinite || !maxHeight.isFinite) {
|
|
return widget.child;
|
|
}
|
|
final newSize = Size(maxWidth, maxHeight);
|
|
if (_lastSize != newSize) {
|
|
_lastSize = newSize;
|
|
// Schedule the update for after the current frame to avoid setState during build.
|
|
// Use _callbackScheduled flag to prevent accumulating multiple callbacks
|
|
// when size changes rapidly before any callback executes.
|
|
if (!_callbackScheduled) {
|
|
_callbackScheduled = true;
|
|
SchedulerBinding.instance.addPostFrameCallback((_) {
|
|
_callbackScheduled = false;
|
|
final currentSize = _lastSize;
|
|
if (mounted && currentSize != null) {
|
|
widget.canvasModel.updateViewStyle();
|
|
widget.inputModel.updateImageWidgetSize(currentSize);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
return widget.child;
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class ImagePaint extends StatefulWidget {
|
|
final FFI ffi;
|
|
final String id;
|
|
final RxBool zoomCursor;
|
|
final RxBool cursorOverImage;
|
|
final RxBool keyboardEnabled;
|
|
final RxBool remoteCursorMoved;
|
|
final Widget Function(Widget)? listenerBuilder;
|
|
|
|
ImagePaint(
|
|
{Key? key,
|
|
required this.ffi,
|
|
required this.id,
|
|
required this.zoomCursor,
|
|
required this.cursorOverImage,
|
|
required this.keyboardEnabled,
|
|
required this.remoteCursorMoved,
|
|
this.listenerBuilder})
|
|
: super(key: key);
|
|
|
|
@override
|
|
State<StatefulWidget> createState() => _ImagePaintState();
|
|
}
|
|
|
|
class _ImagePaintState extends State<ImagePaint> {
|
|
bool _lastRemoteCursorMoved = false;
|
|
|
|
String get id => widget.id;
|
|
RxBool get zoomCursor => widget.zoomCursor;
|
|
RxBool get cursorOverImage => widget.cursorOverImage;
|
|
RxBool get keyboardEnabled => widget.keyboardEnabled;
|
|
RxBool get remoteCursorMoved => widget.remoteCursorMoved;
|
|
Widget Function(Widget)? get listenerBuilder => widget.listenerBuilder;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final m = Provider.of<ImageModel>(context);
|
|
var c = Provider.of<CanvasModel>(context);
|
|
final s = c.scale;
|
|
|
|
bool isViewAdaptive() => c.viewStyle.style == kRemoteViewStyleAdaptive;
|
|
bool isViewOriginal() => c.viewStyle.style == kRemoteViewStyleOriginal;
|
|
|
|
mouseRegion({child}) => Obx(() {
|
|
double getCursorScale() {
|
|
var c = Provider.of<CanvasModel>(context);
|
|
var cursorScale = 1.0;
|
|
if (isWindows) {
|
|
// debug win10
|
|
if (zoomCursor.value && isViewAdaptive()) {
|
|
cursorScale = s * c.devicePixelRatio;
|
|
}
|
|
} else {
|
|
if (zoomCursor.value || isViewOriginal()) {
|
|
cursorScale = s;
|
|
}
|
|
}
|
|
return cursorScale;
|
|
}
|
|
|
|
return MouseRegion(
|
|
cursor: cursorOverImage.isTrue
|
|
? c.cursorEmbedded
|
|
? SystemMouseCursors.none
|
|
// Hide cursor when relative mouse mode is active
|
|
: widget.ffi.inputModel.relativeMouseMode.value
|
|
? SystemMouseCursors.none
|
|
: keyboardEnabled.isTrue
|
|
? (() {
|
|
if (remoteCursorMoved.isTrue) {
|
|
_lastRemoteCursorMoved = true;
|
|
return SystemMouseCursors.none;
|
|
} else {
|
|
if (_lastRemoteCursorMoved) {
|
|
_lastRemoteCursorMoved = false;
|
|
_firstEnterImage.value = true;
|
|
}
|
|
return _buildCustomCursor(
|
|
context, getCursorScale());
|
|
}
|
|
}())
|
|
: _buildDisabledCursor(context, getCursorScale())
|
|
: MouseCursor.defer,
|
|
onHover: (evt) {},
|
|
child: child);
|
|
});
|
|
if (c.imageOverflow.isTrue && c.scrollStyle != ScrollStyle.scrollauto) {
|
|
final paintWidth = c.getDisplayWidth() * s;
|
|
final paintHeight = c.getDisplayHeight() * s;
|
|
final paintSize = Size(paintWidth, paintHeight);
|
|
final paintWidget =
|
|
m.useTextureRender || widget.ffi.ffiModel.pi.forceTextureRender
|
|
? _BuildPaintTextureRender(
|
|
c, s, Offset.zero, paintSize, isViewOriginal())
|
|
: _buildScrollbarNonTextureRender(m, paintSize, s);
|
|
return NotificationListener<ScrollNotification>(
|
|
onNotification: (notification) {
|
|
c.updateScrollPercent();
|
|
return false;
|
|
},
|
|
child: mouseRegion(
|
|
child: Obx(() => _buildCrossScrollbarFromLayout(
|
|
context,
|
|
_buildListener(paintWidget),
|
|
c.size,
|
|
paintSize,
|
|
c.scrollHorizontal,
|
|
c.scrollVertical,
|
|
)),
|
|
));
|
|
} else {
|
|
if (c.size.width > 0 && c.size.height > 0) {
|
|
final paintWidget =
|
|
m.useTextureRender || widget.ffi.ffiModel.pi.forceTextureRender
|
|
? _BuildPaintTextureRender(
|
|
c,
|
|
s,
|
|
Offset(
|
|
isLinux ? c.x.toInt().toDouble() : c.x,
|
|
isLinux ? c.y.toInt().toDouble() : c.y,
|
|
),
|
|
c.size,
|
|
isViewOriginal())
|
|
: _buildScrollAutoNonTextureRender(m, c, s);
|
|
return mouseRegion(child: _buildListener(paintWidget));
|
|
} else {
|
|
return Container();
|
|
}
|
|
}
|
|
}
|
|
|
|
Widget _buildScrollbarNonTextureRender(
|
|
ImageModel m, Size imageSize, double s) {
|
|
return CustomPaint(
|
|
size: imageSize,
|
|
painter: ImagePainter(image: m.image, x: 0, y: 0, scale: s),
|
|
);
|
|
}
|
|
|
|
Widget _buildScrollAutoNonTextureRender(
|
|
ImageModel m, CanvasModel c, double s) {
|
|
double sizeScale = s;
|
|
if (widget.ffi.ffiModel.isPeerLinux) {
|
|
final displays = widget.ffi.ffiModel.pi.getCurDisplays();
|
|
if (displays.isNotEmpty) {
|
|
sizeScale = s / displays[0].scale;
|
|
}
|
|
}
|
|
return CustomPaint(
|
|
size: Size(c.size.width, c.size.height),
|
|
painter: ImagePainter(
|
|
image: m.image,
|
|
x: c.x / sizeScale,
|
|
y: c.y / sizeScale,
|
|
scale: sizeScale),
|
|
);
|
|
}
|
|
|
|
Widget _BuildPaintTextureRender(
|
|
CanvasModel c, double s, Offset offset, Size size, bool isViewOriginal) {
|
|
final ffiModel = c.parent.target!.ffiModel;
|
|
final displays = ffiModel.pi.getCurDisplays();
|
|
final children = <Widget>[];
|
|
final rect = ffiModel.rect;
|
|
if (rect == null) {
|
|
return Container();
|
|
}
|
|
final isPeerLinux = ffiModel.isPeerLinux;
|
|
final curDisplay = ffiModel.pi.currentDisplay;
|
|
for (var i = 0; i < displays.length; i++) {
|
|
final textureId = widget.ffi.textureModel
|
|
.getTextureId(curDisplay == kAllDisplayValue ? i : curDisplay);
|
|
if (true) {
|
|
// both "textureId.value != -1" and "true" seems ok
|
|
final sizeScale = isPeerLinux ? s / displays[i].scale : s;
|
|
children.add(Positioned(
|
|
left: (displays[i].x - rect.left) * s + offset.dx,
|
|
top: (displays[i].y - rect.top) * s + offset.dy,
|
|
width: displays[i].width * sizeScale,
|
|
height: displays[i].height * sizeScale,
|
|
child: Obx(() => Texture(
|
|
textureId: textureId.value,
|
|
filterQuality:
|
|
isViewOriginal ? FilterQuality.none : FilterQuality.low,
|
|
)),
|
|
));
|
|
}
|
|
}
|
|
return SizedBox(
|
|
width: size.width,
|
|
height: size.height,
|
|
child: Stack(children: children),
|
|
);
|
|
}
|
|
|
|
MouseCursor _buildCustomCursor(BuildContext context, double scale) {
|
|
final cursor = Provider.of<CursorModel>(context);
|
|
final cache = cursor.cache ?? preDefaultCursor.cache;
|
|
return buildCursorOfCache(cursor, scale, cache);
|
|
}
|
|
|
|
MouseCursor _buildDisabledCursor(BuildContext context, double scale) {
|
|
final cursor = Provider.of<CursorModel>(context);
|
|
final cache = preForbiddenCursor.cache;
|
|
return buildCursorOfCache(cursor, scale, cache);
|
|
}
|
|
|
|
Widget _buildCrossScrollbarFromLayout(
|
|
BuildContext context,
|
|
Widget child,
|
|
Size layoutSize,
|
|
Size size,
|
|
ScrollController horizontal,
|
|
ScrollController vertical,
|
|
) {
|
|
var widget = child;
|
|
if (layoutSize.width < size.width) {
|
|
widget = ScrollConfiguration(
|
|
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
|
|
child: SingleChildScrollView(
|
|
controller: horizontal,
|
|
scrollDirection: Axis.horizontal,
|
|
physics: cursorOverImage.isTrue
|
|
? const NeverScrollableScrollPhysics()
|
|
: null,
|
|
child: widget,
|
|
),
|
|
);
|
|
} else {
|
|
widget = Row(
|
|
children: [
|
|
Container(
|
|
width: ((layoutSize.width - size.width) ~/ 2).toDouble(),
|
|
),
|
|
widget,
|
|
],
|
|
);
|
|
}
|
|
if (layoutSize.height < size.height) {
|
|
widget = ScrollConfiguration(
|
|
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
|
|
child: SingleChildScrollView(
|
|
controller: vertical,
|
|
physics: cursorOverImage.isTrue
|
|
? const NeverScrollableScrollPhysics()
|
|
: null,
|
|
child: widget,
|
|
),
|
|
);
|
|
} else {
|
|
widget = Column(
|
|
children: [
|
|
Container(
|
|
height: ((layoutSize.height - size.height) ~/ 2).toDouble(),
|
|
),
|
|
widget,
|
|
],
|
|
);
|
|
}
|
|
if (layoutSize.width < size.width) {
|
|
widget = RawScrollbar(
|
|
thickness: kScrollbarThickness,
|
|
thumbColor: Colors.grey,
|
|
controller: horizontal,
|
|
thumbVisibility: false,
|
|
trackVisibility: false,
|
|
notificationPredicate: layoutSize.height < size.height
|
|
? (notification) => notification.depth == 1
|
|
: defaultScrollNotificationPredicate,
|
|
child: widget,
|
|
);
|
|
}
|
|
if (layoutSize.height < size.height) {
|
|
widget = RawScrollbar(
|
|
thickness: kScrollbarThickness,
|
|
thumbColor: Colors.grey,
|
|
controller: vertical,
|
|
thumbVisibility: false,
|
|
trackVisibility: false,
|
|
child: widget,
|
|
);
|
|
}
|
|
|
|
return Container(
|
|
child: widget,
|
|
width: layoutSize.width,
|
|
height: layoutSize.height,
|
|
);
|
|
}
|
|
|
|
Widget _buildListener(Widget child) {
|
|
if (listenerBuilder != null) {
|
|
return listenerBuilder!(child);
|
|
} else {
|
|
return child;
|
|
}
|
|
}
|
|
}
|
|
|
|
class CursorPaint extends StatelessWidget {
|
|
final String id;
|
|
final RxBool zoomCursor;
|
|
|
|
const CursorPaint({
|
|
Key? key,
|
|
required this.id,
|
|
required this.zoomCursor,
|
|
}) : super(key: key);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final m = Provider.of<CursorModel>(context);
|
|
final c = Provider.of<CanvasModel>(context);
|
|
double hotx = m.hotx;
|
|
double hoty = m.hoty;
|
|
if (m.image == null) {
|
|
if (preDefaultCursor.image != null) {
|
|
hotx = preDefaultCursor.image!.width / 2;
|
|
hoty = preDefaultCursor.image!.height / 2;
|
|
}
|
|
}
|
|
|
|
double cx = c.x;
|
|
double cy = c.y;
|
|
if (c.viewStyle.style == kRemoteViewStyleOriginal &&
|
|
c.scrollStyle == ScrollStyle.scrollbar) {
|
|
final rect = c.parent.target!.ffiModel.rect;
|
|
if (rect == null) {
|
|
// unreachable!
|
|
debugPrint('unreachable! The displays rect is null.');
|
|
return Container();
|
|
}
|
|
if (cx < 0) {
|
|
final imageWidth = rect.width * c.scale;
|
|
cx = -imageWidth * c.scrollX;
|
|
}
|
|
if (cy < 0) {
|
|
final imageHeight = rect.height * c.scale;
|
|
cy = -imageHeight * c.scrollY;
|
|
}
|
|
}
|
|
|
|
double x = (m.x - hotx) * c.scale + cx;
|
|
double y = (m.y - hoty) * c.scale + cy;
|
|
double scale = 1.0;
|
|
final isViewOriginal = c.viewStyle.style == kRemoteViewStyleOriginal;
|
|
if (zoomCursor.value || isViewOriginal) {
|
|
x = m.x - hotx + cx / c.scale;
|
|
y = m.y - hoty + cy / c.scale;
|
|
scale = c.scale;
|
|
}
|
|
|
|
return CustomPaint(
|
|
painter: ImagePainter(
|
|
image: m.image ?? preDefaultCursor.image,
|
|
x: x,
|
|
y: y,
|
|
scale: scale,
|
|
),
|
|
);
|
|
}
|
|
}
|