Skip to content

Repository files navigation

Shiftly icon

Shiftly

Keyboard window snapping I built for macOS that bypasses the Secure Input problems swish has.

Same workflow but keyboard driven here. You hold down a modifier key, tap arrow keys to move a window's placement (shown as a colored rectangle), release to move the window there. You have different modifiers for halves, quarters, thirds, sixths, and multi-display moves.

Demo

Demo.mp4

Why

Window managers like Swish and Rectangle read the keyboard through a CGEventTap. MacOS Secure Input, which a lot of apps grab via a password field, blinds event taps system-wide. This has recently become an issue as recently Electron apps have a habit of grabbing it and it breaks until we reboot.

Shiftly avoids the event tap entirely (but not w/o some drawbacks).

Hotkeys come in through Carbon's RegisterEventHotKey, which WindowServer dispatches even while Secure Input is held (same mechanism that keeps Spotlight's Cmd+Space alive in a password field). Release detection polls CGEventSource.flagsState, a state query, not an event stream, and window moves go through the Accessibility API. These all together lets us avoid the Secure Input problems.

This does mean that we are grabbing these modifier keys in essentially any context. You can change the modifiers in the menu bar settings to avoid conflicts with other app keybindings if you'd like.

Gestures

LayerDefaultArrows
Halves & quartersCmdLeft/Right snap to halves, Up maximizes, Down centers. Combine them for quarters - Left then Up and you get the top-left quarter.
Thirds & sixthsCmd+OptLeft/Right walk the window across the screen: left 1/3, left 2/3, middle 1/3, right 2/3, right 1/3. Up/Down will grab the upper or lower half of whatever slot you're in.
DisplaysCmd+ShiftSends the window to the display in that direction, keeping its relative size and position.

Eye Tracking (Beta)

Off by default, under Eye Tracking (Beta) in the menu bar. It changes which window the gestures above act on. Look at a window, hold your modifier & tap an arrow, and it grabs that one instead of whatever you were last focused on.

I'm using whatever camera your mac is plugged into + Vision's face landmarks, all on device. It reads your head angle from where your nose sits relative to your eye line, plus pupil offset, and how open your eyelids are. The eyes matter the most because a webcam can't see your head turn far enough to face a monitor off to one side (dual or triple monitor setups)

Calibrating

Required, and it takes about a minute. Follow the instructions on screen - if it's not clear, the first calibration pass you should try to keep your head as still as possible while moving just your eyes across your monitors.

Calibrations add up. Each run is pooled with your earlier ones instead of replacing them, and the pooled profile is measurably better: every run samples a slightly different sitting posture, and a fit that has only seen one mistakes it for the truth. So when accuracy annoys you, calibrate again - it compounds. Changing your display arrangement starts the pool over, since those readings describe screens that moved.

It also learns from your clicks while you use it. You look at what you click, so each click (when the estimate is steady and roughly agrees) teaches a small per-display correction for how you're sitting right now. That's what keeps this morning's calibration honest this afternoon.

How well it works

slotacts rightacts wrongdoes nothing
half of a 3440x144068%3%29%
third of a 3440x144061%6%33%
sixth of a 3440x144048%10%42%
sixth of a 1692x300839%16%45%

Those numbers are from a single calibration with none of the pooling or click learning above. Replayed against held-out sessions, pooling four calibrations roughly doubled the sixth-of-a-screen hit rate over one and cut wrong actions nearly in half, and the click correction adds a few points on top (tools/gaze_eval.py --sessions reproduces this from your own captures).

It's currently good for grabbing one of the two or three windows you actually have open, and it gets shaky once the slots get small. A slot only has to beat the other windows that are really there, which is an easier problem than a full tiling.

Webcam gaze is good to about 3-4° of visual angle, a couple hundred pixels at desk distance.

Install

git clone https://github.com/jackowfish/shiftly
cd shiftly
./build.sh
open build/Shiftly.app

Grant Accessibility when prompted (System Settings, Privacy & Security, Accessibility). Without it the app can see your keys but can't move anything.

The build signs with a Shiftly Dev Signing certificate if one exists in your keychain, and falls back to ad-hoc signing otherwise.

build.sh is what produces the app: SwiftPM can't assemble a .app, since that needs the Info.plist, the icon, the entitlements, and a signature.

There's a Package.swift anyway, purely so editors resolve the module. Without it SourceKit analyses each file by itself and reports every cross-file symbol as missing.

swift build however works to type-checks everything and gives you a bare binary; it just isn't actually something you can publish.

Layout

Sources/App/ menu bar app, settings, updater
Sources/Snapping/ hotkeys, the gesture ladders, geometry, the overlay
Sources/Gaze/ camera, calibration, the profile, window targeting
tools/ gaze_eval.py and the calibration screenshot renderer
build.sh assembles and signs the .app
Package.swift for editors and `swift build`, not for shipping

Settings

All settings are in the menu bar item. Currently that's per-layer modifier combos, placement rectangle color (or off entirely, which moves windows live on each press), animation speed, and the eye tracking options above. Settings persist across restarts.

License

MIT

About

Keyboard window snapping for macOS that survives stuck Secure Input

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages