1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 19:26:48 +00:00
webvirtcloud/dev/console_layout.py
catborise bfa0fc9fe5 feat(console): typed text follows the VM's keyboard layout
"Type it" sent each character's keysym and let QEMU pick the key from
its own US map, with Shift added for the US shifted characters. A VM
on another layout got other characters: on Turkish Q, '"' typed 'İ',
'@' typed "'", and ğ, ü, ş, ı, ö, ç typed nothing.

The Clipboard dialog now asks for the VM's keyboard layout and
remembers it per browser. Each character is typed as the key that makes
it on that layout (its scancode), with Shift, AltGr or both (AltGr
first: pressed after Shift, the right Alt is Alt on the Linux console),
so the VM's own layout makes the character. The layout is fixed when
typing starts. Text with a character no key types on the layout is not
typed at all, and the dialog lists those characters: a password or a
command with a character left out would be a wrong one.

A layout is one file in console/layouts/: its name and, for each key,
the characters it types plainly, with Shift, with AltGr and with
Shift+AltGr. The view offers every file, US first. dev/console_layout.py
writes a file from the system's XKB data as libxkbcommon computes it
(through ctypes, no new dependency), mapping keys to DOM codes with
noVNC's table from QEMU's keycodemapdb; it lists the dead keys it leaves
out, and drops the keys the keyboard model lacks (the key left of Z on
US pc104 keyboards). English (US) and Turkish Q come with it; the
README says how to add one.
2026-10-27 12:23:32 +03:00

131 lines
5.9 KiB
Python

#!/usr/bin/env python3
"""Write console/layouts/<id>.json, a keyboard layout the VNC console's
"Type it" can type with, from the system's XKB data (xkeyboard-config),
as libxkbcommon computes it.
python3 dev/console_layout.py tr "Turkish Q"
python3 dev/console_layout.py tr "Turkish F" --variant f
python3 dev/console_layout.py us "English (US)" --model pc104
The model is the keyboard's: pc105 (the default) has a key left of Z
(IntlBackslash) that US keyboards (pc104) lack.
Each key the layout writes with gets the characters it types plainly, with
Shift, with AltGr and with Shift+AltGr (null: none, or the same as without
AltGr). Dead keys are left out and listed.
"""
import argparse
import ctypes
import ctypes.util
import json
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SCANCODES = ROOT / "static/js/novnc/core/input/xtscancodes.js"
LAYOUTS = ROOT / "console/layouts"
KEY_DOWN = 1
SHIFT = "ShiftLeft"
ALTGR = "AltRight"
MODIFIERS = [[], [SHIFT], [ALTGR], [ALTGR, SHIFT]] # pressed in this order, as the console page does
# the writing keys a keyboard model lacks (XKB defines them all the same);
# other models, such as jp106 and abnt2, have them
MISSING_KEYS = {"pc104": {"IntlBackslash", "IntlRo", "IntlYen"}, "pc105": {"IntlRo", "IntlYen"}}
class RuleNames(ctypes.Structure):
_fields_ = [(name, ctypes.c_char_p) for name in ("rules", "model", "layout", "variant", "options")]
def load_xkbcommon():
xkb = ctypes.CDLL(ctypes.util.find_library("xkbcommon") or "libxkbcommon.so.0")
for name, restype, argtypes in (
("xkb_context_new", ctypes.c_void_p, [ctypes.c_int]),
("xkb_keymap_new_from_names", ctypes.c_void_p, [ctypes.c_void_p, ctypes.POINTER(RuleNames), ctypes.c_int]),
("xkb_state_new", ctypes.c_void_p, [ctypes.c_void_p]),
("xkb_state_unref", None, [ctypes.c_void_p]),
("xkb_state_update_key", ctypes.c_int, [ctypes.c_void_p, ctypes.c_uint32, ctypes.c_int]),
("xkb_state_key_get_utf32", ctypes.c_uint32, [ctypes.c_void_p, ctypes.c_uint32]),
("xkb_state_key_get_one_sym", ctypes.c_uint32, [ctypes.c_void_p, ctypes.c_uint32]),
("xkb_keysym_get_name", ctypes.c_int, [ctypes.c_uint32, ctypes.c_char_p, ctypes.c_size_t]),
):
function = getattr(xkb, name)
function.restype, function.argtypes = restype, argtypes
return xkb
def linux_keycodes():
"""{DOM code: Linux keycode}, from noVNC's table (QEMU's keycodemapdb)."""
pattern = re.compile(r'^ "(\w+)": 0x\w+, /\* html:\w+ \(\w+\) -> linux:(\d+) ', re.M)
return {code: int(linux) for code, linux in pattern.findall(SCANCODES.read_text())}
def writing_keys(codes, model):
"""The keys of the model's keyboard that write characters: not the keypad,
not Space (typed on its own)."""
missing = MISSING_KEYS.get(model, set()) | {"Space"}
return {code: linux for code, linux in codes.items() if not code.startswith("Numpad") and code not in missing}
def press(xkb, keymap, keycodes, keys):
"""What the last of the keys gives with the others held: (character or None, keysym)."""
state = xkb.xkb_state_new(keymap)
for key in keys[:-1]:
xkb.xkb_state_update_key(state, keycodes[key], KEY_DOWN)
utf32 = xkb.xkb_state_key_get_utf32(state, keycodes[keys[-1]])
keysym = xkb.xkb_state_key_get_one_sym(state, keycodes[keys[-1]])
xkb.xkb_state_unref(state)
return (chr(utf32) if utf32 >= 0x20 and utf32 != 0x7F else None), keysym
def keysym_name(xkb, keysym):
name = ctypes.create_string_buffer(64)
xkb.xkb_keysym_get_name(keysym, name, len(name))
return name.value.decode()
def main():
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("layout", help='XKB layout, e.g. "tr", "de", "fr"')
parser.add_argument("name", help='the name the console shows, e.g. "Turkish Q"')
parser.add_argument("--variant", default="", help='XKB variant, e.g. "f" for Turkish F')
parser.add_argument("--model", default="pc105", help="XKB keyboard model: pc105, or pc104 for US keyboards")
args = parser.parse_args()
xkb = load_xkbcommon()
names = RuleNames(b"evdev", args.model.encode(), args.layout.encode(), args.variant.encode(), b"")
keymap = xkb.xkb_keymap_new_from_names(xkb.xkb_context_new(0), ctypes.byref(names), 0)
if not keymap:
parser.error(f"XKB has no layout {args.layout!r} with variant {args.variant!r}")
# XKB keycodes are Linux keycodes + 8
keycodes = {code: linux + 8 for code, linux in linux_keycodes().items()}
keys, dead = {}, []
for code in sorted(writing_keys(keycodes, args.model), key=keycodes.get):
characters = []
for slot, modifiers in enumerate(MODIFIERS):
character, keysym = press(xkb, keymap, keycodes, [*modifiers, code])
if keysym_name(xkb, keysym).startswith("dead_"):
dead.append(f"{'+'.join([*modifiers, code])}: {keysym_name(xkb, keysym)}")
# AltGr that changes nothing is no AltGr (the US layout has none)
if slot >= 2 and character == characters[slot - 2]:
character = None
characters.append(character)
if any(characters):
keys[code] = characters
layout_id = args.layout + (f"-{args.variant}" if args.variant else "")
lines = ",\n".join(f" {json.dumps(code)}: {json.dumps(chars, ensure_ascii=False)}" for code, chars in keys.items())
source = json.dumps({"xkb_layout": args.layout, "xkb_variant": args.variant, "xkb_model": args.model})
path = LAYOUTS / f"{layout_id}.json"
path.write_text(f'{{\n "name": {json.dumps(args.name, ensure_ascii=False)},\n "source": {source},\n'
f' "keys": {{\n{lines}\n }}\n}}\n')
print(f"wrote {path.relative_to(ROOT)}: {len(keys)} keys")
if dead:
print("dead keys, left out:\n " + "\n ".join(dead))
if __name__ == "__main__":
main()