From 825dec1e38fad1ca593e45ee5b4ab6d2d2e6a03c Mon Sep 17 00:00:00 2001 From: Webber Date: Wed, 22 Apr 2026 19:33:35 +0200 Subject: [PATCH 1/6] feat: sticky snap (tap to latch) activation mode When the new 'stickySnap' setting is enabled, a single press of the activator (secondary mouse button or modifier key) latches snapping on for the rest of the drag. Primary mouse button release commits the snap; Escape cancels. Default is off, so existing users get no behavior change. Why a restart-grab dance is needed ---------------------------------- Muffin's MOVING grab terminates on any secondary-button event while the primary button is held (confirmed empirically, and in src/core/window.c::meta_window_handle_mouse_grab_op_event). A Cinnamon extension cannot veto that from JS because Muffin consumes the event via clutter_event_add_filter, which runs before any stage captured- event or later filter added from JS. The workaround: in grab-op-end we check whether the primary button is still physically down and, if so, re-issue a MOVING grab on the same window via global.display.begin_grab_op(win, MOVING, false, true, 1, 0, time, root_x, root_y). Passing explicit root_x/root_y (vs. the window-level begin_grab_op, which warps the cursor to the window centre) preserves the grip point between cursor and frame. Implementation -------------- - settings-schema.json: new boolean 'stickySnap', default false. - node_tree.js: SnappingOperation gains a #sticky latch plus setSticky()/isSticky accessors. onMotion latches #sticky when a normal activation is observed under sticky mode and treats the latch as a forced snappingEnabled=true thereafter. cancel() does NOT clear the latch (ownership is setSticky/destroy only), so multi-monitor cleanup in onMotion can call cancel without nuking the per-drag state. - window-snapper.js: new activateSticky()/deactivateSticky() and #forceMotionUpdate() that populate the overlay from current pointer state without needing mouse motion. - application.js: grab-op-begin distinguishes fresh drags (build snappers, install Escape filter) from restart cycles (just refresh window ref). grab-op-end schedules a GLib idle restart whenever b1Held, stickySnap, no cancel flag, and under a per-drag cap of MAX_RESTARTS_PER_DRAG=100. No time-based cooldown: Muffin emits grab-end for both the RMB press AND its release within tens of milliseconds, and a cooldown would cause one of them to fall through to finalize(), committing a snap prematurely. - Escape handler installed via Clutter.event_add_filter for the lifetime of a sticky drag. On Escape we set #dragCancelled, call deactivateSticky() on all snappers, and swallow the event. Incidental fix -------------- SnappingOperation.onMotion now calls cancel() when the pointer is outside this monitor's layout rect. Previously an off-monitor snapper silently returned notHandled() and retained stale highlights, so on LMB release every snapper's finalize() could fire, producing phantom snaps on the wrong monitor. The bug existed in hold mode too but was masked by the activation toggling highlights off; sticky mode made it visible. --- application.js | 215 ++++++++++++++++++++++++++++++++++++++----- node_tree.js | 47 +++++++++- settings-schema.json | 6 ++ window-snapper.js | 38 +++++++- 4 files changed, 279 insertions(+), 27 deletions(-) diff --git a/application.js b/application.js index fb9e995..944ba95 100644 --- a/application.js +++ b/application.js @@ -1,4 +1,5 @@ const Clutter = imports.gi.Clutter; +const GLib = imports.gi.GLib; const Main = imports.ui.main; const Meta = imports.gi.Meta; const Settings = imports.ui.settings; @@ -89,6 +90,22 @@ class Application { // the active window snappers for each monitor #windowSnappers = []; + // ----- sticky-snap / restart-grab state (valid only during an active MOVING grab) ----- + // The MetaWindow currently being dragged; kept across restart-grab cycles. + #currentDragWindow = null; + // GLib idle source id for a pending restart (0 = none). + #pendingRestartId = 0; + // Number of restarts for the current drag; safety cap against runaway loops. + #restartCount = 0; + // Clutter event filter id watching for Escape during the drag (0 = none). + #dragKeyFilterId = 0; + // Whether the current drag was cancelled via Escape (skip finalize on end). + #dragCancelled = false; + + // Hard cap on restart-grab attempts within a single drag — if we ever + // exceed this, something is wrong and we give up rather than loop. + static MAX_RESTARTS_PER_DRAG = 100; + #layoutIO; // the layout trees for each display @@ -129,6 +146,16 @@ class Application { snapper.destroy(); } this.#windowSnappers = []; + + // Tear down any drag-lifetime resources that may still be live + // (e.g. if Cinnamon disables the extension mid-drag). + if (this.#pendingRestartId) { + GLib.source_remove(this.#pendingRestartId); + this.#pendingRestartId = 0; + } + this.#removeEscapeFilter(); + this.#currentDragWindow = null; + this.#dragCancelled = false; } #loadThemeColors() { @@ -272,39 +299,185 @@ class Application { #connectWindowGrabs() { // start snapping when the user starts moving a window this.#signals.connect(global.display, 'grab-op-begin', (display, screen, window, op) => { - if (op === Meta.GrabOp.MOVING && window.window_type === Meta.WindowType.NORMAL) { - // reload styling - this.#loadThemeColors(); - const enableSnappingModifiers = mapModifierSettingToModifierType(this.#settings.settingsData.enableSnappingModifiers.value); - const enableMultiSnappingModifiers = mapModifierSettingToModifierType(this.#settings.settingsData.enableMultiSnappingModifiers.value); - const enableMergeAdjacentOnHover = this.#settings.settingsData.mergeAdjacentOnHover.value; - const mergingRadius = this.#settings.settingsData.mergingRadius.value; - const activateWithNonPrimaryButton = this.#settings.settingsData.activateWithNonPrimaryButton.value; - - // Create WindowSnapper for each monitor - const nMonitors = global.display.get_n_monitors(); - for (let i = 0; i < nMonitors; i++) { - const layout = this.#readOrCreateLayoutForDisplay(i, LayoutOf2x2); - const snapper = new WindowSnapper(i, layout, window, enableSnappingModifiers, enableMultiSnappingModifiers, enableMergeAdjacentOnHover, mergingRadius, activateWithNonPrimaryButton); - this.#windowSnappers.push(snapper); - } + if (op !== Meta.GrabOp.MOVING || window.window_type !== Meta.WindowType.NORMAL) { + return Clutter.EVENT_PROPAGATE; + } + + // If snappers already exist for this drag, this grab-begin is a + // restart we scheduled ourselves from grab-op-end. Don't rebuild + // state — just remember the (possibly refreshed) window reference. + if (this.#windowSnappers.length > 0) { + this.#currentDragWindow = window; + return Clutter.EVENT_PROPAGATE; + } + + // --- fresh drag --- + this.#loadThemeColors(); + const enableSnappingModifiers = mapModifierSettingToModifierType(this.#settings.settingsData.enableSnappingModifiers.value); + const enableMultiSnappingModifiers = mapModifierSettingToModifierType(this.#settings.settingsData.enableMultiSnappingModifiers.value); + const enableMergeAdjacentOnHover = this.#settings.settingsData.mergeAdjacentOnHover.value; + const mergingRadius = this.#settings.settingsData.mergingRadius.value; + const activateWithNonPrimaryButton = this.#settings.settingsData.activateWithNonPrimaryButton.value; + const stickySnap = this.#settings.settingsData.stickySnap && this.#settings.settingsData.stickySnap.value; + + this.#currentDragWindow = window; + this.#dragCancelled = false; + this.#restartCount = 0; + + // Create WindowSnapper for each monitor + const nMonitors = global.display.get_n_monitors(); + for (let i = 0; i < nMonitors; i++) { + const layout = this.#readOrCreateLayoutForDisplay(i, LayoutOf2x2); + const snapper = new WindowSnapper(i, layout, window, enableSnappingModifiers, enableMultiSnappingModifiers, enableMergeAdjacentOnHover, mergingRadius, activateWithNonPrimaryButton, stickySnap); + this.#windowSnappers.push(snapper); + } + + // In sticky mode, listen for Escape to cancel the snap (overlay + // goes away, LMB release commits nothing). Key events are not + // consumed by Muffin's grab-op handler, so a Clutter event filter + // installed here will see them. + if (stickySnap) { + this.#installEscapeFilter(); } + return Clutter.EVENT_PROPAGATE; }); // stop snapping when the user stops moving a window this.#signals.connect(global.display, 'grab-op-end', (display, screen, window, op) => { - if (op === Meta.GrabOp.MOVING && window.window_type === Meta.WindowType.NORMAL) { - // Finalize and destroy all window snappers - for (let snapper of this.#windowSnappers) { + if (op !== Meta.GrabOp.MOVING || window.window_type !== Meta.WindowType.NORMAL) { + return Clutter.EVENT_PROPAGATE; + } + if (this.#windowSnappers.length === 0) { + return Clutter.EVENT_PROPAGATE; + } + + const stickySnap = this.#settings.settingsData.stickySnap && this.#settings.settingsData.stickySnap.value; + const activateWithNonPrimaryButton = this.#settings.settingsData.activateWithNonPrimaryButton.value; + + // Decide whether this grab-end is Muffin tearing down the drag + // on us (e.g. after an RMB press or release) while the user is + // still holding the primary mouse button. If so, restart the + // MOVING grab on the same window and keep our snapper state. + // + // There is deliberately NO time-based cooldown here: Muffin + // tears the drag down once for the RMB press AND once for the + // release, both arriving within a few tens of ms. Gating on + // time would cause one of them to fall through to finalize(), + // which would fire the snap prematurely. Instead we cap total + // restarts per drag as a safety net against runaway loops. + const [px, py, state] = global.get_pointer(); + const b1Held = !!(state & Clutter.ModifierType.BUTTON1_MASK); + + if (stickySnap && !this.#dragCancelled && b1Held && this.#currentDragWindow && + this.#restartCount < Application.MAX_RESTARTS_PER_DRAG) { + this.#restartCount += 1; + const win = this.#currentDragWindow; + this.#pendingRestartId = GLib.idle_add(GLib.PRIORITY_HIGH_IDLE, () => { + this.#pendingRestartId = 0; + this.#runRestart(win, activateWithNonPrimaryButton); + return GLib.SOURCE_REMOVE; + }); + // Keep snappers alive — the restart will be a new grab-begin. + return Clutter.EVENT_PROPAGATE; + } + if (this.#restartCount >= Application.MAX_RESTARTS_PER_DRAG) { + global.logWarning(`fancytiles: hit MAX_RESTARTS_PER_DRAG (${Application.MAX_RESTARTS_PER_DRAG}), giving up`); + } + + // Real end of drag: finalize (unless cancelled) and tear down. + for (let snapper of this.#windowSnappers) { + if (!this.#dragCancelled) { snapper.finalize(); - snapper.destroy(); } - this.#windowSnappers = []; + snapper.destroy(); } + this.#windowSnappers = []; + this.#currentDragWindow = null; + this.#dragCancelled = false; + this.#removeEscapeFilter(); return Clutter.EVENT_PROPAGATE; }); } + + #runRestart(win, activateWithNonPrimaryButton) { + // User may have released LMB in the microseconds since schedule. + const [px, py, st] = global.get_pointer(); + if (!(st & Clutter.ModifierType.BUTTON1_MASK) || !win) { + // Let the pending grab-end finalization happen naturally next tick. + for (let snapper of this.#windowSnappers) { + if (!this.#dragCancelled) snapper.finalize(); + snapper.destroy(); + } + this.#windowSnappers = []; + this.#currentDragWindow = null; + this.#dragCancelled = false; + this.#removeEscapeFilter(); + return; + } + try { + const time = global.get_current_time(); + // Use the display-level API with explicit anchor coordinates so + // the grip point on the window's frame is preserved (the + // window-level API warps the cursor to the window center). + // + // meta_display_begin_grab_op(display, window, op, + // pointer_already_grabbed, frame_action, button, modmask, + // timestamp, root_x, root_y) + global.display.begin_grab_op( + win, + Meta.GrabOp.MOVING, + false, // pointer_already_grabbed + true, // frame_action + 1, // button (LMB) + 0, // modmask + time, + px, + py + ); + // The RMB tap that caused Muffin to tear down the drag is our + // cue to latch sticky — but only when the user has opted in to + // RMB-activation. Modifier-key activation latches itself on the + // next onMotion once sticky mode is on. + if (activateWithNonPrimaryButton) { + for (let snapper of this.#windowSnappers) { + snapper.activateSticky(); + } + } + } catch (e) { + global.logError(`fancytiles: restart grab failed: ${e}`); + for (let snapper of this.#windowSnappers) { + snapper.destroy(); + } + this.#windowSnappers = []; + this.#currentDragWindow = null; + this.#dragCancelled = false; + this.#removeEscapeFilter(); + } + } + + #installEscapeFilter() { + if (this.#dragKeyFilterId) return; + this.#dragKeyFilterId = Clutter.event_add_filter(null, (event) => { + if (event.type() === Clutter.EventType.KEY_PRESS && + event.get_key_symbol() === Clutter.KEY_Escape) { + this.#dragCancelled = true; + for (let snapper of this.#windowSnappers) { + snapper.deactivateSticky(); + } + // Swallow the Escape so it doesn't leak to focused windows. + return Clutter.EVENT_STOP; + } + return Clutter.EVENT_PROPAGATE; + }); + } + + #removeEscapeFilter() { + if (!this.#dragKeyFilterId) return; + try { Clutter.event_remove_filter(this.#dragKeyFilterId); } + catch (e) { /* ignore */ } + this.#dragKeyFilterId = 0; + } } module.exports = { Application, LayoutOf2x2 }; diff --git a/node_tree.js b/node_tree.js index 2395a6b..d88ce28 100644 --- a/node_tree.js +++ b/node_tree.js @@ -817,32 +817,66 @@ class SnappingOperation extends LayoutOperation { #enableAdjacentMerging; #mergingRadius; #activateWithNonPrimaryButton; + #stickySnap; + #sticky = false; #previousHighlightedNodes = null; #previousInsetNodeRect = null; - constructor(tree, enableSnappingModifiers, enableMultiSnappingModifiers, enableAdjacentMerging, mergingRadius, activateWithNonPrimaryButton) { + constructor(tree, enableSnappingModifiers, enableMultiSnappingModifiers, enableAdjacentMerging, mergingRadius, activateWithNonPrimaryButton, stickySnap) { super(tree); this.#enableSnappingModifiers = enableSnappingModifiers; this.#enableMultiSnappingModifiers = enableMultiSnappingModifiers; this.#enableAdjacentMerging = enableAdjacentMerging; this.#mergingRadius = mergingRadius; this.#activateWithNonPrimaryButton = activateWithNonPrimaryButton; + this.#stickySnap = !!stickySnap; } - onMotion(x, y, state) { - let snappingEnabled; + // Whether sticky snapping is currently latched on for this drag. + // Set externally (from the restart-grab path in Application) or + // automatically latched inside onMotion on first normal activation. + get isSticky() { return this.#sticky; } + + setSticky(value) { + if (this.#sticky === value) return; + this.#sticky = !!value; + if (!this.#sticky) { + // Turning sticky off — clear destinations and hide overlay. + this.cancel(); + } + } + onMotion(x, y, state) { const Clutter = imports.gi.Clutter; const secondaryButtonPressed = (state & Clutter.ModifierType.BUTTON3_MASK); const modifierPressed = this.#enableSnappingModifiers.some((e) => (state & e)); const noModifierRequired = this.#enableSnappingModifiers.length == 0 && !this.#activateWithNonPrimaryButton; - snappingEnabled = (this.#activateWithNonPrimaryButton && secondaryButtonPressed) || modifierPressed || noModifierRequired; + const normalEnabled = (this.#activateWithNonPrimaryButton && secondaryButtonPressed) || modifierPressed || noModifierRequired; + + // In sticky mode, latch on as soon as a normal activation is seen, + // and stay latched regardless of current button/modifier state. + if (this.#stickySnap && normalEnabled) { + this.#sticky = true; + } + const snappingEnabled = this.#sticky || normalEnabled; if (!snappingEnabled) { return this.cancel(); } + // Multi-monitor: each monitor has its own SnappingOperation, all of + // which receive the same global pointer coordinates. A snapper whose + // monitor does NOT contain the cursor must not hold stale highlights + // — otherwise, on LMB release the stale destination would cause a + // spurious snap on that other monitor. Note: this calls cancel() but + // cancel() no longer clears the sticky latch, so once the cursor + // comes back on-monitor snapping will resume as expected. + const r = this.tree.rect; + if (!r || x < r.x || x >= r.x + r.width || y < r.y || y >= r.y + r.height) { + return this.cancel(); + } + let node = this.tree.findNodeAtPosition(x, y); if(!node){ return OperationResult.notHandled(); @@ -968,6 +1002,11 @@ class SnappingOperation extends LayoutOperation { this.tree.insetNode = null; this.#previousHighlightedNodes = null; this.#previousInsetNodeRect = null; + // NOTE: cancel() deliberately does NOT clear the sticky latch. + // Sticky is owned exclusively by setSticky() (external) and by + // destroy(). That lets the multi-monitor off-monitor cleanup in + // onMotion call cancel() without killing the sticky state that + // belongs to the drag as a whole. if (this.showRegions) { this.showRegions = false; diff --git a/settings-schema.json b/settings-schema.json index 5e97b18..cc1356a 100644 --- a/settings-schema.json +++ b/settings-schema.json @@ -22,6 +22,12 @@ "description": "Activate snapping with secondary mouse button", "tooltip": "When enabled, holding the secondary mouse button while dragging activates snapping mode. Snapping mode is always activated when both the key modifier is set to (none) and activating with the secondary mouse button is disabled.", "default": false + }, + "stickySnap": { + "type": "switch", + "description": "Sticky snapping (tap to latch)", + "tooltip": "When enabled, a single press of the activator (secondary mouse button or modifier key) latches snapping on for the rest of the drag \u2014 you do not need to keep holding it. Release the primary mouse button to commit the snap, or press Escape to cancel. When disabled, you must hold the activator throughout the drag (classic behavior).", + "default": false }, "enableMultiSnappingModifiers": { "type": "combobox", diff --git a/window-snapper.js b/window-snapper.js index a3878af..d8b8cee 100644 --- a/window-snapper.js +++ b/window-snapper.js @@ -42,9 +42,12 @@ class WindowSnapper { // whether to use the non-primary button to activate snapping #activateWithNonPrimaryButton; + // whether sticky snapping (tap-to-latch) is enabled + #stickySnap; + #signals = new SignalManager.SignalManager(null); - constructor(displayIdx, layout, window, enableSnappingModifiers, enableMultiSnappingModifiers, enableAdjacentMerging, mergingRadius, activateWithNonPrimaryButton) { + constructor(displayIdx, layout, window, enableSnappingModifiers, enableMultiSnappingModifiers, enableAdjacentMerging, mergingRadius, activateWithNonPrimaryButton, stickySnap) { // the layout to use for the snapping operation this.#layout = layout; @@ -65,6 +68,9 @@ class WindowSnapper { // whether to use the non-primary button to activate snapping this.#activateWithNonPrimaryButton = activateWithNonPrimaryButton; + // whether sticky snapping is enabled + this.#stickySnap = !!stickySnap; + // get the size of the display let workArea = getUsableScreenArea(displayIdx); @@ -88,11 +94,39 @@ class WindowSnapper { // ensure the layout is correct for the snap area this.#layout.calculateRects(workArea.x, workArea.y, workArea.width, workArea.height); - this.#snappingOperation = new SnappingOperation(this.#layout, this.#enableSnappingModifiers, this.#enableMultiSnappingModifiers, this.#enableAdjacentMerging, this.#mergingRadius, this.#activateWithNonPrimaryButton); + this.#snappingOperation = new SnappingOperation(this.#layout, this.#enableSnappingModifiers, this.#enableMultiSnappingModifiers, this.#enableAdjacentMerging, this.#mergingRadius, this.#activateWithNonPrimaryButton, this.#stickySnap); this.#signals.connect(this.#window, 'position-changed', this.#onWindowMoved.bind(this)); } + // Latch sticky snapping on immediately (e.g. after a restart-grab triggered + // by an RMB tap) and populate the overlay from the current pointer position. + activateSticky() { + if (!this.#snappingOperation) return; + this.#snappingOperation.setSticky(true); + this.#forceMotionUpdate(); + } + + // Release the sticky latch (e.g. user pressed Escape). Clears the overlay. + deactivateSticky() { + if (!this.#snappingOperation) return; + this.#snappingOperation.setSticky(false); + if (this.#container) this.#container.hide(); + if (this.#drawingArea) this.#drawingArea.queue_repaint(); + } + + #forceMotionUpdate() { + if (!this.#snappingOperation) return; + const [x, y, state] = global.get_pointer(); + const result = this.#snappingOperation.onMotion(x, y, state); + if (result && result.shouldRedraw) { + if (this.#snappingOperation.showRegions) { + this.#container.show(); + } + this.#drawingArea.queue_repaint(); + } + } + // snap if the user wants to finalize() { const snappingRect = this.#snappingOperation.currentSnapToRect(); From 1718cd12ba4633b6efc3fe43488046e2ece31feb Mon Sep 17 00:00:00 2001 From: Webber Date: Wed, 22 Apr 2026 19:52:24 +0200 Subject: [PATCH 2/6] refactor: extract DragSession to isolate per-drag lifecycle Application had accumulated five per-drag fields (#currentDragWindow, #pendingRestartId, #restartCount, #dragKeyFilterId, #dragCancelled), three helper methods (#runRestart, #installEscapeFilter, #removeEscapeFilter), and a large amount of state-mutation logic spread across its grab-op-begin / grab-op-end handlers. Together those formed an implicit state machine for keeping the drag alive across Muffin's grab tear-downs, plus Escape cancellation, plus sticky latch activation. This change lifts that state machine into a dedicated DragSession class in drag-session.js, with a small four-method public API: new DragSession({ window, layoutFor, options }) session.onGrabRestart(window) // new grab-begin = our own restart session.tryRestart() // grab-end -> restart or finish? session.finish() // commit (or skip) + tear down Application becomes a thin dispatcher with one drag-related field (#dragSession) and two short handlers. Settings are snapshotted at session construction so mid-drag changes can't corrupt in-flight state. No behavior change. --- application.js | 248 ++++++++-------------------------------------- drag-session.js | 180 +++++++++++++++++++++++++++++++++ node_tree.js | 8 +- window-snapper.js | 50 ++++------ 4 files changed, 244 insertions(+), 242 deletions(-) create mode 100644 drag-session.js diff --git a/application.js b/application.js index 944ba95..a968e7e 100644 --- a/application.js +++ b/application.js @@ -1,5 +1,4 @@ const Clutter = imports.gi.Clutter; -const GLib = imports.gi.GLib; const Main = imports.ui.main; const Meta = imports.gi.Meta; const Settings = imports.ui.settings; @@ -7,10 +6,10 @@ const SignalManager = imports.misc.signalManager; const St = imports.gi.St; const { DefaultColors } = require('./drawing'); +const { DragSession } = require('./drag-session'); const { GridEditor } = require('./grid-editor'); const { LayoutIO } = require('./io-utils'); const { LayoutNode } = require('./node_tree'); -const { WindowSnapper } = require('./window-snapper'); // a hardcoded layout for 2x2 layout as default const LayoutOf2x2 = new LayoutNode(0, [ @@ -87,24 +86,8 @@ class Application { // the active grid editor #gridEditor = null; - // the active window snappers for each monitor - #windowSnappers = []; - - // ----- sticky-snap / restart-grab state (valid only during an active MOVING grab) ----- - // The MetaWindow currently being dragged; kept across restart-grab cycles. - #currentDragWindow = null; - // GLib idle source id for a pending restart (0 = none). - #pendingRestartId = 0; - // Number of restarts for the current drag; safety cap against runaway loops. - #restartCount = 0; - // Clutter event filter id watching for Escape during the drag (0 = none). - #dragKeyFilterId = 0; - // Whether the current drag was cancelled via Escape (skip finalize on end). - #dragCancelled = false; - - // Hard cap on restart-grab attempts within a single drag — if we ever - // exceed this, something is wrong and we give up rather than loop. - static MAX_RESTARTS_PER_DRAG = 100; + // the active drag session, if any (null between drags) + #dragSession = null; #layoutIO; @@ -141,21 +124,10 @@ class Application { this.#gridEditor = null; } - // Destroy all window snappers - for (let snapper of this.#windowSnappers) { - snapper.destroy(); + if (this.#dragSession) { + this.#dragSession.finish(); + this.#dragSession = null; } - this.#windowSnappers = []; - - // Tear down any drag-lifetime resources that may still be live - // (e.g. if Cinnamon disables the extension mid-drag). - if (this.#pendingRestartId) { - GLib.source_remove(this.#pendingRestartId); - this.#pendingRestartId = 0; - } - this.#removeEscapeFilter(); - this.#currentDragWindow = null; - this.#dragCancelled = false; } #loadThemeColors() { @@ -297,186 +269,50 @@ class Application { } #connectWindowGrabs() { - // start snapping when the user starts moving a window - this.#signals.connect(global.display, 'grab-op-begin', (display, screen, window, op) => { - if (op !== Meta.GrabOp.MOVING || window.window_type !== Meta.WindowType.NORMAL) { - return Clutter.EVENT_PROPAGATE; - } - - // If snappers already exist for this drag, this grab-begin is a - // restart we scheduled ourselves from grab-op-end. Don't rebuild - // state — just remember the (possibly refreshed) window reference. - if (this.#windowSnappers.length > 0) { - this.#currentDragWindow = window; - return Clutter.EVENT_PROPAGATE; - } - - // --- fresh drag --- - this.#loadThemeColors(); - const enableSnappingModifiers = mapModifierSettingToModifierType(this.#settings.settingsData.enableSnappingModifiers.value); - const enableMultiSnappingModifiers = mapModifierSettingToModifierType(this.#settings.settingsData.enableMultiSnappingModifiers.value); - const enableMergeAdjacentOnHover = this.#settings.settingsData.mergeAdjacentOnHover.value; - const mergingRadius = this.#settings.settingsData.mergingRadius.value; - const activateWithNonPrimaryButton = this.#settings.settingsData.activateWithNonPrimaryButton.value; - const stickySnap = this.#settings.settingsData.stickySnap && this.#settings.settingsData.stickySnap.value; - - this.#currentDragWindow = window; - this.#dragCancelled = false; - this.#restartCount = 0; - - // Create WindowSnapper for each monitor - const nMonitors = global.display.get_n_monitors(); - for (let i = 0; i < nMonitors; i++) { - const layout = this.#readOrCreateLayoutForDisplay(i, LayoutOf2x2); - const snapper = new WindowSnapper(i, layout, window, enableSnappingModifiers, enableMultiSnappingModifiers, enableMergeAdjacentOnHover, mergingRadius, activateWithNonPrimaryButton, stickySnap); - this.#windowSnappers.push(snapper); - } - - // In sticky mode, listen for Escape to cancel the snap (overlay - // goes away, LMB release commits nothing). Key events are not - // consumed by Muffin's grab-op handler, so a Clutter event filter - // installed here will see them. - if (stickySnap) { - this.#installEscapeFilter(); - } - - return Clutter.EVENT_PROPAGATE; - }); - - // stop snapping when the user stops moving a window - this.#signals.connect(global.display, 'grab-op-end', (display, screen, window, op) => { - if (op !== Meta.GrabOp.MOVING || window.window_type !== Meta.WindowType.NORMAL) { - return Clutter.EVENT_PROPAGATE; - } - if (this.#windowSnappers.length === 0) { - return Clutter.EVENT_PROPAGATE; - } - - const stickySnap = this.#settings.settingsData.stickySnap && this.#settings.settingsData.stickySnap.value; - const activateWithNonPrimaryButton = this.#settings.settingsData.activateWithNonPrimaryButton.value; - - // Decide whether this grab-end is Muffin tearing down the drag - // on us (e.g. after an RMB press or release) while the user is - // still holding the primary mouse button. If so, restart the - // MOVING grab on the same window and keep our snapper state. - // - // There is deliberately NO time-based cooldown here: Muffin - // tears the drag down once for the RMB press AND once for the - // release, both arriving within a few tens of ms. Gating on - // time would cause one of them to fall through to finalize(), - // which would fire the snap prematurely. Instead we cap total - // restarts per drag as a safety net against runaway loops. - const [px, py, state] = global.get_pointer(); - const b1Held = !!(state & Clutter.ModifierType.BUTTON1_MASK); - - if (stickySnap && !this.#dragCancelled && b1Held && this.#currentDragWindow && - this.#restartCount < Application.MAX_RESTARTS_PER_DRAG) { - this.#restartCount += 1; - const win = this.#currentDragWindow; - this.#pendingRestartId = GLib.idle_add(GLib.PRIORITY_HIGH_IDLE, () => { - this.#pendingRestartId = 0; - this.#runRestart(win, activateWithNonPrimaryButton); - return GLib.SOURCE_REMOVE; - }); - // Keep snappers alive — the restart will be a new grab-begin. - return Clutter.EVENT_PROPAGATE; - } - if (this.#restartCount >= Application.MAX_RESTARTS_PER_DRAG) { - global.logWarning(`fancytiles: hit MAX_RESTARTS_PER_DRAG (${Application.MAX_RESTARTS_PER_DRAG}), giving up`); - } - - // Real end of drag: finalize (unless cancelled) and tear down. - for (let snapper of this.#windowSnappers) { - if (!this.#dragCancelled) { - snapper.finalize(); - } - snapper.destroy(); - } - this.#windowSnappers = []; - this.#currentDragWindow = null; - this.#dragCancelled = false; - this.#removeEscapeFilter(); - return Clutter.EVENT_PROPAGATE; - }); + this.#signals.connect(global.display, 'grab-op-begin', + (display, screen, window, op) => this.#onGrabBegin(window, op)); + this.#signals.connect(global.display, 'grab-op-end', + (display, screen, window, op) => this.#onGrabEnd(window, op)); } - #runRestart(win, activateWithNonPrimaryButton) { - // User may have released LMB in the microseconds since schedule. - const [px, py, st] = global.get_pointer(); - if (!(st & Clutter.ModifierType.BUTTON1_MASK) || !win) { - // Let the pending grab-end finalization happen naturally next tick. - for (let snapper of this.#windowSnappers) { - if (!this.#dragCancelled) snapper.finalize(); - snapper.destroy(); - } - this.#windowSnappers = []; - this.#currentDragWindow = null; - this.#dragCancelled = false; - this.#removeEscapeFilter(); + #onGrabBegin(window, op) { + if (op !== Meta.GrabOp.MOVING || window.window_type !== Meta.WindowType.NORMAL) return; + + // A grab-begin while a session is alive is our own restart landing. + if (this.#dragSession) { + this.#dragSession.onGrabRestart(window); return; } - try { - const time = global.get_current_time(); - // Use the display-level API with explicit anchor coordinates so - // the grip point on the window's frame is preserved (the - // window-level API warps the cursor to the window center). - // - // meta_display_begin_grab_op(display, window, op, - // pointer_already_grabbed, frame_action, button, modmask, - // timestamp, root_x, root_y) - global.display.begin_grab_op( - win, - Meta.GrabOp.MOVING, - false, // pointer_already_grabbed - true, // frame_action - 1, // button (LMB) - 0, // modmask - time, - px, - py - ); - // The RMB tap that caused Muffin to tear down the drag is our - // cue to latch sticky — but only when the user has opted in to - // RMB-activation. Modifier-key activation latches itself on the - // next onMotion once sticky mode is on. - if (activateWithNonPrimaryButton) { - for (let snapper of this.#windowSnappers) { - snapper.activateSticky(); - } - } - } catch (e) { - global.logError(`fancytiles: restart grab failed: ${e}`); - for (let snapper of this.#windowSnappers) { - snapper.destroy(); - } - this.#windowSnappers = []; - this.#currentDragWindow = null; - this.#dragCancelled = false; - this.#removeEscapeFilter(); - } - } - #installEscapeFilter() { - if (this.#dragKeyFilterId) return; - this.#dragKeyFilterId = Clutter.event_add_filter(null, (event) => { - if (event.type() === Clutter.EventType.KEY_PRESS && - event.get_key_symbol() === Clutter.KEY_Escape) { - this.#dragCancelled = true; - for (let snapper of this.#windowSnappers) { - snapper.deactivateSticky(); - } - // Swallow the Escape so it doesn't leak to focused windows. - return Clutter.EVENT_STOP; - } - return Clutter.EVENT_PROPAGATE; + // Fresh drag. + this.#loadThemeColors(); + this.#dragSession = new DragSession({ + window, + layoutFor: (i) => this.#readOrCreateLayoutForDisplay(i, LayoutOf2x2), + options: this.#snapshotDragOptions(), }); } - #removeEscapeFilter() { - if (!this.#dragKeyFilterId) return; - try { Clutter.event_remove_filter(this.#dragKeyFilterId); } - catch (e) { /* ignore */ } - this.#dragKeyFilterId = 0; + #onGrabEnd(window, op) { + if (!this.#dragSession) return; + if (op !== Meta.GrabOp.MOVING || window.window_type !== Meta.WindowType.NORMAL) return; + + if (this.#dragSession.tryRestart()) return; // session continues + + this.#dragSession.finish(); + this.#dragSession = null; + } + + #snapshotDragOptions() { + const s = this.#settings.settingsData; + return { + enableSnappingModifiers: mapModifierSettingToModifierType(s.enableSnappingModifiers.value), + enableMultiSnappingModifiers: mapModifierSettingToModifierType(s.enableMultiSnappingModifiers.value), + mergeAdjacentOnHover: s.mergeAdjacentOnHover.value, + mergingRadius: s.mergingRadius.value, + activateWithNonPrimaryButton: s.activateWithNonPrimaryButton.value, + stickySnap: s.stickySnap.value, + }; } } diff --git a/drag-session.js b/drag-session.js new file mode 100644 index 0000000..4f8f89c --- /dev/null +++ b/drag-session.js @@ -0,0 +1,180 @@ +const Clutter = imports.gi.Clutter; +const GLib = imports.gi.GLib; +const Meta = imports.gi.Meta; + +const { WindowSnapper } = require('./window-snapper'); + +// Safety cap against runaway restart loops. +const MAX_RESTARTS = 100; + +/** + * Owns all per-drag state and lifecycle for one MOVING grab on one window. + * + * Muffin terminates a MOVING grab on any secondary mouse button event + * while LMB is held, and a Cinnamon extension cannot veto that from JS. + * Workaround: on every grab-op-end we re-issue the MOVING grab on the + * same window whenever LMB is still physically down, preserving the + * grip point via explicit anchor coordinates. The effect is an + * uninterrupted drag. + * + * Sticky mode layers on: once activated during the drag, the snap + * overlay stays visible until LMB release commits or Escape cancels. + */ +class DragSession { + #window; + #options; + #layoutFor; // (monitorIdx) -> LayoutNode + #snappers = []; + + #cancelled = false; + #restarts = 0; + #pendingRestartId = 0; + #escapeFilterId = 0; + + /** + * @param {Meta.Window} params.window window being dragged + * @param {Function} params.layoutFor (monitorIdx) => LayoutNode + * @param {object} params.options resolved settings snapshot + * + * Settings are snapshot once here so a mid-drag setting change can't + * corrupt in-flight state. + */ + constructor({ window, layoutFor, options }) { + this.#window = window; + this.#layoutFor = layoutFor; + this.#options = options; + + const nMonitors = global.display.get_n_monitors(); + for (let i = 0; i < nMonitors; i++) { + this.#snappers.push(this.#buildSnapper(i)); + } + + if (options.stickySnap) this.#installEscapeFilter(); + } + + // A grab-begin fired while we're alive — that's our own restart landing. + onGrabRestart(window) { + this.#window = window; + } + + // Called on grab-op-end. Returns true if we've scheduled a restart + // (caller keeps the session alive); false if the drag is really over. + tryRestart() { + if (!this.#options.stickySnap || this.#cancelled) return false; + if (this.#restarts >= MAX_RESTARTS) { + global.logWarning(`fancytiles: hit MAX_RESTARTS (${MAX_RESTARTS}), giving up`); + return false; + } + const [anchorX, anchorY, state] = global.get_pointer(); + if (!(state & Clutter.ModifierType.BUTTON1_MASK)) return false; + + this.#scheduleRestart(anchorX, anchorY); + return true; + } + + // Commit the snap (or skip if cancelled) and release all resources. + finish() { + if (this.#pendingRestartId) { + GLib.source_remove(this.#pendingRestartId); + this.#pendingRestartId = 0; + } + this.#removeEscapeFilter(); + + for (const snapper of this.#snappers) { + if (!this.#cancelled) snapper.finalize(); + snapper.destroy(); + } + this.#snappers = []; + this.#window = null; + } + + // ---------------- private ---------------- + + #buildSnapper(monitorIdx) { + const o = this.#options; + return new WindowSnapper( + monitorIdx, + this.#layoutFor(monitorIdx), + this.#window, + o.enableSnappingModifiers, + o.enableMultiSnappingModifiers, + o.mergeAdjacentOnHover, + o.mergingRadius, + o.activateWithNonPrimaryButton, + o.stickySnap, + ); + } + + #scheduleRestart(anchorX, anchorY) { + this.#restarts += 1; + this.#pendingRestartId = GLib.idle_add(GLib.PRIORITY_HIGH_IDLE, () => { + this.#pendingRestartId = 0; + this.#reissueGrab(anchorX, anchorY); + return GLib.SOURCE_REMOVE; + }); + } + + #reissueGrab(anchorX, anchorY) { + // LMB may have been released during the idle race. + const [, , state] = global.get_pointer(); + if (!(state & Clutter.ModifierType.BUTTON1_MASK) || !this.#window) { + this.finish(); + return; + } + + try { + // Display-level begin_grab_op with explicit anchor coords + // preserves the grip point (the window-level API re-anchors + // at the window centre). + global.display.begin_grab_op( + this.#window, + Meta.GrabOp.MOVING, + /* pointer_already_grabbed */ false, + /* frame_action */ true, + /* button */ 1, + /* modmask */ 0, + global.get_current_time(), + anchorX, + anchorY, + ); + } catch (e) { + global.logError(`fancytiles: restart grab failed: ${e}`); + this.#cancelled = true; + this.finish(); + return; + } + + // The secondary-button tap that caused Muffin's tear-down is our + // cue to latch sticky, but only when the user has opted in to + // RMB-activation. Modifier-key activation self-latches on the + // next onMotion. + if (this.#options.activateWithNonPrimaryButton) { + for (const snapper of this.#snappers) snapper.activateSticky(); + } + } + + #installEscapeFilter() { + // Muffin does not consume key events during a MOVING grab, so a + // Clutter filter sees them. + this.#escapeFilterId = Clutter.event_add_filter(null, (event) => { + if (event.type() !== Clutter.EventType.KEY_PRESS) return Clutter.EVENT_PROPAGATE; + if (event.get_key_symbol() !== Clutter.KEY_Escape) return Clutter.EVENT_PROPAGATE; + this.#onEscape(); + return Clutter.EVENT_STOP; + }); + } + + #removeEscapeFilter() { + if (!this.#escapeFilterId) return; + try { Clutter.event_remove_filter(this.#escapeFilterId); } + catch (_) { /* ignore */ } + this.#escapeFilterId = 0; + } + + #onEscape() { + this.#cancelled = true; + for (const snapper of this.#snappers) snapper.deactivateSticky(); + } +} + +module.exports = { DragSession }; diff --git a/node_tree.js b/node_tree.js index d88ce28..ec2d8db 100644 --- a/node_tree.js +++ b/node_tree.js @@ -832,11 +832,9 @@ class SnappingOperation extends LayoutOperation { this.#stickySnap = !!stickySnap; } - // Whether sticky snapping is currently latched on for this drag. - // Set externally (from the restart-grab path in Application) or - // automatically latched inside onMotion on first normal activation. - get isSticky() { return this.#sticky; } - + // Set the sticky-snap latch. Called externally to activate (after an + // RMB-tap restart) or to cancel (on Escape). onMotion also latches it + // automatically on first normal activation under sticky mode. setSticky(value) { if (this.#sticky === value) return; this.#sticky = !!value; diff --git a/window-snapper.js b/window-snapper.js index d8b8cee..cb36191 100644 --- a/window-snapper.js +++ b/window-snapper.js @@ -99,32 +99,21 @@ class WindowSnapper { this.#signals.connect(this.#window, 'position-changed', this.#onWindowMoved.bind(this)); } - // Latch sticky snapping on immediately (e.g. after a restart-grab triggered - // by an RMB tap) and populate the overlay from the current pointer position. + // Latch sticky snapping on immediately (e.g. after a restart-grab + // triggered by an RMB tap) and populate the overlay from the current + // pointer position without requiring mouse motion. activateSticky() { if (!this.#snappingOperation) return; this.#snappingOperation.setSticky(true); - this.#forceMotionUpdate(); + this.#onWindowMoved(); } - // Release the sticky latch (e.g. user pressed Escape). Clears the overlay. + // Release the sticky latch (e.g. the user pressed Escape). deactivateSticky() { if (!this.#snappingOperation) return; this.#snappingOperation.setSticky(false); - if (this.#container) this.#container.hide(); - if (this.#drawingArea) this.#drawingArea.queue_repaint(); - } - - #forceMotionUpdate() { - if (!this.#snappingOperation) return; - const [x, y, state] = global.get_pointer(); - const result = this.#snappingOperation.onMotion(x, y, state); - if (result && result.shouldRedraw) { - if (this.#snappingOperation.showRegions) { - this.#container.show(); - } - this.#drawingArea.queue_repaint(); - } + this.#container.hide(); + this.#drawingArea.queue_repaint(); } // snap if the user wants to @@ -174,20 +163,19 @@ class WindowSnapper { cr.$dispose(); } - #onWindowMoved(actor, event) { - if (!this.#snappingOperation) { - return; - } - - let [x, y, state] = global.get_pointer(); - - let result = this.#snappingOperation.onMotion(x, y, state); - if (result && result.shouldRedraw) { - if (this.#snappingOperation.showRegions) { - this.#container.show(); - } - this.#drawingArea.queue_repaint(); + // Run an onMotion pass for the current pointer position, show the + // overlay if needed, and repaint. Connected to position-changed on + // the window, and also invoked directly from activateSticky() so we + // can render without requiring mouse motion. + #onWindowMoved() { + if (!this.#snappingOperation) return; + const [x, y, state] = global.get_pointer(); + const result = this.#snappingOperation.onMotion(x, y, state); + if (!(result && result.shouldRedraw)) return; + if (this.#snappingOperation.showRegions) { + this.#container.show(); } + this.#drawingArea.queue_repaint(); } } From 640a43f8830ddc09b2c319d486d4340df459fd32 Mon Sep 17 00:00:00 2001 From: Webber Date: Wed, 22 Apr 2026 20:02:33 +0200 Subject: [PATCH 3/6] fix(sticky): activate on RMB-press-without-motion Previously, zones would only appear after the user released the secondary mouse button (Muffin tears down the MOVING grab on RMB release on this build, not on press). If the user pressed and held RMB without moving a pixel, there was no trigger for activateSticky: the grab-restart dance only fires on grab-op-end, and position-changed only fires on actual window motion. Button events are consumed by Muffin's own event filter during a MOVING grab, so a Clutter event filter cannot observe the press directly. Fix: while a sticky drag is waiting for its first activation, poll the pointer at 60 Hz via GLib.timeout_add and re-run onMotion on every snapper. The instant RMB (or the configured modifier) is pressed, onMotion sees the state bit and latches sticky. The poller self-terminates as soon as any snapper reports isSticky, so there's no ongoing cost once the overlay is up. WindowSnapper exposes a small public surface to support this: - isSticky (delegates to SnappingOperation.isSticky) - refreshFromPointer() (the motion-re-eval logic, moved out of #onWindowMoved which is now a one-liner delegate) SnappingOperation.isSticky was re-added after a previous cleanup removed it as unused; DragSession's poll-stop condition needs it. --- drag-session.js | 29 ++++++++++++++++++++++++++++- node_tree.js | 2 ++ window-snapper.js | 36 +++++++++++++++++++++++------------- 3 files changed, 53 insertions(+), 14 deletions(-) diff --git a/drag-session.js b/drag-session.js index 4f8f89c..80e905f 100644 --- a/drag-session.js +++ b/drag-session.js @@ -30,6 +30,7 @@ class DragSession { #restarts = 0; #pendingRestartId = 0; #escapeFilterId = 0; + #activationPollerId = 0; /** * @param {Meta.Window} params.window window being dragged @@ -49,7 +50,10 @@ class DragSession { this.#snappers.push(this.#buildSnapper(i)); } - if (options.stickySnap) this.#installEscapeFilter(); + if (options.stickySnap) { + this.#installEscapeFilter(); + this.#startActivationPoller(); + } } // A grab-begin fired while we're alive — that's our own restart landing. @@ -78,6 +82,7 @@ class DragSession { GLib.source_remove(this.#pendingRestartId); this.#pendingRestartId = 0; } + this.#stopActivationPoller(); this.#removeEscapeFilter(); for (const snapper of this.#snappers) { @@ -175,6 +180,28 @@ class DragSession { this.#cancelled = true; for (const snapper of this.#snappers) snapper.deactivateSticky(); } + + // Until the first activation happens, poll the pointer at 60 Hz so we + // pick up RMB-press-without-motion (Muffin doesn't tear down the grab + // on press in that case, and button events don't reach JS during a + // MOVING grab, so there's no event-driven trigger). Self-terminates + // as soon as any snapper reports sticky = true. + #startActivationPoller() { + this.#activationPollerId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 16, () => { + if (this.#snappers.some(s => s.isSticky)) { + this.#activationPollerId = 0; + return GLib.SOURCE_REMOVE; + } + for (const snapper of this.#snappers) snapper.refreshFromPointer(); + return GLib.SOURCE_CONTINUE; + }); + } + + #stopActivationPoller() { + if (!this.#activationPollerId) return; + GLib.source_remove(this.#activationPollerId); + this.#activationPollerId = 0; + } } module.exports = { DragSession }; diff --git a/node_tree.js b/node_tree.js index ec2d8db..d531676 100644 --- a/node_tree.js +++ b/node_tree.js @@ -832,6 +832,8 @@ class SnappingOperation extends LayoutOperation { this.#stickySnap = !!stickySnap; } + get isSticky() { return this.#sticky; } + // Set the sticky-snap latch. Called externally to activate (after an // RMB-tap restart) or to cancel (on Escape). onMotion also latches it // automatically on first normal activation under sticky mode. diff --git a/window-snapper.js b/window-snapper.js index cb36191..5b727e6 100644 --- a/window-snapper.js +++ b/window-snapper.js @@ -99,13 +99,18 @@ class WindowSnapper { this.#signals.connect(this.#window, 'position-changed', this.#onWindowMoved.bind(this)); } + // Whether the snap latch is currently on for this drag. + get isSticky() { + return this.#snappingOperation ? this.#snappingOperation.isSticky : false; + } + // Latch sticky snapping on immediately (e.g. after a restart-grab // triggered by an RMB tap) and populate the overlay from the current // pointer position without requiring mouse motion. activateSticky() { if (!this.#snappingOperation) return; this.#snappingOperation.setSticky(true); - this.#onWindowMoved(); + this.refreshFromPointer(); } // Release the sticky latch (e.g. the user pressed Escape). @@ -116,6 +121,21 @@ class WindowSnapper { this.#drawingArea.queue_repaint(); } + // Run an onMotion pass for the current pointer position, show the + // overlay if needed, and repaint. Used both for window motion events + // and for DragSession's activation poller (to catch RMB-press-without- + // motion before Muffin's tear-down of the grab). + refreshFromPointer() { + if (!this.#snappingOperation) return; + const [x, y, state] = global.get_pointer(); + const result = this.#snappingOperation.onMotion(x, y, state); + if (!(result && result.shouldRedraw)) return; + if (this.#snappingOperation.showRegions) { + this.#container.show(); + } + this.#drawingArea.queue_repaint(); + } + // snap if the user wants to finalize() { const snappingRect = this.#snappingOperation.currentSnapToRect(); @@ -163,19 +183,9 @@ class WindowSnapper { cr.$dispose(); } - // Run an onMotion pass for the current pointer position, show the - // overlay if needed, and repaint. Connected to position-changed on - // the window, and also invoked directly from activateSticky() so we - // can render without requiring mouse motion. + // position-changed signal handler on the dragged window. #onWindowMoved() { - if (!this.#snappingOperation) return; - const [x, y, state] = global.get_pointer(); - const result = this.#snappingOperation.onMotion(x, y, state); - if (!(result && result.shouldRedraw)) return; - if (this.#snappingOperation.showRegions) { - this.#container.show(); - } - this.#drawingArea.queue_repaint(); + this.refreshFromPointer(); } } From 49c8dcb4811f82873be90e9660a8f4495024cc4a Mon Sep 17 00:00:00 2001 From: Webber Date: Wed, 22 Apr 2026 20:09:14 +0200 Subject: [PATCH 4/6] chore: add .idea/ to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f11b75 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea/ From 12e9c6c53bd750cebe1f4b2bdbd7eb9d9b3092ce Mon Sep 17 00:00:00 2001 From: Webber Date: Wed, 22 Apr 2026 20:41:51 +0200 Subject: [PATCH 5/6] chore(sticky): remove Escape-cancel out of scope Previous commits attempted to wire Escape to cancel a sticky drag via a Clutter event filter and/or a Main.keybindingManager hotkey. Neither actually fires during a MOVING grab: - Muffin's event filter is registered before any extension's, so a JS-added Clutter event filter never sees the Escape key press. - Keybindings registered via Main.keybindingManager reach Muffin's process_event, but process_event filters out every non-workspace action while mouse_grab_move is true (keybindings.c, around the 'mouse_grab_move && !is_workspace_action' guard). On top of that, Muffin's built-in Escape handler unconditionally teleports the window back to its grab-initial position before ending the grab, and that cannot be prevented from the extension layer. Rather than ship half-working cancel code, remove it entirely. Escape during a sticky drag now just triggers Muffin's default behaviour (window back to drag-start, grab ends). The user can re- drag from there. If we ever revisit this, a position-based heuristic to at least hide the zones after Muffin's Escape teleport is documented in the git history. Removed: - DragSession #installEscapeFilter / #removeEscapeFilter / #onEscape - DragSession #escapeFilterId field + call sites - WindowSnapper.deactivateSticky() (had no other callers) - Escape mention in DragSession's doc comment --- drag-session.js | 28 +--------------------------- window-snapper.js | 8 -------- 2 files changed, 1 insertion(+), 35 deletions(-) diff --git a/drag-session.js b/drag-session.js index 80e905f..0973586 100644 --- a/drag-session.js +++ b/drag-session.js @@ -18,7 +18,7 @@ const MAX_RESTARTS = 100; * uninterrupted drag. * * Sticky mode layers on: once activated during the drag, the snap - * overlay stays visible until LMB release commits or Escape cancels. + * overlay stays visible until LMB release commits. */ class DragSession { #window; @@ -29,7 +29,6 @@ class DragSession { #cancelled = false; #restarts = 0; #pendingRestartId = 0; - #escapeFilterId = 0; #activationPollerId = 0; /** @@ -51,7 +50,6 @@ class DragSession { } if (options.stickySnap) { - this.#installEscapeFilter(); this.#startActivationPoller(); } } @@ -83,7 +81,6 @@ class DragSession { this.#pendingRestartId = 0; } this.#stopActivationPoller(); - this.#removeEscapeFilter(); for (const snapper of this.#snappers) { if (!this.#cancelled) snapper.finalize(); @@ -158,29 +155,6 @@ class DragSession { } } - #installEscapeFilter() { - // Muffin does not consume key events during a MOVING grab, so a - // Clutter filter sees them. - this.#escapeFilterId = Clutter.event_add_filter(null, (event) => { - if (event.type() !== Clutter.EventType.KEY_PRESS) return Clutter.EVENT_PROPAGATE; - if (event.get_key_symbol() !== Clutter.KEY_Escape) return Clutter.EVENT_PROPAGATE; - this.#onEscape(); - return Clutter.EVENT_STOP; - }); - } - - #removeEscapeFilter() { - if (!this.#escapeFilterId) return; - try { Clutter.event_remove_filter(this.#escapeFilterId); } - catch (_) { /* ignore */ } - this.#escapeFilterId = 0; - } - - #onEscape() { - this.#cancelled = true; - for (const snapper of this.#snappers) snapper.deactivateSticky(); - } - // Until the first activation happens, poll the pointer at 60 Hz so we // pick up RMB-press-without-motion (Muffin doesn't tear down the grab // on press in that case, and button events don't reach JS during a diff --git a/window-snapper.js b/window-snapper.js index 5b727e6..0109e5b 100644 --- a/window-snapper.js +++ b/window-snapper.js @@ -113,14 +113,6 @@ class WindowSnapper { this.refreshFromPointer(); } - // Release the sticky latch (e.g. the user pressed Escape). - deactivateSticky() { - if (!this.#snappingOperation) return; - this.#snappingOperation.setSticky(false); - this.#container.hide(); - this.#drawingArea.queue_repaint(); - } - // Run an onMotion pass for the current pointer position, show the // overlay if needed, and repaint. Used both for window motion events // and for DragSession's activation poller (to catch RMB-press-without- From 35c99b2901943974e1086517b6f98a68ba0d0d37 Mon Sep 17 00:00:00 2001 From: Webber Date: Wed, 22 Apr 2026 20:47:28 +0200 Subject: [PATCH 6/6] feat!: make sticky snapping the only mode Remove the stickySnap opt-in setting. Snapping activation is now always sticky: a single press of the configured activator (secondary mouse button or modifier key) latches the snap overlay on for the rest of the drag; LMB release commits. Removed: - settings-schema.json 'stickySnap' entry - Application.#snapshotDragOptions 'stickySnap' field - DragSession's 'if (options.stickySnap)' gate around the activation poller (poller always starts) - DragSession.tryRestart's '!this.#options.stickySnap' check - WindowSnapper ctor arg 'stickySnap' and #stickySnap field - SnappingOperation ctor arg 'stickySnap' and #stickySnap field Simplified SnappingOperation.onMotion: the latch now triggers on any normal activation (no mode gate), and snappingEnabled reduces to this.#sticky (the latch). Tooltips for 'enableSnappingModifiers' and 'activateWithNonPrimaryButton' updated from 'holding' to 'pressing' to reflect the new behavior. BREAKING CHANGE: users who relied on classic hold-to-snap UX ('snap only commits while the activator is held at LMB release') will see the overlay stay on after the activator is released and a snap commit on the next LMB release. The Escape key-cancel provided by Muffin (window returns to drag-start, grab ends) is the only way to abort from inside a drag. --- application.js | 1 - drag-session.js | 7 ++----- node_tree.js | 12 +++++------- settings-schema.json | 10 ++-------- window-snapper.js | 10 ++-------- 5 files changed, 11 insertions(+), 29 deletions(-) diff --git a/application.js b/application.js index a968e7e..d017ab7 100644 --- a/application.js +++ b/application.js @@ -311,7 +311,6 @@ class Application { mergeAdjacentOnHover: s.mergeAdjacentOnHover.value, mergingRadius: s.mergingRadius.value, activateWithNonPrimaryButton: s.activateWithNonPrimaryButton.value, - stickySnap: s.stickySnap.value, }; } } diff --git a/drag-session.js b/drag-session.js index 0973586..241fe39 100644 --- a/drag-session.js +++ b/drag-session.js @@ -49,9 +49,7 @@ class DragSession { this.#snappers.push(this.#buildSnapper(i)); } - if (options.stickySnap) { - this.#startActivationPoller(); - } + this.#startActivationPoller(); } // A grab-begin fired while we're alive — that's our own restart landing. @@ -62,7 +60,7 @@ class DragSession { // Called on grab-op-end. Returns true if we've scheduled a restart // (caller keeps the session alive); false if the drag is really over. tryRestart() { - if (!this.#options.stickySnap || this.#cancelled) return false; + if (this.#cancelled) return false; if (this.#restarts >= MAX_RESTARTS) { global.logWarning(`fancytiles: hit MAX_RESTARTS (${MAX_RESTARTS}), giving up`); return false; @@ -103,7 +101,6 @@ class DragSession { o.mergeAdjacentOnHover, o.mergingRadius, o.activateWithNonPrimaryButton, - o.stickySnap, ); } diff --git a/node_tree.js b/node_tree.js index d531676..7ea4500 100644 --- a/node_tree.js +++ b/node_tree.js @@ -817,19 +817,17 @@ class SnappingOperation extends LayoutOperation { #enableAdjacentMerging; #mergingRadius; #activateWithNonPrimaryButton; - #stickySnap; #sticky = false; #previousHighlightedNodes = null; #previousInsetNodeRect = null; - constructor(tree, enableSnappingModifiers, enableMultiSnappingModifiers, enableAdjacentMerging, mergingRadius, activateWithNonPrimaryButton, stickySnap) { + constructor(tree, enableSnappingModifiers, enableMultiSnappingModifiers, enableAdjacentMerging, mergingRadius, activateWithNonPrimaryButton) { super(tree); this.#enableSnappingModifiers = enableSnappingModifiers; this.#enableMultiSnappingModifiers = enableMultiSnappingModifiers; this.#enableAdjacentMerging = enableAdjacentMerging; this.#mergingRadius = mergingRadius; this.#activateWithNonPrimaryButton = activateWithNonPrimaryButton; - this.#stickySnap = !!stickySnap; } get isSticky() { return this.#sticky; } @@ -854,12 +852,12 @@ class SnappingOperation extends LayoutOperation { const normalEnabled = (this.#activateWithNonPrimaryButton && secondaryButtonPressed) || modifierPressed || noModifierRequired; - // In sticky mode, latch on as soon as a normal activation is seen, - // and stay latched regardless of current button/modifier state. - if (this.#stickySnap && normalEnabled) { + // Latch on as soon as a normal activation is seen and stay latched + // regardless of current button/modifier state until LMB release. + if (normalEnabled) { this.#sticky = true; } - const snappingEnabled = this.#sticky || normalEnabled; + const snappingEnabled = this.#sticky; if (!snappingEnabled) { return this.cancel(); diff --git a/settings-schema.json b/settings-schema.json index cc1356a..8a6180f 100644 --- a/settings-schema.json +++ b/settings-schema.json @@ -7,7 +7,7 @@ "enableSnappingModifiers": { "type": "combobox", "description": "Key modifier to activate snapping", - "tooltip": "When set, holding the key modifier while dragging activates snapping mode. Snapping mode is always activated when both the key modifier is set to (none) and activating with the secondary mouse button is disabled.", + "tooltip": "When set, pressing the key modifier while dragging latches snapping on for the rest of the drag. Snapping mode is always activated when both the key modifier is set to (none) and activating with the secondary mouse button is disabled.", "default": "CTRL", "options": { "(none)": "", @@ -20,15 +20,9 @@ "activateWithNonPrimaryButton": { "type": "switch", "description": "Activate snapping with secondary mouse button", - "tooltip": "When enabled, holding the secondary mouse button while dragging activates snapping mode. Snapping mode is always activated when both the key modifier is set to (none) and activating with the secondary mouse button is disabled.", + "tooltip": "When enabled, pressing the secondary mouse button while dragging latches snapping on for the rest of the drag. Snapping mode is always activated when both the key modifier is set to (none) and activating with the secondary mouse button is disabled.", "default": false }, - "stickySnap": { - "type": "switch", - "description": "Sticky snapping (tap to latch)", - "tooltip": "When enabled, a single press of the activator (secondary mouse button or modifier key) latches snapping on for the rest of the drag \u2014 you do not need to keep holding it. Release the primary mouse button to commit the snap, or press Escape to cancel. When disabled, you must hold the activator throughout the drag (classic behavior).", - "default": false - }, "enableMultiSnappingModifiers": { "type": "combobox", "description": "Key modifier to enable merging additional regions", diff --git a/window-snapper.js b/window-snapper.js index 0109e5b..51d57df 100644 --- a/window-snapper.js +++ b/window-snapper.js @@ -42,12 +42,9 @@ class WindowSnapper { // whether to use the non-primary button to activate snapping #activateWithNonPrimaryButton; - // whether sticky snapping (tap-to-latch) is enabled - #stickySnap; - #signals = new SignalManager.SignalManager(null); - constructor(displayIdx, layout, window, enableSnappingModifiers, enableMultiSnappingModifiers, enableAdjacentMerging, mergingRadius, activateWithNonPrimaryButton, stickySnap) { + constructor(displayIdx, layout, window, enableSnappingModifiers, enableMultiSnappingModifiers, enableAdjacentMerging, mergingRadius, activateWithNonPrimaryButton) { // the layout to use for the snapping operation this.#layout = layout; @@ -68,9 +65,6 @@ class WindowSnapper { // whether to use the non-primary button to activate snapping this.#activateWithNonPrimaryButton = activateWithNonPrimaryButton; - // whether sticky snapping is enabled - this.#stickySnap = !!stickySnap; - // get the size of the display let workArea = getUsableScreenArea(displayIdx); @@ -94,7 +88,7 @@ class WindowSnapper { // ensure the layout is correct for the snap area this.#layout.calculateRects(workArea.x, workArea.y, workArea.width, workArea.height); - this.#snappingOperation = new SnappingOperation(this.#layout, this.#enableSnappingModifiers, this.#enableMultiSnappingModifiers, this.#enableAdjacentMerging, this.#mergingRadius, this.#activateWithNonPrimaryButton, this.#stickySnap); + this.#snappingOperation = new SnappingOperation(this.#layout, this.#enableSnappingModifiers, this.#enableMultiSnappingModifiers, this.#enableAdjacentMerging, this.#mergingRadius, this.#activateWithNonPrimaryButton); this.#signals.connect(this.#window, 'position-changed', this.#onWindowMoved.bind(this)); }