"""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