Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 146 additions & 3 deletions src/dock/dock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 = {
Expand Down Expand Up @@ -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<number>();

constructor(context: ExtensionContext) {
super(context);
Expand Down Expand Up @@ -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',
Expand All @@ -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(
Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<number> {
const fullscreenMonitors = new Set<number>();
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();
Expand Down
34 changes: 34 additions & 0 deletions src/dock/edgeGestureGuard.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
71 changes: 68 additions & 3 deletions src/dock/hotArea.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {} },
Expand All @@ -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,
);
Expand Down Expand Up @@ -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()}`, {
Expand All @@ -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) => {
Expand Down Expand Up @@ -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,
Expand All @@ -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();
Expand Down Expand Up @@ -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);
Expand All @@ -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}`;
Expand Down
Loading