(Init): Added shit
This commit is contained in:
1
nirimod/__init__.py
Normal file
1
nirimod/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# nirimod
|
||||
80
nirimod/__main__.py
Normal file
80
nirimod/__main__.py
Normal 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
50
nirimod/app_settings.py
Normal 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
45
nirimod/backup.py
Normal 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
799
nirimod/kdl_parser.py
Normal 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
209
nirimod/niri_ipc.py
Normal 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
|
||||
0
nirimod/pages/__init__.py
Normal file
0
nirimod/pages/__init__.py
Normal file
1222
nirimod/pages/animations.py
Normal 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
452
nirimod/pages/appearance.py
Normal 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
98
nirimod/pages/base.py
Normal 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
776
nirimod/pages/bindings.py
Normal 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
|
||||
161
nirimod/pages/environment.py
Normal file
161
nirimod/pages/environment.py
Normal 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
200
nirimod/pages/gestures.py
Normal 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
380
nirimod/pages/input_page.py
Normal 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 & 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
284
nirimod/pages/layout.py
Normal 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
746
nirimod/pages/outputs.py
Normal 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 & 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
322
nirimod/pages/raw_config.py
Normal 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
171
nirimod/pages/startup.py
Normal 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)
|
||||
1014
nirimod/pages/window_rules.py
Normal file
1014
nirimod/pages/window_rules.py
Normal file
File diff suppressed because it is too large
Load Diff
155
nirimod/pages/workspaces.py
Normal file
155
nirimod/pages/workspaces.py
Normal 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
72
nirimod/profiles.py
Normal 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
149
nirimod/state.py
Normal 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
325
nirimod/theme.py
Normal 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
61
nirimod/undo.py
Normal 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
135
nirimod/updater.py
Normal 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.")
|
||||
5
nirimod/widgets/__init__.py
Normal file
5
nirimod/widgets/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""NiriMod custom widgets."""
|
||||
|
||||
from nirimod.widgets.keyboard_visualizer import KeyboardVisualizer, normalize_key_id
|
||||
|
||||
__all__ = ["KeyboardVisualizer", "normalize_key_id"]
|
||||
696
nirimod/widgets/keyboard_visualizer.py
Normal file
696
nirimod/widgets/keyboard_visualizer.py
Normal 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
1135
nirimod/window.py
Normal file
File diff suppressed because it is too large
Load Diff
299
nirimod/window_effects.py
Normal file
299
nirimod/window_effects.py
Normal 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
168
nirimod/xkb_helper.py
Normal 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")]
|
||||
Reference in New Issue
Block a user