from __future__ import annotations import gi gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") from gi.repository import Adw, Gtk from nirimod import niri_ipc from nirimod.kdl_parser import ( KdlNode, find_or_create, set_child_arg, set_node_flag, safe_switch_connect, ) from nirimod.pages.base import BasePage ACCEL_PROFILES = ["default", "flat", "adaptive"] SCROLL_METHODS_TP = ["two-finger", "edge", "on-button-down", "no-scroll"] CLICK_METHODS = ["button-areas", "clickfinger"] class InputPage(BasePage): def build(self) -> Gtk.Widget: tb, _, _, content = self._make_toolbar_page("Input") self._content = content self._build_content() return tb def _build_content(self): content = self._content nodes = self._nodes kb_expander = Adw.ExpanderRow(title="Keyboard", subtitle="XKB options & key repeat") kb_expander.add_css_class("nm-expander") kb_node = find_or_create(nodes, "input", "keyboard") xkb_node = kb_node.get_child("xkb") or KdlNode("xkb") fields = [ ("layout", "Layout", "e.g. us,ru"), ("variant", "Variant", "e.g. dvorak"), ("model", "Model", ""), ("options", "Options", "e.g. grp:win_space_toggle"), ("rules", "Rules", ""), ] self._xkb_entries: dict[str, Adw.EntryRow] = {} for key, title, ph in fields: row = Adw.EntryRow(title=title) row.set_show_apply_button(True) val = xkb_node.child_arg(key) if xkb_node else None if val: row.set_text(str(val)) row.set_input_purpose(Gtk.InputPurpose.FREE_FORM) row.connect("apply", lambda r, k=key: self._set_xkb(k, r.get_text())) kb_expander.add_row(row) self._xkb_entries[key] = row delay_adj = Gtk.Adjustment( value=kb_node.child_arg("repeat-delay") or 600, lower=100, upper=3000, step_increment=50, ) delay_row = Adw.SpinRow(title="Repeat Delay (ms)", adjustment=delay_adj, digits=0) delay_row.connect("notify::value", lambda r, _: self._set_kb("repeat-delay", int(r.get_value()))) kb_expander.add_row(delay_row) rate_adj = Gtk.Adjustment( value=kb_node.child_arg("repeat-rate") or 25, lower=1, upper=200, step_increment=1, ) rate_row = Adw.SpinRow(title="Repeat Rate (keys/sec)", adjustment=rate_adj, digits=0) rate_row.connect("notify::value", lambda r, _: self._set_kb("repeat-rate", int(r.get_value()))) kb_expander.add_row(rate_row) numlock_row = Adw.SwitchRow(title="Enable Num Lock on Startup") nl_init = kb_node.get_child("numlock") is not None numlock_row.set_active(nl_init) safe_switch_connect(numlock_row, nl_init, self._toggle_numlock) kb_expander.add_row(numlock_row) kb_grp = Adw.PreferencesGroup() kb_grp.add(kb_expander) content.append(kb_grp) # focus / pointer focus_grp = Adw.PreferencesGroup(title="Pointer Behavior") input_node = find_or_create(nodes, "input") ffm_row = Adw.SwitchRow(title="Focus Follows Mouse") ffm_node = input_node.get_child("focus-follows-mouse") ffm_row._last_active = ffm_node is not None ffm_row.set_active(ffm_node is not None) def _on_ffm_toggled(r, _): new_val = r.get_active() if new_val != getattr(r, "_last_active", None): r._last_active = new_val self._toggle_ffm(new_val) ffm_row.connect("notify::active", _on_ffm_toggled) focus_grp.add(ffm_row) scroll_val = 33 if ffm_node: vRaw = ffm_node.props.get("max-scroll-amount") if vRaw is not None: try: scroll_val = int(float(str(vRaw).replace("%", "").strip())) except ValueError: pass self._last_scroll_val = scroll_val scroll_adj = Gtk.Adjustment(value=scroll_val, lower=0, upper=100, step_increment=1) scroll_pct_row = Adw.SpinRow( title="Max Scroll Amount (%)", subtitle="0% = only fully visible windows", adjustment=scroll_adj, digits=0, ) scroll_pct_row.set_sensitive(ffm_node is not None) self._scroll_pct_row = scroll_pct_row scroll_pct_row._last_val = scroll_val def _on_scroll_pct_changed(r, _): new_val = int(r.get_value()) if new_val != getattr(r, "_last_val", None): r._last_val = new_val self._set_ffm_scroll(new_val) scroll_pct_row.connect("notify::value", _on_scroll_pct_changed) focus_grp.add(scroll_pct_row) warp_init = input_node.get_child("warp-mouse-to-focus") is not None warp_row = Adw.SwitchRow(title="Warp Mouse to Focus") warp_row.set_active(warp_init) safe_switch_connect(warp_row, warp_init, lambda enabled: self._toggle_input_flag("warp-mouse-to-focus", enabled)) focus_grp.add(warp_row) content.append(focus_grp) # touchpad tp_expander = Adw.ExpanderRow(title="Touchpad") tp_expander.add_css_class("nm-expander") has_tp = niri_ipc.has_touchpad() if not has_tp: tp_expander.set_subtitle("No touchpad detected") tp_expander.set_sensitive(False) tp_node = find_or_create(nodes, "input", "touchpad") def tp_switch(key, label, subtitle=""): r = Adw.SwitchRow(title=label, subtitle=subtitle) ini = tp_node.get_child(key) is not None r.set_active(ini) safe_switch_connect(r, ini, lambda enabled, k=key: self._set_tp_flag(k, enabled)) return r def tp_bool_switch(key, label, default_active=True, subtitle=""): r = Adw.SwitchRow(title=label, subtitle=subtitle) node = tp_node.get_child(key) if node is not None and node.args: ini = bool(node.args[0]) else: ini = default_active r.set_active(ini) safe_switch_connect(r, ini, lambda enabled, k=key: self._set_tp(k, enabled)) return r tp_expander.add_row(tp_switch("tap", "Tap to Click")) tp_expander.add_row(tp_switch("dwt", "Disable While Typing")) tp_expander.add_row(tp_switch("dwtp", "Disable While Trackpointing")) tp_expander.add_row(tp_switch("natural-scroll", "Natural Scroll")) tp_expander.add_row(tp_bool_switch("drag", "Tap Drag")) tp_expander.add_row(tp_switch("drag-lock", "Tap Drag Lock")) tp_expander.add_row(tp_switch("disabled-on-external-mouse", "Disable on External Mouse")) spd_adj = Gtk.Adjustment(value=float(tp_node.child_arg("accel-speed") or 0.0), lower=-1.0, upper=1.0, step_increment=0.05) spd_row = Adw.SpinRow(title="Accel Speed", adjustment=spd_adj, digits=2) spd_row.connect("notify::value", lambda r, _: self._set_tp("accel-speed", r.get_value())) tp_expander.add_row(spd_row) ap_model = Gtk.StringList.new(ACCEL_PROFILES) ap_row = Adw.ComboRow(title="Accel Profile", model=ap_model) cur_ap = tp_node.child_arg("accel-profile") or "default" if cur_ap in ACCEL_PROFILES: ap_row.set_selected(ACCEL_PROFILES.index(cur_ap)) ap_row.connect("notify::selected", lambda r, _: self._set_tp("accel-profile", ACCEL_PROFILES[r.get_selected()])) tp_expander.add_row(ap_row) sm_model = Gtk.StringList.new(SCROLL_METHODS_TP) sm_row = Adw.ComboRow(title="Scroll Method", model=sm_model) cur_sm = tp_node.child_arg("scroll-method") or "two-finger" if cur_sm in SCROLL_METHODS_TP: sm_row.set_selected(SCROLL_METHODS_TP.index(cur_sm)) sm_row.connect("notify::selected", lambda r, _: self._set_tp("scroll-method", SCROLL_METHODS_TP[r.get_selected()])) tp_expander.add_row(sm_row) cm_model = Gtk.StringList.new(CLICK_METHODS) cm_row = Adw.ComboRow(title="Click Method", model=cm_model) cur_cm = tp_node.child_arg("click-method") or "button-areas" if cur_cm in CLICK_METHODS: cm_row.set_selected(CLICK_METHODS.index(cur_cm)) cm_row.connect("notify::selected", lambda r, _: self._set_tp("click-method", CLICK_METHODS[r.get_selected()])) tp_expander.add_row(cm_row) tp_grp = Adw.PreferencesGroup() tp_grp.add(tp_expander) content.append(tp_grp) # mouse m_expander = Adw.ExpanderRow(title="Mouse") m_expander.add_css_class("nm-expander") m_node = find_or_create(nodes, "input", "mouse") m_nat = Adw.SwitchRow(title="Natural Scroll") mn_init = m_node.get_child("natural-scroll") is not None m_nat.set_active(mn_init) safe_switch_connect(m_nat, mn_init, lambda enabled: self._set_m_flag("natural-scroll", enabled)) m_expander.add_row(m_nat) m_spd_adj = Gtk.Adjustment(value=float(m_node.child_arg("accel-speed") or 0.0), lower=-1.0, upper=1.0, step_increment=0.05) m_spd_row = Adw.SpinRow(title="Accel Speed", adjustment=m_spd_adj, digits=2) m_spd_row.connect("notify::value", lambda r, _: self._set_m("accel-speed", r.get_value())) m_expander.add_row(m_spd_row) m_ap_model = Gtk.StringList.new(ACCEL_PROFILES) m_ap_row = Adw.ComboRow(title="Accel Profile", model=m_ap_model) cur_m_ap = m_node.child_arg("accel-profile") or "default" if cur_m_ap in ACCEL_PROFILES: m_ap_row.set_selected(ACCEL_PROFILES.index(cur_m_ap)) m_ap_row.connect("notify::selected", lambda r, _: self._set_m("accel-profile", ACCEL_PROFILES[r.get_selected()])) m_expander.add_row(m_ap_row) m_grp = Adw.PreferencesGroup() m_grp.add(m_expander) content.append(m_grp) # cursor cursor_grp = Adw.PreferencesGroup(title="Cursor") cursor_node = next((n for n in nodes if n.name == "cursor"), None) size_val = int(cursor_node.child_arg("xcursor-size") or 24) if cursor_node else 24 size_adj = Gtk.Adjustment(value=size_val, lower=8, upper=256, step_increment=2) size_row = Adw.SpinRow(title="Cursor Size (px)", adjustment=size_adj, digits=0) size_row.connect("notify::value", lambda r, _: self._set_cursor("xcursor-size", int(r.get_value()))) cursor_grp.add(size_row) hide_val = int(cursor_node.child_arg("hide-after-inactive-ms") or 0) if cursor_node else 0 hide_adj = Gtk.Adjustment(value=hide_val, lower=0, upper=60000, step_increment=500) hide_row = Adw.SpinRow(title="Hide After Inactive (ms)", subtitle="0 = never hide", adjustment=hide_adj, digits=0) hide_row.connect("notify::value", lambda r, _: self._set_cursor("hide-after-inactive-ms", int(r.get_value()))) cursor_grp.add(hide_row) theme_val = str(cursor_node.child_arg("xcursor-theme") or "") if cursor_node else "" theme_row = Adw.EntryRow(title="Cursor Theme (e.g. Adwaita)") theme_row.set_text(theme_val) theme_row.set_show_apply_button(True) theme_row.connect("apply", lambda r: self._set_cursor_theme(r.get_text())) cursor_grp.add(theme_row) content.append(cursor_grp) def _get_kb_node(self): return find_or_create(self._nodes, "input", "keyboard") def _get_xkb_node(self): kb = self._get_kb_node() xkb = kb.get_child("xkb") if xkb is None: xkb = KdlNode("xkb") kb.children.insert(0, xkb) return xkb def _set_xkb(self, key: str, value: str): xkb = self._get_xkb_node() if value.strip(): set_child_arg(xkb, key, value.strip()) else: from nirimod.kdl_parser import remove_child remove_child(xkb, key) self._commit(f"keyboard xkb {key}") def _set_kb(self, key: str, value): set_child_arg(self._get_kb_node(), key, value) self._commit(f"keyboard {key}") def _toggle_numlock(self, enabled: bool): set_node_flag(self._get_kb_node(), "numlock", enabled) self._commit("keyboard numlock") def _get_input_node(self): return find_or_create(self._nodes, "input") def _toggle_ffm(self, enabled: bool): inp = self._get_input_node() existing = inp.get_child("focus-follows-mouse") if enabled: if existing is None: new_ffm = KdlNode(name="focus-follows-mouse") if hasattr(self, "_last_scroll_val"): new_ffm.props["max-scroll-amount"] = f"{self._last_scroll_val}%" inp.children.insert(0, new_ffm) else: if existing is not None: inp.children.remove(existing) if hasattr(self, "_scroll_pct_row"): self._scroll_pct_row.set_sensitive(enabled) self._commit("focus-follows-mouse") def _set_ffm_scroll(self, pct: int): inp = self._get_input_node() ffm = inp.get_child("focus-follows-mouse") if ffm is None: ffm = KdlNode("focus-follows-mouse") inp.children.append(ffm) ffm.props["max-scroll-amount"] = f"{pct}%" self._commit("ffm scroll amount") def _toggle_input_flag(self, key: str, enabled: bool): set_node_flag(self._get_input_node(), key, enabled) self._commit(f"input {key}") def _get_tp_node(self): return find_or_create(self._nodes, "input", "touchpad") def _set_tp_flag(self, key: str, enabled: bool): set_node_flag(self._get_tp_node(), key, enabled) self._commit(f"touchpad {key}") def _set_tp(self, key: str, value): set_child_arg(self._get_tp_node(), key, value) self._commit(f"touchpad {key}") def _get_m_node(self): return find_or_create(self._nodes, "input", "mouse") def _set_m_flag(self, key: str, enabled: bool): set_node_flag(self._get_m_node(), key, enabled) self._commit(f"mouse {key}") def _set_m(self, key: str, value): set_child_arg(self._get_m_node(), key, value) self._commit(f"mouse {key}") def _get_cursor_node(self): existing = next((n for n in self._nodes if n.name == "cursor"), None) if existing is None: existing = KdlNode("cursor") self._nodes.append(existing) return existing def _set_cursor(self, key: str, value): set_child_arg(self._get_cursor_node(), key, value) self._commit(f"cursor {key}") def _set_cursor_theme(self, theme: str): cur = self._get_cursor_node() if theme.strip(): set_child_arg(cur, "xcursor-theme", theme.strip()) else: from nirimod.kdl_parser import remove_child remove_child(cur, "xcursor-theme") self._commit("cursor xcursor-theme") def refresh(self): child = self._content.get_first_child() while child: next_child = child.get_next_sibling() self._content.remove(child) child = next_child self._build_content()