Skip to content

feat!: toggle snap instead of hold to snap#52

Open
webbertakken wants to merge 6 commits intoBasGeertsema:mainfrom
webbertakken:feature/sticky-snap-toggle
Open

feat!: toggle snap instead of hold to snap#52
webbertakken wants to merge 6 commits intoBasGeertsema:mainfrom
webbertakken:feature/sticky-snap-toggle

Conversation

@webbertakken
Copy link
Copy Markdown

@webbertakken webbertakken commented Apr 22, 2026

What changed:

Technical:

  • Created dedicated DragSession class to encapsulate all the per-drag state.

Additional context:

  • Muffin tears down the MOVING grab on every secondary-button event. There is no possible way to veto this from the js extension.
  • Instead on grab-op-end if primary button is still pressed, we immediately restore the grab, so that the window is still held as dragging. This is an uninterrupted flag that. The measure is invisible to the user.

P.S.:

  • I didn't want to force you into pushing a breaking change per se. So in case you wanted to first publish a version that has a config option for it, that's disabled by default, you can revert the last commit in this PR. (both the last commit and the one before are well tested and completely work for me locally)

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 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.
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.
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
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Toggle Snapping instead of Holding Button Multi-monitor minor hiccups

1 participant