Important information: Google announced that, from September 2026, Android devices will require ALL apps to be signed by Google, effectively leading to an iOS situation. Value your right to a computer that does what you want; do not tolerate this monopolistic practice! Contact me if you don't understand why it is bad. Click to learn more.

 main.py

View raw Download
text/plain • 18.69 kiB
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.input_method_unstable_v2 import (
41
ZwpInputMethodV2,
42
ZwpInputMethodManagerV2
43
)
44
from pywayland.protocol.virtual_keyboard_unstable_v1 import ZwpVirtualKeyboardManagerV1
45
46
import xkbcommon
47
48
import ctypes
49
from cffi import FFI
50
ffi = FFI()
51
ffi.cdef("""
52
void * gdk_wayland_display_get_wl_display (void * display);
53
void * gdk_wayland_surface_get_wl_surface (void * surface);
54
void * gdk_wayland_monitor_get_wl_output (void * monitor);
55
""")
56
gtk = ffi.dlopen("libgtk-4.so.1")
57
58
59
module_directory = Path(__file__).resolve().parent
60
61
62
custom_css = """
63
.kineboard-selected-character {
64
text-decoration: underline;
65
}
66
"""
67
css_provider = Gtk.CssProvider()
68
css_provider.load_from_data(custom_css)
69
Gtk.StyleContext.add_provider_for_display(
70
Gdk.Display.get_default(),
71
css_provider,
72
100
73
)
74
75
76
def get_layout_directories():
77
data_home = Path(os.getenv("XDG_DATA_HOME", Path.home() / ".local" / "share"))
78
data_dirs = [Path(d) for d in os.getenv("XDG_DATA_DIRS", "/usr/local/share:/usr/share").split(":")]
79
all_paths = [data_home / "kineboard" / "layouts"] + [d / "kineboard" / "layouts" for d in data_dirs]
80
return [d for d in all_paths if d.is_dir()]
81
82
83
class GestureZone(GObject.GEnum):
84
CENTRAL = 0
85
TOP_LEFT = 1
86
TOP_RIGHT = 2
87
BOTTOM_RIGHT = 3
88
BOTTOM_LEFT = 4
89
90
91
class SwipeButton(Gtk.Frame):
92
@GObject.Signal
93
def top_left(self):
94
pass
95
96
@GObject.Signal
97
def top_right(self):
98
pass
99
100
@GObject.Signal
101
def bottom_left(self):
102
pass
103
104
@GObject.Signal
105
def bottom_right(self):
106
pass
107
108
@GObject.Signal
109
def central(self):
110
pass
111
112
@GObject.Signal(arg_types=(int,))
113
def drag_changed(self, zone):
114
pass
115
116
def __init__(self, **kwargs):
117
Gtk.Frame.__init__(self, **kwargs)
118
self.gesture_drag = Gtk.GestureDrag()
119
self.gesture_drag.connect("drag-end", self.finish_drag)
120
self.gesture_drag.connect("drag-update", self.move_drag)
121
self.gesture_drag.connect("drag-begin", self.begin_drag)
122
self.add_controller(self.gesture_drag)
123
124
def finish_drag(self, gesture, offset_x, offset_y):
125
if abs(offset_x) / self.get_width() + abs(offset_y) / self.get_height() > 1 / 2:
126
if offset_x > 0 and offset_y > 0:
127
self.emit("bottom_right")
128
elif offset_x <= 0 and offset_y > 0:
129
self.emit("bottom_left")
130
elif offset_x > 0 and offset_y <= 0:
131
self.emit("top_right")
132
elif offset_x <= 0 and offset_y <= 0:
133
self.emit("top_left")
134
else:
135
self.emit("central")
136
137
def move_drag(self, gesture, offset_x, offset_y):
138
if abs(offset_x) / self.get_width() + abs(offset_y) / self.get_height() > 1 / 2:
139
if offset_x > 0 and offset_y > 0:
140
self.emit("drag_changed", GestureZone.BOTTOM_RIGHT)
141
elif offset_x <= 0 and offset_y > 0:
142
self.emit("drag_changed", GestureZone.BOTTOM_LEFT)
143
elif offset_x > 0 and offset_y <= 0:
144
self.emit("drag_changed", GestureZone.TOP_RIGHT)
145
elif offset_x <= 0 and offset_y <= 0:
146
self.emit("drag_changed", GestureZone.TOP_LEFT)
147
else:
148
self.emit("drag_changed", GestureZone.CENTRAL)
149
150
def begin_drag(self, gesture, start_x, start_y):
151
self.emit("drag_changed", GestureZone.CENTRAL)
152
153
154
class SwipeTyper(SwipeButton):
155
@GObject.Signal(arg_types=(str,))
156
def typed(self, character):
157
# Since the drag is finished, remove the underlines
158
for label in self.label_map.values():
159
label.remove_css_class("kineboard-selected-character")
160
161
def underline(self, zone):
162
for label in self.label_map.values():
163
label.remove_css_class("kineboard-selected-character")
164
self.label_map[zone].add_css_class("kineboard-selected-character")
165
166
def __init__(self, tl, tr, bl, br, c, **kwargs):
167
SwipeButton.__init__(self, **kwargs)
168
self.tl = tl
169
self.tr = tr
170
self.bl = bl
171
self.br = br
172
self.c = c
173
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)
174
self.set_child(self.grid)
175
self.tl_label = Gtk.Label(label=tl)
176
self.grid.attach(self.tl_label, 0, 0, 1, 1)
177
self.tr_label = Gtk.Label(label=tr)
178
self.grid.attach(self.tr_label, 2, 0, 1, 1)
179
self.bl_label = Gtk.Label(label=bl)
180
self.grid.attach(self.bl_label, 0, 2, 1, 1)
181
self.br_label = Gtk.Label(label=br)
182
self.grid.attach(self.br_label, 2, 2, 1, 1)
183
self.c_label = Gtk.Label(label=c)
184
self.grid.attach(self.c_label, 1, 1, 1, 1)
185
self.label_map = {
186
GestureZone.CENTRAL: self.c_label,
187
GestureZone.TOP_LEFT: self.tl_label,
188
GestureZone.TOP_RIGHT: self.tr_label,
189
GestureZone.BOTTOM_RIGHT: self.br_label,
190
GestureZone.BOTTOM_LEFT: self.bl_label
191
}
192
self.connect("top_left", lambda p: self.emit("typed", tl))
193
self.connect("top_right", lambda p: self.emit("typed", tr))
194
self.connect("bottom_left", lambda p: self.emit("typed", bl))
195
self.connect("bottom_right", lambda p: self.emit("typed", br))
196
self.connect("central", lambda p: self.emit("typed", c))
197
self.connect("drag_changed", lambda p, zone: self.underline(zone))
198
199
200
class Plane(Gtk.Grid):
201
def __init__(self, app, data, **kwargs):
202
Gtk.Grid.__init__(self, **kwargs)
203
self.app = app
204
self.all_characters = ""
205
for j, column in enumerate(data):
206
for k, key in enumerate(column):
207
widget = SwipeTyper(key["top_left"], key["top_right"], key["bottom_left"],
208
key["bottom_right"], key["central"])
209
widget.connect("typed", self.app.typed)
210
self.attach(widget, k, j, 1, 1)
211
self.all_characters += key["top_left"] + key["top_right"] + key["bottom_left"] + key["bottom_right"] + key["central"]
212
213
214
215
def make_xkb_keymap_from_unicodes(unicodes):
216
symbols = [
217
"key <LCTL> { [ Control_L ] };",
218
"key <LALT> { [ Alt_L ] };",
219
"key <LSHF> { [ Shift_L ] };",
220
"key <LSPR> { [ Super_L ] };",
221
"key <ESCP> { [ Escape ] };",
222
"key <BKSP> { [ BackSpace ] };",
223
"key <RTRN> { [ Return ] };",
224
]
225
keycodes = [
226
"<LCTL> = 4089;",
227
"<LALT> = 4090;",
228
"<LSHF> = 4091;",
229
"<LSPR> = 4092;",
230
"<ESCP> = 4093;",
231
"<BKSP> = 4094;",
232
"<RTRN> = 4095;",
233
]
234
for i, char in enumerate(unicodes):
235
keycode = 0x1000 + i
236
keysym = f"U{ord(char):04X}"
237
symbols.append(f"key <K{i:03X}> {{ [ {keysym} ] }};")
238
keycodes.append(f"<K{i:03X}> = {keycode};")
239
240
return ("xkb_keymap { xkb_keycodes \"kineboard\" { minimum = 8; maximum = 65535; "
241
+ " ".join(keycodes) + " }; xkb_symbols \"kineboard\" { " + " ".join(symbols)
242
+ " }; xkb_types \"kineboard\" { type \"ONE_LEVEL\" { modifiers = none; level_name[Level1] = \"Any\"; }; }; xkb_compatibility \"kineboard\" {}; };")
243
244
245
class ModifierButton(Gtk.ToggleButton):
246
def __init__(self, app, mask, **kwargs):
247
super().__init__(**kwargs)
248
self.app = app
249
self.mask = mask
250
251
self.connect("toggled", self.toggle_modifier)
252
253
def toggle_modifier(self, button):
254
if button.get_active():
255
self.app.depressed_modifiers |= self.mask
256
else:
257
self.app.depressed_modifiers &= ~self.mask
258
259
self.app.send_modifiers()
260
261
262
class Kineboard(Gtk.Application):
263
def __init__(self):
264
Gtk.Application.__init__(
265
self,
266
application_id="com.roundabout_host.roundabout.Kineboard",
267
flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE
268
)
269
self.xkb_context = xkbcommon.xkb.Context()
270
self.revealer = None
271
self.switcher = None
272
self.depressed_modifiers = 0
273
self.character_index = {}
274
self.keymap_fd = None
275
self.vk = None
276
self.keymap_text = ""
277
self.all_characters = ""
278
self.context = None
279
self.input_method_manager = None
280
self.input_method = None
281
self.registry = None
282
self.box = None
283
self.window = None
284
self.stack = None
285
self.display = None
286
self.wl_display = None
287
self.seat = None
288
self.vkm = None
289
self.serial = 0
290
self.add_main_option(
291
"invoke",
292
ord("i"),
293
GLib.OptionFlags.NONE,
294
GLib.OptionArg.NONE,
295
"Manually bring up the keyboard",
296
None
297
)
298
self.add_main_option(
299
"hide",
300
ord("h"),
301
GLib.OptionFlags.NONE,
302
GLib.OptionArg.NONE,
303
"Hide the keyboard",
304
None
305
)
306
307
def toggle_sidebar(self, button):
308
self.revealer.set_reveal_child(not self.revealer.get_reveal_child())
309
310
def send_modifiers(self):
311
self.vk.modifiers(self.depressed_modifiers, 0, 0, 0)
312
313
def do_startup(self):
314
Gtk.Application.do_startup(self)
315
self.display = Gdk.Display.get_default()
316
self.window = Gtk.Window(application=self, title="Demo", focus_on_click=False, focusable=False, can_focus=False)
317
Gtk4LayerShell.init_for_window(self.window)
318
Gtk4LayerShell.set_keyboard_mode(self.window, Gtk4LayerShell.KeyboardMode.NONE)
319
Gtk4LayerShell.set_anchor(self.window, Gtk4LayerShell.Edge.BOTTOM, True)
320
Gtk4LayerShell.set_anchor(self.window, Gtk4LayerShell.Edge.LEFT, True)
321
Gtk4LayerShell.set_anchor(self.window, Gtk4LayerShell.Edge.RIGHT, True)
322
Gtk4LayerShell.auto_exclusive_zone_enable(self.window)
323
self.box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
324
self.main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
325
self.window.set_child(self.main_box)
326
self.stack = Gtk.Stack()
327
self.box.append(self.stack)
328
layouts = itertools.chain.from_iterable((f for f in path.iterdir() if f.is_file()) for path in get_layout_directories())
329
self.all_characters = " "
330
self.switcher = Gtk.StackSidebar(stack=self.stack)
331
self.revealer = Gtk.Revealer(child=self.switcher, transition_type=Gtk.RevealerTransitionType.SLIDE_RIGHT)
332
self.box.prepend(self.revealer)
333
for layout in layouts:
334
stack = Gtk.Stack()
335
336
yaml_loader = yaml.YAML(typ="rt")
337
data = yaml_loader.load(layout)
338
339
controls = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8, margin_top=0,
340
margin_bottom=8, margin_start=8, margin_end=8)
341
342
layout_button = Gtk.Button(child=Gtk.Image.new_from_icon_name("edit-symbolic"))
343
layout_button.set_size_request(48, -1)
344
layout_button.update_property([Gtk.AccessibleProperty.LABEL], ["Switch layout"])
345
layout_button.connect("clicked", self.toggle_sidebar)
346
controls.append(layout_button)
347
348
if len(data["keys"]) >= 2:
349
shift_button = Gtk.ToggleButton(
350
child=Gtk.Image.new_from_icon_name("go-up-symbolic")
351
)
352
shift_button.set_size_request(48, -1)
353
shift_button.update_property([Gtk.AccessibleProperty.LABEL], ["Shift"])
354
controls.append(shift_button)
355
356
def shift(button: Gtk.ToggleButton):
357
stack.set_visible_child_name(f"plane-{int(button.get_active())}")
358
359
shift_button.connect("toggled", shift)
360
361
space_bar = Gtk.Button(hexpand=True)
362
space_bar.update_property([Gtk.AccessibleProperty.LABEL], ["Space"])
363
space_bar.connect("clicked", lambda x: self.typed(x, " "))
364
controls.append(space_bar)
365
366
backspace_button = Gtk.Button(child=Gtk.Image.new_from_icon_name("go-previous-symbolic"))
367
backspace_button.set_size_request(48, -1)
368
backspace_button.update_property([Gtk.AccessibleProperty.LABEL], ["Backspace"])
369
backspace_button.connect("clicked", lambda x: self.send_keysym(0xFF6))
370
controls.append(backspace_button)
371
372
enter_button = Gtk.Button(child=Gtk.Image.new_from_icon_name("keyboard-enter-symbolic"))
373
enter_button.set_size_request(48, -1)
374
enter_button.update_property([Gtk.AccessibleProperty.LABEL], ["Enter"])
375
enter_button.add_css_class("suggested-action")
376
enter_button.connect("clicked", lambda x: self.send_keysym(0xFF7))
377
controls.append(enter_button)
378
379
for i, layer in enumerate(data["keys"]):
380
grid = Plane(self, layer, row_spacing=8, column_spacing=8, row_homogeneous=True,
381
column_homogeneous=True, margin_top=8, margin_bottom=8,
382
margin_end=8, margin_start=8)
383
self.all_characters += grid.all_characters
384
stack.add_named(grid, f"plane-{i}")
385
386
outer_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
387
outer_box.append(stack)
388
outer_box.append(controls)
389
self.stack.add_titled(outer_box, data["symbol"], data["symbol"])
390
391
self.extra_controls = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8, margin_top=0,
392
margin_bottom=8, margin_start=8, margin_end=8)
393
self.extra_controls.append(ModifierButton(self, keymap.mod_get_index("Control"), label="Control"))
394
self.extra_controls.append(ModifierButton(self, keymap.mod_get_index("Alt"), label="Alt"))
395
self.extra_controls.append(ModifierButton(self, keymap.mod_get_index("Shift"), label="Shift"))
396
self.extra_controls.append(ModifierButton(self, keymap.mod_get_index("Super"), label="Super"))
397
398
self.main_box.append(self.extra_controls)
399
self.main_box.append(self.box)
400
401
ctypes.pythonapi.PyCapsule_GetPointer.restype = ctypes.c_void_p
402
ctypes.pythonapi.PyCapsule_GetPointer.argtypes = (ctypes.py_object,)
403
404
self.wl_display = Display()
405
wl_display_ptr = gtk.gdk_wayland_display_get_wl_display(
406
ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(self.display.__gpointer__, None)))
407
self.wl_display._ptr = wl_display_ptr
408
self.registry = self.wl_display.get_registry()
409
self.registry.dispatcher["global"] = self.on_global
410
self.registry.dispatcher["global_remove"] = self.on_global_remove
411
self.wl_display.roundtrip()
412
413
if self.input_method_manager:
414
self.input_method = self.input_method_manager.get_input_method(self.seat)
415
self.input_method.dispatcher["activate"] = self.activate_input
416
self.input_method.dispatcher["deactivate"] = self.deactivate_input
417
else:
418
dialog = Gtk.AlertDialog(message="Input method protocol not supported.")
419
dialog.show()
420
421
self.keymap_text = make_xkb_keymap_from_unicodes(self.all_characters)
422
xkb_keymap = self.xkb_context.keymap_new_from_string(self.keymap_text, xkb.XKB_KEYMAP_FORMAT_TEXT_V1)
423
state = keymap.state_new()
424
self.character_index = {}
425
for i, character in enumerate(self.all_characters):
426
self.character_index[character] = i
427
self.vk = self.vkm.create_virtual_keyboard(self.seat)
428
429
self.keymap_fd = os.memfd_create("keymap")
430
os.write(self.keymap_fd, self.keymap_text.encode("utf-8"))
431
os.lseek(self.keymap_fd, 0, os.SEEK_SET)
432
self.vk.keymap(1, self.keymap_fd, len(self.keymap_text.encode("utf-8")))
433
434
def activate_input(self, im, context):
435
self.window.present()
436
self.context = context
437
if self.context:
438
self.context.dispatcher["commit_state"] = self.commit_state
439
440
def commit_state(self, context, serial):
441
self.serial = serial
442
443
def deactivate_input(self, im, context):
444
self.window.set_visible(False)
445
if self.context:
446
self.context.destroy()
447
self.context = None
448
self.serial = 0
449
450
def on_global(self, registry, name, interface, version):
451
if interface == "zwp_input_method_manager_v2":
452
self.input_method_manager = registry.bind(name, ZwpInputMethodManagerV2, version)
453
elif interface == "wl_seat":
454
self.seat = registry.bind(name, WlSeat, version)
455
elif interface == "zwp_virtual_keyboard_manager_v1":
456
self.vkm = registry.bind(name, ZwpVirtualKeyboardManagerV1, version)
457
print("VKM found!!")
458
459
def on_global_remove(self, registry, name):
460
pass
461
462
def send_keysym(self, key):
463
clock = int(time.monotonic() * 1000)
464
if self.vk:
465
self.vk.key(clock, key, 1)
466
self.vk.key(clock, key, 0)
467
468
def typed(self, button, characters):
469
for char in characters:
470
self.send_keysym(0xFF8 + self.character_index[char])
471
472
def do_activate(self):
473
Gtk.Application.do_activate(self)
474
# self.window.present()
475
476
def do_command_line(self, command_line: Gio.ApplicationCommandLine):
477
options = command_line.get_options_dict()
478
args = command_line.get_arguments()[1:]
479
if options.contains("invoke"):
480
self.activate_input(None, None)
481
elif options.contains("hide"):
482
self.deactivate_input(None, None)
483
return 0
484
485
486
if __name__ == "__main__":
487
kineboard = Kineboard()
488
kineboard.run(sys.argv)
489