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\" {}; 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["name"])
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 not self.input_method:
365
dialog = Gtk.AlertDialog(message="Input method protocol not supported.")
366
dialog.show()
367
368
self.input_method.dispatcher["activate"] = self.activate_input
369
self.input_method.dispatcher["deactivate"] = self.deactivate_input
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