Skip to content

Instantly share code, notes, and snippets.

@smoser
Last active July 24, 2026 18:34
Show Gist options
  • Select an option

  • Save smoser/79f5094ec13d077165a564a0ba3ea73f to your computer and use it in GitHub Desktop.

Select an option

Save smoser/79f5094ec13d077165a564a0ba3ea73f to your computer and use it in GitHub Desktop.
keyboard stuff

Keyboard layout image renderers

Scripts that render combined QWERTY + Colemak-DH keyboard reference images, highlighting only the keys that differ (big amber = Colemak-DH, small grey = QWERTY).

Scripts

Script Output Matches
render_iso.py ~/Pictures/Screenshots/us-qwerty-colemak-dh.png GNOME's pc105 layout viewer (ISO; includes the extra <> key)
render_ansi.py ~/Pictures/Screenshots/us-qwerty-colemak-dh-ansi.png Physical ANSI hardware, e.g. ThinkPad X1 Carbon

render_ansi.py is the practical one for a US ANSI laptop. Its Colemak-DH letters reflect the actual output of GNOME's us+colemak_dh variant as verified by typing (the ISO "angle-mod" flavour: the left-hand bottom row is shifted, so z x c change and b produces z).

Dependencies

  • Python 3
  • render_iso.py: pillow, numpy, scipy — plus a source QWERTY screenshot
  • render_ansi.py: pillow only (draws from scratch)
  • DejaVu fonts (fonts-dejavu-core) at /usr/share/fonts/truetype/dejavu/

Install on Ubuntu:

sudo apt install python3-pil python3-numpy python3-scipy fonts-dejavu-core

Usage

# ANSI, self-contained:
python3 render_ansi.py [OUTPUT_PNG]

# ISO, composites onto a GNOME QWERTY layout screenshot:
python3 render_iso.py [SOURCE_QWERTY_PNG] [OUTPUT_PNG]

Defaults for render_iso.py: reads ~/Pictures/Screenshots/us-qwerty.png. Capture that from GNOME Settings → Keyboard → your US input source → the "eye" / layout-preview, screenshotted at 993×388 (the scripts tolerate small size differences via key auto-detection).

Notes

  • The CHANGES table in render_iso.py and the ROWS table in render_ansi.py are the source of truth for the mapping. If you switch to a different Colemak-DH variant (e.g. a non-angle one where z x c stay put), edit the bottom row there.

Keyboard layouts on this machine — what we learned

Notes from sorting out why the LUKS unlock prompt came up in Colemak-DH, and how keyboard layout config works across the different "worlds" of a Linux boot on frink (Ubuntu 26.04, ThinkPad X1 Carbon Gen 11).

Companion: render-images/ holds scripts that draw combined QWERTY/Colemak-DH reference images. See render-images/README.md.


TL;DR

  • Layout is configured separately in four places that don't talk to each other. Changing one does nothing to the others.
  • The LUKS prompt keymap is baked into the initramfs from /etc/default/keyboard at the time the initramfs is built. gsettings (GNOME) has no effect on it.
  • Fix for "LUKS in the wrong layout" = edit /etc/default/keyboard, then sudo update-initramfs -u -k all.
  • Current state: boot/LUKS is QWERTY, verified by decoding the baked keymap.

The four worlds

World What selects the layout Config location Runtime tool
initramfs / LUKS prompt keymap baked into the initrd /etc/default/keyboardckbcomp at build time (none — fixed per boot)
Console / VT (tty) console keymap /etc/default/keyboard setupcon, loadkeys
X11 / Xwayland / GDM greeter X's XKB /etc/default/keyboard setxkbmap
GNOME Wayland session (the desktop) Mutter, via gsettings org.gnome.desktop.input-sources Settings UI / gsettings

They share one layout database (/usr/share/X11/xkb/, read via libxkbcommon), so us, colemak_dh, grp:alt_shift_toggle mean the same thing everywhere — but who reads which config differs. That's the whole source of the original confusion: toggling in GNOME never touched the boot prompt.

Gotcha: under Wayland, setxkbmap only affects Xwayland apps, not native Wayland clients. Change the live GNOME layout via the Settings UI / gsettings. localectl set-x11-keymap writes /etc/default/keyboard (console + GDM), but not your logged-in GNOME session.


/etc/default/keyboard and keyboard(5)

The Debian/Ubuntu system keyboard config. man keyboard documents the fields:

XKBMODEL="pc105"
XKBLAYOUT="us"          # comma-separated list = multiple "groups"
XKBVARIANT=""           # per-layout variant, e.g. "colemak_dh"
XKBOPTIONS="grp_led:scroll"
  • Feeds the console keymap (via ckbcomp) and thus the initramfs.
  • /etc/vconsole.conf is a systemd mirror of the same info.
  • Inspect with localectl status.
  • After editing, apply to the console with sudo setupcon, and to the initramfs with sudo update-initramfs -u -k all.

Multiple layouts + a switch:

XKBLAYOUT="us,us"
XKBVARIANT=",colemak_dh"          # group 1 = qwerty, group 2 = colemak-dh
XKBOPTIONS="grp:alt_shift_toggle" # Alt+Shift cycles groups (see below)

GNOME / Wayland (gsettings)

The desktop session layout lives in gsettings, read by Mutter:

gsettings get org.gnome.desktop.input-sources sources
# [('xkb', 'us'), ('xkb', 'us+colemak_dh')]
  • Switch layouts in-session with Super+Space (and Shift+Super+Space back); configured under org.gnome.desktop.wm.keybindings switch-input-source.
  • GNOME ignores XKB grp: toggle options (it swaps whole single-group keymaps itself). It does honour non-group options placed in org.gnome.desktop.input-sources xkb-options (e.g. caps:escape, grp_led:*).
  • This config is session-only — irrelevant to console/initramfs/LUKS.

The LUKS / initramfs story

On Ubuntu this uses initramfs-tools (not dracut/mkinitcpio). The prompt is drawn by plymouth (because quiet splash is on the kernel cmdline), with /usr/lib/cryptsetup/askpass as the text fallback.

  • The keymap the prompt uses is whatever was baked into the initrd when it was last built — a snapshot, independent of later config changes.
  • Rebuild after any keyboard config change: sudo update-initramfs -u -k all.

Why ours briefly showed Colemak-DH

The bad boot ran on an older initrd built when Colemak-DH was the active layout. A later apt-get dist-upgrade rebuilt the initrd from the current config (QWERTY first) → back to QWERTY.

Verify what the LUKS prompt will actually do

Decode the keymap baked into the current initrd and check a physical key (keycode 18 = physical E):

tmp=$(mktemp -d); unmkinitramfs /boot/initrd.img-$(uname -r) "$tmp" 2>/dev/null
zcat "$tmp"/*/etc/console-setup/cached_*.kmap.gz | grep -E '^keycode 18 =' | awk '{print $4}'
rm -rf "$tmp"
# +U+0065 = 'e'  -> QWERTY   |   +U+0066 = 'f'  -> Colemak-DH

XKB "groups" and the Alt+Shift toggle

A group is a full layout you can switch between. XKBLAYOUT="us,us" defines two; a grp: option binds a key to cycle them; grp_led: ties a keyboard LED to "non-first group active."

  • Full option catalog: /usr/share/X11/xkb/rules/evdev.lst (grep for grp: and grp_led:).
  • grp:alt_shift_toggle = Alt+Shift; many common toggles are compiled into the console keymap by ckbcomp, so they'd work at the LUKS prompt — but not inside GNOME (which uses Super+Space instead).
  • To ever toggle at the LUKS prompt you must bake in both a second group and a grp: toggle, then rebuild the initramfs. As currently configured (single us, no toggle) there is nothing to switch — which is the safe state for typing a password blind.

Blind by design

A LUKS prompt has no echo and (on the X1) no usable LED:

  • Neither plymouth nor askpass will echo the password in plaintext — no supported flag, and echoing a disk passphrase is a security problem anyway. The most plymouth offers is a masked dot per keystroke (no character info).
  • grp_led: can only drive the three classic lock LEDs (num/caps/scroll). The X1's mute (platform::mute) and mic-mute (platform::micmute) LEDs belong to the audio subsystem, and FnLock is EC/firmware — none are reachable by XKB.
  • So: no layout indicator at the LUKS prompt. Keep boot QWERTY-only while learning Colemak-DH; practise in the desktop where a typo is cheap.

To interactively test the console layout during boot (for debugging, not at the password field): add break=premount to the kernel cmdline to drop into an initramfs shell, then type / run dumpkeys | head.


This machine's specifics

  • OS/init: Ubuntu 26.04, systemd, initramfs-tools, plymouth.
  • Disk: LUKS on nvme0n1p3_crypt (see /etc/crypttab), root /dev/mapper/vgubuntu-root.
  • Config model: XKBMODEL="pc105" (ISO) even though the laptop keyboard is physically ANSI — so GNOME's layout viewer shows an extra <> key the hardware doesn't have.
  • colemak_dh variant is the ISO "angle-mod" flavour: on ANSI hardware the left-hand bottom row is shifted (see table).

Actual us+colemak_dh output (verified by typing on this ANSI keyboard)

            |  qwerty      |  colemak_dh
 top row    |  qwert yuiop |  qwfpb jluy;
 middle row |  asdfg hjkl; |  arstg mneio
 bottom row |  zxcvb nm,./ |  xcdvz kh,./

Bottom-row remap (note the angle shift): z→x x→c c→d v→v b→z n→k m→h.


Command cheat-sheet

# See current console/X config
localectl status
cat /etc/default/keyboard

# See current GNOME (Wayland session) layout
gsettings get org.gnome.desktop.input-sources sources

# Set boot/console to plain US QWERTY, then rebuild initramfs
sudo sed -i 's/^XKBLAYOUT=.*/XKBLAYOUT="us"/; s/^XKBVARIANT=.*/XKBVARIANT=""/' /etc/default/keyboard
sudo update-initramfs -u -k all

# Verify the baked LUKS keymap (see "Verify" above)

# List all group-switch / LED options
grep -E '^\s*grp:' /usr/share/X11/xkb/rules/evdev.lst
grep -E '^\s*grp_led:' /usr/share/X11/xkb/rules/evdev.lst

References

#!/usr/bin/env python3
"""Render a combined QWERTY + Colemak-DH keyboard image (ANSI).
Drawn from scratch (no source screenshot needed) so the geometry matches a
physical ANSI keyboard such as the ThinkPad X1 Carbon: full-width left Shift
(no ISO "<>" key), single-row wide Enter, backslash on the top row.
The Colemak-DH letters here reflect the *actual* output of GNOME's
`us+colemak_dh` variant as verified by typing on ANSI hardware -- it is the
ISO "angle-mod" flavour, so the left-hand bottom row is shifted:
you press: z x c v b n m
you get: x c d v z k h
(If you ever switch to a non-angle Colemak-DH variant where z/x/c stay put,
update the bottom row in ROWS below.)
Deps: pillow (Ubuntu: python3-pil)
Fonts: fonts-dejavu-core (/usr/share/fonts/truetype/dejavu/)
Usage:
python3 render_ansi.py [OUTPUT_PNG]
"""
import os
import sys
from PIL import Image, ImageDraw, ImageFont
OUT = os.path.expanduser(
sys.argv[1] if len(sys.argv) > 1
else "~/Pictures/Screenshots/us-qwerty-colemak-dh-ansi.png")
FP = "/usr/share/fonts/truetype/dejavu/"
BG = (36, 36, 36)
FILL = (67, 67, 71)
WHITE = (228, 228, 228)
AMBER = (245, 175, 45)
DIM = (150, 150, 150)
SPC = (160, 160, 160)
U = 64 # width of one key unit, px
LEFT = 16
TOP = 80
PITCH = 59 # vertical distance between rows
INSET = 4 # gap between adjacent keys
RAD = 8 # key corner radius
# Each key: (label, width_units, colemak).
# colemak is None -> unchanged letter/number (white, centred)
# 'sp' -> special/modifier key (grey word label)
# '' -> blank key
# <letter> -> differs: big amber colemak + small grey qwerty
ROWS = [
[("`", 1, None), ("1", 1, None), ("2", 1, None), ("3", 1, None),
("4", 1, None), ("5", 1, None), ("6", 1, None), ("7", 1, None),
("8", 1, None), ("9", 1, None), ("0", 1, None), ("-", 1, None),
("=", 1, None), ("Bksp", 2, "sp")],
[("Tab", 1.5, "sp"), ("q", 1, None), ("w", 1, None), ("e", 1, "f"),
("r", 1, "p"), ("t", 1, "b"), ("y", 1, "j"), ("u", 1, "l"),
("i", 1, "u"), ("o", 1, "y"), ("p", 1, ";"), ("[", 1, None),
("]", 1, None), ("\\", 1.5, "sp")],
[("Caps", 1.75, "sp"), ("a", 1, None), ("s", 1, "r"), ("d", 1, "s"),
("f", 1, "t"), ("g", 1, None), ("h", 1, "m"), ("j", 1, "n"),
("k", 1, "e"), ("l", 1, "i"), (";", 1, "o"), ("'", 1, None),
("Enter", 2.25, "sp")],
[("Shift", 2.25, "sp"), ("z", 1, "x"), ("x", 1, "c"), ("c", 1, "d"),
("v", 1, None), ("b", 1, "z"), ("n", 1, "k"), ("m", 1, "h"),
(",", 1, None), (".", 1, None), ("/", 1, None), ("Shift", 2.75, "sp")],
[("Ctrl", 1.25, "sp"), ("", 1.25, ""), ("Alt", 1.25, "sp"),
("", 6.25, ""), ("Alt", 1.25, "sp"), ("", 1.25, ""), ("", 1.25, ""),
("Ctrl", 1.25, "sp")],
]
def main():
f_num = ImageFont.truetype(FP + "DejaVuSans.ttf", 22)
f_big = ImageFont.truetype(FP + "DejaVuSans-Bold.ttf", 24)
f_small = ImageFont.truetype(FP + "DejaVuSans.ttf", 14)
f_sp = ImageFont.truetype(FP + "DejaVuSans.ttf", 14)
f_title = ImageFont.truetype(FP + "DejaVuSans.ttf", 20)
f_leg = ImageFont.truetype(FP + "DejaVuSans.ttf", 17)
f_legb = ImageFont.truetype(FP + "DejaVuSans-Bold.ttf", 17)
w = 993
h = TOP + len(ROWS) * PITCH + 8 + 46
img = Image.new("RGB", (w, h), BG)
d = ImageDraw.Draw(img)
d.text((w // 2, 30), "Keyboard Layout — English (US · ANSI)",
font=f_title, fill=WHITE, anchor="mm")
for r, row in enumerate(ROWS):
yslot = TOP + r * PITCH
x = LEFT
for label, units, cm in row:
kw = units * U
x0, y0 = x + INSET, yslot + INSET
x1, y1 = x + kw - INSET, yslot + PITCH - INSET
d.rounded_rectangle([x0, y0, x1, y1], radius=RAD, fill=FILL)
cxk, cyk = (x0 + x1) // 2, (y0 + y1) // 2
if cm is None:
d.text((cxk, cyk), label, font=f_num, fill=WHITE, anchor="mm")
elif cm == "sp":
d.text((cxk, cyk), label, font=f_sp, fill=SPC, anchor="mm")
elif cm == "":
pass
else:
d.rounded_rectangle([x0 + 1, y0 + 1, x1 - 1, y1 - 1],
radius=RAD + 1, outline=AMBER, width=2)
d.text((cxk, cyk + 4), cm, font=f_big, fill=AMBER, anchor="mm")
d.text((x0 + 7, y0 + 6), label, font=f_small, fill=DIM,
anchor="lt")
x += kw
_legend(d, h, f_small, f_leg, f_legb)
img.save(OUT)
print("saved", OUT, img.size)
def _legend(d, height, f_small, f_leg, f_legb):
ly, lx = height - 23, 20
d.rounded_rectangle([lx, ly - 11, lx + 22, ly + 11], radius=5,
fill=FILL, outline=AMBER, width=2)
d.text((lx + 13, ly + 3), "f",
font=ImageFont.truetype(FP + "DejaVuSans-Bold.ttf", 16),
fill=AMBER, anchor="mm")
d.text((lx + 4, ly - 10), "e", font=f_small, fill=DIM, anchor="lt")
lx += 36
for text, font, colour, gap in [
("big amber = Colemak-DH", f_legb, AMBER, 18),
("small grey = QWERTY", f_leg, DIM, 18),
("· only differing keys marked", f_leg, (120, 120, 120), 0),
]:
d.text((lx, ly), text, font=font, fill=colour, anchor="lm")
lx += d.textlength(text, font=font) + gap
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Render a combined QWERTY + Colemak-DH keyboard image (ISO / pc105).
This composites directly onto a GNOME "Keyboard Layout" screenshot of the
US QWERTY layout, reusing its exact geometry, fonts and dark theme. It:
- auto-detects each key rectangle from the screenshot,
- repaints the keys that differ between QWERTY and Colemak-DH,
- draws the Colemak-DH letter big (amber) with the QWERTY letter small (grey).
Matches GNOME's pc105 rendering, including the extra ISO "<>" key that ANSI
ThinkPad hardware does NOT physically have. For a picture that matches the
physical laptop, use render_ansi.py instead.
Deps: pillow, numpy, scipy (Ubuntu: python3-pil python3-numpy python3-scipy)
Fonts: fonts-dejavu-core (/usr/share/fonts/truetype/dejavu/)
Usage:
python3 render_iso.py [SOURCE_QWERTY_PNG] [OUTPUT_PNG]
"""
import os
import sys
import numpy as np
from PIL import Image, ImageDraw, ImageFont
from scipy import ndimage
SRC = os.path.expanduser(
sys.argv[1] if len(sys.argv) > 1
else "~/Pictures/Screenshots/us-qwerty.png")
OUT = os.path.expanduser(
sys.argv[2] if len(sys.argv) > 2
else "~/Pictures/Screenshots/us-qwerty-colemak-dh.png")
FP = "/usr/share/fonts/truetype/dejavu/"
AMBER = (245, 175, 45)
DIM = (150, 150, 150)
# Keys that differ, as (row_y_center, key_x_center, qwerty, colemak).
# The x/y centres correspond to the 993x388 GNOME screenshot; they are matched
# to auto-detected key boxes with a tolerance, so small screenshot differences
# are fine.
CHANGES = [
(177, 278, "e", "f"), (177, 343, "r", "p"), (177, 408, "t", "b"),
(177, 473, "y", "j"), (177, 538, "u", "l"), (177, 603, "i", "u"),
(177, 668, "o", "y"), (177, 733, "p", ";"),
(236, 229, "s", "r"), (236, 294, "d", "s"), (236, 359, "f", "t"),
(236, 489, "h", "m"), (236, 554, "j", "n"), (236, 619, "k", "e"),
(236, 684, "l", "i"), (236, 749, ";", "o"),
(294, 148, "<", "z"), (294, 213, "z", "x"), (294, 278, "x", "c"),
(294, 343, "c", "d"), (294, 473, "b", "z"), (294, 538, "n", "k"),
(294, 603, "m", "h"),
]
def detect_boxes(arr):
"""Return [x0,y0,x1,y1,cx,cy] for each key-sized bright region."""
lum = arr.mean(axis=2)
lbl, n = ndimage.label(lum > 47) # keys are lighter than the dark bg
boxes = []
for i in range(1, n + 1):
ys, xs = np.where(lbl == i)
if len(xs) < 1500:
continue
x0, x1, y0, y1 = xs.min(), xs.max(), ys.min(), ys.max()
if x1 - x0 < 20 or y1 - y0 < 20:
continue
boxes.append([int(x0), int(y0), int(x1), int(y1),
int((x0 + x1) / 2), int((y0 + y1) / 2)])
return boxes
def find_box(boxes, cx, cy, tol=14):
best, best_d = None, 1e9
for b in boxes:
d = abs(b[4] - cx) + abs(b[5] - cy)
if d < best_d and abs(b[5] - cy) < tol * 2 and abs(b[4] - cx) < tol:
best, best_d = b, d
return best
def main():
img = Image.open(SRC).convert("RGB")
w, h = img.size
arr = np.asarray(img)
boxes = detect_boxes(arr)
# Sample the key fill colour from the blank Caps-Lock key (no glyph).
bk = find_box(boxes, 75, 236, tol=30)
patch = arr[bk[1] + 8:bk[3] - 8, bk[0] + 8:bk[2] - 8].reshape(-1, 3)
fill = tuple(int(v) for v in np.median(patch, axis=0))
f_big = ImageFont.truetype(FP + "DejaVuSans-Bold.ttf", 24)
f_small = ImageFont.truetype(FP + "DejaVuSans.ttf", 14)
f_leg = ImageFont.truetype(FP + "DejaVuSans.ttf", 17)
f_legb = ImageFont.truetype(FP + "DejaVuSans-Bold.ttf", 17)
leg = 46
out = Image.new("RGB", (w, h + leg), (36, 36, 36))
out.paste(img, (0, 0))
d = ImageDraw.Draw(out)
for ry, cx, qw, cm in CHANGES:
b = find_box(boxes, cx, ry)
if not b:
print("WARNING: no key box near", cx, ry)
continue
x0, y0, x1, y1 = b[0], b[1], b[2], b[3]
d.rounded_rectangle([x0 + 2, y0 + 2, x1 - 2, y1 - 2], radius=8, fill=fill)
d.rounded_rectangle([x0 + 1, y0 + 1, x1 - 1, y1 - 1], radius=9,
outline=AMBER, width=2)
d.text(((x0 + x1) // 2, (y0 + y1) // 2 + 4), cm, font=f_big,
fill=AMBER, anchor="mm")
d.text((x0 + 7, y0 + 6), qw, font=f_small, fill=DIM, anchor="lt")
_legend(d, out.size[1], fill, f_small, f_leg, f_legb)
out = out.crop((0, 12, w, h + leg)) # trim tiny top bleed from the screenshot
out.save(OUT)
print("fill", fill, "saved", OUT, out.size)
def _legend(d, height, fill, f_small, f_leg, f_legb):
ly, lx = height - 23, 20
d.rectangle([lx, ly - 9, lx + 18, ly + 9], fill=fill, outline=AMBER, width=2)
d.text((lx + 5, ly - 8), "f",
font=ImageFont.truetype(FP + "DejaVuSans-Bold.ttf", 16),
fill=AMBER, anchor="lt")
d.text((lx + 2, ly - 9), "d", font=f_small, fill=DIM, anchor="lt")
lx += 32
for text, font, colour, gap in [
(" big amber = Colemak-DH", f_legb, AMBER, 18),
("small grey = QWERTY", f_leg, DIM, 18),
("· only differing keys marked", f_leg, (120, 120, 120), 0),
]:
d.text((lx, ly), text, font=font, fill=colour, anchor="lm")
lx += d.textlength(text, font=font) + gap
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment