Engineering deep-dive · Tiler v0.8.2
Tiler
one desktop at a time.
How a 4,000-line Swift menu bar app tiles macOS windows with Accessibility, SkyLight and Carbon — and touches nothing it was not asked to.
BikS2013 · engineering talk
github.com/biks2013-tools/tiler · October 2026
00 · Contents
Five parts, from the problem to the release.
01
Why and what. The scope, the audience, and what was left out of HyprMac.
02
Architecture. Components, threads, and how a desktop is identified.
03
Events and layout. Reconcile, frame writes, drags, the BSP tree.
04
Input, focus and UI. macOS 14 focus, Carbon hotkeys, move/resize mode, focus follows mouse, desktop moves, config, Settings.
05
Quality and shipping. Performance, tests, bugs, limits, release pipeline.
At a glance
LanguageSwift 5.10
BuildSwiftPM
Runs onmacOS 14+
Binaryuniversal
Dependenciesnone
Tiler · engineering
02
01 · Scope
Tile one desktop. Touch nothing else.

Tiler does HyprMac's core job — Hyprland-style BSP “dwindle” tiling with keyboard focus, swap and resize — but only on the desktops the user turns on, one at a time.

A desktop is one Space on one display. Space ids change at login; the Space uuid does not, so that is the key that persists.

// Desktop.swift
struct DesktopKey: Codable, Hashable {
    let displayUUID: String
    let spaceUUID: String
}

The hard rules

  • Never touch a window outside a tiled desktop's display and Space — except one window the user explicitly acts on (move/resize mode, move to another desktop, ⌘⌥-drag).
  • Never call Space-mutation APIs; moving a window to another desktop drives the system's own Mission Control shortcut.
  • No polling, never a keyboard tap: react to AX and NSWorkspace events. Focus follows mouse uses a listen-only mouse tap; ⌘⌥-drag one active tap for left-button events only, on its own thread.
  • Every AX call for an app runs on that app's own thread.
  • Act on moved/resized notifications only while the mouse is down.
  • Every config key is required; no silent defaults.
Tiler · engineering
03
01 · Audience
Who Tiler is for — and who it is not for.
01

Mac power users

Several displays, several Spaces, a keyboard-first habit. They want tiling on the coding desktop and free windows on the mail desktop.

02

Engineers

People who want to read, audit or extend a window manager. 4,000 lines, no dependencies, one design document, 30 unit tests.

03

Not the target

Anyone who wants virtual workspaces, per-app rules engines or a scripting language. yabai and AeroSpace cover those.

This deck is for engineers. A separate deck walks users through installing and using Tiler.
Tiler · engineering
04
01 · Origins
What was left out of HyprMac, and why.
HyprMac featureWhy Tiler drops itTiler instead
Ten virtual workspaces, windows hidden in a screen cornerConflicts with “touch only this desktop”. The hide-corner, parking and ghost-window logic was the largest bug source.Native Spaces; each desktop keeps its own tree.
Caps Lock “Hypr” key via an active CGEventTapAn active tap delays every keystroke system-wide when its thread stalls.Carbon RegisterEventHotKey: no permission, only its own keys.
1 Hz polling and frame verification loopsConstant work and fights with apps that refuse a size.AX notifications and a ~1 ms reconcile.
Borders, dimming, scratchpad, overviewOutside the core job; each costs WindowServer time.Not built.
Tiler · engineering
05
01 · Size
Small enough to read in an afternoon.
4,000
lines of Swift, Settings, the shortcuts overlay and the move/resize mode included — against about 35,000 in HyprMac.
Third-party dependencies0
CPU while idle0 %
One reconcile pass≈1 ms
Unit tests (XCTest)30
Private symbols used9
Tiler · engineering
06
02
Architecture.
One main-thread controller decides; one thread per app talks to that app. A pure library holds everything that can be unit-tested.
Section 02
07
02 · Components
The whole app on one page.
MAIN THREAD · UI AppDelegate main.swift · main menu StatusMenu NSStatusItem ShortcutsOverlay non-activating NSPanel SettingsWindowController SettingsModel · SwiftUI panes Controller state · events · reconcile · layout decisions · hotkey actions · drags · config save OWNED BY THE CONTROLLER Desktop[DesktopKey] BSPTree · floating set last applied frames AppHandle[pid] × N own thread + run loop AXObserver · reads · writes Hotkeys Carbon RegisterEventHotKey per-focus registration Spaces · Screen SkyLight + NSScreen read-only lookups other apps' windows (AX) WindowServer TilerCore pure Swift · no AppKit · no AX BSPTreedwindle layout, edits Geometryneighbours, hit tests Configstrict parse, json() KeyCombo · Actionsymbols, titles, groups 30 XCTest tests
Tiler · engineering
08
02 · Threads
The main thread decides. App threads talk to apps.

Main thread

Controller, menu, Settings, overlay, Carbon hotkey events, NSWorkspace notifications. Owns all state; never makes an AX call.

One RunLoopThread per app

AXObserver source, window reads and writes. A hung app stalls only its own thread — and at most 1 s per call.

Nothing tiled, nothing running

Handles exist only while a desktop is tiled. Turning the last one off took the process from 20 threads to 8.

// AppHandle.swift — work hops to the app's thread
func perform(_ block: @escaping () -> Void) {
    guard let runLoop else { return }
    CFRunLoopPerformBlock(runLoop,
        CFRunLoopMode.defaultMode.rawValue, block)
    CFRunLoopWakeUp(runLoop)
}

// results come back on main
func resolve(_ ids: Set<CGWindowID>,
             completion: @escaping (Facts) -> Void) {
    thread.perform { [self] in
        refreshElements()
        let facts = ... isTileable(el) ...
        DispatchQueue.main.async { completion(facts) }
    }
}
Tiler · engineering
09
02 · Desktop identity
Which Space is showing, and which windows are on it.
SkyLight callUsed for
CGSCopyManagedDisplaySpacesEvery Space with id, uuid and type; desktop numbers. Run at launch, on toggle and when Spaces change (~1–15 ms).
SLSManagedDisplayGetCurrentSpaceThe Space a display shows now. Tens of µs, so never cached.
SLSCopySpacesForWindowsThe Spaces a window belongs to (mask 0x7).
SLSManagedDisplayIsAnimatingSkip reconcile mid-switch, when the list mixes two desktops.
// Spaces.swift
static func current(onDisplay uuid: String)
        -> SpaceID {
    let id = SLSManagedDisplayGetCurrentSpace(
        connection, uuid as CFString)
    // "Displays have separate Spaces" off:
    // every display shows the "Main" Space
    return id != 0 ? id
        : SLSManagedDisplayGetCurrentSpace(
            connection, "Main" as CFString)
}

A desktop is visible when its display's current Space equals its stored Space id. Ids are re-resolved from the uuid at launch and on display changes; a desktop deleted in Mission Control is forgotten.

desktops.json keeps floating windows as id + owner pid + frame (read 0.3 s after a move, saved ≤ 1/s). A restore keeps an id only if that pid still owns it, then puts the frame back once the desktop is on screen. Re-floating reuses the last frame if ≥ 50 % is on the display.

Tiler · engineering
10
03
Events and layout.
No polling: every change starts from a notification, is folded into one reconcile pass, and ends in the fewest possible frame writes.
Section 03
11
03 · Event flow
From a notification to a written frame.
AX (per app thread) WindowCreated · Destroyed FocusedWindowChanged Moved · Resized · Miniaturized hop to main as a small enum NSWorkspace / AppKit launch · terminate · hide · activate active Space · active display screen parameters changed already on main scheduleReconcile coalesced: one pass per 20 ms burst reconcile() one CGWindowList call diff tree vs. windows ≈ 1 ms layout(d) only changed frames setFrame app thread, newest wins unknown windows resolved async on their app's thread, then reconcile again
Created windows trigger extra passes at +0.15 s and +0.5 s (some apps show the window late); a window moved by a script gets one at +0.4 s (its Space updates late); an untileable window is re-checked once after 0.6 s.
Tiler · engineering
12
03 · Reconcile
Six steps, about a millisecond.
1
Skip unless a tiled desktop is visible; wait while its display is animating a Space switch.
2
One CGWindowListCopyWindowInfo(onScreenOnly); keep layer-0 windows of other apps with alpha > 0.
3
Per visible desktop: centre on its display and its Space in SLSCopySpacesForWindows.
4
Re-check the Space did not change during the read; if it did, the next event redoes it.
5
Remove vanished and always-float windows; insert new tileable ones next to the last focused tile.
6
Lay out only if something changed, and write only frames that differ from the last one written.

When is a window tileable?

  • role AXWindow, subrole AXStandardWindow
  • not minimised, not full screen
  • an enabled green button (dialogs and settings panes have none)
  • AXSize is settable
  • app not in floatingApps

Everything else floats and is never moved.

Tiler · engineering
13
03 · Writing frames
Write the newest frame only, in an order apps accept.
  • Coalesced. setFrame stores the newest frame per window and schedules one flush. A slow app (iTerm2 took up to 700 ms) never replays stale layouts.
  • Size → position → size. The first resize cannot clamp the move at a screen edge; the second fixes a resize clamped by the old position.
  • AXEnhancedUserInterface is switched off for the writes, or the app animates every resize.
  • Bounded. AXUIElementSetMessagingTimeout = 1 s, system-wide and per element.
// AppHandle.swift — on the app's thread
private func flushFrames() {
    lock.lock()
    let frames = pendingFrames
    pendingFrames.removeAll()
    flushScheduled = false
    lock.unlock()
    let enhanced = bool(app,
        "AXEnhancedUserInterface") == true
    if enhanced { set(app, ..., false) }
    defer { if enhanced { set(app, ..., true) } }
    for (id, frame) in frames {
        guard let el = elements[id] else { continue }
        setSize(el, frame.size)
        setPosition(el, frame.origin)
        setSize(el, frame.size)
    }
}
Tiler · engineering
14
03 · Feedback-loop rule
React to window moves only while the mouse button is down.

Tiler's own writes produce moved/resized notifications too. So do apps that refuse a size or snap themselves. Acting on those would start a fight.

The rule: with no mouse button down, a move of a tiled window is ignored. With the button down it is a user drag, handled once on release.

mouse released read the window's frame (app thread) size changed > 2 pt adoptResize: the dragged edges set split ratios dropped on a tile swap the two windows in the tree anything else snap back — or let go if on another display layout(d) — one write pass
Tiler · engineering
15
03 · Layout
A binary space partition, dwindle style.
H .5 1 V .5 2 3 splits hold orientation + ratio 1 2 3 window 3 split the taller tile 2

A new window splits the last focused tile along its longer side and takes the second half. Ratios are clamped to 0.1–0.9; frames are rounded to whole points so neighbours never overlap.

OperationWhat it does
insert / removeSplit a leaf; on removal the sibling takes the parent's place.
swapExchange two leaves' windows.
moveDividerPrefer the divider on the arrow's side, else the opposite one.
adoptResizeTurn dragged edges into ratios of the splits behind them.
toggleOrientationSide by side ↔ stacked for one split.
mirror · equalizeFlip left ↔ right; set every ratio to 0.5.
Tiler · engineering
16
03 · Neighbours
Which window is “to the left”?

Focus and swap move by geometry, not by tree position, so the keys do what the eye expects. Geometry.neighbor ranks every frame on the arrow's side (4 pt tolerance):

1
overlaps the source across the movement axis
2
then the smaller gap between the facing edges
3
then the smaller offset between centres
4
then the lower window id, so ties are stable
1 2 3 from 3: left → 1, up → 2, down → none (unit-tested)
Tiler · engineering
17
04
Input, focus and UI.
macOS 14 changed how a background app may focus windows. Carbon hotkeys, a strict config file and native Settings complete the picture.
Section 04
18
04 · Focus on macOS 14+
Focusing another app's window from the background.

Since macOS 14, NSRunningApplication.activate() from an inactive app is ignored (“cooperative activation”). The first focus hotkeys did nothing.

Tiler uses what yabai and AeroSpace use: bring the process forward with the target window, post a synthetic make-key-window record, then AXRaise.

Which display is “active”?

The display of the focused window, else the one under the mouse. SLSCopyActiveMenuBarDisplayIdentifier reported the wrong display on macOS 27 and swallowed keys.

// PrivateAPI.swift
func focusWindow(pid: pid_t, wid: CGWindowID) {
    var psn = ProcessSerialNumber()
    guard GetProcessForPID(pid, &psn) == noErr
        else { return }
    _ = _SLPSSetFrontProcessWithOptions(
            &psn, wid, 0x200) // user generated
    // make-key-window record (layout: yabai)
    var bytes = [UInt8](repeating: 0, count: 0xf8)
    bytes[0x04] = 0xf8; bytes[0x3a] = 0x10
    // wid at 0x3c, 0xff over 0x20..<0x30
    bytes[0x08] = 0x01
    _ = SLPSPostEventRecordTo(&psn, &bytes)
    bytes[0x08] = 0x02
    _ = SLPSPostEventRecordTo(&psn, &bytes)
}
Tiler · engineering
19
04 · Hotkeys
Carbon hotkeys, registered only where they apply.
CGEventTapRegisterEventHotKey
PermissionInput Monitoring / Accessibilitynone
Seesevery keystrokeonly its own combos
If its thread stallsall typing lagsnothing else is affected
Conflictscan swallow anythingfirst owner wins
  • Per focus. toggleDesktop, showHotkeys, moveResize and the two desktop moves are always registered; the tiling keys only while the active display shows a tiled desktop. Elsewhere apps keep those keys.
  • Suspended while recording. Settings sets hotkeysSuspended, so a new combination reaches the recorder.
  • Keys borrowed. borrow / release: Esc while the overlay is up; arrows, C, M, Return and Esc while the move/resize mode is on (ids from 0x1000, pressed and released events), given back when it ends.
  • Signature "TLRR"; the id indexes Action.allCases.
Tiler · engineering
20
04 · Move/resize mode
Keyboard moves for untiled windows, borrowed keys.
  • Scope. ⌃⌘M acts on the focused window only, and only when no tiled desktop holds it (isTiled). A tiled window gets a note instead.
  • Keys exist only in the mode. Arrows, ⇧/⌥ arrows, C, M, Return, Esc are borrowed Carbon hotkeys; pressed/released events drive a repeat timer (0.3 s, then 45 ms). App switch, Space change or a click ends it.
  • Own AX thread. WindowMover reads the focused window and writes frames on a RunLoopThread; only the newest frame is written (size → position → size), then the frame the app accepted is read back.
  • Pure arithmetic. WindowNudge in TilerCore: 40 pt or 8 pt steps, top-left anchored resize, min 160×100, clamped to the usable area minus the outer gap.
// WindowNudge.swift — unit-tested
public enum Operation {
    case move(Direction)    // arrows
    case resize(Direction)  // ⇧ arrows
    case center             // C
    case maximize           // M
}

static func apply(_ op: Operation,
                  to frame: CGRect,
                  in area: CGRect,
                  step: CGFloat) -> CGRect
Tiler · engineering
21
04 · Focus follows mouse
Focus follows the pointer, at almost no cost.
At rest listen-only tap on mouse-moved copies after delivery: never delays input 0 work, 0 timers Tracking tap switched off pointer checked every 40 ms ≤ 25 wake-ups/s at any mouse rate Rested one CGWindowList read (≈1 ms) topmost real window under pointer focus it if Tiler manages it first move no move for focusDelay tap back on Tap exists only while the setting is on and a desktop is tiled.
Idle0 CPU
10 s of motion≈0.015 s
Same, NSEvent monitor≈0.13 s
  • The cursor and the Dock's full-screen window are skipped in the hit test.
  • A menu, popover or panel on top blocks; any open menu or the app switcher leaves focus alone.
  • A quick pass never focuses: only a rest does.
Tiler · engineering
22
04 · Window to another desktop
Moving a window between Spaces, the way a person would.
0 ms+50+150+650+800 mouse down title bar, right of zoom drag 1 px window is held Mission Control “move right a space” user's own keys mouse up on the new desktop; pointer put back verify current Space = target? focus Synthetic mouse events carry no modifier flags: the shortcut's ⌃⌘ are still held. Since macOS 14.5 the private move-window-to-Space calls fail; Tiler never calls them.
// com.apple.symbolichotkeys: 79 = left, 81 = right
"81": { enabled = 1; parameters = (65535, 124, 8650752) }
// parameters: (unused, key code = →, modifiers = ⌃ + fn)
  • ⌃⌘, / ⌃⌘. work on every desktop. DesktopMover reads the focused window on its own AX thread: frame, zoom-button position, subrole.
  • No phantom drag. beginDesktopMove sets movingWindow: its moved events are ignored and reconcile waits until it lands.
  • State hand-over. The window leaves the old desktop's tree (that desktop lays out when next shown); a tiled target tiles it; a floating window stays floating there, with its frame.
  • Refusals, with a note: no desktop on that side; a full-screen Space next; a dialog or panel; the Mission Control shortcut turned off.
Tiler · engineering
23
04 · Configuration
One file, strictly parsed, written back by Settings.
  • ~/Library/Application Support/Tiler/config.json, written from a template on first launch.
  • Every key required. Missing, unknown, out-of-range or duplicate keys are errors, never defaults. The last good config stays in force; the menu and Settings show the error.
  • Config.json() writes every key in a fixed order. 0.4 added focusFollowsMouse (bool) and focusDelay (0.05–1 s).
  • While the file has an error, Settings is read-only: it never overwrites a file being fixed by hand.
  • Upgrades. A new setting is a missing key, so an error. Settings offers Add Missing Settings: Config.addingMissingSettings adds only the missing keys from the template, by explicit click.
Settings change → new Config config.json() → text Config.parse(text) — validate write atomically reloadConfig() → hotkeys, relayout Controller.save — the file stays the single source of truth
Tiler · engineering
24
04 · Native UI
AppKit where macOS expects it, SwiftUI inside.
  • Settings: NSTabViewController in toolbar style, one NSHostingController per pane with a grouped Form.
  • Overlay: borderless non-activating NSPanel, .hudWindow material. It never becomes key, so the app underneath keeps the keyboard.
  • Menu bar glyph: a template image drawn in code, filled when the desktop is tiled.
  • App icon: rendered by scripts/make-icon.swift; no image assets in the repo.
Tiler Settings, Shortcuts pane Tiler keyboard shortcuts overlay
Tiler · engineering
25
04 · Private APIs
Nine private symbols, none of them for moving windows.
SymbolPurpose
SLSMainConnectionIDwindow-server connection
CGSCopyManagedDisplaySpaces · SLSManagedDisplayGetCurrentSpace · SLSManagedDisplayIsAnimating · SLSCopySpacesForWindowsSpaces, read-only
_AXUIElementGetWindowAX element → window id
GetProcessForPID · _SLPSSetFrontProcessWithOptions · SLPSPostEventRecordTofocus

Risk, managed

  • All work with SIP on; checked on macOS 27.0.1.
  • The same calls yabai, AeroSpace and HyprMac rely on.
  • Frames are written only through the public AX API.
  • No moving windows between Spaces — that path broke on macOS 26/27.
Tiler · engineering
26
05
Quality and shipping.
What it costs at runtime, how it was tested, what broke along the way, what is still open — and how a release reaches Homebrew.
Section 05
27
05 · Runtime cost
Idle means idle.
0%
CPU while nothing changes. No timers at rest, no polling: Tiler wakes only when the window server or an app tells it something happened.
Reconcile pass≈1 ms
Coalescing window20 ms
Memory (debug build)≈52 MB
Threads, last desktop off20 → 8
Worst AX call1 s cap
Mouse moving, focus follows on≈0.15 %
Tiler · engineering
28
05 · Testing
Unit tests for the logic, live checks for the rest.

30 XCTest tests on TilerCore

BSPTree · 11dwindle split, gaps, remove, swap, divider moves, clamp, mirror, equalize, adoptResize
Config · 14template parses, missing / unknown / duplicate keys, “none”, json() round trip, key events, conflicts, adding missing settings, booleans, desktop-move defaults
WindowNudge · 3move stays inside, resize anchors top-left and clamps, centre and fill
Geometry · 2neighbour ranking, hit testing

Checked live on macOS 27

  • tile on activation; persistence across restarts
  • new and closed windows, swap, resize, float, equalize
  • focus left/right; drag-to-swap; gap reload
  • hotkeys released on untiled desktops; other display never touched
  • overlay Esc and toggle with Finder in front; Settings recorder and conflict check
  • move/resize: tiled window refused; arrows, ⇧, ⌥, hold-repeat, fill; keys released on exit
  • focus follows mouse: rest focuses, a fast pass doesn't, an open menu blocks; floating survives a restart; Add Missing Settings
  • desktop moves: untiled → next and back; into a tiled desktop (tiled) and out; full-screen neighbour refused
Tiler · engineering
29
05 · Found and fixed
What live testing caught.
SymptomCauseFix
Focus hotkeys did nothingmacOS 14 ignores activate() from a background appSLPS front-process + key-window record + AXRaise
Keys swallowed on an untiled desktopActive-menu-bar-display call answered wrong on macOS 27Active display = focused window's, else the mouse's
Overlay ignored EscA non-activating panel gets no keyboard while another app is in frontNever take focus; borrow Esc as a hotkey while visible
Settings opened behind other appsSame cooperative-activation rulefocusWindow on our own window
Default keys moved windows awayRectangle and Hammerspoon own ⌃⌥ and Hyper combosDefaults moved to the ⌃⌘ family
Tiler · engineering
30
05 · Known limits
Still open.

Minimum sizes

Apps that refuse a width overlap their neighbour (Outlook stops at 770 pt). A fix must read back sizes without oscillating.

Shared shortcuts

Carbon accepts a combination another tool already owns, and that tool wins. Settings can only see clashes inside Tiler.

Other window tools

Rectangle, Hammerspoon or HyprMac on the same desktop act on the same windows. Use one manager per desktop.

Drag-resize

Unit-tested through adoptResize; drag-swap was checked live, drag-resize not yet.

Tahoe corners

Large, varying window corner radii on macOS 26+ make equal gaps look uneven. Cosmetic.

Shared Spaces

“Displays have separate Spaces” off is handled (the “Main” Space) but not checked live.

Tiler · engineering
31
05 · Build and release
From swift test to brew install.
swift test30 tests must pass universal buildarm64 + x86_64, release signDeveloper ID +hardened runtime notarizenotarytool, stapleapp and DMG packagezip · DMG · SHA256SUMS GitHub releasebiks2013-tools/tilertag · notes · assets Homebrew cask · biks2013-tools/homebrew-tiler version + sha256 of the zip One command does the left five boxes: TILER_SIGN_IDENTITY=… NOTARY_PROFILE=tiler-notary scripts/package.sh
A stable Developer ID signature keeps the Accessibility grant valid across updates. Gatekeeper reports “Notarized Developer ID” for the app and the disk image. Steps and checks: docs/design/release-runbook.md.
Tiler · engineering
32
05 · Get it, read it
Install it, then open the source.
# install
brew tap biks2013-tools/tiler
brew install --cask tiler

# build from source
git clone https://github.com/biks2013-tools/tiler
cd tiler && swift test && swift build

# drive a running instance
scripts/tiler-ctl.sh toggle active
scripts/tiler-ctl.sh retile | moveresize | dump
Sourcegithub.com/biks2013-tools/tiler
Homebrew tapbiks2013-tools/homebrew-tiler
Decksbiks2013-tools.github.io/tiler
Designdocs/design/project-design.md
Logscom.giorgosmarinos.tiler

First launch asks for Accessibility. That is the only permission Tiler needs.

Tiler · engineering
33
Thank you
Do one job,
on one desktop, well.
Questions, issues and pull requests: github.com/biks2013-tools/tiler
BikS2013 · Tiler v0.8.2 · MIT license
brew install --cask tiler