697 lines
26 KiB
Python
697 lines
26 KiB
Python
"""Keyboard visualizer widget — Cairo DrawingArea keyboard map.
|
|
|
|
Inspired from omer-biz/visu (Elm/WASM) into pure Python + Cairo.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
|
|
try:
|
|
import cairo # noqa: F401
|
|
HAS_CAIRO = True
|
|
except ImportError:
|
|
HAS_CAIRO = False
|
|
|
|
import gi
|
|
|
|
gi.require_version("Gtk", "4.0")
|
|
gi.require_version("Adw", "1")
|
|
from gi.repository import Adw, GLib, GObject, Gtk
|
|
|
|
from nirimod.xkb_helper import XkbHelper
|
|
|
|
KEYBOARD_GEOMETRIES: dict[str, list[list[tuple[str, int]]]] = {
|
|
"ANSI": [
|
|
# Row 0 — function row
|
|
[("escape", 4), ("", 2), ("f1", 3), ("f2", 3), ("f3", 3), ("f4", 3), ("", 2), ("f5", 3), ("f6", 3), ("f7", 3), ("f8", 3), ("", 2), ("f9", 3), ("f10", 3), ("f11", 3), ("f12", 3), ("", 2), ("print", 4), ("insert", 4), ("delete", 4)],
|
|
# Row 1 — number row
|
|
[("grave", 4), ("1", 4), ("2", 4), ("3", 4), ("4", 4), ("5", 4), ("6", 4), ("7", 4), ("8", 4), ("9", 4), ("0", 4), ("minus", 4), ("equal", 4), ("backspace", 8)],
|
|
# Row 2 — QWERTY
|
|
[("tab", 6), ("q", 4), ("w", 4), ("e", 4), ("r", 4), ("t", 4), ("y", 4), ("u", 4), ("i", 4), ("o", 4), ("p", 4), ("bracketleft", 4), ("bracketright", 4), ("backslash", 6)],
|
|
# Row 3 — home row
|
|
[("capslock", 7), ("a", 4), ("s", 4), ("d", 4), ("f", 4), ("g", 4), ("h", 4), ("j", 4), ("k", 4), ("l", 4), ("semicolon", 4), ("quote", 4), ("return", 9)],
|
|
# Row 4 — shift row
|
|
[("shiftleft", 7), ("z", 4), ("x", 4), ("c", 4), ("v", 4), ("b", 4), ("n", 4), ("m", 4), ("comma", 4), ("period", 4), ("slash", 4), ("shiftright", 5), ("up", 4), ("", 4)],
|
|
# Row 5 — bottom row
|
|
[("ctrlleft", 6), ("superleft", 6), ("altleft", 6), ("space", 24), ("altright", 6), ("left", 4), ("down", 4), ("right", 4)],
|
|
],
|
|
"ISO": [
|
|
# Row 0 — function row
|
|
[("escape", 4), ("", 2), ("f1", 3), ("f2", 3), ("f3", 3), ("f4", 3), ("", 2), ("f5", 3), ("f6", 3), ("f7", 3), ("f8", 3), ("", 2), ("f9", 3), ("f10", 3), ("f11", 3), ("f12", 3), ("", 2), ("print", 4), ("insert", 4), ("delete", 4)],
|
|
# Row 1 — number row
|
|
[("grave", 4), ("1", 4), ("2", 4), ("3", 4), ("4", 4), ("5", 4), ("6", 4), ("7", 4), ("8", 4), ("9", 4), ("0", 4), ("minus", 4), ("equal", 4), ("backspace", 8)],
|
|
# Row 2 — QWERTY
|
|
[("tab", 6), ("q", 4), ("w", 4), ("e", 4), ("r", 4), ("t", 4), ("y", 4), ("u", 4), ("i", 4), ("o", 4), ("p", 4), ("bracketleft", 4), ("bracketright", 4), ("return", 6)],
|
|
# Row 3 — home row
|
|
[("capslock", 7), ("a", 4), ("s", 4), ("d", 4), ("f", 4), ("g", 4), ("h", 4), ("j", 4), ("k", 4), ("l", 4), ("semicolon", 4), ("quote", 4), ("backslash", 4), ("return", 5)],
|
|
# Row 4 — shift row
|
|
[("shiftleft", 4), ("less", 4), ("z", 4), ("x", 4), ("c", 4), ("v", 4), ("b", 4), ("n", 4), ("m", 4), ("comma", 4), ("period", 4), ("slash", 4), ("shiftright", 4), ("up", 4), ("", 4)],
|
|
# Row 5 — bottom row
|
|
[("ctrlleft", 6), ("superleft", 6), ("altleft", 6), ("space", 24), ("altright", 6), ("left", 4), ("down", 4), ("right", 4)],
|
|
]
|
|
}
|
|
|
|
_KID_TO_KEYCODE = {
|
|
# Function row
|
|
"escape": 1, "f1": 59, "f2": 60, "f3": 61, "f4": 62, "f5": 63, "f6": 64, "f7": 65, "f8": 66, "f9": 67, "f10": 68, "f11": 87, "f12": 88, "print": 99, "insert": 110, "delete": 111,
|
|
# Number row
|
|
"grave": 41, "1": 2, "2": 3, "3": 4, "4": 5, "5": 6, "6": 7, "7": 8, "8": 9, "9": 10, "0": 11, "minus": 12, "equal": 13, "backspace": 14,
|
|
# Row 2
|
|
"tab": 15, "q": 16, "w": 17, "e": 18, "r": 19, "t": 20, "y": 21, "u": 22, "i": 23, "o": 24, "p": 25, "bracketleft": 26, "bracketright": 27, "backslash": 43,
|
|
# Row 3
|
|
"capslock": 58, "a": 30, "s": 31, "d": 32, "f": 33, "g": 34, "h": 35, "j": 36, "k": 37, "l": 38, "semicolon": 39, "quote": 40, "return": 28,
|
|
# Row 4
|
|
"shiftleft": 42, "less": 94, "z": 44, "x": 45, "c": 46, "v": 47, "b": 48, "n": 49, "m": 50, "comma": 51, "period": 52, "slash": 53, "shiftright": 54, "up": 103,
|
|
# Row 5
|
|
"ctrlleft": 29, "superleft": 125, "altleft": 56, "space": 57, "altright": 100, "left": 105, "down": 108, "right": 106
|
|
}
|
|
|
|
_STATIC_LABELS = {
|
|
"escape": "Esc", "backspace": "Bksp", "tab": "Tab", "return": "Enter", "capslock": "Caps",
|
|
"shiftleft": "Shift", "shiftright": "Shift", "ctrlleft": "Ctrl", "superleft": "Super",
|
|
"altleft": "Alt", "altright": "Alt", "up": "↑", "down": "↓", "left": "←", "right": "→", "space": "",
|
|
"grave": "`",
|
|
"f1": "F1", "f2": "F2", "f3": "F3", "f4": "F4", "f5": "F5", "f6": "F6",
|
|
"f7": "F7", "f8": "F8", "f9": "F9", "f10": "F10", "f11": "F11", "f12": "F12",
|
|
"print": "PrtSc", "insert": "Ins", "delete": "Del",
|
|
}
|
|
|
|
|
|
_MODIFIER_KEY_IDS = {
|
|
"shiftleft",
|
|
"shiftright",
|
|
"ctrlleft",
|
|
"altleft",
|
|
"altright",
|
|
"superleft",
|
|
"capslock",
|
|
"tab",
|
|
"backspace",
|
|
"space",
|
|
}
|
|
|
|
_KEYSYM_ALIAS: dict[str, str] = {
|
|
"return": "return",
|
|
"enter": "return",
|
|
"kp_enter": "return",
|
|
"escape": "escape",
|
|
"esc": "escape",
|
|
"backspace": "backspace",
|
|
"tab": "tab",
|
|
"space": "space",
|
|
"bracketleft": "bracketleft",
|
|
"bracketright": "bracketright",
|
|
"minus": "minus",
|
|
"equal": "equal",
|
|
"period": "period",
|
|
"comma": "comma",
|
|
"slash": "slash",
|
|
"backslash": "backslash",
|
|
"semicolon": "semicolon",
|
|
"apostrophe": "quote",
|
|
"quote": "quote",
|
|
"grave": "grave",
|
|
"up": "up",
|
|
"down": "down",
|
|
"left": "left",
|
|
"right": "right",
|
|
"page_up": "pageup",
|
|
"page_down": "pagedown",
|
|
"home": "home",
|
|
"end": "end",
|
|
"print": "print",
|
|
"sysrq": "print",
|
|
"delete": "delete",
|
|
"del": "delete",
|
|
"insert": "insert",
|
|
"ins": "insert",
|
|
"f1": "f1", "f2": "f2", "f3": "f3", "f4": "f4",
|
|
"f5": "f5", "f6": "f6", "f7": "f7", "f8": "f8",
|
|
"f9": "f9", "f10": "f10", "f11": "f11", "f12": "f12",
|
|
}
|
|
|
|
for _c in "abcdefghijklmnopqrstuvwxyz0123456789":
|
|
_KEYSYM_ALIAS[_c] = _c
|
|
|
|
|
|
def normalize_key_id(raw_key: str) -> str:
|
|
"""Convert a raw keysym (last part of Mod+Shift+X) to a keyboard layout id."""
|
|
k = raw_key.strip().lower()
|
|
return _KEYSYM_ALIAS.get(k, k)
|
|
|
|
|
|
def _rgb(r: int, g: int, b: int, a: float = 1.0):
|
|
return (r / 255, g / 255, b / 255, a)
|
|
|
|
|
|
# Unbound key
|
|
_COL_KEY_BG = _rgb(30, 30, 36) # dark charcoal fill
|
|
_COL_KEY_BORDER = _rgb(255, 255, 255, 0.07) # barely visible edge
|
|
_COL_KEY_FG = _rgb(200, 200, 210) # label colour
|
|
|
|
# Bound key
|
|
_COL_BOUND_BG = _rgb(45, 30, 80) # muted indigo fill
|
|
_COL_BOUND_BORDER = _rgb(100, 60, 160, 1.0) # soft purple border
|
|
_COL_BOUND_GLOW = _rgb(100, 60, 160, 0.20) # subtle outer glow
|
|
_COL_BOUND_MOD = _rgb(160, 140, 200) # muted MOD label tint
|
|
|
|
# Selected key
|
|
_COL_SEL_BG = _rgb(70, 40, 120)
|
|
_COL_SEL_BORDER = _rgb(140, 80, 200, 1.0)
|
|
_COL_SEL_GLOW = _rgb(140, 80, 200, 0.30)
|
|
|
|
# Search-match key
|
|
_COL_SEARCH_BG = _rgb(100, 50, 130)
|
|
_COL_SEARCH_BORDER = _rgb(160, 80, 180, 1.0)
|
|
_COL_SEARCH_GLOW = _rgb(160, 80, 180, 0.25)
|
|
|
|
# Badge pill
|
|
_COL_BADGE_BG = _rgb(80, 40, 140)
|
|
_COL_BADGE_FG = _rgb(255, 255, 255)
|
|
|
|
# Chassis
|
|
_COL_FRAME_BG = _rgb(10, 10, 12)
|
|
_COL_FRAME_BORDER = _rgb(255, 255, 255, 0.07)
|
|
|
|
|
|
class _AspectDrawingArea(Gtk.DrawingArea):
|
|
def __init__(self, ratio=2.43):
|
|
super().__init__()
|
|
self._ratio = ratio
|
|
self.set_hexpand(True)
|
|
|
|
def do_get_request_mode(self):
|
|
return Gtk.SizeRequestMode.HEIGHT_FOR_WIDTH
|
|
|
|
def do_measure(self, orientation, for_size):
|
|
if orientation == Gtk.Orientation.HORIZONTAL:
|
|
return (400, 560, -1, -1)
|
|
else:
|
|
if for_size > 0:
|
|
h = int(for_size / self._ratio)
|
|
return (h, h, -1, -1)
|
|
else:
|
|
h = int(560 / self._ratio)
|
|
return (h, h, -1, -1)
|
|
|
|
|
|
class KeyboardVisualizer(Gtk.Box):
|
|
"""Cairo-rendered ANSI QWERTY keyboard with niri binding overlays."""
|
|
|
|
__gsignals__ = {
|
|
"key-selected": (GObject.SignalFlags.RUN_FIRST, None, (str,)),
|
|
"edit-binding": (GObject.SignalFlags.RUN_FIRST, None, (object,)),
|
|
"add-binding": (GObject.SignalFlags.RUN_FIRST, None, (str,)),
|
|
"delete-binding": (GObject.SignalFlags.RUN_FIRST, None, (object,)),
|
|
}
|
|
|
|
def __init__(self):
|
|
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=12)
|
|
|
|
# State
|
|
self._layout_id: str = "us"
|
|
self._geometry_id: str = "ANSI"
|
|
self._bindings: dict[str, list[dict]] = {} # key_id → [bind_dict, ...]
|
|
self._selected_id: str | None = None
|
|
self._search_q: str = ""
|
|
self._dynamic_keysym_to_kid: dict[str, str] = {}
|
|
|
|
self._xkb = XkbHelper()
|
|
self._xkb.set_layout(self._layout_id)
|
|
|
|
self._key_rects: list[tuple[str, float, float, float, float]] = []
|
|
|
|
if not HAS_CAIRO:
|
|
err_lbl = Gtk.Label(
|
|
label="Cairo is not installed — the physical keyboard view is unavailable.\nInstall dev-python/pycairo and restart.",
|
|
justify=Gtk.Justification.CENTER,
|
|
)
|
|
err_lbl.add_css_class("dim-label")
|
|
err_lbl.set_vexpand(True)
|
|
self.append(err_lbl)
|
|
return
|
|
|
|
# Drawing area
|
|
self._area = _AspectDrawingArea(ratio=2.43)
|
|
self._area.set_draw_func(self._draw)
|
|
self.append(self._area)
|
|
|
|
click = Gtk.GestureClick()
|
|
click.connect("released", self._on_click)
|
|
self._area.add_controller(click)
|
|
|
|
if HAS_CAIRO:
|
|
self._panel = _ActionPanel(
|
|
on_edit=lambda b: self.emit("edit-binding", b),
|
|
on_add=lambda k: self.emit("add-binding", k),
|
|
on_delete=lambda b: self.emit("delete-binding", b),
|
|
)
|
|
self.append(self._panel)
|
|
|
|
# Legend
|
|
self.append(self._build_legend())
|
|
|
|
# Public API
|
|
|
|
def set_bindings(self, bindings: dict[str, list[dict]]) -> None:
|
|
"""Accept a key_id → [bind_dict] mapping and refresh."""
|
|
self._bindings = bindings
|
|
if hasattr(self, "_area"):
|
|
self._area.queue_draw()
|
|
if self._selected_id:
|
|
self._panel.update(
|
|
self._selected_id, self._bindings.get(self._selected_id, [])
|
|
)
|
|
|
|
def set_layout(self, layout_id: str) -> None:
|
|
"""Set the visualizer layout mapping (e.g. 'us', 'it')."""
|
|
self._layout_id = layout_id
|
|
self._xkb.set_layout(layout_id)
|
|
|
|
|
|
base_layout = layout_id.split(":")[0].lower()
|
|
iso_layouts = {'it', 'fr', 'de', 'es', 'pt', 'uk', 'ru', 'ch', 'be', 'no', 'se', 'fi', 'dk'}
|
|
if base_layout in iso_layouts:
|
|
self._geometry_id = "ISO"
|
|
else:
|
|
self._geometry_id = "ANSI"
|
|
|
|
self._dynamic_keysym_to_kid.clear()
|
|
for kid, keycode in _KID_TO_KEYCODE.items():
|
|
sym = self._xkb.get_keysym_name(keycode)
|
|
if sym:
|
|
self._dynamic_keysym_to_kid[sym.lower()] = kid
|
|
|
|
if hasattr(self, "_area"):
|
|
self._area.queue_draw()
|
|
|
|
def set_search(self, query: str) -> None:
|
|
self._search_q = query.strip().lower()
|
|
if hasattr(self, "_area"):
|
|
self._area.queue_draw()
|
|
|
|
|
|
# Internal helpers
|
|
|
|
def _on_click(self, gesture, n_press, x, y):
|
|
for kid, rx, ry, rw, rh in self._key_rects:
|
|
if rx <= x <= rx + rw and ry <= y <= ry + rh:
|
|
self._selected_id = kid
|
|
self._panel.update(kid, self._bindings.get(kid, []))
|
|
self._area.queue_draw()
|
|
self.emit("key-selected", kid)
|
|
return
|
|
|
|
def _matches_search(self, binds: list[dict]) -> bool:
|
|
if not self._search_q:
|
|
return False
|
|
q = self._search_q
|
|
for b in binds:
|
|
if q in b.get("action", "").lower():
|
|
return True
|
|
if q in b.get("keysym", "").lower():
|
|
return True
|
|
return False
|
|
|
|
def _draw(self, area, cr, width: int, height: int):
|
|
if width <= 0 or height <= 0:
|
|
return
|
|
self._key_rects = []
|
|
|
|
# Internal margins
|
|
pad_x, pad_y = 16, 12
|
|
chassis_r = 12.0
|
|
|
|
inner_w = width - 2 * pad_x
|
|
inner_h = height - 2 * pad_y
|
|
|
|
active_geom = KEYBOARD_GEOMETRIES.get(self._geometry_id) or KEYBOARD_GEOMETRIES["ANSI"]
|
|
n_rows = len(active_geom)
|
|
|
|
frow_ratio = 0.7
|
|
frow_gap = max(3.0, inner_h * 0.015)
|
|
row_h = (inner_h - frow_gap) / (frow_ratio + n_rows - 1)
|
|
frow_h = frow_ratio * row_h
|
|
|
|
key_gap = max(2.5, row_h * 0.07)
|
|
radius = max(4.0, row_h * 0.16)
|
|
total_units = max(sum(w for _, w in row) for row in active_geom)
|
|
|
|
# Keyboard chassis
|
|
self._rounded_rect(cr, 0, 0, width, height, chassis_r)
|
|
cr.set_source_rgba(*_COL_FRAME_BG)
|
|
cr.fill_preserve()
|
|
cr.set_source_rgba(*_COL_FRAME_BORDER)
|
|
cr.set_line_width(1.0)
|
|
cr.stroke()
|
|
|
|
for row_idx, row in enumerate(active_geom):
|
|
if row_idx == 0:
|
|
y = float(pad_y)
|
|
this_row_h = frow_h
|
|
else:
|
|
y = float(pad_y + frow_h + frow_gap + (row_idx - 1) * row_h)
|
|
this_row_h = row_h
|
|
x = float(pad_x)
|
|
|
|
for kid, units in row:
|
|
key_w = (units / total_units) * inner_w
|
|
|
|
if not kid:
|
|
x += key_w
|
|
continue
|
|
|
|
label = _STATIC_LABELS.get(kid)
|
|
if label is None:
|
|
keycode = _KID_TO_KEYCODE.get(kid)
|
|
if keycode:
|
|
label = self._xkb.get_label(keycode)
|
|
if label is None:
|
|
label = kid.upper() if len(kid) <= 1 else kid.capitalize()
|
|
else:
|
|
label = label.upper() if len(label) == 1 else label
|
|
|
|
kx = x + key_gap / 2
|
|
ky = y + key_gap / 2
|
|
kw = key_w - key_gap
|
|
kh = this_row_h - key_gap
|
|
|
|
binds = self._bindings.get(kid, [])
|
|
is_bound = bool(binds)
|
|
is_sel = self._selected_id == kid
|
|
is_search = is_bound and self._matches_search(binds)
|
|
|
|
if is_sel:
|
|
fill = _COL_SEL_BG
|
|
border = _COL_SEL_BORDER
|
|
glow = _COL_SEL_GLOW
|
|
elif is_search:
|
|
fill = _COL_SEARCH_BG
|
|
border = _COL_SEARCH_BORDER
|
|
glow = _COL_SEARCH_GLOW
|
|
elif is_bound:
|
|
fill = _COL_BOUND_BG
|
|
border = _COL_BOUND_BORDER
|
|
glow = _COL_BOUND_GLOW
|
|
else:
|
|
fill = _COL_KEY_BG
|
|
border = _COL_KEY_BORDER
|
|
glow = None
|
|
|
|
if glow:
|
|
for spread, alpha_scale in ((6, 0.15), (3, 0.25), (1, 0.35)):
|
|
cr.set_source_rgba(glow[0], glow[1], glow[2], glow[3] * alpha_scale)
|
|
self._rounded_rect(
|
|
cr,
|
|
kx - spread, ky - spread,
|
|
kw + spread * 2, kh + spread * 2,
|
|
radius + spread,
|
|
)
|
|
cr.fill()
|
|
|
|
# Key face fill
|
|
self._rounded_rect(cr, kx, ky, kw, kh, radius)
|
|
cr.set_source_rgba(*fill)
|
|
cr.fill_preserve()
|
|
|
|
# Key border
|
|
lw = 1.2 if (is_bound or is_sel) else 0.8
|
|
cr.set_source_rgba(*border)
|
|
cr.set_line_width(lw)
|
|
cr.stroke()
|
|
|
|
if is_bound:
|
|
first_mod = self._first_modifier(binds)
|
|
if first_mod:
|
|
mod_fs = max(4.5, kh * 0.14)
|
|
cr.select_font_face("Sans", 0, 1)
|
|
cr.set_font_size(mod_fs)
|
|
mx = int(kx + 6)
|
|
my = int(ky + mod_fs + 5)
|
|
cr.set_source_rgba(*_COL_BOUND_MOD)
|
|
cr.move_to(mx, my)
|
|
cr.show_text(first_mod[:3].upper())
|
|
|
|
fs = max(7.0, kh * 0.26)
|
|
cr.select_font_face("Sans", 0, 1)
|
|
cr.set_font_size(fs)
|
|
te = cr.text_extents(label)
|
|
tx = int(kx + (kw - te.width) / 2 - te.x_bearing)
|
|
ty = int(ky + (kh + te.height) / 2 - te.height / 2)
|
|
|
|
# Drop shadow
|
|
cr.set_source_rgba(0, 0, 0, 0.5)
|
|
cr.move_to(tx, ty + 1)
|
|
cr.show_text(label)
|
|
cr.set_source_rgba(1.0, 1.0, 1.0, 0.9)
|
|
cr.move_to(tx, ty)
|
|
cr.show_text(label)
|
|
|
|
if len(binds) > 1:
|
|
badge_txt = str(len(binds))
|
|
bfs = max(5.0, kh * 0.14)
|
|
cr.select_font_face("Sans", 0, 1)
|
|
cr.set_font_size(bfs)
|
|
bte = cr.text_extents(badge_txt)
|
|
bpad = 2.0
|
|
bw = bte.width + bpad * 2
|
|
bh_pill = bte.height + bpad * 2
|
|
bx = int(kx + kw - bw - 5)
|
|
by = int(ky + kh - bh_pill - 5)
|
|
cr.set_source_rgba(*_COL_BADGE_BG)
|
|
self._rounded_rect(cr, bx, by, bw, bh_pill, bh_pill / 2)
|
|
cr.fill()
|
|
cr.set_source_rgba(*_COL_BADGE_FG)
|
|
cr.move_to(int(bx + bpad - bte.x_bearing), int(by + bpad - bte.y_bearing))
|
|
cr.show_text(badge_txt)
|
|
|
|
self._key_rects.append((kid, kx, ky, kw, kh))
|
|
x += key_w
|
|
|
|
@staticmethod
|
|
def _rounded_rect(cr, x: float, y: float, w: float, h: float, r: float):
|
|
r = min(r, w / 2, h / 2)
|
|
cr.new_sub_path()
|
|
cr.arc(x + w - r, y + r, r, -math.pi / 2, 0)
|
|
cr.arc(x + w - r, y + h - r, r, 0, math.pi / 2)
|
|
cr.arc(x + r, y + h - r, r, math.pi / 2, math.pi)
|
|
cr.arc(x + r, y + r, r, math.pi, 3 * math.pi / 2)
|
|
cr.close_path()
|
|
|
|
@staticmethod
|
|
def _first_modifier(binds: list[dict]) -> str:
|
|
if not binds:
|
|
return ""
|
|
keysym = binds[0].get("keysym", "")
|
|
parts = keysym.split("+")
|
|
if len(parts) > 1:
|
|
m = parts[0].lower()
|
|
_mod_labels = {
|
|
"mod": "MOD",
|
|
"super": "SUP",
|
|
"ctrl": "CTL",
|
|
"control": "CTL",
|
|
"shift": "SHF",
|
|
"alt": "ALT",
|
|
"win": "WIN",
|
|
}
|
|
return _mod_labels.get(m, m[:4].upper())
|
|
return ""
|
|
|
|
@staticmethod
|
|
def _build_legend() -> Gtk.Box:
|
|
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=20)
|
|
box.set_halign(Gtk.Align.CENTER)
|
|
box.set_margin_top(2)
|
|
box.set_opacity(0.65)
|
|
|
|
def _chip(rgba_css: str, text: str) -> Gtk.Box:
|
|
hb = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
|
swatch = Gtk.Box()
|
|
swatch.set_size_request(12, 12)
|
|
swatch.add_css_class("nm-kb-swatch")
|
|
|
|
attrs = Gtk.CssProvider()
|
|
attrs.load_from_data(
|
|
f".nm-kb-swatch {{ background: {rgba_css}; border-radius: 3px; }}".encode()
|
|
)
|
|
swatch.get_style_context().add_provider(
|
|
attrs, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
|
|
)
|
|
lbl = Gtk.Label(label=text)
|
|
lbl.add_css_class("caption")
|
|
hb.append(swatch)
|
|
hb.append(lbl)
|
|
return hb
|
|
|
|
box.append(_chip("rgba(147, 51, 234, 0.7)", "Bound"))
|
|
box.append(_chip("rgba(192, 97, 203, 1.0)", "Search match"))
|
|
box.append(_chip("rgba(168, 85, 247, 1.0)", "Selected"))
|
|
box.append(_chip("rgba(24, 24, 27, 1.0)", "Unbound"))
|
|
return box
|
|
|
|
|
|
# Action overlay panel
|
|
|
|
|
|
class _ActionPanel(Gtk.Box):
|
|
"""Shows the binding details for the currently selected key."""
|
|
|
|
def __init__(self, on_edit=None, on_add=None, on_delete=None):
|
|
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
|
self._on_edit = on_edit
|
|
self._on_add = on_add
|
|
self._on_delete = on_delete
|
|
self._current_key_id = None
|
|
self.add_css_class("nm-kb-action-panel")
|
|
self.set_visible(False)
|
|
|
|
# Header row
|
|
header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
|
|
header.set_margin_start(14)
|
|
header.set_margin_end(14)
|
|
header.set_margin_top(10)
|
|
header.set_margin_bottom(6)
|
|
|
|
self._key_label = Gtk.Label(label="")
|
|
self._key_label.add_css_class("nm-kb-key-id-label")
|
|
self._key_label.set_xalign(0.0)
|
|
self._key_label.set_hexpand(True)
|
|
header.append(self._key_label)
|
|
|
|
self._count_label = Gtk.Label(label="")
|
|
self._count_label.add_css_class("dim-label")
|
|
self._count_label.add_css_class("caption")
|
|
header.append(self._count_label)
|
|
|
|
self._header_add_btn = Gtk.Button(icon_name="list-add-symbolic")
|
|
self._header_add_btn.add_css_class("flat")
|
|
self._header_add_btn.add_css_class("circular")
|
|
self._header_add_btn.set_tooltip_text("Add another binding for this key")
|
|
self._header_add_btn.set_valign(Gtk.Align.CENTER)
|
|
self._header_add_btn.set_visible(False)
|
|
self._header_add_btn.connect("clicked", self._on_header_add_clicked)
|
|
header.append(self._header_add_btn)
|
|
|
|
self.append(header)
|
|
|
|
self.append(Gtk.Separator())
|
|
|
|
|
|
self._grp_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
|
self._grp_container.set_margin_start(8)
|
|
self._grp_container.set_margin_end(8)
|
|
self._grp_container.set_margin_top(6)
|
|
self._grp_container.set_margin_bottom(8)
|
|
self.append(self._grp_container)
|
|
|
|
self.set_visible(False)
|
|
|
|
def _on_header_add_clicked(self, *_):
|
|
if self._on_add and self._current_key_id:
|
|
self._on_add(self._current_key_id)
|
|
|
|
def update(self, key_id: str, binds: list[dict]):
|
|
self._current_key_id = key_id
|
|
while True:
|
|
c = self._grp_container.get_first_child()
|
|
if c is None:
|
|
break
|
|
self._grp_container.remove(c)
|
|
|
|
new_grp = Adw.PreferencesGroup()
|
|
|
|
if not binds:
|
|
self._key_label.set_label(key_id.upper())
|
|
self._count_label.set_label("No bindings")
|
|
self._header_add_btn.set_visible(False)
|
|
|
|
add_btn = Gtk.Button(label=f"Create Binding for {key_id.upper()}")
|
|
add_btn.add_css_class("suggested-action")
|
|
add_btn.add_css_class("pill")
|
|
add_btn.set_halign(Gtk.Align.CENTER)
|
|
add_btn.set_margin_top(8)
|
|
add_btn.set_margin_bottom(8)
|
|
if self._on_add:
|
|
add_btn.connect("clicked", lambda *_: self._on_add(key_id))
|
|
|
|
new_grp.add(add_btn)
|
|
else:
|
|
self._key_label.set_label(key_id.upper())
|
|
n = len(binds)
|
|
self._count_label.set_label(f"{n} binding" + ("s" if n != 1 else ""))
|
|
self._header_add_btn.set_visible(True)
|
|
for b in binds:
|
|
keysym = b.get("keysym", "?")
|
|
action = b.get("action", "")
|
|
args = b.get("action_args") or []
|
|
arg_str = " ".join(str(a) for a in args)
|
|
full_action = f"{action} {arg_str}".strip() or "(no action)"
|
|
|
|
row = Adw.ActionRow(title=GLib.markup_escape_text(full_action))
|
|
|
|
keys_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
|
keys_box.set_valign(Gtk.Align.CENTER)
|
|
keys_box.set_margin_start(4)
|
|
keys_box.set_margin_end(16)
|
|
|
|
parts = keysym.split("+")
|
|
_labels = {
|
|
"mod": "Mod",
|
|
"super": "Super",
|
|
"ctrl": "Ctrl",
|
|
"control": "Ctrl",
|
|
"shift": "Shift",
|
|
"alt": "Alt",
|
|
"win": "Win",
|
|
}
|
|
|
|
for i, part in enumerate(parts):
|
|
label_text = part
|
|
is_mod = i < len(parts) - 1
|
|
if is_mod:
|
|
label_text = _labels.get(part.lower(), part)
|
|
else:
|
|
label_text = (
|
|
label_text.upper() if len(label_text) == 1 else label_text
|
|
)
|
|
|
|
cap = Gtk.Label(label=label_text)
|
|
if is_mod:
|
|
cap.add_css_class("nm-keycap-mod")
|
|
else:
|
|
cap.add_css_class("nm-keycap-main")
|
|
keys_box.append(cap)
|
|
|
|
row.add_prefix(keys_box)
|
|
|
|
if b.get("allow_when_locked"):
|
|
lock = Gtk.Label(label="🔒")
|
|
lock.set_tooltip_text("Allowed when screen is locked")
|
|
lock.set_valign(Gtk.Align.CENTER)
|
|
row.add_suffix(lock)
|
|
|
|
edit_btn = Gtk.Button(icon_name="document-edit-symbolic")
|
|
edit_btn.add_css_class("flat")
|
|
edit_btn.add_css_class("circular")
|
|
edit_btn.set_valign(Gtk.Align.CENTER)
|
|
if self._on_edit:
|
|
edit_btn.connect("clicked", lambda *_, bind_ref=b: self._on_edit(bind_ref))
|
|
row.add_suffix(edit_btn)
|
|
|
|
del_btn = Gtk.Button(icon_name="user-trash-symbolic")
|
|
del_btn.add_css_class("flat")
|
|
del_btn.add_css_class("circular")
|
|
del_btn.add_css_class("error")
|
|
del_btn.set_valign(Gtk.Align.CENTER)
|
|
if self._on_delete:
|
|
del_btn.connect("clicked", lambda *_, bind_ref=b: self._on_delete(bind_ref))
|
|
row.add_suffix(del_btn)
|
|
|
|
new_grp.add(row)
|
|
|
|
self._grp_container.append(new_grp)
|
|
self.set_visible(True)
|
|
|