Files
niritings/nirimod/widgets/keyboard_visualizer.py
2026-05-29 00:41:12 +00:00

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)