# winman: A Natural-Language Window Manager I Built to Replace Rectangle
For years I ran [Rectangle](https://rectangleapp.com/) (and later Rectangle Pro) to throw windows into halves and quarters with a keyboard shortcut. It's a great app. But the more monitors I added and the more particular I got about *where exactly* a window should land, the more I found myself fighting the grid. I didn't want "left half of whatever screen I'm on" — I wanted "the left external monitor, full screen, but leave room for the Stage Manager strip." So I built [winman](https://github.com/), a small window manager powered by [Hammerspoon](https://www.hammerspoon.org/) and a Python parser that understands plain English.
## What I actually wanted
Rectangle answers the question "snap this window to a region of the current screen." My real questions were more specific:
- **Which monitor?** I move between a laptop-only setup, a single home external, and a dual-external work setup. The "right" position for a window depends on which physical display is connected.
- **Leave room for Stage Manager.** macOS Stage Manager eats a strip on the left edge. A true "full screen" that ignores it covers the strip and makes it useless.
- **Native fullscreen when I want it.** Sometimes I want the real green-button macOS fullscreen on an external monitor, not just a maximized window.
- **Describe layouts in words.** "Center third," "right two thirds," "70% from left" — I'd rather say what I mean than memorize which of sixteen shortcuts maps to it.
None of these are exotic, but together they pushed past what a fixed grid of shortcuts comfortably handles.
## The architecture
winman is two pieces:
- **`hammerspoon/init.lua`** — keyboard shortcut bindings. Hammerspoon gives you a scriptable handle on every window and screen, so each shortcut moves the focused window to a target screen and frame. It matches monitors *by name* so the same shortcut does the right thing on different setups.
- **`winman.py`** — a natural-language parser. It reads the connected screens, figures out the usable area of each (accounting for the menu bar, Dock, and Stage Manager), and translates a phrase like `"left half on external monitor"` into concrete window bounds.
The split matters: Hammerspoon is excellent at *binding keys and manipulating windows* but awkward for string parsing, while Python is the opposite. So Hammerspoon owns the hotkeys and the live window API, and Python owns the "what does this sentence mean" logic.
## Monitor profiles by name
The key trick that made winman setup-agnostic is matching monitors by model string rather than by index. macOS doesn't guarantee a stable monitor ordering, so "screen 2" is unreliable. Instead, `⌘⇧B` ("move to the primary external monitor") looks at *which* monitor is connected:
| Monitor | Model match | Setup |
|---|---|---|
| Samsung LS27D40xG | `LS27D40xG` | Work (dual external) |
| Samsung LF27T35 | `LF27T35` | Home (single external) |
| Fallback | anything else | Generic external position |
Add a new desk and you just add a row: connect the monitor, read its name with `system_profiler SPDisplaysDataType`, and drop a frame into the profile table. The same shortcut now follows you to the new setup.
## Stage Manager awareness
Getting "full screen but leave the Stage Manager strip" right took some calibration. winman checks whether Stage Manager is enabled:
```python
def check_stage_manager() -> bool:
result = subprocess.run(
["defaults", "read", "com.apple.WindowManager", "GloballyEnabled"],
capture_output=True, text=True,
)
return result.stdout.strip() == "1"
```
When it's on and the command mentions the strip ("leave room for stage manager"), winman shifts the usable area right by a calibrated `STAGE_MANAGER_STRIP` width so the window stops just shy of the sidebar instead of covering it.
## The natural-language part
The parser is deliberately simple — no LLM, just intent matching over the usable screen rectangle. It handles full screen, halves, thirds, quarters, "two thirds," centered fractions, and raw percentages:
```python
# Percentage-based
pct_match = re.search(r'(\d+)\s*%', text)
if pct_match:
pct = int(pct_match.group(1)) / 100.0
if "right" in text:
return [int(x + w * (1 - pct)), int(y), int(x + w), int(y + h)]
...
```
So `winman "70% from left"` and `winman "right third on external monitor"` both just work. The phrases map onto coordinates rather than onto a predefined menu of slots, which is exactly the flexibility I was missing.
The trickiest plumbing is coordinate conversion. macOS Cocoa uses a bottom-left origin and reports multi-monitor geometry relative to the main display; the window APIs I drive want top-left screen coordinates. So winman reads each screen's `frame` and `visibleFrame` via a tiny inline Swift program, then flips and offsets them into a consistent top-left space before doing any layout math.
## What I learned
- **Match hardware by name, not index.** This single decision is what lets one config follow me across three physical setups.
- **Put each tool where it's strong.** Hammerspoon for hotkeys and the live window API; Python for parsing and arithmetic. Bridging them was cheaper than forcing either to do the other's job.
- **"Full screen" is a lie on modern macOS.** Menu bar, Dock, notch, and Stage Manager all carve into the usable rectangle, and a window manager that ignores them feels broken in small, daily ways.
Rectangle is still a fantastic app, and most people should just use it. But building my own meant I could encode *my* monitors, *my* Stage Manager habit, and *my* preferred vocabulary directly into the tool — and that's the kind of small, personal automation I keep finding worth the afternoon it takes.