diff --git a/kalamine/cli.py b/kalamine/cli.py
index 34c5cd0..ef52b96 100644
--- a/kalamine/cli.py
+++ b/kalamine/cli.py
@@ -61,13 +61,11 @@ def file_creation_context(ext: str = "") -> Iterator[Path]:
# Linux driver, user-space
with file_creation_context(".xkb_keymap") as xkb_path:
- with xkb_path.open("w", encoding="utf-8", newline="\n") as file:
- file.write(xkb.xkb_keymap(layout))
+ xkb.xkb_write_files(xkb_path, xkb.xkb_keymap(layout))
# Linux driver, root
with file_creation_context(".xkb_symbols") as xkb_custom_path:
- with xkb_custom_path.open("w", encoding="utf-8", newline="\n") as file:
- file.write(xkb.xkb_symbols(layout))
+ xkb.xkb_write_files(xkb_custom_path, xkb.xkb_symbols(layout))
# JSON data
with file_creation_context(".json") as json_path:
@@ -141,12 +139,10 @@ def build(
file.write(keylayout.keylayout(layout))
elif output_file.suffix == ".xkb_keymap":
- with output_file.open("w", encoding="utf-8", newline="\n") as file:
- file.write(xkb.xkb_keymap(layout))
+ xkb.xkb_write_files(output_file, xkb.xkb_keymap(layout))
elif output_file.suffix == ".xkb_symbols":
- with output_file.open("w", encoding="utf-8", newline="\n") as file:
- file.write(xkb.xkb_symbols(layout))
+ xkb.xkb_write_files(output_file, xkb.xkb_symbols(layout))
elif output_file.suffix == ".json":
output_file.write_text(web.pretty_json(layout), encoding="utf8")
diff --git a/kalamine/data/dead_keys.yaml b/kalamine/data/dead_keys.yaml
index 5cdf6c8..aadadeb 100644
--- a/kalamine/data/dead_keys.yaml
+++ b/kalamine/data/dead_keys.yaml
@@ -31,7 +31,7 @@
- char: '*‟' # there’s no “double grave accent” Unicode character :(
name: doublegrave
- base: AaEeIiOoRrUuѴѴ
+ base: AaEeIiOoRrUuѴѵ
alt: ȀȁȄȅȈȉȌȍȐȑȔȕѶѷ
alt_space: '‟' # U+201F HIGH REVERSED-9 QUOTATION MARK
alt_self: '‟' # U+201F HIGH REVERSED-9 QUOTATION MARK
diff --git a/kalamine/data/geometry.yaml b/kalamine/data/geometry.yaml
index 3045e25..8cdcbe2 100644
--- a/kalamine/data/geometry.yaml
+++ b/kalamine/data/geometry.yaml
@@ -162,3 +162,44 @@ ERGO:
keys: [ ac01, ac02, ac03, ac04, ac05, ac06, ac07, ac08, ac09, ac10, ac11, bksl ]
- offset: 2
keys: [ lsgt, ab01, ab02, ab03, ab04, ab05, ab06, ab07, ab08, ab09, ab10 ]
+
+TYPEMATRIX:
+ template: |
+ ╭╌╌╌╌╌┰─────┬─────┬─────┬─────┬─────┰─────┰─────┬─────┬─────┬─────┬─────┰╌╌╌╌╌┬╌╌╌╌╌┬╌╌╌╌╌╮
+ ┆ ⎋ ┆ ┆ ┆ ┆ ┆ ┆ ⌦ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ Num ┆
+ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆
+ ├╌╌╌╌╌╁─────┼─────┼─────┼─────┼─────╁─────╁─────┼─────┼─────┼─────┼─────╁╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌┤
+ ┆ ┃ │ │ │ │ ┃ ⌫ ┃ │ │ │ │ ┃ ┆ ┆ ┆
+ ┆ ┃ 1 │ 2 │ 3 │ 4 │ 5 ┃ ┃ 6 │ 7 │ 8 │ 9 │ 0 ┃ ┆ ┆ ┆
+ ├╌╌╌╌╌╂─────┼─────┼─────┼─────┼─────┨ ┠─────┼─────┼─────┼─────┼─────╂╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌┤
+ ┆ ↹ ┃ │ │ │ │ ┃ ┃ │ │ │ │ ┃ ┆ ┆ ┆
+ ┆ ┃ │ │ │ │ ┃ ┃ │ │ │ │ ┃ ┆ ┆ ┆
+ ├╌╌╌╌╌╂─────┼─────┼─────┼─────┼─────╂─────╂─────┼─────┼─────┼─────┼─────╂╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌┤
+ ┆ ⇧ ┃ │ │ │ │ ┃ ⏎ ┃ │ │ │ │ ┃ ┆ ⇧ ┆ ⇬ ┆
+ ┆ ┃ │ │ │ │ ┃ ┃ │ │ │ │ ┃ ┆ ┆ ┆
+ ┆ ┠─────┼─────┼─────┼─────┼─────┨ ┠─────┼─────┼─────┼─────┼─────╂╌╌╌╌╌┤ ├╌╌╌╌╌┤
+ ┆ ┃ │ │ │ │ ┃ ┃ │ │ │ │ ┃ ┆ ┆ ┆
+ ┆ ┃ │ │ │ │ ┃ ┃ │ │ │ │ ┃ ┆ ┆ ┆
+ ├╌╌╌╌╌╀─────┼─────┼─────┼─────┴─────┸─────┸─────┴─────┼─────┼─────┼─────╀╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌┤
+ ┆ ⎈ ┆ ┆ ≣ ┆ ┆ ␣ ┆ ┆ ⇱ ┆ ⬆ ┆ ⇲ ┆ ⎈ ┆ ⎗ ┆
+ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆
+ ├╌╌╌╌╌┼╌╌╌╌╌┴╌╌┬╌╌┴╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌┤ ├╌╌╌╌╌┤
+ ┆ Fn ┆ ◆ ┆ ⎇ ┆ ┆ ⎇ ┆ ⬅ ┆ ⬇ ┆ ➡ ┆ ┆ ⎘ ┆
+ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆
+ ╰╌╌╌╌╌┴╌╌╌╌╌╌╌╌┴╌╌╌╌╌╌╌╌╯ ╰╌╌╌╌╌┴╌╌╌╌╌┴╌╌╌╌╌┴╌╌╌╌╌┴╌╌╌╌╌╯╌╌╌╌╌╯
+ digits_row: 1
+ rows:
+ - offset: 2
+ keys: [ esc, fk01, fk02, fk03, fk04, fk05, dele, fk06, fk07, fk08, fk09, fk10, fk11, fk12 ]
+ - offset: 2
+ keys: [ tlde, ae01, ae02, ae03, ae04, ae05, bksp, ae06, ae07, ae08, ae09, ae10, ae11, ae12, i148 ]
+ - offset: 2
+ keys: [ tab, ad01, ad02, ad03, ad04, ad05, xxxx, ad06, ad07, ad08, ad09, ad10, ad11, ad12, i163 ]
+ - offset: 2
+ keys: [ lfsh, ac01, ac02, ac03, ac04, ac05, rtrn, ac06, ac07, ac08, ac09, ac10, ac11, rtsh, caps ]
+ - offset: 2
+ keys: [ xxxx, ab01, ab02, ab03, ab04, ab05, xxxx, ab06, ab07, ab08, ab09, ab10, bksl, xxxx, i180 ]
+ - offset: 2
+ keys: [ lctl, lsgt, menu, muhe, spce, xxxx, spce, xxxx, spce, henk, home, up, end, rctl, pgup ]
+ - offset: 8
+ keys: [ lwin, xxxx, lalt, xxxx, xxxx, xxxx, xxxx, xxxx, ralt, left, down, rght, xxxx, pgdn ]
diff --git a/kalamine/data/qwerty_vk.yaml b/kalamine/data/qwerty_vk.yaml
index 1317f3d..9fd2e2d 100644
--- a/kalamine/data/qwerty_vk.yaml
+++ b/kalamine/data/qwerty_vk.yaml
@@ -1,61 +1,130 @@
# Scancodes <-> Virtual Keys as in qwerty
# this is to keep shortcuts at their qwerty location
-'39': 'SPACE'
+'T39': 'SPACE'
# digits
-'02': '1'
-'03': '2'
-'04': '3'
-'05': '4'
-'06': '5'
-'07': '6'
-'08': '7'
-'09': '8'
-'0a': '9'
-'0b': '0'
+'T02': '1'
+'T03': '2'
+'T04': '3'
+'T05': '4'
+'T06': '5'
+'T07': '6'
+'T08': '7'
+'T09': '8'
+'T0A': '9'
+'T0B': '0'
# letters, first row
-'10': 'Q'
-'11': 'W'
-'12': 'E'
-'13': 'R'
-'14': 'T'
-'15': 'Y'
-'16': 'U'
-'17': 'I'
-'18': 'O'
-'19': 'P'
-
+'T10': 'Q'
+'T11': 'W'
+'T12': 'E'
+'T13': 'R'
+'T14': 'T'
+'T15': 'Y'
+'T16': 'U'
+'T17': 'I'
+'T18': 'O'
+'T19': 'P'
+
# letters, second row
-'1e': 'A'
-'1f': 'S'
-'20': 'D'
-'21': 'F'
-'22': 'G'
-'23': 'H'
-'24': 'J'
-'25': 'K'
-'26': 'L'
-'27': 'OEM_1'
+'T1E': 'A'
+'T1F': 'S'
+'T20': 'D'
+'T21': 'F'
+'T22': 'G'
+'T23': 'H'
+'T24': 'J'
+'T25': 'K'
+'T26': 'L'
+'T27': 'OEM_1'
# letters, third row
-'2c': 'Z'
-'2d': 'X'
-'2e': 'C'
-'2f': 'V'
-'30': 'B'
-'31': 'N'
-'32': 'M'
-'33': 'OEM_COMMA'
-'34': 'OEM_PERIOD'
-'35': 'OEM_2'
+'T2C': 'Z'
+'T2D': 'X'
+'T2E': 'C'
+'T2F': 'V'
+'T30': 'B'
+'T31': 'N'
+'T32': 'M'
+'T33': 'OEM_COMMA'
+'T34': 'OEM_PERIOD'
+'T35': 'OEM_2'
# pinky keys
-'29': 'OEM_3'
-'0c': 'OEM_MINUS'
-'0d': 'OEM_PLUS'
-'1a': 'OEM_4'
-'1b': 'OEM_6'
-'28': 'OEM_7'
-'2b': 'OEM_5'
-'56': 'OEM_102'
\ No newline at end of file
+'T29': 'OEM_3'
+'T0C': 'OEM_MINUS'
+'T0D': 'OEM_PLUS'
+'T1A': 'OEM_4'
+'T1B': 'OEM_6'
+'T28': 'OEM_7'
+'T2B': 'OEM_5'
+'T56': 'OEM_102'
+
+# Numpad
+'T52': 'NUMPAD0'
+'T4F': 'NUMPAD1'
+'T50': 'NUMPAD2'
+'T51': 'NUMPAD3'
+'T4B': 'NUMPAD4'
+'T4C': 'NUMPAD5'
+'T4D': 'NUMPAD6'
+'T47': 'NUMPAD7'
+'T48': 'NUMPAD8'
+'T49': 'NUMPAD9'
+'T37': 'MULTIPLY'
+'T4E': 'ADD'
+'T4A': 'SUBTRACT'
+'X35': 'DIVIDE'
+'T53': 'DECIMAL'
+# 'X1C': '' # NumpadEnter (maps to Return)
+# 'T59': 'VK_CLEAR' # NumpadEqual (VK not mappable)
+'T7E': 'VK_ABNT_C2' # NumadComma
+# 'T45': 'NUMLOCK' # (VK not mappable)
+
+# System
+'T0F': 'TAB'
+'T1C': 'RETURN'
+'T0E': 'BACK' # Backspace
+# 'X52': 'INSERT' # (VK not mappable)
+# 'X53': 'DELETE' # (VK not mappable)
+# 'T3B': 'F1' # (VK not mappable)
+# 'T3C': 'F2' # (VK not mappable)
+# 'T3D': 'F3' # (VK not mappable)
+# 'T3E': 'F4' # (VK not mappable)
+# 'T3F': 'F5' # (VK not mappable)
+# 'T40': 'F6' # (VK not mappable)
+# 'T41': 'F7' # (VK not mappable)
+# 'T42': 'F8' # (VK not mappable)
+# 'T43': 'F9' # (VK not mappable)
+# 'T44': 'F10' # (VK not mappable)
+# 'T57': 'F11' # (VK not mappable)
+# 'T58': 'F12' # (VK not mappable)
+# 'X47': 'HOME' # (VK not mappable)
+# 'X4F': 'END' # (VK not mappable)
+# 'X49': 'PRIOR' # PageUp (VK not mappable)
+# 'X51': 'NEXT' # PageDown (VK not mappable)
+# 'T01': 'ESCAPE' # (VK not mappable)
+# 'X48': 'UP' # (VK not mappable)
+# 'X50': 'DOWN' # (VK not mappable)
+# 'X4B': 'LEFT' # (VK not mappable)
+# 'X4D': 'RIGHT' # (VK not mappable)
+# 'X5D': 'APPS' # ContextMenu (VK not mappable)
+
+# Modifiers
+# 'T2A': 'LSHIFT' # ShiftLeft (VK not mappable)
+# 'T36': 'RSHIFT' # ShiftRight (VK not mappable)
+# 'T3A': 'CAPITAL' # CapsLock (VK not mappable)
+# 'T1D': 'LCONTROL' # ControlLeft (VK not mappable)
+# 'X1D': 'RCONTROL' # ControlRight (VK not mappable)
+# 'T38': 'LMENU' # AltLeft (VK not mappable)
+# 'X38': 'RMENU' # AltRight (VK not mappable)
+# 'X5B': 'LWIN' # MetaLeft (VK not mappable)
+# 'X5C': 'RWIN' # MetaRight (VK not mappable)
+
+# Input method
+# 'T7B': 'NONCONVERT' # Muhenkan (VK not mappable)
+# 'T79': 'CONVERT' # Henkan (VK not mappable)
+
+# Miscellaneous
+# 'X22': 'MEDIA_PLAY_PAUSE' # (VK not mappable)
+# 'X32': 'BROWSER_HOME' # (VK not mappable)
\ No newline at end of file
diff --git a/kalamine/data/scan_codes.yaml b/kalamine/data/scan_codes.yaml
index e3aefd2..98a797e 100644
--- a/kalamine/data/scan_codes.yaml
+++ b/kalamine/data/scan_codes.yaml
@@ -1,188 +1,572 @@
-klc:
- spce: '39'
-
- # digits
- ae01: '02'
- ae02: '03'
- ae03: '04'
- ae04: '05'
- ae05: '06'
- ae06: '07'
- ae07: '08'
- ae08: '09'
- ae09: '0a'
- ae10: '0b'
-
- # letters, first row
- ad01: '10'
- ad02: '11'
- ad03: '12'
- ad04: '13'
- ad05: '14'
- ad06: '15'
- ad07: '16'
- ad08: '17'
- ad09: '18'
- ad10: '19'
-
- # letters, second row
- ac01: '1e'
- ac02: '1f'
- ac03: '20'
- ac04: '21'
- ac05: '22'
- ac06: '23'
- ac07: '24'
- ac08: '25'
- ac09: '26'
- ac10: '27'
-
- # letters, third row
- ab01: '2c'
- ab02: '2d'
- ab03: '2e'
- ab04: '2f'
- ab05: '30'
- ab06: '31'
- ab07: '32'
- ab08: '33'
- ab09: '34'
- ab10: '35'
-
- # pinky keys
- tlde: '29'
- ae11: '0c'
- ae12: '0d'
- ae13: '0d' # XXX FIXME
- ad11: '1a'
- ad12: '1b'
- ac11: '28'
- ab11: '28' # XXX FIXME
- bksl: '2b'
- lsgt: '56'
-
-osx:
- spce: 49
-
- # digits
- ae01: 18 # 1
- ae02: 19 # 2
- ae03: 20 # 3
- ae04: 21 # 4
- ae05: 23 # 5
- ae06: 22 # 6
- ae07: 26 # 7
- ae08: 28 # 8
- ae09: 25 # 9
- ae10: 29 # 0
-
- # letters, first row
- ad01: 12 # Q
- ad02: 13 # W
- ad03: 14 # E
- ad04: 15 # R
- ad05: 17 # T
- ad06: 16 # Y
- ad07: 32 # U
- ad08: 34 # I
- ad09: 31 # O
- ad10: 35 # P
-
- # letters, second row
- ac01: 0 # A
- ac02: 1 # S
- ac03: 2 # D
- ac04: 3 # F
- ac05: 5 # G
- ac06: 4 # H
- ac07: 38 # J
- ac08: 40 # K
- ac09: 37 # L
- ac10: 41 # ★
-
- # letters, third row
- ab01: 6 # Z
- ab02: 7 # X
- ab03: 8 # C
- ab04: 9 # V
- ab05: 11 # B
- ab06: 45 # N
- ab07: 46 # M
- ab08: 43 # ,
- ab09: 47 # .
- ab10: 44 # /
-
- # pinky keys
- tlde: 50 # ~
- ae11: 27 # -
- ae12: 24 # =
- ae13: 42 # XXX FIXME
- ad11: 33 # [
- ad12: 30 # ]
- ac11: 39 # '
- ab11: 39 # XXX FIXME
- bksl: 42 # \
- lsgt: 10 # <
-
-web:
- spce: 'Space'
-
- # digits
- ae01: 'Digit1'
- ae02: 'Digit2'
- ae03: 'Digit3'
- ae04: 'Digit4'
- ae05: 'Digit5'
- ae06: 'Digit6'
- ae07: 'Digit7'
- ae08: 'Digit8'
- ae09: 'Digit9'
- ae10: 'Digit0'
-
- # letters, 1st row
- ad01: 'KeyQ'
- ad02: 'KeyW'
- ad03: 'KeyE'
- ad04: 'KeyR'
- ad05: 'KeyT'
- ad06: 'KeyY'
- ad07: 'KeyU'
- ad08: 'KeyI'
- ad09: 'KeyO'
- ad10: 'KeyP'
-
- # letters, 2nd row
- ac01: 'KeyA'
- ac02: 'KeyS'
- ac03: 'KeyD'
- ac04: 'KeyF'
- ac05: 'KeyG'
- ac06: 'KeyH'
- ac07: 'KeyJ'
- ac08: 'KeyK'
- ac09: 'KeyL'
- ac10: 'Semicolon'
-
- # letters, 3rd row
- ab01: 'KeyZ'
- ab02: 'KeyX'
- ab03: 'KeyC'
- ab04: 'KeyV'
- ab05: 'KeyB'
- ab06: 'KeyN'
- ab07: 'KeyM'
- ab08: 'Comma'
- ab09: 'Period'
- ab10: 'Slash'
-
- # pinky keys
- tlde: 'Backquote'
- ae11: 'Minus'
- ae12: 'Equal'
- ae13: 'IntlYen'
- ad11: 'BracketLeft'
- ad12: 'BracketRight'
- bksl: 'Backslash'
- ac11: 'Quote'
- ab11: 'IntlRo'
- lsgt: 'IntlBackslash'
+digits:
+- xkb: "ae01"
+ web: "Digit1"
+ windows: "T02"
+ macos: "18"
+ hand: null
+- xkb: "ae02"
+ web: "Digit2"
+ windows: "T03"
+ macos: "19"
+ hand: null
+- xkb: "ae03"
+ web: "Digit3"
+ windows: "T04"
+ macos: "20"
+ hand: null
+- xkb: "ae04"
+ web: "Digit4"
+ windows: "T05"
+ macos: "21"
+ hand: null
+- xkb: "ae05"
+ web: "Digit5"
+ windows: "T06"
+ macos: "23"
+ hand: null
+- xkb: "ae06"
+ web: "Digit6"
+ windows: "T07"
+ macos: "22"
+ hand: null
+- xkb: "ae07"
+ web: "Digit7"
+ windows: "T08"
+ macos: "26"
+ hand: null
+- xkb: "ae08"
+ web: "Digit8"
+ windows: "T09"
+ macos: "28"
+ hand: null
+- xkb: "ae09"
+ web: "Digit9"
+ windows: "T0A"
+ macos: "25"
+ hand: null
+- xkb: "ae10"
+ web: "Digit0"
+ windows: "T0B"
+ macos: "29"
+ hand: null
+# Letters, first row
+Letters1:
+- xkb: "ad01"
+ web: "KeyQ"
+ windows: "T10"
+ macos: "12"
+ hand: null
+- xkb: "ad02"
+ web: "KeyW"
+ windows: "T11"
+ macos: "13"
+ hand: null
+- xkb: "ad03"
+ web: "KeyE"
+ windows: "T12"
+ macos: "14"
+ hand: null
+- xkb: "ad04"
+ web: "KeyR"
+ windows: "T13"
+ macos: "15"
+ hand: null
+- xkb: "ad05"
+ web: "KeyT"
+ windows: "T14"
+ macos: "17"
+ hand: null
+- xkb: "ad06"
+ web: "KeyY"
+ windows: "T15"
+ macos: "16"
+ hand: null
+- xkb: "ad07"
+ web: "KeyU"
+ windows: "T16"
+ macos: "32"
+ hand: null
+- xkb: "ad08"
+ web: "KeyI"
+ windows: "T17"
+ macos: "34"
+ hand: null
+- xkb: "ad09"
+ web: "KeyO"
+ windows: "T18"
+ macos: "31"
+ hand: null
+- xkb: "ad10"
+ web: "KeyP"
+ windows: "T19"
+ macos: "35"
+ hand: null
+# Letters, second row
+Letters2:
+- xkb: "ac01"
+ web: "KeyA"
+ windows: "T1E"
+ macos: "0"
+ hand: null
+- xkb: "ac02"
+ web: "KeyS"
+ windows: "T1F"
+ macos: "1"
+ hand: null
+- xkb: "ac03"
+ web: "KeyD"
+ windows: "T20"
+ macos: "2"
+ hand: null
+- xkb: "ac04"
+ web: "KeyF"
+ windows: "T21"
+ macos: "3"
+ hand: null
+- xkb: "ac05"
+ web: "KeyG"
+ windows: "T22"
+ macos: "5"
+ hand: null
+- xkb: "ac06"
+ web: "KeyH"
+ windows: "T23"
+ macos: "4"
+ hand: null
+- xkb: "ac07"
+ web: "KeyJ"
+ windows: "T24"
+ macos: "38"
+ hand: null
+- xkb: "ac08"
+ web: "KeyK"
+ windows: "T25"
+ macos: "40"
+ hand: null
+- xkb: "ac09"
+ web: "KeyL"
+ windows: "T26"
+ macos: "37"
+ hand: null
+- xkb: "ac10"
+ web: "Semicolon"
+ windows: "T27"
+ macos: "41"
+ hand: null
+# Letters, third row
+Letters3:
+- xkb: "ab01"
+ web: "KeyZ"
+ windows: "T2C"
+ macos: "6"
+ hand: null
+- xkb: "ab02"
+ web: "KeyX"
+ windows: "T2D"
+ macos: "7"
+ hand: null
+- xkb: "ab03"
+ web: "KeyC"
+ windows: "T2E"
+ macos: "8"
+ hand: null
+- xkb: "ab04"
+ web: "KeyV"
+ windows: "T2F"
+ macos: "9"
+ hand: null
+- xkb: "ab05"
+ web: "KeyB"
+ windows: "T30"
+ macos: "11"
+ hand: null
+- xkb: "ab06"
+ web: "KeyN"
+ windows: "T31"
+ macos: "45"
+ hand: null
+- xkb: "ab07"
+ web: "KeyM"
+ windows: "T32"
+ macos: "46"
+ hand: null
+- xkb: "ab08"
+ web: "Comma"
+ windows: "T33"
+ macos: "43"
+ hand: null
+- xkb: "ab09"
+ web: "Period"
+ windows: "T34"
+ macos: "47"
+ hand: null
+- xkb: "ab10"
+ web: "Slash"
+ windows: "T35"
+ macos: "44"
+ hand: null
+# Pinky keys
+PinkyKeys:
+- xkb: "ae11"
+ web: "Minus"
+ windows: "T0C"
+ macos: "27"
+ hand: null
+- xkb: "ae12"
+ web: "Equal"
+ windows: "T0D"
+ macos: "24"
+ hand: null
+- xkb: "ae13"
+ web: "IntlYen"
+ windows: "T0D"
+ macos: "42"
+ hand: null
+- xkb: "ad11"
+ web: "BracketLeft"
+ windows: "T1A"
+ macos: "33"
+ hand: null
+- xkb: "ad12"
+ web: "BracketRight"
+ windows: "T1B"
+ macos: "30"
+ hand: null
+- xkb: "ac11"
+ web: "Quote"
+ windows: "T28"
+ macos: "39"
+ hand: null
+- xkb: "ab11"
+ web: "IntlRo"
+ windows: "T28"
+ macos: "39"
+ hand: null
+- xkb: "tlde"
+ web: "Backquote"
+ windows: "T29"
+ macos: "50"
+ hand: null
+- xkb: "bksl"
+ web: "Backslash"
+ windows: "T2B"
+ macos: "42"
+ hand: null
+- xkb: "lsgt"
+ web: "IntlBackslash"
+ windows: "T56"
+ macos: "10"
+ hand: null
+# Space bar row
+SpaceBar:
+- xkb: "spce"
+ web: "Space"
+ windows: "T39"
+ macos: "49"
+ hand: null
+Numpad:
+- xkb: kp0
+ web: Numpad0
+ windows: T52
+ macos: null
+ hand: null
+- xkb: kp1
+ web: Numpad1
+ windows: T4F
+ macos: null
+ hand: null
+- xkb: kp2
+ web: Numpad2
+ windows: T50
+ macos: null
+ hand: null
+- xkb: kp3
+ web: Numpad3
+ windows: T51
+ macos: null
+ hand: null
+- xkb: kp4
+ web: Numpad4
+ windows: T4B
+ macos: null
+ hand: null
+- xkb: kp5
+ web: Numpad5
+ windows: T4C
+ macos: null
+ hand: null
+- xkb: kp6
+ web: Numpad6
+ windows: T4D
+ macos: null
+ hand: null
+- xkb: kp7
+ web: Numpad7
+ windows: T47
+ macos: null
+ hand: null
+- xkb: kp8
+ web: Numpad8
+ windows: T48
+ macos: null
+ hand: null
+- xkb: kp9
+ web: Numpad9
+ windows: T49
+ macos: null
+ hand: null
+- xkb: kpen
+ web: NumpadEnter
+ windows: X1C
+ macos: null
+ hand: null
+- xkb: kpeq
+ web: NumpadEqual
+ windows: T59
+ macos: null
+ hand: null
+- xkb: kpdl
+ web: NumpadDecimal
+ windows: T53
+ macos: null
+ hand: null
+- xkb: kppt
+ web: NumpadComma
+ windows: T7E
+ macos: null
+ hand: null
+- xkb: kpdv
+ web: NumpadDivide
+ windows: X35
+ macos: null
+ hand: null
+- xkb: kpmu
+ web: NumpadMultiply
+ windows: T37
+ macos: null
+ hand: null
+- xkb: kpsu
+ web: NumpadSubtract
+ windows: T4A
+ macos: null
+ hand: null
+- xkb: kpad
+ web: NumpadAdd
+ windows: T4E
+ macos: null
+ hand: null
+- xkb: nmlk
+ web: NumLock
+ windows: T45
+ macos: null
+ hand: null
+System:
+- xkb: "tab"
+ web: Tab
+ windows: T0F
+ macos: null
+ hand: Left
+- xkb: "rtrn"
+ web: Enter
+ windows: T1C
+ macos: null
+ hand: null
+- xkb: "bksp"
+ web: Backspace
+ windows: T0E
+ macos: null
+ hand: null
+- xkb: "dele"
+ web: Delete
+ windows: X53
+ macos: null
+ hand: null
+- xkb: "esc"
+ web: Escape
+ windows: T01
+ macos: null
+ hand: null
+- xkb: "menu"
+ web: ContextMenu
+ windows: X5D
+ macos: null
+ hand: null
+- xkb: "home"
+ web: Home
+ windows: X47
+ macos: null
+ hand: null
+- xkb: "end"
+ web: End
+ windows: X4F
+ macos: null
+ hand: null
+- xkb: "up"
+ web: ArrowUp
+ windows: X48
+ macos: null
+ hand: null
+- xkb: "down"
+ web: ArrowDown
+ windows: X50
+ macos: null
+ hand: null
+- xkb: "left"
+ web: ArrowLeft
+ windows: X4B
+ macos: null
+ hand: null
+- xkb: "rght"
+ web: ArrowDown
+ windows: X50
+ macos: null
+ hand: null
+- xkb: "pgup"
+ web: PageUp
+ windows: X49
+ macos: null
+ hand: null
+- xkb: "pgdn"
+ web: PageDown
+ windows: X51
+ macos: null
+ hand: null
+- xkb: "fk01"
+ web: F1
+ windows: T3B
+ macos: null
+ hand: null
+- xkb: "fk02"
+ web: F2
+ windows: T3C
+ macos: null
+ hand: null
+- xkb: "fk03"
+ web: F3
+ windows: T3D
+ macos: null
+ hand: null
+- xkb: "fk04"
+ web: F4
+ windows: T3E
+ macos: null
+ hand: null
+- xkb: "fk05"
+ web: F5
+ windows: T3F
+ macos: null
+ hand: null
+- xkb: "fk06"
+ web: F6
+ windows: T40
+ macos: null
+ hand: null
+- xkb: "fk07"
+ web: F7
+ windows: T41
+ macos: null
+ hand: null
+- xkb: "fk08"
+ web: F8
+ windows: T42
+ macos: null
+ hand: null
+- xkb: "fk09"
+ web: F9
+ windows: T43
+ macos: null
+ hand: null
+- xkb: "fk10"
+ web: F10
+ windows: T44
+ macos: null
+ hand: null
+- xkb: "fk11"
+ web: F11
+ windows: T57
+ macos: null
+ hand: null
+- xkb: "fk12"
+ web: F12
+ windows: T58
+ macos: null
+ hand: null
+Modifiers:
+- xkb: "lfsh"
+ web: ShiftLeft
+ windows: T2A
+ macos: null
+ hand: Left
+- xkb: "rtsh"
+ web: ShiftRight
+ windows: T36
+ macos: null
+ hand: Right
+- xkb: "caps"
+ web: CapsLock
+ windows: T3A
+ macos: null
+ hand: Left
+- xkb: "lalt"
+ web: "AltLeft"
+ windows: T38
+ macos: null
+ hand: Left
+- xkb: "ralt"
+ web: "AltRight"
+ windows: X38
+ macos: null
+ hand: Right
+- xkb: "lctl"
+ web: ControlLeft
+ windows: T1D
+ macos: null
+ hand: Left
+- xkb: "rctl"
+ web: ControlRight
+ windows: X1D
+ macos: null
+ hand: Right
+- xkb: lwin
+ web: MetaLeft
+ windows: X5B
+ macos: null
+ hand: Left
+- xkb: rwin
+ web: MetaRight
+ windows: X5C
+ macos: null
+ hand: Right
+InputMethod:
+- xkb: "muhe"
+ web: "NonConvert"
+ windows: T7B
+ macos: null
+ hand: Left
+- xkb: "henk"
+ web: "Convert"
+ windows: T79
+ macos: null
+ hand: Right
+# Miscellaneous
+Miscellaneous:
+- xkb: "i148" # Calc
+ web: null
+ windows: null
+ macos: null
+ hand: null
+- xkb: "i163" # Mail
+ web: null
+ windows: null
+ macos: null
+ hand: null
+- xkb: "i172" # Play/Pause
+ web: MediaPlayPause
+ windows: X22
+ macos: null
+ hand: null
+- xkb: "i180" # Home page
+ web: BrowserHome
+ windows: X32
+ macos: null
+ hand: null
diff --git a/kalamine/generators/ahk.py b/kalamine/generators/ahk.py
index 47558b5..315a875 100644
--- a/kalamine/generators/ahk.py
+++ b/kalamine/generators/ahk.py
@@ -6,13 +6,14 @@
"""
import json
-from typing import TYPE_CHECKING, Dict, List
+from typing import TYPE_CHECKING, Dict, List, Optional
if TYPE_CHECKING:
from ..layout import KeyboardLayout
+from ..key import KEYS, Key, KeyCategory
from ..template import load_tpl, substitute_lines
-from ..utils import LAYER_KEYS, SCAN_CODES, Layer, load_data
+from ..utils import Layer, load_data
def ahk_keymap(layout: "KeyboardLayout", altgr: bool = False) -> List[str]:
@@ -21,6 +22,7 @@ def ahk_keymap(layout: "KeyboardLayout", altgr: bool = False) -> List[str]:
prefixes = [" ", "+", "", "", " <^>!", "<^>!+"]
specials = " \u00a0\u202f‘’'\"^`~"
esc_all = True # set to False to ease the debug (more readable AHK script)
+ layers = (Layer.ALTGR, Layer.ALTGR_SHIFT) if altgr else (Layer.BASE, Layer.SHIFT)
def ahk_escape(key: str) -> str:
if len(key) == 1:
@@ -38,30 +40,37 @@ def ahk_actions(symbol: str) -> Dict[str, str]:
return actions
output = []
- for key_name in LAYER_KEYS:
- if key_name.startswith("-"):
- output.append(f"; {key_name[1:]}")
- output.append("")
+ prev_category: Optional[KeyCategory] = None
+ for key in KEYS.values():
+ # TODO: delete test?
+ # if key.id in ["ae13", "ab11"]: # ABNT / JIS keys
+ # continue # these two keys are not supported yet
+ # TODO: add support for all scan codes
+ if key.windows is None or not key.windows.startswith("T"):
+ continue
+
+ # Skip key if not defined and is not alphanumeric
+ if not any(key.id in layout.layers[i] for i in layers) and not key.alphanum:
continue
- if key_name in ["ae13", "ab11"]: # ABNT / JIS keys
- continue # these two keys are not supported yet
+ if key.category is not prev_category:
+ output.append(f"; {key.category.description}")
+ output.append("")
+ prev_category = key.category
- sc = f"SC{SCAN_CODES['klc'][key_name]}"
- for i in (
- [Layer.ALTGR, Layer.ALTGR_SHIFT] if altgr else [Layer.BASE, Layer.SHIFT]
- ):
+ sc = f"SC{key.windows[1:].lower()}"
+ for i in layers:
layer = layout.layers[i]
- if key_name not in layer:
+ if key.id not in layer:
continue
- symbol = layer[key_name]
+ symbol = layer[key.id]
sym = ahk_escape(symbol)
if symbol in layout.dead_keys:
actions = {sym: layout.dead_keys[symbol][symbol]}
- elif key_name == "spce":
- actions = ahk_actions(key_name)
+ elif key.id == "spce":
+ actions = ahk_actions(key.id)
else:
actions = ahk_actions(symbol)
@@ -81,26 +90,35 @@ def ahk_shortcuts(layout: "KeyboardLayout") -> List[str]:
prefixes = [" ^", "^+"]
enabled = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
qwerty_vk = load_data("qwerty_vk")
+ layers = (Layer.BASE, Layer.SHIFT)
output = []
- for key_name in LAYER_KEYS:
- if key_name.startswith("-"):
- output.append(f"; {key_name[1:]}")
- output.append("")
+ prev_category: Optional[KeyCategory] = None
+ for key in KEYS.values():
+ # if key_name in ["ae13", "ab11"]: # ABNT / JIS keys
+ # continue # these two keys are not supported yet
+ # TODO: add support for all scan codes
+ if key.windows is None or not key.windows.startswith("T"):
+ continue
+
+ # Skip key if not defined and is not alphanumeric
+ if not any(key.id in layout.layers[i] for i in layers) and not key.alphanum:
continue
- if key_name in ["ae13", "ab11"]: # ABNT / JIS keys
- continue # these two keys are not supported yet
+ if key.category is not prev_category:
+ output.append(f"; {key.category.description}")
+ output.append("")
+ prev_category = key.category
- scan_code = SCAN_CODES["klc"][key_name]
- for i in [Layer.BASE, Layer.SHIFT]:
+ scan_code = key.windows[1:].lower()
+ for i in layers:
layer = layout.layers[i]
- if key_name not in layer:
+ if key.id not in layer:
continue
- symbol = layer[key_name]
+ symbol = layer[key.id]
if layout.qwerty_shortcuts:
- symbol = qwerty_vk[scan_code]
+ symbol = qwerty_vk[key.windows]
if symbol in enabled:
output.append(f"{prefixes[i]}SC{scan_code}::Send {prefixes[i]}{symbol}")
diff --git a/kalamine/generators/keylayout.py b/kalamine/generators/keylayout.py
index f084c65..2c832e1 100644
--- a/kalamine/generators/keylayout.py
+++ b/kalamine/generators/keylayout.py
@@ -3,13 +3,14 @@
https://developer.apple.com/library/content/technotes/tn2056/
"""
-from typing import TYPE_CHECKING, List, Tuple
+from typing import TYPE_CHECKING, List, Optional, Tuple
if TYPE_CHECKING:
from ..layout import KeyboardLayout
+from ..key import KEYS, KeyCategory
from ..template import load_tpl, substitute_lines
-from ..utils import DK_INDEX, LAYER_KEYS, SCAN_CODES, Layer, hex_ord
+from ..utils import Layer, hex_ord
def _xml_proof(char: str) -> str:
@@ -44,35 +45,39 @@ def has_dead_keys(letter: str) -> bool:
return False
output: List[str] = []
- for key_name in LAYER_KEYS:
- if key_name in ["ae13", "ab11"]: # ABNT / JIS keys
+ prev_category: Optional[KeyCategory] = None
+ for key in KEYS.values():
+ # TODO: remove test and use only next?
+ if key.id in ["ae13", "ab11"]: # ABNT / JIS keys
continue # these two keys are not supported yet
+ if key.macos is None:
+ continue
- if key_name.startswith("-"):
+ if key.category is not prev_category:
if output:
output.append("")
- output.append("")
- continue
+ output.append("")
+ prev_category = key.category
symbol = ""
final_key = True
- if key_name in layer:
- key = layer[key_name]
- if key in layout.dead_keys:
- symbol = f"dead_{DK_INDEX[key].name}"
+ if key.id in layer:
+ value = layer[key.id]
+ if value in layout.dead_keys:
+ symbol = f"dead_{layout.custom_dead_keys[value].name}"
final_key = False
else:
- symbol = _xml_proof(key.upper() if caps else key)
- final_key = not has_dead_keys(key.upper())
+ symbol = _xml_proof(value.upper() if caps else value)
+ final_key = not has_dead_keys(value.upper())
- char = f"code=\"{SCAN_CODES['osx'][key_name]}\"".ljust(10)
+ char = f"code=\"{key.macos}\"".ljust(10)
if final_key:
action = f'output="{symbol}"'
elif symbol.startswith("dead_"):
action = f'action="{_xml_proof_id(symbol)}"'
else:
- action = f'action="{key_name}_{_xml_proof_id(symbol)}"'
+ action = f'action="{key.id}_{_xml_proof_id(symbol)}"'
output.append(f"")
ret_str.append(output)
@@ -87,62 +92,66 @@ def macos_actions(layout: "KeyboardLayout") -> List[str]:
def when(state: str, action: str) -> str:
state_attr = f'state="{state}"'.ljust(18)
if action in layout.dead_keys:
- action_attr = f'next="{DK_INDEX[action].name}"'
+ action_attr = f'next="{layout.custom_dead_keys[action].name}"'
elif action.startswith("dead_"):
action_attr = f'next="{action[5:]}"'
else:
action_attr = f'output="{_xml_proof(action)}"'
return f" "
- def append_actions(key: str, symbol: str, actions: List[Tuple[str, str]]) -> None:
- ret_actions.append(f'')
+ def append_actions(id: str, symbol: str, actions: List[Tuple[str, str]]) -> None:
+ ret_actions.append(f'')
ret_actions.append(when("none", symbol))
for state, out in actions:
ret_actions.append(when(state, out))
ret_actions.append("")
# dead key definitions
- for key in layout.dead_keys:
- name = DK_INDEX[key].name
- term = layout.dead_keys[key][key]
+ for dk in layout.dead_keys:
+ name = layout.custom_dead_keys[dk].name
+ term = layout.dead_keys[dk][dk]
ret_actions.append(f'')
ret_actions.append(f' ')
if name == "1dk" and term in layout.dead_keys:
- nested_dk = DK_INDEX[term].name
+ nested_dk = layout.custom_dead_keys[term].name
ret_actions.append(f' ')
ret_actions.append("")
continue
# normal key actions
- for key_name in LAYER_KEYS:
- if key_name.startswith("-"):
- ret_actions.append("")
- ret_actions.append(f"")
+ prev_category: Optional[KeyCategory] = None
+ for key in KEYS.values():
+ if key.macos is None:
continue
+ if key.category is not prev_category:
+ ret_actions.append("")
+ ret_actions.append(f"")
+ prev_category = key.category
+
for i in [Layer.BASE, Layer.SHIFT, Layer.ALTGR, Layer.ALTGR_SHIFT]:
- if key_name == "spce" or key_name not in layout.layers[i]:
+ if key.id == "spce" or key.id not in layout.layers[i]:
continue
- key = layout.layers[i][key_name]
- if i and key == layout.layers[Layer.BASE][key_name]:
+ value = layout.layers[i][key.id]
+ if i and value == layout.layers[Layer.BASE][key.id]:
continue
- if key in layout.dead_keys:
+ if value in layout.dead_keys:
continue
actions: List[Tuple[str, str]] = []
- for k in DK_INDEX:
+ for k in layout.custom_dead_keys:
if k in layout.dead_keys:
- if key in layout.dead_keys[k]:
- actions.append((DK_INDEX[k].name, layout.dead_keys[k][key]))
+ if value in layout.dead_keys[k]:
+ actions.append((layout.custom_dead_keys[k].name, layout.dead_keys[k][value]))
if actions:
- append_actions(key_name, _xml_proof(key), actions)
+ append_actions(key.id, _xml_proof(value), actions)
# spacebar actions
actions = []
- for k in DK_INDEX:
+ for k in layout.custom_dead_keys:
if k in layout.dead_keys:
- actions.append((DK_INDEX[k].name, layout.dead_keys[k][" "]))
+ actions.append((layout.custom_dead_keys[k].name, layout.dead_keys[k][" "]))
append_actions("spce", " ", actions) # space
append_actions("spce", " ", actions) # no-break space
append_actions("spce", " ", actions) # fine no-break space
@@ -154,11 +163,11 @@ def macos_terminators(layout: "KeyboardLayout") -> List[str]:
"""macOS layout, dead key terminators."""
ret_terminators = []
- for key in DK_INDEX:
+ for key in layout.custom_dead_keys:
if key not in layout.dead_keys:
continue
dk = layout.dead_keys[key]
- name = DK_INDEX[key].name
+ name = layout.custom_dead_keys[key].name
term = dk[key]
if name == "1dk" and term in layout.dead_keys:
term = dk[" "]
diff --git a/kalamine/generators/klc.py b/kalamine/generators/klc.py
index bbc9375..05610a1 100644
--- a/kalamine/generators/klc.py
+++ b/kalamine/generators/klc.py
@@ -11,13 +11,15 @@
"""
import re
-from typing import TYPE_CHECKING, List
+from typing import TYPE_CHECKING, Dict, List, Optional
+
if TYPE_CHECKING:
from ..layout import KeyboardLayout
+from ..key import KEYS
from ..template import load_tpl, substitute_lines, substitute_token
-from ..utils import DK_INDEX, LAYER_KEYS, SCAN_CODES, Layer, hex_ord, load_data
+from ..utils import Layer, SystemSymbol, hex_ord, load_data
# return the corresponding char for a symbol
@@ -46,38 +48,62 @@ def _get_langid(locale: str) -> str:
oem_idx = 0
-def klc_virtual_key(layout: "KeyboardLayout", symbols: list, scan_code: str) -> str:
- if scan_code == "56":
+def check_virtual_key_symbols(
+ virtual_keys: Dict[str, List[str]], vk: str, symbols: List[str]
+):
+ return (symbolsʹ := virtual_keys.get(vk)) is None or symbolsʹ == symbols
+
+
+def klc_virtual_key(
+ layout: "KeyboardLayout",
+ symbols: list,
+ scan_code: str,
+ virtual_keys: Dict[str, List[str]],
+) -> str:
+ if scan_code == "T56":
# manage the ISO key (between shift and Z on ISO keyboards).
# We're assuming that its scancode is always 56
# https://www.win.tue.nl/~aeb/linux/kbd/scancodes.html
return "OEM_102"
+ # Check that the target VK is not already assigned to different symbols
+ def check(vk: str):
+ return check_virtual_key_symbols(virtual_keys, vk, symbols)
+
+ # TODO: add support for Numpad keys
base = _get_chr(symbols[0])
shifted = _get_chr(symbols[1])
# Can’t use `isdigit()` because `²` is a digit but we don't want that as a VK
allowed_digit = "0123456789"
# We assume that digit row always have digit as VK
- if base in allowed_digit:
+ if base in allowed_digit and check(base):
return base
- elif shifted in allowed_digit:
+ elif shifted in allowed_digit and check(shifted):
return shifted
if shifted.isascii() and shifted.isalpha():
return shifted
# VK_OEM_* case
- if base == "," or shifted == ",":
+ if (base == "," or shifted == ",") and check("OEM_COMMA"):
return "OEM_COMMA"
- elif base == "." or shifted == ".":
+ elif (base == "." or shifted == ".") and check("OEM_PERIOD"):
return "OEM_PERIOD"
- elif base == "+" or shifted == "+":
+ elif (base == "+" or shifted == "+") and check("OEM_PLUS"):
return "OEM_PLUS"
- elif base == "-" or shifted == "-":
+ elif (base == "-" or shifted == "-") and check("OEM_MINUS"):
return "OEM_MINUS"
- elif base == " ":
+ elif base == " " and check("SPACE"):
return "SPACE"
+ elif base == "\t" and check("TAB"):
+ return "TAB"
+ elif base == "\r" and check("RETURN"):
+ return "RETURN"
+ elif base == "\b" and check("BACK"):
+ return "BACK"
+ elif base == "\x1b" and check("ESCAPE"):
+ return "ESCAPE"
else:
MAX_OEM = 8
# We affect abitrary OEM VK and it will not match the one
@@ -91,6 +117,33 @@ def klc_virtual_key(layout: "KeyboardLayout", symbols: list, scan_code: str) ->
raise Exception("Too many OEM keys")
+SYSTEM_SYMBOLS: Dict[str, Optional[str]] = {
+ SystemSymbol.Alt.value: None,
+ SystemSymbol.AltGr.value: None,
+ SystemSymbol.BackSpace.value: "\b",
+ SystemSymbol.CapsLock.value: None,
+ SystemSymbol.Compose.value: None,
+ SystemSymbol.Control.value: None,
+ SystemSymbol.Delete.value: None,
+ SystemSymbol.Escape.value: "\x1b",
+ SystemSymbol.Return.value: "\r",
+ SystemSymbol.Shift.value: None,
+ SystemSymbol.Super.value: None,
+ SystemSymbol.Tab.value: "\t",
+ SystemSymbol.RightTab.value: None,
+ SystemSymbol.LeftTab.value: None,
+ SystemSymbol.ArrowUp.value: None,
+ SystemSymbol.ArrowDown.value: None,
+ SystemSymbol.ArrowLeft.value: None,
+ SystemSymbol.ArrowRight.value: None,
+ SystemSymbol.PageUp.value: None,
+ SystemSymbol.PageDown.value: None,
+ SystemSymbol.Home.value: None,
+ SystemSymbol.End.value: None,
+ SystemSymbol.Menu.value: None,
+}
+
+
def klc_keymap(layout: "KeyboardLayout") -> List[str]:
"""Windows layout, main part."""
@@ -100,29 +153,48 @@ def klc_keymap(layout: "KeyboardLayout") -> List[str]:
oem_idx = 0 # Python trick to do equivalent of C static variable
output = []
qwerty_vk = load_data("qwerty_vk")
+ layers = (Layer.BASE, Layer.SHIFT, Layer.ALTGR, Layer.ALTGR_SHIFT)
+ virtual_keys: Dict[str, List[str]] = {}
- for key_name in LAYER_KEYS:
- if key_name.startswith("-"):
+ for key in KEYS.values():
+ if key.id in ["ae13", "ab11"]: # ABNT / JIS keys
+ continue # these two keys are not supported yet
+ if key.windows is None or not key.windows.startswith("T"):
+ # TODO: warning
continue
- if key_name in ["ae13", "ab11"]: # ABNT / JIS keys
- continue # these two keys are not supported yet
+ # Skip key if not defined and is not alphanumeric
+ if not any(key.id in layout.layers[i] for i in layers) and not key.alphanum:
+ continue
symbols = []
description = "//"
is_alpha = False
+ base_system_action_error = False
- for i in [Layer.BASE, Layer.SHIFT, Layer.ALTGR, Layer.ALTGR_SHIFT]:
+ for i in layers:
layer = layout.layers[i]
- if key_name in layer:
- symbol = layer[key_name]
+ if key.id in layer:
+ symbol = layer[key.id]
desc = symbol
if symbol in layout.dead_keys:
desc = layout.dead_keys[symbol][" "]
symbol = hex_ord(desc) + "@"
+ elif symbol in SYSTEM_SYMBOLS:
+ symbolʹ = SYSTEM_SYMBOLS[symbol]
+ if symbolʹ is None:
+ print(
+ f"Warning: unsupported system action for key {key.id}: {symbol}"
+ )
+ if i is Layer.BASE:
+ base_system_action_error = True
+ symbol = "-1"
+ else:
+ symbol = hex_ord(symbolʹ)
+ desc = symbol
else:
- if i == Layer.BASE:
+ if i is Layer.BASE:
is_alpha = symbol.upper() != symbol
if symbol not in supported_symbols:
symbol = hex_ord(symbol)
@@ -132,11 +204,21 @@ def klc_keymap(layout: "KeyboardLayout") -> List[str]:
symbols.append("-1")
description += " " + desc
- scan_code = SCAN_CODES["klc"][key_name]
+ if base_system_action_error:
+ print(f"Warning: cannot map non-system action on system key: {key.id}")
+ continue
+
+ scan_code = key.windows[1:].lower()
- virtual_key = qwerty_vk[scan_code]
- if not layout.qwerty_shortcuts:
- virtual_key = klc_virtual_key(layout, symbols, scan_code)
+ if (
+ layout.qwerty_shortcuts
+ and key.windows in qwerty_vk
+ and check_virtual_key_symbols(virtual_keys, qwerty_vk[key.windows], symbols)
+ ):
+ virtual_key = qwerty_vk[key.windows]
+ else:
+ virtual_key = klc_virtual_key(layout, symbols, key.windows, virtual_keys)
+ virtual_keys[virtual_key] = symbols
if layout.has_altgr:
output.append(
@@ -179,12 +261,14 @@ def klc_deadkeys(layout: "KeyboardLayout") -> List[str]:
output = []
- for k in DK_INDEX:
+ for k in layout.custom_dead_keys:
if k not in layout.dead_keys:
continue
dk = layout.dead_keys[k]
- output.append(f"// DEADKEY: {DK_INDEX[k].name.upper()} //" + "{{{")
+ output.append(
+ f"// DEADKEY: {layout.custom_dead_keys[k].name.upper()} //" + "{{{"
+ )
output.append(f"DEADKEY\t{hex_ord(dk[' '])}")
for base, alt in dk.items():
@@ -212,11 +296,13 @@ def klc_dk_index(layout: "KeyboardLayout") -> List[str]:
"""Windows layout, dead key index."""
output = []
- for k in DK_INDEX:
+ for k in layout.custom_dead_keys:
if k not in layout.dead_keys:
continue
dk = layout.dead_keys[k]
- output.append(f"{hex_ord(dk[' '])}\t\"{DK_INDEX[k].name.upper()}\"")
+ output.append(
+ f"{hex_ord(dk[' '])}\t\"{layout.custom_dead_keys[k].name.upper()}\""
+ )
return output
@@ -225,27 +311,35 @@ def c_keymap(layout: "KeyboardLayout") -> List[str]:
supported_symbols = "1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
qwerty_vk = load_data("qwerty_vk")
+ layers = (Layer.BASE, Layer.SHIFT, Layer.ALTGR, Layer.ALTGR_SHIFT)
+ virtual_keys: Dict[str, List[str]] = {}
global oem_idx
oem_idx = 0 # Python trick to do equivalent of C static variable
output = []
- for key_name in LAYER_KEYS:
- if key_name.startswith("-"):
+ for key in KEYS.values():
+ if key.id in ["ae13", "ab11"]: # ABNT / JIS keys
+ continue # these two keys are not supported yet
+ # TODO: add support for all scan codes
+ if key.windows is None or not key.windows.startswith("T"):
+ # TODO: warning
continue
- if key_name in ["ae13", "ab11"]: # ABNT / JIS keys
- continue # these two keys are not supported yet
+ # Skip key if not defined and is not alphanumeric
+ if not any(key.id in layout.layers[i] for i in layers) and not key.alphanum:
+ continue
symbols = []
dead_symbols = []
is_alpha = False
has_dead_key = False
+ base_system_action_error = False
- for i in [Layer.BASE, Layer.SHIFT, Layer.ALTGR, Layer.ALTGR_SHIFT]:
+ for i in layers:
layer = layout.layers[i]
- if key_name in layer:
- symbol = layer[key_name]
+ if key.id in layer:
+ symbol = layer[key.id]
desc = symbol
dead = "WCH_NONE"
if symbol in layout.dead_keys:
@@ -253,8 +347,20 @@ def c_keymap(layout: "KeyboardLayout") -> List[str]:
symbol = "WCH_DEAD"
dead = hex_ord(desc)
has_dead_key = True
+ elif symbol in SYSTEM_SYMBOLS:
+ symbolʹ = SYSTEM_SYMBOLS[symbol]
+ if symbolʹ is None:
+ print(
+ f"Warning: unsupported system action for key {key.id}: {symbol}"
+ )
+ if i is Layer.BASE:
+ base_system_action_error = True
+ symbol = "-1"
+ else:
+ symbol = hex_ord(symbolʹ)
+ desc = symbol
else:
- if i == Layer.BASE:
+ if i is Layer.BASE:
is_alpha = symbol.upper() != symbol
if symbol not in supported_symbols:
symbol = hex_ord(symbol)
@@ -265,11 +371,21 @@ def c_keymap(layout: "KeyboardLayout") -> List[str]:
symbols.append("WCH_NONE")
dead_symbols.append("WCH_NONE")
- scan_code = SCAN_CODES["klc"][key_name]
+ if base_system_action_error:
+ print(f"Warning: cannot map non-system action on system key: {key.id}")
+ continue
+
+ # scan_code = key.windows[1:].lower()
- virtual_key = qwerty_vk[scan_code]
- if not layout.qwerty_shortcuts:
- virtual_key = klc_virtual_key(layout, symbols, scan_code)
+ if (
+ layout.qwerty_shortcuts
+ and key.windows in qwerty_vk
+ and check_virtual_key_symbols(virtual_keys, qwerty_vk[key.windows], symbols)
+ ):
+ virtual_key = qwerty_vk[key.windows]
+ else:
+ virtual_key = klc_virtual_key(layout, symbols, key.windows, virtual_keys)
+ virtual_keys[virtual_key] = symbols
if len(virtual_key) == 1:
virtual_key_id = f"'{virtual_key}'"
@@ -323,12 +439,12 @@ def c_deadkeys(layout: "KeyboardLayout") -> List[str]:
output = []
- for k in DK_INDEX:
+ for k in layout.custom_dead_keys:
if k not in layout.dead_keys:
continue
dk = layout.dead_keys[k]
- output.append(f"// DEADKEY: {DK_INDEX[k].name.upper()}")
+ output.append(f"// DEADKEY: {layout.custom_dead_keys[k].name.upper()}")
for base, alt in dk.items():
if base == k and alt in base:
@@ -357,11 +473,13 @@ def c_dk_index(layout: "KeyboardLayout") -> List[str]:
"""Windows layout, dead key index."""
output = []
- for k in DK_INDEX:
+ for k in layout.custom_dead_keys:
if k not in layout.dead_keys:
continue
term = layout.dead_keys[k][" "]
- output.append(f'L"\\\\x{hex_ord(term)}"\tL"{DK_INDEX[k].name.upper()}",')
+ output.append(
+ f'L"\\\\x{hex_ord(term)}"\tL"{layout.custom_dead_keys[k].name.upper()}",'
+ )
return output
diff --git a/kalamine/generators/web.py b/kalamine/generators/web.py
index 4c9d023..f3fad6c 100644
--- a/kalamine/generators/web.py
+++ b/kalamine/generators/web.py
@@ -12,7 +12,8 @@
if TYPE_CHECKING:
from ..layout import KeyboardLayout
-from ..utils import LAYER_KEYS, ODK_ID, SCAN_CODES, Layer, upper_key
+from ..key import KEYS
+from ..utils import ODK_ID, Layer, pretty_upper_key
def raw_json(layout: "KeyboardLayout") -> Dict:
@@ -21,15 +22,16 @@ def raw_json(layout: "KeyboardLayout") -> Dict:
# flatten the keymap: each key has an array of 2-4 characters
# correcponding to Base, Shift, AltGr, AltGr+Shift
keymap: Dict[str, List[str]] = {}
- for key_name in LAYER_KEYS:
- if key_name.startswith("-"):
+ for key in KEYS.values():
+ if key.web is None:
+ # TODO: warning
continue
chars = list("")
for i in [Layer.BASE, Layer.SHIFT, Layer.ALTGR, Layer.ALTGR_SHIFT]:
- if key_name in layout.layers[i]:
- chars.append(layout.layers[i][key_name])
+ if key.id in layout.layers[i]:
+ chars.append(layout.layers[i][key.id])
if chars:
- keymap[SCAN_CODES["web"][key_name]] = chars
+ keymap[key.web] = chars
return {
# fmt: off
@@ -79,9 +81,8 @@ def set_key_label(key: Optional[ET.Element], lvl: int, char: str) -> None:
def same_symbol(key_name: str, lower: Layer, upper: Layer):
up = layout.layers[upper]
low = layout.layers[lower]
- if key_name not in up or key_name not in low:
- return False
- return up[key_name] == upper_key(low[key_name], blank_if_obvious=False)
+ return key_name in up and key_name in low and \
+ up[key_name] == pretty_upper_key(low[key_name], blank_if_obvious=False)
# Parse the SVG template
# res = pkgutil.get_data(__package__, "templates/x-keyboard.svg")
@@ -90,30 +91,29 @@ def same_symbol(key_name: str, lower: Layer, upper: Layer):
return ET.ElementTree()
svg = ET.ElementTree(ET.fromstring(res.decode("utf-8")))
- for key_name in LAYER_KEYS:
- if key_name.startswith("-"):
+ for key in KEYS.values():
+ if key.web is None:
+ # TODO: warning
continue
- level = 0
- for i in [
+ for level, i in enumerate((
Layer.BASE,
Layer.SHIFT,
Layer.ALTGR,
Layer.ALTGR_SHIFT,
Layer.ODK,
Layer.ODK_SHIFT,
- ]:
- level += 1
- if key_name not in layout.layers[i]:
+ ), start=1):
+ if key.id not in layout.layers[i]:
continue
- if level == 1 and same_symbol(key_name, Layer.BASE, Layer.SHIFT):
+ if level == 1 and same_symbol(key.id, Layer.BASE, Layer.SHIFT):
continue
- if level == 4 and same_symbol(key_name, Layer.ALTGR, Layer.ALTGR_SHIFT):
+ if level == 4 and same_symbol(key.id, Layer.ALTGR, Layer.ALTGR_SHIFT):
continue
- if level == 6 and same_symbol(key_name, Layer.ODK, Layer.ODK_SHIFT):
+ if level == 6 and same_symbol(key.id, Layer.ODK, Layer.ODK_SHIFT):
continue
- key = svg.find(f".//g[@id=\"{SCAN_CODES['web'][key_name]}\"]", ns)
- set_key_label(key, level, layout.layers[i][key_name])
+ key_elem = svg.find(f".//g[@id=\"{key.web}\"]", ns)
+ set_key_label(key_elem, level, layout.layers[i][key.id])
return svg
diff --git a/kalamine/generators/xkb.py b/kalamine/generators/xkb.py
index 3ef688e..8008ab2 100644
--- a/kalamine/generators/xkb.py
+++ b/kalamine/generators/xkb.py
@@ -4,18 +4,143 @@
- xkb symbols/patch for XOrg (system-wide) & Wayland (system-wide/user-space)
"""
-from typing import TYPE_CHECKING, List
+from dataclasses import dataclass
+import functools
+import itertools
+import locale
+from pathlib import Path
+from typing import TYPE_CHECKING, Dict, Generator, List, Optional, Tuple
if TYPE_CHECKING:
from ..layout import KeyboardLayout
+from ..key import KEYS, Hand, KeyCategory
from ..template import load_tpl, substitute_lines
-from ..utils import DK_INDEX, LAYER_KEYS, ODK_ID, hex_ord, load_data
+from ..utils import DK_INDEX, ODK_ID, DeadKeyDescr, SystemSymbol, hex_ord, load_data
XKB_KEY_SYM = load_data("key_sym")
+XKB_SPECIAL_KEYSYMS = {
+ SystemSymbol.Alt.value: {Hand.Left: "Alt_L", Hand.Right: "Alt_R"},
+ SystemSymbol.AltGr.value: {Hand.Left: "ISO_Level3_Shift"},
+ SystemSymbol.BackSpace.value: {Hand.Left: "BackSpace"},
+ SystemSymbol.CapsLock.value: {Hand.Left: "Caps_Lock"},
+ SystemSymbol.Compose.value: {Hand.Left: "Multi_key"},
+ SystemSymbol.Control.value: {Hand.Left: "Control_L", Hand.Right: "Control_R"},
+ SystemSymbol.Delete.value: {Hand.Left: "Delete"},
+ SystemSymbol.Escape.value: {Hand.Left: "Escape"},
+ SystemSymbol.Return.value: {Hand.Left: "Return"},
+ SystemSymbol.Shift.value: {Hand.Left: "Shift_L", Hand.Right: "Shift_R"},
+ SystemSymbol.Super.value: {Hand.Left: "Super_L", Hand.Right: "Super_R"},
+ SystemSymbol.Tab.value: {Hand.Left: "Tab"},
+ SystemSymbol.RightTab.value: {Hand.Left: "Tab"},
+ SystemSymbol.LeftTab.value: {Hand.Left: "ISO_Left_Tab"},
+ SystemSymbol.ArrowUp.value: {Hand.Left: "Up"},
+ SystemSymbol.ArrowDown.value: {Hand.Left: "Down"},
+ SystemSymbol.ArrowLeft.value: {Hand.Left: "Left"},
+ SystemSymbol.ArrowRight.value: {Hand.Left: "Right"},
+ SystemSymbol.PageUp.value: {Hand.Left: "Prior"},
+ SystemSymbol.PageDown.value: {Hand.Left: "Next"},
+ SystemSymbol.Home.value: {Hand.Left: "Home"},
+ SystemSymbol.End.value: {Hand.Left: "End"},
+ SystemSymbol.Menu.value: {Hand.Left: "Menu"},
+}
+assert all(s.value in XKB_SPECIAL_KEYSYMS for s in SystemSymbol), \
+ tuple(s for s in SystemSymbol if s.value not in XKB_SPECIAL_KEYSYMS)
+def xkb_keysym(char: str, max_length: Optional[int] = None, hand: Optional[Hand]=None) -> str:
+ if char in XKB_SPECIAL_KEYSYMS:
+ keysyms = XKB_SPECIAL_KEYSYMS[char]
+ if hand in keysyms:
+ return keysyms[hand]
+ else:
+ return keysyms[Hand.Left]
+ elif char in XKB_KEY_SYM and (max_length is None or len(XKB_KEY_SYM[char]) <= max_length):
+ return XKB_KEY_SYM[char]
+ else:
+ return f"U{hex_ord(char).upper()}"
-def xkb_table(layout: "KeyboardLayout", xkbcomp: bool = False) -> List[str]:
+
+SPARE_KEYSYMS = (
+ "F20",
+ "F21",
+ "F22",
+ "F23",
+ "F24",
+ "F25",
+ "F26",
+ "F27",
+ "F28",
+ "F29",
+ "F30",
+ "F31",
+ "F32",
+ "F33",
+ "F34",
+ "F35",
+)
+
+@dataclass
+class XKB_Custom_Keysyms:
+ strings: Dict[str, str]
+ deadKeys: Dict[str, str]
+
+
+@dataclass
+class XKB_Output:
+ symbols: str
+ compose: str
+
+
+def _collate(s1: str, s2: str) -> int:
+ s1ʹ = s1.casefold()
+ s2ʹ = s2.casefold()
+ if s1ʹ < s2ʹ:
+ return -1
+ elif s1ʹ > s2ʹ:
+ return +1
+ else:
+ # Note: inverted on purpose, to get lower case before upper case
+ return locale.strcoll(s2, s1)
+
+
+def xkb_make_custom_dead_keys_keysyms(layout: "KeyboardLayout") -> XKB_Custom_Keysyms:
+ layoutSymbols = layout.symbols
+ forbiden = set(itertools.chain(layoutSymbols.strings, layoutSymbols.deadKeys))
+ spares = list(SPARE_KEYSYMS)
+ strings = {}
+ deadKeys = {}
+ for s in sorted(layoutSymbols.strings, key=functools.cmp_to_key(_collate)):
+ if len(s) >= 2:
+ # Try to use one of the characters of the string
+ candidates = tuple(
+ (cʹ, keysym)
+ for c in s
+ for cʹ in (c.lower(), c.upper())
+ if len(cʹ) == 1 and cʹ not in forbiden and (keysym := XKB_KEY_SYM.get(cʹ))
+ )
+ if candidates:
+ strings[s] = candidates[0][1]
+ forbiden.add(candidates[0][0])
+ elif spares:
+ strings[s] = spares.pop(0)
+ else:
+ raise ValueError(f"Cannot encode string: “{s}”")
+ for c in sorted(layoutSymbols.deadKeys, key=functools.cmp_to_key(_collate)):
+ dk = f"*{c}"
+ if dk in DK_INDEX:
+ continue
+ elif c not in forbiden and (keysym := XKB_KEY_SYM.get(c)):
+ deadKeys[dk] = keysym
+ forbiden.add(c)
+ elif spares:
+ deadKeys[dk] = spares.pop(0)
+ else:
+ raise ValueError(f"Cannot encode dead key: “{dk}”")
+
+ return XKB_Custom_Keysyms(strings, deadKeys)
+
+
+def xkb_table(layout: "KeyboardLayout", xkbcomp: bool = False, customDeadKeys: Optional[XKB_Custom_Keysyms]=None) -> List[str]:
"""GNU/Linux layout."""
if layout.qwerty_shortcuts:
@@ -26,30 +151,37 @@ def xkb_table(layout: "KeyboardLayout", xkbcomp: bool = False) -> List[str]:
odk_symbol = "ISO_Level5_Latch" if eight_level else "ISO_Level3_Latch"
max_length = 16 # `ISO_Level3_Latch` should be the longest symbol name
- output: List[str] = []
- for key_name in LAYER_KEYS:
- if key_name.startswith("-"): # separator
- if output:
- output.append("")
- output.append("//" + key_name[1:])
- continue
+ if customDeadKeys is None:
+ customDeadKeys = XKB_Custom_Keysyms({}, {})
+ output: List[str] = []
+ prev_category: Optional[KeyCategory] = None
+ for key in KEYS.values():
descs = []
symbols = []
- for layer in layout.layers.values():
- if key_name in layer:
- keysym = layer[key_name]
+ defined = False
+ for layer, keys in layout.layers.items():
+ if key.id in keys:
+ keysym = keys[key.id]
+ # Hack for Tab
+ if layer.value % 2 == 1 and keysym in ("↹", "⇥"):
+ keysym = "⇤"
desc = keysym
- # dead key?
- if keysym in DK_INDEX:
- name = DK_INDEX[keysym].name
+ if keysymʹ := customDeadKeys.strings.get(keysym):
+ symbol = keysymʹ
+ elif keysymʹ := customDeadKeys.deadKeys.get(keysym):
+ name = layout.custom_dead_keys[keysym].name
+ desc = layout.dead_keys[keysym][keysym]
+ symbol = keysymʹ
+ # predefined dead key?
+ elif keysym in DK_INDEX:
+ name = layout.custom_dead_keys[keysym].name
desc = layout.dead_keys[keysym][keysym]
symbol = odk_symbol if keysym == ODK_ID else f"dead_{name}"
# regular key: use a keysym if possible, utf-8 otherwise
- elif keysym in XKB_KEY_SYM and len(XKB_KEY_SYM[keysym]) <= max_length:
- symbol = XKB_KEY_SYM[keysym]
else:
- symbol = f"U{hex_ord(keysym).upper()}"
+ symbol = xkb_keysym(keysym, max_length=max_length, hand=key.hand)
+ defined = True
else:
desc = " "
symbol = "VoidSymbol"
@@ -57,17 +189,36 @@ def xkb_table(layout: "KeyboardLayout", xkbcomp: bool = False) -> List[str]:
descs.append(desc)
symbols.append(symbol.ljust(max_length))
- key = "{{[ {0}, {1}, {2}, {3}]}}" # 4-level layout by default
+ if not symbols or not defined:
+ continue
+
+ if key.category is not prev_category:
+ if output:
+ output.append("")
+ output.append("// " + key.category.description)
+ prev_category = key.category
+
+ key_template = "{{[ {0}, {1}, {2}, {3}]}}" # 4-level layout by default
description = "{0} {1} {2} {3}"
- if layout.has_altgr and layout.has_1dk:
+ if all(s == symbols[0] for s in symbols):
+ key_template = """{{type[Group1] = \"ONE_LEVEL\",\n [ {0} ]}}"""
+ description = "{0}"
+ symbols = symbols[0:1]
+ descs = descs[0:1]
+ elif all((i % 2 == 0 and s == symbols[0]) or (i % 2 == 1 and s == symbols[1]) for i, s in enumerate(symbols)):
+ key_template = """{{type[Group1] = \"TWO_LEVEL\",\n [ {0}, {1} ]}}"""
+ description = "{0} {1}"
+ symbols = symbols[:2]
+ descs = descs[:2]
+ elif layout.has_altgr and layout.has_1dk:
# 6 layers are needed: they won't fit on the 4-level format.
if xkbcomp: # user-space XKB keymap file (standalone)
# standalone XKB files work best with a dual-group solution:
# one 4-level group for base+1dk, one two-level group for AltGr
- key = "{{[ {}, {}, {}, {}],[ {}, {}]}}"
+ key_template = "{{[ {}, {}, {}, {}],[ {}, {}]}}"
description = "{} {} {} {} {} {}"
else: # eight_level XKB symbols (Neo-like)
- key = "{{[ {0}, {1}, {4}, {5}, {2}, {3}]}}"
+ key_template = "{{[ {0}, {1}, {4}, {5}, {2}, {3}]}}"
description = "{0} {1} {4} {5} {2} {3}"
elif layout.has_altgr:
del symbols[3]
@@ -75,7 +226,8 @@ def xkb_table(layout: "KeyboardLayout", xkbcomp: bool = False) -> List[str]:
del descs[3]
del descs[2]
- line = f"key <{key_name.upper()}> {key.format(*symbols)};"
+ keycode = f"<{key.xkb.upper()}>"
+ line = f"key {keycode: <6} {key_template.format(*symbols)};"
if show_description:
line += (" // " + description.format(*descs)).rstrip()
if line.endswith("\\"):
@@ -85,17 +237,90 @@ def xkb_table(layout: "KeyboardLayout", xkbcomp: bool = False) -> List[str]:
return output
-def xkb_keymap(self) -> str: # will not work with Wayland
+def xkb_keymap(layout: "KeyboardLayout") -> XKB_Output: # will not work with Wayland
"""GNU/Linux driver (standalone / user-space)"""
- out = load_tpl(self, ".xkb_keymap")
- out = substitute_lines(out, "LAYOUT", xkb_table(self, xkbcomp=True))
- return out
+ customDeadKeysKeysyms = xkb_make_custom_dead_keys_keysyms(layout)
+
+ symbols = load_tpl(layout, ".xkb_keymap")
+ symbols = substitute_lines(symbols, "LAYOUT", xkb_table(layout, xkbcomp=True, customDeadKeys=customDeadKeysKeysyms))
+ compose = "\n".join(xcompose(layout, customDeadKeysKeysyms)) if customDeadKeysKeysyms else ""
-def xkb_symbols(self) -> str:
+ return XKB_Output(symbols, compose)
+
+
+def xkb_symbols(layout: "KeyboardLayout") -> XKB_Output:
"""GNU/Linux driver (xkb patch, system or user-space)"""
- out = load_tpl(self, ".xkb_symbols")
- out = substitute_lines(out, "LAYOUT", xkb_table(self, xkbcomp=False))
- return out.replace("//#", "//")
+ customDeadKeysKeysyms = xkb_make_custom_dead_keys_keysyms(layout)
+
+ symbols = load_tpl(layout, ".xkb_symbols")
+ symbols = substitute_lines(symbols, "LAYOUT", xkb_table(layout, xkbcomp=False, customDeadKeys=customDeadKeysKeysyms))
+
+ compose = "\n".join(xcompose(layout, customDeadKeysKeysyms)) if customDeadKeysKeysyms else ""
+
+ return XKB_Output(symbols.replace("//#", "//"), compose)
+
+
+def escapeString(s: str) -> Generator[str, None, None]:
+ for c in s:
+ if (cp := ord(c)) < 0x20:
+ yield f"\\x{cp:0>2x}"
+ match c:
+ case "\\":
+ yield "\\\\"
+ case "\"":
+ yield "\\\""
+ case _:
+ # FIXME escape all relevant chars
+ yield c
+
+
+def _compose_sequences(dk: DeadKeyDescr) -> Generator[Tuple[str, str], None, None]:
+ yield from zip(dk.base, dk.alt)
+ yield (dk.char, dk.alt_self)
+ yield (" ", dk.alt_space)
+
+
+def xcompose(layout: "KeyboardLayout", customDeadKeys: XKB_Custom_Keysyms) -> Generator[str, None, None]:
+ # Strings
+ for s, keysym in sorted(customDeadKeys.strings.items(), key=lambda x: x[1]):
+ s = "".join(escapeString(s))
+ yield f"<{keysym}> : \"{s}\""
+ # Dead keys sequences
+ for dk in layout.custom_dead_keys.values():
+ if dk.char == ODK_ID:
+ continue
+ # Predefined dk
+ if (dkʹ := DK_INDEX.get(dk.char)):
+ predefined = dict(_compose_sequences(dkʹ))
+ else:
+ predefined = {}
+ # Dead key keysym
+ if (dk_keysym := customDeadKeys.deadKeys.get(dk.char)) is None:
+ # dk_keysym = xkb_keysym(dk.char[-1])
+ dk_keysym = f"dead_{dk.name}"
+ # Sequences
+ for base, result in _compose_sequences(dk):
+ if predefined.get(base) == result:
+ # Skip predefined sequence
+ continue
+ # TODO: general chained dead keys?
+ if base == dk.char:
+ base_keysym = dk_keysym
+ else:
+ base_keysym = xkb_keysym(base)
+ result_keysym = xkb_keysym(result)
+ result_string = "".join(escapeString(result))
+ yield f"<{dk_keysym}> <{base_keysym}> : \"{result_string}\" {result_keysym}"
+ yield ""
+
+def xkb_write_files(path: Path, result: XKB_Output):
+ with path.open("w", encoding="utf-8", newline="\n") as file:
+ file.write(result.symbols)
+ if not result.compose:
+ return
+ path = path.with_suffix(".xkb_compose")
+ with path.open("w", encoding="utf-8", newline="\n") as file:
+ file.write(result.compose)
\ No newline at end of file
diff --git a/kalamine/help.py b/kalamine/help.py
index 38c0d39..80d8a84 100644
--- a/kalamine/help.py
+++ b/kalamine/help.py
@@ -1,8 +1,9 @@
from pathlib import Path
from typing import Dict, List
+from kalamine.key import KEYS
+
from .layout import KeyboardLayout
-from .template import SCAN_CODES
from .utils import Layer, load_data
SEPARATOR = (
@@ -93,10 +94,10 @@ def dummy_layout(
layout.geometry = geometry
# ensure there is no empty keys (XXX maybe this should be in layout.py)
- for key in SCAN_CODES["web"].keys():
- if key not in layout.layers[Layer.BASE].keys():
- layout.layers[Layer.BASE][key] = "\\"
- layout.layers[Layer.SHIFT][key] = "|"
+ for key_ in KEYS.values():
+ if key_.alphanum and key_.id not in layout.layers[Layer.BASE].keys():
+ layout.layers[Layer.BASE][key_.id] = "\\"
+ layout.layers[Layer.SHIFT][key_.id] = "|"
return layout
diff --git a/kalamine/key.py b/kalamine/key.py
new file mode 100644
index 0000000..0cf3f34
--- /dev/null
+++ b/kalamine/key.py
@@ -0,0 +1,103 @@
+from dataclasses import dataclass
+from enum import Enum, Flag, auto, unique
+from typing import Any, Dict, Optional, Self
+
+from kalamine.utils import load_data
+
+
+@unique
+class Hand(Enum):
+ Left = auto()
+ Right = auto()
+
+ @classmethod
+ def parse(cls, raw: str) -> Self:
+ for h in cls:
+ if h.name.casefold() == raw.casefold():
+ return h
+ else:
+ raise ValueError(f"Cannot parse hand: “{raw}”")
+
+
+@unique
+class KeyCategory(Flag):
+ Digits = auto()
+ Letters1 = auto()
+ Letters2 = auto()
+ Letters3 = auto()
+ PinkyKeys = auto()
+ SpaceBar = auto()
+ Numpad = auto()
+ System = auto()
+ Modifiers = auto()
+ InputMethod = auto()
+ Miscellaneous = auto()
+ AlphaNum = Digits | Letters1 | Letters2 | Letters3 | PinkyKeys | SpaceBar
+
+ @classmethod
+ def parse(cls, raw: str) -> Self:
+ for kc in cls:
+ if kc.name and kc.name.casefold() == raw.casefold():
+ return kc
+ else:
+ raise ValueError(f"Cannot parse key category: “{raw}”")
+
+ @property
+ def description(self) -> str:
+ descriptions = {
+ KeyCategory.Digits: "Digits",
+ KeyCategory.Letters1: "Letters, first row",
+ KeyCategory.Letters2: "Letters, second row",
+ KeyCategory.Letters3: "Letters, third row",
+ KeyCategory.PinkyKeys: "Pinky keys",
+ KeyCategory.SpaceBar: "Space bar",
+ KeyCategory.Numpad: "Numeric pad",
+ KeyCategory.System: "System",
+ KeyCategory.Modifiers: "Modifiers",
+ KeyCategory.InputMethod: "Input method",
+ KeyCategory.Miscellaneous: "Miscellaneous",
+ }
+ if d := descriptions.get(self):
+ return d
+ else:
+ raise ValueError(f"No description ofr KeyCategory: {self}")
+
+
+@dataclass
+class Key:
+ xkb: str
+ web: Optional[str] = None
+ windows: Optional[str] = None
+ macos: Optional[str] = None
+ hand: Optional[Hand] = None
+ category: KeyCategory = KeyCategory.Miscellaneous
+ "Usual hand on standard (ISO, etc.) keyboard"
+
+ @classmethod
+ def load_data(cls, data: Dict[str, Any]) -> Dict[str, Self]:
+ return {
+ key.xkb: key
+ for category, keys in data.items()
+ for key in (cls.parse(category=category, **entry) for entry in keys)
+ }
+
+ @classmethod
+ def parse(cls, category: str, xkb: str, web: Optional[str], windows: Optional[str], macos: Optional[str], hand: Optional[str]) -> Self:
+ return cls(
+ category=KeyCategory.parse(category),
+ xkb=xkb,
+ web=web,
+ windows=windows,
+ macos=macos,
+ hand=Hand.parse(hand) if hand else None
+ )
+
+ @property
+ def id(self) -> str:
+ return self.xkb
+
+ @property
+ def alphanum(self) -> bool:
+ return bool(self.category & KeyCategory.AlphaNum)
+
+KEYS = Key.load_data(load_data("scan_codes"))
diff --git a/kalamine/layout.py b/kalamine/layout.py
index bae0295..b4701c3 100644
--- a/kalamine/layout.py
+++ b/kalamine/layout.py
@@ -1,20 +1,26 @@
import copy
+import itertools
import sys
from dataclasses import dataclass
from pathlib import Path
-from typing import Dict, List, Optional, Set, Type, TypeVar
+from typing import Any, Dict, List, Optional, Set, Type, TypeVar
import click
import tomli
import yaml
+from .key import KEYS
from .utils import (
DEAD_KEYS,
- LAYER_KEYS,
+ DK_INDEX,
ODK_ID,
+ DeadKeyDescr,
Layer,
+ SpecialSymbol,
+ SystemSymbol,
load_data,
text_to_lines,
+ pretty_upper_key,
upper_key,
)
@@ -109,6 +115,7 @@ class RowDescr:
class GeometryDescr:
template: str
rows: List[RowDescr]
+ digit_row: int = 0
@classmethod
def from_dict(cls: Type[T], src: Dict) -> T:
@@ -122,6 +129,16 @@ def from_dict(cls: Type[T], src: Dict) -> T:
}
+@dataclass
+class LayoutSymbols:
+ strings: Set[str]
+ deadKeys: Set[str]
+
+
+TEMPLATE_DUMMY_KEY = "xxxx"
+TEMPLATE_KEY_WIDTH = 6
+
+
###
# Main
#
@@ -161,12 +178,15 @@ def __init__(
)
self.meta["fileName"] = self.meta["name8"].lower()
+ # Custom dead keys
+ self.custom_dead_keys = self._parse_dead_keys(layout_data.get("dead_keys", {}))
+
# keyboard layers: self.layers & self.dead_keys
rows = copy.deepcopy(GEOMETRY[self.meta["geometry"]].rows)
# Angle Mod permutation
if angle_mod:
- last_row = rows[3]
+ last_row = rows[GEOMETRY[self.meta["geometry"]].digit_row + 3]
if last_row.keys[0] == "lsgt":
# should bevome ['ab05', 'lsgt', 'ab01', 'ab02', 'ab03', 'ab04']
last_row.keys[:6] = [last_row.keys[5]] + last_row.keys[:5]
@@ -191,10 +211,11 @@ def __init__(
)
# space bar
+ # FIXME: dead key in space bar?
spc = SPACEBAR.copy()
if "spacebar" in layout_data:
for k in layout_data["spacebar"]:
- spc[k] = layout_data["spacebar"][k]
+ spc[k] = self._parse_value(layout_data["spacebar"][k])
self.layers[Layer.BASE]["spce"] = " "
self.layers[Layer.SHIFT]["spce"] = spc["shift"]
if True or self.has_1dk: # XXX self.has_1dk is not defined yet
@@ -206,9 +227,97 @@ def __init__(
self.layers[Layer.ALTGR]["spce"] = spc["altgr"]
self.layers[Layer.ALTGR_SHIFT]["spce"] = spc["altgr_shift"]
- self._parse_dead_keys(spc)
+ # Extra mapping
+ if mapping := layout_data.get("mapping"):
+ self._parse_extra_mapping(mapping)
+
+ # Fill special symbols
+ for key in KEYS.values():
+ if base_symbol := self.layers[Layer.BASE].get(key.id):
+ if not SystemSymbol.is_system_symbol(base_symbol):
+ continue
+ shift_symbol = self.layers[Layer.SHIFT].get(key.id, "")
+ if not SystemSymbol.is_system_symbol(shift_symbol):
+ shift_symbol = base_symbol
+ for layer, keys in self.layers.items():
+ if key.id not in keys:
+ keys[key.id] = base_symbol if layer.value % 2 == 0 else shift_symbol
+
+ self._make_dead_keys(spc)
+
+ def _parse_dead_keys(self, raw: Dict[str, Any]) -> Dict[str, DeadKeyDescr]:
+ custom_dead_keys: Dict[str, DeadKeyDescr] = DK_INDEX.copy()
+ for dk_char, definition in raw.items():
+ if dk_char == ODK_ID:
+ raise ValueError("Cannot redefine 1dk")
+ name = definition.get("name")
+ base = definition.get("base")
+ alt = definition.get("alt")
+ alt_space = definition.get("alt_space")
+ alt_self = definition.get("alt_self")
+ if dk := DK_INDEX.get(dk_char):
+ # Redefine existing predefined dead key
+ mapping = dict(zip(dk.base, dk.alt))
+ if base and alt:
+ mapping.update(dict(zip(base, alt)))
+ custom_dead_keys[dk_char] = DeadKeyDescr(
+ char=dk_char,
+ name=name or dk.name,
+ base="".join(mapping.keys()),
+ alt="".join(mapping.values()),
+ alt_space=alt_space or dk.alt_space,
+ alt_self=alt_self or dk.alt_self
+ )
+ elif not dk_char or not name or not base or not alt or not alt_space or not alt_self:
+ raise ValueError(f"Invalid custom dead key definition: {definition}")
+ else:
+ custom_dead_keys[dk_char] = DeadKeyDescr(
+ char=dk_char,
+ name=name,
+ base=base,
+ alt=alt,
+ alt_space=alt_space,
+ alt_self=alt_self
+ )
+ return custom_dead_keys
+
+ def _parse_value(self, raw: str, strip=False) -> str:
+ return SpecialSymbol.parse(raw.strip() if strip else raw)
+
+ @staticmethod
+ def _parse_key_ref(raw: str) -> Optional[str]:
+ if raw.startswith("(") and raw.endswith(")"):
+ if (clone := raw[1:-1]) and clone in KEYS:
+ return clone
+ return None
+
+ def _parse_extra_mapping(self, mapping: Dict[str, str | Dict[str, str]]):
+ layer: Layer | None
+ for raw_key, levels in mapping.items():
+ # TODO: parse key in various ways (XKB, Linux keycode)
+ if raw_key not in KEYS:
+ raise ValueError(f"Unknown key: “{raw_key}”")
+ key = raw_key
+ # Check for key clone
+ if isinstance(levels, str):
+ # Check for clone
+ if clone := self._parse_key_ref(levels):
+ for layer, keys in self.layers.items():
+ if value := keys.get(clone):
+ self.layers[layer][key] = value
+ continue
+ raise ValueError(f"Unsupported key mapping: {raw_key}: {levels}")
+ for raw_layer, raw_value in levels.items():
+ if (layer := Layer.parse(raw_layer)) is None:
+ raise ValueError(f"Cannot parse layer: “{raw_layer}”")
+ if clone := self._parse_key_ref(raw_value):
+ if (value := self.layers[layer].get(clone)) is None:
+ continue
+ else:
+ value = self._parse_value(raw_value)
+ self.layers[layer][key] = value
- def _parse_dead_keys(self, spc: Dict[str, str]) -> None:
+ def _make_dead_keys(self, spc: Dict[str, str]) -> None:
"""Build a deadkey dict."""
def layout_has_char(char: str) -> bool:
@@ -228,7 +337,7 @@ def layout_has_char(char: str) -> bool:
all_spaces.append(space)
self.dead_keys = {}
- for dk in DEAD_KEYS:
+ for dk in itertools.chain(DEAD_KEYS, self.custom_dead_keys.values()):
id = dk.char
if id not in self.dk_set:
continue
@@ -239,9 +348,7 @@ def layout_has_char(char: str) -> bool:
if id == ODK_ID:
self.has_1dk = True
- for key_name in LAYER_KEYS:
- if key_name.startswith("-"):
- continue
+ for key_name in KEYS:
for layer in [Layer.ODK_SHIFT, Layer.ODK]:
if key_name in self.layers[layer]:
deadkey[self.layers[layer.necromance()][key_name]] = (
@@ -264,102 +371,111 @@ def _parse_template(
) -> None:
"""Extract a keyboard layer from a template."""
- j = 0
- col_offset = 0 if layer_number == Layer.BASE else 2
- for row in rows:
- i = row.offset + col_offset
+ col_offset = 0 if layer_number is Layer.BASE else 2
+ for j, row in enumerate(rows):
keys = row.keys
- base = list(template[2 + j * 3])
- shift = list(template[1 + j * 3])
+ base = template[2 + j * 3]
+ shift = template[1 + j * 3]
+
+ for i, key in zip(itertools.count(row.offset + col_offset, TEMPLATE_KEY_WIDTH), keys):
+ if key == TEMPLATE_DUMMY_KEY:
+ continue
- for key in keys:
- base_key = ("*" if base[i - 1] == "*" else "") + base[i]
- shift_key = ("*" if shift[i - 1] == "*" else "") + shift[i]
+ base_key: Optional[str] = self._parse_value(base[i-1:i+1], strip=True)
+ shift_key: Optional[str] = self._parse_value(shift[i-1:i+1], strip=True)
# in the BASE layer, if the base character is undefined, shift prevails
- if base_key == " ":
- if layer_number == Layer.BASE:
+ if not base_key:
+ if layer_number is Layer.BASE and shift_key:
base_key = shift_key.lower()
# in other layers, if the shift character is undefined, base prevails
- elif shift_key == " ":
- if layer_number == Layer.ALTGR:
+ elif not shift_key:
+ if layer_number is Layer.ALTGR:
shift_key = upper_key(base_key)
- elif layer_number == Layer.ODK:
+ elif layer_number is Layer.ODK:
shift_key = upper_key(base_key)
# shift_key = upper_key(base_key, blank_if_obvious=False)
- if base_key != " ":
+ if base_key:
self.layers[layer_number][key] = base_key
- if shift_key != " ":
- self.layers[layer_number.next()][key] = shift_key
-
- for dk in DEAD_KEYS:
- if base_key == dk.char or shift_key == dk.char:
- self.dk_set.add(dk.char)
+ if DeadKeyDescr.is_dead_key(base_key):
+ if (base_key in DK_INDEX) or (base_key in self.custom_dead_keys):
+ self.dk_set.add(base_key)
+ else:
+ raise ValueError(f"Undefined dead key: {base_key}")
- i += 6
- j += 1
+ if shift_key:
+ self.layers[layer_number.next()][key] = shift_key
+ if DeadKeyDescr.is_dead_key(shift_key):
+ if (shift_key in DK_INDEX) or (shift_key in self.custom_dead_keys):
+ self.dk_set.add(shift_key)
+ else:
+ raise ValueError(f"Undefined dead key: {shift_key}")
###
# Geometry: base, full, altgr
#
def _fill_template(
- self, template: List[str], rows: List[RowDescr], layer_number: Layer
+ self, template: List[str], rows: List[RowDescr], layer_number: Optional[Layer]
) -> List[str]:
"""Fill a template with a keyboard layer."""
- if layer_number == Layer.BASE:
+ if layer_number is None:
+ col_offset = 0
+ elif layer_number is Layer.BASE:
col_offset = 0
shift_prevails = True
else: # AltGr or 1dk
col_offset = 2
shift_prevails = False
- j = 0
- for row in rows:
- i = row.offset + col_offset
+ for j, row in enumerate(rows):
keys = row.keys
base = list(template[2 + j * 3])
shift = list(template[1 + j * 3])
- for key in keys:
+ for i, key in zip(itertools.count(row.offset + col_offset, TEMPLATE_KEY_WIDTH), keys):
+ if key == TEMPLATE_DUMMY_KEY:
+ continue
+
+ if layer_number is None:
+ indexes = slice(i - 1, i + 3)
+ base[indexes] = " "
+ shift[indexes] = " "
+ continue
+
+ indexes = slice(i - 1, i + 1)
+
base_key = " "
if key in self.layers[layer_number]:
- base_key = self.layers[layer_number][key]
+ base_key = SpecialSymbol.prettify(self.layers[layer_number][key])
shift_key = " "
if key in self.layers[layer_number.next()]:
- shift_key = self.layers[layer_number.next()][key]
+ shift_key = SpecialSymbol.prettify(self.layers[layer_number.next()][key])
- dead_base = len(base_key) == 2 and base_key[0] == "*"
- dead_shift = len(shift_key) == 2 and shift_key[0] == "*"
+ # Do not display system symbols if they are identical to the first levels
+ if layer_number is not Layer.BASE:
+ if SystemSymbol.is_system_symbol(base_key) and base_key == self.layers[Layer.BASE].get(key):
+ base_key = " "
+ if SystemSymbol.is_system_symbol(shift_key) and shift_key == self.layers[Layer.SHIFT].get(key):
+ shift_key = " "
if shift_prevails:
- shift[i] = shift_key[-1]
- if dead_shift:
- shift[i - 1] = "*"
- if upper_key(base_key) != shift_key:
- base[i] = base_key[-1]
- if dead_base:
- base[i - 1] = "*"
+ shift[indexes] = shift_key.rjust(2)
+ if pretty_upper_key(base_key, blank_if_obvious=False) != shift_key:
+ base[indexes] = base_key.rjust(2)
else:
- base[i] = base_key[-1]
- if dead_base:
- base[i - 1] = "*"
- if upper_key(base_key) != shift_key:
- shift[i] = shift_key[-1]
- if dead_shift:
- shift[i - 1] = "*"
-
- i += 6
+ base[indexes] = base_key.rjust(2)
+ if pretty_upper_key(base_key, blank_if_obvious=False) != shift_key:
+ shift[indexes] = shift_key.rjust(2)
template[2 + j * 3] = "".join(base)
template[1 + j * 3] = "".join(shift)
- j += 1
return template
@@ -369,6 +485,8 @@ def _get_geometry(self, layers: Optional[List[Layer]] = None) -> List[str]:
rows = GEOMETRY[self.geometry].rows
template = GEOMETRY[self.geometry].template.split("\n")[:-1]
+ # Clear template
+ template = self._fill_template(template, rows, None)
for i in layers:
template = self._fill_template(template, rows, i)
return template
@@ -400,3 +518,15 @@ def full(self) -> List[str]:
def altgr(self) -> List[str]:
"""AltGr layer only."""
return self._get_geometry([Layer.ALTGR])
+
+ @property
+ def symbols(self) -> LayoutSymbols:
+ strings = set()
+ deadKeys = set()
+ for levels in self.layers.values():
+ for value in levels.values():
+ if len(value) == 2 and value[0] == "*":
+ deadKeys.add(value[1])
+ elif SystemSymbol.parse(value) is None:
+ strings.add(value)
+ return LayoutSymbols(strings, deadKeys)
diff --git a/kalamine/template.py b/kalamine/template.py
index bdf786e..87e8eec 100644
--- a/kalamine/template.py
+++ b/kalamine/template.py
@@ -9,9 +9,6 @@
from .layout import KeyboardLayout
-SCAN_CODES = load_data("scan_codes")
-
-
def substitute_lines(text: str, variable: str, lines: List[str]) -> str:
prefix = "KALAMINE::"
exp = re.compile(".*" + prefix + variable + ".*")
diff --git a/kalamine/utils.py b/kalamine/utils.py
index d19967e..b0e550b 100644
--- a/kalamine/utils.py
+++ b/kalamine/utils.py
@@ -1,12 +1,16 @@
import pkgutil
from dataclasses import dataclass
-from enum import IntEnum
-from typing import Dict, List, Optional
+from enum import Enum, IntEnum, unique
+from typing import Dict, List, Optional, Self
import yaml
def hex_ord(char: str) -> str:
+ # assert len(char) == 1, char
+ if len(char) != 1:
+ print(f"ERROR: hex_ord: “{char}”")
+ char = char[0]
return hex(ord(char))[2:].zfill(4)
@@ -47,25 +51,44 @@ class Layer(IntEnum):
ALTGR = 4
ALTGR_SHIFT = 5
+ @classmethod
+ def parse(cls, raw: str) -> Self | None:
+ match raw.casefold():
+ case "1dk":
+ return cls(cls.ODK)
+ case "1dk_shift":
+ return cls(cls.ODK_SHIFT)
+ case _:
+ for l in cls:
+ if raw.casefold() == l.name.casefold():
+ return l
+ try:
+ if int(raw, base=10) == l.value:
+ return l
+ except:
+ pass
+ return None
+
def next(self) -> "Layer":
"""The next layer in the layer ordering."""
return Layer(int(self) + 1)
def necromance(self) -> "Layer":
"""Remove the effect of the dead key if any."""
- if self == Layer.ODK:
+ if self is Layer.ODK:
return Layer.BASE
- elif self == Layer.ODK_SHIFT:
+ elif self is Layer.ODK_SHIFT:
return Layer.SHIFT
return self
-def upper_key(letter: Optional[str], blank_if_obvious: bool = True) -> str:
- """This is used for presentation purposes: in a key, the upper character
- becomes blank if it's an obvious uppercase version of the base character."""
+def upper_key(letter: Optional[str]) -> Optional[str]:
+ if not letter:
+ return None
- if letter is None:
- return " "
+ special_symbols = {s.value for s in SystemSymbol}
+ if letter in special_symbols:
+ return letter
custom_alpha = {
"\u00df": "\u1e9e", # ß ẞ
@@ -77,7 +100,8 @@ def upper_key(letter: Optional[str], blank_if_obvious: bool = True) -> str:
"\u2191": "\u21d1", # ↑ ⇑
"\u2192": "\u21d2", # → ⇒
"\u2193": "\u21d3", # ↓ ⇓
- "\u00b5": " ", # µ (to avoid getting `Μ` as uppercase)
+ # FIXME: strange behavior
+ "\u00b5": None, # µ (to avoid getting `Μ` as uppercase)
}
if letter in custom_alpha:
return custom_alpha[letter]
@@ -85,8 +109,18 @@ def upper_key(letter: Optional[str], blank_if_obvious: bool = True) -> str:
if len(letter) == 1 and letter.upper() != letter.lower():
return letter.upper()
+ return None
+
+
+def pretty_upper_key(letter: Optional[str], blank_if_obvious: bool = True) -> str:
+ """This is used for presentation purposes: in a key, the upper character
+ becomes blank if it's an obvious uppercase version of the base character."""
+
+ if (letterʹ := upper_key(letter)) is None:
+ return "" if blank_if_obvious else (letter or "")
+
# dead key or non-letter character
- return " " if blank_if_obvious else letter
+ return "" if blank_if_obvious else letterʹ
@dataclass
@@ -98,73 +132,86 @@ class DeadKeyDescr:
alt_space: str
alt_self: str
+ @staticmethod
+ def is_dead_key(raw: str) -> bool:
+ return len(raw) == 2 and raw[0] == "*"
+
+ @staticmethod
+ def parse_dead_key(raw: str) -> Optional[str]:
+ if len(raw) == 2 and raw[0] == "*":
+ return raw
+ else:
+ return None
+
DEAD_KEYS = [DeadKeyDescr(**data) for data in load_data("dead_keys")]
-DK_INDEX = {}
+DK_INDEX: Dict[str, DeadKeyDescr] = {}
for dk in DEAD_KEYS:
DK_INDEX[dk.char] = dk
-SCAN_CODES = load_data("scan_codes")
-
ODK_ID = "**" # must match the value in dead_keys.yaml
-LAYER_KEYS = [
- "- Digits",
- "ae01",
- "ae02",
- "ae03",
- "ae04",
- "ae05",
- "ae06",
- "ae07",
- "ae08",
- "ae09",
- "ae10",
- "- Letters, first row",
- "ad01",
- "ad02",
- "ad03",
- "ad04",
- "ad05",
- "ad06",
- "ad07",
- "ad08",
- "ad09",
- "ad10",
- "- Letters, second row",
- "ac01",
- "ac02",
- "ac03",
- "ac04",
- "ac05",
- "ac06",
- "ac07",
- "ac08",
- "ac09",
- "ac10",
- "- Letters, third row",
- "ab01",
- "ab02",
- "ab03",
- "ab04",
- "ab05",
- "ab06",
- "ab07",
- "ab08",
- "ab09",
- "ab10",
- "- Pinky keys",
- "ae11",
- "ae12",
- "ae13",
- "ad11",
- "ad12",
- "ac11",
- "ab11",
- "tlde",
- "bksl",
- "lsgt",
- "- Space bar",
- "spce",
-]
+@unique
+class SystemSymbol(Enum):
+ Alt = "⎇"
+ AltGr = "⇮"
+ BackSpace = "⌫"
+ CapsLock = "⇬"
+ Compose = "⎄"
+ Control = "⎈"
+ Delete = "⌦"
+ Escape = "⎋"
+ Return = "⏎"
+ Shift = "⇧"
+ Super = "◆"
+ Tab = "↹"
+ RightTab = "⇥"
+ LeftTab = "⇤"
+ ArrowUp = "⬆"
+ ArrowDown = "⬇"
+ ArrowLeft = "⬅"
+ ArrowRight = "➡"
+ PageUp = "⎗"
+ PageDown = "⎘"
+ Home = "⇱"
+ End = "⇲"
+ Menu = "≣"
+
+ @classmethod
+ def parse(cls, raw: str) -> Optional[Self]:
+ for s in cls:
+ if raw == s.value:
+ return s
+ else:
+ return None
+
+ @classmethod
+ def is_system_symbol(cls, raw: str) -> bool:
+ return cls.parse(raw) is not None
+
+@dataclass
+class SpecialSymbolEntry:
+ value: str
+ pretty: str
+
+class SpecialSymbol(Enum):
+ NarrowNoBreakSpace = SpecialSymbolEntry("\u202F", "n⍽")
+ NoBreakSpace = SpecialSymbolEntry("\u00A0", "⍽")
+ Space = SpecialSymbolEntry(" ", "␣")
+
+ @classmethod
+ def parse(cls, raw: str) -> str:
+ for s in cls:
+ if raw == s.value.pretty:
+ return s.value.value
+ else:
+ return raw
+
+ @classmethod
+ def prettify(cls, raw: str) -> str:
+ for s in cls:
+ if raw == s.value.value:
+ return s.value.pretty
+ else:
+ return raw
diff --git a/tests/test_serializer_klc.py b/tests/test_serializer_klc.py
index ce916ae..8153686 100644
--- a/tests/test_serializer_klc.py
+++ b/tests/test_serializer_klc.py
@@ -80,8 +80,7 @@ def test_ansi_deadkeys():
def test_intl_keymap():
keymap = klc_keymap(LAYOUTS["intl"])
- assert len(keymap) == 49
- assert keymap == split(
+ keymap_ref = split(
"""
02 1 0 1 0021 -1 -1 // 1 !
03 2 0 2 0040 -1 -1 // 2 @
@@ -134,6 +133,32 @@ def test_intl_keymap():
39 SPACE 0 0020 0020 -1 -1 //
"""
)
+ assert len(keymap) == len(keymap_ref)
+ assert keymap == keymap_ref
+
+ extraMapping = {
+ # NOTE: redefine level
+ "ae01": {"shift": "?"},
+ # TODO
+ # # NOTE: test case variants and ODK alias
+ # "kppt": {"base": ",", "sHiFt": ";", "1dk": ".", "ODk_shiFt": ":"},
+ # NOTE: clone level
+ "esc": {"base": "\x1b", "shift": "(ae11)"},
+ # NOTE: clone key
+ "henk": "(lsgt)",
+ }
+
+ extraSymbols = [
+ "01 ESCAPE 0 001b 005f -1 -1 // \x1b _",
+ "79 OEM_8 0 005c 007c -1 -1 // \\ |",
+ ]
+ keymap_extra_ref = keymap_ref + extraSymbols
+ keymap_extra_ref[0] = "02 1 0 1 003f -1 -1 // 1 ?"
+
+ layout = KeyboardLayout(get_layout_dict("intl", extraMapping))
+ keymap = klc_keymap(layout)
+ assert len(keymap) == len(keymap_extra_ref)
+ assert keymap == keymap_extra_ref
def test_intl_deadkeys():
diff --git a/tests/test_serializer_xkb.py b/tests/test_serializer_xkb.py
index 302e4a6..febf4f5 100644
--- a/tests/test_serializer_xkb.py
+++ b/tests/test_serializer_xkb.py
@@ -1,4 +1,5 @@
from textwrap import dedent
+from typing import Dict
from kalamine import KeyboardLayout
from kalamine.generators.xkb import xkb_table
@@ -6,8 +7,10 @@
from .util import get_layout_dict
-def load_layout(filename: str) -> KeyboardLayout:
- return KeyboardLayout(get_layout_dict(filename))
+def load_layout(
+ filename: str, extraMapping: Dict[str, Dict[str, str]]
+) -> KeyboardLayout:
+ return KeyboardLayout(get_layout_dict(filename, extraMapping))
def split(multiline_str: str):
@@ -15,7 +18,7 @@ def split(multiline_str: str):
def test_ansi():
- layout = load_layout("ansi")
+ layout = load_layout("ansi", {})
expected = split(
"""
@@ -70,14 +73,11 @@ def test_ansi():
// Pinky keys
key {[ minus , underscore , VoidSymbol , VoidSymbol ]}; // - _
key {[ equal , plus , VoidSymbol , VoidSymbol ]}; // = +
- key {[ VoidSymbol , VoidSymbol , VoidSymbol , VoidSymbol ]}; //
key {[ bracketleft , braceleft , VoidSymbol , VoidSymbol ]}; // [ {
key {[ bracketright , braceright , VoidSymbol , VoidSymbol ]}; // ] }
key {[ apostrophe , quotedbl , VoidSymbol , VoidSymbol ]}; // ' "
- key {[ VoidSymbol , VoidSymbol , VoidSymbol , VoidSymbol ]}; //
key {[ grave , asciitilde , VoidSymbol , VoidSymbol ]}; // ` ~
key {[ backslash , bar , VoidSymbol , VoidSymbol ]}; // \\ |
- key {[ VoidSymbol , VoidSymbol , VoidSymbol , VoidSymbol ]}; //
// Space bar
key {[ space , space , apostrophe , apostrophe ]}; // ' '
@@ -94,8 +94,6 @@ def test_ansi():
def test_intl():
- layout = load_layout("intl")
-
expected = split(
"""
// Digits
@@ -149,11 +147,9 @@ def test_intl():
// Pinky keys
key {[ minus , underscore , VoidSymbol , VoidSymbol ]}; // - _
key {[ equal , plus , VoidSymbol , VoidSymbol ]}; // = +
- key {[ VoidSymbol , VoidSymbol , VoidSymbol , VoidSymbol ]}; //
key {[ bracketleft , braceleft , VoidSymbol , VoidSymbol ]}; // [ {
key {[ bracketright , braceright , VoidSymbol , VoidSymbol ]}; // ] }
key {[ ISO_Level3_Latch, dead_diaeresis , apostrophe , VoidSymbol ]}; // ' ¨ '
- key {[ VoidSymbol , VoidSymbol , VoidSymbol , VoidSymbol ]}; //
key {[ dead_grave , dead_tilde , VoidSymbol , VoidSymbol ]}; // ` ~
key {[ backslash , bar , VoidSymbol , VoidSymbol ]}; // \\ |
key {[ backslash , bar , VoidSymbol , VoidSymbol ]}; // \\ |
@@ -163,17 +159,46 @@ def test_intl():
"""
)
- xkbcomp = xkb_table(layout, xkbcomp=True)
- assert len(xkbcomp) == len(expected)
- assert xkbcomp == expected
+ extraMapping = {
+ # NOTE: redefine level
+ "ae01": {"shift": "?"},
+ # NOTE: test case variants and ODK alias
+ "menu": {"base": "a", "sHiFt": "A", "1dk": "æ", "ODk_shiFt": "Æ"},
+ # NOTE: clone level
+ "esc": {"base": "(ae11)"},
+ # NOTE: clone key
+ "i172": "(lsgt)",
+ }
+
+ extraSymbols = [
+ "",
+ "// System",
+ "key {[ minus , VoidSymbol , VoidSymbol , VoidSymbol ]}; // -",
+ "key