(Init): Added shit

This commit is contained in:
2026-05-29 00:41:12 +00:00
commit 72005fd71d
52 changed files with 12875 additions and 0 deletions

776
nirimod/pages/bindings.py Normal file
View File

@@ -0,0 +1,776 @@
"""Key Bindings page — list editor + keyboard map visualizer.
Tab 1: "Bindings List" — the original Adw row-based editor (unchanged logic).
Tab 2: "Keyboard Map" — Cairo keyboard visualizer ported from omer-biz/visu.
"""
from __future__ import annotations
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gio, GLib, Gtk
from nirimod.kdl_parser import KdlNode
from nirimod.pages.base import BasePage
from nirimod.widgets import KeyboardVisualizer, normalize_key_id
MODIFIERS = ["Mod", "Super", "Ctrl", "Alt", "Shift"]
NIRI_ACTIONS = [
"close-window",
"focus-column-left",
"focus-column-right",
"focus-column-first",
"focus-column-last",
"focus-window-down",
"focus-window-up",
"move-column-left",
"move-column-right",
"move-column-to-first",
"move-column-to-last",
"move-window-down",
"move-window-up",
"focus-workspace-down",
"focus-workspace-up",
"focus-workspace",
"move-column-to-workspace",
"move-column-to-workspace-down",
"move-column-to-workspace-up",
"move-workspace-down",
"move-workspace-up",
"focus-monitor-left",
"focus-monitor-right",
"focus-monitor-up",
"focus-monitor-down",
"move-column-to-monitor-left",
"move-column-to-monitor-right",
"move-column-to-monitor-down",
"move-column-to-monitor-up",
"maximize-column",
"fullscreen-window",
"maximize-window-to-edges",
"switch-preset-column-width",
"switch-preset-window-height",
"set-column-width",
"set-window-height",
"set-dynamic-cast-window",
"set-dynamic-cast-monitor",
"clear-dynamic-cast-target",
"reset-window-height",
"center-column",
"center-visible-columns",
"screenshot",
"screenshot-screen",
"screenshot-window",
"spawn",
"spawn-sh",
"quit",
"power-off-monitors",
"toggle-window-floating",
"switch-focus-between-floating-and-tiling",
"toggle-column-tabbed-display",
"toggle-overview",
"consume-or-expel-window-left",
"consume-or-expel-window-right",
"consume-window-into-column",
"expel-window-from-column",
"expand-column-to-available-width",
"show-hotkey-overlay",
"toggle-keyboard-shortcuts-inhibit",
"toggle-windowed-fullscreen",
]
_KNOWN_BIND_PROPS = {"allow-when-locked", "repeat"}
def _make_bind(
keysym: str,
action: str = "",
action_args: list | None = None,
allow_when_locked: bool = False,
repeat: bool = True,
extra_props: dict | None = None,
node: KdlNode | None = None,
) -> dict:
return {
"keysym": keysym,
"action": action,
"action_args": action_args or [],
"allow_when_locked": allow_when_locked,
"repeat": repeat,
"extra_props": extra_props or {},
"_node": node,
}
def _parse_binds_from_nodes(nodes: list[KdlNode]) -> list[dict]:
"""Parse all bind nodes from the binds block."""
binds_node = next((n for n in nodes if n.name == "binds"), None)
if not binds_node:
return []
result = []
for child in binds_node.children:
keysym = child.name
action = ""
action_args: list = []
allow_locked = child.props.get("allow-when-locked", False)
repeat = child.props.get("repeat", True)
extra_props = {
k: v for k, v in child.props.items() if k not in _KNOWN_BIND_PROPS
}
for sub in child.children:
action = sub.name
action_args = list(sub.args)
result.append(
_make_bind(
keysym,
action,
action_args,
allow_locked,
repeat,
extra_props,
node=child,
)
)
return result
def _write_binds_to_node(binds_list: list[dict], binds_node: KdlNode):
kept_nodes = {id(b.get("_node")) for b in binds_list if b.get("_node") is not None}
salvaged_trivia = ""
for orig_child in binds_node.children:
if id(orig_child) not in kept_nodes:
salvaged_trivia += orig_child.leading_trivia
new_children = []
for i, b in enumerate(binds_list):
child = b.get("_node")
if child is None:
child = KdlNode(name=b["keysym"])
child.leading_trivia = "\n "
else:
child.name = b["keysym"]
if i == 0 and salvaged_trivia:
child.leading_trivia = salvaged_trivia + child.leading_trivia
salvaged_trivia = ""
child.props.clear()
if b["allow_when_locked"]:
child.props["allow-when-locked"] = True
if not b["repeat"]:
child.props["repeat"] = False
for k, v in b.get("extra_props", {}).items():
child.props[k] = v
if b["action"]:
args = b.get("action_args") or []
if not args:
legacy = b.get("action_arg", "")
if legacy:
args = [legacy]
if child.children:
action_node = child.children[0]
action_node.name = b["action"]
action_node.args = list(args)
child.children = [action_node]
else:
action_node = KdlNode(name=b["action"])
action_node.args = list(args)
action_node.leading_trivia = " "
child.children.append(action_node)
else:
child.children.clear()
new_children.append(child)
if salvaged_trivia:
binds_node.children_trailing_trivia = salvaged_trivia + binds_node.children_trailing_trivia
binds_node.children = new_children
def _build_key_bindings_map(binds: list[dict], viz=None) -> dict[str, list[dict]]:
result: dict[str, list[dict]] = {}
for b in binds:
keysym = b.get("keysym", "")
raw_key = keysym.split("+")[-1].lower()
kid = None
if viz and viz._dynamic_keysym_to_kid:
kid = viz._dynamic_keysym_to_kid.get(raw_key)
if not kid:
kid = normalize_key_id(raw_key)
result.setdefault(kid, []).append(b)
return result
# BindingsPage
class BindingsPage(BasePage):
def __init__(self, window):
super().__init__(window)
self._binds: list[dict] = []
self._search_query = ""
self._kb_search_query = ""
self._file_monitor: Gio.FileMonitor | None = None
self._viz: KeyboardVisualizer | None = None
def build(self) -> Gtk.Widget:
tb = Adw.ToolbarView()
# Custom Header
header_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
header_box.set_margin_start(24)
header_box.set_margin_end(24)
header_box.set_margin_top(20)
header_box.set_margin_bottom(12)
# Title/Subtitle Group
title_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
title_vbox.set_hexpand(True)
self._main_title = Gtk.Label(label="Keybindings")
self._main_title.set_xalign(0.0)
self._main_title.add_css_class("title-1")
title_vbox.append(self._main_title)
self._kb_stats_header = Gtk.Label(label="Detecting bindings...")
self._kb_stats_header.set_xalign(0.0)
self._kb_stats_header.add_css_class("dim-label")
self._kb_stats_header.add_css_class("caption")
title_vbox.append(self._kb_stats_header)
header_box.append(title_vbox)
# Layout Selector (shown only on Keyboard tab)
from nirimod import app_settings
from nirimod.xkb_helper import XkbHelper
self._layouts = XkbHelper.get_available_layouts()
layout_names = [d for _, d in self._layouts]
self._layout_model = Gtk.StringList.new(layout_names)
self._layout_combo = Gtk.DropDown(model=self._layout_model)
self._layout_combo.set_valign(Gtk.Align.CENTER)
self._layout_combo.set_enable_search(True)
# Priority: Settings > Niri Config > US
saved_layout = app_settings.get("kb_layout")
if not saved_layout:
saved_layout = self._get_current_niri_layout() or "us"
selected_idx = 0
for i, (lid, _) in enumerate(self._layouts):
if lid == saved_layout:
selected_idx = i
break
self._layout_combo.set_selected(selected_idx)
self._layout_combo.connect("notify::selected", self._on_layout_changed)
header_box.append(self._layout_combo)
# Add Button (hidden by default, shown on List tab)
self._add_btn = Gtk.Button(icon_name="list-add-symbolic")
self._add_btn.set_tooltip_text("Add binding")
self._add_btn.add_css_class("flat")
self._add_btn.add_css_class("circular")
self._add_btn.set_valign(Gtk.Align.CENTER)
self._add_btn.set_visible(False)
self._add_btn.connect("clicked", self._on_add_clicked)
header_box.append(self._add_btn)
# View Switcher (Styled as Physical/List View buttons)
self._view_stack = Adw.ViewStack()
switcher_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
switcher_box.add_css_class("linked")
switcher_box.set_valign(Gtk.Align.CENTER)
self._btn_physical = Gtk.ToggleButton(label="Physical")
self._btn_list = Gtk.ToggleButton(label="List View")
self._btn_list.set_group(self._btn_physical)
self._btn_physical.connect("toggled", self._on_view_toggle)
self._btn_list.connect("toggled", self._on_view_toggle)
switcher_box.append(self._btn_physical)
switcher_box.append(self._btn_list)
header_box.append(switcher_box)
self._view_stack.set_vexpand(True)
list_page_widget = self._build_list_tab()
self._view_stack.add_named(list_page_widget, "list")
kb_page_widget = self._build_keyboard_tab()
self._view_stack.add_named(kb_page_widget, "keyboard")
# Default to keyboard (Physical)
self._view_stack.set_visible_child_name("keyboard")
self._btn_physical.set_active(True)
main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
main_box.append(header_box)
main_box.append(self._view_stack)
tb.set_content(main_box)
self.refresh()
self._start_file_monitor()
return tb
def _get_current_niri_layout(self):
try:
from nirimod import kdl_parser
nodes = kdl_parser.load_niri_config()
for node in nodes:
if node.name == "input":
kb = node.get_child("keyboard")
if kb:
xkb = kb.get_child("xkb")
if xkb:
layout = xkb.child_arg("layout")
v = xkb.child_arg("variant")
if layout:
return f"{layout}:{v}" if v else layout
except Exception:
pass
return None
def _on_layout_changed(self, dropdown, param):
from nirimod import app_settings
idx = dropdown.get_selected()
if idx < len(self._layouts):
layout_id = self._layouts[idx][0]
app_settings.set("kb_layout", layout_id)
if self._viz:
self._viz.set_layout(layout_id)
def _on_view_toggle(self, btn):
if not btn.get_active():
return
is_list = btn == self._btn_list
self._view_stack.set_visible_child_name("list" if is_list else "keyboard")
self._add_btn.set_visible(is_list)
if hasattr(self, "_layout_combo"):
self._layout_combo.set_visible(not is_list)
def _build_list_tab(self) -> Gtk.Widget:
"""Return the scrollable list editor widget (original UI)."""
scroll = Gtk.ScrolledWindow()
scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
scroll.set_vexpand(True)
content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=20)
content.set_margin_start(32)
content.set_margin_end(32)
content.set_margin_top(24)
content.set_margin_bottom(32)
scroll.set_child(content)
# Search
# Search
search = Gtk.SearchEntry(placeholder_text="Filter bindings…")
search.set_margin_start(0)
search.set_margin_end(0)
search.connect("search-changed", self._on_filter_changed)
content.append(search)
# Binds Grid
self._flowbox = Gtk.FlowBox()
self._flowbox.set_valign(Gtk.Align.START)
self._flowbox.set_max_children_per_line(3)
self._flowbox.set_min_children_per_line(1)
self._flowbox.set_selection_mode(Gtk.SelectionMode.NONE)
self._flowbox.set_column_spacing(16)
self._flowbox.set_row_spacing(16)
self._flowbox.set_homogeneous(True)
content.append(self._flowbox)
return scroll
def _build_keyboard_tab(self) -> Gtk.Widget:
scroll = Gtk.ScrolledWindow()
scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
scroll.set_vexpand(True)
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16)
outer.set_margin_start(24)
outer.set_margin_end(24)
outer.set_margin_top(20)
outer.set_margin_bottom(24)
scroll.set_child(outer)
# Search bar
kb_search = Gtk.SearchEntry(
placeholder_text="Filter by action… (e.g. spawn, focus)"
)
kb_search.connect("search-changed", self._on_kb_search_changed)
outer.append(kb_search)
# Search bar
self._kb_stats = Gtk.Label(label="")
self._kb_stats.set_visible(False)
self._viz = KeyboardVisualizer()
idx = self._layout_combo.get_selected()
if 0 <= idx < len(self._layouts):
self._viz.set_layout(self._layouts[idx][0])
self._viz.connect("key-selected", self._on_kb_key_selected)
self._viz.connect("edit-binding", self._on_kb_edit_binding)
self._viz.connect("add-binding", self._on_kb_add_binding)
self._viz.connect("delete-binding", self._on_kb_delete_binding)
outer.append(self._viz)
return scroll
# Tab switching
# Refresh / sync
def refresh(self):
self._binds = _parse_binds_from_nodes(self._nodes)
self._rebuild_list()
self._refresh_visualizer()
def on_shown(self):
self._refresh_visualizer()
def _refresh_visualizer(self):
if self._viz is None:
return
from nirimod import app_settings
layout_id = app_settings.get("kb_layout")
if not layout_id:
layout_id = self._get_current_niri_layout() or "us"
self._viz.set_layout(layout_id)
binds_map = _build_key_bindings_map(self._binds, self._viz)
self._viz.set_bindings(binds_map)
self._viz.set_search(self._kb_search_query)
n_total = len(self._binds)
self._kb_stats_header.set_label(
f"{n_total} active bindings detected"
)
# List editor helpers (unchanged from original)
def _rebuild_list(self):
if not hasattr(self, "_flowbox"):
return
# Clear existing children
while True:
child = self._flowbox.get_first_child()
if child is None:
break
self._flowbox.remove(child)
q = self._search_query.lower()
visible_count = 0
for i, b in enumerate(self._binds):
if q and q not in b["keysym"].lower() and q not in b["action"].lower():
continue
card = self._make_bind_card(b, i)
self._flowbox.append(card)
visible_count += 1
def _make_bind_card(self, b: dict, idx: int) -> Gtk.Widget:
keysym = b["keysym"]
action = b["action"]
action_args = b.get("action_args") or []
action_arg_display = " ".join(str(a) for a in action_args)
full_action = f"{action} {action_arg_display}".strip()
if not full_action:
full_action = "(unassigned)"
# Card container
card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
card.set_size_request(240, 140)
card.add_css_class("nm-binding-card")
# 1. Keycaps Row
keys_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
parts = keysym.split("+")
_labels = {
"mod": "Mod",
"super": "Super",
"ctrl": "Ctrl",
"control": "Ctrl",
"shift": "Shift",
"alt": "Alt",
}
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)
cap.add_css_class("nm-keycap-purple")
keys_box.append(cap)
if i < len(parts) - 1:
plus = Gtk.Label(label="+")
plus.add_css_class("dim-label")
keys_box.append(plus)
card.append(keys_box)
# 2. "ACTIONS" Label
actions_header = Gtk.Label(label="ACTIONS")
actions_header.set_xalign(0.0)
actions_header.add_css_class("nm-binding-actions-label")
actions_header.set_margin_top(12)
card.append(actions_header)
# 3. Action Name
action_lbl = Gtk.Label(label=full_action)
action_lbl.set_xalign(0.0)
action_lbl.set_ellipsize(3)
action_lbl.add_css_class("nm-binding-action-name")
card.append(action_lbl)
# Spacer to push action buttons to the bottom
spacer = Gtk.Box()
spacer.set_vexpand(True)
card.append(spacer)
bottom_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
bottom_row.set_halign(Gtk.Align.END)
if b.get("allow_when_locked"):
lock = Gtk.Label(label="🔒")
lock.set_opacity(0.6)
bottom_row.append(lock)
edit_btn = Gtk.Button(icon_name="document-edit-symbolic")
edit_btn.add_css_class("flat")
edit_btn.add_css_class("circular")
edit_btn.connect("clicked", lambda *_, i=idx: self._on_edit_clicked(i))
bottom_row.append(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.connect("clicked", lambda *_, i=idx: self._on_delete_clicked(i))
bottom_row.append(del_btn)
card.append(bottom_row)
return card
def _on_filter_changed(self, entry):
self._search_query = entry.get_text().strip()
self._rebuild_list()
def _on_kb_search_changed(self, entry):
self._kb_search_query = entry.get_text().strip()
if self._viz:
self._viz.set_search(self._kb_search_query)
def _on_kb_key_selected(self, viz, key_id: str):
pass
def _on_kb_edit_binding(self, viz, bind_dict):
try:
idx = self._binds.index(bind_dict)
self._show_bind_dialog(bind_dict, idx)
except ValueError:
pass
def _on_kb_delete_binding(self, viz, bind_dict):
try:
idx = self._binds.index(bind_dict)
self._on_delete_clicked(idx)
except ValueError:
pass
def _on_kb_add_binding(self, viz, key_id: str):
if len(key_id) == 1:
display_key = key_id.upper()
else:
display_key = key_id.capitalize()
new_bind = {
"keysym": f"Mod+{display_key}",
"action": "",
"action_args": [],
"allow_when_locked": False,
"repeat": True,
"extra_props": {}
}
self._show_bind_dialog(new_bind, -1)
def _on_delete_clicked(self, idx: int):
if 0 <= idx < len(self._binds):
del self._binds[idx]
self._save_binds()
self._rebuild_list()
self._refresh_visualizer()
def _on_add_clicked(self, *_):
self._show_bind_dialog(None, -1)
def _on_edit_clicked(self, idx: int):
if 0 <= idx < len(self._binds):
self._show_bind_dialog(self._binds[idx], idx)
def _show_bind_dialog(self, bind: dict | None, idx: int):
dialog = Adw.Dialog(title="Edit Binding" if bind else "Add Binding")
dialog.set_content_width(440)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
header = Adw.HeaderBar()
header.set_title_widget(Adw.WindowTitle(title=dialog.get_title()))
box.append(header)
prefs = Adw.PreferencesPage()
prefs.set_vexpand(True)
# Keysym group
keys_grp = Adw.PreferencesGroup(title="Key Combination")
mod_row = Adw.ActionRow(title="Modifiers")
mod_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
mod_box.set_valign(Gtk.Align.CENTER)
mod_checks: dict[str, Gtk.CheckButton] = {}
cur_keysym = bind["keysym"] if bind else ""
keysym_parts_lower = [p.lower() for p in cur_keysym.split("+")[:-1]]
for mod in MODIFIERS:
cb = Gtk.CheckButton(label=mod)
cb.set_active(mod.lower() in keysym_parts_lower)
mod_box.append(cb)
mod_checks[mod] = cb
mod_row.add_suffix(mod_box)
keys_grp.add(mod_row)
key_entry = Adw.EntryRow(title="Key (e.g. T, F1, Return)")
bare = cur_keysym.split("+")[-1] if bind else ""
key_entry.set_text(bare)
keys_grp.add(key_entry)
prefs.add(keys_grp)
# Action group
act_grp = Adw.PreferencesGroup(title="Action")
act_model = Gtk.StringList.new(NIRI_ACTIONS)
act_combo = Adw.ComboRow(title="Action", model=act_model)
cur_action = bind["action"] if bind else ""
if cur_action in NIRI_ACTIONS:
act_combo.set_selected(NIRI_ACTIONS.index(cur_action))
act_grp.add(act_combo)
arg_row = Adw.EntryRow(title="Argument (for spawn, focus-workspace, etc.)")
cur_args = (bind.get("action_args") or []) if bind else []
arg_row.set_text(" ".join(str(a) for a in cur_args) if cur_args else "")
act_grp.add(arg_row)
prefs.add(act_grp)
# Options
opt_grp = Adw.PreferencesGroup(title="Options")
locked_row = Adw.SwitchRow(title="Allow When Locked")
locked_row.set_active(bind["allow_when_locked"] if bind else False)
opt_grp.add(locked_row)
repeat_row = Adw.SwitchRow(title="Repeat")
repeat_row.set_active(bind["repeat"] if bind else True)
opt_grp.add(repeat_row)
prefs.add(opt_grp)
box.append(prefs)
save_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
save_row.set_halign(Gtk.Align.END)
save_row.set_margin_start(16)
save_row.set_margin_end(16)
save_row.set_margin_top(8)
save_row.set_margin_bottom(16)
save_btn = Gtk.Button(label="Save")
save_btn.add_css_class("suggested-action")
save_btn.add_css_class("pill")
def _do_save(*_):
mods = [m for m, cb in mod_checks.items() if cb.get_active()]
key = key_entry.get_text().strip()
if not key:
return
keysym = "+".join(mods + [key])
action_idx = act_combo.get_selected()
action = NIRI_ACTIONS[action_idx] if action_idx < len(NIRI_ACTIONS) else ""
arg_text = arg_row.get_text().strip()
if action == "spawn-sh":
new_args = [arg_text] if arg_text else []
else:
import shlex
try:
new_args = shlex.split(arg_text) if arg_text else []
except ValueError:
new_args = arg_text.split() if arg_text else []
new_bind = _make_bind(
keysym,
action,
new_args,
locked_row.get_active(),
repeat_row.get_active(),
bind.get("extra_props", {}) if bind else {},
node=bind.get("_node") if bind else None,
)
if idx >= 0:
self._binds[idx] = new_bind
else:
self._binds.append(new_bind)
self._save_binds()
self._rebuild_list()
self._refresh_visualizer()
dialog.close()
save_btn.connect("clicked", _do_save)
save_row.append(save_btn)
box.append(save_row)
dialog.set_child(box)
dialog.present(self._win)
def _save_binds(self):
nodes = self._nodes
binds_node = next((n for n in nodes if n.name == "binds"), None)
if binds_node is None:
binds_node = KdlNode("binds")
nodes.append(binds_node)
_write_binds_to_node(self._binds, binds_node)
self._commit("keybindings")
# File monitor (live-sync)
def _start_file_monitor(self):
try:
from nirimod.kdl_parser import NIRI_CONFIG
gfile = Gio.File.new_for_path(str(NIRI_CONFIG))
monitor = gfile.monitor_file(Gio.FileMonitorFlags.NONE, None)
monitor.connect("changed", self._on_config_file_changed)
self._file_monitor = monitor
except Exception:
pass
def _on_config_file_changed(self, monitor, file, other_file, event_type):
if event_type in (Gio.FileMonitorEvent.CHANGED, Gio.FileMonitorEvent.CREATED):
GLib.timeout_add(400, self._reload_from_disk)
def _reload_from_disk(self):
self._win.notify_nodes_changed()
return False # don't repeat