David Balatero

Add QMK visual layer indicator using Hammerspoon


8/30/2020

I recently got a keyboard that supports QMK and have been experimenting with using layers.

I thought it would be nice to add a visual indicator to MacOS, so I can always see which QMK layer is active.

Screenshot of visual layer indicator
The active layer ("raise") is shown in the MacOS menu bar.

To achieve this, you’ll need two things setup:

  • The QMK keymap.c file for your keyboard, so we can add to it
  • Hammerspoon
    • A config file at ~/.hammerspoon/init.lua (it can be blank to start!)

The basic approach is:

  1. When a layer is enabled, send an extra silent key to the operating system
  2. Add a hotkey listener in Hammerspoon to intercept that silent key, and update the layer indicator

Step 1: Send a key when the layer switches #

The first step is to send a key from the QMK firmware to the operating system when a layer is switched. Luckily, F13 through F20 are unused keys on MacOS, and you can hack them for this purpose.

I have two layers on my keyboard:

  1. My main qwerty layer
  2. A raise layer that has the arrow keys, mouse keys, and some volume controls

I decided (arbitrarily) to send F17 when the raise layer is enabled, and F18 when the qwerty layer is enabled.

These are defined in keymap.c as:

#define _QWERTY 0
#define _RAISE 1

Start by creating a custom function that will toggle between the qwerty and raise layers:

void toggle_raise_layer(void);

void toggle_raise_layer() {
if (IS_LAYER_ON(_RAISE)) {
// turn off the layer
layer_off(_RAISE);

// send f18
SEND_STRING(SS_TAP(X_F18));
} else {
// turn layer on
layer_on(_RAISE);

// send f17
SEND_STRING(SS_TAP(X_F17));
}
}

Create a custom keycode so you can toggle the layer on and off:

enum custom_keycodes {
TOGGLE_RAISE = SAFE_RANGE
};

bool process_record_user(uint16_t keycode, keyrecord_t *record) {
switch (keycode) {
case TOGGLE_RAISE:
if (record->event.pressed) toggle_raise_layer();

return false;

default:
// pass all other keypresses through
return true;
}
}

Finally, wire this keycode up to a real key in your keymaps:

// Your keymap looks different than mine!
const uint16_t PROGMEM keymaps[][MATRIX_ROWS][MATRIX_COLS] = {
[_QWERTY] = LAYOUT_6x6(
_______, TOGGLE_RAISE
),

[_RAISE] = LAYOUT_6x6(
_______, _______, ______, TOGGLE_RAISE
)
};

Step 2: Test the F17 and F18 keys #

You can quickly test the F17 and F18 keys are working by adding a quick hotkey bind to ~/.hammerspoon/init.lua:

hs.hotkey.bind({}, 'f17', function()
hs.alert.show("Raise layer on")
end)


hs.hotkey.bind({}, 'f18', function()
hs.alert.show("QWERTY layer on")
end)

Reload Hammerspoon, and tap whatever key you assigned to TOGGLE_RAISE. You should see the alerts from Hammerspoon.

Step 3: Show an indicator with Hammerspoon #

I put together a little Lua object that will draw the layer indicator in the top center of the screen.

Just copy and paste the code below and put it in ~/.hammerspoon/layer-indicator.lua:

local LayerIndicator = {}

local defaultWidth = 75
local defaultHeight = 16
local elementIndexBox = 1
local elementIndexText = 2

function LayerIndicator:new(defaultLayer)
local indicator = {
layer = defaultLayer,
appWatcher = nil,
caffeineWatcher = nil
}

setmetatable(indicator, self)
self.__index = self

indicator.canvas = hs.canvas.new{
w = defaultWidth,
h = defaultHeight,
x = 0,
y = 0,
}

indicator.canvas:insertElement(
{
type = 'rectangle',
action = 'fill',
roundedRectRadii = { xRadius = 4, yRadius = 4 },
fillColor = { red = 0, green = 0, blue = 0, alpha = 1.0 },
frame = { x = "0%", y = "0%", h = "100%", w = "100%", },
},
elementIndexBox
)

indicator.canvas:insertElement(
{
type = 'text',
action = 'fill',
frame = {
x = "10%", y = "0%", h = "100%", w = "95%"
},
text = ""
},
elementIndexText
)

indicator:render()
indicator:show()
indicator:startWatchers()

return indicator
end

function LayerIndicator:startWatchers()
local delayRender = function()
hs.timer.doAfter(10 / 1000, function()
self:render()
end)
end

-- fix alt tabbing from fullscreen games/etc not re-rendering correctly
self.appWatcher = hs.application.watcher.new(function(applicationName, eventType)
if eventType == hs.application.watcher.activated then
delayRender()
end
end)

self.appWatcher:start()

-- fix render on sleep/wake/etc
local caffeineEvents = {}
caffeineEvents[hs.caffeinate.watcher.systemDidWake] = true
caffeineEvents[hs.caffeinate.watcher.screensDidUnlock] = true
caffeineEvents[hs.caffeinate.watcher.screensDidWake] = true

self.caffeineWatcher = hs.caffeinate.watcher.new(function(eventType)
if caffeineEvents[eventType] then
delayRender()
end
end)

self.caffeineWatcher:start()
end

function LayerIndicator:render()
local canvas = self.canvas

-- set the text
canvas:elementAttribute(
elementIndexText,
'text',
hs.styledtext.new(
self.layer.name,
{
font = { name = "Helvetica Bold", size = 11 },
color = self.layer.foreground,
kerning = 0.5
}
)
)

-- box color
canvas:elementAttribute(elementIndexBox, 'fillColor', self.layer.background)

-- position
local frame = self:getFrame()
local frameWidth = frame.w

-- put the indicator in the middle of the screen
local x = (frameWidth / 2) - (defaultWidth / 2)
local y = 3 -- hardcode, you can change this

canvas:topLeft({
x = x,
y = 3
})
end

function LayerIndicator:show()
self.canvas:show()

-- show it above the Menu Bar
self.canvas:level("overlay")
end

function LayerIndicator:getFrame()
return hs.screen.mainScreen():frame()
end

function LayerIndicator:setLayer(layer)
self.layer = layer
self:render()
end

return LayerIndicator

The last thing to do is wire up this LayerIndicator to show your layers. Modify your ~/.hammerspoon/init.lua file:

local function rgba(r, g, b, a)
a = a or 1.0

return {
red = r / 255,
green = g / 255,
blue = b / 255,
alpha = a
}
end

-- Define your layers here, as well as the foreground/background of the
-- layer indicator.
--
-- see http://colorsafe.co for some color combos
local layers = {
default = {
name = "qwerty",
background = rgba(187, 187, 187),
foreground = rgba(46, 52, 59),
},
raise = {
name = "raise",
background = rgba(163, 209, 255),
foreground = rgba(15, 72, 128),
}
}

-- load ~/.hammerspoon/layer-indicator.lua
local LayerIndicator = require "layer-indicator"

-- create a layer indicator
local indicator = LayerIndicator:new(layers.default)

-- intercept QMK keys to change the indicated layer
hs.hotkey.bind({}, 'f17', function()
indicator:setLayer(layers.raise)
end)

hs.hotkey.bind({}, 'f18', function()
indicator:setLayer(layers.default)
end)

Reload your Hammerspoon config, and you should see your new layer indicator overlayed on top of the Mac system menu bar!