diff --git a/docs/generate-keyboard-art.py b/docs/generate-keyboard-art.py new file mode 100644 index 0000000..d2303a2 --- /dev/null +++ b/docs/generate-keyboard-art.py @@ -0,0 +1,111 @@ +"""Render ANSI (US) and ISO (German) keyboards as dense ASCII art. + +Cells are sampled at the same 1:1.67 aspect the browser renders monospace at, +so the board keeps its real proportions and legends get enough vertical +resolution to stay readable. +""" +from PIL import Image, ImageDraw, ImageFont +import json + +COLS, CELL_W, CELL_H = 168, 6, 10 +W = COLS * CELL_W # 1008 +UNITS, KEY_ROWS = 15.0, 5 +UNIT = W / UNITS # 67.2 px +H = int(UNIT * KEY_ROWS) # 336 +ROWS = round(H / CELL_H) # 34 +SS = 3 # supersample for clean edges +RAMP = " .:-=+*#%@" +FONT_B = "/System/Library/Fonts/Supplemental/Arial Bold.ttf" + +# (label, units) — ISO gains a key beside left Shift and a tall L-shaped Enter +ANSI = [ + [("`",1),("1",1),("2",1),("3",1),("4",1),("5",1),("6",1),("7",1),("8",1),("9",1),("0",1),("-",1),("=",1),("BACK",2)], + [("TAB",1.5),("Q",1),("W",1),("E",1),("R",1),("T",1),("Y",1),("U",1),("I",1),("O",1),("P",1),("[",1),("]",1),("\\",1.5)], + [("CAPS",1.75),("A",1),("S",1),("D",1),("F",1),("G",1),("H",1),("J",1),("K",1),("L",1),(";",1),("'",1),("ENTER",2.25)], + [("SHIFT",2.25),("Z",1),("X",1),("C",1),("V",1),("B",1),("N",1),("M",1),(",",1),(".",1),("/",1),("SHIFT",2.75)], + [("CTRL",1.25),("OPT",1.25),("CMD",1.25),("",6.25),("CMD",1.25),("OPT",1.25),("CTRL",1.25),("FN",1.25)], +] +ISO = [ + [("^",1),("1",1),("2",1),("3",1),("4",1),("5",1),("6",1),("7",1),("8",1),("9",1),("0",1),("ß",1),("´",1),("BACK",2)], + [("TAB",1.5),("Q",1),("W",1),("E",1),("R",1),("T",1),("Z",1),("U",1),("I",1),("O",1),("P",1),("Ü",1),("+",1),("ENTER",1.5)], + [("CAPS",1.75),("A",1),("S",1),("D",1),("F",1),("G",1),("H",1),("J",1),("K",1),("L",1),("Ö",1),("Ä",1),("#",1),("ENTER",1.25)], + [("SHIFT",1.25),("<",1),("Y",1),("X",1),("C",1),("V",1),("B",1),("N",1),("M",1),(",",1),(".",1),("-",1),("SHIFT",2.75)], + [("CTRL",1.25),("OPT",1.25),("CMD",1.25),("",6.25),("CMD",1.25),("OPT",1.25),("CTRL",1.25),("FN",1.25)], +] + +def boxes(rows): + out = [] + for ri, row in enumerate(rows): + x = 0.0 + for ci, (label, u) in enumerate(row): + w = u * UNIT + # the two ENTER halves in ISO share one id so they light together + kid = "enter" if label == "ENTER" else f"{ri}-{ci}" + out.append((kid, label, x, ri*UNIT, x+w, ri*UNIT+UNIT)) + x += w + return out + +def render(rows): + img = Image.new("L", (W*SS, H*SS), 0) + d = ImageDraw.Draw(img) + pad, rad = 3*SS, 7*SS + for kid, label, x0, y0, x1, y1 in boxes(rows): + d.rounded_rectangle([x0*SS+pad, y0*SS+pad, x1*SS-pad, y1*SS-pad], radius=rad, fill=255) + # bridge the ISO enter halves so the L reads as one cap + ents = [b for b in boxes(rows) if b[0] == "enter"] + if len(ents) == 2: + (_,_,ax0,ay0,ax1,ay1), (_,_,bx0,by0,bx1,by1) = ents + d.rectangle([max(ax0,bx0)*SS+pad, ay0*SS+pad, min(ax1,bx1)*SS-pad, by1*SS-pad], fill=255) + for kid, label, x0, y0, x1, y1 in boxes(rows): + if not label.strip() or (kid == "enter" and y0 > UNIT*1.5): + continue + size = int((46 if len(label) == 1 else 20 if len(label) <= 5 else 17) * SS) + f = ImageFont.truetype(FONT_B, size) + bb = d.textbbox((0,0), label, font=f) + cx, cy = (x0+x1)/2*SS, (y0+y1)/2*SS + d.text((cx-(bb[2]-bb[0])/2-bb[0], cy-(bb[3]-bb[1])/2-bb[1]), label, font=f, fill=0) + return img.resize((W, H), Image.LANCZOS) + +def grid(img): + px = img.load() + g = [] + for r in range(ROWS): + line = [] + for c in range(COLS): + tot = n = 0 + for y in range(r*CELL_H, min((r+1)*CELL_H, H)): + for x in range(c*CELL_W, (c+1)*CELL_W): + tot += px[x, y]; n += 1 + v = (tot/n/255.0) ** 2.2 if n else 0 + line.append(RAMP[min(len(RAMP)-1, int(v*len(RAMP)))]) + g.append("".join(line)) + return g + +def owners(rows): + bs = boxes(rows) + o = [] + for r in range(ROWS): + cy = r*CELL_H + CELL_H/2 + line = [] + for c in range(COLS): + cx = c*CELL_W + CELL_W/2 + hit = next((k for k,l,x0,y0,x1,y1 in bs if x0+2 <= cx <= x1-2 and y0+2 <= cy <= y1-2), None) + line.append(hit) + o.append(line) + return o + +def keymap(rows): + m = {} + for ri, row in enumerate(rows): + x = 0 + for ci, (label, u) in enumerate(row): + if len(label) == 1 and label.strip(): + m[label.upper()] = f"{ri}-{ci}" + return m + +data = {"cols": COLS, "rows": ROWS, "layouts": {}} +for name, rows in (("ansi", ANSI), ("iso", ISO)): + data["layouts"][name] = {"grid": grid(render(rows)), "owner": owners(rows), "keys": keymap(rows)} +json.dump(data, open("kbd3.json","w")) +print(f"{COLS}x{ROWS} cells, aspect {COLS*CELL_W/(ROWS*CELL_H):.2f} (keyboard is 3.00)") +for l in data["layouts"]["iso"]["grid"][12:19]: print(l) diff --git a/docs/generate-keyboard-html.py b/docs/generate-keyboard-html.py new file mode 100644 index 0000000..9917209 --- /dev/null +++ b/docs/generate-keyboard-html.py @@ -0,0 +1,28 @@ +import json +d = json.load(open("kbd3.json")) +COLS, ROWS = d["cols"], d["rows"] + +def esc(t): + return t.replace("&","&").replace("<","<").replace(">",">") + +def markup(layout): + grid, owner = layout["grid"], layout["owner"] + lines = [] + for r in range(ROWS): + out, i = [], 0 + while i < COLS: + k = owner[r][i] + j = i + while j < COLS and owner[r][j] == k: + j += 1 + seg = esc(grid[r][i:j]) + out.append(seg if k is None else f'{seg}') + i = j + lines.append("".join(out)) + return "\n".join(lines) + +arts = {n: markup(l) for n, l in d["layouts"].items()} +keys = {n: l["keys"] for n, l in d["layouts"].items()} +json.dump({"arts": arts, "keys": keys}, open("emit3.json","w")) +for n, a in arts.items(): + print(n, len(a), "bytes,", a.count(" + + + + +InputPilot — the layout follows the keyboard + + + + + + + + + + + + +
+
+ + + Download +
+
+ +
+
+
+ + inputpilot — Magic Keyboard +
+
+
+
+ =========. -========. :========- .========-  =========  =========. -========. :========- .========-  =========  =========. -========. :========- .==================== 
+:@@@@=@@@@+ @@@@%*@@@% @@@@++@@@@ #@@@+=%@@@ =@@@@%*@@@::@@@*==%@@+ @@@@==@@@% @@@++++@@@ #@@@+=%@@@ =@@@+=%@@@::@@@#=#@@@+ @@@*-+@@@% @@@@%+@@@@ #@@@@@@@@@@@@@@@@@@@@:
+:@@@: .@@@+ @@@-  @@@% @@@::. @@@ #@@+-..@@@ =@@@*  @@@::@@@ .:%@@+ @@@ ::+@@% @@@=- -@@@ #@@- : @@@ =@@- = %@@::@@% + *@@+ @@@ = %@@% @@@@*#@@@@ #@@@@@++@+@++*#%@@@@@:
+:@@@:@-%@@+ @@@%- @@@% @@@@= +@@@ #@@@#..@@@ =@@= . %@@::@@@.- =@@+ @@@ . :@@% @@@@::@@@@ #@@= ..@@@ =@@* . *@@::@@+ @ =@@+ @@@ + +@@% @@@@@@@@@@ #@@@@@..-.-:=..#@@@@@:
+:@@@@@@@@@+ @@@@- @@@% @@@. .:@@@ #@@= . @@@ =@@*=. #@@::@@% . *@@+ @@@.. -@@% @@@@ %@@@@ #@@- . @@@ =@@# . @@@::@@@ . %@@+ @@@ +. @@% @@@@@@@@@@ #@@@@@%%%@%@@%@%@@@@@:
+:@@@@@@@@@+ @@@@@@@@@% @@@@@@@@@@ #@@@@@@@@@ =@@@@@@@@@::@@@@@@@@@+ @@@@@@@@@% @@@@@@@@@@ #@@@@@@@@@ =@@@@@@@@@::@@@@%@@@@+ @@@@@%@@@% @@@@@@@@@@ #@@@@@@@@@@@@@@@@@@@@:
+ .........  .........   .........  .........  .........  .........  .........   .........  .........  .........  .........  .........   .........  .................... 
+ %@@@@@@@@@@@@@# :@@@@@@@@%. %@@@@@@@@: #@@@@@@@@= *@@@@@@@@# =@@@@@@@@# :@@@@@@@@%. %@@@@@@@@: #@@@@@@@@= *@@@@@@@@# =@@@@@@@@# :@@@%@%@@%. %@@@@@@@@: #@@@@@@@@@@@@@@:
+:@@@@@@@@@@@@@@@ =@@=...+@@::@:##.+@.@+ @@#...:@@% @@#...:@@@ #@@....+@@ =@@=...=@@::@@-=@+:@@+ @@@@:*@@@% @@@-..-@@@ #@@:..:@@@ =@@#:*:%@@::@@@@=@@@@+ @@@@@@@@@@@@@@@:
+:@@@@==#-*-=@@@@ =@# %@* @@::@.:.  = @+ @@+ ==*@@% @@+ ++ *@@ #@@@= @@@@ =@@@%. %@@::@@.:@- @@+ @@@@ -@@@% @@: @@..@@ #@@ -# -@@ =@@ =@..@@::@@*: :+@@+ @@-=-+=:+-*:-@@:
+:@@@@*+:--:-@@@@ =@# *=- @@::@+  %  =@+ @@+ ==*@@% @@+ - :@@@ #@@@= @@@@ =@@* :@@@@::@@..@- @@+ @@@@ -@@@% @@. @@..@@ #@@  :-@@@ =@@ =@..@@::@@%# *%@@+ @@:---+-#.+::@@:
+:@@@@@@@@@@@@@@@ =@@*... %@::@@.:@- @@+ @@* ...@@% @@* @% +@@ #@@@+.@@@@ =@@. ..=@@::@@*...+@@+ @@@@.+@@@% @@@....%@@ #@@.+@@@@@ =@@. - =@@::@@@@@@@@@+ @@@@@@@@@@@@@@@:
+.@@@@@@@@@@@@@@@ -@@@@@@@@@:.@@@@@@@@@= @@@@@@@@@# @@@@@@@@@@ *@@@@@@@@@ -@@@@@@@@@:.@@@@@@@@@= @@@@@@@@@# @@@@@@@@@@ *@@@@@@@@@ -@@@#*%@@@:.@@@@@@@@@= @@@@@@@@@@@@@@@:
+ ...............     ......     ......     ......     ......      ......     ......     ......     ......     ......      ......     ......     ......    -@@@@@@@@@@@@:
+.@@@@@@@@@@@@@@@@@@ *@@@@@@@@@ =@@@@@@@@@:.@@@@@@@@@+ @@@@@@@@@# @@@@@@@@@@ *@@@@@@@@@ =@@@@@@@@@:.@@@@@@@@@+ @@@@@@@@@# @@@%++%@@@ *@@@+*#@@@ =@@@@@@@@@::@@@@@@@@@@@@:
+:@@@@@@@@@@@@@@@@@@ #@@@. %@@@ =@@- . *@@::@@. . =@@+ @@@ ..:@@% @@%... #@@ #@@ #@.-@@ =@@@@* @@@::@@::# :@@+ @@@ #@@@@% @@@=. -@@@ #@@@:.#@@@ =@@@-- %@@::@@@@@@@@@@@@:
+:@@@@-=#.=..-.%@@@@ #@@= : @@@ =@@- .:#@@::@@.:@+ @@+ @@@ ..+@@% @@:.@=:+@@ #@@ .. -@@ =@@@@* @@@::@@.  :@@@+ @@@ #@@@@% @@- #% :@@ #@@%  -@@@ =@@= . +@@::@@@@@@@@@@@@:
+:@@@@*=++=+@+=%@@@@ #@% .. =@@ =@@:-# :@@::@@..#: @@+ @@@ #@@@@% @@= *= .@@ #@@ #@ -@@ =@@-:= @@@::@@. # -@@+ @@@ +##@@% @@..@@. @@ #@@  . #@@ =@@-   +@@::@@@@@@@@@@@@:
+:@@@@@@@@@@@@@@@@@@ #@*=@@*-@@ =@@%::-@@@::@@+::=@@@+ @@@:%@@@@% @@@*::-%@@ #@@:%@=*@@ =@@%-:#@@@::@@++@#:%@+ @@@-::-@@% @@%....#@@ #@+ ##:.@@ =@@*+=%@@@::@@@@@@@@@@@@:
+ #################+ -########* .#########  #########: *########= +########+ -########* .#########  #########: *########= +########+ -########* .######### .############:
+ ::::::::::::  :::::::::  .::::::::  .::::::::.  ::::::::.  :::::::::  :::::::::  .::::::::  .::::::::.  ::::::::.  :::::::::  :::::::::  .:::::::::::::::::::::::::::: 
+:@@@@@@@@@@@@::@@@@@@@@@+ @@#%@@#@@% @@@#@@#@@@ #@@@%*@@@@ =@@#@@@#@@::@@%##%@@@+ @@%%@@#@@% @@%#@@#%@@ #@@@@@@@@@ =@@@@@@@@@::@@@@@@@@@+ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@:
+:@%*%@##***#@::@@@#-.+@@+ @@* *. @@% @@@ .. @@@ #@@ .: +@@ =@@ :@..@@::@@. : .@@+ @@: =# @@% @@. =+  @@ #@@@%#@@@@ =@@@@@@@@@::@@@%**@@@+ @@@@@@@@@@*##%##*#*%@@@@@@@@@:
+:@-::=::.#:%@::@@* .+@@@+ @@@* .@@@% @@@@  @@@@ #@* @@@@@@ =@@* * %@@::@@. . :@@+ @@:  : @@% @@..  . @@ #@@@*.@@@@ =@@@%.@@@@::@@@%-=@@@+ @@@@@@@@@@::.:..=@:@@@@@@@@@@:
+:@#+#@###@#@@::@@@@%-+@@+ @@@@ =@@@% @@% :: %@@ #@@ .- =@@ =@@@. :@@@::@@. -. @@+ @@:.%  @@% @@.-  - @@ #@@@%@@@@@ =@@@@@@@@@::@@@@@@@@@+ @@@@@@@@@@*##%##@@#@@@@@@@@@@:
+:@@@@@@@@@@@@::@@@@@@@@@+ @@@@#%@@@% @@%#@@#%@@ #@@@#*%@@@ =@@@@*@@@@::@@#**#@@@+ @@%#@@*@@% @@#%%#%#@@ #@@@@@@@@@ =@@@@@@@@@::@@@@@@@@@+ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@:
+ :----------:  :--------  :--------. .--------:  --------:  --------:  :--------  :--------. .--------:  --------:  --------:  :--------  :---------------------------: 
+ ************  ************  ************  ********************************************************************  ************  ************  ************  ************ 
+:@@@@@@@@@@@@::@@@@@@@@@@@@::@@@@@@@@@@@@::@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@::@@@@@@@@@@@@::@@@@@@@@@@@@::@@@@@@@@@@@@::@@@@@@@@@@@@:
+:@@=+=+==+%@@::@@@=++=*-%@@::@@*+*==+=*@@::@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@::@@*+*==+=*@@::@@@=++=*-%@@::@@=+=+==+%@@::@@@@=*=*@@@@:
+:@*-++*..:=%@::@@*-::=%:@@@::@@-==..:=-@@::@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@::@@-==..:=-@@::@@*-::=%:@@@::@*-++*..:=%@::@@@@.#. @@@@:
+:@@@@@@@@@@@@::@@@@@@@@@@@@::@@@@@@@@@@@@::@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@::@@@@@@@@@@@@::@@@@@@@@@@@@::@@@@@@@@@@@@::@@@@@@@@@@@@:
+.@@@@@@@@@@@@:.@@@@@@@@@@@@:.@@@@@@@@@@@@:.@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@:.@@@@@@@@@@@@:.@@@@@@@@@@@@:.@@@@@@@@@@@@:.@@@@@@@@@@@@:
+ ::::::::::::  ::::::::::::  ::::::::::::  ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::  ::::::::::::  ::::::::::::  ::::::::::::  :::::::::::: 
+
+ +
+
+
+ same keys, different board — watch Enter and left Shift change shape + isoansi +
+
+ +

The layout follows the keyboard.Not the app. Not the window.

+

macOS switches input sources per app and per window — never per keyboard. InputPilot watches which keyboard produced the last key press and switches the input source to match. Map each keyboard once, then forget it exists.

+ + +

macOS 13+ · signed & notarized · Apache-2.0 · v1.0.1 · brew install --cask lucagerlich/tap/inputpilot

+
+ +
+ +
+

What it looks like when it works

+

Nothing. You type on the other keyboard and the language is already right. The debug log is the only place it shows its work — useful when reporting a bug, and safe to share.

+ +
+
+ Debug log EXAMPLE + menu bar → Open Debug +
+
+
[10:24:03.112] INFO permission    Input Monitoring granted.
+
[10:24:03.118] INFO hid-monitor   HID monitor started.
+
[10:24:07.402] INFO active-device Active device changed to Magic Keyboard (VID 1452, PID 640).
+
[10:24:07.806] SWITCH auto          Magic Keyboard → German
+
[10:31:15.220] INFO active-device Active device changed to Keychron K2 (VID 13364, PID 630).
+
[10:31:15.624] SWITCH auto          Keychron K2 → U.S.
+
[10:47:02.918] WARN conflict      Mapped source no longer enabled: com.apple.keylayout.French
+
[10:47:02.918] INFO active-device 
+
+
+ +
+
+
Magic Keyboard
+
Vendor ID1452
+
Product ID640
+
TransportUSB
+
LayoutGerman
+
Status■ active
+
+
+
Keychron K2
+
Vendor ID13364
+
Product ID630
+
TransportBluetooth
+
LayoutU.S.
+
Status■ mapped
+
+
+
Built-in Keyboard
+
Vendor ID1452
+
Product ID834
+
TransportInternal
+
Layoutglobal fallback
+
Status■ unmapped
+
+
+
+ +
+

How it works

+

Three steps, once.

+
+
+
01
+

Press a key

+

Type on each keyboard so InputPilot learns it. Devices are recognised again after unplugging, reconnecting, or moving to another port.

+
+
+
02
+

Pick a layout

+

Assign an input source per keyboard in Settings. Add per-device or global fallbacks for anything unmapped.

+
+
+
03
+

Forget about it

+

A 400 ms debounce and a 1.5 s cooldown keep it from flapping. Modifier-only presses are ignored, so ⌘-Tab never changes your layout.

+
+
+
+ +
+

About that permission

+

InputPilot asks for Input Monitoring — the permission that lets an app see keyboard events. That is a lot to ask for, so here is exactly what it does with it.

+
+

It reads which keyboard sent a key press. It does not read the key.

+
    +
  • The HID callback takes two things from each event: the device that sent it, and whether the key was a modifier. Nothing else.
  • +
  • No key code, no character, no typed text exists anywhere in the app — not in memory, not on disk, not in the debug log.
  • +
  • The only network traffic is the update check. No telemetry, no analytics, no crash reporter.
  • +
  • The source is public and the app is about 4,000 lines. The keyboard handling reads in a few minutes.
  • +
+

Full privacy statement →

+
+
+ +
+

Questions

+
+
Why is it not on the App Store? +

Sandboxed apps cannot read HID devices directly, and that is the only way to tell keyboards apart. So InputPilot ships outside the App Store, signed with a Developer ID certificate and notarized by Apple — it opens without warnings.

+
InputPilot does not appear under Input Monitoring. +

Some Macs do not populate that list automatically. Add it by hand: System Settings → Privacy & Security → Input Monitoring → + → pick InputPilot in Applications, then switch it on.

+
I granted permission but nothing happens. +

macOS applies Input Monitoring on the next launch. Quit InputPilot and open it again.

+
Does it work with Bluetooth keyboards? +

Yes. Keyboards are identified by vendor, product, transport and name, so a Bluetooth keyboard is recognised again after reconnecting.

+
What does it cost? +

Nothing. Free and open source under the Apache 2.0 licence.

+
+
+ +
+

Stop fixing your layout by hand

+

macOS 13 or newer. About a minute to set up.

+ +
+ +
+ + + + + + + +