From c403cd844082bc0038ccfd4db9fc7e3f7aad4796 Mon Sep 17 00:00:00 2001 From: lukecgg Date: Mon, 18 May 2026 10:15:22 +1000 Subject: [PATCH] Gate multi-edge docking behind a settings toggle; default = horizontal slide Replaces the activation-zone approach with an explicit opt-in setting in Settings -> Other ("Allow docking remote toolbar to any window edge"). This addresses the concern that a low-intent horizontal drag shouldn't be able to trigger a high-impact orientation change, while still letting users who want multi-edge docking opt in cleanly. Default (toggle off): - The original horizontal slide is preserved. - The bug fix from the first commit still applies: dragging the handle moves the whole toolbar, and the position persists across collapse/expand (no more re-center on re-open). - Draggable is axis-locked to horizontal so the feedback widget stays on the top line during drag. Opt-in (toggle on): - Full nearest-edge wins with the live preview ghost and corner hysteresis; toolbar reflows vertically on left/right docks. - Draggable is unlocked for 2D drag. Reads the option via mainGetLocalBoolOptionSync so the toolbar's default state matches what the settings checkbox shows; the option key uses the allow- prefix so unset defaults to off. Takes effect on next session (setting is read at session init). The setting key (allow-multi-edge-toolbar-dock) is read by the existing local-options machinery and persists per-install without needing to be registered in libs/hbb_common's KEYS_LOCAL_SETTINGS. Can add that registration in a parallel hbb_common PR if preferred. --- flutter/lib/consts.dart | 2 + .../desktop/pages/desktop_setting_page.dart | 7 ++ .../lib/desktop/widgets/remote_toolbar.dart | 95 ++++++++++++------- 3 files changed, 68 insertions(+), 36 deletions(-) diff --git a/flutter/lib/consts.dart b/flutter/lib/consts.dart index da256c433..adf7b1d45 100644 --- a/flutter/lib/consts.dart +++ b/flutter/lib/consts.dart @@ -144,6 +144,8 @@ const String kOptionRemoteMenubarDragLeft = "remote-menubar-drag-left"; const String kOptionRemoteMenubarDragRight = "remote-menubar-drag-right"; const String kOptionRemoteMenubarEdge = "remote-menubar-edge"; const String kOptionRemoteMenubarFraction = "remote-menubar-frac"; +const String kOptionAllowMultiEdgeToolbarDock = + "allow-multi-edge-toolbar-dock"; const String kOptionHideAbTagsPanel = "hideAbTagsPanel"; const String kOptionRemoteMenubarState = "remoteMenubarState"; const String kOptionPeerSorting = "peer-sorting"; diff --git a/flutter/lib/desktop/pages/desktop_setting_page.dart b/flutter/lib/desktop/pages/desktop_setting_page.dart index 2841c1d27..a5b2689e5 100644 --- a/flutter/lib/desktop/pages/desktop_setting_page.dart +++ b/flutter/lib/desktop/pages/desktop_setting_page.dart @@ -488,6 +488,13 @@ class _GeneralState extends State<_General> { _OptionCheckBox(context, 'Confirm before closing multiple tabs', kOptionEnableConfirmClosingTabs, isServer: false), + if (!isWeb && !bind.isIncomingOnly()) + _OptionCheckBox( + context, + 'Allow docking remote toolbar to any window edge', + kOptionAllowMultiEdgeToolbarDock, + isServer: false, + ), _OptionCheckBox(context, 'Adaptive bitrate', kOptionEnableAbr), if (!isWeb) wallpaper(), if (!isWeb && !bind.isIncomingOnly()) ...[ diff --git a/flutter/lib/desktop/widgets/remote_toolbar.dart b/flutter/lib/desktop/widgets/remote_toolbar.dart index 526081849..6163a8ac2 100644 --- a/flutter/lib/desktop/widgets/remote_toolbar.dart +++ b/flutter/lib/desktop/widgets/remote_toolbar.dart @@ -324,6 +324,12 @@ class _RemoteToolbarState extends State { // (collapsed handle vs expanded toolbar). Updated after every layout pass. final _toolbarSize = Rxn(); 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. + // Read once on init; takes effect on next session. + final _multiEdgeEnabled = false.obs; int get windowId => stateGlobal.windowId; @@ -350,15 +356,25 @@ class _RemoteToolbarState extends State { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) async { - final edgeStr = await bind.sessionGetOption( - sessionId: widget.ffi.sessionId, arg: kOptionRemoteMenubarEdge); + // 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. + _multiEdgeEnabled.value = + mainGetLocalBoolOptionSync(kOptionAllowMultiEdgeToolbarDock); final fracStr = await bind.sessionGetOption( sessionId: widget.ffi.sessionId, arg: kOptionRemoteMenubarFraction); // Backward compat: legacy horizontal-only position. final legacy = await bind.sessionGetOption( sessionId: widget.ffi.sessionId, arg: 'remote-menubar-drag-x'); - // Parse edge independently so a partial write of frac doesn't reset it. - _edge.value = _parseToolbarEdge(edgeStr); + // Only honour a saved edge when the user has opted into multi-edge + // docking. Without the opt-in, the toolbar stays on the top edge. + if (_multiEdgeEnabled.value) { + final edgeStr = await bind.sessionGetOption( + sessionId: widget.ffi.sessionId, arg: kOptionRemoteMenubarEdge); + _edge.value = _parseToolbarEdge(edgeStr); + } else { + _edge.value = _ToolbarEdge.top; + } final rawFraction = (fracStr != null && fracStr.isNotEmpty) ? fracStr : ((legacy != null && legacy.isNotEmpty) ? legacy : '0.5'); @@ -512,6 +528,7 @@ class _RemoteToolbarState extends State { previewEdge: _previewEdge, previewFraction: _previewFraction, isHorizontal: isHorizontal, + multiEdgeEnabled: _multiEdgeEnabled.value, toolbarState: widget.state, setFullscreen: _setFullscreen, setMinimize: _minimize, @@ -2695,6 +2712,10 @@ class _DraggableShowHide extends StatefulWidget { final Rxn<_ToolbarEdge> previewEdge; final Rxn previewFraction; 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; @@ -2711,6 +2732,7 @@ class _DraggableShowHide extends StatefulWidget { required this.previewEdge, required this.previewFraction, required this.isHorizontal, + required this.multiEdgeEnabled, required this.dragging, required this.toolbarState, required this.setFullscreen, @@ -2751,15 +2773,9 @@ class _DraggableShowHideState extends State<_DraggableShowHide> { } } - // Cursor must come within this many pixels of a non-current edge before - // the dock will switch to that edge. Pure "nearest edge wins" made the - // horizontal slide too easy to turn into a high-impact orientation change - // by accident — see PR #15051 review feedback. - static const double _edgeActivationPx = 32.0; - // Once an alternate edge is being previewed, the cursor has to move this - // far back out before the preview reverts. Hysteresis stops the preview - // from flickering at the activation-zone boundary. - static const double _edgeExitPx = 64.0; + // 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; void _updatePreview(Offset cursor) { final mediaSize = MediaQueryData.fromView(View.of(context)).size; @@ -2777,25 +2793,24 @@ class _DraggableShowHideState extends State<_DraggableShowHide> { } final currentDock = widget.edge.value; - final previewed = widget.previewEdge.value; - - // The currently-docked edge is always a candidate: a drag that stays - // away from the screen edges just slides the toolbar along its current - // edge (preserves the prior horizontal-slide behavior). A different - // edge only becomes a candidate when the cursor enters its activation - // zone, with hysteresis so a cursor hovering at the boundary doesn't - // flicker the preview. _ToolbarEdge winner = currentDock; - double bestDist = rawDist(currentDock); - for (final e in _ToolbarEdge.values) { - if (e == currentDock) continue; - final threshold = e == previewed ? _edgeExitPx : _edgeActivationPx; - final d = rawDist(e); - if (d < threshold && d < bestDist) { - bestDist = d; - winner = e; + if (widget.multiEdgeEnabled) { + // Opt-in mode: nearest edge wins (with hysteresis on the currently- + // previewed edge to stop corner flicker). + final previewed = widget.previewEdge.value; + double 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; + } } } + // else: multi-edge disabled — winner stays on the current (top) edge + // and only the fraction updates, i.e. a pure horizontal slide. widget.previewEdge.value = winner; double frac; @@ -2818,11 +2833,16 @@ class _DraggableShowHideState extends State<_DraggableShowHide> { if (newEdge == null || frac == null) return; widget.edge.value = newEdge; widget.fraction.value = frac; - bind.sessionPeerOption( - sessionId: widget.sessionId, - name: kOptionRemoteMenubarEdge, - value: _toolbarEdgeToString(newEdge), - ); + // Only persist the edge when multi-edge docking is opted in. In default + // mode the edge is always top, so writing it back would be noise — and + // we want toggling the setting off to feel like a clean revert. + if (widget.multiEdgeEnabled) { + bind.sessionPeerOption( + sessionId: widget.sessionId, + name: kOptionRemoteMenubarEdge, + value: _toolbarEdgeToString(newEdge), + ); + } bind.sessionPeerOption( sessionId: widget.sessionId, name: kOptionRemoteMenubarFraction, @@ -2838,8 +2858,11 @@ class _DraggableShowHideState extends State<_DraggableShowHide> { Widget _buildDraggable(BuildContext context) { return Draggable( - // No axis lock: the user can drop anywhere and we snap to the nearest - // window edge. + // 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,