1# Copyright (c) 2013 Jacob Mourelos
2# Copyright (c) 2014 Shepilov Vladislav
3# Copyright (c) 2014-2015 Sean Vig
4# Copyright (c) 2014 Tycho Andersen
5# Copyright (c) 2019 zordsdavini
6#
7# Permission is hereby granted, free of charge, to any person obtaining a copy
8# of this software and associated documentation files (the "Software"), to deal
9# in the Software without restriction, including without limitation the rights
10# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11# copies of the Software, and to permit persons to whom the Software is
12# furnished to do so, subject to the following conditions:
13#
14# The above copyright notice and this permission notice shall be included in
15# all copies or substantial portions of the Software.
16#
17# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23# SOFTWARE.
24
25from __future__ import annotations
26
27import re
28from abc import ABCMeta, abstractmethod
29from subprocess import CalledProcessError, check_output
30from typing import TYPE_CHECKING
31
32from libqtile.confreader import ConfigError
33from libqtile.log_utils import logger
34from libqtile.widget import base
35
36if TYPE_CHECKING:
37    from typing import Optional
38
39    from libqtile.core.manager import Qtile
40
41
42class _BaseLayoutBackend(metaclass=ABCMeta):
43    def __init__(self, qtile: Qtile):
44        """
45        This handles getting and setter the keyboard layout with the appropriate
46        backend.
47        """
48
49    @abstractmethod
50    def get_keyboard(self) -> str:
51        """
52        Return the currently used keyboard layout as a string
53
54        Examples: "us", "us dvorak".  In case of error returns "unknown".
55        """
56
57    def set_keyboard(self, layout: str, options: Optional[str]) -> None:
58        """
59        Set the keyboard layout with specified options.
60        """
61
62
63class _X11LayoutBackend(_BaseLayoutBackend):
64    kb_layout_regex = re.compile(r'layout:\s+(?P<layout>\w+)')
65    kb_variant_regex = re.compile(r'variant:\s+(?P<variant>\w+)')
66
67    def get_keyboard(self) -> str:
68        try:
69            command = 'setxkbmap -verbose 10 -query'
70            setxkbmap_output = check_output(command.split(' ')).decode()
71        except CalledProcessError as e:
72            logger.error('Can not get the keyboard layout: {0}'.format(e))
73            return "unknown"
74        except OSError as e:
75            logger.error('Please, check that xset is available: {0}'.format(e))
76            return "unknown"
77
78        match_layout = self.kb_layout_regex.search(setxkbmap_output)
79        if match_layout is None:
80            return 'ERR'
81        keyboard = match_layout.group('layout')
82
83        match_variant = self.kb_variant_regex.search(setxkbmap_output)
84        if match_variant:
85            keyboard += " " + match_variant.group('variant')
86        return keyboard
87
88    def set_keyboard(self, layout: str, options: Optional[str]) -> None:
89        command = ['setxkbmap']
90        command.extend(layout.split(" "))
91        if options:
92            command.extend(['-option', options])
93        try:
94            check_output(command)
95        except CalledProcessError as e:
96            logger.error('Can not change the keyboard layout: {0}'.format(e))
97        except OSError as e:
98            logger.error('Please, check that setxkbmap is available: {0}'.format(e))
99
100
101class _WaylandLayoutBackend(_BaseLayoutBackend):
102    def __init__(self, qtile: Qtile) -> None:
103        self.set_keymap = qtile.core.set_keymap  # type: ignore
104        self._layout: str = ""
105
106    def get_keyboard(self) -> str:
107        return self._layout
108
109    def set_keyboard(self, layout: str, options: Optional[str]) -> None:
110        maybe_variant: Optional[str] = None
111        if " " in layout:
112            layout_name, maybe_variant = layout.split(" ", maxsplit=1)
113        else:
114            layout_name = layout
115        self.set_keymap(layout_name, options, maybe_variant)
116        self._layout = layout
117
118
119layout_backends = {
120    'x11': _X11LayoutBackend,
121    'wayland': _WaylandLayoutBackend,
122}
123
124
125class KeyboardLayout(base.InLoopPollText):
126    """Widget for changing and displaying the current keyboard layout
127
128    To use this widget effectively you need to specify keyboard layouts you want to use (using "configured_keyboards")
129    and bind function "next_keyboard" to specific keys in order to change layouts.
130
131    For example:
132
133        Key([mod], "space", lazy.widget["keyboardlayout"].next_keyboard(), desc="Next keyboard layout."),
134
135    When running Qtile with the X11 backend, this widget requires setxkbmap to be available.
136    """
137    orientations = base.ORIENTATION_HORIZONTAL
138    defaults = [
139        ("update_interval", 1, "Update time in seconds."),
140        ("configured_keyboards", ["us"], "A list of predefined keyboard layouts "
141            "represented as strings. For example: "
142            "['us', 'us colemak', 'es', 'fr']."),
143        ("display_map", {}, "Custom display of layout. Key should be in format "
144            "'layout variant'. For example: "
145            "{'us': 'us ', 'lt sgs': 'sgs', 'ru phonetic': 'ru '}"),
146        ("option", None, "string of setxkbmap option. Ex., 'compose:menu,grp_led:scroll'"),
147    ]
148
149    def __init__(self, **config):
150        base.InLoopPollText.__init__(self, **config)
151        self.add_defaults(KeyboardLayout.defaults)
152        self.add_callbacks({'Button1': self.next_keyboard})
153
154    def _configure(self, qtile, bar):
155        base.InLoopPollText._configure(self, qtile, bar)
156
157        if qtile.core.name not in layout_backends:
158            raise ConfigError(
159                "KeyboardLayout does not support backend: " + qtile.core.name
160            )
161
162        self.backend = layout_backends[qtile.core.name](qtile)
163        self.backend.set_keyboard(self.configured_keyboards[0], self.option)
164
165    def next_keyboard(self):
166        """Set the next layout in the list of configured keyboard layouts as
167        new current layout in use
168
169        If the current keyboard layout is not in the list, it will set as new
170        layout the first one in the list.
171        """
172
173        current_keyboard = self.backend.get_keyboard()
174        if current_keyboard in self.configured_keyboards:
175            # iterate the list circularly
176            next_keyboard = self.configured_keyboards[
177                (self.configured_keyboards.index(current_keyboard) + 1) %
178                len(self.configured_keyboards)]
179        else:
180            next_keyboard = self.configured_keyboards[0]
181
182        self.backend.set_keyboard(next_keyboard, self.option)
183
184        self.tick()
185
186    def poll(self):
187        keyboard = self.backend.get_keyboard()
188        if keyboard in self.display_map.keys():
189            return self.display_map[keyboard]
190        return keyboard.upper()
191
192    def cmd_next_keyboard(self):
193        """Select next keyboard layout"""
194        self.next_keyboard()
195