(Init): Added shit

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

17
.gitignore vendored Normal file
View File

@@ -0,0 +1,17 @@
# Python
__pycache__/
*.py[cod]
# Virtual Environment
.venv/
# NiriMod Temporary Files
*.kdl.bak
*.kdl.tmp
# Scratch / debug scripts
/test_*.py
# Nix
result

54
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,54 @@
# Contributing to NiriMod
Thanks for your interest in contributing! NiriMod is a growing project and PRs are welcome.
## Development setup
System dependencies (Debian/Ubuntu names; adapt for your distro):
```bash
libcairo2-dev libgirepository-2.0-dev libgtk-4-dev libadwaita-1-dev
```
Then:
```bash
git clone https://github.com/srinivasr/nirimod.git
cd nirimod
uv sync
uv run nirimod
```
Requires Python 3.12+ and a running Niri instance for full manual testing.
## Before submitting a PR
Run the same checks that CI runs:
```bash
uv run ruff check --fix .
uv run ruff format .
uv run mypy nirimod
uv run pytest
```
All checks must pass before your PR can be merged.
## Code style
- Ruff enforces linting and formatting rules. Always run with `--fix` and `format`.
- Mypy must pass clean. Don't use `assert` for type narrowing — restructure the code instead.
- Follow existing patterns for option rows, pages, and GTK widgets rather than inventing new ones.
## Scope
- For larger changes, open an issue first so we can discuss the approach.
- System settings that aren't managed by Niri (like Wi-Fi, Bluetooth, general theming outside of compositor scopes) are out of scope.
## Reporting bugs
Open a [GitHub issue](https://github.com/srinivasr/nirimod/issues) and include:
- Niri version (`niri --version`)
- Steps to reproduce
- Relevant log output, if any

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 srinivasr
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

103
README.md Normal file
View File

@@ -0,0 +1,103 @@
<div align="center">
<h1>NiriMod</h1>
**A GTK4/libadwaita config manager for the [niri](https://github.com/niri-wm/niri) Wayland compositor.**
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![Python 3.12+](https://img.shields.io/badge/Python-3.12%2B-blue?logo=python&logoColor=white)](https://python.org)
[![GTK4](https://img.shields.io/badge/GTK-4%20%2B%20libadwaita-4A90D9?logo=gnome&logoColor=white)](https://gtk.org)
[![Wayland](https://img.shields.io/badge/Wayland-native-orange)](https://wayland.freedesktop.org)
</div>
<br>
![NiriMod Interface](media/1.png)
Editing Niri's configuration file by hand works perfectly fine—until you find yourself tweaking animation curves blindly, guessing the exact names of your monitors, or accidentally overlapping your keybinds. NiriMod steps in to provide a clean, native GUI for the tedious parts of configuration, while staying completely out of the way for everything else.
---
## What It Does
NiriMod manages your Niri config via a clean interface, allowing you to easily adjust settings while leaving your custom scripts and comments alone.
- **Display Outputs:** Visually arrange your monitors using drag-and-drop. Easily adjust your resolution, refresh rate, variable refresh rate (VRR), and fractional scaling without diving into the config file.
- **Keybinds:** Manage your shortcuts through an interactive physical keyboard map that lights up bound keys, or use the searchable list view to quickly find and edit specific bindings.
- **Layout & Rules:** Take control of Niri's column layout with a full editor for window rules, column proportions, gaps, struts, and workspaces.
- **System & Input:** Adjust your mouse and touchpad settings, configure swipe gestures, change cursor themes, and manage the environment variables and startup commands Niri uses.
- **Animations:** Stop guessing cubic-bezier values. The visual easing curve editor provides live previews for all of Niri's animation slots (like window open/close or workspace switches).
- **Raw Config Editor:** Sometimes you just want to type. The built-in KDL text editor comes with undo/redo functionality and runs live validation to ensure your manual tweaks are safe.
![Keybinding Management](media/2.png)
---
## Safe, Non-Destructive Editing
We built NiriMod to be strictly non-destructive. It is designed to never break your existing configuration:
- **Strict Validation:** Before anything is written to disk, NiriMod runs `niri validate`. If the validation fails, nothing is saved, keeping your setup safe.
- **Atomic Writes:** Configuration files are saved using temporary files first, which prevents corruption if a save is interrupted.
- **Comment Preservation:** Your custom comments and whitespace formatting are kept completely intact. We don't overwrite your personal notes.
- **Profile Management:** Easily save and switch between full configuration snapshots (like a "work" profile and a "gaming" profile) with a single click.
### Third-Party Shells & Multi-File Configs
![Multi-File Configurations](media/multiple_configs.png)
NiriMod natively supports advanced, multi-file setups. This includes custom visual layers and desktop shells like **Dank Material Shell (DMS)** and **Noctalia**.
If you like to split your configuration using `include` directives, NiriMod handles that transparently. It can parse included files up to 5 levels deep. When you make a change in the user interface, NiriMod is smart enough to track which file that specific setting came from, and it saves the change back to its exact origin.
Because NiriMod only touches the standard Niri settings it understands, your custom shell configurations, advanced scripts, and unrecognized blocks are perfectly preserved just the way you left them.
---
## Installation
### AUR (Arch Linux)
```bash
yay -S nirimod-git
```
### Script (Other Distros)
```bash
curl -sSL https://raw.githubusercontent.com/srinivasr/nirimod/main/install.sh | bash
```
*(You can use `--install` to skip the prompts, `--uninstall` to remove the application, or `--skip-deps` if you prefer to handle dependencies manually).*
---
## Requirements
NiriMod works out of the box on Arch, Fedora, openSUSE, and Debian/Ubuntu. You will need:
- Python 3.12+ and `uv` (the install script handles `uv` for you)
- GTK4, libadwaita, PyGObject, and Pycairo
- The niri Wayland compositor
**Gentoo Users** (requires the [GURU overlay](https://wiki.gentoo.org/wiki/Project:GURU) for `niri`):
```bash
emerge dev-vcs/git net-misc/curl dev-lang/python gui-libs/gtk gui-libs/libadwaita dev-python/pygobject dev-python/pycairo x11-libs/libxkbcommon x11-misc/xkeyboard-config
curl -sSL https://raw.githubusercontent.com/srinivasr/nirimod/main/install.sh | bash -s -- --install --skip-deps
```
---
## Contributing
Contributions are always welcome. If you would like to help out, please check the [CONTRIBUTING.md](CONTRIBUTING.md) file for setup instructions. If you are planning a major change, please open an issue first so we can discuss it.
<a href="https://www.star-history.com/?repos=srinivasr%2Fnirimod&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=srinivasr/nirimod&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=srinivasr/nirimod&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=srinivasr/nirimod&type=date&legend=top-left" />
</picture>
</a>
---
*NiriMod is an independent project and is not affiliated with the official niri team.*

26
data/nirimod.svg Normal file
View File

@@ -0,0 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<defs>
<linearGradient id="primaryGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#10B981" />
<stop offset="100%" stop-color="#059669" />
</linearGradient>
</defs>
<!-- Clean rounded background -->
<rect width="512" height="512" rx="128" fill="#111827" />
<!-- Geometric "N" abstraction - three columns with subtle overlap -->
<g transform="translate(141, 141)">
<!-- Column 1 (Background) -->
<path d="M 0 60 L 0 230 C 0 241 8 250 20 250 L 50 250 L 50 40 L 20 40 C 8 40 0 49 0 60 Z" fill="#374151" />
<!-- Column 2 (Primary/Focused) -->
<rect x="65" y="0" width="100" height="230" rx="12" fill="url(#primaryGradient)" />
<!-- Column 3 (Background) -->
<path d="M 180 30 L 180 200 C 180 211 188 220 200 220 L 230 220 L 230 10 L 200 10 C 188 10 180 19 180 30 Z" fill="#374151" />
<!-- Bold negative space gap (N-shape bridge) -->
<path d="M 65 0 L 165 230" stroke="#111827" stroke-width="20" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

27
flake.lock generated Normal file
View File

@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1776548001,
"narHash": "sha256-ZSK0NL4a1BwVbbTBoSnWgbJy9HeZFXLYQizjb2DPF24=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "b12141ef619e0a9c1c84dc8c684040326f27cdcc",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

35
flake.nix Normal file
View File

@@ -0,0 +1,35 @@
{
description = "A polished GTK4/libadwaita GUI configurator for the niri Wayland compositor";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
outputs =
{ self, nixpkgs }:
let
supportedSystems = [
"x86_64-linux"
"aarch64-linux"
];
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
in
{
packages = forAllSystems (system: let
pkgs = nixpkgs.legacyPackages.${system};
in {
default = pkgs.callPackage ./package.nix { };
});
overlays.default = final: prev: {
nirimod = final.callPackage ./package.nix { };
};
apps = forAllSystems (system: {
default = {
type = "app";
program = "${self.packages.${system}.default}/bin/nirimod";
};
});
};
}

650
install.sh Executable file
View File

@@ -0,0 +1,650 @@
#!/usr/bin/env bash
set -euo pipefail
# Constants & Paths
INSTALLER_VERSION="1.0.0"
INSTALL_DIR="$HOME/.local/share/nirimod"
BIN_DIR="$HOME/.local/bin"
DESKTOP_FILE_DIR="$HOME/.local/share/applications"
ICON_DIR="$HOME/.local/share/icons/hicolor/scalable/apps"
REPO_URL="https://github.com/srinivasr/nirimod"
DISTRO=""
DISTRO_PRETTY=""
DISTRO_LIKE=""
PM=""
IMAGE_BUILT_OS=0
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
# Helpers
info() { echo -e "${BLUE}${NC}$*"; }
success() { echo -e "${GREEN}${NC}$*"; }
warn() { echo -e "${YELLOW}${NC}$*"; }
error() { echo -e "${RED}${NC}$*" >&2; }
step() { echo -e "\n${BOLD}${CYAN}══ $* ══${NC}"; }
pause() {
echo -e "\n${BLUE}Press any key to continue...${NC}"
read -n 1 -s -r < /dev/tty || true
}
ask() {
# ask <prompt> <default> → returns 0 for yes, 1 for no
local prompt="$1" default="${2:-y}"
local yn_hint
[[ "$default" == "y" ]] && yn_hint="[Y/n]" || yn_hint="[y/N]"
read -p "$(echo -e "${YELLOW} ? ${NC}${prompt} ${yn_hint}: ")" reply < /dev/tty || true
reply="${reply:-$default}"
[[ "$reply" =~ ^[Yy]$ ]]
}
print_banner() {
clear
echo -e "${BLUE}${BOLD}NiriMod Installer v${INSTALLER_VERSION}${NC}"
echo -e "${CYAN}GUI Configuration Manager for the Niri Wayland Compositor${NC}\n"
}
# OS Detection
detect_distro() {
DISTRO=""
DISTRO_PRETTY=""
DISTRO_LIKE=""
PM="" # detected package manager
IMAGE_BUILT_OS=0
if [ -f /etc/os-release ]; then
# shellcheck source=/dev/null
. /etc/os-release
DISTRO="${ID:-}"
DISTRO_PRETTY="${PRETTY_NAME:-$ID}"
DISTRO_LIKE="${ID_LIKE:-}"
fi
detect_image_built_os
# Normalize distro id using ID_LIKE fallback
case "$DISTRO" in
arch|manjaro|endeavouros|garuda|artix|parabola)
PM="pacman" ;;
fedora|rhel|centos|rocky|almalinux)
PM="dnf" ;;
opensuse*|sles)
PM="zypper" ;;
ubuntu|debian|linuxmint|pop|elementary|zorin|kali|mx|mxlinux)
PM="apt" ;;
gentoo)
PM="emerge" ;;
*)
# Try ID_LIKE
if [[ "$DISTRO_LIKE" == *"arch"* ]]; then PM="pacman"
elif [[ "$DISTRO_LIKE" == *"fedora"* ]] || [[ "$DISTRO_LIKE" == *"rhel"* ]]; then PM="dnf"
elif [[ "$DISTRO_LIKE" == *"suse"* ]]; then PM="zypper"
elif [[ "$DISTRO_LIKE" == *"debian"* ]] || [[ "$DISTRO_LIKE" == *"ubuntu"* ]]; then PM="apt"
elif [[ "$DISTRO_LIKE" == *"gentoo"* ]]; then PM="emerge"
fi
;;
esac
# Verify the detected package manager actually exists. On image-built Fedora,
# keep the Fedora package family even if dnf is absent; dependency checks use rpm
# and missing packages are reported without attempting a dnf install.
if [ -n "$PM" ] && [ "$IMAGE_BUILT_OS" -ne 1 ] && ! command -v "$PM" &>/dev/null; then
PM=""
fi
# Last resort: probe which package manager is installed
if [ -z "$PM" ]; then
if command -v pacman &>/dev/null; then PM="pacman"
elif command -v dnf &>/dev/null; then PM="dnf"
elif command -v zypper &>/dev/null; then PM="zypper"
elif command -v apt-get &>/dev/null; then PM="apt"
elif command -v emerge &>/dev/null; then PM="emerge"
fi
fi
if [ -z "$PM" ]; then
error "Could not detect a supported package manager."
error "Supported: pacman (Arch), dnf (Fedora/RHEL), zypper (openSUSE), apt (Debian/Ubuntu), emerge (Gentoo)"
error "If you are on an unsupported distro, re-run with --skip-deps and install dependencies manually."
exit 1
fi
info "Detected: ${DISTRO_PRETTY} (package manager: ${PM})"
if [ "$IMAGE_BUILT_OS" -eq 1 ]; then
info "Detected image-built/atomic OS; system dependencies must come from the image."
fi
}
# Package Installation
install_pkgs() {
local pkgs=("$@")
info "Installing: ${pkgs[*]}"
case "$PM" in
pacman) sudo pacman -S --needed --noconfirm "${pkgs[@]}" ;;
dnf) sudo dnf install -y "${pkgs[@]}" ;;
zypper) sudo zypper install -y "${pkgs[@]}" ;;
apt) sudo apt-get update -qq && sudo apt-get install -y "${pkgs[@]}" ;;
emerge)
echo ""
echo -e " ${YELLOW}⚠ Gentoo:${NC} packages compile from source and may take a few minutes."
echo -e " The cairo USE flag will be set for dev-python/pygobject (needed for the keyboard view)."
echo ""
if ask "Proceed with emerge?" y; then
local use_file="/etc/portage/package.use/nirimod"
if ! grep -q "dev-python/pygobject" "$use_file" 2>/dev/null; then
echo "dev-python/pygobject cairo" | sudo tee -a "$use_file" > /dev/null
fi
sudo emerge --newuse --ask=n "${pkgs[@]}" || {
error "emerge failed. Try running manually:"
for pkg in "${pkgs[@]}"; do
echo -e " ${CYAN}sudo emerge $pkg${NC}"
done
exit 1
}
else
warn "Install these manually then re-run: bash install.sh --install --skip-deps"
for pkg in "${pkgs[@]}"; do
echo -e " ${CYAN}sudo emerge $pkg${NC}"
done
exit 1
fi
;;
esac
}
pkg_installed() {
# Returns 0 if the package is installed, 1 otherwise
local pkg="$1"
case "$PM" in
pacman) pacman -Qi "$pkg" &>/dev/null ;;
dnf) rpm -q "$pkg" &>/dev/null || rpm -q --whatprovides "$pkg" &>/dev/null ;;
zypper) rpm -q "$pkg" &>/dev/null || rpm -q --whatprovides "$pkg" &>/dev/null ;;
apt) dpkg-query -W -f='${Status}' "$pkg" 2>/dev/null | grep -q "install ok installed" ;;
emerge)
if command -v qlist &>/dev/null; then
qlist -I "$pkg" &>/dev/null
elif command -v equery &>/dev/null; then
equery -q list "$pkg" &>/dev/null
else
return 1
fi
;;
esac
}
cmd_exists() { command -v "$1" &>/dev/null; }
detect_image_built_os() {
IMAGE_BUILT_OS=0
if [ -e /run/ostree-booted ]; then
IMAGE_BUILT_OS=1
fi
}
needs_uv_preload_cleanup() {
[[ "${LD_PRELOAD:-}" == *"libhardened_malloc.so"* ]] || [[ "${LD_PRELOAD:-}" == *"libno_rlimit_as.so"* ]]
}
filtered_ld_preload() {
local entry
local kept=()
for entry in ${LD_PRELOAD//:/ }; do
case "$entry" in
*libhardened_malloc.so*|*libno_rlimit_as.so*) ;;
*) kept+=("$entry") ;;
esac
done
printf '%s' "${kept[*]}"
}
run_with_filtered_preload() {
if needs_uv_preload_cleanup; then
local preload
preload="$(filtered_ld_preload)"
if [ -n "$preload" ]; then
LD_PRELOAD="$preload" "$@"
else
env -u LD_PRELOAD "$@"
fi
return
fi
"$@"
}
run_uv() {
run_with_filtered_preload uv "$@"
}
resolve_deps() {
MISSING=()
# Baseline tools
if ! cmd_exists git; then
case "$PM" in
pacman) MISSING+=("git") ;;
dnf) MISSING+=("git") ;;
zypper) MISSING+=("git") ;;
apt) MISSING+=("git") ;;
emerge) MISSING+=("dev-vcs/git") ;;
esac
fi
if ! cmd_exists curl; then
case "$PM" in
pacman) MISSING+=("curl") ;;
dnf) MISSING+=("curl") ;;
zypper) MISSING+=("curl") ;;
apt) MISSING+=("curl") ;;
emerge) MISSING+=("net-misc/curl") ;;
esac
fi
if ! cmd_exists python3; then
case "$PM" in
pacman) MISSING+=("python") ;;
dnf) MISSING+=("python3") ;;
zypper) MISSING+=("python3") ;;
apt) MISSING+=("python3") ;;
emerge) MISSING+=("dev-lang/python") ;;
esac
fi
# GTK4
case "$PM" in
pacman)
pkg_installed gtk4 || MISSING+=("gtk4") ;;
dnf)
pkg_installed gtk4 || MISSING+=("gtk4") ;;
zypper)
pkg_installed libgtk-4-1 || MISSING+=("libgtk-4-1") ;;
apt)
pkg_installed libgtk-4-1 || MISSING+=("libgtk-4-1") ;;
emerge)
pkg_installed gui-libs/gtk || MISSING+=("gui-libs/gtk") ;;
esac
# libadwaita
case "$PM" in
pacman)
pkg_installed libadwaita || MISSING+=("libadwaita") ;;
dnf)
pkg_installed libadwaita || MISSING+=("libadwaita") ;;
zypper)
pkg_installed libadwaita-1-0 || MISSING+=("libadwaita-1-0") ;;
apt)
pkg_installed libadwaita-1-0 || MISSING+=("libadwaita-1-0") ;;
emerge)
pkg_installed gui-libs/libadwaita || MISSING+=("gui-libs/libadwaita") ;;
esac
# PyGObject / GObject Introspection
case "$PM" in
pacman)
pkg_installed python-gobject || MISSING+=("python-gobject") ;;
dnf)
pkg_installed python3-gobject || \
pkg_installed python3-gobject-base || \
MISSING+=("python3-gobject") ;;
zypper)
pkg_installed python3-gobject || MISSING+=("python3-gobject") ;;
apt)
pkg_installed python3-gi || MISSING+=("python3-gi")
pkg_installed python3-gi-cairo || MISSING+=("python3-gi-cairo")
;;
emerge)
pkg_installed dev-python/pygobject || MISSING+=("dev-python/pygobject")
pkg_installed dev-python/pycairo || MISSING+=("dev-python/pycairo")
pkg_installed x11-libs/libxkbcommon || MISSING+=("x11-libs/libxkbcommon")
pkg_installed x11-misc/xkeyboard-config || MISSING+=("x11-misc/xkeyboard-config")
;;
esac
# GObject typelibs (needed at runtime for gi.require_version)
case "$PM" in
dnf)
pkg_installed gtk4 || MISSING+=("gtk4")
pkg_installed libadwaita || MISSING+=("libadwaita")
;;
zypper)
pkg_installed typelib-1_0-Gtk-4_0 || MISSING+=("typelib-1_0-Gtk-4_0")
pkg_installed typelib-1_0-Adw-1 || MISSING+=("typelib-1_0-Adw-1")
;;
apt)
pkg_installed gir1.2-gtk-4.0 || MISSING+=("gir1.2-gtk-4.0")
pkg_installed gir1.2-adw-1 || MISSING+=("gir1.2-adw-1")
;;
esac
# Deduplicate
if [ ${#MISSING[@]} -gt 0 ]; then
# Remove duplicate entries
mapfile -t MISSING < <(printf '%s\n' "${MISSING[@]}" | sort -u)
fi
}
# Full Dependency Check
check_dependencies() {
step "Checking System Dependencies"
detect_image_built_os
if [ "${SKIP_DEPS:-0}" -eq 1 ]; then
warn "Skipping system package manager checks (--skip-deps)."
warn "Please ensure git, curl, python3, gtk4, libadwaita, and pygobject are installed manually."
else
detect_distro
resolve_deps
if [ ${#MISSING[@]} -gt 0 ]; then
warn "The following packages are missing:"
for pkg in "${MISSING[@]}"; do
echo -e " ${RED}${NC} $pkg"
done
echo ""
if [ "${IMAGE_BUILT_OS:-0}" -eq 1 ]; then
error "This looks like an image-built/atomic system, so the installer will not run sudo ${PM} install."
error "Add the missing packages to your image recipe or base image, rebuild, then re-run this installer."
warn "If these dependencies are provided outside the system package database, re-run with --skip-deps."
exit 1
fi
if ask "Install missing packages via sudo?"; then
install_pkgs "${MISSING[@]}"
success "System packages installed."
else
error "Cannot proceed without required system packages."
exit 1
fi
else
if [ "${PM:-}" = "emerge" ]; then
success "All system packages are already installed."
echo -e " ${YELLOW}Note:${NC} If the keyboard view is blank, you may need to rebuild pygobject with the cairo USE flag:"
echo -e " ${CYAN}echo 'dev-python/pygobject cairo' | sudo tee -a /etc/portage/package.use/nirimod && sudo emerge --newuse dev-python/pygobject dev-python/pycairo${NC}"
echo ""
else
success "All system packages are already installed."
fi
fi
fi
# Niri compositor check (optional warning)
if ! cmd_exists niri; then
warn "The 'niri' compositor was not found on PATH."
warn "NiriMod requires niri to be running. Install it separately if needed."
warn " Arch: sudo pacman -S niri"
warn " Fedora: sudo dnf install niri"
warn " Gentoo: sudo emerge gui-wm/niri"
echo ""
fi
# uv
step "Checking uv (Python Environment Manager)"
if ! cmd_exists uv; then
warn "'uv' is not installed. It is required to manage NiriMod's Python environment."
if ask "Install 'uv' via the official installer (astral.sh)?"; then
info "Downloading and running the uv installer..."
run_with_filtered_preload bash -c 'set -euo pipefail; curl -LsSf https://astral.sh/uv/install.sh | sh'
# Make cargo/uv available in current session
export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"
if [ -f "$HOME/.cargo/env" ]; then
# shellcheck source=/dev/null
source "$HOME/.cargo/env"
fi
if ! cmd_exists uv; then
error "'uv' was installed but is not on PATH. Please restart your shell and re-run this installer."
error "Or run: export PATH=\"\$HOME/.local/bin:\$HOME/.cargo/bin:\$PATH\""
exit 1
fi
success "'uv' installed successfully: $(run_uv --version)"
else
error "Cannot proceed without 'uv'."
exit 1
fi
else
success "'uv' is available: $(run_uv --version)"
fi
}
# Download / Update Source
download_source() {
step "Fetching Source Code"
if [ -d "$INSTALL_DIR/.git" ]; then
info "Updating existing installation at $INSTALL_DIR ..."
git -C "$INSTALL_DIR" fetch --quiet origin main && \
git -C "$INSTALL_DIR" reset --hard origin/main --quiet \
|| warn "Could not fetch latest changes (maybe no network). Continuing with existing source."
else
info "Cloning repository to $INSTALL_DIR ..."
git clone "$REPO_URL" "$INSTALL_DIR"
fi
success "Source code is ready."
}
# Build & Wire Up
install_app() {
step "Setting Up Python Environment"
cd "$INSTALL_DIR"
info "Creating virtual environment with system site-packages..."
rm -rf .venv # Ensure clean state
run_uv venv --system-site-packages --python python3
run_uv sync --no-dev
# Verification check
if ! run_uv run python -c "import gi" &>/dev/null; then
warn "Virtual environment installed, but 'gi' (PyGObject) is still not found."
warn "This typically happens if the system bindings are missing or Python version mismatch exists."
# Try to diagnose
local host_python_ver
host_python_ver=$(python3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')")
info "Host Python: $host_python_ver"
if ! python3 -c "import gi" &>/dev/null; then
error "PyGObject is NOT installed on your host system."
error "Please install it via your package manager first (e.g., python3-gi or python-gobject)."
exit 1
else
warn "PyGObject is available on host but NOT in the venv. This is unexpected."
warn "Attempting a fallback venv creation..."
rm -rf .venv
run_uv venv --system-site-packages
run_uv sync --no-dev
fi
fi
success "Python environment ready and verified."
# Launcher script
step "Creating Launcher"
mkdir -p "$BIN_DIR"
# Generate launcher script
cat > "$BIN_DIR/nirimod" << EOF
#!/usr/bin/env bash
# NiriMod launcher — auto-generated by install.sh
INSTALL_DIR="${INSTALL_DIR}"
if [ ! -d "\$INSTALL_DIR" ]; then
echo "NiriMod is not installed at \$INSTALL_DIR. Please re-run the installer." >&2
exit 1
fi
export PATH="\$HOME/.local/bin:\$HOME/.cargo/bin:\$PATH"
export PYTHONPATH="\$INSTALL_DIR"
cd "\$INSTALL_DIR"
$(declare -f needs_uv_preload_cleanup)
$(declare -f filtered_ld_preload)
$(declare -f run_with_filtered_preload)
run_with_filtered_preload uv run python3 -m nirimod "\$@"
EOF
chmod +x "$BIN_DIR/nirimod"
success "Launcher created: $BIN_DIR/nirimod"
# Desktop entry
step "Installing Desktop Entry"
mkdir -p "$DESKTOP_FILE_DIR"
mkdir -p "$ICON_DIR"
# Copy icon if it exists in the repo
if [ -f "$INSTALL_DIR/data/nirimod.svg" ]; then
cp "$INSTALL_DIR/data/nirimod.svg" "$ICON_DIR/nirimod.svg"
ICON_NAME="nirimod"
elif [ -f "$INSTALL_DIR/data/nirimod.png" ]; then
cp "$INSTALL_DIR/data/nirimod.png" "$HOME/.local/share/icons/hicolor/256x256/apps/nirimod.png"
ICON_NAME="nirimod"
else
ICON_NAME="preferences-system"
fi
cat > "$DESKTOP_FILE_DIR/io.github.nirimod.desktop" << EOF
[Desktop Entry]
Version=1.0
Name=NiriMod
GenericName=Compositor Settings
Comment=GUI Configuration Manager for the Niri Wayland Compositor
Exec=${BIN_DIR}/nirimod
Icon=${ICON_NAME}
Terminal=false
Type=Application
Categories=Utility;Settings;DesktopSettings;
Keywords=compositor;windowmanager;wayland;niri;settings;config;
StartupNotify=true
StartupWMClass=nirimod
EOF
# Refresh desktop database if available
if cmd_exists update-desktop-database; then
update-desktop-database "$DESKTOP_FILE_DIR" 2>/dev/null || true
fi
if cmd_exists gtk-update-icon-cache; then
gtk-update-icon-cache -f -t "$HOME/.local/share/icons/hicolor" 2>/dev/null || true
fi
success "Desktop entry installed."
if [[ ":$PATH:" != *":$BIN_DIR:"* ]]; then
echo ""
warn "$BIN_DIR isn't in your PATH."
if ask "Add it to your shell profile automatically?"; then
for rc in "$HOME/.bashrc" "$HOME/.zshrc"; do
if [ -f "$rc" ]; then
if ! grep -q 'export PATH=.*\.local/bin' "$rc"; then
echo -e '\n# nirimod' >> "$rc"
echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$rc"
success "Patched $rc"
fi
fi
done
else
warn "You can run it directly: ~/.local/bin/nirimod"
fi
fi
echo ""
success "${BOLD}NiriMod ${INSTALLER_VERSION} installed successfully!${NC}"
info "Launch from your app menu, or run: ${CYAN}~/.local/bin/nirimod${NC}"
}
# Uninstall
uninstall() {
step "Uninstalling NiriMod"
warn "This will remove:"
echo "$INSTALL_DIR"
echo "$BIN_DIR/nirimod"
echo "$DESKTOP_FILE_DIR/io.github.nirimod.desktop"
echo ""
if ! ask "Are you sure you want to uninstall NiriMod?"; then
info "Uninstall cancelled."
return
fi
rm -rf "$INSTALL_DIR"
rm -f "$BIN_DIR/nirimod"
rm -f "$DESKTOP_FILE_DIR/io.github.nirimod.desktop"
rm -f "$ICON_DIR/nirimod.svg"
if cmd_exists update-desktop-database; then
update-desktop-database "$DESKTOP_FILE_DIR" 2>/dev/null || true
fi
success "NiriMod has been uninstalled."
pause
exit 0
}
# Menu
main_menu() {
while true; do
print_banner
echo -e " Please select an option:\n"
echo -e " ${GREEN}1${NC}) Install / Update NiriMod"
echo -e " ${GREEN}2${NC}) Uninstall NiriMod"
echo -e " ${GREEN}q${NC}) Quit"
echo ""
read -p "$(echo -e " ${BOLD}Enter your choice:${NC} ")" choice < /dev/tty || true
case "$choice" in
1)
print_banner
check_dependencies
download_source
install_app
pause
exit 0
;;
2)
print_banner
uninstall
;;
q|Q)
echo -e "\n${BLUE} Goodbye!${NC}\n"
exit 0
;;
*)
error "Invalid option. Please choose 1, 2, or q."
sleep 1
;;
esac
done
}
# Entry Point
# Flags:
# --install Download from GitHub and install (non-interactive)
# --uninstall Remove NiriMod (non-interactive)
# --skip-deps Skip system package manager checks (useful for Gentoo/unsupported distros)
MODE=""
SKIP_DEPS=0
for arg in "$@"; do
case "$arg" in
--install) MODE="install" ;;
--uninstall) MODE="uninstall" ;;
--skip-deps) SKIP_DEPS=1 ;;
*)
error "Unknown option: $arg"
echo "Usage: $0 [--install | --uninstall] [--skip-deps]"
exit 1
;;
esac
done
if [ "$MODE" = "install" ]; then
print_banner
check_dependencies
download_source
install_app
exit 0
elif [ "$MODE" = "uninstall" ]; then
print_banner
uninstall
exit 0
else
main_menu
fi

BIN
media/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

BIN
media/2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 KiB

BIN
media/multiple_configs.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

1
nirimod/__init__.py Normal file
View File

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

80
nirimod/__main__.py Normal file
View File

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

50
nirimod/app_settings.py Normal file
View File

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

45
nirimod/backup.py Normal file
View File

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

799
nirimod/kdl_parser.py Normal file
View File

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

209
nirimod/niri_ipc.py Normal file
View File

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

View File

1222
nirimod/pages/animations.py Normal file

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

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

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

72
nirimod/profiles.py Normal file
View File

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

149
nirimod/state.py Normal file
View File

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

325
nirimod/theme.py Normal file
View File

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

61
nirimod/undo.py Normal file
View File

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

135
nirimod/updater.py Normal file
View File

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

View File

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

View File

@@ -0,0 +1,696 @@
"""Keyboard visualizer widget — Cairo DrawingArea keyboard map.
Inspired from omer-biz/visu (Elm/WASM) into pure Python + Cairo.
"""
from __future__ import annotations
import math
try:
import cairo # noqa: F401
HAS_CAIRO = True
except ImportError:
HAS_CAIRO = False
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, GLib, GObject, Gtk
from nirimod.xkb_helper import XkbHelper
KEYBOARD_GEOMETRIES: dict[str, list[list[tuple[str, int]]]] = {
"ANSI": [
# Row 0 — function row
[("escape", 4), ("", 2), ("f1", 3), ("f2", 3), ("f3", 3), ("f4", 3), ("", 2), ("f5", 3), ("f6", 3), ("f7", 3), ("f8", 3), ("", 2), ("f9", 3), ("f10", 3), ("f11", 3), ("f12", 3), ("", 2), ("print", 4), ("insert", 4), ("delete", 4)],
# Row 1 — number row
[("grave", 4), ("1", 4), ("2", 4), ("3", 4), ("4", 4), ("5", 4), ("6", 4), ("7", 4), ("8", 4), ("9", 4), ("0", 4), ("minus", 4), ("equal", 4), ("backspace", 8)],
# Row 2 — QWERTY
[("tab", 6), ("q", 4), ("w", 4), ("e", 4), ("r", 4), ("t", 4), ("y", 4), ("u", 4), ("i", 4), ("o", 4), ("p", 4), ("bracketleft", 4), ("bracketright", 4), ("backslash", 6)],
# Row 3 — home row
[("capslock", 7), ("a", 4), ("s", 4), ("d", 4), ("f", 4), ("g", 4), ("h", 4), ("j", 4), ("k", 4), ("l", 4), ("semicolon", 4), ("quote", 4), ("return", 9)],
# Row 4 — shift row
[("shiftleft", 7), ("z", 4), ("x", 4), ("c", 4), ("v", 4), ("b", 4), ("n", 4), ("m", 4), ("comma", 4), ("period", 4), ("slash", 4), ("shiftright", 5), ("up", 4), ("", 4)],
# Row 5 — bottom row
[("ctrlleft", 6), ("superleft", 6), ("altleft", 6), ("space", 24), ("altright", 6), ("left", 4), ("down", 4), ("right", 4)],
],
"ISO": [
# Row 0 — function row
[("escape", 4), ("", 2), ("f1", 3), ("f2", 3), ("f3", 3), ("f4", 3), ("", 2), ("f5", 3), ("f6", 3), ("f7", 3), ("f8", 3), ("", 2), ("f9", 3), ("f10", 3), ("f11", 3), ("f12", 3), ("", 2), ("print", 4), ("insert", 4), ("delete", 4)],
# Row 1 — number row
[("grave", 4), ("1", 4), ("2", 4), ("3", 4), ("4", 4), ("5", 4), ("6", 4), ("7", 4), ("8", 4), ("9", 4), ("0", 4), ("minus", 4), ("equal", 4), ("backspace", 8)],
# Row 2 — QWERTY
[("tab", 6), ("q", 4), ("w", 4), ("e", 4), ("r", 4), ("t", 4), ("y", 4), ("u", 4), ("i", 4), ("o", 4), ("p", 4), ("bracketleft", 4), ("bracketright", 4), ("return", 6)],
# Row 3 — home row
[("capslock", 7), ("a", 4), ("s", 4), ("d", 4), ("f", 4), ("g", 4), ("h", 4), ("j", 4), ("k", 4), ("l", 4), ("semicolon", 4), ("quote", 4), ("backslash", 4), ("return", 5)],
# Row 4 — shift row
[("shiftleft", 4), ("less", 4), ("z", 4), ("x", 4), ("c", 4), ("v", 4), ("b", 4), ("n", 4), ("m", 4), ("comma", 4), ("period", 4), ("slash", 4), ("shiftright", 4), ("up", 4), ("", 4)],
# Row 5 — bottom row
[("ctrlleft", 6), ("superleft", 6), ("altleft", 6), ("space", 24), ("altright", 6), ("left", 4), ("down", 4), ("right", 4)],
]
}
_KID_TO_KEYCODE = {
# Function row
"escape": 1, "f1": 59, "f2": 60, "f3": 61, "f4": 62, "f5": 63, "f6": 64, "f7": 65, "f8": 66, "f9": 67, "f10": 68, "f11": 87, "f12": 88, "print": 99, "insert": 110, "delete": 111,
# Number row
"grave": 41, "1": 2, "2": 3, "3": 4, "4": 5, "5": 6, "6": 7, "7": 8, "8": 9, "9": 10, "0": 11, "minus": 12, "equal": 13, "backspace": 14,
# Row 2
"tab": 15, "q": 16, "w": 17, "e": 18, "r": 19, "t": 20, "y": 21, "u": 22, "i": 23, "o": 24, "p": 25, "bracketleft": 26, "bracketright": 27, "backslash": 43,
# Row 3
"capslock": 58, "a": 30, "s": 31, "d": 32, "f": 33, "g": 34, "h": 35, "j": 36, "k": 37, "l": 38, "semicolon": 39, "quote": 40, "return": 28,
# Row 4
"shiftleft": 42, "less": 94, "z": 44, "x": 45, "c": 46, "v": 47, "b": 48, "n": 49, "m": 50, "comma": 51, "period": 52, "slash": 53, "shiftright": 54, "up": 103,
# Row 5
"ctrlleft": 29, "superleft": 125, "altleft": 56, "space": 57, "altright": 100, "left": 105, "down": 108, "right": 106
}
_STATIC_LABELS = {
"escape": "Esc", "backspace": "Bksp", "tab": "Tab", "return": "Enter", "capslock": "Caps",
"shiftleft": "Shift", "shiftright": "Shift", "ctrlleft": "Ctrl", "superleft": "Super",
"altleft": "Alt", "altright": "Alt", "up": "", "down": "", "left": "", "right": "", "space": "",
"grave": "`",
"f1": "F1", "f2": "F2", "f3": "F3", "f4": "F4", "f5": "F5", "f6": "F6",
"f7": "F7", "f8": "F8", "f9": "F9", "f10": "F10", "f11": "F11", "f12": "F12",
"print": "PrtSc", "insert": "Ins", "delete": "Del",
}
_MODIFIER_KEY_IDS = {
"shiftleft",
"shiftright",
"ctrlleft",
"altleft",
"altright",
"superleft",
"capslock",
"tab",
"backspace",
"space",
}
_KEYSYM_ALIAS: dict[str, str] = {
"return": "return",
"enter": "return",
"kp_enter": "return",
"escape": "escape",
"esc": "escape",
"backspace": "backspace",
"tab": "tab",
"space": "space",
"bracketleft": "bracketleft",
"bracketright": "bracketright",
"minus": "minus",
"equal": "equal",
"period": "period",
"comma": "comma",
"slash": "slash",
"backslash": "backslash",
"semicolon": "semicolon",
"apostrophe": "quote",
"quote": "quote",
"grave": "grave",
"up": "up",
"down": "down",
"left": "left",
"right": "right",
"page_up": "pageup",
"page_down": "pagedown",
"home": "home",
"end": "end",
"print": "print",
"sysrq": "print",
"delete": "delete",
"del": "delete",
"insert": "insert",
"ins": "insert",
"f1": "f1", "f2": "f2", "f3": "f3", "f4": "f4",
"f5": "f5", "f6": "f6", "f7": "f7", "f8": "f8",
"f9": "f9", "f10": "f10", "f11": "f11", "f12": "f12",
}
for _c in "abcdefghijklmnopqrstuvwxyz0123456789":
_KEYSYM_ALIAS[_c] = _c
def normalize_key_id(raw_key: str) -> str:
"""Convert a raw keysym (last part of Mod+Shift+X) to a keyboard layout id."""
k = raw_key.strip().lower()
return _KEYSYM_ALIAS.get(k, k)
def _rgb(r: int, g: int, b: int, a: float = 1.0):
return (r / 255, g / 255, b / 255, a)
# Unbound key
_COL_KEY_BG = _rgb(30, 30, 36) # dark charcoal fill
_COL_KEY_BORDER = _rgb(255, 255, 255, 0.07) # barely visible edge
_COL_KEY_FG = _rgb(200, 200, 210) # label colour
# Bound key
_COL_BOUND_BG = _rgb(45, 30, 80) # muted indigo fill
_COL_BOUND_BORDER = _rgb(100, 60, 160, 1.0) # soft purple border
_COL_BOUND_GLOW = _rgb(100, 60, 160, 0.20) # subtle outer glow
_COL_BOUND_MOD = _rgb(160, 140, 200) # muted MOD label tint
# Selected key
_COL_SEL_BG = _rgb(70, 40, 120)
_COL_SEL_BORDER = _rgb(140, 80, 200, 1.0)
_COL_SEL_GLOW = _rgb(140, 80, 200, 0.30)
# Search-match key
_COL_SEARCH_BG = _rgb(100, 50, 130)
_COL_SEARCH_BORDER = _rgb(160, 80, 180, 1.0)
_COL_SEARCH_GLOW = _rgb(160, 80, 180, 0.25)
# Badge pill
_COL_BADGE_BG = _rgb(80, 40, 140)
_COL_BADGE_FG = _rgb(255, 255, 255)
# Chassis
_COL_FRAME_BG = _rgb(10, 10, 12)
_COL_FRAME_BORDER = _rgb(255, 255, 255, 0.07)
class _AspectDrawingArea(Gtk.DrawingArea):
def __init__(self, ratio=2.43):
super().__init__()
self._ratio = ratio
self.set_hexpand(True)
def do_get_request_mode(self):
return Gtk.SizeRequestMode.HEIGHT_FOR_WIDTH
def do_measure(self, orientation, for_size):
if orientation == Gtk.Orientation.HORIZONTAL:
return (400, 560, -1, -1)
else:
if for_size > 0:
h = int(for_size / self._ratio)
return (h, h, -1, -1)
else:
h = int(560 / self._ratio)
return (h, h, -1, -1)
class KeyboardVisualizer(Gtk.Box):
"""Cairo-rendered ANSI QWERTY keyboard with niri binding overlays."""
__gsignals__ = {
"key-selected": (GObject.SignalFlags.RUN_FIRST, None, (str,)),
"edit-binding": (GObject.SignalFlags.RUN_FIRST, None, (object,)),
"add-binding": (GObject.SignalFlags.RUN_FIRST, None, (str,)),
"delete-binding": (GObject.SignalFlags.RUN_FIRST, None, (object,)),
}
def __init__(self):
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=12)
# State
self._layout_id: str = "us"
self._geometry_id: str = "ANSI"
self._bindings: dict[str, list[dict]] = {} # key_id → [bind_dict, ...]
self._selected_id: str | None = None
self._search_q: str = ""
self._dynamic_keysym_to_kid: dict[str, str] = {}
self._xkb = XkbHelper()
self._xkb.set_layout(self._layout_id)
self._key_rects: list[tuple[str, float, float, float, float]] = []
if not HAS_CAIRO:
err_lbl = Gtk.Label(
label="Cairo is not installed — the physical keyboard view is unavailable.\nInstall dev-python/pycairo and restart.",
justify=Gtk.Justification.CENTER,
)
err_lbl.add_css_class("dim-label")
err_lbl.set_vexpand(True)
self.append(err_lbl)
return
# Drawing area
self._area = _AspectDrawingArea(ratio=2.43)
self._area.set_draw_func(self._draw)
self.append(self._area)
click = Gtk.GestureClick()
click.connect("released", self._on_click)
self._area.add_controller(click)
if HAS_CAIRO:
self._panel = _ActionPanel(
on_edit=lambda b: self.emit("edit-binding", b),
on_add=lambda k: self.emit("add-binding", k),
on_delete=lambda b: self.emit("delete-binding", b),
)
self.append(self._panel)
# Legend
self.append(self._build_legend())
# Public API
def set_bindings(self, bindings: dict[str, list[dict]]) -> None:
"""Accept a key_id → [bind_dict] mapping and refresh."""
self._bindings = bindings
if hasattr(self, "_area"):
self._area.queue_draw()
if self._selected_id:
self._panel.update(
self._selected_id, self._bindings.get(self._selected_id, [])
)
def set_layout(self, layout_id: str) -> None:
"""Set the visualizer layout mapping (e.g. 'us', 'it')."""
self._layout_id = layout_id
self._xkb.set_layout(layout_id)
base_layout = layout_id.split(":")[0].lower()
iso_layouts = {'it', 'fr', 'de', 'es', 'pt', 'uk', 'ru', 'ch', 'be', 'no', 'se', 'fi', 'dk'}
if base_layout in iso_layouts:
self._geometry_id = "ISO"
else:
self._geometry_id = "ANSI"
self._dynamic_keysym_to_kid.clear()
for kid, keycode in _KID_TO_KEYCODE.items():
sym = self._xkb.get_keysym_name(keycode)
if sym:
self._dynamic_keysym_to_kid[sym.lower()] = kid
if hasattr(self, "_area"):
self._area.queue_draw()
def set_search(self, query: str) -> None:
self._search_q = query.strip().lower()
if hasattr(self, "_area"):
self._area.queue_draw()
# Internal helpers
def _on_click(self, gesture, n_press, x, y):
for kid, rx, ry, rw, rh in self._key_rects:
if rx <= x <= rx + rw and ry <= y <= ry + rh:
self._selected_id = kid
self._panel.update(kid, self._bindings.get(kid, []))
self._area.queue_draw()
self.emit("key-selected", kid)
return
def _matches_search(self, binds: list[dict]) -> bool:
if not self._search_q:
return False
q = self._search_q
for b in binds:
if q in b.get("action", "").lower():
return True
if q in b.get("keysym", "").lower():
return True
return False
def _draw(self, area, cr, width: int, height: int):
if width <= 0 or height <= 0:
return
self._key_rects = []
# Internal margins
pad_x, pad_y = 16, 12
chassis_r = 12.0
inner_w = width - 2 * pad_x
inner_h = height - 2 * pad_y
active_geom = KEYBOARD_GEOMETRIES.get(self._geometry_id) or KEYBOARD_GEOMETRIES["ANSI"]
n_rows = len(active_geom)
frow_ratio = 0.7
frow_gap = max(3.0, inner_h * 0.015)
row_h = (inner_h - frow_gap) / (frow_ratio + n_rows - 1)
frow_h = frow_ratio * row_h
key_gap = max(2.5, row_h * 0.07)
radius = max(4.0, row_h * 0.16)
total_units = max(sum(w for _, w in row) for row in active_geom)
# Keyboard chassis
self._rounded_rect(cr, 0, 0, width, height, chassis_r)
cr.set_source_rgba(*_COL_FRAME_BG)
cr.fill_preserve()
cr.set_source_rgba(*_COL_FRAME_BORDER)
cr.set_line_width(1.0)
cr.stroke()
for row_idx, row in enumerate(active_geom):
if row_idx == 0:
y = float(pad_y)
this_row_h = frow_h
else:
y = float(pad_y + frow_h + frow_gap + (row_idx - 1) * row_h)
this_row_h = row_h
x = float(pad_x)
for kid, units in row:
key_w = (units / total_units) * inner_w
if not kid:
x += key_w
continue
label = _STATIC_LABELS.get(kid)
if label is None:
keycode = _KID_TO_KEYCODE.get(kid)
if keycode:
label = self._xkb.get_label(keycode)
if label is None:
label = kid.upper() if len(kid) <= 1 else kid.capitalize()
else:
label = label.upper() if len(label) == 1 else label
kx = x + key_gap / 2
ky = y + key_gap / 2
kw = key_w - key_gap
kh = this_row_h - key_gap
binds = self._bindings.get(kid, [])
is_bound = bool(binds)
is_sel = self._selected_id == kid
is_search = is_bound and self._matches_search(binds)
if is_sel:
fill = _COL_SEL_BG
border = _COL_SEL_BORDER
glow = _COL_SEL_GLOW
elif is_search:
fill = _COL_SEARCH_BG
border = _COL_SEARCH_BORDER
glow = _COL_SEARCH_GLOW
elif is_bound:
fill = _COL_BOUND_BG
border = _COL_BOUND_BORDER
glow = _COL_BOUND_GLOW
else:
fill = _COL_KEY_BG
border = _COL_KEY_BORDER
glow = None
if glow:
for spread, alpha_scale in ((6, 0.15), (3, 0.25), (1, 0.35)):
cr.set_source_rgba(glow[0], glow[1], glow[2], glow[3] * alpha_scale)
self._rounded_rect(
cr,
kx - spread, ky - spread,
kw + spread * 2, kh + spread * 2,
radius + spread,
)
cr.fill()
# Key face fill
self._rounded_rect(cr, kx, ky, kw, kh, radius)
cr.set_source_rgba(*fill)
cr.fill_preserve()
# Key border
lw = 1.2 if (is_bound or is_sel) else 0.8
cr.set_source_rgba(*border)
cr.set_line_width(lw)
cr.stroke()
if is_bound:
first_mod = self._first_modifier(binds)
if first_mod:
mod_fs = max(4.5, kh * 0.14)
cr.select_font_face("Sans", 0, 1)
cr.set_font_size(mod_fs)
mx = int(kx + 6)
my = int(ky + mod_fs + 5)
cr.set_source_rgba(*_COL_BOUND_MOD)
cr.move_to(mx, my)
cr.show_text(first_mod[:3].upper())
fs = max(7.0, kh * 0.26)
cr.select_font_face("Sans", 0, 1)
cr.set_font_size(fs)
te = cr.text_extents(label)
tx = int(kx + (kw - te.width) / 2 - te.x_bearing)
ty = int(ky + (kh + te.height) / 2 - te.height / 2)
# Drop shadow
cr.set_source_rgba(0, 0, 0, 0.5)
cr.move_to(tx, ty + 1)
cr.show_text(label)
cr.set_source_rgba(1.0, 1.0, 1.0, 0.9)
cr.move_to(tx, ty)
cr.show_text(label)
if len(binds) > 1:
badge_txt = str(len(binds))
bfs = max(5.0, kh * 0.14)
cr.select_font_face("Sans", 0, 1)
cr.set_font_size(bfs)
bte = cr.text_extents(badge_txt)
bpad = 2.0
bw = bte.width + bpad * 2
bh_pill = bte.height + bpad * 2
bx = int(kx + kw - bw - 5)
by = int(ky + kh - bh_pill - 5)
cr.set_source_rgba(*_COL_BADGE_BG)
self._rounded_rect(cr, bx, by, bw, bh_pill, bh_pill / 2)
cr.fill()
cr.set_source_rgba(*_COL_BADGE_FG)
cr.move_to(int(bx + bpad - bte.x_bearing), int(by + bpad - bte.y_bearing))
cr.show_text(badge_txt)
self._key_rects.append((kid, kx, ky, kw, kh))
x += key_w
@staticmethod
def _rounded_rect(cr, x: float, y: float, w: float, h: float, r: float):
r = min(r, w / 2, h / 2)
cr.new_sub_path()
cr.arc(x + w - r, y + r, r, -math.pi / 2, 0)
cr.arc(x + w - r, y + h - r, r, 0, math.pi / 2)
cr.arc(x + r, y + h - r, r, math.pi / 2, math.pi)
cr.arc(x + r, y + r, r, math.pi, 3 * math.pi / 2)
cr.close_path()
@staticmethod
def _first_modifier(binds: list[dict]) -> str:
if not binds:
return ""
keysym = binds[0].get("keysym", "")
parts = keysym.split("+")
if len(parts) > 1:
m = parts[0].lower()
_mod_labels = {
"mod": "MOD",
"super": "SUP",
"ctrl": "CTL",
"control": "CTL",
"shift": "SHF",
"alt": "ALT",
"win": "WIN",
}
return _mod_labels.get(m, m[:4].upper())
return ""
@staticmethod
def _build_legend() -> Gtk.Box:
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=20)
box.set_halign(Gtk.Align.CENTER)
box.set_margin_top(2)
box.set_opacity(0.65)
def _chip(rgba_css: str, text: str) -> Gtk.Box:
hb = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
swatch = Gtk.Box()
swatch.set_size_request(12, 12)
swatch.add_css_class("nm-kb-swatch")
attrs = Gtk.CssProvider()
attrs.load_from_data(
f".nm-kb-swatch {{ background: {rgba_css}; border-radius: 3px; }}".encode()
)
swatch.get_style_context().add_provider(
attrs, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
)
lbl = Gtk.Label(label=text)
lbl.add_css_class("caption")
hb.append(swatch)
hb.append(lbl)
return hb
box.append(_chip("rgba(147, 51, 234, 0.7)", "Bound"))
box.append(_chip("rgba(192, 97, 203, 1.0)", "Search match"))
box.append(_chip("rgba(168, 85, 247, 1.0)", "Selected"))
box.append(_chip("rgba(24, 24, 27, 1.0)", "Unbound"))
return box
# Action overlay panel
class _ActionPanel(Gtk.Box):
"""Shows the binding details for the currently selected key."""
def __init__(self, on_edit=None, on_add=None, on_delete=None):
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=0)
self._on_edit = on_edit
self._on_add = on_add
self._on_delete = on_delete
self._current_key_id = None
self.add_css_class("nm-kb-action-panel")
self.set_visible(False)
# Header row
header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
header.set_margin_start(14)
header.set_margin_end(14)
header.set_margin_top(10)
header.set_margin_bottom(6)
self._key_label = Gtk.Label(label="")
self._key_label.add_css_class("nm-kb-key-id-label")
self._key_label.set_xalign(0.0)
self._key_label.set_hexpand(True)
header.append(self._key_label)
self._count_label = Gtk.Label(label="")
self._count_label.add_css_class("dim-label")
self._count_label.add_css_class("caption")
header.append(self._count_label)
self._header_add_btn = Gtk.Button(icon_name="list-add-symbolic")
self._header_add_btn.add_css_class("flat")
self._header_add_btn.add_css_class("circular")
self._header_add_btn.set_tooltip_text("Add another binding for this key")
self._header_add_btn.set_valign(Gtk.Align.CENTER)
self._header_add_btn.set_visible(False)
self._header_add_btn.connect("clicked", self._on_header_add_clicked)
header.append(self._header_add_btn)
self.append(header)
self.append(Gtk.Separator())
self._grp_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
self._grp_container.set_margin_start(8)
self._grp_container.set_margin_end(8)
self._grp_container.set_margin_top(6)
self._grp_container.set_margin_bottom(8)
self.append(self._grp_container)
self.set_visible(False)
def _on_header_add_clicked(self, *_):
if self._on_add and self._current_key_id:
self._on_add(self._current_key_id)
def update(self, key_id: str, binds: list[dict]):
self._current_key_id = key_id
while True:
c = self._grp_container.get_first_child()
if c is None:
break
self._grp_container.remove(c)
new_grp = Adw.PreferencesGroup()
if not binds:
self._key_label.set_label(key_id.upper())
self._count_label.set_label("No bindings")
self._header_add_btn.set_visible(False)
add_btn = Gtk.Button(label=f"Create Binding for {key_id.upper()}")
add_btn.add_css_class("suggested-action")
add_btn.add_css_class("pill")
add_btn.set_halign(Gtk.Align.CENTER)
add_btn.set_margin_top(8)
add_btn.set_margin_bottom(8)
if self._on_add:
add_btn.connect("clicked", lambda *_: self._on_add(key_id))
new_grp.add(add_btn)
else:
self._key_label.set_label(key_id.upper())
n = len(binds)
self._count_label.set_label(f"{n} binding" + ("s" if n != 1 else ""))
self._header_add_btn.set_visible(True)
for b in binds:
keysym = b.get("keysym", "?")
action = b.get("action", "")
args = b.get("action_args") or []
arg_str = " ".join(str(a) for a in args)
full_action = f"{action} {arg_str}".strip() or "(no action)"
row = Adw.ActionRow(title=GLib.markup_escape_text(full_action))
keys_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
keys_box.set_valign(Gtk.Align.CENTER)
keys_box.set_margin_start(4)
keys_box.set_margin_end(16)
parts = keysym.split("+")
_labels = {
"mod": "Mod",
"super": "Super",
"ctrl": "Ctrl",
"control": "Ctrl",
"shift": "Shift",
"alt": "Alt",
"win": "Win",
}
for i, part in enumerate(parts):
label_text = part
is_mod = i < len(parts) - 1
if is_mod:
label_text = _labels.get(part.lower(), part)
else:
label_text = (
label_text.upper() if len(label_text) == 1 else label_text
)
cap = Gtk.Label(label=label_text)
if is_mod:
cap.add_css_class("nm-keycap-mod")
else:
cap.add_css_class("nm-keycap-main")
keys_box.append(cap)
row.add_prefix(keys_box)
if b.get("allow_when_locked"):
lock = Gtk.Label(label="🔒")
lock.set_tooltip_text("Allowed when screen is locked")
lock.set_valign(Gtk.Align.CENTER)
row.add_suffix(lock)
edit_btn = Gtk.Button(icon_name="document-edit-symbolic")
edit_btn.add_css_class("flat")
edit_btn.add_css_class("circular")
edit_btn.set_valign(Gtk.Align.CENTER)
if self._on_edit:
edit_btn.connect("clicked", lambda *_, bind_ref=b: self._on_edit(bind_ref))
row.add_suffix(edit_btn)
del_btn = Gtk.Button(icon_name="user-trash-symbolic")
del_btn.add_css_class("flat")
del_btn.add_css_class("circular")
del_btn.add_css_class("error")
del_btn.set_valign(Gtk.Align.CENTER)
if self._on_delete:
del_btn.connect("clicked", lambda *_, bind_ref=b: self._on_delete(bind_ref))
row.add_suffix(del_btn)
new_grp.add(row)
self._grp_container.append(new_grp)
self.set_visible(True)

1135
nirimod/window.py Normal file

File diff suppressed because it is too large Load Diff

299
nirimod/window_effects.py Normal file
View File

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

168
nirimod/xkb_helper.py Normal file
View File

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

78
package.nix Normal file
View File

@@ -0,0 +1,78 @@
{
lib,
python3Packages,
wrapGAppsHook4,
gtk4,
libadwaita,
gdk-pixbuf,
gobject-introspection,
hicolor-icon-theme,
desktop-file-utils,
}:
python3Packages.buildPythonApplication (finalAttrs: {
pname = "nirimod";
version = "0.1.0";
pyproject = true;
src = lib.cleanSource ./.;
# For nixpkgs: replace with fetchFromGitHub pointing to a release tag:
# src = fetchFromGitHub {
# owner = "srinivasr";
# repo = "nirimod";
# tag = "v${finalAttrs.version}";
# hash = "sha256-...";
# };
nativeBuildInputs = [
wrapGAppsHook4
gobject-introspection
desktop-file-utils
];
build-system = with python3Packages; [
hatchling
];
buildInputs = [
gtk4
libadwaita
gdk-pixbuf
hicolor-icon-theme
];
dependencies = with python3Packages; [
pygobject3
];
postInstall = ''
install -Dm644 data/nirimod.svg $out/share/icons/hicolor/scalable/apps/nirimod.svg
mkdir -p $out/share/applications
cat > $out/share/applications/io.github.nirimod.desktop << EOF
[Desktop Entry]
Version=1.0
Name=NiriMod
GenericName=Compositor Settings
Comment=GUI Configuration Manager for the Niri Wayland Compositor
Exec=nirimod
Icon=nirimod
Terminal=false
Type=Application
Categories=Utility;Settings;DesktopSettings;
Keywords=compositor;windowmanager;wayland;niri;settings;config;
StartupNotify=true
StartupWMClass=nirimod
EOF
'';
meta = {
description = "A polished GTK4/libadwaita GUI configurator for the niri Wayland compositor";
homepage = "https://github.com/srinivasr/nirimod";
license = lib.licenses.mit;
maintainers = [ ];
mainProgram = "nirimod";
platforms = lib.platforms.linux;
};
})

34
pyproject.toml Normal file
View File

@@ -0,0 +1,34 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "nirimod"
version = "0.1.0"
description = "A polished GTK4/libadwaita GUI configurator for the niri Wayland compositor"
readme = "README.md"
license = "MIT"
requires-python = ">=3.12"
dependencies = [
# PyGObject (gi.repository.Gtk, Adw) must be installed via system package manager:
# Arch: sudo pacman -S python-gobject gtk4 libadwaita
# Fedora: sudo dnf install python3-gobject gtk4 libadwaita
# Ubuntu: sudo apt install python3-gi gir1.2-gtk-4.0 gir1.2-adw-1
]
[project.scripts]
nirimod = "nirimod.__main__:main"
[tool.hatch.build.targets.wheel]
packages = ["nirimod"]
[tool.ruff]
ignore = ["E402"]
[tool.uv]
python-preference = "system"
[dependency-groups]
dev = [
"pytest>=9.0.3",
]

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""NiriMod test suite."""

311
tests/test_features.py Normal file
View File

@@ -0,0 +1,311 @@
#!/usr/bin/env python3
"""Headless feature tests for NiriMod — exercises every page's logic."""
import sys
import traceback
import os
os.environ.setdefault("DISPLAY", ":0")
os.environ.setdefault("WAYLAND_DISPLAY", "wayland-1")
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
PASS = "[PASS]"
WARN = "[WARN]"
FAIL = "[FAIL]"
results = []
def test(name, fn):
try:
msg = fn()
results.append((PASS, name, msg or ""))
except Exception as e:
results.append((FAIL, name, f"{type(e).__name__}: {e}"))
traceback.print_exc()
test.__test__ = False
# KDL Parser
from nirimod.kdl_parser import parse_kdl, write_kdl, KdlNode
def t_kdl_roundtrip():
src = 'output "eDP-1" { scale 2.0; }\nbinds { XF86AudioRaise { action "volume-up"; } }'
nodes = parse_kdl(src)
assert nodes, "no nodes parsed"
return f"{len(nodes)} nodes"
def t_kdl_include():
src = 'include "~/.config/niri/dms/monitor.kdl"\nspawn-at-startup "waybar"'
nodes = parse_kdl(src)
names = [n.name for n in nodes]
assert "include" in names
assert "spawn-at-startup" in names
return "include + spawn parsed"
def t_kdl_nested():
src = 'window-rule { match app-id="firefox"; open-maximized true; }'
nodes = parse_kdl(src)
assert nodes[0].name == "window-rule"
assert nodes[0].children
return "nested nodes OK"
def t_kdl_write():
node = KdlNode("spawn-at-startup", args=["waybar", "--config", "/etc/waybar.json"])
out = write_kdl([node])
assert "waybar" in out
return out.strip()
test("KDL: parse + write roundtrip", t_kdl_roundtrip)
test("KDL: include directive parsing", t_kdl_include)
test("KDL: nested children parsing", t_kdl_nested)
test("KDL: write KdlNode with args", t_kdl_write)
# AppState
from nirimod.state import AppState
def t_state_load():
s = AppState()
s.load()
assert isinstance(s.nodes, list)
return f"{len(s.nodes)} top-level nodes loaded"
def t_state_dirty():
s = AppState()
s.load()
assert not s.is_dirty
s.mark_dirty()
assert s.is_dirty
s.mark_clean()
assert not s.is_dirty
return "dirty/clean flags OK"
def t_state_discard():
s = AppState()
s.load()
original_len = len(s.nodes)
s.nodes.append(KdlNode("test-node"))
s.mark_dirty()
s.discard()
assert len(s.nodes) == original_len
return f"discarded back to {original_len} nodes"
def t_state_undo():
s = AppState()
s.load()
before = write_kdl(s.nodes)
s.nodes.append(KdlNode("test-undo-node"))
after = write_kdl(s.nodes)
s.push_undo("add test node", before, after)
assert s.undo.can_undo()
entry = s.apply_undo()
assert entry is not None
assert "test-undo-node" not in write_kdl(s.nodes)
return "undo restored previous state"
test("AppState: load from disk", t_state_load)
test("AppState: dirty / clean flags", t_state_dirty)
test("AppState: discard reverts nodes", t_state_discard)
test("AppState: undo stack push+pop", t_state_undo)
# Undo Manager
from nirimod.undo import UndoManager, UndoEntry
def t_undo_redo():
m = UndoManager()
m.push(UndoEntry("step1", "before1", "after1"))
m.push(UndoEntry("step2", "before2", "after2"))
assert m.can_undo()
e = m.pop_undo()
assert e.description == "step2"
assert m.can_redo()
e2 = m.pop_redo()
assert e2.description == "step2"
return "undo→redo cycle OK"
test("UndoManager: push/pop/redo", t_undo_redo)
# Profiles
from nirimod import profiles as prof_mod
def t_profiles_list():
names = prof_mod.list_profiles()
assert isinstance(names, list)
return f"{len(names)} profiles found"
def t_profiles_save_delete():
s = AppState()
s.load()
# save_profile takes name + optional set[Path] of source files
prof_mod.save_profile("__test_profile__", s.source_files)
names = prof_mod.list_profiles()
assert "__test_profile__" in names, f"profile not found in {names}"
prof_mod.delete_profile("__test_profile__")
assert "__test_profile__" not in prof_mod.list_profiles()
return "save + delete profile OK"
test("Profiles: list", t_profiles_list)
test("Profiles: save and delete", t_profiles_save_delete)
# Pages (import + build check)
# We test imports and logic only — no GTK widget creation without display
page_modules = [
("appearance", "nirimod.pages.appearance"),
("animations", "nirimod.pages.animations"),
("layout", "nirimod.pages.layout"),
("startup", "nirimod.pages.startup"),
("environment","nirimod.pages.environment"),
("workspaces", "nirimod.pages.workspaces"),
("window_rules","nirimod.pages.window_rules"),
("bindings", "nirimod.pages.bindings"),
("outputs", "nirimod.pages.outputs"),
("input_page", "nirimod.pages.input_page"),
("gestures", "nirimod.pages.gestures"),
("raw_config", "nirimod.pages.raw_config"),
]
import importlib
for name, module_path in page_modules:
def _test(mp=module_path, n=name):
importlib.import_module(mp)
return "module imported OK"
test(f"Page import: {name}", _test)
# Startup page logic
import shlex
def t_startup_spawn_sh():
cmd = "waybar --config /etc/waybar.json"
node = KdlNode("spawn-sh-at-startup", args=[cmd]) # single string for sh
assert node.args[0] == cmd
return f"spawn-sh-at-startup args = {node.args}"
def t_startup_spawn_direct():
cmd = "dunst"
args = shlex.split(cmd)
node = KdlNode("spawn-at-startup", args=args)
assert node.args == ["dunst"]
return "spawn-at-startup args OK"
test("Startup: spawn-sh-at-startup node", t_startup_spawn_sh)
test("Startup: spawn-at-startup node", t_startup_spawn_direct)
# Animations curve serialization
def t_anim_curve_format():
# The correct niri format is: curve "cubic-bezier" 0.25 0.1 0.25 1.0
kdl = 'animations { workspace-switch { spring damping-ratio=1.0; } }'
nodes = parse_kdl(kdl)
out = write_kdl(nodes)
assert "workspace-switch" in out
return "animation node roundtrip OK"
test("Animations: curve node roundtrip", t_anim_curve_format)
# Environment page logic
def t_env_node():
node = KdlNode("environment", children=[
KdlNode("WAYLAND_DISPLAY", args=["wayland-1"])
])
out = write_kdl([node])
assert "WAYLAND_DISPLAY" in out
return out.strip()
test("Environment: env var node write", t_env_node)
# Window rules logic
def t_window_rule_node():
kdl = 'window-rule { match app-id="org.gnome.Calculator"; open-floating true; }'
nodes = parse_kdl(kdl)
assert nodes[0].name == "window-rule"
children_names = [c.name for c in nodes[0].children]
assert "match" in children_names
assert "open-floating" in children_names
return f"children: {children_names}"
test("Window Rules: parse match+action", t_window_rule_node)
# Output node
def t_output_node():
kdl = 'output "eDP-1" { scale 1.5; transform "90"; mode "1920x1080@60"; }'
nodes = parse_kdl(kdl)
assert nodes[0].name == "output"
assert nodes[0].args == ["eDP-1"]
children = {c.name: c for c in nodes[0].children}
assert "scale" in children
assert float(children["scale"].args[0]) == 1.5
return "output node parsed OK"
test("Outputs: parse output node", t_output_node)
# Bindings logic
def t_binds_node():
kdl = 'binds { Mod+T { action spawn "alacritty"; } Mod+Q { action close-window; } }'
nodes = parse_kdl(kdl)
assert nodes[0].name == "binds"
assert len(nodes[0].children) == 2
return f"{len(nodes[0].children)} binds found"
test("Bindings: parse binds block", t_binds_node)
# Workspaces logic
def t_workspaces_node():
kdl = 'workspaces { workspace "Browser"; workspace "Terminal"; }'
nodes = parse_kdl(kdl)
assert nodes[0].name == "workspaces"
return f"{len(nodes[0].children)} workspaces"
test("Workspaces: parse workspace names", t_workspaces_node)
# NiriIPC
from nirimod import niri_ipc
def t_ipc_is_running():
result = niri_ipc.is_niri_running()
assert isinstance(result, bool)
return f"niri running = {result}"
def t_ipc_has_touchpad():
result = niri_ipc.has_touchpad()
assert isinstance(result, bool)
return f"has touchpad = {result}"
test("NiriIPC: is_niri_running()", t_ipc_is_running)
test("NiriIPC: has_touchpad()", t_ipc_has_touchpad)
# AppSettings
from nirimod import app_settings
def t_app_settings():
original = app_settings.get("auto_update", True)
app_settings.set("auto_update", False)
assert not app_settings.get("auto_update")
app_settings.set("auto_update", original)
return "get/set OK"
test("AppSettings: get/set", t_app_settings)
def _print_results() -> int:
print("\n" + "="*50)
print(" NIRIMOD FEATURE TEST REPORT")
print("="*50)
passed = sum(1 for r in results if r[0] == PASS)
failed = sum(1 for r in results if r[0] == FAIL)
warned = sum(1 for r in results if r[0] == WARN)
for icon, name, detail in results:
status = f"{icon} {name}"
if detail:
print(f"{status}\n{detail}")
else:
print(status)
print("="*50)
print(
f" {passed} passed | {warned} warnings | {failed} failed | {len(results)} total"
)
print("="*50)
return failed
if __name__ == "__main__":
sys.exit(1 if _print_results() else 0)

192
tests/test_ipc.py Normal file
View File

@@ -0,0 +1,192 @@
"""Unit tests for the niri IPC wrappers.
Tests the synchronous helpers and validates that the non-blocking async
dispatch functions are wired correctly, using mocks to avoid requiring
a live niri compositor.
"""
from __future__ import annotations
import unittest
from unittest.mock import patch
class TestRunSync(unittest.TestCase):
"""Tests for the internal _run_sync helper."""
def test_command_not_found(self):
from nirimod.niri_ipc import _run_sync
stdout, stderr, rc = _run_sync(["__nonexistent_binary_xyz__"])
self.assertEqual(rc, 1)
self.assertIn("not found", stderr)
def test_successful_command(self):
from nirimod.niri_ipc import _run_sync
stdout, stderr, rc = _run_sync(["echo", "hello"])
self.assertEqual(rc, 0)
self.assertIn("hello", stdout)
def test_timeout(self):
from nirimod.niri_ipc import _run_sync
stdout, stderr, rc = _run_sync(["sleep", "10"], timeout=0.01)
self.assertEqual(rc, 1)
self.assertIn("timed out", stderr)
class TestIsNiriRunning(unittest.TestCase):
def test_returns_false_when_niri_absent(self):
from nirimod import niri_ipc
with patch.object(niri_ipc, "_run_sync", return_value=("", "not found", 1)):
self.assertFalse(niri_ipc.is_niri_running())
def test_returns_true_when_niri_present(self):
from nirimod import niri_ipc
with patch.object(niri_ipc, "_run_sync", return_value=("niri 1.0\n", "", 0)):
self.assertTrue(niri_ipc.is_niri_running())
class TestValidateConfig(unittest.TestCase):
def test_valid_config(self):
from nirimod import niri_ipc
with patch.object(niri_ipc, "_run_sync", return_value=("Config is valid.\n", "", 0)):
ok, msg = niri_ipc.validate_config()
self.assertTrue(ok)
self.assertIn("valid", msg.lower())
def test_invalid_config(self):
from nirimod import niri_ipc
with patch.object(niri_ipc, "_run_sync", return_value=("", "parse error line 3", 1)):
ok, msg = niri_ipc.validate_config()
self.assertFalse(ok)
self.assertIn("parse error", msg)
def test_with_config_path(self):
from nirimod import niri_ipc
captured = {}
def fake_run(args, timeout=5.0):
captured["args"] = args
return ("ok", "", 0)
with patch.object(niri_ipc, "_run_sync", side_effect=fake_run):
niri_ipc.validate_config("/tmp/test.kdl")
self.assertIn("--config", captured["args"])
self.assertIn("/tmp/test.kdl", captured["args"])
class TestLoadConfigFile(unittest.TestCase):
def test_load_config_file_calls_niri_action(self):
from nirimod import niri_ipc
captured = {}
def fake_run(args, timeout=5.0):
captured["args"] = args
captured["timeout"] = timeout
return ("", "", 0)
with patch.object(niri_ipc, "_run_sync", side_effect=fake_run):
ok, msg = niri_ipc.load_config_file()
self.assertTrue(ok)
self.assertEqual(captured["args"], ["niri", "msg", "action", "load-config-file"])
self.assertEqual(captured["timeout"], 10.0)
self.assertIn("applied", msg)
def test_load_config_file_reports_failure(self):
from nirimod import niri_ipc
with patch.object(niri_ipc, "_run_sync", return_value=("", "reload failed", 1)):
ok, msg = niri_ipc.load_config_file()
self.assertFalse(ok)
self.assertIn("reload failed", msg)
class TestHasTouchpad(unittest.TestCase):
def test_caching(self):
import nirimod.niri_ipc as ipc_mod
# Clear any existing cache
ipc_mod._touchpad_cache = None
call_count = [0]
original_listdir = __import__("os").listdir
def fake_listdir(path):
if path == "/sys/class/input":
call_count[0] += 1
return []
return original_listdir(path)
with patch("os.listdir", side_effect=fake_listdir):
ipc_mod.has_touchpad()
ipc_mod.has_touchpad()
# Second call should use cache, so listdir only called once
self.assertEqual(call_count[0], 1)
# Clean up for other tests
ipc_mod._touchpad_cache = None
class TestGetVersion(unittest.TestCase):
def test_returns_version_string(self):
from nirimod import niri_ipc
with patch.object(niri_ipc, "_run_sync", return_value=("niri 1.2.3\n", "", 0)):
v = niri_ipc.get_version()
self.assertEqual(v, "niri 1.2.3")
def test_returns_unknown_on_failure(self):
from nirimod import niri_ipc
with patch.object(niri_ipc, "_run_sync", return_value=("", "error", 1)):
v = niri_ipc.get_version()
self.assertEqual(v, "unknown")
class TestRunInThread(unittest.TestCase):
"""Compatibility shim run_in_thread should invoke callback."""
def test_shim_calls_callback(self):
from nirimod import niri_ipc
try:
import gi
gi.require_version("GLib", "2.0")
from gi.repository import GLib
except (ModuleNotFoundError, Exception):
self.skipTest("gi (PyGObject) not available in this test environment")
results = []
original_idle_add = GLib.idle_add
def sync_idle_add(fn, *args):
fn(*args)
return 0
GLib.idle_add = sync_idle_add
try:
t = niri_ipc.run_in_thread(lambda: 42, lambda r: results.append(r))
t.join(timeout=2.0)
finally:
GLib.idle_add = original_idle_add
self.assertEqual(results, [42])
if __name__ == "__main__":
unittest.main()

202
tests/test_kdl_parser.py Normal file
View File

@@ -0,0 +1,202 @@
"""Unit tests for the KDL parser and writer.
Tests the core parse → mutate → write round-trip logic that underpins
all config changes in NiriMod.
"""
from __future__ import annotations
import unittest
from nirimod.kdl_parser import (
KdlNode,
KdlRawString,
find_or_create,
parse_kdl,
remove_child,
set_child_arg,
set_node_flag,
write_kdl,
)
class TestKdlRoundTrip(unittest.TestCase):
"""parse_kdl → write_kdl should produce semantically equivalent output."""
def _roundtrip(self, text: str) -> list[KdlNode]:
nodes = parse_kdl(text)
out = write_kdl(nodes)
return parse_kdl(out)
def test_simple_node(self):
nodes = parse_kdl("prefer-no-csd\n")
self.assertEqual(len(nodes), 1)
self.assertEqual(nodes[0].name, "prefer-no-csd")
def test_node_with_string_arg(self):
nodes = parse_kdl('output "eDP-1" {\n scale 2.0\n}\n')
self.assertEqual(nodes[0].name, "output")
self.assertEqual(nodes[0].args[0], "eDP-1")
scale = nodes[0].get_child("scale")
self.assertIsNotNone(scale)
self.assertAlmostEqual(scale.args[0], 2.0)
def test_boolean_values(self):
nodes = parse_kdl(
"input {\n keyboard {\n repeat-rate 30\n xkb-numlock true\n }\n}\n"
)
kb = nodes[0].get_child("keyboard")
self.assertIsNotNone(kb)
numlock = kb.get_child("xkb-numlock")
self.assertIsNotNone(numlock)
self.assertIs(numlock.args[0], True)
def test_raw_string(self):
text = "spawn-at-startup r#\"bash -c 'echo hi'\"#\n"
nodes = parse_kdl(text)
self.assertIsInstance(nodes[0].args[0], KdlRawString)
def test_raw_string_property_preserves_backslash(self):
src = 'match app-id="steam" title=r#"^notificationtoasts_\\d+_desktop$"#\n'
nodes = parse_kdl(src)
title = nodes[0].props["title"]
self.assertIsInstance(title, KdlRawString)
self.assertEqual(title, r"^notificationtoasts_\d+_desktop$")
self.assertIn(r'title=r"^notificationtoasts_\d+_desktop$"', write_kdl(nodes))
def test_write_preserves_children(self):
src = "layout {\n gaps 16\n border {\n width 2\n }\n}\n"
nodes = self._roundtrip(src)
layout = nodes[0]
self.assertEqual(layout.name, "layout")
border = layout.get_child("border")
self.assertIsNotNone(border)
self.assertEqual(border.get_child("width").args[0], 2)
def test_null_value(self):
nodes = parse_kdl("cursor-warps null\n")
self.assertIsNone(nodes[0].args[0])
def test_props(self):
nodes = parse_kdl("position x=0 y=1080\n")
self.assertEqual(nodes[0].props["x"], 0)
self.assertEqual(nodes[0].props["y"], 1080)
def test_empty_input(self):
nodes = parse_kdl("")
self.assertEqual(nodes, [])
self.assertIn("NiriMod", write_kdl(nodes))
def test_comments_are_preserved_as_trivia(self):
src = "// top-level comment\nprefer-no-csd\n"
out = write_kdl(parse_kdl(src))
self.assertIn("prefer-no-csd", out)
class TestMutationHelpers(unittest.TestCase):
"""Tests for find_or_create, set_child_arg, remove_child, set_node_flag."""
def setUp(self):
self.nodes = parse_kdl("layout {\n gaps 8\n}\n")
def test_find_existing(self):
node = find_or_create(self.nodes, "layout")
self.assertEqual(node.name, "layout")
def test_create_missing(self):
node = find_or_create(self.nodes, "input")
self.assertEqual(node.name, "input")
self.assertIn(node, self.nodes)
def test_find_or_create_nested(self):
node = find_or_create(self.nodes, "layout", "struts")
self.assertEqual(node.name, "struts")
def test_set_child_arg_creates(self):
parent = self.nodes[0]
set_child_arg(parent, "border-rule", 4)
child = parent.get_child("border-rule")
self.assertIsNotNone(child)
self.assertEqual(child.args[0], 4)
def test_set_child_arg_updates(self):
parent = self.nodes[0]
set_child_arg(parent, "gaps", 16)
self.assertEqual(parent.get_child("gaps").args[0], 16)
def test_remove_child(self):
parent = self.nodes[0]
remove_child(parent, "gaps")
self.assertIsNone(parent.get_child("gaps"))
def test_remove_nonexistent_is_noop(self):
parent = self.nodes[0]
remove_child(parent, "nonexistent")
self.assertEqual(len(parent.children), 1)
def test_set_node_flag_add(self):
parent = KdlNode("input")
set_node_flag(parent, "warp-mouse-to-focus", True)
self.assertIsNotNone(parent.get_child("warp-mouse-to-focus"))
self.assertEqual(parent.get_child("warp-mouse-to-focus").args, [])
def test_set_node_flag_serializes_bare_flag(self):
parent = KdlNode("blur")
set_node_flag(parent, "off", True)
self.assertIn("off", write_kdl([parent]))
self.assertNotIn("off true", write_kdl([parent]))
def test_set_node_flag_remove(self):
parent = KdlNode("input")
parent.children.append(KdlNode("warp-mouse-to-focus"))
set_node_flag(parent, "warp-mouse-to-focus", False)
self.assertIsNone(parent.get_child("warp-mouse-to-focus"))
def test_set_node_flag_restores_bare_flag(self):
parent = KdlNode("blur")
parent.children.append(KdlNode("off"))
set_node_flag(parent, "off", False)
set_node_flag(parent, "off", True)
self.assertIn("off", write_kdl([parent]))
self.assertNotIn("off true", write_kdl([parent]))
def test_set_node_flag_idempotent_add(self):
parent = KdlNode("input")
set_node_flag(parent, "warp-mouse-to-focus", True)
set_node_flag(parent, "warp-mouse-to-focus", True)
count = sum(1 for c in parent.children if c.name == "warp-mouse-to-focus")
self.assertEqual(count, 1)
class TestWriteKdl(unittest.TestCase):
"""Tests for the KDL serializer."""
def test_write_empty(self):
out = write_kdl([])
self.assertIn("NiriMod", out)
def test_write_simple(self):
nodes = [KdlNode("prefer-no-csd")]
out = write_kdl(nodes)
self.assertIn("prefer-no-csd", out)
def test_write_raw_string(self):
# A value containing a double-quote triggers the hash-delimited r#"..."# form
node = KdlNode("env", args=[KdlRawString('value with "double" quotes')])
out = write_kdl([node])
self.assertIn('r#"', out)
def test_write_nested(self):
parent = KdlNode("layout")
parent.children.append(KdlNode("gaps", args=[16]))
out = write_kdl([parent])
self.assertIn("gaps", out)
self.assertIn("16", out)
if __name__ == "__main__":
unittest.main()

162
tests/test_state.py Normal file
View File

@@ -0,0 +1,162 @@
"""Unit tests for the AppState manager.
Tests state initialization, dirty tracking, undo/redo integration,
commit_save, discard, and node serialization helpers — without requiring
a live GTK session or filesystem access.
"""
from __future__ import annotations
import unittest
from unittest.mock import patch
from nirimod.kdl_parser import KdlNode, KdlRawString, parse_kdl, write_kdl
from nirimod.state import AppState
class TestAppStateInit(unittest.TestCase):
"""AppState starts in a clean, non-dirty state."""
def test_initial_state(self):
state = AppState()
self.assertEqual(state.nodes, [])
self.assertEqual(state.saved_kdl, "")
self.assertFalse(state.is_dirty)
self.assertFalse(state.niri_running)
self.assertFalse(state.has_touchpad)
def test_initial_undo_empty(self):
state = AppState()
self.assertFalse(state.undo.can_undo())
self.assertFalse(state.undo.can_redo())
class TestDirtyTracking(unittest.TestCase):
def test_mark_dirty(self):
state = AppState()
self.assertFalse(state.is_dirty)
state.mark_dirty()
self.assertTrue(state.is_dirty)
def test_mark_clean(self):
state = AppState()
state.mark_dirty()
state.mark_clean()
self.assertFalse(state.is_dirty)
class TestUndoRedo(unittest.TestCase):
def _make_state_with_nodes(self, kdl: str) -> AppState:
state = AppState()
state.nodes = parse_kdl(kdl)
state._saved_kdl = kdl
return state
def test_push_and_apply_undo(self):
state = self._make_state_with_nodes("gaps 8\n")
before = "gaps 8\n"
after = "gaps 16\n"
state.push_undo("change gaps", before, after)
self.assertTrue(state.undo.can_undo())
entry = state.apply_undo()
self.assertIsNotNone(entry)
self.assertEqual(state.nodes[0].get_child("gaps") if state.nodes and state.nodes[0].children else None, None)
# After undo, nodes should be from the 'before' snapshot
kdl_out = write_kdl(state.nodes)
self.assertIn("8", kdl_out)
def test_apply_undo_empty_returns_none(self):
state = AppState()
result = state.apply_undo()
self.assertIsNone(result)
def test_apply_redo_empty_returns_none(self):
state = AppState()
result = state.apply_redo()
self.assertIsNone(result)
def test_undo_sets_dirty(self):
state = self._make_state_with_nodes("gaps 8\n")
state.push_undo("x", "gaps 16\n", "gaps 24\n")
state.apply_undo()
self.assertTrue(state.is_dirty)
def test_redo_after_undo(self):
state = self._make_state_with_nodes("gaps 8\n")
state.push_undo("x", "gaps 8\n", "gaps 16\n")
state.apply_undo()
self.assertTrue(state.undo.can_redo())
entry = state.apply_redo()
self.assertIsNotNone(entry)
kdl_out = write_kdl(state.nodes)
self.assertIn("16", kdl_out)
class TestCommitSave(unittest.TestCase):
def test_commit_save_clears_undo_and_dirty(self):
state = AppState()
state.push_undo("x", "a", "b")
state.mark_dirty()
state.commit_save("new kdl\n")
self.assertEqual(state.saved_kdl, "new kdl\n")
self.assertFalse(state.is_dirty)
self.assertFalse(state.undo.can_undo())
class TestDiscard(unittest.TestCase):
def test_discard_restores_saved_kdl(self):
state = AppState()
state._saved_kdl = "gaps 8\n"
state.nodes = parse_kdl("gaps 16\n")
state.mark_dirty()
state.push_undo("x", "gaps 8\n", "gaps 16\n")
state.discard()
self.assertFalse(state.is_dirty)
self.assertFalse(state.undo.can_undo())
kdl_out = write_kdl(state.nodes)
self.assertIn("8", kdl_out)
def test_discard_empty_saved_kdl(self):
state = AppState()
state._saved_kdl = ""
state.discard()
self.assertEqual(state.nodes, [])
class TestWriteCurrentKdl(unittest.TestCase):
def test_write_current_kdl(self):
state = AppState()
state.nodes = [KdlNode("prefer-no-csd")]
out = state.write_current_kdl()
self.assertIn("prefer-no-csd", out)
def test_write_raw_string(self):
# A string containing a double-quote forces the hash-delimited raw form
node = KdlNode("env", args=[KdlRawString('has "double" quotes')])
out = write_kdl([node])
self.assertIn('r#"', out)
class TestLoad(unittest.TestCase):
def test_load_detects_runtime(self):
state = AppState()
# state.py does: from nirimod import niri_ipc
# We patch at the canonical module location so all references see the mock.
with (
patch("nirimod.niri_ipc.is_niri_running", return_value=True),
patch("nirimod.niri_ipc.has_touchpad", return_value=True),
patch("nirimod.kdl_parser.NIRI_CONFIG") as mock_cfg,
):
mock_cfg.exists.return_value = False
state.load()
self.assertTrue(state.niri_running)
self.assertTrue(state.has_touchpad)
self.assertFalse(state.is_dirty)
if __name__ == "__main__":
unittest.main()

116
tests/test_updater.py Normal file
View File

@@ -0,0 +1,116 @@
"""Unit tests for updater terminal selection."""
from __future__ import annotations
import os
import subprocess
import tempfile
import pytest
pytest.importorskip("gi")
import unittest
from unittest.mock import patch
from nirimod import updater
class TestTerminalCandidates(unittest.TestCase):
def test_terminal_env_is_preferred(self):
with patch.dict(os.environ, {"TERMINAL": "ghostty"}, clear=False):
candidates = list(updater._terminal_candidates())
self.assertEqual(candidates[0], "ghostty")
self.assertIn("xdg-terminal-exec", candidates)
def test_ghostty_is_a_fallback_terminal(self):
self.assertIn("ghostty", updater.FALLBACK_TERMINALS)
class TestBuildTerminalCommand(unittest.TestCase):
def test_xdg_terminal_exec_gets_script_directly(self):
command = updater._build_terminal_command("xdg-terminal-exec", "/tmp/update.sh")
self.assertEqual(command, ["xdg-terminal-exec", "/tmp/update.sh"])
def test_regular_terminal_uses_execute_flag(self):
command = updater._build_terminal_command("ghostty", "/tmp/update.sh")
self.assertEqual(command, ["ghostty", "-e", "/tmp/update.sh"])
def test_terminal_command_with_existing_execute_flag(self):
command = updater._build_terminal_command(
"ghostty --gtk-single-instance=false -e", "/tmp/update.sh"
)
self.assertEqual(
command, ["ghostty", "--gtk-single-instance=false", "-e", "/tmp/update.sh"]
)
def test_invalid_terminal_command_is_ignored(self):
command = updater._build_terminal_command("ghostty '", "/tmp/update.sh")
self.assertIsNone(command)
class TestUpdateAvailability(unittest.TestCase):
def _run_git(self, repo: str, *args: str):
return subprocess.run(
["git", *args],
cwd=repo,
check=True,
capture_output=True,
text=True,
)
def _commit(self, repo: str, message: str) -> str:
self._run_git(repo, "commit", "--allow-empty", "-m", message)
return subprocess.check_output(
["git", "rev-parse", "HEAD"], cwd=repo, text=True
).strip()
def _make_repo(self) -> str:
temp_dir = tempfile.TemporaryDirectory()
self.addCleanup(temp_dir.cleanup)
repo = temp_dir.name
self._run_git(repo, "init")
self._run_git(repo, "config", "user.email", "test@example.com")
self._run_git(repo, "config", "user.name", "Test User")
return repo
def test_branch_ahead_of_remote_main_is_not_update(self):
repo = self._make_repo()
remote_hash = self._commit(repo, "remote main")
local_hash = self._commit(repo, "local branch")
self.assertFalse(updater._update_available(local_hash, remote_hash, repo))
def test_dirty_worktree_still_gets_remote_update(self):
repo = self._make_repo()
local_hash = self._commit(repo, "installed version")
remote_hash = self._commit(repo, "remote main")
self._run_git(repo, "checkout", "--detach", local_hash)
with open(os.path.join(repo, "local-change.txt"), "w") as fh:
fh.write("local edit\n")
self.assertTrue(updater._update_available(local_hash, remote_hash, repo))
class TestLaunchUpdaterInTerminal(unittest.TestCase):
def test_launch_uses_terminal_env(self):
with (
tempfile.TemporaryDirectory() as temp_dir,
patch.dict(os.environ, {"TERMINAL": "ghostty"}, clear=False),
patch.object(updater.tempfile, "gettempdir", return_value=temp_dir),
patch.object(updater.shutil, "which", return_value="/usr/bin/ghostty"),
patch.object(updater.subprocess, "Popen") as popen,
):
updater.launch_updater_in_terminal()
popen.assert_called_once_with(
["ghostty", "-e", os.path.join(temp_dir, "nirimod_update.sh")]
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,384 @@
"""Tests for global window effect rule helpers."""
from __future__ import annotations
import unittest
from nirimod.kdl_parser import KdlNode, parse_kdl, write_kdl
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,
)
class TestGlobalWindowEffects(unittest.TestCase):
def test_blur_effects_are_enabled_without_top_level_off(self):
nodes = parse_kdl(
"""
blur {
passes 3
offset 3
}
"""
)
self.assertTrue(blur_effects_enabled(nodes))
def test_disabling_blur_effects_writes_top_level_off(self):
nodes: list[KdlNode] = []
set_blur_effects_enabled(nodes, False)
out = write_kdl(nodes)
self.assertIn("blur", out)
self.assertIn("off", out)
self.assertNotIn("off true", out)
self.assertFalse(blur_effects_enabled(nodes))
def test_disabling_blur_effects_preserves_quality_settings(self):
nodes = parse_kdl(
"""
blur {
passes 3
offset 3
noise 0.02
saturation 1.5
}
"""
)
set_blur_effects_enabled(nodes, False)
out = write_kdl(nodes)
self.assertIn("off", out)
self.assertIn("passes 3", out)
self.assertIn("offset 3", out)
self.assertIn("noise 0.02", out)
self.assertIn("saturation 1.5", out)
self.assertNotIn("off true", out)
def test_enabling_blur_effects_removes_only_off(self):
nodes = parse_kdl(
"""
blur {
off
passes 3
offset 3
}
"""
)
set_blur_effects_enabled(nodes, True)
out = write_kdl(nodes)
blur = parse_kdl(out)[0]
self.assertIsNone(blur.get_child("off"))
self.assertIn("passes 3", out)
self.assertIn("offset 3", out)
self.assertTrue(blur_effects_enabled(nodes))
def test_enabling_blur_effects_sets_visible_default_opacity_when_unset(self):
nodes = parse_kdl(
"""
blur {
off
passes 3
}
"""
)
set_blur_effects_enabled(nodes, True)
out = write_kdl(nodes)
self.assertEqual(get_global_window_opacity(nodes), 0.9)
self.assertIn("opacity 0.9", out)
def test_enabling_blur_effects_preserves_existing_opacity(self):
nodes = parse_kdl(
"""
blur {
off
passes 3
}
window-rule {
opacity 0.75
}
"""
)
set_blur_effects_enabled(nodes, True)
self.assertEqual(get_global_window_opacity(nodes), 0.75)
self.assertIn("opacity 0.75", write_kdl(nodes))
def test_enabling_blur_creates_matchless_window_rule(self):
nodes: list[KdlNode] = []
set_global_window_blur(nodes, True)
out = write_kdl(nodes)
self.assertIn("window-rule", out)
self.assertIn("background-effect", out)
self.assertIn("blur true", out)
self.assertNotIn("draw-border-with-background", out)
self.assertTrue(global_window_blur_enabled(nodes))
def test_enabling_blur_sets_visible_default_opacity_when_unset(self):
nodes: list[KdlNode] = []
set_global_window_blur(nodes, True)
self.assertEqual(get_global_window_opacity(nodes), 0.9)
self.assertIn("opacity 0.9", write_kdl(nodes))
def test_enabling_blur_preserves_existing_opacity(self):
nodes = parse_kdl(
"""
window-rule {
opacity 0.75
}
"""
)
set_global_window_blur(nodes, True)
self.assertEqual(get_global_window_opacity(nodes), 0.75)
self.assertIn("opacity 0.75", write_kdl(nodes))
def test_disabling_blur_preserves_other_window_effect_settings(self):
nodes = parse_kdl(
"""
window-rule {
geometry-corner-radius 16
draw-border-with-background false
background-effect {
blur true
xray false
}
}
"""
)
set_global_window_blur(nodes, False)
rule = nodes[0]
self.assertEqual(rule.child_arg("geometry-corner-radius"), 16)
self.assertIsNotNone(rule.get_child("draw-border-with-background"))
self.assertIsNotNone(rule.get_child("background-effect"))
self.assertIsNone(rule.get_child("background-effect").get_child("blur"))
self.assertIsNotNone(rule.get_child("background-effect").get_child("xray"))
self.assertFalse(global_window_blur_enabled(nodes))
def test_disabling_blur_resets_window_opacity(self):
nodes = parse_kdl(
"""
window-rule {
opacity 0.95
background-effect {
blur true
xray false
}
}
"""
)
set_global_window_blur(nodes, False)
rule = nodes[0]
self.assertEqual(get_global_window_opacity(nodes), 1.0)
self.assertIsNone(rule.get_child("opacity"))
self.assertIsNone(rule.get_child("background-effect").get_child("blur"))
self.assertIsNotNone(rule.get_child("background-effect").get_child("xray"))
def test_disabling_blur_effects_clears_forced_blur_and_opacity(self):
nodes = parse_kdl(
"""
blur {
passes 3
}
window-rule {
opacity 0.9
background-effect {
blur true
xray false
}
}
window-rule {
match is-focused=true
background-effect {
blur true
}
}
"""
)
set_blur_effects_enabled(nodes, False)
out = write_kdl(nodes)
self.assertIn("off", out)
self.assertFalse(blur_effects_enabled(nodes))
self.assertFalse(global_window_blur_enabled(nodes))
self.assertFalse(focused_window_blur_enabled(nodes))
self.assertEqual(get_global_window_opacity(nodes), 1.0)
self.assertNotIn("blur true", out)
self.assertNotIn("opacity 0.9", out)
def test_corner_radius_writes_clip_and_can_be_removed(self):
nodes: list[KdlNode] = []
set_global_corner_radius(nodes, 16)
self.assertEqual(get_global_corner_radius(nodes), 16)
out = write_kdl(nodes)
self.assertIn("geometry-corner-radius 16", out)
self.assertIn("clip-to-geometry true", out)
set_global_corner_radius(nodes, 0)
self.assertEqual(nodes, [])
def test_matched_rules_are_not_reused_as_global_effect_rules(self):
nodes = parse_kdl(
"""
window-rule {
match app-id="Alacritty"
background-effect {
blur true
}
}
"""
)
set_global_corner_radius(nodes, 12)
self.assertEqual(len([n for n in nodes if n.name == "window-rule"]), 2)
self.assertIsNone(nodes[0].get_child("geometry-corner-radius"))
self.assertEqual(get_global_corner_radius(nodes), 12)
def test_global_opacity_is_removed_when_opaque(self):
nodes: list[KdlNode] = []
set_global_window_opacity(nodes, 0.9)
self.assertEqual(get_global_window_opacity(nodes), 0.9)
self.assertIn("opacity 0.9", write_kdl(nodes))
set_global_window_opacity(nodes, 1.0)
self.assertEqual(nodes, [])
def test_draw_border_with_background_can_be_toggled(self):
nodes: list[KdlNode] = []
self.assertTrue(get_global_draw_border_with_background(nodes))
set_global_draw_border_with_background(nodes, False)
self.assertFalse(get_global_draw_border_with_background(nodes))
self.assertIn("draw-border-with-background false", write_kdl(nodes))
set_global_draw_border_with_background(nodes, True)
self.assertTrue(get_global_draw_border_with_background(nodes))
self.assertEqual(nodes, [])
def test_xray_false_is_written_with_blur(self):
nodes: list[KdlNode] = []
set_global_window_blur(nodes, True)
set_global_window_xray(nodes, False)
out = write_kdl(nodes)
self.assertIn("blur true", out)
self.assertIn("xray false", out)
self.assertFalse(global_window_xray_enabled(nodes))
def test_xray_toggle_does_not_enable_blur(self):
nodes: list[KdlNode] = []
set_global_window_xray(nodes, True)
out = write_kdl(nodes)
self.assertIn("xray true", out)
self.assertNotIn("blur true", out)
self.assertFalse(global_window_blur_enabled(nodes))
def test_generated_global_window_effect_rule_is_compact(self):
nodes: list[KdlNode] = []
set_global_corner_radius(nodes, 16)
set_global_draw_border_with_background(nodes, False)
set_global_window_blur(nodes, True)
set_global_window_xray(nodes, False)
set_global_window_opacity(nodes, 0.75)
self.assertEqual(
write_kdl(nodes).strip(),
"""window-rule {
geometry-corner-radius 16
clip-to-geometry true
draw-border-with-background false
opacity 0.75
background-effect {
blur true
xray false
}
}""",
)
def test_focused_blur_rule_does_not_duplicate_global_effect_settings(self):
nodes: list[KdlNode] = []
set_global_window_blur(nodes, True)
set_global_window_xray(nodes, False)
set_global_window_opacity(nodes, 0.75)
set_focused_window_blur(nodes, True)
out = write_kdl(nodes)
self.assertIn("match is-focused=true", out)
self.assertEqual(out.count("opacity 0.75"), 1)
self.assertEqual(out.count("blur true"), 2)
self.assertEqual(out.count("xray false"), 1)
self.assertTrue(focused_window_blur_enabled(nodes))
def test_disabling_focused_blur_preserves_other_focused_rule_settings(self):
nodes = parse_kdl(
"""
window-rule {
match is-focused=true
block-out-from "screen-capture"
draw-border-with-background false
opacity 0.75
background-effect {
blur true
xray false
}
}
"""
)
set_focused_window_blur(nodes, False)
out = write_kdl(nodes)
self.assertIn('block-out-from "screen-capture"', out)
self.assertIn("draw-border-with-background false", out)
self.assertIn("opacity 0.75", out)
self.assertNotIn("blur true", out)
self.assertIn("xray false", out)
self.assertFalse(focused_window_blur_enabled(nodes))
if __name__ == "__main__":
unittest.main()

173
tests/test_window_rules.py Normal file
View File

@@ -0,0 +1,173 @@
"""Tests for window-rule editor serialization helpers."""
from __future__ import annotations
import unittest
import pytest
pytest.importorskip("gi")
from nirimod.kdl_parser import KdlNode, write_kdl
from nirimod.pages.window_rules import (
CUSTOM_FLOATING_POSITION_INDEX,
DEFAULT_FLOATING_POSITION_RELATIVE_TO,
FLOATING_POSITION_CUSTOM_FIELD_LABELS,
FLOATING_POSITION_LOCATION_LABELS,
SCREENCAST_BLOCK_KEY,
SIZE_PERCENT_PRESETS,
_bool_action_active,
_bool_action_node,
_floating_position_location_index,
_floating_position_setting,
_make_floating_position_node,
_make_size_node,
_window_size_setting,
)
class TestWindowRuleActions(unittest.TestCase):
def test_screencast_block_action_writes_valid_niri_syntax(self):
node = _bool_action_node(SCREENCAST_BLOCK_KEY)
out = write_kdl([KdlNode("window-rule", children=[node])])
self.assertIn('block-out-from "screencast"', out)
self.assertNotIn("block-out-from-screencast", out)
def test_screencast_block_action_reads_current_syntax(self):
rule = KdlNode(
"window-rule", children=[KdlNode("block-out-from", args=["screencast"])]
)
self.assertTrue(_bool_action_active(rule, SCREENCAST_BLOCK_KEY))
def test_screencast_block_action_reads_legacy_syntax(self):
rule = KdlNode(
"window-rule", children=[KdlNode("block-out-from-screencast", args=[True])]
)
self.assertTrue(_bool_action_active(rule, SCREENCAST_BLOCK_KEY))
def test_window_rule_size_default_writes_no_override(self):
self.assertIsNone(_make_size_node("default-column-width", "default", None))
self.assertIsNone(_make_size_node("default-window-height", "default", None))
def test_window_rule_size_presets_include_full_size(self):
self.assertIn(("100%", 1.0), SIZE_PERCENT_PRESETS)
def test_window_rule_width_preset_writes_proportion_node(self):
node = _make_size_node("default-column-width", "proportion", 0.25)
out = write_kdl([KdlNode("window-rule", children=[node])])
self.assertIn("default-column-width", out)
self.assertIn("proportion 0.25", out)
self.assertNotIn("default-column-width 0.25", out)
def test_window_rule_height_preset_writes_proportion_node(self):
node = _make_size_node("default-window-height", "proportion", 1.0)
out = write_kdl([KdlNode("window-rule", children=[node])])
self.assertIn("default-window-height", out)
self.assertIn("proportion 1.0", out)
self.assertNotIn("default-window-height 1.0", out)
def test_window_rule_size_reads_nested_fixed_value(self):
rule = KdlNode(
"window-rule",
children=[
KdlNode(
"default-window-height",
children=[KdlNode("fixed", args=[270])],
)
],
)
self.assertEqual(
_window_size_setting(rule, "default-window-height"),
("fixed", 270),
)
def test_window_rule_size_reads_legacy_direct_fixed_value(self):
rule = KdlNode(
"window-rule",
children=[KdlNode("default-window-height", args=[270])],
)
self.assertEqual(
_window_size_setting(rule, "default-window-height"),
("fixed", 270),
)
def test_floating_position_default_writes_no_override(self):
self.assertIsNone(
_make_floating_position_node(
False, 0, 0, DEFAULT_FLOATING_POSITION_RELATIVE_TO
)
)
def test_floating_position_locations_are_edges_plus_custom(self):
self.assertEqual(
FLOATING_POSITION_LOCATION_LABELS,
["Top", "Bottom", "Left", "Right", "Custom"],
)
def test_floating_position_custom_fields_are_offsets_only(self):
self.assertEqual(
FLOATING_POSITION_CUSTOM_FIELD_LABELS,
["X Offset (px)", "Y Offset (px)"],
)
def test_floating_position_edge_locations_use_zero_offsets(self):
self.assertEqual(
_floating_position_location_index(0, 0, "right"),
FLOATING_POSITION_LOCATION_LABELS.index("Right"),
)
def test_floating_position_edge_offsets_are_custom(self):
self.assertEqual(
_floating_position_location_index(20, 0, "right"),
CUSTOM_FLOATING_POSITION_INDEX,
)
def test_floating_position_custom_location_is_for_non_edge_anchors(self):
self.assertEqual(
_floating_position_location_index(12, 34, "bottom-right"),
CUSTOM_FLOATING_POSITION_INDEX,
)
def test_floating_position_writes_anchor_properties(self):
node = _make_floating_position_node(True, 0, 0, "right")
out = write_kdl([KdlNode("window-rule", children=[node])])
self.assertIn(
'default-floating-position x=0 y=0 relative-to="right"',
out,
)
def test_floating_position_writes_custom_offset(self):
node = _make_floating_position_node(True, 12, 34, "right")
out = write_kdl([KdlNode("window-rule", children=[node])])
self.assertIn(
'default-floating-position x=12 y=34 relative-to="right"',
out,
)
def test_floating_position_reads_existing_anchor(self):
rule = KdlNode(
"window-rule",
children=[
KdlNode(
"default-floating-position",
props={"x": 12, "y": 34, "relative-to": "bottom-right"},
)
],
)
self.assertEqual(
_floating_position_setting(rule),
(True, 12, 34, "bottom-right"),
)
if __name__ == "__main__":
unittest.main()

79
uv.lock generated Normal file
View File

@@ -0,0 +1,79 @@
version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "nirimod"
version = "0.1.0"
source = { editable = "." }
[package.dev-dependencies]
dev = [
{ name = "pytest" },
]
[package.metadata]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=9.0.3" }]
[[package]]
name = "packaging"
version = "26.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pytest"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]