main.py
    
    Python script, ASCII text executable
        
            1
            """ 
        
            2
            Kineboard: an experimental touchscreen keyboard using swipes to allow 
        
            3
            having fewer touch targets. 
        
            4
            Copyright 2025, roundabout-host.com <vlad@roundabout-host.com> 
        
            5
            This program is free software: you can redistribute it and/or modify 
        
            6
            it under the terms of the GNU General Public Licence as published by 
        
            7
            the Free Software Foundation, either version 3 of the Licence, or 
        
            8
            (at your option) any later version. 
        
            9
            This program is distributed in the hope that it will be useful, 
        
            10
            but WITHOUT ANY WARRANTY; without even the implied warranty of 
        
            11
            MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 
        
            12
            GNU General Public Licence for more details. 
        
            13
            You should have received a copy of the GNU General Public Licence 
        
            14
            along with this program. If not, see <https://www.gnu.org/licenses/>. 
        
            15
            """ 
        
            16
             
        
            17
            import itertools 
        
            18
            import sys 
        
            19
            import os 
        
            20
            import time 
        
            21
             
        
            22
            import ruamel.yaml as yaml 
        
            23
            from pathlib import Path 
        
            24
             
        
            25
            os.environ["GI_TYPELIB_PATH"] = "/usr/local/lib/x86_64-linux-gnu/girepository-1.0" 
        
            26
            from ctypes import CDLL 
        
            27
            CDLL("libgtk4-layer-shell.so") 
        
            28
             
        
            29
            import gi 
        
            30
            gi.require_version("Gtk", "4.0") 
        
            31
            gi.require_version("Gtk4LayerShell", "1.0") 
        
            32
            from gi.repository import Gtk, Gdk, GObject, Gtk4LayerShell, GLib, Gio 
        
            33
             
        
            34
            from pywayland.client import Display, EventQueue 
        
            35
            from pywayland.protocol.wayland import WlRegistry, WlSeat 
        
            36
            from pywayland.protocol.input_method_unstable_v1 import ( 
        
            37
                ZwpInputMethodContextV1, 
        
            38
                ZwpInputMethodV1 
        
            39
            ) 
        
            40
            from pywayland.protocol.virtual_keyboard_unstable_v1 import ZwpVirtualKeyboardManagerV1 
        
            41
             
        
            42
             
        
            43
            import ctypes 
        
            44
            from cffi import FFI 
        
            45
            ffi = FFI() 
        
            46
            ffi.cdef(""" 
        
            47
            void * gdk_wayland_display_get_wl_display (void * display); 
        
            48
            void * gdk_wayland_surface_get_wl_surface (void * surface); 
        
            49
            void * gdk_wayland_monitor_get_wl_output (void * monitor); 
        
            50
            """) 
        
            51
            gtk = ffi.dlopen("libgtk-4.so.1") 
        
            52
             
        
            53
             
        
            54
            module_directory = Path(__file__).resolve().parent 
        
            55
             
        
            56
             
        
            57
            custom_css = """ 
        
            58
            .kineboard-selected-character { 
        
            59
                text-decoration: underline; 
        
            60
            } 
        
            61
            """ 
        
            62
            css_provider = Gtk.CssProvider() 
        
            63
            css_provider.load_from_data(custom_css) 
        
            64
            Gtk.StyleContext.add_provider_for_display( 
        
            65
                Gdk.Display.get_default(), 
        
            66
                css_provider, 
        
            67
                100 
        
            68
            ) 
        
            69
             
        
            70
             
        
            71
            def get_layout_directories(): 
        
            72
                data_home = Path(os.getenv("XDG_DATA_HOME", Path.home() / ".local" / "share")) 
        
            73
                data_dirs = [Path(d) for d in os.getenv("XDG_DATA_DIRS", "/usr/local/share:/usr/share").split(":")] 
        
            74
                all_paths = [data_home / "kineboard" / "layouts"] + [d / "kineboard" / "layouts" for d in data_dirs] 
        
            75
                return [d for d in all_paths if d.is_dir()] 
        
            76
             
        
            77
             
        
            78
            class GestureZone(GObject.GEnum): 
        
            79
                CENTRAL = 0 
        
            80
                TOP_LEFT = 1 
        
            81
                TOP_RIGHT = 2 
        
            82
                BOTTOM_RIGHT = 3 
        
            83
                BOTTOM_LEFT = 4 
        
            84
             
        
            85
             
        
            86
            class SwipeButton(Gtk.Frame): 
        
            87
                @GObject.Signal 
        
            88
                def top_left(self): 
        
            89
                    pass 
        
            90
             
        
            91
                @GObject.Signal 
        
            92
                def top_right(self): 
        
            93
                    pass 
        
            94
             
        
            95
                @GObject.Signal 
        
            96
                def bottom_left(self): 
        
            97
                    pass 
        
            98
             
        
            99
                @GObject.Signal 
        
            100
                def bottom_right(self): 
        
            101
                    pass 
        
            102
             
        
            103
                @GObject.Signal 
        
            104
                def central(self): 
        
            105
                    pass 
        
            106
             
        
            107
                @GObject.Signal(arg_types=(int,)) 
        
            108
                def drag_changed(self, zone): 
        
            109
                    pass 
        
            110
             
        
            111
                def __init__(self, **kwargs): 
        
            112
                    Gtk.Frame.__init__(self, **kwargs) 
        
            113
                    self.gesture_drag = Gtk.GestureDrag() 
        
            114
                    self.gesture_drag.connect("drag-end", self.finish_drag) 
        
            115
                    self.gesture_drag.connect("drag-update", self.move_drag) 
        
            116
                    self.gesture_drag.connect("drag-begin", self.begin_drag) 
        
            117
                    self.add_controller(self.gesture_drag) 
        
            118
             
        
            119
                def finish_drag(self, gesture, offset_x, offset_y): 
        
            120
                    if abs(offset_x) / self.get_width() + abs(offset_y) / self.get_height() > 1 / 2: 
        
            121
                        if offset_x > 0 and offset_y > 0: 
        
            122
                            self.emit("bottom_right") 
        
            123
                        elif offset_x <= 0 and offset_y > 0: 
        
            124
                            self.emit("bottom_left") 
        
            125
                        elif offset_x > 0 and offset_y <= 0: 
        
            126
                            self.emit("top_right") 
        
            127
                        elif offset_x <= 0 and offset_y <= 0: 
        
            128
                            self.emit("top_left") 
        
            129
                    else: 
        
            130
                        self.emit("central") 
        
            131
             
        
            132
                def move_drag(self, gesture, offset_x, offset_y): 
        
            133
                    if abs(offset_x) / self.get_width() + abs(offset_y) / self.get_height() > 1 / 2: 
        
            134
                        if offset_x > 0 and offset_y > 0: 
        
            135
                            self.emit("drag_changed", GestureZone.BOTTOM_RIGHT) 
        
            136
                        elif offset_x <= 0 and offset_y > 0: 
        
            137
                            self.emit("drag_changed", GestureZone.BOTTOM_LEFT) 
        
            138
                        elif offset_x > 0 and offset_y <= 0: 
        
            139
                            self.emit("drag_changed", GestureZone.TOP_RIGHT) 
        
            140
                        elif offset_x <= 0 and offset_y <= 0: 
        
            141
                            self.emit("drag_changed", GestureZone.TOP_LEFT) 
        
            142
                    else: 
        
            143
                        self.emit("drag_changed", GestureZone.CENTRAL) 
        
            144
             
        
            145
                def begin_drag(self, gesture, start_x, start_y): 
        
            146
                    self.emit("drag_changed", GestureZone.CENTRAL) 
        
            147
             
        
            148
             
        
            149
            class SwipeTyper(SwipeButton): 
        
            150
                @GObject.Signal(arg_types=(str,)) 
        
            151
                def typed(self, character): 
        
            152
                    # Since the drag is finished, remove the underlines 
        
            153
                    for label in self.label_map.values(): 
        
            154
                        label.remove_css_class("kineboard-selected-character") 
        
            155
             
        
            156
                def underline(self, zone): 
        
            157
                    for label in self.label_map.values(): 
        
            158
                        label.remove_css_class("kineboard-selected-character") 
        
            159
                    self.label_map[zone].add_css_class("kineboard-selected-character") 
        
            160
             
        
            161
                def __init__(self, tl, tr, bl, br, c, **kwargs): 
        
            162
                    SwipeButton.__init__(self, **kwargs) 
        
            163
                    self.tl = tl 
        
            164
                    self.tr = tr 
        
            165
                    self.bl = bl 
        
            166
                    self.br = br 
        
            167
                    self.c = c 
        
            168
                    self.grid = Gtk.Grid(hexpand=True, vexpand=True, row_homogeneous=True, column_homogeneous=True, margin_top=4, margin_bottom=4, margin_start=4, margin_end=4) 
        
            169
                    self.set_child(self.grid) 
        
            170
                    self.tl_label = Gtk.Label(label=tl) 
        
            171
                    self.grid.attach(self.tl_label, 0, 0, 1, 1) 
        
            172
                    self.tr_label = Gtk.Label(label=tr) 
        
            173
                    self.grid.attach(self.tr_label, 2, 0, 1, 1) 
        
            174
                    self.bl_label = Gtk.Label(label=bl) 
        
            175
                    self.grid.attach(self.bl_label, 0, 2, 1, 1) 
        
            176
                    self.br_label = Gtk.Label(label=br) 
        
            177
                    self.grid.attach(self.br_label, 2, 2, 1, 1) 
        
            178
                    self.c_label = Gtk.Label(label=c) 
        
            179
                    self.grid.attach(self.c_label, 1, 1, 1, 1) 
        
            180
                    self.label_map = { 
        
            181
                        GestureZone.CENTRAL: self.c_label, 
        
            182
                        GestureZone.TOP_LEFT: self.tl_label, 
        
            183
                        GestureZone.TOP_RIGHT: self.tr_label, 
        
            184
                        GestureZone.BOTTOM_RIGHT: self.br_label, 
        
            185
                        GestureZone.BOTTOM_LEFT: self.bl_label 
        
            186
                    } 
        
            187
                    self.connect("top_left", lambda p: self.emit("typed", tl)) 
        
            188
                    self.connect("top_right", lambda p: self.emit("typed", tr)) 
        
            189
                    self.connect("bottom_left", lambda p: self.emit("typed", bl)) 
        
            190
                    self.connect("bottom_right", lambda p: self.emit("typed", br)) 
        
            191
                    self.connect("central", lambda p: self.emit("typed", c)) 
        
            192
                    self.connect("drag_changed", lambda p, zone: self.underline(zone)) 
        
            193
             
        
            194
             
        
            195
            class Plane(Gtk.Grid): 
        
            196
                def __init__(self, app, data, **kwargs): 
        
            197
                    Gtk.Grid.__init__(self, **kwargs) 
        
            198
                    self.app = app 
        
            199
                    self.all_characters = "" 
        
            200
                    for j, column in enumerate(data): 
        
            201
                        for k, key in enumerate(column): 
        
            202
                            widget = SwipeTyper(key["top_left"], key["top_right"], key["bottom_left"], 
        
            203
                                                key["bottom_right"], key["central"]) 
        
            204
                            widget.connect("typed", self.app.typed) 
        
            205
                            self.attach(widget, k, j, 1, 1) 
        
            206
                            self.all_characters += key["top_left"] + key["top_right"] + key["bottom_left"] + key["bottom_right"] + key["central"] 
        
            207
             
        
            208
             
        
            209
             
        
            210
            def make_xkb_keymap_from_unicodes(unicodes): 
        
            211
                symbols = [ 
        
            212
                    "key <SP00> { [ BackSpace ] };", 
        
            213
                    "key <SP01> { [ Return ] };", 
        
            214
                ] 
        
            215
                keycodes = [ 
        
            216
                    "<SP00> = 4094;", 
        
            217
                    "<SP01> = 4095;", 
        
            218
                ] 
        
            219
                for i, char in enumerate(unicodes): 
        
            220
                    keycode = 0x1000 + i 
        
            221
                    keysym = f"U{ord(char):04X}" 
        
            222
                    symbols.append(f"key <K{i:03X}> {{ [ {keysym} ] }};") 
        
            223
                    keycodes.append(f"<K{i:03X}> = {keycode};") 
        
            224
             
        
            225
                return ("xkb_keymap { xkb_keycodes \"kineboard\" { minimum = 8; maximum = 65535; " 
        
            226
                        + " ".join(keycodes) + " }; xkb_symbols \"kineboard\" { " + " ".join(symbols) 
        
            227
                        + " }; xkb_types \"kineboard\" { type \"ONE_LEVEL\" { modifiers = none; level_name[Level1] = \"Any\"; }; }; xkb_compatibility \"kineboard\" {}; };") 
        
            228
             
        
            229
             
        
            230
            class Kineboard(Gtk.Application): 
        
            231
                def __init__(self): 
        
            232
                    Gtk.Application.__init__( 
        
            233
                        self, 
        
            234
                        application_id="com.roundabout_host.roundabout.Kineboard", 
        
            235
                        flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE 
        
            236
                    ) 
        
            237
                    self.revealer = None 
        
            238
                    self.switcher = None 
        
            239
                    self.character_index = {} 
        
            240
                    self.keymap_fd = None 
        
            241
                    self.vk = None 
        
            242
                    self.keymap_text = "" 
        
            243
                    self.all_characters = "" 
        
            244
                    self.context = None 
        
            245
                    self.input_method = None 
        
            246
                    self.registry = None 
        
            247
                    self.box = None 
        
            248
                    self.window = None 
        
            249
                    self.stack = None 
        
            250
                    self.display = None 
        
            251
                    self.wl_display = None 
        
            252
                    self.seat = None 
        
            253
                    self.vkm = None 
        
            254
                    self.serial = 0 
        
            255
                    self.add_main_option( 
        
            256
                        "invoke", 
        
            257
                        ord("i"), 
        
            258
                        GLib.OptionFlags.NONE, 
        
            259
                        GLib.OptionArg.NONE, 
        
            260
                        "Manually bring up the keyboard", 
        
            261
                        None 
        
            262
                    ) 
        
            263
                    self.add_main_option( 
        
            264
                        "hide", 
        
            265
                        ord("h"), 
        
            266
                        GLib.OptionFlags.NONE, 
        
            267
                        GLib.OptionArg.NONE, 
        
            268
                        "Hide the keyboard", 
        
            269
                        None 
        
            270
                    ) 
        
            271
             
        
            272
                def toggle_sidebar(self, button): 
        
            273
                    self.revealer.set_reveal_child(not self.revealer.get_reveal_child()) 
        
            274
             
        
            275
                def do_startup(self): 
        
            276
                    Gtk.Application.do_startup(self) 
        
            277
                    self.display = Gdk.Display.get_default() 
        
            278
                    self.window = Gtk.Window(application=self, title="Demo", focus_on_click=False, focusable=False, can_focus=False) 
        
            279
                    Gtk4LayerShell.init_for_window(self.window) 
        
            280
                    Gtk4LayerShell.set_keyboard_mode(self.window, Gtk4LayerShell.KeyboardMode.NONE) 
        
            281
                    Gtk4LayerShell.set_anchor(self.window, Gtk4LayerShell.Edge.BOTTOM, True) 
        
            282
                    Gtk4LayerShell.set_anchor(self.window, Gtk4LayerShell.Edge.LEFT, True) 
        
            283
                    Gtk4LayerShell.set_anchor(self.window, Gtk4LayerShell.Edge.RIGHT, True) 
        
            284
                    Gtk4LayerShell.auto_exclusive_zone_enable(self.window) 
        
            285
                    self.box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) 
        
            286
                    self.window.set_child(self.box) 
        
            287
                    self.stack = Gtk.Stack() 
        
            288
                    self.box.append(self.stack) 
        
            289
                    layouts = itertools.chain.from_iterable((f for f in path.iterdir() if f.is_file()) for path in get_layout_directories()) 
        
            290
                    self.all_characters = " " 
        
            291
                    self.switcher = Gtk.StackSidebar(stack=self.stack) 
        
            292
                    self.revealer = Gtk.Revealer(child=self.switcher, transition_type=Gtk.RevealerTransitionType.SLIDE_RIGHT) 
        
            293
                    self.box.prepend(self.revealer) 
        
            294
                    for layout in layouts: 
        
            295
                        stack = Gtk.Stack() 
        
            296
             
        
            297
                        yaml_loader = yaml.YAML(typ="rt") 
        
            298
                        data = yaml_loader.load(layout) 
        
            299
             
        
            300
                        controls = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8, margin_top=0, 
        
            301
                                           margin_bottom=8, margin_start=8, margin_end=8) 
        
            302
             
        
            303
                        layout_button = Gtk.Button(child=Gtk.Image.new_from_icon_name("edit-symbolic")) 
        
            304
                        layout_button.set_size_request(48, -1) 
        
            305
                        layout_button.update_property([Gtk.AccessibleProperty.LABEL], ["Switch layout"]) 
        
            306
                        layout_button.connect("clicked", self.toggle_sidebar) 
        
            307
                        controls.append(layout_button) 
        
            308
             
        
            309
                        if len(data["keys"]) >= 2: 
        
            310
                            shift_button = Gtk.ToggleButton( 
        
            311
                                    child=Gtk.Image.new_from_icon_name("go-up-symbolic") 
        
            312
                            ) 
        
            313
                            shift_button.set_size_request(48, -1) 
        
            314
                            shift_button.update_property([Gtk.AccessibleProperty.LABEL], ["Shift"]) 
        
            315
                            controls.append(shift_button) 
        
            316
             
        
            317
                            def shift(button: Gtk.ToggleButton): 
        
            318
                                stack.set_visible_child_name(f"plane-{int(button.get_active())}") 
        
            319
             
        
            320
                            shift_button.connect("toggled", shift) 
        
            321
             
        
            322
                        space_bar = Gtk.Button(hexpand=True) 
        
            323
                        space_bar.update_property([Gtk.AccessibleProperty.LABEL], ["Space"]) 
        
            324
                        space_bar.connect("clicked", lambda x: self.typed(x, " ")) 
        
            325
                        controls.append(space_bar) 
        
            326
             
        
            327
                        backspace_button = Gtk.Button(child=Gtk.Image.new_from_icon_name("go-previous-symbolic")) 
        
            328
                        backspace_button.set_size_request(48, -1) 
        
            329
                        backspace_button.update_property([Gtk.AccessibleProperty.LABEL], ["Backspace"]) 
        
            330
                        backspace_button.connect("clicked", lambda x: self.send_keysym(0xFF6)) 
        
            331
                        controls.append(backspace_button) 
        
            332
             
        
            333
                        enter_button = Gtk.Button(child=Gtk.Image.new_from_icon_name("keyboard-enter-symbolic")) 
        
            334
                        enter_button.set_size_request(48, -1) 
        
            335
                        enter_button.update_property([Gtk.AccessibleProperty.LABEL], ["Enter"]) 
        
            336
                        enter_button.add_css_class("suggested-action") 
        
            337
                        enter_button.connect("clicked", lambda x: self.send_keysym(0xFF7)) 
        
            338
                        controls.append(enter_button) 
        
            339
             
        
            340
                        for i, layer in enumerate(data["keys"]): 
        
            341
                            grid = Plane(self, layer, row_spacing=8, column_spacing=8, row_homogeneous=True, 
        
            342
                                         column_homogeneous=True, margin_top=8, margin_bottom=8, 
        
            343
                                         margin_end=8, margin_start=8) 
        
            344
                            self.all_characters += grid.all_characters 
        
            345
                            stack.add_named(grid, f"plane-{i}") 
        
            346
             
        
            347
                        outer_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) 
        
            348
                        outer_box.append(stack) 
        
            349
                        outer_box.append(controls) 
        
            350
                        self.stack.add_titled(outer_box, data["symbol"], data["symbol"]) 
        
            351
             
        
            352
                    ctypes.pythonapi.PyCapsule_GetPointer.restype = ctypes.c_void_p 
        
            353
                    ctypes.pythonapi.PyCapsule_GetPointer.argtypes = (ctypes.py_object,) 
        
            354
             
        
            355
                    self.wl_display = Display() 
        
            356
                    wl_display_ptr = gtk.gdk_wayland_display_get_wl_display( 
        
            357
                            ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(self.display.__gpointer__, None))) 
        
            358
                    self.wl_display._ptr = wl_display_ptr 
        
            359
                    self.registry = self.wl_display.get_registry() 
        
            360
                    self.registry.dispatcher["global"] = self.on_global 
        
            361
                    self.registry.dispatcher["global_remove"] = self.on_global_remove 
        
            362
                    self.wl_display.roundtrip() 
        
            363
             
        
            364
                    if self.input_method: 
        
            365
                        self.input_method.dispatcher["activate"] = self.activate_input 
        
            366
                        self.input_method.dispatcher["deactivate"] = self.deactivate_input 
        
            367
                    else: 
        
            368
                        dialog = Gtk.AlertDialog(message="Input method protocol not supported.") 
        
            369
                        dialog.show() 
        
            370
             
        
            371
                    self.keymap_text = make_xkb_keymap_from_unicodes(self.all_characters) 
        
            372
                    self.character_index = {} 
        
            373
                    for i, character in enumerate(self.all_characters): 
        
            374
                        self.character_index[character] = i 
        
            375
                    self.vk = self.vkm.create_virtual_keyboard(self.seat) 
        
            376
             
        
            377
                    self.keymap_fd = os.memfd_create("keymap") 
        
            378
                    os.write(self.keymap_fd, self.keymap_text.encode("utf-8")) 
        
            379
                    os.lseek(self.keymap_fd, 0, os.SEEK_SET) 
        
            380
                    self.vk.keymap(1, self.keymap_fd, len(self.keymap_text.encode("utf-8"))) 
        
            381
             
        
            382
                def activate_input(self, im, context): 
        
            383
                    self.window.present() 
        
            384
                    self.context = context 
        
            385
                    if self.context: 
        
            386
                        self.context.dispatcher["commit_state"] = self.commit_state 
        
            387
             
        
            388
                def commit_state(self, context, serial): 
        
            389
                    self.serial = serial 
        
            390
             
        
            391
                def deactivate_input(self, im, context): 
        
            392
                    self.window.set_visible(False) 
        
            393
                    if self.context: 
        
            394
                        self.context.destroy() 
        
            395
                        self.context = None 
        
            396
                        self.serial = 0 
        
            397
             
        
            398
                def on_global(self, registry, name, interface, version): 
        
            399
                    if interface == "zwp_input_method_v1": 
        
            400
                        self.input_method = registry.bind(name, ZwpInputMethodV1, version) 
        
            401
                    elif interface == "wl_seat": 
        
            402
                        self.seat = registry.bind(name, WlSeat, version) 
        
            403
                    elif interface == "zwp_virtual_keyboard_manager_v1": 
        
            404
                        self.vkm = registry.bind(name, ZwpVirtualKeyboardManagerV1, version) 
        
            405
             
        
            406
                def on_global_remove(self, registry, name): 
        
            407
                    pass 
        
            408
             
        
            409
                def send_keysym(self, key): 
        
            410
                    clock = int(time.monotonic() * 1000) 
        
            411
                    if self.vk: 
        
            412
                        self.vk.key(clock, key, 1) 
        
            413
                        self.vk.key(clock, key, 0) 
        
            414
             
        
            415
                def typed(self, button, characters): 
        
            416
                    for char in characters: 
        
            417
                        self.send_keysym(0xFF8 + self.character_index[char]) 
        
            418
             
        
            419
                def do_activate(self): 
        
            420
                    Gtk.Application.do_activate(self) 
        
            421
                    # self.window.present() 
        
            422
             
        
            423
                def do_command_line(self, command_line: Gio.ApplicationCommandLine): 
        
            424
                    options = command_line.get_options_dict() 
        
            425
                    args = command_line.get_arguments()[1:] 
        
            426
                    if options.contains("invoke"): 
        
            427
                        self.activate_input(None, None) 
        
            428
                    elif options.contains("hide"): 
        
            429
                        self.deactivate_input(None, None) 
        
            430
                    return 0 
        
            431
             
        
            432
             
        
            433
            if __name__ == "__main__": 
        
            434
                kineboard = Kineboard() 
        
            435
                kineboard.run(sys.argv) 
        
            436