diff --git a/src/dock/dock.ts b/src/dock/dock.ts index 8e7ef22..9cb4973 100644 --- a/src/dock/dock.ts +++ b/src/dock/dock.ts @@ -5,6 +5,7 @@ import St from '@girs/st-18'; import GLib from '@girs/glib-2.0'; import * as Main from '@girs/gnome-shell/ui/main'; +import * as DND from '@girs/gnome-shell/ui/dnd'; import type { ExtensionContext } from '~/core/context.ts'; import { LifecycleScope } from '~/core/lifecycleScope.ts'; @@ -19,6 +20,8 @@ import { DashMotionIntegration } from '~/dock/motion/dashMotionIntegration.ts'; const HOT_AREA_REVEAL_DURATION = 1500; const HOT_AREA_STRIP_HEIGHT = 1; +const CONTEXTUAL_DRAG_REVEAL_DELAY = 800; +const TRANSITION_ACTIVATION_COOLDOWN = 700; const LOG_PREFIX = 'Dock'; export type ManagedDockBinding = { @@ -47,6 +50,11 @@ export class Dock extends Module { private _showExternalStorage = true; private _motionEnabled = true; private _motionProfile: string = DEFAULT_PROFILE; + private _edgeDragMonitor: { dragMotion: (event: any) => number } | null = null; + private _edgeDragTarget: ManagedDockBinding | null = null; + private _edgeDragRevealId = 0; + private _sessionWasLocked = false; + private _fullscreenMonitors = new Set(); constructor(context: ExtensionContext) { super(context); @@ -81,10 +89,17 @@ export class Dock extends Module { Main.overview.dash.hide(); + this._sessionWasLocked = Boolean(Main.sessionMode.isLocked); + this._fullscreenMonitors = this._getFullscreenMonitors(); this._rebuildBindings(); + this._setupContextualDragReveal(); Main.layoutManager.connectObject( 'monitors-changed', - () => this._rebuildBindings(), + () => { + this._rebuildBindings(); + this._fullscreenMonitors = this._getFullscreenMonitors(); + this._beginActivationCooldown('monitors-changed'); + }, 'hot-corners-changed', () => this._rebuildBindings(), 'startup-complete', @@ -93,10 +108,27 @@ export class Dock extends Module { ); this._lifecycle.onDispose(() => Main.layoutManager.disconnectObject(this)); - global.display.connectObject('workareas-changed', () => this._refreshWorkAreas(), this); + global.display.connectObject( + 'workareas-changed', + () => this._refreshWorkAreas(), + 'in-fullscreen-changed', + () => this._handleFullscreenChanged(), + this, + ); this._lifecycle.onDispose(() => global.display.disconnectObject(this)); - Main.sessionMode.connectObject('updated', () => this._refreshBindingsLayout(), this); + Main.sessionMode.connectObject( + 'updated', + () => { + const isLocked = Boolean(Main.sessionMode.isLocked); + if (this._sessionWasLocked && !isLocked) { + this._beginActivationCooldown('session-unlocked'); + } + this._sessionWasLocked = isLocked; + this._refreshBindingsLayout(); + }, + this, + ); this._lifecycle.onDispose(() => Main.sessionMode.disconnectObject(this)); Main.overview.connectObject( @@ -173,10 +205,12 @@ export class Dock extends Module { override disable(): void { Main.overview.dash.show(); + this._teardownContextualDragReveal(); this._lifecycle?.dispose(); this._lifecycle = null; this._dockSettings = null; this._pendingRebuild = false; + this._fullscreenMonitors.clear(); this._clearBindings(); } @@ -518,6 +552,7 @@ export class Dock extends Module { } private _destroyBinding(binding: ManagedDockBinding): void { + if (this._edgeDragTarget === binding) this._clearContextualDragReveal(); logger.debug(`monitor=${binding.monitorIndex} binding destroyed`, { prefix: LOG_PREFIX }); if (binding.autoHideReleaseId) { GLib.source_remove(binding.autoHideReleaseId); @@ -667,6 +702,114 @@ export class Dock extends Module { binding.hotAreaEnableId = 0; } + private _setupContextualDragReveal(): void { + this._edgeDragMonitor = { + dragMotion: (event: any) => { + this._handleContextualDragMotion(event); + return DND.DragMotionResult.CONTINUE; + }, + }; + DND.addDragMonitor(this._edgeDragMonitor); + + Main.xdndHandler.connectObject('drag-end', () => this._clearContextualDragReveal(), this); + Main.overview.connectObject( + 'item-drag-end', + () => this._clearContextualDragReveal(), + 'item-drag-cancelled', + () => this._clearContextualDragReveal(), + 'window-drag-end', + () => this._clearContextualDragReveal(), + 'window-drag-cancelled', + () => this._clearContextualDragReveal(), + this, + ); + } + + private _teardownContextualDragReveal(): void { + this._clearContextualDragReveal(); + if (this._edgeDragMonitor) { + DND.removeDragMonitor(this._edgeDragMonitor); + this._edgeDragMonitor = null; + } + Main.xdndHandler.disconnectObject(this); + } + + private _handleContextualDragMotion(event: any): void { + const { source, x, y } = event; + let target: ManagedDockBinding | null = null; + for (const binding of this._bindings.values()) { + if ( + binding.hotArea?.canStartContextualDragReveal(x, y) && + binding.dash.canAcceptContextualEdgeDrag(source) + ) { + target = binding; + break; + } + } + + if (target === this._edgeDragTarget) return; + + this._clearContextualDragReveal(); + if (!target) return; + + this._edgeDragTarget = target; + this._edgeDragRevealId = GLib.timeout_add( + GLib.PRIORITY_DEFAULT, + CONTEXTUAL_DRAG_REVEAL_DELAY, + () => { + this._edgeDragRevealId = 0; + const currentTarget = this._edgeDragTarget; + this._edgeDragTarget = null; + if ( + currentTarget && + this._bindings.get(currentTarget.monitorIndex) === currentTarget && + currentTarget.hotArea?.canStartContextualDragReveal(x, y) && + currentTarget.dash.canAcceptContextualEdgeDrag(source) + ) { + logger.debug( + `monitor=${currentTarget.monitorIndex} contextual drag reveal after ${CONTEXTUAL_DRAG_REVEAL_DELAY}ms`, + { prefix: LOG_PREFIX }, + ); + this._revealDockFromHotArea(currentTarget); + } + return GLib.SOURCE_REMOVE; + }, + ); + } + + private _clearContextualDragReveal(): void { + if (this._edgeDragRevealId) { + GLib.source_remove(this._edgeDragRevealId); + this._edgeDragRevealId = 0; + } + this._edgeDragTarget = null; + } + + private _beginActivationCooldown(reason: string): void { + this._clearContextualDragReveal(); + this._bindings.forEach((binding) => + binding.hotArea?.beginCooldown(TRANSITION_ACTIVATION_COOLDOWN, reason), + ); + } + + private _getFullscreenMonitors(): Set { + const fullscreenMonitors = new Set(); + const monitors = Main.layoutManager.monitors ?? []; + monitors.forEach((_monitor, index) => { + if (global.display.get_monitor_in_fullscreen(index)) fullscreenMonitors.add(index); + }); + return fullscreenMonitors; + } + + private _handleFullscreenChanged(): void { + const currentFullscreenMonitors = this._getFullscreenMonitors(); + const exitedFullscreen = [...this._fullscreenMonitors].some( + (monitorIndex) => !currentFullscreenMonitors.has(monitorIndex), + ); + this._fullscreenMonitors = currentFullscreenMonitors; + if (exitedFullscreen) this._beginActivationCooldown('fullscreen-exited'); + } + private _setOverviewVisible(overviewShowing: boolean): void { if (!overviewShowing && this._pendingRebuild) { this._rebuildBindings(); diff --git a/src/dock/edgeGestureGuard.ts b/src/dock/edgeGestureGuard.ts new file mode 100644 index 0000000..723d222 --- /dev/null +++ b/src/dock/edgeGestureGuard.ts @@ -0,0 +1,34 @@ +/** + * Latches accidental edge activation while a pointer gesture is in progress. + * + * Once a pressed button or scroll is observed at the edge, suppression remains + * active until the pointer leaves. Requiring a fresh edge entry prevents a + * selection, drag, or scrollbar gesture from revealing the dock on release. + */ +export class EdgeGestureGuard { + private _suppressedUntilLeave = false; + private _cooldownUntilMs = 0; + + observeModifiers(modifiers: number, pointerButtonMask: number): boolean { + if ((modifiers & pointerButtonMask) !== 0) this._suppressedUntilLeave = true; + return this._suppressedUntilLeave; + } + + suppressUntilLeave(): boolean { + const changed = !this._suppressedUntilLeave; + this._suppressedUntilLeave = true; + return changed; + } + + resetAfterPointerLeave(): void { + this._suppressedUntilLeave = false; + } + + beginCooldown(nowMs: number, durationMs: number): void { + this._cooldownUntilMs = Math.max(this._cooldownUntilMs, nowMs + Math.max(0, durationMs)); + } + + isCoolingDown(nowMs: number): boolean { + return nowMs < this._cooldownUntilMs; + } +} diff --git a/src/dock/hotArea.ts b/src/dock/hotArea.ts index 398ca85..e8b1287 100644 --- a/src/dock/hotArea.ts +++ b/src/dock/hotArea.ts @@ -10,12 +10,19 @@ import GObject from '@girs/gobject-2.0'; import * as Layout from '@girs/gnome-shell/ui/layout'; import { logger } from '~/core/logger.ts'; +import { EdgeGestureGuard } from '~/dock/edgeGestureGuard.ts'; import type { DashBounds } from '~/shared/ui/dash.ts'; const LOG_PREFIX = 'DockHotArea'; -const HOT_AREA_TRIGGER_SPEED = 150; +const HOT_AREA_PRESSURE_THRESHOLD = 150; const HOT_AREA_TRIGGER_TIMEOUT = 550; const HOT_AREA_DEBOUNCE_TIMEOUT = 250; +const POINTER_BUTTON_MASK = + Clutter.ModifierType.BUTTON1_MASK | + Clutter.ModifierType.BUTTON2_MASK | + Clutter.ModifierType.BUTTON3_MASK | + Clutter.ModifierType.BUTTON4_MASK | + Clutter.ModifierType.BUTTON5_MASK; @GObject.registerClass({ Signals: { triggered: {} }, @@ -28,13 +35,14 @@ export class DockHotArea extends St.Widget { private _edgeArmed = true; private _grabSuppressed = false; private _pointerDwellTimeoutId = 0; + private _gestureGuard = new EdgeGestureGuard(); override _init(monitor: DashBounds) { super._init({ reactive: true, visible: true, name: 'aurora-dock-hot-area' }); this._monitor = monitor; this._pressureBarrier = new Layout.PressureBarrier( - HOT_AREA_TRIGGER_SPEED, + HOT_AREA_PRESSURE_THRESHOLD, HOT_AREA_TRIGGER_TIMEOUT, Shell.ActionMode.ALL, ); @@ -80,6 +88,7 @@ export class DockHotArea extends St.Widget { 'leave-event', () => { this._clearDebounceTimer(); + this._gestureGuard.resetAfterPointerLeave(); if (this._active && !this._grabSuppressed && !this._edgeArmed) { this._edgeArmed = true; logger.debug(`rearmed after pointer leave geometry=${this._formatGeometry()}`, { @@ -91,6 +100,15 @@ export class DockHotArea extends St.Widget { this, ); + this.connectObject( + 'scroll-event', + () => { + this._suppressActivePointerGesture('scroll'); + return Clutter.EVENT_PROPAGATE; + }, + this, + ); + global.display.connectObject( 'grab-op-begin', (_d: any, _w: any, op: Meta.GrabOp) => { @@ -121,6 +139,7 @@ export class DockHotArea extends St.Widget { this._active = enabled; this.set_reactive(enabled); if (enabled) { + this._gestureGuard.resetAfterPointerLeave(); this._edgeArmed = !this._isPointerInsideHotArea() && !this._grabSuppressed; logger.debug(`enabled=true armed=${this._edgeArmed} geometry=${this._formatGeometry()}`, { prefix: LOG_PREFIX, @@ -136,6 +155,23 @@ export class DockHotArea extends St.Widget { } } + beginCooldown(durationMs: number, reason: string): void { + this._gestureGuard.beginCooldown(this._nowMs(), durationMs); + this._clearDebounceTimer(); + logger.debug(`cooldown=${durationMs}ms reason=${reason} geometry=${this._formatGeometry()}`, { + prefix: LOG_PREFIX, + }); + } + + canStartContextualDragReveal(x: number, y: number): boolean { + return ( + this._active && + !this._grabSuppressed && + !this._gestureGuard.isCoolingDown(this._nowMs()) && + this._containsPoint(x, y) + ); + } + override destroy(): void { global.display.disconnectObject(this); this._destroyBarrier(); @@ -186,11 +222,36 @@ export class DockHotArea extends St.Widget { } private _canTrigger(): boolean { - return this._active && this._edgeArmed && !this._grabSuppressed; + const [, , modifiers] = global.get_pointer(); + if (this._gestureGuard.observeModifiers(Number(modifiers), POINTER_BUTTON_MASK)) { + this._suppressActivePointerGesture('pressed pointer button'); + return false; + } + return ( + this._active && + this._edgeArmed && + !this._grabSuppressed && + !this._gestureGuard.isCoolingDown(this._nowMs()) + ); + } + + private _suppressActivePointerGesture(reason: string): void { + const newlySuppressed = this._gestureGuard.suppressUntilLeave(); + this._edgeArmed = false; + this._clearDebounceTimer(); + if (newlySuppressed) { + logger.debug(`suppressed ${reason}; waiting for pointer leave`, { + prefix: LOG_PREFIX, + }); + } } private _isPointerInsideHotArea(): boolean { const [pointerX, pointerY] = global.get_pointer(); + return this._containsPoint(pointerX, pointerY); + } + + private _containsPoint(pointerX: number, pointerY: number): boolean { const monitor = this._monitor; const bottom = monitor.y + monitor.height; const top = bottom - Math.max(1, this.height || 1); @@ -202,6 +263,10 @@ export class DockHotArea extends St.Widget { ); } + private _nowMs(): number { + return GLib.get_monotonic_time() / 1000; + } + private _formatGeometry(): string { const monitor = this._monitor; return `${monitor.x},${monitor.y} ${monitor.width}x${monitor.height}`; diff --git a/src/shared/ui/dash.ts b/src/shared/ui/dash.ts index 2e88560..2c8ae4d 100644 --- a/src/shared/ui/dash.ts +++ b/src/shared/ui/dash.ts @@ -267,6 +267,17 @@ export class AuroraDash extends Dash { return this._monitorIndex; } + canAcceptContextualEdgeDrag(source: any): boolean { + if (source === Main.xdndHandler) return true; + + const app = Dash.getAppFromSource(source); + return ( + app !== null && + !app.is_window_backed() && + Boolean(global.settings.is_writable('favorite-apps')) + ); + } + set monitorIndex(index: number) { if (this._monitorIndex === index) return; this._monitorIndex = index; diff --git a/tests/shell/auroraDock.js b/tests/shell/auroraDock.js index 449cd62..8c7b5b3 100644 --- a/tests/shell/auroraDock.js +++ b/tests/shell/auroraDock.js @@ -716,6 +716,39 @@ export async function run() { Scripting.scriptEvent('hotAreaRearmedAfterHide'); + // A system transition must temporarily reject both ordinary edge activation + // and contextual DND. Once the cooldown expires, a recognized external drag + // may reveal the hidden dock, but only after the longer dwell. + const hotAreaBounds = binding.hotArea._monitor; + const edgeX = hotAreaBounds.x + Math.floor(hotAreaBounds.width / 2); + const edgeY = hotAreaBounds.y + hotAreaBounds.height - 1; + dock._beginActivationCooldown('shell-test'); + if (binding.hotArea.canStartContextualDragReveal(edgeX, edgeY)) + throw new Error('Hot area accepted contextual DND during the transition cooldown'); + + await Scripting.sleep(750); + if (!binding.hotArea.canStartContextualDragReveal(edgeX, edgeY)) + throw new Error('Hot area remained in transition cooldown after its deadline'); + + dock._handleContextualDragMotion({ + source: Main.xdndHandler, + x: edgeX, + y: edgeY, + }); + await Scripting.sleep(400); + if (binding.hotAreaActive || dash.visible) + throw new Error('Contextual DND revealed the dock before the prolonged dwell elapsed'); + + await Scripting.sleep(500); + if (!binding.hotAreaActive || !dash.visible) + throw new Error('Contextual DND did not reveal the dock after the prolonged dwell'); + + dock._clearHotAreaReveal(binding); + binding.hotAreaActive = false; + binding.dash.blockAutoHide(false); + binding.dash.hide(false); + binding.hotArea.setEnabled(false); + // I12 — switching focus between two fullscreen windows keeps intellihide at // BLOCKED with no enum transition, so `status-changed` never fires. Intellihide // must instead reassert BLOCKED on the focus change so the dock can react. diff --git a/tests/unit/dockEdgeGestureGuard.test.ts b/tests/unit/dockEdgeGestureGuard.test.ts new file mode 100644 index 0000000..50a531f --- /dev/null +++ b/tests/unit/dockEdgeGestureGuard.test.ts @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { EdgeGestureGuard } from '../../src/dock/edgeGestureGuard.ts'; + +const BUTTON_1 = 1 << 8; +const BUTTON_2 = 1 << 9; +const BUTTON_MASK = BUTTON_1 | BUTTON_2; + +test('dock edge gesture guard allows an idle pointer', () => { + const guard = new EdgeGestureGuard(); + + assert.equal(guard.observeModifiers(0, BUTTON_MASK), false); +}); + +test('dock edge gesture guard stays suppressed after a pressed button is released', () => { + const guard = new EdgeGestureGuard(); + + assert.equal(guard.observeModifiers(BUTTON_1, BUTTON_MASK), true); + assert.equal(guard.observeModifiers(0, BUTTON_MASK), true); +}); + +test('dock edge gesture guard requires pointer leave after scroll', () => { + const guard = new EdgeGestureGuard(); + + assert.equal(guard.suppressUntilLeave(), true); + assert.equal(guard.suppressUntilLeave(), false); + assert.equal(guard.observeModifiers(0, BUTTON_MASK), true); + + guard.resetAfterPointerLeave(); + assert.equal(guard.observeModifiers(0, BUTTON_MASK), false); +}); + +test('dock edge gesture guard observes a transition cooldown', () => { + const guard = new EdgeGestureGuard(); + + guard.beginCooldown(1_000, 700); + + assert.equal(guard.isCoolingDown(1_699), true); + assert.equal(guard.isCoolingDown(1_700), false); +}); + +test('dock edge gesture guard never shortens an active cooldown', () => { + const guard = new EdgeGestureGuard(); + + guard.beginCooldown(1_000, 700); + guard.beginCooldown(1_100, 100); + + assert.equal(guard.isCoolingDown(1_699), true); + assert.equal(guard.isCoolingDown(1_700), false); +});