commit 72005fd71d78daad0e5598b7afde31baa5165fda Author: zanewalker Date: Fri May 29 00:41:12 2026 +0000 (Init): Added shit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..56b18d7 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..5992554 --- /dev/null +++ b/CONTRIBUTING.md @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1a84d21 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..74db78c --- /dev/null +++ b/README.md @@ -0,0 +1,103 @@ +
+

NiriMod

+ + **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) +
+ +
+ +![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. + + + + + + Star History Chart + + + +--- + +*NiriMod is an independent project and is not affiliated with the official niri team.* diff --git a/data/nirimod.svg b/data/nirimod.svg new file mode 100644 index 0000000..1330bf5 --- /dev/null +++ b/data/nirimod.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..dfdfdf9 --- /dev/null +++ b/flake.lock @@ -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 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..9dfb41b --- /dev/null +++ b/flake.nix @@ -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"; + }; + }); + }; +} diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..666c204 --- /dev/null +++ b/install.sh @@ -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 → 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 diff --git a/media/1.png b/media/1.png new file mode 100644 index 0000000..044f5a3 Binary files /dev/null and b/media/1.png differ diff --git a/media/2.png b/media/2.png new file mode 100644 index 0000000..dd62b61 Binary files /dev/null and b/media/2.png differ diff --git a/media/multiple_configs.png b/media/multiple_configs.png new file mode 100644 index 0000000..f1d137f Binary files /dev/null and b/media/multiple_configs.png differ diff --git a/nirimod/__init__.py b/nirimod/__init__.py new file mode 100644 index 0000000..5885ae1 --- /dev/null +++ b/nirimod/__init__.py @@ -0,0 +1 @@ +# nirimod diff --git a/nirimod/__main__.py b/nirimod/__main__.py new file mode 100644 index 0000000..4cafbed --- /dev/null +++ b/nirimod/__main__.py @@ -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()) diff --git a/nirimod/app_settings.py b/nirimod/app_settings.py new file mode 100644 index 0000000..674a09c --- /dev/null +++ b/nirimod/app_settings.py @@ -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) diff --git a/nirimod/backup.py b/nirimod/backup.py new file mode 100644 index 0000000..9427154 --- /dev/null +++ b/nirimod/backup.py @@ -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 diff --git a/nirimod/kdl_parser.py b/nirimod/kdl_parser.py new file mode 100644 index 0000000..7ddfd5d --- /dev/null +++ b/nirimod/kdl_parser.py @@ -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) diff --git a/nirimod/niri_ipc.py b/nirimod/niri_ipc.py new file mode 100644 index 0000000..48c9033 --- /dev/null +++ b/nirimod/niri_ipc.py @@ -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 diff --git a/nirimod/pages/__init__.py b/nirimod/pages/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nirimod/pages/animations.py b/nirimod/pages/animations.py new file mode 100644 index 0000000..0335dc9 --- /dev/null +++ b/nirimod/pages/animations.py @@ -0,0 +1,1222 @@ +"""Animations page with bezier curve editor and Nirimation preset browser.""" + +from __future__ import annotations + +import json +import math +from pathlib import Path +import threading +import urllib.error +import urllib.request + +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, parse_kdl, set_child_arg, set_node_flag +from nirimod.pages.base import BasePage + +_NIRIMATION_API = ( + "https://api.github.com/repos/XansiVA/nirimation/contents/animations" +) +_NIRIMATION_RAW = ( + "https://raw.githubusercontent.com/XansiVA/nirimation/main/animations/{name}" +) +_NIRIMATION_HTML = ( + "https://github.com/XansiVA/nirimation/blob/main/animations/{name}" +) + +_JGARZA_API = ( + "https://api.github.com/repos/jgarza9788/niri-animation-collection/contents/animations" +) +_JGARZA_RAW = ( + "https://raw.githubusercontent.com/jgarza9788/niri-animation-collection/main/animations/{name}" +) +_JGARZA_HTML = ( + "https://github.com/jgarza9788/niri-animation-collection/blob/main/animations/{name}" +) + +# In-memory cache: None = not fetched, list = fetched entries, Exception = error +_nirimation_cache: list[dict] | Exception | None = None +_jgarza_cache: list[dict] | Exception | None = None + +# Local presets directory +_LOCAL_PRESETS_DIR = Path("~/.config/nirimod/presets").expanduser() + +# Slug used as subdirectory name for each source +_SOURCE_SLUGS = { + "XansiVA/nirimation": "nirimation", + "jgarza9788/niri-animation-collection": "niri-animation-collection", +} + + + +ANIM_GROUPS = [ + ("Window Management", [ + ("window-open", "Window Open", "window-new-symbolic"), + ("window-close", "Window Close", "window-close-symbolic"), + ("window-movement", "Window Movement", "transform-move-symbolic"), + ("window-resize", "Window Resize", "view-fullscreen-symbolic"), + ]), + ("Workspace", [ + ("workspace-switch", "Workspace Switch", "video-display-symbolic"), + ("horizontal-view-movement", "Horizontal View Movement", "pan-end-symbolic"), + ]), + ("Interface", [ + ("overview-open-close", "Overview Open/Close", "view-app-grid-symbolic"), + ("overview-screenshot", "Overview Screenshot", "camera-photo-symbolic"), + ("screenshot-ui-open", "Screenshot UI Open", "camera-photo-symbolic"), + ("config-notification-open-close", "Config Notification", "preferences-system-symbolic"), + ]) +] + +PRESET_CURVES = { + "ease": (0.25, 0.1, 0.25, 1.0), + "ease-in": (0.42, 0.0, 1.0, 1.0), + "ease-out": (0.0, 0.0, 0.58, 1.0), + "ease-in-out": (0.42, 0.0, 0.58, 1.0), + "linear": (0.0, 0.0, 1.0, 1.0), + "spring": (0.17, 0.67, 0.83, 0.67), +} + + +class BezierEditor(Gtk.DrawingArea): + """Interactive cubic Bézier curve editor with animated preview ball.""" + + def __init__(self, on_changed=None): + super().__init__() + self._cp = [0.25, 0.1, 0.25, 1.0] # x1,y1,x2,y2 + self._on_changed = on_changed + self._dragging: int | None = None # 0=p1, 1=p2 + self._ball_t = 0.0 + self._ball_dir = 1 + self._anim_id: int | None = None + + self.set_content_width(220) + self.set_content_height(180) + self.set_draw_func(self._draw) + + motion = Gtk.EventControllerMotion() + motion.connect("motion", self._on_motion) + self.add_controller(motion) + + click = Gtk.GestureClick() + click.connect("pressed", self._on_press) + click.connect("released", self._on_release) + self.add_controller(click) + + self.add_tick_callback(self._on_tick) + + def set_curve(self, x1, y1, x2, y2): + self._cp = [x1, y1, x2, y2] + self.queue_draw() + + def get_curve(self): + return tuple(self._cp) + + def _on_tick(self, widget, frame_clock): + current_time = frame_clock.get_frame_time() + if not hasattr(self, "_last_time"): + self._last_time = current_time + return True + + dt = (current_time - self._last_time) / 1_000_000.0 + self._last_time = current_time + + # Move at a constant speed of ~0.75 units per second + speed = 0.75 + self._ball_t += (dt * speed) * self._ball_dir + + if self._ball_t >= 1.0: + self._ball_t = 1.0 + self._ball_dir = -1 + elif self._ball_t <= 0.0: + self._ball_t = 0.0 + self._ball_dir = 1 + + self.queue_draw() + return True + + def _bezier_pt(self, t): + x1, y1, x2, y2 = self._cp + # Cubic bezier from (0,0) to (1,1) with controls (x1,y1), (x2,y2) + mt = 1 - t + bx = 3 * mt * mt * t * x1 + 3 * mt * t * t * x2 + t * t * t + by = 3 * mt * mt * t * y1 + 3 * mt * t * t * y2 + t * t * t + return bx, by + + def _canvas_to_cp(self, cx, cy, W, H, pad=20): + """Convert canvas coords to bezier control point (0-1 range).""" + x = (cx - pad) / (W - 2 * pad) + y = 1.0 - (cy - pad) / (H - 2 * pad) + return max(0.0, min(1.0, x)), max(-0.5, min(1.5, y)) + + def _cp_to_canvas(self, x, y, W, H, pad=20): + cx = pad + x * (W - 2 * pad) + cy = pad + (1.0 - y) * (H - 2 * pad) + return cx, cy + + def _draw(self, area, cr, W, H): + pad = 20 + + cr.set_source_rgba(0.08, 0.08, 0.08, 1.0) + cr.rectangle(0, 0, W, H) + cr.fill() + cr.set_source_rgba(0.2, 0.2, 0.22, 0.4) + cr.set_line_width(0.5) + for i in range(5): + gx = pad + i * (W - 2 * pad) / 4 + gy = pad + i * (H - 2 * pad) / 4 + cr.move_to(gx, pad) + cr.line_to(gx, H - pad) + cr.stroke() + cr.move_to(pad, gy) + cr.line_to(W - pad, gy) + cr.stroke() + + x1, y1, x2, y2 = self._cp + px1, py1 = self._cp_to_canvas(x1, y1, W, H, pad) + px2, py2 = self._cp_to_canvas(x2, y2, W, H, pad) + start = self._cp_to_canvas(0, 0, W, H, pad) + end = self._cp_to_canvas(1, 1, W, H, pad) + + cr.set_source_rgba(0.2, 0.2, 0.25, 0.4) + cr.set_line_width(1.0) + cr.move_to(*start) + cr.line_to(px1, py1) + cr.stroke() + cr.move_to(*end) + cr.line_to(px2, py2) + cr.stroke() + + # Bezier path + cr.set_source_rgba(0.3, 0.7, 1.0, 0.9) + cr.set_line_width(2.5) + cr.move_to(*start) + cr.curve_to(px1, py1, px2, py2, *end) + cr.stroke() + + bx_01, by_01 = self._bezier_pt(self._ball_t) + bx_c, by_c = self._cp_to_canvas(bx_01, by_01, W, H, pad) + cr.set_source_rgba(1.0, 0.6, 0.2, 0.95) + cr.arc(bx_c, by_c, 5, 0, 2 * math.pi) + cr.fill() + + for px, py, color in [ + (px1, py1, (0.4, 1.0, 0.5, 1.0)), + (px2, py2, (1.0, 0.4, 0.5, 1.0)), + ]: + cr.set_source_rgba(*color) + cr.arc(px, py, 6, 0, 2 * math.pi) + cr.fill() + cr.set_source_rgba(1, 1, 1, 0.5) + cr.set_line_width(1.5) + cr.arc(px, py, 6, 0, 2 * math.pi) + cr.stroke() + + def _hit_cp(self, cx, cy, W, H, pad=20): + x1, y1, x2, y2 = self._cp + px1, py1 = self._cp_to_canvas(x1, y1, W, H, pad) + px2, py2 = self._cp_to_canvas(x2, y2, W, H, pad) + if math.hypot(cx - px1, cy - py1) < 12: + return 0 + if math.hypot(cx - px2, cy - py2) < 12: + return 1 + return None + + def _on_press(self, gesture, _n, x, y): + W = self.get_width() + H = self.get_height() + self._dragging = self._hit_cp(x, y, W, H) + + def _on_release(self, gesture, _n, x, y): + self._dragging = None + + def _on_motion(self, controller, x, y): + if self._dragging is None: + return + W = self.get_width() + H = self.get_height() + cpx, cpy = self._canvas_to_cp(x, y, W, H) + if self._dragging == 0: + self._cp[0] = cpx + self._cp[1] = cpy + else: + self._cp[2] = cpx + self._cp[3] = cpy + self.queue_draw() + if self._on_changed: + self._on_changed(*self._cp) + + +def _fetch_presets_from_github(api_url, raw_tmpl, html_tmpl, cache_attr, callback): + """Generic preset fetcher for any GitHub contents API endpoint.""" + import sys + mod = sys.modules[__name__] + cached = getattr(mod, cache_attr) + if cached is not None: + GLib.idle_add(callback, cached) + return + + def _worker(): + try: + req = urllib.request.Request( + api_url, + headers={"User-Agent": "nirimod/1.0"}, + ) + with urllib.request.urlopen(req, timeout=10) as resp: + data = json.loads(resp.read().decode()) + + entries = [] + for item in data: + if item.get("type") != "file": + continue + n = item["name"] + if not n.endswith(".kdl"): + continue + stem = n[:-4] # strip .kdl + display = stem.replace("-", " ").replace("_", " ").title() + entries.append( + { + "name": n, + "display_name": display, + "download_url": item.get( + "download_url", + raw_tmpl.format(name=n), + ), + "html_url": item.get( + "html_url", + html_tmpl.format(name=n), + ), + } + ) + entries.sort(key=lambda e: e["display_name"]) + setattr(mod, cache_attr, entries) + GLib.idle_add(callback, entries) + except Exception as exc: + setattr(mod, cache_attr, exc) + GLib.idle_add(callback, exc) + + threading.Thread(target=_worker, daemon=True).start() + + +def _fetch_nirimation_presets(callback): + """Fetch preset list from XansiVA/nirimation in a background thread.""" + _fetch_presets_from_github( + _NIRIMATION_API, _NIRIMATION_RAW, _NIRIMATION_HTML, + "_nirimation_cache", callback, + ) + + +def _fetch_jgarza_presets(callback): + """Fetch preset list from jgarza9788/niri-animation-collection in a background thread.""" + _fetch_presets_from_github( + _JGARZA_API, _JGARZA_RAW, _JGARZA_HTML, + "_jgarza_cache", callback, + ) + + +class AnimationsPage(BasePage): + def __init__(self, window): + super().__init__(window) + self._prev_anim_snapshot = None + self._active_preset_name = None + self._state_file = Path("~/.config/nirimod/animations.json").expanduser() + self._load_state() + + def _load_state(self): + try: + if self._state_file.exists(): + with open(self._state_file, "r", encoding="utf-8") as f: + data = json.load(f) + self._prev_anim_snapshot = data.get("prev_anim_snapshot") + self._active_preset_name = data.get("active_preset_name") + except Exception as e: + print(f"Failed to load animations state: {e}") + + def _save_state(self): + try: + self._state_file.parent.mkdir(parents=True, exist_ok=True) + with open(self._state_file, "w", encoding="utf-8") as f: + json.dump({ + "prev_anim_snapshot": self._prev_anim_snapshot, + "active_preset_name": self._active_preset_name + }, f) + except Exception as e: + print(f"Failed to save animations state: {e}") + + def build(self) -> Gtk.Widget: + tb, header, _, _ = self._make_toolbar_page("") + header.set_title_widget(Gtk.Box()) # hide the default title + + # Custom Header (matches Workspace View / Keybindings aesthetic) + 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="Animations") + self._main_title.set_xalign(0.0) + self._main_title.add_css_class("title-1") + title_vbox.append(self._main_title) + + self._active_preset_lbl = Gtk.Label(label="Using custom animations") + self._active_preset_lbl.set_xalign(0.0) + self._active_preset_lbl.add_css_class("dim-label") + self._active_preset_lbl.add_css_class("caption") + title_vbox.append(self._active_preset_lbl) + header_box.append(title_vbox) + + + # View Switcher (Styled as Custom/Presets 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.START) + + self._btn_custom = Gtk.ToggleButton(label="Custom") + self._btn_presets = Gtk.ToggleButton(label="Presets") + self._btn_presets.set_group(self._btn_custom) + + self._btn_custom.connect("toggled", self._on_view_toggle) + self._btn_presets.connect("toggled", self._on_view_toggle) + + switcher_box.append(self._btn_custom) + switcher_box.append(self._btn_presets) + header_box.append(switcher_box) + + # Custom Header (matches Workspace View / Keybindings aesthetic) + self._view_stack = Adw.ViewStack() + self._view_stack.set_vexpand(True) + + # Tabs + custom_widget = self._build_custom_tab() + self._view_stack.add_named(custom_widget, "custom") + + presets_widget = self._build_presets_tab() + self._view_stack.add_named(presets_widget, "presets") + + # Default to Custom + self._view_stack.set_visible_child_name("custom") + self._btn_custom.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._update_header() + return tb + + def _on_view_toggle(self, btn): + if not btn.get_active(): + return + is_custom = btn == self._btn_custom + self._view_stack.set_visible_child_name("custom" if is_custom else "presets") + + def _update_header(self): + if self._active_preset_name: + self._active_preset_lbl.set_label(f"✨ Active preset: {GLib.markup_escape_text(self._active_preset_name)}") + self._active_preset_lbl.set_use_markup(True) + else: + self._active_preset_lbl.set_label("Using custom animations") + self._active_preset_lbl.set_use_markup(False) + + if hasattr(self, "_custom_switch_grp"): + self._custom_switch_grp.set_visible(self._prev_anim_snapshot is not None) + + def _build_custom_tab(self) -> Gtk.Widget: + """Return the custom animations tab (global toggles, bezier editor, and categories).""" + if not hasattr(self, "_custom_scroll"): + self._custom_scroll = Gtk.ScrolledWindow() + self._custom_scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + self._custom_scroll.set_vexpand(True) + self._custom_content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=20) + self._custom_content.set_hexpand(True) + self._custom_content.set_margin_start(24) + self._custom_content.set_margin_end(24) + self._custom_content.set_margin_top(24) + self._custom_content.set_margin_bottom(24) + self._custom_scroll.set_child(self._custom_content) + else: + while child := self._custom_content.get_first_child(): + self._custom_content.remove(child) + + content = self._custom_content + anim_node = find_or_create(self._nodes, "animations") + + # ── Switch to Custom Banner ────────────────────────────────────────── + self._custom_switch_grp = Adw.PreferencesGroup() + self._custom_switch_grp.set_hexpand(True) + self._custom_switch_row = Adw.ActionRow( + title="Community Preset Active", + subtitle="You are currently using a preset. Switch back to use your custom animation settings." + ) + self._custom_switch_row.add_css_class("property") + self._custom_switch_row.set_icon_name("emblem-important-symbolic") + switch_btn = Gtk.Button(label="Switch to Custom") + switch_btn.add_css_class("suggested-action") + switch_btn.add_css_class("pill") + switch_btn.set_valign(Gtk.Align.CENTER) + switch_btn.set_margin_top(8) + switch_btn.set_margin_bottom(8) + switch_btn.connect("clicked", self._on_restore_previous) + self._custom_switch_row.add_suffix(switch_btn) + self._custom_switch_grp.add(self._custom_switch_row) + self._custom_switch_grp.set_visible(self._prev_anim_snapshot is not None) + content.append(self._custom_switch_grp) + + # ── Global Settings ────────────────────────────────────────────────── + off_grp = Adw.PreferencesGroup( + title="Global Settings", + description="These apply to all animations universally." + ) + off_grp.set_hexpand(True) + off_row = Adw.SwitchRow(title="Enable Animations", subtitle="Toggle all desktop animations on or off") + off_row.set_icon_name("media-playback-start-symbolic") + off_row.set_active(anim_node.get_child("off") is None) + off_row.connect( + "notify::active", lambda r, _: self._toggle_all(not r.get_active()) + ) + off_grp.add(off_row) + + slowdown_val = float(anim_node.child_arg("slowdown") or 1.0) + slowdown_adj = Gtk.Adjustment( + value=slowdown_val, lower=0.1, upper=10.0, step_increment=0.1 + ) + slowdown_row = Adw.SpinRow( + title="Global Slowdown Factor", + subtitle="Multiply all animation durations by this factor", + adjustment=slowdown_adj, digits=1 + ) + slowdown_row.set_icon_name("preferences-system-time-symbolic") + slowdown_row._last_val = slowdown_val + + def _on_slowdown_changed(r, _): + new_val = float(r.get_value()) + if abs(new_val - getattr(r, "_last_val", 0.0)) > 0.01: + r._last_val = new_val + self._set_anim("slowdown", new_val) + + slowdown_row.connect("notify::value", _on_slowdown_changed) + off_grp.add(slowdown_row) + content.append(off_grp) + + # ── Easing Curve Editor ────────────────────────────────────────────── + bezier_grp = Adw.PreferencesGroup( + title="Easing Curve Editor", + description="Design a custom easing curve to apply to any animation below." + ) + bezier_grp.set_hexpand(True) + + editor_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=48) + editor_row.set_margin_start(12) + editor_row.set_margin_end(12) + editor_row.set_margin_top(20) + editor_row.set_margin_bottom(20) + editor_row.set_halign(Gtk.Align.CENTER) + + # Left: bezier canvas + edit_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + edit_vbox.set_valign(Gtk.Align.CENTER) + self._bezier_editor = BezierEditor(on_changed=self._on_bezier_changed) + self._bezier_editor.set_halign(Gtk.Align.CENTER) + edit_vbox.append(self._bezier_editor) + + coords_lbl = Gtk.Label(label="0.25, 0.1, 0.25, 1.0") + coords_lbl.add_css_class("monospace") + coords_lbl.add_css_class("dim-label") + coords_lbl.set_selectable(True) + coords_lbl.set_halign(Gtk.Align.CENTER) + self._coords_lbl = coords_lbl + edit_vbox.append(coords_lbl) + editor_row.append(edit_vbox) + + # Right: quick presets + presets_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16) + presets_vbox.set_valign(Gtk.Align.CENTER) + preset_title = Gtk.Label(label="Quick Presets", xalign=0) + preset_title.add_css_class("heading") + presets_vbox.append(preset_title) + + flow = Gtk.FlowBox() + flow.set_selection_mode(Gtk.SelectionMode.NONE) + flow.set_max_children_per_line(2) + flow.set_min_children_per_line(2) + flow.set_valign(Gtk.Align.START) + flow.set_column_spacing(6) + flow.set_row_spacing(6) + for name, curve in PRESET_CURVES.items(): + btn = Gtk.Button(label=name) + btn.connect("clicked", lambda b, c=curve, n=name: self._apply_preset(c, n)) + flow.append(btn) + presets_vbox.append(flow) + editor_row.append(presets_vbox) + + bezier_grp.add(editor_row) + content.append(bezier_grp) + + # ── Per-animation groups ───────────────────────────────────────────── + for group_title, anims in ANIM_GROUPS: + grp = Adw.PreferencesGroup(title=group_title) + grp.set_hexpand(True) + for anim_key, anim_label, icon_name in anims: + row = self._build_anim_row(anim_key, anim_label, icon_name, anim_node) + grp.add(row) + content.append(grp) + + return self._custom_scroll + + def _build_presets_tab(self) -> Gtk.Widget: + """Return the community presets tab (downloaded + online sources).""" + 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_hexpand(True) + content.set_margin_start(24) + content.set_margin_end(24) + content.set_margin_top(24) + content.set_margin_bottom(24) + scroll.set_child(content) + + # Downloaded / offline section — always shown first + self._presets_content = content + self._local_presets_grp: Adw.PreferencesGroup | None = None + self._refresh_local_presets_group() + + nim_grp = self._build_nirimation_group() + nim_grp.set_hexpand(True) + content.append(nim_grp) + + jgarza_grp = self._build_jgarza_group() + jgarza_grp.set_hexpand(True) + content.append(jgarza_grp) + + return scroll + + # ------------------------------------------------------------------ local + + def _local_preset_dir(self, source_label: str) -> Path: + slug = _SOURCE_SLUGS.get(source_label, source_label.replace("/", "-")) + return _LOCAL_PRESETS_DIR / slug + + def _list_local_presets(self) -> list[dict]: + """Return all downloaded presets sorted by display name.""" + entries: list[dict] = [] + if not _LOCAL_PRESETS_DIR.exists(): + return entries + for source_dir in sorted(_LOCAL_PRESETS_DIR.iterdir()): + if not source_dir.is_dir(): + continue + # Reverse-map slug → label + slug_to_label = {v: k for k, v in _SOURCE_SLUGS.items()} + source_label = slug_to_label.get(source_dir.name, source_dir.name) + for kdl_file in sorted(source_dir.glob("*.kdl")): + stem = kdl_file.stem + display = stem.replace("-", " ").replace("_", " ").title() + entries.append( + { + "name": kdl_file.name, + "display_name": display, + "source_label": source_label, + "local_path": kdl_file, + } + ) + return entries + + def _refresh_local_presets_group(self): + """Rebuild the Downloaded Presets group from the filesystem.""" + # Remove the old group widget from the content box if present + if self._local_presets_grp is not None and hasattr(self, "_presets_content"): + try: + self._presets_content.remove(self._local_presets_grp) + except Exception: + pass + + entries = self._list_local_presets() + + grp = Adw.PreferencesGroup( + title="Downloaded Presets", + description="Locally saved presets — apply these without an internet connection.", + ) + grp.set_hexpand(True) + grp.set_header_suffix(self._make_open_folder_btn()) + self._local_presets_grp = grp + + if not entries: + empty_row = Adw.ActionRow( + title="No presets downloaded yet", + subtitle="Use the download button (\u2193) next to any online preset below.", + ) + empty_row.add_prefix(Gtk.Image.new_from_icon_name("folder-download-symbolic")) + grp.add(empty_row) + else: + for entry in entries: + row = self._make_local_preset_row(entry) + grp.add(row) + + # Prepend at the top of the presets content box + if hasattr(self, "_presets_content"): + # Insert before the first child (nirimation group) + first = self._presets_content.get_first_child() + if first: + self._presets_content.insert_child_after(grp, None) # prepend + else: + self._presets_content.append(grp) + + + def _make_open_folder_btn(self) -> Gtk.Button: + btn = Gtk.Button(icon_name="folder-open-symbolic") + btn.set_tooltip_text("Open presets folder") + btn.add_css_class("flat") + btn.add_css_class("circular") + btn.connect( + "clicked", + lambda _b: Gtk.show_uri(None, _LOCAL_PRESETS_DIR.as_uri(), 0), + ) + return btn + + def _make_local_preset_row(self, entry: dict) -> Adw.ActionRow: + """Row for a locally-downloaded preset (Apply + Delete).""" + row = Adw.ActionRow( + title=entry["display_name"], + subtitle=f"{entry['source_label']} · {entry['name']}", + ) + row.add_prefix(Gtk.Image.new_from_icon_name("drive-harddisk-symbolic")) + + # Delete button + del_btn = Gtk.Button(icon_name="user-trash-symbolic") + del_btn.set_tooltip_text("Delete local copy") + del_btn.add_css_class("flat") + del_btn.add_css_class("circular") + del_btn.set_valign(Gtk.Align.CENTER) + del_btn.connect( + "clicked", + lambda _b, e=entry: self._delete_local_preset(e), + ) + row.add_suffix(del_btn) + + # Apply button + apply_btn = Gtk.Button(label="Apply") + apply_btn.add_css_class("suggested-action") + apply_btn.add_css_class("pill") + apply_btn.set_valign(Gtk.Align.CENTER) + apply_btn.connect( + "clicked", + lambda _b, e=entry, r=row: self._confirm_apply_local_preset(e, r), + ) + row.add_suffix(apply_btn) + + return row + + def _confirm_apply_local_preset(self, entry: dict, row: Adw.ActionRow): + try: + dialog = Adw.AlertDialog( + heading=f"Apply \"{entry['display_name']}\"?", + body=( + "This will fully replace your current animations block with the locally saved " + f"\"{entry['display_name']}\" preset.\n\n" + "Your existing bezier curves and per-animation settings will be overwritten. " + "You can undo this with Ctrl+Z." + ), + ) + dialog.add_response("cancel", "Cancel") + dialog.add_response("apply", "Apply Preset") + dialog.set_response_appearance("apply", Adw.ResponseAppearance.SUGGESTED) + dialog.set_default_response("cancel") + dialog.set_close_response("cancel") + + def _on_response(d, resp): + if resp == "apply": + self._apply_local_preset(entry, row) + + dialog.connect("response", _on_response) + dialog.present(self._win) + except AttributeError: + self._apply_local_preset(entry, row) + + def _apply_local_preset(self, entry: dict, row: Adw.ActionRow): + """Apply a locally-saved .kdl file (no network required).""" + try: + kdl_text = entry["local_path"].read_text(encoding="utf-8") + self._do_apply_kdl_preset(kdl_text, entry["display_name"], row) + except Exception as exc: + self.show_toast(f"Failed to read local preset: {exc}") + + def _delete_local_preset(self, entry: dict): + try: + entry["local_path"].unlink(missing_ok=True) + # Clean up empty source dir + parent = entry["local_path"].parent + if parent.exists() and not any(parent.iterdir()): + parent.rmdir() + self.show_toast(f"🗑 {entry['display_name']} deleted") + self._refresh_local_presets_group() + except Exception as exc: + self.show_toast(f"Delete failed: {exc}") + + def _on_restore_previous(self, _btn): + """Restore the animations block that was saved before the last preset apply.""" + if self._prev_anim_snapshot is None: + return + try: + snap_nodes = parse_kdl(self._prev_anim_snapshot) + snap_anim = next((n for n in snap_nodes if n.name == "animations"), None) + user_nodes = self._nodes + user_anim = next((n for n in reversed(user_nodes) if n.name == "animations"), None) + if user_anim is None: + user_anim = KdlNode(name="animations") + user_anim.leading_trivia = "\n" + user_nodes.append(user_anim) + if snap_anim is not None: + user_anim.children = list(snap_anim.children) + user_anim.args = list(snap_anim.args) + user_anim.props = dict(snap_anim.props) + else: + user_anim.children = [] + user_anim.args = [] + user_anim.props = {} + self._prev_anim_snapshot = None + self._active_preset_name = None + self._save_state() + self._commit("restore previous animations") + self.show_toast("↩ Previous animations restored") + self._update_header() + self._build_custom_tab() # Refresh UI components + except Exception as exc: + self.show_toast(f"Restore failed: {exc}") + + def _build_preset_group( + self, + title: str, + description: str, + fetch_fn, + bust_cache_attr: str, + rows_attr: str, + source_label: str, + repo_url: str, + ) -> Adw.PreferencesGroup: + """Generic builder for a community-preset PreferencesGroup.""" + import sys + mod = sys.modules[__name__] + + header_btns = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + + repo_btn = Gtk.Button(icon_name="web-browser-symbolic") + repo_btn.set_tooltip_text("View repository on GitHub") + repo_btn.add_css_class("flat") + repo_btn.add_css_class("circular") + repo_btn.connect("clicked", lambda _b: Gtk.show_uri(None, repo_url, 0)) + header_btns.append(repo_btn) + + refresh_btn = Gtk.Button(icon_name="view-refresh-symbolic") + refresh_btn.set_tooltip_text("Refresh preset list from GitHub") + refresh_btn.add_css_class("flat") + refresh_btn.add_css_class("circular") + header_btns.append(refresh_btn) + + grp = Adw.PreferencesGroup(title=title, description=description) + grp.set_header_suffix(header_btns) + + spinner = Gtk.Spinner() + spinner.start() + spinner.set_margin_top(8) + spinner.set_margin_bottom(8) + spinner_row = Adw.ActionRow(title="Fetching presets…") + spinner_row.add_prefix(spinner) + grp.add(spinner_row) + + rows: list[Adw.ActionRow] = [] + setattr(self, rows_attr, rows) + + def _on_result(result): + grp.remove(spinner_row) + spinner.stop() + if isinstance(result, Exception): + err_row = Adw.ActionRow( + title="Unable to fetch presets", + subtitle=str(result), + ) + err_row.add_prefix(Gtk.Image.new_from_icon_name("network-error-symbolic")) + grp.add(err_row) + rows.append(err_row) + return + for entry in result: + row = self._make_preset_row(entry, source_label) + grp.add(row) + rows.append(row) + + def _on_refresh_clicked(_btn): + setattr(mod, bust_cache_attr, None) + for row in list(rows): + grp.remove(row) + rows.clear() + sp2 = Gtk.Spinner() + sp2.start() + sp2.set_margin_top(8) + sp2.set_margin_bottom(8) + wait_row = Adw.ActionRow(title="Fetching presets…") + wait_row.add_prefix(sp2) + grp.add(wait_row) + + def _on_result2(result): + grp.remove(wait_row) + sp2.stop() + if isinstance(result, Exception): + err_row = Adw.ActionRow( + title="Unable to fetch presets", + subtitle=str(result), + ) + err_row.add_prefix(Gtk.Image.new_from_icon_name("network-error-symbolic")) + grp.add(err_row) + rows.append(err_row) + return + for entry in result: + row = self._make_preset_row(entry, source_label) + grp.add(row) + rows.append(row) + + fetch_fn(_on_result2) + + refresh_btn.connect("clicked", _on_refresh_clicked) + fetch_fn(_on_result) + return grp + + def _build_nirimation_group(self) -> Adw.PreferencesGroup: + """Build the XansiVA/nirimation presets section.""" + return self._build_preset_group( + title="Nirimation Community Presets", + description="GLSL shader animations from XansiVA/nirimation — replaces your current animations block.", + fetch_fn=_fetch_nirimation_presets, + bust_cache_attr="_nirimation_cache", + rows_attr="_nirimation_rows", + source_label="XansiVA/nirimation", + repo_url="https://github.com/XansiVA/nirimation", + ) + + def _build_jgarza_group(self) -> Adw.PreferencesGroup: + """Build the jgarza9788/niri-animation-collection presets section.""" + return self._build_preset_group( + title="Niri Animation Collection", + description="Community GLSL shader presets from jgarza9788/niri-animation-collection — replaces your current animations block.", + fetch_fn=_fetch_jgarza_presets, + bust_cache_attr="_jgarza_cache", + rows_attr="_jgarza_rows", + source_label="jgarza9788/niri-animation-collection", + repo_url="https://github.com/jgarza9788/niri-animation-collection", + ) + + def _make_preset_row(self, entry: dict, source_label: str) -> Adw.ActionRow: + """Create a single preset row for any community-preset group.""" + row = Adw.ActionRow( + title=entry["display_name"], + subtitle=entry["name"], + ) + + # Download-to-disk button + dl_btn = Gtk.Button(icon_name="folder-download-symbolic") + dl_btn.set_tooltip_text("Download preset for offline use") + dl_btn.add_css_class("flat") + dl_btn.add_css_class("circular") + dl_btn.set_valign(Gtk.Align.CENTER) + dl_btn.connect( + "clicked", + lambda _b, e=entry, r=row, sl=source_label, b=dl_btn: self._download_preset_locally(e, r, sl, b), + ) + row.add_suffix(dl_btn) + + # Apply button + apply_btn = Gtk.Button(label="Apply") + apply_btn.add_css_class("suggested-action") + apply_btn.add_css_class("pill") + apply_btn.set_valign(Gtk.Align.CENTER) + apply_btn.connect( + "clicked", + lambda _b, e=entry, r=row, sl=source_label: self._confirm_apply_preset(e, r, sl), + ) + row.add_suffix(apply_btn) + + return row + + def _download_preset_locally(self, entry, row, source_label, dl_btn): + # download the preset KDL to disk + dest_dir = self._local_preset_dir(source_label) + dest_file = dest_dir / entry["name"] + + dl_btn.set_sensitive(False) + self.show_toast(f"Downloading {entry['display_name']}…", timeout=5) + + def _worker(): + try: + req = urllib.request.Request( + entry["download_url"], + headers={"User-Agent": "nirimod/1.0"}, + ) + with urllib.request.urlopen(req, timeout=10) as resp: + kdl_bytes = resp.read() + GLib.idle_add(_on_done, kdl_bytes, None) + except Exception as exc: + GLib.idle_add(_on_done, None, exc) + + def _on_done(kdl_bytes, error): + dl_btn.set_sensitive(True) + if error: + self.show_toast(f"Download failed: {error}") + return + try: + dest_dir.mkdir(parents=True, exist_ok=True) + dest_file.write_bytes(kdl_bytes) + self.show_toast(f"{entry['display_name']} saved locally") + # Update the download button to show it's already saved + dl_btn.set_icon_name("emblem-ok-symbolic") + dl_btn.set_tooltip_text("Already downloaded") + dl_btn.set_sensitive(False) + self._refresh_local_presets_group() + except Exception as exc: + self.show_toast(f"Save failed: {exc}") + + threading.Thread(target=_worker, daemon=True).start() + + + def _confirm_apply_preset(self, entry, row, source_label="community"): + try: + dialog = Adw.AlertDialog( + heading=f"Apply \"{entry['display_name']}\"?", + body=( + "This will fully replace your current animations block with the " + f"\"{entry['display_name']}\" preset from {source_label}.\n\n" + "Your existing bezier curves and per-animation settings will be overwritten. " + "You can undo this with Ctrl+Z." + ), + ) + dialog.add_response("cancel", "Cancel") + dialog.add_response("apply", "Apply Preset") + dialog.set_response_appearance("apply", Adw.ResponseAppearance.SUGGESTED) + dialog.set_default_response("cancel") + dialog.set_close_response("cancel") + + def _on_response(d, resp): + if resp == "apply": + self._apply_nirimation_preset(entry, row) + + dialog.connect("response", _on_response) + dialog.present(self._win) + except AttributeError: + self._apply_nirimation_preset(entry, row) + + + def _apply_nirimation_preset(self, entry, row): + row.set_sensitive(False) + self.show_toast(f"Downloading {entry['display_name']}...", timeout=5) + + def _worker(): + try: + req = urllib.request.Request( + entry["download_url"], + headers={"User-Agent": "nirimod/1.0"}, + ) + with urllib.request.urlopen(req, timeout=10) as resp: + kdl_text = resp.read().decode() + GLib.idle_add(_on_downloaded, kdl_text, None) + except Exception as exc: + GLib.idle_add(_on_downloaded, None, exc) + + def _on_downloaded(kdl_text, error): + row.set_sensitive(True) + if error: + self.show_toast(f"Failed to download preset: {error}") + return + self._do_apply_kdl_preset(kdl_text, entry["display_name"], row) + + threading.Thread(target=_worker, daemon=True).start() + + def _do_apply_kdl_preset(self, kdl_text, display_name, row): + try: + preset_nodes = parse_kdl(kdl_text) + preset_anim = next( + (n for n in preset_nodes if n.name == "animations"), None + ) + if preset_anim is None: + self.show_toast("Preset has no animations block — nothing applied.") + return + + user_nodes = self._nodes + user_anim = next( + (n for n in reversed(user_nodes) if n.name == "animations"), None + ) + + + if self._prev_anim_snapshot is None: + from nirimod.kdl_parser import write_kdl + if user_anim is not None: + snap_node = KdlNode(name="animations") + snap_node.children = list(user_anim.children) + snap_node.args = list(user_anim.args) + snap_node.props = dict(user_anim.props) + self._prev_anim_snapshot = write_kdl([snap_node]) + else: + self._prev_anim_snapshot = write_kdl([]) + + if user_anim is None: + user_anim = KdlNode(name="animations") + user_anim.leading_trivia = "\n" + user_nodes.append(user_anim) + + user_anim.children = list(preset_anim.children) + user_anim.args = list(preset_anim.args) + user_anim.props = dict(preset_anim.props) + + self._active_preset_name = display_name + self._save_state() + self._commit(f"preset: {display_name}") + self._update_header() + self.show_toast(f"\u2728 {display_name} preset applied!") + except Exception as exc: + self.show_toast(f"Error applying preset: {exc}") + + def _apply_preset(self, curve: tuple, name: str): + self._bezier_editor.set_curve(*curve) + self._update_coords_label() + + def _on_bezier_changed(self, x1, y1, x2, y2): + self._update_coords_label() + + def _update_coords_label(self): + x1, y1, x2, y2 = self._bezier_editor.get_curve() + self._coords_lbl.set_label(f"{x1:.3f}, {y1:.3f}, {x2:.3f}, {y2:.3f}") + + def _build_anim_row( + self, key: str, label: str, icon_name: str, anim_node: KdlNode + ) -> Adw.ExpanderRow: + grp = Adw.ExpanderRow(title=label) + grp.set_icon_name(icon_name) + grp.add_css_class("nm-expander") + an = anim_node.get_child(key) + + enabled_row = Adw.SwitchRow(title="Enabled") + enabled_row.set_active(an is not None and an.get_child("off") is None) + enabled_row.connect( + "notify::active", + lambda r, _, k=key: self._set_anim_enabled(k, r.get_active()), + ) + grp.add_row(enabled_row) + + duration = an.child_arg("duration-ms") if an else 250 + dur_val = int(duration) if duration else 250 + dur_adj = Gtk.Adjustment(value=dur_val, lower=10, upper=2000, step_increment=10) + dur_row = Adw.SpinRow(title="Duration (ms)", adjustment=dur_adj, digits=0) + + dur_row._last_val = dur_val + + def _on_dur_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_anim_prop(k, "duration-ms", new_val) + + dur_row.connect("notify::value", _on_dur_changed) + grp.add_row(dur_row) + + # Apply bezier button + apply_btn = Gtk.Button(label="Apply Editor Curve") + apply_btn.add_css_class("flat") + apply_btn.set_valign(Gtk.Align.CENTER) + + # Determine current curve for subtitle + + curve_node = an.get_child("curve") if an else None + easing = an.get_child("easing") if an else None + current_curve = "" + if curve_node and len(curve_node.args) >= 5: + vals = " ".join(str(v) for v in curve_node.args[1:]) + current_curve = f"cubic-bezier {vals}" + elif easing and easing.child_arg("bezier"): + current_curve = f"bezier {easing.child_arg('bezier')}" + elif easing and easing.args: + current_curve = str(easing.args[0]) + + apply_row = Adw.ActionRow(title="Easing Curve", subtitle=current_curve if current_curve else "Default") + apply_btn.connect("clicked", lambda *_, k=key, ar=apply_row: self._apply_bezier_to_anim(k, ar)) + apply_row.add_suffix(apply_btn) + grp.add_row(apply_row) + + return grp + + def _toggle_all(self, off: bool): + anim = find_or_create(self._nodes, "animations") + set_node_flag(anim, "off", off) + self._commit("animations off") + + def _set_anim(self, key: str, value): + anim = find_or_create(self._nodes, "animations") + set_child_arg(anim, key, value) + self._commit(f"animations {key}") + + def _set_anim_enabled(self, anim_key: str, enabled: bool): + anim = find_or_create(self._nodes, "animations") + an = anim.get_child(anim_key) + if not enabled: + if an is None: + an = KdlNode(anim_key) + anim.children.append(an) + set_node_flag(an, "off", True) + else: + if an: + from nirimod.kdl_parser import remove_child + + remove_child(an, "off") + self._commit(f"animation {anim_key} enabled") + + def _set_anim_prop(self, anim_key: str, prop: str, value): + anim = find_or_create(self._nodes, "animations") + an = anim.get_child(anim_key) + if an is None: + an = KdlNode(anim_key) + anim.children.append(an) + + if prop == "duration-ms": + from nirimod.kdl_parser import remove_child + remove_child(an, "spring") + + set_child_arg(an, prop, value) + self._commit(f"animation {anim_key} {prop}") + + def _apply_bezier_to_anim(self, anim_key: str, apply_row: Adw.ActionRow = None): + x1, y1, x2, y2 = self._bezier_editor.get_curve() + anim = find_or_create(self._nodes, "animations") + an = anim.get_child(anim_key) + if an is None: + an = KdlNode(anim_key) + anim.children.append(an) + + # Remove legacy easing block if present + old_easing = an.get_child("easing") + if old_easing is not None: + an.children.remove(old_easing) + + from nirimod.kdl_parser import remove_child + remove_child(an, "spring") + + curve_node = an.get_child("curve") + if curve_node is None: + curve_node = KdlNode("curve") + an.children.append(curve_node) + curve_node.args = ["cubic-bezier", round(x1, 3), round(y1, 3), round(x2, 3), round(y2, 3)] + + curve_str = f"{x1:.3f} {y1:.3f} {x2:.3f} {y2:.3f}" + self._commit(f"animation {anim_key} bezier") + self.show_toast(f"Bezier applied to {anim_key}") + + if apply_row: + apply_row.set_subtitle(f"cubic-bezier {curve_str}") diff --git a/nirimod/pages/appearance.py b/nirimod/pages/appearance.py new file mode 100644 index 0000000..12ed4a7 --- /dev/null +++ b/nirimod/pages/appearance.py @@ -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() diff --git a/nirimod/pages/base.py b/nirimod/pages/base.py new file mode 100644 index 0000000..4c6f88f --- /dev/null +++ b/nirimod/pages/base.py @@ -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) diff --git a/nirimod/pages/bindings.py b/nirimod/pages/bindings.py new file mode 100644 index 0000000..4129eac --- /dev/null +++ b/nirimod/pages/bindings.py @@ -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 diff --git a/nirimod/pages/environment.py b/nirimod/pages/environment.py new file mode 100644 index 0000000..d1073c4 --- /dev/null +++ b/nirimod/pages/environment.py @@ -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"{key_str}", + 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) diff --git a/nirimod/pages/gestures.py b/nirimod/pages/gestures.py new file mode 100644 index 0000000..f57436d --- /dev/null +++ b/nirimod/pages/gestures.py @@ -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() diff --git a/nirimod/pages/input_page.py b/nirimod/pages/input_page.py new file mode 100644 index 0000000..224101f --- /dev/null +++ b/nirimod/pages/input_page.py @@ -0,0 +1,380 @@ + + +from __future__ import annotations + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +from gi.repository import Adw, Gtk + +from nirimod import niri_ipc +from nirimod.kdl_parser import ( + KdlNode, + find_or_create, + set_child_arg, + set_node_flag, + safe_switch_connect, +) +from nirimod.pages.base import BasePage + +ACCEL_PROFILES = ["default", "flat", "adaptive"] +SCROLL_METHODS_TP = ["two-finger", "edge", "on-button-down", "no-scroll"] +CLICK_METHODS = ["button-areas", "clickfinger"] + + +class InputPage(BasePage): + def build(self) -> Gtk.Widget: + tb, _, _, content = self._make_toolbar_page("Input") + self._content = content + self._build_content() + return tb + + def _build_content(self): + content = self._content + nodes = self._nodes + + kb_expander = Adw.ExpanderRow(title="Keyboard", subtitle="XKB options & key repeat") + kb_expander.add_css_class("nm-expander") + + kb_node = find_or_create(nodes, "input", "keyboard") + xkb_node = kb_node.get_child("xkb") or KdlNode("xkb") + + fields = [ + ("layout", "Layout", "e.g. us,ru"), + ("variant", "Variant", "e.g. dvorak"), + ("model", "Model", ""), + ("options", "Options", "e.g. grp:win_space_toggle"), + ("rules", "Rules", ""), + ] + self._xkb_entries: dict[str, Adw.EntryRow] = {} + for key, title, ph in fields: + row = Adw.EntryRow(title=title) + row.set_show_apply_button(True) + val = xkb_node.child_arg(key) if xkb_node else None + if val: + row.set_text(str(val)) + row.set_input_purpose(Gtk.InputPurpose.FREE_FORM) + row.connect("apply", lambda r, k=key: self._set_xkb(k, r.get_text())) + kb_expander.add_row(row) + self._xkb_entries[key] = row + + delay_adj = Gtk.Adjustment( + value=kb_node.child_arg("repeat-delay") or 600, + lower=100, upper=3000, step_increment=50, + ) + delay_row = Adw.SpinRow(title="Repeat Delay (ms)", adjustment=delay_adj, digits=0) + delay_row.connect("notify::value", lambda r, _: self._set_kb("repeat-delay", int(r.get_value()))) + kb_expander.add_row(delay_row) + + rate_adj = Gtk.Adjustment( + value=kb_node.child_arg("repeat-rate") or 25, + lower=1, upper=200, step_increment=1, + ) + rate_row = Adw.SpinRow(title="Repeat Rate (keys/sec)", adjustment=rate_adj, digits=0) + rate_row.connect("notify::value", lambda r, _: self._set_kb("repeat-rate", int(r.get_value()))) + kb_expander.add_row(rate_row) + + numlock_row = Adw.SwitchRow(title="Enable Num Lock on Startup") + nl_init = kb_node.get_child("numlock") is not None + numlock_row.set_active(nl_init) + safe_switch_connect(numlock_row, nl_init, self._toggle_numlock) + kb_expander.add_row(numlock_row) + + kb_grp = Adw.PreferencesGroup() + kb_grp.add(kb_expander) + content.append(kb_grp) + + # focus / pointer + focus_grp = Adw.PreferencesGroup(title="Pointer Behavior") + input_node = find_or_create(nodes, "input") + + ffm_row = Adw.SwitchRow(title="Focus Follows Mouse") + ffm_node = input_node.get_child("focus-follows-mouse") + ffm_row._last_active = ffm_node is not None + ffm_row.set_active(ffm_node is not None) + + def _on_ffm_toggled(r, _): + new_val = r.get_active() + if new_val != getattr(r, "_last_active", None): + r._last_active = new_val + self._toggle_ffm(new_val) + + ffm_row.connect("notify::active", _on_ffm_toggled) + focus_grp.add(ffm_row) + + scroll_val = 33 + if ffm_node: + vRaw = ffm_node.props.get("max-scroll-amount") + if vRaw is not None: + try: + scroll_val = int(float(str(vRaw).replace("%", "").strip())) + except ValueError: + pass + self._last_scroll_val = scroll_val + scroll_adj = Gtk.Adjustment(value=scroll_val, lower=0, upper=100, step_increment=1) + scroll_pct_row = Adw.SpinRow( + title="Max Scroll Amount (%)", subtitle="0% = only fully visible windows", + adjustment=scroll_adj, digits=0, + ) + scroll_pct_row.set_sensitive(ffm_node is not None) + self._scroll_pct_row = scroll_pct_row + scroll_pct_row._last_val = scroll_val + + def _on_scroll_pct_changed(r, _): + new_val = int(r.get_value()) + if new_val != getattr(r, "_last_val", None): + r._last_val = new_val + self._set_ffm_scroll(new_val) + + scroll_pct_row.connect("notify::value", _on_scroll_pct_changed) + focus_grp.add(scroll_pct_row) + + warp_init = input_node.get_child("warp-mouse-to-focus") is not None + warp_row = Adw.SwitchRow(title="Warp Mouse to Focus") + warp_row.set_active(warp_init) + safe_switch_connect(warp_row, warp_init, + lambda enabled: self._toggle_input_flag("warp-mouse-to-focus", enabled)) + focus_grp.add(warp_row) + content.append(focus_grp) + + # touchpad + tp_expander = Adw.ExpanderRow(title="Touchpad") + tp_expander.add_css_class("nm-expander") + has_tp = niri_ipc.has_touchpad() + if not has_tp: + tp_expander.set_subtitle("No touchpad detected") + tp_expander.set_sensitive(False) + + tp_node = find_or_create(nodes, "input", "touchpad") + + def tp_switch(key, label, subtitle=""): + r = Adw.SwitchRow(title=label, subtitle=subtitle) + ini = tp_node.get_child(key) is not None + r.set_active(ini) + safe_switch_connect(r, ini, lambda enabled, k=key: self._set_tp_flag(k, enabled)) + return r + + def tp_bool_switch(key, label, default_active=True, subtitle=""): + r = Adw.SwitchRow(title=label, subtitle=subtitle) + node = tp_node.get_child(key) + if node is not None and node.args: + ini = bool(node.args[0]) + else: + ini = default_active + r.set_active(ini) + safe_switch_connect(r, ini, lambda enabled, k=key: self._set_tp(k, enabled)) + return r + + tp_expander.add_row(tp_switch("tap", "Tap to Click")) + tp_expander.add_row(tp_switch("dwt", "Disable While Typing")) + tp_expander.add_row(tp_switch("dwtp", "Disable While Trackpointing")) + tp_expander.add_row(tp_switch("natural-scroll", "Natural Scroll")) + tp_expander.add_row(tp_bool_switch("drag", "Tap Drag")) + tp_expander.add_row(tp_switch("drag-lock", "Tap Drag Lock")) + tp_expander.add_row(tp_switch("disabled-on-external-mouse", "Disable on External Mouse")) + + spd_adj = Gtk.Adjustment(value=float(tp_node.child_arg("accel-speed") or 0.0), + lower=-1.0, upper=1.0, step_increment=0.05) + spd_row = Adw.SpinRow(title="Accel Speed", adjustment=spd_adj, digits=2) + spd_row.connect("notify::value", lambda r, _: self._set_tp("accel-speed", r.get_value())) + tp_expander.add_row(spd_row) + + ap_model = Gtk.StringList.new(ACCEL_PROFILES) + ap_row = Adw.ComboRow(title="Accel Profile", model=ap_model) + cur_ap = tp_node.child_arg("accel-profile") or "default" + if cur_ap in ACCEL_PROFILES: + ap_row.set_selected(ACCEL_PROFILES.index(cur_ap)) + ap_row.connect("notify::selected", + lambda r, _: self._set_tp("accel-profile", ACCEL_PROFILES[r.get_selected()])) + tp_expander.add_row(ap_row) + + sm_model = Gtk.StringList.new(SCROLL_METHODS_TP) + sm_row = Adw.ComboRow(title="Scroll Method", model=sm_model) + cur_sm = tp_node.child_arg("scroll-method") or "two-finger" + if cur_sm in SCROLL_METHODS_TP: + sm_row.set_selected(SCROLL_METHODS_TP.index(cur_sm)) + sm_row.connect("notify::selected", + lambda r, _: self._set_tp("scroll-method", SCROLL_METHODS_TP[r.get_selected()])) + tp_expander.add_row(sm_row) + + cm_model = Gtk.StringList.new(CLICK_METHODS) + cm_row = Adw.ComboRow(title="Click Method", model=cm_model) + cur_cm = tp_node.child_arg("click-method") or "button-areas" + if cur_cm in CLICK_METHODS: + cm_row.set_selected(CLICK_METHODS.index(cur_cm)) + cm_row.connect("notify::selected", + lambda r, _: self._set_tp("click-method", CLICK_METHODS[r.get_selected()])) + tp_expander.add_row(cm_row) + + tp_grp = Adw.PreferencesGroup() + tp_grp.add(tp_expander) + content.append(tp_grp) + + # mouse + m_expander = Adw.ExpanderRow(title="Mouse") + m_expander.add_css_class("nm-expander") + m_node = find_or_create(nodes, "input", "mouse") + + m_nat = Adw.SwitchRow(title="Natural Scroll") + mn_init = m_node.get_child("natural-scroll") is not None + m_nat.set_active(mn_init) + safe_switch_connect(m_nat, mn_init, lambda enabled: self._set_m_flag("natural-scroll", enabled)) + m_expander.add_row(m_nat) + + m_spd_adj = Gtk.Adjustment(value=float(m_node.child_arg("accel-speed") or 0.0), + lower=-1.0, upper=1.0, step_increment=0.05) + m_spd_row = Adw.SpinRow(title="Accel Speed", adjustment=m_spd_adj, digits=2) + m_spd_row.connect("notify::value", lambda r, _: self._set_m("accel-speed", r.get_value())) + m_expander.add_row(m_spd_row) + + m_ap_model = Gtk.StringList.new(ACCEL_PROFILES) + m_ap_row = Adw.ComboRow(title="Accel Profile", model=m_ap_model) + cur_m_ap = m_node.child_arg("accel-profile") or "default" + if cur_m_ap in ACCEL_PROFILES: + m_ap_row.set_selected(ACCEL_PROFILES.index(cur_m_ap)) + m_ap_row.connect("notify::selected", + lambda r, _: self._set_m("accel-profile", ACCEL_PROFILES[r.get_selected()])) + m_expander.add_row(m_ap_row) + + m_grp = Adw.PreferencesGroup() + m_grp.add(m_expander) + content.append(m_grp) + + # cursor + cursor_grp = Adw.PreferencesGroup(title="Cursor") + cursor_node = next((n for n in nodes if n.name == "cursor"), None) + + size_val = int(cursor_node.child_arg("xcursor-size") or 24) if cursor_node else 24 + size_adj = Gtk.Adjustment(value=size_val, lower=8, upper=256, step_increment=2) + size_row = Adw.SpinRow(title="Cursor Size (px)", adjustment=size_adj, digits=0) + size_row.connect("notify::value", + lambda r, _: self._set_cursor("xcursor-size", int(r.get_value()))) + cursor_grp.add(size_row) + + hide_val = int(cursor_node.child_arg("hide-after-inactive-ms") or 0) if cursor_node else 0 + hide_adj = Gtk.Adjustment(value=hide_val, lower=0, upper=60000, step_increment=500) + hide_row = Adw.SpinRow(title="Hide After Inactive (ms)", subtitle="0 = never hide", + adjustment=hide_adj, digits=0) + hide_row.connect("notify::value", + lambda r, _: self._set_cursor("hide-after-inactive-ms", int(r.get_value()))) + cursor_grp.add(hide_row) + + theme_val = str(cursor_node.child_arg("xcursor-theme") or "") if cursor_node else "" + theme_row = Adw.EntryRow(title="Cursor Theme (e.g. Adwaita)") + theme_row.set_text(theme_val) + theme_row.set_show_apply_button(True) + theme_row.connect("apply", lambda r: self._set_cursor_theme(r.get_text())) + cursor_grp.add(theme_row) + content.append(cursor_grp) + + + def _get_kb_node(self): + return find_or_create(self._nodes, "input", "keyboard") + + def _get_xkb_node(self): + kb = self._get_kb_node() + xkb = kb.get_child("xkb") + if xkb is None: + xkb = KdlNode("xkb") + kb.children.insert(0, xkb) + return xkb + + def _set_xkb(self, key: str, value: str): + xkb = self._get_xkb_node() + if value.strip(): + set_child_arg(xkb, key, value.strip()) + else: + from nirimod.kdl_parser import remove_child + remove_child(xkb, key) + self._commit(f"keyboard xkb {key}") + + def _set_kb(self, key: str, value): + set_child_arg(self._get_kb_node(), key, value) + self._commit(f"keyboard {key}") + + def _toggle_numlock(self, enabled: bool): + set_node_flag(self._get_kb_node(), "numlock", enabled) + self._commit("keyboard numlock") + + def _get_input_node(self): + return find_or_create(self._nodes, "input") + + def _toggle_ffm(self, enabled: bool): + inp = self._get_input_node() + existing = inp.get_child("focus-follows-mouse") + if enabled: + if existing is None: + new_ffm = KdlNode(name="focus-follows-mouse") + if hasattr(self, "_last_scroll_val"): + new_ffm.props["max-scroll-amount"] = f"{self._last_scroll_val}%" + inp.children.insert(0, new_ffm) + else: + if existing is not None: + inp.children.remove(existing) + if hasattr(self, "_scroll_pct_row"): + self._scroll_pct_row.set_sensitive(enabled) + self._commit("focus-follows-mouse") + + def _set_ffm_scroll(self, pct: int): + inp = self._get_input_node() + ffm = inp.get_child("focus-follows-mouse") + if ffm is None: + ffm = KdlNode("focus-follows-mouse") + inp.children.append(ffm) + ffm.props["max-scroll-amount"] = f"{pct}%" + self._commit("ffm scroll amount") + + def _toggle_input_flag(self, key: str, enabled: bool): + set_node_flag(self._get_input_node(), key, enabled) + self._commit(f"input {key}") + + def _get_tp_node(self): + return find_or_create(self._nodes, "input", "touchpad") + + def _set_tp_flag(self, key: str, enabled: bool): + set_node_flag(self._get_tp_node(), key, enabled) + self._commit(f"touchpad {key}") + + def _set_tp(self, key: str, value): + set_child_arg(self._get_tp_node(), key, value) + self._commit(f"touchpad {key}") + + def _get_m_node(self): + return find_or_create(self._nodes, "input", "mouse") + + def _set_m_flag(self, key: str, enabled: bool): + set_node_flag(self._get_m_node(), key, enabled) + self._commit(f"mouse {key}") + + def _set_m(self, key: str, value): + set_child_arg(self._get_m_node(), key, value) + self._commit(f"mouse {key}") + + def _get_cursor_node(self): + existing = next((n for n in self._nodes if n.name == "cursor"), None) + if existing is None: + existing = KdlNode("cursor") + self._nodes.append(existing) + return existing + + def _set_cursor(self, key: str, value): + set_child_arg(self._get_cursor_node(), key, value) + self._commit(f"cursor {key}") + + def _set_cursor_theme(self, theme: str): + cur = self._get_cursor_node() + if theme.strip(): + set_child_arg(cur, "xcursor-theme", theme.strip()) + else: + from nirimod.kdl_parser import remove_child + remove_child(cur, "xcursor-theme") + self._commit("cursor xcursor-theme") + + def refresh(self): + child = self._content.get_first_child() + while child: + next_child = child.get_next_sibling() + self._content.remove(child) + child = next_child + self._build_content() diff --git a/nirimod/pages/layout.py b/nirimod/pages/layout.py new file mode 100644 index 0000000..e05b1d6 --- /dev/null +++ b/nirimod/pages/layout.py @@ -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() diff --git a/nirimod/pages/outputs.py b/nirimod/pages/outputs.py new file mode 100644 index 0000000..5a948d0 --- /dev/null +++ b/nirimod/pages/outputs.py @@ -0,0 +1,746 @@ +"""Outputs / Monitors page with interactive canvas.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") + + +from gi.repository import Adw, Gtk + +from nirimod import niri_ipc +from nirimod.kdl_parser import KdlNode, set_child_arg, safe_switch_connect +from nirimod.pages.base import BasePage + +if TYPE_CHECKING: + from nirimod.window import NiriModWindow + +TRANSFORMS = [ + "normal", + "90", + "180", + "270", + "flipped", + "flipped-90", + "flipped-180", + "flipped-270", +] + + +class OutputsPage(BasePage): + def __init__(self, window: "NiriModWindow"): + super().__init__(window) + self._outputs: list[dict] = [] + self._current_out: dict | None = None + + self._canvas: Gtk.DrawingArea | None = None + self._drag_output: str | None = None + self._drag_offset: tuple[float, float] = (0, 0) + + def build(self) -> Gtk.Widget: + tb, header, scroll, content = self._make_toolbar_page("Outputs") + + add_fake_btn = Gtk.Button(icon_name="list-add-symbolic") + add_fake_btn.set_tooltip_text("Add fake monitor for testing") + add_fake_btn.add_css_class("flat") + add_fake_btn.connect("clicked", lambda *_: self._add_fake_monitor()) + header.pack_end(add_fake_btn) + + refresh_btn = Gtk.Button(icon_name="view-refresh-symbolic") + refresh_btn.set_tooltip_text("Reload outputs from niri") + refresh_btn.add_css_class("flat") + refresh_btn.connect("clicked", lambda *_: self.refresh()) + header.pack_end(refresh_btn) + + canvas_frame = Gtk.Frame() + canvas_frame.add_css_class("card") + canvas_frame.set_margin_bottom(8) + + self._canvas = Gtk.DrawingArea() + self._canvas.set_content_height(350) + self._canvas.set_draw_func(self._draw_canvas) + canvas_frame.set_child(self._canvas) + content.append(canvas_frame) + + drag = Gtk.GestureDrag() + drag.connect("drag-begin", self._on_drag_begin) + drag.connect("drag-update", self._on_drag_update) + drag.connect("drag-end", self._on_drag_end) + self._canvas.add_controller(drag) + + click = Gtk.GestureClick() + click.connect("pressed", self._on_canvas_click) + self._canvas.add_controller(click) + + self._out_combo = Adw.ComboRow(title="Monitor") + self._out_combo.connect("notify::selected", self._on_output_selected) + sel_group = Adw.PreferencesGroup() + sel_group.add(self._out_combo) + content.append(sel_group) + + self._detail_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + content.append(self._detail_box) + + self.refresh() + return tb + + def refresh(self): + def _on_got_outputs(outputs): + self._outputs = outputs + names = [o.get("name", "?") for o in self._outputs] + model = Gtk.StringList.new(names) + self._out_combo.set_model(model) + if self._outputs: + self._load_output_detail(self._outputs[0]) + if self._canvas: + self._canvas.queue_draw() + # Rebuild search index as the detail rows are now populated + if hasattr(self._win, "_build_search_index"): + self._win._build_search_index() + + niri_ipc.get_outputs(_on_got_outputs) + + def _add_fake_monitor(self): + idx = 1 + while any(o.get("name") == f"fake-{idx}" for o in self._outputs): + idx += 1 + name = f"fake-{idx}" + + o = { + "name": name, + "modes": [{"width": 1920, "height": 1080, "refresh_rate": 60000}], + "current_mode": 0, + "logical": { + "x": 0, + "y": 0, + "width": 1920, + "height": 1080, + "scale": 1.0, + "transform": "normal", + }, + } + self._outputs.append(o) + + names = [out.get("name", "?") for out in self._outputs] + model = Gtk.StringList.new(names) + self._out_combo.set_model(model) + self._out_combo.set_selected(len(self._outputs) - 1) + + if self._canvas: + self._canvas.queue_draw() + if hasattr(self._win, "_build_search_index"): + self._win._build_search_index() + + # Canvas drawing + + def _draw_canvas(self, area, cr, width, height): + if not self._outputs: + cr.set_source_rgba(0.05, 0.05, 0.05, 0.4) + cr.rectangle(0, 0, width, height) + cr.fill() + cr.set_source_rgba(0.5, 0.5, 0.5, 0.8) + cr.select_font_face("Sans", 0, 0) + cr.set_font_size(14) + cr.move_to(width / 2 - 80, height / 2) + cr.show_text("No outputs detected") + return + + min_x = min_y = float("inf") + max_x = max_y = float("-inf") + for o in self._outputs: + pos = o.get("logical", {}) + lx = pos.get("x", 0) + ly = pos.get("y", 0) + lw = pos.get("width", 1920) + lh = pos.get("height", 1080) + min_x = min(min_x, lx) + min_y = min(min_y, ly) + max_x = max(max_x, lx + lw) + max_y = max(max_y, ly + lh) + + if min_x == float("inf"): + min_x = min_y = 0 + max_x = 1920 + max_y = 1080 + + total_w = max_x - min_x + total_h = max_y - min_y + + scale = min(width / max(total_w, 1), height / max(total_h, 1)) * 0.9 + off_x = (width - total_w * scale) / 2 - min_x * scale + off_y = (height - total_h * scale) / 2 - min_y * scale + + if self._drag_output and hasattr(self, "_drag_start_scale"): + scale = self._drag_start_scale + off_x, off_y = self._drag_start_offset + + self._canvas_scale = scale + self._canvas_offset = (off_x, off_y) + self._canvas_pixel_w = width + self._canvas_pixel_h = height + + # grid background + cr.set_source_rgba(1, 1, 1, 0.03) + cr.set_line_width(1) + grid_size = 40 + for gx in range(0, int(width), grid_size): + cr.move_to(gx, 0) + cr.line_to(gx, height) + for gy in range(0, int(height), grid_size): + cr.move_to(0, gy) + cr.line_to(width, gy) + cr.stroke() + + for i, o in enumerate(self._outputs): + pos = o.get("logical", {}) + x = off_x + pos.get("x", 0) * scale + y = off_y + pos.get("y", 0) * scale + w = pos.get("width", 1920) * scale + h = pos.get("height", 1080) * scale + + is_sel = o.get("name") == ( + self._current_out.get("name") if self._current_out else None + ) + + if is_sel: + cr.set_source_rgba(155 / 255, 109 / 255, 1.0, 1.0) + else: + cr.set_source_rgba(0.2, 0.2, 0.2, 1.0) + cr.rectangle(x, y, w, h) + cr.fill_preserve() + + # border + cr.set_line_width(1.5) + if is_sel: + cr.set_source_rgba(0.7, 0.7, 0.75, 0.9) + else: + cr.set_source_rgba(0.4, 0.4, 0.45, 0.6) + cr.stroke() + + name = o.get("name", f"Output {i}") + mode_idx = o.get("current_mode") + modes = o.get("modes", []) + mode = ( + modes[mode_idx] + if isinstance(mode_idx, int) and 0 <= mode_idx < len(modes) + else {} + ) + out_scale = o.get("logical", {}).get("scale", 1.0) + res = f"{mode.get('width', '?')}×{mode.get('height', '?')}" + scale_text = f"Scale: {out_scale}x" + + cr.set_source_rgba(1, 1, 1, 0.95 if is_sel else 0.7) + + cr.select_font_face("Sans", 0, 1) + font_size = max(10, min(16, w / 10)) + cr.set_font_size(font_size) + te = cr.text_extents(name) + cr.move_to(x + w / 2 - te.width / 2, y + h / 2 - font_size * 0.3) + cr.show_text(name) + + cr.select_font_face("Sans", 0, 0) + res_size = max(8, min(12, w / 15)) + cr.set_font_size(res_size) + te2 = cr.text_extents(res) + cr.move_to(x + w / 2 - te2.width / 2, y + h / 2 + res_size * 1.2) + cr.show_text(res) + + cr.set_source_rgba(0.6, 0.6, 0.65, 0.9 if is_sel else 0.6) + scale_size = max(7, min(11, w / 18)) + cr.set_font_size(scale_size) + te3 = cr.text_extents(scale_text) + cr.move_to( + x + w / 2 - te3.width / 2, y + h / 2 + res_size * 1.2 + scale_size * 1.4 + ) + cr.show_text(scale_text) + + def _on_drag_begin(self, gesture, sx, sy): + if not hasattr(self, "_canvas_scale"): + return + scale = self._canvas_scale + ox, oy = self._canvas_offset + for o in reversed(self._outputs): + pos = o.get("logical", {}) + x = ox + pos.get("x", 0) * scale + y = oy + pos.get("y", 0) * scale + w = pos.get("width", 1920) * scale + h = pos.get("height", 1080) * scale + if x <= sx <= x + w and y <= sy <= y + h: + self._drag_output = o["name"] + self._last_dx = 0 + self._last_dy = 0 + self._drag_current_lx = pos.get("x", 0) + self._drag_current_ly = pos.get("y", 0) + self._drag_start_scale = scale + self._drag_start_offset = (ox, oy) + return + + def _on_drag_update(self, gesture, dx, dy): + if not self._drag_output or not hasattr(self, "_canvas_scale"): + return + + scale = getattr(self, "_drag_start_scale", self._canvas_scale) + delta_dx = dx - getattr(self, "_last_dx", 0) + delta_dy = dy - getattr(self, "_last_dy", 0) + self._last_dx = dx + self._last_dy = dy + + self._drag_current_lx += delta_dx / scale + self._drag_current_ly += delta_dy / scale + + new_lx = self._drag_current_lx + new_ly = self._drag_current_ly + + drag_o = next( + (o for o in self._outputs if o.get("name") == self._drag_output), None + ) + if not drag_o: + return + + monitor_scale = drag_o.get("logical", {}).get("scale", 1.0) + mode_idx = drag_o.get("current_mode") + modes = drag_o.get("modes", []) + mode = ( + modes[mode_idx] + if isinstance(mode_idx, int) and 0 <= mode_idx < len(modes) + else {} + ) + pixel_w = mode.get("width", 1920) + pixel_h = mode.get("height", 1080) + + transform = str(drag_o.get("logical", {}).get("transform", "normal")).lower().replace("_", "-") + if transform in ["90", "270", "flipped-90", "flipped-270"]: + pixel_w, pixel_h = pixel_h, pixel_w + + logical_w = pixel_w / monitor_scale + logical_h = pixel_h / monitor_scale + + # edge snapping + SNAP_THRESHOLD = 30 + snapped_x = new_lx + snapped_y = new_ly + closest_x = SNAP_THRESHOLD + 1 + closest_y = SNAP_THRESHOLD + 1 + + dragged_left = new_lx + dragged_right = new_lx + logical_w + dragged_top = new_ly + dragged_bottom = new_ly + logical_h + + for other in self._outputs: + if other.get("name") == self._drag_output: + continue + + other_pos = other.get("logical", {}) + other_x = other_pos.get("x", 0) + other_y = other_pos.get("y", 0) + + other_scale = other_pos.get("scale", 1.0) + other_mode_idx = other.get("current_mode") + other_modes = other.get("modes", []) + other_mode = other_modes[other_mode_idx] if isinstance(other_mode_idx, int) and 0 <= other_mode_idx < len(other_modes) else {} + other_pixel_w = other_mode.get("width", 1920) + other_pixel_h = other_mode.get("height", 1080) + + other_transform = str(other_pos.get("transform", "normal")).lower().replace("_", "-") + if other_transform in ["90", "270", "flipped-90", "flipped-270"]: + other_pixel_w, other_pixel_h = other_pixel_h, other_pixel_w + + other_logical_w = other_pixel_w / other_scale + other_logical_h = other_pixel_h / other_scale + + other_left = other_x + other_right = other_x + other_logical_w + other_top = other_y + other_bottom = other_y + other_logical_h + + for dragged_edge, is_left_edge in [(dragged_left, True), (dragged_right, False)]: + for other_edge in [other_left, other_right]: + dist = abs(dragged_edge - other_edge) + if dist < closest_x: + closest_x = dist + snapped_x = other_edge if is_left_edge else other_edge - logical_w + + for dragged_edge, is_top_edge in [(dragged_top, True), (dragged_bottom, False)]: + for other_edge in [other_top, other_bottom]: + dist = abs(dragged_edge - other_edge) + if dist < closest_y: + closest_y = dist + snapped_y = other_edge if is_top_edge else other_edge - logical_h + + if closest_x <= SNAP_THRESHOLD: + new_lx = snapped_x + if closest_y <= SNAP_THRESHOLD: + new_ly = snapped_y + + if "logical" not in drag_o: + drag_o["logical"] = {} + drag_o["logical"]["x"] = new_lx + drag_o["logical"]["y"] = new_ly + + if self._canvas: + self._canvas.queue_draw() + + def _on_drag_end(self, gesture, dx, dy): + if self._drag_output: + if self._canvas: + self._canvas.queue_draw() + + for o in self._outputs: + self._apply_position(o["name"]) + + if self._current_out: + cur_pos = self._current_out.get("logical", {}) + if hasattr(self, "_pos_x_adj"): + self._pos_x_adj.set_value(cur_pos.get("x", 0)) + if hasattr(self, "_pos_y_adj"): + self._pos_y_adj.set_value(cur_pos.get("y", 0)) + + self._drag_output = None + + def _on_canvas_click(self, gesture, n_press, x, y): + if not hasattr(self, "_canvas_scale"): + return + + scale = self._canvas_scale + ox, oy = self._canvas_offset + + for i, o in reversed(list(enumerate(self._outputs))): + pos = o.get("logical", {}) + mx = ox + pos.get("x", 0) * scale + my = oy + pos.get("y", 0) * scale + mw = pos.get("width", 1920) * scale + mh = pos.get("height", 1080) * scale + + if mx <= x <= mx + mw and my <= y <= my + mh: + self._out_combo.set_selected(i) + return + + def _apply_position(self, name: str): + o = next((x for x in self._outputs if x["name"] == name), None) + if not o: + return + pos = o.get("logical", {}) + + nx = pos.get("x", 0) + ny = pos.get("y", 0) + + out_node = self._get_or_create_out_node(name) + pos_node = out_node.get_child("position") + if pos_node is None: + pos_node = KdlNode(name="position") + out_node.children.append(pos_node) + pos_node.props["x"] = int(round(nx)) + pos_node.props["y"] = int(round(ny)) + + if self._current_out and self._current_out.get("name") == name: + if hasattr(self, "_pos_x_adj"): + self._pos_x_adj.set_value(nx) + if hasattr(self, "_pos_y_adj"): + self._pos_y_adj.set_value(ny) + + self._commit("output position") + + def _on_output_selected(self, combo, _): + idx = combo.get_selected() + if 0 <= idx < len(self._outputs): + self._load_output_detail(self._outputs[idx]) + # Rebuild search index as the detail rows have changed + if hasattr(self._win, "_build_search_index"): + self._win._build_search_index() + + def _load_output_detail(self, output: dict): + self._current_out = output + for child in list(self._detail_box): + self._detail_box.remove(child) + + name = output.get("name", "?") + nodes = self._nodes + out_node = next( + (n for n in nodes if n.name == "output" and n.args and n.args[0] == name), + None, + ) + + modes = output.get("modes", []) + mode_strs = [ + f"{m.get('width', 0)}×{m.get('height', 0)}@{m.get('refresh_rate', 0) / 1000:.3f}" + for m in modes + ] + mode_model = Gtk.StringList.new(mode_strs) + mode_row = Adw.ComboRow(title="Resolution & Refresh Rate") + mode_row.set_model(mode_model) + mode_idx = output.get("current_mode") + cur_mode = ( + modes[mode_idx] + if isinstance(mode_idx, int) and 0 <= mode_idx < len(modes) + else {} + ) + cur_str = f"{cur_mode.get('width', 0)}×{cur_mode.get('height', 0)}@{cur_mode.get('refresh_rate', 0) / 1000:.3f}" + if cur_str in mode_strs: + mode_row.set_selected(mode_strs.index(cur_str)) + mode_row.connect( + "notify::selected", + lambda r, _: self._on_mode_changed(name, modes, r.get_selected()), + ) + + scale_val = round(output.get("logical", {}).get("scale", 1.0), 3) + scale_adj = Gtk.Adjustment( + value=scale_val, + lower=0.01, + upper=100.0, + step_increment=0.05, + ) + scale_row = Adw.SpinRow(title="Scale", adjustment=scale_adj, digits=2) + scale_row.connect( + "notify::value", + lambda r, _: self._set_output_prop(name, "scale", r.get_value()), + ) + + t_model = Gtk.StringList.new(TRANSFORMS) + transform_row = Adw.ComboRow(title="Transform", model=t_model) + cur_t = output.get("logical", {}).get("transform", "normal") + cur_t_norm = str(cur_t).lower().replace("_", "-") if cur_t else "normal" + if cur_t_norm in TRANSFORMS: + transform_row.set_selected(TRANSFORMS.index(cur_t_norm)) + transform_row.connect( + "notify::selected", + lambda r, _: self._set_output_prop( + name, "transform", TRANSFORMS[r.get_selected()] + ), + ) + + px = output.get("logical", {}).get("x", 0) + py = output.get("logical", {}).get("y", 0) + px_adj = Gtk.Adjustment(value=px, lower=-1000000, upper=1000000, step_increment=1) + py_adj = Gtk.Adjustment(value=py, lower=-1000000, upper=1000000, step_increment=1) + self._pos_x_adj = px_adj + self._pos_y_adj = py_adj + pos_x_row = Adw.SpinRow(title="Position X", adjustment=px_adj, digits=0) + pos_y_row = Adw.SpinRow(title="Position Y", adjustment=py_adj, digits=0) + pos_x_row.connect( + "notify::value", + lambda r, _: self._set_output_pos( + name, int(r.get_value()), int(py_adj.get_value()) + ), + ) + pos_y_row.connect( + "notify::value", + lambda r, _: self._set_output_pos( + name, int(px_adj.get_value()), int(r.get_value()) + ), + ) + + vrr_row = Adw.SwitchRow(title="Variable Refresh Rate (VRR)") + vrr_val = ( + (out_node.get_child("variable-refresh-rate") is not None) + if out_node + else False + ) + vrr_row.set_active(vrr_val) + safe_switch_connect( + vrr_row, + vrr_val, + lambda enabled: self._set_output_flag( + name, "variable-refresh-rate", enabled + ), + ) + + off_row = Adw.SwitchRow(title="Disable Output") + off_val = (out_node.get_child("off") is not None) if out_node else False + off_row.set_active(off_val) + safe_switch_connect( + off_row, + off_val, + lambda enabled: self._set_output_flag(name, "off", enabled), + ) + + grp = Adw.PreferencesGroup(title=f"Output: {name}") + for r in [ + mode_row, + scale_row, + transform_row, + pos_x_row, + pos_y_row, + vrr_row, + off_row, + ]: + grp.add(r) + self._detail_box.append(grp) + + if self._canvas: + self._canvas.queue_draw() + + def _ensure_output_fields(self, out_node: KdlNode, name: str): + manual_out = None + try: + manual_nodes = self._nodes + if manual_nodes: + manual_out = next( + ( + n + for n in manual_nodes + if n.name == "output" and n.args and n.args[0] == name + ), + None, + ) + except Exception: + pass + + if manual_out: + if out_node.get_child("mode") is None: + m = manual_out.child_arg("mode") + if m: + set_child_arg(out_node, "mode", m) + if out_node.get_child("scale") is None: + s = manual_out.child_arg("scale") + if s is not None: + set_child_arg(out_node, "scale", s) + if out_node.get_child("transform") is None: + t = manual_out.child_arg("transform") + if t: + set_child_arg(out_node, "transform", t) + if out_node.get_child("position") is None: + pos_node = manual_out.get_child("position") + if pos_node: + new_pos = KdlNode(name="position", props=pos_node.props.copy()) + out_node.children.append(new_pos) + + o = next((x for x in self._outputs if x.get("name") == name), None) + if o: + if out_node.get_child("mode") is None: + mode_idx = o.get("current_mode") + modes = o.get("modes", []) + if isinstance(mode_idx, int) and 0 <= mode_idx < len(modes): + m = modes[mode_idx] + mode_str = f"{m.get('width', 0)}x{m.get('height', 0)}@{m.get('refresh_rate', 0) / 1000:.3f}" + set_child_arg(out_node, "mode", mode_str) + if out_node.get_child("scale") is None: + set_child_arg(out_node, "scale", o.get("logical", {}).get("scale", 1.0)) + if out_node.get_child("transform") is None: + t = o.get("logical", {}).get("transform", "normal") + t = str(t).lower().replace("_", "-") if t else "normal" + if t not in TRANSFORMS: + t = "normal" + set_child_arg(out_node, "transform", t) + pos_node = out_node.get_child("position") + if pos_node is None: + pos_node = KdlNode(name="position") + out_node.children.append(pos_node) + pos_node.props["x"] = o.get("logical", {}).get("x", 0) + pos_node.props["y"] = o.get("logical", {}).get("y", 0) + + def _get_or_create_out_node(self, name: str) -> KdlNode: + nodes = self._nodes + out_node = next( + (n for n in nodes if n.name == "output" and n.args and n.args[0] == name), + None, + ) + is_new = out_node is None + if out_node is None: + out_node = KdlNode(name="output", args=[name]) + nodes.append(out_node) + + assert out_node is not None + + if is_new: + self._ensure_output_fields(out_node, name) + + order = {"mode": 0, "scale": 1, "transform": 2, "position": 3} + out_node.children.sort(key=lambda c: order.get(c.name, 999)) + + return out_node + + def _update_logical_dims(self, o: dict): + if "logical" not in o: + o["logical"] = {} + mode_idx = o.get("current_mode") + modes = o.get("modes", []) + m = ( + modes[mode_idx] + if isinstance(mode_idx, int) and 0 <= mode_idx < len(modes) + else {} + ) + pw = m.get("width", 1920) + ph = m.get("height", 1080) + + scale = o["logical"].get("scale", 1.0) + if scale <= 0: + scale = 1.0 + + t = o["logical"].get("transform", "normal") + t_str = str(t).lower().replace("_", "-") + if t_str in ["90", "270", "flipped-90", "flipped-270"]: + pw, ph = ph, pw + + o["logical"]["width"] = round(pw / scale) + o["logical"]["height"] = round(ph / scale) + + def _on_mode_changed(self, name: str, modes: list, idx: int): + if not (0 <= idx < len(modes)): + return + m = modes[idx] + mode_str = f"{m.get('width', 0)}x{m.get('height', 0)}@{m.get('refresh_rate', 0) / 1000:.3f}" + out_node = self._get_or_create_out_node(name) + set_child_arg(out_node, "mode", mode_str) + + o = next((x for x in self._outputs if x.get("name") == name), None) + if o: + o["current_mode"] = idx + self._update_logical_dims(o) + + self._commit("output mode") + if self._canvas: + self._canvas.queue_draw() + + def _set_output_prop(self, name: str, prop: str, value): + if prop == "scale" and isinstance(value, float): + value = round(value, 3) + + out_node = self._get_or_create_out_node(name) + set_child_arg(out_node, prop, value) + + o = next((x for x in self._outputs if x.get("name") == name), None) + if o: + if "logical" not in o: + o["logical"] = {} + if prop == "scale": + o["logical"]["scale"] = value + elif prop == "transform": + o["logical"]["transform"] = value + self._update_logical_dims(o) + + self._commit(f"output {prop}") + if self._canvas: + self._canvas.queue_draw() + + def _set_output_pos(self, name: str, x: int, y: int): + out_node = self._get_or_create_out_node(name) + pos_node = out_node.get_child("position") + if pos_node is None: + pos_node = KdlNode(name="position") + out_node.children.append(pos_node) + + pos_node.props["x"] = int(round(x)) + pos_node.props["y"] = int(round(y)) + + o = next((out for out in self._outputs if out.get("name") == name), None) + if o: + if "logical" not in o: + o["logical"] = {} + o["logical"]["x"] = x + o["logical"]["y"] = y + + self._commit("output position") + if self._canvas: + self._canvas.queue_draw() + + def _set_output_flag(self, name: str, flag: str, enabled: bool): + from nirimod.kdl_parser import set_node_flag + + out_node = self._get_or_create_out_node(name) + set_node_flag(out_node, flag, enabled) + self._commit(f"output {flag}") diff --git a/nirimod/pages/raw_config.py b/nirimod/pages/raw_config.py new file mode 100644 index 0000000..5411a33 --- /dev/null +++ b/nirimod/pages/raw_config.py @@ -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 + ) diff --git a/nirimod/pages/startup.py b/nirimod/pages/startup.py new file mode 100644 index 0000000..d0f4017 --- /dev/null +++ b/nirimod/pages/startup.py @@ -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) diff --git a/nirimod/pages/window_rules.py b/nirimod/pages/window_rules.py new file mode 100644 index 0000000..44d8886 --- /dev/null +++ b/nirimod/pages/window_rules.py @@ -0,0 +1,1014 @@ +"""Window Rules page — redesigned for usability.""" + +from __future__ import annotations + +from typing import NamedTuple + +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, KdlRawString +from nirimod.pages.base import BasePage + + +# ── Human-readable labels ──────────────────────────────────────────────────── + +SCREENCAST_BLOCK_KEY = "block-out-from" + +BOOL_MATCH_LABELS = { + "is-active": "Is Active", + "is-floating": "Is Floating", + "is-focused": "Is Focused", + "at-startup": "At Startup", +} + +BOOL_ACTION_LABELS = { + "open-maximized": "Open Maximized", + "open-fullscreen": "Open Fullscreen", + "open-floating": "Open Floating", + SCREENCAST_BLOCK_KEY: "Block from Screencast", + "draw-border-with-background": "Draw Border with Background", + "clip-to-geometry": "Clip to Geometry", + "prefer-no-csd": "Prefer No CSD", +} + +NUM_ACTION_LABELS = { + "opacity": ("Opacity", 0.0, 1.0, 0.05, 2), + "geometry-corner-radius": ("Corner Radius (px)", 0, 40, 1, 0), + "min-width": ("Min Width (px)", 0, 7680, 1, 0), + "min-height": ("Min Height (px)", 0, 7680, 1, 0), + "max-width": ("Max Width (px)", 0, 7680, 1, 0), + "max-height": ("Max Height (px)", 0, 7680, 1, 0), +} + +STR_ACTION_LABELS = { + "open-on-workspace": "Open on Workspace", + "open-on-output": "Open on Output", +} + +LAYER_BOOL_ACTION_LABELS = { + "place-within-backdrop": "Place Within Backdrop", + SCREENCAST_BLOCK_KEY: "Block from Screencast", +} + +FLOATING_POSITION_PRESETS = [ + ("Top", "top"), + ("Bottom", "bottom"), + ("Left", "left"), + ("Right", "right"), +] +CUSTOM_FLOATING_POSITION_LABEL = "Custom" +FLOATING_POSITION_LOCATION_LABELS = [ + label for label, _ in FLOATING_POSITION_PRESETS +] + [CUSTOM_FLOATING_POSITION_LABEL] +FLOATING_POSITION_CUSTOM_FIELD_LABELS = ["X Offset (px)", "Y Offset (px)"] +CUSTOM_FLOATING_POSITION_INDEX = len(FLOATING_POSITION_PRESETS) +DEFAULT_FLOATING_POSITION_RELATIVE_TO = "top" +CUSTOM_FLOATING_POSITION_RELATIVE_TO = "top-left" + + +class WindowSizeControlConfig(NamedTuple): + title: str + initial_percent: float + fixed: int + + +SIZE_PERCENT_PRESETS = [ + ("25%", 0.25), + ("33%", 0.33333), + ("50%", 0.5), + ("66%", 0.66667), + ("75%", 0.75), + ("100%", 1.0), +] +SIZE_MODE_LABELS = [label for label, _ in SIZE_PERCENT_PRESETS] + [ + "Custom %", + "Fixed (px)", +] +CUSTOM_SIZE_INDEX = len(SIZE_PERCENT_PRESETS) +FIXED_SIZE_INDEX = CUSTOM_SIZE_INDEX + 1 +WINDOW_SIZE_CONTROLS = { + "default-column-width": WindowSizeControlConfig( + title="Default Width", + initial_percent=50.0, + fixed=800, + ), + "default-window-height": WindowSizeControlConfig( + title="Default Height", + initial_percent=100.0, + fixed=600, + ), +} + + +def _bool_action_active(rule: KdlNode | None, key: str) -> bool: + if rule is None: + return False + if key != SCREENCAST_BLOCK_KEY: + return rule.get_child(key) is not None + + legacy = rule.get_child("block-out-from-screencast") + if legacy is not None: + return True + + node = rule.get_child(SCREENCAST_BLOCK_KEY) + return node is not None and bool(node.args) and node.args[0] == "screencast" + + +def _bool_action_node(key: str) -> KdlNode: + if key == SCREENCAST_BLOCK_KEY: + return KdlNode(SCREENCAST_BLOCK_KEY, args=["screencast"]) + return KdlNode(key, args=[True]) + + +def _floating_position_setting(rule: KdlNode | None) -> tuple[bool, int, int, str]: + if rule is None: + return (False, 0, 0, DEFAULT_FLOATING_POSITION_RELATIVE_TO) + + node = rule.get_child("default-floating-position") + if node is None: + return (False, 0, 0, DEFAULT_FLOATING_POSITION_RELATIVE_TO) + + x = int(node.props.get("x", 0)) + y = int(node.props.get("y", 0)) + relative_to = str( + node.props.get("relative-to", DEFAULT_FLOATING_POSITION_RELATIVE_TO) + ) + return (True, x, y, relative_to) + + +def _make_floating_position_node( + enabled: bool, x: int, y: int, relative_to: str +) -> KdlNode | None: + if not enabled: + return None + relative_to = relative_to.strip() + if not relative_to: + relative_to = DEFAULT_FLOATING_POSITION_RELATIVE_TO + return KdlNode( + "default-floating-position", + props={"x": int(x), "y": int(y), "relative-to": relative_to}, + ) + + +def _floating_position_location_index(x: int, y: int, relative_to: str) -> int: + if x != 0 or y != 0: + return CUSTOM_FLOATING_POSITION_INDEX + for index, (_, preset_relative_to) in enumerate(FLOATING_POSITION_PRESETS): + if relative_to == preset_relative_to: + return index + return CUSTOM_FLOATING_POSITION_INDEX + + +def _legacy_size_arg_setting(value) -> tuple[str, float | int | None]: + if isinstance(value, str): + text = value.strip().rstrip(";") + if not text: + return ("default", None) + if text.endswith("%"): + try: + return ("proportion", round(float(text[:-1]) / 100.0, 5)) + except ValueError: + return ("default", None) + + parts = text.split() + if len(parts) == 2 and parts[0] in {"proportion", "fixed"}: + try: + number = float(parts[1]) + except ValueError: + return ("default", None) + if parts[0] == "proportion": + return ("proportion", round(number, 5)) + return ("fixed", int(number)) + + try: + value = float(text) + except ValueError: + return ("default", None) + + if isinstance(value, bool) or value is None: + return ("default", None) + if isinstance(value, float) and 0 < value <= 1: + return ("proportion", round(value, 5)) + if isinstance(value, (int, float)) and value > 0: + return ("fixed", int(value)) + return ("default", None) + + +def _window_size_setting( + rule: KdlNode | None, key: str +) -> tuple[str, float | int | None]: + if rule is None: + return ("default", None) + + node = rule.get_child(key) + if node is None: + return ("default", None) + + proportion = node.get_child("proportion") + if proportion is not None and proportion.args: + return ("proportion", round(float(proportion.args[0]), 5)) + + fixed = node.get_child("fixed") + if fixed is not None and fixed.args: + return ("fixed", int(float(fixed.args[0]))) + + if node.args: + return _legacy_size_arg_setting(node.args[0]) + + return ("default", None) + + +def _make_size_node(key: str, kind: str, value: float | int | None) -> KdlNode | None: + if kind == "default" or value is None: + return None + if kind not in {"proportion", "fixed"}: + raise ValueError(f"Unsupported window size kind: {kind}") + + node = KdlNode(key) + if kind == "proportion": + node.children.append(KdlNode("proportion", args=[round(float(value), 5)])) + else: + node.children.append(KdlNode("fixed", args=[int(value)])) + return node + + +def _rule_summary(rule: KdlNode) -> tuple[str, str]: + """Return (title, subtitle) for a window-rule row.""" + matches = rule.get_children("match") + if not matches: + title = "Global Rule" + else: + parts = [] + for m in matches: + for k, v in m.props.items(): + parts.append(f"{k}: {v}") + for a in m.args: + parts.append(str(a)) + title = " • ".join(parts) if parts else "(any)" + + badges = [] + for c in rule.children: + if c.name == "match": + continue + if c.name == "opacity" and c.args: + badges.append(f"opacity {c.args[0]}") + elif c.name == "background-effect": + badges.append("blur") + elif c.name == "open-floating": + badges.append("floating") + elif c.name == "open-maximized": + badges.append("maximized") + elif c.name == "open-fullscreen": + badges.append("fullscreen") + elif c.name in ("clip-to-geometry", "geometry-corner-radius"): + pass # skip noisy ones + else: + badges.append(c.name.replace("-", " ")) + + subtitle = ", ".join(badges[:5]) if badges else "no actions" + return GLib.markup_escape_text(title), GLib.markup_escape_text(subtitle) + + +def _layer_rule_summary(rule: KdlNode) -> tuple[str, str]: + match_node = rule.get_child("match") + ns = str(match_node.props.get("namespace", "")) if match_node else "" + title = f"namespace: {ns}" if ns else "(any)" + actions = [c.name.replace("-", " ") for c in rule.children if c.name != "match"] + subtitle = ", ".join(actions) if actions else "no actions" + return GLib.markup_escape_text(title), GLib.markup_escape_text(subtitle) + + +# ── Page ───────────────────────────────────────────────────────────────────── + + +class WindowRulesPage(BasePage): + def build(self) -> Gtk.Widget: + tb, header, _, content = self._make_toolbar_page("Window Rules") + self._content = content + + add_win_btn = Gtk.Button(label="Add Window Rule") + add_win_btn.add_css_class("flat") + add_win_btn.set_tooltip_text("Add a new window rule") + add_win_btn.connect("clicked", self._on_add) + header.pack_end(add_win_btn) + + add_layer_btn = Gtk.Button(label="Add Layer Rule") + add_layer_btn.add_css_class("flat") + add_layer_btn.set_tooltip_text("Add a new layer-shell rule") + add_layer_btn.connect("clicked", self._on_add_layer) + header.pack_end(add_layer_btn) + + self._rules_grp = Adw.PreferencesGroup(title="Window Rules") + content.append(self._rules_grp) + + self._layer_rules_grp = Adw.PreferencesGroup( + title="Layer Rules", + description="Rules for layer-shell surfaces (bars, overlays, wallpapers…)", + ) + content.append(self._layer_rules_grp) + + self.refresh() + return tb + + def refresh(self): + self._rebuild() + self._rebuild_layer() + + # ── Window rules ───────────────────────────────────────────────────────── + + def _get_rules(self) -> list[KdlNode]: + return [n for n in self._nodes if n.name == "window-rule"] + + def _rebuild(self): + parent = self._rules_grp.get_parent() + if parent is None: + return + rules = self._get_rules() + new_grp = Adw.PreferencesGroup( + title="Window Rules", + description=f"{len(rules)} rule(s) — click a row to edit", + ) + for i, rule in enumerate(rules): + new_grp.add(self._make_rule_row(rule, i)) + parent.remove(self._rules_grp) + parent.append(new_grp) + self._rules_grp = new_grp + + def _make_rule_row(self, rule: KdlNode, idx: int) -> Adw.ActionRow: + title, subtitle = _rule_summary(rule) + row = Adw.ActionRow(title=title, subtitle=subtitle) + row.set_activatable(True) + row.set_subtitle_lines(1) + row.add_css_class("monospace") + + # visual badge for blur / opacity + has_blur = rule.get_child("background-effect") is not None + op_node = rule.get_child("opacity") + if has_blur: + lbl = Gtk.Label(label="blur") + lbl.add_css_class("tag") + lbl.add_css_class("accent") + lbl.set_valign(Gtk.Align.CENTER) + row.add_suffix(lbl) + if op_node and op_node.args: + lbl2 = Gtk.Label(label=f"α {op_node.args[0]}") + lbl2.add_css_class("tag") + lbl2.set_valign(Gtk.Align.CENTER) + row.add_suffix(lbl2) + + 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("Delete rule") + del_btn.connect("clicked", lambda *_, i=idx: self._on_delete(i)) + row.add_suffix(del_btn) + + row.connect("activated", lambda *_, i=idx: self._on_edit(i)) + return row + + def _on_add(self, *_): + self._show_rule_dialog(None, -1) + + def _on_edit(self, idx: int): + rules = self._get_rules() + if 0 <= idx < len(rules): + self._show_rule_dialog(rules[idx], idx) + + def _on_delete(self, idx: int): + rules = self._get_rules() + if not (0 <= idx < len(rules)): + return + removed = rules[idx] + self._nodes.remove(removed) + self._commit("remove window rule") + self._rebuild() + + # show a quick-undo toast + t = Adw.Toast(title="Window rule deleted", button_label="Undo", timeout=5) + t.connect("button-clicked", lambda *_: self._win._do_undo()) + self._win._toast_overlay.add_toast(t) + + def _add_floating_position_controls( + self, group: Adw.PreferencesGroup, rule: KdlNode | None + ) -> dict[str, Gtk.Widget | str]: + enabled, x, y, relative_to = _floating_position_setting(rule) + + enabled_row = Adw.SwitchRow( + title="Default Floating Position", + subtitle="Set the initial position for matching floating windows", + ) + enabled_row.set_active(enabled) + group.add(enabled_row) + + location_model = Gtk.StringList.new(FLOATING_POSITION_LOCATION_LABELS) + location_row = Adw.ComboRow(title="Location", model=location_model) + location_row.set_selected(_floating_position_location_index(x, y, relative_to)) + group.add(location_row) + + x_adj = Gtk.Adjustment( + value=x, + lower=-7680, + upper=7680, + step_increment=10, + page_increment=100, + ) + x_row = Adw.SpinRow( + title=FLOATING_POSITION_CUSTOM_FIELD_LABELS[0], + adjustment=x_adj, + digits=0, + ) + group.add(x_row) + + y_adj = Gtk.Adjustment( + value=y, + lower=-7680, + upper=7680, + step_increment=10, + page_increment=100, + ) + y_row = Adw.SpinRow( + title=FLOATING_POSITION_CUSTOM_FIELD_LABELS[1], + adjustment=y_adj, + digits=0, + ) + group.add(y_row) + + def _update_visibility(*_): + active = enabled_row.get_active() + custom = location_row.get_selected() == CUSTOM_FLOATING_POSITION_INDEX + location_row.set_visible(active) + x_row.set_visible(active and custom) + y_row.set_visible(active and custom) + + enabled_row.connect("notify::active", _update_visibility) + location_row.connect("notify::selected", _update_visibility) + _update_visibility() + + custom_relative_to = ( + relative_to + if location_row.get_selected() == CUSTOM_FLOATING_POSITION_INDEX + else CUSTOM_FLOATING_POSITION_RELATIVE_TO + ) + return { + "enabled": enabled_row, + "location": location_row, + "x": x_row, + "y": y_row, + "custom_relative_to": custom_relative_to, + } + + def _floating_position_node_from_controls( + self, controls: dict[str, Gtk.Widget | str] + ) -> KdlNode | None: + enabled_row = controls["enabled"] + enabled = ( + enabled_row.get_active() + if isinstance(enabled_row, Adw.SwitchRow) + else False + ) + location_row = controls["location"] + selected = ( + location_row.get_selected() + if isinstance(location_row, Adw.ComboRow) + else CUSTOM_FLOATING_POSITION_INDEX + ) + if selected < CUSTOM_FLOATING_POSITION_INDEX: + _, relative_to = FLOATING_POSITION_PRESETS[selected] + return _make_floating_position_node(enabled, 0, 0, relative_to) + else: + custom_relative_to = controls.get("custom_relative_to") + relative_to = ( + custom_relative_to + if isinstance(custom_relative_to, str) + else CUSTOM_FLOATING_POSITION_RELATIVE_TO + ) + x_row = controls["x"] + y_row = controls["y"] + x = int(x_row.get_value()) if isinstance(x_row, Adw.SpinRow) else 0 + y = int(y_row.get_value()) if isinstance(y_row, Adw.SpinRow) else 0 + return _make_floating_position_node(enabled, x, y, relative_to) + + def _size_mode_index(self, kind: str, value: float | int | None) -> int: + if kind == "fixed": + return FIXED_SIZE_INDEX + if kind == "proportion" and value is not None: + for i, (_, preset) in enumerate(SIZE_PERCENT_PRESETS): + if abs(float(value) - preset) < 0.00001: + return i + return CUSTOM_SIZE_INDEX + return CUSTOM_SIZE_INDEX + + def _add_size_controls( + self, group: Adw.PreferencesGroup, rule: KdlNode | None, key: str + ) -> dict[str, Gtk.Widget]: + cfg = WINDOW_SIZE_CONTROLS[key] + kind, value = _window_size_setting(rule, key) + title = cfg.title + + override_row = Adw.SwitchRow( + title=f"Override {title}", + subtitle="Off writes no explicit size rule", + ) + override_row.set_active(kind != "default") + group.add(override_row) + + mode_model = Gtk.StringList.new(SIZE_MODE_LABELS) + mode_row = Adw.ComboRow(title=title, model=mode_model) + mode_row.set_selected(self._size_mode_index(kind, value)) + group.add(mode_row) + + custom_value = cfg.initial_percent + if kind == "proportion" and value is not None: + custom_value = round(float(value) * 100.0, 2) + custom_adj = Gtk.Adjustment( + value=custom_value, + lower=1.0, + upper=100.0, + step_increment=1.0, + page_increment=5.0, + ) + custom_row = Adw.SpinRow( + title=f"Custom {title} (%)", adjustment=custom_adj, digits=2 + ) + group.add(custom_row) + + fixed_value = cfg.fixed + if kind == "fixed" and value is not None: + fixed_value = int(value) + fixed_adj = Gtk.Adjustment( + value=fixed_value, + lower=1, + upper=7680, + step_increment=10, + page_increment=100, + ) + fixed_row = Adw.SpinRow( + title=f"Fixed {title} (px)", adjustment=fixed_adj, digits=0 + ) + group.add(fixed_row) + + def _update_visibility(*_): + enabled = override_row.get_active() + selected = mode_row.get_selected() + mode_row.set_visible(enabled) + custom_row.set_visible(enabled and selected == CUSTOM_SIZE_INDEX) + fixed_row.set_visible(enabled and selected == FIXED_SIZE_INDEX) + + override_row.connect("notify::active", _update_visibility) + mode_row.connect("notify::selected", _update_visibility) + _update_visibility() + + return { + "override": override_row, + "mode": mode_row, + "custom": custom_row, + "fixed": fixed_row, + } + + def _size_node_from_controls( + self, key: str, controls: dict[str, Gtk.Widget] + ) -> KdlNode | None: + override_row = controls["override"] + if isinstance(override_row, Adw.SwitchRow) and not override_row.get_active(): + return None + + mode_row = controls["mode"] + selected = mode_row.get_selected() if isinstance(mode_row, Adw.ComboRow) else 0 + if selected == FIXED_SIZE_INDEX: + fixed_row = controls["fixed"] + value = fixed_row.get_value() if isinstance(fixed_row, Adw.SpinRow) else 0 + return _make_size_node(key, "fixed", int(value)) + if selected == CUSTOM_SIZE_INDEX: + custom_row = controls["custom"] + value = ( + custom_row.get_value() / 100.0 + if isinstance(custom_row, Adw.SpinRow) + else 0 + ) + return _make_size_node(key, "proportion", value) + + _, value = SIZE_PERCENT_PRESETS[selected] + return _make_size_node(key, "proportion", value) + + def _show_rule_dialog(self, rule: KdlNode | None, rule_idx: int): + dialog = Adw.Dialog(title="Window Rule") + dialog.set_content_width(520) + dialog.set_content_height(680) + + toolbar_view = Adw.ToolbarView() + hdr = Adw.HeaderBar() + title_lbl = "Edit Window Rule" if rule else "New Window Rule" + hdr.set_title_widget(Adw.WindowTitle(title=title_lbl)) + toolbar_view.add_top_bar(hdr) + + scroll = Gtk.ScrolledWindow() + scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + scroll.set_vexpand(True) + + prefs = Adw.PreferencesPage() + + # ── Match criteria ──────────────────────────────────────────────── + match_grp = Adw.PreferencesGroup( + title="Match Criteria", + description="Leave fields empty to match any window", + ) + match_node = rule.get_child("match") if rule else None + + app_id_row = Adw.EntryRow(title="App ID (regex, e.g. ^kitty$)") + app_id_row.set_text( + str(match_node.props.get("app-id", "")) if match_node else "" + ) + match_grp.add(app_id_row) + + title_row = Adw.EntryRow(title="Window Title (regex)") + title_row.set_text(str(match_node.props.get("title", "")) if match_node else "") + match_grp.add(title_row) + + bool_match_rows: dict[str, Adw.SwitchRow] = {} + for key, label in BOOL_MATCH_LABELS.items(): + sr = Adw.SwitchRow(title=label) + val = match_node.props.get(key, False) if match_node else False + sr.set_active(bool(val)) + match_grp.add(sr) + bool_match_rows[key] = sr + + prefs.add(match_grp) + + # ── Visibility & layout ─────────────────────────────────────────── + layout_grp = Adw.PreferencesGroup( + title="Layout & Visibility", + description="Window-size overrides apply when a matching window opens.", + ) + + size_controls = { + key: self._add_size_controls(layout_grp, rule, key) + for key in WINDOW_SIZE_CONTROLS + } + + bool_rows: dict[str, Adw.SwitchRow] = {} + for key, label in BOOL_ACTION_LABELS.items(): + sr = Adw.SwitchRow(title=label) + sr.set_active(_bool_action_active(rule, key)) + layout_grp.add(sr) + bool_rows[key] = sr + + floating_position_controls = self._add_floating_position_controls( + layout_grp, rule + ) + + prefs.add(layout_grp) + + # ── Visual effects ──────────────────────────────────────────────── + fx_grp = Adw.PreferencesGroup(title="Visual Effects") + + op_val = 0.0 + if rule: + op_node = rule.get_child("opacity") + if op_node and op_node.args: + op_val = float(op_node.args[0]) + op_adj = Gtk.Adjustment(value=op_val, lower=0.0, upper=1.0, step_increment=0.05) + op_row = Adw.SpinRow( + title="Opacity (0 = unset, 1 = fully opaque)", adjustment=op_adj, digits=2 + ) + fx_grp.add(op_row) + + blur_row = Adw.SwitchRow( + title="Background Blur", + subtitle="Adds background-effect { blur true }", + ) + has_blur = False + if rule: + be = rule.get_child("background-effect") + if be is not None: + blur_child = be.get_child("blur") + has_blur = blur_child is not None and ( + not blur_child.args or blur_child.args[0] is True + ) + blur_row.set_active(has_blur) + fx_grp.add(blur_row) + + prefs.add(fx_grp) + + # ── Numeric dimensions ──────────────────────────────────────────── + dim_grp = Adw.PreferencesGroup(title="Dimensions (0 = unset)") + num_rows: dict[str, Adw.SpinRow] = {} + for key, (label, lo, hi, step, digits) in NUM_ACTION_LABELS.items(): + if key == "opacity": + continue # handled above + cur = 0 + if rule: + cn = rule.get_child(key) + cur = cn.args[0] if cn and cn.args else 0 + adj = Gtk.Adjustment( + value=float(cur), lower=lo, upper=hi, step_increment=step + ) + sr = Adw.SpinRow(title=label, adjustment=adj, digits=digits) + dim_grp.add(sr) + num_rows[key] = sr + + prefs.add(dim_grp) + + # ── Workspace / output ──────────────────────────────────────────── + place_grp = Adw.PreferencesGroup(title="Placement") + str_rows: dict[str, Adw.EntryRow] = {} + for key, label in STR_ACTION_LABELS.items(): + e = Adw.EntryRow(title=label) + if rule: + cn = rule.get_child(key) + e.set_text(str(cn.args[0]) if cn and cn.args else "") + place_grp.add(e) + str_rows[key] = e + + prefs.add(place_grp) + + scroll.set_child(prefs) + toolbar_view.set_content(scroll) + + # ── Save button ─────────────────────────────────────────────────── + btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + btn_box.set_halign(Gtk.Align.END) + btn_box.set_margin_start(16) + btn_box.set_margin_end(16) + btn_box.set_margin_top(8) + btn_box.set_margin_bottom(16) + + cancel_btn = Gtk.Button(label="Cancel") + cancel_btn.add_css_class("pill") + cancel_btn.connect("clicked", lambda *_: dialog.close()) + btn_box.append(cancel_btn) + + save_btn = Gtk.Button(label="Save Rule") + save_btn.add_css_class("suggested-action") + save_btn.add_css_class("pill") + btn_box.append(save_btn) + + toolbar_view.add_bottom_bar(btn_box) + + def _save(*_): + new_rule = KdlNode("window-rule") + new_rule.leading_trivia = "\n" + + # match node + m = KdlNode("match") + has_match = False + app_id_text = app_id_row.get_text().strip() + if app_id_text: + m.props["app-id"] = KdlRawString(app_id_text) + has_match = True + title_text = title_row.get_text().strip() + if title_text: + m.props["title"] = KdlRawString(title_text) + has_match = True + for key, sr in bool_match_rows.items(): + if sr.get_active(): + m.props[key] = True + has_match = True + if has_match: + new_rule.children.append(m) + + # per-rule window sizing + for key, controls in size_controls.items(): + cn = self._size_node_from_controls(key, controls) + if cn is not None: + new_rule.children.append(cn) + + # floating position + position_node = self._floating_position_node_from_controls( + floating_position_controls + ) + if position_node is not None: + new_rule.children.append(position_node) + + # bool actions + for key, sr in bool_rows.items(): + if sr.get_active(): + new_rule.children.append(_bool_action_node(key)) + + # opacity + op = op_row.get_value() + if op > 0.0: + cn = KdlNode("opacity") + cn.args = [round(op, 2)] + new_rule.children.append(cn) + + # blur + if blur_row.get_active(): + be = KdlNode("background-effect") + be.children.append(KdlNode("blur", args=[True])) + new_rule.children.append(be) + + # dimensions + for key, sr in num_rows.items(): + v = sr.get_value() + if v > 0: + cn = KdlNode(key) + cn.args = [int(v)] + new_rule.children.append(cn) + + # placement strings + for key, e in str_rows.items(): + v = e.get_text().strip() + if v: + cn = KdlNode(key) + cn.args = [v] + new_rule.children.append(cn) + + rules = self._get_rules() + if rule_idx >= 0 and 0 <= rule_idx < len(rules): + i = self._nodes.index(rules[rule_idx]) + new_rule.source_file = rules[rule_idx].source_file + new_rule.leading_trivia = rules[rule_idx].leading_trivia + self._nodes[i] = new_rule + else: + if rules: + new_rule.source_file = rules[-1].source_file + self._nodes.append(new_rule) + + self._commit("window rule") + self._rebuild() + dialog.close() + + save_btn.connect("clicked", _save) + dialog.set_child(toolbar_view) + dialog.present(self._win) + + # ── Layer rules ─────────────────────────────────────────────────────────── + + def _get_layer_rules(self) -> list[KdlNode]: + return [n for n in self._nodes if n.name == "layer-rule"] + + def _rebuild_layer(self): + parent = self._layer_rules_grp.get_parent() + if parent is None: + return + rules = self._get_layer_rules() + new_grp = Adw.PreferencesGroup( + title="Layer Rules", + description=f"{len(rules)} rule(s) — bars, overlays, wallpapers", + ) + for i, rule in enumerate(rules): + new_grp.add(self._make_layer_rule_row(rule, i)) + parent.remove(self._layer_rules_grp) + parent.append(new_grp) + self._layer_rules_grp = new_grp + + def _make_layer_rule_row(self, rule: KdlNode, idx: int) -> Adw.ActionRow: + title, subtitle = _layer_rule_summary(rule) + row = Adw.ActionRow(title=title, subtitle=subtitle) + row.set_activatable(True) + row.add_css_class("monospace") + + has_blur = rule.get_child("background-effect") is not None + if has_blur: + lbl = Gtk.Label(label="blur") + lbl.add_css_class("tag") + lbl.add_css_class("accent") + lbl.set_valign(Gtk.Align.CENTER) + row.add_suffix(lbl) + + 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("Delete layer rule") + del_btn.connect("clicked", lambda *_, i=idx: self._on_delete_layer(i)) + row.add_suffix(del_btn) + + row.connect("activated", lambda *_, i=idx: self._on_edit_layer(i)) + return row + + def _on_add_layer(self, *_): + self._show_layer_dialog(None, -1) + + def _on_edit_layer(self, idx: int): + rules = self._get_layer_rules() + if 0 <= idx < len(rules): + self._show_layer_dialog(rules[idx], idx) + + def _on_delete_layer(self, idx: int): + rules = self._get_layer_rules() + if not (0 <= idx < len(rules)): + return + self._nodes.remove(rules[idx]) + self._commit("remove layer rule") + self._rebuild_layer() + + t = Adw.Toast(title="Layer rule deleted", button_label="Undo", timeout=5) + t.connect("button-clicked", lambda *_: self._win._do_undo()) + self._win._toast_overlay.add_toast(t) + + def _show_layer_dialog(self, rule: KdlNode | None, idx: int): + dialog = Adw.Dialog(title="Layer Rule") + dialog.set_content_width(460) + + toolbar_view = Adw.ToolbarView() + hdr = Adw.HeaderBar() + hdr.set_title_widget( + Adw.WindowTitle(title="Edit Layer Rule" if rule else "New Layer Rule") + ) + toolbar_view.add_top_bar(hdr) + + prefs = Adw.PreferencesPage() + + match_grp = Adw.PreferencesGroup(title="Match") + match_node = rule.get_child("match") if rule else None + ns_entry = Adw.EntryRow(title="Namespace (regex, e.g. ^waybar$)") + ns_entry.set_text( + str(match_node.props.get("namespace", "")) if match_node else "" + ) + match_grp.add(ns_entry) + prefs.add(match_grp) + + act_grp = Adw.PreferencesGroup(title="Actions") + bool_rows: dict[str, Adw.SwitchRow] = {} + for key, label in LAYER_BOOL_ACTION_LABELS.items(): + sr = Adw.SwitchRow(title=label) + sr.set_active(_bool_action_active(rule, key)) + act_grp.add(sr) + bool_rows[key] = sr + + blur_row = Adw.SwitchRow(title="Background Blur") + has_blur = False + if rule: + be = rule.get_child("background-effect") + if be: + bc = be.get_child("blur") + has_blur = bc is not None and (not bc.args or bc.args[0] is True) + blur_row.set_active(has_blur) + act_grp.add(blur_row) + + op_adj = Gtk.Adjustment(value=1.0, lower=0.0, upper=1.0, step_increment=0.05) + if rule: + op_node = rule.get_child("opacity") + if op_node and op_node.args: + op_adj.set_value(float(op_node.args[0])) + op_row = Adw.SpinRow(title="Opacity (1 = unset)", adjustment=op_adj, digits=2) + act_grp.add(op_row) + + prefs.add(act_grp) + toolbar_view.set_content(prefs) + + btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + btn_box.set_halign(Gtk.Align.END) + btn_box.set_margin_start(16) + btn_box.set_margin_end(16) + btn_box.set_margin_top(8) + btn_box.set_margin_bottom(16) + + cancel_btn = Gtk.Button(label="Cancel") + cancel_btn.add_css_class("pill") + cancel_btn.connect("clicked", lambda *_: dialog.close()) + btn_box.append(cancel_btn) + + save_btn = Gtk.Button(label="Save Rule") + save_btn.add_css_class("suggested-action") + save_btn.add_css_class("pill") + btn_box.append(save_btn) + + toolbar_view.add_bottom_bar(btn_box) + + def _save(*_): + new_rule = KdlNode("layer-rule") + new_rule.leading_trivia = "\n" + ns = ns_entry.get_text().strip() + if ns: + m = KdlNode("match") + m.props["namespace"] = KdlRawString(ns) + new_rule.children.append(m) + for key, sr in bool_rows.items(): + if sr.get_active(): + new_rule.children.append(_bool_action_node(key)) + if blur_row.get_active(): + be = KdlNode("background-effect") + be.children.append(KdlNode("blur", args=[True])) + new_rule.children.append(be) + op = op_row.get_value() + if op < 1.0: + op_node = KdlNode("opacity") + op_node.args = [round(op, 2)] + new_rule.children.append(op_node) + + rules = self._get_layer_rules() + if idx >= 0 and 0 <= idx < len(rules): + i = self._nodes.index(rules[idx]) + new_rule.source_file = rules[idx].source_file + new_rule.leading_trivia = rules[idx].leading_trivia + self._nodes[i] = new_rule + else: + if rules: + new_rule.source_file = rules[-1].source_file + elif self._get_rules(): + new_rule.source_file = self._get_rules()[-1].source_file + self._nodes.append(new_rule) + self._commit("layer rule") + self._rebuild_layer() + dialog.close() + + save_btn.connect("clicked", _save) + dialog.set_child(toolbar_view) + dialog.present(self._win) diff --git a/nirimod/pages/workspaces.py b/nirimod/pages/workspaces.py new file mode 100644 index 0000000..0c2f713 --- /dev/null +++ b/nirimod/pages/workspaces.py @@ -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") diff --git a/nirimod/profiles.py b/nirimod/profiles.py new file mode 100644 index 0000000..d28d011 --- /dev/null +++ b/nirimod/profiles.py @@ -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 + diff --git a/nirimod/state.py b/nirimod/state.py new file mode 100644 index 0000000..f62484f --- /dev/null +++ b/nirimod/state.py @@ -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) diff --git a/nirimod/theme.py b/nirimod/theme.py new file mode 100644 index 0000000..f5af081 --- /dev/null +++ b/nirimod/theme.py @@ -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") diff --git a/nirimod/undo.py b/nirimod/undo.py new file mode 100644 index 0000000..9541c99 --- /dev/null +++ b/nirimod/undo.py @@ -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() diff --git a/nirimod/updater.py b/nirimod/updater.py new file mode 100644 index 0000000..edb1827 --- /dev/null +++ b/nirimod/updater.py @@ -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.") diff --git a/nirimod/widgets/__init__.py b/nirimod/widgets/__init__.py new file mode 100644 index 0000000..835d5e7 --- /dev/null +++ b/nirimod/widgets/__init__.py @@ -0,0 +1,5 @@ +"""NiriMod custom widgets.""" + +from nirimod.widgets.keyboard_visualizer import KeyboardVisualizer, normalize_key_id + +__all__ = ["KeyboardVisualizer", "normalize_key_id"] diff --git a/nirimod/widgets/keyboard_visualizer.py b/nirimod/widgets/keyboard_visualizer.py new file mode 100644 index 0000000..a67a78a --- /dev/null +++ b/nirimod/widgets/keyboard_visualizer.py @@ -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) + diff --git a/nirimod/window.py b/nirimod/window.py new file mode 100644 index 0000000..4f91b4c --- /dev/null +++ b/nirimod/window.py @@ -0,0 +1,1135 @@ +"""Main application window — sidebar + content NavigationSplitView.""" + +from __future__ import annotations + +import hashlib +import shutil + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") + +from gi.repository import Adw, Gdk, Gio, GLib, Gtk, Pango + +from nirimod import kdl_parser +from nirimod import niri_ipc +from nirimod import profiles as prof_mod +from nirimod.state import AppState +from nirimod.theme import CSS + +# Grouped sidebar structure: (section_title, [(page_id, icon, label), ...]) +SIDEBAR_GROUPS = [ + ("Input", [ + ("input", "input-keyboard-symbolic", "Input"), + ("bindings", "preferences-desktop-keyboard-shortcuts-symbolic", "Key Bindings"), + ]), + ("Display", [ + ("outputs", "video-display-symbolic", "Outputs"), + ("appearance", "preferences-desktop-appearance-symbolic", "Appearance"), + ("animations", "applications-multimedia-symbolic", "Animations"), + ]), + ("Workspace", [ + ("layout", "view-grid-symbolic", "Layout"), + ("workspaces", "view-paged-symbolic", "Workspaces"), + ("window_rules", "preferences-system-symbolic", "Window Rules"), + ]), + ("System", [ + ("startup", "system-run-symbolic", "Startup"), + ("environment", "preferences-other-symbolic", "Environment"), + ("gestures", "input-touchpad-symbolic", "Gestures & Misc"), + ]), + ("Advanced", [ + ("raw_config", "text-x-generic-symbolic", "Raw Config"), + ]), +] + +# Flat list for backward compat (select_page, search index, etc.) +SIDEBAR_PAGES = [entry for _, group in SIDEBAR_GROUPS for entry in group] + + +class NiriModWindow(Adw.ApplicationWindow): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.set_title("NiriMod") + self.set_default_size(1060, 720) + + self.app_state = AppState() + self.app_state.load() + + self._current_page_id = "" + self._pages: dict[str, Gtk.Widget] = {} + self._sidebar_rows: dict[str, Gtk.ListBoxRow] = {} + self._sidebar_listboxes: dict[str, Gtk.ListBox] = {} + + + self._load_css() + self._build_ui() + self._check_onboarding() + self._check_for_updates() + self._check_kofi() + + def _load_css(self): + provider = Gtk.CssProvider() + provider.load_from_data(CSS) + Gtk.StyleContext.add_provider_for_display( + Gdk.Display.get_default(), + provider, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, + ) + + def _build_ui(self): + self._toast_overlay = Adw.ToastOverlay() + self.set_content(self._toast_overlay) + + root_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self._toast_overlay.set_child(root_box) + + self._niri_banner = Gtk.Label( + label="⚠ niri is not running — changes will be saved but not applied live", + xalign=0, + ) + self._niri_banner.add_css_class("nm-niri-banner") + self._niri_banner.set_visible(not self.app_state.niri_running) + root_box.append(self._niri_banner) + + self._split_view = Adw.NavigationSplitView() + self._split_view.set_vexpand(True) + root_box.append(self._split_view) + + self._split_view.set_sidebar(self._build_sidebar_nav()) + self._split_view.set_content(self._build_content_nav()) + + self._setup_shortcuts() + + # Navigate to first page + if SIDEBAR_PAGES: + self._select_page(SIDEBAR_PAGES[0][0]) + + def _build_sidebar_nav(self) -> Adw.NavigationPage: + nav = Adw.NavigationPage(title="NiriMod") + + sidebar_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + sidebar_box.add_css_class("nm-sidebar-bg") + + # Header with app title and a menu button for profiles + header = Adw.HeaderBar() + title_widget = Adw.WindowTitle(title="NiriMod") + header.set_title_widget(title_widget) + + sidebar_box.append(header) + + # Search bar + self._search_entry = Gtk.SearchEntry() + self._search_entry.set_placeholder_text("Search settings\u2026") + self._search_entry.add_css_class("nm-search-entry") + self._search_entry.set_margin_start(10) + self._search_entry.set_margin_end(10) + self._search_entry.set_margin_top(10) + self._search_entry.set_margin_bottom(0) + self._search_entry.connect("search-changed", self._on_search_changed) + self._search_entry.connect("stop-search", self._on_stop_search) + # Enter key navigates to the highlighted result + self._search_entry.connect("activate", self._on_search_activate) + # Up/Down keys move the selection without stealing focus + key_ctrl = Gtk.EventControllerKey() + key_ctrl.connect("key-pressed", self._on_search_key_pressed) + self._search_entry.add_controller(key_ctrl) + sidebar_box.append(self._search_entry) + + + self._search_revealer = Gtk.Revealer() + self._search_revealer.set_transition_type(Gtk.RevealerTransitionType.SLIDE_DOWN) + self._search_revealer.set_transition_duration(120) + self._search_revealer.set_reveal_child(False) + self._search_revealer.set_margin_start(8) + self._search_revealer.set_margin_end(8) + self._search_revealer.set_margin_top(4) + self._search_revealer.set_margin_bottom(4) + results_scroll = Gtk.ScrolledWindow() + results_scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + results_scroll.set_max_content_height(300) + results_scroll.set_propagate_natural_height(True) + self._search_results_listbox = Gtk.ListBox() + self._search_results_listbox.set_selection_mode(Gtk.SelectionMode.SINGLE) + self._search_results_listbox.add_css_class("nm-search-results") + self._search_results_listbox.connect("row-activated", self._on_search_result_activated) + results_scroll.set_child(self._search_results_listbox) + self._search_revealer.set_child(results_scroll) + sidebar_box.append(self._search_revealer) + + scroll = Gtk.ScrolledWindow() + scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + scroll.set_vexpand(True) + + nav_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + nav_box.set_margin_top(8) + nav_box.set_margin_bottom(16) + + for section_title, pages in SIDEBAR_GROUPS: + # Section header label + section_lbl = Gtk.Label(label=section_title.upper()) + section_lbl.set_xalign(0.0) + section_lbl.set_margin_start(16) + section_lbl.set_margin_end(16) + section_lbl.set_margin_top(16) + section_lbl.set_margin_bottom(4) + section_lbl.add_css_class("nm-sidebar-section-label") + nav_box.append(section_lbl) + + # Page rows for this section + listbox = Gtk.ListBox() + listbox.set_selection_mode(Gtk.SelectionMode.SINGLE) + listbox.add_css_class("navigation-sidebar") + listbox.add_css_class("nm-sidebar-listbox") + listbox.set_margin_start(8) + listbox.set_margin_end(8) + listbox.connect("row-selected", self._on_row_selected) + + for page_id, icon, label in pages: + row = self._make_sidebar_row(page_id, icon, label) + listbox.append(row) + self._sidebar_rows[page_id] = row + self._sidebar_listboxes[page_id] = listbox + + nav_box.append(listbox) + + scroll.set_child(nav_box) + sidebar_box.append(scroll) + + nav.set_child(sidebar_box) + return nav + + def _make_sidebar_row(self, page_id: str, icon: str, label: str) -> Gtk.ListBoxRow: + row = Gtk.ListBoxRow() + row.page_id = page_id # type: ignore[attr-defined] + + box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + box.set_margin_start(6) + box.set_margin_end(6) + box.set_margin_top(4) + box.set_margin_bottom(4) + + icon_img = Gtk.Image(icon_name=icon) + icon_img.add_css_class("nm-sidebar-icon") + box.append(icon_img) + + text_lbl = Gtk.Label(label=label, xalign=0) + text_lbl.set_hexpand(True) + box.append(text_lbl) + + + + row.set_child(box) + return row + + def _build_content_nav(self) -> Adw.NavigationPage: + self._content_nav = Adw.NavigationPage(title="") + + content_root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + + self._stack = Gtk.Stack() + self._stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE) + self._stack.set_transition_duration(120) + self._stack.set_vexpand(True) + content_root.append(self._stack) + + + self._build_all_pages() + self._build_search_index() + + self._dirty_bar = self._build_dirty_bar() + content_root.append(self._dirty_bar) + + self._content_nav.set_child(content_root) + return self._content_nav + + def _build_dirty_bar(self) -> Gtk.Box: + bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + bar.add_css_class("nm-dirty-bar") + bar.set_visible(False) + bar.set_margin_start(12) + bar.set_margin_end(12) + bar.set_margin_top(6) + bar.set_margin_bottom(6) + + self._dirty_label = Gtk.Label(label="Unsaved changes") + self._dirty_label.set_hexpand(True) + self._dirty_label.set_xalign(0.0) + self._dirty_label.set_opacity(0.7) + bar.append(self._dirty_label) + + self._undo_btn = Gtk.Button(label="Undo") + self._undo_btn.add_css_class("flat") + self._undo_btn.set_tooltip_text("Undo last change (Ctrl+Z)") + self._undo_btn.connect("clicked", lambda *_: self._do_undo()) + bar.append(self._undo_btn) + + self._redo_btn = Gtk.Button(label="Redo") + self._redo_btn.add_css_class("flat") + self._redo_btn.set_tooltip_text("Redo (Ctrl+Shift+Z)") + self._redo_btn.set_sensitive(False) + self._redo_btn.connect("clicked", lambda *_: self._do_redo()) + bar.append(self._redo_btn) + + discard_btn = Gtk.Button(label="Discard") + discard_btn.add_css_class("destructive-action") + discard_btn.add_css_class("flat") + discard_btn.set_tooltip_text("Revert all unsaved changes") + discard_btn.connect("clicked", lambda *_: self._on_discard()) + bar.append(discard_btn) + + save_btn = Gtk.Button(label="Save & Apply") + save_btn.add_css_class("suggested-action") + save_btn.set_tooltip_text("Save to config.kdl and reload niri (Ctrl+S)") + save_btn.connect("clicked", lambda *_: self._on_save()) + bar.append(save_btn) + + return bar + + def _build_all_pages(self): + from nirimod.pages import ( + outputs, + input_page, + layout, + appearance, + animations, + bindings, + window_rules, + startup, + workspaces, + environment, + gestures, + raw_config, + ) + + page_builders = { + "outputs": outputs.OutputsPage, + "input": input_page.InputPage, + "layout": layout.LayoutPage, + "appearance": appearance.AppearancePage, + "animations": animations.AnimationsPage, + "bindings": bindings.BindingsPage, + "window_rules": window_rules.WindowRulesPage, + "startup": startup.StartupPage, + "workspaces": workspaces.WorkspacesPage, + "environment": environment.EnvironmentPage, + "gestures": gestures.GesturesPage, + "raw_config": raw_config.RawConfigPage, + } + for page_id, _, title in SIDEBAR_PAGES: + cls = page_builders.get(page_id) + if cls: + page_obj = cls(window=self) + widget = page_obj.build() + self._pages[page_id] = page_obj + self._stack.add_named(widget, page_id) + + def _on_row_selected(self, _lb, row): + if row is None: + return + pid = getattr(row, "page_id", None) + if pid: + + for other_pid, lb in self._sidebar_listboxes.items(): + if lb is not _lb: + lb.unselect_all() + self._select_page(pid) + + def _select_page(self, page_id: str): + self._current_page_id = page_id + self._stack.set_visible_child_name(page_id) + for pid, _, title in SIDEBAR_PAGES: + if pid == page_id: + self._content_nav.set_title(title) + break + # Select the right sidebar row, deselect others + for pid, lb in self._sidebar_listboxes.items(): + row = self._sidebar_rows.get(pid) + if row: + if pid == page_id: + lb.select_row(row) + + + # Notify page of visibility + page = self._pages.get(page_id) + if page and hasattr(page, "on_shown"): + page.on_shown() + + def _build_search_index(self): + self._search_index: list[dict] = [] + + def traverse(widget, pid, p_title): + + if isinstance(widget, Adw.PreferencesRow): + title = widget.get_title() + if title: + subtitle = widget.get_subtitle() if hasattr(widget, "get_subtitle") else "" + self._search_index.append({ + "page_id": pid, + "page_title": p_title, + "title": title, + "subtitle": subtitle, + "widget": widget, + }) + + + if isinstance(widget, Adw.PreferencesGroup): + title = widget.get_title() + if title: + self._search_index.append({ + "page_id": pid, + "page_title": p_title, + "title": title, + "subtitle": "(Group)", + "widget": widget, + }) + + # Recurse into all children to find nested elements + child = widget.get_first_child() + while child: + traverse(child, pid, p_title) + child = child.get_next_sibling() + + for pid, _icon, p_title in SIDEBAR_PAGES: + stack_child = self._stack.get_child_by_name(pid) + if stack_child: + traverse(stack_child, pid, p_title) + + def _on_search_changed(self, entry): + query = entry.get_text().strip().lower() + if not query or len(query) < 2: + self._search_revealer.set_reveal_child(False) + return + + matches = [ + r for r in self._search_index + if query in r["title"].lower() + or query in r["subtitle"].lower() + or query in r["page_title"].lower() + ] + + child = self._search_results_listbox.get_first_child() + while child: + self._search_results_listbox.remove(child) + child = self._search_results_listbox.get_first_child() + + if matches: + for m in matches: + row = Gtk.ListBoxRow() + row.search_match = m + row.set_focusable(False) + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=1) + box.set_margin_start(8) + box.set_margin_end(8) + box.set_margin_top(6) + box.set_margin_bottom(6) + title_lbl = Gtk.Label(label=m["title"], xalign=0) + title_lbl.add_css_class("heading") + title_lbl.set_focusable(False) + title_lbl.set_ellipsize(Pango.EllipsizeMode.END) + box.append(title_lbl) + sub_text = m["page_title"] + if m["subtitle"]: + sub_text += f" \u2022 {m['subtitle']}" + sub_lbl = Gtk.Label(label=sub_text, xalign=0) + sub_lbl.add_css_class("dim-label") + sub_lbl.set_ellipsize(Pango.EllipsizeMode.END) + sub_lbl.set_focusable(False) + box.append(sub_lbl) + row.set_child(box) + self._search_results_listbox.append(row) + first = self._search_results_listbox.get_row_at_index(0) + if first: + self._search_results_listbox.select_row(first) + self._search_revealer.set_reveal_child(True) + else: + self._search_revealer.set_reveal_child(False) + + def _on_search_key_pressed(self, controller, keyval, keycode, state): + if not self._search_revealer.get_reveal_child(): + return False + lb = self._search_results_listbox + sel = lb.get_selected_row() + if keyval == Gdk.KEY_Down: + idx = (sel.get_index() + 1) if sel else 0 + nxt = lb.get_row_at_index(idx) + if nxt: + lb.select_row(nxt) + return True + if keyval == Gdk.KEY_Up: + if sel and sel.get_index() > 0: + lb.select_row(lb.get_row_at_index(sel.get_index() - 1)) + return True + return False + + def _on_search_activate(self, entry): + if not self._search_revealer.get_reveal_child(): + return + sel = self._search_results_listbox.get_selected_row() + if sel: + self._on_search_result_activated(self._search_results_listbox, sel) + + def _on_stop_search(self, entry): + entry.set_text("") + self._search_revealer.set_reveal_child(False) + + def _on_search_result_activated(self, listbox, row): + if not hasattr(row, "search_match"): + return + + m = row.search_match + self._search_revealer.set_reveal_child(False) + self._search_entry.set_text("") + + # Navigate to the page + self._select_page(m["page_id"]) + + # Highlight the widget + widget = m["widget"] + widget.add_css_class("nm-pulse-highlight") + + def remove_class(): + widget.remove_css_class("nm-pulse-highlight") + return False + + GLib.timeout_add(1500, remove_class) + + # Shortcuts + + def _setup_shortcuts(self): + app = self.get_application() + if not app: + return + shortcuts = [ + ("save", self._on_save, ["s"]), + ("undo", self._do_undo, ["z"]), + ("redo", self._do_redo, ["z"]), + ("search", lambda: self._search_entry.grab_focus(), ["f"]), + ] + for name, fn, accels in shortcuts: + a = Gio.SimpleAction.new(name, None) + a.connect("activate", lambda _a, _p, f=fn: f()) + self.add_action(a) + app.set_accels_for_action(f"win.{name}", accels) + + # Menu actions + open_profiles_action = Gio.SimpleAction.new("open_profiles", None) + open_profiles_action.connect("activate", lambda *_: self._on_profiles_clicked()) + self.add_action(open_profiles_action) + + open_prefs_action = Gio.SimpleAction.new("open_preferences", None) + open_prefs_action.connect("activate", lambda *_: self._open_preferences()) + self.add_action(open_prefs_action) + + reset_config_action = Gio.SimpleAction.new("reset_config", None) + reset_config_action.connect("activate", lambda *_: self._on_reset_config_clicked()) + self.add_action(reset_config_action) + + open_kofi_action = Gio.SimpleAction.new("open_kofi", None) + open_kofi_action.connect("activate", lambda *_: self._show_kofi_dialog()) + self.add_action(open_kofi_action) + + def get_nodes(self): + return self.app_state.nodes + + def mark_dirty(self): + self.app_state.mark_dirty() + self._dirty_bar.set_visible(True) + self._undo_btn.set_sensitive(self.app_state.undo.can_undo()) + self._redo_btn.set_sensitive(self.app_state.undo.can_redo()) + desc = self.app_state.undo.last_description + self._dirty_label.set_label(f"Unsaved: {desc}" if desc else "Unsaved changes") + self._build_search_index() + + def mark_clean(self): + self.app_state.mark_clean() + self._dirty_bar.set_visible(False) + self._dirty_label.set_label("Unsaved changes") + self._redo_btn.set_sensitive(False) + + def push_undo(self, description: str, before: str, after: str): + self.app_state.push_undo(description, before, after) + self._undo_btn.set_sensitive(True) + + def notify_nodes_changed(self): + self.app_state.reload_from_disk() + page = self._pages.get(self._current_page_id) + if page and hasattr(page, "refresh"): + page.refresh() + self._build_search_index() + + def _on_save(self): + 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.app_state.source_files, limit=limit) + + new_kdl = self.app_state.write_current_kdl() + + def _finish_save(reload_result): + reload_ok, reload_msg = reload_result + self.app_state.commit_save(new_kdl) + raw = self._pages.get("raw_config") + if raw and hasattr(raw, "refresh"): + raw.refresh() + self._build_search_index() + self.mark_clean() + if reload_ok: + self.show_toast("Config saved and applied ✓", timeout=3) + else: + self.show_toast( + f"Config saved, but reload failed: {reload_msg}", timeout=8 + ) + + if self.app_state.is_multi_file: + # Snapshot all source files before touching them + snapshots = { + p: p.read_text() for p in self.app_state.source_files if p.exists() + } + self.app_state.write_to_path() + + def _on_validated(result): + ok, msg = result + if not ok: + # Restore all files from snapshots + for p, text in snapshots.items(): + p.write_text(text) + self.show_toast(f"Validation error: {msg}", timeout=8) + return + niri_ipc.run_in_thread(niri_ipc.load_config_file, _finish_save) + + niri_ipc.run_in_thread( + lambda: niri_ipc.validate_config(), _on_validated + ) + else: + tmp_kdl = kdl_parser.NIRI_CONFIG.with_name(".config.kdl.tmp") + self.app_state.write_to_path(tmp_kdl) + + def _on_validated(result): + ok, msg = result + if not ok: + self.show_toast(f"Validation error: {msg}", timeout=8) + tmp_kdl.unlink(missing_ok=True) + return + shutil.move(tmp_kdl, kdl_parser.NIRI_CONFIG) + niri_ipc.run_in_thread(niri_ipc.load_config_file, _finish_save) + + niri_ipc.run_in_thread( + lambda: niri_ipc.validate_config(str(tmp_kdl)), _on_validated + ) + + def _on_discard(self): + self.app_state.discard() + self.mark_clean() + self.notify_nodes_changed() + + def _raw_config_textview_focused(self) -> bool: + """Return True if the raw-config text editor currently has keyboard focus.""" + raw_page = self._pages.get("raw_config") + if raw_page is None: + return False + tv = getattr(raw_page, "_textview", None) + if tv is None: + return False + return tv.has_focus() + + def _do_undo(self): + if self._raw_config_textview_focused(): + raw_page = self._pages.get("raw_config") + buf = raw_page._textview.get_buffer() # type: ignore[union-attr] + if buf.get_can_undo(): + buf.undo() + return + + entry = self.app_state.apply_undo() + if entry is None: + return + + if not self.app_state.undo.can_undo(): + self._undo_btn.set_sensitive(False) + + if self.app_state.is_dirty: + self.mark_dirty() + else: + self.mark_clean() + + self.notify_nodes_changed() + + def _do_redo(self): + if self._raw_config_textview_focused(): + raw_page = self._pages.get("raw_config") + buf = raw_page._textview.get_buffer() + if buf.get_can_redo(): + buf.redo() + return + + entry = self.app_state.apply_redo() + if entry is None: + return + + self._redo_btn.set_sensitive(self.app_state.undo.can_redo()) + self._undo_btn.set_sensitive(True) + self.mark_dirty() + self.notify_nodes_changed() + + def show_toast(self, message: str, timeout: int = 3, copy_text: str | None = None): + toast = Adw.Toast(title=message, timeout=timeout) + if copy_text is not None: + toast.set_button_label("Copy") + toast.connect("button-clicked", lambda *_: self.get_clipboard().set(copy_text)) + elif "error" in message.lower() or "failed" in message.lower(): + toast.set_button_label("Copy") + toast.connect("button-clicked", lambda *_: self.get_clipboard().set(message)) + + self._toast_overlay.add_toast(toast) + + def _get_baseline_dir(self): + from pathlib import Path + path_str = str(kdl_parser.NIRI_CONFIG.resolve()) + path_hash = hashlib.md5(path_str.encode()).hexdigest()[:8] + return Path.home() / ".config" / "nirimod" / "baseline" / f"{kdl_parser.NIRI_CONFIG.name}_{path_hash}" + + def _check_onboarding(self): + baseline_dir = self._get_baseline_dir() + sentinel = baseline_dir / kdl_parser.NIRI_CONFIG.name + if sentinel.exists(): + return + + source_files = sorted(self.app_state.source_files) + filenames = "\n".join(f" • {p.name}" for p in source_files) + body = ( + f"NiriMod will back up your original config files to\n" + f"{baseline_dir}:\n\n" + f"{filenames}\n" + ) + + dialog = Adw.AlertDialog(heading="Welcome to NiriMod", body=body) + dialog.set_body_use_markup(True) + dialog.add_response("cancel", "Not Now") + dialog.add_response("accept", "Create Backup") + dialog.set_response_appearance("accept", Adw.ResponseAppearance.SUGGESTED) + dialog.set_default_response("accept") + dialog.connect("response", self._on_onboarding_response) + dialog.present(self) + + def _check_kofi(self): + from nirimod import app_settings + + if app_settings.get("kofi_v3_dont_show", False): + return + self._show_kofi_dialog() + + def _show_kofi_dialog(self): + from nirimod import app_settings + + dialog = Adw.AlertDialog( + heading="Enjoying NiriMod? ☕", + body=( + "NiriMod is a passion project built entirely in my free time to make customizing Niri easier for everyone.\n\n" + "If it has improved your workflow, please consider supporting its development with a small tip on Ko-fi! " + "Your support directly fuels new features and keeps the project alive." + ), + ) + dialog.add_response("dismiss", "Maybe Later") + dialog.add_response("kofi", "Support on Ko-fi") + dialog.set_response_appearance("kofi", Adw.ResponseAppearance.SUGGESTED) + dialog.set_default_response("kofi") + + dont_show_check = Gtk.CheckButton(label="Don't show this again on startup") + dont_show_check.set_active(app_settings.get("kofi_v3_dont_show", False)) + dont_show_check.set_halign(Gtk.Align.CENTER) + dont_show_check.set_margin_top(4) + dialog.set_extra_child(dont_show_check) + + def _on_kofi_response(dlg, response): + app_settings.set("kofi_v3_dont_show", dont_show_check.get_active()) + if response == "kofi": + Gio.AppInfo.launch_default_for_uri("https://ko-fi.com/srinivasr", None) + + dialog.connect("response", _on_kofi_response) + dialog.present(self) + + def _check_for_updates(self): + from nirimod import app_settings, updater + if app_settings.get("auto_update", True): + updater.check_for_updates(self._on_update_check_result) + + def _on_update_check_result(self, remote_sha: str | None, commit_msg: str | None): + if remote_sha is None: + return + + dialog = Adw.AlertDialog( + heading="Update Available", + body=f"A new version of NiriMod is available on GitHub!\n\nLatest Commit:\n{GLib.markup_escape_text(commit_msg or '')}", + ) + dialog.set_body_use_markup(True) + dialog.add_response("cancel", "Later") + dialog.add_response("update", "Update in Terminal") + dialog.set_response_appearance("update", Adw.ResponseAppearance.SUGGESTED) + + def _on_response(dlg, response): + if response == "update": + from nirimod import updater + updater.launch_updater_in_terminal() + app = self.get_application() + if app: + app.quit() + dialog.connect("response", _on_response) + dialog.present(self) + + def _on_onboarding_response(self, dialog, response): + if response != "accept": + return + baseline_dir = self._get_baseline_dir() + try: + baseline_dir.mkdir(parents=True, exist_ok=True) + for p in self.app_state.source_files: + if p.exists(): + try: + rel = p.relative_to(kdl_parser.NIRI_CONFIG.parent) + dest = baseline_dir / rel + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(p, dest) + except ValueError: + shutil.copy2(p, baseline_dir / p.name) + self.show_toast("Baseline backup created ✓") + except Exception as e: + self.show_toast(f"Backup failed: {e}", timeout=6) + + def _on_reset_config_clicked(self, _btn=None): + baseline_dir = self._get_baseline_dir() + + backups = [] + if kdl_parser.BACKUP_DIR.exists(): + for p in kdl_parser.BACKUP_DIR.iterdir(): + if p.is_dir(): + backups.append((p.stat().st_mtime, p, p.name)) + + backups.sort(key=lambda x: x[0], reverse=True) + + if baseline_dir.exists(): + backups.append((baseline_dir.stat().st_mtime, baseline_dir, "Original Baseline")) + + if not backups: + self.show_toast("No backups available to restore.") + return + + prefs_win = Adw.PreferencesWindow() + prefs_win.set_title("Restore Backup") + prefs_win.set_modal(True) + prefs_win.set_transient_for(self) + prefs_win.set_default_size(500, 400) + + page = Adw.PreferencesPage() + grp = Adw.PreferencesGroup( + title="Available Backups", + description="Select a backup to restore your configuration from." + ) + + for _, path, name in backups: + row = Adw.ActionRow(title=name) + if name == "Original Baseline": + row.set_subtitle("Taken on first launch") + + restore_btn = Gtk.Button(label="Restore") + restore_btn.set_valign(Gtk.Align.CENTER) + restore_btn.add_css_class("flat") + restore_btn.add_css_class("suggested-action") + restore_btn.connect("clicked", lambda _b, p=path: self._confirm_restore(p, prefs_win)) + row.add_suffix(restore_btn) + grp.add(row) + + page.add(grp) + prefs_win.add(page) + prefs_win.present() + + def _confirm_restore(self, backup_dir, parent_dialog): + parent_dialog.close() + dialog = Adw.AlertDialog( + heading="Confirm Restore", + body="Your current configuration will be replaced by this backup. You may want to manually save your current work first." + ) + dialog.add_response("cancel", "Cancel") + dialog.add_response("restore", "Restore") + dialog.set_response_appearance("restore", Adw.ResponseAppearance.DESTRUCTIVE) + dialog.connect("response", lambda dlg, r: self._perform_restore(backup_dir) if r == "restore" else None) + dialog.present(self) + + def _perform_restore(self, backup_dir): + try: + shutil.copytree(backup_dir, kdl_parser.NIRI_CONFIG.parent, dirs_exist_ok=True) + self.app_state.reload_from_disk() + self.notify_nodes_changed() + self.mark_clean() + self.show_toast("Config restored from backup ✓") + except Exception as e: + self.show_toast(f"Restore failed: {e}", timeout=6) + + def _open_preferences(self): + from nirimod import app_settings + + prefs_win = Adw.PreferencesWindow() + prefs_win.set_title("NiriMod Preferences") + prefs_win.set_modal(True) + prefs_win.set_transient_for(self) + prefs_win.set_default_size(500, 400) + + page = Adw.PreferencesPage( + title="General", icon_name="emblem-system-symbolic" + ) + + updates_grp = Adw.PreferencesGroup( + title="Updates", + description="Control how NiriMod checks for new versions", + ) + + auto_update_row = Adw.SwitchRow( + title="Check for Updates Automatically", + subtitle="Checks the GitHub repository for new commits on launch", + ) + auto_update_row.set_active(app_settings.get("auto_update", True)) + auto_update_row.connect( + "notify::active", + lambda row, _: app_settings.set("auto_update", row.get_active()), + ) + updates_grp.add(auto_update_row) + page.add(updates_grp) + + config_grp = Adw.PreferencesGroup( + title="Configuration File", + description="Manage Niri configuration paths and backups", + ) + + config_path_row = Adw.ActionRow(title="Config Path") + current_path = app_settings.get("config_path", "") + config_path_row.set_subtitle(current_path if current_path else "Default (~/.config/niri/config.kdl)") + + browse_btn = Gtk.Button(label="Browse...") + browse_btn.set_valign(Gtk.Align.CENTER) + browse_btn.connect("clicked", lambda _b: self._on_browse_config(prefs_win, config_path_row)) + config_path_row.add_suffix(browse_btn) + + clear_btn = Gtk.Button(icon_name="edit-clear-symbolic") + clear_btn.set_valign(Gtk.Align.CENTER) + clear_btn.set_tooltip_text("Reset to default") + clear_btn.connect("clicked", lambda _b: self._on_clear_config(config_path_row)) + config_path_row.add_suffix(clear_btn) + + config_grp.add(config_path_row) + + backup_path_row = Adw.ActionRow(title="Backup Directory") + current_backup = app_settings.get("backup_path", "") + backup_path_row.set_subtitle(current_backup if current_backup else "Default (~/.config/nirimod/backups)") + + browse_backup_btn = Gtk.Button(label="Browse...") + browse_backup_btn.set_valign(Gtk.Align.CENTER) + browse_backup_btn.connect("clicked", lambda _b: self._on_browse_backup_dir(prefs_win, backup_path_row)) + backup_path_row.add_suffix(browse_backup_btn) + + clear_backup_btn = Gtk.Button(icon_name="edit-clear-symbolic") + clear_backup_btn.set_valign(Gtk.Align.CENTER) + clear_backup_btn.set_tooltip_text("Reset to default") + clear_backup_btn.connect("clicked", lambda _b: self._on_clear_backup_dir(backup_path_row)) + backup_path_row.add_suffix(clear_backup_btn) + + config_grp.add(backup_path_row) + + auto_backup_row = Adw.SwitchRow( + title="Automatic Backups", + subtitle="Create a timestamped backup before saving", + ) + auto_backup_row.set_active(app_settings.get("auto_backup", True)) + auto_backup_row.connect( + "notify::active", + lambda row, _: app_settings.set("auto_backup", row.get_active()), + ) + config_grp.add(auto_backup_row) + + backup_limit_row = Adw.SpinRow( + title="Backup Limit", + subtitle="Maximum number of backups to keep per file (0 = unlimited)", + digits=0, + ) + backup_limit_row.set_adjustment(Gtk.Adjustment(value=app_settings.get("backup_limit", 10), lower=0, upper=1000, step_increment=1)) + backup_limit_row.connect( + "notify::value", + lambda row, _: app_settings.set("backup_limit", int(row.get_value())), + ) + + def _on_auto_backup_changed(switch_row, _param): + backup_limit_row.set_sensitive(switch_row.get_active()) + + auto_backup_row.connect("notify::active", _on_auto_backup_changed) + backup_limit_row.set_sensitive(auto_backup_row.get_active()) + + config_grp.add(backup_limit_row) + page.add(config_grp) + + prefs_win.add(page) + prefs_win.present() + + def _on_browse_config(self, parent_win, row): + from nirimod import app_settings + dialog = Gtk.FileDialog() + dialog.set_title("Select Niri Config") + f = Gtk.FileFilter() + f.set_name("KDL files") + f.add_pattern("*.kdl") + filters = Gio.ListStore.new(Gtk.FileFilter) + filters.append(f) + dialog.set_filters(filters) + + def _on_response(dialog, result): + try: + f = dialog.open_finish(result) + if f: + path = f.get_path() + app_settings.set("config_path", path) + row.set_subtitle(path) + self.show_toast("Restart NiriMod to use the new config path.", timeout=5) + except GLib.Error: + pass + + dialog.open(parent_win, None, _on_response) + + def _on_browse_backup_dir(self, parent_win, row): + from nirimod import app_settings + dialog = Gtk.FileDialog() + dialog.set_title("Select Backup Directory") + + def _on_response(dialog, result): + try: + f = dialog.select_folder_finish(result) + if f: + path = f.get_path() + app_settings.set("backup_path", path) + row.set_subtitle(path) + kdl_parser.set_paths( + config_path=app_settings.get("config_path", ""), + backup_path=path + ) + self.show_toast("Backup directory updated.", timeout=3) + except GLib.Error: + pass + + dialog.select_folder(parent_win, None, _on_response) + + def _on_clear_backup_dir(self, row): + from nirimod import app_settings + app_settings.set("backup_path", "") + row.set_subtitle("Default (~/.config/nirimod/backups)") + kdl_parser.set_paths( + config_path=app_settings.get("config_path", ""), + backup_path="" + ) + self.show_toast("Backup directory reset to default.", timeout=3) + + def _on_clear_config(self, row): + from nirimod import app_settings + app_settings.set("config_path", "") + row.set_subtitle("Default (~/.config/niri/config.kdl)") + self.show_toast("Restart NiriMod to use the default config path.", timeout=5) + + def _on_profiles_clicked(self, _btn=None): + dialog = Adw.AlertDialog(heading="Profiles") + + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + box.set_margin_start(4) + box.set_margin_end(4) + + names = prof_mod.list_profiles() + if names: + grp = Adw.PreferencesGroup(title="Saved Profiles") + for name in names: + row = Adw.ActionRow(title=name) + load_btn = Gtk.Button(label="Load") + load_btn.set_valign(Gtk.Align.CENTER) + load_btn.add_css_class("flat") + load_btn.connect( + "clicked", lambda _b, n=name: self._load_profile(n, dialog) + ) + 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 _b, n=name: self._delete_profile(n, dialog) + ) + row.add_suffix(load_btn) + row.add_suffix(del_btn) + grp.add(row) + box.append(grp) + + save_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + save_row.set_margin_top(8) + entry = Gtk.Entry(placeholder_text="New profile name…") + entry.set_hexpand(True) + save_btn = Gtk.Button(label="Save Current") + save_btn.add_css_class("suggested-action") + save_btn.connect( + "clicked", lambda _b: self._save_profile(entry.get_text(), dialog) + ) + save_row.append(entry) + save_row.append(save_btn) + box.append(save_row) + + dialog.set_extra_child(box) + dialog.add_response("close", "Close") + dialog.present(self) + + def _save_profile(self, name: str, dialog): + name = name.strip() + if not name: + return + prof_mod.save_profile(name, source_files=self.app_state.source_files) + self.show_toast(f"Profile '{name}' saved ✓") + + def _load_profile(self, name: str, dialog): + if prof_mod.load_profile(name): + self.notify_nodes_changed() + self.mark_dirty() + self.show_toast(f"Profile '{name}' loaded") + dialog.close() + + def _delete_profile(self, name: str, dialog): + prof_mod.delete_profile(name) + self.show_toast(f"Profile '{name}' deleted") + + extra = dialog.get_extra_child() + if extra: + dialog.set_extra_child(None) + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) + box.set_margin_start(4) + box.set_margin_end(4) + names = prof_mod.list_profiles() + if names: + grp = Adw.PreferencesGroup(title="Saved Profiles") + for n in names: + row = Adw.ActionRow(title=n) + load_btn = Gtk.Button(label="Load") + load_btn.set_valign(Gtk.Align.CENTER) + load_btn.add_css_class("flat") + load_btn.connect("clicked", lambda _b, nm=n: self._load_profile(nm, dialog)) + 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 _b, nm=n: self._delete_profile(nm, dialog)) + row.add_suffix(load_btn) + row.add_suffix(del_btn) + grp.add(row) + box.append(grp) + save_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + save_row.set_margin_top(8) + entry = Gtk.Entry(placeholder_text="New profile name\u2026") + entry.set_hexpand(True) + save_btn = Gtk.Button(label="Save Current") + save_btn.add_css_class("suggested-action") + save_btn.connect("clicked", lambda _b: self._save_profile(entry.get_text(), dialog)) + save_row.append(entry) + save_row.append(save_btn) + box.append(save_row) + dialog.set_extra_child(box) + diff --git a/nirimod/window_effects.py b/nirimod/window_effects.py new file mode 100644 index 0000000..d45749f --- /dev/null +++ b/nirimod/window_effects.py @@ -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) diff --git a/nirimod/xkb_helper.py b/nirimod/xkb_helper.py new file mode 100644 index 0000000..65224b3 --- /dev/null +++ b/nirimod/xkb_helper.py @@ -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")] diff --git a/package.nix b/package.nix new file mode 100644 index 0000000..b8ec722 --- /dev/null +++ b/package.nix @@ -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; + }; +}) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..06e89f2 --- /dev/null +++ b/pyproject.toml @@ -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", +] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..c6ad5a2 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""NiriMod test suite.""" diff --git a/tests/test_features.py b/tests/test_features.py new file mode 100644 index 0000000..578fc84 --- /dev/null +++ b/tests/test_features.py @@ -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) diff --git a/tests/test_ipc.py b/tests/test_ipc.py new file mode 100644 index 0000000..a9c59cf --- /dev/null +++ b/tests/test_ipc.py @@ -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() diff --git a/tests/test_kdl_parser.py b/tests/test_kdl_parser.py new file mode 100644 index 0000000..6a9380a --- /dev/null +++ b/tests/test_kdl_parser.py @@ -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() diff --git a/tests/test_state.py b/tests/test_state.py new file mode 100644 index 0000000..748eef0 --- /dev/null +++ b/tests/test_state.py @@ -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() diff --git a/tests/test_updater.py b/tests/test_updater.py new file mode 100644 index 0000000..c262b36 --- /dev/null +++ b/tests/test_updater.py @@ -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() diff --git a/tests/test_window_effects.py b/tests/test_window_effects.py new file mode 100644 index 0000000..bdce241 --- /dev/null +++ b/tests/test_window_effects.py @@ -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() diff --git a/tests/test_window_rules.py b/tests/test_window_rules.py new file mode 100644 index 0000000..4e96108 --- /dev/null +++ b/tests/test_window_rules.py @@ -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() diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..4aec677 --- /dev/null +++ b/uv.lock @@ -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" }, +]