"""
Kineboard: an experimental touchscreen keyboard using swipes to allow
having fewer touch targets.
Copyright 2025, roundabout-host.com <vlad@roundabout-host.com>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public Licence as published by
the Free Software Foundation, either version 3 of the Licence, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public Licence for more details.
You should have received a copy of the GNU General Public Licence
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""

import itertools
import sys
import os
import time

import ruamel.yaml as yaml
from pathlib import Path

os.environ["GI_TYPELIB_PATH"] = "/usr/local/lib/x86_64-linux-gnu/girepository-1.0"
from ctypes import CDLL
CDLL("libgtk4-layer-shell.so")

import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Gtk4LayerShell", "1.0")
from gi.repository import Gtk, Gdk, GObject, Gtk4LayerShell, GLib, Gio, GObject

from pywayland.client import Display, EventQueue
from pywayland.protocol.wayland import WlRegistry, WlSeat
from pywayland.protocol.input_method_unstable_v1 import (
    ZwpInputMethodContextV1,
    ZwpInputMethodV1
)
from pywayland.protocol.input_method_unstable_v2 import (
    ZwpInputMethodV2,
    ZwpInputMethodManagerV2
)
from pywayland.protocol.virtual_keyboard_unstable_v1 import ZwpVirtualKeyboardManagerV1

from xkbcommon import xkb

import ctypes
from cffi import FFI
ffi = FFI()
ffi.cdef("""
void * gdk_wayland_display_get_wl_display (void * display);
void * gdk_wayland_surface_get_wl_surface (void * surface);
void * gdk_wayland_monitor_get_wl_output (void * monitor);
""")
gtk = ffi.dlopen("libgtk-4.so.1")


module_directory = Path(__file__).resolve().parent


custom_css = """
.kineboard-selected-character {
    text-decoration: underline;
}
"""
css_provider = Gtk.CssProvider()
css_provider.load_from_data(custom_css)
Gtk.StyleContext.add_provider_for_display(
    Gdk.Display.get_default(),
    css_provider,
    100
)


def get_layout_directories():
    data_home = Path(os.getenv("XDG_DATA_HOME", Path.home() / ".local" / "share"))
    data_dirs = [Path(d) for d in os.getenv("XDG_DATA_DIRS", "/usr/local/share:/usr/share").split(":")]
    all_paths = [data_home / "kineboard" / "layouts"] + [d / "kineboard" / "layouts" for d in data_dirs]
    return [d for d in all_paths if d.is_dir()]


class GestureZone(GObject.GEnum):
    CENTRAL = 0
    TOP_LEFT = 1
    TOP_RIGHT = 2
    BOTTOM_RIGHT = 3
    BOTTOM_LEFT = 4


class SwipeButton(Gtk.Frame):
    @GObject.Signal
    def top_left(self):
        pass

    @GObject.Signal
    def top_right(self):
        pass

    @GObject.Signal
    def bottom_left(self):
        pass

    @GObject.Signal
    def bottom_right(self):
        pass

    @GObject.Signal
    def central(self):
        pass

    @GObject.Signal(arg_types=(int,))
    def drag_changed(self, zone):
        pass

    def __init__(self, **kwargs):
        Gtk.Frame.__init__(self, **kwargs)
        self.gesture_drag = Gtk.GestureDrag()
        self.gesture_drag.connect("drag-end", self.finish_drag)
        self.gesture_drag.connect("drag-update", self.move_drag)
        self.gesture_drag.connect("drag-begin", self.begin_drag)
        self.add_controller(self.gesture_drag)

    def finish_drag(self, gesture, offset_x, offset_y):
        if abs(offset_x) / self.get_width() + abs(offset_y) / self.get_height() > 1 / 2:
            if offset_x > 0 and offset_y > 0:
                self.emit("bottom_right")
            elif offset_x <= 0 and offset_y > 0:
                self.emit("bottom_left")
            elif offset_x > 0 and offset_y <= 0:
                self.emit("top_right")
            elif offset_x <= 0 and offset_y <= 0:
                self.emit("top_left")
        else:
            self.emit("central")

    def move_drag(self, gesture, offset_x, offset_y):
        if abs(offset_x) / self.get_width() + abs(offset_y) / self.get_height() > 1 / 2:
            if offset_x > 0 and offset_y > 0:
                self.emit("drag_changed", GestureZone.BOTTOM_RIGHT)
            elif offset_x <= 0 and offset_y > 0:
                self.emit("drag_changed", GestureZone.BOTTOM_LEFT)
            elif offset_x > 0 and offset_y <= 0:
                self.emit("drag_changed", GestureZone.TOP_RIGHT)
            elif offset_x <= 0 and offset_y <= 0:
                self.emit("drag_changed", GestureZone.TOP_LEFT)
        else:
            self.emit("drag_changed", GestureZone.CENTRAL)

    def begin_drag(self, gesture, start_x, start_y):
        self.emit("drag_changed", GestureZone.CENTRAL)


class SwipeTyper(SwipeButton):
    @GObject.Signal(arg_types=(str,))
    def typed(self, character):
        # Since the drag is finished, remove the underlines
        for label in self.label_map.values():
            label.remove_css_class("kineboard-selected-character")

    def underline(self, zone):
        for label in self.label_map.values():
            label.remove_css_class("kineboard-selected-character")
        self.label_map[zone].add_css_class("kineboard-selected-character")

    def __init__(self, tl, tr, bl, br, c, **kwargs):
        SwipeButton.__init__(self, **kwargs)
        self.tl = tl
        self.tr = tr
        self.bl = bl
        self.br = br
        self.c = c
        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)
        self.set_child(self.grid)
        self.tl_label = Gtk.Label(label=tl)
        self.grid.attach(self.tl_label, 0, 0, 1, 1)
        self.tr_label = Gtk.Label(label=tr)
        self.grid.attach(self.tr_label, 2, 0, 1, 1)
        self.bl_label = Gtk.Label(label=bl)
        self.grid.attach(self.bl_label, 0, 2, 1, 1)
        self.br_label = Gtk.Label(label=br)
        self.grid.attach(self.br_label, 2, 2, 1, 1)
        self.c_label = Gtk.Label(label=c)
        self.grid.attach(self.c_label, 1, 1, 1, 1)
        self.label_map = {
            GestureZone.CENTRAL: self.c_label,
            GestureZone.TOP_LEFT: self.tl_label,
            GestureZone.TOP_RIGHT: self.tr_label,
            GestureZone.BOTTOM_RIGHT: self.br_label,
            GestureZone.BOTTOM_LEFT: self.bl_label
        }
        self.connect("top_left", lambda p: self.emit("typed", tl))
        self.connect("top_right", lambda p: self.emit("typed", tr))
        self.connect("bottom_left", lambda p: self.emit("typed", bl))
        self.connect("bottom_right", lambda p: self.emit("typed", br))
        self.connect("central", lambda p: self.emit("typed", c))
        self.connect("drag_changed", lambda p, zone: self.underline(zone))


class Plane(Gtk.Grid):
    def __init__(self, app, data, **kwargs):
        Gtk.Grid.__init__(self, **kwargs)
        self.app = app
        self.all_characters = ""
        for j, column in enumerate(data):
            for k, key in enumerate(column):
                widget = SwipeTyper(key["top_left"], key["top_right"], key["bottom_left"],
                                    key["bottom_right"], key["central"])
                widget.connect("typed", self.app.typed)
                self.attach(widget, k, j, 1, 1)
                self.all_characters += key["top_left"] + key["top_right"] + key["bottom_left"] + key["bottom_right"] + key["central"]



def make_xkb_keymap_from_unicodes(unicodes):
    symbols = [
        "key <LEFT> { [ Left ] };"
        "key <RGHT> { [ Right ] };"
        "key <UP>   { [ Up ] };"
        "key <DOWN> { [ Down ] };"
        "key <TAB>  { [ Tab ] };",
        "key <LCTL> { [ Control_L ] };",
        "key <LALT> { [ Alt_L ] };",
        "key <LSHF> { [ Shift_L ] };",
        "key <LSPR> { [ Super_L ] };",
        "key <ESCP> { [ Escape ] };",
        "key <BKSP> { [ BackSpace ] };",
        "key <RTRN> { [ Return ] };",
    ]
    keycodes = [
        "<LEFT> = 4084;",
        "<RGHT> = 4085;",
        "<UP>   = 4086;",
        "<DOWN> = 4087;",
        "<TAB>  = 4088;",
        "<LCTL> = 4089;",
        "<LALT> = 4090;",
        "<LSHF> = 4091;",
        "<LSPR> = 4092;",
        "<ESCP> = 4093;",
        "<BKSP> = 4094;",
        "<RTRN> = 4095;",
    ]
    for i, char in enumerate(unicodes):
        keycode = 0x1000 + i
        keysym = f"U{ord(char):04X}"
        symbols.append(f"key <K{i:03X}> {{ [ {keysym} ] }};")
        keycodes.append(f"<K{i:03X}> = {keycode};")

    return ("xkb_keymap { xkb_keycodes \"kineboard\" { minimum = 8; maximum = 65535; "
            + " ".join(keycodes) + " }; xkb_symbols \"kineboard\" { " + " ".join(symbols)
            + " }; xkb_types \"kineboard\" { type \"ONE_LEVEL\" { modifiers = none; level_name[Level1] = \"Any\"; }; }; xkb_compatibility \"kineboard\" {}; };")


from gi.repository import Gio, GLib


def register_client(client_id: str):
    bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)

    proxy = Gio.DBusProxy.new_sync(
        bus,
        Gio.DBusProxyFlags.DO_NOT_LOAD_PROPERTIES |
        Gio.DBusProxyFlags.DO_NOT_AUTO_START_AT_CONSTRUCTION,
        None,
        "org.gnome.SessionManager",
        "/org/gnome/SessionManager",
        "org.gnome.SessionManager",
        None
    )

    startup_id = GLib.getenv("DESKTOP_AUTOSTART_ID") or ""

    def on_registered(proxy, result, loop):
        try:
            proxy.call_finish(result)
        except Exception as e:
            print("cannot register client:", e)
        loop.quit()

    loop = GLib.MainLoop()

    proxy.call(
        "RegisterClient",
        GLib.Variant("(ss)", (client_id, startup_id)),
        Gio.DBusCallFlags.NONE,
        -1,
        None,
        lambda p, r, l: on_registered(p, r, loop),
        loop
    )

    loop.run()
    return proxy
    

class ModifierButton(Gtk.ToggleButton):
    def __init__(self, app, mask, **kwargs):
        super().__init__(**kwargs)
        self.app = app
        self.mask = mask

        self.connect("toggled", self.toggle_modifier)

    def toggle_modifier(self, button):
        if button.get_active():
            self.app.depressed_modifiers |= self.mask
        else:
            self.app.depressed_modifiers &= ~self.mask

        self.app.send_modifiers()


class Kineboard(Gtk.Application):
    def __init__(self):
        Gtk.Application.__init__(
            self,
            application_id="sm.puri.OSK0",
            flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE
        )
        self.xkb_context = xkb.Context()
        self.revealer = None
        self.switcher = None
        self.depressed_modifiers = 0
        self.character_index = {}
        self.keymap_fd = None
        self.vk = None
        self.keymap_text = ""
        self.all_characters = ""
        self.context = None
        self.input_method_manager = None
        self.input_method = None
        self.registry = None
        self.box = None
        self.window = None
        self.stack = None
        self.display = None
        self.wl_display = None
        self.seat = None
        self.vkm = None
        self.serial = 0
        self.extra_revealer = None
        self.extra_viewport = None
        self.extra_scroll = None
        self.extra_controls = None
        self.add_main_option(
            "invoke",
            ord("i"),
            GLib.OptionFlags.NONE,
            GLib.OptionArg.NONE,
            "Manually bring up the keyboard",
            None
        )
        self.add_main_option(
            "hide",
            ord("h"),
            GLib.OptionFlags.NONE,
            GLib.OptionArg.NONE,
            "Hide the keyboard",
            None
        )
    
    def send_modifiers(self):
        self.vk.modifiers(self.depressed_modifiers, 0, 0, 0)

    def do_startup(self):
        Gtk.Application.do_startup(self)
        register_client("sm.puri.OSK0")
        self.display = Gdk.Display.get_default()
        self.window = Gtk.Window(application=self, title="Demo", focus_on_click=False, focusable=False, can_focus=False)
        Gtk4LayerShell.init_for_window(self.window)
        Gtk4LayerShell.set_keyboard_mode(self.window, Gtk4LayerShell.KeyboardMode.NONE)
        self.window.set_default_size(16384, -1)   # large width so that it spans the bottom
        Gtk4LayerShell.set_anchor(self.window, Gtk4LayerShell.Edge.BOTTOM, True)
        Gtk4LayerShell.set_anchor(self.window, Gtk4LayerShell.Edge.LEFT, True)
        Gtk4LayerShell.set_anchor(self.window, Gtk4LayerShell.Edge.RIGHT, True)
        Gtk4LayerShell.auto_exclusive_zone_enable(self.window)
        Gtk4LayerShell.set_namespace(self.window, "osk")
        self.box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
        self.main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, margin_top=8)
        self.window.set_child(self.main_box)
        self.stack = Gtk.Stack()
        self.box.append(self.stack)
        layouts = itertools.chain.from_iterable((f for f in path.iterdir() if f.is_file()) for path in get_layout_directories())
        self.all_characters = " "
        self.switcher = Gtk.StackSidebar(stack=self.stack)
        self.revealer = Gtk.Revealer(child=self.switcher, transition_type=Gtk.RevealerTransitionType.SLIDE_RIGHT)
        self.box.prepend(self.revealer)

        self.extra_controls = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8,
                                      margin_bottom=8, margin_start=8, margin_end=8)
        self.extra_viewport = Gtk.Viewport(child=self.extra_controls)
        self.extra_scroll = Gtk.ScrolledWindow(child=self.extra_viewport, propagate_natural_height=True)
        self.extra_revealer = Gtk.Revealer(child=self.extra_scroll)
        
        for layout in layouts:
            stack = Gtk.Stack()

            yaml_loader = yaml.YAML(typ="rt")
            data = yaml_loader.load(layout)

            controls = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8, margin_top=0,
                               margin_bottom=8, margin_start=8, margin_end=8)

            layout_button = Gtk.ToggleButton(child=Gtk.Image.new_from_icon_name("edit-symbolic"))
            layout_button.set_size_request(48, -1)
            layout_button.update_property([Gtk.AccessibleProperty.LABEL], ["Switch layout"])
            controls.append(layout_button)
            layout_button.bind_property("active", self.revealer, "reveal-child", GObject.BindingFlags.BIDIRECTIONAL)

            control_button = Gtk.ToggleButton(child=Gtk.Image.new_from_icon_name("utilities-terminal-symbolic"))
            control_button.set_size_request(48, -1)
            control_button.update_property([Gtk.AccessibleProperty.LABEL], ["Terminal controls"])
            controls.append(control_button)
            control_button.bind_property("active", self.extra_revealer, "reveal-child", GObject.BindingFlags.BIDIRECTIONAL)

            if len(data["keys"]) >= 2:
                shift_button = Gtk.ToggleButton(
                        child=Gtk.Image.new_from_icon_name("go-up-symbolic")
                )
                shift_button.set_size_request(48, -1)
                shift_button.update_property([Gtk.AccessibleProperty.LABEL], ["Shift"])
                controls.append(shift_button)

                def shift(button: Gtk.ToggleButton):
                    stack.set_visible_child_name(f"plane-{int(button.get_active())}")

                shift_button.connect("toggled", shift)

            space_bar = Gtk.Button(hexpand=True)
            space_bar.update_property([Gtk.AccessibleProperty.LABEL], ["Space"])
            space_bar.connect("clicked", lambda x: self.typed(x, " "))
            controls.append(space_bar)

            backspace_button = Gtk.Button(child=Gtk.Image.new_from_icon_name("edit-clear-symbolic"))
            backspace_button.set_size_request(48, -1)
            backspace_button.update_property([Gtk.AccessibleProperty.LABEL], ["Backspace"])
            backspace_button.connect("clicked", lambda x: self.send_keysym(0xFF6))
            controls.append(backspace_button)

            enter_button = Gtk.Button(child=Gtk.Image.new_from_icon_name("keyboard-enter-symbolic"))
            enter_button.set_size_request(48, -1)
            enter_button.update_property([Gtk.AccessibleProperty.LABEL], ["Enter"])
            enter_button.add_css_class("suggested-action")
            enter_button.connect("clicked", lambda x: self.send_keysym(0xFF7))
            controls.append(enter_button)

            for i, layer in enumerate(data["keys"]):
                grid = Plane(self, layer, row_spacing=8, column_spacing=8, row_homogeneous=True,
                             column_homogeneous=True, margin_bottom=8,
                             margin_end=8, margin_start=8)
                self.all_characters += grid.all_characters
                stack.add_named(grid, f"plane-{i}")

            outer_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
            outer_box.append(stack)
            outer_box.append(controls)
            self.stack.add_titled(outer_box, data["symbol"], data["symbol"])

        self.main_box.append(self.extra_revealer)
        self.main_box.append(self.box)

        ctypes.pythonapi.PyCapsule_GetPointer.restype = ctypes.c_void_p
        ctypes.pythonapi.PyCapsule_GetPointer.argtypes = (ctypes.py_object,)

        self.wl_display = Display()
        wl_display_ptr = gtk.gdk_wayland_display_get_wl_display(
                ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(self.display.__gpointer__, None)))
        self.wl_display._ptr = wl_display_ptr
        self.registry = self.wl_display.get_registry()
        self.registry.dispatcher["global"] = self.on_global
        self.registry.dispatcher["global_remove"] = self.on_global_remove
        self.wl_display.roundtrip()

        if self.input_method_manager:
            self.input_method = self.input_method_manager.get_input_method(self.seat)
            self.input_method.dispatcher["activate"] = self.activate_input
            self.input_method.dispatcher["deactivate"] = self.deactivate_input
        else:
            dialog = Gtk.AlertDialog(message="Input method protocol not supported.")
            dialog.show()

        self.keymap_text = make_xkb_keymap_from_unicodes(self.all_characters)
        xkb_keymap = self.xkb_context.keymap_new_from_string(self.keymap_text)
        state = xkb_keymap.state_new()
        
        escape_button = Gtk.Button(label="Escape")
        escape_button.connect("clicked", lambda x: self.send_keysym(0xFF5))
        self.extra_controls.append(escape_button)
        self.extra_controls.append(ModifierButton(self, 1 << xkb_keymap.mod_get_index("Control"), label="Control"))
        self.extra_controls.append(ModifierButton(self, 1 << xkb_keymap.mod_get_index("Mod1"), label="Alt"))
        self.extra_controls.append(ModifierButton(self, 1 << xkb_keymap.mod_get_index("Shift"), label="Shift"))
        self.extra_controls.append(ModifierButton(self, 1 << xkb_keymap.mod_get_index("Mod4"), label="Super"))
        tab_button = Gtk.Button(label="Tab")
        tab_button.connect("clicked", lambda x: self.send_keysym(0xFF0))
        self.extra_controls.append(tab_button)
        left_button = Gtk.Button(child=Gtk.Image.new_from_icon_name("go-previous-symbolic"))
        left_button.update_property([Gtk.AccessibleProperty.LABEL], ["Left"])
        left_button.connect("clicked", lambda x: self.send_keysym(0xFEC))
        self.extra_controls.append(left_button)
        down_button = Gtk.Button(child=Gtk.Image.new_from_icon_name("go-down-symbolic"))
        down_button.update_property([Gtk.AccessibleProperty.LABEL], ["Down"])
        down_button.connect("clicked", lambda x: self.send_keysym(0xFEF))
        self.extra_controls.append(down_button)
        up_button = Gtk.Button(child=Gtk.Image.new_from_icon_name("go-up-symbolic"))
        up_button.update_property([Gtk.AccessibleProperty.LABEL], ["Up"])
        up_button.connect("clicked", lambda x: self.send_keysym(0xFEE))
        self.extra_controls.append(up_button)
        right_button = Gtk.Button(child=Gtk.Image.new_from_icon_name("go-next-symbolic"))
        right_button.update_property([Gtk.AccessibleProperty.LABEL], ["Right"])
        right_button.connect("clicked", lambda x: self.send_keysym(0xFED))
        self.extra_controls.append(right_button)
        
        self.character_index = {}
        for i, character in enumerate(self.all_characters):
            self.character_index[character] = i
        self.vk = self.vkm.create_virtual_keyboard(self.seat)

        self.keymap_fd = os.memfd_create("keymap")
        os.write(self.keymap_fd, self.keymap_text.encode("utf-8"))
        os.lseek(self.keymap_fd, 0, os.SEEK_SET)
        self.vk.keymap(1, self.keymap_fd, len(self.keymap_text.encode("utf-8")))

    def activate_input(self, im):
        self.window.present()

    def deactivate_input(self, im):
        self.window.set_visible(False)
        self.serial = 0

    def on_global(self, registry, name, interface, version):
        if interface == "zwp_input_method_manager_v2":
            self.input_method_manager = registry.bind(name, ZwpInputMethodManagerV2, version)
        elif interface == "wl_seat":
            self.seat = registry.bind(name, WlSeat, version)
        elif interface == "zwp_virtual_keyboard_manager_v1":
            self.vkm = registry.bind(name, ZwpVirtualKeyboardManagerV1, version)

    def on_global_remove(self, registry, name):
        pass

    def send_keysym(self, key):
        clock = int(time.monotonic() * 1000)
        if self.vk:
            self.vk.key(clock, key, 1)
            self.vk.key(clock, key, 0)

    def typed(self, button, characters):
        for char in characters:
            self.send_keysym(0xFF8 + self.character_index[char])

    def do_activate(self):
        Gtk.Application.do_activate(self)
        # self.window.present()

    def do_command_line(self, command_line: Gio.ApplicationCommandLine):
        options = command_line.get_options_dict()
        args = command_line.get_arguments()[1:]
        if options.contains("invoke"):
            self.activate_input(None, None)
        elif options.contains("hide"):
            self.deactivate_input(None, None)
        return 0


if __name__ == "__main__":
    kineboard = Kineboard()
    kineboard.run(sys.argv)
