558 lines
17 KiB
Python
558 lines
17 KiB
Python
|
# -*- coding: utf-8 -*-
|
||
|
|
||
|
"""
|
||
|
xkbgroup.core
|
||
|
~~~~~~~~~~~~~
|
||
|
|
||
|
This module implements the XKeyboard API.
|
||
|
|
||
|
Classes:
|
||
|
* XKeyboard: the main class.
|
||
|
|
||
|
Exceptions:
|
||
|
* X11Error: raised for errors on X server issues.
|
||
|
|
||
|
:copyright: (c) 2016 by Nguyen Duc My.
|
||
|
:license: MIT, see LICENSE for more details.
|
||
|
"""
|
||
|
|
||
|
import os
|
||
|
import re
|
||
|
import sys
|
||
|
|
||
|
from ctypes import *
|
||
|
try:
|
||
|
from collections import UserList
|
||
|
except ImportError:
|
||
|
from UserList import UserList
|
||
|
|
||
|
from collections import namedtuple
|
||
|
|
||
|
from .xkb import *
|
||
|
|
||
|
|
||
|
# Error-related utilities
|
||
|
|
||
|
OPEN_DISPLAY_ERRORS = {
|
||
|
XkbOD_BadLibraryVersion: "{libname} uses XKB version {used_major}.{used_minor}\n"
|
||
|
"Xlib supports incompatible version {found_major}.{found_minor}",
|
||
|
XkbOD_ConnectionRefused: "Cannot open display \"{display_name}\"",
|
||
|
XkbOD_BadServerVersion: "{libname} uses XKB version {used_major}.{used_minor}\n"
|
||
|
"Server \"{display_name}\" uses incompatible version "
|
||
|
"{found_major}.{found_minor}",
|
||
|
XkbOD_NonXkbServer: "XKB extension not present on \"{display_name}\"",
|
||
|
}
|
||
|
|
||
|
GET_CONTROLS_ERRORS = {
|
||
|
BadAlloc: "Unable to allocate storage",
|
||
|
BadImplementation: "Invalid reply from server",
|
||
|
BadMatch: "A compatible version of Xkb was not available in the server or "
|
||
|
"an argument has correct type and range, but is otherwise invalid",
|
||
|
}
|
||
|
|
||
|
GET_NAMES_ERRORS = {
|
||
|
BadAlloc: "Unable to allocate storage",
|
||
|
BadImplementation: "Invalid reply from server",
|
||
|
BadLength: "The length of a request is shorter or longer than that "
|
||
|
"required to minimally contain the arguments",
|
||
|
BadMatch: "A compatible version of Xkb was not available in the server or "
|
||
|
"an argument has correct type and range, but is otherwise invalid",
|
||
|
}
|
||
|
|
||
|
class X11Error(Exception):
|
||
|
"""Exception class, raised for errors on X server issues."""
|
||
|
|
||
|
def _ensure_type(obj, type):
|
||
|
if not isinstance(obj, type):
|
||
|
raise ValueError("Wrong value type, must be {}.".format(str(type)))
|
||
|
|
||
|
|
||
|
# Both Python 3.2+ compatible and more neat than assigning to __doc__
|
||
|
class GroupData(namedtuple("GroupData", ["num", "name", "symbol", "variant"])):
|
||
|
"""Contains all data about the specific group."""
|
||
|
|
||
|
def __format__(self, format_spec):
|
||
|
"""If format_spec is not empty, use it as a format string in
|
||
|
format_spec.format(...) with keyword arguments named corresponding to
|
||
|
fields. Otherwise just return str(self).
|
||
|
|
||
|
:param format_spec: format specifier
|
||
|
:rtype: str
|
||
|
"""
|
||
|
if len(format_spec) > 0:
|
||
|
return format_spec.format(
|
||
|
num=self.num,
|
||
|
name=self.name,
|
||
|
symbol=self.symbol,
|
||
|
variant=self.variant)
|
||
|
return str(self)
|
||
|
|
||
|
class XKeyboard:
|
||
|
"""The main class.
|
||
|
|
||
|
Usage examples:
|
||
|
|
||
|
# Assume we have the following configuration
|
||
|
$ setxkbmap -layout us,ru,ua,fr
|
||
|
$ python
|
||
|
>>> from xkbgroup import XKeyboard
|
||
|
>>> xkb = XKeyboard()
|
||
|
>>> xkb.group_num
|
||
|
1
|
||
|
>>> xkb.group_num = 2
|
||
|
>>> xkb.group_num
|
||
|
2
|
||
|
>>> xkb.group_num -= 2
|
||
|
>>> xkb.group_num
|
||
|
0
|
||
|
>>> xkb.group_name
|
||
|
English (US)
|
||
|
>>> xkb.group_name = 'Ukrainian'
|
||
|
>>> xkb.group_name
|
||
|
Ukrainian
|
||
|
>>> xkb.group_num
|
||
|
2
|
||
|
>>> xkb.group_symbol
|
||
|
ua
|
||
|
>>> xkb.group_symbol = 'fr'
|
||
|
>>> xkb.group_symbol
|
||
|
fr
|
||
|
>>> xkb.group_variant
|
||
|
''
|
||
|
>>> xkb.group_num -= 3
|
||
|
>>> xkb.group_variant
|
||
|
''
|
||
|
>>> xkb.group_num
|
||
|
0
|
||
|
>>>
|
||
|
"""
|
||
|
|
||
|
# Fields with default values
|
||
|
|
||
|
non_symbols = {"pc", "inet", "group", "terminate"}
|
||
|
|
||
|
|
||
|
# Main methods
|
||
|
|
||
|
def __init__(self, auto_open=True, non_symbols=None):
|
||
|
"""
|
||
|
:param auto_open: if True automatically call open_display().
|
||
|
:param non_symbols: either iterable of string non-symbol names or
|
||
|
None to use the default set of non-symbol names.
|
||
|
"""
|
||
|
if non_symbols:
|
||
|
self.non_symbols = non_symbols
|
||
|
|
||
|
if auto_open:
|
||
|
self.open_display()
|
||
|
|
||
|
def open_display(self):
|
||
|
"""Establishes connection with X server and prepares objects
|
||
|
necessary to retrieve and send data.
|
||
|
"""
|
||
|
self.close_display() # Properly finish previous open_display()
|
||
|
|
||
|
XkbIgnoreExtension(False)
|
||
|
|
||
|
display_name = None
|
||
|
major = c_int(XkbMajorVersion)
|
||
|
minor = c_int(XkbMinorVersion)
|
||
|
reason = c_int()
|
||
|
|
||
|
self._display = XkbOpenDisplay(
|
||
|
display_name,
|
||
|
None, None, byref(major), byref(minor), byref(reason))
|
||
|
if not self._display:
|
||
|
if reason.value in OPEN_DISPLAY_ERRORS:
|
||
|
# Assume POSIX conformance
|
||
|
display_name = os.getenv("DISPLAY") or "default"
|
||
|
|
||
|
raise X11Error(OPEN_DISPLAY_ERRORS[reason.value].format(
|
||
|
libname="xkbgroup",
|
||
|
used_major=XkbMajorVersion,
|
||
|
used_minor=XkbMinorVersion,
|
||
|
found_major=major.value,
|
||
|
found_minor=minor.value,
|
||
|
display_name=display_name)
|
||
|
+ ".")
|
||
|
else:
|
||
|
raise X11Error("Unknown error {} from XkbOpenDisplay.".format(reason.value))
|
||
|
|
||
|
self._keyboard_description = XkbGetMap(self._display, 0, XkbUseCoreKbd)
|
||
|
if not self._keyboard_description:
|
||
|
self.close_display()
|
||
|
raise X11Error("Failed to get keyboard description.")
|
||
|
|
||
|
# Controls mask doesn't affect the availability of xkb->ctrls->num_groups anyway
|
||
|
# Just use a valid value, and xkb->ctrls->num_groups will be definitely set
|
||
|
status = XkbGetControls(self._display, XkbAllControlsMask, self._keyboard_description)
|
||
|
if status != Success:
|
||
|
self.close_display()
|
||
|
raise X11Error(GET_CONTROLS_ERRORS[status] + ".")
|
||
|
|
||
|
names_mask = XkbSymbolsNameMask | XkbGroupNamesMask
|
||
|
status = XkbGetNames(self._display, names_mask, self._keyboard_description)
|
||
|
if status != Success:
|
||
|
self.close_display()
|
||
|
raise X11Error(GET_NAMES_ERRORS[status] + ".")
|
||
|
|
||
|
def close_display(self):
|
||
|
"""Closes connection with X server and cleans up objects
|
||
|
created on open_display().
|
||
|
"""
|
||
|
if hasattr(self, "_keyboard_description") and self._keyboard_description:
|
||
|
names_mask = XkbSymbolsNameMask | XkbGroupNamesMask
|
||
|
XkbFreeNames(self._keyboard_description, names_mask, True)
|
||
|
XkbFreeControls(self._keyboard_description, XkbAllControlsMask, True)
|
||
|
XkbFreeClientMap(self._keyboard_description, 0, True)
|
||
|
del self._keyboard_description
|
||
|
|
||
|
if hasattr(self, "_display") and self._display:
|
||
|
XCloseDisplay(self._display)
|
||
|
del self._display
|
||
|
|
||
|
def __del__(self):
|
||
|
self.close_display()
|
||
|
|
||
|
def __enter__(self):
|
||
|
self.open_display()
|
||
|
return self
|
||
|
|
||
|
def __exit__(self, type, value, traceback):
|
||
|
self.close_display()
|
||
|
|
||
|
|
||
|
# Properties for all layouts
|
||
|
|
||
|
@property
|
||
|
def groups_data(self):
|
||
|
"""All data about all groups (get-only).
|
||
|
|
||
|
:getter: Returns all data about all groups
|
||
|
:type: list of GroupData
|
||
|
"""
|
||
|
return _ListProxy(GroupData(num, name, symbol, variant)
|
||
|
for (num, name, symbol, variant)
|
||
|
in zip(range(self.groups_count),
|
||
|
self.groups_names,
|
||
|
self.groups_symbols,
|
||
|
self.groups_variants))
|
||
|
|
||
|
@property
|
||
|
def groups_count(self):
|
||
|
"""Number of all groups (get-only).
|
||
|
|
||
|
:getter: Returns number of all groups
|
||
|
:type: int
|
||
|
"""
|
||
|
if self._keyboard_description.contents.ctrls is not None:
|
||
|
return self._keyboard_description.contents.ctrls.contents.num_groups
|
||
|
else:
|
||
|
groups_source = self._groups_source
|
||
|
|
||
|
groups_count = 0
|
||
|
while (groups_count < XkbNumKbdGroups and
|
||
|
groups_source[groups_count] != None_):
|
||
|
groups_count += 1
|
||
|
|
||
|
return groups_count
|
||
|
|
||
|
@property
|
||
|
def groups_names(self):
|
||
|
"""Names of all groups (get-only).
|
||
|
|
||
|
:getter: Returns names of all groups
|
||
|
:type: list of str
|
||
|
"""
|
||
|
return _ListProxy(self._get_group_name_by_num(i) for i in range(self.groups_count))
|
||
|
|
||
|
@property
|
||
|
def groups_symbols(self):
|
||
|
"""Symbols of all groups (get-only).
|
||
|
|
||
|
:getter: Returns symbols of all groups
|
||
|
:type: list of str
|
||
|
"""
|
||
|
return _ListProxy(symdata.symbol for symdata in self._symboldata_list)
|
||
|
|
||
|
@property
|
||
|
def groups_variants(self):
|
||
|
"""Variants of all groups (get-only).
|
||
|
|
||
|
:getter: Returns variants of all groups
|
||
|
:type: list of str
|
||
|
"""
|
||
|
return _ListProxy(symdata.variant or "" for symdata in self._symboldata_list)
|
||
|
|
||
|
|
||
|
# Properties and methods for current layout
|
||
|
|
||
|
@property
|
||
|
def group_data(self):
|
||
|
"""All data about the current group (get-only).
|
||
|
|
||
|
:getter: Returns all data about the current group
|
||
|
:type: GroupData
|
||
|
"""
|
||
|
return GroupData(self.group_num,
|
||
|
self.group_name,
|
||
|
self.group_symbol,
|
||
|
self.group_variant)
|
||
|
|
||
|
@property
|
||
|
def group_num(self):
|
||
|
"""Current group number.
|
||
|
|
||
|
:getter: Returns current group number
|
||
|
:setter: Sets current group number
|
||
|
:type: int
|
||
|
"""
|
||
|
xkb_state = XkbStateRec()
|
||
|
XkbGetState(self._display, XkbUseCoreKbd, byref(xkb_state))
|
||
|
return xkb_state.group
|
||
|
|
||
|
@group_num.setter
|
||
|
def group_num(self, value):
|
||
|
_ensure_type(value, int)
|
||
|
if XkbLockGroup(self._display, XkbUseCoreKbd, value):
|
||
|
XFlush(self._display)
|
||
|
else:
|
||
|
self.close_display()
|
||
|
raise X11Error("Failed to set group number.")
|
||
|
|
||
|
|
||
|
@property
|
||
|
def group_name(self):
|
||
|
"""Current group full name.
|
||
|
|
||
|
:getter: Returns current group name
|
||
|
:setter: Sets current group name
|
||
|
:type: str
|
||
|
"""
|
||
|
return self._get_group_name_by_num(self.group_num)
|
||
|
|
||
|
@group_name.setter
|
||
|
def group_name(self, value):
|
||
|
_ensure_type(value, str)
|
||
|
groups_names = self.groups_names
|
||
|
n_mapping = {groups_names[i]: i for i in range(len(groups_names))}
|
||
|
try:
|
||
|
self.group_num = n_mapping[value]
|
||
|
except KeyError as exc:
|
||
|
raise ValueError("Wrong group name.")
|
||
|
|
||
|
|
||
|
@property
|
||
|
def group_symbol(self):
|
||
|
"""Current group symbol.
|
||
|
|
||
|
:getter: Returns current group symbol
|
||
|
:setter: Sets current group symbol
|
||
|
:type: str
|
||
|
"""
|
||
|
s_mapping = {symdata.index: symdata.symbol for symdata in self._symboldata_list}
|
||
|
return s_mapping[self.group_num]
|
||
|
|
||
|
@group_symbol.setter
|
||
|
def group_symbol(self, value):
|
||
|
_ensure_type(value, str)
|
||
|
s_mapping = {symdata.symbol: symdata.index for symdata in self._symboldata_list}
|
||
|
try:
|
||
|
self.group_num = s_mapping[value]
|
||
|
except KeyError as exc:
|
||
|
raise ValueError("Wrong group symbol.")
|
||
|
|
||
|
|
||
|
@property
|
||
|
def group_variant(self):
|
||
|
"""Current group variant (get-only).
|
||
|
|
||
|
:getter: Returns current group variant
|
||
|
:type: str
|
||
|
"""
|
||
|
v_mapping = {symdata.index: symdata.variant for symdata in self._symboldata_list}
|
||
|
return v_mapping[self.group_num] or ""
|
||
|
|
||
|
# Current group variant is a get-only value because variants are associated
|
||
|
# with symbols in /usr/share/X11/xkb/rules/evdev.lst and specified at
|
||
|
# setxkbmap call time
|
||
|
|
||
|
|
||
|
# Formatting method (for the great goodness!)
|
||
|
|
||
|
def format(self, format_str):
|
||
|
"""Returns a formatted version of format_str.
|
||
|
The only named replacement fields supported by this method and
|
||
|
their corresponding API calls are:
|
||
|
|
||
|
* {num} group_num
|
||
|
* {name} group_name
|
||
|
* {symbol} group_symbol
|
||
|
* {variant} group_variant
|
||
|
* {current_data} group_data
|
||
|
* {nums} groups_nums
|
||
|
* {names} groups_names
|
||
|
* {symbols} groups_symbols
|
||
|
* {variants} groups_variants
|
||
|
* {all_data} groups_data
|
||
|
|
||
|
Passing other replacement fields will result in raising exceptions.
|
||
|
|
||
|
:param format_str: a new style format string
|
||
|
:rtype: str
|
||
|
"""
|
||
|
return format_str.format(**{
|
||
|
"num": self.group_num,
|
||
|
"name": self.group_name,
|
||
|
"symbol": self.group_symbol,
|
||
|
"variant": self.group_variant,
|
||
|
"current_data": self.group_data,
|
||
|
"count": self.groups_count,
|
||
|
"names": self.groups_names,
|
||
|
"symbols": self.groups_symbols,
|
||
|
"variants": self.groups_variants,
|
||
|
"all_data": self.groups_data})
|
||
|
|
||
|
def __format__(self, format_spec):
|
||
|
"""Handle format(xkb, format_spec) as xkb.format(format_spec) if
|
||
|
format_spec is not empty. Otherwise just return str(self).
|
||
|
|
||
|
:param format_spec: format specifier
|
||
|
:rtype: str
|
||
|
"""
|
||
|
if len(format_spec) > 0:
|
||
|
return self.format(format_spec)
|
||
|
return str(self)
|
||
|
|
||
|
|
||
|
# Private properties and methods
|
||
|
|
||
|
@property
|
||
|
def _groups_source(self):
|
||
|
return self._keyboard_description.contents.names.contents.groups
|
||
|
|
||
|
@property
|
||
|
def _symbols_source(self):
|
||
|
return self._keyboard_description.contents.names.contents.symbols
|
||
|
|
||
|
@property
|
||
|
def _symboldata_list(self):
|
||
|
symbol_str_atom = self._symbols_source
|
||
|
if symbol_str_atom != None_:
|
||
|
b_symbol_str = XGetAtomName(self._display, symbol_str_atom)
|
||
|
return _parse_symbols(b_symbol_str.decode(), self.non_symbols)
|
||
|
else:
|
||
|
raise X11Error("Failed to get symbol names.")
|
||
|
|
||
|
def _get_group_name_by_num(self, group_num):
|
||
|
cur_group_atom = self._groups_source[group_num]
|
||
|
if cur_group_atom != None_:
|
||
|
b_group_name = XGetAtomName(self._display, cur_group_atom)
|
||
|
return b_group_name.decode() if b_group_name else ""
|
||
|
else:
|
||
|
raise X11Error("Failed to get group name.")
|
||
|
|
||
|
|
||
|
SymbolData = namedtuple("SymbolData", ["symbol", "variant", "index"])
|
||
|
SYMBOL_REGEX = re.compile(r"""
|
||
|
(?P<symbol>\w+)
|
||
|
(?: \( (?P<variant>\w+) \) )?
|
||
|
(?: : (?P<index>\d+) )?
|
||
|
""", re.VERBOSE)
|
||
|
|
||
|
class _Compat_SRE_Pattern:
|
||
|
def __init__(self, re_obj):
|
||
|
self.re_obj = re_obj
|
||
|
|
||
|
def __getattr__(self, name):
|
||
|
return getattr(self.re_obj, name)
|
||
|
|
||
|
# re_obj.fullmatch is a Python 3.4+ only feature
|
||
|
def fullmatch(self, string, pos=None, endpos=None):
|
||
|
pos = pos if pos else 0
|
||
|
endpos = endpos if endpos else len(string)
|
||
|
match = self.re_obj.match(string, pos, endpos)
|
||
|
if match and match.span() != (pos, endpos):
|
||
|
return None
|
||
|
return match
|
||
|
|
||
|
if sys.version_info < (3, 4):
|
||
|
SYMBOL_REGEX = _Compat_SRE_Pattern(SYMBOL_REGEX)
|
||
|
|
||
|
def _parse_symbols(symbols_str, non_symbols, default_index=0):
|
||
|
def get_symboldata(symstr):
|
||
|
match = SYMBOL_REGEX.fullmatch(symstr)
|
||
|
if match:
|
||
|
index = match.group('index')
|
||
|
return SymbolData(
|
||
|
match.group('symbol'),
|
||
|
match.group('variant'),
|
||
|
int(index) - 1 if index else default_index)
|
||
|
else:
|
||
|
raise X11Error("Malformed symbol string: \"{}\"".format(symstr))
|
||
|
|
||
|
symboldata_list = []
|
||
|
for symstr in symbols_str.split('+'):
|
||
|
symboldata = get_symboldata(symstr)
|
||
|
if symboldata.symbol not in non_symbols:
|
||
|
symboldata_list.append(symboldata)
|
||
|
|
||
|
indices = [symdata.index for symdata in symboldata_list]
|
||
|
assert len(indices) == len(set(indices)) # No doubles
|
||
|
|
||
|
return symboldata_list
|
||
|
|
||
|
|
||
|
_COLON_SEPARATOR_REGEX = re.compile(r"(?<!\\):")
|
||
|
|
||
|
class _ListProxy(UserList):
|
||
|
def __format__(self, format_spec):
|
||
|
if len(format_spec) > 0:
|
||
|
spec_parts = _COLON_SEPARATOR_REGEX.split(format_spec)
|
||
|
spec_parts = [s.replace("\\:", ":") for s in spec_parts]
|
||
|
assert len(spec_parts) > 0
|
||
|
|
||
|
elem_spec = spec_parts[0]
|
||
|
elems_formatted = [format(x, elem_spec) for x in self.data]
|
||
|
|
||
|
if len(spec_parts) == 1:
|
||
|
assert len(elem_spec) > 0
|
||
|
return str(elems_formatted)
|
||
|
elif len(spec_parts) == 2:
|
||
|
sep = spec_parts[1]
|
||
|
return sep.join(elems_formatted)
|
||
|
else:
|
||
|
raise ValueError(
|
||
|
"Too many specifiers: \"{}\"".format(format_spec))
|
||
|
|
||
|
return str(self.data)
|
||
|
|
||
|
|
||
|
__all__ = ["XKeyboard", "GroupData", "X11Error"]
|
||
|
|
||
|
|
||
|
def print_xkeyboard(xkb):
|
||
|
print("xkb {")
|
||
|
contents = [
|
||
|
"%d groups {%s}," % (xkb.groups_count, ", ".join(xkb.groups_names)),
|
||
|
"symbols {%s}" % ", ".join(xkb.groups_symbols),
|
||
|
"variants {%s}" % ", ".join('"{}"'.format(variant) for variant in xkb.groups_variants),
|
||
|
"current group: %s (%d) - %s - \"%s\"" %
|
||
|
(xkb.group_symbol, xkb.group_num, xkb.group_name, xkb.group_variant)
|
||
|
]
|
||
|
print("\n".join("\t" + line for line in contents))
|
||
|
print("}")
|
||
|
|
||
|
def test():
|
||
|
with XKeyboard() as xkb:
|
||
|
print_xkeyboard(xkb)
|
||
|
xkb.group_num += 2
|
||
|
print_xkeyboard(xkb)
|
||
|
xkb.group_num -= 3
|
||
|
print_xkeyboard(xkb)
|
||
|
xkb.group_num -= 2
|
||
|
print_xkeyboard(xkb)
|
||
|
|
||
|
if __name__ == '__main__':
|
||
|
test()
|