Mac Automation Stack

Keyboard layers that speed up work on a Mac

13 notes · 19 min

From "hardware" to logic: how I build keyboard layers to speed up work on a Mac: illustration 1

Contents

  1. Keyboard layers2 min
  2. Karabiner = a set of JSON rulesphoto1 min
  3. 1) Caps Lock = Hyper1 min
  4. 2) A layer on Space: tap = space, hold = F181 min
  5. 3) backtick hold = F171 min
  6. 4) Russian layout: targeted rules for Russian typing only1 min
  7. Hammerspoonphoto2 min
  8. Window Manager in Hammerspoon2 min
  9. Media control as scenarios in Hammerspoon3 min
  10. Hammerspoon as a local server and a "remote control"1 min
  11. Monitors and a stable workplace (Hammerspoon)1 min
  12. A MIDI keyboard and OBS as a hardware controller (Hammerspoon)video2 min
  13. The topic of keyboard layers is closed for nowphoto1 min

Keyboard layers

The most basic thing, without which I can hardly find my way around the system any more, is keyboard layers.

I went through different approaches but settled on this pairing:

Karabiner + Hammerspoon

Karabiner-Elements makes the “input hardware” predictable:

it decides which physical key turns into what (tap/hold, a hyper modifier, virtual F keys, rules per layout).

Hammerspoon turns these signals into actions:

launching and focusing apps, managing windows (moving, layouts, moving between monitors, restoring sizes), window navigation, media control for specific apps, system things and quick commands usually hidden in menus. And most importantly, these aren’t just bindings but logic and context (state checks, focusing the right window before an action, delays, fallbacks, scenarios). Hammerspoon also works as a local server for me: I bring up a small HTTP endpoint and can trigger my commands remotely (for example, from Shortcuts on other devices). All in Lua, no magic and no UI mazes.

Why I chose Karabiner + Hammerspoon.

I tried various ready-made solutions, but they almost always have the same problem: I get exactly the set of features the author came up with. That’s fine as long as your scenario matches the “average user”. But as soon as layers, context, non-standard rules, reliable fallbacks and your own logic for windows and media come in, you run into the limits of the interface and the philosophy of a particular program.

Here the approach is different:

  • Karabiner gives me control at the input level: I can turn any physical keys into clean signals and build layers so that typing doesn’t break and conflicts don’t occur.
  • Hammerspoon gives me control at the behaviour level: I describe actions in code, add checks, context, delays and fallbacks, and get a result that works the way I need, not “the way the UI intended”.

In the end it’s not “yet another program with settings” but a platform where I set the rules myself. It’s for this flexibility that I chose this approach.

Back to contents

Karabiner = a set of JSON rules

Karabiner = a set of JSON rules: illustration 1

All the “magic” of Karabiner lives as separate JSON files located at:

/User/.config/karabiner/assets/complex_modifications

This is the folder for so-called Complex Modifications:

each .json file describes one rule or a set of rules.

How it works in reality

  1. I add a new .json to this folder.

  2. Karabiner picks it up as a “modification” that can be enabled.

  3. Then: Karabiner → Complex Modifications → Add rule, and I simply enable the rule I need.

I keep the system modular:

  • one file = one idea
  • rules can be quickly switched on and off, tested and rolled back
  • it’s easy to move to another Mac and keep in git

In effect Karabiner is an “input compiler”: I describe rules in JSON and it reliably turns physical keys into the signals I need.

Next I’ll show a few of my Karabiner modifications, which I built for my workflow and brought to the point of working on autopilot.

Back to contents

1) Caps Lock = Hyper

Caps Lock is taken over as a Hyper modifier (cmd+ctrl+option+shift).

This is the most popular solution and many utilities can do it, but it matters to me that everything related to input lives in one place. I want Karabiner to be responsible for the “keyboard hardware”. So I set Hyper at this level too.

Karabiner has exactly one job here: to replace Caps Lock with a combination that’s almost never used. This turns Caps into a handy “super key” under the little finger and gives a large clean layer of hotkeys.

I’m attaching the JSON file with the modification

Next I’ll show how Space-hold turns into a layer of commands (hotkeys).

Back to contents

2) A layer on Space: tap = space, hold = F18

This is my basic layer and one of the most successful in terms of ergonomics.

The logic is simple: space stays space, and holding space turns into a “mode service key” (in my case the virtual F18). Hammerspoon then listens for F18 and turns on the command layer.

Why Space: it’s in the centre of the keyboard and always under the thumb. It’s the most natural “mode button”, because the thumb is almost always free and easy to hold down without getting in the way of typing.

The key advantage of this approach is that it doesn’t break text input. I type as usual: a quick tap on space always gives a space. And when I need control, I just hold Space and get a temporary command layer that lives exactly as long as the hold is active.

The most important nuance here is hold timing. I retuned this layer and rewrote the JSON several times until I found a comfortable to_if_held_down_threshold_milliseconds value: so that the mode doesn’t turn on by accident while typing, but turns on instantly when needed.

I’m attaching the JSON with this modification.

Next I’ll go through how I build a command hierarchy through F17.

Back to contents

3) backtick hold = F17

By the same logic I have a second layer on backtick: a tap types the character, and a hold sends the virtual F17. I use this layer as an “entry” to an additional command hierarchy through Leader Key.

The scheme is this: holding backtick turns on the layer, and from there I can drop into sublayers by meaning.

For example, backtick (hold) + F means Finder for me. After that the nearest keys on the keyboard become quick shortcuts for opening the folders I need in Finder. This turns navigating the file structure into a couple of presses, with no searching the sidebar and no clicking by hand.

I’ve made a sublayer for ChatGPT in the same way: backtick (hold) + G. In this mode the nearest keys open the right project in ChatGPT. In essence it’s a “project menu under your fingers” that comes up instantly and works predictably.

The convenience of this second layer is that it doesn’t mix everything into one plane: one layer is for everyday navigation, and the backtick layer becomes the entry to deeper, thematic sublayers (folders, projects, tools).

In the next post I’ll show how I make targeted fixes to Russian punctuation in Karabiner without breaking the layout.

Back to contents

4) Russian layout: targeted rules for Russian typing only

Russian-speaking macOS users have a typical problem: in the standard Russian layout comma and period are on Shift+6 and Shift+7. When you write a lot, this is inconvenient and slows typing down.

The most common workaround I’ve seen is to switch to the Russian PC layout in system settings. Punctuation is placed more practically there. But the price of that solution is too high for me: some characters change their behaviour, and the familiar predictability of the question mark between EN and RU breaks too.

So I kept the standard Russian layout and made a targeted fix through Karabiner. The rule turns on only in the Russian input source and doesn’t touch the English layout:

  • Option + comma types ,

  • Option + period types .

As a result , and . in RU end up in the same positions as in EN, but with Option held. For my typing this is more convenient than moving to Russian PC: punctuation becomes accessible, and the rest of the Russian layout logic stays unchanged.

Here I’m describing only the current version of my approach to Karabiner settings. Many implementations and combinations of rules are possible, and over time my setup will most likely expand and evolve. In this post I’m recording exactly the solutions that work best in my daily workflow right now.

In the next post I’ll explain how these signals from Karabiner connect to Hammerspoon and how I turn them into concrete actions.

In the next post I’ll show the basic frame of my Hammerspoon config.

Back to contents

Hammerspoon

Hammerspoon: illustration 1

Hammerspoon is a tool that turns macOS into an environment configured for a specific workflow. In essence it’s a light add-on that listens to keyboard and system events and performs actions described in Lua code. No extra UI or “setup wizards”: behaviour is set directly, as rules and scenarios.

The main value of Hammerspoon is that it works not as “yet another remapper” but as a logic engine:

Launching and focusing apps, windows (positions, layouts, monitors), window navigation, media control, system actions, logic (checks, delays, fallbacks).

In my practice it looks like this:

  • Caps Lock (my “super key” layer): Caps Lock + any digit or key is tied to launching or focusing specific apps. This is my main way of moving around the system without the Dock or search.

  • Space layer: holding Space turns on a separate mode that gathers quick actions, including media control and launching additional apps.

  • Backtick layer: holding backtick is tied to Leader Key: it’s the entry to sublayers where, for example, individual keys open the right folders in Finder or specific projects in ChatGPT. I described this earlier.

Technically all of this is described in the init.lua configuration. The standard path:

~/.hammerspoon/init.lua

It’s convenient to keep the config as code: version it, move it to other devices, develop it step by step. In my setup the configuration syncs through iCloud (via a symlink): the system sees the standard ~/.hammerspoon/ path, but the config actually sits in an iCloud Drive folder, so the same behaviour is available on all devices.

A basic example of what an action looks like in Hammerspoon: a hotkey is created and tied to a function that does the required action, for example opens an app. In the real config this is complemented by layers, modal modes and scenarios that make controlling the system fast and predictable.

What launching an app looks like in Hammerspoon

The basic construct in the config looks like this:

hs.hotkey.bind(hyper, "i", function()
hs.application.open("path to the app")
end)
  • hs.hotkey.bind(...) registers a key combination

  • hyper is a set of modifiers (the Hyper layer)

  • “i” is the trigger key inside the layer

  • function() ... end is the action performed on press

  • hs.application.open(...) opens an app by its exact path

An alternative for many tasks: hs.application.launchOrFocus("AppName").

In the next post I’ll talk about the Window Manager in Hammerspoon.

Back to contents

Window Manager in Hammerspoon

Window management in macOS often fails on details: focus, animations and apps’ internal states. In my Hammerspoon setup windows are controlled directly through the Hammerspoon API (working with the window object and its geometry, not through UI states), so the result is more stable and predictable.

My basic window control interface is built on a Ctrl + Option + key layer. These are quick commands held in muscle memory:

  • Ctrl+Option+A pins a window to the left edge (left half)

  • Ctrl+Option+D pins it to the right edge (right half)

  • Ctrl+Option+S centres the window (and so on)

There are many such combinations: each “logical” key has its own action for moving or resizing a window. For speed on a Mac this is critical: a window stops being an object you “have to arrange by hand” and becomes a state called up with one command.

1) Zero animation

hs.window.animationDuration = 0

2) Two ways of positioning

2.1) moveToUnit(…)

Reliable tiling in screen fractions (0..1): left and right halves, quarters and so on. Resilient to different screens and the Dock/Menu Bar.

2.2) setFrame(…)

For when a specific zone is needed: a 25% column, the bottom quarter, centring and so on. An example of a “right 25% column” from the config:

local win = hs.window.focusedWindow()
local screenFrame = win:screen():frame()
win:setFrame({
  x = screenFrame.x + screenFrame.w * 0.75,
  y = screenFrame.y,
  w = screenFrame.w * 0.25,
  h = screenFrame.h
})

Simply: the working area of the screen is taken and the window is given a rectangle where the width is 25% and x starts at 75% (that is, the right edge).

3) Toggle “arrange / restore”

Before tiling, the original frame is saved in windowStates, and then the window can be returned “as it was” with one action. This makes layouts reversible.

4) App window grid + return

The scenario gathers the current app’s windows, lays them out in a grid on the screen, and restores the original positions when run again. It quickly tidies up and just as quickly brings everything back.

5) Moving between monitors

Moving to a neighbouring screen by topology: west/east/north/south. This is more convenient than “monitor no. 2” because it works by the real arrangement of the screens.

6) Micro-adjustment

Step shifts (in the config the step is 100px) cover the case of “almost perfect, but needs a nudge”.

Why this is more reliable than system hotkeys and some ready-made solutions

System and some third-party commands are often tied to UI state (focus, animations, whether the app “caught up”). Here everything is deterministic: Hammerspoon gets the window, calculates the target geometry and applies it directly (moveToUnit / setFrame / moveToScreen), saving state if needed (windowStates). The plus of Hammerspoon is that I can assemble my own commands and set any parameters in them: exact sizes and coordinates, binding to a screen, shift steps, conditions, saving and restoring state, and any other characteristics my workflow needs.

In the next post: my approach to controlling media through Hammerspoon with delays and fallback.

Back to contents

Media control as scenarios in Hammerspoon

I’d like to have an “inner digital mixer” tied to one control device: to quickly control media and sound in all open apps. I haven’t found a full solution for this yet: what I’ve tried adds latency and sometimes behaves unstably.

With Hammerspoon I managed to get closer to the feeling I want, at least at the level of a practical minimum: controlling playback in specific apps quickly and predictably. It’s not a mixer in the full sense, but it already radically speeds up working with media.

The principle

Not a “universal pause button” but scenarios for specific apps:

activate the right app → let the focus “settle” → send the right command → have a backup path if needed.

1) Spotify: play/pause directly

For Spotify the AppleScript command playpause is used.

The plus of this approach: it isn’t key emulation, the command goes straight to Spotify and doesn’t depend on which window is active.

2) YouTube / Twitch and similar players: focus → k

For YouTube and Twitch the same pattern is used:

  • the app is activated

  • exit from the Space layer before sending k, so the layer doesn’t intercept the key

  • a short delay

  • k is sent (the standard play/pause in players)

Important: exiting the layer here isn’t “cosmetics” but an engineering detail. If the layer stays active, k may be interpreted as a layer command rather than a player command.

Example:

spaceLayer:bind({}, "t", function()
  local app = hs.application.find("Twitch")
  if not app then return hs.alert.show("Twitch is not running") end
  app:activate()
  spaceLayer:exit()
  hs.timer.doAfter(0.05, function() hs.eventtap.keyStroke({}, "k") end)
end)

Breakdown:

  • spaceLayer:bind({}, “t”, …): inside the Space layer the T key starts the scenario.

  • hs.application.find(“Twitch”): find the running Twitch (otherwise show an alert).

  • app:activate(): focus Twitch.

  • spaceLayer:exit(): exit the layer so that k isn’t intercepted by the layer’s hotkeys.

  • hs.timer.doAfter(0.05, …): a small delay for the focus to switch.

  • hs.eventtap.keyStroke({}, “k”): send k (play/pause).

3) Safari: the same principle of “catching the right player”

Separate scenarios are set up for Safari, with the same meaning:

  • focus the browser

  • a short pause

  • send k to the active tab or player

This is convenient when media plays in different places: the command first puts the context in order and only then presses “pause”.

4) Apple Music and Podcasts: focus → space + delay

Music and Podcasts use a different “control language”:

  • the app is activated

  • a small delay (so the focus really moves)

  • space is sent as play/pause

The delay matters here: without it some commands go “nowhere” or to the wrong interface element.

5) Apple Books: UI scripting + fallback (the most reliable option)

Books is made as resilient as possible, because the interface and states there can be capricious:

  • the app is activated

  • through System Events a press of Play/Pause in the menu is tried (different menu names and RU/EN are taken into account)

  • if the menu doesn’t work, the fallback: sending space to the app

In essence this is a “dual-circuit system”: first the most reliable path (the menu), then the backup (the key).

In the next post I’ll show the basic HTTP server code in Hammerspoon and the structure of the endpoints.

Back to contents

Hammerspoon as a local server and a "remote control"

One of the most practical things in my Hammerspoon setup: it works not only as hotkeys but also as a local HTTP server. This turns the Mac into a “hub” that can be triggered with commands from other devices.

How it’s built

  • An HTTP server on a fixed port is brought up (9876 in my case).

  • Authorisation is done with a header token (X-Auth-Token) instead of Basic Auth, because that makes integrating with Shortcuts more convenient and stable.

  • There’s a simple rate limit (a minimum interval between calls) so as not to spam identical commands or run into races.

  • There’s a health check / watchdog: the server regularly checks /ping and restarts if something goes wrong. So it doesn’t “die quietly”.

What it gives in practice

The result is a very direct “remote control” where each endpoint is an action:

  • media commands for specific apps (for example, Twitch/YouTube/Spotify/Music/Podcasts/Books)

  • system commands, including pmset displaysleepnow (to quickly turn off the displays)

The key point: this isn’t a “universal button” but targeted scenarios tied to specific apps and the right sequence of steps (activation, delay, command and so on).

Architecture

  • Mac Studio acts as the hub: it performs actions locally and predictably.

  • Shortcuts on an iPhone or another Mac act as clients: press a button → an HTTP request goes out → the command runs on the Mac.

This gives the feeling of a “home remote”: control of media and system actions isn’t tied to where your hands are, which window is active or which device you’re on.

Next I’ll go through the Hammerspoon + displayplacer pairing: automatic screen layout without manual fixes.

Back to contents

Monitors and a stable workplace (Hammerspoon)

A multi-monitor setup in macOS often behaves chaotically: after displays reconnect, windows “move”, the Dock may end up on the wrong screen, and the monitor layout changes unpredictably. To keep this from breaking the workplace, part of the logic is moved into Hammerspoon.

1) displayplacer + tracking connections

The screen layout is applied not by hand but through displayplacer: Hammerspoon checks for a specific display by persistent ID and understands which configuration is active now (for example, whether an iPad/Sidecar is connected).

2) Automatic layouts through scripts

Scripts are prepared for different scenarios in ~/.layouts/… (roughly “with iPad” and “without iPad”). When the set of screens changes, Hammerspoon waits a short pause, lets macOS “settle”, and then applies the right layout automatically.

3) Anti-chaos after disconnecting Sidecar/iPad

One of the most unpleasant situations: you disconnect the iPad and some windows fly off to the “outermost” screen. In this configuration Hammerspoon tracks the moment of disconnection and neatly returns such windows to the primary display, so you don’t have to search the whole room for them.

In the next post I’ll show how I bring up a WebSocket connection with OBS in Hammerspoon.

Back to contents

A MIDI keyboard and OBS as a hardware controller (Hammerspoon)

I have several MIDI keyboards, so a simple idea came up: use one of them as a physical control panel where keys launch commands. Hammerspoon works here as “firmware”: it receives MIDI events, translates them into actions and controls OBS over WebSocket.

Example:

The config has an action map smkActions[…], and note 48 is assigned:

smkActions[48].label = "OBS: Scene #01"
smkActions[48].fn = function() obsSetSceneByIndex(1) end

What this means:

  • smkActions[48]: the slot for a specific MIDI note (48).

  • .label: a human-readable name (for logs and understanding).

  • .fn = function() … end: the function performed when this MIDI key is pressed.

  • obsSetSceneByIndex(1): switch OBS to the scene with index 1 (the index comes from the OBS scene list).

The MIDI event handler then has this logic: perform actions only in command mode (which is turned on by the sustain pedal/CC64).

The key fragment (the essence):

if smkCommandMode and cmdType == "noteOn" then
  local note = mdTable.note
  local vel  = mdTable.velocity or 0
  if not note or vel <= 0 then return end

  if note < SMK_NOTE_MIN or note > SMK_NOTE_MAX then return end

  local act, layer = smkResolveAction(note)
  if not act then return end
  if not act.hotkey and not act.fn then return end

  -- then debounce and run act.fn / act.hotkey
end
  • smkCommandMode: a boolean flag “command mode on”.

  • cmdType == "noteOn": react to a key press (Note On).

  • mdTable.note: the note number (for example, 48).

  • mdTable.velocity: the force of the press; vel <= 0 is cut off (so as not to trigger “empty” events).

  • SMK_NOTE_MIN/MAX: a range limit, to process only the 25 keys needed.

  • smkResolveAction(note): gets the action for a specific note (taking possible layers and overrides into account).

  • act.fn: if the note has a function, it will be called.

Then obsSetSceneByIndex(1) does roughly this chain:

  1. Gets the list of OBS scenes (through a cache with a small TTL, so as not to poke OBS on every click).

  2. Takes the scene name by index 1.

  3. Sends OBS the SetCurrentProgramScene command over WebSocket.

The final logic of one key

MIDI note 48 → (command mode ON) → find smkActions[48] → call obsSetSceneByIndex(1) → OBS switches the scene to #1.

We meet on the threshold between conditional normality and chaos. Bessmyslennost i skorotechnost

Back to contents

The topic of keyboard layers is closed for now

The topic of keyboard layers is closed for now: illustration 1

It’s important to understand: only part of what can be built on the Karabiner + Hammerspoon pairing is described here. I’ve shown the solutions I came up with for myself and really use every day: layers, signals, logic and scenarios.

The potential is much wider: you can assemble any combinations, add new modes, rules and context, and gradually bring control of the system to a state that suits you perfectly.

I’ll come back to this topic regularly and gradually expand and improve my setup, so the story of layers certainly doesn’t end here, it will only develop

Back to contents