Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.idea/
86 changes: 47 additions & 39 deletions application.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, [
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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 };
178 changes: 178 additions & 0 deletions drag-session.js
Original file line number Diff line number Diff line change
@@ -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 };
43 changes: 40 additions & 3 deletions node_tree.js
Original file line number Diff line number Diff line change
Expand Up @@ -817,6 +817,7 @@ class SnappingOperation extends LayoutOperation {
#enableAdjacentMerging;
#mergingRadius;
#activateWithNonPrimaryButton;
#sticky = false;
#previousHighlightedNodes = null;
#previousInsetNodeRect = null;

Expand All @@ -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();
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 3 additions & 3 deletions settings-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)": "",
Expand All @@ -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",
Expand Down
Loading