]> git.mar77i.info Git - zenbook_conf/commitdiff
initial commit
authormar77i <mar77i@protonmail.ch>
Thu, 12 Dec 2024 23:30:28 +0000 (00:30 +0100)
committermar77i <mar77i@protonmail.ch>
Thu, 12 Dec 2024 23:31:44 +0000 (00:31 +0100)
.gitignore [new file with mode: 0644]
bluetooth.py [new file with mode: 0644]
vectors.py [new file with mode: 0755]
xinput.py [new file with mode: 0644]
xrandr.py [new file with mode: 0644]
zenbook_conf.py [new file with mode: 0755]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..07da734
--- /dev/null
@@ -0,0 +1,3 @@
+.idea/
+__pycache__/
+venv/
diff --git a/bluetooth.py b/bluetooth.py
new file mode 100644 (file)
index 0000000..b9b2185
--- /dev/null
@@ -0,0 +1,42 @@
+from time import sleep
+from traceback import print_exception
+
+import gi
+import gi.repository.GLib
+import pydbus
+
+
+class BluetoothConf:
+    ADAPTER = "org.bluez.Adapter1"
+    RETRIES = 20
+    RETRY_SLEEP = 5
+
+    def __init__(self):
+        self.bus = pydbus.SystemBus().get("org.bluez", "/org/bluez/hci0")
+        self.conf = self.bus.Get(self.ADAPTER, "Powered")
+
+    def update(self, value):
+        assert isinstance(value, bool)
+        if value == self.conf:
+            return
+        for retry in range(self.RETRIES + 1):
+            try:
+                self.bus.Set(self.ADAPTER, "Powered", pydbus.Variant("b", value))
+            except gi.repository.GLib.GError as e:
+                if retry >= self.RETRIES:
+                    raise
+                print_exception(e)
+                print(
+                    f"Sleeping {self.RETRY_SLEEP} seconds to try again "
+                    f"({retry + 1} / {self.RETRIES})..."
+                )
+                sleep(self.RETRY_SLEEP)
+            else:
+                break
+        # dbus-send --system
+        # --dest=org.bluez --print-reply /org/bluez/hci0
+        # org.freedesktop.DBus.Properties.Set
+        # string:org.bluez.Adapter1
+        # string:Powered
+        # variant:boolean:false
+        self.conf = value
diff --git a/vectors.py b/vectors.py
new file mode 100755 (executable)
index 0000000..20880fd
--- /dev/null
@@ -0,0 +1,309 @@
+#!/usr/bin/env python3
+
+from collections.abc import Sequence
+from itertools import chain
+from math import atan2, cos, pi, sin
+
+import pygame
+
+tau = 2 * pi
+
+
+class Shape:
+    def fit(self, pos, unit):
+        raise NotImplementedError
+
+
+class Rect(Shape):
+    def __init__(self, *args):
+        if len(args) == 2:
+            args = tuple(chain.from_iterable(args))
+        self.left, self.top, self.width, self.height = args
+
+    def fit(self, pos, unit):
+        return pygame.Rect(
+            (round(pos[0] + self.left * unit[0]), round(pos[1] + self.top * unit[1])),
+            (round(self.width * unit[0]), round(self.height * unit[1])),
+        )
+
+    def draw(self, surf, pos, unit, color):
+        pygame.draw.rect(surf, color, self.fit(pos, unit))
+
+
+class Polygon(Shape):
+    def __init__(self, points):
+        self.points = list(points)
+
+    def fit(self, pos, unit):
+        return [
+            (round(pos[0] + point[0] * unit[0]), round(pos[1] + point[1] * unit[1]))
+            for point in self.points
+        ]
+
+    def draw(self, surf, pos, unit, color):
+        pygame.draw.polygon(surf, color, self.fit(pos, unit))
+
+
+class Circle(Shape):
+    def __init__(self, pos, radius):
+        self.pos = pos
+        self.radius = radius
+
+    def fit(self, pos, unit):
+        return pygame.Rect(
+            (
+                round(pos[0] + (self.pos[0] - self.radius) * unit[0]),
+                round(pos[1] + (self.pos[1] - self.radius) * unit[1]),
+            ),
+            (round(self.radius * 2 * unit[0]), round(self.radius * 2 * unit[1])),
+        )
+
+    def draw(self, surf, pos, unit, color):
+        pygame.draw.ellipse(surf, color, self.fit(pos, unit))
+
+
+class StrokeLine(Polygon):
+    num_vertices = 32
+
+    def __init__(self, start, end, width):
+        super().__init__(
+            list(self.make_line_shape(start, end, self.num_vertices, width / 2))
+        )
+
+    @staticmethod
+    def make_line_shape(p1, p2, n, shape_r):
+        angle = atan2(p1[1] - p2[1], p1[0] - p2[0])
+        if angle < 0:
+            angle += tau
+        # 0 <= angle < tau
+        # corner below 270°
+        initial_corner = int((angle + pi * 3 / 2) * n / tau + .5) % n
+        # corner below 90°
+        opposing_corner = int((angle + pi / 2) * n / tau + .5) % n
+        shape = [
+            (cos(i * tau / n) * shape_r, sin(i * tau / n) * shape_r)
+            for i in range(n)
+        ]
+        p = p1
+        i = initial_corner
+        while i <= n + initial_corner:
+            mod_i = i % n
+            s = shape[mod_i]
+            yield (p[0] + s[0], p[1] + s[1])
+            if mod_i == opposing_corner and p == p1 and p1 != p2:
+                p = p2
+            else:
+                i += 1
+
+
+class Shapes(Shape):
+    def __init__(self, shapes):
+        self.shapes = shapes
+
+    def draw(self, surf, pos, unit, color):
+        for shape in self.shapes:
+            shape.draw(surf, pos, unit, color)
+
+
+class StrokeCircle(Shapes):
+    def __init__(self, center, radius, width):
+        super().__init__([
+            StrokeLine(
+                (
+                    center[0] + cos(a) * radius,
+                    center[1] + sin(a) * radius,
+                ),
+                (
+                    center[0] + cos(b) * radius,
+                    center[1] + sin(b) * radius,
+                ),
+                width,
+            )
+            for a, b in self.get_angle_segments()
+        ])
+
+    @staticmethod
+    def get_angle_segments():
+        num_vertices = StrokeLine.num_vertices
+        a = 0
+        for i in range(1, num_vertices + 1):
+            b = tau * i / num_vertices
+            yield a, b
+            a = b
+
+
+class StrokeCircleSegment(Shapes):
+    def __init__(self, center, radius, start_angle, end_angle, width):
+        super().__init__([
+            StrokeLine(
+                (
+                    center[0] + cos(a) * radius,
+                    center[1] + sin(a) * radius,
+                ),
+                (
+                    center[0] + cos(b) * radius,
+                    center[1] + sin(b) * radius,
+                ),
+                width,
+            )
+            for a, b in self.get_angle_segments(start_angle, end_angle)
+        ])
+
+    @classmethod
+    def get_angle_segments(cls, start_angle, end_angle):
+        num_vertices = StrokeLine.num_vertices
+        start_angle, end_angle = (
+            min(start_angle, end_angle), max(start_angle, end_angle)
+        )
+        diff_angle = end_angle - start_angle
+        a = 0
+        for i in range(1, num_vertices + 1):
+            b = tau * i / num_vertices
+            if b >= diff_angle:
+                yield a + start_angle, end_angle
+                break
+            yield a + start_angle, b + start_angle
+            a = b
+
+
+laptop_single = Shapes(
+    [
+        Rect((2, 3), (16.4, 1)),
+        Rect((2, 4), (1, 8.1)),
+        Rect((17.4, 4), (1, 8.1)),
+        Rect((2, 12.1), (16.4, 1)),
+        Polygon([(2, 13.1), (18.4, 13.1), (22.4, 18.1), (6, 18.1)]),
+    ]
+)
+laptop_double = Shapes(
+    [
+        Rect((2, 3), (16.4, 1)),
+        Rect((2, 4), (1, 8.1)),
+        Rect((17.4, 4), (1, 8.1)),
+        Rect((2, 12.1), (16.4, 1)),
+        Polygon([(2, 13.1), (3, 13.1), (6.2, 17.1), (5.2, 17.1)]),
+        Polygon([(17.4, 13.1), (18.4, 13.1), (21.6, 17.1), (20.4, 17.1)]),
+        Polygon([(5.2, 17.1), (21.6, 17.1), (22.4, 18.1), (6, 18.1)]),
+    ]
+)
+laptop_vertical = Shapes(
+    [
+        Rect((0.5, 1), (17.75, 1)),
+        Rect((0.5, 2), (1, 14.4)),
+        Rect((9, 2), (1, 14.4)),
+        Rect((17.25, 2), (1, 14.4)),
+        Rect((0.5, 16.4), (17.75, 1)),
+        Polygon([(2, 17.4), (6, 22.4), (20.75, 22.4), (16.75, 17.4)]),
+    ]
+)
+FINGER_RADIUS = 1.25
+touchscreen = Shapes(
+    [
+        StrokeCircleSegment((12, 16), 5, 0, tau * 3 / 8, 1),
+        StrokeLine(
+            (12 + cos(tau * 3 / 8) * 5, 16 + sin(tau * 3 / 8) * 5),
+            (
+                4 + cos(tau * 3 / 8) * FINGER_RADIUS,
+                13 + sin(tau * 3 / 8) * FINGER_RADIUS,
+            ),
+            1,
+        ),
+        StrokeCircleSegment((4, 13), FINGER_RADIUS, tau * 3 / 8, tau * 7 / 8, 1),
+        StrokeLine(
+            (
+                4 + cos(tau * 7 / 8) * FINGER_RADIUS,
+                13 + sin(tau * 7 / 8) * FINGER_RADIUS,
+            ),
+            (7, 13.5),
+            1,
+        ),
+        StrokeLine((7, 13.5), (7, 6), 1),
+        StrokeCircleSegment((8.25, 6), FINGER_RADIUS, pi, tau, 1),
+        StrokeLine((9.5, 6), (9.5, 11), 1),
+        StrokeCircleSegment((11, 11.5), FINGER_RADIUS, tau * 5 / 8, tau * 7 / 8, 1),
+        StrokeCircleSegment((13.25, 12), FINGER_RADIUS, tau * 5 / 8, tau * 7 / 8, 1),
+        StrokeCircleSegment((15.5, 12.5), FINGER_RADIUS, tau * 5 / 8, tau, 1),
+        StrokeLine((16.75, 12.5), (17, 16), 1),
+        StrokeCircleSegment((8.25, 6), 3, pi, tau, 1),
+        StrokeCircleSegment((8.25, 6), 5, pi, tau, 1),
+    ]
+)
+stylus = Shapes(
+    [
+        StrokeCircleSegment((3, 3), 1.5, tau * 3 / 8, tau * 7 / 8, 1),
+        StrokeLine(
+            (3 + cos(tau * 3 / 8) * 1.5, 3 + sin(tau * 3 / 8) * 1.5),
+            (16 + cos(tau * 3 / 8) * 1.5, 16 + sin(tau * 3 / 8) * 1.5),
+            1,
+        ),
+        StrokeLine(
+            (3 + cos(tau * 7 / 8) * 1.5, 3 + sin(tau * 7 / 8) * 1.5),
+            (16 + cos(tau * 7 / 8) * 1.5, 16 + sin(tau * 7 / 8) * 1.5),
+            1,
+        ),
+        StrokeLine(
+            (16 + cos(tau * 3 / 8) * 1.5, 16 + sin(tau * 3 / 8) * 1.5),
+            (18, 18),
+            1,
+        ),
+        StrokeLine(
+            (16 + cos(tau * 7 / 8) * 1.5, 16 + sin(tau * 7 / 8) * 1.5),
+            (18, 18),
+            1,
+        ),
+        StrokeLine((11, 11), (12, 12), 1),
+    ]
+)
+shapes = [
+    {
+        "shape": laptop_single,
+        "pos": (100, 100),
+    },
+    {
+        "shape": laptop_double,
+        "pos": (600, 100),
+    },
+    {
+        "shape": laptop_vertical,
+        "pos": (1100, 100),
+    },
+    {
+        "shape": touchscreen,
+        "pos": (100, 600),
+    },
+    {
+        "shape": stylus,
+        "pos": (600, 600),
+    },
+]
+
+
+def main():
+    surf = pygame.display.set_mode((2000, 1600))
+    running = True
+    dirty = False
+    clock = pygame.time.Clock()
+    while True:
+        for ev in pygame.event.get():
+            if ev.type == pygame.QUIT:
+                running = False
+                break
+            elif ev.type == pygame.WINDOWEXPOSED:
+                dirty = True
+            elif ev.type == pygame.KEYDOWN:
+                if ev.key == pygame.K_ESCAPE:
+                    running = False
+                    break
+        if not running:
+            break
+        elif dirty:
+            surf.fill("black")
+            for item in shapes:
+                item["shape"].draw(surf, item["pos"], (16, 16), "green")
+            pygame.display.update()
+            dirty = False
+        clock.tick(60)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/xinput.py b/xinput.py
new file mode 100644 (file)
index 0000000..1176f55
--- /dev/null
+++ b/xinput.py
@@ -0,0 +1,74 @@
+import os
+import re
+import subprocess
+
+
+class XinputConf:
+    DEVICES = {
+        "ELAN9008:00 04F3:425B": {
+            "output": "eDP-1",
+            "type": "touchpad",
+        },
+        "ELAN9008:00 04F3:425B Touchpad": {
+            "output": "eDP-1",
+            "type": "touchpad",
+        },
+        "ELAN9008:00 04F3:425B Stylus Pen (0)": {
+            "output": "eDP-1",
+            "type": "stylus",
+        },
+        "ELAN9009:00 04F3:425A": {
+            "output": "eDP-2",
+            "type": "touchpad",
+        },
+        "ELAN9009:00 04F3:425A Touchpad": {
+            "output": "eDP-2",
+            "type": "touchpad",
+        },
+        "ELAN9009:00 04F3:425A Stylus Pen (0)": {
+            "output": "eDP-2",
+            "type": "stylus",
+        },
+    }
+    DEVICE_PATTERN = re.compile(
+        "^([\u239c ]\\s*\u21b3|\u223c)\\s+(([^ ]| [^ ])+)\\s*\tid=(\\d+)\t"
+    )
+
+    def __init__(self, xrandr_conf):
+        self.xrandr_conf = xrandr_conf
+        self.current_conf = self.get_conf()
+
+    def get_conf(self):
+        output = subprocess.check_output(["xinput", "list"], text=True)
+        devices = []
+        for line in output.split(os.linesep):
+            match = self.DEVICE_PATTERN.match(line)
+            if match:
+                if match.group(2) not in self.DEVICES:
+                    continue
+                devices.append(
+                    {
+                        "name": match.group(2),
+                        "enabled": not match.group(1).startswith("\u223c"),
+                        "id": match.group(4),
+                        **self.DEVICES[match.group(2)],
+                    }
+                )
+            elif line and "core pointer" not in line and "core keyboard" not in line:
+                print("line", repr(line))
+        return devices
+
+    def update_device(self, device, enable=True):
+        if enable:
+            subprocess.run(["xinput", "enable", device["id"]])
+            subprocess.run(["xinput", "map-to-output", device["id"], device["output"]])
+        else:
+            subprocess.run(["xinput", "disable", device["id"]])
+
+    def update(self, mode):
+        enable_second = mode != "single"
+        for device in self.current_conf:
+            if device["output"] == self.xrandr_conf.OUTPUTS[0]:
+                self.update_device(device)
+            elif device["output"] == self.xrandr_conf.OUTPUTS[1]:
+                self.update_device(device, enable_second)
diff --git a/xrandr.py b/xrandr.py
new file mode 100644 (file)
index 0000000..c203b79
--- /dev/null
+++ b/xrandr.py
@@ -0,0 +1,190 @@
+import os
+import re
+import subprocess
+
+
+class XrandrConf:
+    CONNECTED_MAPPING = {
+        "connected": True,
+        "disconnected": False,
+    }
+    RESOLUTION_PATTERN = re.compile(r"(\d+)x(\d+)\+(\d+)\+(\d+)")
+    MM_SIZE_PATTERN = re.compile(r"(\d+)mm x (\d+)mm")
+    DIRECTIONS = ("normal", "left", "inverted", "right", "invalid direction")
+    REFLECTIONS = (
+        "none", "[Xx] axis", "[Yy] axis", "X and Y axis", "invalid reflection"
+    )
+    DIRECTIONS_REFLECTIONS_PATTERN = re.compile(
+        f"({'|'.join(DIRECTIONS)})( {'|'.join(REFLECTIONS)})? \\("
+    )
+    AVAILABLE_DIRECTIONS_REFLECTIONS_PATTERN = re.compile(
+        f"{'|'.join(DIRECTIONS)}|{'|'.join(REFLECTIONS)}"
+    )
+    MODE_PATTERN = re.compile(r"^\s{2}([^ ]+) \(0x([0-9A-Fa-f]+)\)\s+([^ ]+)MHz")
+    MODE_FLAGS_PATTERN = re.compile("[-+][CHV]Sync|Interlace|DoubleScan")
+    WIDTH_HEIGHT_PATTERN = re.compile(r"^\s{8}(h: width|v: height)\s+(\d+) ")
+    OUTPUTS = ("eDP-1", "eDP-2")
+
+    def __init__(self):
+        self.current_conf = self.get_conf()
+
+    @staticmethod
+    def strip_each(ss):
+        return (s.strip() for s in ss)
+
+    @staticmethod
+    def int_tuple(it):
+        return tuple(int(i) for i in it)
+
+    @classmethod
+    def parse_screen_line(cls, line):
+        screen_id, sizes = cls.strip_each(line.split(":", 1))
+        return {
+            "screen": int(screen_id[7:]),
+            "sizes": [
+                {
+                    "name": name,
+                    "size": cls.int_tuple(cls.strip_each(size.split("x"))),
+                } for name, size in (
+                    cls.strip_each(p.split(" ", 1))
+                    for p in cls.strip_each(sizes.split(","))
+                )
+            ],
+            "outputs": [],
+        }
+
+    @classmethod
+    def parse_output_line(cls, line):
+        name, connected, rest = cls.strip_each(line.split(" ", 2))
+        output = {
+            "name": name,
+            "connected": cls.CONNECTED_MAPPING.get(
+                connected, "unknown connection"
+            ),
+        }
+        if output["connected"] is False:
+            return output
+        resolution = cls.RESOLUTION_PATTERN.search(rest)
+        output["active"] = bool(resolution)
+        if resolution is None:
+            return output
+        parsed = cls.int_tuple(resolution.group(i) for i in range(1, 5))
+        direction_reflection = (
+            cls.DIRECTIONS_REFLECTIONS_PATTERN.search(rest)
+        )
+        end = direction_reflection.end()
+        output.update(
+            {
+                "size": parsed[:2],
+                "pos": parsed[2:],
+                "primary": rest.startswith("primary "),
+                "direction": direction_reflection.group(1),
+                "reflection": direction_reflection.group(2) or "none",
+                "available_rotations_reflections": [
+                    m.group(0)
+                    for m in cls.AVAILABLE_DIRECTIONS_REFLECTIONS_PATTERN.finditer(
+                        rest[end:rest.find(")", end)]
+                    )
+                ],
+                "modes": []
+            }
+        )
+        mm_size = cls.MM_SIZE_PATTERN.search(rest)
+        if mm_size:
+            output["mm_size"] = (
+                int(mm_size.group(1)), int(mm_size.group(2))
+            )
+        return output
+
+    @classmethod
+    def parse_mode(cls, line, width_line, height_line):
+        if line.endswith(" +preferred"):
+            line = line[:-11]
+            preferred = True
+        else:
+            preferred = False
+        if line.endswith(" *current"):
+            line = line[:-9]
+            current = True
+        else:
+            current = False
+        match = cls.MODE_PATTERN.match(line)
+        return {
+            "name": match.group(1),
+            "width": int(cls.WIDTH_HEIGHT_PATTERN.match(width_line).group(2)),
+            "height": int(cls.WIDTH_HEIGHT_PATTERN.match(height_line).group(2)),
+            "mode_id": int(match.group(2), 16),
+            "refresh_rate": float(match.group(3)),
+            "preferred": preferred,
+            "current": current,
+            "mode_flags": cls.MODE_FLAGS_PATTERN.findall(line),
+        }
+
+    @classmethod
+    def get_conf(cls):
+        screens = []
+        current_screen = None
+        output = subprocess.check_output(["xrandr", "--verbose"], text=True)
+        lines = output.split(os.linesep)
+        for i, line in enumerate(lines):
+            if not line:
+                continue
+            elif line.startswith("Screen "):
+                current_screen = len(screens)
+                screens.append(cls.parse_screen_line(line))
+            elif not line.startswith("\t") and not line.startswith(" "):
+                screens[current_screen]["outputs"].append(cls.parse_output_line(line))
+            elif line.startswith("  "):
+                output = screens[current_screen]["outputs"][-1]
+                if line[3] == " " or "modes" not in output:
+                    continue
+                output["modes"].append(cls.parse_mode(*lines[i:i + 3]))
+        #print(json.dumps(screens, indent=4))
+        return screens
+
+    @classmethod
+    def call(cls, output, *args, **kwargs):
+        args = [f"--{arg}" for arg in args]
+        for key, value in kwargs.items():
+            args.extend((f"--{key.replace("_", "-")}", value))
+        return subprocess.run(["xrandr", "--output", cls.OUTPUTS[output], *args])
+
+    def update(self, mode):
+        if mode not in ("single", "double", "vertical"):
+            raise ValueError(f"unknown mode: {mode}")
+        self.call(1, "off", "noprimary")
+        if mode in ("single", "double"):
+            self.call(0, "auto", "primary", rotate="normal")
+        if mode == "double":
+            self.call(1, "auto", rotate="normal", below=self.OUTPUTS[0])
+        if mode == "vertical":
+            self.call(0, "auto", rotate="left")
+            self.call(1, "auto", rotate="left", left_of=self.OUTPUTS[0])
+        self.current_conf = self.get_conf()
+
+    def get_relevant_outputs(self):
+        screen = next(s for s in self.current_conf if s["screen"] == 0)
+        outputs = [None, None]
+        to_finds = list(self.OUTPUTS)
+        for output in screen["outputs"]:
+            for i, to_find in enumerate(to_finds):
+                if output["name"] == to_find:
+                    outputs[self.OUTPUTS.index(to_find)] = output
+                    to_finds.remove(to_find)
+            if not to_finds:
+                return outputs
+        raise ValueError(f"Outputs not found: {', '.join(self.OUTPUTS)}")
+
+    def is_active(self, mode):
+        outputs = self.get_relevant_outputs()
+        if mode == "vertical":
+            expected_direction = "left"
+        else:
+            expected_direction = "normal"
+        second_active = mode != "single"
+        return (
+            outputs[0]["active"]
+            and outputs[0]["direction"] == expected_direction
+            and outputs[1]["active"] == second_active
+            and (not second_active or outputs[1]["direction"] == expected_direction)
+        )
diff --git a/zenbook_conf.py b/zenbook_conf.py
new file mode 100755 (executable)
index 0000000..f270af9
--- /dev/null
@@ -0,0 +1,110 @@
+#!/usr/bin/env python
+
+import pygame
+
+from bluetooth import BluetoothConf
+from vectors import laptop_double, laptop_single, laptop_vertical
+from xinput import XinputConf
+from xrandr import XrandrConf
+
+# - slider button with "undecided" state
+# - slider button for bluetooth
+#  - that springs on for the 2-screen modes, because the keyboard needs it
+# - slider button for touchscreen (undecided if disabled on second screen)
+#  - extra button to calibrate
+# - slider button for stylus (undecided if disabled on second screen)
+#  - extra button to calibrate
+# - recognize current setting for touchscreen and stylus
+# - ability to display messages
+#   - below the three screen buttons
+#   - next to the slider button for touchscreen
+#   - next to the slider button for stylus
+
+
+class ZenbookConf:
+    SHAPES = (
+        {
+            "name": "single",
+            "shape": laptop_single,
+            "rect": pygame.Rect((68, 68), (416, 416)),
+        },
+        {
+            "name": "double",
+            "shape": laptop_double,
+            "rect": pygame.Rect((568, 68), (416, 416)),
+        },
+        {
+            "name": "vertical",
+            "shape": laptop_vertical,
+            "rect": pygame.Rect((1068, 68), (416, 416)),
+        },
+    )
+
+    def __init__(self):
+        pygame.init()
+        self.surf = pygame.display.set_mode((1536, 768))
+        self.font = pygame.font.Font(None, size=64)
+        self.clock = pygame.time.Clock()
+        self.running = True
+        self.dirty = False
+        self.xrandr_conf = XrandrConf()
+        self.xinput_conf = XinputConf(self.xrandr_conf)
+        self.bluetooth_conf = BluetoothConf()
+        self.current_fps = -1
+
+    def handle_event(self, ev):
+        if ev.type == pygame.QUIT:
+            self.running = False
+            return False
+        elif ev.type == pygame.WINDOWEXPOSED:
+            self.dirty = True
+        elif ev.type == pygame.KEYDOWN:
+            if ev.key == pygame.K_ESCAPE:
+                self.running = False
+                return False
+        elif ev.type == pygame.MOUSEBUTTONDOWN:
+            for item in self.SHAPES:
+                if item["rect"].collidepoint(ev.pos):
+                    self.xrandr_conf.update(item["name"])
+                    self.xinput_conf.update(item["name"])
+                    if item["name"] != "single":
+                        self.bluetooth_conf.update(True)
+                    self.dirty = True
+        return True
+
+    def update(self):
+        if self.clock.get_fps() != self.current_fps:
+            self.current_fps = int(self.clock.get_fps())
+            self.dirty = True
+
+    def draw(self):
+        self.surf.fill(0x333333)
+        for item in self.SHAPES:
+            if self.xrandr_conf.is_active(item["name"]):
+                pygame.draw.rect(self.surf, "lime", item["rect"], 8)
+            else:
+                pygame.draw.rect(self.surf, "gray", item["rect"], 8)
+            item["shape"].draw(
+                self.surf,
+                (item["rect"].left + 32, item["rect"].top + 32),
+                (16, 16),
+                "white",
+            )
+
+    def run(self):
+        while True:
+            for ev in pygame.event.get():
+                if not self.handle_event(ev):
+                    break
+            if not self.running:
+                break
+            self.update()
+            if self.dirty:
+                self.draw()
+                pygame.display.update()
+                self.dirty = False
+            self.clock.tick(60)
+
+
+if __name__ == "__main__":
+    ZenbookConf().run()