(Init): Added shit
This commit is contained in:
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# Virtual Environment
|
||||
.venv/
|
||||
|
||||
# NiriMod Temporary Files
|
||||
*.kdl.bak
|
||||
*.kdl.tmp
|
||||
|
||||
|
||||
# Scratch / debug scripts
|
||||
/test_*.py
|
||||
|
||||
# Nix
|
||||
result
|
||||
54
CONTRIBUTING.md
Normal file
54
CONTRIBUTING.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Contributing to NiriMod
|
||||
|
||||
Thanks for your interest in contributing! NiriMod is a growing project and PRs are welcome.
|
||||
|
||||
## Development setup
|
||||
|
||||
System dependencies (Debian/Ubuntu names; adapt for your distro):
|
||||
|
||||
```bash
|
||||
libcairo2-dev libgirepository-2.0-dev libgtk-4-dev libadwaita-1-dev
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/srinivasr/nirimod.git
|
||||
cd nirimod
|
||||
uv sync
|
||||
uv run nirimod
|
||||
```
|
||||
|
||||
Requires Python 3.12+ and a running Niri instance for full manual testing.
|
||||
|
||||
## Before submitting a PR
|
||||
|
||||
Run the same checks that CI runs:
|
||||
|
||||
```bash
|
||||
uv run ruff check --fix .
|
||||
uv run ruff format .
|
||||
uv run mypy nirimod
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
All checks must pass before your PR can be merged.
|
||||
|
||||
## Code style
|
||||
|
||||
- Ruff enforces linting and formatting rules. Always run with `--fix` and `format`.
|
||||
- Mypy must pass clean. Don't use `assert` for type narrowing — restructure the code instead.
|
||||
- Follow existing patterns for option rows, pages, and GTK widgets rather than inventing new ones.
|
||||
|
||||
## Scope
|
||||
|
||||
- For larger changes, open an issue first so we can discuss the approach.
|
||||
- System settings that aren't managed by Niri (like Wi-Fi, Bluetooth, general theming outside of compositor scopes) are out of scope.
|
||||
|
||||
## Reporting bugs
|
||||
|
||||
Open a [GitHub issue](https://github.com/srinivasr/nirimod/issues) and include:
|
||||
|
||||
- Niri version (`niri --version`)
|
||||
- Steps to reproduce
|
||||
- Relevant log output, if any
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 srinivasr
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
103
README.md
Normal file
103
README.md
Normal file
@@ -0,0 +1,103 @@
|
||||
<div align="center">
|
||||
<h1>NiriMod</h1>
|
||||
|
||||
**A GTK4/libadwaita config manager for the [niri](https://github.com/niri-wm/niri) Wayland compositor.**
|
||||
|
||||
[](LICENSE)
|
||||
[](https://python.org)
|
||||
[](https://gtk.org)
|
||||
[](https://wayland.freedesktop.org)
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||

|
||||
|
||||
NiriMod natively supports advanced, multi-file setups. This includes custom visual layers and desktop shells like **Dank Material Shell (DMS)** and **Noctalia**.
|
||||
|
||||
If you like to split your configuration using `include` directives, NiriMod handles that transparently. It can parse included files up to 5 levels deep. When you make a change in the user interface, NiriMod is smart enough to track which file that specific setting came from, and it saves the change back to its exact origin.
|
||||
|
||||
Because NiriMod only touches the standard Niri settings it understands, your custom shell configurations, advanced scripts, and unrecognized blocks are perfectly preserved just the way you left them.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### AUR (Arch Linux)
|
||||
|
||||
```bash
|
||||
yay -S nirimod-git
|
||||
```
|
||||
|
||||
### Script (Other Distros)
|
||||
|
||||
```bash
|
||||
curl -sSL https://raw.githubusercontent.com/srinivasr/nirimod/main/install.sh | bash
|
||||
```
|
||||
|
||||
*(You can use `--install` to skip the prompts, `--uninstall` to remove the application, or `--skip-deps` if you prefer to handle dependencies manually).*
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
NiriMod works out of the box on Arch, Fedora, openSUSE, and Debian/Ubuntu. You will need:
|
||||
- Python 3.12+ and `uv` (the install script handles `uv` for you)
|
||||
- GTK4, libadwaita, PyGObject, and Pycairo
|
||||
- The niri Wayland compositor
|
||||
|
||||
**Gentoo Users** (requires the [GURU overlay](https://wiki.gentoo.org/wiki/Project:GURU) for `niri`):
|
||||
```bash
|
||||
emerge dev-vcs/git net-misc/curl dev-lang/python gui-libs/gtk gui-libs/libadwaita dev-python/pygobject dev-python/pycairo x11-libs/libxkbcommon x11-misc/xkeyboard-config
|
||||
curl -sSL https://raw.githubusercontent.com/srinivasr/nirimod/main/install.sh | bash -s -- --install --skip-deps
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are always welcome. If you would like to help out, please check the [CONTRIBUTING.md](CONTRIBUTING.md) file for setup instructions. If you are planning a major change, please open an issue first so we can discuss it.
|
||||
|
||||
<a href="https://www.star-history.com/?repos=srinivasr%2Fnirimod&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=srinivasr/nirimod&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=srinivasr/nirimod&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=srinivasr/nirimod&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
*NiriMod is an independent project and is not affiliated with the official niri team.*
|
||||
26
data/nirimod.svg
Normal file
26
data/nirimod.svg
Normal file
@@ -0,0 +1,26 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<defs>
|
||||
<linearGradient id="primaryGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#10B981" />
|
||||
<stop offset="100%" stop-color="#059669" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Clean rounded background -->
|
||||
<rect width="512" height="512" rx="128" fill="#111827" />
|
||||
|
||||
<!-- Geometric "N" abstraction - three columns with subtle overlap -->
|
||||
<g transform="translate(141, 141)">
|
||||
<!-- Column 1 (Background) -->
|
||||
<path d="M 0 60 L 0 230 C 0 241 8 250 20 250 L 50 250 L 50 40 L 20 40 C 8 40 0 49 0 60 Z" fill="#374151" />
|
||||
|
||||
<!-- Column 2 (Primary/Focused) -->
|
||||
<rect x="65" y="0" width="100" height="230" rx="12" fill="url(#primaryGradient)" />
|
||||
|
||||
<!-- Column 3 (Background) -->
|
||||
<path d="M 180 30 L 180 200 C 180 211 188 220 200 220 L 230 220 L 230 10 L 200 10 C 188 10 180 19 180 30 Z" fill="#374151" />
|
||||
|
||||
<!-- Bold negative space gap (N-shape bridge) -->
|
||||
<path d="M 65 0 L 165 230" stroke="#111827" stroke-width="20" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
27
flake.lock
generated
Normal file
27
flake.lock
generated
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1776548001,
|
||||
"narHash": "sha256-ZSK0NL4a1BwVbbTBoSnWgbJy9HeZFXLYQizjb2DPF24=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "b12141ef619e0a9c1c84dc8c684040326f27cdcc",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
35
flake.nix
Normal file
35
flake.nix
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
description = "A polished GTK4/libadwaita GUI configurator for the niri Wayland compositor";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
};
|
||||
|
||||
outputs =
|
||||
{ self, nixpkgs }:
|
||||
let
|
||||
supportedSystems = [
|
||||
"x86_64-linux"
|
||||
"aarch64-linux"
|
||||
];
|
||||
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
|
||||
in
|
||||
{
|
||||
packages = forAllSystems (system: let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
in {
|
||||
default = pkgs.callPackage ./package.nix { };
|
||||
});
|
||||
|
||||
overlays.default = final: prev: {
|
||||
nirimod = final.callPackage ./package.nix { };
|
||||
};
|
||||
|
||||
apps = forAllSystems (system: {
|
||||
default = {
|
||||
type = "app";
|
||||
program = "${self.packages.${system}.default}/bin/nirimod";
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
650
install.sh
Executable file
650
install.sh
Executable file
@@ -0,0 +1,650 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Constants & Paths
|
||||
INSTALLER_VERSION="1.0.0"
|
||||
INSTALL_DIR="$HOME/.local/share/nirimod"
|
||||
BIN_DIR="$HOME/.local/bin"
|
||||
DESKTOP_FILE_DIR="$HOME/.local/share/applications"
|
||||
ICON_DIR="$HOME/.local/share/icons/hicolor/scalable/apps"
|
||||
REPO_URL="https://github.com/srinivasr/nirimod"
|
||||
|
||||
DISTRO=""
|
||||
DISTRO_PRETTY=""
|
||||
DISTRO_LIKE=""
|
||||
PM=""
|
||||
IMAGE_BUILT_OS=0
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[0;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Helpers
|
||||
info() { echo -e "${BLUE} ➜ ${NC}$*"; }
|
||||
success() { echo -e "${GREEN} ✓ ${NC}$*"; }
|
||||
warn() { echo -e "${YELLOW} ⚠ ${NC}$*"; }
|
||||
error() { echo -e "${RED} ✗ ${NC}$*" >&2; }
|
||||
step() { echo -e "\n${BOLD}${CYAN}══ $* ══${NC}"; }
|
||||
|
||||
pause() {
|
||||
echo -e "\n${BLUE}Press any key to continue...${NC}"
|
||||
read -n 1 -s -r < /dev/tty || true
|
||||
}
|
||||
|
||||
ask() {
|
||||
# ask <prompt> <default> → returns 0 for yes, 1 for no
|
||||
local prompt="$1" default="${2:-y}"
|
||||
local yn_hint
|
||||
[[ "$default" == "y" ]] && yn_hint="[Y/n]" || yn_hint="[y/N]"
|
||||
read -p "$(echo -e "${YELLOW} ? ${NC}${prompt} ${yn_hint}: ")" reply < /dev/tty || true
|
||||
reply="${reply:-$default}"
|
||||
[[ "$reply" =~ ^[Yy]$ ]]
|
||||
}
|
||||
|
||||
print_banner() {
|
||||
clear
|
||||
echo -e "${BLUE}${BOLD}NiriMod Installer v${INSTALLER_VERSION}${NC}"
|
||||
echo -e "${CYAN}GUI Configuration Manager for the Niri Wayland Compositor${NC}\n"
|
||||
}
|
||||
|
||||
# OS Detection
|
||||
detect_distro() {
|
||||
DISTRO=""
|
||||
DISTRO_PRETTY=""
|
||||
DISTRO_LIKE=""
|
||||
PM="" # detected package manager
|
||||
IMAGE_BUILT_OS=0
|
||||
|
||||
if [ -f /etc/os-release ]; then
|
||||
# shellcheck source=/dev/null
|
||||
. /etc/os-release
|
||||
DISTRO="${ID:-}"
|
||||
DISTRO_PRETTY="${PRETTY_NAME:-$ID}"
|
||||
DISTRO_LIKE="${ID_LIKE:-}"
|
||||
fi
|
||||
|
||||
detect_image_built_os
|
||||
|
||||
# Normalize distro id using ID_LIKE fallback
|
||||
case "$DISTRO" in
|
||||
arch|manjaro|endeavouros|garuda|artix|parabola)
|
||||
PM="pacman" ;;
|
||||
fedora|rhel|centos|rocky|almalinux)
|
||||
PM="dnf" ;;
|
||||
opensuse*|sles)
|
||||
PM="zypper" ;;
|
||||
ubuntu|debian|linuxmint|pop|elementary|zorin|kali|mx|mxlinux)
|
||||
PM="apt" ;;
|
||||
gentoo)
|
||||
PM="emerge" ;;
|
||||
*)
|
||||
# Try ID_LIKE
|
||||
if [[ "$DISTRO_LIKE" == *"arch"* ]]; then PM="pacman"
|
||||
elif [[ "$DISTRO_LIKE" == *"fedora"* ]] || [[ "$DISTRO_LIKE" == *"rhel"* ]]; then PM="dnf"
|
||||
elif [[ "$DISTRO_LIKE" == *"suse"* ]]; then PM="zypper"
|
||||
elif [[ "$DISTRO_LIKE" == *"debian"* ]] || [[ "$DISTRO_LIKE" == *"ubuntu"* ]]; then PM="apt"
|
||||
elif [[ "$DISTRO_LIKE" == *"gentoo"* ]]; then PM="emerge"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
# Verify the detected package manager actually exists. On image-built Fedora,
|
||||
# keep the Fedora package family even if dnf is absent; dependency checks use rpm
|
||||
# and missing packages are reported without attempting a dnf install.
|
||||
if [ -n "$PM" ] && [ "$IMAGE_BUILT_OS" -ne 1 ] && ! command -v "$PM" &>/dev/null; then
|
||||
PM=""
|
||||
fi
|
||||
|
||||
# Last resort: probe which package manager is installed
|
||||
if [ -z "$PM" ]; then
|
||||
if command -v pacman &>/dev/null; then PM="pacman"
|
||||
elif command -v dnf &>/dev/null; then PM="dnf"
|
||||
elif command -v zypper &>/dev/null; then PM="zypper"
|
||||
elif command -v apt-get &>/dev/null; then PM="apt"
|
||||
elif command -v emerge &>/dev/null; then PM="emerge"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$PM" ]; then
|
||||
error "Could not detect a supported package manager."
|
||||
error "Supported: pacman (Arch), dnf (Fedora/RHEL), zypper (openSUSE), apt (Debian/Ubuntu), emerge (Gentoo)"
|
||||
error "If you are on an unsupported distro, re-run with --skip-deps and install dependencies manually."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
info "Detected: ${DISTRO_PRETTY} (package manager: ${PM})"
|
||||
if [ "$IMAGE_BUILT_OS" -eq 1 ]; then
|
||||
info "Detected image-built/atomic OS; system dependencies must come from the image."
|
||||
fi
|
||||
}
|
||||
|
||||
# Package Installation
|
||||
install_pkgs() {
|
||||
local pkgs=("$@")
|
||||
info "Installing: ${pkgs[*]}"
|
||||
case "$PM" in
|
||||
pacman) sudo pacman -S --needed --noconfirm "${pkgs[@]}" ;;
|
||||
dnf) sudo dnf install -y "${pkgs[@]}" ;;
|
||||
zypper) sudo zypper install -y "${pkgs[@]}" ;;
|
||||
apt) sudo apt-get update -qq && sudo apt-get install -y "${pkgs[@]}" ;;
|
||||
emerge)
|
||||
echo ""
|
||||
echo -e " ${YELLOW}⚠ Gentoo:${NC} packages compile from source and may take a few minutes."
|
||||
echo -e " The cairo USE flag will be set for dev-python/pygobject (needed for the keyboard view)."
|
||||
echo ""
|
||||
if ask "Proceed with emerge?" y; then
|
||||
local use_file="/etc/portage/package.use/nirimod"
|
||||
if ! grep -q "dev-python/pygobject" "$use_file" 2>/dev/null; then
|
||||
echo "dev-python/pygobject cairo" | sudo tee -a "$use_file" > /dev/null
|
||||
fi
|
||||
sudo emerge --newuse --ask=n "${pkgs[@]}" || {
|
||||
error "emerge failed. Try running manually:"
|
||||
for pkg in "${pkgs[@]}"; do
|
||||
echo -e " ${CYAN}sudo emerge $pkg${NC}"
|
||||
done
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
warn "Install these manually then re-run: bash install.sh --install --skip-deps"
|
||||
for pkg in "${pkgs[@]}"; do
|
||||
echo -e " ${CYAN}sudo emerge $pkg${NC}"
|
||||
done
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
pkg_installed() {
|
||||
# Returns 0 if the package is installed, 1 otherwise
|
||||
local pkg="$1"
|
||||
case "$PM" in
|
||||
pacman) pacman -Qi "$pkg" &>/dev/null ;;
|
||||
dnf) rpm -q "$pkg" &>/dev/null || rpm -q --whatprovides "$pkg" &>/dev/null ;;
|
||||
zypper) rpm -q "$pkg" &>/dev/null || rpm -q --whatprovides "$pkg" &>/dev/null ;;
|
||||
apt) dpkg-query -W -f='${Status}' "$pkg" 2>/dev/null | grep -q "install ok installed" ;;
|
||||
emerge)
|
||||
if command -v qlist &>/dev/null; then
|
||||
qlist -I "$pkg" &>/dev/null
|
||||
elif command -v equery &>/dev/null; then
|
||||
equery -q list "$pkg" &>/dev/null
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
cmd_exists() { command -v "$1" &>/dev/null; }
|
||||
|
||||
detect_image_built_os() {
|
||||
IMAGE_BUILT_OS=0
|
||||
if [ -e /run/ostree-booted ]; then
|
||||
IMAGE_BUILT_OS=1
|
||||
fi
|
||||
}
|
||||
|
||||
needs_uv_preload_cleanup() {
|
||||
[[ "${LD_PRELOAD:-}" == *"libhardened_malloc.so"* ]] || [[ "${LD_PRELOAD:-}" == *"libno_rlimit_as.so"* ]]
|
||||
}
|
||||
|
||||
filtered_ld_preload() {
|
||||
local entry
|
||||
local kept=()
|
||||
for entry in ${LD_PRELOAD//:/ }; do
|
||||
case "$entry" in
|
||||
*libhardened_malloc.so*|*libno_rlimit_as.so*) ;;
|
||||
*) kept+=("$entry") ;;
|
||||
esac
|
||||
done
|
||||
printf '%s' "${kept[*]}"
|
||||
}
|
||||
|
||||
run_with_filtered_preload() {
|
||||
if needs_uv_preload_cleanup; then
|
||||
local preload
|
||||
preload="$(filtered_ld_preload)"
|
||||
if [ -n "$preload" ]; then
|
||||
LD_PRELOAD="$preload" "$@"
|
||||
else
|
||||
env -u LD_PRELOAD "$@"
|
||||
fi
|
||||
return
|
||||
fi
|
||||
"$@"
|
||||
}
|
||||
|
||||
run_uv() {
|
||||
run_with_filtered_preload uv "$@"
|
||||
}
|
||||
|
||||
resolve_deps() {
|
||||
MISSING=()
|
||||
|
||||
# Baseline tools
|
||||
if ! cmd_exists git; then
|
||||
case "$PM" in
|
||||
pacman) MISSING+=("git") ;;
|
||||
dnf) MISSING+=("git") ;;
|
||||
zypper) MISSING+=("git") ;;
|
||||
apt) MISSING+=("git") ;;
|
||||
emerge) MISSING+=("dev-vcs/git") ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
|
||||
if ! cmd_exists curl; then
|
||||
case "$PM" in
|
||||
pacman) MISSING+=("curl") ;;
|
||||
dnf) MISSING+=("curl") ;;
|
||||
zypper) MISSING+=("curl") ;;
|
||||
apt) MISSING+=("curl") ;;
|
||||
emerge) MISSING+=("net-misc/curl") ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if ! cmd_exists python3; then
|
||||
case "$PM" in
|
||||
pacman) MISSING+=("python") ;;
|
||||
dnf) MISSING+=("python3") ;;
|
||||
zypper) MISSING+=("python3") ;;
|
||||
apt) MISSING+=("python3") ;;
|
||||
emerge) MISSING+=("dev-lang/python") ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# GTK4
|
||||
case "$PM" in
|
||||
pacman)
|
||||
pkg_installed gtk4 || MISSING+=("gtk4") ;;
|
||||
dnf)
|
||||
pkg_installed gtk4 || MISSING+=("gtk4") ;;
|
||||
zypper)
|
||||
pkg_installed libgtk-4-1 || MISSING+=("libgtk-4-1") ;;
|
||||
apt)
|
||||
pkg_installed libgtk-4-1 || MISSING+=("libgtk-4-1") ;;
|
||||
emerge)
|
||||
pkg_installed gui-libs/gtk || MISSING+=("gui-libs/gtk") ;;
|
||||
esac
|
||||
|
||||
# libadwaita
|
||||
case "$PM" in
|
||||
pacman)
|
||||
pkg_installed libadwaita || MISSING+=("libadwaita") ;;
|
||||
dnf)
|
||||
pkg_installed libadwaita || MISSING+=("libadwaita") ;;
|
||||
zypper)
|
||||
pkg_installed libadwaita-1-0 || MISSING+=("libadwaita-1-0") ;;
|
||||
apt)
|
||||
pkg_installed libadwaita-1-0 || MISSING+=("libadwaita-1-0") ;;
|
||||
emerge)
|
||||
pkg_installed gui-libs/libadwaita || MISSING+=("gui-libs/libadwaita") ;;
|
||||
esac
|
||||
|
||||
# PyGObject / GObject Introspection
|
||||
case "$PM" in
|
||||
pacman)
|
||||
pkg_installed python-gobject || MISSING+=("python-gobject") ;;
|
||||
dnf)
|
||||
pkg_installed python3-gobject || \
|
||||
pkg_installed python3-gobject-base || \
|
||||
MISSING+=("python3-gobject") ;;
|
||||
zypper)
|
||||
pkg_installed python3-gobject || MISSING+=("python3-gobject") ;;
|
||||
apt)
|
||||
pkg_installed python3-gi || MISSING+=("python3-gi")
|
||||
pkg_installed python3-gi-cairo || MISSING+=("python3-gi-cairo")
|
||||
;;
|
||||
emerge)
|
||||
pkg_installed dev-python/pygobject || MISSING+=("dev-python/pygobject")
|
||||
pkg_installed dev-python/pycairo || MISSING+=("dev-python/pycairo")
|
||||
pkg_installed x11-libs/libxkbcommon || MISSING+=("x11-libs/libxkbcommon")
|
||||
pkg_installed x11-misc/xkeyboard-config || MISSING+=("x11-misc/xkeyboard-config")
|
||||
;;
|
||||
esac
|
||||
|
||||
# GObject typelibs (needed at runtime for gi.require_version)
|
||||
case "$PM" in
|
||||
dnf)
|
||||
pkg_installed gtk4 || MISSING+=("gtk4")
|
||||
pkg_installed libadwaita || MISSING+=("libadwaita")
|
||||
;;
|
||||
zypper)
|
||||
pkg_installed typelib-1_0-Gtk-4_0 || MISSING+=("typelib-1_0-Gtk-4_0")
|
||||
pkg_installed typelib-1_0-Adw-1 || MISSING+=("typelib-1_0-Adw-1")
|
||||
;;
|
||||
apt)
|
||||
pkg_installed gir1.2-gtk-4.0 || MISSING+=("gir1.2-gtk-4.0")
|
||||
pkg_installed gir1.2-adw-1 || MISSING+=("gir1.2-adw-1")
|
||||
;;
|
||||
esac
|
||||
|
||||
# Deduplicate
|
||||
if [ ${#MISSING[@]} -gt 0 ]; then
|
||||
# Remove duplicate entries
|
||||
mapfile -t MISSING < <(printf '%s\n' "${MISSING[@]}" | sort -u)
|
||||
fi
|
||||
}
|
||||
|
||||
# Full Dependency Check
|
||||
check_dependencies() {
|
||||
step "Checking System Dependencies"
|
||||
detect_image_built_os
|
||||
|
||||
if [ "${SKIP_DEPS:-0}" -eq 1 ]; then
|
||||
warn "Skipping system package manager checks (--skip-deps)."
|
||||
warn "Please ensure git, curl, python3, gtk4, libadwaita, and pygobject are installed manually."
|
||||
else
|
||||
detect_distro
|
||||
resolve_deps
|
||||
|
||||
if [ ${#MISSING[@]} -gt 0 ]; then
|
||||
warn "The following packages are missing:"
|
||||
for pkg in "${MISSING[@]}"; do
|
||||
echo -e " ${RED}•${NC} $pkg"
|
||||
done
|
||||
echo ""
|
||||
if [ "${IMAGE_BUILT_OS:-0}" -eq 1 ]; then
|
||||
error "This looks like an image-built/atomic system, so the installer will not run sudo ${PM} install."
|
||||
error "Add the missing packages to your image recipe or base image, rebuild, then re-run this installer."
|
||||
warn "If these dependencies are provided outside the system package database, re-run with --skip-deps."
|
||||
exit 1
|
||||
fi
|
||||
if ask "Install missing packages via sudo?"; then
|
||||
install_pkgs "${MISSING[@]}"
|
||||
success "System packages installed."
|
||||
else
|
||||
error "Cannot proceed without required system packages."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
if [ "${PM:-}" = "emerge" ]; then
|
||||
success "All system packages are already installed."
|
||||
echo -e " ${YELLOW}Note:${NC} If the keyboard view is blank, you may need to rebuild pygobject with the cairo USE flag:"
|
||||
echo -e " ${CYAN}echo 'dev-python/pygobject cairo' | sudo tee -a /etc/portage/package.use/nirimod && sudo emerge --newuse dev-python/pygobject dev-python/pycairo${NC}"
|
||||
echo ""
|
||||
else
|
||||
success "All system packages are already installed."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Niri compositor check (optional warning)
|
||||
if ! cmd_exists niri; then
|
||||
warn "The 'niri' compositor was not found on PATH."
|
||||
warn "NiriMod requires niri to be running. Install it separately if needed."
|
||||
warn " Arch: sudo pacman -S niri"
|
||||
warn " Fedora: sudo dnf install niri"
|
||||
warn " Gentoo: sudo emerge gui-wm/niri"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# uv
|
||||
step "Checking uv (Python Environment Manager)"
|
||||
if ! cmd_exists uv; then
|
||||
warn "'uv' is not installed. It is required to manage NiriMod's Python environment."
|
||||
if ask "Install 'uv' via the official installer (astral.sh)?"; then
|
||||
info "Downloading and running the uv installer..."
|
||||
run_with_filtered_preload bash -c 'set -euo pipefail; curl -LsSf https://astral.sh/uv/install.sh | sh'
|
||||
# Make cargo/uv available in current session
|
||||
export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"
|
||||
if [ -f "$HOME/.cargo/env" ]; then
|
||||
# shellcheck source=/dev/null
|
||||
source "$HOME/.cargo/env"
|
||||
fi
|
||||
if ! cmd_exists uv; then
|
||||
error "'uv' was installed but is not on PATH. Please restart your shell and re-run this installer."
|
||||
error "Or run: export PATH=\"\$HOME/.local/bin:\$HOME/.cargo/bin:\$PATH\""
|
||||
exit 1
|
||||
fi
|
||||
success "'uv' installed successfully: $(run_uv --version)"
|
||||
else
|
||||
error "Cannot proceed without 'uv'."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
success "'uv' is available: $(run_uv --version)"
|
||||
fi
|
||||
}
|
||||
|
||||
# Download / Update Source
|
||||
download_source() {
|
||||
step "Fetching Source Code"
|
||||
if [ -d "$INSTALL_DIR/.git" ]; then
|
||||
info "Updating existing installation at $INSTALL_DIR ..."
|
||||
git -C "$INSTALL_DIR" fetch --quiet origin main && \
|
||||
git -C "$INSTALL_DIR" reset --hard origin/main --quiet \
|
||||
|| warn "Could not fetch latest changes (maybe no network). Continuing with existing source."
|
||||
else
|
||||
info "Cloning repository to $INSTALL_DIR ..."
|
||||
git clone "$REPO_URL" "$INSTALL_DIR"
|
||||
fi
|
||||
success "Source code is ready."
|
||||
}
|
||||
|
||||
# Build & Wire Up
|
||||
install_app() {
|
||||
step "Setting Up Python Environment"
|
||||
cd "$INSTALL_DIR"
|
||||
|
||||
info "Creating virtual environment with system site-packages..."
|
||||
rm -rf .venv # Ensure clean state
|
||||
run_uv venv --system-site-packages --python python3
|
||||
run_uv sync --no-dev
|
||||
|
||||
# Verification check
|
||||
if ! run_uv run python -c "import gi" &>/dev/null; then
|
||||
warn "Virtual environment installed, but 'gi' (PyGObject) is still not found."
|
||||
warn "This typically happens if the system bindings are missing or Python version mismatch exists."
|
||||
|
||||
# Try to diagnose
|
||||
local host_python_ver
|
||||
host_python_ver=$(python3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')")
|
||||
info "Host Python: $host_python_ver"
|
||||
|
||||
if ! python3 -c "import gi" &>/dev/null; then
|
||||
error "PyGObject is NOT installed on your host system."
|
||||
error "Please install it via your package manager first (e.g., python3-gi or python-gobject)."
|
||||
exit 1
|
||||
else
|
||||
warn "PyGObject is available on host but NOT in the venv. This is unexpected."
|
||||
warn "Attempting a fallback venv creation..."
|
||||
rm -rf .venv
|
||||
run_uv venv --system-site-packages
|
||||
run_uv sync --no-dev
|
||||
fi
|
||||
fi
|
||||
success "Python environment ready and verified."
|
||||
|
||||
# Launcher script
|
||||
step "Creating Launcher"
|
||||
mkdir -p "$BIN_DIR"
|
||||
|
||||
# Generate launcher script
|
||||
cat > "$BIN_DIR/nirimod" << EOF
|
||||
#!/usr/bin/env bash
|
||||
# NiriMod launcher — auto-generated by install.sh
|
||||
INSTALL_DIR="${INSTALL_DIR}"
|
||||
if [ ! -d "\$INSTALL_DIR" ]; then
|
||||
echo "NiriMod is not installed at \$INSTALL_DIR. Please re-run the installer." >&2
|
||||
exit 1
|
||||
fi
|
||||
export PATH="\$HOME/.local/bin:\$HOME/.cargo/bin:\$PATH"
|
||||
export PYTHONPATH="\$INSTALL_DIR"
|
||||
cd "\$INSTALL_DIR"
|
||||
$(declare -f needs_uv_preload_cleanup)
|
||||
$(declare -f filtered_ld_preload)
|
||||
$(declare -f run_with_filtered_preload)
|
||||
run_with_filtered_preload uv run python3 -m nirimod "\$@"
|
||||
EOF
|
||||
chmod +x "$BIN_DIR/nirimod"
|
||||
success "Launcher created: $BIN_DIR/nirimod"
|
||||
|
||||
# Desktop entry
|
||||
step "Installing Desktop Entry"
|
||||
mkdir -p "$DESKTOP_FILE_DIR"
|
||||
mkdir -p "$ICON_DIR"
|
||||
|
||||
# Copy icon if it exists in the repo
|
||||
if [ -f "$INSTALL_DIR/data/nirimod.svg" ]; then
|
||||
cp "$INSTALL_DIR/data/nirimod.svg" "$ICON_DIR/nirimod.svg"
|
||||
ICON_NAME="nirimod"
|
||||
elif [ -f "$INSTALL_DIR/data/nirimod.png" ]; then
|
||||
cp "$INSTALL_DIR/data/nirimod.png" "$HOME/.local/share/icons/hicolor/256x256/apps/nirimod.png"
|
||||
ICON_NAME="nirimod"
|
||||
else
|
||||
ICON_NAME="preferences-system"
|
||||
fi
|
||||
|
||||
cat > "$DESKTOP_FILE_DIR/io.github.nirimod.desktop" << EOF
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Name=NiriMod
|
||||
GenericName=Compositor Settings
|
||||
Comment=GUI Configuration Manager for the Niri Wayland Compositor
|
||||
Exec=${BIN_DIR}/nirimod
|
||||
Icon=${ICON_NAME}
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=Utility;Settings;DesktopSettings;
|
||||
Keywords=compositor;windowmanager;wayland;niri;settings;config;
|
||||
StartupNotify=true
|
||||
StartupWMClass=nirimod
|
||||
EOF
|
||||
|
||||
# Refresh desktop database if available
|
||||
if cmd_exists update-desktop-database; then
|
||||
update-desktop-database "$DESKTOP_FILE_DIR" 2>/dev/null || true
|
||||
fi
|
||||
if cmd_exists gtk-update-icon-cache; then
|
||||
gtk-update-icon-cache -f -t "$HOME/.local/share/icons/hicolor" 2>/dev/null || true
|
||||
fi
|
||||
success "Desktop entry installed."
|
||||
|
||||
if [[ ":$PATH:" != *":$BIN_DIR:"* ]]; then
|
||||
echo ""
|
||||
warn "$BIN_DIR isn't in your PATH."
|
||||
if ask "Add it to your shell profile automatically?"; then
|
||||
for rc in "$HOME/.bashrc" "$HOME/.zshrc"; do
|
||||
if [ -f "$rc" ]; then
|
||||
if ! grep -q 'export PATH=.*\.local/bin' "$rc"; then
|
||||
echo -e '\n# nirimod' >> "$rc"
|
||||
echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$rc"
|
||||
success "Patched $rc"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
else
|
||||
warn "You can run it directly: ~/.local/bin/nirimod"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
success "${BOLD}NiriMod ${INSTALLER_VERSION} installed successfully!${NC}"
|
||||
info "Launch from your app menu, or run: ${CYAN}~/.local/bin/nirimod${NC}"
|
||||
}
|
||||
|
||||
# Uninstall
|
||||
uninstall() {
|
||||
step "Uninstalling NiriMod"
|
||||
warn "This will remove:"
|
||||
echo " • $INSTALL_DIR"
|
||||
echo " • $BIN_DIR/nirimod"
|
||||
echo " • $DESKTOP_FILE_DIR/io.github.nirimod.desktop"
|
||||
echo ""
|
||||
|
||||
if ! ask "Are you sure you want to uninstall NiriMod?"; then
|
||||
info "Uninstall cancelled."
|
||||
return
|
||||
fi
|
||||
|
||||
rm -rf "$INSTALL_DIR"
|
||||
rm -f "$BIN_DIR/nirimod"
|
||||
rm -f "$DESKTOP_FILE_DIR/io.github.nirimod.desktop"
|
||||
rm -f "$ICON_DIR/nirimod.svg"
|
||||
|
||||
if cmd_exists update-desktop-database; then
|
||||
update-desktop-database "$DESKTOP_FILE_DIR" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
success "NiriMod has been uninstalled."
|
||||
pause
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Menu
|
||||
main_menu() {
|
||||
while true; do
|
||||
print_banner
|
||||
echo -e " Please select an option:\n"
|
||||
echo -e " ${GREEN}1${NC}) Install / Update NiriMod"
|
||||
echo -e " ${GREEN}2${NC}) Uninstall NiriMod"
|
||||
echo -e " ${GREEN}q${NC}) Quit"
|
||||
echo ""
|
||||
read -p "$(echo -e " ${BOLD}Enter your choice:${NC} ")" choice < /dev/tty || true
|
||||
|
||||
case "$choice" in
|
||||
1)
|
||||
print_banner
|
||||
check_dependencies
|
||||
download_source
|
||||
install_app
|
||||
pause
|
||||
exit 0
|
||||
;;
|
||||
2)
|
||||
print_banner
|
||||
uninstall
|
||||
;;
|
||||
q|Q)
|
||||
echo -e "\n${BLUE} Goodbye!${NC}\n"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
error "Invalid option. Please choose 1, 2, or q."
|
||||
sleep 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Entry Point
|
||||
# Flags:
|
||||
# --install Download from GitHub and install (non-interactive)
|
||||
# --uninstall Remove NiriMod (non-interactive)
|
||||
# --skip-deps Skip system package manager checks (useful for Gentoo/unsupported distros)
|
||||
|
||||
MODE=""
|
||||
SKIP_DEPS=0
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--install) MODE="install" ;;
|
||||
--uninstall) MODE="uninstall" ;;
|
||||
--skip-deps) SKIP_DEPS=1 ;;
|
||||
*)
|
||||
error "Unknown option: $arg"
|
||||
echo "Usage: $0 [--install | --uninstall] [--skip-deps]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$MODE" = "install" ]; then
|
||||
print_banner
|
||||
check_dependencies
|
||||
download_source
|
||||
install_app
|
||||
exit 0
|
||||
elif [ "$MODE" = "uninstall" ]; then
|
||||
print_banner
|
||||
uninstall
|
||||
exit 0
|
||||
else
|
||||
main_menu
|
||||
fi
|
||||
BIN
media/1.png
Normal file
BIN
media/1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 302 KiB |
BIN
media/2.png
Normal file
BIN
media/2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 289 KiB |
BIN
media/multiple_configs.png
Normal file
BIN
media/multiple_configs.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 146 KiB |
1
nirimod/__init__.py
Normal file
1
nirimod/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# nirimod
|
||||
80
nirimod/__main__.py
Normal file
80
nirimod/__main__.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""NiriMod application entry point."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
try:
|
||||
import gi
|
||||
except ModuleNotFoundError:
|
||||
print(
|
||||
"\033[31mError: Could not find Python GObject bindings (PyGObject).\033[0m",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"This application requires system-level libraries to interface with GTK4.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"\nPlease install the required packages for your distribution:", file=sys.stderr
|
||||
)
|
||||
print(
|
||||
" \033[1mArch:\033[0m sudo pacman -S python-gobject gtk4 libadwaita",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
" \033[1mFedora:\033[0m sudo dnf install python3-gobject gtk4 libadwaita",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
" \033[1mUbuntu:\033[0m sudo apt install python3-gi gir1.2-gtk-4.0 gir1.2-adw-1",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"\nAfter installing, re-run the installer or re-create your virtual environment.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Adw", "1")
|
||||
|
||||
from gi.repository import Adw, Gio, GLib
|
||||
|
||||
from nirimod.window import NiriModWindow
|
||||
|
||||
|
||||
class NiriModApp(Adw.Application):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
application_id="io.github.nirimod",
|
||||
flags=Gio.ApplicationFlags.NON_UNIQUE,
|
||||
)
|
||||
GLib.set_application_name("NiriMod")
|
||||
GLib.set_prgname("nirimod")
|
||||
|
||||
|
||||
# Prefer dark theme globally via libadwaita
|
||||
style_manager = Adw.StyleManager.get_default()
|
||||
style_manager.set_color_scheme(Adw.ColorScheme.FORCE_DARK)
|
||||
|
||||
def do_activate(self):
|
||||
win = self.get_active_window()
|
||||
if win is None:
|
||||
from nirimod import app_settings
|
||||
from nirimod.kdl_parser import set_paths
|
||||
set_paths(
|
||||
config_path=app_settings.get("config_path", ""),
|
||||
backup_path=app_settings.get("backup_path", "")
|
||||
)
|
||||
win = NiriModWindow(application=self)
|
||||
win.present()
|
||||
|
||||
|
||||
def main():
|
||||
app = NiriModApp()
|
||||
return app.run(sys.argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
50
nirimod/app_settings.py
Normal file
50
nirimod/app_settings.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
_SETTINGS_DIR = Path(os.path.expanduser("~/.config/nirimod"))
|
||||
_SETTINGS_FILE = _SETTINGS_DIR / "settings.json"
|
||||
|
||||
_DEFAULTS: dict = {
|
||||
"auto_update": True,
|
||||
"config_path": "",
|
||||
"backup_path": "",
|
||||
"auto_backup": True,
|
||||
"backup_limit": 10,
|
||||
}
|
||||
|
||||
_cache: dict | None = None
|
||||
|
||||
|
||||
def _load() -> dict:
|
||||
global _cache
|
||||
if _cache is not None:
|
||||
return _cache
|
||||
if _SETTINGS_FILE.exists():
|
||||
try:
|
||||
data = json.loads(_SETTINGS_FILE.read_text())
|
||||
_cache = {**_DEFAULTS, **data}
|
||||
return _cache
|
||||
except Exception:
|
||||
pass
|
||||
_cache = dict(_DEFAULTS)
|
||||
return _cache
|
||||
|
||||
|
||||
def _save(data: dict):
|
||||
global _cache
|
||||
_SETTINGS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
_SETTINGS_FILE.write_text(json.dumps(data, indent=2))
|
||||
_cache = data
|
||||
|
||||
|
||||
def get(key: str, default=None):
|
||||
return _load().get(key, default)
|
||||
|
||||
|
||||
def set(key: str, value): # noqa: A001
|
||||
data = dict(_load())
|
||||
data[key] = value
|
||||
_save(data)
|
||||
45
nirimod/backup.py
Normal file
45
nirimod/backup.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Automatic config backup management."""
|
||||
|
||||
import re
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from nirimod import kdl_parser
|
||||
|
||||
def backup_all_sources(source_files: set[Path], limit: int = 10) -> Path | None:
|
||||
if not source_files:
|
||||
return None
|
||||
|
||||
kdl_parser.BACKUP_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
existing_gens = []
|
||||
for p in kdl_parser.BACKUP_DIR.iterdir():
|
||||
if p.is_dir():
|
||||
m = re.match(r"^(?:\(Gen|v|gen)(\d+)", p.name, re.IGNORECASE)
|
||||
if m:
|
||||
existing_gens.append(int(m.group(1)))
|
||||
next_gen = max(existing_gens) + 1 if existing_gens else 1
|
||||
|
||||
ts = datetime.now().strftime("%Y-%m-%d_%H-%M")
|
||||
dest_dir = kdl_parser.BACKUP_DIR / f"(Gen{next_gen}){ts}"
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for src in sorted(source_files):
|
||||
if not src.exists():
|
||||
continue
|
||||
try:
|
||||
rel = src.relative_to(kdl_parser.NIRI_CONFIG.parent)
|
||||
dest = dest_dir / rel
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, dest)
|
||||
except ValueError:
|
||||
shutil.copy2(src, dest_dir / src.name)
|
||||
|
||||
if limit > 0:
|
||||
backups = sorted([p for p in kdl_parser.BACKUP_DIR.iterdir() if p.is_dir()], key=lambda p: p.stat().st_mtime)
|
||||
while len(backups) > limit:
|
||||
oldest = backups.pop(0)
|
||||
shutil.rmtree(oldest)
|
||||
|
||||
return dest_dir
|
||||
799
nirimod/kdl_parser.py
Normal file
799
nirimod/kdl_parser.py
Normal file
@@ -0,0 +1,799 @@
|
||||
"""Lightweight KDL parser and writer for Niri config.kdl.
|
||||
|
||||
Handles the subset of KDL used by niri's config format. For complex
|
||||
cases (nested nodes, attributes) we store raw KDL text and do targeted
|
||||
find/replace rather than a full AST round-trip.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_config_dir = Path(
|
||||
os.environ.get("NIRIMOD_CONFIG_DIR", Path.home() / ".config" / "niri")
|
||||
)
|
||||
NIRI_CONFIG = _config_dir / "config.kdl"
|
||||
PROFILES_DIR = _config_dir / "profiles"
|
||||
BACKUP_DIR = Path.home() / ".config" / "nirimod" / "backups"
|
||||
|
||||
def set_paths(config_path: str | Path | None = None, backup_path: str | Path | None = None) -> None:
|
||||
"""Override the default config and/or backup paths at module level."""
|
||||
global NIRI_CONFIG, PROFILES_DIR, BACKUP_DIR, _config_dir
|
||||
if config_path:
|
||||
p = Path(config_path).expanduser().resolve()
|
||||
_config_dir = p.parent
|
||||
NIRI_CONFIG = p
|
||||
else:
|
||||
_config_dir = Path(
|
||||
os.environ.get("NIRIMOD_CONFIG_DIR", Path.home() / ".config" / "niri")
|
||||
)
|
||||
NIRI_CONFIG = _config_dir / "config.kdl"
|
||||
PROFILES_DIR = _config_dir / "profiles"
|
||||
|
||||
if backup_path:
|
||||
BACKUP_DIR = Path(backup_path).expanduser().resolve()
|
||||
else:
|
||||
BACKUP_DIR = Path.home() / ".config" / "nirimod" / "backups"
|
||||
|
||||
|
||||
class KdlRawString(str):
|
||||
"""Marker class for strings that should be serialized as raw string literals r"..."."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class KdlNode:
|
||||
name: str
|
||||
args: list[Any] = field(default_factory=list)
|
||||
props: dict[str, Any] = field(default_factory=dict)
|
||||
children: list["KdlNode"] = field(default_factory=list)
|
||||
leading_trivia: str = ""
|
||||
trailing_trivia: str = ""
|
||||
children_trailing_trivia: str = ""
|
||||
source_file: Path | None = field(default=None, compare=False, repr=False)
|
||||
_removed_children: dict[str, tuple[int, "KdlNode"]] = field(
|
||||
default_factory=dict, compare=False, repr=False
|
||||
)
|
||||
|
||||
def get_child(self, name: str) -> "KdlNode | None":
|
||||
for c in reversed(self.children):
|
||||
if c.name == name:
|
||||
return c
|
||||
return None
|
||||
|
||||
def get_children(self, name: str) -> list["KdlNode"]:
|
||||
return [c for c in self.children if c.name == name]
|
||||
|
||||
def child_arg(self, name: str, default=None):
|
||||
c = self.get_child(name)
|
||||
if c and c.args:
|
||||
return c.args[0]
|
||||
return default
|
||||
|
||||
def __repr__(self):
|
||||
return f"KdlNode({self.name!r}, args={self.args}, props={self.props}, children={len(self.children)})"
|
||||
|
||||
|
||||
# Tokenizer
|
||||
|
||||
# Token types
|
||||
_TOK_NEWLINE = "NL" # statement-terminating newline
|
||||
_TOK_SEMICOLON = "SC" # ; statement terminator
|
||||
_TOK_LBRACE = "LB" # {
|
||||
_TOK_RBRACE = "RB" # }
|
||||
_TOK_STRING = "STR" # "..."
|
||||
_TOK_RAW_STRING = "RSTR" # r#"..."#
|
||||
_TOK_PLAIN = "PL" # identifier / number / keyword
|
||||
_TOK_SLASHDASH = "SD" # /- (next node/arg suppressed)
|
||||
_TOK_WS = "WS" # whitespace or comment
|
||||
_TOK_EOF = "EOF"
|
||||
|
||||
|
||||
def _lex(text: str) -> list[tuple[str, str]]:
|
||||
"""Return list of (token_type, token_value) from KDL source."""
|
||||
tokens: list[tuple[str, str]] = []
|
||||
i = 0
|
||||
n = len(text)
|
||||
in_node = False # True after we've seen a non-WS token on this line
|
||||
|
||||
while i < n:
|
||||
# whitespace (spaces/tabs only — NOT newlines)
|
||||
if text[i] in " \t":
|
||||
j = i
|
||||
while j < n and text[j] in " \t":
|
||||
j += 1
|
||||
tokens.append((_TOK_WS, text[i:j]))
|
||||
i = j
|
||||
continue
|
||||
|
||||
# line comment
|
||||
if text[i : i + 2] == "//":
|
||||
j = i
|
||||
while j < n and text[j] != "\n":
|
||||
j += 1
|
||||
tokens.append((_TOK_WS, text[i:j]))
|
||||
i = j
|
||||
continue
|
||||
|
||||
# block comment
|
||||
if text[i : i + 2] == "/*":
|
||||
end = text.find("*/", i + 2)
|
||||
j = end + 2 if end != -1 else n
|
||||
tokens.append((_TOK_WS, text[i:j]))
|
||||
i = j
|
||||
continue
|
||||
|
||||
# backslash line continuation — \ before \n keeps node open
|
||||
if text[i] == "\\" and i + 1 < n and text[i + 1] in "\r\n":
|
||||
j = i + 1
|
||||
while j < n and text[j] in " \t\r\n":
|
||||
j += 1
|
||||
tokens.append((_TOK_WS, text[i:j]))
|
||||
i = j
|
||||
continue
|
||||
|
||||
# newline(s) — act as statement terminator
|
||||
if text[i] in "\r\n":
|
||||
j = i
|
||||
while j < n and text[j] in "\r\n":
|
||||
j += 1
|
||||
nl_str = text[i:j]
|
||||
if in_node:
|
||||
tokens.append((_TOK_NEWLINE, nl_str))
|
||||
in_node = False
|
||||
else:
|
||||
tokens.append((_TOK_WS, nl_str))
|
||||
i = j
|
||||
continue
|
||||
|
||||
# semicolon — explicit terminator
|
||||
if text[i] == ";":
|
||||
if in_node:
|
||||
tokens.append((_TOK_SEMICOLON, ";"))
|
||||
in_node = False
|
||||
else:
|
||||
tokens.append((_TOK_WS, ";"))
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# /- (node/arg comment)
|
||||
if text[i : i + 2] == "/-":
|
||||
tokens.append((_TOK_SLASHDASH, "/-"))
|
||||
i += 2
|
||||
in_node = True
|
||||
continue
|
||||
|
||||
# braces
|
||||
if text[i] == "{":
|
||||
tokens.append((_TOK_LBRACE, "{"))
|
||||
in_node = False # children block resets line context
|
||||
i += 1
|
||||
continue
|
||||
if text[i] == "}":
|
||||
tokens.append((_TOK_RBRACE, "}"))
|
||||
in_node = False
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# raw string r#"..."# (handle r#, r##, etc.)
|
||||
if text[i] == "r" and i + 1 < n and text[i + 1] in '#"':
|
||||
j = i + 1
|
||||
while j < n and text[j] == "#":
|
||||
j += 1
|
||||
num_hashes = j - i - 1
|
||||
if j < n and text[j] == '"':
|
||||
start = j + 1
|
||||
end_delim = '"' + "#" * num_hashes
|
||||
end = text.find(end_delim, start)
|
||||
if end == -1:
|
||||
raw = text[start:]
|
||||
i = n
|
||||
else:
|
||||
raw = text[start:end]
|
||||
i = end + len(end_delim)
|
||||
tokens.append((_TOK_RAW_STRING, raw))
|
||||
in_node = True
|
||||
continue
|
||||
|
||||
if text[i] == '"':
|
||||
j = i + 1
|
||||
s = ""
|
||||
while j < n and text[j] != '"':
|
||||
if text[j] == "\\" and j + 1 < n:
|
||||
j += 1
|
||||
esc = {
|
||||
"n": "\n",
|
||||
"t": "\t",
|
||||
"r": "\r",
|
||||
'"': '"',
|
||||
"\\": "\\",
|
||||
"b": "\b",
|
||||
"f": "\f",
|
||||
}.get(text[j], text[j])
|
||||
s += esc
|
||||
else:
|
||||
s += text[j]
|
||||
j += 1
|
||||
tokens.append((_TOK_STRING, s))
|
||||
in_node = True
|
||||
i = j + 1
|
||||
continue
|
||||
|
||||
j = i
|
||||
while j < n and text[j] not in ' \t\r\n;{}"\\':
|
||||
if text[j] == "=" and j + 1 < n and text[j + 1] == "r":
|
||||
k = j + 2
|
||||
while k < n and text[k] == "#":
|
||||
k += 1
|
||||
if k < n and text[k] == '"':
|
||||
j += 1
|
||||
break
|
||||
if text[j] == "/" and j + 1 < n and text[j + 1] in "-/*":
|
||||
break
|
||||
j += 1
|
||||
tok = text[i:j]
|
||||
if tok:
|
||||
tokens.append((_TOK_PLAIN, tok))
|
||||
in_node = True
|
||||
i = j
|
||||
|
||||
if in_node:
|
||||
tokens.append((_TOK_EOF, ""))
|
||||
tokens.append((_TOK_EOF, ""))
|
||||
return tokens
|
||||
|
||||
|
||||
def _parse_value(tok_type: str, tok_val: str) -> Any:
|
||||
if tok_type == _TOK_STRING:
|
||||
return tok_val
|
||||
if tok_type == _TOK_RAW_STRING:
|
||||
return KdlRawString(tok_val)
|
||||
v = tok_val
|
||||
if v == "true":
|
||||
return True
|
||||
if v == "false":
|
||||
return False
|
||||
if v == "null":
|
||||
return None
|
||||
try:
|
||||
return int(v, 0)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
return float(v)
|
||||
except ValueError:
|
||||
pass
|
||||
return v
|
||||
|
||||
|
||||
def _parse_nodes(
|
||||
tokens: list[tuple[str, str]], pos: int, is_top_level: bool = False
|
||||
) -> tuple[list[KdlNode], int, str]:
|
||||
nodes: list[KdlNode] = []
|
||||
n = len(tokens)
|
||||
skip_next = False
|
||||
|
||||
current_trivia = ""
|
||||
|
||||
while pos < n:
|
||||
tt, tv = tokens[pos]
|
||||
|
||||
if tt in (_TOK_WS, _TOK_NEWLINE, _TOK_SEMICOLON):
|
||||
current_trivia += tv
|
||||
pos += 1
|
||||
continue
|
||||
|
||||
if tt == _TOK_EOF:
|
||||
break
|
||||
|
||||
if tt == _TOK_RBRACE:
|
||||
if is_top_level:
|
||||
current_trivia += tv
|
||||
pos += 1
|
||||
continue
|
||||
break
|
||||
|
||||
if tt == _TOK_SLASHDASH:
|
||||
current_trivia += tv
|
||||
skip_next = True
|
||||
pos += 1
|
||||
continue
|
||||
|
||||
if tt not in (_TOK_PLAIN, _TOK_STRING):
|
||||
current_trivia += tv
|
||||
pos += 1
|
||||
continue
|
||||
|
||||
name = tv
|
||||
pos += 1
|
||||
node = KdlNode(name=name)
|
||||
node.leading_trivia = current_trivia
|
||||
current_trivia = ""
|
||||
|
||||
accumulated_ws = ""
|
||||
|
||||
while pos < n:
|
||||
tt2, tv2 = tokens[pos]
|
||||
|
||||
if tt2 in (_TOK_NEWLINE, _TOK_SEMICOLON, _TOK_EOF):
|
||||
node.trailing_trivia += accumulated_ws + tv2
|
||||
pos += 1
|
||||
break
|
||||
|
||||
if tt2 == _TOK_WS:
|
||||
accumulated_ws += tv2
|
||||
pos += 1
|
||||
continue
|
||||
|
||||
if tt2 == _TOK_RBRACE:
|
||||
node.trailing_trivia += accumulated_ws
|
||||
break
|
||||
|
||||
if tt2 == _TOK_LBRACE:
|
||||
node.trailing_trivia += accumulated_ws
|
||||
accumulated_ws = ""
|
||||
pos += 1
|
||||
node.children, pos, node.children_trailing_trivia = _parse_nodes(
|
||||
tokens, pos
|
||||
)
|
||||
if pos < n and tokens[pos][0] == _TOK_RBRACE:
|
||||
pos += 1
|
||||
break
|
||||
|
||||
if tt2 == _TOK_SLASHDASH:
|
||||
accumulated_ws += tv2
|
||||
pos += 1
|
||||
while pos < n and tokens[pos][0] == _TOK_WS:
|
||||
accumulated_ws += tokens[pos][1]
|
||||
pos += 1
|
||||
if pos < n and tokens[pos][0] not in (
|
||||
_TOK_NEWLINE,
|
||||
_TOK_SEMICOLON,
|
||||
_TOK_EOF,
|
||||
_TOK_RBRACE,
|
||||
_TOK_LBRACE,
|
||||
):
|
||||
accumulated_ws += tokens[pos][1]
|
||||
pos += 1
|
||||
continue
|
||||
|
||||
if "/*" in accumulated_ws or "//" in accumulated_ws:
|
||||
node.trailing_trivia += accumulated_ws
|
||||
accumulated_ws = ""
|
||||
|
||||
if tt2 == _TOK_PLAIN and "=" in tv2 and not tv2.startswith("-"):
|
||||
k, _, vraw = tv2.partition("=")
|
||||
if not vraw:
|
||||
pos += 1
|
||||
while pos < n and tokens[pos][0] == _TOK_WS:
|
||||
pos += 1
|
||||
if pos < n and tokens[pos][0] not in (
|
||||
_TOK_NEWLINE,
|
||||
_TOK_SEMICOLON,
|
||||
_TOK_EOF,
|
||||
_TOK_RBRACE,
|
||||
_TOK_LBRACE,
|
||||
):
|
||||
vtt, vtv = tokens[pos]
|
||||
node.props[k] = _parse_value(vtt, vtv)
|
||||
pos += 1
|
||||
elif vraw == "r" or (
|
||||
vraw.startswith("r") and all(c == "#" for c in vraw[1:])
|
||||
):
|
||||
num_hashes = len(vraw) - 1
|
||||
pos += 1
|
||||
while pos < n and tokens[pos][0] == _TOK_WS:
|
||||
pos += 1
|
||||
if pos < n and tokens[pos][0] == _TOK_STRING:
|
||||
node.props[k] = KdlRawString(tokens[pos][1])
|
||||
pos += 1
|
||||
while (
|
||||
num_hashes > 0
|
||||
and pos < n
|
||||
and tokens[pos] == (_TOK_PLAIN, "#")
|
||||
):
|
||||
pos += 1
|
||||
num_hashes -= 1
|
||||
else:
|
||||
node.props[k] = _parse_value(_TOK_PLAIN, vraw)
|
||||
else:
|
||||
node.props[k] = _parse_value(_TOK_PLAIN, vraw)
|
||||
pos += 1
|
||||
else:
|
||||
node.args.append(_parse_value(tt2, tv2))
|
||||
pos += 1
|
||||
|
||||
if skip_next:
|
||||
current_trivia += _write_node(node)
|
||||
skip_next = False
|
||||
else:
|
||||
nodes.append(node)
|
||||
|
||||
return nodes, pos, current_trivia
|
||||
|
||||
|
||||
def parse_kdl(text: str) -> list[KdlNode]:
|
||||
tokens = _lex(text)
|
||||
nodes, _, eof_trivia = _parse_nodes(tokens, 0, is_top_level=True)
|
||||
if nodes and eof_trivia:
|
||||
setattr(nodes[-1], "eof_trivia", eof_trivia)
|
||||
return nodes
|
||||
|
||||
|
||||
def load_niri_config() -> list[KdlNode]:
|
||||
if not NIRI_CONFIG.exists():
|
||||
return []
|
||||
return parse_kdl(NIRI_CONFIG.read_text())
|
||||
|
||||
|
||||
def _resolve_includes(
|
||||
nodes: list[KdlNode],
|
||||
base: Path,
|
||||
depth: int = 0,
|
||||
) -> tuple[list[KdlNode], list[tuple[KdlNode, Path]]]:
|
||||
flat: list[KdlNode] = []
|
||||
slots: list[tuple[KdlNode, Path]] = []
|
||||
|
||||
for i, node in enumerate(nodes):
|
||||
if node.name != "include" or depth > 5:
|
||||
node.source_file = base
|
||||
if depth == 0:
|
||||
node._primary_order = i
|
||||
flat.append(node)
|
||||
continue
|
||||
|
||||
optional = node.props.get("optional", False)
|
||||
if not node.args:
|
||||
node.source_file = base
|
||||
if depth == 0:
|
||||
node._primary_order = i
|
||||
flat.append(node)
|
||||
continue
|
||||
|
||||
node.source_file = base
|
||||
if depth == 0:
|
||||
node._primary_order = i
|
||||
target = base.parent / node.args[0]
|
||||
slots.append((node, target))
|
||||
|
||||
if not target.exists():
|
||||
if not optional:
|
||||
import warnings
|
||||
|
||||
warnings.warn(f"nirimod: included file not found: {target}")
|
||||
continue
|
||||
|
||||
included = parse_kdl(target.read_text())
|
||||
child_flat, child_slots = _resolve_includes(included, target, depth + 1)
|
||||
flat.extend(child_flat)
|
||||
slots.extend(child_slots)
|
||||
|
||||
return flat, slots
|
||||
|
||||
|
||||
def load_niri_config_multi() -> tuple[list[KdlNode], list[tuple[KdlNode, Path]]]:
|
||||
if not NIRI_CONFIG.exists():
|
||||
return [], []
|
||||
raw = parse_kdl(NIRI_CONFIG.read_text())
|
||||
return _resolve_includes(raw, NIRI_CONFIG)
|
||||
|
||||
|
||||
def _atomic_write(path: Path, content: str) -> None:
|
||||
if path.exists() and path.read_text() == content:
|
||||
return
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".nirimod_tmp_")
|
||||
try:
|
||||
os.write(fd, content.encode())
|
||||
os.close(fd)
|
||||
fd = -1
|
||||
os.replace(tmp, path)
|
||||
except Exception:
|
||||
if fd != -1:
|
||||
os.close(fd)
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def save_niri_config_multi(
|
||||
nodes: list[KdlNode],
|
||||
include_slots: list[tuple[KdlNode, Path]],
|
||||
) -> None:
|
||||
primary = NIRI_CONFIG
|
||||
if include_slots and include_slots[0][0].source_file is not None:
|
||||
primary = include_slots[0][0].source_file
|
||||
|
||||
name_to_file: dict[str, Path] = {}
|
||||
for node in nodes:
|
||||
if node.source_file is not None and node.source_file != primary:
|
||||
name_to_file.setdefault(node.name, node.source_file)
|
||||
for node in nodes:
|
||||
if node.source_file is None:
|
||||
node.source_file = name_to_file.get(node.name)
|
||||
|
||||
by_file: dict[Path, list[KdlNode]] = {}
|
||||
config_nodes: list[KdlNode] = []
|
||||
|
||||
for node in nodes:
|
||||
src = node.source_file
|
||||
if src is None or src == primary:
|
||||
config_nodes.append(node)
|
||||
else:
|
||||
by_file.setdefault(src, []).append(node)
|
||||
|
||||
for path, file_nodes in by_file.items():
|
||||
_atomic_write(path, write_kdl(file_nodes))
|
||||
|
||||
_LARGE = 10**9
|
||||
primary_items: list[tuple[int, KdlNode]] = []
|
||||
for inc_node, _ in include_slots:
|
||||
primary_items.append((getattr(inc_node, "_primary_order", _LARGE), inc_node))
|
||||
for node in config_nodes:
|
||||
primary_items.append((getattr(node, "_primary_order", _LARGE), node))
|
||||
primary_items.sort(key=lambda x: x[0])
|
||||
|
||||
_atomic_write(primary, write_kdl([n for _, n in primary_items]))
|
||||
|
||||
|
||||
# Writer
|
||||
|
||||
|
||||
def _val_to_kdl(v: Any) -> str:
|
||||
if isinstance(v, bool):
|
||||
return "true" if v else "false"
|
||||
if isinstance(v, KdlRawString):
|
||||
num_hashes = 0
|
||||
delim = ""
|
||||
while f'"{delim}' in v:
|
||||
num_hashes += 1
|
||||
delim = "#" * num_hashes
|
||||
return f'r{delim}"{v}"{delim}'
|
||||
if isinstance(v, str):
|
||||
escaped = (
|
||||
v.replace("\\", "\\\\")
|
||||
.replace('"', '\\"')
|
||||
.replace("\n", "\\n")
|
||||
.replace("\t", "\\t")
|
||||
)
|
||||
return f'"{escaped}"'
|
||||
if v is None:
|
||||
return "null"
|
||||
return str(v)
|
||||
|
||||
|
||||
def _is_inline_node(node: KdlNode) -> bool:
|
||||
if not node.children:
|
||||
return False
|
||||
if "\n" in node.trailing_trivia:
|
||||
return False
|
||||
for child in node.children:
|
||||
if "\n" in child.leading_trivia or "\n" in child.trailing_trivia:
|
||||
return False
|
||||
if child.children and not _is_inline_node(child):
|
||||
return False
|
||||
if "\n" in node.children_trailing_trivia:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _write_node_inline(node: KdlNode) -> str:
|
||||
# Renders a node as a compact one-liner with no trivia or indentation.
|
||||
if isinstance(node.name, KdlRawString):
|
||||
name_str = _val_to_kdl(node.name)
|
||||
else:
|
||||
name_str = f'"{node.name}"' if " " in node.name else node.name
|
||||
|
||||
parts = [name_str]
|
||||
for a in node.args:
|
||||
parts.append(_val_to_kdl(a))
|
||||
for k, v in node.props.items():
|
||||
parts.append(f"{k}={_val_to_kdl(v)}")
|
||||
|
||||
res = " ".join(parts)
|
||||
if node.children:
|
||||
children_str = " ".join(f"{_write_node_inline(c)};" for c in node.children)
|
||||
res += f" {{ {children_str} }}"
|
||||
return res
|
||||
|
||||
|
||||
def _write_node(node: KdlNode, indent: int = 0) -> str:
|
||||
res = node.leading_trivia
|
||||
|
||||
pad = " " * indent
|
||||
if not node.leading_trivia:
|
||||
res += pad
|
||||
elif node.leading_trivia.endswith("\n"):
|
||||
res += pad
|
||||
|
||||
if isinstance(node.name, KdlRawString):
|
||||
name_str = _val_to_kdl(node.name)
|
||||
else:
|
||||
name_str = f'"{node.name}"' if " " in node.name else node.name
|
||||
|
||||
parts = [name_str]
|
||||
for a in node.args:
|
||||
parts.append(_val_to_kdl(a))
|
||||
for k, v in node.props.items():
|
||||
parts.append(f"{k}={_val_to_kdl(v)}")
|
||||
|
||||
res += " ".join(parts)
|
||||
|
||||
if node.children:
|
||||
if _is_inline_node(node):
|
||||
pre_brace = node.trailing_trivia if node.trailing_trivia else " "
|
||||
if not pre_brace[0].isspace():
|
||||
pre_brace = " " + pre_brace
|
||||
children_str = " ".join(f"{_write_node_inline(c)};" for c in node.children)
|
||||
res += f"{pre_brace}{{ {children_str} }}"
|
||||
else:
|
||||
if not res.endswith(" "):
|
||||
res += " "
|
||||
res += "{"
|
||||
|
||||
tt = node.trailing_trivia
|
||||
if tt and (not tt.isspace() or "\n" in tt):
|
||||
if not tt[0].isspace() and not tt.startswith("\n"):
|
||||
res += " "
|
||||
res += tt
|
||||
|
||||
for child in node.children:
|
||||
child_str = _write_node(child, indent + 1)
|
||||
if (
|
||||
res
|
||||
and not res.endswith("\n")
|
||||
and child_str
|
||||
and not child_str.startswith("\n")
|
||||
):
|
||||
res += "\n"
|
||||
res += child_str
|
||||
|
||||
ctt = node.children_trailing_trivia
|
||||
if ctt and (not ctt.isspace() or "\n" in ctt):
|
||||
lines = ctt.splitlines(keepends=True)
|
||||
while lines and lines[-1].strip() == "":
|
||||
lines.pop()
|
||||
ctt_trimmed = "".join(lines)
|
||||
if ctt_trimmed:
|
||||
res += ctt_trimmed
|
||||
if not res.endswith("\n"):
|
||||
res += "\n"
|
||||
if not res.endswith("\n"):
|
||||
res += "\n"
|
||||
res += pad
|
||||
res += "}"
|
||||
|
||||
return res
|
||||
|
||||
elif node.trailing_trivia:
|
||||
if not node.trailing_trivia[
|
||||
0
|
||||
].isspace() and not node.trailing_trivia.startswith("\n"):
|
||||
res += " "
|
||||
res += node.trailing_trivia
|
||||
|
||||
if not res.endswith("\n"):
|
||||
res += "\n"
|
||||
|
||||
return res
|
||||
|
||||
|
||||
def write_kdl(nodes: list[KdlNode]) -> str:
|
||||
if not nodes:
|
||||
return "// NiriMod configuration\n"
|
||||
|
||||
res = ""
|
||||
for n in nodes:
|
||||
node_str = _write_node(n)
|
||||
if getattr(n, "eof_trivia", None):
|
||||
node_str += getattr(n, "eof_trivia")
|
||||
|
||||
if (
|
||||
res
|
||||
and not res.endswith("\n")
|
||||
and node_str
|
||||
and not node_str.startswith("\n")
|
||||
):
|
||||
res += "\n"
|
||||
res += node_str
|
||||
|
||||
if res and not res.endswith("\n"):
|
||||
res += "\n"
|
||||
|
||||
return res
|
||||
|
||||
|
||||
def save_niri_config(nodes: list[KdlNode], path: Path | None = None) -> None:
|
||||
target = path or NIRI_CONFIG
|
||||
_atomic_write(target, write_kdl(nodes))
|
||||
|
||||
|
||||
# Config mutation helpers
|
||||
|
||||
|
||||
def find_or_create(nodes: list[KdlNode], *path: str) -> KdlNode:
|
||||
"""Navigate/create nested nodes by path, operating on a list of nodes."""
|
||||
current_list = nodes
|
||||
node: KdlNode | None = None
|
||||
for name in path:
|
||||
node = next((n for n in reversed(current_list) if n.name == name), None)
|
||||
if node is None:
|
||||
node = KdlNode(name=name)
|
||||
node.leading_trivia = "\n"
|
||||
current_list.append(node)
|
||||
current_list = node.children
|
||||
return node
|
||||
|
||||
|
||||
def set_child_arg(parent: KdlNode, child_name: str, value: Any) -> None:
|
||||
child = parent.get_child(child_name)
|
||||
if child is None:
|
||||
cache = getattr(parent, "_removed_children", {})
|
||||
if child_name in cache:
|
||||
idx, node = cache[child_name]
|
||||
parent.children.insert(min(idx, len(parent.children)), node)
|
||||
child = node
|
||||
else:
|
||||
child = KdlNode(name=child_name)
|
||||
child.leading_trivia = "\n"
|
||||
parent.children.append(child)
|
||||
child.args = [value]
|
||||
child.props = {}
|
||||
|
||||
|
||||
def remove_child(parent: KdlNode, child_name: str) -> None:
|
||||
existing = parent.get_child(child_name)
|
||||
if existing:
|
||||
if not hasattr(parent, "_removed_children"):
|
||||
parent._removed_children = {}
|
||||
parent._removed_children[child_name] = (
|
||||
parent.children.index(existing),
|
||||
existing,
|
||||
)
|
||||
parent.children.remove(existing)
|
||||
|
||||
|
||||
def set_node_flag(parent: KdlNode, flag_name: str, enabled: bool) -> None:
|
||||
existing = parent.get_child(flag_name)
|
||||
if enabled:
|
||||
if existing is not None:
|
||||
existing.args = []
|
||||
existing.props = {}
|
||||
return
|
||||
cache = getattr(parent, "_removed_children", {})
|
||||
if flag_name in cache:
|
||||
idx, node = cache[flag_name]
|
||||
node.args = []
|
||||
node.props = {}
|
||||
parent.children.insert(min(idx, len(parent.children)), node)
|
||||
else:
|
||||
new_node = KdlNode(name=flag_name)
|
||||
new_node.leading_trivia = "\n"
|
||||
parent.children.insert(0, new_node)
|
||||
elif not enabled and existing is not None:
|
||||
if not hasattr(parent, "_removed_children"):
|
||||
parent._removed_children = {}
|
||||
parent._removed_children[flag_name] = (
|
||||
parent.children.index(existing),
|
||||
existing,
|
||||
)
|
||||
parent.children.remove(existing)
|
||||
|
||||
|
||||
def safe_switch_connect(switch_row, initial_value: bool, callback) -> None:
|
||||
switch_row._last_active = initial_value
|
||||
|
||||
def _guarded(r, _):
|
||||
new_val = r.get_active()
|
||||
if new_val != getattr(r, "_last_active", None):
|
||||
r._last_active = new_val
|
||||
callback(new_val)
|
||||
|
||||
switch_row.connect("notify::active", _guarded)
|
||||
209
nirimod/niri_ipc.py
Normal file
209
nirimod/niri_ipc.py
Normal file
@@ -0,0 +1,209 @@
|
||||
"""Wrappers around `niri msg` C. Low-level IPC operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Callable
|
||||
|
||||
|
||||
|
||||
|
||||
# Internal: synchronous helper
|
||||
|
||||
|
||||
def _run_sync(args: list[str], timeout: float = 5.0) -> tuple[str, str, int]:
|
||||
import subprocess
|
||||
|
||||
try:
|
||||
r = subprocess.run(
|
||||
args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
return r.stdout, r.stderr, r.returncode
|
||||
except FileNotFoundError:
|
||||
return "", "niri: command not found", 1
|
||||
except subprocess.TimeoutExpired:
|
||||
return "", "niri msg timed out", 1
|
||||
|
||||
|
||||
# Internal: non-blocking async dispatch
|
||||
|
||||
|
||||
def _run_async(
|
||||
args: list[str],
|
||||
callback: Callable[[str, str, int], None],
|
||||
) -> None:
|
||||
import gi
|
||||
gi.require_version("Gio", "2.0")
|
||||
gi.require_version("GLib", "2.0")
|
||||
from gi.repository import Gio, GLib
|
||||
|
||||
try:
|
||||
proc = Gio.Subprocess.new(
|
||||
args,
|
||||
Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE,
|
||||
)
|
||||
except GLib.Error:
|
||||
GLib.idle_add(lambda: callback("", "niri: command not found", 1) or False)
|
||||
return
|
||||
|
||||
def _on_done(source: Gio.Subprocess, result: Gio.AsyncResult) -> None:
|
||||
try:
|
||||
ok, stdout_bytes, stderr_bytes = source.communicate_finish(result)
|
||||
stdout = stdout_bytes.get_data().decode("utf-8", errors="replace") if stdout_bytes else ""
|
||||
stderr = stderr_bytes.get_data().decode("utf-8", errors="replace") if stderr_bytes else ""
|
||||
if not ok:
|
||||
rc = 1
|
||||
else:
|
||||
rc = 0 if source.get_exit_status() == 0 else 1
|
||||
except GLib.Error as exc:
|
||||
stdout, stderr, rc = "", str(exc), 1
|
||||
callback(stdout, stderr, rc)
|
||||
|
||||
proc.communicate_async(None, None, _on_done)
|
||||
|
||||
|
||||
# IPC Getters
|
||||
|
||||
|
||||
def is_niri_running() -> bool:
|
||||
"""Return True if `niri msg version` succeeds. Called once at startup."""
|
||||
stdout, _, rc = _run_sync(["niri", "msg", "version"])
|
||||
return rc == 0 and bool(stdout.strip())
|
||||
|
||||
|
||||
def get_version() -> str:
|
||||
stdout, _, rc = _run_sync(["niri", "--version"])
|
||||
return stdout.strip() if rc == 0 else "unknown"
|
||||
|
||||
|
||||
_touchpad_cache: bool | None = None
|
||||
|
||||
|
||||
def has_touchpad() -> bool:
|
||||
import os
|
||||
|
||||
global _touchpad_cache
|
||||
if _touchpad_cache is not None:
|
||||
return _touchpad_cache
|
||||
|
||||
result = False
|
||||
try:
|
||||
for dev in os.listdir("/sys/class/input"):
|
||||
name_file = f"/sys/class/input/{dev}/device/name"
|
||||
if os.path.exists(name_file):
|
||||
with open(name_file) as fh:
|
||||
name = fh.read().lower()
|
||||
if "touchpad" in name or "trackpad" in name:
|
||||
result = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_touchpad_cache = result
|
||||
return result
|
||||
|
||||
|
||||
def validate_config(config_path: str | None = None) -> tuple[bool, str]:
|
||||
cmd = ["niri", "validate"]
|
||||
if config_path:
|
||||
cmd += ["--config", config_path]
|
||||
stdout, stderr, rc = _run_sync(cmd, timeout=10.0)
|
||||
if rc == 0:
|
||||
return True, stdout.strip() or "Config is valid."
|
||||
return False, stderr.strip() or stdout.strip() or "Unknown validation error."
|
||||
|
||||
|
||||
def load_config_file() -> tuple[bool, str]:
|
||||
stdout, stderr, rc = _run_sync(
|
||||
["niri", "msg", "action", "load-config-file"], timeout=10.0
|
||||
)
|
||||
if rc == 0:
|
||||
return True, stdout.strip() or "Config applied."
|
||||
return False, stderr.strip() or stdout.strip() or "Config reload failed."
|
||||
|
||||
|
||||
def get_outputs(callback: Callable[[list[dict]], None]) -> None:
|
||||
def _done(stdout: str, _stderr: str, rc: int) -> None:
|
||||
if rc != 0:
|
||||
callback([])
|
||||
return
|
||||
try:
|
||||
data = json.loads(stdout)
|
||||
callback(list(data.values()) if isinstance(data, dict) else data)
|
||||
except json.JSONDecodeError:
|
||||
callback([])
|
||||
|
||||
_run_async(["niri", "msg", "--json", "outputs"], _done)
|
||||
|
||||
|
||||
def get_workspaces(callback: Callable[[list[dict]], None]) -> None:
|
||||
def _done(stdout: str, _stderr: str, rc: int) -> None:
|
||||
if rc != 0:
|
||||
callback([])
|
||||
return
|
||||
try:
|
||||
callback(json.loads(stdout))
|
||||
except json.JSONDecodeError:
|
||||
callback([])
|
||||
|
||||
_run_async(["niri", "msg", "--json", "workspaces"], _done)
|
||||
|
||||
|
||||
def get_windows(callback: Callable[[list[dict]], None]) -> None:
|
||||
def _done(stdout: str, _stderr: str, rc: int) -> None:
|
||||
if rc != 0:
|
||||
callback([])
|
||||
return
|
||||
try:
|
||||
callback(json.loads(stdout))
|
||||
except json.JSONDecodeError:
|
||||
callback([])
|
||||
|
||||
_run_async(["niri", "msg", "--json", "windows"], _done)
|
||||
|
||||
|
||||
def get_focused_window(callback: Callable[[dict | None], None]) -> None:
|
||||
def _done(stdout: str, _stderr: str, rc: int) -> None:
|
||||
if rc != 0:
|
||||
callback(None)
|
||||
return
|
||||
try:
|
||||
callback(json.loads(stdout))
|
||||
except json.JSONDecodeError:
|
||||
callback(None)
|
||||
|
||||
_run_async(["niri", "msg", "--json", "focused-window"], _done)
|
||||
|
||||
|
||||
def action(action_name: str, *args: str, callback: Callable[[bool], None] | None = None) -> None:
|
||||
cmd = ["niri", "msg", "action", action_name] + list(args)
|
||||
|
||||
def _done(_stdout: str, _stderr: str, rc: int) -> None:
|
||||
if callback is not None:
|
||||
callback(rc == 0)
|
||||
|
||||
_run_async(cmd, _done)
|
||||
|
||||
|
||||
# Legacy thread shims
|
||||
|
||||
|
||||
def run_in_thread(fn: Callable, callback: Callable | None = None):
|
||||
|
||||
import threading
|
||||
|
||||
import gi
|
||||
gi.require_version("GLib", "2.0")
|
||||
from gi.repository import GLib
|
||||
|
||||
def _worker():
|
||||
result = fn()
|
||||
if callback is not None:
|
||||
GLib.idle_add(lambda: callback(result) or False)
|
||||
|
||||
t = threading.Thread(target=_worker, daemon=True)
|
||||
t.start()
|
||||
return t
|
||||
0
nirimod/pages/__init__.py
Normal file
0
nirimod/pages/__init__.py
Normal file
1222
nirimod/pages/animations.py
Normal file
1222
nirimod/pages/animations.py
Normal file
File diff suppressed because it is too large
Load Diff
452
nirimod/pages/appearance.py
Normal file
452
nirimod/pages/appearance.py
Normal file
@@ -0,0 +1,452 @@
|
||||
"""Appearance page — borders, focus ring, shadows, corner radius."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Adw", "1")
|
||||
from gi.repository import Adw, Gdk, Gtk
|
||||
|
||||
from nirimod.kdl_parser import KdlNode, find_or_create, set_child_arg, set_node_flag
|
||||
from nirimod.pages.base import BasePage
|
||||
from nirimod.window_effects import (
|
||||
blur_effects_enabled,
|
||||
focused_window_blur_enabled,
|
||||
get_global_draw_border_with_background,
|
||||
get_global_corner_radius,
|
||||
get_global_window_opacity,
|
||||
global_window_blur_enabled,
|
||||
global_window_xray_enabled,
|
||||
set_focused_window_blur,
|
||||
set_global_draw_border_with_background,
|
||||
set_global_corner_radius,
|
||||
set_global_window_blur,
|
||||
set_global_window_opacity,
|
||||
set_global_window_xray,
|
||||
set_blur_effects_enabled,
|
||||
)
|
||||
|
||||
|
||||
def _parse_color(color_str: str) -> Gdk.RGBA:
|
||||
rgba = Gdk.RGBA()
|
||||
if color_str and not rgba.parse(color_str):
|
||||
rgba.parse("#7fc8ff")
|
||||
return rgba
|
||||
|
||||
|
||||
class AppearancePage(BasePage):
|
||||
def build(self) -> Gtk.Widget:
|
||||
tb, _, _, content = self._make_toolbar_page("Appearance")
|
||||
self._content = content
|
||||
self._build_content()
|
||||
return tb
|
||||
|
||||
def _build_content(self):
|
||||
content = self._content
|
||||
nodes = self._nodes
|
||||
layout = find_or_create(nodes, "layout")
|
||||
|
||||
fr_node = layout.get_child("focus-ring") or KdlNode("focus-ring")
|
||||
fr_group = self._build_border_group("Focus Ring", "focus-ring", fr_node, layout)
|
||||
content.append(fr_group)
|
||||
|
||||
b_node = layout.get_child("border") or KdlNode("border")
|
||||
b_group = self._build_border_group("Border", "border", b_node, layout)
|
||||
content.append(b_group)
|
||||
|
||||
shadow_grp = Adw.PreferencesGroup(title="Shadow")
|
||||
shadow_node = layout.get_child("shadow") or KdlNode("shadow")
|
||||
|
||||
shadow_on_row = Adw.SwitchRow(title="Enable Shadows")
|
||||
shadow_on_row.set_active(shadow_node.get_child("on") is not None)
|
||||
shadow_on_row.connect(
|
||||
"notify::active", lambda r, _: self._set_shadow_flag("on", r.get_active())
|
||||
)
|
||||
shadow_grp.add(shadow_on_row)
|
||||
|
||||
soft_val = int(shadow_node.child_arg("softness") or 30)
|
||||
softness_adj = Gtk.Adjustment(
|
||||
value=soft_val, lower=0, upper=100, step_increment=1
|
||||
)
|
||||
softness_row = Adw.SpinRow(
|
||||
title="Softness (blur radius)", adjustment=softness_adj, digits=0
|
||||
)
|
||||
|
||||
softness_row._last_val = soft_val
|
||||
|
||||
def _on_soft_changed(r, _):
|
||||
new_val = int(r.get_value())
|
||||
if new_val != getattr(r, "_last_val", None):
|
||||
r._last_val = new_val
|
||||
self._set_shadow("softness", new_val)
|
||||
|
||||
softness_row.connect("notify::value", _on_soft_changed)
|
||||
shadow_grp.add(softness_row)
|
||||
|
||||
spread_val = int(shadow_node.child_arg("spread") or 5)
|
||||
spread_adj = Gtk.Adjustment(
|
||||
value=spread_val, lower=-50, upper=100, step_increment=1
|
||||
)
|
||||
spread_row = Adw.SpinRow(title="Spread", adjustment=spread_adj, digits=0)
|
||||
|
||||
spread_row._last_val = spread_val
|
||||
|
||||
def _on_spread_changed(r, _):
|
||||
new_val = int(r.get_value())
|
||||
if new_val != getattr(r, "_last_val", None):
|
||||
r._last_val = new_val
|
||||
self._set_shadow("spread", new_val)
|
||||
|
||||
spread_row.connect("notify::value", _on_spread_changed)
|
||||
shadow_grp.add(spread_row)
|
||||
|
||||
color_str = shadow_node.child_arg("color") or "#0007"
|
||||
color_row = Adw.ActionRow(title="Shadow Color")
|
||||
color_btn = Gtk.ColorDialogButton(
|
||||
dialog=Gtk.ColorDialog(title="Shadow Color", with_alpha=True)
|
||||
)
|
||||
color_btn.set_rgba(_parse_color(color_str))
|
||||
color_btn.set_valign(Gtk.Align.CENTER)
|
||||
color_btn.connect(
|
||||
"notify::rgba", lambda b, _: self._set_shadow_color(b.get_rgba())
|
||||
)
|
||||
color_row.add_suffix(color_btn)
|
||||
shadow_grp.add(color_row)
|
||||
|
||||
draw_behind_row = Adw.SwitchRow(
|
||||
title="Draw Behind Window",
|
||||
subtitle="Fixes corner artifacts with non-CSD apps",
|
||||
)
|
||||
draw_behind_row.set_active(
|
||||
shadow_node.get_child("draw-behind-window") is not None
|
||||
)
|
||||
draw_behind_row.connect(
|
||||
"notify::active",
|
||||
lambda r, _: self._set_shadow_flag("draw-behind-window", r.get_active()),
|
||||
)
|
||||
shadow_grp.add(draw_behind_row)
|
||||
content.append(shadow_grp)
|
||||
|
||||
blur_grp = Adw.PreferencesGroup(
|
||||
title="Blur (Global)",
|
||||
description=(
|
||||
"Requires Niri 26.04 or later. Sets blur quality and optional "
|
||||
"window blur rules."
|
||||
),
|
||||
)
|
||||
blur_node = next((n for n in nodes if n.name == "blur"), None)
|
||||
|
||||
blur_effects_row = Adw.SwitchRow(
|
||||
title="Enable Blur Effects",
|
||||
subtitle="Controls the compositor-level blur { off } setting",
|
||||
)
|
||||
blur_effects_row.set_active(blur_effects_enabled(nodes))
|
||||
blur_effects_row.connect(
|
||||
"notify::active",
|
||||
lambda r, _: self._set_blur_effects_enabled(r.get_active()),
|
||||
)
|
||||
blur_grp.add(blur_effects_row)
|
||||
|
||||
blur_enabled_row = Adw.SwitchRow(
|
||||
title="Force Blur on Windows",
|
||||
subtitle="Adds background-effect { blur true } to the global window rule",
|
||||
)
|
||||
blur_enabled_row.set_active(global_window_blur_enabled(nodes))
|
||||
blur_enabled_row.connect(
|
||||
"notify::active",
|
||||
lambda r, _: self._set_window_blur_enabled(r.get_active()),
|
||||
)
|
||||
blur_grp.add(blur_enabled_row)
|
||||
|
||||
focused_blur_row = Adw.SwitchRow(
|
||||
title="Keep Focused Windows Blurred",
|
||||
subtitle="Adds a focused-window rule that forces blur on",
|
||||
)
|
||||
focused_blur_row.set_active(focused_window_blur_enabled(nodes))
|
||||
focused_blur_row.connect(
|
||||
"notify::active",
|
||||
lambda r, _: self._set_focused_window_blur_enabled(r.get_active()),
|
||||
)
|
||||
blur_grp.add(focused_blur_row)
|
||||
|
||||
xray_row = Adw.SwitchRow(
|
||||
title="Use Xray Wallpaper Blur",
|
||||
subtitle="Use wallpaper-only blur; disable for regular background blur",
|
||||
)
|
||||
xray_row.set_active(global_window_xray_enabled(nodes))
|
||||
xray_row.connect(
|
||||
"notify::active",
|
||||
lambda r, _: self._set_window_blur_xray(r.get_active()),
|
||||
)
|
||||
blur_grp.add(xray_row)
|
||||
|
||||
opacity_val = get_global_window_opacity(nodes)
|
||||
opacity_adj = Gtk.Adjustment(
|
||||
value=opacity_val, lower=0.1, upper=1.0, step_increment=0.05
|
||||
)
|
||||
opacity_row = Adw.SpinRow(
|
||||
title="Window Opacity (1 = unset)", adjustment=opacity_adj, digits=2
|
||||
)
|
||||
|
||||
opacity_row._last_val = opacity_val
|
||||
|
||||
def _on_opacity_changed(r, _):
|
||||
new_val = round(float(r.get_value()), 2)
|
||||
if new_val != getattr(r, "_last_val", None):
|
||||
r._last_val = new_val
|
||||
self._set_window_opacity(new_val)
|
||||
|
||||
opacity_row.connect("notify::value", _on_opacity_changed)
|
||||
blur_grp.add(opacity_row)
|
||||
|
||||
border_bg_row = Adw.SwitchRow(
|
||||
title="Draw Border With Background",
|
||||
subtitle="Disable to avoid focus colors behind translucent windows",
|
||||
)
|
||||
border_bg_row.set_active(get_global_draw_border_with_background(nodes))
|
||||
border_bg_row.connect(
|
||||
"notify::active",
|
||||
lambda r, _: self._set_draw_border_with_background(r.get_active()),
|
||||
)
|
||||
blur_grp.add(border_bg_row)
|
||||
|
||||
passes_val = int(blur_node.child_arg("passes", 0) if blur_node else 0)
|
||||
passes_adj = Gtk.Adjustment(
|
||||
value=passes_val, lower=0, upper=10, step_increment=1
|
||||
)
|
||||
passes_row = Adw.SpinRow(
|
||||
title="Passes", adjustment=passes_adj, digits=0
|
||||
)
|
||||
|
||||
passes_row._last_val = passes_val
|
||||
|
||||
def _on_passes_changed(r, _):
|
||||
new_val = int(r.get_value())
|
||||
if new_val != getattr(r, "_last_val", None):
|
||||
r._last_val = new_val
|
||||
self._set_blur("passes", new_val)
|
||||
|
||||
passes_row.connect("notify::value", _on_passes_changed)
|
||||
blur_grp.add(passes_row)
|
||||
|
||||
offset_val = float(blur_node.child_arg("offset", 2.0) if blur_node else 2.0)
|
||||
offset_adj = Gtk.Adjustment(
|
||||
value=offset_val, lower=0.0, upper=20.0, step_increment=0.1
|
||||
)
|
||||
offset_row = Adw.SpinRow(title="Offset", adjustment=offset_adj, digits=1)
|
||||
|
||||
offset_row._last_val = offset_val
|
||||
|
||||
def _on_offset_changed(r, _):
|
||||
new_val = float(r.get_value())
|
||||
if new_val != getattr(r, "_last_val", None):
|
||||
r._last_val = new_val
|
||||
self._set_blur("offset", new_val)
|
||||
|
||||
offset_row.connect("notify::value", _on_offset_changed)
|
||||
blur_grp.add(offset_row)
|
||||
|
||||
noise_val = float(blur_node.child_arg("noise", 0.0) if blur_node else 0.0)
|
||||
noise_adj = Gtk.Adjustment(
|
||||
value=noise_val, lower=0.0, upper=1.0, step_increment=0.01
|
||||
)
|
||||
noise_row = Adw.SpinRow(title="Noise", adjustment=noise_adj, digits=2)
|
||||
|
||||
noise_row._last_val = noise_val
|
||||
|
||||
def _on_noise_changed(r, _):
|
||||
new_val = float(r.get_value())
|
||||
if new_val != getattr(r, "_last_val", None):
|
||||
r._last_val = new_val
|
||||
self._set_blur("noise", new_val)
|
||||
|
||||
noise_row.connect("notify::value", _on_noise_changed)
|
||||
blur_grp.add(noise_row)
|
||||
|
||||
saturation_val = float(blur_node.child_arg("saturation", 1.0) if blur_node else 1.0)
|
||||
saturation_adj = Gtk.Adjustment(
|
||||
value=saturation_val, lower=0.0, upper=5.0, step_increment=0.1
|
||||
)
|
||||
saturation_row = Adw.SpinRow(
|
||||
title="Saturation", adjustment=saturation_adj, digits=1
|
||||
)
|
||||
|
||||
saturation_row._last_val = saturation_val
|
||||
|
||||
def _on_saturation_changed(r, _):
|
||||
new_val = float(r.get_value())
|
||||
if new_val != getattr(r, "_last_val", None):
|
||||
r._last_val = new_val
|
||||
self._set_blur("saturation", new_val)
|
||||
|
||||
saturation_row.connect("notify::value", _on_saturation_changed)
|
||||
blur_grp.add(saturation_row)
|
||||
|
||||
content.append(blur_grp)
|
||||
|
||||
misc_grp = Adw.PreferencesGroup(title="Window Geometry")
|
||||
|
||||
cr_val = get_global_corner_radius(nodes)
|
||||
cr_adj = Gtk.Adjustment(value=cr_val, lower=0, upper=40, step_increment=1)
|
||||
cr_row = Adw.SpinRow(
|
||||
title="Corner Radius (px)",
|
||||
adjustment=cr_adj,
|
||||
digits=0,
|
||||
)
|
||||
|
||||
cr_row._last_val = cr_val
|
||||
|
||||
def _on_cr_changed(r, _):
|
||||
new_val = int(r.get_value())
|
||||
if new_val != getattr(r, "_last_val", None):
|
||||
r._last_val = new_val
|
||||
self._set_corner_radius(new_val)
|
||||
|
||||
cr_row.connect("notify::value", _on_cr_changed)
|
||||
misc_grp.add(cr_row)
|
||||
content.append(misc_grp)
|
||||
|
||||
def _build_border_group(
|
||||
self, title: str, key: str, node: KdlNode, layout: KdlNode
|
||||
) -> Adw.PreferencesGroup:
|
||||
grp = Adw.PreferencesGroup(title=title)
|
||||
|
||||
off_row = Adw.SwitchRow(title="Enable")
|
||||
off_row.set_active(node.get_child("off") is None)
|
||||
off_row.connect(
|
||||
"notify::active",
|
||||
lambda r, _, k=key: self._set_layout_border_flag(
|
||||
k, "off", not r.get_active()
|
||||
),
|
||||
)
|
||||
grp.add(off_row)
|
||||
|
||||
width_val = int(node.child_arg("width") or 4)
|
||||
width_adj = Gtk.Adjustment(value=width_val, lower=1, upper=20, step_increment=1)
|
||||
width_row = Adw.SpinRow(title="Width (px)", adjustment=width_adj, digits=0)
|
||||
|
||||
width_row._last_val = width_val
|
||||
|
||||
def _on_width_changed(r, _, k=key):
|
||||
new_val = int(r.get_value())
|
||||
if new_val != getattr(r, "_last_val", None):
|
||||
r._last_val = new_val
|
||||
self._set_layout_border(k, "width", new_val)
|
||||
|
||||
width_row.connect("notify::value", _on_width_changed)
|
||||
grp.add(width_row)
|
||||
|
||||
for color_key, color_label in [
|
||||
("active-color", "Active Color"),
|
||||
("inactive-color", "Inactive Color"),
|
||||
]:
|
||||
c_str = node.child_arg(color_key) or (
|
||||
"#7fc8ff" if "active" in color_key else "#202020"
|
||||
)
|
||||
c_row = Adw.ActionRow(title=color_label)
|
||||
c_btn = Gtk.ColorDialogButton(
|
||||
dialog=Gtk.ColorDialog(title=color_label, with_alpha=True)
|
||||
)
|
||||
c_btn.set_rgba(_parse_color(c_str))
|
||||
c_btn.set_valign(Gtk.Align.CENTER)
|
||||
c_btn.connect(
|
||||
"notify::rgba",
|
||||
lambda b, _, k=key, ck=color_key: self._set_layout_border(
|
||||
k, ck, self._rgba_to_hex(b.get_rgba())
|
||||
),
|
||||
)
|
||||
c_row.add_suffix(c_btn)
|
||||
grp.add(c_row)
|
||||
|
||||
return grp
|
||||
|
||||
@staticmethod
|
||||
def _rgba_to_hex(rgba: Gdk.RGBA) -> str:
|
||||
r = int(rgba.red * 255)
|
||||
g = int(rgba.green * 255)
|
||||
b = int(rgba.blue * 255)
|
||||
a = int(rgba.alpha * 255)
|
||||
if a == 255:
|
||||
return f"#{r:02x}{g:02x}{b:02x}"
|
||||
return f"#{r:02x}{g:02x}{b:02x}{a:02x}"
|
||||
|
||||
def _get_layout(self):
|
||||
return find_or_create(self._nodes, "layout")
|
||||
|
||||
def _get_border_node(self, key: str) -> KdlNode:
|
||||
layout = self._get_layout()
|
||||
node = layout.get_child(key)
|
||||
if node is None:
|
||||
node = KdlNode(key)
|
||||
layout.children.append(node)
|
||||
return node
|
||||
|
||||
def _set_layout_border(self, bkey: str, prop: str, value):
|
||||
node = self._get_border_node(bkey)
|
||||
set_child_arg(node, prop, value)
|
||||
self._commit(f"{bkey} {prop}")
|
||||
|
||||
def _set_layout_border_flag(self, bkey: str, flag: str, enabled: bool):
|
||||
node = self._get_border_node(bkey)
|
||||
set_node_flag(node, flag, enabled)
|
||||
self._commit(f"{bkey} {flag}")
|
||||
|
||||
def _get_shadow_node(self) -> KdlNode:
|
||||
layout = self._get_layout()
|
||||
node = layout.get_child("shadow")
|
||||
if node is None:
|
||||
node = KdlNode("shadow")
|
||||
layout.children.append(node)
|
||||
return node
|
||||
|
||||
def _set_shadow(self, prop: str, value):
|
||||
set_child_arg(self._get_shadow_node(), prop, value)
|
||||
self._commit(f"shadow {prop}")
|
||||
|
||||
def _set_shadow_flag(self, flag: str, enabled: bool):
|
||||
set_node_flag(self._get_shadow_node(), flag, enabled)
|
||||
self._commit(f"shadow {flag}")
|
||||
|
||||
def _set_shadow_color(self, rgba: Gdk.RGBA):
|
||||
set_child_arg(self._get_shadow_node(), "color", self._rgba_to_hex(rgba))
|
||||
self._commit("shadow color")
|
||||
|
||||
def _set_blur(self, prop: str, value):
|
||||
blur_node = find_or_create(self._nodes, "blur")
|
||||
set_child_arg(blur_node, prop, value)
|
||||
self._commit(f"blur {prop}")
|
||||
|
||||
def _set_blur_effects_enabled(self, enabled: bool):
|
||||
set_blur_effects_enabled(self._nodes, enabled)
|
||||
self._commit("blur effects")
|
||||
|
||||
def _set_window_blur_enabled(self, enabled: bool):
|
||||
set_global_window_blur(self._nodes, enabled)
|
||||
self._commit("window blur")
|
||||
|
||||
def _set_focused_window_blur_enabled(self, enabled: bool):
|
||||
set_focused_window_blur(self._nodes, enabled)
|
||||
self._commit("focused window blur")
|
||||
|
||||
def _set_window_blur_xray(self, enabled: bool):
|
||||
set_global_window_xray(self._nodes, enabled)
|
||||
self._commit("window blur xray")
|
||||
|
||||
def _set_window_opacity(self, opacity: float):
|
||||
set_global_window_opacity(self._nodes, opacity)
|
||||
self._commit("window opacity")
|
||||
|
||||
def _set_draw_border_with_background(self, enabled: bool):
|
||||
set_global_draw_border_with_background(self._nodes, enabled)
|
||||
self._commit("draw border with background")
|
||||
|
||||
def _set_corner_radius(self, radius: int):
|
||||
set_global_corner_radius(self._nodes, radius)
|
||||
self._commit("corner radius")
|
||||
|
||||
def refresh(self):
|
||||
for child in list(self._content):
|
||||
self._content.remove(child)
|
||||
self._build_content()
|
||||
98
nirimod/pages/base.py
Normal file
98
nirimod/pages/base.py
Normal file
@@ -0,0 +1,98 @@
|
||||
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Adw", "1")
|
||||
gi.require_version("Gio", "2.0")
|
||||
|
||||
from gi.repository import Adw, Gtk, Gio
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nirimod.window import NiriModWindow
|
||||
|
||||
|
||||
def make_toolbar_page(
|
||||
title: str,
|
||||
window=None,
|
||||
) -> tuple[Adw.ToolbarView, Adw.HeaderBar, Gtk.ScrolledWindow, Gtk.Box]:
|
||||
tb = Adw.ToolbarView()
|
||||
header = Adw.HeaderBar()
|
||||
tb.add_top_bar(header)
|
||||
|
||||
# Hamburger menu on the content header (appears next to window close button)
|
||||
if window is not None:
|
||||
menu = Gio.Menu()
|
||||
menu.append("Profiles", "win.open_profiles")
|
||||
menu.append("Preferences", "win.open_preferences")
|
||||
menu.append("Restore Backup...", "win.reset_config")
|
||||
|
||||
kofi_section = Gio.Menu()
|
||||
kofi_section.append("Support on Ko-fi ☕", "win.open_kofi")
|
||||
menu.append_section(None, kofi_section)
|
||||
|
||||
menu_btn = Gtk.MenuButton(icon_name="open-menu-symbolic")
|
||||
menu_btn.set_tooltip_text("Menu")
|
||||
menu_btn.add_css_class("flat")
|
||||
menu_btn.set_menu_model(menu)
|
||||
header.pack_end(menu_btn)
|
||||
|
||||
scroll = Gtk.ScrolledWindow()
|
||||
scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
|
||||
scroll.set_vexpand(True)
|
||||
|
||||
content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=20)
|
||||
content.set_margin_start(32)
|
||||
content.set_margin_end(32)
|
||||
content.set_margin_top(24)
|
||||
content.set_margin_bottom(32)
|
||||
scroll.set_child(content)
|
||||
tb.set_content(scroll)
|
||||
|
||||
return tb, header, scroll, content
|
||||
|
||||
|
||||
class BasePage:
|
||||
def __init__(self, window: "NiriModWindow"):
|
||||
self._win = window
|
||||
|
||||
def _make_toolbar_page(
|
||||
self, title: str
|
||||
) -> tuple[Adw.ToolbarView, Adw.HeaderBar, Gtk.ScrolledWindow, Gtk.Box]:
|
||||
return make_toolbar_page(title, window=self._win)
|
||||
|
||||
@property
|
||||
def _nodes(self):
|
||||
return self._win.get_nodes()
|
||||
|
||||
def _commit(self, description: str = "change"):
|
||||
app_state = self._win.app_state
|
||||
after = app_state.write_current_kdl()
|
||||
|
||||
before = app_state.undo.last_snapshot
|
||||
if before is None:
|
||||
before = app_state.saved_kdl
|
||||
|
||||
if before != after:
|
||||
self._win.push_undo(description, before, after)
|
||||
|
||||
if after == app_state.saved_kdl:
|
||||
self._win.mark_clean()
|
||||
else:
|
||||
self._win.mark_dirty()
|
||||
|
||||
def build(self) -> Gtk.Widget:
|
||||
raise NotImplementedError
|
||||
|
||||
def refresh(self):
|
||||
pass
|
||||
|
||||
def on_shown(self):
|
||||
pass
|
||||
|
||||
def show_toast(self, msg: str, timeout: int = 3):
|
||||
self._win.show_toast(msg, timeout)
|
||||
776
nirimod/pages/bindings.py
Normal file
776
nirimod/pages/bindings.py
Normal file
@@ -0,0 +1,776 @@
|
||||
"""Key Bindings page — list editor + keyboard map visualizer.
|
||||
|
||||
Tab 1: "Bindings List" — the original Adw row-based editor (unchanged logic).
|
||||
Tab 2: "Keyboard Map" — Cairo keyboard visualizer ported from omer-biz/visu.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Adw", "1")
|
||||
from gi.repository import Adw, Gio, GLib, Gtk
|
||||
|
||||
from nirimod.kdl_parser import KdlNode
|
||||
from nirimod.pages.base import BasePage
|
||||
from nirimod.widgets import KeyboardVisualizer, normalize_key_id
|
||||
|
||||
|
||||
MODIFIERS = ["Mod", "Super", "Ctrl", "Alt", "Shift"]
|
||||
|
||||
NIRI_ACTIONS = [
|
||||
"close-window",
|
||||
"focus-column-left",
|
||||
"focus-column-right",
|
||||
"focus-column-first",
|
||||
"focus-column-last",
|
||||
"focus-window-down",
|
||||
"focus-window-up",
|
||||
"move-column-left",
|
||||
"move-column-right",
|
||||
"move-column-to-first",
|
||||
"move-column-to-last",
|
||||
"move-window-down",
|
||||
"move-window-up",
|
||||
"focus-workspace-down",
|
||||
"focus-workspace-up",
|
||||
"focus-workspace",
|
||||
"move-column-to-workspace",
|
||||
"move-column-to-workspace-down",
|
||||
"move-column-to-workspace-up",
|
||||
"move-workspace-down",
|
||||
"move-workspace-up",
|
||||
"focus-monitor-left",
|
||||
"focus-monitor-right",
|
||||
"focus-monitor-up",
|
||||
"focus-monitor-down",
|
||||
"move-column-to-monitor-left",
|
||||
"move-column-to-monitor-right",
|
||||
"move-column-to-monitor-down",
|
||||
"move-column-to-monitor-up",
|
||||
"maximize-column",
|
||||
"fullscreen-window",
|
||||
"maximize-window-to-edges",
|
||||
"switch-preset-column-width",
|
||||
"switch-preset-window-height",
|
||||
"set-column-width",
|
||||
"set-window-height",
|
||||
"set-dynamic-cast-window",
|
||||
"set-dynamic-cast-monitor",
|
||||
"clear-dynamic-cast-target",
|
||||
"reset-window-height",
|
||||
"center-column",
|
||||
"center-visible-columns",
|
||||
"screenshot",
|
||||
"screenshot-screen",
|
||||
"screenshot-window",
|
||||
"spawn",
|
||||
"spawn-sh",
|
||||
"quit",
|
||||
"power-off-monitors",
|
||||
"toggle-window-floating",
|
||||
"switch-focus-between-floating-and-tiling",
|
||||
"toggle-column-tabbed-display",
|
||||
"toggle-overview",
|
||||
"consume-or-expel-window-left",
|
||||
"consume-or-expel-window-right",
|
||||
"consume-window-into-column",
|
||||
"expel-window-from-column",
|
||||
"expand-column-to-available-width",
|
||||
"show-hotkey-overlay",
|
||||
"toggle-keyboard-shortcuts-inhibit",
|
||||
"toggle-windowed-fullscreen",
|
||||
]
|
||||
|
||||
|
||||
_KNOWN_BIND_PROPS = {"allow-when-locked", "repeat"}
|
||||
|
||||
|
||||
def _make_bind(
|
||||
keysym: str,
|
||||
action: str = "",
|
||||
action_args: list | None = None,
|
||||
allow_when_locked: bool = False,
|
||||
repeat: bool = True,
|
||||
extra_props: dict | None = None,
|
||||
node: KdlNode | None = None,
|
||||
) -> dict:
|
||||
return {
|
||||
"keysym": keysym,
|
||||
"action": action,
|
||||
"action_args": action_args or [],
|
||||
"allow_when_locked": allow_when_locked,
|
||||
"repeat": repeat,
|
||||
"extra_props": extra_props or {},
|
||||
"_node": node,
|
||||
}
|
||||
|
||||
|
||||
def _parse_binds_from_nodes(nodes: list[KdlNode]) -> list[dict]:
|
||||
"""Parse all bind nodes from the binds block."""
|
||||
binds_node = next((n for n in nodes if n.name == "binds"), None)
|
||||
if not binds_node:
|
||||
return []
|
||||
result = []
|
||||
for child in binds_node.children:
|
||||
keysym = child.name
|
||||
action = ""
|
||||
action_args: list = []
|
||||
allow_locked = child.props.get("allow-when-locked", False)
|
||||
repeat = child.props.get("repeat", True)
|
||||
extra_props = {
|
||||
k: v for k, v in child.props.items() if k not in _KNOWN_BIND_PROPS
|
||||
}
|
||||
for sub in child.children:
|
||||
action = sub.name
|
||||
action_args = list(sub.args)
|
||||
result.append(
|
||||
_make_bind(
|
||||
keysym,
|
||||
action,
|
||||
action_args,
|
||||
allow_locked,
|
||||
repeat,
|
||||
extra_props,
|
||||
node=child,
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _write_binds_to_node(binds_list: list[dict], binds_node: KdlNode):
|
||||
kept_nodes = {id(b.get("_node")) for b in binds_list if b.get("_node") is not None}
|
||||
salvaged_trivia = ""
|
||||
for orig_child in binds_node.children:
|
||||
if id(orig_child) not in kept_nodes:
|
||||
salvaged_trivia += orig_child.leading_trivia
|
||||
|
||||
new_children = []
|
||||
for i, b in enumerate(binds_list):
|
||||
child = b.get("_node")
|
||||
if child is None:
|
||||
child = KdlNode(name=b["keysym"])
|
||||
child.leading_trivia = "\n "
|
||||
else:
|
||||
child.name = b["keysym"]
|
||||
|
||||
if i == 0 and salvaged_trivia:
|
||||
child.leading_trivia = salvaged_trivia + child.leading_trivia
|
||||
salvaged_trivia = ""
|
||||
|
||||
child.props.clear()
|
||||
if b["allow_when_locked"]:
|
||||
child.props["allow-when-locked"] = True
|
||||
if not b["repeat"]:
|
||||
child.props["repeat"] = False
|
||||
for k, v in b.get("extra_props", {}).items():
|
||||
child.props[k] = v
|
||||
|
||||
if b["action"]:
|
||||
args = b.get("action_args") or []
|
||||
if not args:
|
||||
legacy = b.get("action_arg", "")
|
||||
if legacy:
|
||||
args = [legacy]
|
||||
|
||||
if child.children:
|
||||
action_node = child.children[0]
|
||||
action_node.name = b["action"]
|
||||
action_node.args = list(args)
|
||||
child.children = [action_node]
|
||||
else:
|
||||
action_node = KdlNode(name=b["action"])
|
||||
action_node.args = list(args)
|
||||
action_node.leading_trivia = " "
|
||||
child.children.append(action_node)
|
||||
else:
|
||||
child.children.clear()
|
||||
|
||||
new_children.append(child)
|
||||
|
||||
if salvaged_trivia:
|
||||
binds_node.children_trailing_trivia = salvaged_trivia + binds_node.children_trailing_trivia
|
||||
|
||||
binds_node.children = new_children
|
||||
|
||||
|
||||
def _build_key_bindings_map(binds: list[dict], viz=None) -> dict[str, list[dict]]:
|
||||
result: dict[str, list[dict]] = {}
|
||||
for b in binds:
|
||||
keysym = b.get("keysym", "")
|
||||
raw_key = keysym.split("+")[-1].lower()
|
||||
kid = None
|
||||
if viz and viz._dynamic_keysym_to_kid:
|
||||
kid = viz._dynamic_keysym_to_kid.get(raw_key)
|
||||
if not kid:
|
||||
kid = normalize_key_id(raw_key)
|
||||
result.setdefault(kid, []).append(b)
|
||||
return result
|
||||
|
||||
|
||||
# BindingsPage
|
||||
|
||||
|
||||
class BindingsPage(BasePage):
|
||||
def __init__(self, window):
|
||||
super().__init__(window)
|
||||
self._binds: list[dict] = []
|
||||
self._search_query = ""
|
||||
self._kb_search_query = ""
|
||||
self._file_monitor: Gio.FileMonitor | None = None
|
||||
self._viz: KeyboardVisualizer | None = None
|
||||
|
||||
def build(self) -> Gtk.Widget:
|
||||
tb = Adw.ToolbarView()
|
||||
|
||||
# Custom Header
|
||||
header_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
|
||||
header_box.set_margin_start(24)
|
||||
header_box.set_margin_end(24)
|
||||
header_box.set_margin_top(20)
|
||||
header_box.set_margin_bottom(12)
|
||||
|
||||
# Title/Subtitle Group
|
||||
title_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
|
||||
title_vbox.set_hexpand(True)
|
||||
|
||||
self._main_title = Gtk.Label(label="Keybindings")
|
||||
self._main_title.set_xalign(0.0)
|
||||
self._main_title.add_css_class("title-1")
|
||||
title_vbox.append(self._main_title)
|
||||
|
||||
self._kb_stats_header = Gtk.Label(label="Detecting bindings...")
|
||||
self._kb_stats_header.set_xalign(0.0)
|
||||
self._kb_stats_header.add_css_class("dim-label")
|
||||
self._kb_stats_header.add_css_class("caption")
|
||||
title_vbox.append(self._kb_stats_header)
|
||||
header_box.append(title_vbox)
|
||||
|
||||
# Layout Selector (shown only on Keyboard tab)
|
||||
from nirimod import app_settings
|
||||
from nirimod.xkb_helper import XkbHelper
|
||||
|
||||
self._layouts = XkbHelper.get_available_layouts()
|
||||
layout_names = [d for _, d in self._layouts]
|
||||
self._layout_model = Gtk.StringList.new(layout_names)
|
||||
self._layout_combo = Gtk.DropDown(model=self._layout_model)
|
||||
self._layout_combo.set_valign(Gtk.Align.CENTER)
|
||||
self._layout_combo.set_enable_search(True)
|
||||
|
||||
# Priority: Settings > Niri Config > US
|
||||
saved_layout = app_settings.get("kb_layout")
|
||||
if not saved_layout:
|
||||
saved_layout = self._get_current_niri_layout() or "us"
|
||||
|
||||
selected_idx = 0
|
||||
for i, (lid, _) in enumerate(self._layouts):
|
||||
if lid == saved_layout:
|
||||
selected_idx = i
|
||||
break
|
||||
self._layout_combo.set_selected(selected_idx)
|
||||
|
||||
self._layout_combo.connect("notify::selected", self._on_layout_changed)
|
||||
header_box.append(self._layout_combo)
|
||||
|
||||
# Add Button (hidden by default, shown on List tab)
|
||||
self._add_btn = Gtk.Button(icon_name="list-add-symbolic")
|
||||
self._add_btn.set_tooltip_text("Add binding")
|
||||
self._add_btn.add_css_class("flat")
|
||||
self._add_btn.add_css_class("circular")
|
||||
self._add_btn.set_valign(Gtk.Align.CENTER)
|
||||
self._add_btn.set_visible(False)
|
||||
self._add_btn.connect("clicked", self._on_add_clicked)
|
||||
header_box.append(self._add_btn)
|
||||
|
||||
# View Switcher (Styled as Physical/List View buttons)
|
||||
self._view_stack = Adw.ViewStack()
|
||||
|
||||
switcher_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
|
||||
switcher_box.add_css_class("linked")
|
||||
switcher_box.set_valign(Gtk.Align.CENTER)
|
||||
|
||||
self._btn_physical = Gtk.ToggleButton(label="Physical")
|
||||
self._btn_list = Gtk.ToggleButton(label="List View")
|
||||
self._btn_list.set_group(self._btn_physical)
|
||||
|
||||
self._btn_physical.connect("toggled", self._on_view_toggle)
|
||||
self._btn_list.connect("toggled", self._on_view_toggle)
|
||||
|
||||
switcher_box.append(self._btn_physical)
|
||||
switcher_box.append(self._btn_list)
|
||||
header_box.append(switcher_box)
|
||||
|
||||
self._view_stack.set_vexpand(True)
|
||||
list_page_widget = self._build_list_tab()
|
||||
self._view_stack.add_named(list_page_widget, "list")
|
||||
|
||||
kb_page_widget = self._build_keyboard_tab()
|
||||
self._view_stack.add_named(kb_page_widget, "keyboard")
|
||||
|
||||
# Default to keyboard (Physical)
|
||||
self._view_stack.set_visible_child_name("keyboard")
|
||||
self._btn_physical.set_active(True)
|
||||
|
||||
main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
main_box.append(header_box)
|
||||
main_box.append(self._view_stack)
|
||||
|
||||
tb.set_content(main_box)
|
||||
|
||||
self.refresh()
|
||||
self._start_file_monitor()
|
||||
return tb
|
||||
|
||||
def _get_current_niri_layout(self):
|
||||
try:
|
||||
from nirimod import kdl_parser
|
||||
nodes = kdl_parser.load_niri_config()
|
||||
for node in nodes:
|
||||
if node.name == "input":
|
||||
kb = node.get_child("keyboard")
|
||||
if kb:
|
||||
xkb = kb.get_child("xkb")
|
||||
if xkb:
|
||||
layout = xkb.child_arg("layout")
|
||||
v = xkb.child_arg("variant")
|
||||
if layout:
|
||||
return f"{layout}:{v}" if v else layout
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _on_layout_changed(self, dropdown, param):
|
||||
from nirimod import app_settings
|
||||
idx = dropdown.get_selected()
|
||||
if idx < len(self._layouts):
|
||||
layout_id = self._layouts[idx][0]
|
||||
app_settings.set("kb_layout", layout_id)
|
||||
if self._viz:
|
||||
self._viz.set_layout(layout_id)
|
||||
|
||||
def _on_view_toggle(self, btn):
|
||||
if not btn.get_active():
|
||||
return
|
||||
is_list = btn == self._btn_list
|
||||
self._view_stack.set_visible_child_name("list" if is_list else "keyboard")
|
||||
self._add_btn.set_visible(is_list)
|
||||
if hasattr(self, "_layout_combo"):
|
||||
self._layout_combo.set_visible(not is_list)
|
||||
|
||||
def _build_list_tab(self) -> Gtk.Widget:
|
||||
"""Return the scrollable list editor widget (original UI)."""
|
||||
scroll = Gtk.ScrolledWindow()
|
||||
scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
|
||||
scroll.set_vexpand(True)
|
||||
|
||||
content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=20)
|
||||
content.set_margin_start(32)
|
||||
content.set_margin_end(32)
|
||||
content.set_margin_top(24)
|
||||
content.set_margin_bottom(32)
|
||||
scroll.set_child(content)
|
||||
|
||||
# Search
|
||||
|
||||
# Search
|
||||
search = Gtk.SearchEntry(placeholder_text="Filter bindings…")
|
||||
search.set_margin_start(0)
|
||||
search.set_margin_end(0)
|
||||
search.connect("search-changed", self._on_filter_changed)
|
||||
content.append(search)
|
||||
|
||||
# Binds Grid
|
||||
self._flowbox = Gtk.FlowBox()
|
||||
self._flowbox.set_valign(Gtk.Align.START)
|
||||
self._flowbox.set_max_children_per_line(3)
|
||||
self._flowbox.set_min_children_per_line(1)
|
||||
self._flowbox.set_selection_mode(Gtk.SelectionMode.NONE)
|
||||
self._flowbox.set_column_spacing(16)
|
||||
self._flowbox.set_row_spacing(16)
|
||||
self._flowbox.set_homogeneous(True)
|
||||
content.append(self._flowbox)
|
||||
|
||||
|
||||
return scroll
|
||||
|
||||
def _build_keyboard_tab(self) -> Gtk.Widget:
|
||||
scroll = Gtk.ScrolledWindow()
|
||||
scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
|
||||
scroll.set_vexpand(True)
|
||||
|
||||
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16)
|
||||
outer.set_margin_start(24)
|
||||
outer.set_margin_end(24)
|
||||
outer.set_margin_top(20)
|
||||
outer.set_margin_bottom(24)
|
||||
scroll.set_child(outer)
|
||||
|
||||
# Search bar
|
||||
kb_search = Gtk.SearchEntry(
|
||||
placeholder_text="Filter by action… (e.g. spawn, focus)"
|
||||
)
|
||||
kb_search.connect("search-changed", self._on_kb_search_changed)
|
||||
outer.append(kb_search)
|
||||
|
||||
# Search bar
|
||||
self._kb_stats = Gtk.Label(label="")
|
||||
self._kb_stats.set_visible(False)
|
||||
|
||||
self._viz = KeyboardVisualizer()
|
||||
idx = self._layout_combo.get_selected()
|
||||
if 0 <= idx < len(self._layouts):
|
||||
self._viz.set_layout(self._layouts[idx][0])
|
||||
self._viz.connect("key-selected", self._on_kb_key_selected)
|
||||
self._viz.connect("edit-binding", self._on_kb_edit_binding)
|
||||
self._viz.connect("add-binding", self._on_kb_add_binding)
|
||||
self._viz.connect("delete-binding", self._on_kb_delete_binding)
|
||||
outer.append(self._viz)
|
||||
|
||||
return scroll
|
||||
|
||||
# Tab switching
|
||||
|
||||
|
||||
|
||||
# Refresh / sync
|
||||
|
||||
def refresh(self):
|
||||
self._binds = _parse_binds_from_nodes(self._nodes)
|
||||
self._rebuild_list()
|
||||
self._refresh_visualizer()
|
||||
|
||||
def on_shown(self):
|
||||
self._refresh_visualizer()
|
||||
|
||||
def _refresh_visualizer(self):
|
||||
if self._viz is None:
|
||||
return
|
||||
from nirimod import app_settings
|
||||
layout_id = app_settings.get("kb_layout")
|
||||
if not layout_id:
|
||||
layout_id = self._get_current_niri_layout() or "us"
|
||||
self._viz.set_layout(layout_id)
|
||||
|
||||
binds_map = _build_key_bindings_map(self._binds, self._viz)
|
||||
self._viz.set_bindings(binds_map)
|
||||
self._viz.set_search(self._kb_search_query)
|
||||
n_total = len(self._binds)
|
||||
self._kb_stats_header.set_label(
|
||||
f"{n_total} active bindings detected"
|
||||
)
|
||||
|
||||
# List editor helpers (unchanged from original)
|
||||
|
||||
def _rebuild_list(self):
|
||||
if not hasattr(self, "_flowbox"):
|
||||
return
|
||||
|
||||
# Clear existing children
|
||||
while True:
|
||||
child = self._flowbox.get_first_child()
|
||||
if child is None:
|
||||
break
|
||||
self._flowbox.remove(child)
|
||||
|
||||
q = self._search_query.lower()
|
||||
visible_count = 0
|
||||
for i, b in enumerate(self._binds):
|
||||
if q and q not in b["keysym"].lower() and q not in b["action"].lower():
|
||||
continue
|
||||
card = self._make_bind_card(b, i)
|
||||
self._flowbox.append(card)
|
||||
visible_count += 1
|
||||
|
||||
def _make_bind_card(self, b: dict, idx: int) -> Gtk.Widget:
|
||||
keysym = b["keysym"]
|
||||
action = b["action"]
|
||||
action_args = b.get("action_args") or []
|
||||
action_arg_display = " ".join(str(a) for a in action_args)
|
||||
|
||||
full_action = f"{action} {action_arg_display}".strip()
|
||||
if not full_action:
|
||||
full_action = "(unassigned)"
|
||||
|
||||
# Card container
|
||||
card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
||||
card.set_size_request(240, 140)
|
||||
card.add_css_class("nm-binding-card")
|
||||
|
||||
# 1. Keycaps Row
|
||||
keys_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
||||
parts = keysym.split("+")
|
||||
_labels = {
|
||||
"mod": "Mod",
|
||||
"super": "Super",
|
||||
"ctrl": "Ctrl",
|
||||
"control": "Ctrl",
|
||||
"shift": "Shift",
|
||||
"alt": "Alt",
|
||||
}
|
||||
|
||||
for i, part in enumerate(parts):
|
||||
label_text = part
|
||||
is_mod = i < len(parts) - 1
|
||||
if is_mod:
|
||||
label_text = _labels.get(part.lower(), part)
|
||||
else:
|
||||
label_text = label_text.upper() if len(label_text) == 1 else label_text
|
||||
|
||||
cap = Gtk.Label(label=label_text)
|
||||
cap.add_css_class("nm-keycap-purple")
|
||||
keys_box.append(cap)
|
||||
|
||||
if i < len(parts) - 1:
|
||||
plus = Gtk.Label(label="+")
|
||||
plus.add_css_class("dim-label")
|
||||
keys_box.append(plus)
|
||||
|
||||
card.append(keys_box)
|
||||
|
||||
# 2. "ACTIONS" Label
|
||||
actions_header = Gtk.Label(label="ACTIONS")
|
||||
actions_header.set_xalign(0.0)
|
||||
actions_header.add_css_class("nm-binding-actions-label")
|
||||
actions_header.set_margin_top(12)
|
||||
card.append(actions_header)
|
||||
|
||||
# 3. Action Name
|
||||
action_lbl = Gtk.Label(label=full_action)
|
||||
action_lbl.set_xalign(0.0)
|
||||
action_lbl.set_ellipsize(3)
|
||||
action_lbl.add_css_class("nm-binding-action-name")
|
||||
card.append(action_lbl)
|
||||
|
||||
# Spacer to push action buttons to the bottom
|
||||
spacer = Gtk.Box()
|
||||
spacer.set_vexpand(True)
|
||||
card.append(spacer)
|
||||
|
||||
bottom_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
||||
bottom_row.set_halign(Gtk.Align.END)
|
||||
|
||||
if b.get("allow_when_locked"):
|
||||
lock = Gtk.Label(label="🔒")
|
||||
lock.set_opacity(0.6)
|
||||
bottom_row.append(lock)
|
||||
|
||||
edit_btn = Gtk.Button(icon_name="document-edit-symbolic")
|
||||
edit_btn.add_css_class("flat")
|
||||
edit_btn.add_css_class("circular")
|
||||
edit_btn.connect("clicked", lambda *_, i=idx: self._on_edit_clicked(i))
|
||||
bottom_row.append(edit_btn)
|
||||
|
||||
del_btn = Gtk.Button(icon_name="user-trash-symbolic")
|
||||
del_btn.add_css_class("flat")
|
||||
del_btn.add_css_class("circular")
|
||||
del_btn.add_css_class("error")
|
||||
del_btn.connect("clicked", lambda *_, i=idx: self._on_delete_clicked(i))
|
||||
bottom_row.append(del_btn)
|
||||
|
||||
card.append(bottom_row)
|
||||
|
||||
return card
|
||||
|
||||
def _on_filter_changed(self, entry):
|
||||
self._search_query = entry.get_text().strip()
|
||||
self._rebuild_list()
|
||||
|
||||
def _on_kb_search_changed(self, entry):
|
||||
self._kb_search_query = entry.get_text().strip()
|
||||
if self._viz:
|
||||
self._viz.set_search(self._kb_search_query)
|
||||
|
||||
def _on_kb_key_selected(self, viz, key_id: str):
|
||||
pass
|
||||
|
||||
def _on_kb_edit_binding(self, viz, bind_dict):
|
||||
try:
|
||||
idx = self._binds.index(bind_dict)
|
||||
self._show_bind_dialog(bind_dict, idx)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def _on_kb_delete_binding(self, viz, bind_dict):
|
||||
try:
|
||||
idx = self._binds.index(bind_dict)
|
||||
self._on_delete_clicked(idx)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def _on_kb_add_binding(self, viz, key_id: str):
|
||||
|
||||
if len(key_id) == 1:
|
||||
display_key = key_id.upper()
|
||||
else:
|
||||
display_key = key_id.capitalize()
|
||||
|
||||
new_bind = {
|
||||
"keysym": f"Mod+{display_key}",
|
||||
"action": "",
|
||||
"action_args": [],
|
||||
"allow_when_locked": False,
|
||||
"repeat": True,
|
||||
"extra_props": {}
|
||||
}
|
||||
self._show_bind_dialog(new_bind, -1)
|
||||
|
||||
def _on_delete_clicked(self, idx: int):
|
||||
if 0 <= idx < len(self._binds):
|
||||
del self._binds[idx]
|
||||
self._save_binds()
|
||||
self._rebuild_list()
|
||||
self._refresh_visualizer()
|
||||
|
||||
def _on_add_clicked(self, *_):
|
||||
self._show_bind_dialog(None, -1)
|
||||
|
||||
def _on_edit_clicked(self, idx: int):
|
||||
if 0 <= idx < len(self._binds):
|
||||
self._show_bind_dialog(self._binds[idx], idx)
|
||||
|
||||
def _show_bind_dialog(self, bind: dict | None, idx: int):
|
||||
dialog = Adw.Dialog(title="Edit Binding" if bind else "Add Binding")
|
||||
dialog.set_content_width(440)
|
||||
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
||||
header = Adw.HeaderBar()
|
||||
header.set_title_widget(Adw.WindowTitle(title=dialog.get_title()))
|
||||
box.append(header)
|
||||
|
||||
prefs = Adw.PreferencesPage()
|
||||
prefs.set_vexpand(True)
|
||||
|
||||
# Keysym group
|
||||
keys_grp = Adw.PreferencesGroup(title="Key Combination")
|
||||
|
||||
mod_row = Adw.ActionRow(title="Modifiers")
|
||||
mod_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
||||
mod_box.set_valign(Gtk.Align.CENTER)
|
||||
mod_checks: dict[str, Gtk.CheckButton] = {}
|
||||
cur_keysym = bind["keysym"] if bind else ""
|
||||
keysym_parts_lower = [p.lower() for p in cur_keysym.split("+")[:-1]]
|
||||
for mod in MODIFIERS:
|
||||
cb = Gtk.CheckButton(label=mod)
|
||||
cb.set_active(mod.lower() in keysym_parts_lower)
|
||||
mod_box.append(cb)
|
||||
mod_checks[mod] = cb
|
||||
mod_row.add_suffix(mod_box)
|
||||
keys_grp.add(mod_row)
|
||||
|
||||
key_entry = Adw.EntryRow(title="Key (e.g. T, F1, Return)")
|
||||
bare = cur_keysym.split("+")[-1] if bind else ""
|
||||
key_entry.set_text(bare)
|
||||
keys_grp.add(key_entry)
|
||||
prefs.add(keys_grp)
|
||||
|
||||
# Action group
|
||||
act_grp = Adw.PreferencesGroup(title="Action")
|
||||
act_model = Gtk.StringList.new(NIRI_ACTIONS)
|
||||
act_combo = Adw.ComboRow(title="Action", model=act_model)
|
||||
cur_action = bind["action"] if bind else ""
|
||||
if cur_action in NIRI_ACTIONS:
|
||||
act_combo.set_selected(NIRI_ACTIONS.index(cur_action))
|
||||
act_grp.add(act_combo)
|
||||
|
||||
arg_row = Adw.EntryRow(title="Argument (for spawn, focus-workspace, etc.)")
|
||||
cur_args = (bind.get("action_args") or []) if bind else []
|
||||
arg_row.set_text(" ".join(str(a) for a in cur_args) if cur_args else "")
|
||||
act_grp.add(arg_row)
|
||||
prefs.add(act_grp)
|
||||
|
||||
# Options
|
||||
opt_grp = Adw.PreferencesGroup(title="Options")
|
||||
locked_row = Adw.SwitchRow(title="Allow When Locked")
|
||||
locked_row.set_active(bind["allow_when_locked"] if bind else False)
|
||||
opt_grp.add(locked_row)
|
||||
|
||||
repeat_row = Adw.SwitchRow(title="Repeat")
|
||||
repeat_row.set_active(bind["repeat"] if bind else True)
|
||||
opt_grp.add(repeat_row)
|
||||
prefs.add(opt_grp)
|
||||
|
||||
box.append(prefs)
|
||||
|
||||
save_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
save_row.set_halign(Gtk.Align.END)
|
||||
save_row.set_margin_start(16)
|
||||
save_row.set_margin_end(16)
|
||||
save_row.set_margin_top(8)
|
||||
save_row.set_margin_bottom(16)
|
||||
save_btn = Gtk.Button(label="Save")
|
||||
save_btn.add_css_class("suggested-action")
|
||||
save_btn.add_css_class("pill")
|
||||
|
||||
def _do_save(*_):
|
||||
mods = [m for m, cb in mod_checks.items() if cb.get_active()]
|
||||
key = key_entry.get_text().strip()
|
||||
if not key:
|
||||
return
|
||||
keysym = "+".join(mods + [key])
|
||||
action_idx = act_combo.get_selected()
|
||||
action = NIRI_ACTIONS[action_idx] if action_idx < len(NIRI_ACTIONS) else ""
|
||||
arg_text = arg_row.get_text().strip()
|
||||
if action == "spawn-sh":
|
||||
new_args = [arg_text] if arg_text else []
|
||||
else:
|
||||
import shlex
|
||||
try:
|
||||
new_args = shlex.split(arg_text) if arg_text else []
|
||||
except ValueError:
|
||||
new_args = arg_text.split() if arg_text else []
|
||||
new_bind = _make_bind(
|
||||
keysym,
|
||||
action,
|
||||
new_args,
|
||||
locked_row.get_active(),
|
||||
repeat_row.get_active(),
|
||||
bind.get("extra_props", {}) if bind else {},
|
||||
node=bind.get("_node") if bind else None,
|
||||
)
|
||||
if idx >= 0:
|
||||
self._binds[idx] = new_bind
|
||||
else:
|
||||
self._binds.append(new_bind)
|
||||
self._save_binds()
|
||||
self._rebuild_list()
|
||||
self._refresh_visualizer()
|
||||
dialog.close()
|
||||
|
||||
save_btn.connect("clicked", _do_save)
|
||||
save_row.append(save_btn)
|
||||
box.append(save_row)
|
||||
|
||||
dialog.set_child(box)
|
||||
dialog.present(self._win)
|
||||
|
||||
def _save_binds(self):
|
||||
nodes = self._nodes
|
||||
binds_node = next((n for n in nodes if n.name == "binds"), None)
|
||||
if binds_node is None:
|
||||
binds_node = KdlNode("binds")
|
||||
nodes.append(binds_node)
|
||||
_write_binds_to_node(self._binds, binds_node)
|
||||
self._commit("keybindings")
|
||||
|
||||
# File monitor (live-sync)
|
||||
|
||||
def _start_file_monitor(self):
|
||||
try:
|
||||
from nirimod.kdl_parser import NIRI_CONFIG
|
||||
|
||||
gfile = Gio.File.new_for_path(str(NIRI_CONFIG))
|
||||
monitor = gfile.monitor_file(Gio.FileMonitorFlags.NONE, None)
|
||||
monitor.connect("changed", self._on_config_file_changed)
|
||||
self._file_monitor = monitor
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _on_config_file_changed(self, monitor, file, other_file, event_type):
|
||||
if event_type in (Gio.FileMonitorEvent.CHANGED, Gio.FileMonitorEvent.CREATED):
|
||||
GLib.timeout_add(400, self._reload_from_disk)
|
||||
|
||||
def _reload_from_disk(self):
|
||||
self._win.notify_nodes_changed()
|
||||
return False # don't repeat
|
||||
161
nirimod/pages/environment.py
Normal file
161
nirimod/pages/environment.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""Environment Variables page."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Adw", "1")
|
||||
from gi.repository import Adw, GLib, Gtk
|
||||
|
||||
from nirimod.kdl_parser import KdlNode, find_or_create
|
||||
from nirimod.pages.base import BasePage
|
||||
|
||||
|
||||
class EnvironmentPage(BasePage):
|
||||
def build(self) -> Gtk.Widget:
|
||||
tb, header, _, content = self._make_toolbar_page("Environment")
|
||||
self._content = content
|
||||
|
||||
# Add button has been moved to the page body for better visibility
|
||||
self.refresh()
|
||||
return tb
|
||||
|
||||
def refresh(self):
|
||||
self._rebuild()
|
||||
|
||||
def _get_env_node(self) -> KdlNode:
|
||||
return find_or_create(self._nodes, "environment")
|
||||
|
||||
def _rebuild(self):
|
||||
# Clear existing content
|
||||
while True:
|
||||
child = self._content.get_first_child()
|
||||
if child is None:
|
||||
break
|
||||
self._content.remove(child)
|
||||
|
||||
env = self._get_env_node()
|
||||
entries = list(env.children)
|
||||
|
||||
if not entries:
|
||||
status = Adw.StatusPage(
|
||||
title="No Environment Variables",
|
||||
description="Variables set here will apply to niri and all processes it spawns.",
|
||||
icon_name="preferences-system-symbolic",
|
||||
)
|
||||
|
||||
add_btn = Gtk.Button(label="Add Variable")
|
||||
add_btn.add_css_class("pill")
|
||||
add_btn.add_css_class("suggested-action")
|
||||
add_btn.set_halign(Gtk.Align.CENTER)
|
||||
add_btn.connect("clicked", self._on_add)
|
||||
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
||||
box.set_valign(Gtk.Align.CENTER)
|
||||
box.set_vexpand(True)
|
||||
box.append(status)
|
||||
box.append(add_btn)
|
||||
|
||||
self._content.append(box)
|
||||
else:
|
||||
grp = Adw.PreferencesGroup(
|
||||
title="Environment Variables",
|
||||
description=f"{len(entries)} variable{'s' if len(entries) != 1 else ''} configured",
|
||||
)
|
||||
for i, child in enumerate(entries):
|
||||
row = self._make_row(child, i)
|
||||
grp.add(row)
|
||||
|
||||
self._content.append(grp)
|
||||
|
||||
# Convenient button at the bottom
|
||||
add_btn = Gtk.Button(label="Add Another Variable")
|
||||
add_btn.add_css_class("pill")
|
||||
add_btn.set_halign(Gtk.Align.CENTER)
|
||||
add_btn.set_margin_top(16)
|
||||
add_btn.connect("clicked", self._on_add)
|
||||
self._content.append(add_btn)
|
||||
|
||||
def _make_row(self, node: KdlNode, idx: int) -> Adw.ActionRow:
|
||||
key = node.name
|
||||
val = node.args[0] if node.args else ""
|
||||
|
||||
# Make key bold and distinct
|
||||
key_str = GLib.markup_escape_text(key)
|
||||
val_str = GLib.markup_escape_text(str(val))
|
||||
|
||||
row = Adw.ActionRow(
|
||||
title=f"<b>{key_str}</b>",
|
||||
subtitle=val_str if val_str else "(empty)",
|
||||
)
|
||||
row.set_use_markup(True)
|
||||
edit_btn = Gtk.Button(icon_name="document-edit-symbolic")
|
||||
edit_btn.set_valign(Gtk.Align.CENTER)
|
||||
edit_btn.add_css_class("flat")
|
||||
edit_btn.connect("clicked", lambda *_, i=idx: self._on_edit(i))
|
||||
row.add_suffix(edit_btn)
|
||||
|
||||
del_btn = Gtk.Button(icon_name="user-trash-symbolic")
|
||||
del_btn.set_valign(Gtk.Align.CENTER)
|
||||
del_btn.add_css_class("flat")
|
||||
del_btn.add_css_class("error")
|
||||
del_btn.connect("clicked", lambda *_, i=idx: self._on_delete(i))
|
||||
row.add_suffix(del_btn)
|
||||
return row
|
||||
|
||||
def _on_add(self, *_):
|
||||
self._show_dialog(None, -1)
|
||||
|
||||
def _on_edit(self, idx: int):
|
||||
env = self._get_env_node()
|
||||
if 0 <= idx < len(env.children):
|
||||
self._show_dialog(env.children[idx], idx)
|
||||
|
||||
def _on_delete(self, idx: int):
|
||||
env = self._get_env_node()
|
||||
if 0 <= idx < len(env.children):
|
||||
env.children.pop(idx)
|
||||
self._commit("remove env var")
|
||||
self._rebuild()
|
||||
|
||||
def _show_dialog(self, node: KdlNode | None, idx: int):
|
||||
dialog = Adw.AlertDialog(
|
||||
heading="Environment Variable", body="Set a key=value environment variable."
|
||||
)
|
||||
|
||||
key_entry = Adw.EntryRow(title="Variable Name (e.g. QT_QPA_PLATFORM)")
|
||||
val_entry = Adw.EntryRow(title="Value (e.g. wayland)")
|
||||
if node:
|
||||
key_entry.set_text(node.name)
|
||||
key_entry.set_editable(False) # editing key means replacing the node
|
||||
val_entry.set_text(str(node.args[0]) if node.args else "")
|
||||
|
||||
grp = Adw.PreferencesGroup()
|
||||
grp.add(key_entry)
|
||||
grp.add(val_entry)
|
||||
dialog.set_extra_child(grp)
|
||||
|
||||
dialog.add_response("cancel", "Cancel")
|
||||
dialog.add_response("save", "Save")
|
||||
dialog.set_response_appearance("save", Adw.ResponseAppearance.SUGGESTED)
|
||||
|
||||
def _on_resp(d, r):
|
||||
if r != "save":
|
||||
return
|
||||
key = key_entry.get_text().strip()
|
||||
val = val_entry.get_text()
|
||||
if not key:
|
||||
return
|
||||
env = self._get_env_node()
|
||||
new_node = KdlNode(key, args=[val])
|
||||
if idx >= 0 and 0 <= idx < len(env.children):
|
||||
env.children[idx] = new_node
|
||||
else:
|
||||
env.children.append(new_node)
|
||||
self._commit("env var")
|
||||
self._rebuild()
|
||||
|
||||
dialog.connect("response", _on_resp)
|
||||
dialog.present(self._win)
|
||||
200
nirimod/pages/gestures.py
Normal file
200
nirimod/pages/gestures.py
Normal file
@@ -0,0 +1,200 @@
|
||||
"""Gestures & Miscellaneous settings page."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Adw", "1")
|
||||
from gi.repository import Adw, Gtk
|
||||
|
||||
from nirimod.kdl_parser import (
|
||||
KdlNode,
|
||||
find_or_create,
|
||||
set_node_flag,
|
||||
safe_switch_connect,
|
||||
)
|
||||
from nirimod.pages.base import BasePage
|
||||
|
||||
_CORNERS = [
|
||||
("top-left", "Top-Left", "Moves cursor to the top-left corner"),
|
||||
("top-right", "Top-Right", "Moves cursor to the top-right corner"),
|
||||
("bottom-left", "Bottom-Left", "Moves cursor to the bottom-left corner"),
|
||||
("bottom-right", "Bottom-Right", "Moves cursor to the bottom-right corner"),
|
||||
]
|
||||
|
||||
|
||||
class GesturesPage(BasePage):
|
||||
def build(self) -> Gtk.Widget:
|
||||
tb, _, _, content = self._make_toolbar_page("Gestures & Misc")
|
||||
self._content = content
|
||||
self._build_content()
|
||||
return tb
|
||||
|
||||
def _build_content(self):
|
||||
content = self._content
|
||||
nodes = self._nodes
|
||||
|
||||
# ── Hot Corners ───────────────────────────────────────────────────────
|
||||
hc_grp = Adw.PreferencesGroup(
|
||||
title="Hot Corners",
|
||||
description="Trigger the overview when the cursor touches a screen corner (niri ≥ 25.05)",
|
||||
)
|
||||
gestures_node = next((n for n in nodes if n.name == "gestures"), None)
|
||||
hc_node = gestures_node.get_child("hot-corners") if gestures_node else None
|
||||
hc_off = hc_node is not None and hc_node.get_child("off") is not None
|
||||
hc_enabled = not hc_off
|
||||
|
||||
# Which individual corners are active
|
||||
active_corners: set[str] = set()
|
||||
if hc_node and not hc_off:
|
||||
for corner_key, _, _ in _CORNERS:
|
||||
if hc_node.get_child(corner_key) is not None:
|
||||
active_corners.add(corner_key)
|
||||
|
||||
# ExpanderRow = the enable/disable switch + collapsible corner list
|
||||
hc_expander = Adw.ExpanderRow(
|
||||
title="Enable Hot Corners",
|
||||
subtitle="Expand to choose which corners are active (default: top-left)",
|
||||
)
|
||||
hc_expander.set_expanded(hc_enabled)
|
||||
hc_expander.set_show_enable_switch(True)
|
||||
hc_expander.set_enable_expansion(hc_enabled)
|
||||
|
||||
# Per-corner rows nested inside the expander
|
||||
corner_rows: dict[str, Adw.SwitchRow] = {}
|
||||
for corner_key, corner_label, corner_subtitle in _CORNERS:
|
||||
sr = Adw.SwitchRow(title=corner_label, subtitle=corner_subtitle)
|
||||
is_active = corner_key in active_corners
|
||||
sr.set_active(is_active)
|
||||
safe_switch_connect(
|
||||
sr, is_active,
|
||||
lambda enabled, k=corner_key: self._set_corner(k, enabled),
|
||||
)
|
||||
hc_expander.add_row(sr)
|
||||
corner_rows[corner_key] = sr
|
||||
|
||||
# Wire the expander's enable-switch to the hot corners on/off mutation
|
||||
hc_expander._last_enabled = hc_enabled
|
||||
|
||||
def _on_expander_toggled(expander, _param):
|
||||
val = expander.get_enable_expansion()
|
||||
if val != getattr(expander, "_last_enabled", None):
|
||||
expander._last_enabled = val
|
||||
self._set_hot_corners(val)
|
||||
|
||||
hc_expander.connect("notify::enable-expansion", _on_expander_toggled)
|
||||
|
||||
hc_grp.add(hc_expander)
|
||||
content.append(hc_grp)
|
||||
|
||||
|
||||
# ── Hotkey Overlay ────────────────────────────────────────────────────
|
||||
hko_grp = Adw.PreferencesGroup(title="Hotkey Overlay")
|
||||
hko_node = next((n for n in nodes if n.name == "hotkey-overlay"), None)
|
||||
|
||||
skip_initial = (
|
||||
hko_node is not None and hko_node.get_child("skip-at-startup") is not None
|
||||
)
|
||||
skip_row = Adw.SwitchRow(
|
||||
title="Skip at Startup",
|
||||
subtitle="Don't show the hotkey overlay when niri starts",
|
||||
)
|
||||
skip_row.set_active(skip_initial)
|
||||
safe_switch_connect(skip_row, skip_initial, self._set_skip_hotkey_overlay)
|
||||
hko_grp.add(skip_row)
|
||||
content.append(hko_grp)
|
||||
|
||||
# ── Screenshots ───────────────────────────────────────────────────────
|
||||
ss_grp = Adw.PreferencesGroup(
|
||||
title="Screenshots", description="Path template for saved screenshots"
|
||||
)
|
||||
cur_path = next(
|
||||
(n.args[0] for n in nodes if n.name == "screenshot-path" and n.args),
|
||||
"~/Pictures/Screenshots/Screenshot from %Y-%m-%d %H-%M-%S.png",
|
||||
)
|
||||
path_row = Adw.EntryRow(title="Save Path (strftime format)")
|
||||
path_row.set_text(str(cur_path))
|
||||
path_row.set_show_apply_button(True)
|
||||
path_row.connect("apply", lambda r: self._set_screenshot_path(r.get_text()))
|
||||
ss_grp.add(path_row)
|
||||
content.append(ss_grp)
|
||||
|
||||
# ── Overview ──────────────────────────────────────────────────────────
|
||||
ov_grp = Adw.PreferencesGroup(title="Overview")
|
||||
ov_node = next((n for n in nodes if n.name == "overview"), None)
|
||||
ws_shadow_node = ov_node.get_child("workspace-shadow") if ov_node else None
|
||||
|
||||
ws_shadow_initial = (
|
||||
ws_shadow_node is None or ws_shadow_node.get_child("off") is None
|
||||
)
|
||||
ws_shadow_row = Adw.SwitchRow(
|
||||
title="Workspace Shadow in Overview",
|
||||
subtitle="Show drop shadows under workspaces in overview mode",
|
||||
)
|
||||
ws_shadow_row.set_active(ws_shadow_initial)
|
||||
safe_switch_connect(
|
||||
ws_shadow_row, ws_shadow_initial, self._set_overview_ws_shadow
|
||||
)
|
||||
ov_grp.add(ws_shadow_row)
|
||||
content.append(ov_grp)
|
||||
|
||||
# ── Mutation methods ──────────────────────────────────────────────────────
|
||||
|
||||
def _get_hot_corners_node(self) -> KdlNode:
|
||||
gestures = find_or_create(self._nodes, "gestures")
|
||||
hc = gestures.get_child("hot-corners")
|
||||
if hc is None:
|
||||
hc = KdlNode("hot-corners")
|
||||
hc.leading_trivia = "\n"
|
||||
gestures.children.append(hc)
|
||||
return hc
|
||||
|
||||
def _set_hot_corners(self, enabled: bool):
|
||||
hc = self._get_hot_corners_node()
|
||||
set_node_flag(hc, "off", not enabled)
|
||||
self._commit("gestures hot-corners")
|
||||
|
||||
def _set_corner(self, corner_key: str, enabled: bool):
|
||||
"""Enable or disable an individual hot corner (niri ≥ 25.11)."""
|
||||
hc = self._get_hot_corners_node()
|
||||
# Remove 'off' if it exists — enabling a corner implicitly enables hot corners
|
||||
set_node_flag(hc, "off", False)
|
||||
set_node_flag(hc, corner_key, enabled)
|
||||
self._commit(f"hot-corner {corner_key}")
|
||||
|
||||
def _set_skip_hotkey_overlay(self, skip: bool):
|
||||
nodes = self._nodes
|
||||
hko = next((n for n in nodes if n.name == "hotkey-overlay"), None)
|
||||
if hko is None:
|
||||
hko = KdlNode("hotkey-overlay")
|
||||
nodes.append(hko)
|
||||
set_node_flag(hko, "skip-at-startup", skip)
|
||||
self._commit("hotkey-overlay skip-at-startup")
|
||||
|
||||
def _set_screenshot_path(self, path: str):
|
||||
nodes = self._nodes
|
||||
existing = next((n for n in nodes if n.name == "screenshot-path"), None)
|
||||
if path.strip():
|
||||
if existing:
|
||||
existing.args = [path.strip()]
|
||||
else:
|
||||
nodes.append(KdlNode("screenshot-path", args=[path.strip()]))
|
||||
elif existing:
|
||||
nodes.remove(existing)
|
||||
self._commit("screenshot-path")
|
||||
|
||||
def _set_overview_ws_shadow(self, enabled: bool):
|
||||
ov = find_or_create(self._nodes, "overview")
|
||||
ws_shadow = ov.get_child("workspace-shadow")
|
||||
if ws_shadow is None:
|
||||
ws_shadow = KdlNode("workspace-shadow")
|
||||
ov.children.append(ws_shadow)
|
||||
set_node_flag(ws_shadow, "off", not enabled)
|
||||
self._commit("overview workspace-shadow")
|
||||
|
||||
def refresh(self):
|
||||
for child in list(self._content):
|
||||
self._content.remove(child)
|
||||
self._build_content()
|
||||
380
nirimod/pages/input_page.py
Normal file
380
nirimod/pages/input_page.py
Normal file
@@ -0,0 +1,380 @@
|
||||
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Adw", "1")
|
||||
from gi.repository import Adw, Gtk
|
||||
|
||||
from nirimod import niri_ipc
|
||||
from nirimod.kdl_parser import (
|
||||
KdlNode,
|
||||
find_or_create,
|
||||
set_child_arg,
|
||||
set_node_flag,
|
||||
safe_switch_connect,
|
||||
)
|
||||
from nirimod.pages.base import BasePage
|
||||
|
||||
ACCEL_PROFILES = ["default", "flat", "adaptive"]
|
||||
SCROLL_METHODS_TP = ["two-finger", "edge", "on-button-down", "no-scroll"]
|
||||
CLICK_METHODS = ["button-areas", "clickfinger"]
|
||||
|
||||
|
||||
class InputPage(BasePage):
|
||||
def build(self) -> Gtk.Widget:
|
||||
tb, _, _, content = self._make_toolbar_page("Input")
|
||||
self._content = content
|
||||
self._build_content()
|
||||
return tb
|
||||
|
||||
def _build_content(self):
|
||||
content = self._content
|
||||
nodes = self._nodes
|
||||
|
||||
kb_expander = Adw.ExpanderRow(title="Keyboard", subtitle="XKB options & key repeat")
|
||||
kb_expander.add_css_class("nm-expander")
|
||||
|
||||
kb_node = find_or_create(nodes, "input", "keyboard")
|
||||
xkb_node = kb_node.get_child("xkb") or KdlNode("xkb")
|
||||
|
||||
fields = [
|
||||
("layout", "Layout", "e.g. us,ru"),
|
||||
("variant", "Variant", "e.g. dvorak"),
|
||||
("model", "Model", ""),
|
||||
("options", "Options", "e.g. grp:win_space_toggle"),
|
||||
("rules", "Rules", ""),
|
||||
]
|
||||
self._xkb_entries: dict[str, Adw.EntryRow] = {}
|
||||
for key, title, ph in fields:
|
||||
row = Adw.EntryRow(title=title)
|
||||
row.set_show_apply_button(True)
|
||||
val = xkb_node.child_arg(key) if xkb_node else None
|
||||
if val:
|
||||
row.set_text(str(val))
|
||||
row.set_input_purpose(Gtk.InputPurpose.FREE_FORM)
|
||||
row.connect("apply", lambda r, k=key: self._set_xkb(k, r.get_text()))
|
||||
kb_expander.add_row(row)
|
||||
self._xkb_entries[key] = row
|
||||
|
||||
delay_adj = Gtk.Adjustment(
|
||||
value=kb_node.child_arg("repeat-delay") or 600,
|
||||
lower=100, upper=3000, step_increment=50,
|
||||
)
|
||||
delay_row = Adw.SpinRow(title="Repeat Delay (ms)", adjustment=delay_adj, digits=0)
|
||||
delay_row.connect("notify::value", lambda r, _: self._set_kb("repeat-delay", int(r.get_value())))
|
||||
kb_expander.add_row(delay_row)
|
||||
|
||||
rate_adj = Gtk.Adjustment(
|
||||
value=kb_node.child_arg("repeat-rate") or 25,
|
||||
lower=1, upper=200, step_increment=1,
|
||||
)
|
||||
rate_row = Adw.SpinRow(title="Repeat Rate (keys/sec)", adjustment=rate_adj, digits=0)
|
||||
rate_row.connect("notify::value", lambda r, _: self._set_kb("repeat-rate", int(r.get_value())))
|
||||
kb_expander.add_row(rate_row)
|
||||
|
||||
numlock_row = Adw.SwitchRow(title="Enable Num Lock on Startup")
|
||||
nl_init = kb_node.get_child("numlock") is not None
|
||||
numlock_row.set_active(nl_init)
|
||||
safe_switch_connect(numlock_row, nl_init, self._toggle_numlock)
|
||||
kb_expander.add_row(numlock_row)
|
||||
|
||||
kb_grp = Adw.PreferencesGroup()
|
||||
kb_grp.add(kb_expander)
|
||||
content.append(kb_grp)
|
||||
|
||||
# focus / pointer
|
||||
focus_grp = Adw.PreferencesGroup(title="Pointer Behavior")
|
||||
input_node = find_or_create(nodes, "input")
|
||||
|
||||
ffm_row = Adw.SwitchRow(title="Focus Follows Mouse")
|
||||
ffm_node = input_node.get_child("focus-follows-mouse")
|
||||
ffm_row._last_active = ffm_node is not None
|
||||
ffm_row.set_active(ffm_node is not None)
|
||||
|
||||
def _on_ffm_toggled(r, _):
|
||||
new_val = r.get_active()
|
||||
if new_val != getattr(r, "_last_active", None):
|
||||
r._last_active = new_val
|
||||
self._toggle_ffm(new_val)
|
||||
|
||||
ffm_row.connect("notify::active", _on_ffm_toggled)
|
||||
focus_grp.add(ffm_row)
|
||||
|
||||
scroll_val = 33
|
||||
if ffm_node:
|
||||
vRaw = ffm_node.props.get("max-scroll-amount")
|
||||
if vRaw is not None:
|
||||
try:
|
||||
scroll_val = int(float(str(vRaw).replace("%", "").strip()))
|
||||
except ValueError:
|
||||
pass
|
||||
self._last_scroll_val = scroll_val
|
||||
scroll_adj = Gtk.Adjustment(value=scroll_val, lower=0, upper=100, step_increment=1)
|
||||
scroll_pct_row = Adw.SpinRow(
|
||||
title="Max Scroll Amount (%)", subtitle="0% = only fully visible windows",
|
||||
adjustment=scroll_adj, digits=0,
|
||||
)
|
||||
scroll_pct_row.set_sensitive(ffm_node is not None)
|
||||
self._scroll_pct_row = scroll_pct_row
|
||||
scroll_pct_row._last_val = scroll_val
|
||||
|
||||
def _on_scroll_pct_changed(r, _):
|
||||
new_val = int(r.get_value())
|
||||
if new_val != getattr(r, "_last_val", None):
|
||||
r._last_val = new_val
|
||||
self._set_ffm_scroll(new_val)
|
||||
|
||||
scroll_pct_row.connect("notify::value", _on_scroll_pct_changed)
|
||||
focus_grp.add(scroll_pct_row)
|
||||
|
||||
warp_init = input_node.get_child("warp-mouse-to-focus") is not None
|
||||
warp_row = Adw.SwitchRow(title="Warp Mouse to Focus")
|
||||
warp_row.set_active(warp_init)
|
||||
safe_switch_connect(warp_row, warp_init,
|
||||
lambda enabled: self._toggle_input_flag("warp-mouse-to-focus", enabled))
|
||||
focus_grp.add(warp_row)
|
||||
content.append(focus_grp)
|
||||
|
||||
# touchpad
|
||||
tp_expander = Adw.ExpanderRow(title="Touchpad")
|
||||
tp_expander.add_css_class("nm-expander")
|
||||
has_tp = niri_ipc.has_touchpad()
|
||||
if not has_tp:
|
||||
tp_expander.set_subtitle("No touchpad detected")
|
||||
tp_expander.set_sensitive(False)
|
||||
|
||||
tp_node = find_or_create(nodes, "input", "touchpad")
|
||||
|
||||
def tp_switch(key, label, subtitle=""):
|
||||
r = Adw.SwitchRow(title=label, subtitle=subtitle)
|
||||
ini = tp_node.get_child(key) is not None
|
||||
r.set_active(ini)
|
||||
safe_switch_connect(r, ini, lambda enabled, k=key: self._set_tp_flag(k, enabled))
|
||||
return r
|
||||
|
||||
def tp_bool_switch(key, label, default_active=True, subtitle=""):
|
||||
r = Adw.SwitchRow(title=label, subtitle=subtitle)
|
||||
node = tp_node.get_child(key)
|
||||
if node is not None and node.args:
|
||||
ini = bool(node.args[0])
|
||||
else:
|
||||
ini = default_active
|
||||
r.set_active(ini)
|
||||
safe_switch_connect(r, ini, lambda enabled, k=key: self._set_tp(k, enabled))
|
||||
return r
|
||||
|
||||
tp_expander.add_row(tp_switch("tap", "Tap to Click"))
|
||||
tp_expander.add_row(tp_switch("dwt", "Disable While Typing"))
|
||||
tp_expander.add_row(tp_switch("dwtp", "Disable While Trackpointing"))
|
||||
tp_expander.add_row(tp_switch("natural-scroll", "Natural Scroll"))
|
||||
tp_expander.add_row(tp_bool_switch("drag", "Tap Drag"))
|
||||
tp_expander.add_row(tp_switch("drag-lock", "Tap Drag Lock"))
|
||||
tp_expander.add_row(tp_switch("disabled-on-external-mouse", "Disable on External Mouse"))
|
||||
|
||||
spd_adj = Gtk.Adjustment(value=float(tp_node.child_arg("accel-speed") or 0.0),
|
||||
lower=-1.0, upper=1.0, step_increment=0.05)
|
||||
spd_row = Adw.SpinRow(title="Accel Speed", adjustment=spd_adj, digits=2)
|
||||
spd_row.connect("notify::value", lambda r, _: self._set_tp("accel-speed", r.get_value()))
|
||||
tp_expander.add_row(spd_row)
|
||||
|
||||
ap_model = Gtk.StringList.new(ACCEL_PROFILES)
|
||||
ap_row = Adw.ComboRow(title="Accel Profile", model=ap_model)
|
||||
cur_ap = tp_node.child_arg("accel-profile") or "default"
|
||||
if cur_ap in ACCEL_PROFILES:
|
||||
ap_row.set_selected(ACCEL_PROFILES.index(cur_ap))
|
||||
ap_row.connect("notify::selected",
|
||||
lambda r, _: self._set_tp("accel-profile", ACCEL_PROFILES[r.get_selected()]))
|
||||
tp_expander.add_row(ap_row)
|
||||
|
||||
sm_model = Gtk.StringList.new(SCROLL_METHODS_TP)
|
||||
sm_row = Adw.ComboRow(title="Scroll Method", model=sm_model)
|
||||
cur_sm = tp_node.child_arg("scroll-method") or "two-finger"
|
||||
if cur_sm in SCROLL_METHODS_TP:
|
||||
sm_row.set_selected(SCROLL_METHODS_TP.index(cur_sm))
|
||||
sm_row.connect("notify::selected",
|
||||
lambda r, _: self._set_tp("scroll-method", SCROLL_METHODS_TP[r.get_selected()]))
|
||||
tp_expander.add_row(sm_row)
|
||||
|
||||
cm_model = Gtk.StringList.new(CLICK_METHODS)
|
||||
cm_row = Adw.ComboRow(title="Click Method", model=cm_model)
|
||||
cur_cm = tp_node.child_arg("click-method") or "button-areas"
|
||||
if cur_cm in CLICK_METHODS:
|
||||
cm_row.set_selected(CLICK_METHODS.index(cur_cm))
|
||||
cm_row.connect("notify::selected",
|
||||
lambda r, _: self._set_tp("click-method", CLICK_METHODS[r.get_selected()]))
|
||||
tp_expander.add_row(cm_row)
|
||||
|
||||
tp_grp = Adw.PreferencesGroup()
|
||||
tp_grp.add(tp_expander)
|
||||
content.append(tp_grp)
|
||||
|
||||
# mouse
|
||||
m_expander = Adw.ExpanderRow(title="Mouse")
|
||||
m_expander.add_css_class("nm-expander")
|
||||
m_node = find_or_create(nodes, "input", "mouse")
|
||||
|
||||
m_nat = Adw.SwitchRow(title="Natural Scroll")
|
||||
mn_init = m_node.get_child("natural-scroll") is not None
|
||||
m_nat.set_active(mn_init)
|
||||
safe_switch_connect(m_nat, mn_init, lambda enabled: self._set_m_flag("natural-scroll", enabled))
|
||||
m_expander.add_row(m_nat)
|
||||
|
||||
m_spd_adj = Gtk.Adjustment(value=float(m_node.child_arg("accel-speed") or 0.0),
|
||||
lower=-1.0, upper=1.0, step_increment=0.05)
|
||||
m_spd_row = Adw.SpinRow(title="Accel Speed", adjustment=m_spd_adj, digits=2)
|
||||
m_spd_row.connect("notify::value", lambda r, _: self._set_m("accel-speed", r.get_value()))
|
||||
m_expander.add_row(m_spd_row)
|
||||
|
||||
m_ap_model = Gtk.StringList.new(ACCEL_PROFILES)
|
||||
m_ap_row = Adw.ComboRow(title="Accel Profile", model=m_ap_model)
|
||||
cur_m_ap = m_node.child_arg("accel-profile") or "default"
|
||||
if cur_m_ap in ACCEL_PROFILES:
|
||||
m_ap_row.set_selected(ACCEL_PROFILES.index(cur_m_ap))
|
||||
m_ap_row.connect("notify::selected",
|
||||
lambda r, _: self._set_m("accel-profile", ACCEL_PROFILES[r.get_selected()]))
|
||||
m_expander.add_row(m_ap_row)
|
||||
|
||||
m_grp = Adw.PreferencesGroup()
|
||||
m_grp.add(m_expander)
|
||||
content.append(m_grp)
|
||||
|
||||
# cursor
|
||||
cursor_grp = Adw.PreferencesGroup(title="Cursor")
|
||||
cursor_node = next((n for n in nodes if n.name == "cursor"), None)
|
||||
|
||||
size_val = int(cursor_node.child_arg("xcursor-size") or 24) if cursor_node else 24
|
||||
size_adj = Gtk.Adjustment(value=size_val, lower=8, upper=256, step_increment=2)
|
||||
size_row = Adw.SpinRow(title="Cursor Size (px)", adjustment=size_adj, digits=0)
|
||||
size_row.connect("notify::value",
|
||||
lambda r, _: self._set_cursor("xcursor-size", int(r.get_value())))
|
||||
cursor_grp.add(size_row)
|
||||
|
||||
hide_val = int(cursor_node.child_arg("hide-after-inactive-ms") or 0) if cursor_node else 0
|
||||
hide_adj = Gtk.Adjustment(value=hide_val, lower=0, upper=60000, step_increment=500)
|
||||
hide_row = Adw.SpinRow(title="Hide After Inactive (ms)", subtitle="0 = never hide",
|
||||
adjustment=hide_adj, digits=0)
|
||||
hide_row.connect("notify::value",
|
||||
lambda r, _: self._set_cursor("hide-after-inactive-ms", int(r.get_value())))
|
||||
cursor_grp.add(hide_row)
|
||||
|
||||
theme_val = str(cursor_node.child_arg("xcursor-theme") or "") if cursor_node else ""
|
||||
theme_row = Adw.EntryRow(title="Cursor Theme (e.g. Adwaita)")
|
||||
theme_row.set_text(theme_val)
|
||||
theme_row.set_show_apply_button(True)
|
||||
theme_row.connect("apply", lambda r: self._set_cursor_theme(r.get_text()))
|
||||
cursor_grp.add(theme_row)
|
||||
content.append(cursor_grp)
|
||||
|
||||
|
||||
def _get_kb_node(self):
|
||||
return find_or_create(self._nodes, "input", "keyboard")
|
||||
|
||||
def _get_xkb_node(self):
|
||||
kb = self._get_kb_node()
|
||||
xkb = kb.get_child("xkb")
|
||||
if xkb is None:
|
||||
xkb = KdlNode("xkb")
|
||||
kb.children.insert(0, xkb)
|
||||
return xkb
|
||||
|
||||
def _set_xkb(self, key: str, value: str):
|
||||
xkb = self._get_xkb_node()
|
||||
if value.strip():
|
||||
set_child_arg(xkb, key, value.strip())
|
||||
else:
|
||||
from nirimod.kdl_parser import remove_child
|
||||
remove_child(xkb, key)
|
||||
self._commit(f"keyboard xkb {key}")
|
||||
|
||||
def _set_kb(self, key: str, value):
|
||||
set_child_arg(self._get_kb_node(), key, value)
|
||||
self._commit(f"keyboard {key}")
|
||||
|
||||
def _toggle_numlock(self, enabled: bool):
|
||||
set_node_flag(self._get_kb_node(), "numlock", enabled)
|
||||
self._commit("keyboard numlock")
|
||||
|
||||
def _get_input_node(self):
|
||||
return find_or_create(self._nodes, "input")
|
||||
|
||||
def _toggle_ffm(self, enabled: bool):
|
||||
inp = self._get_input_node()
|
||||
existing = inp.get_child("focus-follows-mouse")
|
||||
if enabled:
|
||||
if existing is None:
|
||||
new_ffm = KdlNode(name="focus-follows-mouse")
|
||||
if hasattr(self, "_last_scroll_val"):
|
||||
new_ffm.props["max-scroll-amount"] = f"{self._last_scroll_val}%"
|
||||
inp.children.insert(0, new_ffm)
|
||||
else:
|
||||
if existing is not None:
|
||||
inp.children.remove(existing)
|
||||
if hasattr(self, "_scroll_pct_row"):
|
||||
self._scroll_pct_row.set_sensitive(enabled)
|
||||
self._commit("focus-follows-mouse")
|
||||
|
||||
def _set_ffm_scroll(self, pct: int):
|
||||
inp = self._get_input_node()
|
||||
ffm = inp.get_child("focus-follows-mouse")
|
||||
if ffm is None:
|
||||
ffm = KdlNode("focus-follows-mouse")
|
||||
inp.children.append(ffm)
|
||||
ffm.props["max-scroll-amount"] = f"{pct}%"
|
||||
self._commit("ffm scroll amount")
|
||||
|
||||
def _toggle_input_flag(self, key: str, enabled: bool):
|
||||
set_node_flag(self._get_input_node(), key, enabled)
|
||||
self._commit(f"input {key}")
|
||||
|
||||
def _get_tp_node(self):
|
||||
return find_or_create(self._nodes, "input", "touchpad")
|
||||
|
||||
def _set_tp_flag(self, key: str, enabled: bool):
|
||||
set_node_flag(self._get_tp_node(), key, enabled)
|
||||
self._commit(f"touchpad {key}")
|
||||
|
||||
def _set_tp(self, key: str, value):
|
||||
set_child_arg(self._get_tp_node(), key, value)
|
||||
self._commit(f"touchpad {key}")
|
||||
|
||||
def _get_m_node(self):
|
||||
return find_or_create(self._nodes, "input", "mouse")
|
||||
|
||||
def _set_m_flag(self, key: str, enabled: bool):
|
||||
set_node_flag(self._get_m_node(), key, enabled)
|
||||
self._commit(f"mouse {key}")
|
||||
|
||||
def _set_m(self, key: str, value):
|
||||
set_child_arg(self._get_m_node(), key, value)
|
||||
self._commit(f"mouse {key}")
|
||||
|
||||
def _get_cursor_node(self):
|
||||
existing = next((n for n in self._nodes if n.name == "cursor"), None)
|
||||
if existing is None:
|
||||
existing = KdlNode("cursor")
|
||||
self._nodes.append(existing)
|
||||
return existing
|
||||
|
||||
def _set_cursor(self, key: str, value):
|
||||
set_child_arg(self._get_cursor_node(), key, value)
|
||||
self._commit(f"cursor {key}")
|
||||
|
||||
def _set_cursor_theme(self, theme: str):
|
||||
cur = self._get_cursor_node()
|
||||
if theme.strip():
|
||||
set_child_arg(cur, "xcursor-theme", theme.strip())
|
||||
else:
|
||||
from nirimod.kdl_parser import remove_child
|
||||
remove_child(cur, "xcursor-theme")
|
||||
self._commit("cursor xcursor-theme")
|
||||
|
||||
def refresh(self):
|
||||
child = self._content.get_first_child()
|
||||
while child:
|
||||
next_child = child.get_next_sibling()
|
||||
self._content.remove(child)
|
||||
child = next_child
|
||||
self._build_content()
|
||||
284
nirimod/pages/layout.py
Normal file
284
nirimod/pages/layout.py
Normal file
@@ -0,0 +1,284 @@
|
||||
"""Layout settings page."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Adw", "1")
|
||||
from gi.repository import Adw, Gtk
|
||||
|
||||
from nirimod.kdl_parser import KdlNode, find_or_create, set_child_arg, remove_child
|
||||
from nirimod.pages.base import BasePage
|
||||
|
||||
|
||||
CENTER_OPTIONS = ["never", "always", "on-overflow"]
|
||||
|
||||
|
||||
class LayoutPage(BasePage):
|
||||
def build(self) -> Gtk.Widget:
|
||||
tb, _, _, content = self._make_toolbar_page("Layout")
|
||||
self._content = content
|
||||
self._build_content()
|
||||
return tb
|
||||
|
||||
def _build_content(self):
|
||||
content = self._content
|
||||
nodes = self._nodes
|
||||
layout = find_or_create(nodes, "layout")
|
||||
|
||||
basic_grp = Adw.PreferencesGroup(title="General")
|
||||
|
||||
gaps_val = int(layout.child_arg("gaps") or 16)
|
||||
gaps_adj = Gtk.Adjustment(value=gaps_val, lower=0, upper=200, step_increment=2)
|
||||
gaps_row = Adw.SpinRow(title="Window Gaps (px)", adjustment=gaps_adj, digits=0)
|
||||
|
||||
gaps_row._last_val = gaps_val
|
||||
|
||||
def _on_gaps_changed(r, _):
|
||||
new_val = int(r.get_value())
|
||||
if new_val != getattr(r, "_last_val", None):
|
||||
r._last_val = new_val
|
||||
self._set_layout("gaps", new_val)
|
||||
|
||||
gaps_row.connect("notify::value", _on_gaps_changed)
|
||||
basic_grp.add(gaps_row)
|
||||
|
||||
cfc_model = Gtk.StringList.new(CENTER_OPTIONS)
|
||||
cfc_row = Adw.ComboRow(title="Center Focused Column", model=cfc_model)
|
||||
cur_cfc = layout.child_arg("center-focused-column") or "never"
|
||||
if cur_cfc in CENTER_OPTIONS:
|
||||
cfc_row.set_selected(CENTER_OPTIONS.index(cur_cfc))
|
||||
cfc_row.connect(
|
||||
"notify::selected",
|
||||
lambda r, _: self._set_layout(
|
||||
"center-focused-column", CENTER_OPTIONS[r.get_selected()]
|
||||
),
|
||||
)
|
||||
basic_grp.add(cfc_row)
|
||||
|
||||
prefer_csd_row = Adw.SwitchRow(
|
||||
title="Prefer No CSD", subtitle="Ask apps to omit client-side decorations"
|
||||
)
|
||||
prefer_csd_row.set_active(any(n.name == "prefer-no-csd" for n in nodes))
|
||||
prefer_csd_row.connect(
|
||||
"notify::active",
|
||||
lambda r, _: self._toggle_top("prefer-no-csd", r.get_active()),
|
||||
)
|
||||
basic_grp.add(prefer_csd_row)
|
||||
|
||||
bg_color_val = str(layout.child_arg("background-color") or "transparent")
|
||||
bg_row = Adw.EntryRow(title="Background Color (e.g. transparent, #000000)")
|
||||
bg_row.set_text(bg_color_val)
|
||||
bg_row.set_show_apply_button(True)
|
||||
bg_row.connect(
|
||||
"apply",
|
||||
lambda r: self._set_layout("background-color", r.get_text().strip()),
|
||||
)
|
||||
basic_grp.add(bg_row)
|
||||
|
||||
content.append(basic_grp)
|
||||
|
||||
dcw_grp = Adw.PreferencesGroup(title="Default Column Width")
|
||||
dcw_node = layout.get_child("default-column-width")
|
||||
|
||||
prop_val = 0.5
|
||||
fixed_val = 800
|
||||
use_fixed = False
|
||||
|
||||
if dcw_node:
|
||||
fc = dcw_node.get_child("fixed")
|
||||
pc = dcw_node.get_child("proportion")
|
||||
if fc and fc.args:
|
||||
fixed_val = int(fc.args[0])
|
||||
use_fixed = True
|
||||
elif pc and pc.args:
|
||||
prop_val = float(pc.args[0])
|
||||
|
||||
mode_model = Gtk.StringList.new(["Proportion", "Fixed (px)"])
|
||||
mode_row = Adw.ComboRow(title="Mode", model=mode_model)
|
||||
mode_row.set_selected(1 if use_fixed else 0)
|
||||
dcw_grp.add(mode_row)
|
||||
|
||||
prop_adj = Gtk.Adjustment(value=prop_val, lower=0.05, upper=1.0, step_increment=0.05)
|
||||
prop_spin = Gtk.SpinButton(adjustment=prop_adj, digits=2, climb_rate=1)
|
||||
prop_spin.set_valign(Gtk.Align.CENTER)
|
||||
prop_spin.connect("value-changed", lambda s: self._set_dcw_proportion(s.get_value()))
|
||||
prop_row = Adw.ActionRow(title="Proportion")
|
||||
prop_row.add_suffix(prop_spin)
|
||||
prop_row.set_visible(not use_fixed)
|
||||
dcw_grp.add(prop_row)
|
||||
|
||||
fixed_adj = Gtk.Adjustment(value=fixed_val, lower=100, upper=7680, step_increment=10)
|
||||
fixed_spin = Gtk.SpinButton(adjustment=fixed_adj, digits=0, climb_rate=1)
|
||||
fixed_spin.set_valign(Gtk.Align.CENTER)
|
||||
fixed_spin.connect("value-changed", lambda s: self._set_dcw_fixed(int(s.get_value())))
|
||||
fixed_row = Adw.ActionRow(title="Fixed Width (px)")
|
||||
fixed_row.add_suffix(fixed_spin)
|
||||
fixed_row.set_visible(use_fixed)
|
||||
dcw_grp.add(fixed_row)
|
||||
|
||||
def _on_mode_changed(r, _):
|
||||
is_fixed = r.get_selected() == 1
|
||||
prop_row.set_visible(not is_fixed)
|
||||
fixed_row.set_visible(is_fixed)
|
||||
if is_fixed:
|
||||
self._set_dcw_fixed(int(fixed_spin.get_value()))
|
||||
else:
|
||||
self._set_dcw_proportion(prop_spin.get_value())
|
||||
|
||||
mode_row.connect("notify::selected", _on_mode_changed)
|
||||
content.append(dcw_grp)
|
||||
|
||||
pw_grp = Adw.PreferencesGroup(title="Preset Column Widths (proportions)")
|
||||
pw_grp.set_description("Cycled through by Mod+R")
|
||||
pcw_node = layout.get_child("preset-column-widths")
|
||||
presets = []
|
||||
if pcw_node:
|
||||
for c in pcw_node.children:
|
||||
if c.name == "proportion" and c.args:
|
||||
presets.append(float(c.args[0]))
|
||||
self._preset_spins: list[Gtk.SpinButton] = []
|
||||
|
||||
for val in presets or [0.333, 0.5, 0.667]:
|
||||
self._add_preset_row(pw_grp, val)
|
||||
add_preset_btn = Gtk.Button(label="Add Preset")
|
||||
add_preset_btn.add_css_class("flat")
|
||||
add_preset_btn.connect("clicked", lambda *_: self._add_preset_row(pw_grp, 0.5))
|
||||
pw_grp.set_header_suffix(add_preset_btn)
|
||||
content.append(pw_grp)
|
||||
|
||||
struts_grp = Adw.PreferencesGroup(title="Struts (outer gaps, px)")
|
||||
struts_node = layout.get_child("struts")
|
||||
for side in ["left", "right", "top", "bottom"]:
|
||||
val = int(struts_node.child_arg(side) or 0) if struts_node else 0
|
||||
adj = Gtk.Adjustment(value=val, lower=0, upper=500, step_increment=4)
|
||||
row = Adw.SpinRow(title=side.capitalize(), adjustment=adj, digits=0)
|
||||
|
||||
row._last_val = val
|
||||
|
||||
def _on_strut_changed(r, _, s=side):
|
||||
new_val = int(r.get_value())
|
||||
if new_val != getattr(r, "_last_val", None):
|
||||
r._last_val = new_val
|
||||
self._set_strut(s, new_val)
|
||||
|
||||
row.connect("notify::value", _on_strut_changed)
|
||||
struts_grp.add(row)
|
||||
content.append(struts_grp)
|
||||
|
||||
def _add_preset_row(self, grp: Adw.PreferencesGroup, val: float):
|
||||
spin_adj = Gtk.Adjustment(value=val, lower=0.05, upper=1.0, step_increment=0.05)
|
||||
spin = Gtk.SpinButton(adjustment=spin_adj, digits=3, climb_rate=1)
|
||||
spin.set_valign(Gtk.Align.CENTER)
|
||||
self._preset_spins.append(spin)
|
||||
|
||||
row = Adw.ActionRow(title=f"Proportion {val:.3f}")
|
||||
spin.connect(
|
||||
"value-changed",
|
||||
lambda s, r=row: (
|
||||
r.set_title(f"Proportion {s.get_value():.3f}"),
|
||||
self._save_presets(),
|
||||
),
|
||||
)
|
||||
|
||||
del_btn = Gtk.Button(icon_name="user-trash-symbolic")
|
||||
del_btn.set_valign(Gtk.Align.CENTER)
|
||||
del_btn.add_css_class("flat")
|
||||
del_btn.add_css_class("error")
|
||||
|
||||
def _on_delete(s=spin):
|
||||
self._preset_spins.remove(s)
|
||||
grp.remove(row)
|
||||
self._save_presets()
|
||||
|
||||
del_btn.connect("clicked", lambda *_: _on_delete())
|
||||
row.add_suffix(spin)
|
||||
row.add_suffix(del_btn)
|
||||
grp.add(row)
|
||||
|
||||
def _save_presets(self):
|
||||
layout = find_or_create(self._nodes, "layout")
|
||||
pcw = layout.get_child("preset-column-widths")
|
||||
if pcw is None:
|
||||
pcw = KdlNode("preset-column-widths")
|
||||
layout.children.append(pcw)
|
||||
new_children = []
|
||||
for i, s in enumerate(self._preset_spins):
|
||||
if i < len(pcw.children):
|
||||
child = pcw.children[i]
|
||||
child.name = "proportion"
|
||||
child.args = [round(s.get_value(), 5)]
|
||||
new_children.append(child)
|
||||
else:
|
||||
new_children.append(KdlNode("proportion", args=[round(s.get_value(), 5)]))
|
||||
|
||||
salvaged = ""
|
||||
for i in range(len(self._preset_spins), len(pcw.children)):
|
||||
salvaged += pcw.children[i].leading_trivia
|
||||
if salvaged and new_children:
|
||||
new_children[-1].trailing_trivia += salvaged
|
||||
|
||||
pcw.children = new_children
|
||||
self._commit("preset column widths")
|
||||
|
||||
def _set_layout(self, key: str, value):
|
||||
layout = find_or_create(self._nodes, "layout")
|
||||
set_child_arg(layout, key, value)
|
||||
self._commit(f"layout {key}")
|
||||
|
||||
def _set_dcw_proportion(self, val: float):
|
||||
layout = find_or_create(self._nodes, "layout")
|
||||
dcw = layout.get_child("default-column-width")
|
||||
if dcw is None:
|
||||
dcw = KdlNode("default-column-width")
|
||||
layout.children.append(dcw)
|
||||
dcw.children = [KdlNode("proportion", args=[round(val, 4)])]
|
||||
self._commit("default column width proportion")
|
||||
|
||||
def _set_dcw_fixed(self, px: int):
|
||||
layout = find_or_create(self._nodes, "layout")
|
||||
dcw = layout.get_child("default-column-width")
|
||||
if dcw is None:
|
||||
dcw = KdlNode("default-column-width")
|
||||
layout.children.append(dcw)
|
||||
dcw.children = [KdlNode("fixed", args=[px])]
|
||||
self._commit("default column width fixed")
|
||||
|
||||
def _set_strut(self, side: str, val: int):
|
||||
layout = find_or_create(self._nodes, "layout")
|
||||
struts = layout.get_child("struts")
|
||||
if struts is None:
|
||||
struts = KdlNode("struts")
|
||||
layout.children.append(struts)
|
||||
if val > 0:
|
||||
set_child_arg(struts, side, val)
|
||||
else:
|
||||
remove_child(struts, side)
|
||||
self._commit(f"strut {side}")
|
||||
|
||||
def _toggle_top(self, key: str, enabled: bool):
|
||||
nodes = self._nodes
|
||||
existing = next((n for n in reversed(nodes) if n.name == key), None)
|
||||
|
||||
app_state = self._win.app_state
|
||||
if enabled and not existing:
|
||||
cache = getattr(app_state, "_removed_top_nodes", {})
|
||||
if key in cache:
|
||||
idx, node = cache[key]
|
||||
nodes.insert(min(idx, len(nodes)), node)
|
||||
else:
|
||||
nodes.append(KdlNode(key))
|
||||
elif not enabled and existing:
|
||||
if not hasattr(app_state, "_removed_top_nodes"):
|
||||
app_state._removed_top_nodes = {}
|
||||
app_state._removed_top_nodes[key] = (nodes.index(existing), existing)
|
||||
nodes.remove(existing)
|
||||
|
||||
self._commit(f"toggle {key}")
|
||||
|
||||
def refresh(self):
|
||||
for child in list(self._content):
|
||||
self._content.remove(child)
|
||||
self._build_content()
|
||||
746
nirimod/pages/outputs.py
Normal file
746
nirimod/pages/outputs.py
Normal file
@@ -0,0 +1,746 @@
|
||||
"""Outputs / Monitors page with interactive canvas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Adw", "1")
|
||||
|
||||
|
||||
from gi.repository import Adw, Gtk
|
||||
|
||||
from nirimod import niri_ipc
|
||||
from nirimod.kdl_parser import KdlNode, set_child_arg, safe_switch_connect
|
||||
from nirimod.pages.base import BasePage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nirimod.window import NiriModWindow
|
||||
|
||||
TRANSFORMS = [
|
||||
"normal",
|
||||
"90",
|
||||
"180",
|
||||
"270",
|
||||
"flipped",
|
||||
"flipped-90",
|
||||
"flipped-180",
|
||||
"flipped-270",
|
||||
]
|
||||
|
||||
|
||||
class OutputsPage(BasePage):
|
||||
def __init__(self, window: "NiriModWindow"):
|
||||
super().__init__(window)
|
||||
self._outputs: list[dict] = []
|
||||
self._current_out: dict | None = None
|
||||
|
||||
self._canvas: Gtk.DrawingArea | None = None
|
||||
self._drag_output: str | None = None
|
||||
self._drag_offset: tuple[float, float] = (0, 0)
|
||||
|
||||
def build(self) -> Gtk.Widget:
|
||||
tb, header, scroll, content = self._make_toolbar_page("Outputs")
|
||||
|
||||
add_fake_btn = Gtk.Button(icon_name="list-add-symbolic")
|
||||
add_fake_btn.set_tooltip_text("Add fake monitor for testing")
|
||||
add_fake_btn.add_css_class("flat")
|
||||
add_fake_btn.connect("clicked", lambda *_: self._add_fake_monitor())
|
||||
header.pack_end(add_fake_btn)
|
||||
|
||||
refresh_btn = Gtk.Button(icon_name="view-refresh-symbolic")
|
||||
refresh_btn.set_tooltip_text("Reload outputs from niri")
|
||||
refresh_btn.add_css_class("flat")
|
||||
refresh_btn.connect("clicked", lambda *_: self.refresh())
|
||||
header.pack_end(refresh_btn)
|
||||
|
||||
canvas_frame = Gtk.Frame()
|
||||
canvas_frame.add_css_class("card")
|
||||
canvas_frame.set_margin_bottom(8)
|
||||
|
||||
self._canvas = Gtk.DrawingArea()
|
||||
self._canvas.set_content_height(350)
|
||||
self._canvas.set_draw_func(self._draw_canvas)
|
||||
canvas_frame.set_child(self._canvas)
|
||||
content.append(canvas_frame)
|
||||
|
||||
drag = Gtk.GestureDrag()
|
||||
drag.connect("drag-begin", self._on_drag_begin)
|
||||
drag.connect("drag-update", self._on_drag_update)
|
||||
drag.connect("drag-end", self._on_drag_end)
|
||||
self._canvas.add_controller(drag)
|
||||
|
||||
click = Gtk.GestureClick()
|
||||
click.connect("pressed", self._on_canvas_click)
|
||||
self._canvas.add_controller(click)
|
||||
|
||||
self._out_combo = Adw.ComboRow(title="Monitor")
|
||||
self._out_combo.connect("notify::selected", self._on_output_selected)
|
||||
sel_group = Adw.PreferencesGroup()
|
||||
sel_group.add(self._out_combo)
|
||||
content.append(sel_group)
|
||||
|
||||
self._detail_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
|
||||
content.append(self._detail_box)
|
||||
|
||||
self.refresh()
|
||||
return tb
|
||||
|
||||
def refresh(self):
|
||||
def _on_got_outputs(outputs):
|
||||
self._outputs = outputs
|
||||
names = [o.get("name", "?") for o in self._outputs]
|
||||
model = Gtk.StringList.new(names)
|
||||
self._out_combo.set_model(model)
|
||||
if self._outputs:
|
||||
self._load_output_detail(self._outputs[0])
|
||||
if self._canvas:
|
||||
self._canvas.queue_draw()
|
||||
# Rebuild search index as the detail rows are now populated
|
||||
if hasattr(self._win, "_build_search_index"):
|
||||
self._win._build_search_index()
|
||||
|
||||
niri_ipc.get_outputs(_on_got_outputs)
|
||||
|
||||
def _add_fake_monitor(self):
|
||||
idx = 1
|
||||
while any(o.get("name") == f"fake-{idx}" for o in self._outputs):
|
||||
idx += 1
|
||||
name = f"fake-{idx}"
|
||||
|
||||
o = {
|
||||
"name": name,
|
||||
"modes": [{"width": 1920, "height": 1080, "refresh_rate": 60000}],
|
||||
"current_mode": 0,
|
||||
"logical": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"scale": 1.0,
|
||||
"transform": "normal",
|
||||
},
|
||||
}
|
||||
self._outputs.append(o)
|
||||
|
||||
names = [out.get("name", "?") for out in self._outputs]
|
||||
model = Gtk.StringList.new(names)
|
||||
self._out_combo.set_model(model)
|
||||
self._out_combo.set_selected(len(self._outputs) - 1)
|
||||
|
||||
if self._canvas:
|
||||
self._canvas.queue_draw()
|
||||
if hasattr(self._win, "_build_search_index"):
|
||||
self._win._build_search_index()
|
||||
|
||||
# Canvas drawing
|
||||
|
||||
def _draw_canvas(self, area, cr, width, height):
|
||||
if not self._outputs:
|
||||
cr.set_source_rgba(0.05, 0.05, 0.05, 0.4)
|
||||
cr.rectangle(0, 0, width, height)
|
||||
cr.fill()
|
||||
cr.set_source_rgba(0.5, 0.5, 0.5, 0.8)
|
||||
cr.select_font_face("Sans", 0, 0)
|
||||
cr.set_font_size(14)
|
||||
cr.move_to(width / 2 - 80, height / 2)
|
||||
cr.show_text("No outputs detected")
|
||||
return
|
||||
|
||||
min_x = min_y = float("inf")
|
||||
max_x = max_y = float("-inf")
|
||||
for o in self._outputs:
|
||||
pos = o.get("logical", {})
|
||||
lx = pos.get("x", 0)
|
||||
ly = pos.get("y", 0)
|
||||
lw = pos.get("width", 1920)
|
||||
lh = pos.get("height", 1080)
|
||||
min_x = min(min_x, lx)
|
||||
min_y = min(min_y, ly)
|
||||
max_x = max(max_x, lx + lw)
|
||||
max_y = max(max_y, ly + lh)
|
||||
|
||||
if min_x == float("inf"):
|
||||
min_x = min_y = 0
|
||||
max_x = 1920
|
||||
max_y = 1080
|
||||
|
||||
total_w = max_x - min_x
|
||||
total_h = max_y - min_y
|
||||
|
||||
scale = min(width / max(total_w, 1), height / max(total_h, 1)) * 0.9
|
||||
off_x = (width - total_w * scale) / 2 - min_x * scale
|
||||
off_y = (height - total_h * scale) / 2 - min_y * scale
|
||||
|
||||
if self._drag_output and hasattr(self, "_drag_start_scale"):
|
||||
scale = self._drag_start_scale
|
||||
off_x, off_y = self._drag_start_offset
|
||||
|
||||
self._canvas_scale = scale
|
||||
self._canvas_offset = (off_x, off_y)
|
||||
self._canvas_pixel_w = width
|
||||
self._canvas_pixel_h = height
|
||||
|
||||
# grid background
|
||||
cr.set_source_rgba(1, 1, 1, 0.03)
|
||||
cr.set_line_width(1)
|
||||
grid_size = 40
|
||||
for gx in range(0, int(width), grid_size):
|
||||
cr.move_to(gx, 0)
|
||||
cr.line_to(gx, height)
|
||||
for gy in range(0, int(height), grid_size):
|
||||
cr.move_to(0, gy)
|
||||
cr.line_to(width, gy)
|
||||
cr.stroke()
|
||||
|
||||
for i, o in enumerate(self._outputs):
|
||||
pos = o.get("logical", {})
|
||||
x = off_x + pos.get("x", 0) * scale
|
||||
y = off_y + pos.get("y", 0) * scale
|
||||
w = pos.get("width", 1920) * scale
|
||||
h = pos.get("height", 1080) * scale
|
||||
|
||||
is_sel = o.get("name") == (
|
||||
self._current_out.get("name") if self._current_out else None
|
||||
)
|
||||
|
||||
if is_sel:
|
||||
cr.set_source_rgba(155 / 255, 109 / 255, 1.0, 1.0)
|
||||
else:
|
||||
cr.set_source_rgba(0.2, 0.2, 0.2, 1.0)
|
||||
cr.rectangle(x, y, w, h)
|
||||
cr.fill_preserve()
|
||||
|
||||
# border
|
||||
cr.set_line_width(1.5)
|
||||
if is_sel:
|
||||
cr.set_source_rgba(0.7, 0.7, 0.75, 0.9)
|
||||
else:
|
||||
cr.set_source_rgba(0.4, 0.4, 0.45, 0.6)
|
||||
cr.stroke()
|
||||
|
||||
name = o.get("name", f"Output {i}")
|
||||
mode_idx = o.get("current_mode")
|
||||
modes = o.get("modes", [])
|
||||
mode = (
|
||||
modes[mode_idx]
|
||||
if isinstance(mode_idx, int) and 0 <= mode_idx < len(modes)
|
||||
else {}
|
||||
)
|
||||
out_scale = o.get("logical", {}).get("scale", 1.0)
|
||||
res = f"{mode.get('width', '?')}×{mode.get('height', '?')}"
|
||||
scale_text = f"Scale: {out_scale}x"
|
||||
|
||||
cr.set_source_rgba(1, 1, 1, 0.95 if is_sel else 0.7)
|
||||
|
||||
cr.select_font_face("Sans", 0, 1)
|
||||
font_size = max(10, min(16, w / 10))
|
||||
cr.set_font_size(font_size)
|
||||
te = cr.text_extents(name)
|
||||
cr.move_to(x + w / 2 - te.width / 2, y + h / 2 - font_size * 0.3)
|
||||
cr.show_text(name)
|
||||
|
||||
cr.select_font_face("Sans", 0, 0)
|
||||
res_size = max(8, min(12, w / 15))
|
||||
cr.set_font_size(res_size)
|
||||
te2 = cr.text_extents(res)
|
||||
cr.move_to(x + w / 2 - te2.width / 2, y + h / 2 + res_size * 1.2)
|
||||
cr.show_text(res)
|
||||
|
||||
cr.set_source_rgba(0.6, 0.6, 0.65, 0.9 if is_sel else 0.6)
|
||||
scale_size = max(7, min(11, w / 18))
|
||||
cr.set_font_size(scale_size)
|
||||
te3 = cr.text_extents(scale_text)
|
||||
cr.move_to(
|
||||
x + w / 2 - te3.width / 2, y + h / 2 + res_size * 1.2 + scale_size * 1.4
|
||||
)
|
||||
cr.show_text(scale_text)
|
||||
|
||||
def _on_drag_begin(self, gesture, sx, sy):
|
||||
if not hasattr(self, "_canvas_scale"):
|
||||
return
|
||||
scale = self._canvas_scale
|
||||
ox, oy = self._canvas_offset
|
||||
for o in reversed(self._outputs):
|
||||
pos = o.get("logical", {})
|
||||
x = ox + pos.get("x", 0) * scale
|
||||
y = oy + pos.get("y", 0) * scale
|
||||
w = pos.get("width", 1920) * scale
|
||||
h = pos.get("height", 1080) * scale
|
||||
if x <= sx <= x + w and y <= sy <= y + h:
|
||||
self._drag_output = o["name"]
|
||||
self._last_dx = 0
|
||||
self._last_dy = 0
|
||||
self._drag_current_lx = pos.get("x", 0)
|
||||
self._drag_current_ly = pos.get("y", 0)
|
||||
self._drag_start_scale = scale
|
||||
self._drag_start_offset = (ox, oy)
|
||||
return
|
||||
|
||||
def _on_drag_update(self, gesture, dx, dy):
|
||||
if not self._drag_output or not hasattr(self, "_canvas_scale"):
|
||||
return
|
||||
|
||||
scale = getattr(self, "_drag_start_scale", self._canvas_scale)
|
||||
delta_dx = dx - getattr(self, "_last_dx", 0)
|
||||
delta_dy = dy - getattr(self, "_last_dy", 0)
|
||||
self._last_dx = dx
|
||||
self._last_dy = dy
|
||||
|
||||
self._drag_current_lx += delta_dx / scale
|
||||
self._drag_current_ly += delta_dy / scale
|
||||
|
||||
new_lx = self._drag_current_lx
|
||||
new_ly = self._drag_current_ly
|
||||
|
||||
drag_o = next(
|
||||
(o for o in self._outputs if o.get("name") == self._drag_output), None
|
||||
)
|
||||
if not drag_o:
|
||||
return
|
||||
|
||||
monitor_scale = drag_o.get("logical", {}).get("scale", 1.0)
|
||||
mode_idx = drag_o.get("current_mode")
|
||||
modes = drag_o.get("modes", [])
|
||||
mode = (
|
||||
modes[mode_idx]
|
||||
if isinstance(mode_idx, int) and 0 <= mode_idx < len(modes)
|
||||
else {}
|
||||
)
|
||||
pixel_w = mode.get("width", 1920)
|
||||
pixel_h = mode.get("height", 1080)
|
||||
|
||||
transform = str(drag_o.get("logical", {}).get("transform", "normal")).lower().replace("_", "-")
|
||||
if transform in ["90", "270", "flipped-90", "flipped-270"]:
|
||||
pixel_w, pixel_h = pixel_h, pixel_w
|
||||
|
||||
logical_w = pixel_w / monitor_scale
|
||||
logical_h = pixel_h / monitor_scale
|
||||
|
||||
# edge snapping
|
||||
SNAP_THRESHOLD = 30
|
||||
snapped_x = new_lx
|
||||
snapped_y = new_ly
|
||||
closest_x = SNAP_THRESHOLD + 1
|
||||
closest_y = SNAP_THRESHOLD + 1
|
||||
|
||||
dragged_left = new_lx
|
||||
dragged_right = new_lx + logical_w
|
||||
dragged_top = new_ly
|
||||
dragged_bottom = new_ly + logical_h
|
||||
|
||||
for other in self._outputs:
|
||||
if other.get("name") == self._drag_output:
|
||||
continue
|
||||
|
||||
other_pos = other.get("logical", {})
|
||||
other_x = other_pos.get("x", 0)
|
||||
other_y = other_pos.get("y", 0)
|
||||
|
||||
other_scale = other_pos.get("scale", 1.0)
|
||||
other_mode_idx = other.get("current_mode")
|
||||
other_modes = other.get("modes", [])
|
||||
other_mode = other_modes[other_mode_idx] if isinstance(other_mode_idx, int) and 0 <= other_mode_idx < len(other_modes) else {}
|
||||
other_pixel_w = other_mode.get("width", 1920)
|
||||
other_pixel_h = other_mode.get("height", 1080)
|
||||
|
||||
other_transform = str(other_pos.get("transform", "normal")).lower().replace("_", "-")
|
||||
if other_transform in ["90", "270", "flipped-90", "flipped-270"]:
|
||||
other_pixel_w, other_pixel_h = other_pixel_h, other_pixel_w
|
||||
|
||||
other_logical_w = other_pixel_w / other_scale
|
||||
other_logical_h = other_pixel_h / other_scale
|
||||
|
||||
other_left = other_x
|
||||
other_right = other_x + other_logical_w
|
||||
other_top = other_y
|
||||
other_bottom = other_y + other_logical_h
|
||||
|
||||
for dragged_edge, is_left_edge in [(dragged_left, True), (dragged_right, False)]:
|
||||
for other_edge in [other_left, other_right]:
|
||||
dist = abs(dragged_edge - other_edge)
|
||||
if dist < closest_x:
|
||||
closest_x = dist
|
||||
snapped_x = other_edge if is_left_edge else other_edge - logical_w
|
||||
|
||||
for dragged_edge, is_top_edge in [(dragged_top, True), (dragged_bottom, False)]:
|
||||
for other_edge in [other_top, other_bottom]:
|
||||
dist = abs(dragged_edge - other_edge)
|
||||
if dist < closest_y:
|
||||
closest_y = dist
|
||||
snapped_y = other_edge if is_top_edge else other_edge - logical_h
|
||||
|
||||
if closest_x <= SNAP_THRESHOLD:
|
||||
new_lx = snapped_x
|
||||
if closest_y <= SNAP_THRESHOLD:
|
||||
new_ly = snapped_y
|
||||
|
||||
if "logical" not in drag_o:
|
||||
drag_o["logical"] = {}
|
||||
drag_o["logical"]["x"] = new_lx
|
||||
drag_o["logical"]["y"] = new_ly
|
||||
|
||||
if self._canvas:
|
||||
self._canvas.queue_draw()
|
||||
|
||||
def _on_drag_end(self, gesture, dx, dy):
|
||||
if self._drag_output:
|
||||
if self._canvas:
|
||||
self._canvas.queue_draw()
|
||||
|
||||
for o in self._outputs:
|
||||
self._apply_position(o["name"])
|
||||
|
||||
if self._current_out:
|
||||
cur_pos = self._current_out.get("logical", {})
|
||||
if hasattr(self, "_pos_x_adj"):
|
||||
self._pos_x_adj.set_value(cur_pos.get("x", 0))
|
||||
if hasattr(self, "_pos_y_adj"):
|
||||
self._pos_y_adj.set_value(cur_pos.get("y", 0))
|
||||
|
||||
self._drag_output = None
|
||||
|
||||
def _on_canvas_click(self, gesture, n_press, x, y):
|
||||
if not hasattr(self, "_canvas_scale"):
|
||||
return
|
||||
|
||||
scale = self._canvas_scale
|
||||
ox, oy = self._canvas_offset
|
||||
|
||||
for i, o in reversed(list(enumerate(self._outputs))):
|
||||
pos = o.get("logical", {})
|
||||
mx = ox + pos.get("x", 0) * scale
|
||||
my = oy + pos.get("y", 0) * scale
|
||||
mw = pos.get("width", 1920) * scale
|
||||
mh = pos.get("height", 1080) * scale
|
||||
|
||||
if mx <= x <= mx + mw and my <= y <= my + mh:
|
||||
self._out_combo.set_selected(i)
|
||||
return
|
||||
|
||||
def _apply_position(self, name: str):
|
||||
o = next((x for x in self._outputs if x["name"] == name), None)
|
||||
if not o:
|
||||
return
|
||||
pos = o.get("logical", {})
|
||||
|
||||
nx = pos.get("x", 0)
|
||||
ny = pos.get("y", 0)
|
||||
|
||||
out_node = self._get_or_create_out_node(name)
|
||||
pos_node = out_node.get_child("position")
|
||||
if pos_node is None:
|
||||
pos_node = KdlNode(name="position")
|
||||
out_node.children.append(pos_node)
|
||||
pos_node.props["x"] = int(round(nx))
|
||||
pos_node.props["y"] = int(round(ny))
|
||||
|
||||
if self._current_out and self._current_out.get("name") == name:
|
||||
if hasattr(self, "_pos_x_adj"):
|
||||
self._pos_x_adj.set_value(nx)
|
||||
if hasattr(self, "_pos_y_adj"):
|
||||
self._pos_y_adj.set_value(ny)
|
||||
|
||||
self._commit("output position")
|
||||
|
||||
def _on_output_selected(self, combo, _):
|
||||
idx = combo.get_selected()
|
||||
if 0 <= idx < len(self._outputs):
|
||||
self._load_output_detail(self._outputs[idx])
|
||||
# Rebuild search index as the detail rows have changed
|
||||
if hasattr(self._win, "_build_search_index"):
|
||||
self._win._build_search_index()
|
||||
|
||||
def _load_output_detail(self, output: dict):
|
||||
self._current_out = output
|
||||
for child in list(self._detail_box):
|
||||
self._detail_box.remove(child)
|
||||
|
||||
name = output.get("name", "?")
|
||||
nodes = self._nodes
|
||||
out_node = next(
|
||||
(n for n in nodes if n.name == "output" and n.args and n.args[0] == name),
|
||||
None,
|
||||
)
|
||||
|
||||
modes = output.get("modes", [])
|
||||
mode_strs = [
|
||||
f"{m.get('width', 0)}×{m.get('height', 0)}@{m.get('refresh_rate', 0) / 1000:.3f}"
|
||||
for m in modes
|
||||
]
|
||||
mode_model = Gtk.StringList.new(mode_strs)
|
||||
mode_row = Adw.ComboRow(title="Resolution & Refresh Rate")
|
||||
mode_row.set_model(mode_model)
|
||||
mode_idx = output.get("current_mode")
|
||||
cur_mode = (
|
||||
modes[mode_idx]
|
||||
if isinstance(mode_idx, int) and 0 <= mode_idx < len(modes)
|
||||
else {}
|
||||
)
|
||||
cur_str = f"{cur_mode.get('width', 0)}×{cur_mode.get('height', 0)}@{cur_mode.get('refresh_rate', 0) / 1000:.3f}"
|
||||
if cur_str in mode_strs:
|
||||
mode_row.set_selected(mode_strs.index(cur_str))
|
||||
mode_row.connect(
|
||||
"notify::selected",
|
||||
lambda r, _: self._on_mode_changed(name, modes, r.get_selected()),
|
||||
)
|
||||
|
||||
scale_val = round(output.get("logical", {}).get("scale", 1.0), 3)
|
||||
scale_adj = Gtk.Adjustment(
|
||||
value=scale_val,
|
||||
lower=0.01,
|
||||
upper=100.0,
|
||||
step_increment=0.05,
|
||||
)
|
||||
scale_row = Adw.SpinRow(title="Scale", adjustment=scale_adj, digits=2)
|
||||
scale_row.connect(
|
||||
"notify::value",
|
||||
lambda r, _: self._set_output_prop(name, "scale", r.get_value()),
|
||||
)
|
||||
|
||||
t_model = Gtk.StringList.new(TRANSFORMS)
|
||||
transform_row = Adw.ComboRow(title="Transform", model=t_model)
|
||||
cur_t = output.get("logical", {}).get("transform", "normal")
|
||||
cur_t_norm = str(cur_t).lower().replace("_", "-") if cur_t else "normal"
|
||||
if cur_t_norm in TRANSFORMS:
|
||||
transform_row.set_selected(TRANSFORMS.index(cur_t_norm))
|
||||
transform_row.connect(
|
||||
"notify::selected",
|
||||
lambda r, _: self._set_output_prop(
|
||||
name, "transform", TRANSFORMS[r.get_selected()]
|
||||
),
|
||||
)
|
||||
|
||||
px = output.get("logical", {}).get("x", 0)
|
||||
py = output.get("logical", {}).get("y", 0)
|
||||
px_adj = Gtk.Adjustment(value=px, lower=-1000000, upper=1000000, step_increment=1)
|
||||
py_adj = Gtk.Adjustment(value=py, lower=-1000000, upper=1000000, step_increment=1)
|
||||
self._pos_x_adj = px_adj
|
||||
self._pos_y_adj = py_adj
|
||||
pos_x_row = Adw.SpinRow(title="Position X", adjustment=px_adj, digits=0)
|
||||
pos_y_row = Adw.SpinRow(title="Position Y", adjustment=py_adj, digits=0)
|
||||
pos_x_row.connect(
|
||||
"notify::value",
|
||||
lambda r, _: self._set_output_pos(
|
||||
name, int(r.get_value()), int(py_adj.get_value())
|
||||
),
|
||||
)
|
||||
pos_y_row.connect(
|
||||
"notify::value",
|
||||
lambda r, _: self._set_output_pos(
|
||||
name, int(px_adj.get_value()), int(r.get_value())
|
||||
),
|
||||
)
|
||||
|
||||
vrr_row = Adw.SwitchRow(title="Variable Refresh Rate (VRR)")
|
||||
vrr_val = (
|
||||
(out_node.get_child("variable-refresh-rate") is not None)
|
||||
if out_node
|
||||
else False
|
||||
)
|
||||
vrr_row.set_active(vrr_val)
|
||||
safe_switch_connect(
|
||||
vrr_row,
|
||||
vrr_val,
|
||||
lambda enabled: self._set_output_flag(
|
||||
name, "variable-refresh-rate", enabled
|
||||
),
|
||||
)
|
||||
|
||||
off_row = Adw.SwitchRow(title="Disable Output")
|
||||
off_val = (out_node.get_child("off") is not None) if out_node else False
|
||||
off_row.set_active(off_val)
|
||||
safe_switch_connect(
|
||||
off_row,
|
||||
off_val,
|
||||
lambda enabled: self._set_output_flag(name, "off", enabled),
|
||||
)
|
||||
|
||||
grp = Adw.PreferencesGroup(title=f"Output: {name}")
|
||||
for r in [
|
||||
mode_row,
|
||||
scale_row,
|
||||
transform_row,
|
||||
pos_x_row,
|
||||
pos_y_row,
|
||||
vrr_row,
|
||||
off_row,
|
||||
]:
|
||||
grp.add(r)
|
||||
self._detail_box.append(grp)
|
||||
|
||||
if self._canvas:
|
||||
self._canvas.queue_draw()
|
||||
|
||||
def _ensure_output_fields(self, out_node: KdlNode, name: str):
|
||||
manual_out = None
|
||||
try:
|
||||
manual_nodes = self._nodes
|
||||
if manual_nodes:
|
||||
manual_out = next(
|
||||
(
|
||||
n
|
||||
for n in manual_nodes
|
||||
if n.name == "output" and n.args and n.args[0] == name
|
||||
),
|
||||
None,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if manual_out:
|
||||
if out_node.get_child("mode") is None:
|
||||
m = manual_out.child_arg("mode")
|
||||
if m:
|
||||
set_child_arg(out_node, "mode", m)
|
||||
if out_node.get_child("scale") is None:
|
||||
s = manual_out.child_arg("scale")
|
||||
if s is not None:
|
||||
set_child_arg(out_node, "scale", s)
|
||||
if out_node.get_child("transform") is None:
|
||||
t = manual_out.child_arg("transform")
|
||||
if t:
|
||||
set_child_arg(out_node, "transform", t)
|
||||
if out_node.get_child("position") is None:
|
||||
pos_node = manual_out.get_child("position")
|
||||
if pos_node:
|
||||
new_pos = KdlNode(name="position", props=pos_node.props.copy())
|
||||
out_node.children.append(new_pos)
|
||||
|
||||
o = next((x for x in self._outputs if x.get("name") == name), None)
|
||||
if o:
|
||||
if out_node.get_child("mode") is None:
|
||||
mode_idx = o.get("current_mode")
|
||||
modes = o.get("modes", [])
|
||||
if isinstance(mode_idx, int) and 0 <= mode_idx < len(modes):
|
||||
m = modes[mode_idx]
|
||||
mode_str = f"{m.get('width', 0)}x{m.get('height', 0)}@{m.get('refresh_rate', 0) / 1000:.3f}"
|
||||
set_child_arg(out_node, "mode", mode_str)
|
||||
if out_node.get_child("scale") is None:
|
||||
set_child_arg(out_node, "scale", o.get("logical", {}).get("scale", 1.0))
|
||||
if out_node.get_child("transform") is None:
|
||||
t = o.get("logical", {}).get("transform", "normal")
|
||||
t = str(t).lower().replace("_", "-") if t else "normal"
|
||||
if t not in TRANSFORMS:
|
||||
t = "normal"
|
||||
set_child_arg(out_node, "transform", t)
|
||||
pos_node = out_node.get_child("position")
|
||||
if pos_node is None:
|
||||
pos_node = KdlNode(name="position")
|
||||
out_node.children.append(pos_node)
|
||||
pos_node.props["x"] = o.get("logical", {}).get("x", 0)
|
||||
pos_node.props["y"] = o.get("logical", {}).get("y", 0)
|
||||
|
||||
def _get_or_create_out_node(self, name: str) -> KdlNode:
|
||||
nodes = self._nodes
|
||||
out_node = next(
|
||||
(n for n in nodes if n.name == "output" and n.args and n.args[0] == name),
|
||||
None,
|
||||
)
|
||||
is_new = out_node is None
|
||||
if out_node is None:
|
||||
out_node = KdlNode(name="output", args=[name])
|
||||
nodes.append(out_node)
|
||||
|
||||
assert out_node is not None
|
||||
|
||||
if is_new:
|
||||
self._ensure_output_fields(out_node, name)
|
||||
|
||||
order = {"mode": 0, "scale": 1, "transform": 2, "position": 3}
|
||||
out_node.children.sort(key=lambda c: order.get(c.name, 999))
|
||||
|
||||
return out_node
|
||||
|
||||
def _update_logical_dims(self, o: dict):
|
||||
if "logical" not in o:
|
||||
o["logical"] = {}
|
||||
mode_idx = o.get("current_mode")
|
||||
modes = o.get("modes", [])
|
||||
m = (
|
||||
modes[mode_idx]
|
||||
if isinstance(mode_idx, int) and 0 <= mode_idx < len(modes)
|
||||
else {}
|
||||
)
|
||||
pw = m.get("width", 1920)
|
||||
ph = m.get("height", 1080)
|
||||
|
||||
scale = o["logical"].get("scale", 1.0)
|
||||
if scale <= 0:
|
||||
scale = 1.0
|
||||
|
||||
t = o["logical"].get("transform", "normal")
|
||||
t_str = str(t).lower().replace("_", "-")
|
||||
if t_str in ["90", "270", "flipped-90", "flipped-270"]:
|
||||
pw, ph = ph, pw
|
||||
|
||||
o["logical"]["width"] = round(pw / scale)
|
||||
o["logical"]["height"] = round(ph / scale)
|
||||
|
||||
def _on_mode_changed(self, name: str, modes: list, idx: int):
|
||||
if not (0 <= idx < len(modes)):
|
||||
return
|
||||
m = modes[idx]
|
||||
mode_str = f"{m.get('width', 0)}x{m.get('height', 0)}@{m.get('refresh_rate', 0) / 1000:.3f}"
|
||||
out_node = self._get_or_create_out_node(name)
|
||||
set_child_arg(out_node, "mode", mode_str)
|
||||
|
||||
o = next((x for x in self._outputs if x.get("name") == name), None)
|
||||
if o:
|
||||
o["current_mode"] = idx
|
||||
self._update_logical_dims(o)
|
||||
|
||||
self._commit("output mode")
|
||||
if self._canvas:
|
||||
self._canvas.queue_draw()
|
||||
|
||||
def _set_output_prop(self, name: str, prop: str, value):
|
||||
if prop == "scale" and isinstance(value, float):
|
||||
value = round(value, 3)
|
||||
|
||||
out_node = self._get_or_create_out_node(name)
|
||||
set_child_arg(out_node, prop, value)
|
||||
|
||||
o = next((x for x in self._outputs if x.get("name") == name), None)
|
||||
if o:
|
||||
if "logical" not in o:
|
||||
o["logical"] = {}
|
||||
if prop == "scale":
|
||||
o["logical"]["scale"] = value
|
||||
elif prop == "transform":
|
||||
o["logical"]["transform"] = value
|
||||
self._update_logical_dims(o)
|
||||
|
||||
self._commit(f"output {prop}")
|
||||
if self._canvas:
|
||||
self._canvas.queue_draw()
|
||||
|
||||
def _set_output_pos(self, name: str, x: int, y: int):
|
||||
out_node = self._get_or_create_out_node(name)
|
||||
pos_node = out_node.get_child("position")
|
||||
if pos_node is None:
|
||||
pos_node = KdlNode(name="position")
|
||||
out_node.children.append(pos_node)
|
||||
|
||||
pos_node.props["x"] = int(round(x))
|
||||
pos_node.props["y"] = int(round(y))
|
||||
|
||||
o = next((out for out in self._outputs if out.get("name") == name), None)
|
||||
if o:
|
||||
if "logical" not in o:
|
||||
o["logical"] = {}
|
||||
o["logical"]["x"] = x
|
||||
o["logical"]["y"] = y
|
||||
|
||||
self._commit("output position")
|
||||
if self._canvas:
|
||||
self._canvas.queue_draw()
|
||||
|
||||
def _set_output_flag(self, name: str, flag: str, enabled: bool):
|
||||
from nirimod.kdl_parser import set_node_flag
|
||||
|
||||
out_node = self._get_or_create_out_node(name)
|
||||
set_node_flag(out_node, flag, enabled)
|
||||
self._commit(f"output {flag}")
|
||||
322
nirimod/pages/raw_config.py
Normal file
322
nirimod/pages/raw_config.py
Normal file
@@ -0,0 +1,322 @@
|
||||
"""Raw Config page — editable view of the full merged config."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Adw", "1")
|
||||
from gi.repository import Gtk, Pango, GLib
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from nirimod import niri_ipc
|
||||
from nirimod.kdl_parser import NIRI_CONFIG
|
||||
from nirimod.pages.base import BasePage
|
||||
|
||||
|
||||
class RawConfigPage(BasePage):
|
||||
def build(self) -> Gtk.Widget:
|
||||
tb, header, _, content = self._make_toolbar_page("Raw Config")
|
||||
self._content = content
|
||||
|
||||
self._scroll_positions: dict[Path, tuple[float, float]] = {}
|
||||
self._buffer_modified = False
|
||||
self._original_text = ""
|
||||
|
||||
self._current_files: list[Path] = []
|
||||
self._file_dropdown = Gtk.DropDown()
|
||||
self._file_dropdown.set_valign(Gtk.Align.CENTER)
|
||||
self._file_dropdown.connect("notify::selected-item", self._on_file_selected)
|
||||
|
||||
title_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
|
||||
title_box.set_halign(Gtk.Align.CENTER)
|
||||
title_box.set_valign(Gtk.Align.CENTER)
|
||||
|
||||
title_label = Gtk.Label(label="Config File")
|
||||
title_label.add_css_class("title")
|
||||
title_box.append(title_label)
|
||||
title_box.append(self._file_dropdown)
|
||||
|
||||
header.pack_start(title_box)
|
||||
title_box.set_margin_start(12)
|
||||
|
||||
# Header actions
|
||||
validate_btn = Gtk.Button(label="Validate")
|
||||
validate_btn.add_css_class("suggested-action")
|
||||
validate_btn.connect("clicked", self._on_validate)
|
||||
header.pack_end(validate_btn)
|
||||
|
||||
self._save_btn = Gtk.Button(label="Save")
|
||||
self._save_btn.add_css_class("suggested-action")
|
||||
self._save_btn.set_tooltip_text("Save this file and reload niri (Ctrl+S)")
|
||||
self._save_btn.connect("clicked", self._on_save_raw)
|
||||
self._save_btn.set_sensitive(False)
|
||||
header.pack_end(self._save_btn)
|
||||
|
||||
self._discard_btn = Gtk.Button(label="Discard")
|
||||
self._discard_btn.add_css_class("destructive-action")
|
||||
self._discard_btn.add_css_class("flat")
|
||||
self._discard_btn.set_tooltip_text("Discard unsaved changes")
|
||||
self._discard_btn.connect("clicked", self._on_discard_raw)
|
||||
self._discard_btn.set_sensitive(False)
|
||||
header.pack_end(self._discard_btn)
|
||||
|
||||
# Editor
|
||||
self._textview = Gtk.TextView()
|
||||
self._textview.set_editable(True)
|
||||
self._textview.set_monospace(True)
|
||||
self._textview.set_wrap_mode(Gtk.WrapMode.NONE)
|
||||
self._textview.set_left_margin(16)
|
||||
self._textview.set_right_margin(16)
|
||||
self._textview.set_top_margin(16)
|
||||
self._textview.set_bottom_margin(16)
|
||||
self._textview.add_css_class("code-editor")
|
||||
|
||||
self._buf = self._textview.get_buffer()
|
||||
self._buf.connect("changed", self._on_buffer_changed)
|
||||
|
||||
self._scroll = Gtk.ScrolledWindow()
|
||||
self._scroll.add_css_class("card")
|
||||
self._scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
|
||||
self._scroll.set_vexpand(True)
|
||||
self._scroll.set_hexpand(True)
|
||||
self._scroll.set_child(self._textview)
|
||||
content.append(self._scroll)
|
||||
|
||||
self.refresh()
|
||||
return tb
|
||||
|
||||
|
||||
# Scroll position helpers
|
||||
|
||||
def _save_scroll_position(self):
|
||||
"""Persist the current scroll position for the active file."""
|
||||
idx = self._file_dropdown.get_selected()
|
||||
if idx == Gtk.INVALID_LIST_POSITION or idx >= len(self._current_files):
|
||||
return
|
||||
path = self._current_files[idx]
|
||||
hadj = self._scroll.get_hadjustment()
|
||||
vadj = self._scroll.get_vadjustment()
|
||||
self._scroll_positions[path] = (hadj.get_value(), vadj.get_value())
|
||||
|
||||
def _restore_scroll_position(self, path: Path):
|
||||
"""Restore the saved scroll position for a given file, if any."""
|
||||
if path not in self._scroll_positions:
|
||||
return
|
||||
hval, vval = self._scroll_positions[path]
|
||||
|
||||
def _apply():
|
||||
hadj = self._scroll.get_hadjustment()
|
||||
vadj = self._scroll.get_vadjustment()
|
||||
hadj.set_value(hval)
|
||||
vadj.set_value(vval)
|
||||
return False # don't repeat
|
||||
|
||||
# Defer one frame so the buffer is fully laid out before scrolling
|
||||
GLib.idle_add(_apply)
|
||||
|
||||
|
||||
# Page lifecycle
|
||||
|
||||
def on_shown(self):
|
||||
"""Called every time the user navigates back to this page."""
|
||||
# Restore scroll for whichever file is currently selected
|
||||
idx = self._file_dropdown.get_selected()
|
||||
if idx != Gtk.INVALID_LIST_POSITION and idx < len(self._current_files):
|
||||
self._restore_scroll_position(self._current_files[idx])
|
||||
|
||||
def refresh(self):
|
||||
state = self._win.app_state
|
||||
|
||||
if state.is_multi_file:
|
||||
self._current_files = sorted(list(state.source_files))
|
||||
if NIRI_CONFIG in self._current_files:
|
||||
self._current_files.remove(NIRI_CONFIG)
|
||||
self._current_files.insert(0, NIRI_CONFIG)
|
||||
else:
|
||||
self._current_files = [NIRI_CONFIG]
|
||||
|
||||
strings = [p.name for p in self._current_files]
|
||||
self._file_dropdown.set_model(Gtk.StringList.new(strings))
|
||||
|
||||
self._load_selected_file()
|
||||
|
||||
def _reload_from_disk(self):
|
||||
"""Re-read the file from disk, discarding any edits."""
|
||||
self._load_selected_file(force=True)
|
||||
|
||||
|
||||
# File loading
|
||||
|
||||
def _on_file_selected(self, dropdown, param):
|
||||
self._save_scroll_position()
|
||||
self._load_selected_file()
|
||||
|
||||
def _load_selected_file(self, force: bool = False):
|
||||
idx = self._file_dropdown.get_selected()
|
||||
if idx == Gtk.INVALID_LIST_POSITION or idx >= len(self._current_files):
|
||||
return
|
||||
|
||||
if self._buffer_modified and not force:
|
||||
self._confirm_discard_then(lambda: self._do_load_file(idx))
|
||||
return
|
||||
|
||||
self._do_load_file(idx)
|
||||
|
||||
def _do_load_file(self, idx: int):
|
||||
path = self._current_files[idx]
|
||||
text = path.read_text() if path.exists() else f"// File not found: {path}"
|
||||
|
||||
self._buf.handler_block_by_func(self._on_buffer_changed)
|
||||
self._buf.set_text(text)
|
||||
self._original_text = text
|
||||
self._apply_syntax_highlighting(self._buf, text)
|
||||
self._buf.handler_unblock_by_func(self._on_buffer_changed)
|
||||
|
||||
self._set_modified(False)
|
||||
self._restore_scroll_position(path)
|
||||
|
||||
|
||||
# Buffer modification tracking
|
||||
|
||||
def _on_buffer_changed(self, buf):
|
||||
text = buf.get_text(buf.get_start_iter(), buf.get_end_iter(), False)
|
||||
is_changed = (text != self._original_text)
|
||||
if is_changed != self._buffer_modified:
|
||||
self._set_modified(is_changed)
|
||||
|
||||
def _set_modified(self, modified: bool):
|
||||
self._buffer_modified = modified
|
||||
self._save_btn.set_sensitive(modified)
|
||||
self._discard_btn.set_sensitive(modified)
|
||||
|
||||
|
||||
# Save / Discard
|
||||
|
||||
def _on_save_raw(self, *_):
|
||||
idx = self._file_dropdown.get_selected()
|
||||
if idx == Gtk.INVALID_LIST_POSITION or idx >= len(self._current_files):
|
||||
return
|
||||
|
||||
path = self._current_files[idx]
|
||||
text = self._buf.get_text(self._buf.get_start_iter(), self._buf.get_end_iter(), False)
|
||||
|
||||
from nirimod import app_settings
|
||||
if app_settings.get("auto_backup", True):
|
||||
from nirimod.backup import backup_all_sources
|
||||
limit = app_settings.get("backup_limit", 10)
|
||||
backup_all_sources(self._win.app_state.source_files, limit=limit)
|
||||
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
try:
|
||||
tmp.write_text(text)
|
||||
except Exception as e:
|
||||
self.show_toast(f"Write error: {e}", timeout=6)
|
||||
return
|
||||
|
||||
self.show_toast("Validating…", timeout=2)
|
||||
|
||||
def _on_validated(result):
|
||||
ok, msg = result
|
||||
if not ok:
|
||||
tmp.unlink(missing_ok=True)
|
||||
self.show_toast(f"Validation error: {msg[:120]}", timeout=8)
|
||||
return
|
||||
try:
|
||||
tmp.replace(path)
|
||||
except Exception as e:
|
||||
self.show_toast(f"Save error: {e}", timeout=6)
|
||||
return
|
||||
|
||||
self._set_modified(False)
|
||||
self._original_text = text
|
||||
self._apply_syntax_highlighting(self._buf, text)
|
||||
niri_ipc.run_in_thread(niri_ipc.load_config_file, self._on_reloaded)
|
||||
|
||||
niri_ipc.run_in_thread(
|
||||
lambda: niri_ipc.validate_config(str(tmp)), _on_validated
|
||||
)
|
||||
|
||||
def _on_reloaded(self, result):
|
||||
ok, msg = result
|
||||
if ok:
|
||||
self.show_toast("Config saved and applied ✓", timeout=3)
|
||||
else:
|
||||
self.show_toast(f"Saved, but reload failed: {msg[:80]}", timeout=8)
|
||||
self._win.app_state.reload_from_disk()
|
||||
self._win._build_search_index()
|
||||
|
||||
def _on_discard_raw(self, *_):
|
||||
self._confirm_discard_then(self._reload_from_disk)
|
||||
|
||||
def _confirm_discard_then(self, callback):
|
||||
import gi
|
||||
gi.require_version("Adw", "1")
|
||||
from gi.repository import Adw
|
||||
|
||||
dialog = Adw.AlertDialog(
|
||||
heading="Discard changes?",
|
||||
body="Your unsaved edits to this file will be lost.",
|
||||
)
|
||||
dialog.add_response("cancel", "Cancel")
|
||||
dialog.add_response("discard", "Discard")
|
||||
dialog.set_response_appearance("discard", Adw.ResponseAppearance.DESTRUCTIVE)
|
||||
dialog.set_default_response("cancel")
|
||||
|
||||
def _on_response(dlg, response):
|
||||
if response == "discard":
|
||||
self._set_modified(False)
|
||||
callback()
|
||||
|
||||
dialog.connect("response", _on_response)
|
||||
dialog.present(self._win)
|
||||
|
||||
|
||||
# Syntax highlighting
|
||||
|
||||
def _apply_syntax_highlighting(self, buf: Gtk.TextBuffer, text: str):
|
||||
tag_table = buf.get_tag_table()
|
||||
|
||||
def _get_or_create_tag(name, **props):
|
||||
t = tag_table.lookup(name)
|
||||
if t is None:
|
||||
t = buf.create_tag(name, **props)
|
||||
return t
|
||||
|
||||
comment_tag = _get_or_create_tag(
|
||||
"comment", foreground="#6a9955", style=Pango.Style.ITALIC
|
||||
)
|
||||
string_tag = _get_or_create_tag("string", foreground="#ce9178")
|
||||
node_tag = _get_or_create_tag("node", foreground="#9cdcfe")
|
||||
keyword_tag = _get_or_create_tag("keyword", foreground="#c586c0")
|
||||
|
||||
import re
|
||||
|
||||
def _apply(pattern, tag, group=0):
|
||||
for m in re.finditer(pattern, text, re.MULTILINE):
|
||||
s = buf.get_iter_at_offset(m.start(group))
|
||||
e = buf.get_iter_at_offset(m.end(group))
|
||||
buf.apply_tag(tag, s, e)
|
||||
|
||||
_apply(r"//[^\n]*", comment_tag)
|
||||
_apply(r'"[^"\\]*(?:\\.[^"\\]*)*"', string_tag)
|
||||
_apply(r"\b(true|false|null)\b", keyword_tag)
|
||||
_apply(r"^(\s*)([a-zA-Z][\w\-]*)", node_tag, group=2)
|
||||
|
||||
|
||||
# Copy / Validate
|
||||
|
||||
|
||||
|
||||
def _on_validate(self, *_):
|
||||
self.show_toast("Validating...")
|
||||
|
||||
def _on_validated(result):
|
||||
ok, msg = result
|
||||
self.show_toast(msg[:120], timeout=5)
|
||||
|
||||
niri_ipc.run_in_thread(
|
||||
lambda: niri_ipc.validate_config(str(NIRI_CONFIG)), _on_validated
|
||||
)
|
||||
171
nirimod/pages/startup.py
Normal file
171
nirimod/pages/startup.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""Startup Programs page."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
import shlex
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Adw", "1")
|
||||
from gi.repository import Adw, Gtk, GLib
|
||||
|
||||
from nirimod.kdl_parser import KdlNode
|
||||
from nirimod.pages.base import BasePage
|
||||
|
||||
|
||||
class StartupPage(BasePage):
|
||||
def build(self) -> Gtk.Widget:
|
||||
tb, header, _, content = self._make_toolbar_page("Startup Programs")
|
||||
self._content = content
|
||||
|
||||
|
||||
|
||||
self.refresh()
|
||||
return tb
|
||||
|
||||
def refresh(self):
|
||||
self._rebuild()
|
||||
|
||||
def _get_entries(self) -> list[KdlNode]:
|
||||
return [
|
||||
n
|
||||
for n in self._nodes
|
||||
if n.name in ("spawn-at-startup", "spawn-sh-at-startup")
|
||||
]
|
||||
|
||||
def _rebuild(self):
|
||||
# Clear existing content
|
||||
while True:
|
||||
child = self._content.get_first_child()
|
||||
if child is None:
|
||||
break
|
||||
self._content.remove(child)
|
||||
|
||||
entries = self._get_entries()
|
||||
|
||||
if not entries:
|
||||
status = Adw.StatusPage(
|
||||
title="No Startup Programs",
|
||||
description="Programs added here will launch automatically when niri starts.",
|
||||
icon_name="applications-system-symbolic",
|
||||
)
|
||||
|
||||
add_btn = Gtk.Button(label="Add Program")
|
||||
add_btn.add_css_class("pill")
|
||||
add_btn.add_css_class("suggested-action")
|
||||
add_btn.set_halign(Gtk.Align.CENTER)
|
||||
add_btn.connect("clicked", self._on_add)
|
||||
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
||||
box.set_valign(Gtk.Align.CENTER)
|
||||
box.set_vexpand(True)
|
||||
box.append(status)
|
||||
box.append(add_btn)
|
||||
|
||||
self._content.append(box)
|
||||
else:
|
||||
grp = Adw.PreferencesGroup(
|
||||
title="Startup Programs",
|
||||
description=f"{len(entries)} program{'s' if len(entries) != 1 else ''} configured to launch",
|
||||
)
|
||||
for i, entry in enumerate(entries):
|
||||
row = self._make_row(entry, i)
|
||||
grp.add(row)
|
||||
|
||||
self._content.append(grp)
|
||||
|
||||
# Also add a convenient button at the bottom
|
||||
add_btn = Gtk.Button(label="Add Another Program")
|
||||
add_btn.add_css_class("pill")
|
||||
add_btn.set_halign(Gtk.Align.CENTER)
|
||||
add_btn.set_margin_top(16)
|
||||
add_btn.connect("clicked", self._on_add)
|
||||
self._content.append(add_btn)
|
||||
|
||||
def _make_row(self, node: KdlNode, idx: int) -> Adw.ActionRow:
|
||||
cmd = " ".join(str(a) for a in node.args)
|
||||
is_sh = "sh" in node.name
|
||||
cmd_str = GLib.markup_escape_text(cmd) if cmd else "(empty)"
|
||||
|
||||
row = Adw.ActionRow(
|
||||
title=cmd_str or "(empty)",
|
||||
subtitle="Via shell (spawn-sh-at-startup)" if is_sh else "Launched directly",
|
||||
)
|
||||
row.set_activatable(True)
|
||||
row.connect("activated", lambda *_, i=idx: self._on_edit(i))
|
||||
|
||||
del_btn = Gtk.Button(icon_name="user-trash-symbolic")
|
||||
del_btn.set_valign(Gtk.Align.CENTER)
|
||||
del_btn.add_css_class("flat")
|
||||
del_btn.add_css_class("error")
|
||||
del_btn.set_tooltip_text("Remove startup entry")
|
||||
del_btn.connect("clicked", lambda *_, i=idx: self._on_delete(i))
|
||||
row.add_suffix(del_btn)
|
||||
return row
|
||||
|
||||
def _on_add(self, *_):
|
||||
self._show_dialog(None, -1)
|
||||
|
||||
def _on_edit(self, idx: int):
|
||||
entries = self._get_entries()
|
||||
if 0 <= idx < len(entries):
|
||||
self._show_dialog(entries[idx], idx)
|
||||
|
||||
def _on_delete(self, idx: int):
|
||||
entries = self._get_entries()
|
||||
if 0 <= idx < len(entries):
|
||||
self._nodes.remove(entries[idx])
|
||||
self._commit("remove startup entry")
|
||||
self._rebuild()
|
||||
|
||||
def _show_dialog(self, node: KdlNode | None, idx: int):
|
||||
dialog = Adw.AlertDialog(
|
||||
heading="Startup Program", body="Enter the command to launch at startup."
|
||||
)
|
||||
cmd_entry = Adw.EntryRow(title="Command")
|
||||
sh_switch = Adw.SwitchRow(title="Use shell (spawn-sh-at-startup)")
|
||||
if node:
|
||||
cmd_entry.set_text(" ".join(str(a) for a in node.args))
|
||||
sh_switch.set_active("sh" in node.name)
|
||||
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
|
||||
grp = Adw.PreferencesGroup()
|
||||
grp.add(cmd_entry)
|
||||
grp.add(sh_switch)
|
||||
box.append(grp)
|
||||
dialog.set_extra_child(box)
|
||||
|
||||
dialog.add_response("cancel", "Cancel")
|
||||
dialog.add_response("save", "Save")
|
||||
dialog.set_response_appearance("save", Adw.ResponseAppearance.SUGGESTED)
|
||||
|
||||
def _on_resp(d, r):
|
||||
if r != "save":
|
||||
return
|
||||
cmd = cmd_entry.get_text().strip()
|
||||
if not cmd:
|
||||
return
|
||||
is_sh = sh_switch.get_active()
|
||||
node_name = "spawn-sh-at-startup" if is_sh else "spawn-at-startup"
|
||||
if is_sh:
|
||||
# sh -c expects a single string; store the whole command as one arg
|
||||
args = [cmd]
|
||||
else:
|
||||
try:
|
||||
args = shlex.split(cmd)
|
||||
except ValueError:
|
||||
args = cmd.split()
|
||||
|
||||
new_node = KdlNode(node_name, args=args)
|
||||
entries = self._get_entries()
|
||||
if idx >= 0 and 0 <= idx < len(entries):
|
||||
i = self._nodes.index(entries[idx])
|
||||
self._nodes[i] = new_node
|
||||
else:
|
||||
self._nodes.append(new_node)
|
||||
self._commit("startup entry")
|
||||
self._rebuild()
|
||||
|
||||
dialog.connect("response", _on_resp)
|
||||
dialog.present(self._win)
|
||||
1014
nirimod/pages/window_rules.py
Normal file
1014
nirimod/pages/window_rules.py
Normal file
File diff suppressed because it is too large
Load Diff
155
nirimod/pages/workspaces.py
Normal file
155
nirimod/pages/workspaces.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""Workspaces page."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Adw", "1")
|
||||
from gi.repository import Adw, Gtk
|
||||
|
||||
from nirimod.kdl_parser import KdlNode, set_child_arg
|
||||
from nirimod import niri_ipc
|
||||
from nirimod.pages.base import BasePage
|
||||
|
||||
|
||||
class WorkspacesPage(BasePage):
|
||||
def build(self) -> Gtk.Widget:
|
||||
tb, header, _, content = self._make_toolbar_page("Workspaces")
|
||||
self._content = content
|
||||
|
||||
add_btn = Gtk.Button(icon_name="list-add-symbolic")
|
||||
add_btn.add_css_class("flat")
|
||||
add_btn.connect("clicked", self._on_add)
|
||||
header.pack_end(add_btn)
|
||||
|
||||
self._grp = Adw.PreferencesGroup(
|
||||
title="Named Workspaces",
|
||||
description="Named workspaces open immediately at niri startup",
|
||||
)
|
||||
content.append(self._grp)
|
||||
self.refresh()
|
||||
return tb
|
||||
|
||||
def refresh(self):
|
||||
self._rebuild()
|
||||
|
||||
def _get_ws_nodes(self) -> list[KdlNode]:
|
||||
return [n for n in self._nodes if n.name == "workspace"]
|
||||
|
||||
def _rebuild(self):
|
||||
parent = self._grp.get_parent()
|
||||
if parent is None:
|
||||
return
|
||||
|
||||
def _on_outputs(outputs_data):
|
||||
ws_nodes = self._get_ws_nodes()
|
||||
outputs = [o.get("name", "") for o in outputs_data]
|
||||
output_model = Gtk.StringList.new(["(any)"] + outputs)
|
||||
|
||||
new_grp = Adw.PreferencesGroup(
|
||||
title="Named Workspaces", description=f"{len(ws_nodes)} workspace(s)"
|
||||
)
|
||||
for i, ws in enumerate(ws_nodes):
|
||||
row = self._make_ws_row(ws, i, outputs, output_model)
|
||||
new_grp.add(row)
|
||||
parent.remove(self._grp)
|
||||
parent.append(new_grp)
|
||||
self._grp = new_grp
|
||||
|
||||
niri_ipc.get_outputs(_on_outputs)
|
||||
|
||||
def _make_ws_row(
|
||||
self, ws: KdlNode, idx: int, outputs: list[str], output_model: Gtk.StringList
|
||||
) -> Adw.ExpanderRow:
|
||||
name = ws.args[0] if ws.args else f"workspace-{idx + 1}"
|
||||
assigned_out = ws.child_arg("open-on-output") or ""
|
||||
|
||||
exp = Adw.ExpanderRow(title=name)
|
||||
|
||||
name_row = Adw.EntryRow(title="Name")
|
||||
name_row.set_text(str(name))
|
||||
name_row.set_show_apply_button(True)
|
||||
name_row.connect("apply", lambda r, i=idx: self._rename_ws(i, r.get_text()))
|
||||
exp.add_row(name_row)
|
||||
|
||||
out_row = Adw.ComboRow(title="Open on Output")
|
||||
out_list = ["(any)"] + outputs
|
||||
out_row.set_model(Gtk.StringList.new(out_list))
|
||||
if assigned_out in outputs:
|
||||
out_row.set_selected(out_list.index(assigned_out))
|
||||
out_row.connect(
|
||||
"notify::selected",
|
||||
lambda r, _, i=idx, ol=out_list: self._set_ws_output(
|
||||
i, ol[r.get_selected()]
|
||||
),
|
||||
)
|
||||
exp.add_row(out_row)
|
||||
|
||||
del_btn = Gtk.Button(icon_name="user-trash-symbolic")
|
||||
del_btn.add_css_class("flat")
|
||||
del_btn.add_css_class("error")
|
||||
del_btn.set_valign(Gtk.Align.CENTER)
|
||||
del_btn.connect("clicked", lambda *_, i=idx: self._on_delete(i))
|
||||
exp.add_suffix(del_btn)
|
||||
|
||||
return exp
|
||||
|
||||
def _on_add(self, *_):
|
||||
dialog = Adw.AlertDialog(
|
||||
heading="Add Workspace", body="Enter a name for the new workspace."
|
||||
)
|
||||
entry = Adw.EntryRow(title="Workspace Name")
|
||||
grp = Adw.PreferencesGroup()
|
||||
grp.add(entry)
|
||||
dialog.set_extra_child(grp)
|
||||
dialog.add_response("cancel", "Cancel")
|
||||
dialog.add_response("add", "Add")
|
||||
dialog.set_response_appearance("add", Adw.ResponseAppearance.SUGGESTED)
|
||||
|
||||
def _on_resp(d, r):
|
||||
if r != "add":
|
||||
return
|
||||
name = entry.get_text().strip()
|
||||
if not name:
|
||||
return
|
||||
node = KdlNode("workspace", args=[name])
|
||||
ws_nodes = self._get_ws_nodes()
|
||||
if ws_nodes:
|
||||
last_idx = self._nodes.index(ws_nodes[-1])
|
||||
self._nodes.insert(last_idx + 1, node)
|
||||
else:
|
||||
# If no workspaces, insert at the top
|
||||
self._nodes.insert(0, node)
|
||||
self._commit("add workspace")
|
||||
self._rebuild()
|
||||
|
||||
dialog.connect("response", _on_resp)
|
||||
dialog.present(self._win)
|
||||
|
||||
def _on_delete(self, idx: int):
|
||||
ws_nodes = self._get_ws_nodes()
|
||||
if 0 <= idx < len(ws_nodes):
|
||||
self._nodes.remove(ws_nodes[idx])
|
||||
self._commit("remove workspace")
|
||||
self._rebuild()
|
||||
|
||||
def _rename_ws(self, idx: int, name: str):
|
||||
ws_nodes = self._get_ws_nodes()
|
||||
if 0 <= idx < len(ws_nodes) and name.strip():
|
||||
ws_nodes[idx].args = [name.strip()]
|
||||
self._commit("rename workspace")
|
||||
self._rebuild()
|
||||
|
||||
def _set_ws_output(self, idx: int, output: str):
|
||||
ws_nodes = self._get_ws_nodes()
|
||||
if 0 <= idx < len(ws_nodes):
|
||||
ws = ws_nodes[idx]
|
||||
if output and output != "(any)":
|
||||
set_child_arg(ws, "open-on-output", output)
|
||||
else:
|
||||
from nirimod.kdl_parser import remove_child
|
||||
|
||||
remove_child(ws, "open-on-output")
|
||||
self._commit("workspace output")
|
||||
72
nirimod/profiles.py
Normal file
72
nirimod/profiles.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Named config profiles: save/load Niri config snapshots."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from nirimod import kdl_parser
|
||||
|
||||
|
||||
def list_profiles() -> list[str]:
|
||||
if not kdl_parser.PROFILES_DIR.exists():
|
||||
return []
|
||||
names = [p.stem for p in kdl_parser.PROFILES_DIR.glob("*.kdl")]
|
||||
names += [p.name for p in kdl_parser.PROFILES_DIR.iterdir() if p.is_dir()]
|
||||
return sorted(names)
|
||||
|
||||
|
||||
def save_profile(name: str, source_files: set[Path] | None = None) -> None:
|
||||
kdl_parser.PROFILES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if source_files and len(source_files) > 1:
|
||||
dest_dir = kdl_parser.PROFILES_DIR / name
|
||||
dest_dir.mkdir(exist_ok=True)
|
||||
for p in source_files:
|
||||
if p.exists():
|
||||
try:
|
||||
rel = p.relative_to(kdl_parser.NIRI_CONFIG.parent)
|
||||
dest = dest_dir / rel
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(p, dest)
|
||||
except ValueError:
|
||||
shutil.copy2(p, dest_dir / p.name)
|
||||
else:
|
||||
if kdl_parser.NIRI_CONFIG.exists():
|
||||
shutil.copy2(kdl_parser.NIRI_CONFIG, kdl_parser.PROFILES_DIR / f"{name}.kdl")
|
||||
|
||||
|
||||
def load_profile(name: str) -> bool:
|
||||
dir_profile = kdl_parser.PROFILES_DIR / name
|
||||
if dir_profile.is_dir():
|
||||
def _restore(src_dir, dest_dir):
|
||||
for f in src_dir.iterdir():
|
||||
if f.is_file():
|
||||
rel = f.relative_to(dir_profile)
|
||||
target = dest_dir / rel
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(f, target)
|
||||
elif f.is_dir():
|
||||
_restore(f, dest_dir)
|
||||
|
||||
_restore(dir_profile, kdl_parser.NIRI_CONFIG.parent)
|
||||
return True
|
||||
|
||||
src = kdl_parser.PROFILES_DIR / f"{name}.kdl"
|
||||
if not src.exists():
|
||||
return False
|
||||
kdl_parser.save_niri_config(kdl_parser.parse_kdl(src.read_text()))
|
||||
return True
|
||||
|
||||
|
||||
def delete_profile(name: str) -> bool:
|
||||
dir_profile = kdl_parser.PROFILES_DIR / name
|
||||
if dir_profile.is_dir():
|
||||
shutil.rmtree(dir_profile)
|
||||
return True
|
||||
p = kdl_parser.PROFILES_DIR / f"{name}.kdl"
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
return True
|
||||
return False
|
||||
|
||||
149
nirimod/state.py
Normal file
149
nirimod/state.py
Normal file
@@ -0,0 +1,149 @@
|
||||
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from nirimod.kdl_parser import (
|
||||
NIRI_CONFIG,
|
||||
KdlNode,
|
||||
load_niri_config_multi,
|
||||
parse_kdl,
|
||||
save_niri_config,
|
||||
save_niri_config_multi,
|
||||
write_kdl,
|
||||
)
|
||||
from nirimod.undo import UndoEntry, UndoManager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class RuntimeInfo:
|
||||
|
||||
niri_running: bool = False
|
||||
has_touchpad: bool = False
|
||||
|
||||
|
||||
class AppState:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._nodes: list[KdlNode] = []
|
||||
self._saved_kdl: str = ""
|
||||
self._undo: UndoManager = UndoManager()
|
||||
self._runtime: RuntimeInfo = RuntimeInfo()
|
||||
self._dirty: bool = False
|
||||
self._include_slots: list[tuple[KdlNode, Path]] = []
|
||||
self._source_files: set[Path] = set()
|
||||
|
||||
def load(self) -> None:
|
||||
from nirimod import niri_ipc
|
||||
|
||||
self._runtime = RuntimeInfo(
|
||||
niri_running=niri_ipc.is_niri_running(),
|
||||
has_touchpad=niri_ipc.has_touchpad(),
|
||||
)
|
||||
self._nodes, self._include_slots = load_niri_config_multi()
|
||||
self._source_files = {NIRI_CONFIG}
|
||||
for _, path in self._include_slots:
|
||||
if path.exists():
|
||||
self._source_files.add(path)
|
||||
self._saved_kdl = write_kdl(self._nodes) if self._nodes else ""
|
||||
self._dirty = False
|
||||
|
||||
@property
|
||||
def nodes(self) -> list[KdlNode]:
|
||||
return self._nodes
|
||||
|
||||
@nodes.setter
|
||||
def nodes(self, value: list[KdlNode]) -> None:
|
||||
self._nodes = value
|
||||
|
||||
@property
|
||||
def saved_kdl(self) -> str:
|
||||
return self._saved_kdl
|
||||
|
||||
@property
|
||||
def source_files(self) -> set[Path]:
|
||||
return self._source_files
|
||||
|
||||
@property
|
||||
def include_slots(self) -> list[tuple[KdlNode, Path]]:
|
||||
return self._include_slots
|
||||
|
||||
@property
|
||||
def is_multi_file(self) -> bool:
|
||||
return bool(self._include_slots)
|
||||
|
||||
@property
|
||||
def niri_running(self) -> bool:
|
||||
return self._runtime.niri_running
|
||||
|
||||
@property
|
||||
def has_touchpad(self) -> bool:
|
||||
return self._runtime.has_touchpad
|
||||
|
||||
@property
|
||||
def is_dirty(self) -> bool:
|
||||
return self._dirty
|
||||
|
||||
def mark_dirty(self) -> None:
|
||||
self._dirty = True
|
||||
|
||||
def mark_clean(self) -> None:
|
||||
self._dirty = False
|
||||
|
||||
@property
|
||||
def undo(self) -> UndoManager:
|
||||
return self._undo
|
||||
|
||||
def push_undo(self, description: str, before: str, after: str) -> None:
|
||||
self._undo.push(UndoEntry(description, before, after))
|
||||
|
||||
def apply_undo(self) -> UndoEntry | None:
|
||||
entry = self._undo.pop_undo()
|
||||
if entry is None:
|
||||
return None
|
||||
self._nodes = parse_kdl(entry.snapshot_before)
|
||||
self._dirty = entry.snapshot_before != self._saved_kdl
|
||||
return entry
|
||||
|
||||
def apply_redo(self) -> UndoEntry | None:
|
||||
entry = self._undo.pop_redo()
|
||||
if entry is None:
|
||||
return None
|
||||
self._nodes = parse_kdl(entry.snapshot_after)
|
||||
self._dirty = entry.snapshot_after != self._saved_kdl
|
||||
return entry
|
||||
|
||||
def discard(self) -> None:
|
||||
self._nodes = parse_kdl(self._saved_kdl) if self._saved_kdl else []
|
||||
self._undo.clear()
|
||||
self._dirty = False
|
||||
|
||||
def commit_save(self, new_kdl: str) -> None:
|
||||
self._saved_kdl = new_kdl
|
||||
self._undo.clear()
|
||||
self._dirty = False
|
||||
|
||||
def reload_from_disk(self) -> None:
|
||||
self._nodes, self._include_slots = load_niri_config_multi()
|
||||
self._source_files = {NIRI_CONFIG}
|
||||
for _, path in self._include_slots:
|
||||
if path.exists():
|
||||
self._source_files.add(path)
|
||||
|
||||
def write_current_kdl(self) -> str:
|
||||
return write_kdl(self._nodes)
|
||||
|
||||
def write_to_path(self, path: Path | None = None) -> None:
|
||||
if path is not None:
|
||||
# Explicit path (e.g. validation temp file) — single file write
|
||||
save_niri_config(self._nodes, path=path)
|
||||
elif self._include_slots:
|
||||
save_niri_config_multi(self._nodes, self._include_slots)
|
||||
else:
|
||||
save_niri_config(self._nodes)
|
||||
325
nirimod/theme.py
Normal file
325
nirimod/theme.py
Normal file
@@ -0,0 +1,325 @@
|
||||
"""CSS theme definitions for NiriMod."""
|
||||
|
||||
CSS = """
|
||||
/* --- Nirimod -- Purple Theme --- */
|
||||
|
||||
/* --- Accent --- */
|
||||
@define-color nm_accent #9b6dff;
|
||||
@define-color nm_accent_mid #7c3aed;
|
||||
@define-color nm_accent_dim rgba(155, 109, 255, 0.13);
|
||||
@define-color nm_accent_hover rgba(155, 109, 255, 0.20);
|
||||
@define-color nm_accent_border rgba(155, 109, 255, 0.28);
|
||||
|
||||
/* --- Surfaces --- */
|
||||
@define-color window_bg_color #111114;
|
||||
@define-color window_fg_color #e8e8ed;
|
||||
@define-color view_bg_color #18181c;
|
||||
@define-color view_fg_color #e8e8ed;
|
||||
@define-color headerbar_bg_color #111114;
|
||||
@define-color card_bg_color #1e1e24;
|
||||
@define-color card_fg_color #e8e8ed;
|
||||
@define-color popover_bg_color #1e1e24;
|
||||
@define-color popover_fg_color #e8e8ed;
|
||||
@define-color dialog_bg_color #18181c;
|
||||
@define-color dialog_fg_color #e8e8ed;
|
||||
|
||||
/* --- Borders --- */
|
||||
@define-color nm_border rgba(255, 255, 255, 0.07);
|
||||
@define-color nm_border_strong rgba(255, 255, 255, 0.12);
|
||||
|
||||
/* --- Window --- */
|
||||
window {
|
||||
background-color: @window_bg_color;
|
||||
color: @window_fg_color;
|
||||
}
|
||||
|
||||
/* --- Header Bars --- */
|
||||
headerbar,
|
||||
.nm-sidebar-bg {
|
||||
background-color: @window_bg_color;
|
||||
background-image: none;
|
||||
box-shadow: none;
|
||||
border-bottom: 1px solid @nm_border;
|
||||
color: @window_fg_color;
|
||||
}
|
||||
|
||||
/* --- Sidebar --- */
|
||||
.navigation-sidebar {
|
||||
background-color: transparent;
|
||||
border-right: 1px solid @nm_border;
|
||||
}
|
||||
|
||||
.nm-sidebar-listbox {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.nm-sidebar-listbox row {
|
||||
border-radius: 7px;
|
||||
margin: 1px 4px;
|
||||
padding: 5px 8px;
|
||||
transition: background 130ms ease;
|
||||
color: @window_fg_color;
|
||||
}
|
||||
|
||||
.nm-sidebar-listbox row:hover {
|
||||
background: rgba(255, 255, 255, 0.045);
|
||||
}
|
||||
|
||||
.nm-sidebar-listbox row:selected {
|
||||
background: @nm_accent_dim;
|
||||
color: @nm_accent;
|
||||
}
|
||||
|
||||
.nm-sidebar-listbox row:selected image,
|
||||
.nm-sidebar-listbox row:selected label {
|
||||
color: @nm_accent;
|
||||
}
|
||||
|
||||
/* --- Section Labels --- */
|
||||
.nm-sidebar-section-label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: rgba(255, 255, 255, 0.30);
|
||||
}
|
||||
|
||||
/* --- Search --- */
|
||||
.nm-search-entry {
|
||||
color: @window_fg_color;
|
||||
background-color: @card_bg_color;
|
||||
border: 1px solid @nm_border;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.nm-search-entry > box { color: @window_fg_color; }
|
||||
.nm-search-entry text { color: @window_fg_color; }
|
||||
|
||||
.nm-search-results {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.nm-search-results row {
|
||||
padding: 8px 12px;
|
||||
border-radius: 7px;
|
||||
margin: 2px 4px;
|
||||
transition: background 110ms ease;
|
||||
}
|
||||
|
||||
.nm-search-results row:hover {
|
||||
background: @nm_accent_dim;
|
||||
}
|
||||
|
||||
/* --- Content Cards --- */
|
||||
.nm-card,
|
||||
preferencesgroup > box {
|
||||
background-color: @card_bg_color;
|
||||
border: 1px solid @nm_border;
|
||||
border-radius: 12px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
row {
|
||||
border-radius: 7px;
|
||||
transition: background 110ms ease;
|
||||
}
|
||||
|
||||
row:hover {
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
|
||||
/* --- Unsaved Changes Bar --- */
|
||||
.nm-dirty-bar {
|
||||
background: rgba(155, 109, 255, 0.07);
|
||||
border-top: 1px solid rgba(155, 109, 255, 0.18);
|
||||
padding: 8px 20px;
|
||||
}
|
||||
|
||||
/* --- Niri Banner --- */
|
||||
.nm-niri-banner {
|
||||
background: rgba(180, 110, 0, 0.10);
|
||||
color: rgba(240, 180, 50, 0.90);
|
||||
padding: 6px 16px;
|
||||
font-size: 13px;
|
||||
border-bottom: 1px solid rgba(180, 110, 0, 0.18);
|
||||
}
|
||||
|
||||
/* --- Badges & Status --- */
|
||||
.nm-badge {
|
||||
background: @nm_accent;
|
||||
color: #111114;
|
||||
border-radius: 12px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 1px 7px;
|
||||
min-width: 16px;
|
||||
}
|
||||
|
||||
/* --- Inline Tag Chips --- */
|
||||
.tag {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: @window_fg_color;
|
||||
border: 1px solid @nm_border_strong;
|
||||
border-radius: 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 1px 7px;
|
||||
}
|
||||
|
||||
.tag.accent {
|
||||
background: @nm_accent_dim;
|
||||
color: @nm_accent;
|
||||
border-color: @nm_accent_border;
|
||||
}
|
||||
|
||||
/* --- Buttons --- */
|
||||
button.suggested-action {
|
||||
border-radius: 9px;
|
||||
font-weight: 600;
|
||||
background: @nm_accent_mid;
|
||||
}
|
||||
|
||||
/* --- Toasts --- */
|
||||
toast {
|
||||
background-color: @card_bg_color;
|
||||
color: @card_fg_color;
|
||||
border: 1px solid @nm_accent_border;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.45);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
toast label { font-weight: 500; }
|
||||
|
||||
/* --- Code Editor --- */
|
||||
.code-editor {
|
||||
background-color: #0d0d10;
|
||||
color: #e8e8ed;
|
||||
border: 1px solid @nm_border;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* --- Keyboard Visualizer --- */
|
||||
.nm-kb-action-panel {
|
||||
background-color: @card_bg_color;
|
||||
border: 1px solid @nm_border;
|
||||
border-radius: 12px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.nm-kb-key-id-label {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: @window_fg_color;
|
||||
}
|
||||
|
||||
.nm-kb-swatch {
|
||||
min-width: 12px;
|
||||
min-height: 12px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* --- Keycaps --- */
|
||||
.nm-keycap-main, .nm-keycap-mod {
|
||||
background-color: @nm_accent_dim;
|
||||
border: 1px solid @nm_accent_border;
|
||||
border-radius: 5px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: @nm_accent;
|
||||
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.nm-keycap-main {
|
||||
background-color: rgba(155, 109, 255, 0.22);
|
||||
border-color: rgba(155, 109, 255, 0.45);
|
||||
color: rgba(210, 190, 255, 1.0);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.nm-keycap-mod { opacity: 0.80; }
|
||||
|
||||
.nm-keycap-purple {
|
||||
background: @nm_accent_dim;
|
||||
color: @nm_accent;
|
||||
border: 1px solid @nm_accent_border;
|
||||
border-radius: 5px;
|
||||
padding: 2px 8px;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* --- Pulse Highlight (search) --- */
|
||||
@keyframes pulse-highlight {
|
||||
0% { background-color: transparent; }
|
||||
18% { background-color: rgba(155, 109, 255, 0.28); }
|
||||
100% { background-color: transparent; }
|
||||
}
|
||||
|
||||
.nm-pulse-highlight {
|
||||
animation-name: pulse-highlight;
|
||||
animation-duration: 1.4s;
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
|
||||
/* --- Animations Page --- */
|
||||
.nm-anim-banner {
|
||||
background: @nm_accent_dim;
|
||||
border: 1px solid @nm_accent_border;
|
||||
border-radius: 10px;
|
||||
padding: 10px 16px;
|
||||
color: @nm_accent;
|
||||
}
|
||||
|
||||
.nm-anim-banner button {
|
||||
background: rgba(155, 109, 255, 0.15);
|
||||
border: 1px solid @nm_accent_border;
|
||||
color: @nm_accent;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
padding: 4px 14px;
|
||||
}
|
||||
|
||||
.nm-anim-banner button:hover {
|
||||
background: @nm_accent_hover;
|
||||
}
|
||||
|
||||
.nm-preset-icon {
|
||||
font-size: 18px;
|
||||
min-width: 28px;
|
||||
}
|
||||
|
||||
/* --- Bindings Page --- */
|
||||
.nm-binding-card {
|
||||
background: rgba(30, 30, 35, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
transition: all 200ms ease;
|
||||
}
|
||||
.nm-binding-card:hover {
|
||||
background: rgba(45, 45, 50, 0.8);
|
||||
border-color: rgba(147, 51, 234, 0.4);
|
||||
}
|
||||
.nm-binding-actions-label {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.05em;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
.nm-binding-action-name {
|
||||
color: rgba(192, 132, 252, 1.0);
|
||||
font-weight: 600;
|
||||
font-size: 1.0rem;
|
||||
}
|
||||
.nm-keycap-purple {
|
||||
background: #581c87;
|
||||
color: white;
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
font-weight: bold;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
""".encode("utf-8")
|
||||
61
nirimod/undo.py
Normal file
61
nirimod/undo.py
Normal file
@@ -0,0 +1,61 @@
|
||||
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class UndoEntry:
|
||||
description: str
|
||||
snapshot_before: str
|
||||
snapshot_after: str
|
||||
|
||||
|
||||
class UndoManager:
|
||||
def __init__(self, max_depth: int = 100):
|
||||
self._stack: list[UndoEntry] = []
|
||||
self._redo_stack: list[UndoEntry] = []
|
||||
self._max = max_depth
|
||||
|
||||
def push(self, entry: UndoEntry) -> None:
|
||||
self._stack.append(entry)
|
||||
if len(self._stack) > self._max:
|
||||
self._stack.pop(0)
|
||||
self._redo_stack.clear()
|
||||
|
||||
@property
|
||||
def last_snapshot(self) -> str | None:
|
||||
if self._stack:
|
||||
return self._stack[-1].snapshot_after
|
||||
return None
|
||||
|
||||
@property
|
||||
def last_description(self) -> str | None:
|
||||
if self._stack:
|
||||
return self._stack[-1].description
|
||||
return None
|
||||
|
||||
def pop_undo(self) -> UndoEntry | None:
|
||||
if not self._stack:
|
||||
return None
|
||||
entry = self._stack.pop()
|
||||
self._redo_stack.append(entry)
|
||||
return entry
|
||||
|
||||
def pop_redo(self) -> UndoEntry | None:
|
||||
if not self._redo_stack:
|
||||
return None
|
||||
entry = self._redo_stack.pop()
|
||||
self._stack.append(entry)
|
||||
return entry
|
||||
|
||||
def can_undo(self) -> bool:
|
||||
return bool(self._stack)
|
||||
|
||||
def can_redo(self) -> bool:
|
||||
return bool(self._redo_stack)
|
||||
|
||||
def clear(self) -> None:
|
||||
self._stack.clear()
|
||||
self._redo_stack.clear()
|
||||
135
nirimod/updater.py
Normal file
135
nirimod/updater.py
Normal file
@@ -0,0 +1,135 @@
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import urllib.request
|
||||
import stat
|
||||
|
||||
API_URL = "https://api.github.com/repos/srinivasr/nirimod/commits/main"
|
||||
INSTALL_DIR = os.path.expanduser("~/.local/share/nirimod")
|
||||
FALLBACK_TERMINALS = [
|
||||
"xdg-terminal-exec",
|
||||
"gnome-terminal",
|
||||
"kgx", # GNOME Console
|
||||
"kitty",
|
||||
"ghostty",
|
||||
"alacritty",
|
||||
"konsole",
|
||||
"foot",
|
||||
"xterm",
|
||||
]
|
||||
|
||||
|
||||
def check_for_updates(callback):
|
||||
|
||||
def _do_check():
|
||||
try:
|
||||
from gi.repository import GLib
|
||||
|
||||
if not os.path.isdir(os.path.join(INSTALL_DIR, ".git")):
|
||||
GLib.idle_add(callback, None, None)
|
||||
return
|
||||
|
||||
local_hash = subprocess.check_output(
|
||||
["git", "rev-parse", "HEAD"],
|
||||
cwd=INSTALL_DIR,
|
||||
text=True,
|
||||
).strip()
|
||||
|
||||
req = urllib.request.Request(
|
||||
API_URL, headers={"User-Agent": "NiriMod-Updater"}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
remote_hash = data.get("sha")
|
||||
commit_msg = data.get("commit", {}).get(
|
||||
"message", "New update available"
|
||||
)
|
||||
|
||||
if _update_available(local_hash, remote_hash, INSTALL_DIR):
|
||||
GLib.idle_add(callback, remote_hash, commit_msg)
|
||||
else:
|
||||
GLib.idle_add(callback, None, None)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Update check failed: {e}")
|
||||
GLib.idle_add(callback, None, None)
|
||||
|
||||
threading.Thread(target=_do_check, daemon=True).start()
|
||||
|
||||
|
||||
def _commit_is_ancestor(commit_hash: str, install_dir: str) -> bool:
|
||||
result = subprocess.run(
|
||||
["git", "merge-base", "--is-ancestor", commit_hash, "HEAD"],
|
||||
cwd=install_dir,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def _update_available(
|
||||
local_hash: str, remote_hash: str | None, install_dir: str = INSTALL_DIR
|
||||
) -> bool:
|
||||
if not remote_hash or remote_hash == local_hash:
|
||||
return False
|
||||
if _commit_is_ancestor(remote_hash, install_dir):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _terminal_candidates():
|
||||
terminal = os.environ.get("TERMINAL", "").strip()
|
||||
if terminal:
|
||||
yield terminal
|
||||
yield from FALLBACK_TERMINALS
|
||||
|
||||
|
||||
def _build_terminal_command(terminal: str, script_path: str) -> list[str] | None:
|
||||
try:
|
||||
parts = shlex.split(terminal)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
if not parts:
|
||||
return None
|
||||
|
||||
if os.path.basename(parts[0]) == "xdg-terminal-exec":
|
||||
return [*parts, script_path]
|
||||
|
||||
if parts[-1] in {"-e", "--execute", "-x"}:
|
||||
return [*parts, script_path]
|
||||
|
||||
return [*parts, "-e", script_path]
|
||||
|
||||
|
||||
def launch_updater_in_terminal():
|
||||
|
||||
script_content = """#!/usr/bin/env bash
|
||||
echo "Starting NiriMod update..."
|
||||
curl -sSL https://raw.githubusercontent.com/srinivasr/nirimod/main/install.sh | bash -s -- --install
|
||||
echo ""
|
||||
echo "Update complete! Press Enter to close this window."
|
||||
read
|
||||
"""
|
||||
script_path = os.path.join(tempfile.gettempdir(), "nirimod_update.sh")
|
||||
with open(script_path, "w") as f:
|
||||
f.write(script_content)
|
||||
os.chmod(script_path, stat.S_IRWXU)
|
||||
|
||||
for term in _terminal_candidates():
|
||||
command = _build_terminal_command(term, script_path)
|
||||
if command is None or shutil.which(command[0]) is None:
|
||||
continue
|
||||
|
||||
try:
|
||||
subprocess.Popen(command)
|
||||
return
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
print("Could not find a suitable terminal to launch the update.")
|
||||
5
nirimod/widgets/__init__.py
Normal file
5
nirimod/widgets/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""NiriMod custom widgets."""
|
||||
|
||||
from nirimod.widgets.keyboard_visualizer import KeyboardVisualizer, normalize_key_id
|
||||
|
||||
__all__ = ["KeyboardVisualizer", "normalize_key_id"]
|
||||
696
nirimod/widgets/keyboard_visualizer.py
Normal file
696
nirimod/widgets/keyboard_visualizer.py
Normal file
@@ -0,0 +1,696 @@
|
||||
"""Keyboard visualizer widget — Cairo DrawingArea keyboard map.
|
||||
|
||||
Inspired from omer-biz/visu (Elm/WASM) into pure Python + Cairo.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
try:
|
||||
import cairo # noqa: F401
|
||||
HAS_CAIRO = True
|
||||
except ImportError:
|
||||
HAS_CAIRO = False
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Adw", "1")
|
||||
from gi.repository import Adw, GLib, GObject, Gtk
|
||||
|
||||
from nirimod.xkb_helper import XkbHelper
|
||||
|
||||
KEYBOARD_GEOMETRIES: dict[str, list[list[tuple[str, int]]]] = {
|
||||
"ANSI": [
|
||||
# Row 0 — function row
|
||||
[("escape", 4), ("", 2), ("f1", 3), ("f2", 3), ("f3", 3), ("f4", 3), ("", 2), ("f5", 3), ("f6", 3), ("f7", 3), ("f8", 3), ("", 2), ("f9", 3), ("f10", 3), ("f11", 3), ("f12", 3), ("", 2), ("print", 4), ("insert", 4), ("delete", 4)],
|
||||
# Row 1 — number row
|
||||
[("grave", 4), ("1", 4), ("2", 4), ("3", 4), ("4", 4), ("5", 4), ("6", 4), ("7", 4), ("8", 4), ("9", 4), ("0", 4), ("minus", 4), ("equal", 4), ("backspace", 8)],
|
||||
# Row 2 — QWERTY
|
||||
[("tab", 6), ("q", 4), ("w", 4), ("e", 4), ("r", 4), ("t", 4), ("y", 4), ("u", 4), ("i", 4), ("o", 4), ("p", 4), ("bracketleft", 4), ("bracketright", 4), ("backslash", 6)],
|
||||
# Row 3 — home row
|
||||
[("capslock", 7), ("a", 4), ("s", 4), ("d", 4), ("f", 4), ("g", 4), ("h", 4), ("j", 4), ("k", 4), ("l", 4), ("semicolon", 4), ("quote", 4), ("return", 9)],
|
||||
# Row 4 — shift row
|
||||
[("shiftleft", 7), ("z", 4), ("x", 4), ("c", 4), ("v", 4), ("b", 4), ("n", 4), ("m", 4), ("comma", 4), ("period", 4), ("slash", 4), ("shiftright", 5), ("up", 4), ("", 4)],
|
||||
# Row 5 — bottom row
|
||||
[("ctrlleft", 6), ("superleft", 6), ("altleft", 6), ("space", 24), ("altright", 6), ("left", 4), ("down", 4), ("right", 4)],
|
||||
],
|
||||
"ISO": [
|
||||
# Row 0 — function row
|
||||
[("escape", 4), ("", 2), ("f1", 3), ("f2", 3), ("f3", 3), ("f4", 3), ("", 2), ("f5", 3), ("f6", 3), ("f7", 3), ("f8", 3), ("", 2), ("f9", 3), ("f10", 3), ("f11", 3), ("f12", 3), ("", 2), ("print", 4), ("insert", 4), ("delete", 4)],
|
||||
# Row 1 — number row
|
||||
[("grave", 4), ("1", 4), ("2", 4), ("3", 4), ("4", 4), ("5", 4), ("6", 4), ("7", 4), ("8", 4), ("9", 4), ("0", 4), ("minus", 4), ("equal", 4), ("backspace", 8)],
|
||||
# Row 2 — QWERTY
|
||||
[("tab", 6), ("q", 4), ("w", 4), ("e", 4), ("r", 4), ("t", 4), ("y", 4), ("u", 4), ("i", 4), ("o", 4), ("p", 4), ("bracketleft", 4), ("bracketright", 4), ("return", 6)],
|
||||
# Row 3 — home row
|
||||
[("capslock", 7), ("a", 4), ("s", 4), ("d", 4), ("f", 4), ("g", 4), ("h", 4), ("j", 4), ("k", 4), ("l", 4), ("semicolon", 4), ("quote", 4), ("backslash", 4), ("return", 5)],
|
||||
# Row 4 — shift row
|
||||
[("shiftleft", 4), ("less", 4), ("z", 4), ("x", 4), ("c", 4), ("v", 4), ("b", 4), ("n", 4), ("m", 4), ("comma", 4), ("period", 4), ("slash", 4), ("shiftright", 4), ("up", 4), ("", 4)],
|
||||
# Row 5 — bottom row
|
||||
[("ctrlleft", 6), ("superleft", 6), ("altleft", 6), ("space", 24), ("altright", 6), ("left", 4), ("down", 4), ("right", 4)],
|
||||
]
|
||||
}
|
||||
|
||||
_KID_TO_KEYCODE = {
|
||||
# Function row
|
||||
"escape": 1, "f1": 59, "f2": 60, "f3": 61, "f4": 62, "f5": 63, "f6": 64, "f7": 65, "f8": 66, "f9": 67, "f10": 68, "f11": 87, "f12": 88, "print": 99, "insert": 110, "delete": 111,
|
||||
# Number row
|
||||
"grave": 41, "1": 2, "2": 3, "3": 4, "4": 5, "5": 6, "6": 7, "7": 8, "8": 9, "9": 10, "0": 11, "minus": 12, "equal": 13, "backspace": 14,
|
||||
# Row 2
|
||||
"tab": 15, "q": 16, "w": 17, "e": 18, "r": 19, "t": 20, "y": 21, "u": 22, "i": 23, "o": 24, "p": 25, "bracketleft": 26, "bracketright": 27, "backslash": 43,
|
||||
# Row 3
|
||||
"capslock": 58, "a": 30, "s": 31, "d": 32, "f": 33, "g": 34, "h": 35, "j": 36, "k": 37, "l": 38, "semicolon": 39, "quote": 40, "return": 28,
|
||||
# Row 4
|
||||
"shiftleft": 42, "less": 94, "z": 44, "x": 45, "c": 46, "v": 47, "b": 48, "n": 49, "m": 50, "comma": 51, "period": 52, "slash": 53, "shiftright": 54, "up": 103,
|
||||
# Row 5
|
||||
"ctrlleft": 29, "superleft": 125, "altleft": 56, "space": 57, "altright": 100, "left": 105, "down": 108, "right": 106
|
||||
}
|
||||
|
||||
_STATIC_LABELS = {
|
||||
"escape": "Esc", "backspace": "Bksp", "tab": "Tab", "return": "Enter", "capslock": "Caps",
|
||||
"shiftleft": "Shift", "shiftright": "Shift", "ctrlleft": "Ctrl", "superleft": "Super",
|
||||
"altleft": "Alt", "altright": "Alt", "up": "↑", "down": "↓", "left": "←", "right": "→", "space": "",
|
||||
"grave": "`",
|
||||
"f1": "F1", "f2": "F2", "f3": "F3", "f4": "F4", "f5": "F5", "f6": "F6",
|
||||
"f7": "F7", "f8": "F8", "f9": "F9", "f10": "F10", "f11": "F11", "f12": "F12",
|
||||
"print": "PrtSc", "insert": "Ins", "delete": "Del",
|
||||
}
|
||||
|
||||
|
||||
_MODIFIER_KEY_IDS = {
|
||||
"shiftleft",
|
||||
"shiftright",
|
||||
"ctrlleft",
|
||||
"altleft",
|
||||
"altright",
|
||||
"superleft",
|
||||
"capslock",
|
||||
"tab",
|
||||
"backspace",
|
||||
"space",
|
||||
}
|
||||
|
||||
_KEYSYM_ALIAS: dict[str, str] = {
|
||||
"return": "return",
|
||||
"enter": "return",
|
||||
"kp_enter": "return",
|
||||
"escape": "escape",
|
||||
"esc": "escape",
|
||||
"backspace": "backspace",
|
||||
"tab": "tab",
|
||||
"space": "space",
|
||||
"bracketleft": "bracketleft",
|
||||
"bracketright": "bracketright",
|
||||
"minus": "minus",
|
||||
"equal": "equal",
|
||||
"period": "period",
|
||||
"comma": "comma",
|
||||
"slash": "slash",
|
||||
"backslash": "backslash",
|
||||
"semicolon": "semicolon",
|
||||
"apostrophe": "quote",
|
||||
"quote": "quote",
|
||||
"grave": "grave",
|
||||
"up": "up",
|
||||
"down": "down",
|
||||
"left": "left",
|
||||
"right": "right",
|
||||
"page_up": "pageup",
|
||||
"page_down": "pagedown",
|
||||
"home": "home",
|
||||
"end": "end",
|
||||
"print": "print",
|
||||
"sysrq": "print",
|
||||
"delete": "delete",
|
||||
"del": "delete",
|
||||
"insert": "insert",
|
||||
"ins": "insert",
|
||||
"f1": "f1", "f2": "f2", "f3": "f3", "f4": "f4",
|
||||
"f5": "f5", "f6": "f6", "f7": "f7", "f8": "f8",
|
||||
"f9": "f9", "f10": "f10", "f11": "f11", "f12": "f12",
|
||||
}
|
||||
|
||||
for _c in "abcdefghijklmnopqrstuvwxyz0123456789":
|
||||
_KEYSYM_ALIAS[_c] = _c
|
||||
|
||||
|
||||
def normalize_key_id(raw_key: str) -> str:
|
||||
"""Convert a raw keysym (last part of Mod+Shift+X) to a keyboard layout id."""
|
||||
k = raw_key.strip().lower()
|
||||
return _KEYSYM_ALIAS.get(k, k)
|
||||
|
||||
|
||||
def _rgb(r: int, g: int, b: int, a: float = 1.0):
|
||||
return (r / 255, g / 255, b / 255, a)
|
||||
|
||||
|
||||
# Unbound key
|
||||
_COL_KEY_BG = _rgb(30, 30, 36) # dark charcoal fill
|
||||
_COL_KEY_BORDER = _rgb(255, 255, 255, 0.07) # barely visible edge
|
||||
_COL_KEY_FG = _rgb(200, 200, 210) # label colour
|
||||
|
||||
# Bound key
|
||||
_COL_BOUND_BG = _rgb(45, 30, 80) # muted indigo fill
|
||||
_COL_BOUND_BORDER = _rgb(100, 60, 160, 1.0) # soft purple border
|
||||
_COL_BOUND_GLOW = _rgb(100, 60, 160, 0.20) # subtle outer glow
|
||||
_COL_BOUND_MOD = _rgb(160, 140, 200) # muted MOD label tint
|
||||
|
||||
# Selected key
|
||||
_COL_SEL_BG = _rgb(70, 40, 120)
|
||||
_COL_SEL_BORDER = _rgb(140, 80, 200, 1.0)
|
||||
_COL_SEL_GLOW = _rgb(140, 80, 200, 0.30)
|
||||
|
||||
# Search-match key
|
||||
_COL_SEARCH_BG = _rgb(100, 50, 130)
|
||||
_COL_SEARCH_BORDER = _rgb(160, 80, 180, 1.0)
|
||||
_COL_SEARCH_GLOW = _rgb(160, 80, 180, 0.25)
|
||||
|
||||
# Badge pill
|
||||
_COL_BADGE_BG = _rgb(80, 40, 140)
|
||||
_COL_BADGE_FG = _rgb(255, 255, 255)
|
||||
|
||||
# Chassis
|
||||
_COL_FRAME_BG = _rgb(10, 10, 12)
|
||||
_COL_FRAME_BORDER = _rgb(255, 255, 255, 0.07)
|
||||
|
||||
|
||||
class _AspectDrawingArea(Gtk.DrawingArea):
|
||||
def __init__(self, ratio=2.43):
|
||||
super().__init__()
|
||||
self._ratio = ratio
|
||||
self.set_hexpand(True)
|
||||
|
||||
def do_get_request_mode(self):
|
||||
return Gtk.SizeRequestMode.HEIGHT_FOR_WIDTH
|
||||
|
||||
def do_measure(self, orientation, for_size):
|
||||
if orientation == Gtk.Orientation.HORIZONTAL:
|
||||
return (400, 560, -1, -1)
|
||||
else:
|
||||
if for_size > 0:
|
||||
h = int(for_size / self._ratio)
|
||||
return (h, h, -1, -1)
|
||||
else:
|
||||
h = int(560 / self._ratio)
|
||||
return (h, h, -1, -1)
|
||||
|
||||
|
||||
class KeyboardVisualizer(Gtk.Box):
|
||||
"""Cairo-rendered ANSI QWERTY keyboard with niri binding overlays."""
|
||||
|
||||
__gsignals__ = {
|
||||
"key-selected": (GObject.SignalFlags.RUN_FIRST, None, (str,)),
|
||||
"edit-binding": (GObject.SignalFlags.RUN_FIRST, None, (object,)),
|
||||
"add-binding": (GObject.SignalFlags.RUN_FIRST, None, (str,)),
|
||||
"delete-binding": (GObject.SignalFlags.RUN_FIRST, None, (object,)),
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=12)
|
||||
|
||||
# State
|
||||
self._layout_id: str = "us"
|
||||
self._geometry_id: str = "ANSI"
|
||||
self._bindings: dict[str, list[dict]] = {} # key_id → [bind_dict, ...]
|
||||
self._selected_id: str | None = None
|
||||
self._search_q: str = ""
|
||||
self._dynamic_keysym_to_kid: dict[str, str] = {}
|
||||
|
||||
self._xkb = XkbHelper()
|
||||
self._xkb.set_layout(self._layout_id)
|
||||
|
||||
self._key_rects: list[tuple[str, float, float, float, float]] = []
|
||||
|
||||
if not HAS_CAIRO:
|
||||
err_lbl = Gtk.Label(
|
||||
label="Cairo is not installed — the physical keyboard view is unavailable.\nInstall dev-python/pycairo and restart.",
|
||||
justify=Gtk.Justification.CENTER,
|
||||
)
|
||||
err_lbl.add_css_class("dim-label")
|
||||
err_lbl.set_vexpand(True)
|
||||
self.append(err_lbl)
|
||||
return
|
||||
|
||||
# Drawing area
|
||||
self._area = _AspectDrawingArea(ratio=2.43)
|
||||
self._area.set_draw_func(self._draw)
|
||||
self.append(self._area)
|
||||
|
||||
click = Gtk.GestureClick()
|
||||
click.connect("released", self._on_click)
|
||||
self._area.add_controller(click)
|
||||
|
||||
if HAS_CAIRO:
|
||||
self._panel = _ActionPanel(
|
||||
on_edit=lambda b: self.emit("edit-binding", b),
|
||||
on_add=lambda k: self.emit("add-binding", k),
|
||||
on_delete=lambda b: self.emit("delete-binding", b),
|
||||
)
|
||||
self.append(self._panel)
|
||||
|
||||
# Legend
|
||||
self.append(self._build_legend())
|
||||
|
||||
# Public API
|
||||
|
||||
def set_bindings(self, bindings: dict[str, list[dict]]) -> None:
|
||||
"""Accept a key_id → [bind_dict] mapping and refresh."""
|
||||
self._bindings = bindings
|
||||
if hasattr(self, "_area"):
|
||||
self._area.queue_draw()
|
||||
if self._selected_id:
|
||||
self._panel.update(
|
||||
self._selected_id, self._bindings.get(self._selected_id, [])
|
||||
)
|
||||
|
||||
def set_layout(self, layout_id: str) -> None:
|
||||
"""Set the visualizer layout mapping (e.g. 'us', 'it')."""
|
||||
self._layout_id = layout_id
|
||||
self._xkb.set_layout(layout_id)
|
||||
|
||||
|
||||
base_layout = layout_id.split(":")[0].lower()
|
||||
iso_layouts = {'it', 'fr', 'de', 'es', 'pt', 'uk', 'ru', 'ch', 'be', 'no', 'se', 'fi', 'dk'}
|
||||
if base_layout in iso_layouts:
|
||||
self._geometry_id = "ISO"
|
||||
else:
|
||||
self._geometry_id = "ANSI"
|
||||
|
||||
self._dynamic_keysym_to_kid.clear()
|
||||
for kid, keycode in _KID_TO_KEYCODE.items():
|
||||
sym = self._xkb.get_keysym_name(keycode)
|
||||
if sym:
|
||||
self._dynamic_keysym_to_kid[sym.lower()] = kid
|
||||
|
||||
if hasattr(self, "_area"):
|
||||
self._area.queue_draw()
|
||||
|
||||
def set_search(self, query: str) -> None:
|
||||
self._search_q = query.strip().lower()
|
||||
if hasattr(self, "_area"):
|
||||
self._area.queue_draw()
|
||||
|
||||
|
||||
# Internal helpers
|
||||
|
||||
def _on_click(self, gesture, n_press, x, y):
|
||||
for kid, rx, ry, rw, rh in self._key_rects:
|
||||
if rx <= x <= rx + rw and ry <= y <= ry + rh:
|
||||
self._selected_id = kid
|
||||
self._panel.update(kid, self._bindings.get(kid, []))
|
||||
self._area.queue_draw()
|
||||
self.emit("key-selected", kid)
|
||||
return
|
||||
|
||||
def _matches_search(self, binds: list[dict]) -> bool:
|
||||
if not self._search_q:
|
||||
return False
|
||||
q = self._search_q
|
||||
for b in binds:
|
||||
if q in b.get("action", "").lower():
|
||||
return True
|
||||
if q in b.get("keysym", "").lower():
|
||||
return True
|
||||
return False
|
||||
|
||||
def _draw(self, area, cr, width: int, height: int):
|
||||
if width <= 0 or height <= 0:
|
||||
return
|
||||
self._key_rects = []
|
||||
|
||||
# Internal margins
|
||||
pad_x, pad_y = 16, 12
|
||||
chassis_r = 12.0
|
||||
|
||||
inner_w = width - 2 * pad_x
|
||||
inner_h = height - 2 * pad_y
|
||||
|
||||
active_geom = KEYBOARD_GEOMETRIES.get(self._geometry_id) or KEYBOARD_GEOMETRIES["ANSI"]
|
||||
n_rows = len(active_geom)
|
||||
|
||||
frow_ratio = 0.7
|
||||
frow_gap = max(3.0, inner_h * 0.015)
|
||||
row_h = (inner_h - frow_gap) / (frow_ratio + n_rows - 1)
|
||||
frow_h = frow_ratio * row_h
|
||||
|
||||
key_gap = max(2.5, row_h * 0.07)
|
||||
radius = max(4.0, row_h * 0.16)
|
||||
total_units = max(sum(w for _, w in row) for row in active_geom)
|
||||
|
||||
# Keyboard chassis
|
||||
self._rounded_rect(cr, 0, 0, width, height, chassis_r)
|
||||
cr.set_source_rgba(*_COL_FRAME_BG)
|
||||
cr.fill_preserve()
|
||||
cr.set_source_rgba(*_COL_FRAME_BORDER)
|
||||
cr.set_line_width(1.0)
|
||||
cr.stroke()
|
||||
|
||||
for row_idx, row in enumerate(active_geom):
|
||||
if row_idx == 0:
|
||||
y = float(pad_y)
|
||||
this_row_h = frow_h
|
||||
else:
|
||||
y = float(pad_y + frow_h + frow_gap + (row_idx - 1) * row_h)
|
||||
this_row_h = row_h
|
||||
x = float(pad_x)
|
||||
|
||||
for kid, units in row:
|
||||
key_w = (units / total_units) * inner_w
|
||||
|
||||
if not kid:
|
||||
x += key_w
|
||||
continue
|
||||
|
||||
label = _STATIC_LABELS.get(kid)
|
||||
if label is None:
|
||||
keycode = _KID_TO_KEYCODE.get(kid)
|
||||
if keycode:
|
||||
label = self._xkb.get_label(keycode)
|
||||
if label is None:
|
||||
label = kid.upper() if len(kid) <= 1 else kid.capitalize()
|
||||
else:
|
||||
label = label.upper() if len(label) == 1 else label
|
||||
|
||||
kx = x + key_gap / 2
|
||||
ky = y + key_gap / 2
|
||||
kw = key_w - key_gap
|
||||
kh = this_row_h - key_gap
|
||||
|
||||
binds = self._bindings.get(kid, [])
|
||||
is_bound = bool(binds)
|
||||
is_sel = self._selected_id == kid
|
||||
is_search = is_bound and self._matches_search(binds)
|
||||
|
||||
if is_sel:
|
||||
fill = _COL_SEL_BG
|
||||
border = _COL_SEL_BORDER
|
||||
glow = _COL_SEL_GLOW
|
||||
elif is_search:
|
||||
fill = _COL_SEARCH_BG
|
||||
border = _COL_SEARCH_BORDER
|
||||
glow = _COL_SEARCH_GLOW
|
||||
elif is_bound:
|
||||
fill = _COL_BOUND_BG
|
||||
border = _COL_BOUND_BORDER
|
||||
glow = _COL_BOUND_GLOW
|
||||
else:
|
||||
fill = _COL_KEY_BG
|
||||
border = _COL_KEY_BORDER
|
||||
glow = None
|
||||
|
||||
if glow:
|
||||
for spread, alpha_scale in ((6, 0.15), (3, 0.25), (1, 0.35)):
|
||||
cr.set_source_rgba(glow[0], glow[1], glow[2], glow[3] * alpha_scale)
|
||||
self._rounded_rect(
|
||||
cr,
|
||||
kx - spread, ky - spread,
|
||||
kw + spread * 2, kh + spread * 2,
|
||||
radius + spread,
|
||||
)
|
||||
cr.fill()
|
||||
|
||||
# Key face fill
|
||||
self._rounded_rect(cr, kx, ky, kw, kh, radius)
|
||||
cr.set_source_rgba(*fill)
|
||||
cr.fill_preserve()
|
||||
|
||||
# Key border
|
||||
lw = 1.2 if (is_bound or is_sel) else 0.8
|
||||
cr.set_source_rgba(*border)
|
||||
cr.set_line_width(lw)
|
||||
cr.stroke()
|
||||
|
||||
if is_bound:
|
||||
first_mod = self._first_modifier(binds)
|
||||
if first_mod:
|
||||
mod_fs = max(4.5, kh * 0.14)
|
||||
cr.select_font_face("Sans", 0, 1)
|
||||
cr.set_font_size(mod_fs)
|
||||
mx = int(kx + 6)
|
||||
my = int(ky + mod_fs + 5)
|
||||
cr.set_source_rgba(*_COL_BOUND_MOD)
|
||||
cr.move_to(mx, my)
|
||||
cr.show_text(first_mod[:3].upper())
|
||||
|
||||
fs = max(7.0, kh * 0.26)
|
||||
cr.select_font_face("Sans", 0, 1)
|
||||
cr.set_font_size(fs)
|
||||
te = cr.text_extents(label)
|
||||
tx = int(kx + (kw - te.width) / 2 - te.x_bearing)
|
||||
ty = int(ky + (kh + te.height) / 2 - te.height / 2)
|
||||
|
||||
# Drop shadow
|
||||
cr.set_source_rgba(0, 0, 0, 0.5)
|
||||
cr.move_to(tx, ty + 1)
|
||||
cr.show_text(label)
|
||||
cr.set_source_rgba(1.0, 1.0, 1.0, 0.9)
|
||||
cr.move_to(tx, ty)
|
||||
cr.show_text(label)
|
||||
|
||||
if len(binds) > 1:
|
||||
badge_txt = str(len(binds))
|
||||
bfs = max(5.0, kh * 0.14)
|
||||
cr.select_font_face("Sans", 0, 1)
|
||||
cr.set_font_size(bfs)
|
||||
bte = cr.text_extents(badge_txt)
|
||||
bpad = 2.0
|
||||
bw = bte.width + bpad * 2
|
||||
bh_pill = bte.height + bpad * 2
|
||||
bx = int(kx + kw - bw - 5)
|
||||
by = int(ky + kh - bh_pill - 5)
|
||||
cr.set_source_rgba(*_COL_BADGE_BG)
|
||||
self._rounded_rect(cr, bx, by, bw, bh_pill, bh_pill / 2)
|
||||
cr.fill()
|
||||
cr.set_source_rgba(*_COL_BADGE_FG)
|
||||
cr.move_to(int(bx + bpad - bte.x_bearing), int(by + bpad - bte.y_bearing))
|
||||
cr.show_text(badge_txt)
|
||||
|
||||
self._key_rects.append((kid, kx, ky, kw, kh))
|
||||
x += key_w
|
||||
|
||||
@staticmethod
|
||||
def _rounded_rect(cr, x: float, y: float, w: float, h: float, r: float):
|
||||
r = min(r, w / 2, h / 2)
|
||||
cr.new_sub_path()
|
||||
cr.arc(x + w - r, y + r, r, -math.pi / 2, 0)
|
||||
cr.arc(x + w - r, y + h - r, r, 0, math.pi / 2)
|
||||
cr.arc(x + r, y + h - r, r, math.pi / 2, math.pi)
|
||||
cr.arc(x + r, y + r, r, math.pi, 3 * math.pi / 2)
|
||||
cr.close_path()
|
||||
|
||||
@staticmethod
|
||||
def _first_modifier(binds: list[dict]) -> str:
|
||||
if not binds:
|
||||
return ""
|
||||
keysym = binds[0].get("keysym", "")
|
||||
parts = keysym.split("+")
|
||||
if len(parts) > 1:
|
||||
m = parts[0].lower()
|
||||
_mod_labels = {
|
||||
"mod": "MOD",
|
||||
"super": "SUP",
|
||||
"ctrl": "CTL",
|
||||
"control": "CTL",
|
||||
"shift": "SHF",
|
||||
"alt": "ALT",
|
||||
"win": "WIN",
|
||||
}
|
||||
return _mod_labels.get(m, m[:4].upper())
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _build_legend() -> Gtk.Box:
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=20)
|
||||
box.set_halign(Gtk.Align.CENTER)
|
||||
box.set_margin_top(2)
|
||||
box.set_opacity(0.65)
|
||||
|
||||
def _chip(rgba_css: str, text: str) -> Gtk.Box:
|
||||
hb = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
||||
swatch = Gtk.Box()
|
||||
swatch.set_size_request(12, 12)
|
||||
swatch.add_css_class("nm-kb-swatch")
|
||||
|
||||
attrs = Gtk.CssProvider()
|
||||
attrs.load_from_data(
|
||||
f".nm-kb-swatch {{ background: {rgba_css}; border-radius: 3px; }}".encode()
|
||||
)
|
||||
swatch.get_style_context().add_provider(
|
||||
attrs, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
|
||||
)
|
||||
lbl = Gtk.Label(label=text)
|
||||
lbl.add_css_class("caption")
|
||||
hb.append(swatch)
|
||||
hb.append(lbl)
|
||||
return hb
|
||||
|
||||
box.append(_chip("rgba(147, 51, 234, 0.7)", "Bound"))
|
||||
box.append(_chip("rgba(192, 97, 203, 1.0)", "Search match"))
|
||||
box.append(_chip("rgba(168, 85, 247, 1.0)", "Selected"))
|
||||
box.append(_chip("rgba(24, 24, 27, 1.0)", "Unbound"))
|
||||
return box
|
||||
|
||||
|
||||
# Action overlay panel
|
||||
|
||||
|
||||
class _ActionPanel(Gtk.Box):
|
||||
"""Shows the binding details for the currently selected key."""
|
||||
|
||||
def __init__(self, on_edit=None, on_add=None, on_delete=None):
|
||||
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
||||
self._on_edit = on_edit
|
||||
self._on_add = on_add
|
||||
self._on_delete = on_delete
|
||||
self._current_key_id = None
|
||||
self.add_css_class("nm-kb-action-panel")
|
||||
self.set_visible(False)
|
||||
|
||||
# Header row
|
||||
header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
|
||||
header.set_margin_start(14)
|
||||
header.set_margin_end(14)
|
||||
header.set_margin_top(10)
|
||||
header.set_margin_bottom(6)
|
||||
|
||||
self._key_label = Gtk.Label(label="")
|
||||
self._key_label.add_css_class("nm-kb-key-id-label")
|
||||
self._key_label.set_xalign(0.0)
|
||||
self._key_label.set_hexpand(True)
|
||||
header.append(self._key_label)
|
||||
|
||||
self._count_label = Gtk.Label(label="")
|
||||
self._count_label.add_css_class("dim-label")
|
||||
self._count_label.add_css_class("caption")
|
||||
header.append(self._count_label)
|
||||
|
||||
self._header_add_btn = Gtk.Button(icon_name="list-add-symbolic")
|
||||
self._header_add_btn.add_css_class("flat")
|
||||
self._header_add_btn.add_css_class("circular")
|
||||
self._header_add_btn.set_tooltip_text("Add another binding for this key")
|
||||
self._header_add_btn.set_valign(Gtk.Align.CENTER)
|
||||
self._header_add_btn.set_visible(False)
|
||||
self._header_add_btn.connect("clicked", self._on_header_add_clicked)
|
||||
header.append(self._header_add_btn)
|
||||
|
||||
self.append(header)
|
||||
|
||||
self.append(Gtk.Separator())
|
||||
|
||||
|
||||
self._grp_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
||||
self._grp_container.set_margin_start(8)
|
||||
self._grp_container.set_margin_end(8)
|
||||
self._grp_container.set_margin_top(6)
|
||||
self._grp_container.set_margin_bottom(8)
|
||||
self.append(self._grp_container)
|
||||
|
||||
self.set_visible(False)
|
||||
|
||||
def _on_header_add_clicked(self, *_):
|
||||
if self._on_add and self._current_key_id:
|
||||
self._on_add(self._current_key_id)
|
||||
|
||||
def update(self, key_id: str, binds: list[dict]):
|
||||
self._current_key_id = key_id
|
||||
while True:
|
||||
c = self._grp_container.get_first_child()
|
||||
if c is None:
|
||||
break
|
||||
self._grp_container.remove(c)
|
||||
|
||||
new_grp = Adw.PreferencesGroup()
|
||||
|
||||
if not binds:
|
||||
self._key_label.set_label(key_id.upper())
|
||||
self._count_label.set_label("No bindings")
|
||||
self._header_add_btn.set_visible(False)
|
||||
|
||||
add_btn = Gtk.Button(label=f"Create Binding for {key_id.upper()}")
|
||||
add_btn.add_css_class("suggested-action")
|
||||
add_btn.add_css_class("pill")
|
||||
add_btn.set_halign(Gtk.Align.CENTER)
|
||||
add_btn.set_margin_top(8)
|
||||
add_btn.set_margin_bottom(8)
|
||||
if self._on_add:
|
||||
add_btn.connect("clicked", lambda *_: self._on_add(key_id))
|
||||
|
||||
new_grp.add(add_btn)
|
||||
else:
|
||||
self._key_label.set_label(key_id.upper())
|
||||
n = len(binds)
|
||||
self._count_label.set_label(f"{n} binding" + ("s" if n != 1 else ""))
|
||||
self._header_add_btn.set_visible(True)
|
||||
for b in binds:
|
||||
keysym = b.get("keysym", "?")
|
||||
action = b.get("action", "")
|
||||
args = b.get("action_args") or []
|
||||
arg_str = " ".join(str(a) for a in args)
|
||||
full_action = f"{action} {arg_str}".strip() or "(no action)"
|
||||
|
||||
row = Adw.ActionRow(title=GLib.markup_escape_text(full_action))
|
||||
|
||||
keys_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
||||
keys_box.set_valign(Gtk.Align.CENTER)
|
||||
keys_box.set_margin_start(4)
|
||||
keys_box.set_margin_end(16)
|
||||
|
||||
parts = keysym.split("+")
|
||||
_labels = {
|
||||
"mod": "Mod",
|
||||
"super": "Super",
|
||||
"ctrl": "Ctrl",
|
||||
"control": "Ctrl",
|
||||
"shift": "Shift",
|
||||
"alt": "Alt",
|
||||
"win": "Win",
|
||||
}
|
||||
|
||||
for i, part in enumerate(parts):
|
||||
label_text = part
|
||||
is_mod = i < len(parts) - 1
|
||||
if is_mod:
|
||||
label_text = _labels.get(part.lower(), part)
|
||||
else:
|
||||
label_text = (
|
||||
label_text.upper() if len(label_text) == 1 else label_text
|
||||
)
|
||||
|
||||
cap = Gtk.Label(label=label_text)
|
||||
if is_mod:
|
||||
cap.add_css_class("nm-keycap-mod")
|
||||
else:
|
||||
cap.add_css_class("nm-keycap-main")
|
||||
keys_box.append(cap)
|
||||
|
||||
row.add_prefix(keys_box)
|
||||
|
||||
if b.get("allow_when_locked"):
|
||||
lock = Gtk.Label(label="🔒")
|
||||
lock.set_tooltip_text("Allowed when screen is locked")
|
||||
lock.set_valign(Gtk.Align.CENTER)
|
||||
row.add_suffix(lock)
|
||||
|
||||
edit_btn = Gtk.Button(icon_name="document-edit-symbolic")
|
||||
edit_btn.add_css_class("flat")
|
||||
edit_btn.add_css_class("circular")
|
||||
edit_btn.set_valign(Gtk.Align.CENTER)
|
||||
if self._on_edit:
|
||||
edit_btn.connect("clicked", lambda *_, bind_ref=b: self._on_edit(bind_ref))
|
||||
row.add_suffix(edit_btn)
|
||||
|
||||
del_btn = Gtk.Button(icon_name="user-trash-symbolic")
|
||||
del_btn.add_css_class("flat")
|
||||
del_btn.add_css_class("circular")
|
||||
del_btn.add_css_class("error")
|
||||
del_btn.set_valign(Gtk.Align.CENTER)
|
||||
if self._on_delete:
|
||||
del_btn.connect("clicked", lambda *_, bind_ref=b: self._on_delete(bind_ref))
|
||||
row.add_suffix(del_btn)
|
||||
|
||||
new_grp.add(row)
|
||||
|
||||
self._grp_container.append(new_grp)
|
||||
self.set_visible(True)
|
||||
|
||||
1135
nirimod/window.py
Normal file
1135
nirimod/window.py
Normal file
File diff suppressed because it is too large
Load Diff
299
nirimod/window_effects.py
Normal file
299
nirimod/window_effects.py
Normal file
@@ -0,0 +1,299 @@
|
||||
"""Helpers for Niri global window effect rules."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from nirimod.kdl_parser import KdlNode, remove_child, set_child_arg, set_node_flag
|
||||
|
||||
_RULE_CHILD_ORDER = [
|
||||
"match",
|
||||
"exclude",
|
||||
"geometry-corner-radius",
|
||||
"clip-to-geometry",
|
||||
"draw-border-with-background",
|
||||
"opacity",
|
||||
"background-effect",
|
||||
]
|
||||
|
||||
_EFFECT_CHILD_ORDER = ["blur", "xray"]
|
||||
_BLUR_CONFIG_CHILD_ORDER = ["off", "passes", "offset", "noise", "saturation"]
|
||||
|
||||
|
||||
def _is_global_window_rule(node: KdlNode) -> bool:
|
||||
return (
|
||||
node.name == "window-rule"
|
||||
and not node.get_children("match")
|
||||
and not node.get_children("exclude")
|
||||
)
|
||||
|
||||
|
||||
def _is_focused_window_rule(node: KdlNode) -> bool:
|
||||
if node.name != "window-rule" or node.get_children("exclude"):
|
||||
return False
|
||||
matches = node.get_children("match")
|
||||
if len(matches) != 1:
|
||||
return False
|
||||
match = matches[0]
|
||||
return match.args == [] and match.props == {"is-focused": True}
|
||||
|
||||
|
||||
def _global_window_rule(nodes: list[KdlNode]) -> KdlNode | None:
|
||||
return next((n for n in reversed(nodes) if _is_global_window_rule(n)), None)
|
||||
|
||||
|
||||
def _focused_window_rule(nodes: list[KdlNode]) -> KdlNode | None:
|
||||
return next((n for n in reversed(nodes) if _is_focused_window_rule(n)), None)
|
||||
|
||||
|
||||
def _blur_config_node(nodes: list[KdlNode]) -> KdlNode | None:
|
||||
return next((n for n in reversed(nodes) if n.name == "blur"), None)
|
||||
|
||||
|
||||
def _ensure_blur_config_node(nodes: list[KdlNode]) -> KdlNode:
|
||||
blur = _blur_config_node(nodes)
|
||||
if blur is None:
|
||||
blur = KdlNode("blur")
|
||||
blur.leading_trivia = "\n"
|
||||
nodes.append(blur)
|
||||
return blur
|
||||
|
||||
|
||||
def _ensure_global_window_rule(nodes: list[KdlNode]) -> KdlNode:
|
||||
rule = _global_window_rule(nodes)
|
||||
if rule is None:
|
||||
rule = KdlNode("window-rule")
|
||||
rule.leading_trivia = "\n"
|
||||
nodes.append(rule)
|
||||
return rule
|
||||
|
||||
|
||||
def _ensure_focused_window_rule(nodes: list[KdlNode]) -> KdlNode:
|
||||
rule = _focused_window_rule(nodes)
|
||||
if rule is None:
|
||||
rule = KdlNode("window-rule")
|
||||
rule.leading_trivia = "\n"
|
||||
rule.children.append(KdlNode("match", props={"is-focused": True}))
|
||||
nodes.append(rule)
|
||||
return rule
|
||||
|
||||
|
||||
def _background_effect(rule: KdlNode) -> KdlNode | None:
|
||||
return rule.get_child("background-effect")
|
||||
|
||||
|
||||
def _ensure_background_effect(rule: KdlNode) -> KdlNode:
|
||||
effect = _background_effect(rule)
|
||||
if effect is None:
|
||||
effect = KdlNode("background-effect")
|
||||
effect.leading_trivia = "\n"
|
||||
rule.children.append(effect)
|
||||
return effect
|
||||
|
||||
|
||||
def _remove_rule_if_empty(nodes: list[KdlNode], rule: KdlNode) -> None:
|
||||
if not rule.args and not rule.props and not rule.children and rule in nodes:
|
||||
nodes.remove(rule)
|
||||
|
||||
|
||||
def _remove_background_effect_if_empty(rule: KdlNode) -> None:
|
||||
effect = _background_effect(rule)
|
||||
if effect and not effect.args and not effect.props and not effect.children:
|
||||
remove_child(rule, "background-effect")
|
||||
|
||||
|
||||
def _compact_generated_spacing(node: KdlNode) -> None:
|
||||
if not node.trailing_trivia or node.trailing_trivia.isspace():
|
||||
node.trailing_trivia = "\n"
|
||||
for child in node.children:
|
||||
if not child.leading_trivia or child.leading_trivia.isspace():
|
||||
child.leading_trivia = ""
|
||||
if not child.trailing_trivia or child.trailing_trivia.isspace():
|
||||
child.trailing_trivia = ""
|
||||
if child.name == "background-effect":
|
||||
_compact_generated_spacing(child)
|
||||
|
||||
|
||||
def _sort_children_by_name(node: KdlNode, order: list[str]) -> None:
|
||||
order_index = {name: index for index, name in enumerate(order)}
|
||||
indexed_children = list(enumerate(node.children))
|
||||
indexed_children.sort(
|
||||
key=lambda item: (order_index.get(item[1].name, len(order)), item[0])
|
||||
)
|
||||
node.children = [child for _, child in indexed_children]
|
||||
|
||||
|
||||
def _finalize_window_rule(rule: KdlNode) -> None:
|
||||
effect = _background_effect(rule)
|
||||
if effect is not None:
|
||||
_sort_children_by_name(effect, _EFFECT_CHILD_ORDER)
|
||||
_sort_children_by_name(rule, _RULE_CHILD_ORDER)
|
||||
_compact_generated_spacing(rule)
|
||||
|
||||
|
||||
def _finalize_blur_config(blur: KdlNode) -> None:
|
||||
_sort_children_by_name(blur, _BLUR_CONFIG_CHILD_ORDER)
|
||||
_compact_generated_spacing(blur)
|
||||
|
||||
|
||||
def _rule_opacity(rule: KdlNode | None) -> float:
|
||||
if rule is None:
|
||||
return 1.0
|
||||
value = rule.child_arg("opacity", 1.0)
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return 1.0
|
||||
|
||||
|
||||
def get_global_draw_border_with_background(nodes: list[KdlNode]) -> bool:
|
||||
rule = _global_window_rule(nodes)
|
||||
if rule is None:
|
||||
return True
|
||||
return rule.child_arg("draw-border-with-background", True) is not False
|
||||
|
||||
|
||||
def set_global_draw_border_with_background(nodes: list[KdlNode], enabled: bool) -> None:
|
||||
rule = _ensure_global_window_rule(nodes)
|
||||
if enabled:
|
||||
remove_child(rule, "draw-border-with-background")
|
||||
else:
|
||||
set_child_arg(rule, "draw-border-with-background", False)
|
||||
_finalize_window_rule(rule)
|
||||
_remove_rule_if_empty(nodes, rule)
|
||||
|
||||
|
||||
def blur_effects_enabled(nodes: list[KdlNode]) -> bool:
|
||||
blur = _blur_config_node(nodes)
|
||||
return blur is None or blur.get_child("off") is None
|
||||
|
||||
|
||||
def set_blur_effects_enabled(nodes: list[KdlNode], enabled: bool) -> None:
|
||||
blur = _ensure_blur_config_node(nodes)
|
||||
set_node_flag(blur, "off", not enabled)
|
||||
_finalize_blur_config(blur)
|
||||
_remove_rule_if_empty(nodes, blur)
|
||||
if enabled:
|
||||
rule = _ensure_global_window_rule(nodes)
|
||||
if _rule_opacity(rule) >= 1.0:
|
||||
set_child_arg(rule, "opacity", 0.9)
|
||||
_finalize_window_rule(rule)
|
||||
else:
|
||||
set_global_window_blur(nodes, False)
|
||||
set_focused_window_blur(nodes, False)
|
||||
|
||||
|
||||
def global_window_blur_enabled(nodes: list[KdlNode]) -> bool:
|
||||
rule = _global_window_rule(nodes)
|
||||
return _rule_blur_enabled(rule)
|
||||
|
||||
|
||||
def focused_window_blur_enabled(nodes: list[KdlNode]) -> bool:
|
||||
rule = _focused_window_rule(nodes)
|
||||
return _rule_blur_enabled(rule)
|
||||
|
||||
|
||||
def _rule_blur_enabled(rule: KdlNode | None) -> bool:
|
||||
if rule is None:
|
||||
return False
|
||||
effect = _background_effect(rule)
|
||||
if effect is None:
|
||||
return False
|
||||
blur = effect.get_child("blur")
|
||||
return blur is not None and (not blur.args or blur.args[0] is True)
|
||||
|
||||
|
||||
def set_global_window_blur(nodes: list[KdlNode], enabled: bool) -> None:
|
||||
if enabled:
|
||||
rule = _ensure_global_window_rule(nodes)
|
||||
if _rule_opacity(rule) >= 1.0:
|
||||
set_child_arg(rule, "opacity", 0.9)
|
||||
effect = _ensure_background_effect(rule)
|
||||
set_child_arg(effect, "blur", True)
|
||||
_finalize_window_rule(rule)
|
||||
return
|
||||
|
||||
existing_rule = _global_window_rule(nodes)
|
||||
if existing_rule is None:
|
||||
return
|
||||
existing_effect = _background_effect(existing_rule)
|
||||
if existing_effect is not None:
|
||||
remove_child(existing_effect, "blur")
|
||||
_remove_background_effect_if_empty(existing_rule)
|
||||
remove_child(existing_rule, "opacity")
|
||||
_finalize_window_rule(existing_rule)
|
||||
_remove_rule_if_empty(nodes, existing_rule)
|
||||
|
||||
|
||||
def set_focused_window_blur(nodes: list[KdlNode], enabled: bool) -> None:
|
||||
if enabled:
|
||||
rule = _ensure_focused_window_rule(nodes)
|
||||
|
||||
effect = _ensure_background_effect(rule)
|
||||
set_child_arg(effect, "blur", True)
|
||||
_finalize_window_rule(rule)
|
||||
return
|
||||
|
||||
existing_rule = _focused_window_rule(nodes)
|
||||
if existing_rule is None:
|
||||
return
|
||||
existing_effect = _background_effect(existing_rule)
|
||||
if existing_effect is not None:
|
||||
remove_child(existing_effect, "blur")
|
||||
_remove_background_effect_if_empty(existing_rule)
|
||||
_finalize_window_rule(existing_rule)
|
||||
if len(existing_rule.children) == 1 and existing_rule.get_child("match"):
|
||||
nodes.remove(existing_rule)
|
||||
|
||||
|
||||
def global_window_xray_enabled(nodes: list[KdlNode]) -> bool:
|
||||
rule = _global_window_rule(nodes)
|
||||
if rule is None:
|
||||
return True
|
||||
effect = _background_effect(rule)
|
||||
if effect is None:
|
||||
return True
|
||||
xray = effect.get_child("xray")
|
||||
return xray is None or not xray.args or xray.args[0] is True
|
||||
|
||||
|
||||
def set_global_window_xray(nodes: list[KdlNode], enabled: bool) -> None:
|
||||
rule = _ensure_global_window_rule(nodes)
|
||||
effect = _ensure_background_effect(rule)
|
||||
set_child_arg(effect, "xray", enabled)
|
||||
_finalize_window_rule(rule)
|
||||
|
||||
|
||||
def get_global_window_opacity(nodes: list[KdlNode]) -> float:
|
||||
return _rule_opacity(_global_window_rule(nodes))
|
||||
|
||||
|
||||
def set_global_window_opacity(nodes: list[KdlNode], opacity: float) -> None:
|
||||
rule = _ensure_global_window_rule(nodes)
|
||||
if opacity < 1.0:
|
||||
set_child_arg(rule, "opacity", round(opacity, 2))
|
||||
else:
|
||||
remove_child(rule, "opacity")
|
||||
_finalize_window_rule(rule)
|
||||
_remove_rule_if_empty(nodes, rule)
|
||||
|
||||
|
||||
def get_global_corner_radius(nodes: list[KdlNode]) -> int:
|
||||
rule = _global_window_rule(nodes)
|
||||
if rule is None:
|
||||
return 0
|
||||
value = rule.child_arg("geometry-corner-radius", 0)
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def set_global_corner_radius(nodes: list[KdlNode], radius: int) -> None:
|
||||
rule = _ensure_global_window_rule(nodes)
|
||||
if radius > 0:
|
||||
set_child_arg(rule, "geometry-corner-radius", radius)
|
||||
set_child_arg(rule, "clip-to-geometry", True)
|
||||
else:
|
||||
remove_child(rule, "geometry-corner-radius")
|
||||
remove_child(rule, "clip-to-geometry")
|
||||
_finalize_window_rule(rule)
|
||||
_remove_rule_if_empty(nodes, rule)
|
||||
168
nirimod/xkb_helper.py
Normal file
168
nirimod/xkb_helper.py
Normal file
@@ -0,0 +1,168 @@
|
||||
import ctypes
|
||||
import ctypes.util
|
||||
import os
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
class XkbHelper:
|
||||
def __init__(self):
|
||||
self.lib = None
|
||||
self.ctx = None
|
||||
self.keymap = None
|
||||
self.state = None
|
||||
|
||||
path = ctypes.util.find_library("xkbcommon")
|
||||
if not path:
|
||||
|
||||
for p in [
|
||||
"/usr/lib/libxkbcommon.so.0",
|
||||
"/usr/lib64/libxkbcommon.so.0",
|
||||
"/lib/x86_64-linux-gnu/libxkbcommon.so.0",
|
||||
"/usr/lib/x86_64-linux-gnu/libxkbcommon.so.0",
|
||||
|
||||
"/usr/lib/libxkbcommon.so",
|
||||
"/usr/lib64/libxkbcommon.so",
|
||||
|
||||
"/lib/libxkbcommon.so.0",
|
||||
|
||||
"/run/current-system/sw/lib/libxkbcommon.so.0",
|
||||
]:
|
||||
if os.path.exists(p):
|
||||
path = p
|
||||
break
|
||||
|
||||
if not path:
|
||||
return
|
||||
|
||||
try:
|
||||
self.lib = ctypes.CDLL(path)
|
||||
|
||||
# Prototypes
|
||||
self.lib.xkb_context_new.restype = ctypes.c_void_p
|
||||
self.lib.xkb_keymap_new_from_names.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_int]
|
||||
self.lib.xkb_keymap_new_from_names.restype = ctypes.c_void_p
|
||||
self.lib.xkb_state_new.argtypes = [ctypes.c_void_p]
|
||||
self.lib.xkb_state_new.restype = ctypes.c_void_p
|
||||
self.lib.xkb_state_key_get_utf8.argtypes = [ctypes.c_void_p, ctypes.c_uint32, ctypes.c_char_p, ctypes.c_size_t]
|
||||
self.lib.xkb_state_key_get_utf8.restype = ctypes.c_int
|
||||
|
||||
self.lib.xkb_state_key_get_one_sym.argtypes = [ctypes.c_void_p, ctypes.c_uint32]
|
||||
self.lib.xkb_state_key_get_one_sym.restype = ctypes.c_uint32
|
||||
|
||||
self.lib.xkb_keysym_get_name.argtypes = [ctypes.c_uint32, ctypes.c_char_p, ctypes.c_size_t]
|
||||
self.lib.xkb_keysym_get_name.restype = ctypes.c_int
|
||||
|
||||
self.lib.xkb_keymap_unref.argtypes = [ctypes.c_void_p]
|
||||
self.lib.xkb_keymap_unref.restype = None
|
||||
self.lib.xkb_state_unref.argtypes = [ctypes.c_void_p]
|
||||
self.lib.xkb_state_unref.restype = None
|
||||
|
||||
self.ctx = self.lib.xkb_context_new(0)
|
||||
except Exception:
|
||||
self.lib = None
|
||||
|
||||
class XkbRuleNames(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("rules", ctypes.c_char_p),
|
||||
("model", ctypes.c_char_p),
|
||||
("layout", ctypes.c_char_p),
|
||||
("variant", ctypes.c_char_p),
|
||||
("options", ctypes.c_char_p),
|
||||
]
|
||||
|
||||
def set_layout(self, layout_id: str):
|
||||
if not self.lib or not self.ctx:
|
||||
return
|
||||
|
||||
parts = layout_id.split(":", 1)
|
||||
layout_name = parts[0]
|
||||
variant_name = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
self._layout_bytes = layout_name.encode()
|
||||
self._variant_bytes = variant_name.encode() if variant_name else None
|
||||
names = self.XkbRuleNames(None, None, self._layout_bytes, self._variant_bytes, None)
|
||||
|
||||
if self.state:
|
||||
self.lib.xkb_state_unref(self.state)
|
||||
self.state = None
|
||||
if self.keymap:
|
||||
self.lib.xkb_keymap_unref(self.keymap)
|
||||
self.keymap = None
|
||||
|
||||
self.keymap = self.lib.xkb_keymap_new_from_names(self.ctx, ctypes.byref(names), 0)
|
||||
if self.keymap:
|
||||
self.state = self.lib.xkb_state_new(self.keymap)
|
||||
|
||||
def get_label(self, keycode: int) -> str | None:
|
||||
if not self.state:
|
||||
return None
|
||||
|
||||
|
||||
xkb_keycode = keycode + 8
|
||||
|
||||
buf = ctypes.create_string_buffer(32)
|
||||
res = self.lib.xkb_state_key_get_utf8(self.state, xkb_keycode, buf, 32)
|
||||
if res > 0:
|
||||
return buf.value.decode('utf-8')
|
||||
return None
|
||||
|
||||
def get_keysym_name(self, keycode: int) -> str | None:
|
||||
if not self.state:
|
||||
return None
|
||||
|
||||
xkb_keycode = keycode + 8
|
||||
sym = self.lib.xkb_state_key_get_one_sym(self.state, xkb_keycode)
|
||||
if sym == 0:
|
||||
return None
|
||||
|
||||
buf = ctypes.create_string_buffer(64)
|
||||
res = self.lib.xkb_keysym_get_name(sym, buf, 64)
|
||||
if res >= 0:
|
||||
return buf.value.decode('utf-8')
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_available_layouts() -> list[tuple[str, str]]:
|
||||
|
||||
paths = [
|
||||
"/usr/share/X11/xkb/rules/evdev.xml",
|
||||
"/usr/share/X11/xkb/rules/base.xml",
|
||||
|
||||
"/usr/share/xkb/rules/evdev.xml",
|
||||
"/usr/share/xkb/rules/base.xml",
|
||||
|
||||
"/run/current-system/sw/share/X11/xkb/rules/evdev.xml",
|
||||
]
|
||||
layouts = []
|
||||
for p in paths:
|
||||
if os.path.exists(p):
|
||||
try:
|
||||
tree = ET.parse(p)
|
||||
root = tree.getroot()
|
||||
for layout in root.findall(".//layout"):
|
||||
config = layout.find("configItem")
|
||||
if config is not None:
|
||||
name = config.findtext("name")
|
||||
desc = config.findtext("description")
|
||||
if name and desc:
|
||||
layouts.append((name, desc))
|
||||
|
||||
|
||||
variant_list = layout.find("variantList")
|
||||
if variant_list is not None:
|
||||
for variant in variant_list.findall("variant"):
|
||||
v_config = variant.find("configItem")
|
||||
if v_config is not None:
|
||||
v_name = v_config.findtext("name")
|
||||
v_desc = v_config.findtext("description")
|
||||
if name and v_name and v_desc:
|
||||
layouts.append((f"{name}:{v_name}", v_desc))
|
||||
|
||||
if layouts:
|
||||
# Sort by description
|
||||
layouts.sort(key=lambda x: x[1])
|
||||
return layouts
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
|
||||
return [("us", "English (US)"), ("us:dvorak", "English (Dvorak)"), ("it", "Italian"), ("fr", "French"), ("de", "German"), ("es", "Spanish")]
|
||||
78
package.nix
Normal file
78
package.nix
Normal file
@@ -0,0 +1,78 @@
|
||||
{
|
||||
lib,
|
||||
python3Packages,
|
||||
wrapGAppsHook4,
|
||||
gtk4,
|
||||
libadwaita,
|
||||
gdk-pixbuf,
|
||||
gobject-introspection,
|
||||
hicolor-icon-theme,
|
||||
desktop-file-utils,
|
||||
}:
|
||||
|
||||
python3Packages.buildPythonApplication (finalAttrs: {
|
||||
pname = "nirimod";
|
||||
version = "0.1.0";
|
||||
|
||||
pyproject = true;
|
||||
|
||||
src = lib.cleanSource ./.;
|
||||
# For nixpkgs: replace with fetchFromGitHub pointing to a release tag:
|
||||
# src = fetchFromGitHub {
|
||||
# owner = "srinivasr";
|
||||
# repo = "nirimod";
|
||||
# tag = "v${finalAttrs.version}";
|
||||
# hash = "sha256-...";
|
||||
# };
|
||||
|
||||
nativeBuildInputs = [
|
||||
wrapGAppsHook4
|
||||
gobject-introspection
|
||||
desktop-file-utils
|
||||
];
|
||||
|
||||
build-system = with python3Packages; [
|
||||
hatchling
|
||||
];
|
||||
|
||||
buildInputs = [
|
||||
gtk4
|
||||
libadwaita
|
||||
gdk-pixbuf
|
||||
hicolor-icon-theme
|
||||
];
|
||||
|
||||
dependencies = with python3Packages; [
|
||||
pygobject3
|
||||
];
|
||||
|
||||
postInstall = ''
|
||||
install -Dm644 data/nirimod.svg $out/share/icons/hicolor/scalable/apps/nirimod.svg
|
||||
|
||||
mkdir -p $out/share/applications
|
||||
cat > $out/share/applications/io.github.nirimod.desktop << EOF
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Name=NiriMod
|
||||
GenericName=Compositor Settings
|
||||
Comment=GUI Configuration Manager for the Niri Wayland Compositor
|
||||
Exec=nirimod
|
||||
Icon=nirimod
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=Utility;Settings;DesktopSettings;
|
||||
Keywords=compositor;windowmanager;wayland;niri;settings;config;
|
||||
StartupNotify=true
|
||||
StartupWMClass=nirimod
|
||||
EOF
|
||||
'';
|
||||
|
||||
meta = {
|
||||
description = "A polished GTK4/libadwaita GUI configurator for the niri Wayland compositor";
|
||||
homepage = "https://github.com/srinivasr/nirimod";
|
||||
license = lib.licenses.mit;
|
||||
maintainers = [ ];
|
||||
mainProgram = "nirimod";
|
||||
platforms = lib.platforms.linux;
|
||||
};
|
||||
})
|
||||
34
pyproject.toml
Normal file
34
pyproject.toml
Normal file
@@ -0,0 +1,34 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "nirimod"
|
||||
version = "0.1.0"
|
||||
description = "A polished GTK4/libadwaita GUI configurator for the niri Wayland compositor"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
# PyGObject (gi.repository.Gtk, Adw) must be installed via system package manager:
|
||||
# Arch: sudo pacman -S python-gobject gtk4 libadwaita
|
||||
# Fedora: sudo dnf install python3-gobject gtk4 libadwaita
|
||||
# Ubuntu: sudo apt install python3-gi gir1.2-gtk-4.0 gir1.2-adw-1
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
nirimod = "nirimod.__main__:main"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["nirimod"]
|
||||
|
||||
[tool.ruff]
|
||||
ignore = ["E402"]
|
||||
|
||||
[tool.uv]
|
||||
python-preference = "system"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=9.0.3",
|
||||
]
|
||||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""NiriMod test suite."""
|
||||
311
tests/test_features.py
Normal file
311
tests/test_features.py
Normal file
@@ -0,0 +1,311 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Headless feature tests for NiriMod — exercises every page's logic."""
|
||||
|
||||
import sys
|
||||
import traceback
|
||||
import os
|
||||
os.environ.setdefault("DISPLAY", ":0")
|
||||
os.environ.setdefault("WAYLAND_DISPLAY", "wayland-1")
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||
PASS = "[PASS]"
|
||||
WARN = "[WARN]"
|
||||
FAIL = "[FAIL]"
|
||||
|
||||
results = []
|
||||
|
||||
def test(name, fn):
|
||||
try:
|
||||
msg = fn()
|
||||
results.append((PASS, name, msg or ""))
|
||||
except Exception as e:
|
||||
results.append((FAIL, name, f"{type(e).__name__}: {e}"))
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
test.__test__ = False
|
||||
|
||||
# KDL Parser
|
||||
from nirimod.kdl_parser import parse_kdl, write_kdl, KdlNode
|
||||
|
||||
def t_kdl_roundtrip():
|
||||
src = 'output "eDP-1" { scale 2.0; }\nbinds { XF86AudioRaise { action "volume-up"; } }'
|
||||
nodes = parse_kdl(src)
|
||||
assert nodes, "no nodes parsed"
|
||||
return f"{len(nodes)} nodes"
|
||||
|
||||
def t_kdl_include():
|
||||
src = 'include "~/.config/niri/dms/monitor.kdl"\nspawn-at-startup "waybar"'
|
||||
nodes = parse_kdl(src)
|
||||
names = [n.name for n in nodes]
|
||||
assert "include" in names
|
||||
assert "spawn-at-startup" in names
|
||||
return "include + spawn parsed"
|
||||
|
||||
def t_kdl_nested():
|
||||
src = 'window-rule { match app-id="firefox"; open-maximized true; }'
|
||||
nodes = parse_kdl(src)
|
||||
assert nodes[0].name == "window-rule"
|
||||
assert nodes[0].children
|
||||
return "nested nodes OK"
|
||||
|
||||
def t_kdl_write():
|
||||
node = KdlNode("spawn-at-startup", args=["waybar", "--config", "/etc/waybar.json"])
|
||||
out = write_kdl([node])
|
||||
assert "waybar" in out
|
||||
return out.strip()
|
||||
|
||||
test("KDL: parse + write roundtrip", t_kdl_roundtrip)
|
||||
test("KDL: include directive parsing", t_kdl_include)
|
||||
test("KDL: nested children parsing", t_kdl_nested)
|
||||
test("KDL: write KdlNode with args", t_kdl_write)
|
||||
|
||||
# AppState
|
||||
from nirimod.state import AppState
|
||||
|
||||
def t_state_load():
|
||||
s = AppState()
|
||||
s.load()
|
||||
assert isinstance(s.nodes, list)
|
||||
return f"{len(s.nodes)} top-level nodes loaded"
|
||||
|
||||
def t_state_dirty():
|
||||
s = AppState()
|
||||
s.load()
|
||||
assert not s.is_dirty
|
||||
s.mark_dirty()
|
||||
assert s.is_dirty
|
||||
s.mark_clean()
|
||||
assert not s.is_dirty
|
||||
return "dirty/clean flags OK"
|
||||
|
||||
def t_state_discard():
|
||||
s = AppState()
|
||||
s.load()
|
||||
original_len = len(s.nodes)
|
||||
s.nodes.append(KdlNode("test-node"))
|
||||
s.mark_dirty()
|
||||
s.discard()
|
||||
assert len(s.nodes) == original_len
|
||||
return f"discarded back to {original_len} nodes"
|
||||
|
||||
def t_state_undo():
|
||||
s = AppState()
|
||||
s.load()
|
||||
before = write_kdl(s.nodes)
|
||||
s.nodes.append(KdlNode("test-undo-node"))
|
||||
after = write_kdl(s.nodes)
|
||||
s.push_undo("add test node", before, after)
|
||||
assert s.undo.can_undo()
|
||||
entry = s.apply_undo()
|
||||
assert entry is not None
|
||||
assert "test-undo-node" not in write_kdl(s.nodes)
|
||||
return "undo restored previous state"
|
||||
|
||||
test("AppState: load from disk", t_state_load)
|
||||
test("AppState: dirty / clean flags", t_state_dirty)
|
||||
test("AppState: discard reverts nodes", t_state_discard)
|
||||
test("AppState: undo stack push+pop", t_state_undo)
|
||||
|
||||
# Undo Manager
|
||||
from nirimod.undo import UndoManager, UndoEntry
|
||||
|
||||
def t_undo_redo():
|
||||
m = UndoManager()
|
||||
m.push(UndoEntry("step1", "before1", "after1"))
|
||||
m.push(UndoEntry("step2", "before2", "after2"))
|
||||
assert m.can_undo()
|
||||
e = m.pop_undo()
|
||||
assert e.description == "step2"
|
||||
assert m.can_redo()
|
||||
e2 = m.pop_redo()
|
||||
assert e2.description == "step2"
|
||||
return "undo→redo cycle OK"
|
||||
|
||||
test("UndoManager: push/pop/redo", t_undo_redo)
|
||||
|
||||
# Profiles
|
||||
from nirimod import profiles as prof_mod
|
||||
|
||||
def t_profiles_list():
|
||||
names = prof_mod.list_profiles()
|
||||
assert isinstance(names, list)
|
||||
return f"{len(names)} profiles found"
|
||||
|
||||
def t_profiles_save_delete():
|
||||
s = AppState()
|
||||
s.load()
|
||||
# save_profile takes name + optional set[Path] of source files
|
||||
prof_mod.save_profile("__test_profile__", s.source_files)
|
||||
names = prof_mod.list_profiles()
|
||||
assert "__test_profile__" in names, f"profile not found in {names}"
|
||||
prof_mod.delete_profile("__test_profile__")
|
||||
assert "__test_profile__" not in prof_mod.list_profiles()
|
||||
return "save + delete profile OK"
|
||||
|
||||
test("Profiles: list", t_profiles_list)
|
||||
test("Profiles: save and delete", t_profiles_save_delete)
|
||||
|
||||
# Pages (import + build check)
|
||||
# We test imports and logic only — no GTK widget creation without display
|
||||
page_modules = [
|
||||
("appearance", "nirimod.pages.appearance"),
|
||||
("animations", "nirimod.pages.animations"),
|
||||
("layout", "nirimod.pages.layout"),
|
||||
("startup", "nirimod.pages.startup"),
|
||||
("environment","nirimod.pages.environment"),
|
||||
("workspaces", "nirimod.pages.workspaces"),
|
||||
("window_rules","nirimod.pages.window_rules"),
|
||||
("bindings", "nirimod.pages.bindings"),
|
||||
("outputs", "nirimod.pages.outputs"),
|
||||
("input_page", "nirimod.pages.input_page"),
|
||||
("gestures", "nirimod.pages.gestures"),
|
||||
("raw_config", "nirimod.pages.raw_config"),
|
||||
]
|
||||
|
||||
import importlib
|
||||
for name, module_path in page_modules:
|
||||
def _test(mp=module_path, n=name):
|
||||
importlib.import_module(mp)
|
||||
return "module imported OK"
|
||||
test(f"Page import: {name}", _test)
|
||||
|
||||
# Startup page logic
|
||||
import shlex
|
||||
|
||||
def t_startup_spawn_sh():
|
||||
cmd = "waybar --config /etc/waybar.json"
|
||||
node = KdlNode("spawn-sh-at-startup", args=[cmd]) # single string for sh
|
||||
assert node.args[0] == cmd
|
||||
return f"spawn-sh-at-startup args = {node.args}"
|
||||
|
||||
def t_startup_spawn_direct():
|
||||
cmd = "dunst"
|
||||
args = shlex.split(cmd)
|
||||
node = KdlNode("spawn-at-startup", args=args)
|
||||
assert node.args == ["dunst"]
|
||||
return "spawn-at-startup args OK"
|
||||
|
||||
test("Startup: spawn-sh-at-startup node", t_startup_spawn_sh)
|
||||
test("Startup: spawn-at-startup node", t_startup_spawn_direct)
|
||||
|
||||
# Animations curve serialization
|
||||
def t_anim_curve_format():
|
||||
# The correct niri format is: curve "cubic-bezier" 0.25 0.1 0.25 1.0
|
||||
kdl = 'animations { workspace-switch { spring damping-ratio=1.0; } }'
|
||||
nodes = parse_kdl(kdl)
|
||||
out = write_kdl(nodes)
|
||||
assert "workspace-switch" in out
|
||||
return "animation node roundtrip OK"
|
||||
|
||||
test("Animations: curve node roundtrip", t_anim_curve_format)
|
||||
|
||||
# Environment page logic
|
||||
def t_env_node():
|
||||
node = KdlNode("environment", children=[
|
||||
KdlNode("WAYLAND_DISPLAY", args=["wayland-1"])
|
||||
])
|
||||
out = write_kdl([node])
|
||||
assert "WAYLAND_DISPLAY" in out
|
||||
return out.strip()
|
||||
|
||||
test("Environment: env var node write", t_env_node)
|
||||
|
||||
# Window rules logic
|
||||
def t_window_rule_node():
|
||||
kdl = 'window-rule { match app-id="org.gnome.Calculator"; open-floating true; }'
|
||||
nodes = parse_kdl(kdl)
|
||||
assert nodes[0].name == "window-rule"
|
||||
children_names = [c.name for c in nodes[0].children]
|
||||
assert "match" in children_names
|
||||
assert "open-floating" in children_names
|
||||
return f"children: {children_names}"
|
||||
|
||||
test("Window Rules: parse match+action", t_window_rule_node)
|
||||
|
||||
# Output node
|
||||
def t_output_node():
|
||||
kdl = 'output "eDP-1" { scale 1.5; transform "90"; mode "1920x1080@60"; }'
|
||||
nodes = parse_kdl(kdl)
|
||||
assert nodes[0].name == "output"
|
||||
assert nodes[0].args == ["eDP-1"]
|
||||
children = {c.name: c for c in nodes[0].children}
|
||||
assert "scale" in children
|
||||
assert float(children["scale"].args[0]) == 1.5
|
||||
return "output node parsed OK"
|
||||
|
||||
test("Outputs: parse output node", t_output_node)
|
||||
|
||||
# Bindings logic
|
||||
def t_binds_node():
|
||||
kdl = 'binds { Mod+T { action spawn "alacritty"; } Mod+Q { action close-window; } }'
|
||||
nodes = parse_kdl(kdl)
|
||||
assert nodes[0].name == "binds"
|
||||
assert len(nodes[0].children) == 2
|
||||
return f"{len(nodes[0].children)} binds found"
|
||||
|
||||
test("Bindings: parse binds block", t_binds_node)
|
||||
|
||||
# Workspaces logic
|
||||
def t_workspaces_node():
|
||||
kdl = 'workspaces { workspace "Browser"; workspace "Terminal"; }'
|
||||
nodes = parse_kdl(kdl)
|
||||
assert nodes[0].name == "workspaces"
|
||||
return f"{len(nodes[0].children)} workspaces"
|
||||
|
||||
test("Workspaces: parse workspace names", t_workspaces_node)
|
||||
|
||||
# NiriIPC
|
||||
from nirimod import niri_ipc
|
||||
|
||||
def t_ipc_is_running():
|
||||
result = niri_ipc.is_niri_running()
|
||||
assert isinstance(result, bool)
|
||||
return f"niri running = {result}"
|
||||
|
||||
def t_ipc_has_touchpad():
|
||||
result = niri_ipc.has_touchpad()
|
||||
assert isinstance(result, bool)
|
||||
return f"has touchpad = {result}"
|
||||
|
||||
test("NiriIPC: is_niri_running()", t_ipc_is_running)
|
||||
test("NiriIPC: has_touchpad()", t_ipc_has_touchpad)
|
||||
|
||||
# AppSettings
|
||||
from nirimod import app_settings
|
||||
|
||||
def t_app_settings():
|
||||
original = app_settings.get("auto_update", True)
|
||||
app_settings.set("auto_update", False)
|
||||
assert not app_settings.get("auto_update")
|
||||
app_settings.set("auto_update", original)
|
||||
return "get/set OK"
|
||||
|
||||
test("AppSettings: get/set", t_app_settings)
|
||||
|
||||
def _print_results() -> int:
|
||||
print("\n" + "="*50)
|
||||
print(" NIRIMOD FEATURE TEST REPORT")
|
||||
print("="*50)
|
||||
|
||||
passed = sum(1 for r in results if r[0] == PASS)
|
||||
failed = sum(1 for r in results if r[0] == FAIL)
|
||||
warned = sum(1 for r in results if r[0] == WARN)
|
||||
|
||||
for icon, name, detail in results:
|
||||
status = f"{icon} {name}"
|
||||
if detail:
|
||||
print(f"{status}\n → {detail}")
|
||||
else:
|
||||
print(status)
|
||||
|
||||
print("="*50)
|
||||
print(
|
||||
f" {passed} passed | {warned} warnings | {failed} failed | {len(results)} total"
|
||||
)
|
||||
print("="*50)
|
||||
return failed
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(1 if _print_results() else 0)
|
||||
192
tests/test_ipc.py
Normal file
192
tests/test_ipc.py
Normal file
@@ -0,0 +1,192 @@
|
||||
"""Unit tests for the niri IPC wrappers.
|
||||
|
||||
Tests the synchronous helpers and validates that the non-blocking async
|
||||
dispatch functions are wired correctly, using mocks to avoid requiring
|
||||
a live niri compositor.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class TestRunSync(unittest.TestCase):
|
||||
"""Tests for the internal _run_sync helper."""
|
||||
|
||||
def test_command_not_found(self):
|
||||
from nirimod.niri_ipc import _run_sync
|
||||
|
||||
stdout, stderr, rc = _run_sync(["__nonexistent_binary_xyz__"])
|
||||
self.assertEqual(rc, 1)
|
||||
self.assertIn("not found", stderr)
|
||||
|
||||
def test_successful_command(self):
|
||||
from nirimod.niri_ipc import _run_sync
|
||||
|
||||
stdout, stderr, rc = _run_sync(["echo", "hello"])
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertIn("hello", stdout)
|
||||
|
||||
def test_timeout(self):
|
||||
from nirimod.niri_ipc import _run_sync
|
||||
|
||||
stdout, stderr, rc = _run_sync(["sleep", "10"], timeout=0.01)
|
||||
self.assertEqual(rc, 1)
|
||||
self.assertIn("timed out", stderr)
|
||||
|
||||
|
||||
class TestIsNiriRunning(unittest.TestCase):
|
||||
def test_returns_false_when_niri_absent(self):
|
||||
from nirimod import niri_ipc
|
||||
|
||||
with patch.object(niri_ipc, "_run_sync", return_value=("", "not found", 1)):
|
||||
self.assertFalse(niri_ipc.is_niri_running())
|
||||
|
||||
def test_returns_true_when_niri_present(self):
|
||||
from nirimod import niri_ipc
|
||||
|
||||
with patch.object(niri_ipc, "_run_sync", return_value=("niri 1.0\n", "", 0)):
|
||||
self.assertTrue(niri_ipc.is_niri_running())
|
||||
|
||||
|
||||
class TestValidateConfig(unittest.TestCase):
|
||||
def test_valid_config(self):
|
||||
from nirimod import niri_ipc
|
||||
|
||||
with patch.object(niri_ipc, "_run_sync", return_value=("Config is valid.\n", "", 0)):
|
||||
ok, msg = niri_ipc.validate_config()
|
||||
self.assertTrue(ok)
|
||||
self.assertIn("valid", msg.lower())
|
||||
|
||||
def test_invalid_config(self):
|
||||
from nirimod import niri_ipc
|
||||
|
||||
with patch.object(niri_ipc, "_run_sync", return_value=("", "parse error line 3", 1)):
|
||||
ok, msg = niri_ipc.validate_config()
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("parse error", msg)
|
||||
|
||||
def test_with_config_path(self):
|
||||
from nirimod import niri_ipc
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_run(args, timeout=5.0):
|
||||
captured["args"] = args
|
||||
return ("ok", "", 0)
|
||||
|
||||
with patch.object(niri_ipc, "_run_sync", side_effect=fake_run):
|
||||
niri_ipc.validate_config("/tmp/test.kdl")
|
||||
|
||||
self.assertIn("--config", captured["args"])
|
||||
self.assertIn("/tmp/test.kdl", captured["args"])
|
||||
|
||||
|
||||
class TestLoadConfigFile(unittest.TestCase):
|
||||
def test_load_config_file_calls_niri_action(self):
|
||||
from nirimod import niri_ipc
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_run(args, timeout=5.0):
|
||||
captured["args"] = args
|
||||
captured["timeout"] = timeout
|
||||
return ("", "", 0)
|
||||
|
||||
with patch.object(niri_ipc, "_run_sync", side_effect=fake_run):
|
||||
ok, msg = niri_ipc.load_config_file()
|
||||
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual(captured["args"], ["niri", "msg", "action", "load-config-file"])
|
||||
self.assertEqual(captured["timeout"], 10.0)
|
||||
self.assertIn("applied", msg)
|
||||
|
||||
def test_load_config_file_reports_failure(self):
|
||||
from nirimod import niri_ipc
|
||||
|
||||
with patch.object(niri_ipc, "_run_sync", return_value=("", "reload failed", 1)):
|
||||
ok, msg = niri_ipc.load_config_file()
|
||||
|
||||
self.assertFalse(ok)
|
||||
self.assertIn("reload failed", msg)
|
||||
|
||||
|
||||
class TestHasTouchpad(unittest.TestCase):
|
||||
def test_caching(self):
|
||||
import nirimod.niri_ipc as ipc_mod
|
||||
|
||||
# Clear any existing cache
|
||||
ipc_mod._touchpad_cache = None
|
||||
|
||||
call_count = [0]
|
||||
|
||||
original_listdir = __import__("os").listdir
|
||||
|
||||
def fake_listdir(path):
|
||||
if path == "/sys/class/input":
|
||||
call_count[0] += 1
|
||||
return []
|
||||
return original_listdir(path)
|
||||
|
||||
with patch("os.listdir", side_effect=fake_listdir):
|
||||
ipc_mod.has_touchpad()
|
||||
ipc_mod.has_touchpad()
|
||||
|
||||
# Second call should use cache, so listdir only called once
|
||||
self.assertEqual(call_count[0], 1)
|
||||
|
||||
# Clean up for other tests
|
||||
ipc_mod._touchpad_cache = None
|
||||
|
||||
|
||||
class TestGetVersion(unittest.TestCase):
|
||||
def test_returns_version_string(self):
|
||||
from nirimod import niri_ipc
|
||||
|
||||
with patch.object(niri_ipc, "_run_sync", return_value=("niri 1.2.3\n", "", 0)):
|
||||
v = niri_ipc.get_version()
|
||||
self.assertEqual(v, "niri 1.2.3")
|
||||
|
||||
def test_returns_unknown_on_failure(self):
|
||||
from nirimod import niri_ipc
|
||||
|
||||
with patch.object(niri_ipc, "_run_sync", return_value=("", "error", 1)):
|
||||
v = niri_ipc.get_version()
|
||||
self.assertEqual(v, "unknown")
|
||||
|
||||
|
||||
class TestRunInThread(unittest.TestCase):
|
||||
"""Compatibility shim run_in_thread should invoke callback."""
|
||||
|
||||
def test_shim_calls_callback(self):
|
||||
from nirimod import niri_ipc
|
||||
|
||||
try:
|
||||
import gi
|
||||
gi.require_version("GLib", "2.0")
|
||||
from gi.repository import GLib
|
||||
except (ModuleNotFoundError, Exception):
|
||||
self.skipTest("gi (PyGObject) not available in this test environment")
|
||||
|
||||
results = []
|
||||
|
||||
original_idle_add = GLib.idle_add
|
||||
|
||||
def sync_idle_add(fn, *args):
|
||||
fn(*args)
|
||||
return 0
|
||||
|
||||
GLib.idle_add = sync_idle_add
|
||||
try:
|
||||
t = niri_ipc.run_in_thread(lambda: 42, lambda r: results.append(r))
|
||||
t.join(timeout=2.0)
|
||||
finally:
|
||||
GLib.idle_add = original_idle_add
|
||||
|
||||
self.assertEqual(results, [42])
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
202
tests/test_kdl_parser.py
Normal file
202
tests/test_kdl_parser.py
Normal file
@@ -0,0 +1,202 @@
|
||||
"""Unit tests for the KDL parser and writer.
|
||||
|
||||
Tests the core parse → mutate → write round-trip logic that underpins
|
||||
all config changes in NiriMod.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from nirimod.kdl_parser import (
|
||||
KdlNode,
|
||||
KdlRawString,
|
||||
find_or_create,
|
||||
parse_kdl,
|
||||
remove_child,
|
||||
set_child_arg,
|
||||
set_node_flag,
|
||||
write_kdl,
|
||||
)
|
||||
|
||||
|
||||
class TestKdlRoundTrip(unittest.TestCase):
|
||||
"""parse_kdl → write_kdl should produce semantically equivalent output."""
|
||||
|
||||
def _roundtrip(self, text: str) -> list[KdlNode]:
|
||||
nodes = parse_kdl(text)
|
||||
out = write_kdl(nodes)
|
||||
return parse_kdl(out)
|
||||
|
||||
def test_simple_node(self):
|
||||
nodes = parse_kdl("prefer-no-csd\n")
|
||||
self.assertEqual(len(nodes), 1)
|
||||
self.assertEqual(nodes[0].name, "prefer-no-csd")
|
||||
|
||||
def test_node_with_string_arg(self):
|
||||
nodes = parse_kdl('output "eDP-1" {\n scale 2.0\n}\n')
|
||||
self.assertEqual(nodes[0].name, "output")
|
||||
self.assertEqual(nodes[0].args[0], "eDP-1")
|
||||
scale = nodes[0].get_child("scale")
|
||||
self.assertIsNotNone(scale)
|
||||
self.assertAlmostEqual(scale.args[0], 2.0)
|
||||
|
||||
def test_boolean_values(self):
|
||||
nodes = parse_kdl(
|
||||
"input {\n keyboard {\n repeat-rate 30\n xkb-numlock true\n }\n}\n"
|
||||
)
|
||||
kb = nodes[0].get_child("keyboard")
|
||||
self.assertIsNotNone(kb)
|
||||
numlock = kb.get_child("xkb-numlock")
|
||||
self.assertIsNotNone(numlock)
|
||||
self.assertIs(numlock.args[0], True)
|
||||
|
||||
def test_raw_string(self):
|
||||
text = "spawn-at-startup r#\"bash -c 'echo hi'\"#\n"
|
||||
nodes = parse_kdl(text)
|
||||
self.assertIsInstance(nodes[0].args[0], KdlRawString)
|
||||
|
||||
def test_raw_string_property_preserves_backslash(self):
|
||||
src = 'match app-id="steam" title=r#"^notificationtoasts_\\d+_desktop$"#\n'
|
||||
nodes = parse_kdl(src)
|
||||
title = nodes[0].props["title"]
|
||||
self.assertIsInstance(title, KdlRawString)
|
||||
self.assertEqual(title, r"^notificationtoasts_\d+_desktop$")
|
||||
self.assertIn(r'title=r"^notificationtoasts_\d+_desktop$"', write_kdl(nodes))
|
||||
|
||||
def test_write_preserves_children(self):
|
||||
src = "layout {\n gaps 16\n border {\n width 2\n }\n}\n"
|
||||
nodes = self._roundtrip(src)
|
||||
layout = nodes[0]
|
||||
self.assertEqual(layout.name, "layout")
|
||||
border = layout.get_child("border")
|
||||
self.assertIsNotNone(border)
|
||||
self.assertEqual(border.get_child("width").args[0], 2)
|
||||
|
||||
def test_null_value(self):
|
||||
nodes = parse_kdl("cursor-warps null\n")
|
||||
self.assertIsNone(nodes[0].args[0])
|
||||
|
||||
def test_props(self):
|
||||
nodes = parse_kdl("position x=0 y=1080\n")
|
||||
self.assertEqual(nodes[0].props["x"], 0)
|
||||
self.assertEqual(nodes[0].props["y"], 1080)
|
||||
|
||||
def test_empty_input(self):
|
||||
nodes = parse_kdl("")
|
||||
self.assertEqual(nodes, [])
|
||||
self.assertIn("NiriMod", write_kdl(nodes))
|
||||
|
||||
def test_comments_are_preserved_as_trivia(self):
|
||||
src = "// top-level comment\nprefer-no-csd\n"
|
||||
out = write_kdl(parse_kdl(src))
|
||||
self.assertIn("prefer-no-csd", out)
|
||||
|
||||
|
||||
class TestMutationHelpers(unittest.TestCase):
|
||||
"""Tests for find_or_create, set_child_arg, remove_child, set_node_flag."""
|
||||
|
||||
def setUp(self):
|
||||
self.nodes = parse_kdl("layout {\n gaps 8\n}\n")
|
||||
|
||||
def test_find_existing(self):
|
||||
node = find_or_create(self.nodes, "layout")
|
||||
self.assertEqual(node.name, "layout")
|
||||
|
||||
def test_create_missing(self):
|
||||
node = find_or_create(self.nodes, "input")
|
||||
self.assertEqual(node.name, "input")
|
||||
self.assertIn(node, self.nodes)
|
||||
|
||||
def test_find_or_create_nested(self):
|
||||
node = find_or_create(self.nodes, "layout", "struts")
|
||||
self.assertEqual(node.name, "struts")
|
||||
|
||||
def test_set_child_arg_creates(self):
|
||||
parent = self.nodes[0]
|
||||
set_child_arg(parent, "border-rule", 4)
|
||||
child = parent.get_child("border-rule")
|
||||
self.assertIsNotNone(child)
|
||||
self.assertEqual(child.args[0], 4)
|
||||
|
||||
def test_set_child_arg_updates(self):
|
||||
parent = self.nodes[0]
|
||||
set_child_arg(parent, "gaps", 16)
|
||||
self.assertEqual(parent.get_child("gaps").args[0], 16)
|
||||
|
||||
def test_remove_child(self):
|
||||
parent = self.nodes[0]
|
||||
remove_child(parent, "gaps")
|
||||
self.assertIsNone(parent.get_child("gaps"))
|
||||
|
||||
def test_remove_nonexistent_is_noop(self):
|
||||
parent = self.nodes[0]
|
||||
remove_child(parent, "nonexistent")
|
||||
self.assertEqual(len(parent.children), 1)
|
||||
|
||||
def test_set_node_flag_add(self):
|
||||
parent = KdlNode("input")
|
||||
set_node_flag(parent, "warp-mouse-to-focus", True)
|
||||
self.assertIsNotNone(parent.get_child("warp-mouse-to-focus"))
|
||||
self.assertEqual(parent.get_child("warp-mouse-to-focus").args, [])
|
||||
|
||||
def test_set_node_flag_serializes_bare_flag(self):
|
||||
parent = KdlNode("blur")
|
||||
|
||||
set_node_flag(parent, "off", True)
|
||||
|
||||
self.assertIn("off", write_kdl([parent]))
|
||||
self.assertNotIn("off true", write_kdl([parent]))
|
||||
|
||||
def test_set_node_flag_remove(self):
|
||||
parent = KdlNode("input")
|
||||
parent.children.append(KdlNode("warp-mouse-to-focus"))
|
||||
set_node_flag(parent, "warp-mouse-to-focus", False)
|
||||
self.assertIsNone(parent.get_child("warp-mouse-to-focus"))
|
||||
|
||||
def test_set_node_flag_restores_bare_flag(self):
|
||||
parent = KdlNode("blur")
|
||||
parent.children.append(KdlNode("off"))
|
||||
|
||||
set_node_flag(parent, "off", False)
|
||||
set_node_flag(parent, "off", True)
|
||||
|
||||
self.assertIn("off", write_kdl([parent]))
|
||||
self.assertNotIn("off true", write_kdl([parent]))
|
||||
|
||||
def test_set_node_flag_idempotent_add(self):
|
||||
parent = KdlNode("input")
|
||||
set_node_flag(parent, "warp-mouse-to-focus", True)
|
||||
set_node_flag(parent, "warp-mouse-to-focus", True)
|
||||
count = sum(1 for c in parent.children if c.name == "warp-mouse-to-focus")
|
||||
self.assertEqual(count, 1)
|
||||
|
||||
|
||||
class TestWriteKdl(unittest.TestCase):
|
||||
"""Tests for the KDL serializer."""
|
||||
|
||||
def test_write_empty(self):
|
||||
out = write_kdl([])
|
||||
self.assertIn("NiriMod", out)
|
||||
|
||||
def test_write_simple(self):
|
||||
nodes = [KdlNode("prefer-no-csd")]
|
||||
out = write_kdl(nodes)
|
||||
self.assertIn("prefer-no-csd", out)
|
||||
|
||||
def test_write_raw_string(self):
|
||||
# A value containing a double-quote triggers the hash-delimited r#"..."# form
|
||||
node = KdlNode("env", args=[KdlRawString('value with "double" quotes')])
|
||||
out = write_kdl([node])
|
||||
self.assertIn('r#"', out)
|
||||
|
||||
def test_write_nested(self):
|
||||
parent = KdlNode("layout")
|
||||
parent.children.append(KdlNode("gaps", args=[16]))
|
||||
out = write_kdl([parent])
|
||||
self.assertIn("gaps", out)
|
||||
self.assertIn("16", out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
162
tests/test_state.py
Normal file
162
tests/test_state.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""Unit tests for the AppState manager.
|
||||
|
||||
Tests state initialization, dirty tracking, undo/redo integration,
|
||||
commit_save, discard, and node serialization helpers — without requiring
|
||||
a live GTK session or filesystem access.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from nirimod.kdl_parser import KdlNode, KdlRawString, parse_kdl, write_kdl
|
||||
from nirimod.state import AppState
|
||||
|
||||
|
||||
class TestAppStateInit(unittest.TestCase):
|
||||
"""AppState starts in a clean, non-dirty state."""
|
||||
|
||||
def test_initial_state(self):
|
||||
state = AppState()
|
||||
self.assertEqual(state.nodes, [])
|
||||
self.assertEqual(state.saved_kdl, "")
|
||||
self.assertFalse(state.is_dirty)
|
||||
self.assertFalse(state.niri_running)
|
||||
self.assertFalse(state.has_touchpad)
|
||||
|
||||
def test_initial_undo_empty(self):
|
||||
state = AppState()
|
||||
self.assertFalse(state.undo.can_undo())
|
||||
self.assertFalse(state.undo.can_redo())
|
||||
|
||||
|
||||
class TestDirtyTracking(unittest.TestCase):
|
||||
def test_mark_dirty(self):
|
||||
state = AppState()
|
||||
self.assertFalse(state.is_dirty)
|
||||
state.mark_dirty()
|
||||
self.assertTrue(state.is_dirty)
|
||||
|
||||
def test_mark_clean(self):
|
||||
state = AppState()
|
||||
state.mark_dirty()
|
||||
state.mark_clean()
|
||||
self.assertFalse(state.is_dirty)
|
||||
|
||||
|
||||
class TestUndoRedo(unittest.TestCase):
|
||||
def _make_state_with_nodes(self, kdl: str) -> AppState:
|
||||
state = AppState()
|
||||
state.nodes = parse_kdl(kdl)
|
||||
state._saved_kdl = kdl
|
||||
return state
|
||||
|
||||
def test_push_and_apply_undo(self):
|
||||
state = self._make_state_with_nodes("gaps 8\n")
|
||||
before = "gaps 8\n"
|
||||
after = "gaps 16\n"
|
||||
state.push_undo("change gaps", before, after)
|
||||
self.assertTrue(state.undo.can_undo())
|
||||
|
||||
entry = state.apply_undo()
|
||||
self.assertIsNotNone(entry)
|
||||
self.assertEqual(state.nodes[0].get_child("gaps") if state.nodes and state.nodes[0].children else None, None)
|
||||
# After undo, nodes should be from the 'before' snapshot
|
||||
kdl_out = write_kdl(state.nodes)
|
||||
self.assertIn("8", kdl_out)
|
||||
|
||||
def test_apply_undo_empty_returns_none(self):
|
||||
state = AppState()
|
||||
result = state.apply_undo()
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_apply_redo_empty_returns_none(self):
|
||||
state = AppState()
|
||||
result = state.apply_redo()
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_undo_sets_dirty(self):
|
||||
state = self._make_state_with_nodes("gaps 8\n")
|
||||
state.push_undo("x", "gaps 16\n", "gaps 24\n")
|
||||
state.apply_undo()
|
||||
self.assertTrue(state.is_dirty)
|
||||
|
||||
def test_redo_after_undo(self):
|
||||
state = self._make_state_with_nodes("gaps 8\n")
|
||||
state.push_undo("x", "gaps 8\n", "gaps 16\n")
|
||||
state.apply_undo()
|
||||
self.assertTrue(state.undo.can_redo())
|
||||
entry = state.apply_redo()
|
||||
self.assertIsNotNone(entry)
|
||||
kdl_out = write_kdl(state.nodes)
|
||||
self.assertIn("16", kdl_out)
|
||||
|
||||
|
||||
class TestCommitSave(unittest.TestCase):
|
||||
def test_commit_save_clears_undo_and_dirty(self):
|
||||
state = AppState()
|
||||
state.push_undo("x", "a", "b")
|
||||
state.mark_dirty()
|
||||
state.commit_save("new kdl\n")
|
||||
self.assertEqual(state.saved_kdl, "new kdl\n")
|
||||
self.assertFalse(state.is_dirty)
|
||||
self.assertFalse(state.undo.can_undo())
|
||||
|
||||
|
||||
class TestDiscard(unittest.TestCase):
|
||||
def test_discard_restores_saved_kdl(self):
|
||||
state = AppState()
|
||||
state._saved_kdl = "gaps 8\n"
|
||||
state.nodes = parse_kdl("gaps 16\n")
|
||||
state.mark_dirty()
|
||||
state.push_undo("x", "gaps 8\n", "gaps 16\n")
|
||||
|
||||
state.discard()
|
||||
|
||||
self.assertFalse(state.is_dirty)
|
||||
self.assertFalse(state.undo.can_undo())
|
||||
kdl_out = write_kdl(state.nodes)
|
||||
self.assertIn("8", kdl_out)
|
||||
|
||||
def test_discard_empty_saved_kdl(self):
|
||||
state = AppState()
|
||||
state._saved_kdl = ""
|
||||
state.discard()
|
||||
self.assertEqual(state.nodes, [])
|
||||
|
||||
|
||||
class TestWriteCurrentKdl(unittest.TestCase):
|
||||
def test_write_current_kdl(self):
|
||||
state = AppState()
|
||||
state.nodes = [KdlNode("prefer-no-csd")]
|
||||
out = state.write_current_kdl()
|
||||
self.assertIn("prefer-no-csd", out)
|
||||
|
||||
def test_write_raw_string(self):
|
||||
# A string containing a double-quote forces the hash-delimited raw form
|
||||
node = KdlNode("env", args=[KdlRawString('has "double" quotes')])
|
||||
out = write_kdl([node])
|
||||
self.assertIn('r#"', out)
|
||||
|
||||
|
||||
class TestLoad(unittest.TestCase):
|
||||
def test_load_detects_runtime(self):
|
||||
state = AppState()
|
||||
# state.py does: from nirimod import niri_ipc
|
||||
# We patch at the canonical module location so all references see the mock.
|
||||
with (
|
||||
patch("nirimod.niri_ipc.is_niri_running", return_value=True),
|
||||
patch("nirimod.niri_ipc.has_touchpad", return_value=True),
|
||||
patch("nirimod.kdl_parser.NIRI_CONFIG") as mock_cfg,
|
||||
):
|
||||
mock_cfg.exists.return_value = False
|
||||
state.load()
|
||||
|
||||
self.assertTrue(state.niri_running)
|
||||
self.assertTrue(state.has_touchpad)
|
||||
self.assertFalse(state.is_dirty)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
116
tests/test_updater.py
Normal file
116
tests/test_updater.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""Unit tests for updater terminal selection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("gi")
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from nirimod import updater
|
||||
|
||||
|
||||
class TestTerminalCandidates(unittest.TestCase):
|
||||
def test_terminal_env_is_preferred(self):
|
||||
with patch.dict(os.environ, {"TERMINAL": "ghostty"}, clear=False):
|
||||
candidates = list(updater._terminal_candidates())
|
||||
|
||||
self.assertEqual(candidates[0], "ghostty")
|
||||
self.assertIn("xdg-terminal-exec", candidates)
|
||||
|
||||
def test_ghostty_is_a_fallback_terminal(self):
|
||||
self.assertIn("ghostty", updater.FALLBACK_TERMINALS)
|
||||
|
||||
|
||||
class TestBuildTerminalCommand(unittest.TestCase):
|
||||
def test_xdg_terminal_exec_gets_script_directly(self):
|
||||
command = updater._build_terminal_command("xdg-terminal-exec", "/tmp/update.sh")
|
||||
|
||||
self.assertEqual(command, ["xdg-terminal-exec", "/tmp/update.sh"])
|
||||
|
||||
def test_regular_terminal_uses_execute_flag(self):
|
||||
command = updater._build_terminal_command("ghostty", "/tmp/update.sh")
|
||||
|
||||
self.assertEqual(command, ["ghostty", "-e", "/tmp/update.sh"])
|
||||
|
||||
def test_terminal_command_with_existing_execute_flag(self):
|
||||
command = updater._build_terminal_command(
|
||||
"ghostty --gtk-single-instance=false -e", "/tmp/update.sh"
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
command, ["ghostty", "--gtk-single-instance=false", "-e", "/tmp/update.sh"]
|
||||
)
|
||||
|
||||
def test_invalid_terminal_command_is_ignored(self):
|
||||
command = updater._build_terminal_command("ghostty '", "/tmp/update.sh")
|
||||
|
||||
self.assertIsNone(command)
|
||||
|
||||
|
||||
class TestUpdateAvailability(unittest.TestCase):
|
||||
def _run_git(self, repo: str, *args: str):
|
||||
return subprocess.run(
|
||||
["git", *args],
|
||||
cwd=repo,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
def _commit(self, repo: str, message: str) -> str:
|
||||
self._run_git(repo, "commit", "--allow-empty", "-m", message)
|
||||
return subprocess.check_output(
|
||||
["git", "rev-parse", "HEAD"], cwd=repo, text=True
|
||||
).strip()
|
||||
|
||||
def _make_repo(self) -> str:
|
||||
temp_dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temp_dir.cleanup)
|
||||
repo = temp_dir.name
|
||||
self._run_git(repo, "init")
|
||||
self._run_git(repo, "config", "user.email", "test@example.com")
|
||||
self._run_git(repo, "config", "user.name", "Test User")
|
||||
return repo
|
||||
|
||||
def test_branch_ahead_of_remote_main_is_not_update(self):
|
||||
repo = self._make_repo()
|
||||
remote_hash = self._commit(repo, "remote main")
|
||||
local_hash = self._commit(repo, "local branch")
|
||||
|
||||
self.assertFalse(updater._update_available(local_hash, remote_hash, repo))
|
||||
|
||||
def test_dirty_worktree_still_gets_remote_update(self):
|
||||
repo = self._make_repo()
|
||||
local_hash = self._commit(repo, "installed version")
|
||||
remote_hash = self._commit(repo, "remote main")
|
||||
self._run_git(repo, "checkout", "--detach", local_hash)
|
||||
with open(os.path.join(repo, "local-change.txt"), "w") as fh:
|
||||
fh.write("local edit\n")
|
||||
|
||||
self.assertTrue(updater._update_available(local_hash, remote_hash, repo))
|
||||
|
||||
|
||||
class TestLaunchUpdaterInTerminal(unittest.TestCase):
|
||||
def test_launch_uses_terminal_env(self):
|
||||
with (
|
||||
tempfile.TemporaryDirectory() as temp_dir,
|
||||
patch.dict(os.environ, {"TERMINAL": "ghostty"}, clear=False),
|
||||
patch.object(updater.tempfile, "gettempdir", return_value=temp_dir),
|
||||
patch.object(updater.shutil, "which", return_value="/usr/bin/ghostty"),
|
||||
patch.object(updater.subprocess, "Popen") as popen,
|
||||
):
|
||||
updater.launch_updater_in_terminal()
|
||||
|
||||
popen.assert_called_once_with(
|
||||
["ghostty", "-e", os.path.join(temp_dir, "nirimod_update.sh")]
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
384
tests/test_window_effects.py
Normal file
384
tests/test_window_effects.py
Normal file
@@ -0,0 +1,384 @@
|
||||
"""Tests for global window effect rule helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from nirimod.kdl_parser import KdlNode, parse_kdl, write_kdl
|
||||
from nirimod.window_effects import (
|
||||
blur_effects_enabled,
|
||||
focused_window_blur_enabled,
|
||||
get_global_draw_border_with_background,
|
||||
get_global_corner_radius,
|
||||
get_global_window_opacity,
|
||||
global_window_blur_enabled,
|
||||
global_window_xray_enabled,
|
||||
set_focused_window_blur,
|
||||
set_global_draw_border_with_background,
|
||||
set_global_corner_radius,
|
||||
set_global_window_blur,
|
||||
set_global_window_opacity,
|
||||
set_global_window_xray,
|
||||
set_blur_effects_enabled,
|
||||
)
|
||||
|
||||
|
||||
class TestGlobalWindowEffects(unittest.TestCase):
|
||||
def test_blur_effects_are_enabled_without_top_level_off(self):
|
||||
nodes = parse_kdl(
|
||||
"""
|
||||
blur {
|
||||
passes 3
|
||||
offset 3
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
self.assertTrue(blur_effects_enabled(nodes))
|
||||
|
||||
def test_disabling_blur_effects_writes_top_level_off(self):
|
||||
nodes: list[KdlNode] = []
|
||||
|
||||
set_blur_effects_enabled(nodes, False)
|
||||
|
||||
out = write_kdl(nodes)
|
||||
self.assertIn("blur", out)
|
||||
self.assertIn("off", out)
|
||||
self.assertNotIn("off true", out)
|
||||
self.assertFalse(blur_effects_enabled(nodes))
|
||||
|
||||
def test_disabling_blur_effects_preserves_quality_settings(self):
|
||||
nodes = parse_kdl(
|
||||
"""
|
||||
blur {
|
||||
passes 3
|
||||
offset 3
|
||||
noise 0.02
|
||||
saturation 1.5
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
set_blur_effects_enabled(nodes, False)
|
||||
|
||||
out = write_kdl(nodes)
|
||||
self.assertIn("off", out)
|
||||
self.assertIn("passes 3", out)
|
||||
self.assertIn("offset 3", out)
|
||||
self.assertIn("noise 0.02", out)
|
||||
self.assertIn("saturation 1.5", out)
|
||||
self.assertNotIn("off true", out)
|
||||
|
||||
def test_enabling_blur_effects_removes_only_off(self):
|
||||
nodes = parse_kdl(
|
||||
"""
|
||||
blur {
|
||||
off
|
||||
passes 3
|
||||
offset 3
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
set_blur_effects_enabled(nodes, True)
|
||||
|
||||
out = write_kdl(nodes)
|
||||
blur = parse_kdl(out)[0]
|
||||
self.assertIsNone(blur.get_child("off"))
|
||||
self.assertIn("passes 3", out)
|
||||
self.assertIn("offset 3", out)
|
||||
self.assertTrue(blur_effects_enabled(nodes))
|
||||
|
||||
def test_enabling_blur_effects_sets_visible_default_opacity_when_unset(self):
|
||||
nodes = parse_kdl(
|
||||
"""
|
||||
blur {
|
||||
off
|
||||
passes 3
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
set_blur_effects_enabled(nodes, True)
|
||||
|
||||
out = write_kdl(nodes)
|
||||
self.assertEqual(get_global_window_opacity(nodes), 0.9)
|
||||
self.assertIn("opacity 0.9", out)
|
||||
|
||||
def test_enabling_blur_effects_preserves_existing_opacity(self):
|
||||
nodes = parse_kdl(
|
||||
"""
|
||||
blur {
|
||||
off
|
||||
passes 3
|
||||
}
|
||||
window-rule {
|
||||
opacity 0.75
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
set_blur_effects_enabled(nodes, True)
|
||||
|
||||
self.assertEqual(get_global_window_opacity(nodes), 0.75)
|
||||
self.assertIn("opacity 0.75", write_kdl(nodes))
|
||||
|
||||
def test_enabling_blur_creates_matchless_window_rule(self):
|
||||
nodes: list[KdlNode] = []
|
||||
|
||||
set_global_window_blur(nodes, True)
|
||||
|
||||
out = write_kdl(nodes)
|
||||
self.assertIn("window-rule", out)
|
||||
self.assertIn("background-effect", out)
|
||||
self.assertIn("blur true", out)
|
||||
self.assertNotIn("draw-border-with-background", out)
|
||||
self.assertTrue(global_window_blur_enabled(nodes))
|
||||
|
||||
def test_enabling_blur_sets_visible_default_opacity_when_unset(self):
|
||||
nodes: list[KdlNode] = []
|
||||
|
||||
set_global_window_blur(nodes, True)
|
||||
|
||||
self.assertEqual(get_global_window_opacity(nodes), 0.9)
|
||||
self.assertIn("opacity 0.9", write_kdl(nodes))
|
||||
|
||||
def test_enabling_blur_preserves_existing_opacity(self):
|
||||
nodes = parse_kdl(
|
||||
"""
|
||||
window-rule {
|
||||
opacity 0.75
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
set_global_window_blur(nodes, True)
|
||||
|
||||
self.assertEqual(get_global_window_opacity(nodes), 0.75)
|
||||
self.assertIn("opacity 0.75", write_kdl(nodes))
|
||||
|
||||
def test_disabling_blur_preserves_other_window_effect_settings(self):
|
||||
nodes = parse_kdl(
|
||||
"""
|
||||
window-rule {
|
||||
geometry-corner-radius 16
|
||||
draw-border-with-background false
|
||||
background-effect {
|
||||
blur true
|
||||
xray false
|
||||
}
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
set_global_window_blur(nodes, False)
|
||||
|
||||
rule = nodes[0]
|
||||
self.assertEqual(rule.child_arg("geometry-corner-radius"), 16)
|
||||
self.assertIsNotNone(rule.get_child("draw-border-with-background"))
|
||||
self.assertIsNotNone(rule.get_child("background-effect"))
|
||||
self.assertIsNone(rule.get_child("background-effect").get_child("blur"))
|
||||
self.assertIsNotNone(rule.get_child("background-effect").get_child("xray"))
|
||||
self.assertFalse(global_window_blur_enabled(nodes))
|
||||
|
||||
def test_disabling_blur_resets_window_opacity(self):
|
||||
nodes = parse_kdl(
|
||||
"""
|
||||
window-rule {
|
||||
opacity 0.95
|
||||
background-effect {
|
||||
blur true
|
||||
xray false
|
||||
}
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
set_global_window_blur(nodes, False)
|
||||
|
||||
rule = nodes[0]
|
||||
self.assertEqual(get_global_window_opacity(nodes), 1.0)
|
||||
self.assertIsNone(rule.get_child("opacity"))
|
||||
self.assertIsNone(rule.get_child("background-effect").get_child("blur"))
|
||||
self.assertIsNotNone(rule.get_child("background-effect").get_child("xray"))
|
||||
|
||||
def test_disabling_blur_effects_clears_forced_blur_and_opacity(self):
|
||||
nodes = parse_kdl(
|
||||
"""
|
||||
blur {
|
||||
passes 3
|
||||
}
|
||||
window-rule {
|
||||
opacity 0.9
|
||||
background-effect {
|
||||
blur true
|
||||
xray false
|
||||
}
|
||||
}
|
||||
window-rule {
|
||||
match is-focused=true
|
||||
background-effect {
|
||||
blur true
|
||||
}
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
set_blur_effects_enabled(nodes, False)
|
||||
|
||||
out = write_kdl(nodes)
|
||||
self.assertIn("off", out)
|
||||
self.assertFalse(blur_effects_enabled(nodes))
|
||||
self.assertFalse(global_window_blur_enabled(nodes))
|
||||
self.assertFalse(focused_window_blur_enabled(nodes))
|
||||
self.assertEqual(get_global_window_opacity(nodes), 1.0)
|
||||
self.assertNotIn("blur true", out)
|
||||
self.assertNotIn("opacity 0.9", out)
|
||||
|
||||
def test_corner_radius_writes_clip_and_can_be_removed(self):
|
||||
nodes: list[KdlNode] = []
|
||||
|
||||
set_global_corner_radius(nodes, 16)
|
||||
|
||||
self.assertEqual(get_global_corner_radius(nodes), 16)
|
||||
out = write_kdl(nodes)
|
||||
self.assertIn("geometry-corner-radius 16", out)
|
||||
self.assertIn("clip-to-geometry true", out)
|
||||
|
||||
set_global_corner_radius(nodes, 0)
|
||||
|
||||
self.assertEqual(nodes, [])
|
||||
|
||||
def test_matched_rules_are_not_reused_as_global_effect_rules(self):
|
||||
nodes = parse_kdl(
|
||||
"""
|
||||
window-rule {
|
||||
match app-id="Alacritty"
|
||||
background-effect {
|
||||
blur true
|
||||
}
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
set_global_corner_radius(nodes, 12)
|
||||
|
||||
self.assertEqual(len([n for n in nodes if n.name == "window-rule"]), 2)
|
||||
self.assertIsNone(nodes[0].get_child("geometry-corner-radius"))
|
||||
self.assertEqual(get_global_corner_radius(nodes), 12)
|
||||
|
||||
def test_global_opacity_is_removed_when_opaque(self):
|
||||
nodes: list[KdlNode] = []
|
||||
|
||||
set_global_window_opacity(nodes, 0.9)
|
||||
|
||||
self.assertEqual(get_global_window_opacity(nodes), 0.9)
|
||||
self.assertIn("opacity 0.9", write_kdl(nodes))
|
||||
|
||||
set_global_window_opacity(nodes, 1.0)
|
||||
|
||||
self.assertEqual(nodes, [])
|
||||
|
||||
def test_draw_border_with_background_can_be_toggled(self):
|
||||
nodes: list[KdlNode] = []
|
||||
|
||||
self.assertTrue(get_global_draw_border_with_background(nodes))
|
||||
|
||||
set_global_draw_border_with_background(nodes, False)
|
||||
|
||||
self.assertFalse(get_global_draw_border_with_background(nodes))
|
||||
self.assertIn("draw-border-with-background false", write_kdl(nodes))
|
||||
|
||||
set_global_draw_border_with_background(nodes, True)
|
||||
|
||||
self.assertTrue(get_global_draw_border_with_background(nodes))
|
||||
self.assertEqual(nodes, [])
|
||||
|
||||
def test_xray_false_is_written_with_blur(self):
|
||||
nodes: list[KdlNode] = []
|
||||
|
||||
set_global_window_blur(nodes, True)
|
||||
set_global_window_xray(nodes, False)
|
||||
|
||||
out = write_kdl(nodes)
|
||||
self.assertIn("blur true", out)
|
||||
self.assertIn("xray false", out)
|
||||
self.assertFalse(global_window_xray_enabled(nodes))
|
||||
|
||||
def test_xray_toggle_does_not_enable_blur(self):
|
||||
nodes: list[KdlNode] = []
|
||||
|
||||
set_global_window_xray(nodes, True)
|
||||
|
||||
out = write_kdl(nodes)
|
||||
self.assertIn("xray true", out)
|
||||
self.assertNotIn("blur true", out)
|
||||
self.assertFalse(global_window_blur_enabled(nodes))
|
||||
|
||||
def test_generated_global_window_effect_rule_is_compact(self):
|
||||
nodes: list[KdlNode] = []
|
||||
|
||||
set_global_corner_radius(nodes, 16)
|
||||
set_global_draw_border_with_background(nodes, False)
|
||||
set_global_window_blur(nodes, True)
|
||||
set_global_window_xray(nodes, False)
|
||||
set_global_window_opacity(nodes, 0.75)
|
||||
|
||||
self.assertEqual(
|
||||
write_kdl(nodes).strip(),
|
||||
"""window-rule {
|
||||
geometry-corner-radius 16
|
||||
clip-to-geometry true
|
||||
draw-border-with-background false
|
||||
opacity 0.75
|
||||
background-effect {
|
||||
blur true
|
||||
xray false
|
||||
}
|
||||
}""",
|
||||
)
|
||||
|
||||
def test_focused_blur_rule_does_not_duplicate_global_effect_settings(self):
|
||||
nodes: list[KdlNode] = []
|
||||
set_global_window_blur(nodes, True)
|
||||
set_global_window_xray(nodes, False)
|
||||
set_global_window_opacity(nodes, 0.75)
|
||||
|
||||
set_focused_window_blur(nodes, True)
|
||||
|
||||
out = write_kdl(nodes)
|
||||
self.assertIn("match is-focused=true", out)
|
||||
self.assertEqual(out.count("opacity 0.75"), 1)
|
||||
self.assertEqual(out.count("blur true"), 2)
|
||||
self.assertEqual(out.count("xray false"), 1)
|
||||
self.assertTrue(focused_window_blur_enabled(nodes))
|
||||
|
||||
def test_disabling_focused_blur_preserves_other_focused_rule_settings(self):
|
||||
nodes = parse_kdl(
|
||||
"""
|
||||
window-rule {
|
||||
match is-focused=true
|
||||
block-out-from "screen-capture"
|
||||
draw-border-with-background false
|
||||
opacity 0.75
|
||||
background-effect {
|
||||
blur true
|
||||
xray false
|
||||
}
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
set_focused_window_blur(nodes, False)
|
||||
|
||||
out = write_kdl(nodes)
|
||||
self.assertIn('block-out-from "screen-capture"', out)
|
||||
self.assertIn("draw-border-with-background false", out)
|
||||
self.assertIn("opacity 0.75", out)
|
||||
self.assertNotIn("blur true", out)
|
||||
self.assertIn("xray false", out)
|
||||
self.assertFalse(focused_window_blur_enabled(nodes))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
173
tests/test_window_rules.py
Normal file
173
tests/test_window_rules.py
Normal file
@@ -0,0 +1,173 @@
|
||||
"""Tests for window-rule editor serialization helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("gi")
|
||||
|
||||
from nirimod.kdl_parser import KdlNode, write_kdl
|
||||
from nirimod.pages.window_rules import (
|
||||
CUSTOM_FLOATING_POSITION_INDEX,
|
||||
DEFAULT_FLOATING_POSITION_RELATIVE_TO,
|
||||
FLOATING_POSITION_CUSTOM_FIELD_LABELS,
|
||||
FLOATING_POSITION_LOCATION_LABELS,
|
||||
SCREENCAST_BLOCK_KEY,
|
||||
SIZE_PERCENT_PRESETS,
|
||||
_bool_action_active,
|
||||
_bool_action_node,
|
||||
_floating_position_location_index,
|
||||
_floating_position_setting,
|
||||
_make_floating_position_node,
|
||||
_make_size_node,
|
||||
_window_size_setting,
|
||||
)
|
||||
|
||||
|
||||
class TestWindowRuleActions(unittest.TestCase):
|
||||
def test_screencast_block_action_writes_valid_niri_syntax(self):
|
||||
node = _bool_action_node(SCREENCAST_BLOCK_KEY)
|
||||
out = write_kdl([KdlNode("window-rule", children=[node])])
|
||||
|
||||
self.assertIn('block-out-from "screencast"', out)
|
||||
self.assertNotIn("block-out-from-screencast", out)
|
||||
|
||||
def test_screencast_block_action_reads_current_syntax(self):
|
||||
rule = KdlNode(
|
||||
"window-rule", children=[KdlNode("block-out-from", args=["screencast"])]
|
||||
)
|
||||
|
||||
self.assertTrue(_bool_action_active(rule, SCREENCAST_BLOCK_KEY))
|
||||
|
||||
def test_screencast_block_action_reads_legacy_syntax(self):
|
||||
rule = KdlNode(
|
||||
"window-rule", children=[KdlNode("block-out-from-screencast", args=[True])]
|
||||
)
|
||||
|
||||
self.assertTrue(_bool_action_active(rule, SCREENCAST_BLOCK_KEY))
|
||||
|
||||
def test_window_rule_size_default_writes_no_override(self):
|
||||
self.assertIsNone(_make_size_node("default-column-width", "default", None))
|
||||
self.assertIsNone(_make_size_node("default-window-height", "default", None))
|
||||
|
||||
def test_window_rule_size_presets_include_full_size(self):
|
||||
self.assertIn(("100%", 1.0), SIZE_PERCENT_PRESETS)
|
||||
|
||||
def test_window_rule_width_preset_writes_proportion_node(self):
|
||||
node = _make_size_node("default-column-width", "proportion", 0.25)
|
||||
out = write_kdl([KdlNode("window-rule", children=[node])])
|
||||
|
||||
self.assertIn("default-column-width", out)
|
||||
self.assertIn("proportion 0.25", out)
|
||||
self.assertNotIn("default-column-width 0.25", out)
|
||||
|
||||
def test_window_rule_height_preset_writes_proportion_node(self):
|
||||
node = _make_size_node("default-window-height", "proportion", 1.0)
|
||||
out = write_kdl([KdlNode("window-rule", children=[node])])
|
||||
|
||||
self.assertIn("default-window-height", out)
|
||||
self.assertIn("proportion 1.0", out)
|
||||
self.assertNotIn("default-window-height 1.0", out)
|
||||
|
||||
def test_window_rule_size_reads_nested_fixed_value(self):
|
||||
rule = KdlNode(
|
||||
"window-rule",
|
||||
children=[
|
||||
KdlNode(
|
||||
"default-window-height",
|
||||
children=[KdlNode("fixed", args=[270])],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
_window_size_setting(rule, "default-window-height"),
|
||||
("fixed", 270),
|
||||
)
|
||||
|
||||
def test_window_rule_size_reads_legacy_direct_fixed_value(self):
|
||||
rule = KdlNode(
|
||||
"window-rule",
|
||||
children=[KdlNode("default-window-height", args=[270])],
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
_window_size_setting(rule, "default-window-height"),
|
||||
("fixed", 270),
|
||||
)
|
||||
|
||||
def test_floating_position_default_writes_no_override(self):
|
||||
self.assertIsNone(
|
||||
_make_floating_position_node(
|
||||
False, 0, 0, DEFAULT_FLOATING_POSITION_RELATIVE_TO
|
||||
)
|
||||
)
|
||||
|
||||
def test_floating_position_locations_are_edges_plus_custom(self):
|
||||
self.assertEqual(
|
||||
FLOATING_POSITION_LOCATION_LABELS,
|
||||
["Top", "Bottom", "Left", "Right", "Custom"],
|
||||
)
|
||||
|
||||
def test_floating_position_custom_fields_are_offsets_only(self):
|
||||
self.assertEqual(
|
||||
FLOATING_POSITION_CUSTOM_FIELD_LABELS,
|
||||
["X Offset (px)", "Y Offset (px)"],
|
||||
)
|
||||
|
||||
def test_floating_position_edge_locations_use_zero_offsets(self):
|
||||
self.assertEqual(
|
||||
_floating_position_location_index(0, 0, "right"),
|
||||
FLOATING_POSITION_LOCATION_LABELS.index("Right"),
|
||||
)
|
||||
|
||||
def test_floating_position_edge_offsets_are_custom(self):
|
||||
self.assertEqual(
|
||||
_floating_position_location_index(20, 0, "right"),
|
||||
CUSTOM_FLOATING_POSITION_INDEX,
|
||||
)
|
||||
|
||||
def test_floating_position_custom_location_is_for_non_edge_anchors(self):
|
||||
self.assertEqual(
|
||||
_floating_position_location_index(12, 34, "bottom-right"),
|
||||
CUSTOM_FLOATING_POSITION_INDEX,
|
||||
)
|
||||
|
||||
def test_floating_position_writes_anchor_properties(self):
|
||||
node = _make_floating_position_node(True, 0, 0, "right")
|
||||
out = write_kdl([KdlNode("window-rule", children=[node])])
|
||||
|
||||
self.assertIn(
|
||||
'default-floating-position x=0 y=0 relative-to="right"',
|
||||
out,
|
||||
)
|
||||
|
||||
def test_floating_position_writes_custom_offset(self):
|
||||
node = _make_floating_position_node(True, 12, 34, "right")
|
||||
out = write_kdl([KdlNode("window-rule", children=[node])])
|
||||
|
||||
self.assertIn(
|
||||
'default-floating-position x=12 y=34 relative-to="right"',
|
||||
out,
|
||||
)
|
||||
|
||||
def test_floating_position_reads_existing_anchor(self):
|
||||
rule = KdlNode(
|
||||
"window-rule",
|
||||
children=[
|
||||
KdlNode(
|
||||
"default-floating-position",
|
||||
props={"x": 12, "y": 34, "relative-to": "bottom-right"},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
_floating_position_setting(rule),
|
||||
(True, 12, 34, "bottom-right"),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
79
uv.lock
generated
Normal file
79
uv.lock
generated
Normal file
@@ -0,0 +1,79 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nirimod"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "pytest", specifier = ">=9.0.3" }]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.20.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||
]
|
||||
Reference in New Issue
Block a user