designing custom backlighting for my keyboard

using qmk to write custom firmware for my ergodox ez

espurr

i own an ergodox ez, which is a split, columnar keyboard. it does an even better job precluding rsi in my wrists than my old single-board split microsoft keyboard did. ergodox ez keyboards also run open-source qmk firmware, meaning they are extensively configurable. i love my keyboard, but everybody laughs at how silly it looks. :⁠(

the firmware i designed for my ergodox ez implements a flashy reactive backlighting & has a keymap & macros designed for c programming with neovim.

key layout

my layout is basically qwerty but with thumb keys:

keyboard layout

escape & underscore are next to space as fairly important keys. escape is used to change modes in neovim & i use snake case for variables & types in c. that key with tux on it is equivalent to the windows key.

my function keys use qmk's tap dance functionality. in my case, pressing a function key twice or thrice quickly will output a different function key. that way, fewer keys are used for uncommon keycodes.

opposite the underscore key is a macro. c uses -> for accessing structure members via pointers, which is a pretty common operation. when i press the macro key, the MACRO keycode is sent, and my function uses SEND_STRING() to type ->.

switch (keycode) {
case KC_BSPC:
	if (last)
		unregister_code(KC_BSPC), register_code(KC_BSPC);
	break;
case MACRO:
	SEND_STRING(SS_TAP(X_MINUS) SS_LSFT(SS_TAP(X_DOT)));
	break;
case DISCO:
	disco = !disco, lkeys = rkeys = 0;
}
last = (keycode == MACRO);

this function looks for two other keys: backspace & DISCO. when backspace is pressed, it registers the key an extra time if MACRO was the last keycode sent, completely erasing the -> generated by the macro. the DISCO keycode intuitively toggles disco mode.

disco mode

solid or breathing backlighting is totally pedestrian, so i wrote my own, more reactive backlighting. it works like this, with both halves of the keyboard working independently of the other:

those first two points are easy to implement: just keep a counter for both sides that track how many keys are pressed. the last point is a bit more complicated. here is the function that is triggered after every key press:

void
post_process_record_user(uint16_t keycode, keyrecord_t *record)
{
	if (!disco)
		return;

	if (!record->event.pressed) {
		if (record->event.key.row < 7 && lkeys > 0)
			--lkeys;
		else if (record->event.key.row >= 7 && rkeys > 0)
			--rkeys;
		return;
	}

	if (record->event.key.row < 7) {
		lhue = rand() % 255, lval = 255, ++lkeys, lmid = 15;
		if (record->event.key.col != 5)
			lmid = 28 - 2 * record->event.key.row;
		for (uint8_t i = RGBLED_NUM / 2; i < RGBLED_NUM; ++i)
			sethsv(lhue, 255, BASE(lval, i, lmid), &led[i]);
	} else {
		rhue = rand() % 255, rval = 255, ++rkeys, rmid = 14;
		if (record->event.key.col != 5)
			rmid = 27 - 2 * record->event.key.row;
		for (uint8_t i = 0; i < RGBLED_NUM / 2; ++i)
			sethsv(rhue, 255, BASE(rval, i, rmid), &led[i]);
	}
	rgblight_set();
}

the first if block returns if disco mode is inactive so the backlights fade & turn off. the second if block decrements the key counter for either side when a key on that side is released by checking the row number.

the final if block has an else block that operates for the opposite side of the keyboard with different variables, but they both do the same thing. the hue is set to one of 256 colours randomly with lhue = rand() % 255, the intensity is maxed with lval = 255, the number of keys pressed is incremented, and the lmid variable is set to a default value.

that default value represents the index of the LED closest to the middle of the keyboard. the thumb keys have weird column numbers, so an if block checks if the key pressed was not a thumb key. if so, it changes the index to that of an LED under the row of the key which was pressed.

finally, the function iterates through all the LEDs on each side, setting their values. another function, which has the same loops, runs every 20 milliseconds to fade out the backlights when no keys are pressed on a side.

but what about the last point? what does that BASE() macro do? well...

cubic fade & distance scaling

the human perception of change in light intensity follows a logarithmic scale. i approximate this with a cubic function (f(x) = x3 ÷ 2552) in the following macro:

#define BASE(val, ind, mid) ((uint32_t)(val) * (val) * (val) / 255 / 255) *   \
	5 / (((ind) > (mid) ? (ind) - (mid) : (mid) - (ind)) + 5)

the val argument of BASE() is the linearly decremented brightness. the maximum intensity is 255, so i modify the invariant points of my cubic function by dividing x3 by 2552.

the second line of BASE() deals with the other two arguments: ind, which is the index of the LED whose brightness is being calculated, and mid, which is the index of the LED closest to the key which was pressed. dividing the brightness by their difference dims LEDs the further they are from the key pressed.

the constant value 5 controls the magnitude of the decrease. a smaller number would allow greater changes because the difference of ind & mid would have a greater impact on the fraction a ÷ ax (where a is the constant & x is the difference).

result

with some simple counters & some slightly involved maths, the backlights react to key presses on each side based on which key was pressed and fade nicely when all keys are released.

here is the firmware in action: