diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f11b75 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea/ diff --git a/application.js b/application.js index fb9e995..d017ab7 100644 --- a/application.js +++ b/application.js @@ -6,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, [ @@ -86,8 +86,8 @@ class Application { // the active grid editor #gridEditor = null; - // the active window snappers for each monitor - #windowSnappers = []; + // the active drag session, if any (null between drags) + #dragSession = null; #layoutIO; @@ -124,11 +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 = []; } #loadThemeColors() { @@ -270,41 +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) { - // 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); - } - } - 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)); + } - // 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) { - snapper.finalize(); - snapper.destroy(); - } - this.#windowSnappers = []; - } - return Clutter.EVENT_PROPAGATE; + #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; + } + + // Fresh drag. + this.#loadThemeColors(); + this.#dragSession = new DragSession({ + window, + layoutFor: (i) => this.#readOrCreateLayoutForDisplay(i, LayoutOf2x2), + options: this.#snapshotDragOptions(), }); } + + #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, + }; + } } module.exports = { Application, LayoutOf2x2 }; diff --git a/drag-session.js b/drag-session.js new file mode 100644 index 0000000..241fe39 --- /dev/null +++ b/drag-session.js @@ -0,0 +1,178 @@ +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. + */ +class DragSession { + #window; + #options; + #layoutFor; // (monitorIdx) -> LayoutNode + #snappers = []; + + #cancelled = false; + #restarts = 0; + #pendingRestartId = 0; + #activationPollerId = 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)); + } + + this.#startActivationPoller(); + } + + // 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.#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.#stopActivationPoller(); + + 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, + ); + } + + #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(); + } + } + + // 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 2395a6b..7ea4500 100644 --- a/node_tree.js +++ b/node_tree.js @@ -817,6 +817,7 @@ class SnappingOperation extends LayoutOperation { #enableAdjacentMerging; #mergingRadius; #activateWithNonPrimaryButton; + #sticky = false; #previousHighlightedNodes = null; #previousInsetNodeRect = null; @@ -829,20 +830,51 @@ class SnappingOperation extends LayoutOperation { this.#activateWithNonPrimaryButton = activateWithNonPrimaryButton; } - onMotion(x, y, state) { - let snappingEnabled; + 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; + 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; + + // 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; 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 +1000,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..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,9 +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 - }, + }, "enableMultiSnappingModifiers": { "type": "combobox", "description": "Key modifier to enable merging additional regions", diff --git a/window-snapper.js b/window-snapper.js index a3878af..51d57df 100644 --- a/window-snapper.js +++ b/window-snapper.js @@ -93,6 +93,35 @@ 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.refreshFromPointer(); + } + + // 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(); @@ -140,20 +169,9 @@ 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(); - } + // position-changed signal handler on the dragged window. + #onWindowMoved() { + this.refreshFromPointer(); } }