Skip to content

feat: double-tap Move/Resize modifier to maximize / minimize a window - #187

Closed
Liotou wants to merge 6 commits into
pablopunk:mainfrom
Liotou:feat/double-tap-modifier-maximize-minimize
Closed

Liotou wants to merge 6 commits into
pablopunk:mainfrom
Liotou:feat/double-tap-modifier-maximize-minimize

Conversation

@Liotou

@Liotou Liotou commented Sep 10, 2026 •

Copy link
Copy Markdown

What

Opt-in "Double-tap modifier keys" preference. With a modifier-only Move/Resize shortcut:

  • Double-tap Resize → maximize the window under the cursor (a second tap restores).
  • Double-tap Move → minimize to the Dock.
  • "Swap double-tap actions" flips which key does which.

Targets the window under the cursor, falling back to the focused window; respects ignored apps. The maximize/restore transition is animated (~0.18s ease-out) and honors macOS's "Tiled windows have margins" setting (8pt edge gap when on).

Why

SwiftShift is mouse-driven. This adds a keyboard path for the two most common window actions with no new hotkey — it reuses the already-configured modifier (Resize → size, Move → sends the window away).

How

DoubleTapActionManager watches .flagsChanged with a press/release state machine. A hold-drag never registers: both taps must be short (< 0.30s), close together (< 0.40s), no mouse button held, pointer barely moving. WindowSnapActionRunner performs the action, keeping a bounded history of pre-maximize frames for restore. WindowFrameAnimator interpolates the frame through the existing AXWindowWriter queue and disables AXEnhancedUserInterface for smooth motion on Electron apps. New WindowManager helpers cover frame get/set, minimize, geometry, and tiling margins. Wired into lifecycle and shortcut save/delete like MouseChordActionManager.

Only modifier-only shortcuts are supported. "Maximize" = the visible frame, not native fullscreen.

Tests

xcodebuild test green, 53 tests including new coordinate-conversion tests. Timing and animation verified manually.

Try it

Enable the preference, set Move/Resize to modifier-only shortcuts, hover a window, double-tap either modifier.

🤖 Generated with Claude Code

Adds an opt-in "Double-tap modifier keys" preference. When enabled and the
Move/Resize shortcut is a modifier-only combo:

- double-tap the Resize modifier -> maximize the window under the cursor to the
  screen's visible frame; a second double-tap restores the previous frame
- double-tap the Move modifier -> minimize the window to the Dock

Detection lives in DoubleTapActionManager (NSEvent global + local monitors on
.flagsChanged). A normal press-and-hold drag never registers: both taps must be
short and close together, with no other key, no mouse button held, and no active
SwiftShift drag in between. WindowSnapActionRunner performs the action, targeting
the window under the cursor (falling back to the focused window) and honoring the
ignored-apps list; it keeps a small history of pre-maximize frames for restore.

WindowManager gains getFrame/setFrame/setMinimized/isAlive, an AppKit<->AX rect
converter (axRect(fromAppKit:)), screenAXVisibleFrame(containing:), and
getFocusedWindow(). Wired into AppDelegate lifecycle, shortcut save/delete, and
the post-system-event input-hook rebuild.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@codesandbox

codesandbox Bot commented Sep 10, 2026

Copy link
Copy Markdown

Review or Edit in CodeSandbox

Open the branch in Web Editor • VS Code • Insiders

Open Preview

@vercel

vercel Bot commented Sep 10, 2026

Copy link
Copy Markdown

@Liotou is attempting to deploy a commit to the Pablo Varela's projects Team on Vercel.

A member of the Team first needs to authorize it.

Liotou and others added 4 commits September 10, 2026 10:38
… shortcut

handlePress/handleRelease guarded on !ShortcutsManager.hasActiveShortcut to avoid
firing mid-drag, but holding the Move/Resize modifier arms that flag immediately
(before any drag), so the guard tripped on every tap and the sequence never
started. Drop that guard; instead reject the sequence only when a mouse button is
held or the pointer drifts more than maxPointerDrift between the first tap and the
trigger (covers keyboard-only move/resize, which tracks on mouse motion). Timing
loosened slightly (0.30s tap / 0.40s gap) now that the drift check rules out
drag false-positives.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds WindowFrameAnimator: interpolates the window frame from its current rect to
the target over 0.18s with an ease-out cubic curve, ~90 steps/s, pushing each
step through AXWindowWriter (background serial queue, latest-wins) so the main
thread never blocks on AX IPC. For the gesture it also turns off the target app's
AXEnhancedUserInterface (restored after), which is what keeps the motion smooth
on Chromium/Electron windows.

WindowSnapActionRunner now routes toggle-maximize / restore / center through the
animator; a new trigger mid-glide cancels the running animation and starts a
fresh one from the live frame, and reasons about the in-flight target when
deciding maximize-vs-restore. Near-identical frames still snap directly.

New WindowManager helpers: enhancedUIState(forAppOf:) / setEnhancedUI(_:forApp:).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
New doubleTapActionsSwapped toggle (shown under "Double-tap modifier keys" only
when that's on). Default: Resize modifier maximizes, Move modifier minimizes.
When swapped: Move maximizes, Resize minimizes. loadConfigs() picks the maximize
modifier from the preference; the parent row's subtitle reflects the current
mapping.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
When com.apple.WindowManager EnableTiledWindowMargins is on (macOS 15+,
System Settings → Desktop & Dock → Windows), maximize now insets the target
by 8 pt on every side to match macOS's own tiling gap, instead of going
edge-to-edge. Off / pre-Sequoia keeps the full visible frame. The
already-maximized check compares against the same inset target.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@pablopunk

Copy link
Copy Markdown
Owner

@codex pls review this PR and rewrite the description to be 250 words or less

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5666793fbb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Swift Shift/src/Manager/ShortcutsManager.swift
Comment thread Swift Shift/src/Manager/ShortcutsManager.swift
Comment thread Swift Shift/src/Manager/ShortcutsManager.swift
Comment thread Swift Shift/src/Manager/ShortcutsManager.swift
Comment thread Swift Shift/src/Manager/ShortcutsManager.swift
Comment thread Swift Shift/src/Manager/ShortcutsManager.swift Outdated
@Liotou

Liotou commented Sep 12, 2026

Copy link
Copy Markdown
Author

Trimmed the description to ~250 words (kept What / Why / How / Scope / Tests / Try it). Leaving the code review itself to @codex as asked.

🤖 Addressed by Claude Code

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

- Cancel a pending double-tap sequence when an extra modifier joins the
  configured combo instead of treating it as a release. When one shortcut's
  flags are a subset of another's (e.g. Move=⌥, Resize=⌥⇧), pressing/releasing
  the extra modifier flipped the exact-flags match and was counted as a
  release-then-press, so releasing the still-held modifier could fire a
  double-tap that never happened. handleFlagsChanged now only calls
  handleRelease when a configured bit actually went up; a superset transition
  clears the tap state instead.

- Cancel the maximize/restore animation before a real move/resize gesture
  starts (MouseTracker.prepareTracking). Both shared AXWindowWriter, so
  starting a drag mid-glide could apply animation frames to the newly tracked
  window, and the glide's endGesture() could detach the writer out from under
  the live drag.

- WindowManager.isAlive now caps the AX messaging timeout at 0.15s (was the
  ~6s global default) and only accepts .success as "alive" (was "anything but
  .invalidUIElement", which kept records alive on .cannotComplete). Toggling
  maximize prunes up to 12 stored records synchronously on the main thread, so
  an unresponsive-but-not-terminated app could previously stall SwiftShift for
  many seconds.

- MaximizedRecord now stores the frame it was maximized to. The "already
  maximized" check compares the live frame against that stored frame instead
  of a freshly recomputed target, so a target that moves after the fact (Dock,
  resolution, tiling-margin setting) no longer makes the window look
  "un-maximized" and silently discards the true restore frame.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@pablopunk

Copy link
Copy Markdown
Owner

hey @Liotou, thanks for the PR. I tried it, and decided not to include this into Swift Shift. Reasons:

  • I like to think the original intent of the app is still the ONLY feature it has. Do one thing well.
  • These new features have nothing to do with it, since Swift Shift was made to make the mouse experience better, not the keyboard (which can already be achieved with tons of apps and builtin shortcuts like cmd+M).
  • The options are already cluttered and this just adds more confusion.

Feel free to keep running your own fork if you really want this. This is something I do with https://github.com/pablopunk/AeroSpace (since I want features the author does not want)

@pablopunk pablopunk closed this Sep 15, 2026
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.

2 participants