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 {[ a , A , ae , AE ]}; // a A æ Æ", + "", + "// Miscellaneous", + "key {[ backslash , bar , VoidSymbol , VoidSymbol ]}; // \\ |", + ] + + extraExpected = expected + extraSymbols + extraExpected[1] = ( + "key {[ 1 , question , VoidSymbol , VoidSymbol ]}; // 1 ?" + ) - xkbpatch = xkb_table(layout, xkbcomp=False) - assert len(xkbpatch) == len(expected) - assert xkbpatch == expected + for mapping, expectedʹ in (({}, expected), (extraMapping, extraExpected)): + layout = load_layout("intl", mapping) + + xkbcomp = xkb_table(layout, xkbcomp=True) + assert len(xkbcomp) == len(expectedʹ) + assert xkbcomp == expectedʹ + + xkbpatch = xkb_table(layout, xkbcomp=False) + assert len(xkbpatch) == len(expectedʹ) + assert xkbpatch == expectedʹ def test_prog(): - layout = load_layout("prog") + layout = load_layout("prog", {}) expected = split( """ @@ -228,14 +253,11 @@ def test_prog(): // 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 , dead_acute , dead_diaeresis ]}; // ' " ´ ¨ - key {[ VoidSymbol , VoidSymbol , VoidSymbol , VoidSymbol ]}; // key {[ grave , asciitilde , dead_grave , dead_tilde ]}; // ` ~ ` ~ key {[ backslash , bar , VoidSymbol , VoidSymbol ]}; // \\ | - key {[ VoidSymbol , VoidSymbol , VoidSymbol , VoidSymbol ]}; // // Space bar key {[ space , space , space , space ]}; // diff --git a/tests/util.py b/tests/util.py index c4b35f2..45a8d0b 100644 --- a/tests/util.py +++ b/tests/util.py @@ -1,14 +1,19 @@ """Some util functions for tests.""" from pathlib import Path -from typing import Dict +from typing import Dict, Optional import tomli -def get_layout_dict(filename: str) -> Dict: +def get_layout_dict( + filename: str, extraMapping: Optional[Dict[str, Dict[str, str]]] = None +) -> Dict: """Return the layout directory path.""" file_path = Path(__file__).parent.parent / f"layouts/{filename}.toml" with file_path.open(mode="rb") as file: - return tomli.load(file) + layout = tomli.load(file) + if extraMapping: + layout.update({"mapping": extraMapping}) + return layout