From 063db02c27edf4116f515f3685776740634c3833 Mon Sep 17 00:00:00 2001 From: Yegor Lukash Date: Fri, 17 Jul 2026 08:27:42 +0200 Subject: [PATCH 1/2] Add --map mode and make layout target for generating layout maps --- Makefile | 23 ++++++++++++-- README.md | 22 ++++++++++---- tests/maptool.sh | 32 ++++++++++++++++++++ tools/dump_layout.py | 71 +++++++++++++++++++++++++++++++++++++------- 4 files changed, 128 insertions(+), 20 deletions(-) create mode 100755 tests/maptool.sh diff --git a/Makefile b/Makefile index af05293..5c349bc 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,9 @@ .POSIX: -.PHONY: test test-golden test-integration apply fixtures lint +.PHONY: test test-golden test-integration test-maptool apply fixtures lint layout list-layouts -test: test-golden test-integration +BASE = ABC + +test: test-golden test-integration test-maptool test-golden: tests/golden.sh @@ -9,6 +11,21 @@ test-golden: test-integration: tests/integration.sh +test-maptool: + tests/maptool.sh + +# generate a layout map from macOS layout data, e.g.: +# make layout LAYOUT=Ukrainian -> layouts/ukrainian.map +# make layout LAYOUT=Greek OUT=layouts/el.map +list-layouts: + python3 tools/dump_layout.py --list + +layout: + @[ -n "$(LAYOUT)" ] || { echo 'usage: make layout LAYOUT= [BASE=ABC] [OUT=layouts/.map]'; exit 1; } + @out='$(OUT)'; \ + [ -n "$$out" ] || out="layouts/$$(printf '%s' '$(LAYOUT)' | sed 's/.*\.//' | tr '[:upper:]' '[:lower:]').map"; \ + python3 tools/dump_layout.py --map '$(BASE)' '$(LAYOUT)' > "$$out" && echo "wrote $$out" + # apply mirrors to the running tmux server (same as reloading the config) apply: scripts/mirror.sh @@ -27,4 +44,4 @@ fixtures: done lint: - shellcheck langmap.tmux scripts/mirror.sh tests/golden.sh tests/integration.sh + shellcheck langmap.tmux scripts/mirror.sh tests/golden.sh tests/integration.sh tests/maptool.sh diff --git a/README.md b/README.md index d133ea5..71fa2c8 100644 --- a/README.md +++ b/README.md @@ -73,20 +73,30 @@ Reloading your config re-runs the plugin, which is the same thing. ## Adding a layout +On macOS the map file is generated straight from the system layout data +(the tool reads it via the Carbon framework, so this part is macOS only): + ```sh -python3 tools/dump_layout.py --list -python3 tools/dump_layout.py com.apple.keylayout.ABC com.apple.keylayout.Ukrainian +make list-layouts # find your layout's name +make layout LAYOUT=Ukrainian # writes layouts/ukrainian.map +``` + +Then enable it: + +```tmux +set -g @langmap-layouts 'ukrainian' ``` -Turn the dump into `` lines (drop rows where both -columns match), save as `layouts/uk.map`, then set -`@langmap-layouts 'uk'`. See the headers of the shipped maps for the exact -format. +Short names expand to `com.apple.keylayout.`; `BASE=` (default `ABC`) +and `OUT=` override the reference layout and the output path. On other +systems write the file by hand: `` per line, both +cases listed explicitly, identity pairs omitted (see the shipped maps). ## Development ```sh make test # golden (awk transform vs fixtures) + integration (isolated tmux server) + # + maptool (shipped maps vs live macOS layout data; skipped elsewhere) make apply # apply mirrors to the running tmux server make fixtures # re-capture test fixtures from a clean tmux server make lint # shellcheck over the shell entry points diff --git a/tests/maptool.sh b/tests/maptool.sh new file mode 100755 index 0000000..a050fb7 --- /dev/null +++ b/tests/maptool.sh @@ -0,0 +1,32 @@ +#!/bin/sh +# Verify that dump_layout.py --map regenerates the shipped layout maps from +# the live macOS layout data. Guards both the tool and the committed maps. +# Ordering differs (tool emits in keycode order), so compare sorted data lines. +set -eu + +TESTS_DIR=$(cd "$(dirname "$0")" && pwd) +ROOT=$(dirname "$TESTS_DIR") + +if [ "$(uname)" != "Darwin" ]; then + echo "skip: maptool (needs macOS keyboard layout data)" + exit 0 +fi + +check() { + gen=$(mktemp "${TMPDIR:-/tmp}/langmap-maptool.XXXXXX") + shipped=$(mktemp "${TMPDIR:-/tmp}/langmap-maptool.XXXXXX") + python3 "$ROOT/tools/dump_layout.py" --map ABC "$2" \ + | grep -v '^##' | LC_ALL=C sort > "$gen" + grep -v '^##' "$ROOT/layouts/$1.map" | LC_ALL=C sort > "$shipped" + if diff -u "$shipped" "$gen"; then + echo "ok: maptool $1" + else + echo "FAIL: maptool $1 (generated map differs from shipped)" + rm -f "$gen" "$shipped" + exit 1 + fi + rm -f "$gen" "$shipped" +} + +check ru-pc RussianWin +check ru-mac Russian diff --git a/tools/dump_layout.py b/tools/dump_layout.py index f0c1f9c..6a8473f 100755 --- a/tools/dump_layout.py +++ b/tools/dump_layout.py @@ -9,7 +9,11 @@ from ctypes import (POINTER, byref, c_bool, c_char_p, c_long, c_uint8, c_uint16, c_uint32, c_ulong, c_void_p, create_string_buffer) -Carbon = ctypes.CDLL('/System/Library/Frameworks/Carbon.framework/Carbon') +try: + Carbon = ctypes.CDLL('/System/Library/Frameworks/Carbon.framework/Carbon') +except OSError: + sys.exit('macOS only: this tool reads keyboard layouts via the Carbon ' + 'framework; on other systems write the .map file by hand') Carbon.TISCreateInputSourceList.restype = c_void_p Carbon.TISCreateInputSourceList.argtypes = [c_void_p, c_bool] @@ -80,15 +84,19 @@ def translate(lp, keycode, shift): return ''.join(chr(c) for c in chars[:length.value]) -def main(): - if len(sys.argv) == 2 and sys.argv[1] == '--list': - for src in list_sources(): - sid = source_id(src) - if sid and layout_ptr(src): - print(sid) - return +USAGE = '''usage: + dump_layout.py --list list input source IDs + dump_layout.py 5-column dump for eyeball verification + dump_layout.py --map ready layouts/*.map file on stdout - base_id, target_id = sys.argv[1], sys.argv[2] +Short names expand to com.apple.keylayout., e.g. ABC, RussianWin.''' + + +def expand_id(name): + return name if '.' in name else 'com.apple.keylayout.' + name + + +def find_layouts(base_id, target_id): base = target = None for src in list_sources(): sid = source_id(src) @@ -96,8 +104,49 @@ def main(): base = layout_ptr(src) if sid == target_id: target = layout_ptr(src) - if not base or not target: - sys.exit(f'layout not found: base={bool(base)} target={bool(target)}') + for ptr, sid in ((base, base_id), (target, target_id)): + if not ptr: + sys.exit(f'layout not found: {sid} (see --list)') + return base, target + + +def emit_map(base, target, base_id, target_id): + print(f'## {target_id} vs {base_id}, ANSI keyboard.') + print('## Generated with tools/dump_layout.py --map. Format: ' + ', identity pairs omitted.') + print('## Comment lines start with "##". A line starting with a single ' + '"#" is data.') + for kc in ANSI_KEYCODES: + for shift in (0, 1): + us = translate(base, kc, shift) + ru = translate(target, kc, shift) + # skip dead keys / multi-char output: not bindable as tmux keys + if len(us) != 1 or len(ru) != 1 or us == ru: + continue + print(f'{us}\t{ru}') + + +def main(): + args = sys.argv[1:] + if args == ['--list']: + for src in list_sources(): + sid = source_id(src) + if sid and layout_ptr(src): + print(sid) + return + + as_map = bool(args) and args[0] == '--map' + if as_map: + args = args[1:] + if len(args) != 2: + sys.exit(USAGE) + + base_id, target_id = expand_id(args[0]), expand_id(args[1]) + base, target = find_layouts(base_id, target_id) + + if as_map: + emit_map(base, target, base_id, target_id) + return for kc in ANSI_KEYCODES: row = [translate(base, kc, 0), translate(base, kc, 1), From 7d478580bf40ff48644009d32bd791df205c3657 Mon Sep 17 00:00:00 2001 From: Yegor Lukash Date: Fri, 17 Jul 2026 08:33:50 +0200 Subject: [PATCH 2/2] Pin keyboard type to ANSI for reproducible layout maps --- README.md | 9 ++-- layouts/ru-mac.map | 95 ++++++++++++++++------------------ layouts/ru-pc.map | 90 ++++++++++++++++---------------- tests/fixtures/prefix.expected | 1 + tools/dump_layout.py | 19 +++++-- 5 files changed, 113 insertions(+), 101 deletions(-) diff --git a/README.md b/README.md index 71fa2c8..299065a 100644 --- a/README.md +++ b/README.md @@ -88,9 +88,12 @@ set -g @langmap-layouts 'ukrainian' ``` Short names expand to `com.apple.keylayout.`; `BASE=` (default `ABC`) -and `OUT=` override the reference layout and the output path. On other -systems write the file by hand: `` per line, both -cases listed explicitly, identity pairs omitted (see the shipped maps). +and `OUT=` override the reference layout and the output path. Maps are +generated for the ANSI keyboard type; a few corner keys differ on ISO/JIS +keyboards, pass `--kbd-type native` to `tools/dump_layout.py` for your +machine's own type. On other systems write the file by hand: +`` per line, both cases listed explicitly, +identity pairs omitted (see the shipped maps). ## Development diff --git a/layouts/ru-mac.map b/layouts/ru-mac.map index ec087e7..530cd5c 100644 --- a/layouts/ru-mac.map +++ b/layouts/ru-mac.map @@ -1,80 +1,77 @@ -## Russian - Apple (com.apple.keylayout.Russian) vs US ANSI (com.apple.keylayout.ABC). -## Generated with tools/dump_layout.py on macOS (Darwin 25.3), verified 2026-07-14. -## Format: . One pair per line. Identity pairs omitted. +## com.apple.keylayout.Russian vs com.apple.keylayout.ABC, keyboard type 40 (40 = ANSI). +## Generated with tools/dump_layout.py --map. Format: , identity pairs omitted. ## Comment lines start with "##". A line starting with a single "#" is data. -## Differs from ru-pc: shifted digit row (%, :, comma, dot, ; on Shift+4..8), -## "ё" on the backslash key, "/" and "?" unchanged. +` ] +~ [ +@ " +# № +$ % +% : +^ , +& . +* ; q й -w ц -e у -r к -t е -y н -u г -i ш -o щ -p з -a ф -s ы -d в -f а -g п -h р -j о -k л -l д -z я -x ч -c с -v м -b и -n т -m ь Q Й +w ц W Ц +e у E У +r к R К +t е T Е +y н Y Н +u г U Г +i ш I Ш +o щ O Щ +p з P З +[ х +{ Х +] ъ +} Ъ +\ ё +| Ё +a ф A Ф +s ы S Ы +d в D В +f а F А +g п G П +h р H Р +j о J О +k л K Л +l д L Д +; ж +: Ж +' э +" Э +z я Z Я +x ч X Ч +c с C С +v м V М +b и B И +n т N Т +m ь M Ь -` ] -~ [ -@ " -# № -$ % -% : -^ , -& . -* ; -[ х -{ Х -] ъ -} Ъ -\ ё -| Ё -; ж -: Ж -' э -" Э , б < Б . ю diff --git a/layouts/ru-pc.map b/layouts/ru-pc.map index 04c7701..434f118 100644 --- a/layouts/ru-pc.map +++ b/layouts/ru-pc.map @@ -1,76 +1,74 @@ -## Russian - PC (com.apple.keylayout.RussianWin) vs US ANSI (com.apple.keylayout.ABC). -## Generated with tools/dump_layout.py on macOS (Darwin 25.3), verified 2026-07-14. -## Format: . One pair per line. Identity pairs omitted. -## Comment lines start with "##". A line starting with a single "#" is data -## (the "#" key maps to "№"). +## com.apple.keylayout.RussianWin vs com.apple.keylayout.ABC, keyboard type 40 (40 = ANSI). +## Generated with tools/dump_layout.py --map. Format: , identity pairs omitted. +## Comment lines start with "##". A line starting with a single "#" is data. +` ё +~ Ë +@ " +# № +$ ; +^ : +& ? q й -w ц -e у -r к -t е -y н -u г -i ш -o щ -p з -a ф -s ы -d в -f а -g п -h р -j о -k л -l д -z я -x ч -c с -v м -b и -n т -m ь Q Й +w ц W Ц +e у E У +r к R К +t е T Е +y н Y Н +u г U Г +i ш I Ш +o щ O Щ +p з P З +[ х +{ Х +] ъ +} Ъ +| / +a ф A Ф +s ы S Ы +d в D В +f а F А +g п G П +h р H Р +j о J О +k л K Л +l д L Д +; ж +: Ж +' э +" Э +z я Z Я +x ч X Ч +c с C С +v м V М +b и B И +n т N Т +m ь M Ь -` ] -~ [ -@ " -# № -$ ; -^ : -& ? -[ х -{ Х -] ъ -} Ъ -| / -; ж -: Ж -' э -" Э , б < Б . ю diff --git a/tests/fixtures/prefix.expected b/tests/fixtures/prefix.expected index 57e7962..97eafc8 100644 --- a/tests/fixtures/prefix.expected +++ b/tests/fixtures/prefix.expected @@ -32,6 +32,7 @@ bind-key -T prefix 'ч' confirm-before -p "kill-pane #P? (y/n)" kill-pane bind-key -T prefix 'я' resize-pane -Z bind-key -T prefix 'Х' swap-pane -U bind-key -T prefix 'Ъ' swap-pane -D +bind-key -T prefix 'Ë' show-messages bind-key -T prefix 'M-т' next-window -a bind-key -T prefix 'M-щ' rotate-window -D bind-key -T prefix 'M-з' previous-window -a diff --git a/tools/dump_layout.py b/tools/dump_layout.py index 6a8473f..d2bb4fa 100755 --- a/tools/dump_layout.py +++ b/tools/dump_layout.py @@ -37,7 +37,10 @@ kTISPropertyInputSourceID = c_void_p.in_dll(Carbon, 'kTISPropertyInputSourceID') kTISPropertyUnicodeKeyLayoutData = c_void_p.in_dll(Carbon, 'kTISPropertyUnicodeKeyLayoutData') kCFStringEncodingUTF8 = 0x08000100 -KBD_TYPE = Carbon.LMGetKbdType() +# UCKeyTranslate output depends on the physical keyboard type: e.g. RussianWin +# maps the backtick key to ё on ANSI (40) but to ] on ISO machines. Pin ANSI +# for reproducible maps; --kbd-type overrides (see LMGetKbdType for "native"). +KBD_TYPE = 40 # ANSI virtual keycodes for keys that differ across layouts (letters + punct) ANSI_KEYCODES = [ @@ -89,7 +92,9 @@ def translate(lp, keycode, shift): dump_layout.py 5-column dump for eyeball verification dump_layout.py --map ready layouts/*.map file on stdout -Short names expand to com.apple.keylayout., e.g. ABC, RussianWin.''' +Short names expand to com.apple.keylayout., e.g. ABC, RussianWin. +--kbd-type |native selects the keyboard type (default 40, ANSI); layouts +map corner keys differently per keyboard type, "native" uses this machine's.''' def expand_id(name): @@ -111,7 +116,7 @@ def find_layouts(base_id, target_id): def emit_map(base, target, base_id, target_id): - print(f'## {target_id} vs {base_id}, ANSI keyboard.') + print(f'## {target_id} vs {base_id}, keyboard type {KBD_TYPE} (40 = ANSI).') print('## Generated with tools/dump_layout.py --map. Format: ' ', identity pairs omitted.') print('## Comment lines start with "##". A line starting with a single ' @@ -127,7 +132,15 @@ def emit_map(base, target, base_id, target_id): def main(): + global KBD_TYPE args = sys.argv[1:] + if '--kbd-type' in args: + i = args.index('--kbd-type') + if i + 1 >= len(args): + sys.exit(USAGE) + val = args[i + 1] + KBD_TYPE = Carbon.LMGetKbdType() if val == 'native' else int(val) + del args[i:i + 2] if args == ['--list']: for src in list_sources(): sid = source_id(src)