diff --git a/keyviz/README.md b/keyviz/README.md new file mode 100644 index 00000000..8b525017 --- /dev/null +++ b/keyviz/README.md @@ -0,0 +1,65 @@ +# Key Visualizer + +A minimal, real-time floating on-screen keystroke visualizer HUD for Noctalia. Displays typed keys and modifier combinations as translucent, blurred glass keycaps directly on your screen. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `h-jangra/keyviz` | +| Entries | Bar widget: `widget`; panel: `overlay`; shortcut: `toggle`; service: `keylistener` | + +## Requirements + +- `python3` on `PATH` (used by the background event listener service). +- Linux user in the `input` group to read `/dev/input/event*` devices: + ```sh + sudo usermod -a -G input $USER + ``` + *(Note: Log out and log back in for group membership to take effect).* + +## Usage + +### Panel + +Toggle the key visualizer overlay HUD directly via IPC: + +```sh +noctalia msg panel-toggle h-jangra/keyviz:overlay +``` + +### Bar Widget & Shortcut + +- **Bar Widget (`widget`)**: Add `h-jangra/keyviz:widget` to your bar items in Noctalia settings to get a quick visual status icon and toggle button. +- **Control Center Shortcut (`toggle`)**: Add `h-jangra/keyviz:toggle` to your control center shortcuts to pause or resume key visualization. + +### IPC Commands + +Control the listener service dynamically: + +```sh +# Toggle key visualizer on/off +noctalia msg plugin h-jangra/keyviz:keylistener all toggle + +# Clear currently displayed keys +noctalia msg plugin h-jangra/keyviz:keylistener all clear +``` + +## Settings + +Configure Keyviz in **Noctalia Settings → Plugins → Key Visualizer**: + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `enabled_by_default` | `bool` | `true` | Start visualizer automatically when Noctalia starts. | +| `padding` | `int` | `6` | Internal padding spacing inside overlay around keycaps (`0` – `30`). | +| `margin` | `int` | `6` | Spacing gap between visualized key combinations (`0` – `30`). | +| `timeout_ms` | `int` | `500` | Inactivity duration (ms) before keys disappear (`200` – `5000`). | +| `max_keys` | `int` | `4` | Maximum number of key combinations to display (`1` – `8`). | +| `font_size` | `select` | `medium` | Text size of keycaps (`small`, `medium`, `large`). | +| `badge_style` | `select` | `glass` | Visual style (`glass`, `solid`, `accent`). | +| `show_modifiers_only` | `bool` | `false` | Only visualize combinations with Ctrl, Alt, Shift, or Super. | + +## License + +MIT diff --git a/keyviz/overlay.luau b/keyviz/overlay.luau new file mode 100644 index 00000000..38935e8f --- /dev/null +++ b/keyviz/overlay.luau @@ -0,0 +1,122 @@ +--!nonstrict + +local symbols = { + Up = "↑", + Down = "↓", + Left = "←", + Right = "→", + Enter = "↵", + Backspace = "⌫", + Tab = "⇥", + Space = "␣", + Esc = "Esc", + Super = "⌘", + Ctrl = "Ctrl", + Alt = "Alt", + Shift = "⇧", +} + +local function getFontSize() + local size = noctalia.getConfig("font_size") or "medium" + + if size == "small" then + return 11 + elseif size == "large" then + return 15 + end + + return 13 +end + +local function getStyle() + local style = noctalia.getConfig("badge_style") or "glass" + + if style == "solid" then + return "surface_container_high", "outline", 1 + elseif style == "accent" then + return "primary/0.16", "primary/0.75", 1 + end + + return "surface_container_high/0.92", "outline/0.55", 1 +end + +local function render() + local keys = noctalia.state.get("active_keys") or {} + + if #keys == 0 then + panel.render(ui.row({})) + return + end + + local fontSize = getFontSize() + local fill, border, borderWidth = getStyle() + local gap = noctalia.getConfig("margin") or 6 + local padding = noctalia.getConfig("padding") or 6 + local items = {} + + for _, combo in ipairs(keys) do + local children = {} + local parts = string.split(combo, " + ") + + for i, part in ipairs(parts) do + table.insert(children, ui.label({ + text = symbols[part] or part, + fontSize = fontSize, + fontWeight = "bold", + color = "on_surface", + })) + + if i < #parts then + table.insert(children, ui.label({ + text = "+", + fontSize = math.max(9, fontSize - 3), + fontWeight = "bold", + color = "on_surface_variant", + })) + end + end + + local isSingleKey = #parts == 1 and #combo <= 3 + local padH = isSingleKey and 7 or 10 + local itemHeight = 30 + + table.insert(items, ui.row({ + height = itemHeight, + paddingH = padH, + gap = 6, + fill = fill, + border = border, + borderWidth = borderWidth, + radius = 7, + align = "center", + justify = "center", + }, children)) + end + + panel.render(ui.row({ + flexGrow = 1, + align = "center", + justify = "center", + }, { + ui.row({ + gap = gap, + padding = padding, + align = "center", + justify = "center", + }, items), + })) +end + +noctalia.state.watch("active_keys", render) + +function onConfigChanged() + render() +end + +function onOpen() + render() +end + +render() + + diff --git a/keyviz/plugin.toml b/keyviz/plugin.toml new file mode 100644 index 00000000..78aa000b --- /dev/null +++ b/keyviz/plugin.toml @@ -0,0 +1,110 @@ +id = "h-jangra/keyviz" +name = "Key Visualizer" +version = "0.1.0" +plugin_api = 13 +author = "h-jangra" +license = "MIT" +dependencies = ["python3"] +tags = ["utility", "bar", "service", "shortcut", "panel"] +icon = "keyboard" +description = "Real-time floating on-screen keystroke visualizer for Noctalia." + +[[setting]] +key = "enabled_by_default" +type = "bool" +label_key = "settings.enabled_by_default.label" +description_key = "settings.enabled_by_default.description" +default = true + +[[setting]] +key = "padding" +type = "int" +label_key = "settings.padding.label" +description_key = "settings.padding.description" +default = 6 +min = 0 +max = 30 +step = 1 + +[[setting]] +key = "margin" +type = "int" +label_key = "settings.margin.label" +description_key = "settings.margin.description" +default = 6 +min = 0 +max = 30 +step = 1 + +[[setting]] +key = "timeout_ms" +type = "int" +label_key = "settings.timeout_ms.label" +description_key = "settings.timeout_ms.description" +default = 500 +min = 200 +max = 5000 +step = 100 + +[[setting]] +key = "max_keys" +type = "int" +label_key = "settings.max_keys.label" +description_key = "settings.max_keys.description" +default = 4 +min = 1 +max = 8 + +[[setting]] +key = "font_size" +type = "select" +label_key = "settings.font_size.label" +description_key = "settings.font_size.description" +options = [ + { value = "small", label_key = "settings.font_size.options.small" }, + { value = "medium", label_key = "settings.font_size.options.medium" }, + { value = "large", label_key = "settings.font_size.options.large" }, +] +default = "medium" + +[[setting]] +key = "badge_style" +type = "select" +label_key = "settings.badge_style.label" +description_key = "settings.badge_style.description" +options = [ + { value = "glass", label_key = "settings.badge_style.options.glass" }, + { value = "solid", label_key = "settings.badge_style.options.solid" }, + { value = "accent", label_key = "settings.badge_style.options.accent" }, +] +default = "glass" + +[[setting]] +key = "show_modifiers_only" +type = "bool" +label_key = "settings.show_modifiers_only.label" +description_key = "settings.show_modifiers_only.description" +default = false + +[[service]] +id = "keylistener" +entry = "service.luau" + +[[panel]] +id = "overlay" +entry = "overlay.luau" +width = 340 +height = 48 +placement = "floating" +position = "bottom_center" +persistent = true +dismiss_on_outside_click = false +keyboard_focus = "none" + +[[widget]] +id = "widget" +entry = "widget.luau" + +[[shortcut]] +id = "toggle" +entry = "shortcut.luau" diff --git a/keyviz/scripts/listener.py b/keyviz/scripts/listener.py new file mode 100644 index 00000000..9160ed33 --- /dev/null +++ b/keyviz/scripts/listener.py @@ -0,0 +1,402 @@ +#!/usr/bin/env python3 +""" +scripts/listener.py - Keyboard Event Listener for Noctalia Keyviz + +Reads Linux /dev/input/event* devices using blocking I/O in reader threads, +tracks modifier states, formats Keyviz-style key combinations, +and emits JSON events to stdout for service.luau. +""" + +import argparse +import glob +import json +import os +import signal +import struct +import sys +import threading +import time + +# Handle termination signals cleanly +signal.signal(signal.SIGTERM, lambda _s, _f: sys.exit(0)) +signal.signal(signal.SIGINT, lambda _s, _f: sys.exit(0)) + +EVENT_FORMAT = "qqHHi" +EVENT_SIZE = struct.calcsize(EVENT_FORMAT) +EV_KEY = 1 + +KEY_NAMES = { + 1: "Esc", + 2: "1", + 3: "2", + 4: "3", + 5: "4", + 6: "5", + 7: "6", + 8: "7", + 9: "8", + 10: "9", + 11: "0", + 12: "-", + 13: "=", + 14: "Backspace", + 15: "Tab", + 16: "Q", + 17: "W", + 18: "E", + 19: "R", + 20: "T", + 21: "Y", + 22: "U", + 23: "I", + 24: "O", + 25: "P", + 26: "[", + 27: "]", + 28: "Enter", + 29: "Ctrl", + 30: "A", + 31: "S", + 32: "D", + 33: "F", + 34: "G", + 35: "H", + 36: "J", + 37: "K", + 38: "L", + 39: ";", + 40: "'", + 41: "`", + 42: "Shift", + 43: "\\", + 44: "Z", + 45: "X", + 46: "C", + 47: "V", + 48: "B", + 49: "N", + 50: "M", + 51: ",", + 52: ".", + 53: "/", + 54: "Shift", + 55: "KP *", + 56: "Alt", + 57: "Space", + 58: "CapsLock", + 59: "F1", + 60: "F2", + 61: "F3", + 62: "F4", + 63: "F5", + 64: "F6", + 65: "F7", + 66: "F8", + 67: "F9", + 68: "F10", + 69: "NumLock", + 70: "ScrollLock", + 71: "KP 7", + 72: "KP 8", + 73: "KP 9", + 74: "KP -", + 75: "KP 4", + 76: "KP 5", + 77: "KP 6", + 78: "KP +", + 79: "KP 1", + 80: "KP 2", + 81: "KP 3", + 82: "KP 0", + 83: "KP .", + 87: "F11", + 88: "F12", + 96: "KP Enter", + 97: "Ctrl", + 98: "KP /", + 99: "Print", + 100: "Alt", + 102: "Home", + 103: "Up", + 104: "PageUp", + 105: "Left", + 106: "Right", + 107: "End", + 108: "Down", + 109: "PageDown", + 110: "Insert", + 111: "Delete", + 113: "Mute", + 114: "VolDown", + 115: "VolUp", + 116: "Power", + 117: "KP =", + 119: "Pause", + 125: "Super", + 126: "Super", + 127: "Menu", + 128: "Stop", + 183: "F13", + 184: "F14", + 185: "F15", + 186: "F16", + 187: "F17", + 188: "F18", + 189: "F19", + 190: "F20", + 191: "F21", + 192: "F22", + 193: "F23", + 194: "F24", + 210: "Print", +} + +MODIFIER_CODES = { + 29: "Ctrl", + 97: "Ctrl", + 42: "Shift", + 54: "Shift", + 56: "Alt", + 100: "Alt", + 125: "Super", + 126: "Super", +} + + +def _read_ev_bits(dev_path): + """Read EV capability bitmask from sysfs for a device path.""" + try: + real = os.path.realpath(dev_path) + event_name = os.path.basename(real) + sysfs = f"/sys/class/input/{event_name}/device/capabilities/ev" + with open(sysfs, "r") as f: + return int(f.read().strip(), 16) + except Exception: + return None + + +def _is_real_keyboard(dev_path): + """Return True only for devices that have both EV_KEY (1) and EV_REP (20) capabilities.""" + ev = _read_ev_bits(dev_path) + if ev is None: + return True + EV_KEY_BIT = 1 << 1 + EV_REP_BIT = 1 << 20 + return bool(ev & EV_KEY_BIT) and bool(ev & EV_REP_BIT) + + +def find_keyboard_devices(): + """Discover real keyboard input event nodes (devices with EV_KEY + EV_REP).""" + devices = [] + seen = set() + + if os.path.exists("/proc/bus/input/devices"): + try: + with open("/proc/bus/input/devices", "r") as f: + content = f.read() + for block in content.split("\n\n"): + is_kbd = False + event_name = None + for line in block.splitlines(): + if ( + "sysrq" in line.lower() + or "kbd" in line.lower() + or "EV=120013" in line + or "EV=100013" in line + or "EV=12001f" in line + ): + is_kbd = True + if line.startswith("H: Handlers="): + for part in line.split(): + if part.startswith("event"): + event_name = part + if is_kbd and event_name: + dev_path = f"/dev/input/{event_name}" + if dev_path not in seen and _is_real_keyboard(dev_path): + devices.append(dev_path) + seen.add(dev_path) + except Exception: + pass + + for pattern in ["/dev/input/by-id/*kbd*", "/dev/input/by-path/*kbd*"]: + for path in glob.glob(pattern): + try: + target = os.path.realpath(path) + if target not in seen and _is_real_keyboard(target): + devices.append(target) + seen.add(target) + except Exception: + pass + + if not devices: + for path in sorted(glob.glob("/dev/input/event*")): + if path not in seen and _is_real_keyboard(path): + devices.append(path) + seen.add(path) + + return devices + + +def format_combination(modifiers, key_name): + parts = [] + for mod in ["Super", "Ctrl", "Alt", "Shift"]: + if mod != key_name and mod in set(modifiers): + parts.append(mod) + parts.append(key_name) + return " + ".join(parts) + + +def read_device_blocking(fd, dev, q): + try: + while True: + try: + data = os.read(fd, EVENT_SIZE * 16) + except OSError: + break + if not data: + break + q.put(("data", fd, dev, data)) + finally: + q.put(("closed", fd, dev, b"")) + + +def main(): + parser = argparse.ArgumentParser(description="Noctalia Keyviz Keyboard Listener") + parser.add_argument("--test-devices", action="store_true", help="List discovered keyboard devices") + args = parser.parse_args() + + if args.test_devices: + all_devs = find_keyboard_devices() + readable = [d for d in all_devs if os.access(d, os.R_OK)] + print(f"Total discovered keyboard devices: {len(all_devs)}") + for d in all_devs: + status = "READABLE" if d in readable else "PERMISSION_DENIED" + print(f" {d} -> {status}") + sys.exit(0) + + + + import queue + + last_error_time = 0 + + while True: + all_devs = find_keyboard_devices() + readable_devs = [d for d in all_devs if os.access(d, os.R_OK)] + + if not readable_devs: + now = time.time() + if now - last_error_time > 5: + error_payload = { + "type": "error", + "error": "permission_denied", + "message": "Input permission required: run `sudo usermod -aG input $USER` and re-login.", + "devices_found": all_devs, + "pid": os.getpid(), + } + print(json.dumps(error_payload), flush=True) + last_error_time = now + time.sleep(2) + continue + + fds = {} + for dev in readable_devs: + try: + fd = os.open(dev, os.O_RDONLY) + fds[fd] = dev + except Exception: + pass + + if not fds: + time.sleep(2) + continue + + ready_payload = { + "type": "ready", + "device_count": len(fds), + "devices": list(fds.values()), + "pid": os.getpid(), + } + print(json.dumps(ready_payload), flush=True) + + active_modifiers = set() + dev_queue = queue.SimpleQueue() + + for fd, dev in fds.items(): + t = threading.Thread( + target=read_device_blocking, + args=(fd, dev, dev_queue), + daemon=True, + ) + t.start() + + open_fds = set(fds.keys()) + + try: + while open_fds: + try: + kind, fd, dev, data = dev_queue.get(timeout=2.0) + except Exception: + continue + + if kind == "closed": + open_fds.discard(fd) + continue + + offset = 0 + while offset + EVENT_SIZE <= len(data): + chunk = data[offset : offset + EVENT_SIZE] + offset += EVENT_SIZE + tv_sec, tv_usec, ev_type, code, value = struct.unpack(EVENT_FORMAT, chunk) + + if ev_type != EV_KEY: + continue + + if code in MODIFIER_CODES: + mod_name = MODIFIER_CODES[code] + if value in (1, 2): + active_modifiers.add(mod_name) + elif value == 0: + active_modifiers.discard(mod_name) + out = { + "type": "release", + "key": mod_name, + "code": code, + "modifiers": list(active_modifiers), + } + print(json.dumps(out), flush=True) + + if value == 1: # Initial key press + key_name = KEY_NAMES.get(code, f"Key_{code}") + combo = format_combination(active_modifiers, key_name) + mods_list = list(active_modifiers) + out = { + "type": "press", + "key": key_name, + "combo": combo, + "code": code, + "is_modifier": code in MODIFIER_CODES, + "modifiers": mods_list, + "timestamp": int(time.time() * 1000), + } + print(json.dumps(out), flush=True) + + except Exception as exc: + err_out = { + "type": "error", + "error": "listener_crashed", + "message": str(exc) or "Listener loop crashed, restarting.", + } + print(json.dumps(err_out), flush=True) + finally: + for fd in list(fds.keys()): + try: + os.close(fd) + except Exception: + pass + fds.clear() + time.sleep(2) + + +if __name__ == "__main__": + main() diff --git a/keyviz/service.luau b/keyviz/service.luau new file mode 100644 index 00000000..982aee74 --- /dev/null +++ b/keyviz/service.luau @@ -0,0 +1,167 @@ +--!nonstrict + +local active = noctalia.getConfig("enabled_by_default") ~= false +local keys = {} +local lastPress = 0 +local lastWasModifier = false +local listenerRunning = false +local listenerPid: number? = nil + +noctalia.state.set("is_active", active) +noctalia.state.set("active_keys", {}) + +local script = `{noctalia.pluginDir()}/scripts/listener.py` + +local function openPanel() + if type(noctalia.runAsync) == "function" then + noctalia.runAsync("/usr/bin/noctalia msg panel-open h-jangra/keyviz:overlay", function() end) + end +end + +local function closePanel() + if type(noctalia.runAsync) == "function" then + noctalia.runAsync("/usr/bin/noctalia msg panel-close h-jangra/keyviz:overlay", function() end) + end +end + +local function clear() + keys = {} + lastWasModifier = false + lastPress = 0 + noctalia.state.set("active_keys", {}) + closePanel() +end + +local function decodeJson(str: string) + if type(json) == "table" and type(json.decode) == "function" then + return pcall(json.decode, str) + elseif type(noctalia.json) == "table" and type(noctalia.json.decode) == "function" then + return pcall(noctalia.json.decode, str) + end + return false, nil +end + +local function stopListener() + if listenerPid and type(noctalia.runAsync) == "function" then + noctalia.runAsync(`kill -TERM {listenerPid}`, function() end) + listenerPid = nil + end + + if type(noctalia.runAsync) == "function" then + noctalia.runAsync(`pkill -TERM -f "{script}"`, function() end) + end + + listenerRunning = false + clear() +end + +local function startListener() + if listenerRunning then + return + end + + listenerRunning = true + + noctalia.runStream(`python3 -u "{script}"`, function(line) + local ok, event = decodeJson(line) + + if not ok or type(event) ~= "table" then + return + end + + if type(event.pid) == "number" then + listenerPid = event.pid + end + + if event.type == "release" then + if not event.modifiers or #event.modifiers == 0 then + lastWasModifier = false + end + return + end + + if event.type ~= "press" or not active then + return + end + + local mods = event.modifiers or {} + + if noctalia.getConfig("show_modifiers_only") == true and #mods == 0 then + return + end + + local combo = event.combo or event.key + local isMod = event.is_modifier == true + + if isMod then + if lastWasModifier and #keys > 0 then + keys[#keys] = combo + else + table.insert(keys, combo) + end + lastWasModifier = true + else + if lastWasModifier and #keys > 0 then + keys[#keys] = combo + else + table.insert(keys, combo) + end + lastWasModifier = false + end + + local maxKeys = noctalia.getConfig("max_keys") or 4 + + while #keys > maxKeys do + table.remove(keys, 1) + end + + lastPress = noctalia.nowMs() + + local copy = {} + + for _, key in ipairs(keys) do + table.insert(copy, key) + end + + noctalia.state.set("active_keys", copy) + openPanel() + end) +end + +noctalia.state.watch("is_active", function(value) + active = value == true + + if active then + startListener() + else + stopListener() + end +end) + +function onIpc(event: string, _payload: any) + if event == "toggle" then + noctalia.state.set("is_active", not active) + elseif event == "enable" then + noctalia.state.set("is_active", true) + elseif event == "disable" then + noctalia.state.set("is_active", false) + elseif event == "clear" then + clear() + end +end + +function onExit() + stopListener() +end + +noctalia.setUpdateInterval(50) + +function update() + if #keys > 0 and noctalia.nowMs() - lastPress >= (noctalia.getConfig("timeout_ms") or 500) then + clear() + end +end + +if active then + startListener() +end diff --git a/keyviz/shortcut.luau b/keyviz/shortcut.luau new file mode 100644 index 00000000..181689f4 --- /dev/null +++ b/keyviz/shortcut.luau @@ -0,0 +1,29 @@ +--!nonstrict + +local active = noctalia.state.get("is_active") ~= false + +local function render() + shortcut.setLabel(active and "Keyviz On" or "Keyviz Off") + shortcut.setIcon(active and "keyboard" or "keyboard-off") + shortcut.setActive(active) +end + +noctalia.state.watch("is_active", function(value) + active = value == true + render() +end) + +function onClick() + active = not active + noctalia.state.set("is_active", active) + if active and type(noctalia.runAsync) == "function" then + noctalia.runAsync("/usr/bin/noctalia msg panel-open h-jangra/keyviz:overlay", function() end) + end + render() +end + +function onRightClick() + onClick() +end + +render() diff --git a/keyviz/thumbnail.webp b/keyviz/thumbnail.webp new file mode 100644 index 00000000..b0996b7a Binary files /dev/null and b/keyviz/thumbnail.webp differ diff --git a/keyviz/translations/en.json b/keyviz/translations/en.json new file mode 100644 index 00000000..f335ecc4 --- /dev/null +++ b/keyviz/translations/en.json @@ -0,0 +1,53 @@ +{ + "title": "Key Visualizer", + "tooltip_active": "Key Visualizer: Active", + "tooltip_paused": "Key Visualizer: Paused", + "notify_resumed": "Keystroke visualizer active", + "notify_paused": "Keystroke visualizer paused", + "shortcut_active": "Keyviz On", + "shortcut_paused": "Keyviz Off", + "settings": { + "enabled_by_default": { + "label": "Enabled by default", + "description": "Start keystroke visualizer automatically when Noctalia starts" + }, + "padding": { + "label": "Overlay padding", + "description": "Internal padding spacing inside the overlay around keycaps (in pixels)" + }, + "margin": { + "label": "Keycap spacing", + "description": "Spacing gap between visualized key combinations (in pixels)" + }, + "timeout_ms": { + "label": "Key timeout (ms)", + "description": "How long keys remain visible on screen after release (in milliseconds)" + }, + "max_keys": { + "label": "Maximum key combos", + "description": "Maximum number of recent key combinations to display simultaneously" + }, + "font_size": { + "label": "Key font size", + "description": "Text size of displayed keycaps", + "options": { + "small": "Small (13px)", + "medium": "Medium (16px)", + "large": "Large (20px)" + } + }, + "badge_style": { + "label": "Visual style", + "description": "Appearance and background style of on-screen keycaps", + "options": { + "glass": "Glass (Blurred)", + "solid": "Solid Surface", + "accent": "Accent Outline" + } + }, + "show_modifiers_only": { + "label": "Shortcuts only", + "description": "Only visualize key combinations containing Ctrl, Alt, Shift, or Super" + } + } +} diff --git a/keyviz/widget.luau b/keyviz/widget.luau new file mode 100644 index 00000000..f1d4f944 --- /dev/null +++ b/keyviz/widget.luau @@ -0,0 +1,61 @@ +--!nonstrict +-- Bar [[widget]] entry for Key Visualizer (Keyviz) +-- Provides a clean status icon on the Noctalia bar with left-click toggle support. + +local isActive = noctalia.state.get("is_active") ~= false +local function render() + local isVert = (type(barWidget.isVertical) == "function" and barWidget.isVertical()) or false + local container = isVert and ui.column or ui.row + + local iconColor = if isActive then "primary" else "on_surface_variant" + local tooltipText = if isActive then (noctalia.tr("tooltip_active") or "Key Visualizer: Active") else (noctalia.tr("tooltip_paused") or "Key Visualizer: Paused") + + local children = { + ui.glyph({ + name = "keyboard", + size = 14, + color = iconColor, + }), + } + + barWidget.render(container({ + align = "center", + justify = "center", + gap = 6, + }, children)) + + if type(barWidget.setTooltip) == "function" then + barWidget.setTooltip(tooltipText) + end + if type(barWidget.setActive) == "function" then + barWidget.setActive(isActive) + end +end + +noctalia.state.watch("is_active", function(val) + if val ~= nil then + isActive = val == true + render() + end +end) + +function onClick() + isActive = not isActive + noctalia.state.set("is_active", isActive) + + if not isActive then + noctalia.state.set("active_keys", {}) + end + + render() +end + +function onRightClick() + onClick() +end + +function onConfigChanged() + render() +end + +render()