(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

1
nirimod/__init__.py Normal file
View File

@@ -0,0 +1 @@
# nirimod

80
nirimod/__main__.py Normal file
View File

@@ -0,0 +1,80 @@
"""NiriMod application entry point."""
from __future__ import annotations
import sys
try:
import gi
except ModuleNotFoundError:
print(
"\033[31mError: Could not find Python GObject bindings (PyGObject).\033[0m",
file=sys.stderr,
)
print(
"This application requires system-level libraries to interface with GTK4.",
file=sys.stderr,
)
print(
"\nPlease install the required packages for your distribution:", file=sys.stderr
)
print(
" \033[1mArch:\033[0m sudo pacman -S python-gobject gtk4 libadwaita",
file=sys.stderr,
)
print(
" \033[1mFedora:\033[0m sudo dnf install python3-gobject gtk4 libadwaita",
file=sys.stderr,
)
print(
" \033[1mUbuntu:\033[0m sudo apt install python3-gi gir1.2-gtk-4.0 gir1.2-adw-1",
file=sys.stderr,
)
print(
"\nAfter installing, re-run the installer or re-create your virtual environment.",
file=sys.stderr,
)
sys.exit(1)
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gio, GLib
from nirimod.window import NiriModWindow
class NiriModApp(Adw.Application):
def __init__(self):
super().__init__(
application_id="io.github.nirimod",
flags=Gio.ApplicationFlags.NON_UNIQUE,
)
GLib.set_application_name("NiriMod")
GLib.set_prgname("nirimod")
# Prefer dark theme globally via libadwaita
style_manager = Adw.StyleManager.get_default()
style_manager.set_color_scheme(Adw.ColorScheme.FORCE_DARK)
def do_activate(self):
win = self.get_active_window()
if win is None:
from nirimod import app_settings
from nirimod.kdl_parser import set_paths
set_paths(
config_path=app_settings.get("config_path", ""),
backup_path=app_settings.get("backup_path", "")
)
win = NiriModWindow(application=self)
win.present()
def main():
app = NiriModApp()
return app.run(sys.argv)
if __name__ == "__main__":
sys.exit(main())

50
nirimod/app_settings.py Normal file
View File

@@ -0,0 +1,50 @@
from __future__ import annotations
import json
import os
from pathlib import Path
_SETTINGS_DIR = Path(os.path.expanduser("~/.config/nirimod"))
_SETTINGS_FILE = _SETTINGS_DIR / "settings.json"
_DEFAULTS: dict = {
"auto_update": True,
"config_path": "",
"backup_path": "",
"auto_backup": True,
"backup_limit": 10,
}
_cache: dict | None = None
def _load() -> dict:
global _cache
if _cache is not None:
return _cache
if _SETTINGS_FILE.exists():
try:
data = json.loads(_SETTINGS_FILE.read_text())
_cache = {**_DEFAULTS, **data}
return _cache
except Exception:
pass
_cache = dict(_DEFAULTS)
return _cache
def _save(data: dict):
global _cache
_SETTINGS_DIR.mkdir(parents=True, exist_ok=True)
_SETTINGS_FILE.write_text(json.dumps(data, indent=2))
_cache = data
def get(key: str, default=None):
return _load().get(key, default)
def set(key: str, value): # noqa: A001
data = dict(_load())
data[key] = value
_save(data)

45
nirimod/backup.py Normal file
View File

@@ -0,0 +1,45 @@
"""Automatic config backup management."""
import re
import shutil
from datetime import datetime
from pathlib import Path
from nirimod import kdl_parser
def backup_all_sources(source_files: set[Path], limit: int = 10) -> Path | None:
if not source_files:
return None
kdl_parser.BACKUP_DIR.mkdir(parents=True, exist_ok=True)
existing_gens = []
for p in kdl_parser.BACKUP_DIR.iterdir():
if p.is_dir():
m = re.match(r"^(?:\(Gen|v|gen)(\d+)", p.name, re.IGNORECASE)
if m:
existing_gens.append(int(m.group(1)))
next_gen = max(existing_gens) + 1 if existing_gens else 1
ts = datetime.now().strftime("%Y-%m-%d_%H-%M")
dest_dir = kdl_parser.BACKUP_DIR / f"(Gen{next_gen}){ts}"
dest_dir.mkdir(parents=True, exist_ok=True)
for src in sorted(source_files):
if not src.exists():
continue
try:
rel = src.relative_to(kdl_parser.NIRI_CONFIG.parent)
dest = dest_dir / rel
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dest)
except ValueError:
shutil.copy2(src, dest_dir / src.name)
if limit > 0:
backups = sorted([p for p in kdl_parser.BACKUP_DIR.iterdir() if p.is_dir()], key=lambda p: p.stat().st_mtime)
while len(backups) > limit:
oldest = backups.pop(0)
shutil.rmtree(oldest)
return dest_dir

799
nirimod/kdl_parser.py Normal file
View File

@@ -0,0 +1,799 @@
"""Lightweight KDL parser and writer for Niri config.kdl.
Handles the subset of KDL used by niri's config format. For complex
cases (nested nodes, attributes) we store raw KDL text and do targeted
find/replace rather than a full AST round-trip.
"""
from __future__ import annotations
import os
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
_config_dir = Path(
os.environ.get("NIRIMOD_CONFIG_DIR", Path.home() / ".config" / "niri")
)
NIRI_CONFIG = _config_dir / "config.kdl"
PROFILES_DIR = _config_dir / "profiles"
BACKUP_DIR = Path.home() / ".config" / "nirimod" / "backups"
def set_paths(config_path: str | Path | None = None, backup_path: str | Path | None = None) -> None:
"""Override the default config and/or backup paths at module level."""
global NIRI_CONFIG, PROFILES_DIR, BACKUP_DIR, _config_dir
if config_path:
p = Path(config_path).expanduser().resolve()
_config_dir = p.parent
NIRI_CONFIG = p
else:
_config_dir = Path(
os.environ.get("NIRIMOD_CONFIG_DIR", Path.home() / ".config" / "niri")
)
NIRI_CONFIG = _config_dir / "config.kdl"
PROFILES_DIR = _config_dir / "profiles"
if backup_path:
BACKUP_DIR = Path(backup_path).expanduser().resolve()
else:
BACKUP_DIR = Path.home() / ".config" / "nirimod" / "backups"
class KdlRawString(str):
"""Marker class for strings that should be serialized as raw string literals r"..."."""
pass
@dataclass
class KdlNode:
name: str
args: list[Any] = field(default_factory=list)
props: dict[str, Any] = field(default_factory=dict)
children: list["KdlNode"] = field(default_factory=list)
leading_trivia: str = ""
trailing_trivia: str = ""
children_trailing_trivia: str = ""
source_file: Path | None = field(default=None, compare=False, repr=False)
_removed_children: dict[str, tuple[int, "KdlNode"]] = field(
default_factory=dict, compare=False, repr=False
)
def get_child(self, name: str) -> "KdlNode | None":
for c in reversed(self.children):
if c.name == name:
return c
return None
def get_children(self, name: str) -> list["KdlNode"]:
return [c for c in self.children if c.name == name]
def child_arg(self, name: str, default=None):
c = self.get_child(name)
if c and c.args:
return c.args[0]
return default
def __repr__(self):
return f"KdlNode({self.name!r}, args={self.args}, props={self.props}, children={len(self.children)})"
# Tokenizer
# Token types
_TOK_NEWLINE = "NL" # statement-terminating newline
_TOK_SEMICOLON = "SC" # ; statement terminator
_TOK_LBRACE = "LB" # {
_TOK_RBRACE = "RB" # }
_TOK_STRING = "STR" # "..."
_TOK_RAW_STRING = "RSTR" # r#"..."#
_TOK_PLAIN = "PL" # identifier / number / keyword
_TOK_SLASHDASH = "SD" # /- (next node/arg suppressed)
_TOK_WS = "WS" # whitespace or comment
_TOK_EOF = "EOF"
def _lex(text: str) -> list[tuple[str, str]]:
"""Return list of (token_type, token_value) from KDL source."""
tokens: list[tuple[str, str]] = []
i = 0
n = len(text)
in_node = False # True after we've seen a non-WS token on this line
while i < n:
# whitespace (spaces/tabs only — NOT newlines)
if text[i] in " \t":
j = i
while j < n and text[j] in " \t":
j += 1
tokens.append((_TOK_WS, text[i:j]))
i = j
continue
# line comment
if text[i : i + 2] == "//":
j = i
while j < n and text[j] != "\n":
j += 1
tokens.append((_TOK_WS, text[i:j]))
i = j
continue
# block comment
if text[i : i + 2] == "/*":
end = text.find("*/", i + 2)
j = end + 2 if end != -1 else n
tokens.append((_TOK_WS, text[i:j]))
i = j
continue
# backslash line continuation — \ before \n keeps node open
if text[i] == "\\" and i + 1 < n and text[i + 1] in "\r\n":
j = i + 1
while j < n and text[j] in " \t\r\n":
j += 1
tokens.append((_TOK_WS, text[i:j]))
i = j
continue
# newline(s) — act as statement terminator
if text[i] in "\r\n":
j = i
while j < n and text[j] in "\r\n":
j += 1
nl_str = text[i:j]
if in_node:
tokens.append((_TOK_NEWLINE, nl_str))
in_node = False
else:
tokens.append((_TOK_WS, nl_str))
i = j
continue
# semicolon — explicit terminator
if text[i] == ";":
if in_node:
tokens.append((_TOK_SEMICOLON, ";"))
in_node = False
else:
tokens.append((_TOK_WS, ";"))
i += 1
continue
# /- (node/arg comment)
if text[i : i + 2] == "/-":
tokens.append((_TOK_SLASHDASH, "/-"))
i += 2
in_node = True
continue
# braces
if text[i] == "{":
tokens.append((_TOK_LBRACE, "{"))
in_node = False # children block resets line context
i += 1
continue
if text[i] == "}":
tokens.append((_TOK_RBRACE, "}"))
in_node = False
i += 1
continue
# raw string r#"..."# (handle r#, r##, etc.)
if text[i] == "r" and i + 1 < n and text[i + 1] in '#"':
j = i + 1
while j < n and text[j] == "#":
j += 1
num_hashes = j - i - 1
if j < n and text[j] == '"':
start = j + 1
end_delim = '"' + "#" * num_hashes
end = text.find(end_delim, start)
if end == -1:
raw = text[start:]
i = n
else:
raw = text[start:end]
i = end + len(end_delim)
tokens.append((_TOK_RAW_STRING, raw))
in_node = True
continue
if text[i] == '"':
j = i + 1
s = ""
while j < n and text[j] != '"':
if text[j] == "\\" and j + 1 < n:
j += 1
esc = {
"n": "\n",
"t": "\t",
"r": "\r",
'"': '"',
"\\": "\\",
"b": "\b",
"f": "\f",
}.get(text[j], text[j])
s += esc
else:
s += text[j]
j += 1
tokens.append((_TOK_STRING, s))
in_node = True
i = j + 1
continue
j = i
while j < n and text[j] not in ' \t\r\n;{}"\\':
if text[j] == "=" and j + 1 < n and text[j + 1] == "r":
k = j + 2
while k < n and text[k] == "#":
k += 1
if k < n and text[k] == '"':
j += 1
break
if text[j] == "/" and j + 1 < n and text[j + 1] in "-/*":
break
j += 1
tok = text[i:j]
if tok:
tokens.append((_TOK_PLAIN, tok))
in_node = True
i = j
if in_node:
tokens.append((_TOK_EOF, ""))
tokens.append((_TOK_EOF, ""))
return tokens
def _parse_value(tok_type: str, tok_val: str) -> Any:
if tok_type == _TOK_STRING:
return tok_val
if tok_type == _TOK_RAW_STRING:
return KdlRawString(tok_val)
v = tok_val
if v == "true":
return True
if v == "false":
return False
if v == "null":
return None
try:
return int(v, 0)
except ValueError:
pass
try:
return float(v)
except ValueError:
pass
return v
def _parse_nodes(
tokens: list[tuple[str, str]], pos: int, is_top_level: bool = False
) -> tuple[list[KdlNode], int, str]:
nodes: list[KdlNode] = []
n = len(tokens)
skip_next = False
current_trivia = ""
while pos < n:
tt, tv = tokens[pos]
if tt in (_TOK_WS, _TOK_NEWLINE, _TOK_SEMICOLON):
current_trivia += tv
pos += 1
continue
if tt == _TOK_EOF:
break
if tt == _TOK_RBRACE:
if is_top_level:
current_trivia += tv
pos += 1
continue
break
if tt == _TOK_SLASHDASH:
current_trivia += tv
skip_next = True
pos += 1
continue
if tt not in (_TOK_PLAIN, _TOK_STRING):
current_trivia += tv
pos += 1
continue
name = tv
pos += 1
node = KdlNode(name=name)
node.leading_trivia = current_trivia
current_trivia = ""
accumulated_ws = ""
while pos < n:
tt2, tv2 = tokens[pos]
if tt2 in (_TOK_NEWLINE, _TOK_SEMICOLON, _TOK_EOF):
node.trailing_trivia += accumulated_ws + tv2
pos += 1
break
if tt2 == _TOK_WS:
accumulated_ws += tv2
pos += 1
continue
if tt2 == _TOK_RBRACE:
node.trailing_trivia += accumulated_ws
break
if tt2 == _TOK_LBRACE:
node.trailing_trivia += accumulated_ws
accumulated_ws = ""
pos += 1
node.children, pos, node.children_trailing_trivia = _parse_nodes(
tokens, pos
)
if pos < n and tokens[pos][0] == _TOK_RBRACE:
pos += 1
break
if tt2 == _TOK_SLASHDASH:
accumulated_ws += tv2
pos += 1
while pos < n and tokens[pos][0] == _TOK_WS:
accumulated_ws += tokens[pos][1]
pos += 1
if pos < n and tokens[pos][0] not in (
_TOK_NEWLINE,
_TOK_SEMICOLON,
_TOK_EOF,
_TOK_RBRACE,
_TOK_LBRACE,
):
accumulated_ws += tokens[pos][1]
pos += 1
continue
if "/*" in accumulated_ws or "//" in accumulated_ws:
node.trailing_trivia += accumulated_ws
accumulated_ws = ""
if tt2 == _TOK_PLAIN and "=" in tv2 and not tv2.startswith("-"):
k, _, vraw = tv2.partition("=")
if not vraw:
pos += 1
while pos < n and tokens[pos][0] == _TOK_WS:
pos += 1
if pos < n and tokens[pos][0] not in (
_TOK_NEWLINE,
_TOK_SEMICOLON,
_TOK_EOF,
_TOK_RBRACE,
_TOK_LBRACE,
):
vtt, vtv = tokens[pos]
node.props[k] = _parse_value(vtt, vtv)
pos += 1
elif vraw == "r" or (
vraw.startswith("r") and all(c == "#" for c in vraw[1:])
):
num_hashes = len(vraw) - 1
pos += 1
while pos < n and tokens[pos][0] == _TOK_WS:
pos += 1
if pos < n and tokens[pos][0] == _TOK_STRING:
node.props[k] = KdlRawString(tokens[pos][1])
pos += 1
while (
num_hashes > 0
and pos < n
and tokens[pos] == (_TOK_PLAIN, "#")
):
pos += 1
num_hashes -= 1
else:
node.props[k] = _parse_value(_TOK_PLAIN, vraw)
else:
node.props[k] = _parse_value(_TOK_PLAIN, vraw)
pos += 1
else:
node.args.append(_parse_value(tt2, tv2))
pos += 1
if skip_next:
current_trivia += _write_node(node)
skip_next = False
else:
nodes.append(node)
return nodes, pos, current_trivia
def parse_kdl(text: str) -> list[KdlNode]:
tokens = _lex(text)
nodes, _, eof_trivia = _parse_nodes(tokens, 0, is_top_level=True)
if nodes and eof_trivia:
setattr(nodes[-1], "eof_trivia", eof_trivia)
return nodes
def load_niri_config() -> list[KdlNode]:
if not NIRI_CONFIG.exists():
return []
return parse_kdl(NIRI_CONFIG.read_text())
def _resolve_includes(
nodes: list[KdlNode],
base: Path,
depth: int = 0,
) -> tuple[list[KdlNode], list[tuple[KdlNode, Path]]]:
flat: list[KdlNode] = []
slots: list[tuple[KdlNode, Path]] = []
for i, node in enumerate(nodes):
if node.name != "include" or depth > 5:
node.source_file = base
if depth == 0:
node._primary_order = i
flat.append(node)
continue
optional = node.props.get("optional", False)
if not node.args:
node.source_file = base
if depth == 0:
node._primary_order = i
flat.append(node)
continue
node.source_file = base
if depth == 0:
node._primary_order = i
target = base.parent / node.args[0]
slots.append((node, target))
if not target.exists():
if not optional:
import warnings
warnings.warn(f"nirimod: included file not found: {target}")
continue
included = parse_kdl(target.read_text())
child_flat, child_slots = _resolve_includes(included, target, depth + 1)
flat.extend(child_flat)
slots.extend(child_slots)
return flat, slots
def load_niri_config_multi() -> tuple[list[KdlNode], list[tuple[KdlNode, Path]]]:
if not NIRI_CONFIG.exists():
return [], []
raw = parse_kdl(NIRI_CONFIG.read_text())
return _resolve_includes(raw, NIRI_CONFIG)
def _atomic_write(path: Path, content: str) -> None:
if path.exists() and path.read_text() == content:
return
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".nirimod_tmp_")
try:
os.write(fd, content.encode())
os.close(fd)
fd = -1
os.replace(tmp, path)
except Exception:
if fd != -1:
os.close(fd)
try:
os.unlink(tmp)
except OSError:
pass
raise
def save_niri_config_multi(
nodes: list[KdlNode],
include_slots: list[tuple[KdlNode, Path]],
) -> None:
primary = NIRI_CONFIG
if include_slots and include_slots[0][0].source_file is not None:
primary = include_slots[0][0].source_file
name_to_file: dict[str, Path] = {}
for node in nodes:
if node.source_file is not None and node.source_file != primary:
name_to_file.setdefault(node.name, node.source_file)
for node in nodes:
if node.source_file is None:
node.source_file = name_to_file.get(node.name)
by_file: dict[Path, list[KdlNode]] = {}
config_nodes: list[KdlNode] = []
for node in nodes:
src = node.source_file
if src is None or src == primary:
config_nodes.append(node)
else:
by_file.setdefault(src, []).append(node)
for path, file_nodes in by_file.items():
_atomic_write(path, write_kdl(file_nodes))
_LARGE = 10**9
primary_items: list[tuple[int, KdlNode]] = []
for inc_node, _ in include_slots:
primary_items.append((getattr(inc_node, "_primary_order", _LARGE), inc_node))
for node in config_nodes:
primary_items.append((getattr(node, "_primary_order", _LARGE), node))
primary_items.sort(key=lambda x: x[0])
_atomic_write(primary, write_kdl([n for _, n in primary_items]))
# Writer
def _val_to_kdl(v: Any) -> str:
if isinstance(v, bool):
return "true" if v else "false"
if isinstance(v, KdlRawString):
num_hashes = 0
delim = ""
while f'"{delim}' in v:
num_hashes += 1
delim = "#" * num_hashes
return f'r{delim}"{v}"{delim}'
if isinstance(v, str):
escaped = (
v.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\n", "\\n")
.replace("\t", "\\t")
)
return f'"{escaped}"'
if v is None:
return "null"
return str(v)
def _is_inline_node(node: KdlNode) -> bool:
if not node.children:
return False
if "\n" in node.trailing_trivia:
return False
for child in node.children:
if "\n" in child.leading_trivia or "\n" in child.trailing_trivia:
return False
if child.children and not _is_inline_node(child):
return False
if "\n" in node.children_trailing_trivia:
return False
return True
def _write_node_inline(node: KdlNode) -> str:
# Renders a node as a compact one-liner with no trivia or indentation.
if isinstance(node.name, KdlRawString):
name_str = _val_to_kdl(node.name)
else:
name_str = f'"{node.name}"' if " " in node.name else node.name
parts = [name_str]
for a in node.args:
parts.append(_val_to_kdl(a))
for k, v in node.props.items():
parts.append(f"{k}={_val_to_kdl(v)}")
res = " ".join(parts)
if node.children:
children_str = " ".join(f"{_write_node_inline(c)};" for c in node.children)
res += f" {{ {children_str} }}"
return res
def _write_node(node: KdlNode, indent: int = 0) -> str:
res = node.leading_trivia
pad = " " * indent
if not node.leading_trivia:
res += pad
elif node.leading_trivia.endswith("\n"):
res += pad
if isinstance(node.name, KdlRawString):
name_str = _val_to_kdl(node.name)
else:
name_str = f'"{node.name}"' if " " in node.name else node.name
parts = [name_str]
for a in node.args:
parts.append(_val_to_kdl(a))
for k, v in node.props.items():
parts.append(f"{k}={_val_to_kdl(v)}")
res += " ".join(parts)
if node.children:
if _is_inline_node(node):
pre_brace = node.trailing_trivia if node.trailing_trivia else " "
if not pre_brace[0].isspace():
pre_brace = " " + pre_brace
children_str = " ".join(f"{_write_node_inline(c)};" for c in node.children)
res += f"{pre_brace}{{ {children_str} }}"
else:
if not res.endswith(" "):
res += " "
res += "{"
tt = node.trailing_trivia
if tt and (not tt.isspace() or "\n" in tt):
if not tt[0].isspace() and not tt.startswith("\n"):
res += " "
res += tt
for child in node.children:
child_str = _write_node(child, indent + 1)
if (
res
and not res.endswith("\n")
and child_str
and not child_str.startswith("\n")
):
res += "\n"
res += child_str
ctt = node.children_trailing_trivia
if ctt and (not ctt.isspace() or "\n" in ctt):
lines = ctt.splitlines(keepends=True)
while lines and lines[-1].strip() == "":
lines.pop()
ctt_trimmed = "".join(lines)
if ctt_trimmed:
res += ctt_trimmed
if not res.endswith("\n"):
res += "\n"
if not res.endswith("\n"):
res += "\n"
res += pad
res += "}"
return res
elif node.trailing_trivia:
if not node.trailing_trivia[
0
].isspace() and not node.trailing_trivia.startswith("\n"):
res += " "
res += node.trailing_trivia
if not res.endswith("\n"):
res += "\n"
return res
def write_kdl(nodes: list[KdlNode]) -> str:
if not nodes:
return "// NiriMod configuration\n"
res = ""
for n in nodes:
node_str = _write_node(n)
if getattr(n, "eof_trivia", None):
node_str += getattr(n, "eof_trivia")
if (
res
and not res.endswith("\n")
and node_str
and not node_str.startswith("\n")
):
res += "\n"
res += node_str
if res and not res.endswith("\n"):
res += "\n"
return res
def save_niri_config(nodes: list[KdlNode], path: Path | None = None) -> None:
target = path or NIRI_CONFIG
_atomic_write(target, write_kdl(nodes))
# Config mutation helpers
def find_or_create(nodes: list[KdlNode], *path: str) -> KdlNode:
"""Navigate/create nested nodes by path, operating on a list of nodes."""
current_list = nodes
node: KdlNode | None = None
for name in path:
node = next((n for n in reversed(current_list) if n.name == name), None)
if node is None:
node = KdlNode(name=name)
node.leading_trivia = "\n"
current_list.append(node)
current_list = node.children
return node
def set_child_arg(parent: KdlNode, child_name: str, value: Any) -> None:
child = parent.get_child(child_name)
if child is None:
cache = getattr(parent, "_removed_children", {})
if child_name in cache:
idx, node = cache[child_name]
parent.children.insert(min(idx, len(parent.children)), node)
child = node
else:
child = KdlNode(name=child_name)
child.leading_trivia = "\n"
parent.children.append(child)
child.args = [value]
child.props = {}
def remove_child(parent: KdlNode, child_name: str) -> None:
existing = parent.get_child(child_name)
if existing:
if not hasattr(parent, "_removed_children"):
parent._removed_children = {}
parent._removed_children[child_name] = (
parent.children.index(existing),
existing,
)
parent.children.remove(existing)
def set_node_flag(parent: KdlNode, flag_name: str, enabled: bool) -> None:
existing = parent.get_child(flag_name)
if enabled:
if existing is not None:
existing.args = []
existing.props = {}
return
cache = getattr(parent, "_removed_children", {})
if flag_name in cache:
idx, node = cache[flag_name]
node.args = []
node.props = {}
parent.children.insert(min(idx, len(parent.children)), node)
else:
new_node = KdlNode(name=flag_name)
new_node.leading_trivia = "\n"
parent.children.insert(0, new_node)
elif not enabled and existing is not None:
if not hasattr(parent, "_removed_children"):
parent._removed_children = {}
parent._removed_children[flag_name] = (
parent.children.index(existing),
existing,
)
parent.children.remove(existing)
def safe_switch_connect(switch_row, initial_value: bool, callback) -> None:
switch_row._last_active = initial_value
def _guarded(r, _):
new_val = r.get_active()
if new_val != getattr(r, "_last_active", None):
r._last_active = new_val
callback(new_val)
switch_row.connect("notify::active", _guarded)

209
nirimod/niri_ipc.py Normal file
View File

@@ -0,0 +1,209 @@
"""Wrappers around `niri msg` C. Low-level IPC operations."""
from __future__ import annotations
import json
from typing import Callable
# Internal: synchronous helper
def _run_sync(args: list[str], timeout: float = 5.0) -> tuple[str, str, int]:
import subprocess
try:
r = subprocess.run(
args,
capture_output=True,
text=True,
timeout=timeout,
)
return r.stdout, r.stderr, r.returncode
except FileNotFoundError:
return "", "niri: command not found", 1
except subprocess.TimeoutExpired:
return "", "niri msg timed out", 1
# Internal: non-blocking async dispatch
def _run_async(
args: list[str],
callback: Callable[[str, str, int], None],
) -> None:
import gi
gi.require_version("Gio", "2.0")
gi.require_version("GLib", "2.0")
from gi.repository import Gio, GLib
try:
proc = Gio.Subprocess.new(
args,
Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE,
)
except GLib.Error:
GLib.idle_add(lambda: callback("", "niri: command not found", 1) or False)
return
def _on_done(source: Gio.Subprocess, result: Gio.AsyncResult) -> None:
try:
ok, stdout_bytes, stderr_bytes = source.communicate_finish(result)
stdout = stdout_bytes.get_data().decode("utf-8", errors="replace") if stdout_bytes else ""
stderr = stderr_bytes.get_data().decode("utf-8", errors="replace") if stderr_bytes else ""
if not ok:
rc = 1
else:
rc = 0 if source.get_exit_status() == 0 else 1
except GLib.Error as exc:
stdout, stderr, rc = "", str(exc), 1
callback(stdout, stderr, rc)
proc.communicate_async(None, None, _on_done)
# IPC Getters
def is_niri_running() -> bool:
"""Return True if `niri msg version` succeeds. Called once at startup."""
stdout, _, rc = _run_sync(["niri", "msg", "version"])
return rc == 0 and bool(stdout.strip())
def get_version() -> str:
stdout, _, rc = _run_sync(["niri", "--version"])
return stdout.strip() if rc == 0 else "unknown"
_touchpad_cache: bool | None = None
def has_touchpad() -> bool:
import os
global _touchpad_cache
if _touchpad_cache is not None:
return _touchpad_cache
result = False
try:
for dev in os.listdir("/sys/class/input"):
name_file = f"/sys/class/input/{dev}/device/name"
if os.path.exists(name_file):
with open(name_file) as fh:
name = fh.read().lower()
if "touchpad" in name or "trackpad" in name:
result = True
break
except Exception:
pass
_touchpad_cache = result
return result
def validate_config(config_path: str | None = None) -> tuple[bool, str]:
cmd = ["niri", "validate"]
if config_path:
cmd += ["--config", config_path]
stdout, stderr, rc = _run_sync(cmd, timeout=10.0)
if rc == 0:
return True, stdout.strip() or "Config is valid."
return False, stderr.strip() or stdout.strip() or "Unknown validation error."
def load_config_file() -> tuple[bool, str]:
stdout, stderr, rc = _run_sync(
["niri", "msg", "action", "load-config-file"], timeout=10.0
)
if rc == 0:
return True, stdout.strip() or "Config applied."
return False, stderr.strip() or stdout.strip() or "Config reload failed."
def get_outputs(callback: Callable[[list[dict]], None]) -> None:
def _done(stdout: str, _stderr: str, rc: int) -> None:
if rc != 0:
callback([])
return
try:
data = json.loads(stdout)
callback(list(data.values()) if isinstance(data, dict) else data)
except json.JSONDecodeError:
callback([])
_run_async(["niri", "msg", "--json", "outputs"], _done)
def get_workspaces(callback: Callable[[list[dict]], None]) -> None:
def _done(stdout: str, _stderr: str, rc: int) -> None:
if rc != 0:
callback([])
return
try:
callback(json.loads(stdout))
except json.JSONDecodeError:
callback([])
_run_async(["niri", "msg", "--json", "workspaces"], _done)
def get_windows(callback: Callable[[list[dict]], None]) -> None:
def _done(stdout: str, _stderr: str, rc: int) -> None:
if rc != 0:
callback([])
return
try:
callback(json.loads(stdout))
except json.JSONDecodeError:
callback([])
_run_async(["niri", "msg", "--json", "windows"], _done)
def get_focused_window(callback: Callable[[dict | None], None]) -> None:
def _done(stdout: str, _stderr: str, rc: int) -> None:
if rc != 0:
callback(None)
return
try:
callback(json.loads(stdout))
except json.JSONDecodeError:
callback(None)
_run_async(["niri", "msg", "--json", "focused-window"], _done)
def action(action_name: str, *args: str, callback: Callable[[bool], None] | None = None) -> None:
cmd = ["niri", "msg", "action", action_name] + list(args)
def _done(_stdout: str, _stderr: str, rc: int) -> None:
if callback is not None:
callback(rc == 0)
_run_async(cmd, _done)
# Legacy thread shims
def run_in_thread(fn: Callable, callback: Callable | None = None):
import threading
import gi
gi.require_version("GLib", "2.0")
from gi.repository import GLib
def _worker():
result = fn()
if callback is not None:
GLib.idle_add(lambda: callback(result) or False)
t = threading.Thread(target=_worker, daemon=True)
t.start()
return t

View File

1222
nirimod/pages/animations.py Normal file

File diff suppressed because it is too large Load Diff

452
nirimod/pages/appearance.py Normal file
View File

@@ -0,0 +1,452 @@
"""Appearance page — borders, focus ring, shadows, corner radius."""
from __future__ import annotations
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gdk, Gtk
from nirimod.kdl_parser import KdlNode, find_or_create, set_child_arg, set_node_flag
from nirimod.pages.base import BasePage
from nirimod.window_effects import (
blur_effects_enabled,
focused_window_blur_enabled,
get_global_draw_border_with_background,
get_global_corner_radius,
get_global_window_opacity,
global_window_blur_enabled,
global_window_xray_enabled,
set_focused_window_blur,
set_global_draw_border_with_background,
set_global_corner_radius,
set_global_window_blur,
set_global_window_opacity,
set_global_window_xray,
set_blur_effects_enabled,
)
def _parse_color(color_str: str) -> Gdk.RGBA:
rgba = Gdk.RGBA()
if color_str and not rgba.parse(color_str):
rgba.parse("#7fc8ff")
return rgba
class AppearancePage(BasePage):
def build(self) -> Gtk.Widget:
tb, _, _, content = self._make_toolbar_page("Appearance")
self._content = content
self._build_content()
return tb
def _build_content(self):
content = self._content
nodes = self._nodes
layout = find_or_create(nodes, "layout")
fr_node = layout.get_child("focus-ring") or KdlNode("focus-ring")
fr_group = self._build_border_group("Focus Ring", "focus-ring", fr_node, layout)
content.append(fr_group)
b_node = layout.get_child("border") or KdlNode("border")
b_group = self._build_border_group("Border", "border", b_node, layout)
content.append(b_group)
shadow_grp = Adw.PreferencesGroup(title="Shadow")
shadow_node = layout.get_child("shadow") or KdlNode("shadow")
shadow_on_row = Adw.SwitchRow(title="Enable Shadows")
shadow_on_row.set_active(shadow_node.get_child("on") is not None)
shadow_on_row.connect(
"notify::active", lambda r, _: self._set_shadow_flag("on", r.get_active())
)
shadow_grp.add(shadow_on_row)
soft_val = int(shadow_node.child_arg("softness") or 30)
softness_adj = Gtk.Adjustment(
value=soft_val, lower=0, upper=100, step_increment=1
)
softness_row = Adw.SpinRow(
title="Softness (blur radius)", adjustment=softness_adj, digits=0
)
softness_row._last_val = soft_val
def _on_soft_changed(r, _):
new_val = int(r.get_value())
if new_val != getattr(r, "_last_val", None):
r._last_val = new_val
self._set_shadow("softness", new_val)
softness_row.connect("notify::value", _on_soft_changed)
shadow_grp.add(softness_row)
spread_val = int(shadow_node.child_arg("spread") or 5)
spread_adj = Gtk.Adjustment(
value=spread_val, lower=-50, upper=100, step_increment=1
)
spread_row = Adw.SpinRow(title="Spread", adjustment=spread_adj, digits=0)
spread_row._last_val = spread_val
def _on_spread_changed(r, _):
new_val = int(r.get_value())
if new_val != getattr(r, "_last_val", None):
r._last_val = new_val
self._set_shadow("spread", new_val)
spread_row.connect("notify::value", _on_spread_changed)
shadow_grp.add(spread_row)
color_str = shadow_node.child_arg("color") or "#0007"
color_row = Adw.ActionRow(title="Shadow Color")
color_btn = Gtk.ColorDialogButton(
dialog=Gtk.ColorDialog(title="Shadow Color", with_alpha=True)
)
color_btn.set_rgba(_parse_color(color_str))
color_btn.set_valign(Gtk.Align.CENTER)
color_btn.connect(
"notify::rgba", lambda b, _: self._set_shadow_color(b.get_rgba())
)
color_row.add_suffix(color_btn)
shadow_grp.add(color_row)
draw_behind_row = Adw.SwitchRow(
title="Draw Behind Window",
subtitle="Fixes corner artifacts with non-CSD apps",
)
draw_behind_row.set_active(
shadow_node.get_child("draw-behind-window") is not None
)
draw_behind_row.connect(
"notify::active",
lambda r, _: self._set_shadow_flag("draw-behind-window", r.get_active()),
)
shadow_grp.add(draw_behind_row)
content.append(shadow_grp)
blur_grp = Adw.PreferencesGroup(
title="Blur (Global)",
description=(
"Requires Niri 26.04 or later. Sets blur quality and optional "
"window blur rules."
),
)
blur_node = next((n for n in nodes if n.name == "blur"), None)
blur_effects_row = Adw.SwitchRow(
title="Enable Blur Effects",
subtitle="Controls the compositor-level blur { off } setting",
)
blur_effects_row.set_active(blur_effects_enabled(nodes))
blur_effects_row.connect(
"notify::active",
lambda r, _: self._set_blur_effects_enabled(r.get_active()),
)
blur_grp.add(blur_effects_row)
blur_enabled_row = Adw.SwitchRow(
title="Force Blur on Windows",
subtitle="Adds background-effect { blur true } to the global window rule",
)
blur_enabled_row.set_active(global_window_blur_enabled(nodes))
blur_enabled_row.connect(
"notify::active",
lambda r, _: self._set_window_blur_enabled(r.get_active()),
)
blur_grp.add(blur_enabled_row)
focused_blur_row = Adw.SwitchRow(
title="Keep Focused Windows Blurred",
subtitle="Adds a focused-window rule that forces blur on",
)
focused_blur_row.set_active(focused_window_blur_enabled(nodes))
focused_blur_row.connect(
"notify::active",
lambda r, _: self._set_focused_window_blur_enabled(r.get_active()),
)
blur_grp.add(focused_blur_row)
xray_row = Adw.SwitchRow(
title="Use Xray Wallpaper Blur",
subtitle="Use wallpaper-only blur; disable for regular background blur",
)
xray_row.set_active(global_window_xray_enabled(nodes))
xray_row.connect(
"notify::active",
lambda r, _: self._set_window_blur_xray(r.get_active()),
)
blur_grp.add(xray_row)
opacity_val = get_global_window_opacity(nodes)
opacity_adj = Gtk.Adjustment(
value=opacity_val, lower=0.1, upper=1.0, step_increment=0.05
)
opacity_row = Adw.SpinRow(
title="Window Opacity (1 = unset)", adjustment=opacity_adj, digits=2
)
opacity_row._last_val = opacity_val
def _on_opacity_changed(r, _):
new_val = round(float(r.get_value()), 2)
if new_val != getattr(r, "_last_val", None):
r._last_val = new_val
self._set_window_opacity(new_val)
opacity_row.connect("notify::value", _on_opacity_changed)
blur_grp.add(opacity_row)
border_bg_row = Adw.SwitchRow(
title="Draw Border With Background",
subtitle="Disable to avoid focus colors behind translucent windows",
)
border_bg_row.set_active(get_global_draw_border_with_background(nodes))
border_bg_row.connect(
"notify::active",
lambda r, _: self._set_draw_border_with_background(r.get_active()),
)
blur_grp.add(border_bg_row)
passes_val = int(blur_node.child_arg("passes", 0) if blur_node else 0)
passes_adj = Gtk.Adjustment(
value=passes_val, lower=0, upper=10, step_increment=1
)
passes_row = Adw.SpinRow(
title="Passes", adjustment=passes_adj, digits=0
)
passes_row._last_val = passes_val
def _on_passes_changed(r, _):
new_val = int(r.get_value())
if new_val != getattr(r, "_last_val", None):
r._last_val = new_val
self._set_blur("passes", new_val)
passes_row.connect("notify::value", _on_passes_changed)
blur_grp.add(passes_row)
offset_val = float(blur_node.child_arg("offset", 2.0) if blur_node else 2.0)
offset_adj = Gtk.Adjustment(
value=offset_val, lower=0.0, upper=20.0, step_increment=0.1
)
offset_row = Adw.SpinRow(title="Offset", adjustment=offset_adj, digits=1)
offset_row._last_val = offset_val
def _on_offset_changed(r, _):
new_val = float(r.get_value())
if new_val != getattr(r, "_last_val", None):
r._last_val = new_val
self._set_blur("offset", new_val)
offset_row.connect("notify::value", _on_offset_changed)
blur_grp.add(offset_row)
noise_val = float(blur_node.child_arg("noise", 0.0) if blur_node else 0.0)
noise_adj = Gtk.Adjustment(
value=noise_val, lower=0.0, upper=1.0, step_increment=0.01
)
noise_row = Adw.SpinRow(title="Noise", adjustment=noise_adj, digits=2)
noise_row._last_val = noise_val
def _on_noise_changed(r, _):
new_val = float(r.get_value())
if new_val != getattr(r, "_last_val", None):
r._last_val = new_val
self._set_blur("noise", new_val)
noise_row.connect("notify::value", _on_noise_changed)
blur_grp.add(noise_row)
saturation_val = float(blur_node.child_arg("saturation", 1.0) if blur_node else 1.0)
saturation_adj = Gtk.Adjustment(
value=saturation_val, lower=0.0, upper=5.0, step_increment=0.1
)
saturation_row = Adw.SpinRow(
title="Saturation", adjustment=saturation_adj, digits=1
)
saturation_row._last_val = saturation_val
def _on_saturation_changed(r, _):
new_val = float(r.get_value())
if new_val != getattr(r, "_last_val", None):
r._last_val = new_val
self._set_blur("saturation", new_val)
saturation_row.connect("notify::value", _on_saturation_changed)
blur_grp.add(saturation_row)
content.append(blur_grp)
misc_grp = Adw.PreferencesGroup(title="Window Geometry")
cr_val = get_global_corner_radius(nodes)
cr_adj = Gtk.Adjustment(value=cr_val, lower=0, upper=40, step_increment=1)
cr_row = Adw.SpinRow(
title="Corner Radius (px)",
adjustment=cr_adj,
digits=0,
)
cr_row._last_val = cr_val
def _on_cr_changed(r, _):
new_val = int(r.get_value())
if new_val != getattr(r, "_last_val", None):
r._last_val = new_val
self._set_corner_radius(new_val)
cr_row.connect("notify::value", _on_cr_changed)
misc_grp.add(cr_row)
content.append(misc_grp)
def _build_border_group(
self, title: str, key: str, node: KdlNode, layout: KdlNode
) -> Adw.PreferencesGroup:
grp = Adw.PreferencesGroup(title=title)
off_row = Adw.SwitchRow(title="Enable")
off_row.set_active(node.get_child("off") is None)
off_row.connect(
"notify::active",
lambda r, _, k=key: self._set_layout_border_flag(
k, "off", not r.get_active()
),
)
grp.add(off_row)
width_val = int(node.child_arg("width") or 4)
width_adj = Gtk.Adjustment(value=width_val, lower=1, upper=20, step_increment=1)
width_row = Adw.SpinRow(title="Width (px)", adjustment=width_adj, digits=0)
width_row._last_val = width_val
def _on_width_changed(r, _, k=key):
new_val = int(r.get_value())
if new_val != getattr(r, "_last_val", None):
r._last_val = new_val
self._set_layout_border(k, "width", new_val)
width_row.connect("notify::value", _on_width_changed)
grp.add(width_row)
for color_key, color_label in [
("active-color", "Active Color"),
("inactive-color", "Inactive Color"),
]:
c_str = node.child_arg(color_key) or (
"#7fc8ff" if "active" in color_key else "#202020"
)
c_row = Adw.ActionRow(title=color_label)
c_btn = Gtk.ColorDialogButton(
dialog=Gtk.ColorDialog(title=color_label, with_alpha=True)
)
c_btn.set_rgba(_parse_color(c_str))
c_btn.set_valign(Gtk.Align.CENTER)
c_btn.connect(
"notify::rgba",
lambda b, _, k=key, ck=color_key: self._set_layout_border(
k, ck, self._rgba_to_hex(b.get_rgba())
),
)
c_row.add_suffix(c_btn)
grp.add(c_row)
return grp
@staticmethod
def _rgba_to_hex(rgba: Gdk.RGBA) -> str:
r = int(rgba.red * 255)
g = int(rgba.green * 255)
b = int(rgba.blue * 255)
a = int(rgba.alpha * 255)
if a == 255:
return f"#{r:02x}{g:02x}{b:02x}"
return f"#{r:02x}{g:02x}{b:02x}{a:02x}"
def _get_layout(self):
return find_or_create(self._nodes, "layout")
def _get_border_node(self, key: str) -> KdlNode:
layout = self._get_layout()
node = layout.get_child(key)
if node is None:
node = KdlNode(key)
layout.children.append(node)
return node
def _set_layout_border(self, bkey: str, prop: str, value):
node = self._get_border_node(bkey)
set_child_arg(node, prop, value)
self._commit(f"{bkey} {prop}")
def _set_layout_border_flag(self, bkey: str, flag: str, enabled: bool):
node = self._get_border_node(bkey)
set_node_flag(node, flag, enabled)
self._commit(f"{bkey} {flag}")
def _get_shadow_node(self) -> KdlNode:
layout = self._get_layout()
node = layout.get_child("shadow")
if node is None:
node = KdlNode("shadow")
layout.children.append(node)
return node
def _set_shadow(self, prop: str, value):
set_child_arg(self._get_shadow_node(), prop, value)
self._commit(f"shadow {prop}")
def _set_shadow_flag(self, flag: str, enabled: bool):
set_node_flag(self._get_shadow_node(), flag, enabled)
self._commit(f"shadow {flag}")
def _set_shadow_color(self, rgba: Gdk.RGBA):
set_child_arg(self._get_shadow_node(), "color", self._rgba_to_hex(rgba))
self._commit("shadow color")
def _set_blur(self, prop: str, value):
blur_node = find_or_create(self._nodes, "blur")
set_child_arg(blur_node, prop, value)
self._commit(f"blur {prop}")
def _set_blur_effects_enabled(self, enabled: bool):
set_blur_effects_enabled(self._nodes, enabled)
self._commit("blur effects")
def _set_window_blur_enabled(self, enabled: bool):
set_global_window_blur(self._nodes, enabled)
self._commit("window blur")
def _set_focused_window_blur_enabled(self, enabled: bool):
set_focused_window_blur(self._nodes, enabled)
self._commit("focused window blur")
def _set_window_blur_xray(self, enabled: bool):
set_global_window_xray(self._nodes, enabled)
self._commit("window blur xray")
def _set_window_opacity(self, opacity: float):
set_global_window_opacity(self._nodes, opacity)
self._commit("window opacity")
def _set_draw_border_with_background(self, enabled: bool):
set_global_draw_border_with_background(self._nodes, enabled)
self._commit("draw border with background")
def _set_corner_radius(self, radius: int):
set_global_corner_radius(self._nodes, radius)
self._commit("corner radius")
def refresh(self):
for child in list(self._content):
self._content.remove(child)
self._build_content()

98
nirimod/pages/base.py Normal file
View File

@@ -0,0 +1,98 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
gi.require_version("Gio", "2.0")
from gi.repository import Adw, Gtk, Gio
if TYPE_CHECKING:
from nirimod.window import NiriModWindow
def make_toolbar_page(
title: str,
window=None,
) -> tuple[Adw.ToolbarView, Adw.HeaderBar, Gtk.ScrolledWindow, Gtk.Box]:
tb = Adw.ToolbarView()
header = Adw.HeaderBar()
tb.add_top_bar(header)
# Hamburger menu on the content header (appears next to window close button)
if window is not None:
menu = Gio.Menu()
menu.append("Profiles", "win.open_profiles")
menu.append("Preferences", "win.open_preferences")
menu.append("Restore Backup...", "win.reset_config")
kofi_section = Gio.Menu()
kofi_section.append("Support on Ko-fi ☕", "win.open_kofi")
menu.append_section(None, kofi_section)
menu_btn = Gtk.MenuButton(icon_name="open-menu-symbolic")
menu_btn.set_tooltip_text("Menu")
menu_btn.add_css_class("flat")
menu_btn.set_menu_model(menu)
header.pack_end(menu_btn)
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)
tb.set_content(scroll)
return tb, header, scroll, content
class BasePage:
def __init__(self, window: "NiriModWindow"):
self._win = window
def _make_toolbar_page(
self, title: str
) -> tuple[Adw.ToolbarView, Adw.HeaderBar, Gtk.ScrolledWindow, Gtk.Box]:
return make_toolbar_page(title, window=self._win)
@property
def _nodes(self):
return self._win.get_nodes()
def _commit(self, description: str = "change"):
app_state = self._win.app_state
after = app_state.write_current_kdl()
before = app_state.undo.last_snapshot
if before is None:
before = app_state.saved_kdl
if before != after:
self._win.push_undo(description, before, after)
if after == app_state.saved_kdl:
self._win.mark_clean()
else:
self._win.mark_dirty()
def build(self) -> Gtk.Widget:
raise NotImplementedError
def refresh(self):
pass
def on_shown(self):
pass
def show_toast(self, msg: str, timeout: int = 3):
self._win.show_toast(msg, timeout)

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

View File

@@ -0,0 +1,161 @@
"""Environment Variables page."""
from __future__ import annotations
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, GLib, Gtk
from nirimod.kdl_parser import KdlNode, find_or_create
from nirimod.pages.base import BasePage
class EnvironmentPage(BasePage):
def build(self) -> Gtk.Widget:
tb, header, _, content = self._make_toolbar_page("Environment")
self._content = content
# Add button has been moved to the page body for better visibility
self.refresh()
return tb
def refresh(self):
self._rebuild()
def _get_env_node(self) -> KdlNode:
return find_or_create(self._nodes, "environment")
def _rebuild(self):
# Clear existing content
while True:
child = self._content.get_first_child()
if child is None:
break
self._content.remove(child)
env = self._get_env_node()
entries = list(env.children)
if not entries:
status = Adw.StatusPage(
title="No Environment Variables",
description="Variables set here will apply to niri and all processes it spawns.",
icon_name="preferences-system-symbolic",
)
add_btn = Gtk.Button(label="Add Variable")
add_btn.add_css_class("pill")
add_btn.add_css_class("suggested-action")
add_btn.set_halign(Gtk.Align.CENTER)
add_btn.connect("clicked", self._on_add)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
box.set_valign(Gtk.Align.CENTER)
box.set_vexpand(True)
box.append(status)
box.append(add_btn)
self._content.append(box)
else:
grp = Adw.PreferencesGroup(
title="Environment Variables",
description=f"{len(entries)} variable{'s' if len(entries) != 1 else ''} configured",
)
for i, child in enumerate(entries):
row = self._make_row(child, i)
grp.add(row)
self._content.append(grp)
# Convenient button at the bottom
add_btn = Gtk.Button(label="Add Another Variable")
add_btn.add_css_class("pill")
add_btn.set_halign(Gtk.Align.CENTER)
add_btn.set_margin_top(16)
add_btn.connect("clicked", self._on_add)
self._content.append(add_btn)
def _make_row(self, node: KdlNode, idx: int) -> Adw.ActionRow:
key = node.name
val = node.args[0] if node.args else ""
# Make key bold and distinct
key_str = GLib.markup_escape_text(key)
val_str = GLib.markup_escape_text(str(val))
row = Adw.ActionRow(
title=f"<b>{key_str}</b>",
subtitle=val_str if val_str else "(empty)",
)
row.set_use_markup(True)
edit_btn = Gtk.Button(icon_name="document-edit-symbolic")
edit_btn.set_valign(Gtk.Align.CENTER)
edit_btn.add_css_class("flat")
edit_btn.connect("clicked", lambda *_, i=idx: self._on_edit(i))
row.add_suffix(edit_btn)
del_btn = Gtk.Button(icon_name="user-trash-symbolic")
del_btn.set_valign(Gtk.Align.CENTER)
del_btn.add_css_class("flat")
del_btn.add_css_class("error")
del_btn.connect("clicked", lambda *_, i=idx: self._on_delete(i))
row.add_suffix(del_btn)
return row
def _on_add(self, *_):
self._show_dialog(None, -1)
def _on_edit(self, idx: int):
env = self._get_env_node()
if 0 <= idx < len(env.children):
self._show_dialog(env.children[idx], idx)
def _on_delete(self, idx: int):
env = self._get_env_node()
if 0 <= idx < len(env.children):
env.children.pop(idx)
self._commit("remove env var")
self._rebuild()
def _show_dialog(self, node: KdlNode | None, idx: int):
dialog = Adw.AlertDialog(
heading="Environment Variable", body="Set a key=value environment variable."
)
key_entry = Adw.EntryRow(title="Variable Name (e.g. QT_QPA_PLATFORM)")
val_entry = Adw.EntryRow(title="Value (e.g. wayland)")
if node:
key_entry.set_text(node.name)
key_entry.set_editable(False) # editing key means replacing the node
val_entry.set_text(str(node.args[0]) if node.args else "")
grp = Adw.PreferencesGroup()
grp.add(key_entry)
grp.add(val_entry)
dialog.set_extra_child(grp)
dialog.add_response("cancel", "Cancel")
dialog.add_response("save", "Save")
dialog.set_response_appearance("save", Adw.ResponseAppearance.SUGGESTED)
def _on_resp(d, r):
if r != "save":
return
key = key_entry.get_text().strip()
val = val_entry.get_text()
if not key:
return
env = self._get_env_node()
new_node = KdlNode(key, args=[val])
if idx >= 0 and 0 <= idx < len(env.children):
env.children[idx] = new_node
else:
env.children.append(new_node)
self._commit("env var")
self._rebuild()
dialog.connect("response", _on_resp)
dialog.present(self._win)

200
nirimod/pages/gestures.py Normal file
View File

@@ -0,0 +1,200 @@
"""Gestures & Miscellaneous settings page."""
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.kdl_parser import (
KdlNode,
find_or_create,
set_node_flag,
safe_switch_connect,
)
from nirimod.pages.base import BasePage
_CORNERS = [
("top-left", "Top-Left", "Moves cursor to the top-left corner"),
("top-right", "Top-Right", "Moves cursor to the top-right corner"),
("bottom-left", "Bottom-Left", "Moves cursor to the bottom-left corner"),
("bottom-right", "Bottom-Right", "Moves cursor to the bottom-right corner"),
]
class GesturesPage(BasePage):
def build(self) -> Gtk.Widget:
tb, _, _, content = self._make_toolbar_page("Gestures & Misc")
self._content = content
self._build_content()
return tb
def _build_content(self):
content = self._content
nodes = self._nodes
# ── Hot Corners ───────────────────────────────────────────────────────
hc_grp = Adw.PreferencesGroup(
title="Hot Corners",
description="Trigger the overview when the cursor touches a screen corner (niri ≥ 25.05)",
)
gestures_node = next((n for n in nodes if n.name == "gestures"), None)
hc_node = gestures_node.get_child("hot-corners") if gestures_node else None
hc_off = hc_node is not None and hc_node.get_child("off") is not None
hc_enabled = not hc_off
# Which individual corners are active
active_corners: set[str] = set()
if hc_node and not hc_off:
for corner_key, _, _ in _CORNERS:
if hc_node.get_child(corner_key) is not None:
active_corners.add(corner_key)
# ExpanderRow = the enable/disable switch + collapsible corner list
hc_expander = Adw.ExpanderRow(
title="Enable Hot Corners",
subtitle="Expand to choose which corners are active (default: top-left)",
)
hc_expander.set_expanded(hc_enabled)
hc_expander.set_show_enable_switch(True)
hc_expander.set_enable_expansion(hc_enabled)
# Per-corner rows nested inside the expander
corner_rows: dict[str, Adw.SwitchRow] = {}
for corner_key, corner_label, corner_subtitle in _CORNERS:
sr = Adw.SwitchRow(title=corner_label, subtitle=corner_subtitle)
is_active = corner_key in active_corners
sr.set_active(is_active)
safe_switch_connect(
sr, is_active,
lambda enabled, k=corner_key: self._set_corner(k, enabled),
)
hc_expander.add_row(sr)
corner_rows[corner_key] = sr
# Wire the expander's enable-switch to the hot corners on/off mutation
hc_expander._last_enabled = hc_enabled
def _on_expander_toggled(expander, _param):
val = expander.get_enable_expansion()
if val != getattr(expander, "_last_enabled", None):
expander._last_enabled = val
self._set_hot_corners(val)
hc_expander.connect("notify::enable-expansion", _on_expander_toggled)
hc_grp.add(hc_expander)
content.append(hc_grp)
# ── Hotkey Overlay ────────────────────────────────────────────────────
hko_grp = Adw.PreferencesGroup(title="Hotkey Overlay")
hko_node = next((n for n in nodes if n.name == "hotkey-overlay"), None)
skip_initial = (
hko_node is not None and hko_node.get_child("skip-at-startup") is not None
)
skip_row = Adw.SwitchRow(
title="Skip at Startup",
subtitle="Don't show the hotkey overlay when niri starts",
)
skip_row.set_active(skip_initial)
safe_switch_connect(skip_row, skip_initial, self._set_skip_hotkey_overlay)
hko_grp.add(skip_row)
content.append(hko_grp)
# ── Screenshots ───────────────────────────────────────────────────────
ss_grp = Adw.PreferencesGroup(
title="Screenshots", description="Path template for saved screenshots"
)
cur_path = next(
(n.args[0] for n in nodes if n.name == "screenshot-path" and n.args),
"~/Pictures/Screenshots/Screenshot from %Y-%m-%d %H-%M-%S.png",
)
path_row = Adw.EntryRow(title="Save Path (strftime format)")
path_row.set_text(str(cur_path))
path_row.set_show_apply_button(True)
path_row.connect("apply", lambda r: self._set_screenshot_path(r.get_text()))
ss_grp.add(path_row)
content.append(ss_grp)
# ── Overview ──────────────────────────────────────────────────────────
ov_grp = Adw.PreferencesGroup(title="Overview")
ov_node = next((n for n in nodes if n.name == "overview"), None)
ws_shadow_node = ov_node.get_child("workspace-shadow") if ov_node else None
ws_shadow_initial = (
ws_shadow_node is None or ws_shadow_node.get_child("off") is None
)
ws_shadow_row = Adw.SwitchRow(
title="Workspace Shadow in Overview",
subtitle="Show drop shadows under workspaces in overview mode",
)
ws_shadow_row.set_active(ws_shadow_initial)
safe_switch_connect(
ws_shadow_row, ws_shadow_initial, self._set_overview_ws_shadow
)
ov_grp.add(ws_shadow_row)
content.append(ov_grp)
# ── Mutation methods ──────────────────────────────────────────────────────
def _get_hot_corners_node(self) -> KdlNode:
gestures = find_or_create(self._nodes, "gestures")
hc = gestures.get_child("hot-corners")
if hc is None:
hc = KdlNode("hot-corners")
hc.leading_trivia = "\n"
gestures.children.append(hc)
return hc
def _set_hot_corners(self, enabled: bool):
hc = self._get_hot_corners_node()
set_node_flag(hc, "off", not enabled)
self._commit("gestures hot-corners")
def _set_corner(self, corner_key: str, enabled: bool):
"""Enable or disable an individual hot corner (niri ≥ 25.11)."""
hc = self._get_hot_corners_node()
# Remove 'off' if it exists — enabling a corner implicitly enables hot corners
set_node_flag(hc, "off", False)
set_node_flag(hc, corner_key, enabled)
self._commit(f"hot-corner {corner_key}")
def _set_skip_hotkey_overlay(self, skip: bool):
nodes = self._nodes
hko = next((n for n in nodes if n.name == "hotkey-overlay"), None)
if hko is None:
hko = KdlNode("hotkey-overlay")
nodes.append(hko)
set_node_flag(hko, "skip-at-startup", skip)
self._commit("hotkey-overlay skip-at-startup")
def _set_screenshot_path(self, path: str):
nodes = self._nodes
existing = next((n for n in nodes if n.name == "screenshot-path"), None)
if path.strip():
if existing:
existing.args = [path.strip()]
else:
nodes.append(KdlNode("screenshot-path", args=[path.strip()]))
elif existing:
nodes.remove(existing)
self._commit("screenshot-path")
def _set_overview_ws_shadow(self, enabled: bool):
ov = find_or_create(self._nodes, "overview")
ws_shadow = ov.get_child("workspace-shadow")
if ws_shadow is None:
ws_shadow = KdlNode("workspace-shadow")
ov.children.append(ws_shadow)
set_node_flag(ws_shadow, "off", not enabled)
self._commit("overview workspace-shadow")
def refresh(self):
for child in list(self._content):
self._content.remove(child)
self._build_content()

380
nirimod/pages/input_page.py Normal file
View File

@@ -0,0 +1,380 @@
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 &amp; 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()

284
nirimod/pages/layout.py Normal file
View File

@@ -0,0 +1,284 @@
"""Layout settings page."""
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.kdl_parser import KdlNode, find_or_create, set_child_arg, remove_child
from nirimod.pages.base import BasePage
CENTER_OPTIONS = ["never", "always", "on-overflow"]
class LayoutPage(BasePage):
def build(self) -> Gtk.Widget:
tb, _, _, content = self._make_toolbar_page("Layout")
self._content = content
self._build_content()
return tb
def _build_content(self):
content = self._content
nodes = self._nodes
layout = find_or_create(nodes, "layout")
basic_grp = Adw.PreferencesGroup(title="General")
gaps_val = int(layout.child_arg("gaps") or 16)
gaps_adj = Gtk.Adjustment(value=gaps_val, lower=0, upper=200, step_increment=2)
gaps_row = Adw.SpinRow(title="Window Gaps (px)", adjustment=gaps_adj, digits=0)
gaps_row._last_val = gaps_val
def _on_gaps_changed(r, _):
new_val = int(r.get_value())
if new_val != getattr(r, "_last_val", None):
r._last_val = new_val
self._set_layout("gaps", new_val)
gaps_row.connect("notify::value", _on_gaps_changed)
basic_grp.add(gaps_row)
cfc_model = Gtk.StringList.new(CENTER_OPTIONS)
cfc_row = Adw.ComboRow(title="Center Focused Column", model=cfc_model)
cur_cfc = layout.child_arg("center-focused-column") or "never"
if cur_cfc in CENTER_OPTIONS:
cfc_row.set_selected(CENTER_OPTIONS.index(cur_cfc))
cfc_row.connect(
"notify::selected",
lambda r, _: self._set_layout(
"center-focused-column", CENTER_OPTIONS[r.get_selected()]
),
)
basic_grp.add(cfc_row)
prefer_csd_row = Adw.SwitchRow(
title="Prefer No CSD", subtitle="Ask apps to omit client-side decorations"
)
prefer_csd_row.set_active(any(n.name == "prefer-no-csd" for n in nodes))
prefer_csd_row.connect(
"notify::active",
lambda r, _: self._toggle_top("prefer-no-csd", r.get_active()),
)
basic_grp.add(prefer_csd_row)
bg_color_val = str(layout.child_arg("background-color") or "transparent")
bg_row = Adw.EntryRow(title="Background Color (e.g. transparent, #000000)")
bg_row.set_text(bg_color_val)
bg_row.set_show_apply_button(True)
bg_row.connect(
"apply",
lambda r: self._set_layout("background-color", r.get_text().strip()),
)
basic_grp.add(bg_row)
content.append(basic_grp)
dcw_grp = Adw.PreferencesGroup(title="Default Column Width")
dcw_node = layout.get_child("default-column-width")
prop_val = 0.5
fixed_val = 800
use_fixed = False
if dcw_node:
fc = dcw_node.get_child("fixed")
pc = dcw_node.get_child("proportion")
if fc and fc.args:
fixed_val = int(fc.args[0])
use_fixed = True
elif pc and pc.args:
prop_val = float(pc.args[0])
mode_model = Gtk.StringList.new(["Proportion", "Fixed (px)"])
mode_row = Adw.ComboRow(title="Mode", model=mode_model)
mode_row.set_selected(1 if use_fixed else 0)
dcw_grp.add(mode_row)
prop_adj = Gtk.Adjustment(value=prop_val, lower=0.05, upper=1.0, step_increment=0.05)
prop_spin = Gtk.SpinButton(adjustment=prop_adj, digits=2, climb_rate=1)
prop_spin.set_valign(Gtk.Align.CENTER)
prop_spin.connect("value-changed", lambda s: self._set_dcw_proportion(s.get_value()))
prop_row = Adw.ActionRow(title="Proportion")
prop_row.add_suffix(prop_spin)
prop_row.set_visible(not use_fixed)
dcw_grp.add(prop_row)
fixed_adj = Gtk.Adjustment(value=fixed_val, lower=100, upper=7680, step_increment=10)
fixed_spin = Gtk.SpinButton(adjustment=fixed_adj, digits=0, climb_rate=1)
fixed_spin.set_valign(Gtk.Align.CENTER)
fixed_spin.connect("value-changed", lambda s: self._set_dcw_fixed(int(s.get_value())))
fixed_row = Adw.ActionRow(title="Fixed Width (px)")
fixed_row.add_suffix(fixed_spin)
fixed_row.set_visible(use_fixed)
dcw_grp.add(fixed_row)
def _on_mode_changed(r, _):
is_fixed = r.get_selected() == 1
prop_row.set_visible(not is_fixed)
fixed_row.set_visible(is_fixed)
if is_fixed:
self._set_dcw_fixed(int(fixed_spin.get_value()))
else:
self._set_dcw_proportion(prop_spin.get_value())
mode_row.connect("notify::selected", _on_mode_changed)
content.append(dcw_grp)
pw_grp = Adw.PreferencesGroup(title="Preset Column Widths (proportions)")
pw_grp.set_description("Cycled through by Mod+R")
pcw_node = layout.get_child("preset-column-widths")
presets = []
if pcw_node:
for c in pcw_node.children:
if c.name == "proportion" and c.args:
presets.append(float(c.args[0]))
self._preset_spins: list[Gtk.SpinButton] = []
for val in presets or [0.333, 0.5, 0.667]:
self._add_preset_row(pw_grp, val)
add_preset_btn = Gtk.Button(label="Add Preset")
add_preset_btn.add_css_class("flat")
add_preset_btn.connect("clicked", lambda *_: self._add_preset_row(pw_grp, 0.5))
pw_grp.set_header_suffix(add_preset_btn)
content.append(pw_grp)
struts_grp = Adw.PreferencesGroup(title="Struts (outer gaps, px)")
struts_node = layout.get_child("struts")
for side in ["left", "right", "top", "bottom"]:
val = int(struts_node.child_arg(side) or 0) if struts_node else 0
adj = Gtk.Adjustment(value=val, lower=0, upper=500, step_increment=4)
row = Adw.SpinRow(title=side.capitalize(), adjustment=adj, digits=0)
row._last_val = val
def _on_strut_changed(r, _, s=side):
new_val = int(r.get_value())
if new_val != getattr(r, "_last_val", None):
r._last_val = new_val
self._set_strut(s, new_val)
row.connect("notify::value", _on_strut_changed)
struts_grp.add(row)
content.append(struts_grp)
def _add_preset_row(self, grp: Adw.PreferencesGroup, val: float):
spin_adj = Gtk.Adjustment(value=val, lower=0.05, upper=1.0, step_increment=0.05)
spin = Gtk.SpinButton(adjustment=spin_adj, digits=3, climb_rate=1)
spin.set_valign(Gtk.Align.CENTER)
self._preset_spins.append(spin)
row = Adw.ActionRow(title=f"Proportion {val:.3f}")
spin.connect(
"value-changed",
lambda s, r=row: (
r.set_title(f"Proportion {s.get_value():.3f}"),
self._save_presets(),
),
)
del_btn = Gtk.Button(icon_name="user-trash-symbolic")
del_btn.set_valign(Gtk.Align.CENTER)
del_btn.add_css_class("flat")
del_btn.add_css_class("error")
def _on_delete(s=spin):
self._preset_spins.remove(s)
grp.remove(row)
self._save_presets()
del_btn.connect("clicked", lambda *_: _on_delete())
row.add_suffix(spin)
row.add_suffix(del_btn)
grp.add(row)
def _save_presets(self):
layout = find_or_create(self._nodes, "layout")
pcw = layout.get_child("preset-column-widths")
if pcw is None:
pcw = KdlNode("preset-column-widths")
layout.children.append(pcw)
new_children = []
for i, s in enumerate(self._preset_spins):
if i < len(pcw.children):
child = pcw.children[i]
child.name = "proportion"
child.args = [round(s.get_value(), 5)]
new_children.append(child)
else:
new_children.append(KdlNode("proportion", args=[round(s.get_value(), 5)]))
salvaged = ""
for i in range(len(self._preset_spins), len(pcw.children)):
salvaged += pcw.children[i].leading_trivia
if salvaged and new_children:
new_children[-1].trailing_trivia += salvaged
pcw.children = new_children
self._commit("preset column widths")
def _set_layout(self, key: str, value):
layout = find_or_create(self._nodes, "layout")
set_child_arg(layout, key, value)
self._commit(f"layout {key}")
def _set_dcw_proportion(self, val: float):
layout = find_or_create(self._nodes, "layout")
dcw = layout.get_child("default-column-width")
if dcw is None:
dcw = KdlNode("default-column-width")
layout.children.append(dcw)
dcw.children = [KdlNode("proportion", args=[round(val, 4)])]
self._commit("default column width proportion")
def _set_dcw_fixed(self, px: int):
layout = find_or_create(self._nodes, "layout")
dcw = layout.get_child("default-column-width")
if dcw is None:
dcw = KdlNode("default-column-width")
layout.children.append(dcw)
dcw.children = [KdlNode("fixed", args=[px])]
self._commit("default column width fixed")
def _set_strut(self, side: str, val: int):
layout = find_or_create(self._nodes, "layout")
struts = layout.get_child("struts")
if struts is None:
struts = KdlNode("struts")
layout.children.append(struts)
if val > 0:
set_child_arg(struts, side, val)
else:
remove_child(struts, side)
self._commit(f"strut {side}")
def _toggle_top(self, key: str, enabled: bool):
nodes = self._nodes
existing = next((n for n in reversed(nodes) if n.name == key), None)
app_state = self._win.app_state
if enabled and not existing:
cache = getattr(app_state, "_removed_top_nodes", {})
if key in cache:
idx, node = cache[key]
nodes.insert(min(idx, len(nodes)), node)
else:
nodes.append(KdlNode(key))
elif not enabled and existing:
if not hasattr(app_state, "_removed_top_nodes"):
app_state._removed_top_nodes = {}
app_state._removed_top_nodes[key] = (nodes.index(existing), existing)
nodes.remove(existing)
self._commit(f"toggle {key}")
def refresh(self):
for child in list(self._content):
self._content.remove(child)
self._build_content()

746
nirimod/pages/outputs.py Normal file
View File

@@ -0,0 +1,746 @@
"""Outputs / Monitors page with interactive canvas."""
from __future__ import annotations
from typing import TYPE_CHECKING
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, set_child_arg, safe_switch_connect
from nirimod.pages.base import BasePage
if TYPE_CHECKING:
from nirimod.window import NiriModWindow
TRANSFORMS = [
"normal",
"90",
"180",
"270",
"flipped",
"flipped-90",
"flipped-180",
"flipped-270",
]
class OutputsPage(BasePage):
def __init__(self, window: "NiriModWindow"):
super().__init__(window)
self._outputs: list[dict] = []
self._current_out: dict | None = None
self._canvas: Gtk.DrawingArea | None = None
self._drag_output: str | None = None
self._drag_offset: tuple[float, float] = (0, 0)
def build(self) -> Gtk.Widget:
tb, header, scroll, content = self._make_toolbar_page("Outputs")
add_fake_btn = Gtk.Button(icon_name="list-add-symbolic")
add_fake_btn.set_tooltip_text("Add fake monitor for testing")
add_fake_btn.add_css_class("flat")
add_fake_btn.connect("clicked", lambda *_: self._add_fake_monitor())
header.pack_end(add_fake_btn)
refresh_btn = Gtk.Button(icon_name="view-refresh-symbolic")
refresh_btn.set_tooltip_text("Reload outputs from niri")
refresh_btn.add_css_class("flat")
refresh_btn.connect("clicked", lambda *_: self.refresh())
header.pack_end(refresh_btn)
canvas_frame = Gtk.Frame()
canvas_frame.add_css_class("card")
canvas_frame.set_margin_bottom(8)
self._canvas = Gtk.DrawingArea()
self._canvas.set_content_height(350)
self._canvas.set_draw_func(self._draw_canvas)
canvas_frame.set_child(self._canvas)
content.append(canvas_frame)
drag = Gtk.GestureDrag()
drag.connect("drag-begin", self._on_drag_begin)
drag.connect("drag-update", self._on_drag_update)
drag.connect("drag-end", self._on_drag_end)
self._canvas.add_controller(drag)
click = Gtk.GestureClick()
click.connect("pressed", self._on_canvas_click)
self._canvas.add_controller(click)
self._out_combo = Adw.ComboRow(title="Monitor")
self._out_combo.connect("notify::selected", self._on_output_selected)
sel_group = Adw.PreferencesGroup()
sel_group.add(self._out_combo)
content.append(sel_group)
self._detail_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
content.append(self._detail_box)
self.refresh()
return tb
def refresh(self):
def _on_got_outputs(outputs):
self._outputs = outputs
names = [o.get("name", "?") for o in self._outputs]
model = Gtk.StringList.new(names)
self._out_combo.set_model(model)
if self._outputs:
self._load_output_detail(self._outputs[0])
if self._canvas:
self._canvas.queue_draw()
# Rebuild search index as the detail rows are now populated
if hasattr(self._win, "_build_search_index"):
self._win._build_search_index()
niri_ipc.get_outputs(_on_got_outputs)
def _add_fake_monitor(self):
idx = 1
while any(o.get("name") == f"fake-{idx}" for o in self._outputs):
idx += 1
name = f"fake-{idx}"
o = {
"name": name,
"modes": [{"width": 1920, "height": 1080, "refresh_rate": 60000}],
"current_mode": 0,
"logical": {
"x": 0,
"y": 0,
"width": 1920,
"height": 1080,
"scale": 1.0,
"transform": "normal",
},
}
self._outputs.append(o)
names = [out.get("name", "?") for out in self._outputs]
model = Gtk.StringList.new(names)
self._out_combo.set_model(model)
self._out_combo.set_selected(len(self._outputs) - 1)
if self._canvas:
self._canvas.queue_draw()
if hasattr(self._win, "_build_search_index"):
self._win._build_search_index()
# Canvas drawing
def _draw_canvas(self, area, cr, width, height):
if not self._outputs:
cr.set_source_rgba(0.05, 0.05, 0.05, 0.4)
cr.rectangle(0, 0, width, height)
cr.fill()
cr.set_source_rgba(0.5, 0.5, 0.5, 0.8)
cr.select_font_face("Sans", 0, 0)
cr.set_font_size(14)
cr.move_to(width / 2 - 80, height / 2)
cr.show_text("No outputs detected")
return
min_x = min_y = float("inf")
max_x = max_y = float("-inf")
for o in self._outputs:
pos = o.get("logical", {})
lx = pos.get("x", 0)
ly = pos.get("y", 0)
lw = pos.get("width", 1920)
lh = pos.get("height", 1080)
min_x = min(min_x, lx)
min_y = min(min_y, ly)
max_x = max(max_x, lx + lw)
max_y = max(max_y, ly + lh)
if min_x == float("inf"):
min_x = min_y = 0
max_x = 1920
max_y = 1080
total_w = max_x - min_x
total_h = max_y - min_y
scale = min(width / max(total_w, 1), height / max(total_h, 1)) * 0.9
off_x = (width - total_w * scale) / 2 - min_x * scale
off_y = (height - total_h * scale) / 2 - min_y * scale
if self._drag_output and hasattr(self, "_drag_start_scale"):
scale = self._drag_start_scale
off_x, off_y = self._drag_start_offset
self._canvas_scale = scale
self._canvas_offset = (off_x, off_y)
self._canvas_pixel_w = width
self._canvas_pixel_h = height
# grid background
cr.set_source_rgba(1, 1, 1, 0.03)
cr.set_line_width(1)
grid_size = 40
for gx in range(0, int(width), grid_size):
cr.move_to(gx, 0)
cr.line_to(gx, height)
for gy in range(0, int(height), grid_size):
cr.move_to(0, gy)
cr.line_to(width, gy)
cr.stroke()
for i, o in enumerate(self._outputs):
pos = o.get("logical", {})
x = off_x + pos.get("x", 0) * scale
y = off_y + pos.get("y", 0) * scale
w = pos.get("width", 1920) * scale
h = pos.get("height", 1080) * scale
is_sel = o.get("name") == (
self._current_out.get("name") if self._current_out else None
)
if is_sel:
cr.set_source_rgba(155 / 255, 109 / 255, 1.0, 1.0)
else:
cr.set_source_rgba(0.2, 0.2, 0.2, 1.0)
cr.rectangle(x, y, w, h)
cr.fill_preserve()
# border
cr.set_line_width(1.5)
if is_sel:
cr.set_source_rgba(0.7, 0.7, 0.75, 0.9)
else:
cr.set_source_rgba(0.4, 0.4, 0.45, 0.6)
cr.stroke()
name = o.get("name", f"Output {i}")
mode_idx = o.get("current_mode")
modes = o.get("modes", [])
mode = (
modes[mode_idx]
if isinstance(mode_idx, int) and 0 <= mode_idx < len(modes)
else {}
)
out_scale = o.get("logical", {}).get("scale", 1.0)
res = f"{mode.get('width', '?')}×{mode.get('height', '?')}"
scale_text = f"Scale: {out_scale}x"
cr.set_source_rgba(1, 1, 1, 0.95 if is_sel else 0.7)
cr.select_font_face("Sans", 0, 1)
font_size = max(10, min(16, w / 10))
cr.set_font_size(font_size)
te = cr.text_extents(name)
cr.move_to(x + w / 2 - te.width / 2, y + h / 2 - font_size * 0.3)
cr.show_text(name)
cr.select_font_face("Sans", 0, 0)
res_size = max(8, min(12, w / 15))
cr.set_font_size(res_size)
te2 = cr.text_extents(res)
cr.move_to(x + w / 2 - te2.width / 2, y + h / 2 + res_size * 1.2)
cr.show_text(res)
cr.set_source_rgba(0.6, 0.6, 0.65, 0.9 if is_sel else 0.6)
scale_size = max(7, min(11, w / 18))
cr.set_font_size(scale_size)
te3 = cr.text_extents(scale_text)
cr.move_to(
x + w / 2 - te3.width / 2, y + h / 2 + res_size * 1.2 + scale_size * 1.4
)
cr.show_text(scale_text)
def _on_drag_begin(self, gesture, sx, sy):
if not hasattr(self, "_canvas_scale"):
return
scale = self._canvas_scale
ox, oy = self._canvas_offset
for o in reversed(self._outputs):
pos = o.get("logical", {})
x = ox + pos.get("x", 0) * scale
y = oy + pos.get("y", 0) * scale
w = pos.get("width", 1920) * scale
h = pos.get("height", 1080) * scale
if x <= sx <= x + w and y <= sy <= y + h:
self._drag_output = o["name"]
self._last_dx = 0
self._last_dy = 0
self._drag_current_lx = pos.get("x", 0)
self._drag_current_ly = pos.get("y", 0)
self._drag_start_scale = scale
self._drag_start_offset = (ox, oy)
return
def _on_drag_update(self, gesture, dx, dy):
if not self._drag_output or not hasattr(self, "_canvas_scale"):
return
scale = getattr(self, "_drag_start_scale", self._canvas_scale)
delta_dx = dx - getattr(self, "_last_dx", 0)
delta_dy = dy - getattr(self, "_last_dy", 0)
self._last_dx = dx
self._last_dy = dy
self._drag_current_lx += delta_dx / scale
self._drag_current_ly += delta_dy / scale
new_lx = self._drag_current_lx
new_ly = self._drag_current_ly
drag_o = next(
(o for o in self._outputs if o.get("name") == self._drag_output), None
)
if not drag_o:
return
monitor_scale = drag_o.get("logical", {}).get("scale", 1.0)
mode_idx = drag_o.get("current_mode")
modes = drag_o.get("modes", [])
mode = (
modes[mode_idx]
if isinstance(mode_idx, int) and 0 <= mode_idx < len(modes)
else {}
)
pixel_w = mode.get("width", 1920)
pixel_h = mode.get("height", 1080)
transform = str(drag_o.get("logical", {}).get("transform", "normal")).lower().replace("_", "-")
if transform in ["90", "270", "flipped-90", "flipped-270"]:
pixel_w, pixel_h = pixel_h, pixel_w
logical_w = pixel_w / monitor_scale
logical_h = pixel_h / monitor_scale
# edge snapping
SNAP_THRESHOLD = 30
snapped_x = new_lx
snapped_y = new_ly
closest_x = SNAP_THRESHOLD + 1
closest_y = SNAP_THRESHOLD + 1
dragged_left = new_lx
dragged_right = new_lx + logical_w
dragged_top = new_ly
dragged_bottom = new_ly + logical_h
for other in self._outputs:
if other.get("name") == self._drag_output:
continue
other_pos = other.get("logical", {})
other_x = other_pos.get("x", 0)
other_y = other_pos.get("y", 0)
other_scale = other_pos.get("scale", 1.0)
other_mode_idx = other.get("current_mode")
other_modes = other.get("modes", [])
other_mode = other_modes[other_mode_idx] if isinstance(other_mode_idx, int) and 0 <= other_mode_idx < len(other_modes) else {}
other_pixel_w = other_mode.get("width", 1920)
other_pixel_h = other_mode.get("height", 1080)
other_transform = str(other_pos.get("transform", "normal")).lower().replace("_", "-")
if other_transform in ["90", "270", "flipped-90", "flipped-270"]:
other_pixel_w, other_pixel_h = other_pixel_h, other_pixel_w
other_logical_w = other_pixel_w / other_scale
other_logical_h = other_pixel_h / other_scale
other_left = other_x
other_right = other_x + other_logical_w
other_top = other_y
other_bottom = other_y + other_logical_h
for dragged_edge, is_left_edge in [(dragged_left, True), (dragged_right, False)]:
for other_edge in [other_left, other_right]:
dist = abs(dragged_edge - other_edge)
if dist < closest_x:
closest_x = dist
snapped_x = other_edge if is_left_edge else other_edge - logical_w
for dragged_edge, is_top_edge in [(dragged_top, True), (dragged_bottom, False)]:
for other_edge in [other_top, other_bottom]:
dist = abs(dragged_edge - other_edge)
if dist < closest_y:
closest_y = dist
snapped_y = other_edge if is_top_edge else other_edge - logical_h
if closest_x <= SNAP_THRESHOLD:
new_lx = snapped_x
if closest_y <= SNAP_THRESHOLD:
new_ly = snapped_y
if "logical" not in drag_o:
drag_o["logical"] = {}
drag_o["logical"]["x"] = new_lx
drag_o["logical"]["y"] = new_ly
if self._canvas:
self._canvas.queue_draw()
def _on_drag_end(self, gesture, dx, dy):
if self._drag_output:
if self._canvas:
self._canvas.queue_draw()
for o in self._outputs:
self._apply_position(o["name"])
if self._current_out:
cur_pos = self._current_out.get("logical", {})
if hasattr(self, "_pos_x_adj"):
self._pos_x_adj.set_value(cur_pos.get("x", 0))
if hasattr(self, "_pos_y_adj"):
self._pos_y_adj.set_value(cur_pos.get("y", 0))
self._drag_output = None
def _on_canvas_click(self, gesture, n_press, x, y):
if not hasattr(self, "_canvas_scale"):
return
scale = self._canvas_scale
ox, oy = self._canvas_offset
for i, o in reversed(list(enumerate(self._outputs))):
pos = o.get("logical", {})
mx = ox + pos.get("x", 0) * scale
my = oy + pos.get("y", 0) * scale
mw = pos.get("width", 1920) * scale
mh = pos.get("height", 1080) * scale
if mx <= x <= mx + mw and my <= y <= my + mh:
self._out_combo.set_selected(i)
return
def _apply_position(self, name: str):
o = next((x for x in self._outputs if x["name"] == name), None)
if not o:
return
pos = o.get("logical", {})
nx = pos.get("x", 0)
ny = pos.get("y", 0)
out_node = self._get_or_create_out_node(name)
pos_node = out_node.get_child("position")
if pos_node is None:
pos_node = KdlNode(name="position")
out_node.children.append(pos_node)
pos_node.props["x"] = int(round(nx))
pos_node.props["y"] = int(round(ny))
if self._current_out and self._current_out.get("name") == name:
if hasattr(self, "_pos_x_adj"):
self._pos_x_adj.set_value(nx)
if hasattr(self, "_pos_y_adj"):
self._pos_y_adj.set_value(ny)
self._commit("output position")
def _on_output_selected(self, combo, _):
idx = combo.get_selected()
if 0 <= idx < len(self._outputs):
self._load_output_detail(self._outputs[idx])
# Rebuild search index as the detail rows have changed
if hasattr(self._win, "_build_search_index"):
self._win._build_search_index()
def _load_output_detail(self, output: dict):
self._current_out = output
for child in list(self._detail_box):
self._detail_box.remove(child)
name = output.get("name", "?")
nodes = self._nodes
out_node = next(
(n for n in nodes if n.name == "output" and n.args and n.args[0] == name),
None,
)
modes = output.get("modes", [])
mode_strs = [
f"{m.get('width', 0)}×{m.get('height', 0)}@{m.get('refresh_rate', 0) / 1000:.3f}"
for m in modes
]
mode_model = Gtk.StringList.new(mode_strs)
mode_row = Adw.ComboRow(title="Resolution &amp; Refresh Rate")
mode_row.set_model(mode_model)
mode_idx = output.get("current_mode")
cur_mode = (
modes[mode_idx]
if isinstance(mode_idx, int) and 0 <= mode_idx < len(modes)
else {}
)
cur_str = f"{cur_mode.get('width', 0)}×{cur_mode.get('height', 0)}@{cur_mode.get('refresh_rate', 0) / 1000:.3f}"
if cur_str in mode_strs:
mode_row.set_selected(mode_strs.index(cur_str))
mode_row.connect(
"notify::selected",
lambda r, _: self._on_mode_changed(name, modes, r.get_selected()),
)
scale_val = round(output.get("logical", {}).get("scale", 1.0), 3)
scale_adj = Gtk.Adjustment(
value=scale_val,
lower=0.01,
upper=100.0,
step_increment=0.05,
)
scale_row = Adw.SpinRow(title="Scale", adjustment=scale_adj, digits=2)
scale_row.connect(
"notify::value",
lambda r, _: self._set_output_prop(name, "scale", r.get_value()),
)
t_model = Gtk.StringList.new(TRANSFORMS)
transform_row = Adw.ComboRow(title="Transform", model=t_model)
cur_t = output.get("logical", {}).get("transform", "normal")
cur_t_norm = str(cur_t).lower().replace("_", "-") if cur_t else "normal"
if cur_t_norm in TRANSFORMS:
transform_row.set_selected(TRANSFORMS.index(cur_t_norm))
transform_row.connect(
"notify::selected",
lambda r, _: self._set_output_prop(
name, "transform", TRANSFORMS[r.get_selected()]
),
)
px = output.get("logical", {}).get("x", 0)
py = output.get("logical", {}).get("y", 0)
px_adj = Gtk.Adjustment(value=px, lower=-1000000, upper=1000000, step_increment=1)
py_adj = Gtk.Adjustment(value=py, lower=-1000000, upper=1000000, step_increment=1)
self._pos_x_adj = px_adj
self._pos_y_adj = py_adj
pos_x_row = Adw.SpinRow(title="Position X", adjustment=px_adj, digits=0)
pos_y_row = Adw.SpinRow(title="Position Y", adjustment=py_adj, digits=0)
pos_x_row.connect(
"notify::value",
lambda r, _: self._set_output_pos(
name, int(r.get_value()), int(py_adj.get_value())
),
)
pos_y_row.connect(
"notify::value",
lambda r, _: self._set_output_pos(
name, int(px_adj.get_value()), int(r.get_value())
),
)
vrr_row = Adw.SwitchRow(title="Variable Refresh Rate (VRR)")
vrr_val = (
(out_node.get_child("variable-refresh-rate") is not None)
if out_node
else False
)
vrr_row.set_active(vrr_val)
safe_switch_connect(
vrr_row,
vrr_val,
lambda enabled: self._set_output_flag(
name, "variable-refresh-rate", enabled
),
)
off_row = Adw.SwitchRow(title="Disable Output")
off_val = (out_node.get_child("off") is not None) if out_node else False
off_row.set_active(off_val)
safe_switch_connect(
off_row,
off_val,
lambda enabled: self._set_output_flag(name, "off", enabled),
)
grp = Adw.PreferencesGroup(title=f"Output: {name}")
for r in [
mode_row,
scale_row,
transform_row,
pos_x_row,
pos_y_row,
vrr_row,
off_row,
]:
grp.add(r)
self._detail_box.append(grp)
if self._canvas:
self._canvas.queue_draw()
def _ensure_output_fields(self, out_node: KdlNode, name: str):
manual_out = None
try:
manual_nodes = self._nodes
if manual_nodes:
manual_out = next(
(
n
for n in manual_nodes
if n.name == "output" and n.args and n.args[0] == name
),
None,
)
except Exception:
pass
if manual_out:
if out_node.get_child("mode") is None:
m = manual_out.child_arg("mode")
if m:
set_child_arg(out_node, "mode", m)
if out_node.get_child("scale") is None:
s = manual_out.child_arg("scale")
if s is not None:
set_child_arg(out_node, "scale", s)
if out_node.get_child("transform") is None:
t = manual_out.child_arg("transform")
if t:
set_child_arg(out_node, "transform", t)
if out_node.get_child("position") is None:
pos_node = manual_out.get_child("position")
if pos_node:
new_pos = KdlNode(name="position", props=pos_node.props.copy())
out_node.children.append(new_pos)
o = next((x for x in self._outputs if x.get("name") == name), None)
if o:
if out_node.get_child("mode") is None:
mode_idx = o.get("current_mode")
modes = o.get("modes", [])
if isinstance(mode_idx, int) and 0 <= mode_idx < len(modes):
m = modes[mode_idx]
mode_str = f"{m.get('width', 0)}x{m.get('height', 0)}@{m.get('refresh_rate', 0) / 1000:.3f}"
set_child_arg(out_node, "mode", mode_str)
if out_node.get_child("scale") is None:
set_child_arg(out_node, "scale", o.get("logical", {}).get("scale", 1.0))
if out_node.get_child("transform") is None:
t = o.get("logical", {}).get("transform", "normal")
t = str(t).lower().replace("_", "-") if t else "normal"
if t not in TRANSFORMS:
t = "normal"
set_child_arg(out_node, "transform", t)
pos_node = out_node.get_child("position")
if pos_node is None:
pos_node = KdlNode(name="position")
out_node.children.append(pos_node)
pos_node.props["x"] = o.get("logical", {}).get("x", 0)
pos_node.props["y"] = o.get("logical", {}).get("y", 0)
def _get_or_create_out_node(self, name: str) -> KdlNode:
nodes = self._nodes
out_node = next(
(n for n in nodes if n.name == "output" and n.args and n.args[0] == name),
None,
)
is_new = out_node is None
if out_node is None:
out_node = KdlNode(name="output", args=[name])
nodes.append(out_node)
assert out_node is not None
if is_new:
self._ensure_output_fields(out_node, name)
order = {"mode": 0, "scale": 1, "transform": 2, "position": 3}
out_node.children.sort(key=lambda c: order.get(c.name, 999))
return out_node
def _update_logical_dims(self, o: dict):
if "logical" not in o:
o["logical"] = {}
mode_idx = o.get("current_mode")
modes = o.get("modes", [])
m = (
modes[mode_idx]
if isinstance(mode_idx, int) and 0 <= mode_idx < len(modes)
else {}
)
pw = m.get("width", 1920)
ph = m.get("height", 1080)
scale = o["logical"].get("scale", 1.0)
if scale <= 0:
scale = 1.0
t = o["logical"].get("transform", "normal")
t_str = str(t).lower().replace("_", "-")
if t_str in ["90", "270", "flipped-90", "flipped-270"]:
pw, ph = ph, pw
o["logical"]["width"] = round(pw / scale)
o["logical"]["height"] = round(ph / scale)
def _on_mode_changed(self, name: str, modes: list, idx: int):
if not (0 <= idx < len(modes)):
return
m = modes[idx]
mode_str = f"{m.get('width', 0)}x{m.get('height', 0)}@{m.get('refresh_rate', 0) / 1000:.3f}"
out_node = self._get_or_create_out_node(name)
set_child_arg(out_node, "mode", mode_str)
o = next((x for x in self._outputs if x.get("name") == name), None)
if o:
o["current_mode"] = idx
self._update_logical_dims(o)
self._commit("output mode")
if self._canvas:
self._canvas.queue_draw()
def _set_output_prop(self, name: str, prop: str, value):
if prop == "scale" and isinstance(value, float):
value = round(value, 3)
out_node = self._get_or_create_out_node(name)
set_child_arg(out_node, prop, value)
o = next((x for x in self._outputs if x.get("name") == name), None)
if o:
if "logical" not in o:
o["logical"] = {}
if prop == "scale":
o["logical"]["scale"] = value
elif prop == "transform":
o["logical"]["transform"] = value
self._update_logical_dims(o)
self._commit(f"output {prop}")
if self._canvas:
self._canvas.queue_draw()
def _set_output_pos(self, name: str, x: int, y: int):
out_node = self._get_or_create_out_node(name)
pos_node = out_node.get_child("position")
if pos_node is None:
pos_node = KdlNode(name="position")
out_node.children.append(pos_node)
pos_node.props["x"] = int(round(x))
pos_node.props["y"] = int(round(y))
o = next((out for out in self._outputs if out.get("name") == name), None)
if o:
if "logical" not in o:
o["logical"] = {}
o["logical"]["x"] = x
o["logical"]["y"] = y
self._commit("output position")
if self._canvas:
self._canvas.queue_draw()
def _set_output_flag(self, name: str, flag: str, enabled: bool):
from nirimod.kdl_parser import set_node_flag
out_node = self._get_or_create_out_node(name)
set_node_flag(out_node, flag, enabled)
self._commit(f"output {flag}")

322
nirimod/pages/raw_config.py Normal file
View File

@@ -0,0 +1,322 @@
"""Raw Config page — editable view of the full merged config."""
from __future__ import annotations
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Gtk, Pango, GLib
from pathlib import Path
from nirimod import niri_ipc
from nirimod.kdl_parser import NIRI_CONFIG
from nirimod.pages.base import BasePage
class RawConfigPage(BasePage):
def build(self) -> Gtk.Widget:
tb, header, _, content = self._make_toolbar_page("Raw Config")
self._content = content
self._scroll_positions: dict[Path, tuple[float, float]] = {}
self._buffer_modified = False
self._original_text = ""
self._current_files: list[Path] = []
self._file_dropdown = Gtk.DropDown()
self._file_dropdown.set_valign(Gtk.Align.CENTER)
self._file_dropdown.connect("notify::selected-item", self._on_file_selected)
title_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
title_box.set_halign(Gtk.Align.CENTER)
title_box.set_valign(Gtk.Align.CENTER)
title_label = Gtk.Label(label="Config File")
title_label.add_css_class("title")
title_box.append(title_label)
title_box.append(self._file_dropdown)
header.pack_start(title_box)
title_box.set_margin_start(12)
# Header actions
validate_btn = Gtk.Button(label="Validate")
validate_btn.add_css_class("suggested-action")
validate_btn.connect("clicked", self._on_validate)
header.pack_end(validate_btn)
self._save_btn = Gtk.Button(label="Save")
self._save_btn.add_css_class("suggested-action")
self._save_btn.set_tooltip_text("Save this file and reload niri (Ctrl+S)")
self._save_btn.connect("clicked", self._on_save_raw)
self._save_btn.set_sensitive(False)
header.pack_end(self._save_btn)
self._discard_btn = Gtk.Button(label="Discard")
self._discard_btn.add_css_class("destructive-action")
self._discard_btn.add_css_class("flat")
self._discard_btn.set_tooltip_text("Discard unsaved changes")
self._discard_btn.connect("clicked", self._on_discard_raw)
self._discard_btn.set_sensitive(False)
header.pack_end(self._discard_btn)
# Editor
self._textview = Gtk.TextView()
self._textview.set_editable(True)
self._textview.set_monospace(True)
self._textview.set_wrap_mode(Gtk.WrapMode.NONE)
self._textview.set_left_margin(16)
self._textview.set_right_margin(16)
self._textview.set_top_margin(16)
self._textview.set_bottom_margin(16)
self._textview.add_css_class("code-editor")
self._buf = self._textview.get_buffer()
self._buf.connect("changed", self._on_buffer_changed)
self._scroll = Gtk.ScrolledWindow()
self._scroll.add_css_class("card")
self._scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
self._scroll.set_vexpand(True)
self._scroll.set_hexpand(True)
self._scroll.set_child(self._textview)
content.append(self._scroll)
self.refresh()
return tb
# Scroll position helpers
def _save_scroll_position(self):
"""Persist the current scroll position for the active file."""
idx = self._file_dropdown.get_selected()
if idx == Gtk.INVALID_LIST_POSITION or idx >= len(self._current_files):
return
path = self._current_files[idx]
hadj = self._scroll.get_hadjustment()
vadj = self._scroll.get_vadjustment()
self._scroll_positions[path] = (hadj.get_value(), vadj.get_value())
def _restore_scroll_position(self, path: Path):
"""Restore the saved scroll position for a given file, if any."""
if path not in self._scroll_positions:
return
hval, vval = self._scroll_positions[path]
def _apply():
hadj = self._scroll.get_hadjustment()
vadj = self._scroll.get_vadjustment()
hadj.set_value(hval)
vadj.set_value(vval)
return False # don't repeat
# Defer one frame so the buffer is fully laid out before scrolling
GLib.idle_add(_apply)
# Page lifecycle
def on_shown(self):
"""Called every time the user navigates back to this page."""
# Restore scroll for whichever file is currently selected
idx = self._file_dropdown.get_selected()
if idx != Gtk.INVALID_LIST_POSITION and idx < len(self._current_files):
self._restore_scroll_position(self._current_files[idx])
def refresh(self):
state = self._win.app_state
if state.is_multi_file:
self._current_files = sorted(list(state.source_files))
if NIRI_CONFIG in self._current_files:
self._current_files.remove(NIRI_CONFIG)
self._current_files.insert(0, NIRI_CONFIG)
else:
self._current_files = [NIRI_CONFIG]
strings = [p.name for p in self._current_files]
self._file_dropdown.set_model(Gtk.StringList.new(strings))
self._load_selected_file()
def _reload_from_disk(self):
"""Re-read the file from disk, discarding any edits."""
self._load_selected_file(force=True)
# File loading
def _on_file_selected(self, dropdown, param):
self._save_scroll_position()
self._load_selected_file()
def _load_selected_file(self, force: bool = False):
idx = self._file_dropdown.get_selected()
if idx == Gtk.INVALID_LIST_POSITION or idx >= len(self._current_files):
return
if self._buffer_modified and not force:
self._confirm_discard_then(lambda: self._do_load_file(idx))
return
self._do_load_file(idx)
def _do_load_file(self, idx: int):
path = self._current_files[idx]
text = path.read_text() if path.exists() else f"// File not found: {path}"
self._buf.handler_block_by_func(self._on_buffer_changed)
self._buf.set_text(text)
self._original_text = text
self._apply_syntax_highlighting(self._buf, text)
self._buf.handler_unblock_by_func(self._on_buffer_changed)
self._set_modified(False)
self._restore_scroll_position(path)
# Buffer modification tracking
def _on_buffer_changed(self, buf):
text = buf.get_text(buf.get_start_iter(), buf.get_end_iter(), False)
is_changed = (text != self._original_text)
if is_changed != self._buffer_modified:
self._set_modified(is_changed)
def _set_modified(self, modified: bool):
self._buffer_modified = modified
self._save_btn.set_sensitive(modified)
self._discard_btn.set_sensitive(modified)
# Save / Discard
def _on_save_raw(self, *_):
idx = self._file_dropdown.get_selected()
if idx == Gtk.INVALID_LIST_POSITION or idx >= len(self._current_files):
return
path = self._current_files[idx]
text = self._buf.get_text(self._buf.get_start_iter(), self._buf.get_end_iter(), False)
from nirimod import app_settings
if app_settings.get("auto_backup", True):
from nirimod.backup import backup_all_sources
limit = app_settings.get("backup_limit", 10)
backup_all_sources(self._win.app_state.source_files, limit=limit)
tmp = path.with_suffix(path.suffix + ".tmp")
try:
tmp.write_text(text)
except Exception as e:
self.show_toast(f"Write error: {e}", timeout=6)
return
self.show_toast("Validating…", timeout=2)
def _on_validated(result):
ok, msg = result
if not ok:
tmp.unlink(missing_ok=True)
self.show_toast(f"Validation error: {msg[:120]}", timeout=8)
return
try:
tmp.replace(path)
except Exception as e:
self.show_toast(f"Save error: {e}", timeout=6)
return
self._set_modified(False)
self._original_text = text
self._apply_syntax_highlighting(self._buf, text)
niri_ipc.run_in_thread(niri_ipc.load_config_file, self._on_reloaded)
niri_ipc.run_in_thread(
lambda: niri_ipc.validate_config(str(tmp)), _on_validated
)
def _on_reloaded(self, result):
ok, msg = result
if ok:
self.show_toast("Config saved and applied ✓", timeout=3)
else:
self.show_toast(f"Saved, but reload failed: {msg[:80]}", timeout=8)
self._win.app_state.reload_from_disk()
self._win._build_search_index()
def _on_discard_raw(self, *_):
self._confirm_discard_then(self._reload_from_disk)
def _confirm_discard_then(self, callback):
import gi
gi.require_version("Adw", "1")
from gi.repository import Adw
dialog = Adw.AlertDialog(
heading="Discard changes?",
body="Your unsaved edits to this file will be lost.",
)
dialog.add_response("cancel", "Cancel")
dialog.add_response("discard", "Discard")
dialog.set_response_appearance("discard", Adw.ResponseAppearance.DESTRUCTIVE)
dialog.set_default_response("cancel")
def _on_response(dlg, response):
if response == "discard":
self._set_modified(False)
callback()
dialog.connect("response", _on_response)
dialog.present(self._win)
# Syntax highlighting
def _apply_syntax_highlighting(self, buf: Gtk.TextBuffer, text: str):
tag_table = buf.get_tag_table()
def _get_or_create_tag(name, **props):
t = tag_table.lookup(name)
if t is None:
t = buf.create_tag(name, **props)
return t
comment_tag = _get_or_create_tag(
"comment", foreground="#6a9955", style=Pango.Style.ITALIC
)
string_tag = _get_or_create_tag("string", foreground="#ce9178")
node_tag = _get_or_create_tag("node", foreground="#9cdcfe")
keyword_tag = _get_or_create_tag("keyword", foreground="#c586c0")
import re
def _apply(pattern, tag, group=0):
for m in re.finditer(pattern, text, re.MULTILINE):
s = buf.get_iter_at_offset(m.start(group))
e = buf.get_iter_at_offset(m.end(group))
buf.apply_tag(tag, s, e)
_apply(r"//[^\n]*", comment_tag)
_apply(r'"[^"\\]*(?:\\.[^"\\]*)*"', string_tag)
_apply(r"\b(true|false|null)\b", keyword_tag)
_apply(r"^(\s*)([a-zA-Z][\w\-]*)", node_tag, group=2)
# Copy / Validate
def _on_validate(self, *_):
self.show_toast("Validating...")
def _on_validated(result):
ok, msg = result
self.show_toast(msg[:120], timeout=5)
niri_ipc.run_in_thread(
lambda: niri_ipc.validate_config(str(NIRI_CONFIG)), _on_validated
)

171
nirimod/pages/startup.py Normal file
View File

@@ -0,0 +1,171 @@
"""Startup Programs page."""
from __future__ import annotations
import shlex
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gtk, GLib
from nirimod.kdl_parser import KdlNode
from nirimod.pages.base import BasePage
class StartupPage(BasePage):
def build(self) -> Gtk.Widget:
tb, header, _, content = self._make_toolbar_page("Startup Programs")
self._content = content
self.refresh()
return tb
def refresh(self):
self._rebuild()
def _get_entries(self) -> list[KdlNode]:
return [
n
for n in self._nodes
if n.name in ("spawn-at-startup", "spawn-sh-at-startup")
]
def _rebuild(self):
# Clear existing content
while True:
child = self._content.get_first_child()
if child is None:
break
self._content.remove(child)
entries = self._get_entries()
if not entries:
status = Adw.StatusPage(
title="No Startup Programs",
description="Programs added here will launch automatically when niri starts.",
icon_name="applications-system-symbolic",
)
add_btn = Gtk.Button(label="Add Program")
add_btn.add_css_class("pill")
add_btn.add_css_class("suggested-action")
add_btn.set_halign(Gtk.Align.CENTER)
add_btn.connect("clicked", self._on_add)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
box.set_valign(Gtk.Align.CENTER)
box.set_vexpand(True)
box.append(status)
box.append(add_btn)
self._content.append(box)
else:
grp = Adw.PreferencesGroup(
title="Startup Programs",
description=f"{len(entries)} program{'s' if len(entries) != 1 else ''} configured to launch",
)
for i, entry in enumerate(entries):
row = self._make_row(entry, i)
grp.add(row)
self._content.append(grp)
# Also add a convenient button at the bottom
add_btn = Gtk.Button(label="Add Another Program")
add_btn.add_css_class("pill")
add_btn.set_halign(Gtk.Align.CENTER)
add_btn.set_margin_top(16)
add_btn.connect("clicked", self._on_add)
self._content.append(add_btn)
def _make_row(self, node: KdlNode, idx: int) -> Adw.ActionRow:
cmd = " ".join(str(a) for a in node.args)
is_sh = "sh" in node.name
cmd_str = GLib.markup_escape_text(cmd) if cmd else "(empty)"
row = Adw.ActionRow(
title=cmd_str or "(empty)",
subtitle="Via shell (spawn-sh-at-startup)" if is_sh else "Launched directly",
)
row.set_activatable(True)
row.connect("activated", lambda *_, i=idx: self._on_edit(i))
del_btn = Gtk.Button(icon_name="user-trash-symbolic")
del_btn.set_valign(Gtk.Align.CENTER)
del_btn.add_css_class("flat")
del_btn.add_css_class("error")
del_btn.set_tooltip_text("Remove startup entry")
del_btn.connect("clicked", lambda *_, i=idx: self._on_delete(i))
row.add_suffix(del_btn)
return row
def _on_add(self, *_):
self._show_dialog(None, -1)
def _on_edit(self, idx: int):
entries = self._get_entries()
if 0 <= idx < len(entries):
self._show_dialog(entries[idx], idx)
def _on_delete(self, idx: int):
entries = self._get_entries()
if 0 <= idx < len(entries):
self._nodes.remove(entries[idx])
self._commit("remove startup entry")
self._rebuild()
def _show_dialog(self, node: KdlNode | None, idx: int):
dialog = Adw.AlertDialog(
heading="Startup Program", body="Enter the command to launch at startup."
)
cmd_entry = Adw.EntryRow(title="Command")
sh_switch = Adw.SwitchRow(title="Use shell (spawn-sh-at-startup)")
if node:
cmd_entry.set_text(" ".join(str(a) for a in node.args))
sh_switch.set_active("sh" in node.name)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
grp = Adw.PreferencesGroup()
grp.add(cmd_entry)
grp.add(sh_switch)
box.append(grp)
dialog.set_extra_child(box)
dialog.add_response("cancel", "Cancel")
dialog.add_response("save", "Save")
dialog.set_response_appearance("save", Adw.ResponseAppearance.SUGGESTED)
def _on_resp(d, r):
if r != "save":
return
cmd = cmd_entry.get_text().strip()
if not cmd:
return
is_sh = sh_switch.get_active()
node_name = "spawn-sh-at-startup" if is_sh else "spawn-at-startup"
if is_sh:
# sh -c expects a single string; store the whole command as one arg
args = [cmd]
else:
try:
args = shlex.split(cmd)
except ValueError:
args = cmd.split()
new_node = KdlNode(node_name, args=args)
entries = self._get_entries()
if idx >= 0 and 0 <= idx < len(entries):
i = self._nodes.index(entries[idx])
self._nodes[i] = new_node
else:
self._nodes.append(new_node)
self._commit("startup entry")
self._rebuild()
dialog.connect("response", _on_resp)
dialog.present(self._win)

File diff suppressed because it is too large Load Diff

155
nirimod/pages/workspaces.py Normal file
View File

@@ -0,0 +1,155 @@
"""Workspaces page."""
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.kdl_parser import KdlNode, set_child_arg
from nirimod import niri_ipc
from nirimod.pages.base import BasePage
class WorkspacesPage(BasePage):
def build(self) -> Gtk.Widget:
tb, header, _, content = self._make_toolbar_page("Workspaces")
self._content = content
add_btn = Gtk.Button(icon_name="list-add-symbolic")
add_btn.add_css_class("flat")
add_btn.connect("clicked", self._on_add)
header.pack_end(add_btn)
self._grp = Adw.PreferencesGroup(
title="Named Workspaces",
description="Named workspaces open immediately at niri startup",
)
content.append(self._grp)
self.refresh()
return tb
def refresh(self):
self._rebuild()
def _get_ws_nodes(self) -> list[KdlNode]:
return [n for n in self._nodes if n.name == "workspace"]
def _rebuild(self):
parent = self._grp.get_parent()
if parent is None:
return
def _on_outputs(outputs_data):
ws_nodes = self._get_ws_nodes()
outputs = [o.get("name", "") for o in outputs_data]
output_model = Gtk.StringList.new(["(any)"] + outputs)
new_grp = Adw.PreferencesGroup(
title="Named Workspaces", description=f"{len(ws_nodes)} workspace(s)"
)
for i, ws in enumerate(ws_nodes):
row = self._make_ws_row(ws, i, outputs, output_model)
new_grp.add(row)
parent.remove(self._grp)
parent.append(new_grp)
self._grp = new_grp
niri_ipc.get_outputs(_on_outputs)
def _make_ws_row(
self, ws: KdlNode, idx: int, outputs: list[str], output_model: Gtk.StringList
) -> Adw.ExpanderRow:
name = ws.args[0] if ws.args else f"workspace-{idx + 1}"
assigned_out = ws.child_arg("open-on-output") or ""
exp = Adw.ExpanderRow(title=name)
name_row = Adw.EntryRow(title="Name")
name_row.set_text(str(name))
name_row.set_show_apply_button(True)
name_row.connect("apply", lambda r, i=idx: self._rename_ws(i, r.get_text()))
exp.add_row(name_row)
out_row = Adw.ComboRow(title="Open on Output")
out_list = ["(any)"] + outputs
out_row.set_model(Gtk.StringList.new(out_list))
if assigned_out in outputs:
out_row.set_selected(out_list.index(assigned_out))
out_row.connect(
"notify::selected",
lambda r, _, i=idx, ol=out_list: self._set_ws_output(
i, ol[r.get_selected()]
),
)
exp.add_row(out_row)
del_btn = Gtk.Button(icon_name="user-trash-symbolic")
del_btn.add_css_class("flat")
del_btn.add_css_class("error")
del_btn.set_valign(Gtk.Align.CENTER)
del_btn.connect("clicked", lambda *_, i=idx: self._on_delete(i))
exp.add_suffix(del_btn)
return exp
def _on_add(self, *_):
dialog = Adw.AlertDialog(
heading="Add Workspace", body="Enter a name for the new workspace."
)
entry = Adw.EntryRow(title="Workspace Name")
grp = Adw.PreferencesGroup()
grp.add(entry)
dialog.set_extra_child(grp)
dialog.add_response("cancel", "Cancel")
dialog.add_response("add", "Add")
dialog.set_response_appearance("add", Adw.ResponseAppearance.SUGGESTED)
def _on_resp(d, r):
if r != "add":
return
name = entry.get_text().strip()
if not name:
return
node = KdlNode("workspace", args=[name])
ws_nodes = self._get_ws_nodes()
if ws_nodes:
last_idx = self._nodes.index(ws_nodes[-1])
self._nodes.insert(last_idx + 1, node)
else:
# If no workspaces, insert at the top
self._nodes.insert(0, node)
self._commit("add workspace")
self._rebuild()
dialog.connect("response", _on_resp)
dialog.present(self._win)
def _on_delete(self, idx: int):
ws_nodes = self._get_ws_nodes()
if 0 <= idx < len(ws_nodes):
self._nodes.remove(ws_nodes[idx])
self._commit("remove workspace")
self._rebuild()
def _rename_ws(self, idx: int, name: str):
ws_nodes = self._get_ws_nodes()
if 0 <= idx < len(ws_nodes) and name.strip():
ws_nodes[idx].args = [name.strip()]
self._commit("rename workspace")
self._rebuild()
def _set_ws_output(self, idx: int, output: str):
ws_nodes = self._get_ws_nodes()
if 0 <= idx < len(ws_nodes):
ws = ws_nodes[idx]
if output and output != "(any)":
set_child_arg(ws, "open-on-output", output)
else:
from nirimod.kdl_parser import remove_child
remove_child(ws, "open-on-output")
self._commit("workspace output")

72
nirimod/profiles.py Normal file
View File

@@ -0,0 +1,72 @@
"""Named config profiles: save/load Niri config snapshots."""
from __future__ import annotations
import shutil
from pathlib import Path
from nirimod import kdl_parser
def list_profiles() -> list[str]:
if not kdl_parser.PROFILES_DIR.exists():
return []
names = [p.stem for p in kdl_parser.PROFILES_DIR.glob("*.kdl")]
names += [p.name for p in kdl_parser.PROFILES_DIR.iterdir() if p.is_dir()]
return sorted(names)
def save_profile(name: str, source_files: set[Path] | None = None) -> None:
kdl_parser.PROFILES_DIR.mkdir(parents=True, exist_ok=True)
if source_files and len(source_files) > 1:
dest_dir = kdl_parser.PROFILES_DIR / name
dest_dir.mkdir(exist_ok=True)
for p in source_files:
if p.exists():
try:
rel = p.relative_to(kdl_parser.NIRI_CONFIG.parent)
dest = dest_dir / rel
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(p, dest)
except ValueError:
shutil.copy2(p, dest_dir / p.name)
else:
if kdl_parser.NIRI_CONFIG.exists():
shutil.copy2(kdl_parser.NIRI_CONFIG, kdl_parser.PROFILES_DIR / f"{name}.kdl")
def load_profile(name: str) -> bool:
dir_profile = kdl_parser.PROFILES_DIR / name
if dir_profile.is_dir():
def _restore(src_dir, dest_dir):
for f in src_dir.iterdir():
if f.is_file():
rel = f.relative_to(dir_profile)
target = dest_dir / rel
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(f, target)
elif f.is_dir():
_restore(f, dest_dir)
_restore(dir_profile, kdl_parser.NIRI_CONFIG.parent)
return True
src = kdl_parser.PROFILES_DIR / f"{name}.kdl"
if not src.exists():
return False
kdl_parser.save_niri_config(kdl_parser.parse_kdl(src.read_text()))
return True
def delete_profile(name: str) -> bool:
dir_profile = kdl_parser.PROFILES_DIR / name
if dir_profile.is_dir():
shutil.rmtree(dir_profile)
return True
p = kdl_parser.PROFILES_DIR / f"{name}.kdl"
if p.exists():
p.unlink()
return True
return False

149
nirimod/state.py Normal file
View File

@@ -0,0 +1,149 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
from nirimod.kdl_parser import (
NIRI_CONFIG,
KdlNode,
load_niri_config_multi,
parse_kdl,
save_niri_config,
save_niri_config_multi,
write_kdl,
)
from nirimod.undo import UndoEntry, UndoManager
if TYPE_CHECKING:
pass
@dataclass
class RuntimeInfo:
niri_running: bool = False
has_touchpad: bool = False
class AppState:
def __init__(self) -> None:
self._nodes: list[KdlNode] = []
self._saved_kdl: str = ""
self._undo: UndoManager = UndoManager()
self._runtime: RuntimeInfo = RuntimeInfo()
self._dirty: bool = False
self._include_slots: list[tuple[KdlNode, Path]] = []
self._source_files: set[Path] = set()
def load(self) -> None:
from nirimod import niri_ipc
self._runtime = RuntimeInfo(
niri_running=niri_ipc.is_niri_running(),
has_touchpad=niri_ipc.has_touchpad(),
)
self._nodes, self._include_slots = load_niri_config_multi()
self._source_files = {NIRI_CONFIG}
for _, path in self._include_slots:
if path.exists():
self._source_files.add(path)
self._saved_kdl = write_kdl(self._nodes) if self._nodes else ""
self._dirty = False
@property
def nodes(self) -> list[KdlNode]:
return self._nodes
@nodes.setter
def nodes(self, value: list[KdlNode]) -> None:
self._nodes = value
@property
def saved_kdl(self) -> str:
return self._saved_kdl
@property
def source_files(self) -> set[Path]:
return self._source_files
@property
def include_slots(self) -> list[tuple[KdlNode, Path]]:
return self._include_slots
@property
def is_multi_file(self) -> bool:
return bool(self._include_slots)
@property
def niri_running(self) -> bool:
return self._runtime.niri_running
@property
def has_touchpad(self) -> bool:
return self._runtime.has_touchpad
@property
def is_dirty(self) -> bool:
return self._dirty
def mark_dirty(self) -> None:
self._dirty = True
def mark_clean(self) -> None:
self._dirty = False
@property
def undo(self) -> UndoManager:
return self._undo
def push_undo(self, description: str, before: str, after: str) -> None:
self._undo.push(UndoEntry(description, before, after))
def apply_undo(self) -> UndoEntry | None:
entry = self._undo.pop_undo()
if entry is None:
return None
self._nodes = parse_kdl(entry.snapshot_before)
self._dirty = entry.snapshot_before != self._saved_kdl
return entry
def apply_redo(self) -> UndoEntry | None:
entry = self._undo.pop_redo()
if entry is None:
return None
self._nodes = parse_kdl(entry.snapshot_after)
self._dirty = entry.snapshot_after != self._saved_kdl
return entry
def discard(self) -> None:
self._nodes = parse_kdl(self._saved_kdl) if self._saved_kdl else []
self._undo.clear()
self._dirty = False
def commit_save(self, new_kdl: str) -> None:
self._saved_kdl = new_kdl
self._undo.clear()
self._dirty = False
def reload_from_disk(self) -> None:
self._nodes, self._include_slots = load_niri_config_multi()
self._source_files = {NIRI_CONFIG}
for _, path in self._include_slots:
if path.exists():
self._source_files.add(path)
def write_current_kdl(self) -> str:
return write_kdl(self._nodes)
def write_to_path(self, path: Path | None = None) -> None:
if path is not None:
# Explicit path (e.g. validation temp file) — single file write
save_niri_config(self._nodes, path=path)
elif self._include_slots:
save_niri_config_multi(self._nodes, self._include_slots)
else:
save_niri_config(self._nodes)

325
nirimod/theme.py Normal file
View File

@@ -0,0 +1,325 @@
"""CSS theme definitions for NiriMod."""
CSS = """
/* --- Nirimod -- Purple Theme --- */
/* --- Accent --- */
@define-color nm_accent #9b6dff;
@define-color nm_accent_mid #7c3aed;
@define-color nm_accent_dim rgba(155, 109, 255, 0.13);
@define-color nm_accent_hover rgba(155, 109, 255, 0.20);
@define-color nm_accent_border rgba(155, 109, 255, 0.28);
/* --- Surfaces --- */
@define-color window_bg_color #111114;
@define-color window_fg_color #e8e8ed;
@define-color view_bg_color #18181c;
@define-color view_fg_color #e8e8ed;
@define-color headerbar_bg_color #111114;
@define-color card_bg_color #1e1e24;
@define-color card_fg_color #e8e8ed;
@define-color popover_bg_color #1e1e24;
@define-color popover_fg_color #e8e8ed;
@define-color dialog_bg_color #18181c;
@define-color dialog_fg_color #e8e8ed;
/* --- Borders --- */
@define-color nm_border rgba(255, 255, 255, 0.07);
@define-color nm_border_strong rgba(255, 255, 255, 0.12);
/* --- Window --- */
window {
background-color: @window_bg_color;
color: @window_fg_color;
}
/* --- Header Bars --- */
headerbar,
.nm-sidebar-bg {
background-color: @window_bg_color;
background-image: none;
box-shadow: none;
border-bottom: 1px solid @nm_border;
color: @window_fg_color;
}
/* --- Sidebar --- */
.navigation-sidebar {
background-color: transparent;
border-right: 1px solid @nm_border;
}
.nm-sidebar-listbox {
background: transparent;
border: none;
}
.nm-sidebar-listbox row {
border-radius: 7px;
margin: 1px 4px;
padding: 5px 8px;
transition: background 130ms ease;
color: @window_fg_color;
}
.nm-sidebar-listbox row:hover {
background: rgba(255, 255, 255, 0.045);
}
.nm-sidebar-listbox row:selected {
background: @nm_accent_dim;
color: @nm_accent;
}
.nm-sidebar-listbox row:selected image,
.nm-sidebar-listbox row:selected label {
color: @nm_accent;
}
/* --- Section Labels --- */
.nm-sidebar-section-label {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
color: rgba(255, 255, 255, 0.30);
}
/* --- Search --- */
.nm-search-entry {
color: @window_fg_color;
background-color: @card_bg_color;
border: 1px solid @nm_border;
border-radius: 8px;
}
.nm-search-entry > box { color: @window_fg_color; }
.nm-search-entry text { color: @window_fg_color; }
.nm-search-results {
background: transparent;
border: none;
}
.nm-search-results row {
padding: 8px 12px;
border-radius: 7px;
margin: 2px 4px;
transition: background 110ms ease;
}
.nm-search-results row:hover {
background: @nm_accent_dim;
}
/* --- Content Cards --- */
.nm-card,
preferencesgroup > box {
background-color: @card_bg_color;
border: 1px solid @nm_border;
border-radius: 12px;
padding: 4px;
}
row {
border-radius: 7px;
transition: background 110ms ease;
}
row:hover {
background: rgba(255, 255, 255, 0.025);
}
/* --- Unsaved Changes Bar --- */
.nm-dirty-bar {
background: rgba(155, 109, 255, 0.07);
border-top: 1px solid rgba(155, 109, 255, 0.18);
padding: 8px 20px;
}
/* --- Niri Banner --- */
.nm-niri-banner {
background: rgba(180, 110, 0, 0.10);
color: rgba(240, 180, 50, 0.90);
padding: 6px 16px;
font-size: 13px;
border-bottom: 1px solid rgba(180, 110, 0, 0.18);
}
/* --- Badges & Status --- */
.nm-badge {
background: @nm_accent;
color: #111114;
border-radius: 12px;
font-size: 10px;
font-weight: 700;
padding: 1px 7px;
min-width: 16px;
}
/* --- Inline Tag Chips --- */
.tag {
background: rgba(255, 255, 255, 0.06);
color: @window_fg_color;
border: 1px solid @nm_border_strong;
border-radius: 5px;
font-size: 11px;
font-weight: 600;
padding: 1px 7px;
}
.tag.accent {
background: @nm_accent_dim;
color: @nm_accent;
border-color: @nm_accent_border;
}
/* --- Buttons --- */
button.suggested-action {
border-radius: 9px;
font-weight: 600;
background: @nm_accent_mid;
}
/* --- Toasts --- */
toast {
background-color: @card_bg_color;
color: @card_fg_color;
border: 1px solid @nm_accent_border;
border-radius: 20px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.45);
margin-bottom: 20px;
}
toast label { font-weight: 500; }
/* --- Code Editor --- */
.code-editor {
background-color: #0d0d10;
color: #e8e8ed;
border: 1px solid @nm_border;
border-radius: 10px;
}
/* --- Keyboard Visualizer --- */
.nm-kb-action-panel {
background-color: @card_bg_color;
border: 1px solid @nm_border;
border-radius: 12px;
padding: 4px;
}
.nm-kb-key-id-label {
font-size: 20px;
font-weight: 700;
color: @window_fg_color;
}
.nm-kb-swatch {
min-width: 12px;
min-height: 12px;
border-radius: 3px;
}
/* --- Keycaps --- */
.nm-keycap-main, .nm-keycap-mod {
background-color: @nm_accent_dim;
border: 1px solid @nm_accent_border;
border-radius: 5px;
padding: 2px 8px;
font-size: 12px;
font-weight: 600;
color: @nm_accent;
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.3);
}
.nm-keycap-main {
background-color: rgba(155, 109, 255, 0.22);
border-color: rgba(155, 109, 255, 0.45);
color: rgba(210, 190, 255, 1.0);
font-weight: 700;
}
.nm-keycap-mod { opacity: 0.80; }
.nm-keycap-purple {
background: @nm_accent_dim;
color: @nm_accent;
border: 1px solid @nm_accent_border;
border-radius: 5px;
padding: 2px 8px;
font-weight: 600;
font-size: 12px;
}
/* --- Pulse Highlight (search) --- */
@keyframes pulse-highlight {
0% { background-color: transparent; }
18% { background-color: rgba(155, 109, 255, 0.28); }
100% { background-color: transparent; }
}
.nm-pulse-highlight {
animation-name: pulse-highlight;
animation-duration: 1.4s;
animation-timing-function: ease-out;
}
/* --- Animations Page --- */
.nm-anim-banner {
background: @nm_accent_dim;
border: 1px solid @nm_accent_border;
border-radius: 10px;
padding: 10px 16px;
color: @nm_accent;
}
.nm-anim-banner button {
background: rgba(155, 109, 255, 0.15);
border: 1px solid @nm_accent_border;
color: @nm_accent;
font-weight: 600;
border-radius: 8px;
padding: 4px 14px;
}
.nm-anim-banner button:hover {
background: @nm_accent_hover;
}
.nm-preset-icon {
font-size: 18px;
min-width: 28px;
}
/* --- Bindings Page --- */
.nm-binding-card {
background: rgba(30, 30, 35, 0.6);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
padding: 16px;
transition: all 200ms ease;
}
.nm-binding-card:hover {
background: rgba(45, 45, 50, 0.8);
border-color: rgba(147, 51, 234, 0.4);
}
.nm-binding-actions-label {
color: rgba(255, 255, 255, 0.4);
font-weight: 800;
letter-spacing: 0.05em;
font-size: 0.7rem;
}
.nm-binding-action-name {
color: rgba(192, 132, 252, 1.0);
font-weight: 600;
font-size: 1.0rem;
}
.nm-keycap-purple {
background: #581c87;
color: white;
border-radius: 6px;
padding: 2px 8px;
font-weight: bold;
font-size: 0.8rem;
}
""".encode("utf-8")

61
nirimod/undo.py Normal file
View File

@@ -0,0 +1,61 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class UndoEntry:
description: str
snapshot_before: str
snapshot_after: str
class UndoManager:
def __init__(self, max_depth: int = 100):
self._stack: list[UndoEntry] = []
self._redo_stack: list[UndoEntry] = []
self._max = max_depth
def push(self, entry: UndoEntry) -> None:
self._stack.append(entry)
if len(self._stack) > self._max:
self._stack.pop(0)
self._redo_stack.clear()
@property
def last_snapshot(self) -> str | None:
if self._stack:
return self._stack[-1].snapshot_after
return None
@property
def last_description(self) -> str | None:
if self._stack:
return self._stack[-1].description
return None
def pop_undo(self) -> UndoEntry | None:
if not self._stack:
return None
entry = self._stack.pop()
self._redo_stack.append(entry)
return entry
def pop_redo(self) -> UndoEntry | None:
if not self._redo_stack:
return None
entry = self._redo_stack.pop()
self._stack.append(entry)
return entry
def can_undo(self) -> bool:
return bool(self._stack)
def can_redo(self) -> bool:
return bool(self._redo_stack)
def clear(self) -> None:
self._stack.clear()
self._redo_stack.clear()

135
nirimod/updater.py Normal file
View File

@@ -0,0 +1,135 @@
import json
import os
import shlex
import shutil
import subprocess
import tempfile
import threading
import urllib.request
import stat
API_URL = "https://api.github.com/repos/srinivasr/nirimod/commits/main"
INSTALL_DIR = os.path.expanduser("~/.local/share/nirimod")
FALLBACK_TERMINALS = [
"xdg-terminal-exec",
"gnome-terminal",
"kgx", # GNOME Console
"kitty",
"ghostty",
"alacritty",
"konsole",
"foot",
"xterm",
]
def check_for_updates(callback):
def _do_check():
try:
from gi.repository import GLib
if not os.path.isdir(os.path.join(INSTALL_DIR, ".git")):
GLib.idle_add(callback, None, None)
return
local_hash = subprocess.check_output(
["git", "rev-parse", "HEAD"],
cwd=INSTALL_DIR,
text=True,
).strip()
req = urllib.request.Request(
API_URL, headers={"User-Agent": "NiriMod-Updater"}
)
with urllib.request.urlopen(req, timeout=5) as resp:
data = json.loads(resp.read().decode("utf-8"))
remote_hash = data.get("sha")
commit_msg = data.get("commit", {}).get(
"message", "New update available"
)
if _update_available(local_hash, remote_hash, INSTALL_DIR):
GLib.idle_add(callback, remote_hash, commit_msg)
else:
GLib.idle_add(callback, None, None)
except Exception as e:
print(f"Update check failed: {e}")
GLib.idle_add(callback, None, None)
threading.Thread(target=_do_check, daemon=True).start()
def _commit_is_ancestor(commit_hash: str, install_dir: str) -> bool:
result = subprocess.run(
["git", "merge-base", "--is-ancestor", commit_hash, "HEAD"],
cwd=install_dir,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
return result.returncode == 0
def _update_available(
local_hash: str, remote_hash: str | None, install_dir: str = INSTALL_DIR
) -> bool:
if not remote_hash or remote_hash == local_hash:
return False
if _commit_is_ancestor(remote_hash, install_dir):
return False
return True
def _terminal_candidates():
terminal = os.environ.get("TERMINAL", "").strip()
if terminal:
yield terminal
yield from FALLBACK_TERMINALS
def _build_terminal_command(terminal: str, script_path: str) -> list[str] | None:
try:
parts = shlex.split(terminal)
except ValueError:
return None
if not parts:
return None
if os.path.basename(parts[0]) == "xdg-terminal-exec":
return [*parts, script_path]
if parts[-1] in {"-e", "--execute", "-x"}:
return [*parts, script_path]
return [*parts, "-e", script_path]
def launch_updater_in_terminal():
script_content = """#!/usr/bin/env bash
echo "Starting NiriMod update..."
curl -sSL https://raw.githubusercontent.com/srinivasr/nirimod/main/install.sh | bash -s -- --install
echo ""
echo "Update complete! Press Enter to close this window."
read
"""
script_path = os.path.join(tempfile.gettempdir(), "nirimod_update.sh")
with open(script_path, "w") as f:
f.write(script_content)
os.chmod(script_path, stat.S_IRWXU)
for term in _terminal_candidates():
command = _build_terminal_command(term, script_path)
if command is None or shutil.which(command[0]) is None:
continue
try:
subprocess.Popen(command)
return
except Exception:
continue
print("Could not find a suitable terminal to launch the update.")

View File

@@ -0,0 +1,5 @@
"""NiriMod custom widgets."""
from nirimod.widgets.keyboard_visualizer import KeyboardVisualizer, normalize_key_id
__all__ = ["KeyboardVisualizer", "normalize_key_id"]

View File

@@ -0,0 +1,696 @@
"""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)

1135
nirimod/window.py Normal file

File diff suppressed because it is too large Load Diff

299
nirimod/window_effects.py Normal file
View File

@@ -0,0 +1,299 @@
"""Helpers for Niri global window effect rules."""
from __future__ import annotations
from nirimod.kdl_parser import KdlNode, remove_child, set_child_arg, set_node_flag
_RULE_CHILD_ORDER = [
"match",
"exclude",
"geometry-corner-radius",
"clip-to-geometry",
"draw-border-with-background",
"opacity",
"background-effect",
]
_EFFECT_CHILD_ORDER = ["blur", "xray"]
_BLUR_CONFIG_CHILD_ORDER = ["off", "passes", "offset", "noise", "saturation"]
def _is_global_window_rule(node: KdlNode) -> bool:
return (
node.name == "window-rule"
and not node.get_children("match")
and not node.get_children("exclude")
)
def _is_focused_window_rule(node: KdlNode) -> bool:
if node.name != "window-rule" or node.get_children("exclude"):
return False
matches = node.get_children("match")
if len(matches) != 1:
return False
match = matches[0]
return match.args == [] and match.props == {"is-focused": True}
def _global_window_rule(nodes: list[KdlNode]) -> KdlNode | None:
return next((n for n in reversed(nodes) if _is_global_window_rule(n)), None)
def _focused_window_rule(nodes: list[KdlNode]) -> KdlNode | None:
return next((n for n in reversed(nodes) if _is_focused_window_rule(n)), None)
def _blur_config_node(nodes: list[KdlNode]) -> KdlNode | None:
return next((n for n in reversed(nodes) if n.name == "blur"), None)
def _ensure_blur_config_node(nodes: list[KdlNode]) -> KdlNode:
blur = _blur_config_node(nodes)
if blur is None:
blur = KdlNode("blur")
blur.leading_trivia = "\n"
nodes.append(blur)
return blur
def _ensure_global_window_rule(nodes: list[KdlNode]) -> KdlNode:
rule = _global_window_rule(nodes)
if rule is None:
rule = KdlNode("window-rule")
rule.leading_trivia = "\n"
nodes.append(rule)
return rule
def _ensure_focused_window_rule(nodes: list[KdlNode]) -> KdlNode:
rule = _focused_window_rule(nodes)
if rule is None:
rule = KdlNode("window-rule")
rule.leading_trivia = "\n"
rule.children.append(KdlNode("match", props={"is-focused": True}))
nodes.append(rule)
return rule
def _background_effect(rule: KdlNode) -> KdlNode | None:
return rule.get_child("background-effect")
def _ensure_background_effect(rule: KdlNode) -> KdlNode:
effect = _background_effect(rule)
if effect is None:
effect = KdlNode("background-effect")
effect.leading_trivia = "\n"
rule.children.append(effect)
return effect
def _remove_rule_if_empty(nodes: list[KdlNode], rule: KdlNode) -> None:
if not rule.args and not rule.props and not rule.children and rule in nodes:
nodes.remove(rule)
def _remove_background_effect_if_empty(rule: KdlNode) -> None:
effect = _background_effect(rule)
if effect and not effect.args and not effect.props and not effect.children:
remove_child(rule, "background-effect")
def _compact_generated_spacing(node: KdlNode) -> None:
if not node.trailing_trivia or node.trailing_trivia.isspace():
node.trailing_trivia = "\n"
for child in node.children:
if not child.leading_trivia or child.leading_trivia.isspace():
child.leading_trivia = ""
if not child.trailing_trivia or child.trailing_trivia.isspace():
child.trailing_trivia = ""
if child.name == "background-effect":
_compact_generated_spacing(child)
def _sort_children_by_name(node: KdlNode, order: list[str]) -> None:
order_index = {name: index for index, name in enumerate(order)}
indexed_children = list(enumerate(node.children))
indexed_children.sort(
key=lambda item: (order_index.get(item[1].name, len(order)), item[0])
)
node.children = [child for _, child in indexed_children]
def _finalize_window_rule(rule: KdlNode) -> None:
effect = _background_effect(rule)
if effect is not None:
_sort_children_by_name(effect, _EFFECT_CHILD_ORDER)
_sort_children_by_name(rule, _RULE_CHILD_ORDER)
_compact_generated_spacing(rule)
def _finalize_blur_config(blur: KdlNode) -> None:
_sort_children_by_name(blur, _BLUR_CONFIG_CHILD_ORDER)
_compact_generated_spacing(blur)
def _rule_opacity(rule: KdlNode | None) -> float:
if rule is None:
return 1.0
value = rule.child_arg("opacity", 1.0)
try:
return float(value)
except (TypeError, ValueError):
return 1.0
def get_global_draw_border_with_background(nodes: list[KdlNode]) -> bool:
rule = _global_window_rule(nodes)
if rule is None:
return True
return rule.child_arg("draw-border-with-background", True) is not False
def set_global_draw_border_with_background(nodes: list[KdlNode], enabled: bool) -> None:
rule = _ensure_global_window_rule(nodes)
if enabled:
remove_child(rule, "draw-border-with-background")
else:
set_child_arg(rule, "draw-border-with-background", False)
_finalize_window_rule(rule)
_remove_rule_if_empty(nodes, rule)
def blur_effects_enabled(nodes: list[KdlNode]) -> bool:
blur = _blur_config_node(nodes)
return blur is None or blur.get_child("off") is None
def set_blur_effects_enabled(nodes: list[KdlNode], enabled: bool) -> None:
blur = _ensure_blur_config_node(nodes)
set_node_flag(blur, "off", not enabled)
_finalize_blur_config(blur)
_remove_rule_if_empty(nodes, blur)
if enabled:
rule = _ensure_global_window_rule(nodes)
if _rule_opacity(rule) >= 1.0:
set_child_arg(rule, "opacity", 0.9)
_finalize_window_rule(rule)
else:
set_global_window_blur(nodes, False)
set_focused_window_blur(nodes, False)
def global_window_blur_enabled(nodes: list[KdlNode]) -> bool:
rule = _global_window_rule(nodes)
return _rule_blur_enabled(rule)
def focused_window_blur_enabled(nodes: list[KdlNode]) -> bool:
rule = _focused_window_rule(nodes)
return _rule_blur_enabled(rule)
def _rule_blur_enabled(rule: KdlNode | None) -> bool:
if rule is None:
return False
effect = _background_effect(rule)
if effect is None:
return False
blur = effect.get_child("blur")
return blur is not None and (not blur.args or blur.args[0] is True)
def set_global_window_blur(nodes: list[KdlNode], enabled: bool) -> None:
if enabled:
rule = _ensure_global_window_rule(nodes)
if _rule_opacity(rule) >= 1.0:
set_child_arg(rule, "opacity", 0.9)
effect = _ensure_background_effect(rule)
set_child_arg(effect, "blur", True)
_finalize_window_rule(rule)
return
existing_rule = _global_window_rule(nodes)
if existing_rule is None:
return
existing_effect = _background_effect(existing_rule)
if existing_effect is not None:
remove_child(existing_effect, "blur")
_remove_background_effect_if_empty(existing_rule)
remove_child(existing_rule, "opacity")
_finalize_window_rule(existing_rule)
_remove_rule_if_empty(nodes, existing_rule)
def set_focused_window_blur(nodes: list[KdlNode], enabled: bool) -> None:
if enabled:
rule = _ensure_focused_window_rule(nodes)
effect = _ensure_background_effect(rule)
set_child_arg(effect, "blur", True)
_finalize_window_rule(rule)
return
existing_rule = _focused_window_rule(nodes)
if existing_rule is None:
return
existing_effect = _background_effect(existing_rule)
if existing_effect is not None:
remove_child(existing_effect, "blur")
_remove_background_effect_if_empty(existing_rule)
_finalize_window_rule(existing_rule)
if len(existing_rule.children) == 1 and existing_rule.get_child("match"):
nodes.remove(existing_rule)
def global_window_xray_enabled(nodes: list[KdlNode]) -> bool:
rule = _global_window_rule(nodes)
if rule is None:
return True
effect = _background_effect(rule)
if effect is None:
return True
xray = effect.get_child("xray")
return xray is None or not xray.args or xray.args[0] is True
def set_global_window_xray(nodes: list[KdlNode], enabled: bool) -> None:
rule = _ensure_global_window_rule(nodes)
effect = _ensure_background_effect(rule)
set_child_arg(effect, "xray", enabled)
_finalize_window_rule(rule)
def get_global_window_opacity(nodes: list[KdlNode]) -> float:
return _rule_opacity(_global_window_rule(nodes))
def set_global_window_opacity(nodes: list[KdlNode], opacity: float) -> None:
rule = _ensure_global_window_rule(nodes)
if opacity < 1.0:
set_child_arg(rule, "opacity", round(opacity, 2))
else:
remove_child(rule, "opacity")
_finalize_window_rule(rule)
_remove_rule_if_empty(nodes, rule)
def get_global_corner_radius(nodes: list[KdlNode]) -> int:
rule = _global_window_rule(nodes)
if rule is None:
return 0
value = rule.child_arg("geometry-corner-radius", 0)
try:
return int(value)
except (TypeError, ValueError):
return 0
def set_global_corner_radius(nodes: list[KdlNode], radius: int) -> None:
rule = _ensure_global_window_rule(nodes)
if radius > 0:
set_child_arg(rule, "geometry-corner-radius", radius)
set_child_arg(rule, "clip-to-geometry", True)
else:
remove_child(rule, "geometry-corner-radius")
remove_child(rule, "clip-to-geometry")
_finalize_window_rule(rule)
_remove_rule_if_empty(nodes, rule)

168
nirimod/xkb_helper.py Normal file
View File

@@ -0,0 +1,168 @@
import ctypes
import ctypes.util
import os
import xml.etree.ElementTree as ET
class XkbHelper:
def __init__(self):
self.lib = None
self.ctx = None
self.keymap = None
self.state = None
path = ctypes.util.find_library("xkbcommon")
if not path:
for p in [
"/usr/lib/libxkbcommon.so.0",
"/usr/lib64/libxkbcommon.so.0",
"/lib/x86_64-linux-gnu/libxkbcommon.so.0",
"/usr/lib/x86_64-linux-gnu/libxkbcommon.so.0",
"/usr/lib/libxkbcommon.so",
"/usr/lib64/libxkbcommon.so",
"/lib/libxkbcommon.so.0",
"/run/current-system/sw/lib/libxkbcommon.so.0",
]:
if os.path.exists(p):
path = p
break
if not path:
return
try:
self.lib = ctypes.CDLL(path)
# Prototypes
self.lib.xkb_context_new.restype = ctypes.c_void_p
self.lib.xkb_keymap_new_from_names.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_int]
self.lib.xkb_keymap_new_from_names.restype = ctypes.c_void_p
self.lib.xkb_state_new.argtypes = [ctypes.c_void_p]
self.lib.xkb_state_new.restype = ctypes.c_void_p
self.lib.xkb_state_key_get_utf8.argtypes = [ctypes.c_void_p, ctypes.c_uint32, ctypes.c_char_p, ctypes.c_size_t]
self.lib.xkb_state_key_get_utf8.restype = ctypes.c_int
self.lib.xkb_state_key_get_one_sym.argtypes = [ctypes.c_void_p, ctypes.c_uint32]
self.lib.xkb_state_key_get_one_sym.restype = ctypes.c_uint32
self.lib.xkb_keysym_get_name.argtypes = [ctypes.c_uint32, ctypes.c_char_p, ctypes.c_size_t]
self.lib.xkb_keysym_get_name.restype = ctypes.c_int
self.lib.xkb_keymap_unref.argtypes = [ctypes.c_void_p]
self.lib.xkb_keymap_unref.restype = None
self.lib.xkb_state_unref.argtypes = [ctypes.c_void_p]
self.lib.xkb_state_unref.restype = None
self.ctx = self.lib.xkb_context_new(0)
except Exception:
self.lib = None
class XkbRuleNames(ctypes.Structure):
_fields_ = [
("rules", ctypes.c_char_p),
("model", ctypes.c_char_p),
("layout", ctypes.c_char_p),
("variant", ctypes.c_char_p),
("options", ctypes.c_char_p),
]
def set_layout(self, layout_id: str):
if not self.lib or not self.ctx:
return
parts = layout_id.split(":", 1)
layout_name = parts[0]
variant_name = parts[1] if len(parts) > 1 else ""
self._layout_bytes = layout_name.encode()
self._variant_bytes = variant_name.encode() if variant_name else None
names = self.XkbRuleNames(None, None, self._layout_bytes, self._variant_bytes, None)
if self.state:
self.lib.xkb_state_unref(self.state)
self.state = None
if self.keymap:
self.lib.xkb_keymap_unref(self.keymap)
self.keymap = None
self.keymap = self.lib.xkb_keymap_new_from_names(self.ctx, ctypes.byref(names), 0)
if self.keymap:
self.state = self.lib.xkb_state_new(self.keymap)
def get_label(self, keycode: int) -> str | None:
if not self.state:
return None
xkb_keycode = keycode + 8
buf = ctypes.create_string_buffer(32)
res = self.lib.xkb_state_key_get_utf8(self.state, xkb_keycode, buf, 32)
if res > 0:
return buf.value.decode('utf-8')
return None
def get_keysym_name(self, keycode: int) -> str | None:
if not self.state:
return None
xkb_keycode = keycode + 8
sym = self.lib.xkb_state_key_get_one_sym(self.state, xkb_keycode)
if sym == 0:
return None
buf = ctypes.create_string_buffer(64)
res = self.lib.xkb_keysym_get_name(sym, buf, 64)
if res >= 0:
return buf.value.decode('utf-8')
return None
@staticmethod
def get_available_layouts() -> list[tuple[str, str]]:
paths = [
"/usr/share/X11/xkb/rules/evdev.xml",
"/usr/share/X11/xkb/rules/base.xml",
"/usr/share/xkb/rules/evdev.xml",
"/usr/share/xkb/rules/base.xml",
"/run/current-system/sw/share/X11/xkb/rules/evdev.xml",
]
layouts = []
for p in paths:
if os.path.exists(p):
try:
tree = ET.parse(p)
root = tree.getroot()
for layout in root.findall(".//layout"):
config = layout.find("configItem")
if config is not None:
name = config.findtext("name")
desc = config.findtext("description")
if name and desc:
layouts.append((name, desc))
variant_list = layout.find("variantList")
if variant_list is not None:
for variant in variant_list.findall("variant"):
v_config = variant.find("configItem")
if v_config is not None:
v_name = v_config.findtext("name")
v_desc = v_config.findtext("description")
if name and v_name and v_desc:
layouts.append((f"{name}:{v_name}", v_desc))
if layouts:
# Sort by description
layouts.sort(key=lambda x: x[1])
return layouts
except Exception:
continue
return [("us", "English (US)"), ("us:dvorak", "English (Dvorak)"), ("it", "Italian"), ("fr", "French"), ("de", "German"), ("es", "Spanish")]