mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-11-04 16:35:19 +00:00
* fix web break introduced in 38f130071 fix(linux): enable mouse side buttons in remote sessions (#14848)
* feat(shortcuts): user-configurable keyboard shortcuts for session actions
Adds a keyboard shortcut feature (Rust matcher + Dart UI + cross-language
parity tests) that lets users bind combinations like Ctrl+Alt+Shift+P to
session actions. Bindings are stored in LocalConfig under
`keyboard-shortcuts`; the matcher gates dispatch on `enabled` and
`pass_through` flags so flipping the master switch off is a hard stop.
Wire-up summary:
- src/keyboard/shortcuts.rs: matcher, default bindings, parity test against
flutter/test/fixtures/default_keyboard_shortcuts.json
- src/keyboard.rs: shortcut intercept in process_event{,_with_session},
feature-gated to `flutter`; runs before key swapping so users bind to
physical keys
- src/flutter_ffi.rs: main_reload_keyboard_shortcuts +
main_get_default_keyboard_shortcuts; reload_from_config seeded in main_init
- flutter/lib/common/widgets/keyboard_shortcuts/: shared config page body,
recording dialog, shortcut display formatter, action group registry
- flutter/lib/desktop/pages/desktop_keyboard_shortcuts_page.dart and
flutter/lib/mobile/pages/mobile_keyboard_shortcuts_page.dart: platform
shells around the shared body
- flutter/lib/models/shortcut_model.dart: per-session ShortcutModel +
registerSessionShortcutActions for actions with no toolbar TToggleMenu /
TRadioMenu (fullscreen, switch display/tab, close tab, voice call, etc.)
- flutter/lib/common/widgets/toolbar.dart: optional `actionId` field on
TToggleMenu / TRadioMenu, plus per-helper auto-register pass that wires
tagged entries' existing onChanged into the ShortcutModel
- flutter/test/keyboard_shortcuts_test.dart + fixtures: cross-language
parity (default bindings, supported key vocabulary)
Design principles applied during review:
1. Additions are fine; modifications to original logic must be deliberate.
Tagging an existing TToggleMenu entry with `actionId:` is an addition.
Rewriting its onChanged to satisfy a new contract is a modification —
and was reverted for every case where the original click behavior was
working. Four closures were touched and then reverted (mobile View
Mode, Privacy mode multi-impl, Relative mouse mode, Reverse mouse
wheel); their shortcuts are wired via standalone closures in
shortcut_model.dart instead.
2. Toolbar auto-register is reserved for entries whose onChanged is
inherently self-flipping — typically `sessionToggleOption(name)` where
the named option is flipped in place and the input bool is unused. The
register pass passes `!menu.value` from registration time, which is
harmless under self-flipping but wrong for closures that consume the
input bool directly. Tagging a non-self-flipping entry forces a closure
rewrite; choose non-toolbar registration in that case.
3. When shortcuts are disabled, toolbar behavior must be bit-for-bit
unchanged. The matcher's `enabled`-gate already guarantees no
dispatch; the auto-register pass is left unconditional (its only effect
is HashMap operations on a separate ShortcutModel) so mid-session
enable works without a reconnect. The trade-off is intentional and
documented at the top of toolbarControls.
4. Comments stay terse. Rationale lives in one place — the doc comment of
the helper or registration site, not duplicated at every call site.
5. Where an existing helper needs a new optional behavior (e.g.
`_OptionCheckBox` gaining a tooltip slot), the new branch must reduce
to byte-identical output for existing callers (`trailing == null`
case → original `Expanded(Text)` layout). Verified.
6. Action IDs and labels stay consistent. Renamed `reset_cursor` →
`reset_canvas` so the action ID matches its user-facing label
("Reset canvas") and capability flag.
Out-of-scope but included:
- AGENTS.md: documents flutter_rust_bridge no-codegen workflow and the
Web target's hand-written TS client, since both are load-bearing for
any new FFI work.
- remote_toolbar.dart: i18n fix for the per-monitor tooltip ("All
monitors" / "Monitor #N"), unrelated to shortcuts but kept here.
* langs
* fix(keyboard): shortcuts, harden config and callback lifecycle
Signed-off-by: fufesou <linlong1266@gmail.com>
* feat(keyboard): shortcuts, color of "Reset to defaults"
Signed-off-by: fufesou <linlong1266@gmail.com>
* feat(keyboard): shortcuts, release keys before shortcut callback
Signed-off-by: fufesou <linlong1266@gmail.com>
* feat(keyboard): shortcuts, debug web
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix(keyboard): iPad, icon
Signed-off-by: fufesou <linlong1266@gmail.com>
* langs: add shortcut keys to az, gl, ur
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D
* fix(keyboard): shortcuts, keep local modifiers and release keys on the right session
When a chord fired, the shortcut path reused release_remote_keys(), which
replays synthetic releases through process_event(). That cleared the local
MODIFIERS_STATE while the user still held the modifiers, so the next key of
a held chord no longer matched and leaked to the remote, and it sent the
releases to the globally current session instead of the one that typed the
chord.
The shortcut path now releases the remote keys itself: it sends through the
caller's session, and restores the modifiers it released on the remote as
still held locally. The press that fired is remembered until its release,
so its auto repeats and its release are consumed instead of reaching the
remote.
Also state the contract for unavailable actions: a bound chord always
belongs to RustDesk and is a no-op when the session has no handler.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D
* fix(keyboard): shortcuts, record the physical key
The recording dialog and the Web Flutter matcher named keys by
LogicalKeyboardKey, while the native matcher sees the physical key through
its USB HID usage and the Web JS matcher through KeyboardEvent.code. On
AZERTY, QWERTZ, Dvorak and similar layouts a recorded binding then did not
fire, or fired from another key.
Name keys by their physical position everywhere: physicalKeyName maps the
USB HID usage to the stored name. A new fixture pins every name to its USB
HID usage, checked on the Dart side and against rdev::usb_hid_key_from_code
on the Rust side, and a test covers AZERTY and QWERTZ events.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D
* fix(keyboard): shortcuts, keep an empty binding list on re-enable
setEnabled seeded the defaults whenever the binding list was empty, so a
user who cleared every binding got all defaults back after disable and
enable. Seed only when the config has never held a bindings list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D
* fix(keyboard): shortcuts, track every fired key until its release
FIRED_KEY held a single key, so overlapping shortcut keys broke it: with the
chord held, P then C overwrote P, P's release reached the remote, and a P
repeat could fire the screenshot again. A repeat after the modifiers were
released was also treated as a new press and leaked to the remote.
Keep a set of fired keys instead. Once a press fires, every event of that
key belongs to RustDesk until its release: repeats are consumed without
dispatching, whatever the modifiers do, and the release removes the key.
Entries whose release never arrives are dropped when a session view is
entered or a session is closed, not by guessing from the modifiers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D
* fix(keyboard): shortcuts, keep fired keys per session
FIRED_KEYS was process-global while dispatch is per session. Closing or
entering any session cleared the fired keys of all of them, so a key still
held in another session leaked its release to that remote, and a key fired
in one session made the same key in another session look like a repeat.
Key the fired set by session: try_dispatch resolves the session first and
only looks at that session's keys, and session_close clears only the
closing session. Entering a session view no longer clears anything: it is
a pointer enter, and a fired key may still be physically held across it.
A key whose release is missed otherwise heals itself: its next press is
consumed as a repeat and that release removes it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D
* fix(shortcuts): preserve input state and refresh action availability
* fix(keyboard): shortcuts, keep a fired key owned across session close
Close tab is itself a shortcut, so the session that consumed the key press
is gone before the key is released. Both matchers dropped the key with the
session: the Flutter dispatcher removed its own entries in clear(), and the
Rust set was keyed by session and cleared in session_close. The release,
and the repeats while the key stayed held, then reached the next session's
remote as ordinary input.
Ownership of a fired key is physical, so it is now one set shared by every
session on each side, and closing a session no longer touches it: clear()
only drops the pending action and modifier replay, and the Rust session
state only tracks released modifiers. A key whose release is missed heals
itself on its next press. Entering view-only with the Flutter input source
still clears the Rust set, since that source stops routing key events to it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D
* fix(keyboard): shortcuts, drop the Toggle Input Source action
The action switches the key capture backend between key down and key up.
Fired-key ownership lives in whichever matcher saw the press, the Flutter
dispatcher or the Rust set, and there is no handoff between them: after the
switch the repeats and the release of the held key reach the other backend,
which forwards them to the remote and, while the chord is held, matches the
action again and toggles the source back. Remove the shortcut instead of
building a handoff for one action. The toolbar radio menu is unchanged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D
* perf(keyboard): shortcuts, parse the config once per stored value
The Flutter matcher read the local config three times and parsed its JSON
three times on every key press in legacy mode, on Web and in view-only.
Keep one parsed snapshot keyed by the raw stored string, so a key press
costs one config read and no parsing while the config is unchanged. The
existing readers are thin views of the same snapshot.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D
* fix(keyboard): shortcuts, run focus-changing actions on key release
Close tab and tab switching move keyboard focus to another session before
the key that fired them is released. On Linux the Flutter input source
routes map-mode sessions through the Rust matcher and legacy-mode sessions
through the Dart matcher, and neither knows the other's fired keys, so
after such an action the repeats and the release of the held key reached
the next session's remote, and the key stayed recorded as fired in the
matcher that saw the press.
Routing Linux legacy mode through the Rust FFI path instead is not a drop-in
change: the raw key handler builds its event with no unicode, so Rust's
legacy mode would fall back to US key names and lose the layout-aware
characters the Dart path sends today, and it would take modifiers from the
X11 key state instead of Flutter's.
Run these three actions when the fired key is released. The matcher that
consumed the press then also sees the repeats and the release, the remote
modifiers are still released on the press, and a session that closes drops
the action it owed. Both matchers keep the same list of actions.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D
* test(keyboard): shortcuts, pin the key-up action list on both sides
kShortcutActionsRunOnKeyUp and runs_on_release are two hand-kept lists of
the actions that run on key release. Add a shared fixture and check each
side against it, the way the key vocabulary and the default bindings are
already checked, so adding a focus-changing action on one side breaks a
test until the other side follows. The Dart test also checks that every
listed id is a configurable action.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ra4my61t8q1FN5n59wB16D
* fix: prevent lost input after remote session shortcuts
Avoid synthetic Alt presses that activate the Windows system menu
and swallow the next character.
Trigger restart, switch-sides, and chat actions on key release so
focus changes cannot leave subsequent shortcut presses ignored.
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix(mobile): correct Chat keyboard and input helper behavior
- Keep soft keyboard and helper hidden when Chat opens via shortcut
- Restore soft input on touch and remote focus after Chat closes
- Handle modifier releases while Chat owns focus
- Clear hidden virtual modifiers while preserving held keys and Pin behavior
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix(shortcuts): prevent recording non-simultaneous key combinations
Build new captures from currently held keys instead of reusing released
letters. Preserve the last valid chord after release so it can be saved.
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix(shortcuts): isolate window actions and prevent duplicate Alt presses
- Dispatch shortcut actions only to the originating session window.
- Clear local modifier state on focus loss without resending modifiers
already released to Windows peers, preventing lost following input.
- Strengthen the regression assertion against extra Alt keydown events
Signed-off-by: fufesou <linlong1266@gmail.com>
* refact(shortcuts): empty default shortcuts for Cmd+Option+Shift+Del on macOS and iOS
Because Del may be ambiguous for new macOS users.
Cmd+Option+Shift+Backspace is "Empty Trash without confirmation" shortcut on macOS.
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: use session peer platform for Ctrl+Alt+Del events
Fix Android-to-Linux Ctrl+Alt+Del delivery by using the session's peer
platform instead of the desktop global session, which defaults to Windows
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: hide relative mouse mode shortcuts on Wayland
Separate relative mouse mode availability from screenshot support.
Filter unsupported actions and defaults while preserving saved bindings
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: align shortcut availability with platform support
- Hide unsupported mobile and web shortcut actions
- Enable mobile scaling and Android voice call shortcuts
- Use openMonitorInTheSameTab() for display switching
- Preserve cursor position when switching displays on desktop and web
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: prevent held-key shortcut recording bypass
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: preserve keyboard input after shortcut dialogs
Run native clipboard shortcuts on key release and wait for remote modifier releases before dispatching.
Release remote modifiers when mobile local UI takes focus, and document the tested iPad Bluetooth Mute shortcut limitation.
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: preserve remote key state during local shortcuts
Keep unrelated held keys pressed for session actions that retain input. Preserve key release when focus or input handling changes, and avoid synthetic Alt taps during shortcut dispatch.
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: suspend saved relative mouse shortcuts on Wayland
Filter the native active binding cache without changing stored configuration, so the binding becomes available again under X11.
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: exclude unsupported Wayland shortcuts from Flutter matching
Keep saved bindings intact while filtering the active config used by the Flutter matcher. Cover stored bindings and enable/pass-through flags in the existing shortcut tests.
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: retain forwarded key ownership across shortcut repeats
Ignore repeat presses for keys already forwarded to the peer, and release ordinary held keys before Show My Cursor enables view-only. Cover both paths with converter-level regressions.
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: release Flutter-held keys before view-only shortcuts
Track ordinary keys forwarded by the Dart matcher paths and await their releases before View Only or Show My Cursor disables input. Keep Mute key ownership and existing empty-cleanup timing unchanged.
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: filter active Flutter shortcuts by platform capabilities
Reuse the Settings action filter for saved bindings while preserving storage, default-seeding behavior and configurable Apple Ctrl+Alt+Del bindings.
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: filter native shortcut caches for mobile controllers
Suspend saved desktop-only actions on Android and iOS and hide unavailable iOS actions without changing persisted bindings. Keep native cache filtering aligned with Flutter platform capabilities.
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: share filtered shortcut bindings with the Web matcher
Pass the active platform config to JavaScript at startup and after settings changes, preserving unsupported saved bindings in local storage.
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: release Flutter-held keys before focus-changing shortcuts
Drain ordinary keys owned by the original session before run-on-key-up
actions and native clipboard callbacks. Preserve Mute holds, pending-action
cancellation and Web clipboard key-down activation.
Document the required Web matcher counterpart in the bridge:
rustdesk/rustdesk-web-js@94abe431d6
Validation: 61 native Flutter tests, 32 Chrome/HTML tests, targeted analysis
and Windows debug build.
Signed-off-by: fufesou <linlong1266@gmail.com>
* refact: remove the useless comments
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: prevent Windows menu activation from Alt-only shortcuts
Neutralize the forwarded Alt with a balanced temporary Ctrl press when a consumed local shortcut releases its only modifier in Windows Map mode. Keep physical modifier tracking and normal Alt pass-through unchanged.
Add a regression covering both Alt keys, trigger repeats and the eventual physical release. Verified with 36 native shortcut tests and real Windows menu/input checks.
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: prevent Alt menu activation in all keyboard modes
Extend Windows Alt-only shortcut neutralization to Translate and Legacy. Keep the temporary Ctrl packets in Map mode so Legacy modifier synchronization cannot release Alt before Ctrl.
Extend the existing regression across keyboard modes and decode scan, virtual and control-key events. Verify 36 native tests and 34 Windows menu replay cases.
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: initialize outgoing chat before shortcut dispatch
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: guard Windows Alt release in Flutter legacy shortcuts
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: coordinate Web shortcut dispatch and Alt modifier cleanup
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: distinguish AltGraph from ordinary Alt shortcuts
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: dispatch macOS Web shortcuts without waiting for keyup
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: support legacy peers in privacy mode shortcut
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: exclude AltGraph from Flutter shortcut matching
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix: exclude GTK AltGr input from raw shortcut matching
Signed-off-by: fufesou <linlong1266@gmail.com>
* test: trim redundant keyboard shortcut coverage
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix(keyboard): prevent Cmd shortcuts from opening remote Start
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix(shortcuts): guard Windows menu keys after Ctrl/Cmd swapping
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix(shortcuts): align recording and display routing
Reject non-Apple AltGraph captures to match runtime filtering. Honor individual-display windows for next, previous, and all-display shortcuts.
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix(shortcuts): reject GTK AltGr chords in recorder
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix(android): restore keyboard focus for modifier-first input
Focus the existing FlutterView when a modifier is pressed without native view focus. This restores shortcut-first input after returning to RustDesk while preserving focused inputs and normal key dispatch.
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix(shortcuts): release held keys before keyboard mode changes
Drain Flutter-forwarded keys before keyboard-mode shortcut actions so releases use the original encoding. This prevents held keys from remaining down after a Web Input source 2 mode change.
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix(i18n): use sentence case for shortcut labels
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix(mobile): preserve touch modifiers in the input helper
Keep touch-selected modifiers visible after hiding the keyboard. Clear physical modifier releases locally in view-only mode so they do not reopen the helper.
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix(web): preserve modifiers across Legacy-to-Map shortcuts
Signed-off-by: fufesou <linlong1266@gmail.com>
* fix(shortcuts): check support before registering all displays view
Signed-off-by: fufesou <linlong1266@gmail.com>
---------
Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: fufesou <linlong1266@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
3822 lines
120 KiB
Dart
3822 lines
120 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/keyboard_shortcuts/display.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_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';
|
|
import 'package:flutter_hbb/common/widgets/custom_scale_base.dart';
|
|
|
|
enum _ToolbarEdge { top, right, bottom, left }
|
|
|
|
_ToolbarEdge _parseToolbarEdge(String? s) {
|
|
switch (s) {
|
|
case 'right':
|
|
return _ToolbarEdge.right;
|
|
case 'bottom':
|
|
return _ToolbarEdge.bottom;
|
|
case 'left':
|
|
return _ToolbarEdge.left;
|
|
default:
|
|
return _ToolbarEdge.top;
|
|
}
|
|
}
|
|
|
|
String _toolbarEdgeToString(_ToolbarEdge e) {
|
|
switch (e) {
|
|
case _ToolbarEdge.top:
|
|
return 'top';
|
|
case _ToolbarEdge.right:
|
|
return 'right';
|
|
case _ToolbarEdge.bottom:
|
|
return 'bottom';
|
|
case _ToolbarEdge.left:
|
|
return 'left';
|
|
}
|
|
}
|
|
|
|
bool _isHorizontalEdge(_ToolbarEdge e) =>
|
|
e == _ToolbarEdge.top || e == _ToolbarEdge.bottom;
|
|
|
|
const _legacyRemoteMenubarDragX = 'remote-menubar-drag-x';
|
|
|
|
double _clampToolbarFraction(double fraction, double left, double right) {
|
|
if (fraction < left) fraction = left;
|
|
if (fraction > right) fraction = right;
|
|
return fraction;
|
|
}
|
|
|
|
Size _toolbarSizeForEdge(_ToolbarEdge edge, Size? measured) {
|
|
final isHorizontal = _isHorizontalEdge(edge);
|
|
final fallback = isHorizontal ? const Size(360, 40) : const Size(40, 360);
|
|
final size = measured ?? fallback;
|
|
final long = size.longestSide;
|
|
final short = size.shortestSide;
|
|
return Size(isHorizontal ? long : short, isHorizontal ? short : long);
|
|
}
|
|
|
|
Offset _toolbarOffsetForEdge({
|
|
required _ToolbarEdge edge,
|
|
required double fraction,
|
|
required Size parentSize,
|
|
required Size toolbarSize,
|
|
}) {
|
|
final xTravel = parentSize.width - toolbarSize.width;
|
|
final yTravel = parentSize.height - toolbarSize.height;
|
|
switch (edge) {
|
|
case _ToolbarEdge.top:
|
|
return Offset(xTravel * fraction, 0);
|
|
case _ToolbarEdge.bottom:
|
|
return Offset(xTravel * fraction, yTravel);
|
|
case _ToolbarEdge.left:
|
|
return Offset(0, yTravel * fraction);
|
|
case _ToolbarEdge.right:
|
|
return Offset(xTravel, yTravel * fraction);
|
|
}
|
|
}
|
|
|
|
double _fractionForAlignedDrag({
|
|
required double cursor,
|
|
required double grabOffset,
|
|
required double parentExtent,
|
|
required double toolbarExtent,
|
|
required double left,
|
|
required double right,
|
|
}) {
|
|
final travelExtent = parentExtent - toolbarExtent;
|
|
if (travelExtent <= 0) {
|
|
return _clampToolbarFraction(0.5, left, right);
|
|
}
|
|
return _clampToolbarFraction(
|
|
(cursor - grabOffset) / travelExtent, left, right);
|
|
}
|
|
|
|
({double left, double right}) _fractionBoundsForEdge(
|
|
_ToolbarEdge edge,
|
|
double left,
|
|
double right,
|
|
) {
|
|
return _isHorizontalEdge(edge)
|
|
? (left: left, right: right)
|
|
: (left: 0, right: 1);
|
|
}
|
|
|
|
String _toolbarRawFraction({
|
|
required bool multiEdgeEnabled,
|
|
required _ToolbarEdge edge,
|
|
required String? savedFraction,
|
|
required String? legacyFraction,
|
|
}) {
|
|
if (!multiEdgeEnabled) {
|
|
return (legacyFraction != null && legacyFraction.isNotEmpty)
|
|
? legacyFraction
|
|
: '0.5';
|
|
}
|
|
if (savedFraction != null && savedFraction.isNotEmpty) {
|
|
return savedFraction;
|
|
}
|
|
if (edge == _ToolbarEdge.top &&
|
|
legacyFraction != null &&
|
|
legacyFraction.isNotEmpty) {
|
|
return legacyFraction;
|
|
}
|
|
return '0.5';
|
|
}
|
|
|
|
// Returns the alignment for the wrapper Align that positions the entire
|
|
// toolbar against the given edge at the given fraction along that edge.
|
|
// Alignment uses [-1, 1] coordinates (0 = center).
|
|
Alignment _alignmentForEdge(_ToolbarEdge edge, double fraction) {
|
|
final f = fraction * 2 - 1;
|
|
switch (edge) {
|
|
case _ToolbarEdge.top:
|
|
return Alignment(f, -1);
|
|
case _ToolbarEdge.bottom:
|
|
return Alignment(f, 1);
|
|
case _ToolbarEdge.left:
|
|
return Alignment(-1, f);
|
|
case _ToolbarEdge.right:
|
|
return Alignment(1, f);
|
|
}
|
|
}
|
|
|
|
// The drag handle hangs off the side of the toolbar facing away from the
|
|
// docked edge, so the icons themselves sit flush against that edge.
|
|
BorderRadius _collapseHandleBorderRadius(_ToolbarEdge edge) {
|
|
const r = Radius.circular(5);
|
|
switch (edge) {
|
|
case _ToolbarEdge.top:
|
|
return const BorderRadius.vertical(bottom: r);
|
|
case _ToolbarEdge.bottom:
|
|
return const BorderRadius.vertical(top: r);
|
|
case _ToolbarEdge.left:
|
|
return const BorderRadius.horizontal(right: r);
|
|
case _ToolbarEdge.right:
|
|
return const BorderRadius.horizontal(left: r);
|
|
}
|
|
}
|
|
|
|
int _monitorMenuQuarterTurns(_ToolbarEdge edge) {
|
|
switch (edge) {
|
|
case _ToolbarEdge.left:
|
|
return 1;
|
|
case _ToolbarEdge.right:
|
|
return 3;
|
|
case _ToolbarEdge.top:
|
|
case _ToolbarEdge.bottom:
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
IconData _toolbarCollapseIcon(_ToolbarEdge edge, bool isCollapsed) {
|
|
switch (edge) {
|
|
case _ToolbarEdge.top:
|
|
return isCollapsed ? Icons.expand_more : Icons.expand_less;
|
|
case _ToolbarEdge.bottom:
|
|
return isCollapsed ? Icons.expand_less : Icons.expand_more;
|
|
case _ToolbarEdge.left:
|
|
return isCollapsed ? Icons.chevron_right : Icons.chevron_left;
|
|
case _ToolbarEdge.right:
|
|
return isCollapsed ? Icons.chevron_left : Icons.chevron_right;
|
|
}
|
|
}
|
|
|
|
class _ToolbarDockingOptions {
|
|
_ToolbarDockingOptions({
|
|
required this.edge,
|
|
required this.fraction,
|
|
required this.multiEdgeEnabled,
|
|
});
|
|
|
|
_ToolbarEdge edge;
|
|
double fraction;
|
|
bool multiEdgeEnabled;
|
|
}
|
|
|
|
final _toolbarDockingOptionsBySession = <String, _ToolbarDockingOptions>{};
|
|
|
|
String _toolbarDockingCacheKey(SessionID sessionId) => sessionId.toString();
|
|
|
|
_ToolbarDockingOptions? _cachedToolbarDockingOptions(SessionID sessionId) =>
|
|
_toolbarDockingOptionsBySession[_toolbarDockingCacheKey(sessionId)];
|
|
|
|
void _cacheToolbarDockingOptions({
|
|
required SessionID sessionId,
|
|
required _ToolbarEdge edge,
|
|
required double fraction,
|
|
required bool multiEdgeEnabled,
|
|
}) {
|
|
final key = _toolbarDockingCacheKey(sessionId);
|
|
final cached = _toolbarDockingOptionsBySession[key];
|
|
if (cached == null) {
|
|
_toolbarDockingOptionsBySession[key] = _ToolbarDockingOptions(
|
|
edge: edge,
|
|
fraction: fraction,
|
|
multiEdgeEnabled: multiEdgeEnabled,
|
|
);
|
|
return;
|
|
}
|
|
cached.edge = edge;
|
|
cached.fraction = fraction;
|
|
cached.multiEdgeEnabled = multiEdgeEnabled;
|
|
}
|
|
|
|
class ToolbarState {
|
|
late RxBool _pin;
|
|
|
|
RxBool collapse = false.obs;
|
|
RxBool hide = false.obs;
|
|
|
|
// Track initialization state to prevent flickering
|
|
final RxBool initialized = false.obs;
|
|
bool _isInitializing = false;
|
|
|
|
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;
|
|
|
|
/// Initialize all toolbar states from session options.
|
|
/// This should be called once when the toolbar is first created.
|
|
Future<void> init(SessionID sessionId) async {
|
|
if (initialized.value || _isInitializing) return;
|
|
_isInitializing = true;
|
|
|
|
try {
|
|
// Load both states in parallel for better performance
|
|
final results = await Future.wait([
|
|
bind.sessionGetToggleOption(
|
|
sessionId: sessionId, arg: kOptionCollapseToolbar),
|
|
bind.sessionGetToggleOption(
|
|
sessionId: sessionId, arg: kOptionHideToolbar),
|
|
]);
|
|
|
|
collapse.value = results[0] ?? false;
|
|
hide.value = results[1] ?? false;
|
|
} finally {
|
|
_isInitializing = false;
|
|
initialized.value = true;
|
|
}
|
|
}
|
|
|
|
switchCollapse(SessionID sessionId) async {
|
|
bind.sessionToggleOption(
|
|
sessionId: sessionId, value: kOptionCollapseToolbar);
|
|
collapse.value = !collapse.value;
|
|
}
|
|
|
|
// Switch hide state for entire toolbar visibility
|
|
switchHide(SessionID sessionId) async {
|
|
bind.sessionToggleOption(sessionId: sessionId, value: kOptionHideToolbar);
|
|
hide.value = !hide.value;
|
|
}
|
|
|
|
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 _fraction = 0.5.obs;
|
|
final _edge = _ToolbarEdge.top.obs;
|
|
final _dragging = false.obs;
|
|
// Live drag preview: where the toolbar would dock if the user dropped now.
|
|
final _previewEdge = Rxn<_ToolbarEdge>();
|
|
final _previewFraction = Rxn<double>();
|
|
// Measured size of the live toolbar, so the preview ghost matches reality
|
|
// (collapsed handle vs expanded toolbar). Updated after every layout pass.
|
|
final _toolbarSize = Rxn<Size>();
|
|
final _toolbarKey = GlobalKey(debugLabel: 'remote_toolbar_root');
|
|
// When false (default), the toolbar stays on the top edge and the drag
|
|
// handle just slides it horizontally — preserving long-standing UX while
|
|
// still fixing the bug where dragging only moved the handle. When true,
|
|
// the user has opted into multi-edge docking with nearest-edge snap.
|
|
// Kept in sync after settings-triggered rebuilds.
|
|
final _multiEdgeEnabled = false.obs;
|
|
final _dockingOptionsInitialized = false.obs;
|
|
bool _pendingDockingOptionSync = false;
|
|
int _dockingOptionSyncSerial = 0;
|
|
int _dragEpoch = 0;
|
|
|
|
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 collapse => widget.state.collapse;
|
|
RxBool get hide => widget.state.hide;
|
|
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();
|
|
|
|
Future<void> _syncDockingOptions({required bool force}) async {
|
|
final syncSerial = ++_dockingOptionSyncSerial;
|
|
if (_dragging.isTrue) {
|
|
_deferDockingOptionsSync();
|
|
return;
|
|
}
|
|
final dragEpoch = _dragEpoch;
|
|
|
|
// Use the canonical helper so the option's documented default semantics
|
|
// apply (allow-* prefix => default false). Keeping it raw-string would
|
|
// diverge from how _OptionCheckBox displays the same key.
|
|
final multiEdgeEnabled =
|
|
mainGetLocalBoolOptionSync(kOptionAllowMultiEdgeToolbarDock);
|
|
final cached = _cachedToolbarDockingOptions(widget.ffi.sessionId);
|
|
if (cached == null && pi.isSet.isFalse) {
|
|
return;
|
|
}
|
|
final hadDockingOptions = cached != null;
|
|
final wasMultiEdgeEnabled =
|
|
cached?.multiEdgeEnabled ?? _multiEdgeEnabled.value;
|
|
if (!force &&
|
|
hadDockingOptions &&
|
|
wasMultiEdgeEnabled == multiEdgeEnabled) {
|
|
_pendingDockingOptionSync = false;
|
|
return;
|
|
}
|
|
|
|
final savedFraction = await bind.sessionGetOption(
|
|
sessionId: widget.ffi.sessionId, arg: kOptionRemoteMenubarFraction);
|
|
// Backward compat: legacy horizontal-only position.
|
|
final legacyFraction = await bind.sessionGetOption(
|
|
sessionId: widget.ffi.sessionId, arg: _legacyRemoteMenubarDragX);
|
|
if (!mounted || syncSerial != _dockingOptionSyncSerial) return;
|
|
|
|
var nextEdge = _edge.value;
|
|
var savedFractionForNextEdge = savedFraction;
|
|
var keepCurrentPosition = false;
|
|
if (!multiEdgeEnabled) {
|
|
nextEdge = _ToolbarEdge.top;
|
|
} else if (force || wasMultiEdgeEnabled || cached == null) {
|
|
final edgeStr = await bind.sessionGetOption(
|
|
sessionId: widget.ffi.sessionId, arg: kOptionRemoteMenubarEdge);
|
|
if (!mounted || syncSerial != _dockingOptionSyncSerial) return;
|
|
nextEdge = _parseToolbarEdge(edgeStr);
|
|
} else {
|
|
// The setting changed from top-only to multi-edge while this toolbar is
|
|
// already visible. Keep its current position instead of jumping to the
|
|
// last saved multi-edge dock.
|
|
nextEdge = cached.edge;
|
|
savedFractionForNextEdge = cached.fraction.toString();
|
|
keepCurrentPosition = true;
|
|
}
|
|
|
|
final rawFraction = _toolbarRawFraction(
|
|
multiEdgeEnabled: multiEdgeEnabled,
|
|
edge: nextEdge,
|
|
savedFraction: savedFractionForNextEdge,
|
|
legacyFraction: legacyFraction,
|
|
);
|
|
// Clamp to the saved drag-bound contract so a corrupted or out-of-range
|
|
// saved value can't bypass it until the user drags again.
|
|
final dragLeft = double.tryParse(
|
|
bind.mainGetLocalOption(key: kOptionRemoteMenubarDragLeft)) ??
|
|
0.0;
|
|
final dragRight = double.tryParse(
|
|
bind.mainGetLocalOption(key: kOptionRemoteMenubarDragRight)) ??
|
|
1.0;
|
|
final fractionBounds =
|
|
_fractionBoundsForEdge(nextEdge, dragLeft, dragRight);
|
|
final nextFraction = (double.tryParse(rawFraction) ?? 0.5)
|
|
.clamp(fractionBounds.left, fractionBounds.right)
|
|
.toDouble();
|
|
if (!mounted || syncSerial != _dockingOptionSyncSerial) return;
|
|
if (_dragging.isTrue || dragEpoch != _dragEpoch) {
|
|
_deferDockingOptionsSync();
|
|
return;
|
|
}
|
|
_edge.value = nextEdge;
|
|
_fraction.value = nextFraction;
|
|
_multiEdgeEnabled.value = multiEdgeEnabled;
|
|
_dockingOptionsInitialized.value = true;
|
|
_cacheToolbarDockingOptions(
|
|
sessionId: widget.ffi.sessionId,
|
|
edge: nextEdge,
|
|
fraction: nextFraction,
|
|
multiEdgeEnabled: multiEdgeEnabled,
|
|
);
|
|
_pendingDockingOptionSync = false;
|
|
if (!multiEdgeEnabled || keepCurrentPosition) {
|
|
bind.sessionPeerOption(
|
|
sessionId: widget.ffi.sessionId,
|
|
name: kOptionRemoteMenubarEdge,
|
|
value: _toolbarEdgeToString(nextEdge),
|
|
);
|
|
bind.sessionPeerOption(
|
|
sessionId: widget.ffi.sessionId,
|
|
name: kOptionRemoteMenubarFraction,
|
|
value: nextFraction.toString(),
|
|
);
|
|
}
|
|
}
|
|
|
|
void _deferDockingOptionsSync() {
|
|
_pendingDockingOptionSync = true;
|
|
if (_dragging.isFalse) {
|
|
_syncDockingOptionsAfterDragIfNeeded();
|
|
}
|
|
}
|
|
|
|
void _markToolbarDragEpoch() {
|
|
++_dragEpoch;
|
|
}
|
|
|
|
void _syncDockingOptionsAfterDragIfNeeded() {
|
|
if (!_pendingDockingOptionSync) return;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
|
await _syncDockingOptions(force: false);
|
|
});
|
|
}
|
|
|
|
@override
|
|
initState() {
|
|
super.initState();
|
|
|
|
final cached = _cachedToolbarDockingOptions(widget.ffi.sessionId);
|
|
final multiEdgeEnabled =
|
|
mainGetLocalBoolOptionSync(kOptionAllowMultiEdgeToolbarDock);
|
|
final shouldResetToTop =
|
|
cached != null && cached.multiEdgeEnabled && !multiEdgeEnabled;
|
|
if (cached != null && !shouldResetToTop) {
|
|
_edge.value = cached.edge;
|
|
_fraction.value = cached.fraction;
|
|
_multiEdgeEnabled.value = multiEdgeEnabled;
|
|
_dockingOptionsInitialized.value = true;
|
|
}
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
|
await _syncDockingOptions(force: cached == null || shouldResetToTop);
|
|
// Initialize toolbar states (collapse, hide) from session options
|
|
widget.state.init(widget.ffi.sessionId);
|
|
});
|
|
|
|
_debouncerHide = Debouncer<int>(
|
|
Duration(milliseconds: 5000),
|
|
onChanged: _debouncerHideProc,
|
|
initialValue: 0,
|
|
);
|
|
|
|
widget.onEnterOrLeaveImageSetter(identityHashCode(this), (enter) {
|
|
if (enter) {
|
|
triggerAutoHide();
|
|
_isCursorOverImage = true;
|
|
} else {
|
|
_isCursorOverImage = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant RemoteToolbar oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
|
await _syncDockingOptions(force: false);
|
|
});
|
|
}
|
|
|
|
_debouncerHideProc(int v) {
|
|
if (!pin && collapse.isFalse && _isCursorOverImage && _dragging.isFalse) {
|
|
collapse.value = true;
|
|
}
|
|
}
|
|
|
|
@override
|
|
dispose() {
|
|
++_dockingOptionSyncSerial;
|
|
widget.onEnterOrLeaveImageCleaner(identityHashCode(this));
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Obx(() {
|
|
// Wait for initialization to complete to prevent flickering
|
|
if (!widget.state.initialized.value ||
|
|
!_dockingOptionsInitialized.value) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
// If toolbar is hidden, return empty widget
|
|
if (hide.value) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
final edge = _edge.value;
|
|
final isHorizontal = _isHorizontalEdge(edge);
|
|
|
|
// Measure the live toolbar after every layout so the preview ghost can
|
|
// match its actual footprint (collapsed handle vs expanded toolbar).
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (_dragging.isTrue) return;
|
|
final ro = _toolbarKey.currentContext?.findRenderObject();
|
|
if (ro is RenderBox && ro.hasSize) {
|
|
final s = ro.size;
|
|
if (_toolbarSize.value != s) _toolbarSize.value = s;
|
|
}
|
|
});
|
|
|
|
final toolbar = Align(
|
|
alignment: _alignmentForEdge(edge, _fraction.value),
|
|
child: KeyedSubtree(
|
|
key: _toolbarKey,
|
|
child: collapse.isFalse
|
|
? _buildToolbar(context, edge, isHorizontal)
|
|
: _buildDraggableCollapse(context, edge, isHorizontal),
|
|
),
|
|
);
|
|
|
|
// Always return the Stack — even when not dragging — so the toolbar's
|
|
// position in the Element tree stays stable. Wrapping/unwrapping it
|
|
// mid-drag was killing the Draggable's gesture state.
|
|
return Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
IgnorePointer(
|
|
child: Obx(() {
|
|
final pe = _previewEdge.value;
|
|
final pf = _previewFraction.value;
|
|
if (!_dragging.isTrue || pe == null || pf == null) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
return _buildDragPreview(context, pe, pf, _toolbarSize.value);
|
|
}),
|
|
),
|
|
toolbar,
|
|
],
|
|
);
|
|
});
|
|
}
|
|
|
|
Widget _buildDragPreview(BuildContext context, _ToolbarEdge edge,
|
|
double fraction, Size? measured) {
|
|
final color = Theme.of(context).colorScheme.primary;
|
|
// Use the measured live toolbar size so collapsed vs expanded looks
|
|
// right. The current orientation may differ from the preview orientation
|
|
// (e.g. dragging a top-docked toolbar toward the left edge), so swap the
|
|
// long/short axes when previewing a different orientation.
|
|
final previewSize = _toolbarSizeForEdge(edge, measured);
|
|
return Align(
|
|
alignment: _alignmentForEdge(edge, fraction),
|
|
child: Container(
|
|
width: previewSize.width,
|
|
height: previewSize.height,
|
|
decoration: BoxDecoration(
|
|
color: color.withOpacity(0.10),
|
|
borderRadius: BorderRadius.circular(6),
|
|
border: Border.all(color: color.withOpacity(0.55), width: 1.5),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildDraggableCollapse(
|
|
BuildContext context, _ToolbarEdge edge, bool isHorizontal) {
|
|
return Obx(() {
|
|
if (collapse.isFalse && _dragging.isFalse) {
|
|
triggerAutoHide();
|
|
}
|
|
final borderRadius = _collapseHandleBorderRadius(edge);
|
|
return Offstage(
|
|
offstage: _dragging.isTrue,
|
|
child: Material(
|
|
elevation: _ToolbarTheme.elevation,
|
|
shadowColor: MyTheme.color(context).shadow,
|
|
borderRadius: borderRadius,
|
|
child: _DraggableShowHide(
|
|
id: widget.id,
|
|
ffi: widget.ffi,
|
|
sessionId: widget.ffi.sessionId,
|
|
dragging: _dragging,
|
|
fraction: _fraction,
|
|
edge: _edge,
|
|
previewEdge: _previewEdge,
|
|
previewFraction: _previewFraction,
|
|
toolbarSize: _toolbarSize,
|
|
markDragEpoch: _markToolbarDragEpoch,
|
|
syncDockingOptionsAfterDragIfNeeded:
|
|
_syncDockingOptionsAfterDragIfNeeded,
|
|
isHorizontal: isHorizontal,
|
|
multiEdgeEnabled: _multiEdgeEnabled.value,
|
|
toolbarState: widget.state,
|
|
setFullscreen: _setFullscreen,
|
|
setMinimize: _minimize,
|
|
borderRadius: borderRadius,
|
|
),
|
|
),
|
|
);
|
|
});
|
|
}
|
|
|
|
Widget _buildToolbar(
|
|
BuildContext context, _ToolbarEdge edge, bool isHorizontal) {
|
|
final List<Widget> toolbarItems = [];
|
|
toolbarItems.add(_PinMenu(state: widget.state));
|
|
toolbarItems.add(Obx(() {
|
|
final privacyModeState = PrivacyModeState.find(widget.id);
|
|
if ((privacyModeState.isEmpty ||
|
|
allowDisplaySwitchInPrivacyMode(pi, privacyModeState.value)) &&
|
|
pi.displaysCount.value > 1 &&
|
|
mainGetLocalBoolOptionSync(kOptionAllowMonitorSwitchMainToolbar)) {
|
|
return _MainMonitorSwitchButton(id: widget.id, ffi: widget.ffi);
|
|
} else {
|
|
return const Offstage();
|
|
}
|
|
}));
|
|
if (!isWebDesktop) {
|
|
toolbarItems.add(_MobileActionMenu(ffi: widget.ffi));
|
|
}
|
|
|
|
toolbarItems.add(Obx(() {
|
|
final privacyModeState = PrivacyModeState.find(widget.id);
|
|
if ((privacyModeState.isEmpty ||
|
|
allowDisplaySwitchInPrivacyMode(pi, privacyModeState.value)) &&
|
|
pi.displaysCount.value > 1) {
|
|
return _MonitorMenu(
|
|
id: widget.id,
|
|
ffi: widget.ffi,
|
|
edge: edge,
|
|
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));
|
|
// innerAxis: how the toolbar icons themselves flow.
|
|
// outerAxis: how the toolbar block and the handle stack against each other
|
|
// (perpendicular to the dock edge, so the handle hangs off the interior face).
|
|
final innerAxis = isHorizontal ? Axis.horizontal : Axis.vertical;
|
|
final outerAxis = isHorizontal ? Axis.vertical : Axis.horizontal;
|
|
final spacer = isHorizontal
|
|
? SizedBox(width: _ToolbarTheme.buttonHMargin * 2)
|
|
: SizedBox(height: _ToolbarTheme.buttonHMargin * 2);
|
|
final toolbarMaterial = 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: innerAxis,
|
|
child: Theme(
|
|
data: themeData(),
|
|
child: _ToolbarTheme.borderWrapper(
|
|
context,
|
|
Flex(
|
|
direction: innerAxis,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
spacer,
|
|
...toolbarItems,
|
|
spacer,
|
|
],
|
|
),
|
|
toolbarBorderRadius),
|
|
),
|
|
),
|
|
);
|
|
final handle = _buildDraggableCollapse(context, edge, isHorizontal);
|
|
// The handle hangs off the interior face of the toolbar (away from the
|
|
// docked edge), centered along that face by the Flex's default cross-axis
|
|
// alignment, so the icons themselves sit flush against the docked edge.
|
|
final children = (edge == _ToolbarEdge.top || edge == _ToolbarEdge.left)
|
|
? [toolbarMaterial, handle]
|
|
: [handle, toolbarMaterial];
|
|
return Flex(
|
|
direction: outerAxis,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: children,
|
|
);
|
|
}
|
|
|
|
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 _MonitorCycle {
|
|
final String id;
|
|
final FFI ffi;
|
|
const _MonitorCycle(this.id, this.ffi);
|
|
|
|
PeerInfo get _pi => ffi.ffiModel.pi;
|
|
int get total => _pi.displays.length;
|
|
int get _current => CurrentDisplayState.find(id).value;
|
|
bool get _inRange => _current >= 0 && _current < total;
|
|
|
|
String get label => _inRange ? '${_current + 1}' : '*';
|
|
String get tooltip => '${translate('Switch display')} ($label/$total)';
|
|
|
|
void next() {
|
|
final t = total;
|
|
if (t < 2) return;
|
|
final from = _inRange ? _current : -1;
|
|
final target = (from + 1) % t;
|
|
final isChooseDisplayToOpenInNewWindow = _pi.isSupportMultiDisplay &&
|
|
bind.sessionGetDisplaysAsIndividualWindows(sessionId: ffi.sessionId) ==
|
|
'Y';
|
|
if (isChooseDisplayToOpenInNewWindow) {
|
|
openMonitorInNewTabOrWindow(target, ffi.id, _pi);
|
|
} else {
|
|
openMonitorInTheSameTab(target, ffi, _pi, updateCursorPos: false);
|
|
}
|
|
}
|
|
}
|
|
|
|
class _MainMonitorSwitchButton extends StatelessWidget {
|
|
final String id;
|
|
final FFI ffi;
|
|
|
|
const _MainMonitorSwitchButton({
|
|
Key? key,
|
|
required this.id,
|
|
required this.ffi,
|
|
}) : super(key: key);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final cycle = _MonitorCycle(id, ffi);
|
|
return Obx(() {
|
|
if (cycle.total < 2) return const Offstage();
|
|
final label = cycle.label;
|
|
|
|
return _IconMenuButton(
|
|
tooltip: cycle.tooltip,
|
|
color: _ToolbarTheme.blueColor,
|
|
hoverColor: _ToolbarTheme.hoverBlueColor,
|
|
onPressed: cycle.next,
|
|
icon: SizedBox(
|
|
width: _ToolbarTheme.buttonSize,
|
|
height: _ToolbarTheme.buttonSize,
|
|
child: Stack(
|
|
alignment: const Alignment(0, -0.125),
|
|
children: [
|
|
SvgPicture.asset(
|
|
'assets/display_switcher.svg',
|
|
colorFilter:
|
|
const ColorFilter.mode(Colors.white, BlendMode.srcIn),
|
|
width: _ToolbarTheme.buttonSize,
|
|
height: _ToolbarTheme.buttonSize,
|
|
),
|
|
Text(
|
|
label,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(
|
|
color: Colors.black,
|
|
fontSize: 11,
|
|
height: 1,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
class _MonitorMenu extends StatelessWidget {
|
|
final String id;
|
|
final FFI ffi;
|
|
final _ToolbarEdge edge;
|
|
final Function(VoidCallback) setRemoteState;
|
|
const _MonitorMenu({
|
|
Key? key,
|
|
required this.id,
|
|
required this.ffi,
|
|
required this.edge,
|
|
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) {
|
|
final child = showMonitorsToolbar
|
|
? buildMultiMonitorMenu(context)
|
|
: Obx(() => buildMonitorMenu(context));
|
|
final quarterTurns = _monitorMenuQuarterTurns(edge);
|
|
if (quarterTurns == 0) return child;
|
|
return RotatedBox(
|
|
quarterTurns: quarterTurns,
|
|
child: child,
|
|
);
|
|
}
|
|
|
|
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 height = rect.height * scale;
|
|
final startY = (_ToolbarTheme.buttonSize - height) * 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: height + 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 {
|
|
final hint = e.actionId == null
|
|
? null
|
|
: ShortcutDisplay.formatFor(e.actionId!);
|
|
final child = hint == null
|
|
? e.child
|
|
: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Flexible(child: e.child),
|
|
Flexible(
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(left: 16),
|
|
child: Text(
|
|
hint,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(context)
|
|
.textTheme
|
|
.bodySmall
|
|
?.copyWith(
|
|
color: Theme.of(context).hintColor,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
return MenuButton(
|
|
child: 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;
|
|
Size? _waylandMaximizedWorkAreaSize;
|
|
Rect? _waylandWorkAreaScreenFrame;
|
|
double? _waylandWorkAreaScaleFactor;
|
|
Rect? _x11WorkArea;
|
|
Rect? _x11WorkAreaScreenFrame;
|
|
double? _x11WorkAreaScaleFactor;
|
|
|
|
ScreenAdjustor({
|
|
required this.id,
|
|
required this.ffi,
|
|
required this.cbExitFullscreen,
|
|
});
|
|
|
|
bool get isFullscreen => stateGlobal.fullscreen.isTrue;
|
|
int get windowId => stateGlobal.windowId;
|
|
|
|
Future<bool?> isWindowMaximized() async {
|
|
try {
|
|
return await WindowController.fromWindowId(windowId).isMaximized();
|
|
} catch (_) {
|
|
// The delayed resolution callback may run after the window is disposed.
|
|
return null;
|
|
}
|
|
}
|
|
|
|
adjustWindow(BuildContext context) {
|
|
return futureBuilder(
|
|
future: isWindowCanBeAdjusted(context),
|
|
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(),
|
|
],
|
|
);
|
|
});
|
|
}
|
|
|
|
// Linux screen and work-area coordinates can use different units or become
|
|
// unreliable across Wayland/X11 state changes, so normalize reported frames
|
|
// and cache usable work-area measurements before sizing the window.
|
|
|
|
Future<void> _updateLinuxWorkAreaCache({
|
|
required window_size.Screen screen,
|
|
required Rect wndRect,
|
|
required bool isWayland,
|
|
required bool isX11,
|
|
required bool forMenu,
|
|
}) async {
|
|
if (isWayland &&
|
|
(_waylandWorkAreaScreenFrame != screen.frame ||
|
|
_waylandWorkAreaScaleFactor != screen.scaleFactor)) {
|
|
_waylandMaximizedWorkAreaSize = null;
|
|
_waylandWorkAreaScreenFrame = screen.frame;
|
|
_waylandWorkAreaScaleFactor = screen.scaleFactor;
|
|
}
|
|
if (isWayland &&
|
|
forMenu &&
|
|
!isFullscreen &&
|
|
await isWindowMaximized() == true) {
|
|
_waylandMaximizedWorkAreaSize = wndRect.size;
|
|
}
|
|
if (isX11 &&
|
|
(_x11WorkAreaScreenFrame != screen.frame ||
|
|
_x11WorkAreaScaleFactor != screen.scaleFactor)) {
|
|
_x11WorkArea = null;
|
|
_x11WorkAreaScreenFrame = screen.frame;
|
|
_x11WorkAreaScaleFactor = screen.scaleFactor;
|
|
}
|
|
if (isX11 && forMenu && !isFullscreen) {
|
|
_x11WorkArea = screen.visibleFrame;
|
|
}
|
|
}
|
|
|
|
Future<Rect?> _getEffectiveScreenFrame({
|
|
required window_size.Screen screen,
|
|
required bool isWayland,
|
|
required bool isX11,
|
|
required bool forMenu,
|
|
}) async {
|
|
Rect frameRect = screen.visibleFrame;
|
|
if (isMacOS && forMenu && isFullscreen) {
|
|
List<double>? workArea;
|
|
try {
|
|
workArea = await kMacOSPermChannel
|
|
.invokeListMethod<double>('getMacOSWorkAreaSize');
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
if (workArea == null || workArea.length != 2) {
|
|
return null;
|
|
}
|
|
frameRect = Rect.fromLTWH(
|
|
frameRect.left,
|
|
frameRect.top,
|
|
workArea[0] < frameRect.width ? workArea[0] : frameRect.width,
|
|
workArea[1] < frameRect.height ? workArea[1] : frameRect.height,
|
|
);
|
|
}
|
|
final x11WorkArea = _x11WorkArea;
|
|
if (isX11 &&
|
|
forMenu &&
|
|
isFullscreen &&
|
|
x11WorkArea != null &&
|
|
(x11WorkArea.width < frameRect.width ||
|
|
x11WorkArea.height < frameRect.height)) {
|
|
frameRect = x11WorkArea;
|
|
}
|
|
final screenScale = screen.scaleFactor;
|
|
if (isWayland && screenScale > 1.01) {
|
|
String monitorLayoutMode;
|
|
try {
|
|
monitorLayoutMode =
|
|
await bind.mainGetCommon(key: 'gnome-monitor-layout-mode');
|
|
} catch (_) {
|
|
monitorLayoutMode = '';
|
|
}
|
|
if (monitorLayoutMode == 'physical') {
|
|
frameRect = Rect.fromLTRB(
|
|
frameRect.left / screenScale,
|
|
frameRect.top / screenScale,
|
|
frameRect.right / screenScale,
|
|
frameRect.bottom / screenScale,
|
|
);
|
|
}
|
|
}
|
|
return frameRect;
|
|
}
|
|
|
|
Future<Rect?> _getAdjustedWindowFrame(Size mediaSize,
|
|
{bool forMenu = false}) async {
|
|
final screen = _screen;
|
|
if (screen != null) {
|
|
// Windows window frames use physical pixels while Flutter view sizes are
|
|
// logical. macOS and Linux window frames use the same units as Flutter.
|
|
double scale = isWindows ? screen.scaleFactor : 1.0;
|
|
final Rect wndRect;
|
|
try {
|
|
wndRect = await WindowController.fromWindowId(windowId).getFrame();
|
|
} catch (e) {
|
|
debugPrint("Failed to get frame of window $windowId, it may be hidden");
|
|
return null;
|
|
}
|
|
// On Windows, wndRect is GetWindowRect while mediaSize is 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;
|
|
// canvasModel.scale is the rendered scale and already applies kIgnoreDpi.
|
|
// Use it instead of the remote source resolution.
|
|
final isWayland = isLinux && bind.mainCurrentIsWayland();
|
|
final isX11 = isLinux && !isWayland;
|
|
await _updateLinuxWorkAreaCache(
|
|
screen: screen,
|
|
wndRect: wndRect,
|
|
isWayland: isWayland,
|
|
isX11: isX11,
|
|
forMenu: forMenu,
|
|
);
|
|
if (isWindows && forMenu && isFullscreen) {
|
|
// desktop_multi_window's hidden title bar keeps 8 physical pixels on
|
|
// each horizontal edge and at the bottom, plus up to 1px at the top.
|
|
// Fullscreen removes these in WM_NCCALCSIZE, so predict the restored
|
|
// frame's worst-case padding when deciding whether to show the menu.
|
|
magicWidth = 16.0;
|
|
magicHeight = 9.0;
|
|
}
|
|
double horizontalEdges;
|
|
double verticalEdges;
|
|
if (forMenu && (isLinux || ((isMacOS || isWindows) && isFullscreen))) {
|
|
// Linux Adjust Window unmaximizes; macOS and Windows exit fullscreen
|
|
// before resizing. Predict the restored normal-window edges when
|
|
// deciding whether to show the menu item.
|
|
final resizePadding = isLinux && !kUseCompatibleUiMode
|
|
? kDragToResizeAreaPaddingSize
|
|
: 0.0;
|
|
final windowEdge = kWindowBorderWidth + resizePadding;
|
|
horizontalEdges = windowEdge * 2;
|
|
verticalEdges = kDesktopRemoteTabBarHeight + windowEdge * 2;
|
|
} else {
|
|
horizontalEdges = CanvasModel.leftToEdge + CanvasModel.rightToEdge;
|
|
verticalEdges = CanvasModel.topToEdge + CanvasModel.bottomToEdge;
|
|
}
|
|
final width = (canvasModel.getDisplayWidth() * canvasModel.scale +
|
|
horizontalEdges) *
|
|
scale +
|
|
magicWidth;
|
|
final height =
|
|
(canvasModel.getDisplayHeight() * canvasModel.scale + verticalEdges) *
|
|
scale +
|
|
magicHeight;
|
|
double left = wndRect.left + (wndRect.width - width) / 2;
|
|
double top = wndRect.top + (wndRect.height - height) / 2;
|
|
|
|
final frameRect = await _getEffectiveScreenFrame(
|
|
screen: screen,
|
|
isWayland: isWayland,
|
|
isX11: isX11,
|
|
forMenu: forMenu,
|
|
);
|
|
if (frameRect == null) {
|
|
return null;
|
|
}
|
|
var availableSize = frameRect.size;
|
|
if (isWayland && forMenu && _waylandMaximizedWorkAreaSize != null) {
|
|
final cachedSize = _waylandMaximizedWorkAreaSize!;
|
|
availableSize = Size(
|
|
cachedSize.width < availableSize.width
|
|
? cachedSize.width
|
|
: availableSize.width,
|
|
cachedSize.height < availableSize.height
|
|
? cachedSize.height
|
|
: availableSize.height,
|
|
);
|
|
}
|
|
// A window frame cannot be smaller than its client area. Tolerate small
|
|
// floating-point differences; larger negative values mean the native
|
|
// frame and Flutter view metrics are not synchronized.
|
|
if (magicWidth < -0.1 || magicHeight < -0.1) {
|
|
return null;
|
|
}
|
|
// Reject implausibly small targets to avoid hiding the window.
|
|
if (width < 300 || height < 300) {
|
|
return null;
|
|
}
|
|
// The remote size may change after the menu is built. Reject targets
|
|
// that exceed the available area.
|
|
final exceedsScreen =
|
|
width > availableSize.width || height > availableSize.height;
|
|
if (exceedsScreen) {
|
|
return null;
|
|
}
|
|
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;
|
|
}
|
|
return Rect.fromLTWH(left, top, width, height);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
doAdjustWindow([BuildContext? context]) async {
|
|
// A resolution change is adjusted after a delay, when the menu context may
|
|
// already be disposed. Each desktop_multi_window window has its own engine,
|
|
// so that engine's first view is the current window.
|
|
final views = WidgetsBinding.instance.platformDispatcher.views;
|
|
if (context == null && views.isEmpty) {
|
|
return;
|
|
}
|
|
final view = context != null ? View.of(context) : views.first;
|
|
await updateScreen();
|
|
if (_screen != null) {
|
|
final wc = WindowController.fromWindowId(windowId);
|
|
final wasFullscreen = isFullscreen;
|
|
cbExitFullscreen();
|
|
if (wasFullscreen) {
|
|
// Wait for the native fullscreen exit to update the window frame.
|
|
await Future.delayed(Duration(milliseconds: 700));
|
|
await updateScreen();
|
|
}
|
|
if (isLinux) {
|
|
final isMaximized = await isWindowMaximized();
|
|
if (isMaximized == null) {
|
|
return;
|
|
}
|
|
if (isMaximized == true) {
|
|
// setFrame may be ignored while the native window is maximized.
|
|
try {
|
|
await wc.unmaximize();
|
|
} catch (_) {
|
|
return;
|
|
}
|
|
stateGlobal.setMaximized(false);
|
|
// Wait for the window manager and Flutter view metrics to reflect
|
|
// the restored window before calculating and setting its frame.
|
|
await Future.delayed(Duration(milliseconds: 300));
|
|
await updateScreen();
|
|
}
|
|
}
|
|
final mediaSize = MediaQueryData.fromView(view).size;
|
|
final frame = await _getAdjustedWindowFrame(mediaSize);
|
|
if (frame == null) {
|
|
return;
|
|
}
|
|
try {
|
|
await wc.setFrame(frame);
|
|
} catch (_) {
|
|
return;
|
|
}
|
|
stateGlobal.setMaximized(false);
|
|
}
|
|
}
|
|
|
|
updateScreen() async {
|
|
_screen = await _getCurrentScreen();
|
|
}
|
|
|
|
Future<window_size.Screen?> _getCurrentScreen() async {
|
|
try {
|
|
return (await window_size.getWindowInfo()).screen;
|
|
} catch (e) {
|
|
debugPrint('Failed to get current window screen: $e');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Future<bool> isWindowCanBeAdjusted([BuildContext? context]) async {
|
|
if (isWeb) {
|
|
return false;
|
|
}
|
|
// Capture the view before awaiting because the menu context may be disposed.
|
|
final views = WidgetsBinding.instance.platformDispatcher.views;
|
|
if (context == null && views.isEmpty) {
|
|
return false;
|
|
}
|
|
final view = context != null ? View.of(context) : views.first;
|
|
final mediaSize = MediaQueryData.fromView(view).size;
|
|
final viewStyle =
|
|
await bind.sessionGetViewStyle(sessionId: ffi.sessionId) ?? '';
|
|
if (viewStyle != kRemoteViewStyleOriginal) {
|
|
return false;
|
|
}
|
|
final remoteCount = RemoteCountState.find().value;
|
|
if (remoteCount != 1) {
|
|
return false;
|
|
}
|
|
await updateScreen();
|
|
if (_screen == null) {
|
|
return false;
|
|
}
|
|
return await _getAdjustedWindowFrame(mediaSize, forMenu: true) != null;
|
|
}
|
|
}
|
|
|
|
class _DisplayMenu extends StatefulWidget {
|
|
final String id;
|
|
final FFI ffi;
|
|
final ToolbarState state;
|
|
final Function(bool) setFullscreen;
|
|
const _DisplayMenu(
|
|
{required this.id,
|
|
required this.ffi,
|
|
required this.state,
|
|
required this.setFullscreen});
|
|
|
|
@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) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
menuChildrenGetter(_IconSubmenuButtonState state) {
|
|
final menuChildren = <Widget>[
|
|
_screenAdjustor.adjustWindow(context),
|
|
viewStyle(customPercent: _customPercent),
|
|
scrollStyle(state, colorScheme),
|
|
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
|
|
final privacyModeState = PrivacyModeState.find(id);
|
|
if (ffi.connType == ConnType.defaultConn &&
|
|
(pi.features.privacyMode || privacyModeState.isNotEmpty) &&
|
|
(ffiModel.keyboard || privacyModeState.isNotEmpty)) {
|
|
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()),
|
|
]);
|
|
}
|
|
}
|
|
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(_IconSubmenuButtonState state, ColorScheme colorScheme) {
|
|
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) ?? '';
|
|
final edgeScrollEdgeThickness = await bind
|
|
.sessionGetEdgeScrollEdgeThickness(sessionId: ffi.sessionId);
|
|
return {
|
|
'visible': visible,
|
|
'scrollStyle': scrollStyle,
|
|
'edgeScrollEdgeThickness': edgeScrollEdgeThickness,
|
|
};
|
|
}(), hasData: (data) {
|
|
final visible = data['visible'] as bool;
|
|
if (!visible) return Offstage();
|
|
final groupValue = data['scrollStyle'] as String;
|
|
final edgeScrollEdgeThickness = data['edgeScrollEdgeThickness'] as int;
|
|
|
|
onChangeScrollStyle(String? value) async {
|
|
if (value == null) return;
|
|
await bind.sessionSetScrollStyle(
|
|
sessionId: ffi.sessionId, value: value);
|
|
widget.ffi.canvasModel.updateScrollStyle();
|
|
state.setState(() {});
|
|
}
|
|
|
|
onChangeEdgeScrollEdgeThickness(double? value) async {
|
|
if (value == null) return;
|
|
final newThickness = value.round();
|
|
await bind.sessionSetEdgeScrollEdgeThickness(
|
|
sessionId: ffi.sessionId, value: newThickness);
|
|
widget.ffi.canvasModel.updateEdgeScrollEdgeThickness(newThickness);
|
|
state.setState(() {});
|
|
}
|
|
|
|
return Obx(() => Column(children: [
|
|
RdoMenuButton<String>(
|
|
child: Text(translate('ScrollAuto')),
|
|
value: kRemoteScrollStyleAuto,
|
|
groupValue: groupValue,
|
|
onChanged: widget.ffi.canvasModel.imageOverflow.value
|
|
? (value) => onChangeScrollStyle(value)
|
|
: null,
|
|
closeOnActivate: groupValue != kRemoteScrollStyleEdge,
|
|
ffi: widget.ffi,
|
|
),
|
|
RdoMenuButton<String>(
|
|
child: Text(translate('Scrollbar')),
|
|
value: kRemoteScrollStyleBar,
|
|
groupValue: groupValue,
|
|
onChanged: widget.ffi.canvasModel.imageOverflow.value
|
|
? (value) => onChangeScrollStyle(value)
|
|
: null,
|
|
closeOnActivate: groupValue != kRemoteScrollStyleEdge,
|
|
ffi: widget.ffi,
|
|
),
|
|
if (!isWeb) ...[
|
|
RdoMenuButton<String>(
|
|
child: Text(translate('ScrollEdge')),
|
|
value: kRemoteScrollStyleEdge,
|
|
groupValue: groupValue,
|
|
closeOnActivate: false,
|
|
onChanged: widget.ffi.canvasModel.imageOverflow.value
|
|
? (value) => onChangeScrollStyle(value)
|
|
: null,
|
|
ffi: widget.ffi,
|
|
),
|
|
Offstage(
|
|
offstage: groupValue != kRemoteScrollStyleEdge,
|
|
child: EdgeThicknessControl(
|
|
value: edgeScrollEdgeThickness.toDouble(),
|
|
onChanged: onChangeEdgeScrollEdgeThickness,
|
|
colorScheme: colorScheme,
|
|
)),
|
|
],
|
|
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 CustomScaleControls<_CustomScaleMenuControls> {
|
|
@override
|
|
FFI get ffi => widget.ffi;
|
|
|
|
@override
|
|
ValueChanged<int>? get onScaleChanged => widget.onChanged;
|
|
|
|
@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: '$scaleValue%',
|
|
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: CustomScaleControls.minPercent.toDouble(),
|
|
max: CustomScaleControls.maxPercent.toDouble(),
|
|
width: 52,
|
|
height: 24,
|
|
radius: 4,
|
|
displayValueForNormalized: (t) => mapPosToPercent(t),
|
|
),
|
|
),
|
|
child: Slider(
|
|
value: scalePos,
|
|
min: 0.0,
|
|
max: 1.0,
|
|
// Use a wide range of divisions (calculated as (CustomScaleControls.maxPercent - CustomScaleControls.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:
|
|
(CustomScaleControls.maxPercent - CustomScaleControls.minPercent)
|
|
.round(),
|
|
onChanged: onSliderChanged,
|
|
),
|
|
),
|
|
);
|
|
|
|
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: () => nudgeScale(-1),
|
|
),
|
|
),
|
|
Expanded(child: sliderControl),
|
|
Tooltip(
|
|
message: translate('Increase'),
|
|
child: IconButton(
|
|
iconSize: 16,
|
|
padding: EdgeInsets.all(1),
|
|
constraints: smallBtnConstraints,
|
|
icon: const Icon(Icons.add),
|
|
onPressed: () => nudgeScale(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;
|
|
final String unit;
|
|
// 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,
|
|
this.unit = '%',
|
|
});
|
|
|
|
@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 value from normalized slider value.
|
|
final int displayValue = displayValueForNormalized != null
|
|
? displayValueForNormalized!(value)
|
|
: (min + value * (max - min)).round();
|
|
final TextSpan span = TextSpan(
|
|
text: '$displayValue$unit',
|
|
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();
|
|
try {
|
|
final window = await window_size.getWindowInfo();
|
|
final screen = window.screen;
|
|
if (screen != null) {
|
|
setState(() {
|
|
_localResolution = Resolution(
|
|
screen.frame.width.toInt(),
|
|
screen.frame.height.toInt(),
|
|
);
|
|
});
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Failed to get local resolution on Wayland: $e');
|
|
}
|
|
}
|
|
|
|
_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()) {
|
|
return;
|
|
}
|
|
if (widget.screenAdjustor.isFullscreen) {
|
|
return;
|
|
}
|
|
if ((await widget.screenAdjustor.isWindowMaximized()) == false) {
|
|
// This delayed callback can outlive the menu State, so its context
|
|
// is unsafe.
|
|
widget.screenAdjustor.doAdjustWindow();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
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() {
|
|
final toggles = toolbarKeyboardToggles(ffi)
|
|
.map((e) => CkbMenuButton(
|
|
value: e.value,
|
|
onChanged: e.onChanged,
|
|
child: e.child,
|
|
ffi: ffi) as Widget)
|
|
.toList();
|
|
if (toggles.isNotEmpty) {
|
|
toggles.add(Divider());
|
|
}
|
|
return toggles;
|
|
}
|
|
|
|
return _IconSubmenuButton(
|
|
tooltip: 'Keyboard Settings',
|
|
svg: "assets/keyboard_mouse.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);
|
|
// Release native input; see the macOS trade-offs in RemotePage.
|
|
if (isMacOS) ffi.inputModel.enterOrLeave(false);
|
|
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(_IconSubmenuButtonState state) {
|
|
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 hideRecordingButton =
|
|
bind.mainGetLocalOption(key: kOptionHideRecordingButton) == 'Y';
|
|
final visible = !hideRecordingButton &&
|
|
(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: () async {
|
|
if (await showConnEndAuditDialogCloseCanceled(ffi: ffi)) {
|
|
return;
|
|
}
|
|
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: translate(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(_IconSubmenuButtonState state) 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 // discard @protected
|
|
void setState(VoidCallback fn) {
|
|
super.setState(fn);
|
|
}
|
|
|
|
@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(this)
|
|
.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 FFI ffi;
|
|
final SessionID sessionId;
|
|
final RxDouble fraction;
|
|
final Rx<_ToolbarEdge> edge;
|
|
final Rxn<_ToolbarEdge> previewEdge;
|
|
final Rxn<double> previewFraction;
|
|
final Rxn<Size> toolbarSize;
|
|
final VoidCallback markDragEpoch;
|
|
final VoidCallback syncDockingOptionsAfterDragIfNeeded;
|
|
final bool isHorizontal;
|
|
// Whether multi-edge docking is enabled for this session (toggled in
|
|
// Settings -> Other). When false, the drag handle slides the toolbar
|
|
// horizontally on the top edge and never switches edges.
|
|
final bool multiEdgeEnabled;
|
|
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.ffi,
|
|
required this.sessionId,
|
|
required this.fraction,
|
|
required this.edge,
|
|
required this.previewEdge,
|
|
required this.previewFraction,
|
|
required this.toolbarSize,
|
|
required this.markDragEpoch,
|
|
required this.syncDockingOptionsAfterDragIfNeeded,
|
|
required this.isHorizontal,
|
|
required this.multiEdgeEnabled,
|
|
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> {
|
|
double left = 0.0;
|
|
double right = 1.0;
|
|
Offset? _lastPointerDown;
|
|
Offset? _dragGrabOffset;
|
|
double? _dragLongAxisGrabOffset;
|
|
Size? _dragToolbarSize;
|
|
|
|
RxBool get collapse => widget.toolbarState.collapse;
|
|
|
|
@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;
|
|
}
|
|
}
|
|
|
|
// Bias applied to the currently-previewed edge so a drag hovering between
|
|
// two edges doesn't flicker. Only relevant when multi-edge is enabled.
|
|
static const double _switchHysteresisPx = 50.0;
|
|
|
|
_ToolbarEdge _nearestToolbarEdge(Offset cursor, Size mediaSize) {
|
|
if (!widget.multiEdgeEnabled) return widget.edge.value;
|
|
|
|
double rawDist(_ToolbarEdge e) {
|
|
switch (e) {
|
|
case _ToolbarEdge.top:
|
|
return cursor.dy;
|
|
case _ToolbarEdge.bottom:
|
|
return mediaSize.height - cursor.dy;
|
|
case _ToolbarEdge.left:
|
|
return cursor.dx;
|
|
case _ToolbarEdge.right:
|
|
return mediaSize.width - cursor.dx;
|
|
}
|
|
}
|
|
|
|
final previewed = widget.previewEdge.value;
|
|
var winner = widget.edge.value;
|
|
var best = double.infinity;
|
|
for (final e in _ToolbarEdge.values) {
|
|
final biased =
|
|
e == previewed ? rawDist(e) - _switchHysteresisPx : rawDist(e);
|
|
if (biased < best) {
|
|
best = biased;
|
|
winner = e;
|
|
}
|
|
}
|
|
return winner;
|
|
}
|
|
|
|
void _ensureDragGrabOffset(Offset cursor) {
|
|
if (_dragGrabOffset != null) return;
|
|
final mediaSize = MediaQueryData.fromView(View.of(context)).size;
|
|
final toolbarSize =
|
|
_toolbarSizeForEdge(widget.edge.value, widget.toolbarSize.value);
|
|
_dragToolbarSize = toolbarSize;
|
|
final toolbarOffset = _toolbarOffsetForEdge(
|
|
edge: widget.edge.value,
|
|
fraction: widget.fraction.value,
|
|
parentSize: mediaSize,
|
|
toolbarSize: toolbarSize,
|
|
);
|
|
_dragGrabOffset = cursor - toolbarOffset;
|
|
_dragLongAxisGrabOffset = _isHorizontalEdge(widget.edge.value)
|
|
? _dragGrabOffset?.dx
|
|
: _dragGrabOffset?.dy;
|
|
}
|
|
|
|
double _dragGrabOffsetForEdge(_ToolbarEdge edge, Size toolbarSize) {
|
|
final offset = _dragLongAxisGrabOffset ?? 0;
|
|
final extent =
|
|
_isHorizontalEdge(edge) ? toolbarSize.width : toolbarSize.height;
|
|
return _clampToolbarFraction(offset, 0, extent);
|
|
}
|
|
|
|
void _updatePreview(Offset cursor) {
|
|
_ensureDragGrabOffset(cursor);
|
|
final mediaSize = MediaQueryData.fromView(View.of(context)).size;
|
|
final winner = _nearestToolbarEdge(cursor, mediaSize);
|
|
widget.previewEdge.value = winner;
|
|
|
|
final toolbarSize = _toolbarSizeForEdge(winner, _dragToolbarSize);
|
|
final grabOffset = _dragGrabOffsetForEdge(winner, toolbarSize);
|
|
final double frac;
|
|
if (winner == _ToolbarEdge.top || winner == _ToolbarEdge.bottom) {
|
|
frac = _fractionForAlignedDrag(
|
|
cursor: cursor.dx,
|
|
grabOffset: grabOffset,
|
|
parentExtent: mediaSize.width,
|
|
toolbarExtent: toolbarSize.width,
|
|
left: left,
|
|
right: right,
|
|
);
|
|
} else {
|
|
final fractionBounds = _fractionBoundsForEdge(winner, left, right);
|
|
frac = _fractionForAlignedDrag(
|
|
cursor: cursor.dy,
|
|
grabOffset: grabOffset,
|
|
parentExtent: mediaSize.height,
|
|
toolbarExtent: toolbarSize.height,
|
|
left: fractionBounds.left,
|
|
right: fractionBounds.right,
|
|
);
|
|
}
|
|
widget.previewFraction.value = frac;
|
|
}
|
|
|
|
void _resetDragTracking() {
|
|
_lastPointerDown = null;
|
|
_dragGrabOffset = null;
|
|
_dragLongAxisGrabOffset = null;
|
|
_dragToolbarSize = null;
|
|
}
|
|
|
|
void _commitPreview() {
|
|
final newEdge = widget.previewEdge.value;
|
|
final frac = widget.previewFraction.value;
|
|
widget.previewEdge.value = null;
|
|
widget.previewFraction.value = null;
|
|
widget.dragging.value = false;
|
|
widget.markDragEpoch();
|
|
_resetDragTracking();
|
|
widget.syncDockingOptionsAfterDragIfNeeded();
|
|
if (newEdge == null || frac == null) return;
|
|
widget.edge.value = newEdge;
|
|
widget.fraction.value = frac;
|
|
_cacheToolbarDockingOptions(
|
|
sessionId: widget.sessionId,
|
|
edge: newEdge,
|
|
fraction: frac,
|
|
multiEdgeEnabled: widget.multiEdgeEnabled,
|
|
);
|
|
bind.sessionPeerOption(
|
|
sessionId: widget.sessionId,
|
|
name: kOptionRemoteMenubarEdge,
|
|
value: _toolbarEdgeToString(newEdge),
|
|
);
|
|
bind.sessionPeerOption(
|
|
sessionId: widget.sessionId,
|
|
name: kOptionRemoteMenubarFraction,
|
|
value: frac.toString(),
|
|
);
|
|
if (widget.multiEdgeEnabled) {
|
|
return;
|
|
}
|
|
bind.sessionPeerOption(
|
|
sessionId: widget.sessionId,
|
|
name: _legacyRemoteMenubarDragX,
|
|
value: frac.toString(),
|
|
);
|
|
}
|
|
|
|
Widget _buildDraggable(BuildContext context) {
|
|
return Listener(
|
|
onPointerDown: (event) => _lastPointerDown = event.position,
|
|
child: Draggable(
|
|
// When multi-edge docking is off the toolbar stays on the top edge,
|
|
// so lock the feedback to horizontal motion — otherwise the handle
|
|
// floats away from the top while dragging and the toolbar looks
|
|
// unmoored. When multi-edge is on we need 2D drag for snap-to-edge.
|
|
axis: widget.multiEdgeEnabled ? null : Axis.horizontal,
|
|
child: Icon(
|
|
widget.isHorizontal ? Icons.drag_indicator : Icons.drag_handle,
|
|
size: 20,
|
|
color: MyTheme.color(context).drag_indicator,
|
|
),
|
|
feedback: widget,
|
|
onDragStarted: () {
|
|
widget.markDragEpoch();
|
|
final pointerDown = _lastPointerDown;
|
|
if (pointerDown != null) {
|
|
_ensureDragGrabOffset(pointerDown);
|
|
}
|
|
widget.dragging.value = true;
|
|
// Seed the preview at the current docked edge/fraction so something
|
|
// shows the instant the drag begins, before the first onDragUpdate.
|
|
widget.previewEdge.value = widget.edge.value;
|
|
widget.previewFraction.value = widget.fraction.value;
|
|
},
|
|
onDragUpdate: (details) {
|
|
_updatePreview(details.globalPosition);
|
|
},
|
|
onDragEnd: (_) => _commitPreview(),
|
|
),
|
|
);
|
|
}
|
|
|
|
@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 axis = widget.isHorizontal ? Axis.horizontal : Axis.vertical;
|
|
final child = Flex(
|
|
direction: axis,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_buildDraggable(context),
|
|
Obx(() => collapse.isTrue
|
|
? _MinimizedMonitorSwitchButton(id: widget.id, ffi: widget.ffi)
|
|
: const Offstage()),
|
|
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.switchCollapse(widget.sessionId);
|
|
}),
|
|
Obx((() => Tooltip(
|
|
message: translate(
|
|
collapse.isFalse ? 'Hide Toolbar' : 'Show Toolbar'),
|
|
child: Icon(
|
|
_toolbarCollapseIcon(widget.edge.value, collapse.isTrue),
|
|
size: iconSize,
|
|
),
|
|
))),
|
|
),
|
|
if (isWebDesktop)
|
|
Obx(() {
|
|
if (collapse.isFalse) {
|
|
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: widget.isHorizontal ? 20 : null,
|
|
width: widget.isHorizontal ? null : 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,
|
|
),
|
|
);
|
|
}
|
|
|
|
class EdgeThicknessControl extends StatelessWidget {
|
|
final double value;
|
|
final ValueChanged<double>? onChanged;
|
|
final ColorScheme? colorScheme;
|
|
|
|
const EdgeThicknessControl({
|
|
Key? key,
|
|
required this.value,
|
|
this.onChanged,
|
|
this.colorScheme,
|
|
}) : super(key: key);
|
|
|
|
static const double kMin = 20;
|
|
static const double kMax = 150;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = this.colorScheme ?? Theme.of(context).colorScheme;
|
|
|
|
final slider = SliderTheme(
|
|
data: SliderTheme.of(context).copyWith(
|
|
activeTrackColor: colorScheme.primary,
|
|
thumbColor: colorScheme.primary,
|
|
overlayColor: colorScheme.primary.withOpacity(0.1),
|
|
showValueIndicator: ShowValueIndicator.never,
|
|
thumbShape: _RectValueThumbShape(
|
|
min: EdgeThicknessControl.kMin,
|
|
max: EdgeThicknessControl.kMax,
|
|
width: 52,
|
|
height: 24,
|
|
radius: 4,
|
|
unit: 'px',
|
|
),
|
|
),
|
|
child: Semantics(
|
|
value: value.toInt().toString(),
|
|
child: Slider(
|
|
value: value,
|
|
min: EdgeThicknessControl.kMin,
|
|
max: EdgeThicknessControl.kMax,
|
|
divisions:
|
|
(EdgeThicknessControl.kMax - EdgeThicknessControl.kMin).round(),
|
|
semanticFormatterCallback: (double newValue) =>
|
|
"${newValue.round()}px",
|
|
onChanged: onChanged,
|
|
),
|
|
),
|
|
);
|
|
|
|
return slider;
|
|
}
|
|
}
|
|
|
|
class _MinimizedMonitorSwitchButton extends StatelessWidget {
|
|
final String id;
|
|
final FFI ffi;
|
|
|
|
const _MinimizedMonitorSwitchButton({
|
|
Key? key,
|
|
required this.id,
|
|
required this.ffi,
|
|
}) : super(key: key);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
const double iconSize = 20;
|
|
final cycle = _MonitorCycle(id, ffi);
|
|
|
|
return Obx(() {
|
|
final label = cycle.label;
|
|
if (!mainGetLocalBoolOptionSync(kOptionAllowMonitorSwitchMainToolbar) ||
|
|
!mainGetLocalBoolOptionSync(kOptionAllowMonitorSwitchMinToolbar)) {
|
|
return const Offstage();
|
|
}
|
|
if (cycle.total < 2) return const Offstage();
|
|
final privacyModeState = PrivacyModeState.find(id);
|
|
if (privacyModeState.isNotEmpty &&
|
|
!allowDisplaySwitchInPrivacyMode(
|
|
ffi.ffiModel.pi, privacyModeState.value)) {
|
|
return const Offstage();
|
|
}
|
|
|
|
return Tooltip(
|
|
message: cycle.tooltip,
|
|
child: TextButton(
|
|
onPressed: cycle.next,
|
|
style: ButtonStyle(
|
|
minimumSize: MaterialStateProperty.all(const Size(0, 0)),
|
|
padding: MaterialStateProperty.all(EdgeInsets.zero),
|
|
backgroundColor: MaterialStateProperty.resolveWith((states) {
|
|
if (states.contains(MaterialState.hovered)) {
|
|
return _ToolbarTheme.blueColor.withOpacity(0.15);
|
|
}
|
|
return null;
|
|
}),
|
|
),
|
|
child: Stack(
|
|
alignment: const Alignment(0, -0.125),
|
|
children: [
|
|
SvgPicture.asset(
|
|
'assets/display_switcher.svg',
|
|
colorFilter:
|
|
ColorFilter.mode(_ToolbarTheme.blueColor, BlendMode.srcIn),
|
|
width: iconSize,
|
|
height: iconSize,
|
|
),
|
|
Text(
|
|
label,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 9,
|
|
height: 1,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
});
|
|
}
|
|
}
|