# Windows (/docs/haus/rooms/windows)



Windows arrange themselves into a tree instead of piling up, and the keyboard
moves them around it. Still native macOS windows, driven by
[AeroSpace](https://github.com/nikitabobko/AeroSpace).

```nix
haus.windows.enable = true;
```

The chords are [Keys](/docs/haus/rooms/keys), ⇪ and ⌥ both. This page is what
they drive.

Windows scramble when the Mac wakes, and windows puts them back by itself,
workspace and tiled layout both. <kbd>⇪</kbd> <kbd>\`</kbd> is that repair on
demand. Apps you told to float stay floating.

## Zoom the window you're pointing at [#zoom-the-window-youre-pointing-at]

`⌥F` only reaches the window you are *in*. Its pointer twin reaches the one you
point at, on another monitor included:

```nix
haus.windows.mouseFullscreen = "right";   # right | left | none
```

The modifier follows [`haus.keys.windowNav`](/docs/haus/rooms/keys#not-fond-of-these-keys),
the same click puts it back, and it declines a lone window as `⌥F` does.
[pounce](/docs/pounce)'s event tap carries the chord, so it wants
`haus.launcher.enable` and the Accessibility grant the palette already asks for,
both true on [hacker](/docs/haus/desktops/hacker).

<Callout type="warn" title="This spends a gesture machine-wide">
  The click is consumed over **every** app, not just tiled ones. `"right"` costs
  you macOS's ⌥ *variant* of a context menu (in Finder, "Copy X as Pathname") and
  ⌥ + right-**drag**. `"left"` costs far more: multi-cursor in GUI editors,
  ⌥-click-a-link to download, ⌥-click a menu extra, every ⌥-drag. There is no
  `ctrl` value, because ctrl+click **is** macOS's secondary click.
</Callout>

## A window on its own does not zoom [#a-window-on-its-own-does-not-zoom]

Fullscreen is a *mode*, not a size. On a workspace holding one window it would
change nothing visible and flag the workspace anyway, so the next window you
opened there would open *behind* this one. Every path declines it: `⌥F`, `⌥` +
right-click, and the zoom that clicking an
[agent lane's banner](/docs/haus/rooms/ai#knowing-which-agent-needs-you) takes
for you. Company is the way out, since AeroSpace drops the mode the moment you
focus a sibling. If a press looks like it did nothing, count the windows.

## Workspaces, and what lives on them [#workspaces-and-what-lives-on-them]

A workspace is a named place with apps that belong to it. Declare one and its
apps' windows move there by themselves:

```nix
haus.workspaces.comms = {
  key = "c";                       # ⇪ ⇧C throws the focused window here
  icon = ":slack:";                # the bar pill's glyph
  apps = [ "slack" "discord" ];    # roster ids
};
```

The attribute name **is** the workspace id: a letter like `T`, or a word.

| Field  | What to know                                                                                                                                                                             |
| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `apps` | [roster](/docs/haus/rooms/apps) ids that live here, so launching one lands you here. An app belongs to at most one workspace, and an id the roster hasn't got warns.                     |
| `key`  | The throw key. There is no bare-key binding, since that namespace is the launcher letters from `haus.roster`; `null` leaves the workspace reachable only by launching an app of its own. |
| `icon` | A [SketchyBar app-font](https://github.com/kvndrsslr/sketchybar-app-font) ligature like `:slack:`. Anything else is drawn in the bar's own font.                                         |

### The numbered ones [#the-numbered-ones]

Four numbered workspaces exist alongside whatever you name:

```nix
haus.windows.numberedWorkspaces = 6;   # 0 to 10, reached by `1`-`9` then `0`
```

Each takes its digit in launch mode, so a `haus.workspaces` key colliding with a
newly claimed digit is a rebuild error rather than one of two bindings silently
lost. Raising the count never renames a workspace.

### Pin one to a screen [#pin-one-to-a-screen]

A workspace opens on whichever display you last used it on. Name the display and
it stops wandering:

```nix
haus.windows.workspaceMonitors = {
  "1" = "main";  "2" = "main";  "5" = "secondary";
  comms = [ "Dell U2720Q" "main" ];
};
```

Numbered workspaces by digit, named ones by name, one table. A **list** is a
fallback chain, which is how `comms` survives the Dell being unplugged.

|                             |                                                  |
| --------------------------- | ------------------------------------------------ |
| `main`, `secondary`         | where the display is, true on any desk           |
| `2`                         | its position left to right, `1` being leftmost   |
| `Dell`                      | part of its name, matched without regard to case |
| `^built-in retina display$` | a regex, when you wrap it in `^` and `$`         |

Only those first two are AeroSpace keywords; the rest match the display's
*localized* name, so `built-in` finds the panel on an English-language MacBook
and nothing at all on a Mac mini. A shared
[desktop](/docs/haus/desktops/creating) may not name a panel here: "Dell U2720Q"
describes a purchase, so that line stays in your own host file.

A name this machine hasn't got, or a pattern AeroSpace rejects (an empty string,
monitor `0`, an apostrophe), is a rebuild error: AeroSpace drops the first in
silence, and the second stops `aerospace.toml` parsing at all, which costs you
every binding in the file.

## Spin the layout until it's right [#spin-the-layout-until-its-right]

`⌥/` and `⌥,&#x60; change one split. &#x2A;*⇪ then `.`** reshapes the whole focused
workspace, one stop round three arrangements, columns → grid → accordion:

| Shape         | What you get                                                                                                 |
| ------------- | ------------------------------------------------------------------------------------------------------------ |
| **Grid**      | √n columns, rounded up, each **widened in proportion to the windows it holds**, so all end up the same size  |
| **Accordion** | One flat row, the focused window taking the space and its neighbours left as slivers `accordionPadding` wide |
| **Columns**   | One flat row again, what a tiled workspace does on its own                                                   |

**Each workspace remembers its own shape**, so `.` on one you have not used it on
deals a grid rather than continuing a sequence started elsewhere; press again on
the shape you have and it reapplies fresh, which is the move once a new window
has wandered in. The bar's pill names the shape and advances the dial when
clicked. **Floating windows are left alone**, so a terminal popup over the
tiling neither moves nor counts.

Accordion is **AeroSpace's own layout** rather than a shape haus deals, so it
holds for every window you open afterwards instead of drifting the way a grid
does. Which shape a workspace starts in:

```nix
haus.windows.defaultLayout      = "accordion";  # or "tiles"
haus.windows.accordionPadding   = 80;           # points left for the neighbours
haus.windows.defaultOrientation = "auto";       # auto | horizontal | vertical
```

`"auto"` picks per monitor from its shape, side by side on a wide screen and
stacked on a tall one.

## How much air between the windows [#how-much-air-between-the-windows]

Gaps follow `haus.ui.scale`, and the edge the bar sits on reserves the bar's own
height. What you set is the *base*:

```nix
haus.windows.gaps = {
  inner       = { builtin = 0; external = 0; };   # between two windows
  outer.left  = { builtin = 0; external = 0; };   # at the screen's edges
  outer.right = { builtin = 0; external = 0; };
};
```

Two numbers per gap, 10 on the built-in and 20 around an external out of the
box, so the same gap reads the same size on panels of very different pitch. `0`
is `0` at every scale. `outer.top` and `outer.bottom` are not settable: they
carry the bar's reservation, and macOS excludes nothing at the bottom of a
display, so `0` there would be windows drawn under the bar.

The bar's side padding, the near-fullscreen terminal popups (⌘G, ⌘⇧F) and the
[wallpaper](/docs/haus/rooms/appearance)'s debug band read the same numbers.

## Movement you didn't ask for [#movement-you-didnt-ask-for]

```nix
haus.windows.mouseFollowsFocus = true;    # off by default
haus.windows.gravity           = false;   # on by default
```

The pointer follows keyboard focus, between windows as well as between screens,
and lazily: nothing moves while it is already inside whatever took focus.

**Gravity** returns you to the most recently populated workspace when the one
you're on loses its last window, to a ⌘Q taking every window of an app at once
or a ⌘W or ⌃D closing the last one on a page. It fires only for a workspace you
*emptied*, and it reads the [bar](/docs/haus/rooms/bar)'s event stream, so no
bar means no gravity. These two are the largest unasked movement haus makes,
which is why
[`haus.appearance.reduceMotion`](/docs/haus/rooms/appearance#motion) switches
both off.

## macOS has its own opinion about windows [#macos-has-its-own-opinion-about-windows]

macOS turns edge-drag tiling **on** out of the box, so a fresh Mac has two
window managers and the symptom is "windows won't stay where I put them":

```nix
haus.windows.nativeTiling.edgeDrag          = false;  # the one to turn off if you tile
haus.windows.nativeTiling.topEdgeFullscreen = false;
haus.windows.nativeTiling.optionAccelerator = true;   # ⌥-drag still tiles
haus.windows.stageManager.enable            = false;

haus.windows.desktop.hideIcons     = true;   # files stay in ~/Desktop
haus.windows.desktop.hideWidgets   = true;
haus.windows.desktop.clickToReveal = false;  # macOS 14's click-the-wallpaper behaviour
```

The two `nativeTiling` keys are independent, so `optionAccelerator` is a second
way in rather than a replacement for turning the bare drag off. haus warns
rather than errors when the tiler and either of the others are on together,
naming both switches: "Stage Manager on the laptop panel, tiling on the
external" is a real way to work.

<Callout title="These land at your next login">
  This group writes `com.apple.WindowManager`, which macOS reads when your login
  session starts and never re-reads. &#x2A;*The write happens during the rebuild; the
  effect appears at your next login.** `haus plan` says so before the rebuild
  rather than after.
</Callout>

Unset is the default here, as for every macOS setting haus curates, and unset
doesn't mean "off": haus writes nothing, so a choice you made by hand survives.

`haus.windows.enable = false;` puts the Caps-Lock remap, the launcher and
AeroSpace out of the way. Nothing else in haus depends on it.

## Options [#options]

[Every setting, with types and defaults](/docs/haus/reference/options#windows).
